// SPDX-License-Identifier: MPL-2.0 package authsqlite import ( "context" "errors" "path/filepath" "sync" "testing" "time" "gamertan.com/web/access" "gamertan.com/web/organizations" ) func ownedOrganizationFixture(t *testing.T) (*Store, *organizations.Service, access.Policy, organizations.CreateOrganization) { t.Helper() store, err := Open(filepath.Join(t.TempDir(), "owned.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { store.Close() }) now := time.Unix(2000, 0).UTC() if _, err = store.db.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,registration_pending,created_at,updated_at) VALUES('customer-12345','customer','customer','customer@example.test','customer@example.test','Customer','active',0,2000,2000)`); err != nil { t.Fatal(err) } policy := access.Policy{ Roles: map[string]string{"customer.owner": "Customer owner", "home.owner": "Merchant owner"}, Permissions: map[string]string{"customer.purchase": "Purchase", "merchant.manage": "Manage merchant"}, Grants: map[string][]string{"customer.owner": {"customer.purchase"}, "home.owner": {"merchant.manage"}}, } accessService, err := access.New(store, policy, access.Options{}) if err != nil { t.Fatal(err) } if err = accessService.Seed(t.Context()); err != nil { t.Fatal(err) } service, err := organizations.New(store, organizations.Options{OwnerRole: "customer.owner", Now: func() time.Time { return now }}) if err != nil { t.Fatal(err) } return store, service, policy, organizations.CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345", RequestID: "request-creation"} } func countOwnedRows(t *testing.T, store *Store, want int) { t.Helper() for _, table := range []string{"gwf_organizations", "gwf_organization_memberships", "gwf_access_bindings", "gwf_access_audit_events"} { var count int if err := store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil { t.Fatal(err) } expected := want if table == "gwf_access_audit_events" { expected *= 2 } if count != expected { t.Errorf("%s count=%d want=%d", table, count, expected) } } } func TestOwnedOrganizationCommitsScopedOwnerAndAudits(t *testing.T) { store, service, policy, input := ownedOrganizationFixture(t) organization, err := service.CreateOwnedOrganization(t.Context(), input) if err != nil { t.Fatal(err) } countOwnedRows(t, store, 1) accessService, err := access.New(store, policy, access.Options{}) if err != nil { t.Fatal(err) } for _, test := range []struct { scope string permission string want bool }{ {organization.ID, "customer.purchase", true}, {organization.ID, "merchant.manage", false}, {"other-org-12345", "customer.purchase", false}, } { decision, err := accessService.Authorize(t.Context(), input.OwnerUserID, access.Scope{OrganizationID: test.scope}, test.permission) if err != nil || decision.Allowed != test.want { t.Fatalf("scope=%s permission=%s decision=%+v err=%v", test.scope, test.permission, decision, err) } } for _, action := range []string{"organization.create", "access.binding.grant"} { var actor, request string if err = store.db.QueryRow(`SELECT actor_user_id,request_id FROM gwf_access_audit_events WHERE organization_id=? AND action=?`, organization.ID, action).Scan(&actor, &request); err != nil { t.Fatal(err) } if actor != input.OwnerUserID || request != input.RequestID { t.Fatalf("audit actor=%q request=%q", actor, request) } } if _, err = service.CreateOwnedOrganization(t.Context(), input); err == nil { t.Fatal("duplicate slug accepted") } countOwnedRows(t, store, 1) } func TestOwnedOrganizationRollsBackEveryWriteFailure(t *testing.T) { for _, stage := range []struct{ table, when string }{ {"gwf_organizations", ""}, {"gwf_organization_memberships", ""}, {"gwf_access_bindings", ""}, {"gwf_access_audit_events", " WHEN NEW.action='organization.create'"}, {"gwf_access_audit_events", " WHEN NEW.action='access.binding.grant'"}, } { t.Run(stage.table+stage.when, func(t *testing.T) { store, service, _, input := ownedOrganizationFixture(t) if _, err := store.db.Exec(`CREATE TRIGGER reject_creation BEFORE INSERT ON ` + stage.table + stage.when + ` BEGIN SELECT RAISE(ABORT,'injected write failure'); END`); err != nil { t.Fatal(err) } if organization, err := service.CreateOwnedOrganization(t.Context(), input); err == nil || organization.ID != "" { t.Fatalf("organization=%+v err=%v", organization, err) } countOwnedRows(t, store, 0) }) } } func TestOwnedOrganizationRejectsMissingRoleAndUnavailableOwner(t *testing.T) { for _, change := range []string{ `DELETE FROM gwf_access_role_permissions WHERE role_name='customer.owner'; DELETE FROM gwf_access_roles WHERE name='customer.owner'`, `UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`, `UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`, `DELETE FROM gwf_users WHERE id='customer-12345'`, } { t.Run(change, func(t *testing.T) { store, service, _, input := ownedOrganizationFixture(t) if _, err := store.db.Exec(change); err != nil { t.Fatal(err) } if organization, err := service.CreateOwnedOrganization(t.Context(), input); err == nil || organization.ID != "" { t.Fatalf("organization=%+v err=%v", organization, err) } countOwnedRows(t, store, 0) }) } } func TestConcurrentOwnedOrganizationCreationHasOneCompleteWinner(t *testing.T) { store, service, _, input := ownedOrganizationFixture(t) var workers sync.WaitGroup results := make(chan error, 8) for range 8 { workers.Go(func() { _, err := service.CreateOwnedOrganization(t.Context(), input); results <- err }) } workers.Wait() close(results) winners := 0 for err := range results { if err == nil { winners++ } } if winners != 1 { t.Fatalf("successful creations=%d", winners) } countOwnedRows(t, store, 1) } func TestLegacyOrganizationCreationRemainsMembershipOnly(t *testing.T) { store, service, _, input := ownedOrganizationFixture(t) if _, err := service.CreateOrganization(t.Context(), input); err != nil { t.Fatal(err) } var bindings int if err := store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_bindings`).Scan(&bindings); err != nil { t.Fatal(err) } if bindings != 0 { t.Fatal("legacy creation unexpectedly granted authority") } } func TestOwnedOrganizationSurvivesReopenAndProtectsLastOwner(t *testing.T) { store, service, _, input := ownedOrganizationFixture(t) organization, err := service.CreateOwnedOrganization(t.Context(), input) if err != nil { t.Fatal(err) } var sequence int var name, path string if err = store.db.QueryRow(`PRAGMA database_list`).Scan(&sequence, &name, &path); err != nil { t.Fatal(err) } if err = store.Close(); err != nil { t.Fatal(err) } reopened, err := OpenWithOptions(path, OpenOptions{Migrate: false}) if err != nil { t.Fatal(err) } defer reopened.Close() if err = reopened.RequireCurrentSchema(t.Context()); err != nil { t.Fatal(err) } countOwnedRows(t, reopened, 1) service, err = organizations.New(reopened, organizations.Options{OwnerRole: "customer.owner"}) if err != nil { t.Fatal(err) } err = service.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{ OrganizationID: organization.ID, UserID: input.OwnerUserID, ActorUserID: input.OwnerUserID, ExpectedStatus: "active", }) if !errors.Is(err, organizations.ErrLastOwner) { t.Fatalf("last owner removal: %v", err) } err = service.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{ OrganizationID: organization.ID, UserID: input.OwnerUserID, ActorUserID: input.OwnerUserID, ExpectedStatus: "active", Status: "suspended", }) if !errors.Is(err, organizations.ErrLastOwner) { t.Fatalf("last owner suspension: %v", err) } countOwnedRows(t, reopened, 1) } type capturedOwnedStore struct { *Store setup organizations.OwnedOrganization } func (store *capturedOwnedStore) CreateOwnedOrganization(_ context.Context, setup organizations.OwnedOrganization) error { store.setup = setup return nil } func TestOwnedOrganizationRejectsMismatchedAuthorityAndAudits(t *testing.T) { for _, test := range []struct { name string change func(*organizations.OwnedOrganization) }{ {"foreign member", func(s *organizations.OwnedOrganization) { s.Membership.OrganizationID = "other-org-12345" }}, {"foreign owner", func(s *organizations.OwnedOrganization) { s.OwnerBinding.SubjectID = "other-user-12345" }}, {"foreign scope", func(s *organizations.OwnedOrganization) { s.OwnerBinding.Scope.OrganizationID = "other-org-12345" }}, {"narrow scope", func(s *organizations.OwnedOrganization) { s.OwnerBinding.Scope.ProjectID = "project-12345" }}, {"team owner", func(s *organizations.OwnedOrganization) { s.OwnerBinding.SubjectKind = access.Team }}, {"wrong audit actor", func(s *organizations.OwnedOrganization) { s.AccessAudit.ActorUserID = "other-user-12345" }}, {"wrong audit binding", func(s *organizations.OwnedOrganization) { s.AccessAudit.ResourceID = "other-binding-12345" }}, {"wrong creation resource", func(s *organizations.OwnedOrganization) { s.OrganizationAudit.ResourceID = "other-org-12345" }}, {"wrong request", func(s *organizations.OwnedOrganization) { s.AccessAudit.RequestID = "other-request" }}, {"archived organization", func(s *organizations.OwnedOrganization) { s.Organization.Status = "archived" }}, } { t.Run(test.name, func(t *testing.T) { store, _, _, input := ownedOrganizationFixture(t) capture := &capturedOwnedStore{Store: store} service, err := organizations.New(capture, organizations.Options{OwnerRole: "customer.owner"}) if err != nil { t.Fatal(err) } if _, err = service.CreateOwnedOrganization(t.Context(), input); err != nil { t.Fatal(err) } test.change(&capture.setup) if err = store.CreateOwnedOrganization(t.Context(), capture.setup); err == nil { t.Fatal("invalid creation accepted") } countOwnedRows(t, store, 0) }) } }