v0.281.0: 'Done' asks the probe first; sign-up closed after the first admin (decision 47); R-713 code-bound values refused, ${NAME|base64}
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
This commit is contained in:
2026-09-29 09:57:58 +02:00
parent 3a12c7341a
commit 149467c795
28 changed files with 1928 additions and 15 deletions
+44 -3
View File
@@ -1,6 +1,7 @@
package stacks
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
@@ -60,23 +61,63 @@ func expandAfterInstall(cmd []string, allowed []string, env map[string]string) (
for _, n := range allowed {
ok[n] = true
}
var missing []string
var missing, unsafe []string
out := make([]string, len(cmd))
for i, a := range cmd {
// R-713 (v0.281.0): where does the value land? Its OWN argument ("${X}") or a plain argument
// ("--password=${X}", "admin:${X}") cannot be read as code — any value. Text with spaces, quotes or
// brackets around it ('… "${X}" …' in an Elixir or Python string) can: there the raw value must not hold a
// quote, a backslash, $, {, }, a backtick or a line break. `${X|base64}` is always safe (letters, digits,
// +, /, =): the template decodes it in its own code (claper).
codeShaped := !argumentShaped(a)
out[i] = os.Expand(a, func(k string) string {
if !ok[k] || env[k] == "" {
name, enc, _ := strings.Cut(k, "|")
if !ok[name] || env[name] == "" {
missing = append(missing, name)
return ""
}
v := env[name]
switch enc {
case "":
case "base64":
return base64.StdEncoding.EncodeToString([]byte(v))
default:
missing = append(missing, k)
return ""
}
return env[k]
if codeShaped && strings.ContainsAny(v, codeUnsafeChars) {
unsafe = append(unsafe, name)
}
return v
})
}
if len(missing) > 0 {
return nil, fmt.Errorf("after_install: %v not declared in env or has no value — not run", missing)
}
if len(unsafe) > 0 {
return nil, fmt.Errorf("after_install: the value of %v would be read as code (it holds a quote, a backslash, $, {, }, a backtick or a line break) — not run; the template should pass it as its own argument or as ${NAME|base64}", unsafe)
}
return out, nil
}
// codeUnsafeChars can end a string or start an interpolation in the code a command carries.
const codeUnsafeChars = "'\"\\$`{}\n\r\x00"
// argumentShaped: with every ${…} removed, the element is empty or plain argument text (a flag, a name, a "user:"
// prefix) — nothing that can open or close a string in code.
func argumentShaped(a string) bool {
rest := os.Expand(a, func(string) string { return "" })
for _, r := range rest {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
case strings.ContainsRune("-_.:=/@+,", r):
default:
return false
}
}
return true
}
// RunAfterInstall runs the app's after_install once, after a FRESH install (main.go's deploy-done hook).
// Returns (ran, error). Records the outcome in app.yaml either way.
func (m *Manager) RunAfterInstall(name string, wait time.Duration) (bool, error) {
@@ -37,7 +37,10 @@ func TestAfterInstall_ReplacesTheDefaultLoginOnceAndRecordsIt(t *testing.T) {
// 1. Success: the value is filled in, the command runs ONCE, the record says ok, the log never carries it.
var calls [][]string
m.afterLoadFn = func(_ string, args ...string) (string, error) { calls = append(calls, args); return "... FELHOM_OK", nil }
m.afterLoadFn = func(_ string, args ...string) (string, error) {
calls = append(calls, args)
return "... FELHOM_OK", nil
}
cmd, err := expandAfterInstall(ai.Command, ai.Env, map[string]string{"ADMIN_PASSWORD": "Gen3r4tedValue"})
must(t, err)
if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err != nil {
@@ -55,7 +58,10 @@ func TestAfterInstall_ReplacesTheDefaultLoginOnceAndRecordsIt(t *testing.T) {
// 2. No success marker: retried, then recorded as FAILED — never recorded ok.
calls = nil
m.afterLoadFn = func(_ string, args ...string) (string, error) { calls = append(calls, args); return "boom", errors.New("exit 1") }
m.afterLoadFn = func(_ string, args ...string) (string, error) {
calls = append(calls, args)
return "boom", errors.New("exit 1")
}
if err := m.runAfterInstallNow("cloudapp", ai, cmd, record); err == nil {
t.Fatal("a failing command reported success")
}
@@ -89,3 +95,34 @@ func TestAfterInstall_ReplacesTheDefaultLoginOnceAndRecordsIt(t *testing.T) {
t.Fatal("an undeclared name was filled in")
}
}
// R-713 (v0.281.0): a value pasted into CODE must not be able to end a string or start an interpolation; the
// same value as its own argument, a plain argument, or ${NAME|base64} is fine.
// COMPANION RED-PROOF: make argumentShaped return true always → the claper-shaped case runs with the quote.
func TestR713_AValueThatWouldBeReadAsCodeIsRefused(t *testing.T) {
typed := map[string]string{"ADMIN_PASSWORD": `My"pass#{System.halt()}`}
allowed := []string{"ADMIN_PASSWORD"}
claperShaped := []string{"/app/bin/claper", "rpc", `Claper.x(u, %{password: "${ADMIN_PASSWORD}"})`}
if _, err := expandAfterInstall(claperShaped, allowed, typed); err == nil || !strings.Contains(err.Error(), "read as code") {
t.Fatalf("a quote and #{ went into Elixir code: %v", err)
}
for _, cmd := range [][]string{
{"python3", "-c", "import sys; f(sys.argv[1])", "${ADMIN_PASSWORD}"}, // its own argument (mealie, wger)
{"php", "artisan", "x", "--password=${ADMIN_PASSWORD}"}, // a plain argument (bookstack)
{"python3", "cps.py", "-s", "admin:${ADMIN_PASSWORD}"}, // calibre-web
{"/app/bin/claper", "rpc", `x(Base.decode64!("${ADMIN_PASSWORD|base64}"))`}, // claper, fixed
} {
out, err := expandAfterInstall(cmd, allowed, typed)
if err != nil {
t.Fatalf("%v: refused a safe placement: %v", cmd, err)
}
if strings.Contains(cmd[len(cmd)-1], "|base64") {
if strings.ContainsAny(out[len(out)-1][len(`x(Base.decode64!("`):], `'\{}$`+"`") || strings.Contains(out[len(out)-1], `My"pass`) {
t.Fatalf("base64 form leaked the raw value: %q", out[len(out)-1])
}
}
}
if _, err := expandAfterInstall([]string{"x", "${ADMIN_PASSWORD|rot13}"}, allowed, typed); err == nil {
t.Fatal("an unknown encoding was accepted")
}
}
+7 -2
View File
@@ -60,8 +60,11 @@ type Metadata struct {
// app's first setup is done — for an app whose first visitor creates the admin. See setup_gate.go.
SetupGate bool `yaml:"setup_gate,omitempty" json:"setup_gate,omitempty"`
// SetupDoneProbe (v0.280.0) is the app's own read-only "an admin exists" status; absent = the household's button.
SetupDoneProbe *SetupDoneProbe `yaml:"setup_done_probe,omitempty" json:"setup_done_probe,omitempty"`
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
SetupDoneProbe *SetupDoneProbe `yaml:"setup_done_probe,omitempty" json:"setup_done_probe,omitempty"`
// SignupBlock (v0.281.0, `09` §3 decision 47): a traefik matcher for the app's own sign-up address, closed once the
// setup gate opens. See signup_block.go.
SignupBlock string `yaml:"signup_block,omitempty" json:"signup_block,omitempty"`
Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"`
// InitialCreds: for apps that auto-generate a first-login credential into a file inside the
// container (e.g. Crafty's default-creds.txt). The controller reads + parses that file live and
// surfaces it on the app page, so the customer never has to dig through logs. Optional.
@@ -146,6 +149,8 @@ type AppInfo struct {
Prerequisites []string `yaml:"prerequisites" json:"prerequisites"`
DefaultCreds string `yaml:"default_creds" json:"default_creds"`
DocsURL string `yaml:"docs_url" json:"docs_url"`
// AddPeople (v0.281.0, decision 47): how the household adds a family member once sign-up is closed.
AddPeople string `yaml:"add_people,omitempty" json:"add_people,omitempty"`
}
// OptionalConfigGroup defines a group of optional config fields (e.g., "Metadata providers").
@@ -69,6 +69,7 @@ type AppInfoOverlay struct {
FirstSteps []string `yaml:"first_steps,omitempty"`
Prerequisites []string `yaml:"prerequisites,omitempty"`
DefaultCreds *string `yaml:"default_creds,omitempty"`
AddPeople *string `yaml:"add_people,omitempty"`
}
// DeployFieldOverlay translates one deploy field, found by EnvVar.
@@ -178,6 +179,7 @@ func (m Metadata) For(lang string) Metadata {
if ov.AppInfo != nil {
out.AppInfo.Tagline = overlayStr(ov.AppInfo.Tagline, out.AppInfo.Tagline)
out.AppInfo.DefaultCreds = overlayStr(ov.AppInfo.DefaultCreds, out.AppInfo.DefaultCreds)
out.AppInfo.AddPeople = overlayStr(ov.AppInfo.AddPeople, out.AppInfo.AddPeople)
out.AppInfo.UseCases = overlayList(ov.AppInfo.UseCases, out.AppInfo.UseCases)
out.AppInfo.FirstSteps = overlayList(ov.AppInfo.FirstSteps, out.AppInfo.FirstSteps)
out.AppInfo.Prerequisites = overlayList(ov.AppInfo.Prerequisites, out.AppInfo.Prerequisites)
+14
View File
@@ -65,6 +65,8 @@ type SetupGateRecord struct {
Hosts []string `yaml:"hosts,omitempty" json:"hosts,omitempty"`
OpenedAt string `yaml:"opened_at,omitempty" json:"opened_at,omitempty"`
OpenedBy string `yaml:"opened_by,omitempty" json:"opened_by,omitempty"`
// SignupOpenUntil (v0.281.0, decision 47): the household opened sign-up until this time (signup_block.go).
SignupOpenUntil string `yaml:"signup_open_until,omitempty" json:"signup_open_until,omitempty"`
}
// Closed reports whether the gate stands.
@@ -296,6 +298,14 @@ func (m *Manager) OpenSetupGate(name, by string) error {
}
dir := filepath.Dir(st.ComposePath)
now := m.now().UTC().Format(time.RFC3339)
// Decision 47: the sign-up block goes up BEFORE the gate comes down, so there is no moment where a stranger
// can sign up. Cannot write it → the gate stays closed (the loop or the next press tries again).
block := strings.TrimSpace(st.Meta.SignupBlock)
if block != "" {
if err := m.writeSignupBlock(name, st.AppConfig.SetupGate.Hosts, block); err != nil {
return fmt.Errorf("setup gate %s: the sign-up block could not be written, so the gate stays closed: %w", name, err)
}
}
opened := false
m.mutateAppConfig(name, dir, "setup_gate", func(cfg *AppConfig) bool {
if !cfg.SetupGate.Closed() {
@@ -306,6 +316,9 @@ func (m *Manager) OpenSetupGate(name, by string) error {
return true
})
if !opened {
if block != "" {
_ = m.removeSignupBlockFile(name) // still gated: the household may still need the sign-up address
}
return fmt.Errorf("setup gate %s: the record could not be written", name)
}
if err := m.removeSetupGateFile(name); err != nil {
@@ -435,6 +448,7 @@ func (m *Manager) SetupGateTick() {
}
}
}
m.reconcileSignupBlocks()
}
// RunSetupGateLoop runs SetupGateTick every interval until ctx ends.
+208
View File
@@ -0,0 +1,208 @@
package stacks
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// ── Sign-up closed once the first admin exists (v0.281.0, `09` §3 decision 47) ─────────────────────────
//
// Operator ruling 2026-09-29 (R-711, option A): after an app's first admin exists, a stranger can no longer make an
// account. Measured on 9202 the same day: opengist and wishlist keep "sign-up on/off" ONLY in their own admin
// settings (no env, no CLI), and vikunja reads it at start but then offers no way to add a user but its CLI. So the
// box does not reach into each app: when an app's setup gate OPENS, the box keeps a small traefik router in front of
// the app's own sign-up address only (`.felhom.yml` `signup_block:`, a traefik matcher), answered by the controller
// with "sign-up is closed on this app" (internal/web/setup_gate.go ServeSignupClosed). Everything else of the app is reached as
// without a gate. The household lets a family member join by opening sign-up for 15 minutes from the app page
// (OpenSignupWindow); the loop closes it again. Decided by CC unattended 2026-09-29 — the operator may reverse.
//
// signup_block: "PathPrefix(`/-/register`)" # opengist
//
// Only an app whose setup gate this box opened carries a block: an app installed before (no gate record) is never
// touched — the same rule as the gate itself (Part 0, 2026-09-29).
// Pinned by internal/stacks/signup_block_test.go.
// signupWindow is how long the household's "open sign-up" press lasts.
var signupWindow = 15 * time.Minute
// signupClosedPath is where the block sends a request (replacePath), answered by the controller.
const signupClosedPath = "/__felhom_gate/signup-closed"
func (m *Manager) signupBlockPath(name string) string {
return filepath.Join(m.setupGateDir(), "signup-block-"+name+".yml")
}
func renderSignupBlock(name string, hosts []string, fragment string) string {
hs := make([]string, 0, len(hosts))
for _, h := range hosts {
hs = append(hs, "Host(`"+h+"`)")
}
rule := "(" + strings.Join(hs, " || ") + ") && (" + fragment + ")"
r := "felhom-signup-block-" + name
var b strings.Builder
fmt.Fprintf(&b, "# Sign-up block for %s — managed by felhom-controller (`09` §3 decision 47).\n", name)
b.WriteString("# The app's own sign-up address answers \"sign-up is closed\"; the household opens it for 15 minutes from the app page.\n")
b.WriteString("http:\n middlewares:\n")
fmt.Fprintf(&b, " %s:\n replacePath:\n path: %q\n", r, signupClosedPath)
b.WriteString(" routers:\n")
fmt.Fprintf(&b, " %s:\n rule: %q\n priority: %d\n", r, rule, 2*setupGatePriority+len(rule))
b.WriteString(" entryPoints:\n - websecure\n tls: {}\n")
fmt.Fprintf(&b, " middlewares:\n - %s@file\n service: %s\n", r, r)
fmt.Fprintf(&b, " services:\n %s:\n loadBalancer:\n servers:\n - url: \"http://felhom-controller:8080\"\n", r)
return b.String()
}
func (m *Manager) writeSignupBlock(name string, hosts []string, fragment string) error {
if len(hosts) == 0 || strings.TrimSpace(fragment) == "" {
return fmt.Errorf("signup block %s: no host or no rule", name)
}
if err := os.MkdirAll(m.setupGateDir(), 0o755); err != nil {
return err
}
want := renderSignupBlock(name, hosts, fragment)
p := m.signupBlockPath(name)
if cur, err := os.ReadFile(p); err == nil && string(cur) == want {
return nil
}
tmp := p + ".tmp"
if err := os.WriteFile(tmp, []byte(want), 0o644); err != nil {
return err
}
return os.Rename(tmp, p)
}
func (m *Manager) removeSignupBlockFile(name string) error {
err := os.Remove(m.signupBlockPath(name))
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// signupWindowOpen: the household's 15 minutes are running.
func (r *SetupGateRecord) signupWindowOpen(now time.Time) bool {
if r == nil || r.SignupOpenUntil == "" {
return false
}
t, err := time.Parse(time.RFC3339, r.SignupOpenUntil)
return err == nil && now.Before(t)
}
// SignupBlocked reports whether an app's sign-up is closed now, and until when a household's window runs ("" = none).
func (m *Manager) SignupBlocked(name string) (blocked bool, windowUntil string) {
st, ok := m.GetStack(name)
if !ok || !st.Deployed || st.AppConfig == nil || st.AppConfig.SetupGate == nil || strings.TrimSpace(st.Meta.SignupBlock) == "" {
return false, ""
}
g := st.AppConfig.SetupGate
if g.State != SetupGateOpen {
return false, ""
}
if g.signupWindowOpen(m.now()) {
return false, g.SignupOpenUntil
}
return true, ""
}
// ErrNoSignupBlock: the app has no sign-up block to open (never gated here, not open yet, or no signup_block).
var ErrNoSignupBlock = fmt.Errorf("the app has no closed sign-up")
// OpenSignupWindow is the household's "open sign-up for 15 minutes": the record, then the file goes. The loop puts
// the block back once the window has passed.
func (m *Manager) OpenSignupWindow(name string) (string, error) {
st, ok := m.GetStack(name)
if !ok || !st.Deployed || st.AppConfig == nil || st.AppConfig.SetupGate == nil ||
st.AppConfig.SetupGate.State != SetupGateOpen || strings.TrimSpace(st.Meta.SignupBlock) == "" {
return "", ErrNoSignupBlock
}
until := m.now().Add(signupWindow).UTC().Format(time.RFC3339)
done := false
m.mutateAppConfig(name, filepath.Dir(st.ComposePath), "signup_window", func(cfg *AppConfig) bool {
if cfg.SetupGate == nil || cfg.SetupGate.State != SetupGateOpen {
return false
}
cfg.SetupGate.SignupOpenUntil = until
done = true
return true
})
if !done {
return "", fmt.Errorf("signup window %s: the record could not be written", name)
}
if err := m.removeSignupBlockFile(name); err != nil {
return "", err
}
m.logger.Printf("[INFO] [stacks] %s: the household opened sign-up until %s — the loop closes it again", name, until)
return until, nil
}
// reconcileSignupBlocks: every app whose sign-up should be closed has its block file; every other block file goes.
func (m *Manager) reconcileSignupBlocks() {
type want struct {
hosts []string
fragment string
}
wants := map[string]want{}
m.mu.RLock()
now := m.now()
for n, st := range m.stacks {
g := func() *SetupGateRecord {
if st.AppConfig == nil {
return nil
}
return st.AppConfig.SetupGate
}()
if !st.Deployed || g == nil || g.State != SetupGateOpen || strings.TrimSpace(st.Meta.SignupBlock) == "" || g.signupWindowOpen(now) {
continue
}
wants[n] = want{hosts: append([]string(nil), g.Hosts...), fragment: st.Meta.SignupBlock}
}
m.mu.RUnlock()
if ents, err := os.ReadDir(m.setupGateDir()); err == nil {
for _, e := range ents {
n := e.Name()
if !strings.HasPrefix(n, "signup-block-") || !strings.HasSuffix(n, ".yml") {
continue
}
app := strings.TrimSuffix(strings.TrimPrefix(n, "signup-block-"), ".yml")
if _, ok := wants[app]; !ok {
if err := m.removeSignupBlockFile(app); err == nil {
m.logger.Printf("[INFO] [stacks] %s: sign-up block removed (window open, app removed, or no block wanted)", app)
}
}
}
}
for n, w := range wants {
if err := m.writeSignupBlock(n, w.hosts, w.fragment); err != nil {
m.logger.Printf("[ERROR] [stacks] %s: the sign-up block could not be written: %v — sign-up is OPEN until it is", n, err)
}
}
}
// SetupGateProbe asks the app's own "setup done" status once (the household's button asks it first, Part A of the
// 2026-09-29 afternoon brief). has=false: the template declares no probe.
func (m *Manager) SetupGateProbe(name string) (has bool, done bool, got string, err error) {
st, ok := m.GetStack(name)
if !ok {
return false, false, "", fmt.Errorf("stack %q not found", name)
}
p := st.Meta.SetupDoneProbe
if p == nil || p.URL == "" {
return false, false, "", nil
}
body, err := setupGateProbeGet(p.URL)
if err != nil {
return true, false, "", err
}
done, got = probeSaysDone(body, p.Field, p.Done)
return true, done, got, nil
}
// SetSetupGateProbeGetForTest swaps the probe's HTTP read (a test seam for other packages); returns the restore.
func SetSetupGateProbeGetForTest(f func(url string) ([]byte, error)) func() {
old := setupGateProbeGet
setupGateProbeGet = f
return func() { setupGateProbeGet = old }
}
@@ -0,0 +1,118 @@
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)
}
}