- FieldRelationContract/FieldRelationProvider bind every type: relation
field to a belongsTo foreign key or a belongsToMany pivot; activation
fails naming plugin, controller and field on a missing or broken contract
- GET /{vendor}/{plugin}/{controller}/fields/{field}/options serves
{value, label} pages scoped by pact.RelationExtendOptionsQuery, behind
the controller permission; read-only and non-relation fields are 404
- Saves apply present relation keys after the Before hook: ids are
revalidated through the same scoped query (422 and full rollback
otherwise), belongsTo sets the foreign key, belongsToMany replaces pivot
rows in submitted order with the order column set to the index
- Show, create and update return relation values in data and meta.labels
- A belongsTo on a protected fill key is read-only (D-26)
- One six-segment GET pattern dispatches relation lists and field options,
which ServeMux cannot register side by side
- Admin OpenAPI documents the options route and RecordEnvelope
116 lines
2.9 KiB
Go
116 lines
2.9 KiB
Go
package cabana
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestPhase09ContractInventory fails when the committed framework admin
|
|
// OpenAPI document (admin/openapi/admin.json, D-15) drops an admin API route
|
|
// or a protected route's 401 response. Paths are prefix-relative (D-03).
|
|
func TestPhase09ContractInventory(t *testing.T) {
|
|
_, file, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
t.Fatal("caller")
|
|
}
|
|
specPath := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "admin", "openapi", "admin.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 /auth/login": true,
|
|
"POST /auth/refresh": true,
|
|
}
|
|
seen := map[string]bool{}
|
|
apiRoutes := 0
|
|
for _, route := range phase09Routes {
|
|
if route.spa {
|
|
continue
|
|
}
|
|
apiRoutes++
|
|
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
|
|
_, ok200 := op.Responses["200"]
|
|
_, ok201 := op.Responses["201"]
|
|
if !ok200 && !ok201 {
|
|
t.Fatalf("%s has no 200 or 201 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) != apiRoutes {
|
|
t.Fatalf("contract routes=%d want %d", len(seen), apiRoutes)
|
|
}
|
|
if len(spec.Paths) != len(pathsOf(phase09Routes)) {
|
|
t.Fatalf("openapi lists %d paths, the mounted API has %d", len(spec.Paths), len(pathsOf(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
|
|
}
|
|
|
|
func pathsOf(routes []adminRoute) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, route := range routes {
|
|
if route.spa {
|
|
continue
|
|
}
|
|
if _, path, ok := splitRoute(route.key); ok {
|
|
out[path] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|