Files
felhom-controller/controller/internal/stacks/filebrowser_reset_test.go
T
admin 7e791d9b5d
gates / gates (push) Successful in 43s
v0.321.0: FileBrowser's stale password and its own banners; one backup section on the Data tab; install address of an installed app
R-1101: the change-it line is gone for FileBrowser; in the generated state the page asks FileBrowser whether
the stored password still logs in (only its own 401/403 counts) and, refused, says so and offers the
new-password press. The rendered FileBrowser config switches off its update notice and its Install-app
prompt. The Data tab's backup part is one section in plain words (no off-drive, no PBS); the export
password eye is a text button. R-1095: /apps/{slug}/install of an installed app 302s to its settings tab.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 17:17:26 +02:00

185 lines
7.4 KiB
Go

package stacks
import (
"context"
"errors"
"net/http/httptest"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// v0.320.0 — „Uj jelszo beallitasa": the box replaces a FileBrowser admin password it does not know. The apply
// (stop / CLI / start) is seamed by fbSetPassword; the verify runs against the fake FileBrowser API, as R-513's does.
const fbUnknownOld = "TESTONLY-set-by-hand-9q"
func fbResetManager(t *testing.T, state string, apply func(f *fakeFB, pw string)) (*Manager, *settings.Settings, []byte, *fakeFB, *int) {
t.Helper()
fake := &fakeFB{password: fbUnknownOld}
srv := httptest.NewServer(fake.handler())
t.Cleanup(srv.Close)
m, st, key := fbManager(t, srv.URL)
if state != "" {
if err := st.SetFileBrowserAdmin(state, "", "2026-10-16T00:00:00Z"); err != nil {
t.Fatal(err)
}
}
calls := 0
m.fbSetPassword = func(_ context.Context, pw string) error {
calls++
fake.mu.Lock()
apply(fake, pw)
fake.mu.Unlock()
return nil
}
return m, st, key, fake, &calls
}
// RED-PROOF (2026-10-16, REPORT): with the `crypto.Encrypt` line replaced by storing `pw` as it is, this failed at
// „the stored value is the plaintext password".
func TestFileBrowserReset_SetsVerifiesAndStoresEncrypted(t *testing.T) {
m, st, key, fake, calls := fbResetManager(t, settings.FileBrowserAdminOperator, func(f *fakeFB, pw string) { f.password = pw })
if err := m.ResetFileBrowserAdminPassword(context.Background()); err != nil {
t.Fatalf("reset: %v", err)
}
if *calls != 1 {
t.Fatalf("apply ran %d times", *calls)
}
state, enc, _ := st.GetFileBrowserAdmin()
if state != settings.FileBrowserAdminGenerated {
t.Fatalf("state = %q, want generated", state)
}
if enc == "" || enc == fake.password {
t.Fatal("the stored value is the plaintext password (or empty)")
}
if got, err := crypto.Decrypt(key, enc); err != nil || got != fake.password {
t.Fatalf("the stored value does not decrypt to the password FileBrowser now has (err %v)", err)
}
if fake.password == fbUnknownOld || len(fake.password) != 16 {
t.Fatalf("FileBrowser's password was not replaced by a generated 16-character one")
}
}
// RED-PROOF (2026-10-16, REPORT): with the `code != http.StatusOK` verify deleted, this failed at „a failed verify
// recorded generated" — the box would offer a password that does not log in.
func TestFileBrowserReset_FailedVerifyKeepsOperator(t *testing.T) {
// The CLI „succeeded" but FileBrowser still has another password.
m, st, _, _, _ := fbResetManager(t, settings.FileBrowserAdminOperator, func(f *fakeFB, _ string) { f.password = "TESTONLY-something-else" })
err := m.ResetFileBrowserAdminPassword(context.Background())
if err == nil {
t.Fatal("a reset whose new password does not log in reported success")
}
if state, enc, _ := st.GetFileBrowserAdmin(); state != settings.FileBrowserAdminOperator || enc != "" {
t.Fatalf("a failed verify recorded %q (enc set: %v), want operator and nothing stored", state, enc != "")
}
// admin/admin still logging in after the apply is a failure too.
m2, st2, _, _, _ := fbResetManager(t, settings.FileBrowserAdminOperator, func(f *fakeFB, _ string) { f.password = "admin" })
if err := m2.ResetFileBrowserAdminPassword(context.Background()); err == nil {
t.Fatal("a reset that left admin/admin working reported success")
}
if state, _, _ := st2.GetFileBrowserAdmin(); state != settings.FileBrowserAdminOperator {
t.Fatalf("state = %q, want operator", state)
}
}
// RED-PROOF (2026-10-16, REPORT): with the state check removed, this failed at „generated: the apply ran" — the
// press would have replaced a password the household can already see.
func TestFileBrowserReset_OnlyInOperatorState(t *testing.T) {
for _, state := range []string{"", settings.FileBrowserAdminGenerated} {
m, st, _, _, calls := fbResetManager(t, state, func(f *fakeFB, pw string) { f.password = pw })
err := m.ResetFileBrowserAdminPassword(context.Background())
if !errors.Is(err, ErrFileBrowserNotOperator) {
t.Errorf("state %q: err = %v, want ErrFileBrowserNotOperator", state, err)
}
if *calls != 0 {
t.Errorf("state %q: the apply ran", state)
}
if got, _, _ := st.GetFileBrowserAdmin(); got != state {
t.Errorf("state %q changed to %q", state, got)
}
}
}
// RED-PROOF (2026-10-16, REPORT): with the protected check removed from DeployStack, this failed at „filebrowser
// (Protected=true): err = went on into the deploy".
func TestDeployStack_RefusesProtectedStack(t *testing.T) {
cfg := config.Default()
cfg.Stacks.Protected = []string{"filebrowser"}
for _, c := range []struct {
name string
protected bool
}{{"filebrowser", true}, {"filebrowser", false}, {"samba", false}} {
m := &Manager{cfg: cfg, stacks: map[string]*Stack{c.name: {Name: c.name, Protected: c.protected}}}
var err error
func() {
// This bare manager has nothing a real deploy needs, so a call that gets PAST the guard panics — which
// is itself the proof that it went on into the deploy.
defer func() {
if r := recover(); r != nil {
err = errors.New("went on into the deploy (panicked on the bare manager)")
}
}()
_, err = m.DeployStack(DeployRequest{StackName: c.name})
}()
if !errors.Is(err, ErrProtectedStack) {
t.Errorf("%s (Protected=%v): err = %v, want ErrProtectedStack", c.name, c.protected, err)
}
if m.stacks[c.name].Deploying {
t.Errorf("%s: left Deploying set", c.name)
}
}
}
func TestEffectiveLoginKind_DefaultAndUnknown(t *testing.T) {
for in, want := range map[string]string{"": "first_visit", "first_visit": "first_visit", "email": "email",
"none": "none", "magic": "first_visit", "EMAIL": "first_visit"} {
if got := (AppInfo{LoginKind: in}).EffectiveLoginKind(); got != want {
t.Errorf("login_kind %q → %q, want %q", in, got, want)
}
}
}
// R-1101 (v0.321.0): the stored password is called refused ONLY on FileBrowser's own 401.
// RED-PROOF (2026-10-16, REPORT): with `refused := err == nil && (...)` replaced by `refused := code != http.StatusOK`,
// this failed at „FileBrowser down reads as refused".
func TestFileBrowserStoredPassword_RefusedOnlyOnA401(t *testing.T) {
fake := &fakeFB{password: "TESTONLY-stored-1"}
srv := httptest.NewServer(fake.handler())
m, st, key := fbManager(t, srv.URL)
enc, err := crypto.Encrypt(key, "TESTONLY-stored-1")
if err != nil {
t.Fatal(err)
}
if err := st.SetFileBrowserAdmin(settings.FileBrowserAdminGenerated, enc, "2026-10-16T00:00:00Z"); err != nil {
t.Fatal(err)
}
if m.FileBrowserStoredPasswordRefused() {
t.Fatal("the stored password still logs in, and it reads as refused")
}
fake.mu.Lock()
fake.password = "TESTONLY-changed-in-filebrowser"
fake.mu.Unlock()
if !m.FileBrowserStoredPasswordRefused() {
t.Fatal("FileBrowser refuses the stored password (401), and it does not read as refused")
}
// The reset is then offered in the generated state, and replaces it.
calls := 0
m.fbSetPassword = func(_ context.Context, pw string) error {
calls++
fake.mu.Lock()
fake.password = pw
fake.mu.Unlock()
return nil
}
if err := m.ResetFileBrowserAdminPassword(context.Background()); err != nil || calls != 1 {
t.Fatalf("reset with a refused stored password: err %v, apply ran %d times", err, calls)
}
srv.Close()
if m.FileBrowserStoredPasswordRefused() {
t.Fatal("FileBrowser down reads as refused — an unproven refusal must not tell the household its password changed")
}
}