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