package stacks import ( "encoding/json" "io" "log" "net/http" "net/http/httptest" "path/filepath" "strings" "sync" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/crypto" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // R-513 — FileBrowser accepted admin/admin on every box. The fake below behaves like the measured // Quantum 1.3.3 API (evidence-p1fixes-2026-09-15/B1): login by X-Password, PUT /api/users with the // current password in X-Password. type fakeFB struct { mu sync.Mutex password string puts int } func (f *fakeFB) handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/api/auth/login", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() if r.Header.Get("X-Password") != f.password { w.WriteHeader(http.StatusUnauthorized) return } io.WriteString(w, "tok-123") }) mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() if r.Header.Get("X-Auth") != "tok-123" { w.WriteHeader(http.StatusUnauthorized) return } switch r.Method { case http.MethodGet: io.WriteString(w, `{"id":1,"username":"admin"}`) case http.MethodPut: if r.Header.Get("X-Password") != f.password { w.WriteHeader(http.StatusUnauthorized) return } var body struct { Which []string `json:"which"` Data struct { Password string `json:"password"` } `json:"data"` } _ = json.NewDecoder(r.Body).Decode(&body) f.password = body.Data.Password f.puts++ w.WriteHeader(http.StatusNoContent) } }) return mux } func fbManager(t *testing.T, base string) (*Manager, *settings.Settings, []byte) { t.Helper() st, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0)) if err != nil { t.Fatal(err) } key := make([]byte, 32) for i := range key { key[i] = byte(i + 1) } return &Manager{logger: log.New(io.Discard, "", 0), settings: st, encKey: key, fbBaseURL: base}, st, key } // The consequence: after one tick admin/admin no longer logs in, the stored (decrypted) password // does, and the state is "generated". A second tick changes nothing. // // RED-PROOF (run 2026-09-15, recorded in REPORT.md): with EnsureFileBrowserAdminPassword returning // nil before the PUT, admin/admin stayed valid and this failed at "admin/admin still logs in". func TestFileBrowserAdmin_DefaultLoginReplaced(t *testing.T) { fb := &fakeFB{password: "admin"} srv := httptest.NewServer(fb.handler()) defer srv.Close() m, st, key := fbManager(t, srv.URL) if err := m.EnsureFileBrowserAdminPassword(); err != nil { t.Fatalf("ensure: %v", err) } if _, c, _ := fbLogin(srv.Client().Do, srv.URL, "admin"); c == http.StatusOK { t.Fatalf("admin/admin still logs in — R-513 not fixed") } state, enc, at := st.GetFileBrowserAdmin() if state != settings.FileBrowserAdminGenerated || enc == "" || at == "" { t.Fatalf("decision not recorded: state=%q enc=%v at=%q", state, enc != "", at) } if enc == fb.password { t.Fatal("the password was stored in plaintext") } pw, err := crypto.Decrypt(key, enc) if err != nil || len(pw) != 16 { t.Fatalf("stored password does not decrypt to a 16-char value (len=%d err=%v)", len(pw), err) } if _, c, _ := fbLogin(srv.Client().Do, srv.URL, pw); c != http.StatusOK { t.Fatalf("the stored password does not log in (HTTP %d)", c) } _ = m.EnsureFileBrowserAdminPassword() if fb.puts != 1 { t.Fatalf("a recorded decision was acted on again (puts=%d)", fb.puts) } } // A password set by hand (admin/admin refused) is NEVER overwritten. func TestFileBrowserAdmin_HandSetPasswordLeftAlone(t *testing.T) { fb := &fakeFB{password: "operator-set-by-hand"} srv := httptest.NewServer(fb.handler()) defer srv.Close() m, st, _ := fbManager(t, srv.URL) if err := m.EnsureFileBrowserAdminPassword(); err != nil { t.Fatal(err) } if fb.puts != 0 || fb.password != "operator-set-by-hand" { t.Fatalf("hand-set password was changed (puts=%d)", fb.puts) } if state, enc, _ := st.GetFileBrowserAdmin(); state != settings.FileBrowserAdminOperator || enc != "" { t.Fatalf("want state operator with no stored value, got %q enc=%v", state, enc != "") } } // FileBrowser not reachable → nothing recorded, so the next tick tries again. func TestFileBrowserAdmin_UnreachableRecordsNothing(t *testing.T) { srv := httptest.NewServer(http.NotFoundHandler()) url := srv.URL srv.Close() m, st, _ := fbManager(t, url) if err := m.EnsureFileBrowserAdminPassword(); err == nil { t.Fatal("want an error (for the log) when FileBrowser is unreachable") } if state, _, _ := st.GetFileBrowserAdmin(); state != "" { t.Fatalf("an unreachable FileBrowser recorded a decision: %q", state) } } // R-977: the decision is no longer once per box. A FileBrowser whose data was re-created (a new run whose database // starts on admin/admin again) is reset at its first tick; the same run is never probed twice; a new run on another // password (the household's own) leaves the recorded decision alone. // // RED-PROOF (2026-10-17): with the old early return (`if state != "" { return nil }`) the second run keeps // admin/admin and this fails at „the default login came back on a new run and was left". func TestFileBrowserAdmin_R977_DefaultBackOnANewRunIsReplaced(t *testing.T) { fb := &fakeFB{password: "admin"} srv := httptest.NewServer(fb.handler()) defer srv.Close() m, st, key := fbManager(t, srv.URL) run := "2026-10-17T06:54:56Z" m.fbStarted = func() string { return run } if err := m.EnsureFileBrowserAdminPassword(); err != nil { // first run: a new box t.Fatal(err) } _, enc1, _ := st.GetFileBrowserAdmin() // The data volume is re-created: FileBrowser starts again on its default. fb.mu.Lock() fb.password = "admin" fb.mu.Unlock() if err := m.EnsureFileBrowserAdminPassword(); err != nil { // the SAME run: not probed again t.Fatal(err) } if fb.puts != 1 { t.Fatalf("the same FileBrowser run was probed and changed again (puts=%d)", fb.puts) } run = "2026-10-17T09:12:00Z" // the box brought FileBrowser back: a new run if err := m.EnsureFileBrowserAdminPassword(); err != nil { t.Fatal(err) } if _, c, _ := fbLogin(srv.Client().Do, srv.URL, "admin"); c == http.StatusOK { t.Fatal("the default login came back on a new run and was left") } state, enc2, _ := st.GetFileBrowserAdmin() if state != settings.FileBrowserAdminGenerated || enc2 == "" || enc2 == enc1 { t.Fatalf("a new generated password must be recorded: state=%q changed=%v", state, enc2 != enc1) } pw, err := crypto.Decrypt(key, enc2) if err != nil { t.Fatal(err) } if _, c, _ := fbLogin(srv.Client().Do, srv.URL, pw); c != http.StatusOK { t.Fatalf("the recorded password does not log in (HTTP %d)", c) } // The household changes it in FileBrowser; a later run on THEIR password changes nothing. fb.mu.Lock() fb.password = "the-households-own" fb.mu.Unlock() run = "2026-10-18T02:30:00Z" puts := fb.puts if err := m.EnsureFileBrowserAdminPassword(); err != nil { t.Fatal(err) } if fb.puts != puts || fb.password != "the-households-own" { t.Fatalf("a run on the household's own password was changed (puts %d -> %d)", puts, fb.puts) } if s, e, _ := st.GetFileBrowserAdmin(); s != settings.FileBrowserAdminGenerated || e != enc2 { t.Fatalf("the recorded decision moved on a run that is not on the default: state=%q", s) } } // R-977, measured live: the tick that brings FileBrowser back probes it while it is still starting, and without a wait // the default login stayed open until the next round (5 minutes on 9202). A NEW run is given a short wait. // RED-PROOF (2026-10-17): with one try per tick this fails at „the default stayed open". func TestFileBrowserAdmin_R977_NewRunIsWaitedForInTheSameTick(t *testing.T) { fb := &fakeFB{password: "admin"} starting := 3 // the first three answers: FileBrowser not ready yet inner := fb.handler() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fb.mu.Lock() notReady := starting > 0 if notReady { starting-- } fb.mu.Unlock() if notReady { w.WriteHeader(http.StatusBadGateway) return } inner.ServeHTTP(w, r) })) defer srv.Close() m, st, _ := fbManager(t, srv.URL) m.fbStarted = func() string { return "2026-10-17T09:24:35Z" } if err := m.EnsureFileBrowserAdminPassword(); err != nil { t.Fatalf("one tick: %v", err) } if _, c, _ := fbLogin(srv.Client().Do, srv.URL, "admin"); c == http.StatusOK { t.Fatal("the default stayed open after the tick that started FileBrowser") } if s, _, _ := st.GetFileBrowserAdmin(); s != settings.FileBrowserAdminGenerated { t.Fatalf("state %q, want generated", s) } } // R-1126 (D11 = A): files. is published (FileBrowser joined to traefik-public) only AFTER this run's default // login was checked; a check that cannot finish leaves it unpublished; a later run already published is not touched. // RED-PROOF (2026-10-17): ensureFileBrowserPublic without the „this run was checked" test → the unreachable case fails. func TestFileBrowserGate_PublishedOnlyAfterTheCheck(t *testing.T) { var events []string fb := &fakeFB{password: "admin"} inner := fb.handler() down := false srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if down { w.WriteHeader(http.StatusBadGateway) return } if r.Method == http.MethodPut { events = append(events, "password replaced") } inner.ServeHTTP(w, r) })) defer srv.Close() m, _, _ := fbManager(t, srv.URL) run := "run-1" m.fbStarted = func() string { return run } nets := map[string]bool{} m.fbNetOnFn = func(c, n string) bool { return nets[c+"@"+n] } m.fbNetConnectFn = func(c, n string) error { nets[c+"@"+n] = true events = append(events, "connect "+c+" "+n) return nil } // A new run whose FileBrowser does not answer: nothing is published. down = true m.fileBrowserGateLocked() if nets["filebrowser@traefik-public"] { t.Fatal("published while this run's default login was never checked") } // It answers, on its default: replaced first, published after. down = false m.fileBrowserGateLocked() want := []string{"connect felhom-controller felhom-filebrowser", "password replaced", "connect filebrowser traefik-public"} if strings.Join(events, " | ") != strings.Join(want, " | ") { t.Fatalf("order:\n got %v\n want %v", events, want) } // A new run (a restart) on the box's own password: checked, nothing changes, nothing connected twice. run = "run-2" m.fileBrowserGateLocked() if len(events) != 3 { t.Fatalf("a checked restart did more: %v", events) } } // R-1126 TOCTOU: the gate connects the container whose run it checked, by ID — so a FileBrowser re-created between the // check and the connect (web.SyncFileBrowserMounts runs outside infraMu) is never the one published. // RED-PROOF (2026-10-17): connecting by the name „filebrowser" fails this test. func TestFileBrowserGate_ConnectsTheCheckedContainerById(t *testing.T) { fb := &fakeFB{password: "admin"} srv := httptest.NewServer(fb.handler()) defer srv.Close() m, _, _ := fbManager(t, srv.URL) m.fbStarted = func() string { return "3f2a9c 2026-10-17T11:20:00Z" } var connected []string m.fbNetOnFn = func(c, n string) bool { return false } m.fbNetConnectFn = func(c, n string) error { connected = append(connected, c+"@"+n) return nil } m.fileBrowserGateLocked() if len(connected) != 2 || connected[1] != "3f2a9c@traefik-public" { t.Fatalf("the checked container must be published by its id, got %v", connected) } }