623 lines
18 KiB
Go
623 lines
18 KiB
Go
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")
|
|
}
|
|
}
|