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.
233 lines
8.9 KiB
Go
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)
|
|
}
|
|
}
|