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.
184 lines
6.7 KiB
Go
184 lines
6.7 KiB
Go
package store
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestProjectAccountCheckedMutationsRespectEffectiveStateAndOwner(t *testing.T) {
|
|
st := openTestStore(t)
|
|
t.Logf("project account fence dialect=%s", st.Dialect())
|
|
for _, account := range []struct {
|
|
owner string
|
|
admin bool
|
|
}{{"admin", true}, {"alice", false}, {"bob", false}} {
|
|
if err := st.CreateAccount(account.owner, "", "", account.admin); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := st.CreateProjectAccountChecked("missing", "forbidden", false); !errors.Is(err, ErrAccountForbidden) {
|
|
t.Fatalf("missing actor created a project: %v", err)
|
|
}
|
|
project, err := st.CreateProjectAccountChecked("alice", "original", false)
|
|
if err != nil || project.Owner != "alice" {
|
|
t.Fatalf("active owner could not create project: %+v %v", project, err)
|
|
}
|
|
for _, mutate := range []func() error{
|
|
func() error { return st.RenameProjectAccountChecked("bob", project.ID, "foreign", false) },
|
|
func() error {
|
|
return st.UpdateProjectSettingsAccountChecked("bob", project.ID, map[string]any{"foreign": true}, false)
|
|
},
|
|
func() error { return st.DeleteProjectAccountChecked("bob", project.ID, false) },
|
|
} {
|
|
if err := mutate(); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("foreign actor changed project: %v", err)
|
|
}
|
|
}
|
|
if err := st.RenameProjectAccountChecked("alice", project.ID, "renamed", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.UpdateProjectSettingsAccountChecked("alice", project.ID, map[string]any{"value": "retained"}, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
deletable, err := st.CreateProjectAccountChecked("bob", "temporary", false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
child, err := st.CreateConversation("bob", deletable.ID, "child", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.DeleteProjectAccountChecked("bob", deletable.ID, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.GetProject("bob", deletable.ID); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("successful delete left project behind: %v", err)
|
|
}
|
|
if _, err := st.GetConversation("bob", child.ID); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("project deletion did not cascade to its conversation: %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.CreateProjectAccountChecked("alice", "late", false); !errors.Is(err, ErrAccountForbidden) {
|
|
t.Fatalf("disabled actor created a project: %v", err)
|
|
}
|
|
for _, mutate := range []func() error{
|
|
func() error { return st.RenameProjectAccountChecked("alice", project.ID, "late", false) },
|
|
func() error {
|
|
return st.UpdateProjectSettingsAccountChecked("alice", project.ID, map[string]any{"late": true}, false)
|
|
},
|
|
func() error { return st.DeleteProjectAccountChecked("alice", project.ID, false) },
|
|
} {
|
|
if err := mutate(); !errors.Is(err, ErrAccountForbidden) {
|
|
t.Fatalf("disabled actor changed project: %v", err)
|
|
}
|
|
}
|
|
got, err := st.GetProject("alice", project.ID)
|
|
if err != nil || got.Name != "renamed" || got.Settings["value"] != "retained" || got.Settings["late"] != nil {
|
|
t.Fatalf("rejected mutations changed project: %+v %v", got, err)
|
|
}
|
|
projects, err := st.ListProjects("alice")
|
|
if err != nil || len(projects) != 1 {
|
|
t.Fatalf("rejected create wrote a project: %+v %v", projects, err)
|
|
}
|
|
if err := st.DeleteProjectAccountChecked("bob", "missing-project", false); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("missing project delete: %v", err)
|
|
}
|
|
if err := st.DeleteProjectAccountChecked("admin", project.ID, false); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("administrator crossed project ownership boundary: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProjectAccountCheckedImplicitDefaultRequiresExplicitAllowance(t *testing.T) {
|
|
st := openTestStore(t)
|
|
if _, err := st.CreateProjectAccountChecked(OwnerDefault, "rejected", false); !errors.Is(err, ErrAccountForbidden) {
|
|
t.Fatalf("implicit default admitted without authorization: %v", err)
|
|
}
|
|
project, err := st.CreateProjectAccountChecked(OwnerDefault, "local", true)
|
|
if err != nil || project.Owner != OwnerDefault {
|
|
t.Fatalf("implicit local owner was rejected: %+v %v", project, err)
|
|
}
|
|
if err := st.RenameProjectAccountChecked(OwnerDefault, project.ID, "renamed", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.UpdateProjectSettingsAccountChecked(OwnerDefault, project.ID, map[string]any{"local": true}, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.CreateAccount("admin", "", "", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.EnsureUser(OwnerDefault, ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
account, err := st.GetAccountByOwner(OwnerDefault)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.DeleteProjectAccountChecked(OwnerDefault, project.ID, true); !errors.Is(err, ErrAccountForbidden) {
|
|
t.Fatalf("explicitly disabled local owner deleted project: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPGProjectCreateAccountFenceSerializesWithDisable(t *testing.T) {
|
|
st := openTestStore(t)
|
|
if st.Dialect() != "postgres" {
|
|
t.Skip("real PostgreSQL required")
|
|
}
|
|
other := reopenAccountModelStore(t, st)
|
|
if err := st.CreateAccount("admin", "", "", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.CreateAccount("alice", "", "", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
account, err := st.GetAccountByOwner("alice")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
release := holdPGAccountWrite(t, st, "projects", "INSERT", "project_create")
|
|
type result struct {
|
|
owner string
|
|
err error
|
|
}
|
|
createDone := make(chan result, 1)
|
|
go func() {
|
|
project, err := st.CreateProjectAccountChecked("alice", "first", false)
|
|
createDone <- result{project.Owner, err}
|
|
}()
|
|
waitForPGAccountWrite(t, st, "project_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 final project insert: %v", err)
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
release()
|
|
created := awaitPGAccountResult(t, createDone)
|
|
if created.err != nil || created.owner != "alice" {
|
|
t.Fatalf("project insert did not complete before disable: %+v", created)
|
|
}
|
|
if err := awaitPGAccountResult(t, disableDone); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.CreateProjectAccountChecked("alice", "late", false); !errors.Is(err, ErrAccountForbidden) {
|
|
t.Fatalf("late project write passed after disable: %v", err)
|
|
}
|
|
projects, err := st.ListProjects("alice")
|
|
if err != nil || len(projects) != 1 || projects[0].Name != "first" {
|
|
t.Fatalf("project writes crossed disable boundary: %+v %v", projects, err)
|
|
}
|
|
}
|