181 lines
8.0 KiB
Go
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)
|
|
}
|
|
}
|