功能修改
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# SCUM Companion One-Shot Smoke
|
||||
|
||||
This plugin-owned fixture proves the Platform Client Manager and Game Client Bridge integration without adding SCUM behavior to Run. The command registers the deployed component, sends one heartbeat, claims at most one command, processes only `companion.diagnostics`, and uploads one typed `companion.health` snapshot.
|
||||
|
||||
Use it only with a dedicated non-production server instance whose bridge queue contains no shared or production work. The claim API cannot filter by command type, so this smoke command must never target a shared or production queue.
|
||||
|
||||
Before starting it, confirm that the isolated queue is otherwise empty and queue exactly one `companion.diagnostics` command through the Platform SCUM operations page. Use the bounded payload `includeWindowState=false` and `maxEntries=1`. Do not pass an operator session or API token to the companion process.
|
||||
|
||||
## Package
|
||||
|
||||
Build the one-shot command from this directory:
|
||||
|
||||
```bash
|
||||
go build -o scum-companion-smoke ./cmd/scum-companion-smoke
|
||||
```
|
||||
|
||||
Place the generated `config.yaml` beside the executable. The command intentionally has no `--config` flag and reads only that sidecar filename from its working directory. `config.yaml.example` documents the generated shape; deployed identity and generation values must come from the fenced Client Manager lifecycle input.
|
||||
|
||||
The Platform base URL must be a trusted HTTPS origin. The client uses host system certificate roots, requires TLS 1.2 or newer, and does not follow redirects. Local acceptance therefore needs a hostname and certificate already trusted by the machine account running the package. The certificate SAN must cover the configured hostname or IP; a certificate for `localhost` does not cover `127.0.0.1` unless that IP is also present. Do not use HTTP fallback, certificate-skip flags, custom root overrides, or other verification bypasses for local testing.
|
||||
|
||||
The supervisor supplies the component proof through the environment variable named by `proof.materialEnv`. Bind it from the protected component package at process start. Do not place the proof in `config.yaml`, command arguments, command-line environment assignments, shell history, documentation, or logs.
|
||||
|
||||
The process environment must also set `SCUM_COMPANION_SMOKE_SCOPE` to `isolated-non-production`. This value is a non-secret safety acknowledgement; configure it in the supervisor rather than placing component proof material on a command line.
|
||||
|
||||
Run the executable from the package working directory:
|
||||
|
||||
```bash
|
||||
./scum-companion-smoke
|
||||
```
|
||||
|
||||
The JSON output contains only claimed/completed/unsupported counts, command IDs, and the accepted snapshot ID. It never prints the component proof, component session, command payloads, host paths, or transport details. The run proceeds only when exactly one live `companion.diagnostics` command with a valid bounded payload is claimed. Zero, multiple, expired, malformed, or unsupported commands stop the smoke immediately; they are not acknowledged, completed, or executed, and no snapshot is uploaded. Platform lease fencing remains authoritative.
|
||||
|
||||
Each invocation uploads health to a fresh bounded `smoke-<random>` stream with sequence `1`. This avoids reusing production stream state and remains safe across process restarts without storing a host path or local sequence file.
|
||||
@@ -0,0 +1,516 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
registerPath = "/api/v1/client-managers/register"
|
||||
heartbeatPath = "/api/v1/client-managers/heartbeat"
|
||||
claimPath = "/api/v1/game-client-bridge/companion/commands/claim"
|
||||
snapshotPath = "/api/v1/game-client-bridge/companion/snapshots"
|
||||
|
||||
maxRequestBytes = 128 * 1024
|
||||
maxResponseBytes = 4 * 1024 * 1024
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Now func() time.Time
|
||||
Nonce func() (string, error)
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
config Config
|
||||
proof string
|
||||
httpClient *http.Client
|
||||
now func() time.Time
|
||||
nonce func() (string, error)
|
||||
|
||||
mu sync.Mutex
|
||||
sessionToken string
|
||||
sessionExpiresAt time.Time
|
||||
heartbeatSequence uint64
|
||||
snapshotSequences map[string]uint64
|
||||
}
|
||||
|
||||
type Registration struct {
|
||||
ExpiresAt time.Time
|
||||
HeartbeatEverySeconds int
|
||||
ServerTime time.Time
|
||||
}
|
||||
|
||||
type HealthReport struct {
|
||||
Status string
|
||||
Reason string
|
||||
}
|
||||
|
||||
type HeartbeatResult struct {
|
||||
Status string
|
||||
Health string
|
||||
NextHeartbeatSeconds int
|
||||
SessionExpiresAt time.Time
|
||||
ServerTime time.Time
|
||||
}
|
||||
|
||||
type ClaimedCommand struct {
|
||||
ID string `json:"id"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
CommandType string `json:"commandType"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Priority int `json:"priority"`
|
||||
FencingToken uint64 `json:"fencingToken"`
|
||||
ClaimedAt time.Time `json:"claimedAt"`
|
||||
LeaseExpiresAt time.Time `json:"leaseExpiresAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type CommandAck struct {
|
||||
CommandID string `json:"commandId"`
|
||||
State string `json:"state"`
|
||||
FencingToken uint64 `json:"fencingToken"`
|
||||
AcknowledgedAt time.Time `json:"acknowledgedAt"`
|
||||
}
|
||||
|
||||
type CommandResult struct {
|
||||
Status string
|
||||
Summary string
|
||||
Payload map[string]any
|
||||
}
|
||||
|
||||
type CompletedCommand struct {
|
||||
CommandID string `json:"commandId"`
|
||||
State string `json:"state"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CompletedAt time.Time `json:"completedAt"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Type string
|
||||
SchemaVersion string
|
||||
StreamKey string
|
||||
Sequence uint64
|
||||
ObservedAt time.Time
|
||||
Payload map[string]any
|
||||
KeepForSeconds int
|
||||
MaxRecords int
|
||||
}
|
||||
|
||||
type AcceptedSnapshot struct {
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
Type string `json:"type"`
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
StreamKey string `json:"streamKey"`
|
||||
Sequence uint64 `json:"sequence"`
|
||||
AcceptedAt time.Time `json:"acceptedAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type HTTPError struct {
|
||||
StatusCode int
|
||||
ExpectedStatus int
|
||||
}
|
||||
|
||||
func (err HTTPError) Error() string {
|
||||
return fmt.Sprintf("platform request returned HTTP %d; expected %d", err.StatusCode, err.ExpectedStatus)
|
||||
}
|
||||
|
||||
func NewClient(config Config, options Options) (*Client, error) {
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
proof, configured := os.LookupEnv(config.Proof.MaterialEnv)
|
||||
if !configured || proof == "" {
|
||||
return nil, fmt.Errorf("component proof environment variable is not configured")
|
||||
}
|
||||
if len(proof) > 4096 {
|
||||
return nil, fmt.Errorf("component proof environment variable is invalid")
|
||||
}
|
||||
|
||||
baseURL, err := canonicalPlatformOrigin(config.Platform.BaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport := secureDefaultTransport()
|
||||
timeout := time.Duration(config.Timing.RequestTimeoutSeconds) * time.Second
|
||||
httpClient := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
nonce := options.Nonce
|
||||
if nonce == nil {
|
||||
nonce = randomNonce
|
||||
}
|
||||
config.Capabilities = append([]string(nil), config.Capabilities...)
|
||||
config.Platform.BaseURL = baseURL
|
||||
return &Client{config: config, proof: proof, httpClient: httpClient, now: now, nonce: nonce, snapshotSequences: make(map[string]uint64)}, nil
|
||||
}
|
||||
|
||||
func secureDefaultTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
}
|
||||
}
|
||||
|
||||
func (client *Client) Register(ctx context.Context) (Registration, error) {
|
||||
nonce, err := client.nonce()
|
||||
if err != nil {
|
||||
return Registration{}, fmt.Errorf("create component registration nonce: %w", err)
|
||||
}
|
||||
if !validNonce(nonce) {
|
||||
return Registration{}, fmt.Errorf("component registration nonce is invalid")
|
||||
}
|
||||
component := client.config.Component
|
||||
request := registerRequest{
|
||||
InstallationID: component.InstallationID,
|
||||
ServerInstanceID: component.ServerInstanceID,
|
||||
ProfileKey: component.ProfileKey,
|
||||
ArtifactID: component.ArtifactID,
|
||||
Version: component.Version,
|
||||
SourceRevision: component.SourceRevision,
|
||||
TargetOS: component.TargetOS,
|
||||
TargetArch: component.TargetArch,
|
||||
KeyGeneration: component.KeyGeneration,
|
||||
DeploymentGeneration: component.DeploymentGeneration,
|
||||
Capabilities: append([]string(nil), client.config.Capabilities...),
|
||||
Timestamp: client.now().UTC(),
|
||||
Nonce: nonce,
|
||||
}
|
||||
request.Signature = registrationSignature(client.proof, request)
|
||||
var response registerResponse
|
||||
if err := client.postJSON(ctx, registerPath, http.StatusOK, request, &response); err != nil {
|
||||
return Registration{}, err
|
||||
}
|
||||
if !response.Accepted || response.InstallationID != component.InstallationID || response.SessionToken == "" || response.ExpiresAt.IsZero() || !client.now().Before(response.ExpiresAt) {
|
||||
return Registration{}, fmt.Errorf("platform returned an invalid component registration")
|
||||
}
|
||||
client.mu.Lock()
|
||||
client.sessionToken = response.SessionToken
|
||||
client.sessionExpiresAt = response.ExpiresAt
|
||||
client.heartbeatSequence = 0
|
||||
client.mu.Unlock()
|
||||
return Registration{ExpiresAt: response.ExpiresAt, HeartbeatEverySeconds: response.HeartbeatEverySeconds, ServerTime: response.ServerTime}, nil
|
||||
}
|
||||
|
||||
func (client *Client) Heartbeat(ctx context.Context, report HealthReport) (HeartbeatResult, error) {
|
||||
if report.Status != "healthy" && report.Status != "degraded" && report.Status != "unhealthy" && report.Status != "offline" {
|
||||
return HeartbeatResult{}, fmt.Errorf("component health status is invalid")
|
||||
}
|
||||
token, sessionExpiresAt, sequence, err := client.nextHeartbeat()
|
||||
if err != nil {
|
||||
return HeartbeatResult{}, err
|
||||
}
|
||||
request := heartbeatRequest{
|
||||
InstallationID: client.config.Component.InstallationID,
|
||||
SessionToken: token,
|
||||
Sequence: sequence,
|
||||
Health: report.Status,
|
||||
HealthReason: report.Reason,
|
||||
Capabilities: append([]string(nil), client.config.Capabilities...),
|
||||
SentAt: client.now().UTC(),
|
||||
}
|
||||
var response heartbeatResponse
|
||||
if err := client.postJSON(ctx, heartbeatPath, http.StatusOK, request, &response); err != nil {
|
||||
return HeartbeatResult{}, err
|
||||
}
|
||||
if !response.Accepted || response.InstallationID != client.config.Component.InstallationID || !response.SessionExpiresAt.Equal(sessionExpiresAt) {
|
||||
return HeartbeatResult{}, fmt.Errorf("platform returned an invalid component heartbeat")
|
||||
}
|
||||
return HeartbeatResult{Status: response.Status, Health: response.Health, NextHeartbeatSeconds: response.NextHeartbeatSeconds, SessionExpiresAt: response.SessionExpiresAt, ServerTime: response.ServerTime}, nil
|
||||
}
|
||||
|
||||
func (client *Client) ClaimCommands(ctx context.Context, limit int) ([]ClaimedCommand, error) {
|
||||
if limit < 0 || limit > 50 {
|
||||
return nil, fmt.Errorf("claim limit must be between 0 and 50")
|
||||
}
|
||||
token, err := client.currentSession()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var response claimResponse
|
||||
if err := client.postJSON(ctx, claimPath, http.StatusOK, claimRequest{SessionToken: token, Limit: limit}, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.Count != len(response.Items) {
|
||||
return nil, fmt.Errorf("platform returned an invalid command claim batch")
|
||||
}
|
||||
return append([]ClaimedCommand(nil), response.Items...), nil
|
||||
}
|
||||
|
||||
func (client *Client) AckCommand(ctx context.Context, commandID string, fencingToken uint64) (CommandAck, error) {
|
||||
if commandID == "" || fencingToken == 0 {
|
||||
return CommandAck{}, fmt.Errorf("command ID and fencing token are required")
|
||||
}
|
||||
token, err := client.currentSession()
|
||||
if err != nil {
|
||||
return CommandAck{}, err
|
||||
}
|
||||
path := "/api/v1/game-client-bridge/companion/commands/" + url.PathEscape(commandID) + "/ack"
|
||||
var response CommandAck
|
||||
if err := client.postJSON(ctx, path, http.StatusOK, ackRequest{SessionToken: token, FencingToken: fencingToken}, &response); err != nil {
|
||||
return CommandAck{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (client *Client) CompleteCommand(ctx context.Context, commandID string, fencingToken uint64, result CommandResult) (CompletedCommand, error) {
|
||||
if commandID == "" || fencingToken == 0 {
|
||||
return CompletedCommand{}, fmt.Errorf("command ID and fencing token are required")
|
||||
}
|
||||
if result.Status != "succeeded" && result.Status != "failed" && result.Status != "cancelled" {
|
||||
return CompletedCommand{}, fmt.Errorf("command result status is invalid")
|
||||
}
|
||||
token, err := client.currentSession()
|
||||
if err != nil {
|
||||
return CompletedCommand{}, err
|
||||
}
|
||||
path := "/api/v1/game-client-bridge/companion/commands/" + url.PathEscape(commandID) + "/result"
|
||||
request := resultRequest{SessionToken: token, FencingToken: fencingToken, Status: result.Status, Summary: result.Summary, Payload: result.Payload}
|
||||
var response CompletedCommand
|
||||
if err := client.postJSON(ctx, path, http.StatusOK, request, &response); err != nil {
|
||||
return CompletedCommand{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (client *Client) UploadSnapshot(ctx context.Context, snapshot Snapshot) (AcceptedSnapshot, error) {
|
||||
if snapshot.Type == "" || snapshot.SchemaVersion == "" || snapshot.StreamKey == "" || snapshot.Sequence == 0 || snapshot.ObservedAt.IsZero() || snapshot.Payload == nil || snapshot.KeepForSeconds <= 0 {
|
||||
return AcceptedSnapshot{}, fmt.Errorf("typed snapshot is incomplete")
|
||||
}
|
||||
if err := client.reserveSnapshotSequence(snapshot); err != nil {
|
||||
return AcceptedSnapshot{}, err
|
||||
}
|
||||
token, err := client.currentSession()
|
||||
if err != nil {
|
||||
return AcceptedSnapshot{}, err
|
||||
}
|
||||
request := snapshotRequest{
|
||||
SessionToken: token, Type: snapshot.Type, SchemaVersion: snapshot.SchemaVersion, StreamKey: snapshot.StreamKey,
|
||||
Sequence: snapshot.Sequence, ObservedAt: snapshot.ObservedAt.UTC(), Payload: snapshot.Payload,
|
||||
KeepForSeconds: snapshot.KeepForSeconds, MaxRecords: snapshot.MaxRecords,
|
||||
}
|
||||
var response AcceptedSnapshot
|
||||
if err := client.postJSON(ctx, snapshotPath, http.StatusAccepted, request, &response); err != nil {
|
||||
return AcceptedSnapshot{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (client *Client) currentSession() (string, error) {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
if client.sessionToken == "" || client.sessionExpiresAt.IsZero() || !client.now().Before(client.sessionExpiresAt) {
|
||||
return "", errors.New("component session is unavailable or expired")
|
||||
}
|
||||
return client.sessionToken, nil
|
||||
}
|
||||
|
||||
func (client *Client) nextHeartbeat() (string, time.Time, uint64, error) {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
if client.sessionToken == "" || client.sessionExpiresAt.IsZero() || !client.now().Before(client.sessionExpiresAt) {
|
||||
return "", time.Time{}, 0, errors.New("component session is unavailable or expired")
|
||||
}
|
||||
client.heartbeatSequence++
|
||||
return client.sessionToken, client.sessionExpiresAt, client.heartbeatSequence, nil
|
||||
}
|
||||
|
||||
func (client *Client) reserveSnapshotSequence(snapshot Snapshot) error {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
key := snapshot.Type + "\x00" + snapshot.StreamKey
|
||||
if snapshot.Sequence <= client.snapshotSequences[key] {
|
||||
return fmt.Errorf("snapshot sequence must increase for its typed stream")
|
||||
}
|
||||
client.snapshotSequences[key] = snapshot.Sequence
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *Client) postJSON(ctx context.Context, path string, expectedStatus int, payload any, target any) error {
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode platform request: %w", err)
|
||||
}
|
||||
if len(encoded) > maxRequestBytes {
|
||||
return fmt.Errorf("platform request exceeds the bounded payload size")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.config.Platform.BaseURL+path, bytes.NewReader(encoded))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create platform request: %w", err)
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := client.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send platform request: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != expectedStatus {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
|
||||
return HTTPError{StatusCode: response.StatusCode, ExpectedStatus: expectedStatus}
|
||||
}
|
||||
decoder := json.NewDecoder(io.LimitReader(response.Body, maxResponseBytes))
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return fmt.Errorf("decode platform response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func registrationSignature(proof string, request registerRequest) string {
|
||||
capabilities := append([]string(nil), request.Capabilities...)
|
||||
sort.Strings(capabilities)
|
||||
canonical := strings.Join([]string{
|
||||
request.InstallationID,
|
||||
request.ServerInstanceID,
|
||||
request.ProfileKey,
|
||||
request.ArtifactID,
|
||||
request.Version,
|
||||
request.SourceRevision,
|
||||
request.TargetOS,
|
||||
request.TargetArch,
|
||||
strconv.Itoa(request.KeyGeneration),
|
||||
strconv.Itoa(request.DeploymentGeneration),
|
||||
request.Timestamp.UTC().Format(time.RFC3339Nano),
|
||||
request.Nonce,
|
||||
strings.Join(capabilities, ","),
|
||||
}, "\n")
|
||||
mac := hmac.New(sha256.New, []byte(proof))
|
||||
_, _ = mac.Write([]byte(canonical))
|
||||
return "sha256:" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func randomNonce() (string, error) {
|
||||
value := make([]byte, 24)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func validNonce(value string) bool {
|
||||
if len(value) < 16 || len(value) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character >= 'A' && character <= 'Z' || character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '_' || character == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
InstallationID string `json:"installationId"`
|
||||
ServerInstanceID string `json:"serverInstanceId"`
|
||||
ProfileKey string `json:"profileKey"`
|
||||
ArtifactID string `json:"artifactId"`
|
||||
Version string `json:"version"`
|
||||
SourceRevision string `json:"sourceRevision"`
|
||||
TargetOS string `json:"targetOs"`
|
||||
TargetArch string `json:"targetArch"`
|
||||
KeyGeneration int `json:"keyGeneration"`
|
||||
DeploymentGeneration int `json:"deploymentGeneration"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Nonce string `json:"nonce"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
type registerResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
InstallationID string `json:"installationId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
HeartbeatEverySeconds int `json:"heartbeatEverySeconds"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type heartbeatRequest struct {
|
||||
InstallationID string `json:"installationId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Sequence uint64 `json:"sequence"`
|
||||
Health string `json:"health"`
|
||||
HealthReason string `json:"healthReason,omitempty"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
}
|
||||
|
||||
type heartbeatResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
InstallationID string `json:"installationId"`
|
||||
Status string `json:"status"`
|
||||
Health string `json:"health"`
|
||||
NextHeartbeatSeconds int `json:"nextHeartbeatSeconds"`
|
||||
SessionExpiresAt time.Time `json:"sessionExpiresAt"`
|
||||
ServerTime time.Time `json:"serverTime"`
|
||||
}
|
||||
|
||||
type claimRequest struct {
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type claimResponse struct {
|
||||
Items []ClaimedCommand `json:"items"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type ackRequest struct {
|
||||
SessionToken string `json:"sessionToken"`
|
||||
FencingToken uint64 `json:"fencingToken"`
|
||||
}
|
||||
|
||||
type resultRequest struct {
|
||||
SessionToken string `json:"sessionToken"`
|
||||
FencingToken uint64 `json:"fencingToken"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Payload map[string]any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type snapshotRequest struct {
|
||||
SessionToken string `json:"sessionToken"`
|
||||
Type string `json:"type"`
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
StreamKey string `json:"streamKey"`
|
||||
Sequence uint64 `json:"sequence"`
|
||||
ObservedAt time.Time `json:"observedAt"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
KeepForSeconds int `json:"keepForSeconds"`
|
||||
MaxRecords int `json:"maxRecords,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testProof = "fixture-component-proof-material"
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return fn(request)
|
||||
}
|
||||
|
||||
func TestLoadConfigKeepsProofOutOfConfig(t *testing.T) {
|
||||
file, err := os.Open("config.yaml.example")
|
||||
if err != nil {
|
||||
t.Fatalf("open config fixture: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
config, err := LoadConfig(file)
|
||||
if err != nil {
|
||||
t.Fatalf("load config fixture: %v", err)
|
||||
}
|
||||
encoded, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config fixture: %v", err)
|
||||
}
|
||||
serialized := string(encoded)
|
||||
for _, forbidden := range []string{testProof, "scum_client_credential", "sessionToken", "/api/v1/scum-clients/", "InsecureSkipVerify"} {
|
||||
if strings.Contains(serialized, forbidden) {
|
||||
t.Fatalf("config exposed forbidden content %q: %s", forbidden, serialized)
|
||||
}
|
||||
}
|
||||
|
||||
t.Setenv(ProofEnvironment, "")
|
||||
if _, err := NewClient(config, Options{}); err == nil {
|
||||
t.Fatal("expected missing proof environment to be rejected")
|
||||
}
|
||||
t.Setenv(ProofEnvironment, testProof)
|
||||
if _, err := NewClient(config, Options{}); err != nil {
|
||||
t.Fatalf("create client from proof environment: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigRejectsLegacySharedCredentialFields(t *testing.T) {
|
||||
fixture, err := os.ReadFile("config.yaml.example")
|
||||
if err != nil {
|
||||
t.Fatalf("read config fixture: %v", err)
|
||||
}
|
||||
for name, legacy := range map[string]string{
|
||||
"legacy endpoint": "server_url: https://legacy.example.test\n",
|
||||
"shared credential": "scum_client_credential: legacy-shared-value\n",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := LoadConfig(strings.NewReader(string(fixture) + legacy)); err == nil {
|
||||
t.Fatalf("expected legacy config field to be rejected: %s", legacy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientRejectsHTTPPlatformBaseURL(t *testing.T) {
|
||||
config := loadTestConfig(t)
|
||||
config.Platform.BaseURL = "http://platform.example.test"
|
||||
t.Setenv(ProofEnvironment, testProof)
|
||||
|
||||
if _, err := NewClient(config, Options{}); err == nil || !strings.Contains(err.Error(), "HTTPS") {
|
||||
t.Fatalf("expected HTTP platform origin to be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientCanonicalizesPlatformOriginAndRejectsInvalidPorts(t *testing.T) {
|
||||
t.Setenv(ProofEnvironment, testProof)
|
||||
config := loadTestConfig(t)
|
||||
config.Platform.BaseURL = " https://platform.example.test:8443/ "
|
||||
client, err := NewClient(config, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("create client: %v", err)
|
||||
}
|
||||
if client.config.Platform.BaseURL != "https://platform.example.test:8443" {
|
||||
t.Fatalf("platform origin was not canonicalized: %q", client.config.Platform.BaseURL)
|
||||
}
|
||||
|
||||
for _, baseURL := range []string{"https://:443", "https://platform.example.test:", "https://platform.example.test:65536"} {
|
||||
config.Platform.BaseURL = baseURL
|
||||
if _, err := NewClient(config, Options{}); err == nil {
|
||||
t.Fatalf("expected invalid platform origin to be rejected: %q", baseURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientUsesBoundedConfiguredRequestTimeout(t *testing.T) {
|
||||
config := loadTestConfig(t)
|
||||
config.Timing.RequestTimeoutSeconds = 7
|
||||
t.Setenv(ProofEnvironment, testProof)
|
||||
|
||||
client, err := NewClient(config, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("create client: %v", err)
|
||||
}
|
||||
if client.httpClient.Timeout != 7*time.Second {
|
||||
t.Fatalf("unexpected request timeout: %s", client.httpClient.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientDefaultTransportVerifiesSystemRootsWithTLS12Minimum(t *testing.T) {
|
||||
config := loadTestConfig(t)
|
||||
t.Setenv(ProofEnvironment, testProof)
|
||||
|
||||
client, err := NewClient(config, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("create client: %v", err)
|
||||
}
|
||||
transport, ok := client.httpClient.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected transport type: %T", client.httpClient.Transport)
|
||||
}
|
||||
if transport == http.DefaultTransport {
|
||||
t.Fatal("client must own its transport")
|
||||
}
|
||||
if transport.TLSClientConfig == nil {
|
||||
t.Fatal("client transport must declare an explicit TLS policy")
|
||||
}
|
||||
if transport.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("client transport must verify server certificates")
|
||||
}
|
||||
if transport.TLSClientConfig.MinVersion < tls.VersionTLS12 {
|
||||
t.Fatalf("client transport allows TLS below 1.2: %x", transport.TLSClientConfig.MinVersion)
|
||||
}
|
||||
if transport.TLSClientConfig.RootCAs != nil {
|
||||
t.Fatal("nil RootCAs must select the host system root pool")
|
||||
}
|
||||
if client.httpClient.CheckRedirect == nil {
|
||||
t.Fatal("client must explicitly reject redirects")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientRejectsUntrustedTLSServerByDefault(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
config := loadTestConfig(t)
|
||||
config.Platform.BaseURL = server.URL
|
||||
t.Setenv(ProofEnvironment, testProof)
|
||||
client, err := NewClient(config, Options{
|
||||
Now: func() time.Time { return time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC) },
|
||||
Nonce: func() (string, error) { return "nonce-fixture-untrusted-tls", nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create client: %v", err)
|
||||
}
|
||||
|
||||
_, err = client.Register(context.Background())
|
||||
var verificationError *tls.CertificateVerificationError
|
||||
if !errors.As(err, &verificationError) {
|
||||
t.Fatalf("expected untrusted TLS certificate verification failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRejectsRedirectWithoutReplayingComponentSession(t *testing.T) {
|
||||
stamp := time.Date(2026, 7, 20, 9, 30, 0, 0, time.UTC)
|
||||
config := loadTestConfig(t)
|
||||
requests := 0
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
requests++
|
||||
switch requests {
|
||||
case 1:
|
||||
assertPlatformRequest(t, request, registerPath)
|
||||
return jsonHTTPResponse(http.StatusOK, registerResponse{
|
||||
Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-one",
|
||||
ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp,
|
||||
}), nil
|
||||
case 2:
|
||||
assertPlatformRequest(t, request, claimPath)
|
||||
var body claimRequest
|
||||
decodeRequest(t, request, &body)
|
||||
if body.SessionToken != "component-session-one" {
|
||||
t.Fatalf("claim did not carry the component session: %+v", body)
|
||||
}
|
||||
response := jsonHTTPResponse(http.StatusTemporaryRedirect, nil)
|
||||
response.Header.Set("Location", "https://redirect.example.test/capture")
|
||||
return response, nil
|
||||
default:
|
||||
t.Fatalf("redirect replayed component material to %s", request.URL.String())
|
||||
return nil, fmt.Errorf("unexpected redirected request")
|
||||
}
|
||||
})
|
||||
client := newTestClient(t, config, transport, stamp)
|
||||
if _, err := client.Register(context.Background()); err != nil {
|
||||
t.Fatalf("register client: %v", err)
|
||||
}
|
||||
_, err := client.ClaimCommands(context.Background(), 1)
|
||||
var statusError HTTPError
|
||||
if !errors.As(err, &statusError) || statusError.StatusCode != http.StatusTemporaryRedirect {
|
||||
t.Fatalf("expected redirect response to be rejected, got %v", err)
|
||||
}
|
||||
if requests != 2 {
|
||||
t.Fatalf("redirect unexpectedly triggered %d requests", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterUsesExactCanonicalHMAC(t *testing.T) {
|
||||
stamp := time.Date(2026, 7, 20, 5, 4, 3, 123456789, time.UTC)
|
||||
config := loadTestConfig(t)
|
||||
for left, right := 0, len(config.Capabilities)-1; left < right; left, right = left+1, right-1 {
|
||||
config.Capabilities[left], config.Capabilities[right] = config.Capabilities[right], config.Capabilities[left]
|
||||
}
|
||||
var captured registerRequest
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
assertPlatformRequest(t, request, registerPath)
|
||||
decodeRequest(t, request, &captured)
|
||||
if strings.Contains(captured.Signature, testProof) {
|
||||
t.Fatal("registration signature contains raw proof")
|
||||
}
|
||||
return jsonHTTPResponse(http.StatusOK, registerResponse{
|
||||
Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-one",
|
||||
ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp,
|
||||
}), nil
|
||||
})
|
||||
client := newTestClient(t, config, transport, stamp)
|
||||
registration, err := client.Register(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("register client: %v", err)
|
||||
}
|
||||
if registration.HeartbeatEverySeconds != 30 || !registration.ExpiresAt.Equal(stamp.Add(15*time.Minute)) {
|
||||
t.Fatalf("unexpected registration projection: %+v", registration)
|
||||
}
|
||||
if captured.Timestamp.Format(time.RFC3339Nano) != "2026-07-20T05:04:03.123456789Z" {
|
||||
t.Fatalf("registration timestamp was not RFC3339Nano UTC: %s", captured.Timestamp.Format(time.RFC3339Nano))
|
||||
}
|
||||
expected := independentRegistrationSignature(testProof, captured)
|
||||
if captured.Signature != expected {
|
||||
t.Fatalf("unexpected registration signature: got %s want %s", captured.Signature, expected)
|
||||
}
|
||||
if captured.Nonce != "nonce-fixture-registration-0001" {
|
||||
t.Fatalf("unexpected registration nonce: %s", captured.Nonce)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotSequenceContinuesAcrossComponentSessions(t *testing.T) {
|
||||
stamp := time.Date(2026, 7, 20, 7, 0, 0, 0, time.UTC)
|
||||
config := loadTestConfig(t)
|
||||
step := 0
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
switch step {
|
||||
case 0, 2:
|
||||
assertPlatformRequest(t, request, registerPath)
|
||||
step++
|
||||
session := "component-session-one"
|
||||
if step == 3 {
|
||||
session = "component-session-two"
|
||||
}
|
||||
return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: session, ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp}), nil
|
||||
case 1, 3:
|
||||
assertPlatformRequest(t, request, snapshotPath)
|
||||
var body snapshotRequest
|
||||
decodeRequest(t, request, &body)
|
||||
expectedSession := "component-session-one"
|
||||
expectedSequence := uint64(10)
|
||||
if step == 3 {
|
||||
expectedSession = "component-session-two"
|
||||
expectedSequence = 11
|
||||
}
|
||||
if body.SessionToken != expectedSession || body.Sequence != expectedSequence {
|
||||
t.Fatalf("snapshot did not continue across component sessions: %+v", body)
|
||||
}
|
||||
step++
|
||||
return jsonHTTPResponse(http.StatusAccepted, AcceptedSnapshot{SnapshotID: fmt.Sprintf("snapshot-%d", body.Sequence), ProfileKey: ProfileKey, Type: body.Type, SchemaVersion: body.SchemaVersion, StreamKey: body.StreamKey, Sequence: body.Sequence, AcceptedAt: stamp, ExpiresAt: stamp.Add(7 * 24 * time.Hour)}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request %s", request.URL.Path)
|
||||
}
|
||||
})
|
||||
t.Setenv(ProofEnvironment, testProof)
|
||||
nonce := 0
|
||||
client, err := NewClient(config, Options{
|
||||
Now: func() time.Time { return stamp },
|
||||
Nonce: func() (string, error) {
|
||||
nonce++
|
||||
return fmt.Sprintf("nonce-fixture-session-%04d", nonce), nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create client: %v", err)
|
||||
}
|
||||
client.httpClient.Transport = transport
|
||||
upload := func(sequence uint64) error {
|
||||
_, err := client.UploadSnapshot(context.Background(), Snapshot{Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: sequence, ObservedAt: stamp, Payload: map[string]any{"status": "online", "observedAt": stamp.Format(time.RFC3339)}, KeepForSeconds: 604800, MaxRecords: 1000})
|
||||
return err
|
||||
}
|
||||
if _, err := client.Register(context.Background()); err != nil {
|
||||
t.Fatalf("register first session: %v", err)
|
||||
}
|
||||
if err := upload(10); err != nil {
|
||||
t.Fatalf("upload first session snapshot: %v", err)
|
||||
}
|
||||
if _, err := client.Register(context.Background()); err != nil {
|
||||
t.Fatalf("register replacement session: %v", err)
|
||||
}
|
||||
if err := upload(10); err == nil || !strings.Contains(err.Error(), "sequence") {
|
||||
t.Fatalf("expected stale sequence to be rejected before transport, got %v", err)
|
||||
}
|
||||
if err := upload(11); err != nil {
|
||||
t.Fatalf("upload replacement session snapshot: %v", err)
|
||||
}
|
||||
if step != 4 {
|
||||
t.Fatalf("expected four transport requests, got %d", step)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotRequiresHTTPAccepted(t *testing.T) {
|
||||
stamp := time.Date(2026, 7, 20, 8, 0, 0, 0, time.UTC)
|
||||
config := loadTestConfig(t)
|
||||
step := 0
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
step++
|
||||
if step == 1 {
|
||||
return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-one", ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp}), nil
|
||||
}
|
||||
return jsonHTTPResponse(http.StatusOK, AcceptedSnapshot{}), nil
|
||||
})
|
||||
client := newTestClient(t, config, transport, stamp)
|
||||
if _, err := client.Register(context.Background()); err != nil {
|
||||
t.Fatalf("register client: %v", err)
|
||||
}
|
||||
_, err := client.UploadSnapshot(context.Background(), Snapshot{Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: stamp, Payload: map[string]any{"status": "online", "observedAt": stamp.Format(time.RFC3339)}, KeepForSeconds: 604800, MaxRecords: 1000})
|
||||
var statusError HTTPError
|
||||
if !errors.As(err, &statusError) || statusError.StatusCode != http.StatusOK || statusError.ExpectedStatus != http.StatusAccepted {
|
||||
t.Fatalf("expected exact 202 enforcement, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponentSessionHeartbeatAndBridgeTransport(t *testing.T) {
|
||||
stamp := time.Date(2026, 7, 20, 6, 0, 0, 0, time.UTC)
|
||||
config := loadTestConfig(t)
|
||||
sessionExpiry := stamp.Add(15 * time.Minute)
|
||||
step := 0
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
if strings.Contains(request.URL.Path, "/api/v1/scum-clients/") {
|
||||
t.Fatalf("legacy endpoint used: %s", request.URL.Path)
|
||||
}
|
||||
switch step {
|
||||
case 0:
|
||||
assertPlatformRequest(t, request, registerPath)
|
||||
var body registerRequest
|
||||
decodeRequest(t, request, &body)
|
||||
step++
|
||||
return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-one", ExpiresAt: sessionExpiry, HeartbeatEverySeconds: 30, ServerTime: stamp}), nil
|
||||
case 1:
|
||||
assertPlatformRequest(t, request, heartbeatPath)
|
||||
var body heartbeatRequest
|
||||
decodeRequest(t, request, &body)
|
||||
if body.SessionToken != "component-session-one" || body.Sequence != 1 || body.Health != "healthy" || body.HealthReason != "bridge ready" {
|
||||
t.Fatalf("unexpected heartbeat body: %+v", body)
|
||||
}
|
||||
step++
|
||||
return jsonHTTPResponse(http.StatusOK, heartbeatResponse{Accepted: true, InstallationID: config.Component.InstallationID, Status: "online", Health: "healthy", NextHeartbeatSeconds: 30, SessionExpiresAt: sessionExpiry, ServerTime: stamp}), nil
|
||||
case 2:
|
||||
assertPlatformRequest(t, request, claimPath)
|
||||
var body claimRequest
|
||||
decodeRequest(t, request, &body)
|
||||
if body.SessionToken != "component-session-one" || body.Limit != 5 {
|
||||
t.Fatalf("unexpected claim body: %+v", body)
|
||||
}
|
||||
step++
|
||||
return jsonHTTPResponse(http.StatusOK, claimResponse{Items: []ClaimedCommand{{ID: "command-1", ProfileKey: ProfileKey, CommandType: "companion.diagnostics", Payload: map[string]any{"includeWindowState": true}, FencingToken: 9, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(5 * time.Minute)}}, Count: 1}), nil
|
||||
case 3:
|
||||
assertPlatformRequest(t, request, "/api/v1/game-client-bridge/companion/commands/command-1/ack")
|
||||
var body ackRequest
|
||||
decodeRequest(t, request, &body)
|
||||
if body.SessionToken != "component-session-one" || body.FencingToken != 9 {
|
||||
t.Fatalf("unexpected ack body: %+v", body)
|
||||
}
|
||||
step++
|
||||
return jsonHTTPResponse(http.StatusOK, CommandAck{CommandID: "command-1", State: "claimed", FencingToken: 9, AcknowledgedAt: stamp}), nil
|
||||
case 4:
|
||||
assertPlatformRequest(t, request, "/api/v1/game-client-bridge/companion/commands/command-1/result")
|
||||
var body resultRequest
|
||||
decodeRequest(t, request, &body)
|
||||
if body.SessionToken != "component-session-one" || body.FencingToken != 9 || body.Status != "succeeded" || body.Summary != "diagnostics collected" || body.Payload["entries"] != float64(3) {
|
||||
t.Fatalf("unexpected result body: %+v", body)
|
||||
}
|
||||
step++
|
||||
return jsonHTTPResponse(http.StatusOK, map[string]any{"commandId": "command-1", "state": "succeeded", "result": map[string]any{"status": "succeeded", "summary": "diagnostics collected", "completedAt": stamp}, "updatedAt": stamp, "completedAt": stamp}), nil
|
||||
case 5:
|
||||
assertPlatformRequest(t, request, snapshotPath)
|
||||
var body snapshotRequest
|
||||
decodeRequest(t, request, &body)
|
||||
if body.SessionToken != "component-session-one" || body.Type != "companion.health" || body.SchemaVersion != "1" || body.StreamKey != "current" || body.Sequence != 1 || body.KeepForSeconds != 604800 || body.MaxRecords != 1000 {
|
||||
t.Fatalf("unexpected snapshot body: %+v", body)
|
||||
}
|
||||
step++
|
||||
return jsonHTTPResponse(http.StatusAccepted, AcceptedSnapshot{SnapshotID: "snapshot-1", ProfileKey: ProfileKey, Type: body.Type, SchemaVersion: body.SchemaVersion, StreamKey: body.StreamKey, Sequence: body.Sequence, AcceptedAt: stamp, ExpiresAt: stamp.Add(7 * 24 * time.Hour)}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected request %s", request.URL.Path)
|
||||
}
|
||||
})
|
||||
client := newTestClient(t, config, transport, stamp)
|
||||
if _, err := client.Register(context.Background()); err != nil {
|
||||
t.Fatalf("register client: %v", err)
|
||||
}
|
||||
if _, err := client.Heartbeat(context.Background(), HealthReport{Status: "healthy", Reason: "bridge ready"}); err != nil {
|
||||
t.Fatalf("heartbeat: %v", err)
|
||||
}
|
||||
commands, err := client.ClaimCommands(context.Background(), 5)
|
||||
if err != nil || len(commands) != 1 {
|
||||
t.Fatalf("claim commands: commands=%+v err=%v", commands, err)
|
||||
}
|
||||
if _, err := client.AckCommand(context.Background(), commands[0].ID, commands[0].FencingToken); err != nil {
|
||||
t.Fatalf("ack command: %v", err)
|
||||
}
|
||||
if _, err := client.CompleteCommand(context.Background(), commands[0].ID, commands[0].FencingToken, CommandResult{Status: "succeeded", Summary: "diagnostics collected", Payload: map[string]any{"entries": 3}}); err != nil {
|
||||
t.Fatalf("complete command: %v", err)
|
||||
}
|
||||
snapshot := Snapshot{Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: stamp, Payload: map[string]any{"status": "online", "observedAt": stamp.Format(time.RFC3339)}, KeepForSeconds: 604800, MaxRecords: 1000}
|
||||
accepted, err := client.UploadSnapshot(context.Background(), snapshot)
|
||||
if err != nil || accepted.SnapshotID != "snapshot-1" {
|
||||
t.Fatalf("upload snapshot: accepted=%+v err=%v", accepted, err)
|
||||
}
|
||||
if step != 6 {
|
||||
t.Fatalf("expected six fixed API requests, got %d", step)
|
||||
}
|
||||
}
|
||||
|
||||
func loadTestConfig(t *testing.T) Config {
|
||||
t.Helper()
|
||||
file, err := os.Open("config.yaml.example")
|
||||
if err != nil {
|
||||
t.Fatalf("open config fixture: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
config, err := LoadConfig(file)
|
||||
if err != nil {
|
||||
t.Fatalf("load config fixture: %v", err)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func newTestClient(t *testing.T, config Config, transport http.RoundTripper, stamp time.Time) *Client {
|
||||
t.Helper()
|
||||
t.Setenv(ProofEnvironment, testProof)
|
||||
client, err := NewClient(config, Options{
|
||||
Now: func() time.Time { return stamp },
|
||||
Nonce: func() (string, error) { return "nonce-fixture-registration-0001", nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create test client: %v", err)
|
||||
}
|
||||
client.httpClient.Transport = transport
|
||||
return client
|
||||
}
|
||||
|
||||
func assertPlatformRequest(t *testing.T, request *http.Request, expectedPath string) {
|
||||
t.Helper()
|
||||
if request.Method != http.MethodPost || request.URL.Scheme != "https" || request.URL.Host != "platform.example.test" || request.URL.Path != expectedPath {
|
||||
t.Fatalf("unexpected platform request: %s %s", request.Method, request.URL.String())
|
||||
}
|
||||
if request.Header.Get("Authorization") != "" {
|
||||
t.Fatalf("component session must be carried in the typed JSON body, got Authorization header")
|
||||
}
|
||||
if request.Header.Get("Content-Type") != "application/json" || request.Header.Get("Accept") != "application/json" {
|
||||
t.Fatalf("unexpected request headers: %+v", request.Header)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeRequest(t *testing.T, request *http.Request, target any) {
|
||||
t.Helper()
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonHTTPResponse(status int, payload any) *http.Response {
|
||||
encoded, _ := json.Marshal(payload)
|
||||
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(encoded)))}
|
||||
}
|
||||
|
||||
func independentRegistrationSignature(proof string, request registerRequest) string {
|
||||
capabilities := append([]string(nil), request.Capabilities...)
|
||||
sort.Strings(capabilities)
|
||||
canonical := strings.Join([]string{
|
||||
request.InstallationID,
|
||||
request.ServerInstanceID,
|
||||
request.ProfileKey,
|
||||
request.ArtifactID,
|
||||
request.Version,
|
||||
request.SourceRevision,
|
||||
request.TargetOS,
|
||||
request.TargetArch,
|
||||
strconv.Itoa(request.KeyGeneration),
|
||||
strconv.Itoa(request.DeploymentGeneration),
|
||||
request.Timestamp.UTC().Format(time.RFC3339Nano),
|
||||
request.Nonce,
|
||||
strings.Join(capabilities, ","),
|
||||
}, "\n")
|
||||
mac := hmac.New(sha256.New, []byte(proof))
|
||||
_, _ = mac.Write([]byte(canonical))
|
||||
return "sha256:" + hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
companion "browser.local/plugins/scum-server-plugin/companion"
|
||||
)
|
||||
|
||||
func main() {
|
||||
result := companion.SmokeResult{CompletedCommandIDs: []string{}, UnsupportedCommandIDs: []string{}}
|
||||
if len(os.Args) != 1 {
|
||||
writeResult(result)
|
||||
os.Exit(1)
|
||||
}
|
||||
file, err := os.Open("config.yaml")
|
||||
if err != nil {
|
||||
writeResult(result)
|
||||
os.Exit(1)
|
||||
}
|
||||
config, err := companion.LoadConfig(file)
|
||||
closeErr := file.Close()
|
||||
if err != nil || closeErr != nil {
|
||||
writeResult(result)
|
||||
os.Exit(1)
|
||||
}
|
||||
client, err := companion.NewClient(config, companion.Options{})
|
||||
if err != nil {
|
||||
writeResult(result)
|
||||
os.Exit(1)
|
||||
}
|
||||
_ = os.Unsetenv(companion.ProofEnvironment)
|
||||
isolated := os.Getenv(companion.SmokeIsolationEnvironment) == companion.SmokeIsolationValue
|
||||
requestBudget := time.Duration(config.Timing.RequestTimeoutSeconds) * time.Second * time.Duration(2*companion.SmokeClaimLimit+4)
|
||||
if requestBudget > 45*time.Second {
|
||||
requestBudget = 45 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), requestBudget)
|
||||
defer cancel()
|
||||
result, err = companion.RunOneShotSmoke(ctx, client, companion.SmokeOptions{IsolatedNonProduction: isolated})
|
||||
writeResult(result)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func writeResult(result companion.SmokeResult) {
|
||||
_ = json.NewEncoder(os.Stdout).Encode(result)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
ConfigSchemaVersion = 1
|
||||
PluginID = "game.scum"
|
||||
ProfileKey = "scum-client-manager"
|
||||
ProofEnvironment = "SCUM_COMPONENT_PROOF"
|
||||
)
|
||||
|
||||
var requiredCapabilities = []string{
|
||||
"component.register",
|
||||
"component.heartbeat",
|
||||
"component.health",
|
||||
"component.control",
|
||||
"game-client.bridge",
|
||||
"logs.stream",
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
SchemaVersion int `json:"schemaVersion" yaml:"schemaVersion"`
|
||||
Platform PlatformConfig `json:"platform" yaml:"platform"`
|
||||
Component ComponentConfig `json:"component" yaml:"component"`
|
||||
Proof ProofConfig `json:"proof" yaml:"proof"`
|
||||
Session SessionConfig `json:"session" yaml:"session"`
|
||||
Capabilities []string `json:"capabilities" yaml:"capabilities"`
|
||||
Timing TimingConfig `json:"timing" yaml:"timing"`
|
||||
TLS TransportTLSConfig `json:"tls" yaml:"tls"`
|
||||
}
|
||||
|
||||
type PlatformConfig struct {
|
||||
BaseURL string `json:"baseUrl" yaml:"baseUrl"`
|
||||
}
|
||||
|
||||
type ComponentConfig struct {
|
||||
InstallationID string `json:"installationId" yaml:"installationId"`
|
||||
ServerInstanceID string `json:"serverInstanceId" yaml:"serverInstanceId"`
|
||||
PluginID string `json:"pluginId" yaml:"pluginId"`
|
||||
ProfileKey string `json:"profileKey" yaml:"profileKey"`
|
||||
ArtifactID string `json:"artifactId" yaml:"artifactId"`
|
||||
Version string `json:"version" yaml:"version"`
|
||||
SourceRevision string `json:"sourceRevision" yaml:"sourceRevision"`
|
||||
TargetOS string `json:"targetOs" yaml:"targetOs"`
|
||||
TargetArch string `json:"targetArch" yaml:"targetArch"`
|
||||
KeyGeneration int `json:"keyGeneration" yaml:"keyGeneration"`
|
||||
DeploymentGeneration int `json:"deploymentGeneration" yaml:"deploymentGeneration"`
|
||||
}
|
||||
|
||||
type ProofConfig struct {
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
MaterialEnv string `json:"materialEnv" yaml:"materialEnv"`
|
||||
}
|
||||
|
||||
type SessionConfig struct {
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
}
|
||||
|
||||
type TimingConfig struct {
|
||||
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds" yaml:"heartbeatIntervalSeconds"`
|
||||
CommandPollIntervalSeconds int `json:"commandPollIntervalSeconds" yaml:"commandPollIntervalSeconds"`
|
||||
RequestTimeoutSeconds int `json:"requestTimeoutSeconds" yaml:"requestTimeoutSeconds"`
|
||||
}
|
||||
|
||||
type TransportTLSConfig struct {
|
||||
Policy string `json:"policy" yaml:"policy"`
|
||||
}
|
||||
|
||||
func LoadConfig(reader io.Reader) (Config, error) {
|
||||
decoder := yaml.NewDecoder(reader)
|
||||
decoder.KnownFields(true)
|
||||
var config Config
|
||||
if err := decoder.Decode(&config); err != nil {
|
||||
return Config{}, fmt.Errorf("decode companion config: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
if err == nil {
|
||||
return Config{}, fmt.Errorf("decode companion config: multiple documents are not allowed")
|
||||
}
|
||||
return Config{}, fmt.Errorf("decode companion config: %w", err)
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
config.Platform.BaseURL, _ = canonicalPlatformOrigin(config.Platform.BaseURL)
|
||||
config.Capabilities = append([]string(nil), config.Capabilities...)
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (config Config) Validate() error {
|
||||
if config.SchemaVersion != ConfigSchemaVersion {
|
||||
return fmt.Errorf("companion config schema version is unsupported")
|
||||
}
|
||||
if _, err := canonicalPlatformOrigin(config.Platform.BaseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
component := config.Component
|
||||
if component.InstallationID == "" || component.ServerInstanceID == "" || component.ArtifactID == "" || component.Version == "" || component.SourceRevision == "" {
|
||||
return fmt.Errorf("component identity is incomplete")
|
||||
}
|
||||
if component.PluginID != PluginID || component.ProfileKey != ProfileKey || component.TargetOS != "windows" || component.TargetArch != "amd64" {
|
||||
return fmt.Errorf("component identity does not match the SCUM companion profile")
|
||||
}
|
||||
if component.KeyGeneration <= 0 || component.DeploymentGeneration <= 0 {
|
||||
return fmt.Errorf("component generations must be positive")
|
||||
}
|
||||
if config.Proof.Mode != "hmac-sha256" || config.Proof.MaterialEnv != ProofEnvironment {
|
||||
return fmt.Errorf("component proof policy is unsupported")
|
||||
}
|
||||
if config.Session.Mode != "component-session" {
|
||||
return fmt.Errorf("component session policy is unsupported")
|
||||
}
|
||||
if config.TLS.Policy != "verify-system-roots" {
|
||||
return fmt.Errorf("TLS policy must verify system roots")
|
||||
}
|
||||
if err := validateCapabilities(config.Capabilities); err != nil {
|
||||
return err
|
||||
}
|
||||
if config.Timing.HeartbeatIntervalSeconds < 5 || config.Timing.HeartbeatIntervalSeconds > 300 || config.Timing.CommandPollIntervalSeconds < 1 || config.Timing.CommandPollIntervalSeconds > 60 || config.Timing.RequestTimeoutSeconds < 1 || config.Timing.RequestTimeoutSeconds > 60 {
|
||||
return fmt.Errorf("companion timing policy is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalPlatformOrigin(value string) (string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path != "" && parsed.Path != "/" {
|
||||
return "", fmt.Errorf("platform base URL must be a credential-free HTTPS origin")
|
||||
}
|
||||
port := parsed.Port()
|
||||
if strings.HasSuffix(parsed.Host, ":") || port != "" {
|
||||
value, err := strconv.Atoi(port)
|
||||
if err != nil || value < 1 || value > 65535 {
|
||||
return "", fmt.Errorf("platform base URL must use a valid HTTPS port")
|
||||
}
|
||||
}
|
||||
return (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host}).String(), nil
|
||||
}
|
||||
|
||||
func validateCapabilities(capabilities []string) error {
|
||||
if len(capabilities) != len(requiredCapabilities) {
|
||||
return fmt.Errorf("component capabilities do not match the SCUM companion profile")
|
||||
}
|
||||
actual := make(map[string]struct{}, len(capabilities))
|
||||
for _, capability := range capabilities {
|
||||
if _, exists := actual[capability]; exists {
|
||||
return fmt.Errorf("component capabilities must be unique")
|
||||
}
|
||||
actual[capability] = struct{}{}
|
||||
}
|
||||
for _, capability := range requiredCapabilities {
|
||||
if _, exists := actual[capability]; !exists {
|
||||
return fmt.Errorf("component capabilities do not match the SCUM companion profile")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
schemaVersion: 1
|
||||
platform:
|
||||
baseUrl: https://platform.example.test
|
||||
component:
|
||||
installationId: client-manager-installation-example
|
||||
serverInstanceId: server-example
|
||||
pluginId: game.scum
|
||||
profileKey: scum-client-manager
|
||||
artifactId: artifact-example
|
||||
version: 1.0.0
|
||||
sourceRevision: example-revision
|
||||
targetOs: windows
|
||||
targetArch: amd64
|
||||
keyGeneration: 1
|
||||
deploymentGeneration: 1
|
||||
proof:
|
||||
mode: hmac-sha256
|
||||
materialEnv: SCUM_COMPONENT_PROOF
|
||||
session:
|
||||
mode: component-session
|
||||
capabilities:
|
||||
- component.register
|
||||
- component.heartbeat
|
||||
- component.health
|
||||
- component.control
|
||||
- game-client.bridge
|
||||
- logs.stream
|
||||
timing:
|
||||
heartbeatIntervalSeconds: 30
|
||||
commandPollIntervalSeconds: 5
|
||||
requestTimeoutSeconds: 15
|
||||
tls:
|
||||
policy: verify-system-roots
|
||||
@@ -0,0 +1,5 @@
|
||||
module browser.local/plugins/scum-server-plugin/companion
|
||||
|
||||
go 1.25.1
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -0,0 +1,4 @@
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,166 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
SmokeClaimLimit = 1
|
||||
SmokeIsolationEnvironment = "SCUM_COMPANION_SMOKE_SCOPE"
|
||||
SmokeIsolationValue = "isolated-non-production"
|
||||
companionDiagnosticsCommandType = "companion.diagnostics"
|
||||
companionHealthSnapshotType = "companion.health"
|
||||
companionHealthSchemaVersion = "1"
|
||||
companionHealthKeepForSeconds = 7 * 24 * 60 * 60
|
||||
companionHealthMaxRecords = 1000
|
||||
)
|
||||
|
||||
type SmokeOptions struct {
|
||||
IsolatedNonProduction bool
|
||||
StreamSuffix func() (string, error)
|
||||
}
|
||||
|
||||
type SmokeResult struct {
|
||||
ClaimedCount int `json:"claimedCount"`
|
||||
CompletedCount int `json:"completedCount"`
|
||||
UnsupportedCount int `json:"unsupportedCount"`
|
||||
CompletedCommandIDs []string `json:"completedCommandIds"`
|
||||
UnsupportedCommandIDs []string `json:"unsupportedCommandIds"`
|
||||
SnapshotID string `json:"snapshotId,omitempty"`
|
||||
}
|
||||
|
||||
func RunOneShotSmoke(ctx context.Context, client *Client, options SmokeOptions) (SmokeResult, error) {
|
||||
result := SmokeResult{CompletedCommandIDs: []string{}, UnsupportedCommandIDs: []string{}}
|
||||
if client == nil {
|
||||
return result, fmt.Errorf("companion client is required")
|
||||
}
|
||||
if !options.IsolatedNonProduction {
|
||||
return result, fmt.Errorf("one-shot smoke requires an isolated non-production queue")
|
||||
}
|
||||
streamSuffix := options.StreamSuffix
|
||||
if streamSuffix == nil {
|
||||
streamSuffix = randomNonce
|
||||
}
|
||||
suffix, err := streamSuffix()
|
||||
if err != nil || !validNonce(suffix) {
|
||||
return result, fmt.Errorf("create isolated smoke stream")
|
||||
}
|
||||
streamKey := "smoke-" + suffix
|
||||
if len(streamKey) > 80 {
|
||||
return result, fmt.Errorf("isolated smoke stream is too long")
|
||||
}
|
||||
if _, err := client.Register(ctx); err != nil {
|
||||
return result, err
|
||||
}
|
||||
observedAt := client.now().UTC()
|
||||
if _, err := client.Heartbeat(ctx, HealthReport{Status: "healthy", Reason: "one-shot smoke ready"}); err != nil {
|
||||
return result, err
|
||||
}
|
||||
commands, err := client.ClaimCommands(ctx, SmokeClaimLimit)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.ClaimedCount = len(commands)
|
||||
if len(commands) != 1 {
|
||||
return result, fmt.Errorf("one-shot smoke requires exactly one isolated command")
|
||||
}
|
||||
command := commands[0]
|
||||
if err := validateSmokeDiagnosticsCommand(command, observedAt); err != nil {
|
||||
result.UnsupportedCount = 1
|
||||
if command.ID != "" {
|
||||
result.UnsupportedCommandIDs = append(result.UnsupportedCommandIDs, command.ID)
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
ack, err := client.AckCommand(ctx, command.ID, command.FencingToken)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if ack.CommandID != command.ID || ack.FencingToken != command.FencingToken || ack.State != "claimed" {
|
||||
return result, fmt.Errorf("platform returned an invalid smoke acknowledgement")
|
||||
}
|
||||
diagnostics := map[string]any{
|
||||
"status": "online",
|
||||
"version": client.config.Component.Version,
|
||||
"lastHeartbeatAt": observedAt.Format(time.RFC3339Nano),
|
||||
}
|
||||
completed, err := client.CompleteCommand(ctx, command.ID, command.FencingToken, CommandResult{Status: "succeeded", Summary: "companion diagnostics available", Payload: diagnostics})
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if completed.CommandID != command.ID || completed.State != "succeeded" {
|
||||
return result, fmt.Errorf("platform returned an invalid smoke command result")
|
||||
}
|
||||
result.CompletedCount = 1
|
||||
result.CompletedCommandIDs = append(result.CompletedCommandIDs, command.ID)
|
||||
|
||||
health := map[string]any{
|
||||
"status": "online",
|
||||
"version": client.config.Component.Version,
|
||||
"observedAt": observedAt.Format(time.RFC3339Nano),
|
||||
"capabilities": append([]string(nil), client.config.Capabilities...),
|
||||
}
|
||||
accepted, err := client.UploadSnapshot(ctx, Snapshot{
|
||||
Type: companionHealthSnapshotType,
|
||||
SchemaVersion: companionHealthSchemaVersion,
|
||||
StreamKey: streamKey,
|
||||
Sequence: 1,
|
||||
ObservedAt: observedAt,
|
||||
Payload: health,
|
||||
KeepForSeconds: companionHealthKeepForSeconds,
|
||||
MaxRecords: companionHealthMaxRecords,
|
||||
})
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if accepted.SnapshotID == "" || accepted.ProfileKey != ProfileKey || accepted.Type != companionHealthSnapshotType || accepted.SchemaVersion != companionHealthSchemaVersion || accepted.StreamKey != streamKey || accepted.Sequence != 1 {
|
||||
return result, fmt.Errorf("platform returned an invalid smoke snapshot acknowledgement")
|
||||
}
|
||||
result.SnapshotID = accepted.SnapshotID
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateSmokeDiagnosticsCommand(command ClaimedCommand, stamp time.Time) error {
|
||||
if command.CommandType != companionDiagnosticsCommandType {
|
||||
return fmt.Errorf("isolated smoke claimed an unsupported command")
|
||||
}
|
||||
if command.ID == "" || command.ProfileKey != ProfileKey || command.FencingToken == 0 {
|
||||
return fmt.Errorf("isolated smoke command identity is invalid")
|
||||
}
|
||||
if command.LeaseExpiresAt.IsZero() || command.ExpiresAt.IsZero() || !stamp.Before(command.LeaseExpiresAt) || !stamp.Before(command.ExpiresAt) {
|
||||
return fmt.Errorf("isolated smoke command lease is not live")
|
||||
}
|
||||
if command.Payload == nil {
|
||||
return fmt.Errorf("isolated smoke diagnostics payload is invalid")
|
||||
}
|
||||
for key, value := range command.Payload {
|
||||
switch key {
|
||||
case "includeWindowState":
|
||||
if _, ok := value.(bool); !ok {
|
||||
return fmt.Errorf("isolated smoke diagnostics payload is invalid")
|
||||
}
|
||||
case "maxEntries":
|
||||
if !boundedDiagnosticsEntries(value) {
|
||||
return fmt.Errorf("isolated smoke diagnostics payload is invalid")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("isolated smoke diagnostics payload is invalid")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boundedDiagnosticsEntries(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed >= 1 && typed <= 20
|
||||
case int64:
|
||||
return typed >= 1 && typed <= 20
|
||||
case float64:
|
||||
return typed >= 1 && typed <= 20 && typed == float64(int(typed))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package companion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunOneShotSmokeProcessesOnlySafeDiagnosticsAndUploadsIsolatedHealth(t *testing.T) {
|
||||
stamp := time.Date(2026, 7, 20, 9, 10, 11, 123456789, time.UTC)
|
||||
config := loadTestConfig(t)
|
||||
sessionExpiry := stamp.Add(15 * time.Minute)
|
||||
step := 0
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
switch step {
|
||||
case 0:
|
||||
assertPlatformRequest(t, request, registerPath)
|
||||
step++
|
||||
return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-smoke", ExpiresAt: sessionExpiry, HeartbeatEverySeconds: 30, ServerTime: stamp}), nil
|
||||
case 1:
|
||||
assertPlatformRequest(t, request, heartbeatPath)
|
||||
var body heartbeatRequest
|
||||
decodeRequest(t, request, &body)
|
||||
if body.SessionToken != "component-session-smoke" || body.HealthReason != "one-shot smoke ready" {
|
||||
t.Fatalf("unexpected smoke heartbeat: %+v", body)
|
||||
}
|
||||
step++
|
||||
return jsonHTTPResponse(http.StatusOK, heartbeatResponse{Accepted: true, InstallationID: config.Component.InstallationID, Status: "online", Health: "healthy", NextHeartbeatSeconds: 30, SessionExpiresAt: sessionExpiry, ServerTime: stamp}), nil
|
||||
case 2:
|
||||
assertPlatformRequest(t, request, claimPath)
|
||||
var body claimRequest
|
||||
decodeRequest(t, request, &body)
|
||||
if body.SessionToken != "component-session-smoke" || body.Limit != 1 {
|
||||
t.Fatalf("unexpected smoke claim: %+v", body)
|
||||
}
|
||||
step++
|
||||
return jsonHTTPResponse(http.StatusOK, claimResponse{Items: []ClaimedCommand{{ID: "diagnostics-1", ProfileKey: ProfileKey, CommandType: companionDiagnosticsCommandType, Payload: map[string]any{"includeWindowState": true, "maxEntries": 3}, FencingToken: 17, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(5 * time.Minute)}}, Count: 1}), nil
|
||||
case 3:
|
||||
assertPlatformRequest(t, request, "/api/v1/game-client-bridge/companion/commands/diagnostics-1/ack")
|
||||
var body ackRequest
|
||||
decodeRequest(t, request, &body)
|
||||
if body.SessionToken != "component-session-smoke" || body.FencingToken != 17 {
|
||||
t.Fatalf("unexpected smoke ack: %+v", body)
|
||||
}
|
||||
step++
|
||||
return jsonHTTPResponse(http.StatusOK, CommandAck{CommandID: "diagnostics-1", State: "claimed", FencingToken: 17, AcknowledgedAt: stamp}), nil
|
||||
case 4:
|
||||
assertPlatformRequest(t, request, "/api/v1/game-client-bridge/companion/commands/diagnostics-1/result")
|
||||
var body resultRequest
|
||||
decodeRequest(t, request, &body)
|
||||
if body.SessionToken != "component-session-smoke" || body.FencingToken != 17 || body.Status != "succeeded" || body.Summary != "companion diagnostics available" {
|
||||
t.Fatalf("unexpected smoke result: %+v", body)
|
||||
}
|
||||
if body.Payload["status"] != "online" || body.Payload["version"] != config.Component.Version || body.Payload["lastHeartbeatAt"] != stamp.Format(time.RFC3339Nano) {
|
||||
t.Fatalf("unexpected bounded diagnostics payload: %+v", body.Payload)
|
||||
}
|
||||
serialized, _ := json.Marshal(body)
|
||||
if strings.Contains(string(serialized), testProof) {
|
||||
t.Fatalf("result exposed proof: %s", serialized)
|
||||
}
|
||||
step++
|
||||
return jsonHTTPResponse(http.StatusOK, map[string]any{"commandId": "diagnostics-1", "state": "succeeded", "result": map[string]any{"status": "succeeded", "summary": "companion diagnostics available", "completedAt": stamp}, "updatedAt": stamp, "completedAt": stamp}), nil
|
||||
case 5:
|
||||
assertPlatformRequest(t, request, snapshotPath)
|
||||
var body snapshotRequest
|
||||
decodeRequest(t, request, &body)
|
||||
if body.SessionToken != "component-session-smoke" || body.Type != companionHealthSnapshotType || body.SchemaVersion != companionHealthSchemaVersion || body.StreamKey != "smoke-fixture-stream-nonce-0001" || body.Sequence != 1 {
|
||||
t.Fatalf("unexpected isolated smoke snapshot identity: %+v", body)
|
||||
}
|
||||
if !body.ObservedAt.Equal(stamp) || body.KeepForSeconds != 604800 || body.MaxRecords != 1000 || body.Payload["status"] != "online" || body.Payload["observedAt"] != stamp.Format(time.RFC3339Nano) {
|
||||
t.Fatalf("unexpected typed health snapshot: %+v", body)
|
||||
}
|
||||
step++
|
||||
return jsonHTTPResponse(http.StatusAccepted, AcceptedSnapshot{SnapshotID: "health-snapshot-1", ProfileKey: ProfileKey, Type: body.Type, SchemaVersion: body.SchemaVersion, StreamKey: body.StreamKey, Sequence: body.Sequence, AcceptedAt: stamp, ExpiresAt: stamp.Add(7 * 24 * time.Hour)}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected smoke transport: %s", request.URL.Path)
|
||||
}
|
||||
})
|
||||
client := newTestClient(t, config, transport, stamp)
|
||||
result, err := RunOneShotSmoke(context.Background(), client, SmokeOptions{IsolatedNonProduction: true, StreamSuffix: func() (string, error) { return "fixture-stream-nonce-0001", nil }})
|
||||
if err != nil {
|
||||
t.Fatalf("run one-shot smoke: %v", err)
|
||||
}
|
||||
if result.ClaimedCount != 1 || result.CompletedCount != 1 || result.UnsupportedCount != 0 || result.SnapshotID != "health-snapshot-1" {
|
||||
t.Fatalf("unexpected safe smoke result: %+v", result)
|
||||
}
|
||||
if len(result.CompletedCommandIDs) != 1 || result.CompletedCommandIDs[0] != "diagnostics-1" || len(result.UnsupportedCommandIDs) != 0 {
|
||||
t.Fatalf("unexpected smoke IDs: %+v", result)
|
||||
}
|
||||
assertSafeSmokeOutput(t, result)
|
||||
if step != 6 {
|
||||
t.Fatalf("expected diagnostics ack/result plus snapshot, got %d requests", step)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOneShotSmokeLeavesUnsupportedCommandUnackedAndUnexecuted(t *testing.T) {
|
||||
stamp := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
|
||||
config := loadTestConfig(t)
|
||||
sessionExpiry := stamp.Add(15 * time.Minute)
|
||||
step := 0
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
switch step {
|
||||
case 0:
|
||||
step++
|
||||
return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-unsupported", ExpiresAt: sessionExpiry, HeartbeatEverySeconds: 30, ServerTime: stamp}), nil
|
||||
case 1:
|
||||
step++
|
||||
return jsonHTTPResponse(http.StatusOK, heartbeatResponse{Accepted: true, InstallationID: config.Component.InstallationID, Status: "online", Health: "healthy", NextHeartbeatSeconds: 30, SessionExpiresAt: sessionExpiry, ServerTime: stamp}), nil
|
||||
case 2:
|
||||
step++
|
||||
return jsonHTTPResponse(http.StatusOK, claimResponse{Items: []ClaimedCommand{{ID: "unsupported-1", ProfileKey: ProfileKey, CommandType: "announcement.send", Payload: map[string]any{"message": "must not execute"}, FencingToken: 18, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(5 * time.Minute)}}, Count: 1}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported command triggered transport: %s", request.URL.Path)
|
||||
}
|
||||
})
|
||||
client := newTestClient(t, config, transport, stamp)
|
||||
result, err := RunOneShotSmoke(context.Background(), client, SmokeOptions{IsolatedNonProduction: true, StreamSuffix: func() (string, error) { return "fixture-stream-nonce-0002", nil }})
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported") {
|
||||
t.Fatalf("expected unsupported smoke to stop, result=%+v err=%v", result, err)
|
||||
}
|
||||
if result.ClaimedCount != 1 || result.CompletedCount != 0 || result.UnsupportedCount != 1 || len(result.CompletedCommandIDs) != 0 || len(result.UnsupportedCommandIDs) != 1 || result.UnsupportedCommandIDs[0] != "unsupported-1" {
|
||||
t.Fatalf("unsupported command did not fail safely: %+v", result)
|
||||
}
|
||||
assertSafeSmokeOutput(t, result)
|
||||
if step != 3 {
|
||||
t.Fatalf("expected no unsupported ack/result requests, got %d steps", step)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOneShotSmokeRejectsNonSingletonClaimBeforeExecution(t *testing.T) {
|
||||
stamp := time.Date(2026, 7, 20, 10, 30, 0, 0, time.UTC)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
items []ClaimedCommand
|
||||
}{
|
||||
{name: "zero"},
|
||||
{name: "multiple", items: []ClaimedCommand{{ID: "diagnostics-1"}, {ID: "diagnostics-2"}}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
config := loadTestConfig(t)
|
||||
step := 0
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
step++
|
||||
switch step {
|
||||
case 1:
|
||||
return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-singleton", ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp}), nil
|
||||
case 2:
|
||||
return jsonHTTPResponse(http.StatusOK, heartbeatResponse{Accepted: true, InstallationID: config.Component.InstallationID, Status: "online", Health: "healthy", NextHeartbeatSeconds: 30, SessionExpiresAt: stamp.Add(15 * time.Minute), ServerTime: stamp}), nil
|
||||
case 3:
|
||||
return jsonHTTPResponse(http.StatusOK, claimResponse{Items: test.items, Count: len(test.items)}), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("non-singleton claim triggered execution: %s", request.URL.Path)
|
||||
}
|
||||
})
|
||||
client := newTestClient(t, config, transport, stamp)
|
||||
result, err := RunOneShotSmoke(context.Background(), client, SmokeOptions{IsolatedNonProduction: true, StreamSuffix: func() (string, error) { return "fixture-singleton-nonce", nil }})
|
||||
if err == nil || !strings.Contains(err.Error(), "exactly one") || result.ClaimedCount != len(test.items) || step != 3 {
|
||||
t.Fatalf("non-singleton claim was not rejected: result=%+v step=%d err=%v", result, step, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSmokeDiagnosticsCommandRejectsUnsafeClaims(t *testing.T) {
|
||||
stamp := time.Date(2026, 7, 20, 11, 0, 0, 0, time.UTC)
|
||||
valid := ClaimedCommand{ID: "diagnostics-1", ProfileKey: ProfileKey, CommandType: companionDiagnosticsCommandType, Payload: map[string]any{"includeWindowState": false, "maxEntries": float64(5)}, FencingToken: 7, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(2 * time.Minute)}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*ClaimedCommand)
|
||||
}{
|
||||
{name: "profile", mutate: func(command *ClaimedCommand) { command.ProfileKey = "other" }},
|
||||
{name: "fence", mutate: func(command *ClaimedCommand) { command.FencingToken = 0 }},
|
||||
{name: "lease", mutate: func(command *ClaimedCommand) { command.LeaseExpiresAt = stamp }},
|
||||
{name: "expiry", mutate: func(command *ClaimedCommand) { command.ExpiresAt = stamp.Add(-time.Second) }},
|
||||
{name: "nil payload", mutate: func(command *ClaimedCommand) { command.Payload = nil }},
|
||||
{name: "unknown payload", mutate: func(command *ClaimedCommand) { command.Payload["message"] = "not allowed" }},
|
||||
{name: "payload type", mutate: func(command *ClaimedCommand) { command.Payload["maxEntries"] = 2.5 }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := valid
|
||||
candidate.Payload = map[string]any{"includeWindowState": false, "maxEntries": float64(5)}
|
||||
test.mutate(&candidate)
|
||||
if err := validateSmokeDiagnosticsCommand(candidate, stamp); err == nil {
|
||||
t.Fatalf("expected unsafe diagnostics claim to be rejected: %+v", candidate)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOneShotSmokeRequiresIsolatedNonProductionQueue(t *testing.T) {
|
||||
result, err := RunOneShotSmoke(context.Background(), &Client{}, SmokeOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "isolated non-production") {
|
||||
t.Fatalf("expected isolation gate, result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSafeSmokeOutput(t *testing.T, result SmokeResult) {
|
||||
t.Helper()
|
||||
serialized, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal smoke result: %v", err)
|
||||
}
|
||||
for _, forbidden := range []string{testProof, "component-session", "includeWindowState", "must not execute", "payload"} {
|
||||
if strings.Contains(string(serialized), forbidden) {
|
||||
t.Fatalf("smoke output exposed %q: %s", forbidden, serialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user