package httpapi import ( "fmt" "net/http" "net/http/httptest" "regexp" "strings" "testing" "gopkg.in/yaml.v3" ) func TestV1OpenAPIYAMLParses(t *testing.T) { t.Parallel() root := mustParseOpenAPIRoot(t, v1OpenAPIYAML) if root["openapi"] == nil { t.Fatal("missing openapi version field") } if root["paths"] == nil { t.Fatal("missing paths field") } } // TestV1OpenAPIYAMLDefaultServerIsProduction ensures public docs default to // Descrybe-hosted https://descrybe.io/api/v1 (customers do not self-host the API). // Verified against legacy openapi + descrybe-api-documentation.md; no api.descrybe.io. func TestV1OpenAPIYAMLDefaultServerIsProduction(t *testing.T) { t.Parallel() root := mustParseOpenAPIRoot(t, v1OpenAPIYAML) servers, ok := root["servers"].([]any) if !ok || len(servers) == 0 { t.Fatal("missing servers list") } first, ok := servers[0].(map[string]any) if !ok { t.Fatalf("servers[0] must be a mapping, got %T", servers[0]) } url, _ := first["url"].(string) if url != "https://descrybe.io/api/v1" { t.Fatalf("servers[0].url = %q, want https://descrybe.io/api/v1", url) } } // TestV1OpenAPIYAMLHandlerServesValidYAML ensures GET /api/v1/openapi.yaml body // (what Scalar/js-yaml consumes) parses with a real YAML parser. func TestV1OpenAPIYAMLHandlerServesValidYAML(t *testing.T) { t.Parallel() 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) } ct := w.Header().Get("Content-Type") if !strings.Contains(ct, "yaml") { t.Fatalf("Content-Type=%q, want yaml", ct) } _ = mustParseOpenAPIRoot(t, w.Body.Bytes()) } // TestV1OpenAPIYAMLNoUnquotedBraceProse guards against Scalar/js-yaml failures like: // // YAMLParseError: Nested mappings are not allowed in compact mappings // // which happen when an unquoted description/summary/title/example contains `{ key: ... }` mid-prose. // Also rejects unquoted `[]`/`{}` fragments, `: ` sequences, and `#` that are not a pure flow node. func TestV1OpenAPIYAMLNoUnquotedBraceProse(t *testing.T) { t.Parallel() lineRe := regexp.MustCompile(`^(\s*)(description|summary|title|example):\s*(.*)$`) pureFlowRe := regexp.MustCompile(`^(\{[^}]*\}|\[[^\]]*\])$`) nestedColonRe := regexp.MustCompile(`\{[^}\n]*:`) bracketRe := regexp.MustCompile(`[{}\[\]]`) var bad []string for i, line := range strings.Split(string(v1OpenAPIYAML), "\n") { m := lineRe.FindStringSubmatch(line) if m == nil { continue } val := strings.TrimSpace(m[3]) if val == "" || val == "|" || val == ">" || val == "|-" || val == ">-" || val == "|+" || val == ">+" { continue } if strings.HasPrefix(val, `"`) || strings.HasPrefix(val, "'") { continue } if pureFlowRe.MatchString(val) { continue } reason := "" switch { case nestedColonRe.MatchString(val): reason = "{ key: } prose" case bracketRe.MatchString(val): reason = "unquoted []/{}" case strings.Contains(val, ": "): reason = "unquoted ': ' sequence" case strings.Contains(val, "#"): reason = "unquoted #" } if reason == "" { continue } trimmed := strings.TrimRight(line, "\r") if len(trimmed) > 160 { trimmed = trimmed[:160] + "…" } bad = append(bad, fmt.Sprintf("line %d (%s): %s", i+1, reason, strings.TrimSpace(trimmed))) } if len(bad) > 0 { t.Fatalf("unquoted description/summary/title/example prose breaks YAML parsers:\n%s", strings.Join(bad, "\n")) } } func TestV1OpenAPIYAMLRapiDocCompatibleExamples(t *testing.T) { t.Parallel() root := mustParseOpenAPIRoot(t, v1OpenAPIYAML) var bad []string collectRapiDocUnsafeExamples(root, "root", &bad) if len(bad) > 0 { t.Fatalf("RapiDoc standardizeExample throws on singular example objects with null fields; wrap as examples.*.value:\n%s", strings.Join(bad, "\n")) } } func collectRapiDocUnsafeExamples(node any, path string, bad *[]string) { switch v := node.(type) { case map[string]any: if ex, ok := v["example"]; ok { if m, ok := ex.(map[string]any); ok { if _, hasValue := m["value"]; !hasValue { for key, val := range m { if val == nil { *bad = append(*bad, fmt.Sprintf("%s.example.%s is null", path, key)) } } } } } for key, child := range v { collectRapiDocUnsafeExamples(child, path+"."+key, bad) } case []any: for i, child := range v { collectRapiDocUnsafeExamples(child, fmt.Sprintf("%s[%d]", path, i), bad) } } } func mustParseOpenAPIRoot(t *testing.T, raw []byte) map[string]any { t.Helper() var doc any if err := yaml.Unmarshal(raw, &doc); err != nil { t.Fatalf("openapi YAML must parse: %v", err) } root, ok := doc.(map[string]any) if !ok { t.Fatalf("openapi root must be a mapping, got %T", doc) } return root } func TestV1OpenAPIYAMLNoTabs(t *testing.T) { t.Parallel() for i, line := range strings.Split(string(v1OpenAPIYAML), "\n") { if strings.Contains(line, "\t") { t.Fatalf("tabs break YAML indentation (line %d)", i+1) } } } func TestV1OpenAPIYAMLNoDuplicateKeys(t *testing.T) { t.Parallel() var root yaml.Node if err := yaml.Unmarshal(v1OpenAPIYAML, &root); err != nil { t.Fatalf("parse: %v", err) } var bad []string collectDuplicateYAMLKeys(&root, "", &bad) if len(bad) > 0 { t.Fatalf("duplicate YAML keys:\n%s", strings.Join(bad, "\n")) } } func collectDuplicateYAMLKeys(n *yaml.Node, path string, bad *[]string) { if n == nil { return } switch n.Kind { case yaml.DocumentNode: for _, c := range n.Content { collectDuplicateYAMLKeys(c, path, bad) } case yaml.MappingNode: seen := make(map[string]int, len(n.Content)/2) for i := 0; i+1 < len(n.Content); i += 2 { k := n.Content[i] v := n.Content[i+1] key := k.Value childPath := path + "/" + key if prev, ok := seen[key]; ok { *bad = append(*bad, fmt.Sprintf("%s (lines %d and %d)", childPath, prev, k.Line)) } else { seen[key] = k.Line } collectDuplicateYAMLKeys(v, childPath, bad) } case yaml.SequenceNode: for i, c := range n.Content { collectDuplicateYAMLKeys(c, fmt.Sprintf("%s/%d", path, i), bad) } } } func TestV1OpenAPIYAMLRefsResolve(t *testing.T) { t.Parallel() var doc map[string]any if err := yaml.Unmarshal(v1OpenAPIYAML, &doc); err != nil { t.Fatalf("parse: %v", err) } comps, _ := doc["components"].(map[string]any) if comps == nil { t.Fatal("missing components") } var missing []string walkOpenAPIRefs(doc, comps, &missing) if len(missing) > 0 { t.Fatalf("unresolved $ref targets:\n%s", strings.Join(missing, "\n")) } } func walkOpenAPIRefs(node any, comps map[string]any, missing *[]string) { switch v := node.(type) { case map[string]any: if ref, ok := v["$ref"].(string); ok { if err := resolveComponentRef(ref, comps); err != nil { *missing = append(*missing, err.Error()) } } for _, child := range v { walkOpenAPIRefs(child, comps, missing) } case []any: for _, child := range v { walkOpenAPIRefs(child, comps, missing) } } } func resolveComponentRef(ref string, comps map[string]any) error { const prefix = "#/components/" if !strings.HasPrefix(ref, prefix) { return fmt.Errorf("%q: only local #/components/… refs are supported", ref) } rest := strings.TrimPrefix(ref, prefix) section, name, ok := strings.Cut(rest, "/") if !ok || section == "" || name == "" { return fmt.Errorf("%q: bad component ref format", ref) } name = strings.ReplaceAll(strings.ReplaceAll(name, "~1", "/"), "~0", "~") bucket, _ := comps[section].(map[string]any) if bucket == nil { return fmt.Errorf("%q: unknown components section %q", ref, section) } if _, ok := bucket[name]; !ok { return fmt.Errorf("%q: missing target", ref) } return nil } // TestV1OpenAPIYAMLSecuredOpsDocumentErrors ensures every public v1 operation // documents realistic error responses via shared components (401/403/404/409/422/429/500). func TestV1OpenAPIYAMLSecuredOpsDocumentErrors(t *testing.T) { t.Parallel() root := mustParseOpenAPIRoot(t, v1OpenAPIYAML) comps, _ := root["components"].(map[string]any) responses, _ := comps["responses"].(map[string]any) for _, name := range []string{ "Unauthorized", "Forbidden", "NotFound", "BadRequest", "ValidationError", "Conflict", "TooManyRequests", "InternalServerError", } { if _, ok := responses[name]; !ok { t.Fatalf("missing components.responses.%s", name) } } schemas, _ := comps["schemas"].(map[string]any) if _, ok := schemas["FlatAPIError"]; !ok { t.Fatal("missing components.schemas.FlatAPIError") } paths, _ := root["paths"].(map[string]any) rootSec := root["security"] var bad []string for path, raw := range paths { item, _ := raw.(map[string]any) for method, opRaw := range item { switch method { case "get", "post", "put", "patch", "delete": default: continue } op, _ := opRaw.(map[string]any) sec := op["security"] if sec == nil { sec = rootSec } unauth := false if sl, ok := sec.([]any); ok && len(sl) == 0 { unauth = true } resp, _ := op["responses"].(map[string]any) codes := make(map[string]bool, len(resp)) for c := range resp { codes[c] = true } need := map[string]bool{"500": true} if !unauth { need["401"] = true if strings.Contains(path, "{") { need["403"] = true need["404"] = true need["400"] = true need["422"] = true } if method == "post" || method == "put" || method == "patch" { need["400"] = true need["422"] = true } if isOpenAPIHeavyMutation(path, method) { need["429"] = true } if strings.HasPrefix(path, "/team") || strings.HasPrefix(path, "/admin") { need["403"] = true need["409"] = true } } for code := range need { if !codes[code] { bad = append(bad, fmt.Sprintf("%s %s missing %s", strings.ToUpper(method), path, code)) } } } } if len(bad) > 0 { t.Fatalf("incomplete error responses:\n%s", strings.Join(bad, "\n")) } } func isOpenAPIHeavyMutation(path, method string) bool { if method != "post" { return false } switch path { case "/process", "/products/process": return true } if strings.HasSuffix(path, "/sync-process-sample") || strings.HasSuffix(path, "/extract-schema") || strings.HasSuffix(path, "/generate") || strings.HasSuffix(path, "/export-products") { return true } if strings.HasSuffix(path, "/retry") && strings.Contains(path, "/process/") { return true } return strings.HasSuffix(path, "/sync") && strings.Contains(path, "/feeds/") } // TestV1OpenAPIYAMLProcessDualIDsAndGatesDocumentsContracts locks the // products/process dual-ID + plan-gate docs against PresentProduct.id footguns. func TestV1OpenAPIYAMLProcessDualIDsAndGatesDocumentsContracts(t *testing.T) { t.Parallel() doc := string(v1OpenAPIYAML) for _, needle := range []string{ "Dual IDs (do not confuse)", "never PresentProduct.id", "data[].raw_product_id", "assertV1ProcessGates runs before EnsureRaw", "normalize_only", "plan_gate", "feature_disabled", } { if !strings.Contains(doc, needle) { t.Fatalf("openapi missing process dual-id/gates contract text %q", needle) } } root := mustParseOpenAPIRoot(t, v1OpenAPIYAML) comps, _ := root["components"].(map[string]any) schemas, _ := comps["schemas"].(map[string]any) present, _ := schemas["PresentProduct"].(map[string]any) props, _ := present["properties"].(map[string]any) if _, ok := props["raw_product_id"]; !ok { t.Fatal("PresentProduct must document raw_product_id for dual-mode clients") } paths, _ := root["paths"].(map[string]any) processPath, _ := paths["/products/process"].(map[string]any) post, _ := processPath["post"].(map[string]any) resps, _ := post["responses"].(map[string]any) if _, ok := resps["402"]; !ok { t.Fatal("POST /products/process must document HTTP 402 plan gates") } } // TestV1OpenAPIYAMLProcessItemEprelShape locks ProcessItem.eprel // to the live nested object from eprel.MergeInto / extractEPRELFromAttrs. func TestV1OpenAPIYAMLProcessItemEprelShape(t *testing.T) { t.Parallel() root := mustParseOpenAPIRoot(t, v1OpenAPIYAML) comps, _ := root["components"].(map[string]any) schemas, _ := comps["schemas"].(map[string]any) item, _ := schemas["ProcessItem"].(map[string]any) itemProps, _ := item["properties"].(map[string]any) eprel, _ := itemProps["eprel"].(map[string]any) eprelProps, _ := eprel["properties"].(map[string]any) if eprelProps == nil { t.Fatal("ProcessItem.eprel.properties missing") } want := []string{"id", "label", "pdf", "energy_class", "energy_scale"} for _, key := range want { if _, ok := eprelProps[key]; !ok { t.Fatalf("ProcessItem.eprel missing property %q (live shape)", key) } } if len(eprelProps) != len(want) { keys := make([]string, 0, len(eprelProps)) for k := range eprelProps { keys = append(keys, k) } t.Fatalf("ProcessItem.eprel properties = %v, want exactly %v", keys, want) } } func TestV1OpenAPIYAMLNoLegacyWording(t *testing.T) { t.Parallel() assertOpenAPIYAMLOmitsToken(t, "legacy") } func TestV1OpenAPIYAMLNoA1Wording(t *testing.T) { t.Parallel() // Case-sensitive: lowercase "a1" appears in UUIDs and must stay allowed. assertOpenAPIYAMLOmitsToken(t, "A1") } func TestV1OpenAPIYAMLOmitsInternalPromptWording(t *testing.T) { t.Parallel() for _, token := range []string{ "role-section prompts", "plain description, meta", "after AI/manual edit", } { assertOpenAPIYAMLOmitsToken(t, token) } } func TestV1OpenAPIYAMLProcessItemCompletedExamplesOmitLeakyShape(t *testing.T) { t.Parallel() root := mustParseOpenAPIRoot(t, v1OpenAPIYAML) leaky := []string{"id", "title", "meta_title", "meta_description"} assertProcessItems := func(where string, items []any) { t.Helper() if len(items) == 0 { t.Fatalf("%s: expected completed ProcessItem example", where) } for i, raw := range items { item, _ := raw.(map[string]any) if item == nil { t.Fatalf("%s[%d]: not an object", where, i) } if _, ok := item["ean"]; !ok { t.Fatalf("%s[%d]: missing ean", where, i) } for _, k := range leaky { if _, ok := item[k]; ok { t.Fatalf("%s[%d] must omit %q (do not teach leaky ProcessItem shape)", where, i, k) } } } } comps, _ := root["components"].(map[string]any) examples, _ := comps["examples"].(map[string]any) completed, _ := examples["ProcessCompletedExample"].(map[string]any) val, _ := completed["value"].(map[string]any) data, _ := val["data"].(map[string]any) items, _ := data["items"].([]any) assertProcessItems("ProcessCompletedExample", items) paths, _ := root["paths"].(map[string]any) productsProcess := openAPIJSONExamples(t, paths, "/products/process/{id}", "get", "200") prodCompleted, _ := productsProcess["completed"].(map[string]any) prodVal, _ := prodCompleted["value"].(map[string]any) prodData, _ := prodVal["data"].(map[string]any) prodItems, _ := prodData["items"].([]any) assertProcessItems("GET /products/process/{id} completed", prodItems) legacyProcess := openAPIJSONExamples(t, paths, "/process/{id}", "get", "200") legacyCompleted, _ := legacyProcess["completed"].(map[string]any) legacyVal, _ := legacyCompleted["value"].(map[string]any) legacyItems, _ := legacyVal["items"].([]any) assertProcessItems("GET /process/{id} completed", legacyItems) } func openAPIJSONExamples(t *testing.T, paths map[string]any, path, method, status string) map[string]any { t.Helper() p, _ := paths[path].(map[string]any) op, _ := p[method].(map[string]any) resps, _ := op["responses"].(map[string]any) resp, _ := resps[status].(map[string]any) content, _ := resp["content"].(map[string]any) appJSON, _ := content["application/json"].(map[string]any) examples, _ := appJSON["examples"].(map[string]any) if examples == nil { t.Fatalf("%s %s %s: missing application/json examples", method, path, status) } return examples } func assertOpenAPIYAMLOmitsToken(t *testing.T, token string) { t.Helper() doc := string(v1OpenAPIYAML) if token == "legacy" { doc = strings.ToLower(doc) } if i := strings.Index(doc, token); i >= 0 { start := i - 40 if start < 0 { start = 0 } end := i + 40 if end > len(doc) { end = len(doc) } t.Fatalf("public OpenAPI YAML must not mention %q (near %q)", token, doc[start:end]) } } func TestV1OpenAPIYAMLEmptySecurityOnlyOnPublicProbes(t *testing.T) { t.Parallel() root := mustParseOpenAPIRoot(t, v1OpenAPIYAML) sec, _ := root["security"].([]any) if len(sec) == 0 { t.Fatal("document-level security must require API key schemes") } var hasBearer, hasAPIKey bool for _, item := range sec { m, _ := item.(map[string]any) if _, ok := m["BearerAuth"]; ok { hasBearer = true } if _, ok := m["ApiKeyAuth"]; ok { hasAPIKey = true } } if !hasBearer || !hasAPIKey { t.Fatal("document-level security must include BearerAuth and ApiKeyAuth") } paths, _ := root["paths"].(map[string]any) methods := []string{"get", "post", "put", "patch", "delete"} var bad []string for p, raw := range paths { item, _ := raw.(map[string]any) for _, m := range methods { op, _ := item[m].(map[string]any) if op == nil { continue } rawSec, ok := op["security"] if !ok { continue } arr, _ := rawSec.([]any) if len(arr) != 0 { continue } if p != "/health" && p != "/openapi.yaml" { bad = append(bad, m+" "+p) } } } if len(bad) > 0 { t.Fatalf("empty security (no API key) on non-probe operations: %v", bad) } } // TestV1OpenAPIYAMLDocumentsAPIKeyCutoverReissue locks cutover honesty: legacy // API keys were not migrated and clients must create new keys. func TestV1OpenAPIYAMLDocumentsAPIKeyCutoverReissue(t *testing.T) { t.Parallel() doc := string(v1OpenAPIYAML) for _, needle := range []string{ "Cutover / migration (reissue)", "were not migrated", "/settings?tab=api-keys", "non-migrated (pre-cutover)", "create a new dk_ key", } { if !strings.Contains(doc, needle) { t.Fatalf("openapi missing API key cutover reissue text %q", needle) } } if strings.Contains(doc, "must mint a new") { t.Fatal("openapi should say create (not mint) for API key reissue copy") } }