package stacks import ( "os" "path/filepath" "strings" "testing" "time" ) // v0.281.0 (`09` §3 decision 47) — sign-up closed once the first admin exists, and the household's 15 minutes. const signupYml = "display_name: Gated App\nsetup_gate: true\nsignup_block: \"PathPrefix(`/signup`)\"\n" + "deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n" // Opening the gate puts the sign-up block up FIRST; then the gate comes down. // COMPANION RED-PROOF: drop the writeSignupBlock call in OpenSetupGate → "no sign-up block after the gate opened" // fails (between the open and the next tick a stranger could sign up). func TestSignupBlock_TheGateOpensOnlyWithTheBlockUp(t *testing.T) { m := gateManager(t, signupYml) closedGate(t, m) must(t, m.OpenSetupGate("gapp", SetupGateByHousehold)) b, err := os.ReadFile(m.signupBlockPath("gapp")) if err != nil { t.Fatal("no sign-up block after the gate opened") } for _, want := range []string{"Host(`gapp.example.hu`)", "PathPrefix(`/signup`)", `path: "/__felhom_gate/signup-closed"`, "http://felhom-controller:8080"} { if !strings.Contains(string(b), want) { t.Errorf("block lacks %q:\n%s", want, b) } } if blocked, _ := m.SignupBlocked("gapp"); !blocked { t.Fatal("SignupBlocked says open") } // Still gated → no block (the household may need the sign-up address for the first admin). m2 := gateManager(t, signupYml) closedGate(t, m2) m2.SetupGateTick() if _, err := os.Stat(m2.signupBlockPath("gapp")); !os.IsNotExist(err) { t.Fatal("a block stands while the gate is still closed — the household could not sign up as the first admin") } } // A block that cannot be written keeps the gate CLOSED. // COMPANION RED-PROOF: ignore writeSignupBlock's error in OpenSetupGate → the gate opens with sign-up wide open. func TestSignupBlock_UnwritableBlockKeepsTheGateClosed(t *testing.T) { m := gateManager(t, signupYml) dir := closedGate(t, m) must(t, os.MkdirAll(m.signupBlockPath("gapp"), 0o755)) // a DIRECTORY where the file must go: rename fails must(t, os.WriteFile(filepath.Join(m.signupBlockPath("gapp"), "x"), []byte("x"), 0o644)) if err := m.OpenSetupGate("gapp", SetupGateByHousehold); err == nil { t.Fatal("the gate opened although the sign-up block could not be written") } if c := LoadAppConfig(dir); !c.SetupGate.Closed() { t.Fatal("record says open") } if _, err := os.Stat(m.setupGatePath("gapp")); err != nil { t.Fatal("the gate file was removed") } } // The household's window lifts the block for 15 minutes; the loop puts it back after. // COMPANION RED-PROOF: make signupWindowOpen always true → "the block did not come back" fails. func TestSignupBlock_TheWindowOpensAndCloses(t *testing.T) { m := gateManager(t, signupYml) closedGate(t, m) must(t, m.OpenSetupGate("gapp", SetupGateByProbe)) until, err := m.OpenSignupWindow("gapp") must(t, err) if until == "" { t.Fatal("no end time") } if _, err := os.Stat(m.signupBlockPath("gapp")); !os.IsNotExist(err) { t.Fatal("the window opened but the block is still up") } m.SetupGateTick() if _, err := os.Stat(m.signupBlockPath("gapp")); !os.IsNotExist(err) { t.Fatal("a tick inside the window put the block back") } if blocked, u := m.SignupBlocked("gapp"); blocked || u != until { t.Fatalf("inside the window: blocked=%v until=%q", blocked, u) } later := time.Now().Add(signupWindow + time.Minute) m.updateNowFn = func() time.Time { return later } m.SetupGateTick() if _, err := os.Stat(m.signupBlockPath("gapp")); err != nil { t.Fatal("the window passed but the block did not come back") } } // An app with no gate record (installed before, or on a box that never gated it) never gets a block, even when its // template has signup_block — Part 0's rule. A removed app's block goes. // COMPANION RED-PROOF: drop the `g == nil ||` / State check in reconcileSignupBlocks → a block appears on an // app nobody gated. func TestSignupBlock_NeverOnAnAppThisBoxDidNotGate(t *testing.T) { m := gateManager(t, signupYml) 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() must(t, os.MkdirAll(m.setupGateDir(), 0o755)) must(t, os.WriteFile(filepath.Join(m.setupGateDir(), "signup-block-gone.yml"), []byte("x"), 0o644)) m.SetupGateTick() if _, err := os.Stat(m.signupBlockPath("gapp")); !os.IsNotExist(err) { t.Fatal("a sign-up block appeared on an app this box never gated") } if _, err := os.Stat(m.setupGatePath("gapp")); !os.IsNotExist(err) { t.Fatal("a catalog setup_gate closed an app that was already installed") } if _, err := os.Stat(filepath.Join(m.setupGateDir(), "signup-block-gone.yml")); !os.IsNotExist(err) { t.Fatal("a block nobody owns was kept") } if _, err := m.OpenSignupWindow("gapp"); err != ErrNoSignupBlock { t.Fatalf("window on an ungated app: %v", err) } }