Files
descrybe/apps/api/internal/httpapi/v1_openapi_test.go
T

661 lines
19 KiB
Go
Raw Normal View History

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")
}
}
2026-08-17 21:20:45 +02:00
// TestV1OpenAPIYAMLProcessItemEprelShape locks ProcessItem.eprel
2026-08-16 16:57:36 +02:00
// to the live nested object from eprel.MergeInto / extractEPRELFromAttrs.
2026-08-17 21:20:45 +02:00
func TestV1OpenAPIYAMLProcessItemEprelShape(t *testing.T) {
2026-08-16 16:57:36 +02:00
t.Parallel()
root := mustParseOpenAPIRoot(t, v1OpenAPIYAML)
comps, _ := root["components"].(map[string]any)
schemas, _ := comps["schemas"].(map[string]any)
2026-08-17 21:20:45 +02:00
item, _ := schemas["ProcessItem"].(map[string]any)
2026-08-16 16:57:36 +02:00
itemProps, _ := item["properties"].(map[string]any)
eprel, _ := itemProps["eprel"].(map[string]any)
eprelProps, _ := eprel["properties"].(map[string]any)
if eprelProps == nil {
2026-08-17 21:20:45 +02:00
t.Fatal("ProcessItem.eprel.properties missing")
2026-08-16 16:57:36 +02:00
}
want := []string{"id", "label", "pdf", "energy_class", "energy_scale"}
for _, key := range want {
if _, ok := eprelProps[key]; !ok {
2026-08-17 21:20:45 +02:00
t.Fatalf("ProcessItem.eprel missing property %q (live shape)", key)
2026-08-16 16:57:36 +02:00
}
}
if len(eprelProps) != len(want) {
keys := make([]string, 0, len(eprelProps))
for k := range eprelProps {
keys = append(keys, k)
}
2026-08-17 21:20:45 +02:00
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)
}
}
2026-08-17 21:56:09 +02:00
func TestV1OpenAPIYAMLDocumentsAIDescriptionHTML(t *testing.T) {
t.Parallel()
doc := string(v1OpenAPIYAML)
for _, needle := range []string{
"AI descriptions (HTML body, not a short blurb)",
"description_template (description formula)",
"one- or two-sentence",
"<h1>VOX electric cooker EHT 6020 WG</h1>",
"<h2>Key features</h2>",
} {
if !strings.Contains(doc, needle) {
t.Fatalf("openapi missing AI description contract text %q", needle)
}
}
root := mustParseOpenAPIRoot(t, v1OpenAPIYAML)
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)
if len(items) == 0 {
t.Fatal("ProcessCompletedExample: expected items")
}
item, _ := items[0].(map[string]any)
desc, _ := item["description"].(string)
for _, tag := range []string{"<h1>", "<p>", "<h2>", "<ul>", "<li>"} {
if !strings.Contains(desc, tag) {
t.Fatalf("ProcessCompletedExample description must include %s (got %q)", tag, desc)
}
}
if len(desc) < 800 {
t.Fatalf("ProcessCompletedExample description too short (%d chars); expected full AI HTML body", len(desc))
}
}
2026-08-17 21:20:45 +02:00
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)
2026-08-16 16:57:36 +02:00
}
}
// 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")
}
}