test: validate openapi contract graph

This commit is contained in:
youfak 2026-07-29 21:57:42 +08:00
parent 02097596fa
commit b86a729792
5 changed files with 181 additions and 0 deletions

View File

@ -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
}

View File

@ -224,6 +224,10 @@ Adapter 已通过真实 Redis 8.2 运行同一套公用契约;原子 Lua 覆
and resync messages.
- [ ] Validate OpenAPI and compile protobuf descriptors in CI.
当前进度2026-07-29Distribution/Admin OpenAPI 已由 Go 测试在双平台 CI
校验本地 `$ref` 闭合、operationId 唯一、响应存在及 security scheme 引用;
完整 OAS 工具验证与 Protobuf descriptor CI 编译仍待完成。
## Task 13: Deployment and Observability
**Files:** `deploy/**`, `internal/platform/**`, `docs/operations/**`

View File

@ -17,6 +17,8 @@
- Distribution OpenAPI一次性独占提取、partial/allOrNothing、幂等键、
Redis TTL 活动池原子语义、TTL/健康过滤结果与标准错误。
- Admin OpenAPI状态、Upstream 启停、Routing 切换和配置重载。
- 两份 OpenAPI 已进入 Go/CI 结构门禁覆盖本地引用闭合、operationId、响应和
security scheme完整标准工具验证仍待补齐。
- ProtobufWorker 注册、全量/增量 Snapshot、`usable_until`、ACK、运行态/
结果上报、Checker 任务与 Observation。

View File

@ -33,6 +33,7 @@
- Redis TTL 活动池、Leader 租约、限流、短期幂等窗口和失联恢复。
- Snapshot/Delta/ACK/Report 的版本与校验和兼容性。
- OpenAPI 错误模型、认证矩阵、批量 fulfillment。
- OpenAPI 本地引用闭合、operationId 唯一、响应集合和 security scheme 引用。
- README、设计/部署文档中的相对链接,以及公开 Go 命令引用的仓库目标。
### 集成与端到端

View File

@ -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/真实契约仍待依赖确认。