Pulse/internal/agentexec/docker_update_codec_coverage_test.go
rcourtman e1985528d9 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.
2026-07-14 14:45:55 +01:00

838 lines
28 KiB
Go

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)
}