mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-04 13:23:28 +00:00
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.
838 lines
28 KiB
Go
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)
|
|
}
|