carlos / platform Public

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>
Paul Campbell 9ca4a6c6874ff0053d56f1e908bb5dc767516a34 parent 64a4e66
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)
+ }
+ }
+}