Files
Agent-System-Backend/internal/store/project_audit_test.go
weichen 63ebc85695 Make PostgreSQL project audit cutoffs commit ordered
Why: PostgreSQL sequence IDs could commit out of order and make fixed project audit cursors permanently skip a late row.

Evidence: SELF PG cross-page RED, CACHE 10 counterexample, configuration drift RED/GREEN, and pre-commit Go, PG migration, and race receipts are recorded in the W9 task card. Fixed-HEAD checks follow this commit.

Impact: Guard implicit command audit ID allocation with a transaction advisory lock; reject unsafe sequence/default drift; preserve explicit-ID storage migration; report committed-prefix only for verified project pages.

Refs: Approved W9 PostgreSQL cutoff adjustment r1; W9 Backend task card.
2026-10-14 05:41:37 +00:00

268 lines
9.2 KiB
Go

package store
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
agentcommand "agent-command"
)
func TestProjectCommandAuditsUseStoredConversationProjectAndFixedCutoff(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnsureUser("alice", "Alice at action time"); 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)
}
convA, err := st.CreateConversation("alice", string(projectA.ID), "A work", "")
if err != nil {
t.Fatal(err)
}
convB, err := st.CreateConversation("alice", string(projectB.ID), "B work", "")
if err != nil {
t.Fatal(err)
}
appendCommand := func(owner, conversation, command string) {
t.Helper()
if err := st.AppendAudit(owner, agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: command, ConversationID: conversation, State: agentcommand.StateCommitted, ParamsDigest: "private-digest"}); err != nil {
t.Fatal(err)
}
}
appendCommand("alice", string(convA.ID), "a.one")
appendCommand("alice", string(convB.ID), "b.one")
appendCommand("alice", "client-supplied-only", "unbound")
appendCommand("bob", string(convA.ID), "wrong-owner")
appendCommand("alice", string(convA.ID), "a.two")
var expectedCutoff int64
if err := st.db.QueryRow(st.q(`SELECT id FROM command_audit WHERE command_id=?`), "a.two").Scan(&expectedCutoff); err != nil {
t.Fatal(err)
}
appendCommand("alice", string(convB.ID), "b.latest")
first, cutoff, next, err := st.ListProjectCommandAudits("alice", string(projectA.ID), 0, 0, 1)
if err != nil {
t.Fatal(err)
}
if len(first) != 1 || first[0].Envelope.CommandID != "a.two" || first[0].ActorName != "Alice at action time" || next == 0 || cutoff != expectedCutoff {
t.Fatalf("first page: rows=%+v cutoff=%d next=%d", first, cutoff, next)
}
appendCommand("alice", string(convA.ID), "a.late")
second, fixed, next, err := st.ListProjectCommandAudits("alice", string(projectA.ID), next, cutoff, 1)
if err != nil {
t.Fatal(err)
}
if fixed != cutoff || len(second) != 1 || second[0].Envelope.CommandID != "a.one" || next != 0 {
t.Fatalf("second page: rows=%+v cutoff=%d next=%d", second, fixed, next)
}
other, _, _, err := st.ListProjectCommandAudits("alice", string(projectB.ID), 0, 0, 10)
if err != nil {
t.Fatal(err)
}
if len(other) != 2 || other[0].Envelope.CommandID != "b.latest" || other[1].Envelope.CommandID != "b.one" {
t.Fatalf("B rows: %+v", other)
}
unknown, _, _, err := st.ListProjectCommandAudits("alice", "untrusted-project", 0, 0, 10)
if err != nil {
t.Fatal(err)
}
if len(unknown) != 0 {
t.Fatalf("untrusted project rows: %+v", unknown)
}
}
func TestProjectCommandAuditKeepsActorNameSnapshot(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.EnsureUser("alice", "Former name"); 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), "A work", "")
if err != nil {
t.Fatal(err)
}
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "old", ConversationID: string(c.ID)}); err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(st.q(`UPDATE users SET name=? WHERE owner=?`), "Current name", "alice"); err != nil {
t.Fatal(err)
}
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "new", ConversationID: string(c.ID)}); err != nil {
t.Fatal(err)
}
legacy, err := json.Marshal(agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "legacy", ConversationID: string(c.ID)})
if err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(st.q(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, owner, payload) VALUES (?, ?, '', '', '', '', ?, ?, ?)`), time.Now().UTC().Format(time.RFC3339Nano), "legacy", string(c.ID), "alice", string(legacy)); err != nil {
t.Fatal(err)
}
rows, _, _, err := st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 10)
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].ActorName != "Current name" || rows[1].ActorName != "Former name" {
t.Fatalf("names: %+v", rows)
}
var legacyProject string
if err := st.db.QueryRow(st.q(`SELECT project_id FROM command_audit WHERE command_id=?`), "legacy").Scan(&legacyProject); err != nil {
t.Fatal(err)
}
if legacyProject != "" {
t.Fatalf("legacy attribution was fabricated: %q", legacyProject)
}
}
func TestProjectCommandAuditDoesNotMoveWithConversation(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "audit.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
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)
}
appendCommand := func(id string) {
t.Helper()
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: id, ConversationID: string(c.ID)}); err != nil {
t.Fatal(err)
}
}
appendCommand("before-move")
if _, err := st.db.Exec(st.q(`UPDATE conversations SET project_id=? WHERE id=?`), string(b.ID), string(c.ID)); err != nil {
t.Fatal(err)
}
appendCommand("after-move")
oldProject, _, _, err := st.ListProjectCommandAudits("alice", string(a.ID), 0, 0, 10)
if err != nil {
t.Fatal(err)
}
newProject, _, _, err := st.ListProjectCommandAudits("alice", string(b.ID), 0, 0, 10)
if err != nil {
t.Fatal(err)
}
if len(oldProject) != 1 || oldProject[0].Envelope.CommandID != "before-move" || len(newProject) != 1 || newProject[0].Envelope.CommandID != "after-move" {
t.Fatalf("project attribution changed: A=%+v B=%+v", oldProject, newProject)
}
}
// Under the old PostgreSQL SERIAL default, an uncommitted middle ID can be
// skipped after a higher committed row advances the page cursor past it.
// The committed-prefix implementation must keep the later writer waiting.
func TestProjectAuditPostgresCursorNeverSkipsLateCommit(t *testing.T) {
if os.Getenv("AGENT_PG_TEST_DSN") == "" {
t.Skip("AGENT_PG_TEST_DSN unset: PG concurrency check NOT_RUN")
}
st := openTestStore(t)
if st.Dialect() != "postgres" {
t.Fatal("expected postgres")
}
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)
}
for _, command := range []string{"lower-one", "lower-two"} {
if err := st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: command, ConversationID: string(c.ID), State: agentcommand.StateCommitted}); err != nil {
t.Fatal(err)
}
}
env := agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "middle-pending", ConversationID: string(c.ID), State: agentcommand.StateCommitted}
raw, err := json.Marshal(env)
if err != nil {
t.Fatal(err)
}
tx, err := st.db.Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
var middleID int64
if err := tx.QueryRow(st.q(`INSERT INTO command_audit(time,command_id,source,entrance,state,operation_id,conversation_id,owner,payload,actor_name,project_id)
VALUES (?,?,'','','committed','',?,?,?,?,?) RETURNING id`), time.Now().UTC().Format(time.RFC3339Nano), "middle-pending", string(c.ID), "alice", string(raw), "Alice", string(p.ID)).Scan(&middleID); err != nil {
t.Fatal(err)
}
highDone := make(chan error, 1)
started := make(chan struct{})
go func() {
close(started)
highDone <- st.AppendAudit("alice", agentcommand.AuditEnvelope{Time: time.Now(), Actor: "alice", CommandID: "higher-committed", ConversationID: string(c.ID), State: agentcommand.StateCommitted})
}()
<-started
highCommittedEarly := false
select {
case err := <-highDone:
if err != nil {
t.Fatal(err)
}
highCommittedEarly = true
case <-time.After(250 * time.Millisecond):
}
var first []ProjectCommandAuditRow
var cutoff, next int64
if highCommittedEarly {
first, cutoff, next, err = st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 2)
if err != nil {
tx.Rollback()
t.Fatal(err)
}
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if !highCommittedEarly {
if err := <-highDone; err != nil {
t.Fatal(err)
}
first, cutoff, next, err = st.ListProjectCommandAudits("alice", string(p.ID), 0, 0, 2)
if err != nil {
t.Fatal(err)
}
}
second, fixed, _, err := st.ListProjectCommandAudits("alice", string(p.ID), next, cutoff, 2)
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{}
for _, row := range append(first, second...) {
seen[row.Envelope.CommandID] = true
}
if next == 0 || fixed != cutoff || len(seen) != 4 || !seen["middle-pending"] {
t.Fatalf("cross-page audit omitted a committed row: early=%v middleID=%d first=%+v second=%+v cutoff=%d next=%d", highCommittedEarly, middleID, first, second, cutoff, next)
}
}