533db535f1
build / build (push) Successful in 1m12s
按已确认需求提供独立安全模型配置、真实探测、正式字典字段评估与原问题译中,持久批次支持只补空白、暂停恢复、有限重试、幂等和逐字段依据。覆盖固定规则、本地语言、组织关联与主表有效看板共用治理侧读模型,保留源生产和冻结历史。 验证:完整 make verify 退出0,702项Go与329项前端通过;默认118项外部/隔离集成明确跳过,另跑完整镜像及组织隔离PG竞态。12并发、输入漂移、过期租约、暂停/恢复及有限重试通过。用户授权的百炼 deepseek-v4.1-flash 完成真实后台与浏览器评估/翻译;552行、16列、11块看板及完整PNG、独立配置页测试/保存、重开恢复和零JS异常通过。仅非业务样本外发,自动处理关闭,原payload不变。 上线前治理库已做导出快照原生备份并真实恢复,60表行数与17个QA表内容哈希一致;不修改DobotAI生产或隧道。本提交时尚未部署,线上验收须独立完成。
174 lines
6.2 KiB
Go
174 lines
6.2 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestOpenAPIContractMatchesRegisteredRoutesAndOmitsSecrets(t *testing.T) {
|
|
contract, err := os.ReadFile("../../api/openapi.yaml")
|
|
if err != nil {
|
|
t.Fatalf("read OpenAPI contract: %v", err)
|
|
}
|
|
|
|
var document struct {
|
|
OpenAPI string `json:"openapi"`
|
|
Info map[string]any `json:"info"`
|
|
Paths map[string]map[string]any `json:"paths"`
|
|
Components map[string]map[string]any `json:"components"`
|
|
}
|
|
if err := json.Unmarshal(contract, &document); err != nil {
|
|
t.Fatalf("OpenAPI contract must use JSON-compatible YAML: %v", err)
|
|
}
|
|
if document.OpenAPI != "3.1.0" || document.Info["title"] != "Knowledge Governance API" || len(document.Paths) == 0 {
|
|
t.Fatalf("invalid OpenAPI header: %#v", document)
|
|
}
|
|
|
|
routes, err := os.ReadFile("server.go")
|
|
if err != nil {
|
|
t.Fatalf("read registered routes: %v", err)
|
|
}
|
|
matcher := regexp.MustCompile(`mux\.HandleFunc\("(GET|POST|PUT|DELETE) ([^"]+)"`)
|
|
for _, match := range matcher.FindAllStringSubmatch(string(routes), -1) {
|
|
method, path := strings.ToLower(match[1]), match[2]
|
|
operation, ok := document.Paths[path][method].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("OpenAPI is missing %s %s", strings.ToUpper(method), path)
|
|
}
|
|
if strings.TrimSpace(stringValue(operation["operationId"])) == "" {
|
|
t.Fatalf("OpenAPI operation %s %s has no operationId", strings.ToUpper(method), path)
|
|
}
|
|
responses, ok := operation["responses"].(map[string]any)
|
|
if !ok || responses["400"] == nil || responses["500"] == nil {
|
|
t.Fatalf("OpenAPI operation %s %s must document 400 and 500 responses", strings.ToUpper(method), path)
|
|
}
|
|
}
|
|
|
|
var guardBaseURL func(any, string)
|
|
guardBaseURL = func(node any, path string) {
|
|
switch value := node.(type) {
|
|
case map[string]any:
|
|
for key, child := range value {
|
|
if key == "base_url" && path != "components.schemas.QAAISafeSettings.properties" && path != "components.schemas.QAAISaveSettings.properties" {
|
|
t.Fatalf("unapproved endpoint exposure at %s", path)
|
|
}
|
|
next := key
|
|
if path != "" {
|
|
next = path + "." + key
|
|
}
|
|
guardBaseURL(child, next)
|
|
}
|
|
case []any:
|
|
for _, child := range value {
|
|
guardBaseURL(child, path)
|
|
}
|
|
}
|
|
}
|
|
var tree any
|
|
_ = json.Unmarshal(contract, &tree)
|
|
guardBaseURL(tree, "")
|
|
lower := strings.ToLower(string(contract))
|
|
for _, forbidden := range []string{"secret_ref", "authorization_code", "manifest_sha256", "knowledge_id", "test_kb_id", "download_code", "cookie"} {
|
|
if strings.Contains(lower, forbidden) {
|
|
t.Fatalf("OpenAPI contract must not expose %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenAPICatalogMutationsRequireIdempotencyKeyHeader(t *testing.T) {
|
|
contract, err := os.ReadFile("../../api/openapi.yaml")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var document struct {
|
|
Paths map[string]map[string]any `json:"paths"`
|
|
Components struct {
|
|
Parameters map[string]map[string]any `json:"parameters"`
|
|
} `json:"components"`
|
|
}
|
|
if err := json.Unmarshal(contract, &document); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
parameter, ok := document.Components.Parameters["IdempotencyKey"]
|
|
if !ok || parameter["name"] != "Idempotency-Key" || parameter["in"] != "header" || parameter["required"] != true {
|
|
t.Fatalf("IdempotencyKey header must be required: %#v", parameter)
|
|
}
|
|
schema, ok := parameter["schema"].(map[string]any)
|
|
if !ok || schema["type"] != "string" || schema["minLength"] != float64(1) || schema["maxLength"] != float64(128) {
|
|
t.Fatalf("IdempotencyKey header schema is unsafe: %#v", parameter["schema"])
|
|
}
|
|
for _, path := range []string{"/api/v1/qa-ops/organization/sync", "/api/v1/source-catalog/route", "/api/v1/source-catalog/sources/{id}/refresh"} {
|
|
operation, ok := document.Paths[path]["post"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("missing POST %s", path)
|
|
}
|
|
parameters, ok := operation["parameters"].([]any)
|
|
if !ok {
|
|
t.Fatalf("POST %s must declare the idempotency header", path)
|
|
}
|
|
found := false
|
|
for _, value := range parameters {
|
|
parameterRef, _ := value.(map[string]any)
|
|
if parameterRef["$ref"] == "#/components/parameters/IdempotencyKey" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("POST %s must reference IdempotencyKey", path)
|
|
}
|
|
responses, _ := operation["responses"].(map[string]any)
|
|
accepted, _ := responses["202"].(map[string]any)
|
|
if accepted["$ref"] != "#/components/responses/OperationEnvelope" {
|
|
t.Fatalf("POST %s must document created/replayed operation response: %#v", path, accepted)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQAAIContractDescribesAllAdministratorPathsAndWriteOnlyCredential(t *testing.T) {
|
|
raw, err := os.ReadFile("../../api/openapi.yaml")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var document struct {
|
|
Paths map[string]map[string]any `json:"paths"`
|
|
Components struct {
|
|
Schemas map[string]map[string]any `json:"schemas"`
|
|
} `json:"components"`
|
|
}
|
|
if json.Unmarshal(raw, &document) != nil {
|
|
t.Fatal("invalid API document")
|
|
}
|
|
for _, route := range []struct{ path, method string }{{"/settings", "get"}, {"/settings", "post"}, {"/settings/test", "post"}, {"/automation", "post"}, {"/previews", "post"}, {"/batches", "post"}, {"/batches/{id}/actions", "post"}, {"/status", "get"}, {"/records/{id}", "get"}} {
|
|
path := "/api/v1/qa-ops/ai" + route.path
|
|
op, ok := document.Paths[path][route.method].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("missing %s %s", route.method, path)
|
|
}
|
|
responses, _ := op["responses"].(map[string]any)
|
|
for _, status := range []string{"400", "401", "403", "500", "503"} {
|
|
if responses[status] == nil {
|
|
t.Fatalf("missing %s for %s", status, path)
|
|
}
|
|
}
|
|
}
|
|
safe := document.Components.Schemas["QAAISafeSettings"]
|
|
properties, _ := safe["properties"].(map[string]any)
|
|
if properties["api_key"] != nil || properties["key_ciphertext"] != nil || properties["key_configured"] == nil || safe["additionalProperties"] != false {
|
|
t.Fatal("safe settings contract exposes secrets or accepts unknown fields")
|
|
}
|
|
input := document.Components.Schemas["QAAISaveSettings"]
|
|
properties, _ = input["properties"].(map[string]any)
|
|
key, _ := properties["api_key"].(map[string]any)
|
|
if key["writeOnly"] != true {
|
|
t.Fatal("API key request property is not write-only")
|
|
}
|
|
}
|
|
|
|
func stringValue(value any) string {
|
|
result, _ := value.(string)
|
|
return result
|
|
}
|