feat: add administration domain core
This commit is contained in:
1 parent
065dcd955a
commit
cea2751dc5
9 files changed
+1135
No files matched your search
@@ -0,0 +1,219 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user