feat(09-12): add the phase 9 acceptance gate

- Document every D-09 admin route for the OpenAPI contract.
- Fail the gate on skipped tests, zero-test runs, and a missing admin path.
This commit is contained in:
Jakub Zych
2026-09-27 03:02:00 +02:00
parent 30bfd2d8d5
commit 4392550e23
3 changed files with 650 additions and 0 deletions

379
cabana/admin_openapi.go Normal file
View File

@@ -0,0 +1,379 @@
package cabana
// Admin API annotations. swag reads these with the handler package so
// docs/openapi.json lists every D-09 route. The functions are not mounted;
// service.mount in http.go is the runtime route table, and
// TestPhase09PermissionMatrix fails if the two lists diverge.
// ErrorBody is one D-10 error object.
type ErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details"`
}
// ErrorEnvelope is the D-10 error envelope.
type ErrorEnvelope struct {
Error ErrorBody `json:"error"`
}
// SuccessMeta is the D-10 meta object.
type SuccessMeta struct {
Locale string `json:"locale,omitempty"`
Page int `json:"page,omitempty"`
PerPage int `json:"per_page,omitempty"`
Total int `json:"total,omitempty"`
LastPage int `json:"last_page,omitempty"`
}
// SuccessEnvelope is the D-10 success envelope.
type SuccessEnvelope struct {
Data any `json:"data"`
Meta SuccessMeta `json:"meta"`
}
// AdminLoginData is the admin login payload.
type AdminLoginData struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
}
// AdminLoginEnvelope is the admin login success body.
type AdminLoginEnvelope struct {
Data AdminLoginData `json:"data"`
Meta SuccessMeta `json:"meta"`
}
// AdminLogin documents POST /_admin/api/v1/auth/login.
//
// @Summary Admin login
// @Tags admin
// @Accept json
// @Produce json
// @Success 200 {object} AdminLoginEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Router /_admin/api/v1/auth/login [post]
func AdminLogin() {}
// AdminRefresh documents POST /_admin/api/v1/auth/refresh.
//
// @Summary Refresh an admin token
// @Tags admin
// @Accept json
// @Produce json
// @Success 200 {object} AdminLoginEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Router /_admin/api/v1/auth/refresh [post]
func AdminRefresh() {}
// AdminLogout documents POST /_admin/api/v1/auth/logout.
//
// @Summary Admin logout
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Router /_admin/api/v1/auth/logout [post]
func AdminLogout() {}
// AdminMe documents GET /_admin/api/v1/auth/me.
//
// @Summary Current admin
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Router /_admin/api/v1/auth/me [get]
func AdminMe() {}
// AdminNavigation documents GET /_admin/api/v1/navigation.
//
// @Summary Admin navigation
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Router /_admin/api/v1/navigation [get]
func AdminNavigation() {}
// AdminSettingsList documents GET /_admin/api/v1/settings.
//
// @Summary List admin settings
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Router /_admin/api/v1/settings [get]
func AdminSettingsList() {}
// AdminSettingsSchema documents GET /_admin/api/v1/settings/{code}/schema.
//
// @Summary Admin settings schema
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param code path string true "Settings code"
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/settings/{code}/schema [get]
func AdminSettingsSchema() {}
// AdminSettingsGet documents GET /_admin/api/v1/settings/{code}.
//
// @Summary Read admin settings
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param code path string true "Settings code"
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/settings/{code} [get]
func AdminSettingsGet() {}
// AdminSettingsPut documents PUT /_admin/api/v1/settings/{code}.
//
// @Summary Update admin settings
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param code path string true "Settings code"
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/settings/{code} [put]
func AdminSettingsPut() {}
// AdminListSchema documents the list schema route.
//
// @Summary Admin list schema
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/list [get]
func AdminListSchema() {}
// AdminFormSchema documents the form schema route.
//
// @Summary Admin form schema
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/form [get]
func AdminFormSchema() {}
// AdminRelationSchema documents the relation schema route.
//
// @Summary Admin relation schema
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param name path string true "Relation name"
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/relation/{name} [get]
func AdminRelationSchema() {}
// AdminList documents the record list route.
//
// @Summary List admin records
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller} [get]
func AdminList() {}
// AdminCreate documents the record create route.
//
// @Summary Create an admin record
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller} [post]
func AdminCreate() {}
// AdminBulkDelete documents the bulk delete route.
//
// @Summary Bulk-delete admin records
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/bulk-delete [post]
func AdminBulkDelete() {}
// AdminShow documents the record show route.
//
// @Summary Show an admin record
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Record id"
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [get]
func AdminShow() {}
// AdminUpdate documents the record update route.
//
// @Summary Update an admin record
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Record id"
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [put]
func AdminUpdate() {}
// AdminDelete documents the record delete route.
//
// @Summary Delete an admin record
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Record id"
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [delete]
func AdminDelete() {}
// AdminRelationLinked documents the linked-relation route.
//
// @Summary List linked relation records
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param name path string true "Relation name"
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name} [get]
func AdminRelationLinked() {}
// AdminRelationCandidates documents the relation candidate route.
//
// @Summary List relation candidates
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param name path string true "Relation name"
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates [get]
func AdminRelationCandidates() {}
// AdminRelationLink documents the relation link route.
//
// @Summary Link relation records
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param name path string true "Relation name"
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link [post]
func AdminRelationLink() {}
// AdminRelationUnlink documents the relation unlink route.
//
// @Summary Unlink relation records
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Owner id"
// @Param name path string true "Relation name"
// @Success 200 {object} SuccessEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post]
func AdminRelationUnlink() {}

View File

@@ -0,0 +1,91 @@
package cabana
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// TestPhase09ContractInventory fails when the committed OpenAPI document
// drops a D-09 route or a protected route's 401 response.
func TestPhase09ContractInventory(t *testing.T) {
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("caller")
}
specPath := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "fonoteka.go", "docs", "openapi.json"))
raw, err := os.ReadFile(specPath)
if err != nil {
t.Fatalf("read %s: %v", specPath, err)
}
var spec struct {
Paths map[string]map[string]struct {
Responses map[string]json.RawMessage `json:"responses"`
Security []map[string]json.RawMessage `json:"security"`
} `json:"paths"`
Components struct {
SecuritySchemes map[string]json.RawMessage `json:"securitySchemes"`
} `json:"components"`
}
if err := json.Unmarshal(raw, &spec); err != nil {
t.Fatal(err)
}
if _, ok := spec.Components.SecuritySchemes["BackendBearer"]; !ok {
t.Fatal("openapi is missing the BackendBearer scheme")
}
public := map[string]bool{
"POST /_admin/api/v1/auth/login": true,
"POST /_admin/api/v1/auth/refresh": true,
}
seen := map[string]bool{}
for _, route := range phase09Routes {
method, path, ok := splitRoute(route.key)
if !ok {
t.Fatalf("bad route key %s", route.key)
}
method = strings.ToLower(method)
ops, ok := spec.Paths[path]
if !ok {
t.Fatalf("openapi missing %s", path)
}
op, ok := ops[method]
if !ok {
t.Fatalf("openapi missing %s %s", method, path)
}
key := route.key
if seen[key] {
t.Fatalf("duplicate contract route %s", key)
}
seen[key] = true
if _, ok := op.Responses["200"]; !ok {
t.Fatalf("%s has no 200 response", key)
}
if public[key] {
if _, ok := op.Responses["401"]; !ok {
t.Fatalf("%s has no 401 response", key)
}
continue
}
if len(op.Security) == 0 {
t.Fatalf("%s has no BackendBearer security requirement", key)
}
if _, ok := op.Responses["401"]; !ok {
t.Fatalf("%s has no 401 response", key)
}
}
if len(seen) != len(phase09Routes) {
t.Fatalf("contract routes=%d want %d", len(seen), len(phase09Routes))
}
}
func splitRoute(key string) (method, path string, ok bool) {
for i := 0; i < len(key); i++ {
if key[i] == ' ' {
return key[:i], key[i+1:], key[i+1:] != ""
}
}
return "", "", false
}