diff --git a/api/openapi/validation_test.go b/api/openapi/validation_test.go new file mode 100644 index 0000000..b0db7a1 --- /dev/null +++ b/api/openapi/validation_test.go @@ -0,0 +1,169 @@ +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 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 +} diff --git a/docs/development/implementation-plan.md b/docs/development/implementation-plan.md index a792278..061a70b 100644 --- a/docs/development/implementation-plan.md +++ b/docs/development/implementation-plan.md @@ -224,6 +224,10 @@ Adapter 已通过真实 Redis 8.2 运行同一套公用契约;原子 Lua 覆 and resync messages. - [ ] Validate OpenAPI and compile protobuf descriptors in CI. +当前进度(2026-07-29):Distribution/Admin OpenAPI 已由 Go 测试在双平台 CI +校验本地 `$ref` 闭合、operationId 唯一、响应存在及 security scheme 引用; +完整 OAS 工具验证与 Protobuf descriptor CI 编译仍待完成。 + ## Task 13: Deployment and Observability **Files:** `deploy/**`, `internal/platform/**`, `docs/operations/**` diff --git a/docs/requirements/completion-audit.md b/docs/requirements/completion-audit.md index f0dda09..fef8b7b 100644 --- a/docs/requirements/completion-audit.md +++ b/docs/requirements/completion-audit.md @@ -17,6 +17,8 @@ - Distribution OpenAPI:一次性独占提取、partial/allOrNothing、幂等键、 Redis TTL 活动池原子语义、TTL/健康过滤结果与标准错误。 - Admin OpenAPI:状态、Upstream 启停、Routing 切换和配置重载。 +- 两份 OpenAPI 已进入 Go/CI 结构门禁,覆盖本地引用闭合、operationId、响应和 + security scheme;完整标准工具验证仍待补齐。 - Protobuf:Worker 注册、全量/增量 Snapshot、`usable_until`、ACK、运行态/ 结果上报、Checker 任务与 Observation。 diff --git a/docs/testing/test-strategy.md b/docs/testing/test-strategy.md index 38d04e1..47e1d74 100644 --- a/docs/testing/test-strategy.md +++ b/docs/testing/test-strategy.md @@ -33,6 +33,7 @@ - Redis TTL 活动池、Leader 租约、限流、短期幂等窗口和失联恢复。 - Snapshot/Delta/ACK/Report 的版本与校验和兼容性。 - OpenAPI 错误模型、认证矩阵、批量 fulfillment。 +- OpenAPI 本地引用闭合、operationId 唯一、响应集合和 security scheme 引用。 - README、设计/部署文档中的相对链接,以及公开 Go 命令引用的仓库目标。 ### 集成与端到端 diff --git a/progress.md b/progress.md index ac7a430..fe3cbe6 100644 --- a/progress.md +++ b/progress.md @@ -2,6 +2,11 @@ ## 2026-07-29 +- 新增 Distribution/Admin OpenAPI 公用 CI 结构门禁,递归验证本地 `$ref`、 + operationId 唯一性、HTTP operation 响应和 security scheme 引用;完整 OAS + 工具验证与 Protobuf descriptor CI 编译仍保持未完成。 +- 一次 OpenAPI 检索在 PowerShell 双引号中误触发 `$ref` 变量解析;已改用单引号 + 模式继续审计,未重复原命令。 - 新增 PostgreSQL 18 隔离测试服务的静态契约:回环端口、正确 PG18 tmpfs 数据 根目录、零持久卷;Redis 测试改用独立 Compose 项目和显式服务启动,避免两个 fixture 相互影响。未拉取或启动容器,pgx Adapter/真实契约仍待依赖确认。