mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
test(coverage): agentexec codecs and licensing service branch coverage
Add purely-additive branch-coverage tests for freshly-landed, previously- untested backend surface: - internal/agentexec: docker container update codec (decode/validate/bind/ digest/result-cross-validation), residual host apt codec validation arms (host update + storage cleanup decode/bind/result/operation-query identity), and NormalizeDeployMaxParallel clamping. - pkg/licensing: service helpers (unionFeatures, safeIntFromInt64, remainingDaysCeil, ensureGracePeriodEnd, Set/Get Evaluator+StateMachine, IsValid, CurrentUnsafeForTesting, dev-mode) and activation GrantEnvelope ParseExpiresAt + uncapped-core-monitoring tier classification. No source changed.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 "}))
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user