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,173 @@
|
||||
package administration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleSuperAdmin Role = "super_admin"
|
||||
RoleOrganizationAdmin Role = "organization_admin"
|
||||
RoleUser Role = "user"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusActive Status = "active"
|
||||
StatusDisabled Status = "disabled"
|
||||
)
|
||||
|
||||
type Actor struct {
|
||||
ID string
|
||||
Role Role
|
||||
OrganizationID string
|
||||
}
|
||||
type Account struct {
|
||||
ID, Phone, DisplayName string
|
||||
Role Role
|
||||
OrganizationID string
|
||||
Status Status
|
||||
PasswordHash, PasswordSalt string
|
||||
FailedLoginCount int
|
||||
LockedUntil *time.Time
|
||||
SessionVersion int
|
||||
LastLoginAt *time.Time
|
||||
LegacySubject string
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
}
|
||||
type Organization struct {
|
||||
ID, Name string
|
||||
Status Status
|
||||
ArchiveOwnerID string
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
}
|
||||
type AccountProjection struct {
|
||||
ID, Phone, DisplayName string
|
||||
Role Role
|
||||
OrganizationID string
|
||||
Status Status
|
||||
CreatedAt time.Time
|
||||
LastLoginAt, LockedUntil *time.Time
|
||||
}
|
||||
type OrganizationProjection struct {
|
||||
ID, Name string
|
||||
Status Status
|
||||
}
|
||||
type AccountFilters struct {
|
||||
OrganizationID string
|
||||
Role Role
|
||||
IncludeDisabled bool
|
||||
}
|
||||
type PasswordHash struct{ Hash, Salt string }
|
||||
type Store interface {
|
||||
ListAccounts(context.Context, AccountFilters) ([]Account, error)
|
||||
GetAccount(context.Context, string) (Account, bool, error)
|
||||
CreateAccount(context.Context, Account) (Account, error)
|
||||
UpdateAccount(context.Context, Account) (Account, error)
|
||||
DeleteAccount(context.Context, string, string) error
|
||||
ListOrganizations(context.Context, bool) ([]Organization, error)
|
||||
GetOrganization(context.Context, string) (Organization, bool, error)
|
||||
CreateOrganization(context.Context, Organization) (Organization, error)
|
||||
UpdateOrganization(context.Context, Organization) (Organization, error)
|
||||
DeleteOrganization(context.Context, string) error
|
||||
CountOrganizationMembers(context.Context, string) (int, error)
|
||||
}
|
||||
type ErrorKind string
|
||||
|
||||
const (
|
||||
ErrorValidation ErrorKind = "validation"
|
||||
ErrorForbidden ErrorKind = "forbidden"
|
||||
ErrorNotFound ErrorKind = "not_found"
|
||||
ErrorConflict ErrorKind = "conflict"
|
||||
ErrorInfrastructure ErrorKind = "infrastructure"
|
||||
)
|
||||
|
||||
type Error struct {
|
||||
Kind ErrorKind
|
||||
Message string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *Error) Error() string { return e.Message }
|
||||
func (e *Error) Unwrap() error { return e.Err }
|
||||
func IsKind(err error, kind ErrorKind) bool {
|
||||
var target *Error
|
||||
return errors.As(err, &target) && target.Kind == kind
|
||||
}
|
||||
func StatusCode(err error) int {
|
||||
var target *Error
|
||||
if !errors.As(err, &target) {
|
||||
return 500
|
||||
}
|
||||
switch target.Kind {
|
||||
case ErrorValidation:
|
||||
return 400
|
||||
case ErrorForbidden:
|
||||
return 403
|
||||
case ErrorNotFound:
|
||||
return 404
|
||||
case ErrorConflict:
|
||||
return 409
|
||||
default:
|
||||
return 500
|
||||
}
|
||||
}
|
||||
func problem(kind ErrorKind, message string) error { return &Error{Kind: kind, Message: message} }
|
||||
func infrastructure(operation string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var typed *Error
|
||||
if errors.As(err, &typed) {
|
||||
return err
|
||||
}
|
||||
return &Error{Kind: ErrorInfrastructure, Message: operation + ": " + err.Error(), Err: err}
|
||||
}
|
||||
|
||||
type CreateAccountInput struct {
|
||||
Phone, DisplayName, Password string
|
||||
Role Role
|
||||
OrganizationID, LegacySubject string
|
||||
}
|
||||
type UpdateAccountInput struct {
|
||||
DisplayName *string
|
||||
Role *Role
|
||||
OrganizationID *string
|
||||
Status *Status
|
||||
Password *string
|
||||
ClearLoginLock bool
|
||||
}
|
||||
type UpdateOrganizationInput struct {
|
||||
Name *string
|
||||
Status *Status
|
||||
}
|
||||
|
||||
func AuthorizeAccountTarget(actor Actor, target Account) error {
|
||||
if actor.Role == RoleSuperAdmin {
|
||||
return nil
|
||||
}
|
||||
if actor.Role == RoleOrganizationAdmin && target.Role == RoleUser && actor.OrganizationID != "" && actor.OrganizationID == target.OrganizationID {
|
||||
return nil
|
||||
}
|
||||
return problem(ErrorForbidden, "组织管理员只能管理本组织普通用户。")
|
||||
}
|
||||
func requireSuper(actor Actor) error {
|
||||
if actor.Role != RoleSuperAdmin {
|
||||
return problem(ErrorForbidden, "需要超级管理员权限。")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func validRole(role Role) bool {
|
||||
return role == RoleSuperAdmin || role == RoleOrganizationAdmin || role == RoleUser
|
||||
}
|
||||
func validStatus(status Status) bool { return status == StatusActive || status == StatusDisabled }
|
||||
func ProjectAccount(a Account) AccountProjection {
|
||||
return AccountProjection{ID: a.ID, Phone: a.Phone, DisplayName: a.DisplayName, Role: a.Role, OrganizationID: a.OrganizationID, Status: a.Status, CreatedAt: a.CreatedAt, LastLoginAt: a.LastLoginAt, LockedUntil: a.LockedUntil}
|
||||
}
|
||||
func ProjectOrganization(o Organization) OrganizationProjection {
|
||||
return OrganizationProjection{ID: o.ID, Name: o.Name, Status: o.Status}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package administration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Service) ListOrganizations(ctx context.Context, actor Actor) ([]OrganizationProjection, error) {
|
||||
include := actor.Role == RoleSuperAdmin
|
||||
if actor.Role != RoleSuperAdmin && actor.Role != RoleOrganizationAdmin {
|
||||
return nil, problem(ErrorForbidden, "需要管理员权限。")
|
||||
}
|
||||
organizations, err := s.store.ListOrganizations(ctx, include)
|
||||
if err != nil {
|
||||
return nil, infrastructure("list organizations", err)
|
||||
}
|
||||
out := make([]OrganizationProjection, 0, len(organizations))
|
||||
for _, org := range organizations {
|
||||
if actor.Role == RoleSuperAdmin || org.ID == actor.OrganizationID {
|
||||
out = append(out, ProjectOrganization(org))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s *Service) CreateOrganization(ctx context.Context, actor Actor, name string) (Organization, error) {
|
||||
if err := requireSuper(actor); err != nil {
|
||||
return Organization{}, err
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return Organization{}, problem(ErrorValidation, "组织名称不能为空。")
|
||||
}
|
||||
now := s.now()
|
||||
id := s.id("org")
|
||||
org := Organization{ID: id, Name: name, Status: StatusActive, ArchiveOwnerID: "archive:" + id, CreatedAt: now, UpdatedAt: now}
|
||||
created, err := s.store.CreateOrganization(ctx, org)
|
||||
return created, infrastructure("create organization", err)
|
||||
}
|
||||
func (s *Service) UpdateOrganization(ctx context.Context, actor Actor, id string, patch UpdateOrganizationInput) (Organization, error) {
|
||||
if err := requireSuper(actor); err != nil {
|
||||
return Organization{}, err
|
||||
}
|
||||
current, found, err := s.store.GetOrganization(ctx, id)
|
||||
if err != nil {
|
||||
return Organization{}, infrastructure("get organization", err)
|
||||
}
|
||||
if !found {
|
||||
return Organization{}, problem(ErrorNotFound, "组织不存在。")
|
||||
}
|
||||
if patch.Name != nil {
|
||||
name := strings.TrimSpace(*patch.Name)
|
||||
if name == "" {
|
||||
return Organization{}, problem(ErrorValidation, "组织名称不能为空。")
|
||||
}
|
||||
current.Name = name
|
||||
}
|
||||
if patch.Status != nil {
|
||||
if !validStatus(*patch.Status) {
|
||||
return Organization{}, problem(ErrorValidation, "组织状态不正确。")
|
||||
}
|
||||
current.Status = *patch.Status
|
||||
}
|
||||
current.UpdatedAt = s.now()
|
||||
updated, err := s.store.UpdateOrganization(ctx, current)
|
||||
return updated, infrastructure("update organization", err)
|
||||
}
|
||||
func (s *Service) DeleteOrganization(ctx context.Context, actor Actor, id string) error {
|
||||
if err := requireSuper(actor); err != nil {
|
||||
return err
|
||||
}
|
||||
count, err := s.store.CountOrganizationMembers(ctx, id)
|
||||
if err != nil {
|
||||
return infrastructure("count organization members", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return problem(ErrorConflict, "组织仍有账号,不能删除。")
|
||||
}
|
||||
if err := s.store.DeleteOrganization(ctx, id); err != nil {
|
||||
return infrastructure("delete organization", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package administration
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"golang.org/x/crypto/scrypt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`)
|
||||
|
||||
func NormalizePhone(value string) string {
|
||||
r := strings.NewReplacer(" ", "", "\t", "", "\n", "", "\r", "", "(", "", ")", "", "-", "")
|
||||
return r.Replace(strings.TrimSpace(value))
|
||||
}
|
||||
func ValidPhone(value string) bool { return phonePattern.MatchString(NormalizePhone(value)) }
|
||||
func HashPassword(password string) (PasswordHash, error) {
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return PasswordHash{}, err
|
||||
}
|
||||
encoded := hex.EncodeToString(salt)
|
||||
derived, err := scrypt.Key([]byte(password), []byte(encoded), 16384, 8, 1, 64)
|
||||
if err != nil {
|
||||
return PasswordHash{}, err
|
||||
}
|
||||
return PasswordHash{Hash: hex.EncodeToString(derived), Salt: encoded}, nil
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package administration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
id func(string) string
|
||||
now func() time.Time
|
||||
hash func(string) (PasswordHash, error)
|
||||
}
|
||||
type Option func(*Service)
|
||||
|
||||
func WithIDGenerator(fn func(string) string) Option { return func(s *Service) { s.id = fn } }
|
||||
func WithClock(fn func() time.Time) Option { return func(s *Service) { s.now = fn } }
|
||||
func WithPasswordHasher(fn func(string) (PasswordHash, error)) Option {
|
||||
return func(s *Service) { s.hash = fn }
|
||||
}
|
||||
func NewService(store Store, options ...Option) *Service {
|
||||
s := &Service{store: store, id: randomID, now: time.Now, hash: HashPassword}
|
||||
for _, option := range options {
|
||||
option(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
func randomID(prefix string) string {
|
||||
raw := make([]byte, 12)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
return prefix + "-" + hex.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func (s *Service) ListAccounts(ctx context.Context, actor Actor, filters AccountFilters) ([]AccountProjection, error) {
|
||||
if actor.Role == RoleOrganizationAdmin {
|
||||
if actor.OrganizationID == "" {
|
||||
return nil, problem(ErrorForbidden, "当前账号没有组织归属。")
|
||||
}
|
||||
filters.OrganizationID, filters.Role = actor.OrganizationID, RoleUser
|
||||
} else if actor.Role != RoleSuperAdmin {
|
||||
return nil, problem(ErrorForbidden, "需要管理员权限。")
|
||||
}
|
||||
accounts, err := s.store.ListAccounts(ctx, filters)
|
||||
if err != nil {
|
||||
return nil, infrastructure("list accounts", err)
|
||||
}
|
||||
out := make([]AccountProjection, len(accounts))
|
||||
for i, a := range accounts {
|
||||
out[i] = ProjectAccount(a)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s *Service) CreateAccount(ctx context.Context, actor Actor, input CreateAccountInput) (Account, error) {
|
||||
input.Phone = NormalizePhone(input.Phone)
|
||||
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
if !ValidPhone(input.Phone) {
|
||||
return Account{}, problem(ErrorValidation, "手机号格式不正确。")
|
||||
}
|
||||
if input.DisplayName == "" {
|
||||
return Account{}, problem(ErrorValidation, "显示名称不能为空。")
|
||||
}
|
||||
if len(input.Password) < 8 {
|
||||
return Account{}, problem(ErrorValidation, "初始密码至少需要 8 位。")
|
||||
}
|
||||
if !validRole(input.Role) {
|
||||
return Account{}, problem(ErrorValidation, "账号角色不正确。")
|
||||
}
|
||||
if actor.Role == RoleOrganizationAdmin {
|
||||
if input.Role != RoleUser {
|
||||
return Account{}, problem(ErrorForbidden, "组织管理员只能创建普通用户。")
|
||||
}
|
||||
if actor.OrganizationID == "" {
|
||||
return Account{}, problem(ErrorForbidden, "当前账号没有组织归属。")
|
||||
}
|
||||
input.OrganizationID = actor.OrganizationID
|
||||
} else if actor.Role != RoleSuperAdmin {
|
||||
return Account{}, problem(ErrorForbidden, "需要管理员权限。")
|
||||
}
|
||||
if err := s.validateMembership(ctx, input.Role, input.OrganizationID); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
hashed, err := s.hash(input.Password)
|
||||
if err != nil {
|
||||
return Account{}, infrastructure("hash password", err)
|
||||
}
|
||||
now := s.now()
|
||||
a := Account{ID: s.id("user"), Phone: input.Phone, DisplayName: input.DisplayName, Role: input.Role, OrganizationID: input.OrganizationID, Status: StatusActive, PasswordHash: hashed.Hash, PasswordSalt: hashed.Salt, SessionVersion: 1, LegacySubject: strings.TrimSpace(input.LegacySubject), CreatedAt: now, UpdatedAt: now}
|
||||
created, err := s.store.CreateAccount(ctx, a)
|
||||
return created, infrastructure("create account", err)
|
||||
}
|
||||
func (s *Service) UpdateAccount(ctx context.Context, actor Actor, id string, patch UpdateAccountInput) (Account, error) {
|
||||
current, found, err := s.store.GetAccount(ctx, id)
|
||||
if err != nil {
|
||||
return Account{}, infrastructure("get account", err)
|
||||
}
|
||||
if !found {
|
||||
return Account{}, problem(ErrorNotFound, "账号不存在。")
|
||||
}
|
||||
if err := AuthorizeAccountTarget(actor, current); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if actor.Role != RoleSuperAdmin && ((patch.Role != nil && *patch.Role != current.Role) || patch.OrganizationID != nil) {
|
||||
return Account{}, problem(ErrorForbidden, "组织管理员不能修改账号角色或归属。")
|
||||
}
|
||||
next := current
|
||||
mutates := false
|
||||
if patch.DisplayName != nil {
|
||||
name := strings.TrimSpace(*patch.DisplayName)
|
||||
if name == "" {
|
||||
return Account{}, problem(ErrorValidation, "显示名称不能为空。")
|
||||
}
|
||||
next.DisplayName = name
|
||||
}
|
||||
if patch.Role != nil {
|
||||
if !validRole(*patch.Role) {
|
||||
return Account{}, problem(ErrorValidation, "账号角色不正确。")
|
||||
}
|
||||
next.Role = *patch.Role
|
||||
mutates = true
|
||||
}
|
||||
if patch.OrganizationID != nil {
|
||||
next.OrganizationID = strings.TrimSpace(*patch.OrganizationID)
|
||||
mutates = true
|
||||
}
|
||||
if patch.Status != nil {
|
||||
if !validStatus(*patch.Status) {
|
||||
return Account{}, problem(ErrorValidation, "账号状态不正确。")
|
||||
}
|
||||
next.Status = *patch.Status
|
||||
mutates = true
|
||||
}
|
||||
if err := s.validateMembership(ctx, next.Role, next.OrganizationID); err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
if patch.Password != nil {
|
||||
if len(*patch.Password) < 8 {
|
||||
return Account{}, problem(ErrorValidation, "新密码至少需要 8 位。")
|
||||
}
|
||||
hashed, err := s.hash(*patch.Password)
|
||||
if err != nil {
|
||||
return Account{}, infrastructure("hash password", err)
|
||||
}
|
||||
next.PasswordHash, next.PasswordSalt = hashed.Hash, hashed.Salt
|
||||
mutates = true
|
||||
}
|
||||
if patch.ClearLoginLock {
|
||||
next.FailedLoginCount, next.LockedUntil = 0, nil
|
||||
}
|
||||
if mutates {
|
||||
next.SessionVersion++
|
||||
}
|
||||
next.UpdatedAt = s.now()
|
||||
updated, err := s.store.UpdateAccount(ctx, next)
|
||||
return updated, infrastructure("update account", err)
|
||||
}
|
||||
func (s *Service) SetAccountStatus(ctx context.Context, actor Actor, id string, status Status) (Account, error) {
|
||||
if actor.ID == id && status == StatusDisabled {
|
||||
return Account{}, problem(ErrorValidation, "不能停用当前登录账号。")
|
||||
}
|
||||
return s.UpdateAccount(ctx, actor, id, UpdateAccountInput{Status: &status, ClearLoginLock: status == StatusActive})
|
||||
}
|
||||
func (s *Service) ResetPassword(ctx context.Context, actor Actor, id, password string) (Account, error) {
|
||||
return s.UpdateAccount(ctx, actor, id, UpdateAccountInput{Password: &password, ClearLoginLock: true})
|
||||
}
|
||||
func (s *Service) DeleteAccount(ctx context.Context, actor Actor, id string) (string, error) {
|
||||
current, found, err := s.store.GetAccount(ctx, id)
|
||||
if err != nil {
|
||||
return "", infrastructure("get account", err)
|
||||
}
|
||||
if !found {
|
||||
return "", problem(ErrorNotFound, "账号不存在。")
|
||||
}
|
||||
if err := AuthorizeAccountTarget(actor, current); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if actor.ID == id {
|
||||
return "", problem(ErrorValidation, "不能删除当前登录账号。")
|
||||
}
|
||||
if current.Role == RoleSuperAdmin {
|
||||
return "", problem(ErrorValidation, "不能直接删除超级管理员账号。")
|
||||
}
|
||||
archive := "archive:global"
|
||||
if current.OrganizationID != "" {
|
||||
org, found, err := s.store.GetOrganization(ctx, current.OrganizationID)
|
||||
if err != nil {
|
||||
return "", infrastructure("get organization", err)
|
||||
}
|
||||
if found {
|
||||
archive = org.ArchiveOwnerID
|
||||
}
|
||||
}
|
||||
if err := s.store.DeleteAccount(ctx, id, archive); err != nil {
|
||||
return "", infrastructure("delete account", err)
|
||||
}
|
||||
return archive, nil
|
||||
}
|
||||
func (s *Service) validateMembership(ctx context.Context, role Role, organizationID string) error {
|
||||
if role == RoleSuperAdmin && organizationID == "" {
|
||||
return nil
|
||||
}
|
||||
if organizationID == "" {
|
||||
return problem(ErrorValidation, "普通账号必须归属有效组织。")
|
||||
}
|
||||
org, found, err := s.store.GetOrganization(ctx, organizationID)
|
||||
if err != nil {
|
||||
return infrastructure("get organization", err)
|
||||
}
|
||||
if !found || org.Status != StatusActive {
|
||||
return problem(ErrorValidation, "账号归属的组织不存在或已停用。")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/administration"
|
||||
)
|
||||
|
||||
const accountColumns = `id, phone, display_name, role, organization_id, status, password_hash, password_salt, failed_login_count, locked_until, session_version, last_login_at, legacy_subject, created_at, updated_at`
|
||||
const organizationColumns = `id, name, status, archive_owner_id, created_at, updated_at`
|
||||
const ListAdministrationAccountsSQL = `SELECT ` + accountColumns + ` FROM public.platform_users WHERE ($1::text = '' OR organization_id = $1::text) AND ($2::text = '' OR role = $2::text) AND ($3::boolean OR status = 'active') ORDER BY created_at DESC`
|
||||
const GetAdministrationAccountSQL = `SELECT ` + accountColumns + ` FROM public.platform_users WHERE id = $1::text`
|
||||
const CreateAdministrationAccountSQL = `INSERT INTO public.platform_users (` + accountColumns + `) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING ` + accountColumns
|
||||
const UpdateAdministrationAccountSQL = `UPDATE public.platform_users SET phone=$2, display_name=$3, role=$4, organization_id=$5, status=$6, password_hash=$7, password_salt=$8, failed_login_count=$9, locked_until=$10, session_version=$11, last_login_at=$12, legacy_subject=$13, created_at=$14, updated_at=$15 WHERE id=$1::text RETURNING ` + accountColumns
|
||||
const ListAdministrationOrganizationsSQL = `SELECT ` + organizationColumns + ` FROM public.platform_organizations WHERE ($1::boolean OR status = 'active') ORDER BY created_at ASC`
|
||||
const GetAdministrationOrganizationSQL = `SELECT ` + organizationColumns + ` FROM public.platform_organizations WHERE id = $1::text`
|
||||
const CreateAdministrationOrganizationSQL = `INSERT INTO public.platform_organizations (` + organizationColumns + `) VALUES ($1,$2,$3,$4,$5,$6) RETURNING ` + organizationColumns
|
||||
const UpdateAdministrationOrganizationSQL = `UPDATE public.platform_organizations SET name=$2, status=$3, archive_owner_id=$4, created_at=$5, updated_at=$6 WHERE id=$1::text RETURNING ` + organizationColumns
|
||||
const CountAdministrationOrganizationMembersSQL = `SELECT count(*) FROM public.platform_users WHERE organization_id = $1::text`
|
||||
const DeleteAdministrationOrganizationSQL = `DELETE FROM public.platform_organizations WHERE id = $1::text RETURNING id`
|
||||
const ArchiveAssetsSQL = `UPDATE public.assets SET owner_id = $2::text WHERE owner_id = $1::text`
|
||||
const ArchiveGenerationJobsSQL = `UPDATE public.generation_jobs SET owner_id = $2::text WHERE owner_id = $1::text`
|
||||
const ArchiveProjectsSQL = `UPDATE public.projects SET owner_id = $2::text WHERE owner_id = $1::text`
|
||||
const ArchiveImageTemplatesSQL = `UPDATE public.image_templates SET owner_id = $2::text WHERE owner_id = $1::text`
|
||||
const DeleteAdministrationAccountSQL = `DELETE FROM public.platform_users WHERE id = $1::text`
|
||||
|
||||
func (db *Database) administrationQuerier() (Querier, error) {
|
||||
if db.config.Backend != BackendPostgres || db.querier == nil {
|
||||
return nil, fmt.Errorf("PostgreSQL is unavailable when ZHINIAN_DATA_BACKEND=%s", db.config.Backend)
|
||||
}
|
||||
return db.querier, nil
|
||||
}
|
||||
func (db *Database) ListAccounts(ctx context.Context, f administration.AccountFilters) ([]administration.Account, error) {
|
||||
q, e := db.administrationQuerier()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
rows, e := q.Query(ctx, ListAdministrationAccountsSQL, f.OrganizationID, f.Role, f.IncludeDisabled)
|
||||
if e != nil {
|
||||
return nil, fmt.Errorf("list administration accounts: %w", e)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []administration.Account
|
||||
for rows.Next() {
|
||||
a, e := scanAdministrationAccount(rows)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
func (db *Database) GetAccount(ctx context.Context, id string) (administration.Account, bool, error) {
|
||||
q, e := db.administrationQuerier()
|
||||
if e != nil {
|
||||
return administration.Account{}, false, e
|
||||
}
|
||||
rows, e := q.Query(ctx, GetAdministrationAccountSQL, id)
|
||||
if e != nil {
|
||||
return administration.Account{}, false, fmt.Errorf("get administration account: %w", e)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return administration.Account{}, false, rows.Err()
|
||||
}
|
||||
a, e := scanAdministrationAccount(rows)
|
||||
return a, e == nil, e
|
||||
}
|
||||
func (db *Database) CreateAccount(ctx context.Context, a administration.Account) (administration.Account, error) {
|
||||
return db.writeAccount(ctx, CreateAdministrationAccountSQL, a)
|
||||
}
|
||||
func (db *Database) UpdateAccount(ctx context.Context, a administration.Account) (administration.Account, error) {
|
||||
return db.writeAccount(ctx, UpdateAdministrationAccountSQL, a)
|
||||
}
|
||||
func (db *Database) writeAccount(ctx context.Context, statement string, a administration.Account) (administration.Account, error) {
|
||||
q, e := db.administrationQuerier()
|
||||
if e != nil {
|
||||
return administration.Account{}, e
|
||||
}
|
||||
rows, e := q.Query(ctx, statement, accountArgs(a)...)
|
||||
if e != nil {
|
||||
return administration.Account{}, administrationWriteError(e)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return administration.Account{}, &administration.Error{Kind: administration.ErrorNotFound, Message: "账号不存在。"}
|
||||
}
|
||||
return scanAdministrationAccount(rows)
|
||||
}
|
||||
func accountArgs(a administration.Account) []any {
|
||||
return []any{a.ID, a.Phone, a.DisplayName, a.Role, optionalDatabaseText(a.OrganizationID), a.Status, a.PasswordHash, a.PasswordSalt, a.FailedLoginCount, a.LockedUntil, a.SessionVersion, a.LastLoginAt, optionalDatabaseText(a.LegacySubject), a.CreatedAt, a.UpdatedAt}
|
||||
}
|
||||
func scanAdministrationAccount(rows Rows) (administration.Account, error) {
|
||||
var a administration.Account
|
||||
var org, legacy sql.NullString
|
||||
var locked, last sql.NullTime
|
||||
if e := rows.Scan(&a.ID, &a.Phone, &a.DisplayName, &a.Role, &org, &a.Status, &a.PasswordHash, &a.PasswordSalt, &a.FailedLoginCount, &locked, &a.SessionVersion, &last, &legacy, &a.CreatedAt, &a.UpdatedAt); e != nil {
|
||||
return a, fmt.Errorf("scan administration account: %w", e)
|
||||
}
|
||||
if org.Valid {
|
||||
a.OrganizationID = org.String
|
||||
}
|
||||
if legacy.Valid {
|
||||
a.LegacySubject = legacy.String
|
||||
}
|
||||
if locked.Valid {
|
||||
a.LockedUntil = &locked.Time
|
||||
}
|
||||
if last.Valid {
|
||||
a.LastLoginAt = &last.Time
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (db *Database) ListOrganizations(ctx context.Context, include bool) ([]administration.Organization, error) {
|
||||
q, e := db.administrationQuerier()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
rows, e := q.Query(ctx, ListAdministrationOrganizationsSQL, include)
|
||||
if e != nil {
|
||||
return nil, fmt.Errorf("list administration organizations: %w", e)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []administration.Organization
|
||||
for rows.Next() {
|
||||
o, e := scanAdministrationOrganization(rows)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
func (db *Database) GetOrganization(ctx context.Context, id string) (administration.Organization, bool, error) {
|
||||
q, e := db.administrationQuerier()
|
||||
if e != nil {
|
||||
return administration.Organization{}, false, e
|
||||
}
|
||||
rows, e := q.Query(ctx, GetAdministrationOrganizationSQL, id)
|
||||
if e != nil {
|
||||
return administration.Organization{}, false, e
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return administration.Organization{}, false, rows.Err()
|
||||
}
|
||||
o, e := scanAdministrationOrganization(rows)
|
||||
return o, e == nil, e
|
||||
}
|
||||
func (db *Database) CreateOrganization(ctx context.Context, o administration.Organization) (administration.Organization, error) {
|
||||
return db.writeOrganization(ctx, CreateAdministrationOrganizationSQL, o)
|
||||
}
|
||||
func (db *Database) UpdateOrganization(ctx context.Context, o administration.Organization) (administration.Organization, error) {
|
||||
return db.writeOrganization(ctx, UpdateAdministrationOrganizationSQL, o)
|
||||
}
|
||||
func (db *Database) writeOrganization(ctx context.Context, statement string, o administration.Organization) (administration.Organization, error) {
|
||||
q, e := db.administrationQuerier()
|
||||
if e != nil {
|
||||
return administration.Organization{}, e
|
||||
}
|
||||
rows, e := q.Query(ctx, statement, o.ID, o.Name, o.Status, o.ArchiveOwnerID, o.CreatedAt, o.UpdatedAt)
|
||||
if e != nil {
|
||||
return administration.Organization{}, administrationWriteError(e)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return administration.Organization{}, &administration.Error{Kind: administration.ErrorNotFound, Message: "组织不存在。"}
|
||||
}
|
||||
return scanAdministrationOrganization(rows)
|
||||
}
|
||||
func scanAdministrationOrganization(rows Rows) (administration.Organization, error) {
|
||||
var o administration.Organization
|
||||
if e := rows.Scan(&o.ID, &o.Name, &o.Status, &o.ArchiveOwnerID, &o.CreatedAt, &o.UpdatedAt); e != nil {
|
||||
return o, fmt.Errorf("scan administration organization: %w", e)
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
func (db *Database) CountOrganizationMembers(ctx context.Context, id string) (int, error) {
|
||||
q, e := db.administrationQuerier()
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
rows, e := q.Query(ctx, CountAdministrationOrganizationMembersSQL, id)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
return 0, fmt.Errorf("member count returned no row")
|
||||
}
|
||||
var count int
|
||||
e = rows.Scan(&count)
|
||||
return count, e
|
||||
}
|
||||
func (db *Database) DeleteOrganization(ctx context.Context, id string) error {
|
||||
q, e := db.administrationQuerier()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
rows, e := q.Query(ctx, DeleteAdministrationOrganizationSQL, id)
|
||||
if e != nil {
|
||||
return administrationWriteError(e)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
if e := rows.Err(); e != nil {
|
||||
return e
|
||||
}
|
||||
return &administration.Error{Kind: administration.ErrorNotFound, Message: "组织不存在。"}
|
||||
}
|
||||
var deletedID string
|
||||
if e := rows.Scan(&deletedID); e != nil {
|
||||
return fmt.Errorf("scan deleted organization: %w", e)
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
func (db *Database) DeleteAccount(ctx context.Context, id, archive string) error {
|
||||
if db.config.Backend != BackendPostgres || db.transactions == nil {
|
||||
return fmt.Errorf("PostgreSQL is unavailable when ZHINIAN_DATA_BACKEND=%s", db.config.Backend)
|
||||
}
|
||||
tx, e := db.transactions.Begin(ctx)
|
||||
if e != nil {
|
||||
return fmt.Errorf("begin delete account transaction: %w", e)
|
||||
}
|
||||
done := false
|
||||
defer func() {
|
||||
if !done {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
for _, statement := range []string{ArchiveAssetsSQL, ArchiveGenerationJobsSQL, ArchiveProjectsSQL, ArchiveImageTemplatesSQL} {
|
||||
if e = tx.Exec(ctx, statement, id, archive); e != nil {
|
||||
return fmt.Errorf("archive account ownership: %w", e)
|
||||
}
|
||||
}
|
||||
if e = tx.Exec(ctx, DeleteAdministrationAccountSQL, id); e != nil {
|
||||
return fmt.Errorf("delete administration account: %w", e)
|
||||
}
|
||||
if e = tx.Commit(ctx); e != nil {
|
||||
return fmt.Errorf("commit delete account transaction: %w", e)
|
||||
}
|
||||
done = true
|
||||
return nil
|
||||
}
|
||||
func administrationWriteError(err error) error {
|
||||
if sqlState(err) == "23505" {
|
||||
return &administration.Error{Kind: administration.ErrorConflict, Message: "唯一字段已存在。", Err: err}
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/administration"
|
||||
)
|
||||
|
||||
func TestAdministrationListAccountsUsesExplicitColumnsAndParameterizedFilters(t *testing.T) {
|
||||
rows := &identityRows{}
|
||||
querier := &identityQuerier{rows: rows}
|
||||
db := NewDatabase(Config{Backend: BackendPostgres}, querier)
|
||||
_, err := db.ListAccounts(context.Background(), administration.AccountFilters{OrganizationID: "org-1", Role: administration.RoleUser, IncludeDisabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if querier.sql != ListAdministrationAccountsSQL {
|
||||
t.Fatalf("SQL=%q", querier.sql)
|
||||
}
|
||||
if !reflect.DeepEqual(querier.args, []any{"org-1", administration.RoleUser, true}) {
|
||||
t.Fatalf("args=%#v", querier.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAccountArchivesAllOwnedDataAndIdentityInOneTransaction(t *testing.T) {
|
||||
tx := &administrationTransaction{}
|
||||
db := NewDatabase(Config{Backend: BackendPostgres}, &administrationPool{tx: tx})
|
||||
if err := db.DeleteAccount(context.Background(), "user-1", "archive:org-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantSQL := []string{ArchiveAssetsSQL, ArchiveGenerationJobsSQL, ArchiveProjectsSQL, ArchiveImageTemplatesSQL, DeleteAdministrationAccountSQL}
|
||||
if !reflect.DeepEqual(tx.sql, wantSQL) {
|
||||
t.Fatalf("SQL sequence=%#v want %#v", tx.sql, wantSQL)
|
||||
}
|
||||
for _, args := range tx.args {
|
||||
if !reflect.DeepEqual(args, []any{"user-1", "archive:org-1"}) && !reflect.DeepEqual(args, []any{"user-1"}) {
|
||||
t.Fatalf("args=%#v", args)
|
||||
}
|
||||
}
|
||||
if tx.commits != 1 || tx.rollbacks != 0 {
|
||||
t.Fatalf("commits=%d rollbacks=%d", tx.commits, tx.rollbacks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAccountRollsBackArchiveTransactionFailure(t *testing.T) {
|
||||
tx := &administrationTransaction{failAt: 3, err: errors.New("projects failed")}
|
||||
db := NewDatabase(Config{Backend: BackendPostgres}, &administrationPool{tx: tx})
|
||||
err := db.DeleteAccount(context.Background(), "user-1", "archive:org-1")
|
||||
if !errors.Is(err, tx.err) || tx.commits != 0 || tx.rollbacks != 1 {
|
||||
t.Fatalf("err=%v commits=%d rollbacks=%d", err, tx.commits, tx.rollbacks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministrationStoreFailsClosedWithoutPostgres(t *testing.T) {
|
||||
db := NewDatabase(Config{Backend: BackendLocal}, &identityQuerier{})
|
||||
if _, err := db.ListOrganizations(context.Background(), true); err == nil {
|
||||
t.Fatal("ListOrganizations error=nil")
|
||||
}
|
||||
if err := db.DeleteAccount(context.Background(), "u", "a"); err == nil {
|
||||
t.Fatal("DeleteAccount error=nil")
|
||||
}
|
||||
}
|
||||
|
||||
var _ administration.Store = (*Database)(nil)
|
||||
|
||||
type administrationPool struct{ tx *administrationTransaction }
|
||||
|
||||
func (p *administrationPool) Query(context.Context, string, ...any) (Rows, error) {
|
||||
return nil, errors.New("outside transaction")
|
||||
}
|
||||
func (p *administrationPool) Begin(context.Context) (Transaction, error) { return p.tx, nil }
|
||||
|
||||
type administrationTransaction struct {
|
||||
sql []string
|
||||
args [][]any
|
||||
failAt int
|
||||
err error
|
||||
commits, rollbacks int
|
||||
}
|
||||
|
||||
func (t *administrationTransaction) Query(context.Context, string, ...any) (Rows, error) {
|
||||
return nil, errors.New("unexpected query")
|
||||
}
|
||||
func (t *administrationTransaction) Exec(_ context.Context, query string, args ...any) error {
|
||||
t.sql = append(t.sql, query)
|
||||
t.args = append(t.args, args)
|
||||
if t.failAt == len(t.sql) {
|
||||
return t.err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (t *administrationTransaction) Commit(context.Context) error { t.commits++; return nil }
|
||||
func (t *administrationTransaction) Rollback(context.Context) error { t.rollbacks++; return nil }
|
||||
Reference in new issue
Block a user