Files
Agent-System-Backend/internal/app/project_audit_matrix_test.go
T
agent-exec-transport 97f60c1df6
governance / governance (pull_request) Successful in 15s
ci / vet-and-test (pull_request) Successful in 1m20s
agent-exec transport f461d74086dd6a80db72f83748f34b04/128fee1609ad
2026-10-22 03:41:15 +00:00

703 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
// W9 X2 出口矩阵:可选 ProjectAuditHost 的读取面按【受信项目、actor、resource、
// run、source offset 与截止】读取,并结合 RDM audit.read 回调与每对象正文权做
// 服务端裁剪。本文件覆盖批准需求第(4)条列出的九个出口场景,以及第(3)条
// “缺基座能力返回 unsupported 覆盖范围”的语义。
//
// RDM 消费端(audit.read / doc.read 回调的领域实现)不在本仓:这里只验证宿主
// 面必须向注册方询问、按对象裁剪、缺能力即 unsupported,不在宿主侧复制策略。
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// auditMatrix 是出口矩阵的共用基座:alice 拥有 A/B 两个项目并在其中各有一份
// 会话,bob 是 A 的第二成员(会话建在 A 下),carol 只存在于账号目录、不参与
// 任何审计行。alice 的 op-a 是一条已发布链接(绑定 document/doc-1 与一次 run)。
type auditMatrix struct {
t *testing.T
st *store.Store
caps hostcaps
projectA core.ID
projectB core.ID
foreign core.ID
aliceConv core.ID
bobConv core.ID
runID string
link extension.ProjectAuditLinkReceipt
// 策略旋钮:每个用例按出口场景改写,回调只读它们。
auditAll bool
auditRead map[core.ID]bool
metadata map[string]bool
body map[string]bool
auditCalls []string
discloseCall int
}
func newAuditMatrix(t *testing.T) *auditMatrix {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
m := &auditMatrix{t: t, st: st, auditRead: map[core.ID]bool{}, metadata: map[string]bool{}, body: map[string]bool{}}
for _, account := range []struct{ owner, name string }{
{"alice", "Alice at commit"}, {"bob", "Bob"}, {"carol", "Carol directory only"},
} {
if err := st.EnsureUser(account.owner, account.name); err != nil {
t.Fatal(err)
}
}
projectA, err := st.CreateProject("alice", "A")
if err != nil {
t.Fatal(err)
}
projectB, err := st.CreateProject("alice", "B")
if err != nil {
t.Fatal(err)
}
foreign, err := st.CreateProject("carol", "C")
if err != nil {
t.Fatal(err)
}
m.projectA, m.projectB, m.foreign = projectA.ID, projectB.ID, foreign.ID
aliceConv, err := st.CreateConversation("alice", string(projectA.ID), "A work", "")
if err != nil {
t.Fatal(err)
}
otherConv, err := st.CreateConversation("alice", string(projectB.ID), "B work", "")
if err != nil {
t.Fatal(err)
}
bobConv, err := st.CreateConversationInProject("bob", string(projectA.ID), "A shared", "")
if err != nil {
t.Fatal(err)
}
m.aliceConv, m.bobConv = aliceConv.ID, bobConv.ID
run := core.Run{ConversationID: aliceConv.ID, ProjectID: projectA.ID, Owner: "alice",
Kind: core.RunConversation, Status: core.RunRunning, Phase: core.PhaseThinking}
if err := st.CreateRun(&run); err != nil {
t.Fatal(err)
}
m.runID = string(run.ID)
m.append("alice", string(aliceConv.ID), "alice.submit", "op-a", m.runID)
m.append("alice", string(otherConv.ID), "alice.other", "op-b", "")
m.append("bob", string(bobConv.ID), "bob.submit", "op-bob", "")
m.caps = hostcaps{st: st, projectAudit: &projectAuditSlot{}}
reg := pluginRegistry{pluginID: "rdm", caps: m.caps}
if err := reg.RegisterProjectAuditAuthorizer(func(actor string, projectID core.ID) (bool, error) {
m.auditCalls = append(m.auditCalls, actor+"@"+string(projectID))
return m.auditAll || m.auditRead[projectID], nil
}); err != nil {
t.Fatal(err)
}
factory, err := (recordRegistry{Registry: reg}).RegisterProjectAuditLinkVerifier(func(_ context.Context, proof extension.ProjectAuditLinkProof) (bool, error) {
return proof.Owner == "alice" && proof.ProjectID == m.projectA && proof.SourceKind == "domain-commit" &&
proof.SourceID == "commit-a" && proof.OperationID == "op-a" && proof.ResourceKind == "document" &&
proof.ResourceID == "doc-1" && proof.RunID == m.runID, nil
})
if err != nil {
t.Fatal(err)
}
if err := reg.RegisterProjectAuditDisclosureAuthorizer(func(_ context.Context, actor string, resource extension.ProjectAuditLinkedResource) (extension.ProjectAuditDisclosure, error) {
m.discloseCall++
return extension.ProjectAuditDisclosure{Metadata: actor == "bob" && m.metadata[resource.ResourceID],
Body: actor == "bob" && m.body[resource.ResourceID]}, nil
}); err != nil {
t.Fatal(err)
}
publisher, err := factory.ForOwner("alice")
if err != nil {
t.Fatal(err)
}
receipt, err := publisher.PublishProjectAuditLink(context.Background(), extension.ProjectAuditLinkRequest{
ProjectID: m.projectA, SourceKind: "domain-commit", SourceID: "commit-a", OperationID: "op-a",
ResourceKind: "document", ResourceID: "doc-1", RunID: m.runID, DisclosureClass: "metadata", IdempotencyKey: "outbox-a",
})
if err != nil {
t.Fatal(err)
}
m.link = receipt
return m
}
func (m *auditMatrix) append(owner, conversationID, commandID, operationID, runID string) {
m.t.Helper()
if err := m.st.AppendAudit(owner, agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: owner, CommandID: commandID,
ConversationID: conversationID, State: agentcommand.StateCommitted, OperationID: operationID, RunID: runID,
ParamsDigest: "sensitive-digest"}); err != nil {
m.t.Fatal(err)
}
}
func (m *auditMatrix) host(owner string) inprocHost {
return inprocHost{caps: m.caps, owner: owner, actor: owner, pluginID: "rdm"}
}
func (m *auditMatrix) linkedQuery() extension.ProjectAuditQuery {
return extension.ProjectAuditQuery{ProjectID: m.projectA, LinkedSourceKind: "domain-commit",
LinkedSourceID: "commit-a", LinkedHostOffset: m.link.HostAuditOffset}
}
func (m *auditMatrix) openBody() {
m.metadata["doc-1"], m.body["doc-1"] = true, true
}
// 同 owner 跨项目/两成员:受信项目归属与成员身份都必须在服务端判定。
func TestProjectAuditExitMatrixSameOwnerCrossProjectAndTwoMembers(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
m.auditRead[m.projectB] = true
m.metadata["doc-1"] = true
bobPage, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA})
if err != nil {
t.Fatal(err)
}
if len(bobPage.Receipts) != 1 || bobPage.Receipts[0].CommandID != "bob.submit" || bobPage.Receipts[0].ActorName != "Bob" {
t.Fatalf("second member sees another member's row: %+v", bobPage.Receipts)
}
alicePage, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA})
if err != nil {
t.Fatal(err)
}
if len(alicePage.Receipts) != 1 || alicePage.Receipts[0].CommandID != "alice.submit" || alicePage.Receipts[0].ActorName != "Alice at commit" {
t.Fatalf("owner page left its project scope: %+v", alicePage.Receipts)
}
otherProject, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectB})
if err != nil {
t.Fatal(err)
}
if len(otherProject.Receipts) != 1 || otherProject.Receipts[0].CommandID != "alice.other" {
t.Fatalf("project B page: %+v", otherProject.Receipts)
}
m.auditRead[m.projectB] = false
if _, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectB}); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("non-member read another project: %v", err)
}
if got := m.auditCalls[len(m.auditCalls)-1]; got != "bob@"+string(m.projectB) {
t.Fatalf("host did not ask the domain about the caller's project: %s", got)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{}); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("empty project id: %v", err)
}
}
// 恶意 projectId:调用方自报的项目标识只是定位符,既不能换出别的项目,也不能
// 让“看起来通过”的未知项目变成受信项目流。
func TestProjectAuditExitMatrixMaliciousProjectID(t *testing.T) {
m := newAuditMatrix(t)
// 领域回调对所有项目一律放行(配置失误):宿主仍只能按受信存储归集。
m.auditAll = true
for _, projectID := range []core.ID{m.foreign, "no-such-project"} {
page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: projectID})
if err != nil {
t.Fatalf("project %q: %v", projectID, err)
}
if len(page.Receipts) != 0 {
t.Fatalf("project %q returned rows: %+v", projectID, page.Receipts)
}
if page.Coverage.LegacyProjectBinding != "not-recorded" || page.Coverage.Export != "unsupported" {
t.Fatalf("project %q coverage: %+v", projectID, page.Coverage)
}
}
// 领域误放行也不能换出受信任存储之外的对象:每个主体只看得到自己的行。
for _, c := range []struct {
owner string
projectID core.ID
want string
}{
{"alice", m.projectA, "alice.submit"}, {"bob", m.projectA, "bob.submit"},
{"alice", m.projectB, "alice.other"}, {"bob", m.projectB, ""},
} {
page, err := m.host(c.owner).QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: c.projectID})
if err != nil {
t.Fatalf("%s@%s: %v", c.owner, c.projectID, err)
}
got := []string{}
for _, receipt := range page.Receipts {
got = append(got, receipt.CommandID)
}
if strings.Join(got, ",") != c.want {
t.Fatalf("%s@%s rows = %v, want [%s]", c.owner, c.projectID, got, c.want)
}
}
// 他人项目下的链接按 projectId 定位失败:不得跨项目取回。
crossProject := m.linkedQuery()
crossProject.ProjectID = m.foreign
if _, err := m.host("bob").QueryProjectAudit(context.Background(), crossProject); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("cross-project link locator: %v", err)
}
// 调用方自报的 resource/run 定位符必须与受信绑定一致,否则拒绝。
// 先打开逐对象元数据权,使拒绝只能来自定位符校验本身。
m.metadata["doc-1"] = true
for _, mutate := range []func(*extension.ProjectAuditQuery){
func(q *extension.ProjectAuditQuery) { q.LinkedResourceID = "doc-2" },
func(q *extension.ProjectAuditQuery) { q.LinkedResourceKind = "run" },
func(q *extension.ProjectAuditQuery) { q.LinkedRunID = "run-other" },
} {
q := m.linkedQuery()
mutate(&q)
if _, err := m.host("bob").QueryProjectAudit(context.Background(), q); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("forged locator was accepted: %+v err=%v", q, err)
}
}
q := m.linkedQuery()
q.LinkedResourceKind, q.LinkedResourceID, q.LinkedRunID = "document", "doc-1", m.runID
if _, err := m.host("bob").QueryProjectAudit(context.Background(), q); err != nil {
t.Fatalf("matching locators were refused: %v", err)
}
}
// audit.read 无正文:元数据权成立、正文权不成立时只出元数据;请求正文则拒绝,
// 不得返回一份“被悄悄裁剪”的页冒充有正文覆盖。
func TestProjectAuditExitMatrixAuditReadWithoutBody(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
m.metadata["doc-1"] = true
page, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery())
if err != nil {
t.Fatal(err)
}
if len(page.Receipts) != 1 || page.Receipts[0].Resource != nil || page.Receipts[0].ActorName != "Alice at commit" {
t.Fatalf("metadata-only page: %+v", page.Receipts)
}
if page.Coverage.ResourceBodies != "unsupported" || page.Coverage.CommandAudit != "verified-exact-link-only" {
t.Fatalf("coverage overstated body coverage: %+v", page.Coverage)
}
body := m.linkedQuery()
body.IncludeBody = true
if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("body requested without the body right: %v", err)
}
// 无受信对象绑定的 actor 自有页没有每对象正文权可言:显式 unsupported。
owned := extension.ProjectAuditQuery{ProjectID: m.projectA, IncludeBody: true}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), owned); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("actor-owned body read: %v", err)
}
// 正文权成立时按对象裁剪下发,且不下发正文载荷。
m.openBody()
// 有权但未请求:不主动多给对象身份。
undeclared, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery())
if err != nil {
t.Fatal(err)
}
if len(undeclared.Receipts) != 1 || undeclared.Receipts[0].Resource != nil || undeclared.Coverage.ResourceBodies != "unsupported" {
t.Fatalf("body right was exercised without a body request: %+v coverage=%+v", undeclared.Receipts, undeclared.Coverage)
}
bodyPage, err := m.host("bob").QueryProjectAudit(context.Background(), body)
if err != nil {
t.Fatal(err)
}
if len(bodyPage.Receipts) != 1 || bodyPage.Receipts[0].Resource == nil ||
bodyPage.Receipts[0].Resource.Kind != "document" || bodyPage.Receipts[0].Resource.ID != "doc-1" ||
bodyPage.Receipts[0].Resource.RunID != m.runID || bodyPage.Coverage.ResourceBodies != "verified-exact-link-body" ||
bodyPage.Coverage.RunRelations != "verified-exact-link-run" {
t.Fatalf("body-permitted page: %+v coverage=%+v", bodyPage.Receipts, bodyPage.Coverage)
}
raw, err := json.Marshal(bodyPage)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "sensitive-digest") || strings.Contains(string(raw), "paramsDigest") ||
strings.Contains(string(raw), string(m.aliceConv)) {
t.Fatalf("body-permitted page leaked a payload or private locator: %s", raw)
}
}
// 正文无 audit.read:对象权先成立也必须先被项目 audit.read 挡住,且不得因为
// 逐对象判定而向无 audit.read 的主体泄露“这个对象存在”。
func TestProjectAuditExitMatrixBodyWithoutAuditRead(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
m.openBody()
body := m.linkedQuery()
body.IncludeBody = true
if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); err != nil {
t.Fatalf("baseline body read: %v", err)
}
m.auditRead[m.projectA] = false
before := m.discloseCall
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("body right survived revoked audit.read: %v", err)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), body); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("body read without audit.read: %v", err)
}
if m.discloseCall != before {
t.Fatalf("per-object decision was consulted before audit.read: %d -> %d", before, m.discloseCall)
}
}
// 对象已撤权/删改:撤权走逐对象判定;删除与改动让存储侧无法再把该链接解析成
// 受信任回执;已删除的 run 关系也不再服务回执。
func TestProjectAuditExitMatrixObjectRevokedDeletedOrEdited(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
m.openBody()
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil {
t.Fatalf("baseline link: %v", err)
}
m.metadata["doc-1"], m.body["doc-1"] = false, false
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("revoked object: %v", err)
}
m.openBody()
// run 关系被改(run 移出本项目):链接仍在,但受约束的 run 不再匹配。
if _, err := m.st.DB().Exec(`UPDATE runs SET project_id=? WHERE id=?`, string(m.projectB), m.runID); err != nil {
t.Fatal(err)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("moved run kept serving its receipt: %v", err)
}
if _, err := m.st.DB().Exec(`UPDATE runs SET project_id=? WHERE id=?`, string(m.projectA), m.runID); err != nil {
t.Fatal(err)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil {
t.Fatalf("restored run: %v", err)
}
// 宿主回执被改动(载荷里的 operationId 漂移):不再是领域验证过的那一条。
var stored string
if err := m.st.DB().QueryRow(`SELECT payload FROM command_audit WHERE id=?`, m.link.HostAuditOffset).Scan(&stored); err != nil {
t.Fatal(err)
}
forged, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: "alice",
CommandID: "alice.submit", ConversationID: string(m.aliceConv), State: agentcommand.StateCommitted, OperationID: "op-forged"})
if err != nil {
t.Fatal(err)
}
if _, err := m.st.DB().Exec(`UPDATE command_audit SET payload=? WHERE id=?`, string(forged), m.link.HostAuditOffset); err != nil {
t.Fatal(err)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("edited receipt still resolved: %v", err)
}
if _, err := m.st.DB().Exec(`UPDATE command_audit SET payload=? WHERE id=?`, stored, m.link.HostAuditOffset); err != nil {
t.Fatal(err)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); err != nil {
t.Fatalf("restored receipt: %v", err)
}
// 链接被删:关联不再存在,回执不再属于本项目审计。
if _, err := m.st.DB().Exec(`DELETE FROM project_audit_links WHERE id=?`, m.link.LinkID); err != nil {
t.Fatal(err)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("deleted link still resolved: %v", err)
}
// 宿主回执的项目归属被改:外键仍在,但它已不是这一条被验证的绑定。
if _, err := m.st.DB().Exec(`INSERT INTO project_audit_links(id,host_audit_offset,project_id,owner,source_kind,source_id,operation_id,resource_kind,resource_id,run_id,disclosure_class,idempotency_key,created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, m.link.LinkID, m.link.HostAuditOffset, string(m.projectA), "alice", "domain-commit",
"commit-a", "op-a", "document", "doc-1", m.runID, "metadata", "outbox-a", time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
if _, err := m.st.DB().Exec(`UPDATE command_audit SET project_id=? WHERE id=?`, string(m.projectB), m.link.HostAuditOffset); err != nil {
t.Fatal(err)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditDenied) {
t.Fatalf("re-attributed receipt still resolved: %v", err)
}
}
// 历史 cutoff:第一页固定的上限在其后写入的行不得进入本次分页视图。
func TestProjectAuditExitMatrixHistoricalCutoff(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
for i := 0; i < 4; i++ {
m.append("bob", string(m.bobConv), "bob.bulk-"+string(rune('a'+i)), "op-bulk", "")
}
first, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 2})
if err != nil {
t.Fatal(err)
}
if len(first.Receipts) != 2 || first.CutoffOffset == 0 || first.NextOffset == 0 {
t.Fatalf("first page: %+v", first)
}
m.append("bob", string(m.bobConv), "bob.after-cutoff", "op-after", "")
seen := map[string]bool{}
for _, receipt := range first.Receipts {
seen[receipt.CommandID] = true
}
before, cutoff := first.NextOffset, first.CutoffOffset
for page := first; page.NextOffset != 0; {
if before >= cutoff {
t.Fatalf("cursor did not move: before=%d cutoff=%d", before, cutoff)
}
page, err = m.host("bob").QueryProjectAudit(context.Background(),
extension.ProjectAuditQuery{ProjectID: m.projectA, BeforeOffset: before, CutoffOffset: cutoff, Limit: 2})
if err != nil {
t.Fatal(err)
}
if page.CutoffOffset != cutoff {
t.Fatalf("cutoff moved mid-read: %d -> %d", cutoff, page.CutoffOffset)
}
before = page.NextOffset
for _, receipt := range page.Receipts {
if seen[receipt.CommandID] {
t.Fatalf("duplicate receipt across pages: %s", receipt.CommandID)
}
seen[receipt.CommandID] = true
}
}
if len(seen) != 5 || seen["bob.after-cutoff"] {
t.Fatalf("historical cutoff read drifted: %v", seen)
}
// 不带 cutoff 的新读取才能看到其后写入的行。
fresh, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(fresh.Receipts) != 6 || fresh.CutoffOffset <= cutoff {
t.Fatalf("fresh read did not advance: n=%d cutoff=%d", len(fresh.Receipts), fresh.CutoffOffset)
}
}
// 导出:审计授权不是导出授权。本切片不提供整段导出,只能分页,且覆盖显式声明。
func TestProjectAuditExitMatrixExportIsRefusedNotDegraded(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
m.openBody()
for _, q := range []extension.ProjectAuditQuery{
{ProjectID: m.projectA, Export: true},
{ProjectID: m.projectA, Export: true, Limit: 100000},
} {
page, err := m.host("bob").QueryProjectAudit(context.Background(), q)
if !errors.Is(err, extension.ErrProjectAuditUnsupported) || len(page.Receipts) != 0 {
t.Fatalf("export was not refused: page=%+v err=%v", page, err)
}
}
exportLink := m.linkedQuery()
exportLink.Export = true
if _, err := m.host("bob").QueryProjectAudit(context.Background(), exportLink); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("export through the linked path: %v", err)
}
linked, err := m.host("bob").QueryProjectAudit(context.Background(), m.linkedQuery())
if err != nil {
t.Fatal(err)
}
if linked.Coverage.Export != "unsupported" {
t.Fatalf("linked page export coverage: %+v", linked.Coverage)
}
for i := 0; i < 205; i++ {
m.append("bob", string(m.bobConv), "bob.flood", "op-flood", "")
}
huge, err := m.host("bob").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 100000})
if err != nil {
t.Fatal(err)
}
if len(huge.Receipts) > 200 || huge.NextOffset == 0 || huge.Coverage.Export != "unsupported" {
t.Fatalf("unbounded read was not capped and reported: n=%d next=%d coverage=%+v", len(huge.Receipts), huge.NextOffset, huge.Coverage)
}
}
// 长分页:多次翻页必须完整、不重不漏,且游标单调收敛。
func TestProjectAuditExitMatrixLongPagination(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
const total = 25
for i := 0; i < total; i++ {
m.append("bob", string(m.bobConv), "bob.page", "op-page", "")
}
seen := map[int64]bool{}
before, cutoff := int64(0), int64(0)
for pages := 0; ; pages++ {
if pages > 10 {
t.Fatalf("pagination did not terminate: %d rows", len(seen))
}
page, err := m.host("bob").QueryProjectAudit(context.Background(),
extension.ProjectAuditQuery{ProjectID: m.projectA, BeforeOffset: before, CutoffOffset: cutoff, Limit: 7})
if err != nil {
t.Fatal(err)
}
switch {
case cutoff == 0:
cutoff = page.CutoffOffset
if cutoff == 0 {
t.Fatal("first page must establish a cutoff")
}
case page.CutoffOffset != cutoff:
t.Fatalf("cutoff drifted on page %d: %d != %d", pages, page.CutoffOffset, cutoff)
}
if len(page.Receipts) > 7 {
t.Fatalf("page %d exceeded the requested limit: %d", pages, len(page.Receipts))
}
for i, receipt := range page.Receipts {
if seen[receipt.SourceOffset] {
t.Fatalf("offset %d repeated on page %d", receipt.SourceOffset, pages)
}
if i > 0 && receipt.SourceOffset >= page.Receipts[i-1].SourceOffset {
t.Fatalf("page %d is not strictly descending: %+v", pages, page.Receipts)
}
seen[receipt.SourceOffset] = true
}
if page.NextOffset == 0 {
break
}
if before != 0 && page.NextOffset >= before {
t.Fatalf("cursor did not advance: %d -> %d", before, page.NextOffset)
}
before = page.NextOffset
}
if len(seen) != total+1 {
t.Fatalf("long pagination lost rows: got %d want %d", len(seen), total+1)
}
}
// 旧记录不补造字段:显示名是动作当时记录的最小快照,旧行保持 not-recorded;
// 账号目录也不会随审计页下发。
func TestProjectAuditExitMatrixLegacyRowsAreNotBackfilled(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
legacy, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now().UTC(), Actor: "alice",
CommandID: "alice.legacy", ConversationID: string(m.aliceConv), State: agentcommand.StateCommitted})
if err != nil {
t.Fatal(err)
}
if _, err := m.st.DB().Exec(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload,actor_name,project_id)
VALUES (?,?,'legacy','','committed','',?,?,?,'',?)`, time.Now().UTC().Format(time.RFC3339Nano), "alice.legacy",
string(m.aliceConv), "alice", string(legacy), string(m.projectA)); err != nil {
t.Fatal(err)
}
page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA})
if err != nil {
t.Fatal(err)
}
var legacyReceipt *extension.HostAuditReceipt
for i := range page.Receipts {
if page.Receipts[i].CommandID == "alice.legacy" {
legacyReceipt = &page.Receipts[i]
}
}
if legacyReceipt == nil || legacyReceipt.ActorName != "" || legacyReceipt.ActorNameStatus != "not-recorded" {
t.Fatalf("legacy row fabricated a display name: %+v", page.Receipts)
}
if _, err := m.st.DB().Exec(`UPDATE users SET name=? WHERE owner=?`, "Alice renamed", "alice"); err != nil {
t.Fatal(err)
}
after, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA})
if err != nil {
t.Fatal(err)
}
seen := 0
for _, receipt := range after.Receipts {
seen++
switch receipt.CommandID {
case "alice.legacy":
if receipt.ActorName != "" || receipt.ActorNameStatus != "not-recorded" {
t.Fatalf("rename backfilled an old row: %+v", receipt)
}
case "alice.submit":
if receipt.ActorName != "Alice at commit" || receipt.ActorNameStatus != "recorded" {
t.Fatalf("recorded name was not preserved: %+v", receipt)
}
}
}
if seen != 2 {
t.Fatalf("unexpected row count after rename: %d", seen)
}
m.append("alice", string(m.aliceConv), "alice.after-rename", "op-rename", "")
fresh, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 1})
if err != nil {
t.Fatal(err)
}
if len(fresh.Receipts) != 1 || fresh.Receipts[0].CommandID != "alice.after-rename" || fresh.Receipts[0].ActorName != "Alice renamed" {
t.Fatalf("new row did not record the current name: %+v", fresh.Receipts)
}
raw, err := json.Marshal(page)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "Carol directory only") || strings.Contains(string(raw), "Bob") {
t.Fatalf("account directory leaked through the audit page: %s", raw)
}
}
// owner 全量审计不是项目流:租户级审计与项目审计互不越界;缺基座能力一律
// unsupported(而非静默空页或降级读取)。
func TestProjectAuditOwnerWideAuditIsNotAProjectFlowAndUnsupportedIsExplicit(t *testing.T) {
m := newAuditMatrix(t)
m.auditRead[m.projectA] = true
m.metadata["doc-1"] = true
// 租户级审计(含无项目归属的旧行)不得成为项目流的取数来源。
if _, err := m.st.DB().Exec(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload)
VALUES (?,'unbound','','','committed','','','alice','{}')`, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
t.Fatal(err)
}
ownerWide, err := m.st.ListAudits("alice", "", 100)
if err != nil {
t.Fatal(err)
}
page, err := m.host("alice").QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA, Limit: 100})
if err != nil {
t.Fatal(err)
}
if len(ownerWide) <= len(page.Receipts) {
t.Fatalf("owner-wide audit unexpectedly matched the project page: %d vs %d", len(ownerWide), len(page.Receipts))
}
for _, receipt := range page.Receipts {
if receipt.CommandID == "unbound" || receipt.CommandID == "bob.submit" {
t.Fatalf("owner-wide or cross-member row reached the project page: %+v", receipt)
}
}
if page.Coverage.CrossMember != "unsupported" || page.Coverage.LegacyProjectBinding != "not-recorded" {
t.Fatalf("coverage must state the project-flow boundary: %+v", page.Coverage)
}
// 缺基座能力:没有槽位、没有注册方、不是注册插件的宿主句柄。
if _, err := (inprocHost{caps: hostcaps{st: m.st}, owner: "bob", actor: "bob", pluginID: "rdm"}).
QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("missing audit slot: %v", err)
}
if _, err := (inprocHost{caps: hostcaps{st: m.st, projectAudit: &projectAuditSlot{}}, owner: "bob", actor: "bob", pluginID: "rdm"}).
QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("unregistered authorizer: %v", err)
}
if _, err := (inprocHost{caps: m.caps, owner: "bob", actor: "bob", pluginID: "other"}).
QueryProjectAudit(context.Background(), extension.ProjectAuditQuery{ProjectID: m.projectA}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("another plugin borrowed the policy: %v", err)
}
// 逐对象判定缺失时,精确链接读取也必须 unsupported。
noDisclosure := hostcaps{st: m.st, projectAudit: &projectAuditSlot{}}
if err := (pluginRegistry{pluginID: "rdm", caps: noDisclosure}).RegisterProjectAuditAuthorizer(
func(string, core.ID) (bool, error) { return true, nil }); err != nil {
t.Fatal(err)
}
if _, err := (inprocHost{caps: noDisclosure, owner: "bob", actor: "bob", pluginID: "rdm"}).
QueryProjectAudit(context.Background(), m.linkedQuery()); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("missing disclosure authorizer: %v", err)
}
// 声明式扩展与缺少该接口的注册表都明确拒绝注册,不静默忽略。
if err := (compiledRegistry{}).RegisterProjectAuditAuthorizer(func(string, core.ID) (bool, error) { return true, nil }); err == nil {
t.Fatal("declarative plugin registered an audit authorizer")
}
if err := (recordRegistry{Registry: pluginRegistry{pluginID: "rdm", caps: hostcaps{}}}).RegisterProjectAuditAuthorizer(
func(string, core.ID) (bool, error) { return true, nil }); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("registry without audit capability: %v", err)
}
// 导出与无对象正文权同样是显式 unsupported。
if _, err := m.host("bob").QueryProjectAudit(context.Background(),
extension.ProjectAuditQuery{ProjectID: m.projectA, Export: true}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("export on the actor-owned page: %v", err)
}
if _, err := m.host("bob").QueryProjectAudit(context.Background(),
extension.ProjectAuditQuery{ProjectID: m.projectA, IncludeBody: true}); !errors.Is(err, extension.ErrProjectAuditUnsupported) {
t.Fatalf("body on the actor-owned page: %v", err)
}
}