diff --git a/internal/agentexec/apt_codec_coverage_test.go b/internal/agentexec/apt_codec_coverage_test.go new file mode 100644 index 000000000..345699a2e --- /dev/null +++ b/internal/agentexec/apt_codec_coverage_test.go @@ -0,0 +1,748 @@ +package agentexec + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt" +) + +// --- JSON / identity helpers for coverage tests --- + +func covHostUpdateDigest(actionID, operation string, opVersion int, hash string) string { + d, err := hostUpdateRequestDigest(HostUpdatePayload{ + ActionID: actionID, + Operation: operation, + OperationVersion: opVersion, + ExpectedInventoryHash: hash, + }) + if err != nil { + panic(err) + } + return d +} + +func covHostStorageCleanupDigest(actionID, operation string, opVersion int, fingerprint string) string { + d, err := hostStorageCleanupRequestDigest(HostStorageCleanupPayload{ + ActionID: actionID, + Operation: operation, + OperationVersion: opVersion, + ExpectedFingerprint: fingerprint, + }) + if err != nil { + panic(err) + } + return d +} + +func covBuildHostUpdatePayloadJSON(t *testing.T, requestID, actionID, operation string, opVersion int, hash string, timeout int) []byte { + t.Helper() + raw, err := json.Marshal(map[string]any{ + "request_id": requestID, + "action_id": actionID, + "operation": operation, + "operation_version": opVersion, + "request_digest": covHostUpdateDigest(actionID, operation, opVersion, hash), + "expected_inventory_hash": hash, + "timeout": timeout, + }) + if err != nil { + t.Fatal(err) + } + return raw +} + +func covBuildHostStorageCleanupPayloadJSON(t *testing.T, requestID, actionID, operation string, opVersion int, fingerprint string, timeout int) []byte { + t.Helper() + raw, err := json.Marshal(map[string]any{ + "request_id": requestID, + "action_id": actionID, + "operation": operation, + "operation_version": opVersion, + "request_digest": covHostStorageCleanupDigest(actionID, operation, opVersion, fingerprint), + "expected_fingerprint": fingerprint, + "timeout": timeout, + }) + if err != nil { + t.Fatal(err) + } + return raw +} + +func covWithJSONField(t *testing.T, raw []byte, key string, value any) []byte { + t.Helper() + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + m[key] = value + out, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return out +} + +func covHostUpdateIdentity(agentID string) operationreceipt.Identity { + req := HostUpdatePayload{ + RequestID: "cov.dispatch.1", + ActionID: "cov-action", + Operation: HostUpdateOperationInstall, + ExpectedInventoryHash: "sha256:" + strings.Repeat("a", 64), + } + if err := BindHostUpdatePayload(&req); err != nil { + panic(err) + } + return HostUpdateOperationIdentity(agentID, req) +} + +func covHostCleanupIdentity(agentID string) operationreceipt.Identity { + req := HostStorageCleanupPayload{ + RequestID: "cov.dispatch.1", + ActionID: "cov-action", + Operation: HostStorageCleanupOperationPackageCache, + ExpectedFingerprint: "sha256:" + strings.Repeat("c", 64), + } + if err := BindHostStorageCleanupPayload(&req); err != nil { + panic(err) + } + return HostStorageCleanupOperationIdentity(agentID, req) +} + +func covTerminalRecord(identity operationreceipt.Identity, resultKind string, resultVersion int, result json.RawMessage, terminalAt time.Time) *operationreceipt.Record { + return &operationreceipt.Record{ + Identity: identity, + State: operationreceipt.StateTerminal, + AcceptedAt: terminalAt.Add(-10 * time.Minute), + StartedAt: terminalAt.Add(-9 * time.Minute), + TerminalAt: terminalAt, + ResultKind: resultKind, + ResultVersion: resultVersion, + Result: result, + } +} + +func covAcceptedRecord(identity operationreceipt.Identity, acceptedAt time.Time) *operationreceipt.Record { + return &operationreceipt.Record{ + Identity: identity, + State: operationreceipt.StateAccepted, + AcceptedAt: acceptedAt, + } +} + +// --------------------------------------------------------------------------- +// DecodeHostUpdatePayload — apt_codec.go:32 +// --------------------------------------------------------------------------- + +func TestCoverageDecodeHostUpdatePayloadRejectionArms(t *testing.T) { + hash := "sha256:" + strings.Repeat("a", 64) + valid := covBuildHostUpdatePayloadJSON(t, "r1", "a1", HostUpdateOperationInstall, HostAPTOperationVersion, hash, 30) + + // Success case. + t.Run("valid payload decodes", func(t *testing.T) { + got, err := DecodeHostUpdatePayload(valid) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.RequestID != "r1" || got.ActionID != "a1" || got.Operation != HostUpdateOperationInstall { + t.Fatalf("decoded payload mismatch: %#v", got) + } + }) + + for _, tc := range []struct { + name string + body []byte + wantSub string + }{ + {"empty payload", []byte(""), "empty"}, + {"whitespace only", []byte(" \n\t "), "empty"}, + {"malformed JSON", []byte(`{"request_id":"incomplete`), ""}, + {"unknown field rejected", covWithJSONField(t, valid, "rogue_field", true), "unknown field"}, + {"trailing JSON", append(append([]byte{}, valid...), '{', '}'), "trailing"}, + {"missing request id", covBuildHostUpdatePayloadJSON(t, "", "a1", HostUpdateOperationInstall, HostAPTOperationVersion, hash, 30), "request id is required"}, + {"empty action id", covBuildHostUpdatePayloadJSON(t, "r1", "", HostUpdateOperationInstall, HostAPTOperationVersion, hash, 30), "action id is required"}, + {"unsupported operation", covBuildHostUpdatePayloadJSON(t, "r1", "a1", "bogus_op", HostAPTOperationVersion, hash, 30), "unsupported host update operation"}, + {"unsupported operation version", covBuildHostUpdatePayloadJSON(t, "r1", "a1", HostUpdateOperationInstall, 99, hash, 30), "unsupported host update operation version"}, + {"request digest mismatch", covWithJSONField(t, valid, "request_digest", "sha256:"+strings.Repeat("0", 64)), "digest mismatch"}, + {"invalid inventory hash", covBuildHostUpdatePayloadJSON(t, "r1", "a1", HostUpdateOperationInstall, HostAPTOperationVersion, "not-a-hash", 30), "expected inventory hash"}, + {"negative timeout", covBuildHostUpdatePayloadJSON(t, "r1", "a1", HostUpdateOperationInstall, HostAPTOperationVersion, hash, -1), "timeout"}, + {"timeout exceeds maximum", covBuildHostUpdatePayloadJSON(t, "r1", "a1", HostUpdateOperationInstall, HostAPTOperationVersion, hash, 9999), "timeout"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := DecodeHostUpdatePayload(tc.body) + if err == nil { + t.Fatal("expected error, got nil") + } + if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantSub) + } + }) + } +} + +// --------------------------------------------------------------------------- +// DecodeHostStorageCleanupPayload — apt_codec.go:54 +// --------------------------------------------------------------------------- + +func TestCoverageDecodeHostStorageCleanupPayloadRejectionArms(t *testing.T) { + fp := "sha256:" + strings.Repeat("c", 64) + valid := covBuildHostStorageCleanupPayloadJSON(t, "r1", "a1", HostStorageCleanupOperationPackageCache, HostAPTOperationVersion, fp, 30) + + t.Run("valid payload decodes", func(t *testing.T) { + got, err := DecodeHostStorageCleanupPayload(valid) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.RequestID != "r1" || got.ActionID != "a1" || got.Operation != HostStorageCleanupOperationPackageCache { + t.Fatalf("decoded payload mismatch: %#v", got) + } + }) + + for _, tc := range []struct { + name string + body []byte + wantSub string + }{ + {"empty payload", []byte(""), "empty"}, + {"whitespace only", []byte("\t\n "), "empty"}, + {"malformed JSON", []byte(`{"request_id": invalid`), ""}, + {"unknown field rejected", covWithJSONField(t, valid, "rogue_field", 42), "unknown field"}, + {"trailing JSON", append(append([]byte{}, valid...), '{', '}'), "trailing"}, + {"missing request id", covBuildHostStorageCleanupPayloadJSON(t, "", "a1", HostStorageCleanupOperationPackageCache, HostAPTOperationVersion, fp, 30), "invalid request id"}, + {"empty action id", covBuildHostStorageCleanupPayloadJSON(t, "r1", "", HostStorageCleanupOperationPackageCache, HostAPTOperationVersion, fp, 30), "invalid action id"}, + {"unsupported operation", covBuildHostStorageCleanupPayloadJSON(t, "r1", "a1", "bogus_cleanup", HostAPTOperationVersion, fp, 30), "unsupported host storage cleanup operation"}, + {"unsupported operation version", covBuildHostStorageCleanupPayloadJSON(t, "r1", "a1", HostStorageCleanupOperationPackageCache, 77, fp, 30), "unsupported host storage cleanup operation version"}, + {"request digest mismatch", covWithJSONField(t, valid, "request_digest", "sha256:"+strings.Repeat("9", 64)), "digest mismatch"}, + {"invalid fingerprint", covBuildHostStorageCleanupPayloadJSON(t, "r1", "a1", HostStorageCleanupOperationPackageCache, HostAPTOperationVersion, "garbage-fp", 30), "expected cleanup fingerprint"}, + {"negative timeout", covBuildHostStorageCleanupPayloadJSON(t, "r1", "a1", HostStorageCleanupOperationPackageCache, HostAPTOperationVersion, fp, -1), "timeout"}, + {"timeout exceeds maximum", covBuildHostStorageCleanupPayloadJSON(t, "r1", "a1", HostStorageCleanupOperationPackageCache, HostAPTOperationVersion, fp, 9999), "timeout"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := DecodeHostStorageCleanupPayload(tc.body) + if err == nil { + t.Fatal("expected error, got nil") + } + if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantSub) + } + }) + } +} + +// --------------------------------------------------------------------------- +// ValidateOperationQueryResultForIdentity — apt_codec.go:98 +// --------------------------------------------------------------------------- + +func TestCoverageValidateOperationQueryResultForIdentity(t *testing.T) { + now := time.Now().UTC() + updateIdentity := covHostUpdateIdentity("agent-1") + cleanupIdentity := covHostCleanupIdentity("agent-1") + + // A mismatched identity for the identity-mismatch arm. + otherIdentity := updateIdentity + otherIdentity.ActionID = "different-action" + + // A bare identity with an unsupported operation kind (still normalisable). + bogusIdentity := operationreceipt.Identity{ + AttemptID: "cov.dispatch.1", + ActionID: "cov-action", + OperationKind: "bogus_operation", + OperationVersion: 1, + RequestDigest: "sha256:" + strings.Repeat("a", 64), + AgentID: "agent-1", + } + + // Valid not-found result (success path: nil record, no error). + notFoundOK := operationreceipt.QueryResult{Version: operationreceipt.ProtocolVersion, Status: operationreceipt.QueryNotFound} + + // Not-found with a stray record. + notFoundWithRecord := operationreceipt.QueryResult{ + Version: operationreceipt.ProtocolVersion, + Status: operationreceipt.QueryNotFound, + Record: covAcceptedRecord(updateIdentity, now), + } + + // Unsupported version. + badVersion := operationreceipt.QueryResult{Version: 99, Status: operationreceipt.QueryNotFound} + + // Found-terminal but nil record. + nilRecord := operationreceipt.QueryResult{ + Version: operationreceipt.ProtocolVersion, + Status: operationreceipt.QueryFoundTerminal, + } + + // Identity mismatch (record identity differs). + mismatchResult := operationreceipt.QueryResult{ + Version: operationreceipt.ProtocolVersion, + Status: operationreceipt.QueryFoundInterrupted, + Record: covAcceptedRecord(otherIdentity, now), + } + + // ValidateRecord failure (zero AcceptedAt). + badRecord := &operationreceipt.Record{ + Identity: updateIdentity, + State: operationreceipt.StateAccepted, + AcceptedAt: time.Time{}, + } + validateRecordFail := operationreceipt.QueryResult{ + Version: operationreceipt.ProtocolVersion, + Status: operationreceipt.QueryFoundInterrupted, + Record: badRecord, + } + + // Unknown status (neither terminal nor interrupted). + unknownStatus := operationreceipt.QueryResult{ + Version: operationreceipt.ProtocolVersion, + Status: "bogus_status", + Record: covAcceptedRecord(updateIdentity, now), + } + + // Interrupted status with a terminal-state record (state mismatch). + interruptedTerminal := operationreceipt.QueryResult{ + Version: operationreceipt.ProtocolVersion, + Status: operationreceipt.QueryFoundInterrupted, + Record: covTerminalRecord(updateIdentity, "any", 1, json.RawMessage(`{}`), now), + } + + // Terminal status with a non-terminal-state record. + terminalNonTerminalRecord := operationreceipt.QueryResult{ + Version: operationreceipt.ProtocolVersion, + Status: operationreceipt.QueryFoundTerminal, + Record: covAcceptedRecord(updateIdentity, now), + } + + // Valid interrupted result (success path). + validInterrupted := operationreceipt.QueryResult{ + Version: operationreceipt.ProtocolVersion, + Status: operationreceipt.QueryFoundInterrupted, + Record: covAcceptedRecord(updateIdentity, now), + } + + // Terminal but receivedAt is zero. + zeroReceivedAt := operationreceipt.QueryResult{ + Version: operationreceipt.ProtocolVersion, + Status: operationreceipt.QueryFoundTerminal, + Record: covTerminalRecord(updateIdentity, HostUpdateReceiptKind, HostAPTReceiptVersion, json.RawMessage(`{}`), now), + } + + // Host update envelope mismatch (wrong ResultKind). + updateEnvelopeMismatch := operationreceipt.QueryResult{ + Version: operationreceipt.ProtocolVersion, + Status: operationreceipt.QueryFoundTerminal, + Record: covTerminalRecord(updateIdentity, "wrong.kind", HostAPTReceiptVersion, json.RawMessage(`{}`), now), + } + + // Host update decode error (correct envelope, malformed payload). + updateDecodeError := operationreceipt.QueryResult{ + Version: operationreceipt.ProtocolVersion, + Status: operationreceipt.QueryFoundTerminal, + Record: covTerminalRecord(updateIdentity, HostUpdateReceiptKind, HostAPTReceiptVersion, json.RawMessage(`{"rogue":true}`), now), + } + + // Host storage cleanup envelope mismatch. + cleanupEnvelopeMismatch := operationreceipt.QueryResult{ + Version: operationreceipt.ProtocolVersion, + Status: operationreceipt.QueryFoundTerminal, + Record: covTerminalRecord(cleanupIdentity, "wrong.kind", HostAPTReceiptVersion, json.RawMessage(`{}`), now), + } + + // Host storage cleanup decode error. + cleanupDecodeError := operationreceipt.QueryResult{ + Version: operationreceipt.ProtocolVersion, + Status: operationreceipt.QueryFoundTerminal, + Record: covTerminalRecord(cleanupIdentity, HostStorageCleanupReceiptKind, HostAPTReceiptVersion, json.RawMessage(`{"rogue":true}`), now), + } + + // Unsupported operation kind (default switch arm). + unsupportedKind := operationreceipt.QueryResult{ + Version: operationreceipt.ProtocolVersion, + Status: operationreceipt.QueryFoundTerminal, + Record: covTerminalRecord(bogusIdentity, "any.kind", 1, json.RawMessage(`{}`), now), + } + + for _, tc := range []struct { + name string + result operationreceipt.QueryResult + identity operationreceipt.Identity + receivedAt time.Time + wantSub string + wantOK bool + }{ + {"valid not-found returns nil", notFoundOK, updateIdentity, now, "", true}, + {"valid interrupted returns nil", validInterrupted, updateIdentity, now, "", true}, + {"unsupported version", badVersion, updateIdentity, now, "unsupported operation query result version", false}, + {"not-found with record", notFoundWithRecord, updateIdentity, now, "not-found operation query result contains a record", false}, + {"nil record identity mismatch", nilRecord, updateIdentity, now, "identity mismatch", false}, + {"record identity mismatch", mismatchResult, updateIdentity, now, "identity mismatch", false}, + {"validate record failure", validateRecordFail, updateIdentity, now, "invalid operation receipt bounds", false}, + {"unknown status mismatch", unknownStatus, updateIdentity, now, "status and record state mismatch", false}, + {"interrupted with terminal state", interruptedTerminal, updateIdentity, now, "status and record state mismatch", false}, + {"terminal with non-terminal record", terminalNonTerminalRecord, updateIdentity, now, "terminal operation query result requires terminal record", false}, + {"zero receivedAt rejected", zeroReceivedAt, updateIdentity, time.Time{}, "invalid or implausibly future", false}, + {"host update envelope mismatch", updateEnvelopeMismatch, updateIdentity, now, "host update query result envelope mismatch", false}, + {"host update decode error", updateDecodeError, updateIdentity, now, "", false}, + {"host cleanup envelope mismatch", cleanupEnvelopeMismatch, cleanupIdentity, now, "host cleanup query result envelope mismatch", false}, + {"host cleanup decode error", cleanupDecodeError, cleanupIdentity, now, "", false}, + {"unsupported operation kind", unsupportedKind, bogusIdentity, now, "unsupported operation query kind", false}, + } { + t.Run(tc.name, func(t *testing.T) { + err := ValidateOperationQueryResultForIdentity(tc.result, tc.identity, tc.receivedAt) + if tc.wantOK { + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } + return + } + if err == nil { + t.Fatal("expected error, got nil") + } + if tc.wantSub != "" && !strings.Contains(err.Error(), tc.wantSub) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantSub) + } + }) + } +} + +// --------------------------------------------------------------------------- +// BindHostUpdatePayload — apt_codec.go:193 +// --------------------------------------------------------------------------- + +func TestCoverageBindHostUpdatePayload(t *testing.T) { + t.Run("nil payload rejected", func(t *testing.T) { + if err := BindHostUpdatePayload(nil); err == nil || !strings.Contains(err.Error(), "host update payload is required") { + t.Fatalf("nil payload error=%v", err) + } + }) + + t.Run("valid payload binds version and digest", func(t *testing.T) { + hash := "sha256:" + strings.Repeat("a", 64) + req := &HostUpdatePayload{ + RequestID: "bind-1", + ActionID: "act-1", + Operation: HostUpdateOperationInstall, + ExpectedInventoryHash: hash, + } + if err := BindHostUpdatePayload(req); err != nil { + t.Fatalf("bind error: %v", err) + } + if req.OperationVersion != HostAPTOperationVersion { + t.Fatalf("operation version = %d, want %d", req.OperationVersion, HostAPTOperationVersion) + } + wantDigest := covHostUpdateDigest("act-1", HostUpdateOperationInstall, HostAPTOperationVersion, hash) + if req.RequestDigest != wantDigest { + t.Fatalf("request digest = %q, want %q", req.RequestDigest, wantDigest) + } + }) +} + +// --------------------------------------------------------------------------- +// BindHostStorageCleanupPayload — apt_codec.go:215 +// --------------------------------------------------------------------------- + +func TestCoverageBindHostStorageCleanupPayload(t *testing.T) { + t.Run("nil payload rejected", func(t *testing.T) { + if err := BindHostStorageCleanupPayload(nil); err == nil || !strings.Contains(err.Error(), "host storage cleanup payload is required") { + t.Fatalf("nil payload error=%v", err) + } + }) + + t.Run("valid payload binds version and digest", func(t *testing.T) { + fp := "sha256:" + strings.Repeat("c", 64) + req := &HostStorageCleanupPayload{ + RequestID: "bind-2", + ActionID: "act-2", + Operation: HostStorageCleanupOperationPackageCache, + ExpectedFingerprint: fp, + } + if err := BindHostStorageCleanupPayload(req); err != nil { + t.Fatalf("bind error: %v", err) + } + if req.OperationVersion != HostAPTOperationVersion { + t.Fatalf("operation version = %d, want %d", req.OperationVersion, HostAPTOperationVersion) + } + wantDigest := covHostStorageCleanupDigest("act-2", HostStorageCleanupOperationPackageCache, HostAPTOperationVersion, fp) + if req.RequestDigest != wantDigest { + t.Fatalf("request digest = %q, want %q", req.RequestDigest, wantDigest) + } + }) +} + +// --------------------------------------------------------------------------- +// ValidateHostUpdateResultPayload — apt_codec.go:245 +// --------------------------------------------------------------------------- + +func TestCoverageValidateHostUpdateResultPayload(t *testing.T) { + now := time.Now().UTC() + hashA := "sha256:" + strings.Repeat("a", 64) + hashB := "sha256:" + strings.Repeat("b", 64) + + // inconclusiveBase passes the internal validator and reaches every public + // arm when specific fields are tweaked. + inconclusiveBase := HostUpdateResultPayload{ + RequestID: "r1", + ActionID: "a1", + ExecutionPhase: HostUpdatePhaseVerify, + Verification: HostUpdateVerificationInconclusive, + } + + // A fully valid verified+complete result for the success arm. + validVerified := HostUpdateResultPayload{ + RequestID: "r1", + ActionID: "a1", + Success: true, + ExecutionPhase: HostUpdatePhaseComplete, + MutationStarted: true, + HealthChecked: true, + PackageManagerHealthy: true, + Verification: HostUpdateVerificationVerified, + Before: HostPackageUpdateSnapshot{Supported: true, Manager: "apt", InventoryHash: hashA, PendingCount: 1, CheckedAt: now.Add(-2 * time.Minute)}, + After: HostPackageUpdateSnapshot{Supported: true, Manager: "apt", InventoryHash: hashB, PendingCount: 0, CheckedAt: now.Add(-time.Minute)}, + } + + for _, tc := range []struct { + name string + result HostUpdateResultPayload + wantSub string + wantOK bool + }{ + { + name: "valid verified complete accepted", + result: validVerified, + wantOK: true, + }, + { + name: "empty action id", + result: func() HostUpdateResultPayload { r := inconclusiveBase; r.ActionID = ""; return r }(), + wantSub: "invalid action id", + }, + { + name: "unsupported execution phase", + result: func() HostUpdateResultPayload { + r := inconclusiveBase + r.ExecutionPhase = "bogus_phase" + return r + }(), + wantSub: "unsupported host update execution phase", + }, + { + name: "success in wrong phase", + result: func() HostUpdateResultPayload { + r := inconclusiveBase + r.Success = true + r.ExecutionPhase = HostUpdatePhasePreflight + return r + }(), + wantSub: "successful host update mutation must be in verify or complete phase", + }, + { + name: "evidence timestamps invalid chronology", + result: func() HostUpdateResultPayload { + r := inconclusiveBase + r.Verification = HostUpdateVerificationFailed + r.Before = HostPackageUpdateSnapshot{CheckedAt: now} + r.After = HostPackageUpdateSnapshot{CheckedAt: time.Time{}} + return r + }(), + wantSub: "evidence-bearing host update observation timestamps are invalid", + }, + { + name: "mutation state conflicts with phase", + result: func() HostUpdateResultPayload { + r := inconclusiveBase + r.MutationStarted = true + r.ExecutionPhase = HostUpdatePhasePreflight + return r + }(), + wantSub: "host update mutation state conflicts with execution phase", + }, + { + name: "recovery required without mutation", + result: func() HostUpdateResultPayload { + r := inconclusiveBase + r.RecoveryRequired = true + return r + }(), + wantSub: "host update recovery requirement conflicts with mutation state", + }, + { + name: "healthy package manager without health check", + result: func() HostUpdateResultPayload { + r := inconclusiveBase + r.PackageManagerHealthy = true + return r + }(), + wantSub: "healthy package manager claim requires a completed health check", + }, + { + name: "unhealthy package manager after mutation without recovery", + result: func() HostUpdateResultPayload { + r := inconclusiveBase + r.HealthChecked = true + r.MutationStarted = true + return r + }(), + wantSub: "unhealthy package manager after mutation requires recovery", + }, + { + name: "partial install without recovery", + result: func() HostUpdateResultPayload { + r := inconclusiveBase + r.ExecutionPhase = HostUpdatePhaseInstall + r.MutationStarted = true + return r + }(), + wantSub: "partial host update install requires recovery", + }, + { + name: "successful completion cannot require recovery", + result: func() HostUpdateResultPayload { + r := inconclusiveBase + r.Success = true + r.ExecutionPhase = HostUpdatePhaseComplete + r.MutationStarted = true + r.RecoveryRequired = true + return r + }(), + wantSub: "successful host update completion cannot require recovery", + }, + { + name: "verified but not complete", + result: func() HostUpdateResultPayload { + r := validVerified + r.ExecutionPhase = HostUpdatePhaseVerify + return r + }(), + wantSub: "verified host update must be complete", + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := ValidateHostUpdateResultPayload(&tc.result) + if tc.wantOK { + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } + return + } + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tc.wantSub) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantSub) + } + }) + } +} + +// --------------------------------------------------------------------------- +// ValidateHostStorageCleanupResultPayload — apt_codec.go:289 +// --------------------------------------------------------------------------- + +func TestCoverageValidateHostStorageCleanupResultPayload(t *testing.T) { + now := time.Now().UTC() + fpA := "sha256:" + strings.Repeat("a", 64) + fpB := "sha256:" + strings.Repeat("b", 64) + + inconclusiveBase := HostStorageCleanupResultPayload{ + RequestID: "r1", + ActionID: "a1", + ExecutionPhase: HostStorageCleanupPhaseVerify, + Verification: HostStorageCleanupVerificationInconclusive, + } + + validVerified := HostStorageCleanupResultPayload{ + RequestID: "r1", + ActionID: "a1", + Success: true, + ExecutionPhase: HostStorageCleanupPhaseComplete, + MutationStarted: true, + Verification: HostStorageCleanupVerificationVerified, + Before: HostStorageCleanupSnapshot{Supported: true, Provider: "apt-package-cache", Fingerprint: fpA, ReclaimableBytes: 100, CheckedAt: now.Add(-2 * time.Minute)}, + After: HostStorageCleanupSnapshot{Supported: true, Provider: "apt-package-cache", Fingerprint: fpB, ReclaimableBytes: 10, CheckedAt: now.Add(-time.Minute)}, + ReclaimedBytes: 90, + } + + for _, tc := range []struct { + name string + result HostStorageCleanupResultPayload + wantSub string + wantOK bool + }{ + { + name: "valid verified complete accepted", + result: validVerified, + wantOK: true, + }, + { + name: "empty action id", + result: func() HostStorageCleanupResultPayload { r := inconclusiveBase; r.ActionID = ""; return r }(), + wantSub: "invalid action id", + }, + { + name: "unsupported execution phase", + result: func() HostStorageCleanupResultPayload { + r := inconclusiveBase + r.ExecutionPhase = "bogus_phase" + return r + }(), + wantSub: "unsupported host storage cleanup execution phase", + }, + { + name: "success in wrong phase", + result: func() HostStorageCleanupResultPayload { + r := inconclusiveBase + r.Success = true + r.ExecutionPhase = HostStorageCleanupPhasePreflight + return r + }(), + wantSub: "successful host storage cleanup mutation must be in verify or complete phase", + }, + { + name: "verified but not complete", + result: func() HostStorageCleanupResultPayload { + r := validVerified + r.ExecutionPhase = HostStorageCleanupPhaseVerify + return r + }(), + wantSub: "verified host storage cleanup must be complete", + }, + { + name: "evidence timestamps invalid chronology", + result: func() HostStorageCleanupResultPayload { + r := inconclusiveBase + r.Verification = HostStorageCleanupVerificationFailed + r.Before = HostStorageCleanupSnapshot{CheckedAt: now} + r.After = HostStorageCleanupSnapshot{CheckedAt: time.Time{}} + return r + }(), + wantSub: "evidence-bearing host storage cleanup observation timestamps are invalid", + }, + { + name: "mutation state conflicts with phase", + result: func() HostStorageCleanupResultPayload { + r := inconclusiveBase + r.MutationStarted = true + r.ExecutionPhase = HostStorageCleanupPhasePreflight + return r + }(), + wantSub: "host storage cleanup mutation state conflicts with execution phase", + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := ValidateHostStorageCleanupResultPayload(&tc.result) + if tc.wantOK { + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } + return + } + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tc.wantSub) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantSub) + } + }) + } +} diff --git a/internal/agentexec/docker_update_codec_coverage_test.go b/internal/agentexec/docker_update_codec_coverage_test.go new file mode 100644 index 000000000..55a77b7c5 --- /dev/null +++ b/internal/agentexec/docker_update_codec_coverage_test.go @@ -0,0 +1,838 @@ +package agentexec + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +// This file is a table-driven coverage test for the docker container update +// codec. It exercises every public codec function in docker_update_codec.go +// plus the unexported digest helper, with emphasis on the validation guards, +// the strict-JSON rejection shape (unknown fields / trailing data / malformed +// JSON / empty body), and the request/result cross-validation mismatch +// branches. It is the strict-codec sibling of apt_codec_test.go and asserts +// the same closed-contract rejection behaviour. + +var ( + testValidUpdateContainerID = "abcdef123456" + testValidUpdateContainerIDB = "0123456789ab" + testValidUpdateImageDigest = "sha256:" + strings.Repeat("a", 64) + testValidUpdateImageDigestAlt = "sha256:" + strings.Repeat("b", 64) + testValidUpdateRequestDigest = "sha256:" + strings.Repeat("c", 64) +) + +// newBoundUpdatePayload returns a fully bound, valid DockerContainerUpdatePayload +// suitable for both validation and decode round-trips. +func newBoundUpdatePayload(t *testing.T) DockerContainerUpdatePayload { + t.Helper() + payload := DockerContainerUpdatePayload{ + RequestID: "update-request-1", + ActionID: "update-action-1", + Runtime: "docker", + ContainerID: testValidUpdateContainerID, + ExpectedImageDigest: testValidUpdateImageDigest, + Timeout: 600, + } + if err := BindDockerContainerUpdatePayload(&payload); err != nil { + t.Fatalf("bind baseline update payload: %v", err) + } + return payload +} + +// newValidUpdateResult returns a result that passes ValidateDockerContainerUpdateResultPayload +// on its own (preflight phase, no completion requirements). +func newValidUpdateResult() DockerContainerUpdateResultPayload { + return DockerContainerUpdateResultPayload{ + RequestID: "update-request-1", + ActionID: "update-action-1", + Operation: DockerContainerOperationUpdate, + OperationVersion: DockerContainerUpdateOperationVersion, + RequestDigest: testValidUpdateRequestDigest, + ContainerID: testValidUpdateContainerID, + ExecutionPhase: DockerContainerPhasePreflight, + } +} + +// newValidCompleteUpdateResult returns a result in the terminal phase that +// satisfies every completion guard (replacement container, mutation done, no +// error, readback observation). +func newValidCompleteUpdateResult(req DockerContainerUpdatePayload) DockerContainerUpdateResultPayload { + observed := time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC) + return DockerContainerUpdateResultPayload{ + RequestID: req.RequestID, + ActionID: req.ActionID, + Operation: req.Operation, + OperationVersion: req.OperationVersion, + RequestDigest: req.RequestDigest, + ContainerID: req.ContainerID, + ExecutionPhase: DockerContainerPhaseComplete, + MutationStarted: true, + MutationCompleted: true, + ReadbackRan: true, + NewContainerID: testValidUpdateContainerIDB, + After: DockerContainerLifecycleSnapshot{ + ContainerID: testValidUpdateContainerIDB, + State: "running", + Running: true, + ObservedAt: observed, + }, + } +} + +// --- DecodeDockerContainerUpdatePayload: strict acceptance & rejection --- + +func TestDecodeDockerContainerUpdatePayloadStrictAcceptance(t *testing.T) { + valid := newBoundUpdatePayload(t) + validJSON, err := json.Marshal(valid) + if err != nil { + t.Fatal(err) + } + base := string(validJSON) + + for _, tc := range []struct { + name string + body string + wantError string + }{ + { + name: "valid happy path decodes", + body: base, + }, + { + name: "empty body rejected", + body: "", + wantError: "empty", + }, + { + name: "whitespace-only body rejected", + body: " \n\t ", + wantError: "empty", + }, + { + name: "malformed json rejected", + body: `{"request_id":"r1","action_id":`, + wantError: "EOF", + }, + { + name: "unknown field rejected", + body: strings.TrimSuffix(base, "}") + `,"command":"docker pull"}`, + wantError: "unknown field", + }, + { + name: "trailing json rejected", + body: base + `{}`, + wantError: "trailing", + }, + { + name: "missing required request id rejected at validate step", + body: rejson(t, valid, "request_id", ""), + wantError: "request or action id", + }, + { + name: "non-digest expected image rejected at validate step", + body: rejson(t, mutatePayload(valid, func(p *DockerContainerUpdatePayload) { p.ExpectedImageDigest = "busybox:latest" }), "", ""), + wantError: "expected image digest", + }, + } { + t.Run(tc.name, func(t *testing.T) { + decoded, err := DecodeDockerContainerUpdatePayload([]byte(tc.body)) + if tc.wantError == "" { + if err != nil { + t.Fatalf("expected decode success, got error: %v", err) + } + if decoded.RequestID != valid.RequestID || decoded.ActionID != valid.ActionID { + t.Fatalf("decoded identity drift: %+v", decoded) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got success: %+v", tc.wantError, decoded) + } + if !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantError) + } + }) + } +} + +// --- DecodeDockerContainerUpdateResultPayload: strict acceptance & rejection --- + +func TestDecodeDockerContainerUpdateResultPayloadStrictAcceptance(t *testing.T) { + validResult := newValidCompleteUpdateResult(newBoundUpdatePayload(t)) + validJSON, err := json.Marshal(validResult) + if err != nil { + t.Fatal(err) + } + base := string(validJSON) + + for _, tc := range []struct { + name string + body string + wantError string + }{ + { + name: "valid complete result decodes", + body: base, + }, + { + name: "empty body rejected", + body: "", + wantError: "empty", + }, + { + name: "malformed json rejected", + body: `{"request_id":"r1", oops}`, + wantError: "invalid character", + }, + { + name: "unknown field rejected", + body: strings.TrimSuffix(base, "}") + `,"verification":"verified"}`, + wantError: "unknown field", + }, + { + name: "trailing json rejected", + body: base + ` {}`, + wantError: "trailing", + }, + { + name: "missing request id rejected at validate step", + body: rejson(t, validResult, "request_id", ""), + wantError: "result identity", + }, + { + name: "unsupported execution phase rejected at validate step", + body: rejson(t, mutateResult(validResult, func(r *DockerContainerUpdateResultPayload) { r.ExecutionPhase = "frobnicate" }), "", ""), + wantError: "execution phase", + }, + } { + t.Run(tc.name, func(t *testing.T) { + decoded, err := DecodeDockerContainerUpdateResultPayload([]byte(tc.body)) + if tc.wantError == "" { + if err != nil { + t.Fatalf("expected decode success, got error: %v", err) + } + if decoded.RequestID != validResult.RequestID { + t.Fatalf("decoded identity drift: %+v", decoded) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got success: %+v", tc.wantError, decoded) + } + if !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantError) + } + }) + } +} + +// --- BindDockerContainerUpdatePayload --- + +func TestBindDockerContainerUpdatePayload(t *testing.T) { + t.Run("nil payload rejected", func(t *testing.T) { + if err := BindDockerContainerUpdatePayload(nil); err == nil { + t.Fatal("nil payload was accepted by bind") + } + }) + + t.Run("bind stamps operation version and recomputes digest", func(t *testing.T) { + payload := DockerContainerUpdatePayload{ + RequestID: "r1", + ActionID: "a1", + Runtime: " Docker ", + ContainerID: " ABCDEF123456 ", + ExpectedImageDigest: " SHA256:" + strings.Repeat("A", 64) + " ", + } + if err := BindDockerContainerUpdatePayload(&payload); err != nil { + t.Fatalf("bind failed: %v", err) + } + if payload.Operation != DockerContainerOperationUpdate { + t.Fatalf("operation not stamped: %q", payload.Operation) + } + if payload.OperationVersion != DockerContainerUpdateOperationVersion { + t.Fatalf("operation version not stamped: %d", payload.OperationVersion) + } + if payload.RequestDigest == "" { + t.Fatal("request digest not stamped") + } + // The bound payload must pass validation once its raw fields are + // trimmed/lowercased (validation normalises the same way the digest + // does, so the stamped digest matches). + if err := ValidateDockerContainerUpdatePayload(&payload); err != nil { + t.Fatalf("bound payload did not validate: %v", err) + } + }) +} + +// --- dockerContainerUpdateRequestDigest (white-box) --- + +func TestDockerContainerUpdateRequestDigest(t *testing.T) { + base := DockerContainerUpdatePayload{ + ActionID: "action-1", + Operation: DockerContainerOperationUpdate, + OperationVersion: DockerContainerUpdateOperationVersion, + Runtime: "docker", + ContainerID: testValidUpdateContainerID, + ExpectedImageDigest: testValidUpdateImageDigest, + } + digestA, err := dockerContainerUpdateRequestDigest(base) + if err != nil { + t.Fatalf("digest failed: %v", err) + } + if !strings.HasPrefix(digestA, "sha256:") { + t.Fatalf("digest not sha256-prefixed: %q", digestA) + } + + t.Run("deterministic for identical input", func(t *testing.T) { + digestB, err := dockerContainerUpdateRequestDigest(base) + if err != nil { + t.Fatal(err) + } + if digestA != digestB { + t.Fatalf("digest not deterministic: %q vs %q", digestA, digestB) + } + }) + + t.Run("case and whitespace normalised", func(t *testing.T) { + upper := base + upper.Runtime = " DOCKER " + upper.ContainerID = " ABCDEF123456 " + upper.ExpectedImageDigest = " SHA256:" + strings.Repeat("A", 64) + " " + upper.ActionID = " action-1 " + digestUpper, err := dockerContainerUpdateRequestDigest(upper) + if err != nil { + t.Fatal(err) + } + if digestUpper != digestA { + t.Fatalf("digest not normalised for case/whitespace: %q vs %q", digestUpper, digestA) + } + }) + + t.Run("action id change alters digest", func(t *testing.T) { + changed := base + changed.ActionID = "action-2" + digestChanged, err := dockerContainerUpdateRequestDigest(changed) + if err != nil { + t.Fatal(err) + } + if digestChanged == digestA { + t.Fatal("action id change did not alter digest") + } + }) +} + +// --- ValidateDockerContainerUpdatePayload: every branch --- + +func TestValidateDockerContainerUpdatePayloadBranches(t *testing.T) { + base := newBoundUpdatePayload(t) + + for _, tc := range []struct { + name string + mutate func(*DockerContainerUpdatePayload) + wantError string + }{ + { + name: "valid happy path", + mutate: func(*DockerContainerUpdatePayload) {}, + }, + { + name: "missing request id", + mutate: func(p *DockerContainerUpdatePayload) { p.RequestID = "" }, + wantError: "request or action id", + }, + { + name: "missing action id", + mutate: func(p *DockerContainerUpdatePayload) { p.ActionID = "" }, + wantError: "request or action id", + }, + { + name: "oversized request id", + mutate: func(p *DockerContainerUpdatePayload) { p.RequestID = strings.Repeat("x", maxRequestIDLength+1) }, + wantError: "request or action id", + }, + { + name: "oversized action id", + mutate: func(p *DockerContainerUpdatePayload) { p.ActionID = strings.Repeat("y", maxRequestIDLength+1) }, + wantError: "request or action id", + }, + { + name: "unsupported operation", + mutate: func(p *DockerContainerUpdatePayload) { p.Operation = DockerContainerOperationRestart }, + wantError: "unsupported docker container update operation", + }, + { + name: "unsupported operation version", + mutate: func(p *DockerContainerUpdatePayload) { p.OperationVersion = 99 }, + wantError: "unsupported docker container update operation version", + }, + { + name: "unsupported runtime", + mutate: func(p *DockerContainerUpdatePayload) { p.Runtime = "containerd" }, + wantError: "unsupported container runtime", + }, + { + name: "podman runtime accepted", + mutate: func(p *DockerContainerUpdatePayload) { p.Runtime = "podman" }, + wantError: "", // podman is accepted; bound digest already covers it though, so this needs rebinding + }, + { + name: "container id wrong format", + mutate: func(p *DockerContainerUpdatePayload) { p.ContainerID = "xyz123" }, + wantError: "immutable hexadecimal id", + }, + { + name: "container id too short", + mutate: func(p *DockerContainerUpdatePayload) { p.ContainerID = "abc" }, + wantError: "immutable hexadecimal id", + }, + { + name: "expected image digest not a digest", + mutate: func(p *DockerContainerUpdatePayload) { p.ExpectedImageDigest = "busybox:latest" }, + wantError: "invalid docker update expected image digest", + }, + { + name: "expected image digest tampered but still pattern-valid triggers digest mismatch", + mutate: func(p *DockerContainerUpdatePayload) { p.ExpectedImageDigest = testValidUpdateImageDigestAlt }, + wantError: "request digest mismatch", + }, + { + name: "request digest tampered directly", + mutate: func(p *DockerContainerUpdatePayload) { p.RequestDigest = testValidUpdateImageDigestAlt }, + wantError: "request digest mismatch", + }, + { + name: "negative timeout rejected", + mutate: func(p *DockerContainerUpdatePayload) { p.Timeout = -1 }, + wantError: "timeout must be between 0 and", + }, + { + name: "timeout exceeding maximum rejected", + mutate: func(p *DockerContainerUpdatePayload) { p.Timeout = maxDockerContainerUpdateTimeoutSeconds + 1 }, + wantError: "timeout must be between 0 and", + }, + } { + t.Run(tc.name, func(t *testing.T) { + p := base + tc.mutate(&p) + // podman runtime is accepted; its digest must be rebound for the + // digest-mismatch check to stay quiet so we observe the runtime + // branch's accept path rather than a digest drift. + if p.Runtime == "podman" { + if err := BindDockerContainerUpdatePayload(&p); err != nil { + t.Fatalf("rebind for podman failed: %v", err) + } + } + err := ValidateDockerContainerUpdatePayload(&p) + if tc.wantError == "" { + if err != nil { + t.Fatalf("expected validation success, got: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got success", tc.wantError) + } + if !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantError) + } + }) + } + + t.Run("nil payload rejected", func(t *testing.T) { + if err := ValidateDockerContainerUpdatePayload(nil); err == nil { + t.Fatal("nil payload accepted") + } + }) + + t.Run("zero timeout is defaulted", func(t *testing.T) { + p := base + p.Timeout = 0 + if err := ValidateDockerContainerUpdatePayload(&p); err != nil { + t.Fatalf("zero timeout should be defaulted, got: %v", err) + } + if p.Timeout != defaultDockerContainerUpdateTimeoutSeconds { + t.Fatalf("timeout not defaulted: got %d want %d", p.Timeout, defaultDockerContainerUpdateTimeoutSeconds) + } + }) + + t.Run("exact maximum timeout accepted", func(t *testing.T) { + p := base + p.Timeout = maxDockerContainerUpdateTimeoutSeconds + if err := ValidateDockerContainerUpdatePayload(&p); err != nil { + t.Fatalf("maximum timeout should be accepted, got: %v", err) + } + }) +} + +// --- ValidateDockerContainerUpdateResultPayload: every branch --- + +func TestValidateDockerContainerUpdateResultPayloadBranches(t *testing.T) { + base := newValidCompleteUpdateResult(newBoundUpdatePayload(t)) + + for _, tc := range []struct { + name string + mutate func(*DockerContainerUpdateResultPayload) + wantError string + }{ + { + name: "valid complete happy path", + mutate: func(*DockerContainerUpdateResultPayload) {}, + wantError: "", + }, + { + name: "missing request id", + mutate: func(r *DockerContainerUpdateResultPayload) { r.RequestID = "" }, + wantError: "result identity", + }, + { + name: "missing action id", + mutate: func(r *DockerContainerUpdateResultPayload) { r.ActionID = "" }, + wantError: "result identity", + }, + { + name: "oversized request id", + mutate: func(r *DockerContainerUpdateResultPayload) { r.RequestID = strings.Repeat("x", maxRequestIDLength+1) }, + wantError: "result identity", + }, + { + name: "unsupported operation", + mutate: func(r *DockerContainerUpdateResultPayload) { r.Operation = DockerContainerOperationRestart }, + wantError: "unsupported docker update result operation", + }, + { + name: "wrong operation version in binding", + mutate: func(r *DockerContainerUpdateResultPayload) { r.OperationVersion = 99 }, + wantError: "invalid docker update result binding", + }, + { + name: "bad container id in binding", + mutate: func(r *DockerContainerUpdateResultPayload) { r.ContainerID = "xyz123" }, + wantError: "invalid docker update result binding", + }, + { + name: "bad request digest format in binding", + mutate: func(r *DockerContainerUpdateResultPayload) { r.RequestDigest = "not-a-digest" }, + wantError: "invalid docker update result binding", + }, + { + name: "unsupported execution phase", + mutate: func(r *DockerContainerUpdateResultPayload) { r.ExecutionPhase = "frobnicate" }, + wantError: "execution phase", + }, + { + name: "invalid replacement container id", + mutate: func(r *DockerContainerUpdateResultPayload) { r.NewContainerID = "xyz123" }, + wantError: "invalid replacement container id", + }, + { + name: "error string exceeds bound", + mutate: func(r *DockerContainerUpdateResultPayload) { + r.Error = strings.Repeat("e", 1025) + r.ExecutionPhase = DockerContainerPhaseMutate + r.MutationCompleted = false + }, + wantError: "exceeds bounded contract", + }, + { + name: "container name exceeds bound", + mutate: func(r *DockerContainerUpdateResultPayload) { + r.ContainerName = strings.Repeat("n", maxDockerContainerNameLength+1) + }, + wantError: "exceeds bounded contract", + }, + { + name: "backup container name exceeds bound", + mutate: func(r *DockerContainerUpdateResultPayload) { + r.BackupContainer = strings.Repeat("b", maxDockerContainerNameLength+1) + }, + wantError: "exceeds bounded contract", + }, + { + name: "old image digest exceeds length bound", + mutate: func(r *DockerContainerUpdateResultPayload) { + r.OldImageDigest = strings.Repeat("o", maxDockerImageDigestLength+1) + }, + wantError: "digest exceeds bounded contract", + }, + { + name: "new image digest exceeds length bound", + mutate: func(r *DockerContainerUpdateResultPayload) { + r.NewImageDigest = strings.Repeat("m", maxDockerImageDigestLength+1) + }, + wantError: "digest exceeds bounded contract", + }, + { + name: "mutation completed without mutation started", + mutate: func(r *DockerContainerUpdateResultPayload) { + r.ExecutionPhase = DockerContainerPhaseMutate + r.MutationStarted = false + r.MutationCompleted = true + r.NewContainerID = "" + }, + wantError: "completed docker update mutation requires mutation start", + }, + { + name: "rolled back without rollback attempted", + mutate: func(r *DockerContainerUpdateResultPayload) { + r.ExecutionPhase = DockerContainerPhaseMutate + r.MutationStarted = true + r.MutationCompleted = false + r.RolledBack = true + r.RollbackAttempted = false + r.NewContainerID = "" + r.Error = "create failed" + }, + wantError: "rollback success requires a rollback attempt", + }, + { + name: "rollback attempted without mutation started", + mutate: func(r *DockerContainerUpdateResultPayload) { + r.ExecutionPhase = DockerContainerPhaseMutate + r.MutationStarted = false + r.MutationCompleted = false + r.RollbackAttempted = true + r.RolledBack = false + r.NewContainerID = "" + }, + wantError: "rollback requires mutation start", + }, + { + name: "readback ran without after observation", + mutate: func(r *DockerContainerUpdateResultPayload) { + r.ExecutionPhase = DockerContainerPhaseMutate + r.MutationStarted = true + r.MutationCompleted = false + r.ReadbackRan = true + r.After = DockerContainerLifecycleSnapshot{} + r.NewContainerID = "" + }, + wantError: "readback requires an observation", + }, + { + name: "complete phase with error set", + mutate: func(r *DockerContainerUpdateResultPayload) { r.Error = "something went wrong" }, + wantError: "complete docker update requires a replacement container and no error", + }, + { + name: "complete phase without mutation completed", + mutate: func(r *DockerContainerUpdateResultPayload) { r.MutationCompleted = false }, + wantError: "complete docker update requires a replacement container and no error", + }, + { + name: "complete phase without replacement container", + mutate: func(r *DockerContainerUpdateResultPayload) { r.NewContainerID = "" }, + wantError: "complete docker update requires a replacement container and no error", + }, + } { + t.Run(tc.name, func(t *testing.T) { + r := base + tc.mutate(&r) + err := ValidateDockerContainerUpdateResultPayload(&r) + if tc.wantError == "" { + if err != nil { + t.Fatalf("expected validation success, got: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got success", tc.wantError) + } + if !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantError) + } + }) + } + + t.Run("nil result rejected", func(t *testing.T) { + if err := ValidateDockerContainerUpdateResultPayload(nil); err == nil { + t.Fatal("nil result accepted") + } + }) + + t.Run("preflight result without completion requirements accepted", func(t *testing.T) { + r := newValidUpdateResult() + if err := ValidateDockerContainerUpdateResultPayload(&r); err != nil { + t.Fatalf("preflight result should be accepted, got: %v", err) + } + }) + + t.Run("verify phase accepted", func(t *testing.T) { + r := newValidCompleteUpdateResult(newBoundUpdatePayload(t)) + r.ExecutionPhase = DockerContainerPhaseVerify + if err := ValidateDockerContainerUpdateResultPayload(&r); err != nil { + t.Fatalf("verify phase should be accepted, got: %v", err) + } + }) +} + +// --- DockerContainerUpdateOperationIdentity --- + +func TestDockerContainerUpdateOperationIdentity(t *testing.T) { + req := newBoundUpdatePayload(t) + identity := DockerContainerUpdateOperationIdentity(" agent-42 ", req) + if identity.AttemptID != req.RequestID { + t.Fatalf("attempt id mismatch: %q vs %q", identity.AttemptID, req.RequestID) + } + if identity.ActionID != req.ActionID { + t.Fatalf("action id mismatch: %q vs %q", identity.ActionID, req.ActionID) + } + if identity.OperationKind != req.Operation { + t.Fatalf("operation kind mismatch: %q vs %q", identity.OperationKind, req.Operation) + } + if identity.OperationVersion != req.OperationVersion { + t.Fatalf("operation version mismatch: %d vs %d", identity.OperationVersion, req.OperationVersion) + } + if identity.RequestDigest != req.RequestDigest { + t.Fatalf("request digest mismatch: %q vs %q", identity.RequestDigest, req.RequestDigest) + } + if identity.AgentID != "agent-42" { + t.Fatalf("agent id not trimmed: %q", identity.AgentID) + } + if identity.AgentID == "" { + t.Fatal("empty agent id should still be passed through (trimmed)") + } +} + +// --- ValidateDockerContainerUpdateResultForRequest: every cross-validation branch --- + +func TestValidateDockerContainerUpdateResultForRequestBranches(t *testing.T) { + req := newBoundUpdatePayload(t) + + t.Run("happy path", func(t *testing.T) { + result := newValidCompleteUpdateResult(req) + if err := ValidateDockerContainerUpdateResultForRequest(req, result); err != nil { + t.Fatalf("expected success, got: %v", err) + } + }) + + t.Run("invalid request rejected before result checked", func(t *testing.T) { + badReq := req + badReq.RequestID = "" + result := newValidCompleteUpdateResult(req) + err := ValidateDockerContainerUpdateResultForRequest(badReq, result) + if err == nil || !strings.Contains(err.Error(), "request or action id") { + t.Fatalf("expected request-id error, got: %v", err) + } + }) + + t.Run("invalid result rejected", func(t *testing.T) { + result := newValidCompleteUpdateResult(req) + result.ExecutionPhase = "frobnicate" + err := ValidateDockerContainerUpdateResultForRequest(req, result) + if err == nil || !strings.Contains(err.Error(), "execution phase") { + t.Fatalf("expected execution-phase error, got: %v", err) + } + }) + + // NOTE: result.Operation and result.OperationVersion mismatch branches + // (line 186 of docker_update_codec.go) cannot be exercised here, because + // ValidateDockerContainerUpdateResultPayload requires Operation == + // DockerContainerOperationUpdate and OperationVersion == + // DockerContainerUpdateOperationVersion to even pass; the request's own + // validator enforces the same constants. Those two comparison operands + // are therefore effectively dead in the cross-validator and are noted as + // suspected dead branches in GLM_REPORT.md (not fixed). + for _, tc := range []struct { + name string + mutate func(*DockerContainerUpdateResultPayload) + wantError string + }{ + { + name: "request id mismatch", + mutate: func(r *DockerContainerUpdateResultPayload) { r.RequestID = "other-request" }, + wantError: "identity mismatch", + }, + { + name: "action id mismatch", + mutate: func(r *DockerContainerUpdateResultPayload) { r.ActionID = "other-action" }, + wantError: "identity mismatch", + }, + { + name: "container mismatch on result", + mutate: func(r *DockerContainerUpdateResultPayload) { r.ContainerID = testValidUpdateContainerIDB }, + wantError: "container mismatch", + }, + { + name: "after-state container mismatch", + mutate: func(r *DockerContainerUpdateResultPayload) { + r.After = DockerContainerLifecycleSnapshot{ + ContainerID: "1234567890ab", + ObservedAt: time.Now().UTC(), + } + }, + wantError: "after-state container mismatch", + }, + } { + t.Run(tc.name, func(t *testing.T) { + result := newValidCompleteUpdateResult(req) + tc.mutate(&result) + err := ValidateDockerContainerUpdateResultForRequest(req, result) + if err == nil { + t.Fatalf("expected error containing %q, got success", tc.wantError) + } + if !strings.Contains(err.Error(), tc.wantError) { + t.Fatalf("error %q does not contain %q", err.Error(), tc.wantError) + } + }) + } + + t.Run("after container equal to new container by case is accepted", func(t *testing.T) { + result := newValidCompleteUpdateResult(req) + // NewContainerID is lowercase hex; force the after-state to use the + // same id but uppercase to confirm EqualFold passes. + result.NewContainerID = testValidUpdateContainerID + result.After.ContainerID = strings.ToUpper(testValidUpdateContainerID) + if err := ValidateDockerContainerUpdateResultForRequest(req, result); err != nil { + t.Fatalf("expected case-insensitive after-state match to pass, got: %v", err) + } + }) +} + +// --- helpers --- + +// mutatePayload returns a copy of p with the mutator applied. Useful for +// building a base JSON whose fields have already passed Bind, then perturbing +// them for a decode test. +func mutatePayload(p DockerContainerUpdatePayload, mutator func(*DockerContainerUpdatePayload)) DockerContainerUpdatePayload { + cp := p + mutator(&cp) + return cp +} + +// mutateResult returns a copy of r with the mutator applied. +func mutateResult(r DockerContainerUpdateResultPayload, mutator func(*DockerContainerUpdateResultPayload)) DockerContainerUpdateResultPayload { + cp := r + mutator(&cp) + return cp +} + +// rejson marshals the given value to canonical JSON. If key/value are both +// non-empty, the resulting JSON object has its key overwritten with value +// (used to delete or rewrite a single field while staying strict-decodable). +// When key/value are empty, the value is marshalled unchanged. +func rejson(t *testing.T, v any, key, value string) string { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + if key == "" && value == "" { + return string(raw) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if value == "" { + delete(m, key) + } else { + m[key] = value + } + out, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return string(out) +} diff --git a/internal/agentexec/types_coverage_test.go b/internal/agentexec/types_coverage_test.go new file mode 100644 index 000000000..de28df4c1 --- /dev/null +++ b/internal/agentexec/types_coverage_test.go @@ -0,0 +1,31 @@ +package agentexec + +import "testing" + +func TestCoverageNormalizeDeployMaxParallel(t *testing.T) { + for _, tc := range []struct { + name string + value int + defaultValue int + want int + }{ + {"zero value uses default", 0, 5, 5}, + {"negative value uses default", -3, 4, 4}, + {"normal passthrough below cap", 3, 5, 3}, + {"one passthrough", 1, 5, 1}, + {"at cap passthrough", MaxDeployParallel, 5, MaxDeployParallel}, + {"over cap clamped to max", 15, 5, MaxDeployParallel}, + {"far over cap clamped to max", 1000, 1, MaxDeployParallel}, + {"default over cap clamped to max", 0, 25, MaxDeployParallel}, + {"default zero stays zero", 0, 0, 0}, + {"positive value ignores zero default", 7, 0, 7}, + } { + t.Run(tc.name, func(t *testing.T) { + got := NormalizeDeployMaxParallel(tc.value, tc.defaultValue) + if got != tc.want { + t.Fatalf("NormalizeDeployMaxParallel(%d, %d) = %d, want %d", + tc.value, tc.defaultValue, got, tc.want) + } + }) + } +} diff --git a/pkg/licensing/activation_types_coverage_test.go b/pkg/licensing/activation_types_coverage_test.go new file mode 100644 index 000000000..6c32b02e5 --- /dev/null +++ b/pkg/licensing/activation_types_coverage_test.go @@ -0,0 +1,113 @@ +package licensing + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// These tests add coverage for previously-uncovered functions in +// activation_types.go. White-box (same package) so unexported helpers can be +// exercised directly. + +func TestCoverageParseExpiresAt(t *testing.T) { + t.Run("empty_string_returns_zero", func(t *testing.T) { + g := GrantEnvelope{ExpiresAt: ""} + assert.Equal(t, int64(0), g.ParseExpiresAt()) + }) + + t.Run("zero_value_envelope_returns_zero", func(t *testing.T) { + var g GrantEnvelope + assert.Equal(t, int64(0), g.ParseExpiresAt()) + }) + + t.Run("valid_rfc3339_returns_unix_ts", func(t *testing.T) { + ts := "2025-01-15T10:30:00Z" + g := GrantEnvelope{ExpiresAt: ts} + expected, err := time.Parse(time.RFC3339, ts) + assert.NoError(t, err) + assert.Equal(t, expected.Unix(), g.ParseExpiresAt()) + }) + + t.Run("valid_rfc3339_with_offset_returns_unix_ts", func(t *testing.T) { + ts := "2025-06-30T23:59:59-07:00" + g := GrantEnvelope{ExpiresAt: ts} + expected, err := time.Parse(time.RFC3339, ts) + assert.NoError(t, err) + assert.Equal(t, expected.Unix(), g.ParseExpiresAt()) + }) + + t.Run("invalid_format_returns_zero", func(t *testing.T) { + g := GrantEnvelope{ExpiresAt: "not-a-date"} + assert.Equal(t, int64(0), g.ParseExpiresAt()) + }) + + t.Run("garbage_numeric_returns_zero", func(t *testing.T) { + // A bare unix epoch number is not RFC3339 and must not parse. + g := GrantEnvelope{ExpiresAt: "0"} + assert.Equal(t, int64(0), g.ParseExpiresAt()) + }) + + t.Run("value_receiver_does_not_panic", func(t *testing.T) { + g := GrantEnvelope{ExpiresAt: "2025-01-15T10:30:00Z"} + assert.NotPanics(t, func() { + _ = g.ParseExpiresAt() + }) + }) +} + +func TestCoverageGrantClaimsUseUncappedCoreMonitoring(t *testing.T) { + t.Run("nil_claims_false", func(t *testing.T) { + assert.False(t, grantClaimsUseUncappedCoreMonitoring(nil)) + }) + + t.Run("uncapped_tier_pro_true", func(t *testing.T) { + assert.True(t, grantClaimsUseUncappedCoreMonitoring(&GrantClaims{Tier: string(TierPro)})) + }) + + t.Run("uncapped_tier_free_true", func(t *testing.T) { + assert.True(t, grantClaimsUseUncappedCoreMonitoring(&GrantClaims{Tier: string(TierFree)})) + }) + + t.Run("uncapped_tier_relay_true", func(t *testing.T) { + assert.True(t, grantClaimsUseUncappedCoreMonitoring(&GrantClaims{Tier: string(TierRelay)})) + }) + + t.Run("uncapped_tier_lifetime_true", func(t *testing.T) { + assert.True(t, grantClaimsUseUncappedCoreMonitoring(&GrantClaims{Tier: string(TierLifetime)})) + }) + + t.Run("uncapped_tier_business_true", func(t *testing.T) { + assert.True(t, grantClaimsUseUncappedCoreMonitoring(&GrantClaims{Tier: string(TierBusiness)})) + }) + + t.Run("capped_tier_cloud_false", func(t *testing.T) { + assert.False(t, grantClaimsUseUncappedCoreMonitoring(&GrantClaims{Tier: string(TierCloud)})) + }) + + t.Run("capped_tier_msp_false", func(t *testing.T) { + assert.False(t, grantClaimsUseUncappedCoreMonitoring(&GrantClaims{Tier: string(TierMSP)})) + }) + + t.Run("capped_tier_enterprise_false", func(t *testing.T) { + assert.False(t, grantClaimsUseUncappedCoreMonitoring(&GrantClaims{Tier: string(TierEnterprise)})) + }) + + t.Run("empty_tier_false", func(t *testing.T) { + assert.False(t, grantClaimsUseUncappedCoreMonitoring(&GrantClaims{Tier: ""})) + }) + + t.Run("unknown_tier_false", func(t *testing.T) { + assert.False(t, grantClaimsUseUncappedCoreMonitoring(&GrantClaims{Tier: "does-not-exist"})) + }) + + t.Run("case_insensitive_uppercase_true", func(t *testing.T) { + // IsSelfHostedCoreMonitoringUncappedTier lowercases the input. + assert.True(t, grantClaimsUseUncappedCoreMonitoring(&GrantClaims{Tier: "PRO"})) + }) + + t.Run("case_insensitive_mixed_with_whitespace_true", func(t *testing.T) { + assert.True(t, grantClaimsUseUncappedCoreMonitoring(&GrantClaims{Tier: " Pro_Plus "})) + }) +} diff --git a/pkg/licensing/service_coverage_test.go b/pkg/licensing/service_coverage_test.go new file mode 100644 index 000000000..c90790a2b --- /dev/null +++ b/pkg/licensing/service_coverage_test.go @@ -0,0 +1,343 @@ +package licensing + +import ( + "math" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// These tests add coverage for previously-uncovered pure helpers and simple +// *Service accessors/mutators in service.go. They are white-box (same package) +// so unexported helpers can be exercised directly. + +// ----------------------------------------------------------------------------- +// Pure helpers (no *Service setup required) +// ----------------------------------------------------------------------------- + +func TestCoverageUnionFeatures(t *testing.T) { + t.Run("both_empty", func(t *testing.T) { + got := unionFeatures(nil, nil) + assert.Empty(t, got) + }) + + t.Run("first_empty_second_non_empty", func(t *testing.T) { + got := unionFeatures(nil, []string{"a"}) + assert.Equal(t, []string{"a"}, got) + }) + + t.Run("second_empty_first_non_empty", func(t *testing.T) { + got := unionFeatures([]string{"a"}, nil) + assert.Equal(t, []string{"a"}, got) + }) + + t.Run("overlap_dedup", func(t *testing.T) { + got := unionFeatures([]string{"a", "b"}, []string{"b", "c"}) + assert.Equal(t, []string{"a", "b", "c"}, got) + }) + + t.Run("sorted_output", func(t *testing.T) { + got := unionFeatures([]string{"c", "a"}, []string{"b"}) + assert.Equal(t, []string{"a", "b", "c"}, got) + assert.True(t, sliceIsSorted(got), "output must be sorted") + }) + + t.Run("duplicates_within_same_slice", func(t *testing.T) { + got := unionFeatures([]string{"a", "a"}, nil) + assert.Equal(t, []string{"a"}, got) + }) + + t.Run("does_not_mutate_input_order_semantics", func(t *testing.T) { + // Unsorted, overlapping inputs from both sides collapse to one sorted set. + got := unionFeatures([]string{"z", "a", "m"}, []string{"m", "q"}) + assert.Equal(t, []string{"a", "m", "q", "z"}, got) + }) +} + +func sliceIsSorted(s []string) bool { + for i := 1; i < len(s); i++ { + if s[i-1] > s[i] { + return false + } + } + return true +} + +func TestCoverageSafeIntFromInt64(t *testing.T) { + t.Run("normal_passthrough", func(t *testing.T) { + assert.Equal(t, 100, safeIntFromInt64(100)) + }) + + t.Run("zero", func(t *testing.T) { + assert.Equal(t, 0, safeIntFromInt64(0)) + }) + + t.Run("negative_clamps_to_zero", func(t *testing.T) { + assert.Equal(t, 0, safeIntFromInt64(-1)) + assert.Equal(t, 0, safeIntFromInt64(-9999)) + }) + + t.Run("large_negative", func(t *testing.T) { + assert.Equal(t, 0, safeIntFromInt64(math.MinInt64)) + }) + + t.Run("maxint64_clamps_to_maxint", func(t *testing.T) { + maxInt := int(^uint(0) >> 1) + assert.Equal(t, maxInt, safeIntFromInt64(math.MaxInt64)) + }) + + t.Run("large_in_range_value_on_64bit", func(t *testing.T) { + // 1 << 40 fits in a 64-bit int. + assert.Equal(t, 1<<40, safeIntFromInt64(int64(1)<<40)) + }) +} + +func TestCoverageRemainingDaysCeil(t *testing.T) { + const day = int64(86400) + + t.Run("expired_returns_zero", func(t *testing.T) { + now := int64(1_000_000) + assert.Equal(t, 0, remainingDaysCeil(now-day, now)) + }) + + t.Run("negative_delta_returns_zero", func(t *testing.T) { + now := int64(2_000_000) + assert.Equal(t, 0, remainingDaysCeil(now-5, now)) + }) + + t.Run("exact_boundary_delta_zero", func(t *testing.T) { + now := int64(3_000_000) + assert.Equal(t, 0, remainingDaysCeil(now, now)) + }) + + t.Run("one_second_rounds_up_to_one_day", func(t *testing.T) { + now := int64(4_000_000) + assert.Equal(t, 1, remainingDaysCeil(now+1, now)) + }) + + t.Run("partial_day_rounds_up", func(t *testing.T) { + now := int64(5_000_000) + // 1.5 days -> ceil -> 2 + assert.Equal(t, 2, remainingDaysCeil(now+(day+day/2), now)) + }) + + t.Run("exactly_one_day", func(t *testing.T) { + now := int64(6_000_000) + assert.Equal(t, 1, remainingDaysCeil(now+day, now)) + }) + + t.Run("exactly_two_days", func(t *testing.T) { + now := int64(7_000_000) + assert.Equal(t, 2, remainingDaysCeil(now+2*day, now)) + }) +} + +// ----------------------------------------------------------------------------- +// *Service helpers / accessors +// ----------------------------------------------------------------------------- + +func TestCoverageEnsureGracePeriodEnd(t *testing.T) { + t.Run("nil_license_noop", func(t *testing.T) { + s := NewService() + s.mu.Lock() + s.ensureGracePeriodEnd() + s.mu.Unlock() + // Still no license, no panic, no grace period set. + assert.Nil(t, s.CurrentUnsafeForTesting()) + }) + + t.Run("nil_GracePeriodEnd_sets_from_ExpiresAt_plus_grace", func(t *testing.T) { + s := NewService() + expiresAt := time.Now().Add(24 * time.Hour).Unix() + lic := &License{Claims: Claims{ExpiresAt: expiresAt}} + s.SetCurrentForTesting(lic) + + s.mu.Lock() + s.ensureGracePeriodEnd() + s.mu.Unlock() + + got := s.CurrentUnsafeForTesting().GracePeriodEnd + require.NotNil(t, got) + expected := time.Unix(expiresAt, 0).Add(DefaultGracePeriod) + assert.True(t, got.Equal(expected), "grace end = expiresAt + DefaultGracePeriod") + }) + + t.Run("already_set_noop", func(t *testing.T) { + s := NewService() + preExisting := time.Now().Add(48 * time.Hour) + lic := &License{ + Claims: Claims{ExpiresAt: time.Now().Add(24 * time.Hour).Unix()}, + GracePeriodEnd: &preExisting, + } + s.SetCurrentForTesting(lic) + + s.mu.Lock() + s.ensureGracePeriodEnd() + s.mu.Unlock() + + got := s.CurrentUnsafeForTesting().GracePeriodEnd + require.NotNil(t, got) + assert.True(t, got.Equal(preExisting), "pre-existing grace period must be preserved") + }) +} + +func TestCoverageSetEvaluator(t *testing.T) { + t.Run("set_nil", func(t *testing.T) { + s := NewService() + s.SetEvaluator(nil) + assert.Nil(t, s.Evaluator()) + }) + + t.Run("set_non_nil_then_get", func(t *testing.T) { + s := NewService() + eval := NewEvaluator(NewTokenSource(&Claims{Tier: TierPro})) + s.SetEvaluator(eval) + assert.Same(t, eval, s.Evaluator()) + }) + + t.Run("overwrite", func(t *testing.T) { + s := NewService() + first := NewEvaluator(NewTokenSource(&Claims{Tier: TierPro})) + second := NewEvaluator(NewTokenSource(&Claims{Tier: TierProPlus})) + s.SetEvaluator(first) + s.SetEvaluator(second) + assert.Same(t, second, s.Evaluator()) + }) +} + +func TestCoverageSetStateMachine(t *testing.T) { + t.Run("nil_is_noop_safe", func(t *testing.T) { + s := NewService() + // Must not panic and must leave subscription derivation claim-based. + s.SetStateMachine(nil) + assert.NotPanics(t, func() { + _ = s.SubscriptionState() + }) + }) + + t.Run("non_nil_configures_state_machine_hook", func(t *testing.T) { + // When the state-machine hook is configured and a license with a non-empty + // SubState is present, SubscriptionState short-circuits to the claim's + // SubState instead of deriving from expiration/grace logic. We observe + // the toggle using an expired-but-claimed-active license. + s := NewService() + lic := &License{ + Claims: Claims{ + Tier: TierPro, + ExpiresAt: time.Now().Add(-365 * 24 * time.Hour).Unix(), // long expired + SubState: SubStateActive, + }, + } + s.SetCurrentForTesting(lic) + + // Without the hook: expiration derivation wins -> expired. + assert.Equal(t, string(SubStateExpired), s.SubscriptionState()) + + // With the hook: claim SubState is returned verbatim -> active. + s.SetStateMachine("fake-state-machine") + assert.Equal(t, string(SubStateActive), s.SubscriptionState()) + + // Clearing the hook restores derived behavior -> expired. + s.SetStateMachine(nil) + assert.Equal(t, string(SubStateExpired), s.SubscriptionState()) + }) +} + +func TestCoverageCurrentUnsafeForTesting(t *testing.T) { + t.Run("nil_when_no_license", func(t *testing.T) { + s := NewService() + assert.Nil(t, s.CurrentUnsafeForTesting()) + }) + + t.Run("returns_internal_pointer", func(t *testing.T) { + s := NewService() + lic := &License{Claims: Claims{Tier: TierPro}} + s.SetCurrentForTesting(lic) + // Unsafe accessor returns the exact internal pointer (not a clone). + assert.Same(t, lic, s.CurrentUnsafeForTesting()) + }) +} + +func TestCoverageIsValid(t *testing.T) { + t.Run("nil_license_false", func(t *testing.T) { + s := NewService() + assert.False(t, s.IsValid()) + }) + + t.Run("active_valid_true", func(t *testing.T) { + s := NewService() + s.SetCurrentForTesting(&License{ + Claims: Claims{ + Tier: TierPro, + ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Unix(), + }, + }) + assert.True(t, s.IsValid()) + }) + + t.Run("lifetime_valid_true", func(t *testing.T) { + s := NewService() + s.SetCurrentForTesting(&License{ + Claims: Claims{Tier: TierLifetime}, // ExpiresAt == 0 -> never expires + }) + assert.True(t, s.IsValid()) + }) + + t.Run("expired_past_grace_false", func(t *testing.T) { + s := NewService() + s.SetCurrentForTesting(&License{ + Claims: Claims{ + Tier: TierPro, + ExpiresAt: time.Now().Add(-365 * 24 * time.Hour).Unix(), + }, + }) + assert.False(t, s.IsValid()) + }) + + t.Run("suspended_claim_false", func(t *testing.T) { + s := NewService() + s.SetCurrentForTesting(&License{ + Claims: Claims{ + Tier: TierPro, + ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Unix(), + SubState: SubStateSuspended, + }, + }) + assert.False(t, s.IsValid()) + }) +} + +func TestCoverageIsLicenseValidationDevMode(t *testing.T) { + t.Run("unset_is_false", func(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "") + assert.False(t, IsLicenseValidationDevMode()) + }) + + t.Run("explicit_false", func(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "false") + assert.False(t, IsLicenseValidationDevMode()) + }) + + t.Run("explicit_true", func(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "true") + assert.True(t, IsLicenseValidationDevMode()) + }) + + t.Run("true_with_surrounding_whitespace", func(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", " true ") + assert.True(t, IsLicenseValidationDevMode()) + }) + + t.Run("uppercase_true_is_case_insensitive_match", func(t *testing.T) { + // Uses strings.EqualFold, so "TRUE" also enables dev mode. + t.Setenv("PULSE_LICENSE_DEV_MODE", "TRUE") + assert.True(t, IsLicenseValidationDevMode()) + }) + + t.Run("non_true_word_false", func(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "yes") + assert.False(t, IsLicenseValidationDevMode()) + }) +}