verify / verify (push) Successful in 4m17s
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
439 lines
19 KiB
Go
439 lines
19 KiB
Go
// 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;
|
|
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)
|
|
})
|
|
}
|
|
}
|