package web import ( "io" "log" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "golang.org/x/crypto/bcrypt" ) // serverWithPassword builds a hub server whose CONFIG seed (hub.yaml auth.password_hash) is the // bcrypt hash of plaintext — mirroring a freshly-deployed hub with no UI override yet. func serverWithPassword(t *testing.T, plaintext string) (*Server, *store.Store) { t.Helper() st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0)) if err != nil { t.Fatalf("store.New: %v", err) } t.Cleanup(func() { st.Close() }) hash, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcrypt.DefaultCost) if err != nil { t.Fatalf("seed hash: %v", err) } s := New(st, string(hash), "", "test", 30*time.Minute, log.New(io.Discard, "", 0)) return s, st } func postChangePassword(t *testing.T, s *Server, current, next, confirm string) *httptest.ResponseRecorder { t.Helper() form := url.Values{ "current_password": {current}, "new_password": {next}, "confirm_password": {confirm}, } r := httptest.NewRequest(http.MethodPost, "/configuration/password", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.handleChangePassword(w, r) return w } // TestEffectivePasswordHash_DBOverrideWins locks the precedence the whole feature rests on: a // hub_settings override wins over the config/env seed, and clearing it falls back to the seed. // Companion red-proof: make effectivePasswordHash return s.configPasswordHash unconditionally → the // "DB override wins" assertion fails. func TestEffectivePasswordHash_DBOverrideWins(t *testing.T) { s, st := serverWithPassword(t, "seed-password") // No override yet → the seed is authoritative. if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("seed-password")) != nil { t.Fatal("with no DB override the config seed must be effective") } dbHash, _ := bcrypt.GenerateFromPassword([]byte("db-password"), bcrypt.DefaultCost) if err := st.SetOperatorPasswordHash(string(dbHash)); err != nil { t.Fatal(err) } // Override present → it wins; the seed must no longer authenticate. if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("db-password")) != nil { t.Error("DB override must win over the config seed") } if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("seed-password")) == nil { t.Error("the config seed must NOT authenticate once a DB override is set") } // Clearing the override → fall back to the seed (the break-glass reset path). if err := st.SetOperatorPasswordHash(""); err != nil { t.Fatal(err) } if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("seed-password")) != nil { t.Error("clearing the DB override must fall back to the config seed") } } // TestChangePassword_HappyPath: a correct current password + matching confirmation persists a DB // override, the new password authenticates end-to-end (through handleLogin), and the OLD password is // dead. Companion red-proof: drop the SetOperatorPasswordHash call → the new password never takes // effect and the login assertion fails. func TestChangePassword_HappyPath(t *testing.T) { s, st := serverWithPassword(t, "old-password") w := postChangePassword(t, s, "old-password", "brand-new-password", "brand-new-password") if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash=pw_changed") { t.Fatalf("expected redirect to pw_changed, got %d %q", w.Code, w.Header().Get("Location")) } // The override is persisted and the new password is effective; the old one is gone. if st.GetOperatorPasswordHash() == "" { t.Fatal("change must persist a hub_settings override") } if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("brand-new-password")) != nil { t.Error("new password must be effective after change") } if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("old-password")) == nil { t.Error("old password must stop working after change") } // End-to-end: /login now accepts the new password and rejects the old. if code := loginStatus(t, s, "brand-new-password"); code != http.StatusSeeOther { t.Errorf("login with new password: got %d, want 303", code) } if code := loginStatus(t, s, "old-password"); code != http.StatusUnauthorized { t.Errorf("login with old password: got %d, want 401", code) } } // TestChangePassword_WrongCurrentRejected is the security anchor: an attacker on an open session (or a // typo) cannot set a new password without the current one. Companion red-proof: remove the // current-password bcrypt check in handleChangePassword → this test fails (the override gets written). func TestChangePassword_WrongCurrentRejected(t *testing.T) { s, st := serverWithPassword(t, "old-password") w := postChangePassword(t, s, "WRONG", "brand-new-password", "brand-new-password") if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash=pw_current_wrong") { t.Fatalf("expected redirect to pw_current_wrong, got %d %q", w.Code, w.Header().Get("Location")) } if st.GetOperatorPasswordHash() != "" { t.Error("a wrong current password must NOT write an override") } if bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte("old-password")) != nil { t.Error("the original password must still be effective after a rejected change") } } // TestChangePassword_ValidationRejections: mismatch, too-short, and no-op changes are all refused and // leave the password untouched. func TestChangePassword_ValidationRejections(t *testing.T) { cases := []struct { name, current, next, confirm, wantFlash string }{ {"mismatch", "old-password", "brand-new-password", "different-confirm", "pw_mismatch"}, {"too_short", "old-password", "short", "short", "pw_too_short"}, {"unchanged", "old-password", "old-password", "old-password", "pw_unchanged"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { s, st := serverWithPassword(t, "old-password") w := postChangePassword(t, s, c.current, c.next, c.confirm) if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash="+c.wantFlash) { t.Fatalf("expected redirect to %s, got %d %q", c.wantFlash, w.Code, w.Header().Get("Location")) } if st.GetOperatorPasswordHash() != "" { t.Errorf("%s: rejected change must NOT write an override", c.name) } }) } } // TestConfigurationPage_RendersPasswordCard: the change-password form renders through the production // template with the three fields and the correct POST target. func TestConfigurationPage_RendersPasswordCard(t *testing.T) { s, _ := newTestServer(t) req := httptest.NewRequest(http.MethodGet, "/configuration", nil) w := httptest.NewRecorder() s.handleConfiguration(w, req) if w.Code != http.StatusOK { t.Fatalf("configuration page: %d", w.Code) } body := w.Body.String() for _, want := range []string{`action="/configuration/password"`, `name="current_password"`, `name="new_password"`, `name="confirm_password"`} { if !strings.Contains(body, want) { t.Errorf("configuration page missing %q", want) } } } // loginStatus drives handleLogin with a password and returns the HTTP status (303 = accepted, // 401 = rejected). func loginStatus(t *testing.T, s *Server, password string) int { t.Helper() form := url.Values{"password": {password}} r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.handleLogin(w, r) return w.Code }