package stacks import ( "encoding/json" "io" "log" "net/http" "net/http/httptest" "path/filepath" "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) } }