package backup import ( "context" "log" "os" "path/filepath" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // R-241 — THE MINT GUARD. This file is the session's headline test. // // The defect, measured on the final walk (SPIKE-r241-recovery-offer-2026-08-07): a rebuilt box's // credential self-heal reached WriteOffboxSecrets at 03:18:06Z and minted a fresh repository password // over a hub package sealing a DIFFERENT key. The recovery screen then correctly reported that there // was nothing recoverable under the key the box held. The screen was honest; the minting was not. // // Scenario A asserts the key is NOT written. Scenario B asserts the guard is narrow enough that a // first-time box still starts — the guard's own failure mode, and the one an over-broad fix produces. // mintGuardManager builds a Manager with NO offbox secrets written, so the mint branch is live. // hubHoldsPackage sets the ACK-cached fact the guard consults. func mintGuardManager(t *testing.T, hubHoldsPackage bool) (*Manager, *settings.Settings, string) { t.Helper() logger := log.New(os.Stderr, "", 0) dataDir := t.TempDir() sett, err := settings.Load(filepath.Join(dataDir, "settings.json"), logger) if err != nil { t.Fatal(err) } cfg := &config.Config{} cfg.Paths.DataDir = dataDir cfg.Paths.SystemDataPath = filepath.Join(dataDir, "sys") m := NewManager(cfg, sett, logger) if err := sett.SetHubEscrowIdentityPresent(hubHoldsPackage); err != nil { t.Fatal(err) } return m, sett, filepath.Join(dataDir, "offbox", "repo_password") } // ── SCENARIO A — the box does not mint over a sealed package ──────────────────────────────────── // // RED-PROOF: delete the `if m.sealedPackageHeld()` block in WriteOffboxSecrets. The password file // then exists and this test fails on the first assertion — which is exactly the 03:18:06Z event. func TestR241_ScenarioA_NoMintWhenHubHoldsSealedPackage(t *testing.T) { m, _, pwPath := mintGuardManager(t, true) err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey") if !IsOffboxSealedPackageHeld(err) { t.Fatalf("want the sealed-package refusal sentinel, got %v", err) } // THE ASSERTION THAT IS THE WHOLE SESSION: no key on disk. if _, serr := os.Stat(pwPath); !os.IsNotExist(serr) { t.Fatalf("R-241 REGRESSION: a repository password was minted over the hub's sealed package (stat err=%v)", serr) } // The transport IS still written — the refusal is a holding state, not a failure. Without this the // recovery screen could not bring the tier up when the key arrives (R-219). for _, f := range []string{"ssh_key", "known_hosts"} { if _, serr := os.Stat(filepath.Join(filepath.Dir(pwPath), f)); serr != nil { t.Errorf("transport file %s should still be written on the refusal path: %v", f, serr) } } } // Scenario A at the APPLY level — the path the self-heal actually takes. ApplyOffsiteTarget must // swallow the sentinel, record the target, and NOT stage an escrow. func TestR241_ScenarioA_ApplyOffsiteTargetHoldsInsteadOfMinting(t *testing.T) { m, sett, pwPath := mintGuardManager(t, true) staged := 0 stage := func(ctx context.Context, pw string) error { staged++; return nil } tgt := &settings.OffboxTarget{Enabled: true, Host: "box.example", Port: 23, User: "u1", RepoPath: "/home/felhom-repo"} if err := m.ApplyOffsiteTarget(context.Background(), tgt, "KEYMATERIAL", "box.example ssh-ed25519 HOSTKEY", stage); err != nil { t.Fatalf("apply should SUCCEED into the holding state, not fail: %v", err) } if _, serr := os.Stat(pwPath); !os.IsNotExist(serr) { t.Fatalf("R-241 REGRESSION: apply minted a repository password over the sealed package") } if staged != 0 { t.Errorf("nothing may be staged for escrow — there is no key to escrow; staged=%d", staged) } // The target is recorded, so the box stops declaring needs_credential and the hub stops re-staging. if got := sett.GetOffboxTarget(); got == nil { t.Fatal("the transport target must be recorded, or the hub re-stages a consumed credential forever") } // Runs stay gated: no password file ⇒ not configured. if m.OffboxConfigured() { t.Error("OffboxConfigured must be false while the key is awaited — runs must not proceed") } // And the box says so, in the state the hub reads. if !m.OffboxAwaitingRecoveryKey() { t.Error("OffboxAwaitingRecoveryKey should be true in the holding state") } st := m.OffboxReportStatus() if st == nil || st.State != OffsiteStateAwaitingRecoveryKey { t.Fatalf("want declared state %q, got %+v", OffsiteStateAwaitingRecoveryKey, st) } if st.Enabled { t.Error("the declared holding object must carry Enabled=false so existing hub readers stay inert") } } // ── SCENARIO B — a box the hub holds nothing for still mints, exactly as today ─────────────────── // // RED-PROOF: widen the guard to `if true` (or drop the GetHubEscrowIdentityPresent() conjunct in // sealedPackageHeld). A first-time box then cannot start, and this test fails — the failure mode an // over-broad fix produces, which is why the guard is written as a conjunction. func TestR241_ScenarioB_FirstTimeBoxStillMints(t *testing.T) { m, _, pwPath := mintGuardManager(t, false) // the hub holds nothing for us if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil { t.Fatalf("a first-time box must mint exactly as before, got %v", err) } pw, rerr := os.ReadFile(pwPath) if rerr != nil { t.Fatalf("a first-time box must get a repository password: %v", rerr) } if !offboxRepoPwPattern.Match(pw) { t.Errorf("minted password is not the expected 64-hex shape") } if m.OffboxAwaitingRecoveryKey() { t.Error("a box with no sealed package is not awaiting anything") } } // The guard must not fire once a key EXISTS — a healthy box re-applying its target (a quota bump, // a hub re-push) must be untouched, package or no package. This is the idempotency half. func TestR241_ExistingKeyIsNeverDisturbed(t *testing.T) { m, _, pwPath := mintGuardManager(t, false) if err := m.WriteOffboxSecrets("K", "kh"); err != nil { t.Fatal(err) } before, err := os.ReadFile(pwPath) if err != nil { t.Fatal(err) } // Now the hub starts holding a package (the ceremony ran) and the target is re-applied. if err := m.settings.SetHubEscrowIdentityPresent(true); err != nil { t.Fatal(err) } if err := m.WriteOffboxSecrets("K2", "kh2"); err != nil { t.Fatalf("a re-apply on a box that already has a key must not be refused: %v", err) } after, err := os.ReadFile(pwPath) if err != nil { t.Fatal(err) } if string(before) != string(after) { t.Error("the existing repository password must never be rotated by an apply") } if m.OffboxAwaitingRecoveryKey() { t.Error("a box holding its key is not awaiting one") } } // Fail-safe: an unreadable settings store must not block a tier. A transient read failure turning // into a permanently-held tier is a worse defect than the one being fixed. func TestR241_NilSettingsDoesNotBlockTheMint(t *testing.T) { logger := log.New(os.Stderr, "", 0) dataDir := t.TempDir() cfg := &config.Config{} cfg.Paths.DataDir = dataDir m := NewManager(cfg, nil, logger) if m.sealedPackageHeld() { t.Fatal("a nil settings store must read as 'no package held' — fail toward letting the box work") } } // Scenario E's carve-out, pinned for the HOLDING state too. A customer who switched off-site off is // not awaiting a recovery key, and must not declare one. The first draft of // OffboxAwaitingRecoveryKey omitted `t.Enabled` and TestOffsiteDeclare_DisabledTargetIsNotStranded // caught it; this test pins the same invariant from the new predicate's own side, so a future edit // to THIS function fails here rather than in a neighbouring file. func TestR241_DisabledTargetIsNotAwaitingAnything(t *testing.T) { m, sett, _ := mintGuardManager(t, true) // the hub holds a package, and there is no key if err := sett.SetOffboxTarget(&settings.OffboxTarget{ Enabled: false, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", }); err != nil { t.Fatal(err) } if m.OffboxAwaitingRecoveryKey() { t.Fatal("a deliberately DISABLED target must never declare the holding state (Scenario E)") } if st := m.OffboxReportStatus(); st != nil { t.Fatalf("a disabled target must stay silent in the report, got %+v", st) } }