package policy import ( "bytes" "encoding/json" "os" "path/filepath" "testing" ) // TestVendoredCopyMatchesContracts fails loudly when the embedded policy and contracts/ drift apart. // Without this the shipped binary can enforce something other than what the tests in test/ checked, // which is exactly the silent-divergence case embedding was supposed to avoid. // // The file this reads is the one this repository owns. The relay keeps its own copy — it derives its // limits from the same numbers — and the two are compared at runtime by the policy digest the agent // reports in its handshake, which the relay surfaces per node. A build-time comparison across two // repositories is not something either side can make, so the guard is the digest and the drift is // visible in /v1/status rather than in a test. func TestVendoredCopyMatchesContracts(t *testing.T) { upstream, err := os.ReadFile(filepath.Join("..", "..", "contracts", "agent-policy.json")) if err != nil { t.Fatalf("read contracts/agent-policy.json: %v", err) } if !bytes.Equal(upstream, Raw()) { t.Fatal("internal/policy/agent-policy.json differs from contracts/agent-policy.json; run make vendor") } } func loaded(t *testing.T) *Policy { t.Helper() p, err := Load() if err != nil { t.Fatalf("Load: %v", err) } return p } func TestDecisionTable(t *testing.T) { p := loaded(t) cases := []struct { method, path string allowed bool dangerous bool }{ {"GET", "/api/health", true, false}, {"GET", "/api/sms/list", true, false}, {"GET", "/api/health?fresh=1", true, false}, {"POST", "/api/sms/send", true, true}, {"POST", "/api/sms/batch-delete", true, true}, // Reads must not be writable even though the path is known. {"POST", "/api/health", false, false}, {"DELETE", "/api/sms/list", false, false}, // Console-only surface that the app never constructs. {"GET", "/api/esim/profiles", false, false}, {"POST", "/api/device-network/ddns/config", false, false}, {"DELETE", "/api/sim/1", false, false}, // Auth is the agent's own business. {"POST", "/api/auth/login", false, false}, {"GET", "/api/auth/logout", false, false}, {"POST", "/api/login", false, false}, // Traversal and off-api targets. {"GET", "/etc/passwd", false, false}, {"GET", "/api/../etc/passwd", false, false}, {"GET", "/api/health/../../etc/passwd", false, false}, {"GET", "", false, false}, {"", "/api/health", false, false}, } for _, c := range cases { allowed, dangerous, reason := p.Decision(c.method, c.path) if allowed != c.allowed { t.Errorf("%s %s: allowed=%v want %v (reason %q)", c.method, c.path, allowed, c.allowed, reason) } if allowed && dangerous != c.dangerous { t.Errorf("%s %s: dangerous=%v want %v", c.method, c.path, dangerous, c.dangerous) } if !allowed && reason == "" { t.Errorf("%s %s: denied without a reason", c.method, c.path) } } } // TestFailClosedOnTampering walks the mutations an operator or a bad merge could plausibly produce // and asserts Parse rejects each one rather than degrading to something looser. func TestFailClosedOnTampering(t *testing.T) { base := Raw() mutate := func(t *testing.T, set func(m map[string]any)) []byte { t.Helper() var m map[string]any if err := json.Unmarshal(base, &m); err != nil { t.Fatalf("unmarshal base: %v", err) } set(m) out, err := json.Marshal(m) if err != nil { t.Fatalf("marshal: %v", err) } return out } t.Run("non loopback egress host", func(t *testing.T) { raw := mutate(t, func(m map[string]any) { m["egress"].(map[string]any)["dialedHosts"] = []any{"10.0.0.9"} }) if _, err := Parse(raw); err == nil { t.Fatal("accepted a routable egress host") } }) t.Run("hostname egress", func(t *testing.T) { raw := mutate(t, func(m map[string]any) { m["egress"].(map[string]any)["dialedHosts"] = []any{"attacker.example"} }) if _, err := Parse(raw); err == nil { t.Fatal("accepted a hostname; that reopens DNS rebinding and /etc/hosts pivots") } }) t.Run("mode changed", func(t *testing.T) { raw := mutate(t, func(m map[string]any) { m["egress"].(map[string]any)["mode"] = "any" }) if _, err := Parse(raw); err == nil { t.Fatal("accepted egress.mode=any") } }) t.Run("auth proxied", func(t *testing.T) { raw := mutate(t, func(m map[string]any) { m["authPaths"].(map[string]any)["proxied"] = true }) if _, err := Parse(raw); err == nil { t.Fatal("accepted proxied auth paths") } }) t.Run("read path is an auth path", func(t *testing.T) { raw := mutate(t, func(m map[string]any) { m["read"] = append(m["read"].([]any), "/api/auth/session") }) if _, err := Parse(raw); err == nil { t.Fatal("accepted an auth path on the read allowlist") } }) t.Run("write without danger", func(t *testing.T) { raw := mutate(t, func(m map[string]any) { m["write"].(map[string]any)["/api/sim/disable"] = []any{"POST"} }) if _, err := Parse(raw); err == nil { t.Fatal("accepted a write that is not declared dangerous") } }) t.Run("write allows GET", func(t *testing.T) { raw := mutate(t, func(m map[string]any) { m["write"].(map[string]any)["/api/sms/send"] = []any{"GET"} }) if _, err := Parse(raw); err == nil { t.Fatal("accepted GET as a write method") } }) t.Run("dangerous path not writable", func(t *testing.T) { raw := mutate(t, func(m map[string]any) { m["dangerous"] = append(m["dangerous"].([]any), "/api/stats") }) if _, err := Parse(raw); err == nil { t.Fatal("accepted a dangerous path with no write entry") } }) t.Run("ttl beyond skew", func(t *testing.T) { raw := mutate(t, func(m map[string]any) { m["limits"].(map[string]any)["requestTtlSeconds"] = 600 }) if _, err := Parse(raw); err == nil { t.Fatal("accepted a TTL the replay window cannot cover") } }) t.Run("replay cache below mailbox", func(t *testing.T) { raw := mutate(t, func(m map[string]any) { m["limits"].(map[string]any)["replayCacheSize"] = 2 }) if _, err := Parse(raw); err == nil { t.Fatal("accepted a replay cache that evicts live requests") } }) t.Run("schema bump", func(t *testing.T) { raw := mutate(t, func(m map[string]any) { m["schemaVersion"] = 2 }) if _, err := Parse(raw); err == nil { t.Fatal("accepted an unknown schemaVersion") } }) t.Run("traversal path", func(t *testing.T) { raw := mutate(t, func(m map[string]any) { m["read"] = append(m["read"].([]any), "/api/../etc/shadow") }) if _, err := Parse(raw); err == nil { t.Fatal("accepted a traversal path") } }) t.Run("malformed json", func(t *testing.T) { if _, err := Parse([]byte("{")); err == nil { t.Fatal("accepted malformed json") } }) } func TestDigestIsStable(t *testing.T) { p := loaded(t) if len(p.SHA256()) != 64 { t.Fatalf("digest looks wrong: %q", p.SHA256()) } again, err := Load() if err != nil { t.Fatalf("Load twice: %v", err) } if again.SHA256() != p.SHA256() { t.Fatal("digest changed between loads") } }