149467c795
gates / gates (push) Successful in 23s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
119 lines
5.0 KiB
Go
119 lines
5.0 KiB
Go
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)
|
|
}
|
|
}
|