Three items from docs/improvement-audit.md §2: I-5 Verlauf pagination - AkteService.ListEvents now accepts a (before *uuid.UUID, limit int) cursor - SQL uses a composite (created_at, id) cursor subquery — stable across rows written in the same microsecond - Handler parses ?before=<uuid>&limit=<n>, service clamps to 200 - Frontend fetches first page (50) on init and exposes a "Mehr laden" / "Load more" button that keeps paging until the tail returns < page size - i18n keys akten.detail.verlauf.loadMore / .loadingMore in DE + EN I-8 patholo → paliad client-side rename with migrations - i18n.ts: STORAGE_KEY is now paliad-lang; one-shot migration reads the old patholo-lang value, writes the new key, deletes the old - sidebar.ts: same pattern for paliad-sidebar-pinned - Cookie rename with dual-read grace period: SessionCookieName is paliad_session, LegacySessionCookieName keeps patholo_session as read-only fallback. Requests using the legacy cookie get upgraded to paliad_session in the response; legacy cookie is expired in the same response. ClearAuthCookies clears both names to prevent stale-cookie resurrection. Remove the legacy fallback after 2026-05-18 (30d cookie max age). - handlers/links.go:extractEmailFromCookie reads either cookie name via auth.SessionCookieName / auth.LegacySessionCookieName P-6 Single source of truth for offices - New internal/offices package: Office struct + All + IsValid + Keys - akte_service.go switched from inline isValidOffice to offices.IsValid - GET /api/offices returns the list with DE + EN labels - Akte create form (akten-neu.tsx) has an empty <select>; the client TS fetches /api/offices and populates options, re-rendering on lang change Tests: - internal/offices/offices_test.go covers IsValid + Keys + label coverage - internal/auth: three new Middleware tests — legacy cookie still authenticates + upgrades the browser, new cookie wins when both are present (no clobber), missing cookie returns 401 on API paths Build: go build ./... + go vet ./... + go test ./... + bun run build all clean. Known out-of-scope: handlers/links.go still POSTs to public.patholo_link_* via PostgREST; migration 011 created fresh paliad.link_* tables but the handler refactor (move to direct DB, copy data, drop public tables) is a separate phase documented in that migration's header.
189 lines
5.7 KiB
Go
189 lines
5.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
// testSecret mirrors the format of a Supabase JWT signing key.
|
|
var testSecret = []byte("test-secret-for-hs256-verification-123")
|
|
|
|
func sign(t *testing.T, secret []byte, claims jwt.MapClaims) string {
|
|
t.Helper()
|
|
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
s, err := tok.SignedString(secret)
|
|
if err != nil {
|
|
t.Fatalf("sign: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func TestVerifyToken_Valid(t *testing.T) {
|
|
c := &Client{JWTSecret: testSecret}
|
|
token := sign(t, testSecret, jwt.MapClaims{
|
|
"sub": "11111111-1111-1111-1111-111111111111",
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
got, err := c.VerifyToken(token)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got.Sub != "11111111-1111-1111-1111-111111111111" {
|
|
t.Errorf("sub: got %q", got.Sub)
|
|
}
|
|
}
|
|
|
|
func TestVerifyToken_WrongSecret(t *testing.T) {
|
|
c := &Client{JWTSecret: testSecret}
|
|
token := sign(t, []byte("attacker-guessed-wrong"), jwt.MapClaims{
|
|
"sub": "11111111-1111-1111-1111-111111111111",
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
if _, err := c.VerifyToken(token); err == nil {
|
|
t.Fatal("expected error for wrong-signature token, got nil (auth bypass)")
|
|
}
|
|
}
|
|
|
|
func TestVerifyToken_Expired(t *testing.T) {
|
|
c := &Client{JWTSecret: testSecret}
|
|
token := sign(t, testSecret, jwt.MapClaims{
|
|
"sub": "11111111-1111-1111-1111-111111111111",
|
|
"exp": time.Now().Add(-time.Hour).Unix(),
|
|
})
|
|
_, err := c.VerifyToken(token)
|
|
if err == nil {
|
|
t.Fatal("expected error for expired token")
|
|
}
|
|
if !errors.Is(err, jwt.ErrTokenExpired) {
|
|
t.Errorf("expected ErrTokenExpired, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyToken_AlgNone(t *testing.T) {
|
|
c := &Client{JWTSecret: testSecret}
|
|
// An attacker might try alg=none to bypass signature checks.
|
|
tok := jwt.NewWithClaims(jwt.SigningMethodNone, jwt.MapClaims{
|
|
"sub": "22222222-2222-2222-2222-222222222222",
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
token, err := tok.SignedString(jwt.UnsafeAllowNoneSignatureType)
|
|
if err != nil {
|
|
t.Fatalf("sign none: %v", err)
|
|
}
|
|
if _, err := c.VerifyToken(token); err == nil {
|
|
t.Fatal("expected error for alg=none, got nil (critical bypass)")
|
|
}
|
|
}
|
|
|
|
func TestVerifyToken_MissingSub(t *testing.T) {
|
|
c := &Client{JWTSecret: testSecret}
|
|
token := sign(t, testSecret, jwt.MapClaims{
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
if _, err := c.VerifyToken(token); err == nil {
|
|
t.Fatal("expected error for missing sub claim")
|
|
}
|
|
}
|
|
|
|
func TestVerifyToken_Garbage(t *testing.T) {
|
|
c := &Client{JWTSecret: testSecret}
|
|
if _, err := c.VerifyToken("not.a.jwt"); err == nil {
|
|
t.Fatal("expected error for garbage token")
|
|
}
|
|
}
|
|
|
|
// TestMiddleware_LegacyCookieAccepted covers the patholo_session → paliad_session
|
|
// rename grace period: a user whose browser still holds the legacy cookie must
|
|
// stay authenticated and receive the new cookie in the response.
|
|
func TestMiddleware_LegacyCookieAccepted(t *testing.T) {
|
|
c := &Client{JWTSecret: testSecret}
|
|
token := sign(t, testSecret, jwt.MapClaims{
|
|
"sub": "11111111-1111-1111-1111-111111111111",
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
|
|
var nextHit bool
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
nextHit = true
|
|
if _, ok := verifiedClaimsFromContext(r.Context()); !ok {
|
|
t.Error("handler reached without verified claims in ctx")
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/api/anything", nil)
|
|
req.AddCookie(&http.Cookie{Name: LegacySessionCookieName, Value: token})
|
|
rec := httptest.NewRecorder()
|
|
c.Middleware(next).ServeHTTP(rec, req)
|
|
|
|
if !nextHit {
|
|
t.Fatalf("legacy cookie should authenticate; got status %d", rec.Code)
|
|
}
|
|
|
|
// Response should upgrade the caller to paliad_session and expire the legacy one.
|
|
var sawNew, sawLegacyExpiry bool
|
|
for _, cookie := range rec.Result().Cookies() {
|
|
if cookie.Name == SessionCookieName && cookie.Value == token && cookie.MaxAge > 0 {
|
|
sawNew = true
|
|
}
|
|
if cookie.Name == LegacySessionCookieName && cookie.MaxAge < 0 {
|
|
sawLegacyExpiry = true
|
|
}
|
|
}
|
|
if !sawNew {
|
|
t.Error("expected paliad_session to be set on upgrade path")
|
|
}
|
|
if !sawLegacyExpiry {
|
|
t.Error("expected patholo_session to be expired on upgrade path")
|
|
}
|
|
}
|
|
|
|
func TestMiddleware_NewCookiePreferredOverLegacy(t *testing.T) {
|
|
c := &Client{JWTSecret: testSecret}
|
|
good := sign(t, testSecret, jwt.MapClaims{
|
|
"sub": "aaaa",
|
|
"exp": time.Now().Add(time.Hour).Unix(),
|
|
})
|
|
bad := "garbage"
|
|
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest("GET", "/api/anything", nil)
|
|
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: good})
|
|
req.AddCookie(&http.Cookie{Name: LegacySessionCookieName, Value: bad})
|
|
rec := httptest.NewRecorder()
|
|
c.Middleware(next).ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 using paliad_session; got %d", rec.Code)
|
|
}
|
|
// When the new cookie authenticated the request, the legacy cookie must
|
|
// not be re-copied on top of it (would clobber a valid session with a
|
|
// stale one).
|
|
for _, cookie := range rec.Result().Cookies() {
|
|
if cookie.Name == SessionCookieName && cookie.Value == bad {
|
|
t.Fatal("legacy cookie value was copied over the valid paliad_session — rename upgrade is unsafe")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMiddleware_NoCookieRejected(t *testing.T) {
|
|
c := &Client{JWTSecret: testSecret}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Fatal("next should not run without auth")
|
|
})
|
|
req := httptest.NewRequest("GET", "/api/anything", nil)
|
|
rec := httptest.NewRecorder()
|
|
c.Middleware(next).ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("expected 401 on API without cookies, got %d", rec.Code)
|
|
}
|
|
}
|