504eae018b
gates / gates (push) Successful in 24s
Five red-proofs. MinAgent 0.131.0 unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
239 lines
9.8 KiB
Go
239 lines
9.8 KiB
Go
package stacks
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// R-524 (v0.260.0) — the order, and the refusal to move a pin backwards.
|
|
//
|
|
// The defect this pins was MEASURED, not imagined: BIGNIGHT Phase 6, 2026-09-15, privatebin
|
|
// installed 2.0.6 against a catalog reverted to 2.0.5, badge „Frissítés elérhető — ma", and the
|
|
// button behind it offering the downgrade.
|
|
|
|
func oi(ref string) InstalledImage {
|
|
return InstalledImage{Ref: ref, Digest: "sha256:x", At: "2026-09-01T00:00:00Z"}
|
|
}
|
|
|
|
// coStack builds a deployed app with its record and its CATALOG images stated explicitly.
|
|
func coStack(installed map[string]InstalledImage, catalog map[string]string) Stack {
|
|
return Stack{
|
|
Name: "privatebin",
|
|
Deployed: true,
|
|
State: StateRunning,
|
|
AppConfig: &AppConfig{Deployed: true, InstalledImages: installed},
|
|
CatalogImages: catalog,
|
|
}
|
|
}
|
|
|
|
// TestR524_CatalogOrder is the whole verdict table, including every arm that must NOT be Ahead.
|
|
//
|
|
// COMPANION RED-PROOF (run 2026-09-21): in CatalogOrder, change the Ahead arm's guard
|
|
// `if cmp, ok := CompareImageRefs(...); !ok || cmp <= 0` to `if cmp, ok := ...; cmp < 0` — i.e. the
|
|
// plausible-looking implementation that treats an UNORDERABLE pair as ahead. The three floating-tag
|
|
// sub-tests below („a floating tag …", „a different image entirely", „a digest pin") then fail with
|
|
// Ahead, which is the verdict that would suppress a real „Frissítés elérhető" on 23 of the catalog's
|
|
// 66 pins. Reverted.
|
|
func TestR524_CatalogOrder(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
stack Stack
|
|
want UpdateOrder
|
|
}{
|
|
{
|
|
name: "level — every service matches",
|
|
stack: coStack(map[string]InstalledImage{"web": oi("privatebin/pdo:2.0.5")}, map[string]string{"web": "privatebin/pdo:2.0.5"}),
|
|
want: UpdateOrderCurrent,
|
|
},
|
|
{
|
|
name: "behind — the catalog is newer",
|
|
stack: coStack(map[string]InstalledImage{"web": oi("privatebin/pdo:2.0.5")}, map[string]string{"web": "privatebin/pdo:2.0.6"}),
|
|
want: UpdateOrderBehind,
|
|
},
|
|
{
|
|
name: "AHEAD — THE BIGNIGHT CASE: the box updated, the catalog was reverted",
|
|
stack: coStack(map[string]InstalledImage{"web": oi("privatebin/pdo:2.0.6")}, map[string]string{"web": "privatebin/pdo:2.0.5"}),
|
|
want: UpdateOrderAhead,
|
|
},
|
|
{
|
|
name: "ahead across a major — still ahead, still no downgrade",
|
|
stack: coStack(map[string]InstalledImage{"web": oi("privatebin/pdo:3.0.0")}, map[string]string{"web": "privatebin/pdo:2.0.5"}),
|
|
want: UpdateOrderAhead,
|
|
},
|
|
{
|
|
name: "ahead on a TWO-PART tag (mariadb:11.6 style)",
|
|
stack: coStack(map[string]InstalledImage{"db": oi("mariadb:11.7")}, map[string]string{"db": "mariadb:11.6"}),
|
|
want: UpdateOrderAhead,
|
|
},
|
|
{
|
|
name: "MIXED — one newer, one older — is BEHIND, never ahead",
|
|
stack: coStack(
|
|
map[string]InstalledImage{"web": oi("privatebin/pdo:2.0.6"), "db": oi("mariadb:11.4")},
|
|
map[string]string{"web": "privatebin/pdo:2.0.5", "db": "mariadb:11.6"}),
|
|
want: UpdateOrderBehind,
|
|
},
|
|
{
|
|
name: "a floating tag cannot be ordered — behind, as before",
|
|
stack: coStack(map[string]InstalledImage{"db": oi("postgres:16-alpine")}, map[string]string{"db": "postgres:15-alpine"}),
|
|
want: UpdateOrderBehind,
|
|
},
|
|
{
|
|
name: "a different image entirely — the numbers compare, the comparison is meaningless",
|
|
stack: coStack(map[string]InstalledImage{"web": oi("privatebin/pdo:2.0.6")}, map[string]string{"web": "alpine:3.20"}),
|
|
want: UpdateOrderBehind,
|
|
},
|
|
{
|
|
name: "a digest pin carries no order",
|
|
stack: coStack(map[string]InstalledImage{"web": oi("privatebin/pdo@sha256:aaaa")}, map[string]string{"web": "privatebin/pdo:2.0.5"}),
|
|
want: UpdateOrderBehind,
|
|
},
|
|
{
|
|
name: "a service was ADDED by the template — behind, not an order at all",
|
|
stack: coStack(map[string]InstalledImage{"web": oi("privatebin/pdo:2.0.6")}, map[string]string{"web": "privatebin/pdo:2.0.5", "db": "mariadb:11.6"}),
|
|
want: UpdateOrderBehind,
|
|
},
|
|
{
|
|
name: "NO RECORD AT ALL — unknown, and never current",
|
|
stack: coStack(nil, map[string]string{"web": "privatebin/pdo:2.0.5"}),
|
|
want: UpdateOrderUnknown,
|
|
},
|
|
{
|
|
name: "no readable catalog template — unknown",
|
|
stack: coStack(map[string]InstalledImage{"web": oi("privatebin/pdo:2.0.6")}, nil),
|
|
want: UpdateOrderUnknown,
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := CatalogOrder(c.stack); got != c.want {
|
|
t.Errorf("CatalogOrder = %v, want %v", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestR524_ProtectedAndUndeployedAreUnknown keeps the three carried-over guards honest: they were in
|
|
// web.compareInstalledToTemplate before the move and a move is exactly when a guard gets dropped.
|
|
func TestR524_ProtectedAndUndeployedAreUnknown(t *testing.T) {
|
|
base := coStack(map[string]InstalledImage{"web": oi("privatebin/pdo:2.0.6")}, map[string]string{"web": "privatebin/pdo:2.0.5"})
|
|
if CatalogOrder(base) != UpdateOrderAhead {
|
|
t.Fatalf("the base case must be Ahead, or this test proves nothing")
|
|
}
|
|
for _, c := range []struct {
|
|
name string
|
|
mut func(*Stack)
|
|
}{
|
|
{"not deployed", func(s *Stack) { s.Deployed = false }},
|
|
{"protected infra", func(s *Stack) { s.Protected = true }},
|
|
{"orphaned", func(s *Stack) { s.Orphaned = true }},
|
|
} {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
st := base
|
|
c.mut(&st)
|
|
if got := CatalogOrder(st); got != UpdateOrderUnknown {
|
|
t.Errorf("CatalogOrder = %v, want Unknown", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestR524_CompareImageRefs pins the normaliser in front of util.Version.Compare, where the traps
|
|
// live: a registry port that looks like a tag, a two-part tag, a leading v, a zero-padded component.
|
|
func TestR524_CompareImageRefs(t *testing.T) {
|
|
cases := []struct {
|
|
a, b string
|
|
wantCmp int
|
|
wantOK bool
|
|
}{
|
|
{"privatebin/pdo:2.0.6", "privatebin/pdo:2.0.5", 1, true},
|
|
{"privatebin/pdo:2.0.5", "privatebin/pdo:2.0.6", -1, true},
|
|
{"privatebin/pdo:2.0.5", "privatebin/pdo:2.0.5", 0, true},
|
|
{"lscr.io/linuxserver/bookstack:v26.05.2", "lscr.io/linuxserver/bookstack:25.02.2", 1, true},
|
|
// 26.05.2 must beat 26.5.1 and NOT lose to it on the zero: Atoi("05") is 5.
|
|
{"x/y:26.05.2", "x/y:26.5.1", 1, true},
|
|
{"mariadb:11.7", "mariadb:11.6", 1, true},
|
|
// A two-part tag pads to .0, so 11.6 is older than 11.6.1 and level with itself.
|
|
{"mariadb:11.6", "mariadb:11.6.1", -1, true},
|
|
// A registry PORT is not a tag.
|
|
{"gitea.dooplex.hu:3000/admin/app", "gitea.dooplex.hu:3000/admin/app", 0, false},
|
|
{"postgres:16-alpine", "postgres:15-alpine", 0, false},
|
|
{"redis:7-alpine", "redis:7-alpine", 0, false},
|
|
{"app:latest", "app:2.0.0", 0, false},
|
|
{"app:20260915", "app:20260914", 0, false},
|
|
{"app@sha256:aa", "app:2.0.0", 0, false},
|
|
{"alpine:3.20", "privatebin/pdo:2.0.5", 0, false},
|
|
{"app", "app:2.0.0", 0, false},
|
|
// Real catalog shapes. The suffix must be IDENTICAL for the numbers to be compared.
|
|
{"nextcloud:31.0.15-apache", "nextcloud:31.0.14-apache", 1, true},
|
|
{"nextcloud:31.0.14-apache", "nextcloud:31.0.14", 0, false},
|
|
{"x/y:26.05.2-ls311", "x/y:26.05.2-ls310", 0, false},
|
|
{"postgis/postgis:16-3.5-alpine", "postgis/postgis:15-3.5-alpine", 0, false},
|
|
{"kimai/kimai2:apache-2.57.0", "kimai/kimai2:apache-2.56.0", 0, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.a+" vs "+c.b, func(t *testing.T) {
|
|
cmp, ok := CompareImageRefs(c.a, c.b)
|
|
if ok != c.wantOK {
|
|
t.Fatalf("orderable = %v, want %v", ok, c.wantOK)
|
|
}
|
|
if ok && cmp != c.wantCmp {
|
|
t.Errorf("cmp = %d, want %d", cmp, c.wantCmp)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── The refusal: the guarded Update never moves a pin backwards (R-524) ──────────────────────────
|
|
|
|
// TestR524_PreflightRefusesDowngrade is the CONSEQUENCE test, not the mechanism test: the question
|
|
// is not "does CatalogOrder say Ahead" (TestR524_CatalogOrder asks that) but "does the button
|
|
// refuse". R-97b's Scenario F is the reason the two are separate — the mechanism was proven and the
|
|
// consequence was still broken.
|
|
//
|
|
// COMPANION RED-PROOF (run 2026-09-21): delete the `CatalogOrder(*st) == UpdateOrderAhead` block from
|
|
// UpdatePreflight. The `ahead` sub-test then fails with `ref = <nil>` — the update is allowed, and
|
|
// the next thing it does is advance the pin to the older image. Reverted.
|
|
func TestR524_PreflightRefusesDowngrade(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
installed string
|
|
catalog string
|
|
wantReason string // "" = must be allowed
|
|
}{
|
|
{"ahead — the downgrade is refused", "nextcloud:31.0.15-apache", "nextcloud:31.0.14-apache", "downgrade"},
|
|
{"behind — the ordinary update is allowed", "nextcloud:31.0.13-apache", "nextcloud:31.0.14-apache", ""},
|
|
{"unorderable — allowed, exactly as before v0.260.0", "nextcloud:31-apache", "nextcloud:30-apache", ""},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
m, _, _, _ := newSlice4Manager(t)
|
|
st := m.stacks["nextcloud"]
|
|
st.AppConfig.InstalledImages = map[string]InstalledImage{"web": oi(c.installed)}
|
|
st.CatalogImages = map[string]string{"web": c.catalog}
|
|
|
|
ref := m.UpdatePreflight("nextcloud")
|
|
if c.wantReason == "" {
|
|
if ref != nil {
|
|
t.Fatalf("this update must be allowed, got refusal %q: %s", ref.Reason, ref.Message)
|
|
}
|
|
return
|
|
}
|
|
if ref == nil {
|
|
t.Fatalf("this update must be REFUSED with reason %q, got nil", c.wantReason)
|
|
}
|
|
if ref.Reason != c.wantReason {
|
|
t.Fatalf("reason = %q, want %q (message %q)", ref.Reason, c.wantReason, ref.Message)
|
|
}
|
|
// The refusal carries its bundle key, so api.Router.errText renders it in the
|
|
// household's language. Without the Cause it would be Hungarian on an English page —
|
|
// the R-589 failure in a new place.
|
|
if ref.Cause == nil {
|
|
t.Error("the downgrade refusal must carry its key as a Cause, not only a Hungarian literal")
|
|
}
|
|
if !strings.Contains(ref.Message, "katal") {
|
|
t.Errorf("the Hungarian fallback must name the catalog, got %q", ref.Message)
|
|
}
|
|
})
|
|
}
|
|
}
|