proxy-pool/api/openapi/validation_test.go

195 lines
5.0 KiB
Go

package openapi
import (
"fmt"
"os"
"strings"
"testing"
"go.yaml.in/yaml/v4"
)
var httpOperationMethods = map[string]struct{}{
"delete": {}, "get": {}, "head": {}, "options": {},
"patch": {}, "post": {}, "put": {}, "trace": {},
}
func TestOpenAPIDocumentsHaveClosedContracts(t *testing.T) {
for _, name := range []string{"proxy-pool.yaml", "admin.yaml"} {
name := name
t.Run(name, func(t *testing.T) {
root := readOpenAPIRoot(t, name)
validateLocalReferences(t, root)
validateOperations(t, root)
validateSecurityRequirements(t, root)
})
}
}
func TestOpenAPITagsHaveDescriptions(t *testing.T) {
for _, name := range []string{"proxy-pool.yaml", "admin.yaml"} {
name := name
t.Run(name, func(t *testing.T) {
root := readOpenAPIRoot(t, name)
tags, ok := root["tags"].([]any)
if !ok || len(tags) == 0 {
t.Fatal("tags must be a non-empty array")
}
for _, rawTag := range tags {
tag, ok := rawTag.(map[string]any)
if !ok {
t.Errorf("tag has type %T, want object", rawTag)
continue
}
name, _ := tag["name"].(string)
description, _ := tag["description"].(string)
if strings.TrimSpace(description) == "" {
t.Errorf("tag %q has no description", name)
}
}
})
}
}
func validateLocalReferences(t *testing.T, root map[string]any) {
t.Helper()
walkOpenAPI(root, func(path string, value any) {
object, ok := value.(map[string]any)
if !ok {
return
}
reference, exists := object["$ref"]
if !exists {
return
}
text, ok := reference.(string)
if !ok || !strings.HasPrefix(text, "#/") {
t.Errorf("%s has unsupported reference %v", path, reference)
return
}
if _, ok := resolveLocalReference(root, text); !ok {
t.Errorf("%s points to missing local reference %q", path, text)
}
})
}
func validateOperations(t *testing.T, root map[string]any) {
t.Helper()
paths, ok := root["paths"].(map[string]any)
if !ok || len(paths) == 0 {
t.Fatal("paths must be a non-empty object")
}
operationIDs := make(map[string]string)
for path, rawItem := range paths {
item, ok := rawItem.(map[string]any)
if !ok {
t.Errorf("path %q has type %T, want object", path, rawItem)
continue
}
for method, rawOperation := range item {
if _, ok := httpOperationMethods[strings.ToLower(method)]; !ok {
continue
}
operation, ok := rawOperation.(map[string]any)
if !ok {
t.Errorf("%s %s has type %T, want object", method, path, rawOperation)
continue
}
operationID, _ := operation["operationId"].(string)
if strings.TrimSpace(operationID) == "" {
t.Errorf("%s %s has no operationId", method, path)
} else if previous, duplicate := operationIDs[operationID]; duplicate {
t.Errorf("operationId %q is shared by %s and %s %s", operationID, previous, method, path)
} else {
operationIDs[operationID] = method + " " + path
}
responses, ok := operation["responses"].(map[string]any)
if !ok || len(responses) == 0 {
t.Errorf("%s %s has no responses", method, path)
}
}
}
}
func validateSecurityRequirements(t *testing.T, root map[string]any) {
t.Helper()
components, _ := root["components"].(map[string]any)
schemes, _ := components["securitySchemes"].(map[string]any)
walkOpenAPI(root, func(path string, value any) {
if !strings.HasSuffix(path, ".security") {
return
}
requirements, ok := value.([]any)
if !ok {
t.Errorf("%s has type %T, want array", path, value)
return
}
for _, rawRequirement := range requirements {
requirement, ok := rawRequirement.(map[string]any)
if !ok {
t.Errorf("%s contains %T, want object", path, rawRequirement)
continue
}
for name := range requirement {
if _, exists := schemes[name]; !exists {
t.Errorf("%s references unknown security scheme %q", path, name)
}
}
}
})
}
func readOpenAPIRoot(t *testing.T, name string) map[string]any {
t.Helper()
payload, err := os.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
var root map[string]any
if err := yaml.Unmarshal(payload, &root); err != nil {
t.Fatalf("parse %s: %v", name, err)
}
return root
}
func walkOpenAPI(value any, visit func(string, any)) {
var walk func(string, any)
walk = func(path string, current any) {
visit(path, current)
switch item := current.(type) {
case map[string]any:
for key, child := range item {
walk(joinOpenAPIPath(path, key), child)
}
case []any:
for index, child := range item {
walk(fmt.Sprintf("%s[%d]", path, index), child)
}
}
}
walk("$", value)
}
func resolveLocalReference(root map[string]any, reference string) (any, bool) {
var current any = root
for _, token := range strings.Split(strings.TrimPrefix(reference, "#/"), "/") {
token = strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~")
object, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = object[token]
if !ok {
return nil, false
}
}
return current, true
}
func joinOpenAPIPath(parent, child string) string {
if parent == "" {
return child
}
return parent + "." + child
}