package stacks import ( "os" "path/filepath" "strings" "testing" "time" ) // R-741 (decision 45): an after_install app is installed HELD — the gate's door in front of it — until its // known first login is replaced. Nothing here reaches Docker (gateManager's stub + the composeExecFn/afterLoadFn seams). const heldYml = "display_name: Held App\n" + "after_install:\n service: gapp\n env: [ADMIN_PASSWORD]\n command: [\"set-pw\", \"admin:${ADMIN_PASSWORD}\"]\n success: \"changed\"\n" + "app_info:\n default_creds: \"admin / admin123\"\n" + "deploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n" + " - env_var: ADMIN_PASSWORD\n type: password\n generate: \"password:24\"\n" func deployHeld(t *testing.T, m *Manager) (existedAtUp bool, atUp string) { t.Helper() p := m.installHoldPath("gapp") m.composeExecFn = func(_ string, _ map[string]string, args ...string) (string, error) { if len(args) > 0 && args[0] == "up" { b, err := os.ReadFile(p) existedAtUp, atUp = err == nil, string(b) } return "", nil } done := make(chan bool, 1) m.SetDeployDoneHook(func(_ string, ok bool, _ string) { done <- ok }) if _, err := m.DeployStack(DeployRequest{StackName: "gapp", Values: map[string]string{"ADMIN_PASSWORD": "Gen-Pw-123456789"}}); err != nil { t.Fatal(err) } select { case <-done: case <-time.After(20 * time.Second): t.Fatal("the deploy never ended") } return existedAtUp, atUp } // The hold stands BEFORE the first start, and it is the gate's door (forwardAuth), above the gate's priority. // COMPANION RED-PROOF: drop the prepareInstallHold block in DeployStack → "the hold file did not exist" fails. func TestInstallHold_WrittenBeforeTheFirstStart(t *testing.T) { m := gateManager(t, heldYml) existed, atUp := deployHeld(t, m) if !existed { t.Fatal("the hold file did not exist when the app was first started — its known default login was reachable (R-741)") } for _, want := range []string{"Host(`gapp.example.hu`)", `service: "gapp@docker"`, setupGateAuthURL, "felhom-install-hold-gapp@file"} { if !strings.Contains(atUp, want) { t.Errorf("the hold file lacks %q:\n%s", want, atUp) } } if !strings.Contains(atUp, "priority: 3000") { t.Errorf("the hold must outrank the setup gate and the sign-up block:\n%s", atUp) } cfg := LoadAppConfig(filepath.Join(m.cfg.Paths.StacksDir, "gapp")) if cfg == nil || !cfg.InstallHold.Closed() || strings.Join(cfg.InstallHold.Hosts, ",") != "gapp.example.hu" { t.Fatalf("app.yaml hold record: %+v", cfg) } if app, closed, found := m.SetupGateHost("gapp.example.hu"); !found || !closed || app != "gapp" { t.Fatalf("the door must see the held host as closed: %q %v %v", app, closed, found) } } // A template without after_install is never held (no change for 40-odd apps). func TestInstallHold_OnlyForAfterInstallTemplates(t *testing.T) { m := gateManager(t, "display_name: Plain\ndeploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n") existed, _ := deployHeld(t, m) if existed { t.Fatal("an app without after_install was held") } } // after_install succeeding OPENS the hold: record first, file gone, the door lets everyone through. // COMPANION RED-PROOF: drop the OpenInstallHold call in runAfterInstallNow → "still held after the login was replaced". func TestInstallHold_OpensWhenAfterInstallSucceeds(t *testing.T) { m := gateManager(t, heldYml) deployHeld(t, m) m.afterLoadFn = func(string, ...string) (string, error) { return "Password for user 'admin' changed", nil } st, _ := m.GetStack("gapp") dir := filepath.Dir(st.ComposePath) rec := func(ok bool, d string) { m.mutateAppConfig("gapp", dir, "after_install", func(c *AppConfig) bool { c.AfterInstall = &AfterInstallRecord{At: "x", OK: ok, Detail: d} return true }) } if err := m.runAfterInstallNow("gapp", st.Meta.AfterInstall, []string{"set-pw", "admin:x"}, rec); err != nil { t.Fatal(err) } if _, err := os.Stat(m.installHoldPath("gapp")); !os.IsNotExist(err) { t.Fatal("the hold file is still there after the login was replaced") } cfg := LoadAppConfig(dir) if cfg.InstallHold.Closed() || cfg.InstallHold.OpenedBy != InstallHoldByAfterInstall { t.Fatalf("still held after the login was replaced: %+v", cfg.InstallHold) } if _, closed, _ := m.SetupGateHost("gapp.example.hu"); closed { t.Fatal("the door still refuses strangers after the hold opened") } } // A failed after_install keeps the hold (the app is NOT published with its known login); the household's // "I changed it" opens it. // COMPANION RED-PROOF: drop the OpenInstallHold call in MarkDefaultLoginChanged → "the household's word did not open". func TestInstallHold_FailureKeepsItTheHouseholdOpensIt(t *testing.T) { m := gateManager(t, heldYml) deployHeld(t, m) st, _ := m.GetStack("gapp") dir := filepath.Dir(st.ComposePath) m.mutateAppConfig("gapp", dir, "after_install", func(c *AppConfig) bool { c.AfterInstall = &AfterInstallRecord{At: "x", OK: false, Detail: "no marker"} return true }) must(t, m.ScanStacks()) m.installHoldTick() if _, err := os.Stat(m.installHoldPath("gapp")); err != nil { t.Fatal("a failed after_install dropped the hold — the known default login would be public") } must(t, m.ScanStacks()) if err := m.MarkDefaultLoginChanged("gapp", "household"); err != nil { t.Fatal(err) } if _, err := os.Stat(m.installHoldPath("gapp")); !os.IsNotExist(err) { t.Fatal("the household's word did not open the hold") } } // The loop: a record that says the login was replaced (a restore, a crash between record and removal) opens; a // stale file of an app that is not held goes; a closed hold's file is (re)written. func TestInstallHold_LoopReconciles(t *testing.T) { m := gateManager(t, heldYml) deployHeld(t, m) st, _ := m.GetStack("gapp") dir := filepath.Dir(st.ComposePath) must(t, os.Remove(m.installHoldPath("gapp"))) must(t, m.ScanStacks()) m.installHoldTick() if _, err := os.Stat(m.installHoldPath("gapp")); err != nil { t.Fatal("a closed hold's missing file was not rewritten") } must(t, os.WriteFile(m.installHoldPath("ghost"), []byte("x"), 0o644)) m.mutateAppConfig("gapp", dir, "after_install", func(c *AppConfig) bool { c.AfterInstall = &AfterInstallRecord{At: "x", OK: true} return true }) must(t, m.ScanStacks()) m.installHoldTick() if _, err := os.Stat(m.installHoldPath("gapp")); !os.IsNotExist(err) { t.Fatal("a hold whose after_install succeeded was not opened by the loop") } if _, err := os.Stat(m.installHoldPath("ghost")); !os.IsNotExist(err) { t.Fatal("a stale hold file of an app that is not held was kept") } } // An install made by THIS process is never re-run by the loop (its hook is running after_install already); one // made before the process started, with no record, is re-run once. // COMPANION RED-PROOF: drop the DeployedAt-before-process-start check → "re-ran an install this process made". func TestInstallHold_ReRunsOnlyAnInstallTheRestartCutOff(t *testing.T) { m := gateManager(t, heldYml) deployHeld(t, m) calls := 0 prev := installHoldAfterInstall installHoldAfterInstall = func(*Manager, string) { calls++ } defer func() { installHoldAfterInstall = prev }() installHoldRetried.Delete("gapp") defer installHoldRetried.Delete("gapp") setRunning := func() { m.mu.Lock() m.stacks["gapp"].State = StateRunning m.stacks["gapp"].Deploying = false m.mu.Unlock() } must(t, m.ScanStacks()) setRunning() m.installHoldTick() time.Sleep(20 * time.Millisecond) if calls != 0 { t.Fatal("the loop re-ran after_install for an install this process made — twice at once") } st, _ := m.GetStack("gapp") m.mutateAppConfig("gapp", filepath.Dir(st.ComposePath), "deployed_at", func(c *AppConfig) bool { c.DeployedAt = installHoldProcessStart.Add(-time.Hour).UTC().Format(time.RFC3339) return true }) must(t, m.ScanStacks()) setRunning() m.installHoldTick() m.installHoldTick() time.Sleep(20 * time.Millisecond) if calls != 1 { t.Fatalf("an install the restart cut off was re-run %d times, want once", calls) } }