package administration import ( "context" "encoding/json" "errors" "os" "testing" "time" ) type contractFixture struct { PhoneCases []struct { Input, Normalized string Valid bool } `json:"phoneCases"` AuthorizationCases []struct { Name, ActorRole, ActorOrganizationID, TargetRole, TargetOrganizationID string Allowed bool } `json:"authorizationCases"` SessionVersionCases []struct { Name string RoleChanged, OrganizationChanged, StatusChanged, PasswordChanged, ClearLoginLock, Increment bool } `json:"sessionVersionCases"` } func TestPhoneContract(t *testing.T) { fixture := loadContract(t) for _, tc := range fixture.PhoneCases { if got := NormalizePhone(tc.Input); got != tc.Normalized { t.Errorf("NormalizePhone(%q)=%q want %q", tc.Input, got, tc.Normalized) } if got := ValidPhone(tc.Input); got != tc.Valid { t.Errorf("ValidPhone(%q)=%v want %v", tc.Input, got, tc.Valid) } } } func TestAccountTargetAuthorizationContract(t *testing.T) { fixture := loadContract(t) for _, tc := range fixture.AuthorizationCases { t.Run(tc.Name, func(t *testing.T) { err := AuthorizeAccountTarget(Actor{Role: Role(tc.ActorRole), OrganizationID: tc.ActorOrganizationID}, Account{Role: Role(tc.TargetRole), OrganizationID: tc.TargetOrganizationID}) if tc.Allowed && err != nil { t.Fatalf("unexpected error %v", err) } if !tc.Allowed && !IsKind(err, ErrorForbidden) { t.Fatalf("error=%v want forbidden", err) } }) } } func TestCreateAccountValidatesAndScopesOrganizationAdministrator(t *testing.T) { store := &fakeStore{organizations: map[string]Organization{"org-1": {ID: "org-1", Status: StatusActive}}} service := testService(store) actor := Actor{ID: "admin-1", Role: RoleOrganizationAdmin, OrganizationID: "org-1"} account, err := service.CreateAccount(context.Background(), actor, CreateAccountInput{Phone: " 138 (0013)-8000 ", DisplayName: " User ", Password: "password8", Role: RoleUser, OrganizationID: "org-other"}) if err != nil { t.Fatal(err) } if account.Phone != "13800138000" || account.DisplayName != "User" || account.OrganizationID != "org-1" || account.SessionVersion != 1 { t.Fatalf("account=%#v", account) } for _, input := range []CreateAccountInput{ {Phone: "bad", DisplayName: "User", Password: "password8", Role: RoleUser}, {Phone: "13800138001", DisplayName: " ", Password: "password8", Role: RoleUser}, {Phone: "13800138001", DisplayName: "User", Password: "short", Role: RoleUser}, {Phone: "13800138001", DisplayName: "User", Password: "password8", Role: RoleOrganizationAdmin}, } { _, err := service.CreateAccount(context.Background(), actor, input) if !IsKind(err, ErrorValidation) && !IsKind(err, ErrorForbidden) { t.Fatalf("input=%#v error=%v", input, err) } } } func TestUpdateAccountSessionVersionContract(t *testing.T) { fixture := loadContract(t) for _, tc := range fixture.SessionVersionCases { t.Run(tc.Name, func(t *testing.T) { store := &fakeStore{accounts: map[string]Account{"user-1": {ID: "user-1", DisplayName: "Before", Role: RoleUser, OrganizationID: "org-1", Status: StatusActive, SessionVersion: 5}}, organizations: map[string]Organization{"org-1": {ID: "org-1", Status: StatusActive}, "org-2": {ID: "org-2", Status: StatusActive}}} service := testService(store) patch := UpdateAccountInput{ClearLoginLock: tc.ClearLoginLock} if tc.Name == "display name only" { value := "After" patch.DisplayName = &value } if tc.RoleChanged { value := RoleOrganizationAdmin patch.Role = &value } if tc.OrganizationChanged { value := "org-2" patch.OrganizationID = &value } if tc.StatusChanged { value := StatusDisabled patch.Status = &value } if tc.PasswordChanged { value := "password9" patch.Password = &value } got, err := service.UpdateAccount(context.Background(), Actor{ID: "super", Role: RoleSuperAdmin}, "user-1", patch) if err != nil { t.Fatal(err) } want := 5 if tc.Increment { want++ } if got.SessionVersion != want { t.Fatalf("sessionVersion=%d want %d", got.SessionVersion, want) } }) } } func TestOrganizationAdministrationAndDeleteConflict(t *testing.T) { store := &fakeStore{organizations: map[string]Organization{}} service := testService(store) org, err := service.CreateOrganization(context.Background(), Actor{Role: RoleSuperAdmin}, " Acme ") if err != nil || org.Name != "Acme" || org.ArchiveOwnerID != "archive:org-fixed" { t.Fatalf("org=%#v err=%v", org, err) } if _, err := service.CreateOrganization(context.Background(), Actor{Role: RoleOrganizationAdmin}, "Nope"); !IsKind(err, ErrorForbidden) { t.Fatalf("error=%v", err) } store.memberCount = 1 if err := service.DeleteOrganization(context.Background(), Actor{Role: RoleSuperAdmin}, org.ID); !IsKind(err, ErrorConflict) { t.Fatalf("error=%v", err) } } func TestInfrastructureErrorTaxonomy(t *testing.T) { store := &fakeStore{err: errors.New("database unavailable")} _, err := testService(store).ListAccounts(context.Background(), Actor{Role: RoleSuperAdmin}, AccountFilters{}) if !IsKind(err, ErrorInfrastructure) || !errors.Is(err, store.err) { t.Fatalf("error=%v", err) } } func loadContract(t *testing.T) contractFixture { t.Helper() raw, err := os.ReadFile("../../../contracts/admin/accounts-organizations-v1.json") if err != nil { t.Fatal(err) } var fixture contractFixture if err := json.Unmarshal(raw, &fixture); err != nil { t.Fatal(err) } return fixture } func testService(store Store) *Service { return NewService(store, WithIDGenerator(func(prefix string) string { return prefix + "-fixed" }), WithClock(func() time.Time { return time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC) }), WithPasswordHasher(func(string) (PasswordHash, error) { return PasswordHash{Hash: "hash", Salt: "salt"}, nil })) } type fakeStore struct { accounts map[string]Account organizations map[string]Organization memberCount int err error } func (s *fakeStore) ListAccounts(context.Context, AccountFilters) ([]Account, error) { if s.err != nil { return nil, s.err } var out []Account for _, a := range s.accounts { out = append(out, a) } return out, nil } func (s *fakeStore) GetAccount(_ context.Context, id string) (Account, bool, error) { a, ok := s.accounts[id] return a, ok, s.err } func (s *fakeStore) CreateAccount(_ context.Context, a Account) (Account, error) { if s.accounts == nil { s.accounts = map[string]Account{} } s.accounts[a.ID] = a return a, s.err } func (s *fakeStore) UpdateAccount(_ context.Context, a Account) (Account, error) { s.accounts[a.ID] = a return a, s.err } func (s *fakeStore) DeleteAccount(context.Context, string, string) error { return s.err } func (s *fakeStore) ListOrganizations(context.Context, bool) ([]Organization, error) { var out []Organization for _, o := range s.organizations { out = append(out, o) } return out, s.err } func (s *fakeStore) GetOrganization(_ context.Context, id string) (Organization, bool, error) { o, ok := s.organizations[id] return o, ok, s.err } func (s *fakeStore) CreateOrganization(_ context.Context, o Organization) (Organization, error) { if s.organizations == nil { s.organizations = map[string]Organization{} } s.organizations[o.ID] = o return o, s.err } func (s *fakeStore) UpdateOrganization(_ context.Context, o Organization) (Organization, error) { s.organizations[o.ID] = o return o, s.err } func (s *fakeStore) DeleteOrganization(context.Context, string) error { return s.err } func (s *fakeStore) CountOrganizationMembers(context.Context, string) (int, error) { return s.memberCount, s.err } func TestUpdateAccountPrefersAtomicIntentOverStaleFullRowWrite(t *testing.T) { now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC) base := &fakeStore{ accounts: map[string]Account{"user-1": { ID: "user-1", DisplayName: "Before", Role: RoleUser, OrganizationID: "org-1", Status: StatusActive, PasswordHash: "stale-hash", PasswordSalt: "stale-salt", SessionVersion: 4, }}, organizations: map[string]Organization{"org-1": {ID: "org-1", Status: StatusActive}}, } store := &atomicAccountStore{fakeStore: base, result: Account{ ID: "user-1", DisplayName: "After", Role: RoleUser, OrganizationID: "org-1", Status: StatusActive, PasswordHash: "concurrent-password-hash", PasswordSalt: "concurrent-password-salt", SessionVersion: 8, }} service := NewService(store, WithClock(func() time.Time { return now })) name := "After" got, err := service.UpdateAccount(context.Background(), Actor{ID: "super", Role: RoleSuperAdmin}, "user-1", UpdateAccountInput{DisplayName: &name}) if err != nil { t.Fatal(err) } if store.fullRowWrites != 0 { t.Fatalf("full-row writes=%d, want zero", store.fullRowWrites) } if store.id != "user-1" || store.update.DisplayName == nil || *store.update.DisplayName != "After" { t.Fatalf("atomic update=(id=%q, update=%#v)", store.id, store.update) } if store.update.PasswordHash != nil || store.update.IncrementSessionVersion { t.Fatalf("display-only update must not overwrite password or increment session: %#v", store.update) } if got.PasswordHash != "concurrent-password-hash" || got.SessionVersion != 8 { t.Fatalf("result=%#v, want database-current security state", got) } } type atomicAccountStore struct { *fakeStore id string update AccountUpdate result Account fullRowWrites int } func (s *atomicAccountStore) UpdateAccount(ctx context.Context, account Account) (Account, error) { s.fullRowWrites++ return s.fakeStore.UpdateAccount(ctx, account) } func (s *atomicAccountStore) ApplyAccountUpdate(_ context.Context, id string, update AccountUpdate) (Account, error) { s.id, s.update = id, update return s.result, nil }