Files
NianAIGC/backend/internal/administration/service_test.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
}