Files
felhom-controller/controller/internal/stacks/filebrowser_password_test.go
T
admin 30956aabd2
gates / gates (push) Successful in 44s
R-977: a new FileBrowser run is given ~20 s to answer in the same tick
rc.1 on 9202: the tick that brought FileBrowser back probed it while it was starting; the default login stayed
open for one 5-minute round. TestFileBrowserAdmin_R977_NewRunIsWaitedForInTheSameTick, red-proved.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-17 11:41:18 +02:00

247 lines
8.5 KiB
Go

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