Files
descrybe/apps/api/internal/httpapi/v1_csrf_tenant_test.go
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

200 lines
5.6 KiB
Go

package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/google/uuid"
)
func testAPIServer() *Server {
sm := scs.New()
sm.Cookie.Name = "descrybe_session"
return &Server{
Config: config.Config{
CSRFCookieName: "descrybe_csrf",
WebOrigin: "http://localhost:5173",
},
Sessions: sm,
Auth: &auth.Service{},
}
}
func TestRequireAPIKeyUnauthorizedWithoutKey(t *testing.T) {
t.Parallel()
s := testAPIServer()
h := s.RequireAPIKey(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/products", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `"code":"unauthorized"`) || !strings.Contains(body, `"message":"Unauthorized"`) {
t.Fatalf("want legacy coded error envelope, got %s", body)
}
}
func TestRequireAPIKeyBindsTenantContext(t *testing.T) {
t.Parallel()
companyID := uuid.MustParse("11111111-1111-1111-1111-111111111111")
userID := uuid.MustParse("22222222-2222-2222-2222-222222222222")
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), ctxUserID, userID)
ctx = context.WithValue(ctx, ctxCompanyID, companyID)
ctx = context.WithValue(ctx, ctxRole, "api")
cid, ok := CompanyIDFromContext(ctx)
if !ok || cid != companyID {
t.Fatalf("company binding failed: %v ok=%v", cid, ok)
}
uid, ok := UserIDFromContext(ctx)
if !ok || uid != userID {
t.Fatalf("user binding failed: %v ok=%v", uid, ok)
}
role, _ := RoleFromContext(ctx)
if role != "api" {
t.Fatalf("role = %q", role)
}
w.WriteHeader(http.StatusNoContent)
})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/products", nil))
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d", rec.Code)
}
}
func TestRouterV1POSTSkipsCSRFDashboardStillRequires(t *testing.T) {
t.Parallel()
s := testAPIServer()
h := s.Router()
// /api/v1 mutating call without CSRF cookie/header must not be 403 csrf;
// without a valid API key it should be 401 from RequireAPIKey.
v1 := httptest.NewRecorder()
reqV1 := httptest.NewRequest(http.MethodPost, "/api/v1/categories", nil)
h.ServeHTTP(v1, reqV1)
if v1.Code == http.StatusForbidden {
t.Fatalf("v1 must skip CSRF; got 403 body=%s", v1.Body.String())
}
if v1.Code != http.StatusUnauthorized {
t.Fatalf("v1 without API key status = %d, want 401", v1.Code)
}
dash := httptest.NewRecorder()
reqDash := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
h.ServeHTTP(dash, reqDash)
if dash.Code != http.StatusForbidden {
t.Fatalf("dashboard POST without CSRF status = %d, want 403", dash.Code)
}
}
func TestRouterV1OpenAPIAndHealthNoAPIKey(t *testing.T) {
t.Parallel()
s := testAPIServer()
h := s.Router()
openAPI := httptest.NewRecorder()
h.ServeHTTP(openAPI, httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil))
if openAPI.Code != http.StatusOK {
t.Fatalf("openapi status = %d", openAPI.Code)
}
health := httptest.NewRecorder()
h.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/api/v1/health", nil))
if health.Code != http.StatusOK {
t.Fatalf("v1 health status = %d", health.Code)
}
}
func TestRouterV1LegacyAliasesRequireAPIKey(t *testing.T) {
t.Parallel()
s := testAPIServer()
h := s.Router()
paths := []struct {
method string
path string
}{
{http.MethodPost, "/api/v1/products/process"},
{http.MethodGet, "/api/v1/products/process/11111111-1111-1111-1111-111111111111"},
{http.MethodPost, "/api/v1/categories/create"},
{http.MethodPost, "/api/v1/attributes/create"},
{http.MethodPost, "/api/v1/process"},
}
for _, tc := range paths {
rec := httptest.NewRecorder()
req := httptest.NewRequest(tc.method, tc.path, nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s %s status = %d, want 401", tc.method, tc.path, rec.Code)
}
}
}
func TestV1OpenAPIIncludesProcessAndFeeds(t *testing.T) {
t.Parallel()
body := string(v1OpenAPIYAML)
for _, needle := range []string{
"/products/process:",
"/process:",
"/feeds:",
"/categories/create:",
"/attributes/create:",
"raw_product_ids",
"items[].ean",
"process_id",
"LegacyStartProcessByEAN",
"StartProcessByRawIDs",
"LegacyProcessCompleted",
"X-API-Key",
"https://descrybe.io/api/v1",
"BearerAuth",
"ApiKeyAuth",
"Use my API key",
"Settings -> API keys",
"mapped_total",
"active_total",
"needs_review",
"HealthStatus",
"maintenance",
"read_only",
"FeedListResponse",
"ProductListResponse",
"PresentProduct",
"ProductQualityListResponse",
"/products/quality:",
"Wireless earbuds",
"ProcessingJobAccepted",
// Team/Admin paths are dashboard OFF_SURFACE (session+CSRF under /api),
// not part of the public API-key contract needles for this doc.
"ReissueSetPasswordInvite",
"cannot demote the last admin",
"skipped_synthetic",
"SessionCookie",
"CSRFHeader",
"code: unauthorized",
"message: Unauthorized",
"legacy envelope",
"/process/{id}/retry:",
} {
if !strings.Contains(body, needle) {
t.Fatalf("openapi missing %q", needle)
}
}
// Dual-mode: legacy EAN path must not be described as an alias of /process.
if strings.Contains(body, "Alias of POST /process") {
t.Fatal("openapi still treats /products/process as alias of /process")
}
}