package web import ( "context" "net/http" "net/http/httptest" "net/url" "strconv" "strings" "sync" "testing" "time" "github.com/go-webauthn/webauthn/webauthn" "hearthforge/internal/db" ) func getWithCookie(h http.Handler, path string, c *http.Cookie) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, path, nil) req.AddCookie(c) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec } func TestPasswordChangeDropsOtherSessions(t *testing.T) { _, h := newTestServer(t) current, other := loginAsAdmin(t, h), loginAsAdmin(t, h) rec := postForm(t, h, "/settings/password", url.Values{ "current_password": {testAdminPassword}, "new_password": {"new-password-1"}, "confirm_password": {"new-password-1"}, }, current) if loc := rec.Header().Get("Location"); loc != "/settings?success=password" { t.Fatalf("Location = %q", loc) } if rec := getWithCookie(h, "/settings", other); rec.Code != http.StatusFound { t.Errorf("other session still works: status %d", rec.Code) } if rec := getWithCookie(h, "/settings", current); rec.Code != http.StatusOK { t.Errorf("current session was dropped: status %d", rec.Code) } } func TestStaleSessionMustSignInAgain(t *testing.T) { s, h := newTestServer(t) cookie := loginAsAdmin(t, h) const note = "needs a recent sign-in" if strings.Contains(getWithCookie(h, "/settings", cookie).Body.String(), note) { t.Error("fresh session shows the sign-in note") } old := time.Now().UTC().Add(-time.Hour).Format(db.ISOLayout) if _, err := s.DB.ExecContext(context.Background(), `UPDATE sessions SET created_at = ?`, old); err != nil { t.Fatal(err) } if page := getWithCookie(h, "/settings", cookie).Body.String(); !strings.Contains(page, note) || !strings.Contains(page, `href="/login?next=%2Fsettings"`) { t.Error("stale session lacks the sign-in note") } rec := postForm(t, h, "/settings/password/remove", nil, cookie) if loc := rec.Header().Get("Location"); loc != "/login?next=%2Fsettings" { t.Fatalf("remove password Location = %q", loc) } rec = postForm(t, h, "/settings/passkey/revoke", url.Values{"id": {"1"}}, cookie) if loc := rec.Header().Get("Location"); loc != "/login?next=%2Fsettings" { t.Fatalf("revoke passkey Location = %q", loc) } rec = postForm(t, h, "/auth/passkey/register/options", nil, cookie) if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), `"reauth"`) { t.Fatalf("passkey options: %d %s", rec.Code, rec.Body.String()) } page := getWithCookie(h, "/login?next=%2Fsettings", cookie).Body.String() if !strings.Contains(page, `name="next" value="/settings"`) || !strings.Contains(page, "sign in again") { t.Error("login page lacks the return path or the notice") } for next, want := range map[string]string{"/settings": "/settings", "//evil.example": "/", "/\\evil.example": "/"} { rec = postForm(t, h, "/login", url.Values{ "username": {db.AdminUsername}, "password": {testAdminPassword}, "next": {next}, }) if loc := rec.Header().Get("Location"); loc != want { t.Errorf("next %q: Location = %q, want %q", next, loc, want) } } } func TestPasswordChangeIsRateLimited(t *testing.T) { s, h := newTestServer(t) s.Cfg.RateLimitDisabled = false cookie := loginAsAdmin(t, h) var loc string for range 11 { rec := postForm(t, h, "/settings/password", url.Values{ "new_password": {"new-password-1"}, "confirm_password": {"mismatch-1"}, }, cookie) loc, _ = url.QueryUnescape(rec.Header().Get("Location")) } if !strings.Contains(loc, "Too many attempts") { t.Errorf("Location = %q", loc) } } func TestReservedUsernameIgnoresCase(t *testing.T) { _, h := newTestServer(t) rec := postForm(t, h, "/register", url.Values{ "username": {"Admin"}, "password": {"password123"}, "password2": {"password123"}, }) if !strings.Contains(rec.Body.String(), "reserved") { t.Errorf("body = %q", rec.Body.String()) } } func registryRequest(user, pass, remote string) *http.Request { req := httptest.NewRequest(http.MethodGet, "/v2/", nil) req.SetBasicAuth(user, pass) req.RemoteAddr = remote return req } func TestRegistryUserCachesVerifiedCredentials(t *testing.T) { s, _ := newTestServer(t) s.Cfg.RateLimitDisabled = false const ip = "198.51.100.7:1" // More requests than the limiter allows: only the first runs argon2. for i := range 15 { if _, _, ok := s.registryUser(registryRequest(db.AdminUsername, testAdminPassword, ip)); !ok { t.Fatalf("request %d rejected", i) } } hash, err := db.HashPassword("changed-password") if err != nil { t.Fatal(err) } if err := s.DB.SetPasswordHash(context.Background(), 1, &hash); err != nil { t.Fatal(err) } if _, _, ok := s.registryUser(registryRequest(db.AdminUsername, testAdminPassword, ip)); ok { t.Error("cached credentials survived a password change") } } func TestRegistryUserCountsParallelGuesses(t *testing.T) { s, _ := newTestServer(t) s.Cfg.RateLimitDisabled = false const ip = "198.51.100.8:1" var wg sync.WaitGroup for range 10 { wg.Go(func() { s.registryUser(registryRequest(db.AdminUsername, "wrong", ip)) }) } wg.Wait() if _, _, ok := s.registryUser(registryRequest(db.AdminUsername, testAdminPassword, ip)); ok { t.Error("correct password accepted after the limit was used up") } } func TestChallengeStoreEvictsOldestWhenFull(t *testing.T) { c := challengeStore{entries: map[string]challengeEntry{}} c.put("first", webauthn.SessionData{}) later := time.Now().Add(time.Hour) for i := range maxChallenges - 1 { c.entries[strconv.Itoa(i)] = challengeEntry{expires: later} } c.put("new", webauthn.SessionData{}) if _, ok := c.peek("new"); !ok { t.Error("new entry was refused") } if _, ok := c.peek("first"); ok { t.Error("oldest entry was not evicted") } if len(c.entries) != maxChallenges { t.Errorf("entries = %d, want %d", len(c.entries), maxChallenges) } }