17863fe8bc
gates / gates (push) Successful in 47s
MinAgent 0.155.0 (unchanged). Tests red-proved by mutation (A: 10, B: 7, C: 1, C2: 2). Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
185 lines
9.3 KiB
Go
185 lines
9.3 KiB
Go
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")
|
|
}
|
|
}
|