Files
web/authsqlite/owned_organization_test.go
T
gamertan ed0cc8ceff
verify / verify (push) Successful in 3m48s
Add atomic owned organization creation
2026-09-04 23:19:05 -04:00

265 lines
9.9 KiB
Go

// 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)
})
}
}