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} } // 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() var buf bytes.Buffer if body != nil { if err := json.NewEncoder(&buf).Encode(body); err != nil { t.Fatalf("encode body: %v", err) } } req := httptest.NewRequest(method, path, &buf) 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 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") } }