package stacks import ( "context" "fmt" "net/http/httptest" "os" "path/filepath" "runtime" "strings" "sync" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/config" ) // R-1018: the OnlyOffice link restarts FileBrowser (Apply and Revoke), and a re-created FileBrowser starts off // traefik-public (R-1126). The consequence pinned: after RestartStack("filebrowser") the NEW run is checked and joined to // traefik-public by itself, within seconds — not at the next base-stack round (files. answered 502 for 4 min 8 s // on demo-hp, 2026-10-18). Another app's restart publishes nothing. RED-PROOF: remove the gate call from RestartStack → // "not published again" (nothing connects until a round runs, and no round runs here). func TestRestartStack_R1018_FileBrowserPublishedAgainAtOnce(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("the stub compose binary is a shell script") } fb := &fakeFB{password: "admin"} srv := httptest.NewServer(fb.handler()) defer srv.Close() m, _, _ := fbManager(t, srv.URL) root := t.TempDir() cfg := &config.Config{} cfg.Paths.StacksDir = root m.cfg = cfg m.stacks = map[string]*Stack{} for _, name := range []string{"filebrowser", "bookstack"} { dir := filepath.Join(root, name) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte("services:\n web:\n image: nginx:1.27\n"), 0o644); err != nil { t.Fatal(err) } m.stacks[name] = &Stack{Name: name, ComposePath: filepath.Join(dir, "docker-compose.yml"), Deployed: true} } // compose: a stub that records the recreate; docker inspect: the start time moves after it (RestartStack's own check). bin := t.TempDir() marker := filepath.Join(t.TempDir(), "recreated") script := fmt.Sprintf("#!/bin/sh\nfor a in \"$@\"; do [ \"$a\" = \"--force-recreate\" ] && touch %q; done\nexit 0\n", marker) if err := os.WriteFile(filepath.Join(bin, "docker-compose"), []byte(script), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) m.composeCmd = "docker-compose" m.execFn = func(string, ...string) (string, error) { return "", nil } recreated := func() bool { _, err := os.Stat(marker); return err == nil } m.installedExecFn = func(_ context.Context, _ string, _ []string, name string, args ...string) (string, error) { if name == "docker" && len(args) > 0 && args[0] == "compose" { name, args = "docker-compose", args[1:] } started := "2026-10-18T18:00:00Z" if recreated() { started = "2026-10-18T18:32:14Z" } switch { case name == "docker-compose" && len(args) > 0 && args[0] == "ps": return `{"ID":"aaa111bbb222","Name":"web-1","Service":"web"}`, nil case name == "docker" && len(args) > 0 && args[0] == "inspect" && strings.Contains(strings.Join(args, " "), "State.StartedAt"): return "aaa111bbb222ccc333" + inspectSep + "started" + inspectSep + started + "\n", nil case name == "docker" && len(args) > 0 && args[0] == "inspect": return "aaa111bbb222" + inspectSep + "nginx:1.27" + inspectSep + "sha256:i\n", nil case name == "docker" && len(args) > 1 && args[0] == "image": return "sha256:i" + inspectSep + "nginx@sha256:d\n", nil } return "", nil } // The gate's view of FileBrowser: run 1 was checked and public; a recreate makes run 2, off traefik-public. var mu sync.Mutex nets := map[string]bool{"filebrowser@traefik-public": true, "felhom-controller@felhom-filebrowser": true} connected := make(chan string, 8) m.fbStarted = func() string { if recreated() { return "run-2" } return "run-1" } m.fbProbedRun = "run-1" m.fbNetOnFn = func(c, n string) bool { mu.Lock(); defer mu.Unlock(); return nets[c+"@"+n] } m.fbNetConnectFn = func(c, n string) error { mu.Lock() nets[c+"@"+n] = true mu.Unlock() connected <- c + "@" + n return nil } // Another app's restart publishes nothing. if err := m.RestartStack("bookstack"); err != nil { t.Fatalf("bookstack restart: %v", err) } select { case c := <-connected: t.Fatalf("a bookstack restart touched FileBrowser's networks: %s", c) case <-time.After(300 * time.Millisecond): } os.Remove(marker) // FileBrowser's restart: the recreate drops it from traefik-public; the gate must put run 2 back by itself. mu.Lock() delete(nets, "filebrowser@traefik-public") mu.Unlock() if err := m.RestartStack("filebrowser"); err != nil { t.Fatalf("filebrowser restart: %v", err) } deadline := time.After(10 * time.Second) for { select { case c := <-connected: if c == "filebrowser@traefik-public" { if m.fbProbedRun != "run-2" { t.Fatalf("published, but the checked run is %q, not the new run-2", m.fbProbedRun) } return } case <-deadline: t.Fatal("FileBrowser was not published again after its restart — files. stays 502 until a base-stack round") } } }