273 lines
8.8 KiB
Go
273 lines
8.8 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)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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())
|
||
|
|
}
|
||
|
|
}
|