220 lines
7.7 KiB
Go
220 lines
7.7 KiB
Go
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
|
|
}
|