Files
felhom-controller/controller/internal/backup/offbox_declare_test.go
T
admin a3499d1807
gates / gates (push) Successful in 9s
v0.201.0 — a correct recovery code is never called wrong again (CAMPAIGN-11) — MinAgent 0.125.0
R-216: the offsite key recovery is a coupled feature and now says so. featureProbes +
featureMinAgent 0.125.0 + a Supports gate at the unlock entry point, FAILING CLOSED — an
agent that cannot answer is named as such instead of the customer's code being blamed.
Measured live: a 404 from agent 0.120.0 came back as "we did not accept your recovery
code, check that all ten words", in 0.134 s, against a perfect code.

R-218: delete the repo-password short-circuit in needsOffsiteCredential. The declaration
stops when the TIER WORKS, not when a key exists — installing a key is the recovery
screen's whole job, so succeeding at recovery was switching off the mechanism that would
have delivered the coordinates to use it.

R-219: the unlock finishes the job — place the key, bring the tier up, then list. Without
it the promised listing could never render on the shape the screen exists for.

R-217: an unreadable store no longer claims to have opened with unattributable content
(the OffsiteInventory{} zero value). Opened / empty / unreadable are three states.

R-222: a code that is right about a RETAINED earlier package is named, not blamed. States
what the hub knows and promises nothing — no read path exists.

R-215: GET /recovery is gated on the same predicate as the interception.

Five red-proofs, each demonstrated failing and restored.
2026-08-05 17:48:08 +02:00

194 lines
7.9 KiB
Go

package backup
import (
"encoding/json"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-204 item 4 / R-193 — a REBUILT box declares that it needs an off-site credential, instead of
// reporting an absence the hub cannot interpret.
//
// THE POINT OF THESE TESTS is the conjunction. An absent off-site object has FOUR meanings (never
// configured / mid-restart / a transient read failure / rebuilt-and-stranded). The declaration has
// one, and it is only sound because BOTH halves are required: a fresh data area AND a hub-held
// recovery package. Scenario B is the one that matters most — drop the escrow half and every
// un-configured box in the fleet starts asking for a credential.
// bareManager builds a Manager with NO off-site target and NO repository password — the shape of a
// freshly rebuilt box before anything is configured.
func bareManager(t *testing.T) (*Manager, *settings.Settings) {
t.Helper()
lg := log.New(io.Discard, "", 0)
dataDir := t.TempDir()
sett, err := settings.Load(filepath.Join(dataDir, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = dataDir
cfg.Paths.SystemDataPath = filepath.Join(dataDir, "sys")
return NewManager(cfg, sett, lg), sett
}
// SCENARIO A — a rebuilt box (fresh data area + a hub-held escrow) DECLARES the state.
//
// RED-PROOF: remove the `GetHubEscrowIdentityPresent()` condition from needsOffsiteCredential —
// Scenario A still passes (it has an escrow), and SCENARIO B FAILS, which is the point: the plausible
// wrong fix is to declare on freshness alone, and that would make every un-configured box in the
// fleet ask for a credential.
func TestOffsiteDeclare_RebuiltBoxDeclaresNeedsCredential(t *testing.T) {
m, sett := bareManager(t)
if err := sett.SetHubEscrowIdentityPresent(true); err != nil {
t.Fatal(err)
}
st := m.OffboxReportStatus()
if st == nil {
t.Fatal("a rebuilt box reported NO off-site object — the hub cannot distinguish it from a box that never had off-site backups (this is the defect)")
}
if st.State != OffsiteStateNeedsCredential {
t.Fatalf("declared state = %q, want %q", st.State, OffsiteStateNeedsCredential)
}
// Enabled MUST be false and the sizes zero — that is what makes the declaration inert to the
// hub's existing fill and staleness checkers (and to a pre-upgrade hub).
if st.Enabled {
t.Error("a declaration must not claim the tier is enabled — the hub's staleness check keys on it")
}
if st.QuotaGB != 0 || st.RepoSizeBytes != 0 || st.SnapshotCount != 0 {
t.Errorf("a declaration must carry zero sizes (fill band keys on them): %+v", st)
}
// And it must be on the off-site object, not a new top-level field.
b, err := json.Marshal(st)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"state":"needs_credential"`) {
t.Fatalf("declared state absent from the marshalled off-site object: %s", b)
}
if !strings.Contains(string(b), `"enabled":false`) {
t.Fatalf("marshalled object must carry enabled:false: %s", b)
}
}
// SCENARIO B — a box that never had off-site backups says NOTHING. This is the guard on the
// conjunction; without it the feature churns credentials fleet-wide.
func TestOffsiteDeclare_NeverHadOffsiteSaysNothing(t *testing.T) {
m, _ := bareManager(t) // fresh data area, but NO hub-held escrow
if st := m.OffboxReportStatus(); st != nil {
t.Fatalf("a box that never had off-site backups DECLARED a need: %+v — every un-configured box in the fleet would now ask for a credential", st)
}
}
// SCENARIO D (R-218) — THE DECLARATION STOPS WHEN THE TIER WORKS, NOT WHEN A KEY EXISTS.
//
// ⚠ THIS TEST ASSERTED THE OPPOSITE until v0.201.0, and it was green the whole time. It required a
// box holding a repository password to stay SILENT — which reads as a sound freshness test and is the
// exact opposite on the one path that matters, because installing a repository password is the
// RECOVERY SCREEN'S WHOLE JOB. Measured live 2026-08-05 (CAMPAIGN-11 Phase 1): 32 seconds after the
// hub re-staged the credential, the customer's successful unlock switched off the mechanism that
// would have delivered the coordinates for the key they had just recovered. Deadlock, both halves.
//
// RED-PROOF: restore the `if _, ok := m.OffboxRepoPasswordHash(); ok { return false }` short-circuit
// in needsOffsiteCredential and this test FAILS — the box goes silent again with no target, which is
// the deadlock. Demonstrated failing before this test was kept.
func TestOffsiteDeclare_StillDeclaresAfterARecoveredKeyIsPlaced(t *testing.T) {
m, sett := bareManager(t)
if err := sett.SetHubEscrowIdentityPresent(true); err != nil {
t.Fatal(err)
}
// The post-unlock shape: the recovered repository password is on disk, and there is STILL no
// off-site target — so the box cannot use what it just recovered.
if err := os.MkdirAll(m.offboxDir(), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(m.offboxPwPath(), []byte("a-recovered-repository-password"), 0o600); err != nil {
t.Fatal(err)
}
st := m.OffboxReportStatus()
if st == nil {
t.Fatal("R-218: the box went SILENT after recovering its key while still having no off-site target — the hub's staged credential is never collected and nothing ever asks again")
}
if st.State != OffsiteStateNeedsCredential {
t.Fatalf("declared state = %q, want %q", st.State, OffsiteStateNeedsCredential)
}
}
// SCENARIO E — and once the tier ACTUALLY WORKS the box goes quiet. This is the condition that
// replaces the deleted one, and the pair above/below is what makes the deletion safe.
func TestOffsiteDeclare_ConfiguredTierIsSilent(t *testing.T) {
m, sett := bareManager(t)
if err := sett.SetHubEscrowIdentityPresent(true); err != nil {
t.Fatal(err)
}
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
Schedule: "daily", EscrowState: "escrowed",
}); err != nil {
t.Fatal(err)
}
st := m.OffboxReportStatus()
if st == nil {
t.Fatal("a configured tier must still report its ordinary off-site object")
}
if st.State == OffsiteStateNeedsCredential {
t.Fatal("a box whose tier is configured must not keep asking for a credential")
}
}
// A DISABLED target is the customer's own choice, not a rebuild — it must not declare either.
func TestOffsiteDeclare_DisabledTargetIsNotStranded(t *testing.T) {
m, sett := bareManager(t)
if err := sett.SetHubEscrowIdentityPresent(true); err != nil {
t.Fatal(err)
}
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: false, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
}); err != nil {
t.Fatal(err)
}
if st := m.OffboxReportStatus(); st != nil {
t.Fatalf("a deliberately DISABLED target declared a need: %+v", st)
}
}
// A CONFIGURED box's report object must be byte-identical to v0.198.0's — no `state` key at all.
// This is what lets a pre-upgrade hub and every existing checker read the fleet unchanged.
func TestOffsiteDeclare_ConfiguredBoxJSONIsUnchanged(t *testing.T) {
m, sett := bareManager(t)
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
Schedule: "daily", EscrowState: "escrowed", LastStatus: "ok",
}); err != nil {
t.Fatal(err)
}
st := m.OffboxReportStatus()
if st == nil {
t.Fatal("a configured box must still report an off-site object")
}
if st.State != "" {
t.Errorf("a configured box must declare NO state, got %q", st.State)
}
b, err := json.Marshal(st)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), `"state"`) {
t.Fatalf("a healthy report's JSON gained a `state` key — it must stay byte-compatible: %s", b)
}
if !strings.Contains(string(b), `"enabled":true`) {
t.Fatalf("a configured box must report enabled:true: %s", b)
}
}