Files
Agent-System-Backend/internal/store/cards_account_write_test.go
T
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes.

Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending.

Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete.

Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 07:33:54 +00:00

233 lines
8.9 KiB
Go

package store
import (
"errors"
"testing"
"time"
core "agent-system-core"
)
func cardAccountFixture(t *testing.T, st *Store) (core.Project, core.Conversation, core.ActivityCard) {
t.Helper()
for _, a := range []struct {
owner string
admin bool
}{{"admin", true}, {"alice", false}, {"bob", false}} {
if err := st.CreateAccount(a.owner, "", "", a.admin); err != nil {
t.Fatal(err)
}
}
project, err := st.CreateProject("alice", "cards")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation("alice", project.ID, "cards", "")
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
card := core.ActivityCard{ID: NewID("card"), ProjectID: project.ID, ConversationID: conv.ID,
Title: "Review", Status: core.CardActionRequired, CreatedAt: now, UpdatedAt: now}
return project, conv, card
}
func TestCardAccountCheckedWriteUsesPersistedOwnerAndResourceLinks(t *testing.T) {
st := openTestStore(t)
project, conv, card := cardAccountFixture(t, st)
if err := st.SaveCardAccountChecked("alice", &card, "", false); err != nil {
t.Fatal(err)
}
card.Title = "foreign replacement"
if err := st.SaveCardVersionedAccountChecked("bob", &card, "", 1, false); !errors.Is(err, ErrNotFound) {
t.Fatalf("foreign actor changed card: %v", err)
}
card.Title = "linked elsewhere"
card.ProjectID = "other-project"
if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); !errors.Is(err, ErrNotFound) {
t.Fatalf("payload changed immutable project link: %v", err)
}
card.ProjectID = project.ID
card.ConversationID = "other-conversation"
if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); !errors.Is(err, ErrNotFound) {
t.Fatalf("payload changed immutable conversation link: %v", err)
}
card.ConversationID = conv.ID
if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); err != nil {
t.Fatalf("owner update: %v", err)
}
if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); !errors.Is(err, ErrConflict) {
t.Fatalf("stale version replaced card: %v", err)
}
foreignProject, err := st.CreateProject("bob", "foreign")
if err != nil {
t.Fatal(err)
}
foreign := card
foreign.ID = NewID("card")
foreign.ProjectID = foreignProject.ID
foreign.ConversationID = ""
if err := st.SaveCardAccountChecked("alice", &foreign, "", false); !errors.Is(err, ErrNotFound) {
t.Fatalf("foreign project accepted: %v", err)
}
foreign.ProjectID = project.ID
foreign.ConversationID = conv.ID
if err := st.SaveCardAccountChecked("bob", &foreign, "", false); !errors.Is(err, ErrNotFound) {
t.Fatalf("foreign conversation accepted: %v", err)
}
account, err := st.GetAccountByOwner("alice")
if err != nil {
t.Fatal(err)
}
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
t.Fatal(err)
}
card.Title = "late"
if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 2, false); !errors.Is(err, ErrAccountForbidden) {
t.Fatalf("disabled actor changed card: %v", err)
}
if err := st.SaveCardAccountChecked("alice", &foreign, "", false); !errors.Is(err, ErrAccountForbidden) {
t.Fatalf("disabled actor created card: %v", err)
}
got, version, err := st.GetCardVersion("alice", card.ID)
if err != nil || got.Title != "linked elsewhere" || version != 2 {
t.Fatalf("denied write altered card: %+v version=%d err=%v", got, version, err)
}
}
func TestSharedProjectMemberCanSaveOwnConversationCard(t *testing.T) {
st := openTestStore(t)
for _, owner := range []string{"creator", "member"} {
if err := st.CreateAccount(owner, "", "", false); err != nil {
t.Fatal(err)
}
}
project, err := st.CreateProject("creator", "shared")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversationAccountChecked("member", string(project.ID), "private work", "", false, false)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
card := core.ActivityCard{ID: NewID("card"), ProjectID: project.ID, ConversationID: conv.ID,
Title: "Member decision", Status: core.CardActionRequired, CreatedAt: now, UpdatedAt: now}
if err := st.SaveCardAccountChecked("member", &card, "hitl:member", false); err != nil {
t.Fatalf("member conversation card rejected: %v", err)
}
card.Status = core.CardResolved
if err := st.SaveCardVersionedAccountChecked("member", &card, "", 1, false); err != nil {
t.Fatalf("member card status rejected: %v", err)
}
if _, err := st.GetCard("member", card.ID); err != nil {
t.Fatal(err)
}
}
func TestCardArchiveAccountCheckedCopiesAndDismissesAtomically(t *testing.T) {
st := openTestStore(t)
project, _, card := cardAccountFixture(t, st)
if err := st.SaveCardAccountChecked("alice", &card, "", false); err != nil {
t.Fatal(err)
}
if isSQLite(st) {
if _, err := st.db.Exec(`CREATE TRIGGER block_archive_dismiss BEFORE UPDATE ON activity_cards
WHEN NEW.status='dismissed' BEGIN SELECT RAISE(ABORT, 'blocked'); END`); err != nil {
t.Fatal(err)
}
if _, _, _, err := st.ArchiveCardToProjectAccountChecked("alice", card.ID, time.Now().UTC(), false); err == nil {
t.Fatal("archive should fail when origin dismissal fails")
}
if _, err := st.CardIDByRef("alice", "to_project:"+card.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("failed dismissal committed project copy: %v", err)
}
if _, err := st.db.Exec(`DROP TRIGGER block_archive_dismiss`); err != nil {
t.Fatal(err)
}
}
archived, origin, created, err := st.ArchiveCardToProjectAccountChecked("alice", card.ID, time.Now().UTC(), false)
if err != nil || !created || archived.ProjectID != project.ID || archived.ConversationID != "" || archived.AnchorMessageID != core.ID(card.ID) || origin.Status != core.CardDismissed {
t.Fatalf("archive did not atomically copy and dismiss: archived=%+v origin=%+v created=%v err=%v", archived, origin, created, err)
}
again, _, created, err := st.ArchiveCardToProjectAccountChecked("alice", card.ID, time.Now().UTC(), false)
if err != nil || created || again.ID != archived.ID {
t.Fatalf("retry duplicated archive: %+v created=%v err=%v", again, created, err)
}
if _, _, _, err := st.ArchiveCardToProjectAccountChecked("bob", card.ID, time.Now().UTC(), false); !errors.Is(err, ErrNotFound) {
t.Fatalf("foreign actor archived card: %v", err)
}
account, err := st.GetAccountByOwner("alice")
if err != nil {
t.Fatal(err)
}
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
t.Fatal(err)
}
if _, _, _, err := st.ArchiveCardToProjectAccountChecked("alice", card.ID, time.Now().UTC(), false); !errors.Is(err, ErrAccountForbidden) {
t.Fatalf("disabled actor passed idempotent archive: %v", err)
}
}
func TestCardAccountCheckedImplicitDefaultRequiresAllowance(t *testing.T) {
st := openTestStore(t)
card := core.ActivityCard{ID: NewID("card"), Title: "local", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
if err := st.SaveCardAccountChecked(OwnerDefault, &card, "", false); !errors.Is(err, ErrAccountForbidden) {
t.Fatalf("implicit owner admitted without allowance: %v", err)
}
if err := st.SaveCardAccountChecked(OwnerDefault, &card, "", true); err != nil {
t.Fatal(err)
}
if err := st.EnsureUser(OwnerDefault, ""); err != nil {
t.Fatal(err)
}
if _, err := st.DB().Exec(st.q(`UPDATE users SET enabled=FALSE WHERE owner=?`), OwnerDefault); err != nil {
t.Fatal(err)
}
if err := st.SaveCardVersionedAccountChecked(OwnerDefault, &card, "", 1, true); !errors.Is(err, ErrAccountForbidden) {
t.Fatalf("explicitly disabled local actor updated card: %v", err)
}
}
func TestPGCardAccountFenceSerializesWithDisable(t *testing.T) {
st := openTestStore(t)
if st.Dialect() != "postgres" {
t.Skip("real PostgreSQL required")
}
other := reopenAccountModelStore(t, st)
_, _, card := cardAccountFixture(t, st)
account, err := st.GetAccountByOwner("alice")
if err != nil {
t.Fatal(err)
}
release := holdPGAccountWrite(t, st, "activity_cards", "INSERT", "card_create")
writeDone := make(chan error, 1)
go func() { writeDone <- st.SaveCardAccountChecked("alice", &card, "", false) }()
waitForPGAccountWrite(t, st, "card_create")
disableDone := make(chan error, 1)
go func() {
_, err := other.SetAccountEnabled("admin", account.ID, account.StateVersion, false)
disableDone <- err
}()
select {
case err := <-disableDone:
t.Fatalf("disable committed ahead of paused card insert: %v", err)
case <-time.After(100 * time.Millisecond):
}
release()
if err := awaitPGAccountResult(t, writeDone); err != nil {
t.Fatal(err)
}
if err := awaitPGAccountResult(t, disableDone); err != nil {
t.Fatal(err)
}
card.Title = "late"
if err := st.SaveCardVersionedAccountChecked("alice", &card, "", 1, false); !errors.Is(err, ErrAccountForbidden) {
t.Fatalf("card write crossed disable boundary: %v", err)
}
got, version, err := st.GetCardVersion("alice", card.ID)
if err != nil || got.Title != "Review" || version != 1 {
t.Fatalf("late write changed card: %+v version=%d err=%v", got, version, err)
}
}