2026-08-09 22:47:43 +02:00
|
|
|
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",
|
2026-08-17 21:20:45 +02:00
|
|
|
"StartProcessByEAN",
|
2026-08-09 22:47:43 +02:00
|
|
|
"StartProcessByRawIDs",
|
2026-08-17 21:20:45 +02:00
|
|
|
"ProcessCompletedExample",
|
2026-08-09 22:47:43 +02:00
|
|
|
"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",
|
2026-08-17 21:20:45 +02:00
|
|
|
"coded envelope",
|
2026-08-09 22:47:43 +02:00
|
|
|
"/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")
|
|
|
|
|
}
|
|
|
|
|
}
|