package stacks import ( "os" "path/filepath" "strings" "sync" "testing" "time" ) // v0.282.0 — the app's own sign-up switch (after_setup), "close sign-up now" (decision 49), richer probes (R-715). const nativeCompose = "services:\n" + " gapp:\n image: busybox\n environment:\n - DISABLE_REGISTRATION=${SIGNUP_CLOSED:-false}\n labels:\n" + " - \"traefik.enable=true\"\n" + " - \"traefik.http.routers.gapp.rule=Host(`${SUBDOMAIN}.${DOMAIN}`)\"\n" + " - \"traefik.http.services.gapp.loadbalancer.server.port=80\"\n" const nativeYml = "display_name: Gated App\nsetup_gate: true\nsignup_block: \"PathPrefix(`/signup`)\"\n" + "after_setup:\n env:\n SIGNUP_CLOSED: \"true\"\n" + "deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n" type upRecorder struct { mu sync.Mutex n int env []string m *Manager } func (u *upRecorder) up(name string) error { u.mu.Lock() defer u.mu.Unlock() u.n++ c := LoadAppConfig(filepath.Join(u.m.cfg.Paths.StacksDir, name)) u.env = append(u.env, c.Env["SIGNUP_CLOSED"]) return nil } func nativeManager(t *testing.T, compose string) (*Manager, *upRecorder) { t.Helper() m := gateManager(t, nativeYml) must(t, os.WriteFile(filepath.Join(m.cfg.Paths.StacksDir, "gapp", "docker-compose.yml"), []byte(compose), 0o644)) must(t, m.ScanStacks()) m.afterSetupSync = true u := &upRecorder{m: m} m.afterSetupUpFn = u.up return m, u } // When the gate opens the box sets the app's own switch (then starts it once), after the block is up. // COMPANION RED-PROOF: drop the goNativeLock call in OpenSetupGate → "the app's own switch was not set" fails. func TestAfterSetup_TheGateOpeningSetsTheAppsOwnSwitch(t *testing.T) { m, u := nativeManager(t, nativeCompose) dir := closedGate(t, m) must(t, m.OpenSetupGate("gapp", SetupGateByHousehold)) c := LoadAppConfig(dir) if c.Env["SIGNUP_CLOSED"] != "true" || u.n != 1 { t.Fatalf("the app's own switch was not set: env=%q starts=%d", c.Env["SIGNUP_CLOSED"], u.n) } if c.AfterSetup == nil || !c.AfterSetup.OK || c.SetupGate.NativeLock != NativeLockApplied { t.Fatalf("record %+v / native %q", c.AfterSetup, c.SetupGate.NativeLock) } if _, err := os.Stat(m.signupBlockPath("gapp")); err != nil { t.Fatal("the address block is not up — two locks, not one") } m.SetupGateTick() // an applied lock is not re-applied (no restart per tick) if u.n != 1 { t.Fatalf("the loop restarted the app again (%d starts)", u.n) } } // The household's window lifts the app's own switch too, and the loop closes it again after. // COMPANION RED-PROOF: drop the goNativeLock(true, …) in reconcileSignupBlocks → "the switch stayed open" fails. func TestAfterSetup_TheWindowLiftsItAndTheLoopClosesItAgain(t *testing.T) { m, u := nativeManager(t, nativeCompose) dir := closedGate(t, m) must(t, m.OpenSetupGate("gapp", SetupGateByHousehold)) _, err := m.OpenSignupWindow("gapp") must(t, err) if c := LoadAppConfig(dir); c.Env["SIGNUP_CLOSED"] != "" || c.SetupGate.NativeLock != NativeLockLifted || u.n != 2 { t.Fatalf("window: env=%q native=%q starts=%d", c.Env["SIGNUP_CLOSED"], c.SetupGate.NativeLock, u.n) } later := time.Now().Add(signupWindow + time.Minute) m.updateNowFn = func() time.Time { return later } m.SetupGateTick() if c := LoadAppConfig(dir); c.Env["SIGNUP_CLOSED"] != "true" || c.SetupGate.NativeLock != NativeLockApplied || u.n != 3 { t.Fatalf("the switch stayed open after the window: env=%q native=%q starts=%d", c.Env["SIGNUP_CLOSED"], c.SetupGate.NativeLock, u.n) } } // An installed version whose compose does not read the variable is reported, never recorded as locked. // COMPANION RED-PROOF: drop the composeMissingVars check → the record says ok and this fails. func TestAfterSetup_AnOldComposeIsReportedNotFaked(t *testing.T) { m, u := nativeManager(t, strings.Replace(nativeCompose, " - DISABLE_REGISTRATION=${SIGNUP_CLOSED:-false}\n", " - TZ=x\n", 1)) dir := closedGate(t, m) must(t, m.OpenSetupGate("gapp", SetupGateByHousehold)) c := LoadAppConfig(dir) if c.AfterSetup == nil || c.AfterSetup.OK || !strings.Contains(c.AfterSetup.Detail, "SIGNUP_CLOSED") || u.n != 0 || c.SetupGate.NativeLock == NativeLockApplied { t.Fatalf("an unread switch was recorded as set: %+v native=%q starts=%d", c.AfterSetup, c.SetupGate.NativeLock, u.n) } if _, err := os.Stat(m.signupBlockPath("gapp")); err != nil { t.Fatal("the block must hold anyway") } } // Decision 49: offered only for an installed app with a template lock and no lock record; the press writes the // block and sets the switch, never a gate; offered once. // COMPANION RED-PROOF: return true from CloseSignupOffered when SetupGate != nil → the second press succeeds. func TestCloseSignup_OnceOnAnAppInstalledBeforeTheRule(t *testing.T) { m, u := nativeManager(t, nativeCompose) dir := filepath.Join(m.cfg.Paths.StacksDir, "gapp") cfg := &AppConfig{Deployed: true, Env: map[string]string{"DOMAIN": "example.hu", "SUBDOMAIN": "gapp"}} must(t, SaveAppConfig(dir, cfg, m.encKey, nil)) m.mu.Lock() m.stacks["gapp"].Deployed, m.stacks["gapp"].State, m.stacks["gapp"].AppConfig = true, StateRunning, cfg m.mu.Unlock() if !m.CloseSignupOffered("gapp") { t.Fatal("not offered on an installed app without a lock") } must(t, m.CloseSignupNow("gapp")) c := LoadAppConfig(dir) if c.SetupGate == nil || c.SetupGate.State != SetupGateOpen || c.SetupGate.OpenedBy != SetupGateByCloseSignup || strings.Join(c.SetupGate.Hosts, ",") != "gapp.example.hu" { t.Fatalf("lock record %+v", c.SetupGate) } if _, err := os.Stat(m.setupGatePath("gapp")); !os.IsNotExist(err) { t.Fatal("close sign-up GATED the app") } if _, err := os.Stat(m.signupBlockPath("gapp")); err != nil { t.Fatal("no address block") } if c.Env["SIGNUP_CLOSED"] != "true" || u.n != 1 { t.Fatalf("the app's own switch: env=%q starts=%d", c.Env["SIGNUP_CLOSED"], u.n) } m.mu.Lock() m.stacks["gapp"].AppConfig = c m.mu.Unlock() if m.CloseSignupOffered("gapp") { t.Fatal("offered twice") } if err := m.CloseSignupNow("gapp"); err != ErrCloseSignupNotOffered { t.Fatalf("second press: %v", err) } } // R-715: list indexes and a "done" HTTP status. // COMPANION RED-PROOF: drop the []interface{} case in probeSaysDone → the ghost/home-assistant rows fail. func TestProbe_ListIndexesAndADoneStatus(t *testing.T) { for _, c := range []struct { body, field, done string want bool }{ {`{"setup":[{"status":true}]}`, "setup.0.status", "true", true}, {`{"setup":[{"status":false}]}`, "setup.0.status", "true", false}, {`[{"step":"user","done":true},{"step":"core_config","done":false}]`, "0.done", "true", true}, {`[{"step":"user","done":false}]`, "0.done", "true", false}, {`[]`, "0.done", "true", false}, {`{"setup":[]}`, "setup.3.status", "true", false}, } { if got, _ := probeSaysDone([]byte(c.body), c.field, c.done); got != c.want { t.Errorf("%s @ %s: %v, want %v", c.body, c.field, got, c.want) } } old := setupGateProbeFetch t.Cleanup(func() { setupGateProbeFetch = old }) status := 200 setupGateProbeFetch = func(string) (int, []byte, error) { return status, []byte(`{"access_token":"x"}`), nil } p := &SetupDoneProbe{URL: "u", Field: "error.code", Done: "405", DoneStatus: 405} if done, _, _ := probeOnce(p); done { t.Fatal("gramps-web before its setup (200 + a token) read as done") } status = 405 if done, _, err := probeOnce(p); !done || err != nil { t.Fatalf("gramps-web after its setup (405) not read as done: %v", err) } status = 500 if done, _, err := probeOnce(p); done || err == nil { t.Fatal("another status read as done (must fail closed)") } }