Files
msa-agent/internal/policy/policy_test.go
T
chick 3473816167 feat: 独立成仓库,自己拥有契约文件
agent 从 simadmin-relay 拆出来,作为公开仓库:安装命令要由节点执行,而节点没有凭据,所以
二进制必须放在匿名可读的地方,而 relay 源码与 IPA 继续私有。

拆出来之后有一处必须改,不是搬文件:`contracts/agent-policy.json` 原先由 relay 仓库拥有、
agent 侧是 vendored 副本(Makefile 从 `../contracts/` 拷、policy_test.go 断言两份相等)。
现在反过来——**agent 拥有策略**,因为 agent 是执行它的那一方,让它去拷贝一份住在别处的文件
本来就是别扭的。relay 保留 vendored 副本(它的限额从同一组数字推导),而两侧的一致性不再
靠构建期比较(跨仓库的构建期比较谁也做不了),改由运行时保证:agent 在握手时上报策略摘要,
relay 拿自己那份比对,在 `/v1/status` 里逐节点给出 `policyMatches`。这比原来的测试更强,
因为它比的是**实际部署的那两份**,而不是两个 checkout 里的文件。

`contracts/test-vectors.json` 同理:Go 实现生成它(`make vectors`),relay 的 Node 参考实现
与 app 的 Swift 测试从 vendored 副本重算。实测重新生成的结果与 relay 那份逐字节相同。

systemd 单元的 `Documentation=` 指向本仓库。`VERSION` 提到 1.0.0。

20 个提交的历史完整保留(filter-branch 按子目录抽取)。
2026-09-17 23:24:57 +08:00

217 lines
6.9 KiB
Go

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")
}
}