Files
descrybe/apps/api/internal/httpapi/v1_auth_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

199 lines
6.4 KiB
Go

package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestExtractAPIKeyBearerAndHeader(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
r.Header.Set("Authorization", "Bearer dk_abc")
if got := extractAPIKey(r); got != "dk_abc" {
t.Fatalf("bearer: got %q", got)
}
r2 := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
r2.Header.Set("X-API-Key", "dk_xyz")
if got := extractAPIKey(r2); got != "dk_xyz" {
t.Fatalf("x-api-key: got %q", got)
}
r3 := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
r3.Header.Set("X-API-Key", "dk_header")
r3.Header.Set("Authorization", "Bearer dk_bearer")
if got := extractAPIKey(r3); got != "dk_bearer" {
t.Fatalf("bearer should win (legacy): got %q", got)
}
r3b := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
r3b.Header.Set("X-Api-Key", "dk_legacy_spelling")
if got := extractAPIKey(r3b); got != "dk_legacy_spelling" {
t.Fatalf("X-Api-Key: got %q", got)
}
r4 := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
if got := extractAPIKey(r4); got != "" {
t.Fatalf("missing key: got %q", got)
}
}
func TestParseLimitOffset(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/x?limit=10&offset=5", nil)
limit, offset := ParseLimitOffset(r)
if limit != 10 || offset != 5 {
t.Fatalf("got limit=%d offset=%d", limit, offset)
}
r2 := httptest.NewRequest(http.MethodGet, "/x?limit=999&offset=-1", nil)
limit, offset = ParseLimitOffset(r2)
if limit != maxPageLimit || offset != 0 {
t.Fatalf("caps: got limit=%d offset=%d want max=%d", limit, offset, maxPageLimit)
}
// Invalid / missing params are silently normalized (not 400).
r3 := httptest.NewRequest(http.MethodGet, "/x?limit=abc&offset=xyz", nil)
limit, offset = ParseLimitOffset(r3)
if limit != defaultPageLimit || offset != 0 {
t.Fatalf("invalid normalize: got limit=%d offset=%d", limit, offset)
}
r4 := httptest.NewRequest(http.MethodGet, "/x", nil)
limit, offset = ParseLimitOffset(r4)
if limit != defaultPageLimit || offset != 0 {
t.Fatalf("defaults: got limit=%d offset=%d", limit, offset)
}
}
func TestParseLimitOffsetMax(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/x?limit=1500", nil)
limit, _ := ParseLimitOffsetMax(r, maxTreePageLimit)
if limit != 1500 {
t.Fatalf("got limit=%d want 1500", limit)
}
limit, _ = ParseLimitOffsetMax(r, maxPageLimit)
if limit != maxPageLimit {
t.Fatalf("got limit=%d want %d", limit, maxPageLimit)
}
}
func TestPageSlice(t *testing.T) {
items := []int{1, 2, 3, 4, 5}
page, total := pageSlice(items, 2, 1)
if total != 5 || len(page) != 2 || page[0] != 2 || page[1] != 3 {
t.Fatalf("page=%v total=%d", page, total)
}
page, total = pageSlice(items, 10, 10)
if total != 5 || len(page) != 0 {
t.Fatalf("empty page expected, got %v total=%d", page, total)
}
}
func TestListCampaignsNilServicePreservesParsedLimit(t *testing.T) {
s := &Server{}
r := httptest.NewRequest(http.MethodGet, "/api/campaigns?limit=7&offset=3", nil)
w := httptest.NewRecorder()
s.handleListCampaigns(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `"limit":7`) || !strings.Contains(body, `"offset":3`) || !strings.Contains(body, `"total":0`) {
t.Fatalf("unexpected body %s", body)
}
}
func TestV1OpenAPIDocumentsPublicAPIAuth(t *testing.T) {
body := string(v1OpenAPIYAML)
for _, want := range []string{
"BearerAuth:",
"ApiKeyAuth:",
"name: X-API-Key",
"## Authentication",
"/settings?tab=api-keys",
"https://descrybe.io/api/v1",
"Use my API key",
"30 requests per minute",
"Retry-After",
"rate limit exceeded",
"code: unauthorized",
"LegacyAPIError",
"security: []",
} {
if !strings.Contains(body, want) {
t.Fatalf("OpenAPI missing auth doc %q", want)
}
}
if !strings.Contains(body, "Forbidden:") {
t.Fatal("OpenAPI missing Forbidden response component")
}
// Public probes must opt out of document-level API-key security.
if !strings.Contains(body, "/health:") || !strings.Contains(body, "/openapi.yaml:") {
t.Fatal("OpenAPI missing public health/openapi paths")
}
for _, heavy := range []string{
"/products/process:",
"/feeds/{id}/sync:",
"/feeds/{id}/extract-schema:",
"/feeds/{id}/sync-process-sample:",
"/export-feeds/{id}/generate:",
"/export-feeds/{id}/export-products:",
"/process:",
"/process/{id}/retry:",
} {
if !strings.Contains(body, heavy) {
t.Fatalf("OpenAPI missing heavy path %q", heavy)
}
}
// Heavy mutations document 429 via shared component.
if strings.Count(body, `"429": { $ref: "#/components/responses/TooManyRequests" }`) < 6 {
t.Fatalf("expected multiple TooManyRequests refs on heavy mutations, got %d",
strings.Count(body, `"429": { $ref: "#/components/responses/TooManyRequests" }`))
}
}
func TestV1OpenAPIRouteMounted(t *testing.T) {
s := &Server{}
r := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil)
w := httptest.NewRecorder()
s.handleV1OpenAPI(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status %d", w.Code)
}
if body := w.Body.String(); len(body) < 20 || body[:8] != "openapi:" {
t.Fatalf("unexpected body prefix %q", body[:min(20, len(body))])
}
if cc := w.Header().Get("Cache-Control"); !strings.Contains(cc, "max-age=") || !strings.Contains(cc, "stale-while-revalidate=") {
t.Fatalf("unexpected Cache-Control %q", cc)
}
if vary := w.Header().Get("Vary"); !strings.Contains(vary, "Accept-Encoding") {
t.Fatalf("unexpected Vary %q", vary)
}
etag := w.Header().Get("ETag")
if etag == "" {
t.Fatal("missing ETag")
}
r304 := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil)
r304.Header.Set("If-None-Match", etag)
w304 := httptest.NewRecorder()
s.handleV1OpenAPI(w304, r304)
if w304.Code != http.StatusNotModified {
t.Fatalf("If-None-Match status %d", w304.Code)
}
rGzip := httptest.NewRequest(http.MethodGet, "/api/v1/openapi.yaml", nil)
rGzip.Header.Set("Accept-Encoding", "gzip")
wGzip := httptest.NewRecorder()
s.handleV1OpenAPI(wGzip, rGzip)
if wGzip.Code != http.StatusOK {
t.Fatalf("gzip status %d", wGzip.Code)
}
if wGzip.Header().Get("Content-Encoding") != "gzip" {
t.Fatalf("expected Content-Encoding gzip, got %q", wGzip.Header().Get("Content-Encoding"))
}
if len(wGzip.Body.Bytes()) == 0 || wGzip.Body.Len() >= len(v1OpenAPIYAML) {
t.Fatalf("gzip body should be non-empty and smaller than raw (%d vs %d)", wGzip.Body.Len(), len(v1OpenAPIYAML))
}
}