package api import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "gitea.stevedudenhoeffer.com/steve/pansy/internal/config" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service" "gitea.stevedudenhoeffer.com/steve/pansy/internal/store" ) // authEngine builds an API engine backed by a fresh in-memory database. func authEngine(t *testing.T, cfg *config.Config) *gin.Engine { t.Helper() gin.SetMode(gin.TestMode) db, err := store.Open(":memory:") if err != nil { t.Fatalf("store.Open: %v", err) } t.Cleanup(func() { db.Close() }) if err := db.Migrate(context.Background()); err != nil { t.Fatalf("Migrate: %v", err) } return New(cfg, service.New(db, cfg)) } func localCfg() *config.Config { return &config.Config{Registration: config.RegistrationOpen, LocalAuth: true} } // encodeBody JSON-encodes v into a reader for a request body. func encodeBody(t *testing.T, v any) *bytes.Buffer { t.Helper() var buf bytes.Buffer if v != nil { if err := json.NewEncoder(&buf).Encode(v); err != nil { t.Fatalf("encode body: %v", err) } } return &buf } // doJSON issues a JSON request, optionally carrying a session cookie. func doJSON(t *testing.T, r *gin.Engine, method, path string, body any, cookie *http.Cookie) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, path, encodeBody(t, body)) req.Header.Set("Content-Type", "application/json") if cookie != nil { req.AddCookie(cookie) } w := httptest.NewRecorder() r.ServeHTTP(w, req) return w } // sessionCookieFrom extracts the pansy_session cookie from a response. func sessionCookieFrom(t *testing.T, w *httptest.ResponseRecorder) *http.Cookie { t.Helper() for _, ck := range w.Result().Cookies() { if ck.Name == sessionCookie { return ck } } t.Fatalf("no %s cookie set (status %d, body %s)", sessionCookie, w.Code, w.Body.String()) return nil } func TestRegisterLoginMeLogoutFlow(t *testing.T) { r := authEngine(t, localCfg()) // Register auto-logs-in and sets a cookie. w := doJSON(t, r, http.MethodPost, "/api/v1/auth/register", map[string]string{"email": "a@example.com", "displayName": "Alice", "password": "password123"}, nil) if w.Code != http.StatusOK { t.Fatalf("register status = %d, body %s", w.Code, w.Body.String()) } cookie := sessionCookieFrom(t, w) if !cookie.HttpOnly { t.Error("session cookie must be HttpOnly") } if cookie.SameSite != http.SameSiteLaxMode { t.Errorf("session cookie SameSite = %v, want Lax", cookie.SameSite) } if cookie.Secure { t.Error("session cookie must not be Secure without an https base URL") } // /me with the cookie returns the user; password hash is never serialized. w = doJSON(t, r, http.MethodGet, "/api/v1/auth/me", nil, cookie) if w.Code != http.StatusOK { t.Fatalf("me status = %d, body %s", w.Code, w.Body.String()) } if strings.Contains(w.Body.String(), "password") { t.Errorf("/me leaked a password field: %s", w.Body.String()) } // Logout clears the cookie and invalidates the session. w = doJSON(t, r, http.MethodPost, "/api/v1/auth/logout", nil, cookie) if w.Code != http.StatusOK { t.Fatalf("logout status = %d", w.Code) } w = doJSON(t, r, http.MethodGet, "/api/v1/auth/me", nil, cookie) if w.Code != http.StatusUnauthorized { t.Errorf("me after logout status = %d, want 401", w.Code) } } func TestMeRequiresAuth(t *testing.T) { r := authEngine(t, localCfg()) w := doJSON(t, r, http.MethodGet, "/api/v1/auth/me", nil, nil) if w.Code != http.StatusUnauthorized { t.Errorf("me without cookie status = %d, want 401", w.Code) } } func TestLoginWrongPasswordIs401(t *testing.T) { r := authEngine(t, localCfg()) doJSON(t, r, http.MethodPost, "/api/v1/auth/register", map[string]string{"email": "a@example.com", "displayName": "Alice", "password": "password123"}, nil) w := doJSON(t, r, http.MethodPost, "/api/v1/auth/login", map[string]string{"email": "a@example.com", "password": "wrong"}, nil) if w.Code != http.StatusUnauthorized { t.Errorf("bad login status = %d, want 401", w.Code) } } func TestRegisterValidationRejectsShortPassword(t *testing.T) { r := authEngine(t, localCfg()) w := doJSON(t, r, http.MethodPost, "/api/v1/auth/register", map[string]string{"email": "a@example.com", "displayName": "Alice", "password": "short"}, nil) if w.Code != http.StatusBadRequest { t.Errorf("short-password register status = %d, want 400", w.Code) } } func TestProvidersEndpoint(t *testing.T) { r := authEngine(t, localCfg()) w := doJSON(t, r, http.MethodGet, "/api/v1/auth/providers", nil, nil) if w.Code != http.StatusOK { t.Fatalf("providers status = %d", w.Code) } var got struct { Local bool `json:"local"` OIDC bool `json:"oidc"` } if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { t.Fatalf("decode providers: %v", err) } if !got.Local || got.OIDC { t.Errorf("providers = %+v, want local=true oidc=false", got) } } func TestAuthenticatedRequestRefreshesCookie(t *testing.T) { // requireAuth must re-set the session cookie so the browser's Max-Age slides // with the server session rather than expiring 30 days after login. r := authEngine(t, localCfg()) w := doJSON(t, r, http.MethodPost, "/api/v1/auth/register", map[string]string{"email": "a@example.com", "displayName": "Alice", "password": "password123"}, nil) cookie := sessionCookieFrom(t, w) w = doJSON(t, r, http.MethodGet, "/api/v1/auth/me", nil, cookie) if w.Code != http.StatusOK { t.Fatalf("me status = %d", w.Code) } // A fresh session cookie should be present on the /me response. refreshed := sessionCookieFrom(t, w) if refreshed.Value != cookie.Value { t.Errorf("refreshed cookie value changed: got %q want same token", refreshed.Value) } if refreshed.MaxAge <= 0 { t.Errorf("refreshed cookie Max-Age = %d, want > 0", refreshed.MaxAge) } } func TestCSRFGuardRejectsCrossOrigin(t *testing.T) { cfg := localCfg() cfg.BaseURL = "https://pansy.example.com" r := authEngine(t, cfg) body := map[string]string{"email": "a@example.com", "displayName": "Alice", "password": "password123"} // Cross-origin POST is rejected before touching the service. req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", encodeBody(t, body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", "https://evil.example.com") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusForbidden { t.Fatalf("cross-origin register status = %d, want 403", w.Code) } // Same-origin POST is allowed. req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", encodeBody(t, body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", "https://pansy.example.com") w = httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("same-origin register status = %d, want 200 (body %s)", w.Code, w.Body.String()) } } func TestCSRFGuardNoopWithoutBaseURL(t *testing.T) { // In local dev (no PANSY_BASE_URL) the guard must not interfere, even with a // foreign Origin (the Vite dev proxy forwards the browser's origin). r := authEngine(t, localCfg()) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", encodeBody(t, map[string]string{"email": "a@example.com", "displayName": "Alice", "password": "password123"})) req.Header.Set("Content-Type", "application/json") req.Header.Set("Origin", "http://localhost:5173") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("dev cross-origin register status = %d, want 200", w.Code) } } func TestSecureCookieWithHTTPSBaseURL(t *testing.T) { cfg := localCfg() cfg.BaseURL = "https://pansy.example.com" r := authEngine(t, cfg) w := doJSON(t, r, http.MethodPost, "/api/v1/auth/register", map[string]string{"email": "a@example.com", "displayName": "Alice", "password": "password123"}, nil) if w.Code != http.StatusOK { t.Fatalf("register status = %d", w.Code) } if !sessionCookieFrom(t, w).Secure { t.Error("session cookie should be Secure when base URL is https") } }