Files
felhom-controller/controller/internal/stacks/filebrowser_reset_test.go
T
admin f30a80841e
gates / gates (push) Successful in 50s
v0.320.0: FileBrowser is part of the box again; every app says where its login is; a „Fájlok" button
FileBrowser (and traefik, cloudflared, samba) has no app.yaml, so Deployed is false. Since v0.315.0 the
app page read Deployed alone and showed FileBrowser "not installed", with Install and without its login.
A protected infra stack is now installed by definition on the app page (Deployed || Protected, as every
other surface already counted): settings/guide/log tabs, Restart only, no install page, no data tab.
The Install press would have deployed the infra stack as an app: DeployStack and the deploy API now
refuse a protected stack.

In the operator state (the box does not know FileBrowser's password - both demo boxes) the login panel
offers "Új jelszó beállítása": the file browser's own CLI with the server stopped (spiked on demo-hp:
new 200, old 401, users/shares/settings unchanged), verified, stored encrypted, state generated.

Every installed app's settings tab has the login panel with exactly one answer: the box's fields, the
login the app wrote (InitialCreds), or a sentence chosen by the catalog's new app_info.login_kind
(first_visit default, email, none; none behind the family gate says the family sign-in is the login).

"Fájlok ↗" opens the library folder, else the first declared folder, else a media app's first present
drive's media folder. The data tab lists each drive's media folders; a folder whose drive is not bound
into FileBrowser is text, never a link.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 15:19:14 +02:00

144 lines
5.8 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)
}
}
}