Files
felhom-controller/controller/internal/stacks/unattended_security_test.go
T

109 lines
5.1 KiB
Go

package stacks
import (
"strings"
"testing"
)
// v0.341.0 — D39 = B (`09` §3 decision 255): the night leg takes a `security_fix` step like a proven one, and every
// other guard holds for it unchanged and in the same order. Same real leg over the real guarded update as
// unattended_test.go (legManager: nextcloud pinned at A, the catalog at C, ladder A→B→C).
//
// COMPANION RED-PROOF (REPORT.md): against the OLD verdict line (`e.Verdict != "proven"`) the first two tests fail
// (the step is skipped as not_proven). The held and stopped-by-household guards run BEFORE the verdict line, so the old
// line cannot turn them red; their red-proof is the mutation "a security_fix step returns before the guards" (both
// fail). The unknown-verdict test's red-proof is the mutation "any verdict but unrecorded is taken" (it fails).
const secMarks = `{"files_may_change": false, "needs_person": null, "memory_tight": false}`
// securityLine is a security_fix ladder entry A→B with its public notice, optionally with other marks.
func securityLine(from, to, marks string) string {
return ` - {"from": {"web": "` + from + `"}, "to": {"web": "` + to + `"}, "digest": {"web": "sha256:` +
strings.Repeat("a", 64) + `"}, "verdict": "security_fix", "marks": ` + marks +
`, "security": {"ids": ["CVE-2026-96652", "CVE-2026-96654"], "advisory": "https://www.cisa.gov/kev", "fixed_in": "1.43.3.10861"}, "seed": "none"}` + "\n"
}
func TestD39_SecurityFixStepIsTakenAndNamed(t *testing.T) {
m, dir, _, ups, logBuf := legManager(t)
writeLadder(t, m, securityLine(ladderA, ladderB, secMarks), ladderLine(ladderB, ladderC))
legOpts(m, nil)
s := mustLeg(t, m)
if len(*ups) != 1 || pinOf(t, dir) != ladderB {
t.Fatalf("the security_fix step was not taken: ups=%v pin=%s summary=%+v", *ups, pinOf(t, dir), s)
}
st := legStepFor(s, "nextcloud")
if st.Outcome != LegOutcomeDone || strings.Join(st.Security, ",") != "CVE-2026-96652,CVE-2026-96654" {
t.Fatalf("the summary step must be done and name the ids, got %+v", st)
}
want := "[update-leg] nextcloud: security fix CVE-2026-96652,CVE-2026-96654 taken (" + summarisePin(map[string]string{"web": ladderA}) + " → " + summarisePin(map[string]string{"web": ladderB}) + ")"
if !strings.Contains(logBuf.String(), want) {
t.Fatalf("the log must carry %q; log:\n%s", want, logBuf.String())
}
if !strings.Contains(s.Line(), "[security fix nextcloud: CVE-2026-96652,CVE-2026-96654 done]") {
t.Fatalf("the operator's summary line must name the security fix: %s", s.Line())
}
}
func TestD39_SecurityFixWithNeedsPersonIsSkipped(t *testing.T) {
m, dir, _, ups, _ := legManager(t)
writeLadder(t, m, securityLine(ladderA, ladderB, `{"files_may_change": false, "needs_person": "the admin must sign in again", "memory_tight": false}`),
ladderLine(ladderB, ladderC))
legOpts(m, nil)
s := mustLeg(t, m)
if len(*ups) != 0 || pinOf(t, dir) != ladderA || legStepFor(s, "nextcloud").Reason != LegSkipNeedsPerson {
t.Fatalf("security_fix + needs_person: ups=%v pin=%s step=%+v, want skipped %q", *ups, pinOf(t, dir), legStepFor(s, "nextcloud"), LegSkipNeedsPerson)
}
}
func TestD39_SecurityFixOnAHeldAppIsSkipped(t *testing.T) {
m, _, g, ups, _ := legManager(t)
g.held, g.holdWhy = true, "HELD"
writeLadder(t, m, securityLine(ladderA, ladderB, secMarks), ladderLine(ladderB, ladderC))
legOpts(m, nil)
s := mustLeg(t, m)
if len(*ups) != 0 || legStepFor(s, "nextcloud").Reason != LegSkipHeld {
t.Fatalf("security_fix on a held app: ups=%v step=%+v, want skipped %q", *ups, legStepFor(s, "nextcloud"), LegSkipHeld)
}
}
func TestD39_SecurityFixOnAnAppTheHouseholdStoppedIsSkipped(t *testing.T) {
m, _, _, ups, _ := legManager(t)
writeLadder(t, m, securityLine(ladderA, ladderB, secMarks), ladderLine(ladderB, ladderC))
legOpts(m, nil)
if err := m.ScanStacks(); err != nil {
t.Fatal(err)
}
if err := m.SetDesiredState("nextcloud", DesiredStateStopped); err != nil {
t.Fatal(err)
}
s := mustLeg(t, m)
if len(*ups) != 0 || legStepFor(s, "nextcloud").Reason != LegSkipStoppedByHousehold {
t.Fatalf("security_fix on a stopped app: ups=%v step=%+v, want skipped %q", *ups, legStepFor(s, "nextcloud"), LegSkipStoppedByHousehold)
}
}
func TestD39_AnUnknownVerdictIsStillNotProven(t *testing.T) {
for _, v := range []string{"security-fix", "failed", "inconclusive", ""} {
m, _, _, ups, _ := legManager(t)
writeLadder(t, m, ladderLineMarks(ladderA, ladderB, v, secMarks), ladderLine(ladderB, ladderC))
legOpts(m, nil)
s := mustLeg(t, m)
if len(*ups) != 0 || legStepFor(s, "nextcloud").Reason != LegSkipNotProven {
t.Fatalf("verdict %q: ups=%v step=%+v, want skipped %q", v, *ups, legStepFor(s, "nextcloud"), LegSkipNotProven)
}
}
}
func TestD39_ProvenIsUnchangedAndCarriesNoSecurityNames(t *testing.T) {
m, dir, _, _, logBuf := legManager(t)
legOpts(m, nil)
s := mustLeg(t, m)
st := legStepFor(s, "nextcloud")
if pinOf(t, dir) != ladderB || st.Outcome != LegOutcomeDone || st.Security != nil {
t.Fatalf("a proven step: pin=%s step=%+v, want B done with no security names", pinOf(t, dir), st)
}
if strings.Contains(logBuf.String(), "security fix") || strings.Contains(s.Line(), "security fix") {
t.Fatalf("a proven step must not be logged as a security fix")
}
}