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.
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user