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