v0.243.0: FileBrowser generated admin password (R-513); per-tier whole-guest backup truth (R-517); skip absent-storage tiers (R-518); OOM-killed worker visible (R-514)
gates / gates (push) Successful in 14s
gates / gates (push) Successful in 14s
MinAgent: 0.131.0 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user