7e791d9b5d
gates / gates (push) Successful in 43s
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
185 lines
7.4 KiB
Go
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")
|
|
}
|
|
}
|