Files
Agent-System-Backend/internal/store/project_audit_links_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

181 lines
8.0 KiB
Go

package store
import (
"encoding/json"
"errors"
"path/filepath"
"testing"
"time"
agentcommand "agent-command"
)
func TestProjectAuditLinkStoreIdempotenceAndExactScope(t *testing.T) {
st := openTestStore(t) // SQLite locally; isolated PostgreSQL schema when configured.
if err := st.EnsureUser("alice", "Alice"); err != nil {
t.Fatal(err)
}
a, err := st.CreateProject("alice", "A")
if err != nil {
t.Fatal(err)
}
b, err := st.CreateProject("alice", "B")
if err != nil {
t.Fatal(err)
}
c, err := st.CreateConversation("alice", string(a.ID), "work", "")
if err != nil {
t.Fatal(err)
}
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm", OperationID: "op-1", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil {
t.Fatal(err)
}
rows, _, _, err := st.ListProjectCommandAudits("alice", string(a.ID), 0, 0, 10)
if err != nil {
t.Fatal(err)
}
resolved, err := st.CommittedAuditOffset("alice", string(a.ID), "op-1")
if err != nil || resolved != rows[0].Offset {
t.Fatalf("trusted operation lookup: %d %v", resolved, err)
}
if _, err := st.CommittedAuditOffset("alice", string(a.ID), "op-pending"); !errors.Is(err, ErrNotFound) {
t.Fatalf("unrecorded operation should wait: %v", err)
}
link := ProjectAuditLink{HostAuditOffset: rows[0].Offset, ProjectID: string(a.ID), Owner: "alice", SourceKind: "domain-commit", SourceID: "commit-1", OperationID: "op-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"}
first, err := st.SaveProjectAuditLink(link)
if err != nil {
t.Fatal(err)
}
retry, err := st.SaveProjectAuditLink(link)
if err != nil || retry.ID != first.ID {
t.Fatalf("retry: first=%+v retry=%+v err=%v", first, retry, err)
}
if found, err := st.ProjectAuditLinkByKey("alice", "outbox-1"); err != nil || found.ID != first.ID {
t.Fatalf("key lookup: %+v %v", found, err)
}
if _, _, err := st.ProjectAuditLinkedReceipt(string(b.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) {
t.Fatalf("wrong project: %v", err)
}
if _, _, err := st.ProjectAuditLinkedReceipt(string(a.ID), "domain-commit", "other", rows[0].Offset); !errors.Is(err, ErrNotFound) {
t.Fatalf("wrong source: %v", err)
}
got, audit, err := st.ProjectAuditLinkedReceipt(string(a.ID), "domain-commit", "commit-1", rows[0].Offset)
if err != nil || got.ID != first.ID || audit.Envelope.CommandID != "rdm.confirm" {
t.Fatalf("linked read: %+v %+v %v", got, audit, err)
}
link.ResourceID = "doc-2"
if _, err := st.SaveProjectAuditLink(link); !errors.Is(err, ErrConflict) {
t.Fatalf("same key different binding: %v", err)
}
link.ResourceID = "doc-1"
link.IdempotencyKey = "outbox-2"
if _, err := st.SaveProjectAuditLink(link); !errors.Is(err, ErrConflict) {
t.Fatalf("same source different key: %v", err)
}
if err := st.EnsureUser("bob", "Bob"); err != nil {
t.Fatal(err)
}
bobConversation, err := st.CreateConversationInProject("bob", string(a.ID), "shared work", "")
if err != nil {
t.Fatal(err)
}
if err := st.AppendAudit("bob", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "bob", CommandID: "rdm.confirm", OperationID: "op-bob", ConversationID: string(bobConversation.ID), State: agentcommand.StateCommitted}); err != nil {
t.Fatal(err)
}
bobRows, _, _, err := st.ListProjectCommandAudits("bob", string(a.ID), 0, 0, 10)
if err != nil {
t.Fatal(err)
}
bobLink := ProjectAuditLink{HostAuditOffset: bobRows[0].Offset, ProjectID: string(a.ID), Owner: "bob", SourceKind: "domain-commit", SourceID: "commit-bob", OperationID: "op-bob", ResourceKind: "document", ResourceID: "doc-bob", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"}
if _, err := st.SaveProjectAuditLink(bobLink); err != nil {
t.Fatalf("another owner may use the same outbox key: %v", err)
}
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm", OperationID: "op-1", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil {
t.Fatal(err)
}
if _, err := st.CommittedAuditOffset("alice", string(a.ID), "op-1"); !errors.Is(err, ErrConflict) {
t.Fatalf("ambiguous host operation must not choose one: %v", err)
}
}
// 已发布链接的绑定不可变:宿主回执在发布后被删或被改(状态、operation、载荷)
// 时,读取侧不得再把它当作领域验证过的那一条返回。SQLite 是本测试的落点——
// PostgreSQL 由 all-writer 触发器在写侧直接拒绝改动。
func TestProjectAuditLinkedReceiptRefusesEditedOrDeletedHostRows(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "links.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnsureUser("alice", "Alice"); err != nil {
t.Fatal(err)
}
p, err := st.CreateProject("alice", "A")
if err != nil {
t.Fatal(err)
}
c, err := st.CreateConversation("alice", string(p.ID), "work", "")
if err != nil {
t.Fatal(err)
}
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm",
OperationID: "op-1", ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil {
t.Fatal(err)
}
rows, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
if err != nil {
t.Fatal(err)
}
link := ProjectAuditLink{HostAuditOffset: rows[0].Offset, ProjectID: string(p.ID), Owner: "alice", SourceKind: "domain-commit",
SourceID: "commit-1", OperationID: "op-1", ResourceKind: "document", ResourceID: "doc-1", DisclosureClass: "metadata", IdempotencyKey: "outbox-1"}
if _, err := st.SaveProjectAuditLink(link); err != nil {
t.Fatal(err)
}
if got, audit, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); err != nil ||
got.OperationID != "op-1" || audit.Envelope.CommandID != "rdm.confirm" {
t.Fatalf("baseline link: %+v %+v %v", got, audit, err)
}
var stored string
if err := st.db.QueryRow(st.q(`SELECT payload FROM command_audit WHERE id=?`), rows[0].Offset).Scan(&stored); err != nil {
t.Fatal(err)
}
forged, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "rdm.confirm",
OperationID: "op-forged", ConversationID: string(c.ID), State: agentcommand.StateCommitted})
if err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET payload=? WHERE id=?`), string(forged), rows[0].Offset); err != nil {
t.Fatal(err)
}
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrConflict) {
t.Fatalf("edited envelope still resolved as the verified link: %v", err)
}
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET payload=? WHERE id=?`), stored, rows[0].Offset); err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET operation_id=? WHERE id=?`), "op-other", rows[0].Offset); err != nil {
t.Fatal(err)
}
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) {
t.Fatalf("edited operation column still resolved: %v", err)
}
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET operation_id=? WHERE id=?`), "op-1", rows[0].Offset); err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET state=? WHERE id=?`), "accepted", rows[0].Offset); err != nil {
t.Fatal(err)
}
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) {
t.Fatalf("uncommitted row still resolved: %v", err)
}
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET state=? WHERE id=?`), "committed", rows[0].Offset); err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(st.q(`UPDATE command_audit SET project_id=? WHERE id=?`), "", rows[0].Offset); err != nil {
t.Fatal(err)
}
if _, _, err := st.ProjectAuditLinkedReceipt(string(p.ID), "domain-commit", "commit-1", rows[0].Offset); !errors.Is(err, ErrNotFound) {
t.Fatalf("re-attributed row still resolved: %v", err)
}
}