223 lines
6.7 KiB
Go
223 lines
6.7 KiB
Go
package api
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sigs.k8s.io/yaml"
|
|
)
|
|
|
|
// This is the §28 #7 OpenAPI parity gate. docs/openapi.yaml is checked against
|
|
// the routes the handlers are actually built from — the same internalAPIRoutes /
|
|
// externalAPIRoutes tables in api.go — in BOTH directions and including each
|
|
// route's Zero-Trust tier. So three things cannot silently drift:
|
|
//
|
|
// - a route served but undocumented (or documented but unserved) fails the build;
|
|
// - a route whose documented face set disagrees with where it is mounted fails;
|
|
// - a route whose documented tier disagrees with its computed tier fails — this
|
|
// is the §14 four-power guard: an admin route quietly demoted to app, or an
|
|
// internal service route re-faced as external, breaks `go test ./...`.
|
|
//
|
|
// http.ServeMux exposes no way to enumerate its patterns, so a doc-vs-mux probe is
|
|
// impossible; reading the shared route table is the only exact check (api.go).
|
|
|
|
// oasKnownMethods is the set of OpenAPI path-item keys that denote operations.
|
|
// Any other key (summary, parameters, description, servers, ...) is ignored.
|
|
var oasKnownMethods = map[string]bool{
|
|
"get": true, "post": true, "put": true, "patch": true,
|
|
"delete": true, "head": true, "options": true, "trace": true,
|
|
}
|
|
|
|
// oasDoc is the slice of docs/openapi.yaml this test reads. Body schemas and the
|
|
// rest of the document are intentionally not modelled — only the surface the
|
|
// handlers must agree with.
|
|
type oasDoc struct {
|
|
OpenAPI string `json:"openapi"`
|
|
Paths map[string]map[string]oasOp `json:"paths"`
|
|
}
|
|
|
|
type oasOp struct {
|
|
Faces []string `json:"x-felis-face"`
|
|
Tier string `json:"x-felis-tier"`
|
|
Callers []string `json:"x-felis-callers"`
|
|
// Setup marks an operation a setup-lockdown session may still use.
|
|
Setup bool `json:"x-felis-setup-allowed"`
|
|
}
|
|
|
|
// oasFacet is the classification of one {method, path}: which face(s) serve it
|
|
// and at which Zero-Trust tier.
|
|
type oasFacet struct {
|
|
faces map[string]bool
|
|
tier string
|
|
// callers is the internal route's caller set, empty elsewhere.
|
|
callers map[string]bool
|
|
// setup is the route's SetupAllowed: reachable during the setup lockdown.
|
|
setup bool
|
|
}
|
|
|
|
func TestOpenAPIMatchesServedRoutes(t *testing.T) {
|
|
served := oasServedFacets(t)
|
|
documented := oasDocumentedFacets(t)
|
|
|
|
// 1. Exact {method, path} set parity, both directions.
|
|
for key := range served {
|
|
if _, ok := documented[key]; !ok {
|
|
t.Errorf("route SERVED but not documented in docs/openapi.yaml: %s", key)
|
|
}
|
|
}
|
|
for key := range documented {
|
|
if _, ok := served[key]; !ok {
|
|
t.Errorf("route DOCUMENTED in docs/openapi.yaml but not served: %s", key)
|
|
}
|
|
}
|
|
|
|
// 2. For every shared route, face set and tier must agree exactly.
|
|
for key, s := range served {
|
|
d, ok := documented[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if !oasSameSet(s.faces, d.faces) {
|
|
t.Errorf("%s: x-felis-face mismatch — served %v, documented %v",
|
|
key, oasSortedKeys(s.faces), oasSortedKeys(d.faces))
|
|
}
|
|
if s.tier != d.tier {
|
|
t.Errorf("%s: x-felis-tier mismatch — served %q, documented %q", key, s.tier, d.tier)
|
|
}
|
|
if s.setup != d.setup {
|
|
t.Errorf("%s: x-felis-setup-allowed mismatch — served %v, documented %v", key, s.setup, d.setup)
|
|
}
|
|
if !oasSameSet(s.callers, d.callers) {
|
|
t.Errorf("%s: x-felis-callers mismatch — served %v, documented %v",
|
|
key, oasSortedKeys(s.callers), oasSortedKeys(d.callers))
|
|
}
|
|
}
|
|
}
|
|
|
|
// oasServedFacets derives the live route surface from the route tables the
|
|
// handlers are built from, keyed by "METHOD /path". The tier is computed the same
|
|
// way buildFace treats the route: Public -> public; internal-face -> service;
|
|
// external-face Admin -> admin; otherwise app. /healthz is added by both tables,
|
|
// so its face set merges to {internal, external} and its tier must agree (public).
|
|
func oasServedFacets(t *testing.T) map[string]oasFacet {
|
|
t.Helper()
|
|
a := &API{}
|
|
out := map[string]oasFacet{}
|
|
add := func(method, pattern, face, tier string, setup bool, callers ...Caller) {
|
|
key := method + " " + pattern
|
|
f, ok := out[key]
|
|
if !ok {
|
|
f = oasFacet{faces: map[string]bool{}, callers: map[string]bool{}}
|
|
}
|
|
for _, c := range callers {
|
|
f.callers[string(c)] = true
|
|
}
|
|
f.faces[face] = true
|
|
if f.tier != "" && f.tier != tier {
|
|
t.Fatalf("%s: route table assigns conflicting tiers %q and %q", key, f.tier, tier)
|
|
}
|
|
f.tier = tier
|
|
f.setup = f.setup || setup
|
|
out[key] = f
|
|
}
|
|
for _, rt := range a.internalAPIRoutes() {
|
|
tier := "service"
|
|
if rt.Public {
|
|
tier = "public"
|
|
}
|
|
add(rt.Method, rt.Pattern, "internal", tier, rt.SetupAllowed, rt.Callers...)
|
|
}
|
|
for _, rt := range a.externalAPIRoutes() {
|
|
var tier string
|
|
switch {
|
|
case rt.Public:
|
|
tier = "public"
|
|
case rt.Owner:
|
|
tier = "owner"
|
|
case rt.Admin:
|
|
tier = "admin"
|
|
default:
|
|
tier = "app"
|
|
}
|
|
add(rt.Method, rt.Pattern, "external", tier, rt.SetupAllowed)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// oasDocumentedFacets parses docs/openapi.yaml into the same shape. An operation
|
|
// missing x-felis-face or x-felis-tier is a failure, not a skip: a new route
|
|
// cannot be documented without classifying its face and tier.
|
|
func oasDocumentedFacets(t *testing.T) map[string]oasFacet {
|
|
t.Helper()
|
|
path := filepath.Join("..", "..", "docs", "openapi.yaml")
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
var doc oasDoc
|
|
if err := yaml.Unmarshal(raw, &doc); err != nil {
|
|
t.Fatalf("parse %s: %v", path, err)
|
|
}
|
|
if !strings.HasPrefix(doc.OpenAPI, "3.1") {
|
|
t.Fatalf("%s: expected OpenAPI 3.1, got openapi: %q", path, doc.OpenAPI)
|
|
}
|
|
|
|
out := map[string]oasFacet{}
|
|
for rawPath, item := range doc.Paths {
|
|
for method, op := range item {
|
|
if !oasKnownMethods[strings.ToLower(method)] {
|
|
continue
|
|
}
|
|
key := strings.ToUpper(method) + " " + rawPath
|
|
if len(op.Faces) == 0 {
|
|
t.Errorf("%s: missing x-felis-face", key)
|
|
continue
|
|
}
|
|
if op.Tier == "" {
|
|
t.Errorf("%s: missing x-felis-tier", key)
|
|
continue
|
|
}
|
|
faces := map[string]bool{}
|
|
for _, f := range op.Faces {
|
|
if f != "internal" && f != "external" {
|
|
t.Errorf("%s: x-felis-face has unknown face %q", key, f)
|
|
}
|
|
faces[f] = true
|
|
}
|
|
if _, dup := out[key]; dup {
|
|
t.Errorf("%s: documented more than once", key)
|
|
}
|
|
callers := map[string]bool{}
|
|
for _, c := range op.Callers {
|
|
callers[c] = true
|
|
}
|
|
out[key] = oasFacet{faces: faces, tier: op.Tier, callers: callers, setup: op.Setup}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func oasSameSet(a, b map[string]bool) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for k := range a {
|
|
if !b[k] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func oasSortedKeys(m map[string]bool) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|