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.
268 lines
9.2 KiB
Go
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)
|
|
}
|
|
}
|