status: the schema is allowlisted — an extra field fails the build
Implements Task 5 contract tests for the fleet status page. The allowlist (allowedBoxKeys, allowedFleetKeys) IS the schema: every new field addition must be explicit, never accidental. Tests guard against sensitive data leaks. Includes: - assertOnlyKeys: recursive walk of decoded JSON, checking keys against allowlist - scanForLeaks: shared helper for banned string detection (Task 6 reuse) - fixtureRoutes/fixtureProbes: test fixtures with realistic counts and bands - TestBoxSchemaAllowlisted/TestFleetSchemaAllowlisted: guard new fields - TestNoDigitsInBandSubtrees: verify band words carry no digits Verified: adding junk_field to Box fails the test; removing it passes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+252
−0
internal/status/contract_test.go+252 −0
diff --git a/internal/status/contract_test.go b/internal/status/contract_test.go| new file mode 100644 |
| index 000000000..871ed8ae4 |
| --- /dev/null |
| +++ b/internal/status/contract_test.go |
| @@ -0,0 +1,252 @@ |
| +package status_test |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// ---- Task 5: Contract tests — allowlist + digit scan ---- |
| +// The allowlist IS the schema. Adding a field to the structs without adding |
| +// it here fails the build — that friction is the point: every new field is a |
| +// deliberate publication decision. This file guards the public JSON contract |
| +// that the fleet status page publishes, ensuring no accidental leaks of |
| +// hostnames, IDs, or other sensitive data slip into the output. |
| + |
| +// allowedBoxKeys are THE schema for Box's JSON output. Any new field in Box |
| +// must be explicitly added here to pass the test — design friction that makes |
| +// the publication decision deliberate, never accidental. The map keys are path |
| +// strings ("" for root, "fieldname" for nested, "fieldname[]" for array |
| +// elements); values are the allowed field names at that path. |
| +var allowedBoxKeys = map[string][]string{ |
| + "": {"generated_at", "label", "region", "routes", "load", "capacity", "replication"}, |
| + "routes": {"static", "resident", "hibernating", "awake", "asleep"}, |
| + "replication": {"healthy", "lag_seconds"}, |
| +} |
| + |
| +// allowedFleetKeys are THE schema for Fleet's JSON output. Same friction as |
| +// allowedBoxKeys: every new field is a deliberate publication decision. |
| +var allowedFleetKeys = map[string][]string{ |
| + "": {"generated_at", "boxes", "instances", "capacity", "apps", "apps_unlisted", "services", "replication"}, |
| + "boxes[]": {"label", "region", "load", "stale", "routes"}, |
| + "boxes[].routes": {"static", "resident", "hibernating", "awake", "asleep"}, |
| + "instances": {"total", "static", "resident", "hibernating", "awake", "asleep"}, |
| + "capacity": {"level"}, |
| + "apps[]": {"name", "instances", "healthy", "channel", "version"}, |
| + "services[]": {"name", "up"}, |
| + "replication": {"healthy", "lag_seconds"}, |
| +} |
| + |
| +// assertOnlyKeys walks the decoded JSON map recursively, ensuring every key |
| +// at every path is in the allowlist for that path. Arrays walk their elements; |
| +// a key not in the allowlist fails with the offending path. This is the guard: |
| +// if a new field leaks into the JSON without an explicit allowlist entry, the |
| +// test fails and forces a review. |
| +func assertOnlyKeys(t *testing.T, j []byte, allowedKeys map[string][]string) { |
| + t.Helper() |
| + var decoded interface{} |
| + if err := json.Unmarshal(j, &decoded); err != nil { |
| + t.Fatalf("unmarshal JSON: %v", err) |
| + } |
| + walkKeys(t, decoded, "", allowedKeys) |
| +} |
| + |
| +// walkKeys recursively traverses the decoded JSON structure, checking that |
| +// every key at every path is in the allowlist. It handles maps and arrays |
| +// appropriately, using "" for root, "fieldname" for nested maps, and |
| +// "fieldname[]" for array element paths. |
| +func walkKeys(t *testing.T, obj interface{}, path string, allowedKeys map[string][]string) { |
| + switch v := obj.(type) { |
| + case map[string]interface{}: |
| + // This is a map (object). Check that every key is in the allowlist |
| + // for this path. |
| + allowed := allowedKeys[path] |
| + for key := range v { |
| + found := false |
| + for _, a := range allowed { |
| + if a == key { |
| + found = true |
| + break |
| + } |
| + } |
| + if !found { |
| + t.Errorf("unexpected key %q at path %q", key, path) |
| + continue |
| + } |
| + // Recurse into this key's value, building the child path. |
| + var childPath string |
| + if path == "" { |
| + childPath = key |
| + } else { |
| + childPath = path + "." + key |
| + } |
| + walkKeys(t, v[key], childPath, allowedKeys) |
| + } |
| + |
| + case []interface{}: |
| + // This is an array. Recurse into each element with path + "[]". |
| + elemPath := path + "[]" |
| + for _, elem := range v { |
| + walkKeys(t, elem, elemPath, allowedKeys) |
| + } |
| + |
| + case string, float64, bool, nil: |
| + // Leaf values; nothing to check. |
| + } |
| +} |
| + |
| +// scanForLeaks searches the JSON bytes for any of the banned strings and |
| +// returns the list of banned strings actually found. Used by tests to verify |
| +// sensitive data (hostnames, IDs) never leaks into the output. |
| +func scanForLeaks(j []byte, banned []string) []string { |
| + jStr := string(j) |
| + var found []string |
| + for _, b := range banned { |
| + if strings.Contains(jStr, b) { |
| + found = append(found, b) |
| + } |
| + } |
| + return found |
| +} |
| + |
| +// fixtureRoutes creates test routes with a variety of kinds, some hibernating, |
| +// and at least one opted-in app. The result is suitable for CollectBox and |
| +// BuildFleet tests. It includes: |
| +// - A static route (not hibernating, not instance) |
| +// - A resident route (not hibernating, instance kind) |
| +// - A hibernating route with an opted-in app |
| +// - Additional hibernating routes without opt-in to test the various counts |
| +func fixtureRoutes() []registry.Route { |
| + return []registry.Route{ |
| + { |
| + Host: "static-route.internal", |
| + Kind: "static", |
| + App: "", |
| + Status: "ready", |
| + Socket: "", |
| + Hibernate: false, |
| + }, |
| + { |
| + Host: "resident-app.internal", |
| + Kind: "instance", |
| + App: "resident-service", |
| + Status: "ready", |
| + Socket: "", |
| + Hibernate: false, |
| + StatusNamed: false, |
| + }, |
| + { |
| + Host: "hibernating-opted.internal", |
| + Kind: "instance", |
| + App: "opted-app", |
| + Status: "ready", |
| + Socket: "/var/run/opted-app.sock", |
| + Hibernate: true, |
| + StatusNamed: true, |
| + Channel: "stable", |
| + Version: "abc1234", |
| + }, |
| + { |
| + Host: "hibernating-not-opted.internal", |
| + Kind: "instance", |
| + App: "not-opted", |
| + Status: "ready", |
| + Socket: "/var/run/not-opted.sock", |
| + Hibernate: true, |
| + StatusNamed: false, |
| + }, |
| + { |
| + Host: "svc-pdf.internal", |
| + Kind: "service", |
| + App: "svc/pdf", |
| + Status: "ready", |
| + Socket: "", |
| + Hibernate: false, |
| + }, |
| + } |
| +} |
| + |
| +// fixtureProbes creates test probes with known fractions so that banding |
| +// produces non-nil results. It uses values in the middle bands so that |
| +// load and capacity both resolve to band words (not nil). |
| +func fixtureProbes() status.Probes { |
| + memFrac := 0.75 // Between 0.70 and 0.90 -> "busy" |
| + diskFrac := 0.80 // Between 0.70 and 0.90 -> "getting-full" |
| + lagSecs := int64(300) // 5 minutes, healthy (< 15 min) |
| + |
| + // Listening function returns true for hibernating routes, false otherwise. |
| + listening := func(sock string) *bool { |
| + if strings.HasPrefix(sock, "/var/run/") { |
| + // Hibernating routes; return true for "awake" |
| + v := strings.Contains(sock, "opted") |
| + return &v |
| + } |
| + return nil |
| + } |
| + |
| + return status.Probes{ |
| + MachineID: "test-machine-id", |
| + Region: "us-west-1", |
| + MemFrac: &memFrac, |
| + DiskFrac: &diskFrac, |
| + Listening: listening, |
| + LagSecs: &lagSecs, |
| + } |
| +} |
| + |
| +// TestBoxSchemaAllowlisted verifies that Box's JSON output carries only |
| +// fields listed in allowedBoxKeys. Any new field added to Box must be |
| +// explicitly added to allowedBoxKeys to pass this test — that's the guard. |
| +func TestBoxSchemaAllowlisted(t *testing.T) { |
| + b := status.CollectBox(fixtureRoutes(), fixtureProbes(), time.Now()) |
| + j, err := json.Marshal(b) |
| + if err != nil { |
| + t.Fatalf("marshal Box: %v", err) |
| + } |
| + assertOnlyKeys(t, j, allowedBoxKeys) |
| +} |
| + |
| +// TestFleetSchemaAllowlisted verifies that Fleet's JSON output carries only |
| +// fields listed in allowedFleetKeys. Same guard as TestBoxSchemaAllowlisted, |
| +// but for the merged feed: new fields must be explicitly allowlisted. |
| +func TestFleetSchemaAllowlisted(t *testing.T) { |
| + s := teststore(t) |
| + now := time.Now() |
| + |
| + // Write a box so BuildFleet has something to merge. |
| + if err := status.WriteBox(context.Background(), s, |
| + status.CollectBox(fixtureRoutes(), fixtureProbes(), now)); err != nil { |
| + t.Fatalf("WriteBox: %v", err) |
| + } |
| + |
| + // Build the fleet over the teststore with routes that produce apps/services. |
| + f, err := status.BuildFleet(context.Background(), s, fixtureRoutes(), now, 5*time.Minute) |
| + if err != nil { |
| + t.Fatalf("BuildFleet: %v", err) |
| + } |
| + |
| + j, err := json.Marshal(f) |
| + if err != nil { |
| + t.Fatalf("marshal Fleet: %v", err) |
| + } |
| + assertOnlyKeys(t, j, allowedFleetKeys) |
| +} |
| + |
| +// TestNoDigitsInBandSubtrees verifies that the band words (load, capacity) |
| +// never carry numeric digits. The privacy commitment: never publish raw |
| +// fractions or counts, only words. A digit in these fields would violate |
| +// that promise — and could leak enough information for an observer to |
| +// reconstruct the missing band threshold. |
| +func TestNoDigitsInBandSubtrees(t *testing.T) { |
| + b := status.CollectBox(fixtureRoutes(), fixtureProbes(), time.Now()) |
| + for name, v := range map[string]*string{"load": b.Load, "capacity": b.Capacity} { |
| + if v != nil && strings.ContainsAny(*v, "0123456789") { |
| + t.Errorf("%s carries a digit: %q", name, *v) |
| + } |
| + } |
| +} |