Files
felhom-controller/controller/internal/stacks/after_setup_test.go
T

181 lines
7.6 KiB
Go

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)")
}
}