Files
felhom-controller/controller/internal/backup/offbox_mintguard_r241_test.go
T
admin 763de3a025 R-241 part 1: the box does not mint a repository key over a sealed package
THE DEFECT. WriteOffboxSecrets auto-generated on ONE input - does the file
exist. Its two neighbours in the same file, OffsiteRecoveryOffer and
needsOffsiteCredential, both consult GetHubEscrowIdentityPresent(). The same
fact was available on three paths and used on two.

Measured on the final walk: a rebuilt box's credential self-heal reached here
at 03:18:06Z and minted 9b4a9a9d over a hub package sealing 30ef574f. The
recovery screen then correctly reported nothing recoverable under the key the
box held. The screen was honest; the minting was not. And the flag was not
merely available at that moment - it was the PRECONDITION of the chain that
reached this function, logged at 02:48:03Z, six ticks earlier.

THE GUARD IS A CONJUNCTION, deliberately: a package held AND no key present.
A box the hub holds nothing for mints exactly as before.

The refusal is a HOLDING state, not a failure. ApplyOffsiteTarget catches the
sentinel and still writes the transport (ssh key, known_hosts, coordinates),
so the recovery screen can bring the tier up the instant the escrowed key is
placed (R-219). Returning the error instead would leave needsOffsiteCredential
true forever and the hub re-staging a consumed credential on every cycle.

New declared state offsite.state=awaiting_recovery_key, shown INERT to every
existing hub reader from their code rather than assumed: offsiteheal acts on
exactly one string; isStale needs Enabled && escrowed and this carries
Enabled=false; the delivery checker skips the applied shape; an unknown state
string is ignored by encoding/json. So NO hub change is needed for this part.

OffboxAwaitingRecoveryKey is DERIVED, not stored - the operator's ruling that
the state should be fixed rather than remembered, applied to this field too.

t.Enabled is load-bearing in that predicate and was MISSING in the first
draft. The existing TestOffsiteDeclare_DisabledTargetIsNotStranded caught it,
not review: a customer who switched off-site off is not awaiting anything.
Now pinned from the new predicate's own side as well.

Tests: Scenario A (no key written; transport still written; apply holds and
stages nothing), Scenario B (first-time box still mints), idempotency, the
nil-settings fail-safe, and the Scenario E carve-out.

RED-PROOFS, each with the mutation confirmed present in the file first:
  A) guard block deleted   -> both Scenario A tests FAIL with
     "R-241 REGRESSION: apply minted a repository password over the sealed
     package"; Scenario B still passes (the mutation is specific)
  B) guard over-widened (hub-package conjunct dropped) -> Scenario B FAILS
     with a first-time box unable to start; Scenario A still passes

Green: go build, go vet, go test ./... all pass; controller_gates all OK.
2026-08-07 11:25:58 +02:00

193 lines
8.4 KiB
Go

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