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