Files
descrybe/apps/api/internal/httpapi/auth_session_integration_test.go
T
2026-08-17 09:33:07 +02:00

282 lines
9.2 KiB
Go

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())
}
}