// SPDX-License-Identifier: MPL-2.0 package authsqlite import ( "errors" "slices" "testing" "time" "gamertan.com/web/access" "gamertan.com/web/organizations" ) type roleSetFixture struct { store *Store access *access.Service organizations *organizations.Service org organizations.Organization now time.Time } const roleOwner = "customer-12345" const roleMember = "member-12345678" func newRoleSetFixture(t *testing.T) roleSetFixture { t.Helper() store, _, policy, input := ownedOrganizationFixture(t) policy.Roles["buyer"] = "Buyer" policy.Roles["billing"] = "Billing manager" policy.Roles["member"] = "Member" policy.Permissions["billing.manage"] = "Manage billing" policy.Grants["buyer"] = []string{"customer.purchase"} policy.Grants["billing"] = []string{"billing.manage"} policy.Grants["member"] = nil now := time.Unix(2100, 0).UTC() accessService, err := access.New(store, policy, access.Options{OwnerRole: "customer.owner", Now: func() time.Time { return now }}) 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", OwnerManagedInvitations: true, Now: func() time.Time { return now }}) if err != nil { t.Fatal(err) } org, err := service.CreateOwnedOrganization(t.Context(), input) if err != nil { t.Fatal(err) } 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(?,?,?,?,?,?,'active',0,2000,2000)`, roleMember, "member", "member", "member@example.test", "member@example.test", "Member"); err != nil { t.Fatal(err) } return roleSetFixture{store: store, access: accessService, organizations: service, org: org, now: now} } func (f roleSetFixture) invite(t *testing.T, roles ...string) (string, organizations.Invitation) { t.Helper() raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "member@example.test", InvitedByUserID: roleOwner, DirectRoles: roles, Lifetime: time.Hour, RequestID: "request-invite"}) if err != nil { t.Fatal(err) } return raw, invitation } func (f roleSetFixture) addMember(t *testing.T) { t.Helper() raw, _ := f.invite(t, "member") if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err != nil { t.Fatal(err) } } func (f roleSetFixture) bindings(t *testing.T, user string) ([]string, []string) { t.Helper() bindings, err := f.access.OrganizationUserBindings(t.Context(), f.org.ID, 100) if err != nil { t.Fatal(err) } var ids, roles []string for _, binding := range bindings { if binding.SubjectID == user { ids = append(ids, binding.ID) roles = append(roles, binding.Role) } } slices.Sort(ids) slices.Sort(roles) return ids, roles } func (f roleSetFixture) change(t *testing.T, actor, target string, roles ...string) error { t.Helper() ids, _ := f.bindings(t, target) _, err := f.access.ReplaceOrganizationUserRoles(t.Context(), access.OrganizationUserRolesChange{OrganizationID: f.org.ID, UserID: target, ActorUserID: actor, RequestID: "request-roles", Roles: roles, ExpectedBindingIDs: ids}) return err } func TestRoleSetCombinesCapabilitiesWithoutNarrowGrantChanges(t *testing.T) { f := newRoleSetFixture(t) f.addMember(t) project, err := f.organizations.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: f.org.ID, Slug: "project", Name: "Project"}) if err != nil { t.Fatal(err) } narrow, err := f.access.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: roleMember, Role: "member", Scope: access.Scope{OrganizationID: f.org.ID, ProjectID: project.ID}, GrantedBy: roleOwner}) if err != nil { t.Fatal(err) } if err = f.change(t, roleOwner, roleMember, "buyer", "billing"); err != nil { t.Fatal(err) } _, roles := f.bindings(t, roleMember) if !slices.Equal(roles, []string{"billing", "buyer"}) { t.Fatalf("roles=%v", roles) } for _, permission := range []string{"customer.purchase", "billing.manage"} { decision, err := f.access.Authorize(t.Context(), roleMember, access.Scope{OrganizationID: f.org.ID}, permission) if err != nil || !decision.Allowed { t.Fatalf("permission=%s allowed=%v err=%v", permission, decision.Allowed, err) } } assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=? AND revoked_at IS NULL`, narrow.ID, 1) assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 1) if err = f.change(t, roleOwner, roleMember, "billing", "buyer"); !errors.Is(err, access.ErrRoleUnchanged) { t.Fatalf("unchanged=%v", err) } if err = f.change(t, roleMember, roleMember, "member"); !errors.Is(err, access.ErrOwnerAuthority) { t.Fatalf("non-owner bulk change=%v", err) } if err = f.change(t, roleOwner, roleOwner, "billing", "buyer"); !errors.Is(err, access.ErrLastOwner) { t.Fatalf("last owner=%v", err) } if err = f.change(t, roleOwner, roleMember, "customer.owner", "billing"); err != nil { t.Fatal(err) } if err = f.change(t, roleMember, roleOwner, "buyer"); err != nil { t.Fatal(err) } if err = f.change(t, roleMember, roleMember, "buyer"); !errors.Is(err, access.ErrLastOwner) { t.Fatalf("new last owner=%v", err) } } func TestRoleSetConcurrentChangesHaveOneWinner(t *testing.T) { f := newRoleSetFixture(t) f.addMember(t) ids, _ := f.bindings(t, roleMember) start := make(chan struct{}) results := make(chan error, 2) for range 2 { go func() { <-start _, err := f.access.ReplaceOrganizationUserRoles(t.Context(), access.OrganizationUserRolesChange{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleOwner, Roles: []string{"buyer", "billing"}, ExpectedBindingIDs: ids}) results <- err }() } close(start) success, conflict := 0, 0 for range 2 { err := <-results switch { case err == nil: success++ case errors.Is(err, access.ErrRoleChangeConflict): conflict++ default: t.Fatalf("concurrent error=%v", err) } } if success != 1 || conflict != 1 { t.Fatalf("success=%d conflict=%d", success, conflict) } _, roles := f.bindings(t, roleMember) if !slices.Equal(roles, []string{"billing", "buyer"}) { t.Fatalf("roles=%v", roles) } assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 1) } func TestRoleSetRollsBackRevocationAndPartialInsert(t *testing.T) { for _, stage := range []string{"second binding", "audit", "missing role"} { t.Run(stage, func(t *testing.T) { f := newRoleSetFixture(t) f.addMember(t) before, _ := f.bindings(t, roleMember) var statement string switch stage { case "second binding": statement = `CREATE TRIGGER fail_binding BEFORE INSERT ON gwf_access_bindings WHEN NEW.role_name='buyer' BEGIN SELECT RAISE(ABORT,'write failure'); END` case "audit": statement = `CREATE TRIGGER fail_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='access.role.replace' BEGIN SELECT RAISE(ABORT,'audit failure'); END` case "missing role": statement = `DELETE FROM gwf_access_role_permissions WHERE role_name='buyer'; DELETE FROM gwf_access_roles WHERE name='buyer'` } if _, err := f.store.db.Exec(statement); err != nil { t.Fatal(err) } if err := f.change(t, roleOwner, roleMember, "billing", "buyer"); err == nil { t.Fatal("write failure accepted") } after, roles := f.bindings(t, roleMember) if !slices.Equal(before, after) || !slices.Equal(roles, []string{"member"}) { t.Fatalf("after=%v roles=%v", after, roles) } assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=? AND action='access.role.replace'`, f.org.ID, 0) }) } } func TestRoleInvitationPreservesRolesAuthorityAndSingleUse(t *testing.T) { f := newRoleSetFixture(t) raw, invitation := f.invite(t, "buyer", "billing") stored, err := f.store.InvitationByDigest(t.Context(), invitation.Digest, f.now) if err != nil { t.Fatal(err) } if stored.RequiredOwnerRole != "customer.owner" || stored.DirectRole != "" || !slices.Equal(stored.DirectRoles, []string{"billing", "buyer"}) { t.Fatalf("roles=%v authority=%q", stored.DirectRoles, stored.RequiredOwnerRole) } listed, err := f.organizations.Invitations(t.Context(), f.org.ID, 10) if err != nil || len(listed) != 1 || !slices.Equal(listed[0].DirectRoles, stored.DirectRoles) { t.Fatalf("listed=%v err=%v", listed, err) } assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.create' AND request_id='request-invite'`, invitation.ID, 1) if err = f.organizations.AcceptInvitation(t.Context(), raw, roleOwner); err == nil { t.Fatal("wrong email accepted") } start := make(chan struct{}) results := make(chan error, 2) for range 2 { go func() { <-start; results <- f.organizations.AcceptInvitation(t.Context(), raw, roleMember) }() } close(start) success := 0 for range 2 { if err := <-results; err == nil { success++ } else if !errors.Is(err, organizations.ErrInvitationNotFound) { t.Fatalf("accept error=%v", err) } } if success != 1 { t.Fatalf("accepted=%d", success) } _, roles := f.bindings(t, roleMember) if !slices.Equal(roles, []string{"billing", "buyer"}) { t.Fatalf("roles=%v", roles) } assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.accept'`, invitation.ID, 1) if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); !errors.Is(err, organizations.ErrInvitationNotFound) { t.Fatalf("replay=%v", err) } } func TestRoleInvitationRechecksGrantorAndRecipient(t *testing.T) { for _, mutation := range []string{ `UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id='customer-12345'`, `UPDATE gwf_organization_memberships SET status='suspended' WHERE user_id='customer-12345'`, `UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`, `UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`, `DELETE FROM gwf_organization_memberships WHERE user_id='customer-12345'`, `UPDATE gwf_users SET status='disabled' WHERE id='member-12345678'`, `UPDATE gwf_users SET registration_pending=1 WHERE id='member-12345678'`, `UPDATE gwf_organizations SET status='archived'`, `UPDATE gwf_organization_invitations SET expires_at=2100`, `UPDATE gwf_organization_invitations SET revoked_at=2100`, `UPDATE gwf_organization_invitations SET direct_roles_json='["buyer","buyer"]'`, } { t.Run(mutation, func(t *testing.T) { f := newRoleSetFixture(t) raw, invitation := f.invite(t, "buyer", "billing") if _, err := f.store.db.Exec(mutation); err != nil { t.Fatal(err) } // Stored authority still applies through a differently configured service. other, err := organizations.New(f.store, organizations.Options{Now: func() time.Time { return f.now }}) if err != nil { t.Fatal(err) } if err = other.AcceptInvitation(t.Context(), raw, roleMember); err == nil { t.Fatal("stale or invalid authority accepted") } assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0) assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=?`, roleMember, 0) assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND used_at IS NOT NULL`, invitation.ID, 0) }) } } func TestRoleInvitationRejectsImplicitReactivationAndNonOwnerManagement(t *testing.T) { f := newRoleSetFixture(t) f.addMember(t) if _, _, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "new@example.test", InvitedByUserID: roleMember, DirectRoles: []string{"buyer", "billing"}, Lifetime: time.Hour}); !errors.Is(err, organizations.ErrOwnerAuthority) { t.Fatalf("member invite=%v", err) } raw, invitation := f.invite(t, "buyer", "billing") if err := f.organizations.RevokeInvitation(t.Context(), f.org.ID, invitation.ID, roleMember, "request-revoke"); !errors.Is(err, organizations.ErrOwnerAuthority) { t.Fatalf("member revoke=%v", err) } if err := f.organizations.SetMembershipStatus(t.Context(), f.org.ID, roleMember, "suspended", roleOwner, "request-suspend"); err != nil { t.Fatal(err) } if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err == nil { t.Fatal("invitation reactivated suspended member") } assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='suspended'`, roleMember, 1) if err := f.organizations.RevokeInvitation(t.Context(), f.org.ID, invitation.ID, roleOwner, "request-revoke-owner"); err != nil { t.Fatal(err) } } func TestRoleInvitationAcceptanceRollsBackEveryWrite(t *testing.T) { for _, stage := range []string{"membership", "binding", "audit", "missing role"} { t.Run(stage, func(t *testing.T) { f := newRoleSetFixture(t) raw, invitation := f.invite(t, "buyer", "billing") var statement string switch stage { case "membership": statement = `CREATE TRIGGER fail_member BEFORE INSERT ON gwf_organization_memberships BEGIN SELECT RAISE(ABORT,'membership failure'); END` case "binding": statement = `CREATE TRIGGER fail_binding BEFORE INSERT ON gwf_access_bindings WHEN NEW.role_name='buyer' BEGIN SELECT RAISE(ABORT,'binding failure'); END` case "audit": statement = `CREATE TRIGGER fail_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='invitation.accept' BEGIN SELECT RAISE(ABORT,'audit failure'); END` case "missing role": statement = `DELETE FROM gwf_access_role_permissions WHERE role_name='buyer'; DELETE FROM gwf_access_roles WHERE name='buyer'` } if _, err := f.store.db.Exec(statement); err != nil { t.Fatal(err) } if err := f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err == nil { t.Fatal("partial acceptance succeeded") } assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0) assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=?`, roleMember, 0) assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND used_at IS NOT NULL`, invitation.ID, 0) assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE resource_id=? AND action='invitation.accept'`, invitation.ID, 0) }) } } func TestRoleInvitationMigrationPreservesLegacyAndRequiresExplicitMigration(t *testing.T) { f := newRoleSetFixture(t) raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, Email: "member@example.test", InvitedByUserID: roleOwner, DirectRole: "customer.owner", Lifetime: time.Hour}) if err != nil { t.Fatal(err) } // Reconstruct the previous invitation schema in this disposable database. if _, err = f.store.db.Exec(`ALTER TABLE gwf_organization_invitations DROP COLUMN direct_roles_json; ALTER TABLE gwf_organization_invitations DROP COLUMN required_owner_role; ALTER TABLE gwf_users DROP COLUMN profile_revision; DELETE FROM gamertan_web_migrations WHERE version>=10`); err != nil { t.Fatal(err) } if err = f.store.RequireCurrentSchema(t.Context()); err == nil { t.Fatal("old schema accepted without migration") } for range 2 { if err = f.store.Migrate(t.Context()); err != nil { t.Fatal(err) } } if err = f.store.RequireCurrentSchema(t.Context()); err != nil { t.Fatal(err) } stored, err := f.store.InvitationByDigest(t.Context(), invitation.Digest, f.now) if err != nil || stored.DirectRole != "customer.owner" || len(stored.DirectRoles) != 0 || stored.RequiredOwnerRole != "" { t.Fatalf("legacy changed: %+v err=%v", stored, err) } if _, err = f.store.db.Exec(`UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id=?`, roleOwner); err != nil { t.Fatal(err) } if err = f.organizations.AcceptInvitation(t.Context(), raw, roleMember); !errors.Is(err, organizations.ErrOwnerAuthority) { t.Fatalf("legacy owner authority=%v", err) } if _, err = f.store.db.Exec(`UPDATE gwf_access_bindings SET revoked_at=NULL WHERE subject_id=?`, roleOwner); err != nil { t.Fatal(err) } if err = f.organizations.AcceptInvitation(t.Context(), raw, roleMember); err != nil { t.Fatal(err) } ids, roles := f.bindings(t, roleMember) if !slices.Equal(roles, []string{"customer.owner"}) || !slices.Equal(ids, []string{"invite-" + invitation.ID}) { t.Fatalf("legacy IDs=%v roles=%v", ids, roles) } } func TestRoleInvitationUsesAdvancingClockAndBoundAudit(t *testing.T) { f := newRoleSetFixture(t) service, err := organizations.New(f.store, organizations.Options{OwnerRole: "customer.owner", OwnerManagedInvitations: true}) if err != nil { t.Fatal(err) } raw, invitation, err := service.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, InvitedByUserID: roleOwner, Email: "member@example.test", DirectRoles: []string{"billing", "buyer"}, Lifetime: time.Hour}) if err != nil { t.Fatal(err) } audit := organizations.AuditEvent{ID: "audit-accept-12345", OrganizationID: f.org.ID, ActorUserID: roleMember, Action: "invitation.accept", ResourceType: "invitation", ResourceID: invitation.ID, Summary: "Invitation accepted", CreatedAt: time.Now().UTC()} for _, field := range []string{"actor", "resource", "action"} { bad := audit switch field { case "actor": bad.ActorUserID = roleOwner case "resource": bad.ResourceID = "invitation-other" case "action": bad.Action = "invitation.create" } if err = f.store.AcceptInvitationWithRoles(t.Context(), invitation.Digest, roleMember, "customer.owner", time.Now().UTC(), bad); err == nil { t.Fatalf("mismatched audit %s accepted", field) } assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 0) } if err = service.AcceptInvitation(t.Context(), raw, roleMember); err != nil { t.Fatalf("real clock acceptance=%v", err) } } func TestRoleInvitationCreationIsAtomicAndRejectsUnknownRole(t *testing.T) { for _, fail := range []string{"unknown role", "audit"} { t.Run(fail, func(t *testing.T) { f := newRoleSetFixture(t) roles := []string{"billing", "buyer"} if fail == "unknown role" { roles = append(roles, "unknown") } else if _, err := f.store.db.Exec(`CREATE TRIGGER fail_invite BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='invitation.create' BEGIN SELECT RAISE(ABORT,'audit failure'); END`); err != nil { t.Fatal(err) } raw, invitation, err := f.organizations.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: f.org.ID, InvitedByUserID: roleOwner, Email: "member@example.test", DirectRoles: roles, Lifetime: time.Hour}) if err == nil || raw != "" || invitation.ID != "" { t.Fatal("failed creation returned invitation") } assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE organization_id=?`, f.org.ID, 0) }) } } func TestRoleInvitationCannotBypassMembershipChanges(t *testing.T) { for _, optimistic := range []bool{false, true} { t.Run(map[bool]string{false: "legacy removal", true: "optimistic removal"}[optimistic], func(t *testing.T) { f := newRoleSetFixture(t) oldToken, oldInvitation := f.invite(t, "buyer", "billing") f.addMember(t) if err := f.organizations.AcceptInvitation(t.Context(), oldToken, roleMember); !errors.Is(err, organizations.ErrInvitationNotFound) { t.Fatalf("old invite elevated existing member=%v", err) } _, roles := f.bindings(t, roleMember) if !slices.Equal(roles, []string{"member"}) { t.Fatalf("roles changed=%v", roles) } remove := func() error { if optimistic { return f.organizations.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleOwner, ExpectedStatus: "active", RequestID: "request-remove"}) } return f.organizations.RemoveMembership(t.Context(), f.org.ID, roleMember, roleOwner, "request-remove") } if _, err := f.store.db.Exec(`CREATE TRIGGER fail_removal BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='membership.remove' BEGIN SELECT RAISE(ABORT,'audit failure'); END`); err != nil { t.Fatal(err) } if err := remove(); err == nil { t.Fatal("unaudited removal succeeded") } assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=?`, roleMember, 1) assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND revoked_at IS NULL AND used_at IS NULL`, oldInvitation.ID, 1) if _, err := f.store.db.Exec(`DROP TRIGGER fail_removal`); err != nil { t.Fatal(err) } if err := remove(); err != nil { t.Fatal(err) } assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND revoked_at IS NOT NULL AND used_at IS NULL`, oldInvitation.ID, 1) if err := f.organizations.AcceptInvitation(t.Context(), oldToken, roleMember); !errors.Is(err, organizations.ErrInvitationNotFound) { t.Fatalf("old invite restored removed member=%v", err) } newToken, _ := f.invite(t, "buyer") if err := f.organizations.AcceptInvitation(t.Context(), newToken, roleMember); err != nil { t.Fatalf("intentional fresh invitation=%v", err) } _, roles = f.bindings(t, roleMember) if !slices.Equal(roles, []string{"buyer"}) { t.Fatalf("fresh roles=%v", roles) } }) } }