thread-master/apps/backend/internal/module/member/usecase/auth_test.go

394 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package usecase_test
import (
"context"
"fmt"
"strings"
"sync"
"testing"
"time"
"apps/backend/internal/module/member/domain"
"apps/backend/internal/module/member/usecase"
tokenUC "apps/backend/internal/module/token/usecase"
"github.com/stretchr/testify/require"
)
// fakeRepo is test-only (not a production memory store).
type fakeRepo struct {
mu sync.Mutex
byUID map[int64]*domain.Member
byEmail map[string]int64
byPhone map[string]int64
identities map[string]*domain.Identity // platform:login_id
byUIDIds map[int64][]string
seq int64
codes map[string]string
refresh map[string]int64
settings map[int64]*domain.UserSettings
}
func newFake() *fakeRepo {
return &fakeRepo{
byUID: map[int64]*domain.Member{}, byEmail: map[string]int64{}, byPhone: map[string]int64{},
identities: map[string]*domain.Identity{}, byUIDIds: map[int64][]string{},
seq: domain.MinMemberUID, codes: map[string]string{}, refresh: map[string]int64{},
settings: map[int64]*domain.UserSettings{},
}
}
func idKey(loginID, platform string) string { return platform + ":" + loginID }
func (f *fakeRepo) NextUID(ctx context.Context) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.seq++
if f.seq < domain.MinMemberUID {
f.seq = domain.MinMemberUID + 1
}
return f.seq, nil
}
func (f *fakeRepo) CreateMember(ctx context.Context, m *domain.Member) error {
f.mu.Lock()
defer f.mu.Unlock()
if m.Email != "" {
if _, ok := f.byEmail[m.Email]; ok {
return domain.ErrEmailTaken
}
f.byEmail[m.Email] = m.UID
}
if m.Phone != "" {
f.byPhone[m.Phone] = m.UID
}
cp := *m
f.byUID[m.UID] = &cp
return nil
}
func (f *fakeRepo) FindByUID(ctx context.Context, uid int64) (*domain.Member, error) {
f.mu.Lock()
defer f.mu.Unlock()
m, ok := f.byUID[uid]
if !ok {
return nil, domain.ErrNotFound
}
cp := *m
return &cp, nil
}
func (f *fakeRepo) FindByEmail(ctx context.Context, email string) (*domain.Member, error) {
f.mu.Lock()
defer f.mu.Unlock()
uid, ok := f.byEmail[email]
if !ok {
return nil, domain.ErrNotFound
}
cp := *f.byUID[uid]
return &cp, nil
}
func (f *fakeRepo) FindByPhone(ctx context.Context, phone string) (*domain.Member, error) {
f.mu.Lock()
defer f.mu.Unlock()
uid, ok := f.byPhone[phone]
if !ok {
return nil, domain.ErrNotFound
}
cp := *f.byUID[uid]
return &cp, nil
}
func (f *fakeRepo) UpdateMember(ctx context.Context, m *domain.Member) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, ok := f.byUID[m.UID]; !ok {
return domain.ErrNotFound
}
cp := *m
f.byUID[m.UID] = &cp
if m.Email != "" {
f.byEmail[m.Email] = m.UID
}
return nil
}
func (f *fakeRepo) ListPage(ctx context.Context, page, pageSize int, query, status string) ([]*domain.Member, int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
query = strings.ToLower(strings.TrimSpace(query))
var all []*domain.Member
for _, m := range f.byUID {
if status != "" && m.Status != status {
continue
}
if query != "" {
uidStr := fmt.Sprintf("%d", m.UID)
if !strings.Contains(strings.ToLower(m.Email), query) &&
!strings.Contains(strings.ToLower(m.DisplayName), query) &&
!strings.Contains(uidStr, query) {
continue
}
}
cp := *m
all = append(all, &cp)
}
total := int64(len(all))
start := (page - 1) * pageSize
if start >= len(all) {
return []*domain.Member{}, total, nil
}
end := start + pageSize
if end > len(all) {
end = len(all)
}
return all[start:end], total, nil
}
func (f *fakeRepo) CountActiveAdmins(ctx context.Context) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
var n int64
for _, m := range f.byUID {
if m.IsAdmin() && m.Status != domain.StatusSuspended {
n++
}
}
return n, nil
}
func (f *fakeRepo) FindByInviteCode(ctx context.Context, code string) (*domain.Member, error) {
f.mu.Lock()
defer f.mu.Unlock()
code = strings.ToUpper(strings.TrimSpace(code))
for _, m := range f.byUID {
if strings.ToUpper(strings.TrimSpace(m.InviteCode)) == code {
cp := *m
return &cp, nil
}
}
return nil, domain.ErrNotFound
}
func (f *fakeRepo) ListAllMembers(ctx context.Context) ([]*domain.Member, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]*domain.Member, 0, len(f.byUID))
for _, m := range f.byUID {
cp := *m
out = append(out, &cp)
}
return out, nil
}
func (f *fakeRepo) CreateIdentity(ctx context.Context, id *domain.Identity) error {
f.mu.Lock()
defer f.mu.Unlock()
k := idKey(id.LoginID, id.Platform)
if _, ok := f.identities[k]; ok {
return domain.ErrIdentityTaken
}
cp := *id
f.identities[k] = &cp
f.byUIDIds[id.UID] = append(f.byUIDIds[id.UID], k)
return nil
}
func (f *fakeRepo) FindIdentity(ctx context.Context, loginID, platform string) (*domain.Identity, error) {
f.mu.Lock()
defer f.mu.Unlock()
id, ok := f.identities[idKey(loginID, platform)]
if !ok {
return nil, domain.ErrIdentityNotFound
}
cp := *id
return &cp, nil
}
func (f *fakeRepo) ListIdentitiesByUID(ctx context.Context, uid int64) ([]*domain.Identity, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []*domain.Identity
for _, k := range f.byUIDIds[uid] {
if id, ok := f.identities[k]; ok {
cp := *id
out = append(out, &cp)
}
}
return out, nil
}
func (f *fakeRepo) DeleteIdentity(ctx context.Context, loginID, platform string) error {
f.mu.Lock()
defer f.mu.Unlock()
k := idKey(loginID, platform)
id, ok := f.identities[k]
if !ok {
return domain.ErrIdentityNotFound
}
delete(f.identities, k)
var rest []string
for _, x := range f.byUIDIds[id.UID] {
if x != k {
rest = append(rest, x)
}
}
f.byUIDIds[id.UID] = rest
return nil
}
func (f *fakeRepo) UpdateIdentityPassword(ctx context.Context, loginID, platform, passwordHash string) error {
f.mu.Lock()
defer f.mu.Unlock()
id, ok := f.identities[idKey(loginID, platform)]
if !ok {
return domain.ErrIdentityNotFound
}
id.PasswordHash = passwordHash
return nil
}
func (f *fakeRepo) SetCode(kind, key, code string, ttl time.Duration) {
f.mu.Lock()
defer f.mu.Unlock()
f.codes[kind+":"+key] = code
}
func (f *fakeRepo) CheckCode(kind, key, code string) bool {
f.mu.Lock()
defer f.mu.Unlock()
k := kind + ":" + key
v, ok := f.codes[k]
if !ok || v != code {
return false
}
delete(f.codes, k)
return true
}
func (f *fakeRepo) PeekCode(kind, key, code string) bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.codes[kind+":"+key] == code
}
func (f *fakeRepo) SaveRefresh(jti string, uid int64, exp time.Time) {
f.mu.Lock()
defer f.mu.Unlock()
f.refresh[jti] = uid
}
func (f *fakeRepo) ConsumeRefresh(jti string) (int64, bool) {
f.mu.Lock()
defer f.mu.Unlock()
uid, ok := f.refresh[jti]
if !ok {
return 0, false
}
delete(f.refresh, jti)
return uid, true
}
func (f *fakeRepo) RevokeRefresh(jti string) {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.refresh, jti)
}
func (f *fakeRepo) RevokeAllRefreshByUID(ctx context.Context, uid int64) error {
f.mu.Lock()
defer f.mu.Unlock()
for j, u := range f.refresh {
if u == uid {
delete(f.refresh, j)
}
}
return nil
}
func (f *fakeRepo) GetSettings(ctx context.Context, uid int64) (*domain.UserSettings, error) {
return domain.DefaultSettings(uid), nil
}
func (f *fakeRepo) SaveSettings(ctx context.Context, uid int64, s *domain.UserSettings) error {
return nil
}
func TestAU_01_LoginOk(t *testing.T) {
store := newFake()
svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10)
const strong = "Admin123!@#x"
m, tokens, err := svc.Register(context.Background(), "admin@haixun.local", strong, "Admin")
require.NoError(t, err)
m.Roles = []string{"admin", "member"}
m.Status = domain.StatusActive
m.EmailVerified = true
require.NoError(t, store.UpdateMember(context.Background(), m))
m2, tokens2, err := svc.Login(context.Background(), "admin@haixun.local", strong)
require.NoError(t, err)
require.Equal(t, m.UID, m2.UID)
require.True(t, m.UID >= domain.MinMemberUID)
require.NotEmpty(t, tokens.AccessToken)
require.NotEmpty(t, tokens2.AccessToken)
}
func TestAU_02_LoginBadPassword(t *testing.T) {
store := newFake()
svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10)
const strong = "Admin123!@#x"
_, _, err := svc.Register(context.Background(), "admin@haixun.local", strong, "Admin")
require.NoError(t, err)
_, _, err = svc.Login(context.Background(), "admin@haixun.local", "wrong")
require.ErrorIs(t, err, domain.ErrBadPassword)
}
func TestAR_02_RegisterOk(t *testing.T) {
store := newFake()
svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10)
m, tokens, err := svc.Register(context.Background(), "new@example.com", "Secret12!@#a", "New")
require.NoError(t, err)
require.True(t, m.UID >= domain.MinMemberUID)
require.NotEmpty(t, tokens.AccessToken)
ids, err := svc.ListIdentities(context.Background(), m.UID)
require.NoError(t, err)
require.Len(t, ids, 1)
}
func TestAU_07_Refresh(t *testing.T) {
store := newFake()
svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("sec-a", "sec-r", 3600, 86400), 10)
_, tokens, err := svc.Register(context.Background(), "r@example.com", "Secret12!@#a", "R")
require.NoError(t, err)
next, err := svc.Refresh(context.Background(), tokens.RefreshToken)
require.NoError(t, err)
require.NotEmpty(t, next.AccessToken)
_, err = svc.Refresh(context.Background(), tokens.RefreshToken)
require.Error(t, err)
}
func TestBind_Unbind(t *testing.T) {
store := newFake()
svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10)
m, _, err := svc.Register(context.Background(), "b@example.com", "Secret12!@#a", "B")
require.NoError(t, err)
id, err := svc.BindAccount(context.Background(), m.UID, "g-sub-1", domain.PlatformGoogle, domain.AccountTypePlatform, "")
require.NoError(t, err)
require.Equal(t, "g-sub-1", id.LoginID)
ids, _ := svc.ListIdentities(context.Background(), m.UID)
require.Len(t, ids, 2)
require.NoError(t, svc.UnbindAccount(context.Background(), m.UID, "g-sub-1", domain.PlatformGoogle))
err = svc.UnbindAccount(context.Background(), m.UID, "b@example.com", domain.PlatformPassword)
require.Error(t, err)
}
func TestLoginOAuth_Mock(t *testing.T) {
store := newFake()
svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10)
sub, email, name, err := svc.VerifyGoogleIDToken(context.Background(), "mock:g123:u@g.com")
require.NoError(t, err)
require.Equal(t, "g123", sub)
m, pair, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, sub, email, name)
require.NoError(t, err)
require.NotEmpty(t, pair.AccessToken)
require.True(t, m.UID >= domain.MinMemberUID)
m2, _, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, sub, email, name)
require.NoError(t, err)
require.Equal(t, m.UID, m2.UID)
}
func TestMember_UidIs8DigitsFrom1M(t *testing.T) {
require.Equal(t, int64(1_000_000), domain.MinMemberUID)
// 1_000_000 是 7 位;顯示補零成 8 位01000000
require.Equal(t, "01000000", fmt.Sprintf("%08d", domain.MinMemberUID))
require.Equal(t, "01000001", fmt.Sprintf("%08d", domain.MinMemberUID+1))
}