package httpapi import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/auth" "github.com/descrybe/descrybe-v2/apps/api/internal/billing" "github.com/descrybe/descrybe-v2/apps/api/internal/catalog" "github.com/descrybe/descrybe-v2/apps/api/internal/config" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) // TestAuthSessionCoreEndpoints exercises login/me/select-company/company/api-keys/logout // with semi-real fixtures against a live DATABASE_URL (skips when unset). func TestAuthSessionCoreEndpoints(t *testing.T) { dsn := strings.TrimSpace(os.Getenv("DATABASE_URL")) if dsn == "" { t.Skip("DATABASE_URL not set") } ctx := t.Context() pg, err := pgxpool.New(ctx, dsn) if err != nil { t.Fatalf("postgres: %v", err) } t.Cleanup(pg.Close) companyID := uuid.New() userID := uuid.New() prefix := companyID.String()[:8] email := fmt.Sprintf("auth-smoke-%s@example.test", prefix) password := "AuthSmoke123!" hash, err := auth.HashPassword(password) if err != nil { t.Fatalf("hash password: %v", err) } _, err = pg.Exec(ctx, `INSERT INTO companies (id, name, language) VALUES ($1, $2, 'en')`, companyID, "Auth Smoke Co "+prefix) if err != nil { t.Fatalf("seed company: %v", err) } _, err = pg.Exec(ctx, ` INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active) VALUES ($1, $2, $3, $4, false, false, true)`, userID, email, "Auth Smoke", hash) if err != nil { t.Fatalf("seed user: %v", err) } _, err = pg.Exec(ctx, ` INSERT INTO memberships (company_id, user_id, role, status) VALUES ($1, $2, 'admin', 'active')`, companyID, userID) if err != nil { t.Fatalf("seed membership: %v", err) } _, err = pg.Exec(ctx, `INSERT INTO credit_balances (company_id) VALUES ($1) ON CONFLICT DO NOTHING`, companyID) if err != nil { t.Fatalf("seed credits: %v", err) } // Free defaults deny settings.api_keys / capability.api_access; Starter+ matches prod gate. billingSvc := &billing.Service{Pool: pg} if err := billingSvc.EnsureDefaultPlans(ctx); err != nil { t.Fatalf("ensure plans: %v", err) } starterID, err := billingSvc.PlanIDByName(ctx, "Starter") if err != nil { t.Fatalf("starter plan: %v", err) } assigned, err := billingSvc.AssignPlanIfMissing(ctx, companyID, starterID) if err != nil { t.Fatalf("assign starter: %v", err) } if !assigned { t.Fatal("expected Starter plan assignment for api-key entitlement") } t.Cleanup(func() { cleanupCtx := t.Context() _, _ = pg.Exec(cleanupCtx, `DELETE FROM api_keys WHERE company_id = $1 OR user_id = $2`, companyID, userID) _, _ = pg.Exec(cleanupCtx, `DELETE FROM company_plans WHERE company_id = $1`, companyID) _, _ = pg.Exec(cleanupCtx, `DELETE FROM memberships WHERE company_id = $1 OR user_id = $2`, companyID, userID) _, _ = pg.Exec(cleanupCtx, `DELETE FROM credit_balances WHERE company_id = $1`, companyID) _, _ = pg.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, userID) _, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID) }) sessions := auth.NewSessionManager(pg, "descrybe_session", "", false, 24) s := &Server{ Config: config.Config{ CSRFCookieName: "descrybe_csrf", WebOrigin: "http://localhost:5174", SessionSecure: false, LowCreditsThreshold: 100, TokenSigningSecret: "test-token-signing-secret-32chars!!", }, Pool: pg, Sessions: sessions, Auth: &auth.Service{Pool: pg}, Billing: &billing.Service{Pool: pg}, Catalog: &catalog.Service{Pool: pg}, } h := s.Router() jar := map[string]string{} collectCookies := func(rec *httptest.ResponseRecorder) { for _, c := range rec.Result().Cookies() { if c.MaxAge < 0 || (c.Expires.Before(time.Now()) && !c.Expires.IsZero()) { delete(jar, c.Name) continue } if c.Value != "" { jar[c.Name] = c.Value } } } applyCookies := func(req *http.Request) { for name, value := range jar { req.AddCookie(&http.Cookie{Name: name, Value: value}) } } do := func(method, path, body string, withCSRF bool) *httptest.ResponseRecorder { var req *http.Request if body == "" { req = httptest.NewRequest(method, path, nil) } else { req = httptest.NewRequest(method, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") } req.RemoteAddr = "127.0.0.1:34567" applyCookies(req) if withCSRF { csrf := jar["descrybe_csrf"] if csrf == "" { t.Fatal("missing CSRF cookie before mutating request") } req.Header.Set("X-CSRF-Token", csrf) } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) collectCookies(rec) return rec } decode := func(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { t.Helper() var out map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { t.Fatalf("json status=%d body=%s err=%v", rec.Code, rec.Body.String(), err) } return out } // Seed CSRF via unauthenticated /me (401 expected). rec := do(http.MethodGet, "/api/auth/me", "", false) if rec.Code != http.StatusUnauthorized { t.Fatalf("unauth me status=%d body=%s", rec.Code, rec.Body.String()) } if jar["descrybe_csrf"] == "" { t.Fatal("expected descrybe_csrf cookie") } // Login without CSRF → 403. rec = do(http.MethodPost, "/api/auth/login", fmt.Sprintf(`{"email":%q,"password":%q}`, email, password), false) if rec.Code != http.StatusForbidden { t.Fatalf("login without csrf status=%d body=%s", rec.Code, rec.Body.String()) } // Bad password → 401. rec = do(http.MethodPost, "/api/auth/login", fmt.Sprintf(`{"email":%q,"password":"WrongPass999!"}`, email), true) if rec.Code != http.StatusUnauthorized { t.Fatalf("bad password status=%d body=%s", rec.Code, rec.Body.String()) } // Successful login. rec = do(http.MethodPost, "/api/auth/login", fmt.Sprintf(`{"email":%q,"password":%q}`, email, password), true) if rec.Code != http.StatusOK { t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String()) } login := decode(t, rec) if fmt.Sprint(login["company_id"]) != companyID.String() { t.Fatalf("login company_id=%v want %s", login["company_id"], companyID) } userObj, _ := login["user"].(map[string]any) if fmt.Sprint(userObj["email"]) != email { t.Fatalf("login email=%v", userObj["email"]) } if jar["descrybe_session"] == "" { t.Fatal("expected session cookie after login") } // Me. rec = do(http.MethodGet, "/api/auth/me", "", false) if rec.Code != http.StatusOK { t.Fatalf("me status=%d body=%s", rec.Code, rec.Body.String()) } me := decode(t, rec) if fmt.Sprint(me["active_company_id"]) != companyID.String() { t.Fatalf("active_company_id=%v", me["active_company_id"]) } if _, ok := me["credits"]; !ok { t.Fatalf("me missing credits: %v", me) } // Select company (same id). rec = do(http.MethodPost, "/api/auth/select-company", fmt.Sprintf(`{"company_id":%q}`, companyID.String()), true) if rec.Code != http.StatusOK { t.Fatalf("select-company status=%d body=%s", rec.Code, rec.Body.String()) } // Core tenant routes. rec = do(http.MethodGet, "/api/company", "", false) if rec.Code != http.StatusOK { t.Fatalf("company status=%d body=%s", rec.Code, rec.Body.String()) } co := decode(t, rec) if !strings.Contains(fmt.Sprint(co["name"]), "Auth Smoke Co") { t.Fatalf("company name=%v", co["name"]) } rec = do(http.MethodGet, "/api/billing/credits", "", false) if rec.Code != http.StatusOK { t.Fatalf("billing credits status=%d body=%s", rec.Code, rec.Body.String()) } rec = do(http.MethodGet, "/api/api-keys", "", false) if rec.Code != http.StatusOK { t.Fatalf("list api-keys status=%d body=%s", rec.Code, rec.Body.String()) } rec = do(http.MethodPost, "/api/api-keys", `{"name":"auth-smoke-key"}`, true) if rec.Code != http.StatusCreated { t.Fatalf("create api-key status=%d body=%s", rec.Code, rec.Body.String()) } created := decode(t, rec) rawKey := fmt.Sprint(created["key"]) keyID := fmt.Sprint(created["id"]) if !strings.HasPrefix(rawKey, "dk_") || keyID == "" { t.Fatalf("create api-key payload=%v", created) } rec = do(http.MethodPatch, "/api/api-keys/"+keyID, `{"name":"auth-smoke-key-renamed"}`, true) if rec.Code != http.StatusOK { t.Fatalf("rename api-key status=%d body=%s", rec.Code, rec.Body.String()) } renamed := decode(t, rec) if fmt.Sprint(renamed["name"]) != "auth-smoke-key-renamed" { t.Fatalf("rename api-key name=%v", renamed["name"]) } // Public v1 with the new key (CSRF skipped). v1 := httptest.NewRecorder() v1Req := httptest.NewRequest(http.MethodGet, "/api/v1/products?limit=1", nil) v1Req.Header.Set("Authorization", "Bearer "+rawKey) h.ServeHTTP(v1, v1Req) if v1.Code != http.StatusOK { t.Fatalf("v1 products status=%d body=%s", v1.Code, v1.Body.String()) } rec = do(http.MethodDelete, "/api/api-keys/"+keyID, "", true) if rec.Code != http.StatusOK { t.Fatalf("revoke api-key status=%d body=%s", rec.Code, rec.Body.String()) } rec = do(http.MethodPost, "/api/auth/logout", `{}`, true) if rec.Code != http.StatusOK { t.Fatalf("logout status=%d body=%s", rec.Code, rec.Body.String()) } rec = do(http.MethodGet, "/api/auth/me", "", false) if rec.Code != http.StatusUnauthorized { t.Fatalf("me after logout status=%d body=%s", rec.Code, rec.Body.String()) } }