package stacks import ( "os" "path/filepath" "testing" ) // R-1057 (v0.314.0) — a restore puts the setup gate back as it was when the backup was taken. Through the PRODUCTION // restore write (PersistUnitRedeployConfigGate) and the gate's own loop (SetupGateTick). var gateEnv = map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"} func gappDir(m *Manager) string { return filepath.Join(m.cfg.Paths.StacksDir, "gapp") } func gateFileExists(m *Manager) bool { _, err := os.Stat(m.setupGatePath("gapp")) return err == nil } // markRunning sets the restored app running in memory, as the restore's start would (the loop probes only a running app). func markRunning(m *Manager) { m.mu.Lock() m.stacks["gapp"].State = StateRunning m.mu.Unlock() } // Closed at the backup → closed after a REMOVE + restore: the record, and the gate file before anything starts. // COMPANION RED-PROOF: return early from applyGateAtBackup (mutation „no gate on restore") → this fails with no record. func TestR1057_ClosedAtTheBackupComesBackClosed(t *testing.T) { m := gateManager(t, gatedYml) if LoadAppConfig(gappDir(m)) != nil { t.Fatal("precondition: the removed app has no app.yaml") } must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{Known: true, Closed: true})) got := LoadAppConfig(gappDir(m)) if got == nil || !got.SetupGate.Closed() { t.Fatalf("closed at the backup must come back closed, got %+v", got) } if !gateFileExists(m) { t.Fatal("the gate's traefik file is not there — a stranger reaches the app") } } // Open at the backup → no closed gate (the data came back with its admin; the rule's intent, unchanged). // COMPANION RED-PROOF: treat every known record as closed in applyGateAtBackup → this fails. func TestR1057_OpenAtTheBackupComesBackOpen(t *testing.T) { m := gateManager(t, gatedYml) must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{Known: true, Closed: false, OpenedBy: SetupGateByHousehold})) got := LoadAppConfig(gappDir(m)) if got.SetupGate.Closed() || gateFileExists(m) { t.Fatalf("open at the backup must come back open, got %+v (file %v)", got.SetupGate, gateFileExists(m)) } } // No record (a backup made before v0.314.0) + a probe that says „not done" → closed, and the loop keeps it closed. // COMPANION RED-PROOF: drop the `!gate.Known && removed` case → the app comes back open and this fails. func TestR1057_NoRecordProbeNotDoneStaysClosed(t *testing.T) { m := gateManager(t, gatedYml) t.Cleanup(SetSetupGateProbeGetForTest(func(string) ([]byte, error) { return []byte(`{"data":{"initialized":false}}`), nil })) must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{})) markRunning(m) m.SetupGateTick() if got := LoadAppConfig(gappDir(m)); !got.SetupGate.Closed() || !gateFileExists(m) { t.Fatalf("no record + not set up must stay closed, got %+v", got.SetupGate) } } // No record + a probe that says „done" → closed at the restore, opened by the loop (the data had its admin). // COMPANION RED-PROOF: skip the probe in SetupGateTick → the gate never opens and this fails. func TestR1057_NoRecordProbeDoneOpensByTheLoop(t *testing.T) { m := gateManager(t, gatedYml) t.Cleanup(SetSetupGateProbeGetForTest(func(string) ([]byte, error) { return []byte(`{"data":{"initialized":true}}`), nil })) must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{})) if got := LoadAppConfig(gappDir(m)); !got.SetupGate.Closed() { t.Fatalf("never open by default: closed until the probe has spoken, got %+v", got.SetupGate) } markRunning(m) m.SetupGateTick() got := LoadAppConfig(gappDir(m)) if got.SetupGate.Closed() || got.SetupGate.OpenedBy != SetupGateByProbe || gateFileExists(m) { t.Fatalf("the probe says done — the loop must open it, got %+v", got.SetupGate) } } // No record + no probe (Plex's shape: setup_gate, no probe) → closed until the household's „Kész". // COMPANION RED-PROOF: as TestR1057_NoRecordProbeNotDoneStaysClosed. func TestR1057_NoRecordNoProbeStaysClosed(t *testing.T) { m := gateManager(t, plexShapeYml) must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{})) markRunning(m) m.SetupGateTick() if got := LoadAppConfig(gappDir(m)); !got.SetupGate.Closed() || !gateFileExists(m) { t.Fatalf("no probe: only the household opens it, got %+v", got.SetupGate) } } // Plex's own gate lines (app-catalog-felhom.eu templates/plex/.felhom.yml, 2026-10-15): gated, no probe — the // controller asks Plex whether it is claimed when the household presses „Kész". const plexShapeYml = "display_name: Plex\nsetup_gate: true\n" + "deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: plex\n" // The gate file exists BEFORE the restored app's first start (decision 46 spike F2), through the real write + the real // compose call: the docker stand-in on PATH (R-650's seam) records, for every `compose up`, whether the file stood. // COMPANION RED-PROOF: drop prepareSetupGate from applyGateAtBackup's closed case → „no" is recorded and this fails. func TestR1057_TheGateFileStandsBeforeTheRestoredAppStarts(t *testing.T) { m := gateManager(t, gatedYml) mark := filepath.Join(t.TempDir(), "up.log") stub := "#!/bin/sh\ncase \"$*\" in *\" up \"*|*\" up\") if [ -f '" + m.setupGatePath("gapp") + "' ]; then echo yes >> '" + mark + "'; else echo no >> '" + mark + "'; fi;; esac\nexit 0\n" bin := filepath.SplitList(os.Getenv("PATH"))[0] must(t, os.WriteFile(filepath.Join(bin, "docker"), []byte(stub), 0o755)) must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{Known: true, Closed: true})) _ = m.upFromAppConfig("gapp") b, _ := os.ReadFile(mark) if string(b) != "yes\n" { t.Fatalf("compose up saw the gate file: %q — the restored app started before its gate stood", b) } } // An app NOT removed whose backup was closed (restored over a set-up install) is gated again: the data it gets back // has no admin. COMPANION RED-PROOF: restrict the closed case to `removed` → this fails. func TestR1057_InPlaceRestoreOfAClosedBackupRegates(t *testing.T) { m := gateManager(t, gatedYml) must(t, SaveAppConfig(gappDir(m), &AppConfig{Deployed: true, Env: gateEnv, SetupGate: &SetupGateRecord{State: SetupGateOpen, OpenedBy: SetupGateByHousehold}}, m.encKey, nil)) must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{Known: true, Closed: true})) if got := LoadAppConfig(gappDir(m)); !got.SetupGate.Closed() || !gateFileExists(m) { t.Fatalf("a pre-setup backup restored over a set-up app must be gated, got %+v", got.SetupGate) } } // An app re-gated since (reinstalled, gate closed) restored from a backup taken after its setup → opened. // COMPANION RED-PROOF: drop the reopen case → it stays closed and this fails. func TestR1057_InPlaceRestoreOfAnOpenBackupOpens(t *testing.T) { m := gateManager(t, gatedYml) closedGate(t, m) must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{Known: true, Closed: false})) if got := LoadAppConfig(gappDir(m)); got.SetupGate.Closed() || gateFileExists(m) { t.Fatalf("the restored data has its admin — the gate must open, got %+v", got.SetupGate) } } // An app NOT removed and a backup with no record: the app keeps its own record (unchanged behaviour). func TestR1057_InPlaceNoRecordKeepsTheAppsRecord(t *testing.T) { m := gateManager(t, gatedYml) must(t, SaveAppConfig(gappDir(m), &AppConfig{Deployed: true, Env: gateEnv, SetupGate: &SetupGateRecord{State: SetupGateOpen, OpenedBy: SetupGateByHousehold}}, m.encKey, nil)) must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{})) if got := LoadAppConfig(gappDir(m)); got.SetupGate.Closed() || got.SetupGate.OpenedBy != SetupGateByHousehold { t.Fatalf("an installed app keeps its record when the backup has none, got %+v", got.SetupGate) } } // The capture side: what a backup records, and that ReadGateAtBackup reads it back from a unit's app.yaml. // COMPANION RED-PROOF: make GateCapture return nil for a nil record → the never-gated case fails. func TestR1057_CaptureAndReadBack(t *testing.T) { gated := &Metadata{SetupGate: true} if GateCapture(&Metadata{}, nil) != nil { t.Fatal("a template with no gate records nothing (its unit bytes stay unchanged)") } if g := GateCapture(gated, nil); g == nil || g.State != SetupGateOpen { t.Fatalf("installed before gates existed = never gated = open, got %+v", g) } if g := GateCapture(gated, &SetupGateRecord{State: SetupGateClosed}); g == nil || g.State != SetupGateClosed { t.Fatalf("closed record → closed, got %+v", g) } dir := t.TempDir() p := filepath.Join(dir, "app.yaml") for body, want := range map[string]GateAtBackup{ "deployed: true\nenv: {}\nsetup_gate:\n state: closed\n": {Known: true, Closed: true}, "deployed: true\nenv: {}\nsetup_gate:\n state: open\n opened_by: probe\n": {Known: true, Closed: false, OpenedBy: "probe"}, "deployed: true\nenv: {}\n": {}, "deployed: true\nenv: {}\nsetup_gate:\n state: weird\n": {Known: true, Closed: true}, // fail closed } { must(t, os.WriteFile(p, []byte(body), 0o600)) if got := ReadGateAtBackup(p); got != want { t.Errorf("%q → %+v, want %+v", body, got, want) } } if got := ReadGateAtBackup(filepath.Join(dir, "absent.yaml")); got.Known { t.Fatal("an absent file is not a record") } }