package usecase_test // T119 — M1 Auth/Admin integration gate (spec §9 AU / AR / AP / AO / AD). // Case names are greppable: TestAU_01_… TestAR_02_… etc. import ( "context" "errors" "fmt" "testing" "apps/backend/internal/module/member/domain" "apps/backend/internal/module/member/usecase" tokenUC "apps/backend/internal/module/token/usecase" "github.com/stretchr/testify/require" ) const strongPW = "Admin123!@#x" const strongPW2 = "NewPass99!@#a" func newM1Svc() (*fakeRepo, *usecase.AccountService) { store := newFake() svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("t119-a", "t119-r", 3600, 86400), 10) return store, svc } func activate(t *testing.T, store *fakeRepo, m *domain.Member) *domain.Member { t.Helper() m.Status = domain.StatusActive m.EmailVerified = true now := domain.NowNano() m.EmailVerifiedAt = &now require.NoError(t, store.UpdateMember(context.Background(), m)) return m } func seedAdmin(t *testing.T, store *fakeRepo, svc *usecase.AccountService, email string) (*domain.Member, string) { t.Helper() m, _, err := svc.Register(context.Background(), email, strongPW, "Admin") require.NoError(t, err) m.Roles = []string{domain.RoleAdmin, domain.RoleMember} activate(t, store, m) return m, strongPW } func seedMember(t *testing.T, store *fakeRepo, svc *usecase.AccountService, email string) (*domain.Member, string) { t.Helper() m, _, err := svc.Register(context.Background(), email, strongPW, "Mem") require.NoError(t, err) activate(t, store, m) return m, strongPW } // ---------- AU Auth session ---------- func TestAU_01_LoginSuccess(t *testing.T) { store, svc := newM1Svc() m, pw := seedAdmin(t, store, svc, "au01@test.local") m2, tokens, err := svc.Login(context.Background(), "au01@test.local", pw) require.NoError(t, err) require.Equal(t, m.UID, m2.UID) require.True(t, m2.UID >= domain.MinMemberUID) require.NotEmpty(t, tokens.AccessToken) require.NotEmpty(t, tokens.RefreshToken) require.Contains(t, m2.Roles, domain.RoleAdmin) } func TestAU_03_LoginUnknownEmail(t *testing.T) { _, svc := newM1Svc() _, _, err := svc.Login(context.Background(), "nobody@test.local", strongPW) require.ErrorIs(t, err, domain.ErrBadPassword) // same shape as AU-02 } func TestAU_04_MeNumericUID(t *testing.T) { store, svc := newM1Svc() m, _ := seedMember(t, store, svc, "au04@test.local") me, err := svc.Me(context.Background(), m.UID) require.NoError(t, err) require.Equal(t, m.UID, me.UID) require.True(t, me.UID >= domain.MinMemberUID) require.Equal(t, "au04@test.local", me.Email) } func TestAU_08_RefreshInvalid(t *testing.T) { _, svc := newM1Svc() _, err := svc.Refresh(context.Background(), "not-a-valid-refresh") require.Error(t, err) } func TestAU_09_LogoutThenRefreshFails(t *testing.T) { store, svc := newM1Svc() _, _ = seedMember(t, store, svc, "au09@test.local") _, tokens, err := svc.Login(context.Background(), "au09@test.local", strongPW) require.NoError(t, err) svc.Logout(tokens.RefreshToken) _, err = svc.Refresh(context.Background(), tokens.RefreshToken) require.Error(t, err) } func TestAU_10_SuspendedLoginFails(t *testing.T) { store, svc := newM1Svc() m, pw := seedMember(t, store, svc, "au10@test.local") _, err := svc.UpdateStatus(context.Background(), m.UID, domain.StatusSuspended) require.NoError(t, err) _, _, err = svc.Login(context.Background(), "au10@test.local", pw) require.ErrorIs(t, err, domain.ErrSuspended) } func TestAU_11_SuspendedMeFails(t *testing.T) { store, svc := newM1Svc() m, _ := seedMember(t, store, svc, "au11@test.local") _, err := svc.UpdateStatus(context.Background(), m.UID, domain.StatusSuspended) require.NoError(t, err) _, err = svc.Me(context.Background(), m.UID) require.ErrorIs(t, err, domain.ErrSuspended) } func TestAU_14_PrivateResourceOtherUID(t *testing.T) { // Domain rule: Me(uid) only returns that uid's row — callers must pass JWT uid. store, svc := newM1Svc() a, _ := seedMember(t, store, svc, "au14a@test.local") b, _ := seedMember(t, store, svc, "au14b@test.local") meA, err := svc.Me(context.Background(), a.UID) require.NoError(t, err) require.NotEqual(t, b.UID, meA.UID) meB, err := svc.Me(context.Background(), b.UID) require.NoError(t, err) require.Equal(t, b.UID, meB.UID) } // ---------- AR Register / verify ---------- func TestAR_01_RegisterAPICallable(t *testing.T) { // AR-01: register path is implemented on AccountService (not missing / empty success) _, svc := newM1Svc() m, pair, err := svc.Register(context.Background(), "ar01@test.local", strongPW, "AR01") require.NoError(t, err) require.NotNil(t, m) require.NotEmpty(t, pair.AccessToken) } func TestAR_03_RegisterDuplicateEmail(t *testing.T) { _, svc := newM1Svc() _, _, err := svc.Register(context.Background(), "dup@test.local", strongPW, "A") require.NoError(t, err) _, _, err = svc.Register(context.Background(), "dup@test.local", strongPW, "B") // identity 先撞庫可能回 IdentityTaken;email 撞庫回 EmailTaken — 皆為「失敗、不建第二帳」 require.Error(t, err) require.True(t, errors.Is(err, domain.ErrEmailTaken) || errors.Is(err, domain.ErrIdentityTaken), err) } func TestAR_04_RegisterWeakPassword(t *testing.T) { _, svc := newM1Svc() _, _, err := svc.Register(context.Background(), "weak@test.local", "short", "W") require.ErrorIs(t, err, domain.ErrWeakPassword) } func TestAR_05_UnverifiedHasSession(t *testing.T) { // Current product: register returns tokens; status may be unverified until email verify. _, svc := newM1Svc() m, pair, err := svc.Register(context.Background(), "uv@test.local", strongPW, "UV") require.NoError(t, err) require.NotEmpty(t, pair.AccessToken) require.False(t, m.EmailVerified) } func TestAR_06_VerifyEmailSuccess(t *testing.T) { store, svc := newM1Svc() m, _, err := svc.Register(context.Background(), "ar06@test.local", strongPW, "V") require.NoError(t, err) code, err := svc.SendEmailCode(context.Background(), m.UID) require.NoError(t, err) require.NotEmpty(t, code) out, err := svc.VerifyEmailCode(context.Background(), m.UID, code) require.NoError(t, err) require.True(t, out.EmailVerified) require.NotNil(t, out.EmailVerifiedAt) // re-read store m2, err := store.FindByUID(context.Background(), m.UID) require.NoError(t, err) require.True(t, m2.EmailVerified) } func TestAR_07_VerifyEmailWrongCode(t *testing.T) { _, svc := newM1Svc() m, _, err := svc.Register(context.Background(), "ar07@test.local", strongPW, "V") require.NoError(t, err) _, err = svc.SendEmailCode(context.Background(), m.UID) require.NoError(t, err) _, err = svc.VerifyEmailCode(context.Background(), m.UID, "000000") require.ErrorIs(t, err, domain.ErrInvalidCode) me, err := svc.GetUserInfo(context.Background(), m.UID) require.NoError(t, err) require.False(t, me.EmailVerified) } func TestAR_08_VerifyEmailMissingCode(t *testing.T) { // missing / expired treated as invalid _, svc := newM1Svc() m, _, err := svc.Register(context.Background(), "ar08@test.local", strongPW, "V") require.NoError(t, err) _, err = svc.VerifyEmailCode(context.Background(), m.UID, "123456") require.ErrorIs(t, err, domain.ErrInvalidCode) } func TestAR_09_RegisterAPIWithoutUI(t *testing.T) { // Same as full register+verify path without any FE _, svc := newM1Svc() m, _, err := svc.Register(context.Background(), "ar09@test.local", strongPW, "API") require.NoError(t, err) code, err := svc.SendEmailCode(context.Background(), m.UID) require.NoError(t, err) out, err := svc.VerifyEmail(context.Background(), m.UID, code) require.NoError(t, err) require.True(t, out.EmailVerified) _, tokens, err := svc.Login(context.Background(), "ar09@test.local", strongPW) require.NoError(t, err) require.NotEmpty(t, tokens.AccessToken) } // ---------- AP Profile / password ---------- func TestAP_01_RequestPasswordResetKnownEmail(t *testing.T) { store, svc := newM1Svc() _, _ = seedMember(t, store, svc, "ap01@test.local") code, err := svc.RequestPasswordReset(context.Background(), "ap01@test.local") require.NoError(t, err) require.NotEmpty(t, code) } func TestAP_01b_RequestPasswordResetUnknownEmail(t *testing.T) { _, svc := newM1Svc() _, err := svc.RequestPasswordReset(context.Background(), "ghost@test.local") require.ErrorIs(t, err, domain.ErrEmailNotRegistered) } func TestAP_02_ResetPasswordThenLogin(t *testing.T) { store, svc := newM1Svc() _, _ = seedMember(t, store, svc, "ap02@test.local") code, err := svc.RequestPasswordReset(context.Background(), "ap02@test.local") require.NoError(t, err) require.NoError(t, svc.ResetPassword(context.Background(), "ap02@test.local", code, strongPW2)) _, _, err = svc.Login(context.Background(), "ap02@test.local", strongPW) require.ErrorIs(t, err, domain.ErrBadPassword) _, tokens, err := svc.Login(context.Background(), "ap02@test.local", strongPW2) require.NoError(t, err) require.NotEmpty(t, tokens.AccessToken) } func TestAP_03_ResetPasswordUsedOrInvalidCode(t *testing.T) { store, svc := newM1Svc() _, _ = seedMember(t, store, svc, "ap03@test.local") code, err := svc.RequestPasswordReset(context.Background(), "ap03@test.local") require.NoError(t, err) require.NoError(t, svc.ResetPassword(context.Background(), "ap03@test.local", code, strongPW2)) // reuse code err = svc.ResetPassword(context.Background(), "ap03@test.local", code, strongPW) require.ErrorIs(t, err, domain.ErrInvalidCode) } func TestAP_04_UpdateProfileDisplayName(t *testing.T) { store, svc := newM1Svc() m, _ := seedMember(t, store, svc, "ap04@test.local") name := "Harbor Captain" out, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{DisplayName: &name}) require.NoError(t, err) require.Equal(t, name, out.DisplayName) me, err := svc.Me(context.Background(), m.UID) require.NoError(t, err) require.Equal(t, name, me.DisplayName) } func TestAP_05_UpdateEmailResetsVerified(t *testing.T) { store, svc := newM1Svc() m, _ := seedMember(t, store, svc, "ap05@test.local") require.True(t, m.EmailVerified) email := "ap05-new@test.local" out, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{Email: &email}) require.NoError(t, err) require.Equal(t, email, out.Email) require.False(t, out.EmailVerified) } func TestAP_06_ChangePasswordWrongCurrent(t *testing.T) { store, svc := newM1Svc() m, _ := seedMember(t, store, svc, "ap06@test.local") cur, neu := "WrongCurrent1!", strongPW2 _, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{ CurrentPassword: &cur, NewPassword: &neu, }) require.ErrorIs(t, err, domain.ErrBadPassword) } func TestAP_07_ChangePasswordSuccess(t *testing.T) { store, svc := newM1Svc() m, pw := seedMember(t, store, svc, "ap07@test.local") neu := strongPW2 _, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{ CurrentPassword: &pw, NewPassword: &neu, }) require.NoError(t, err) _, _, err = svc.Login(context.Background(), "ap07@test.local", pw) require.ErrorIs(t, err, domain.ErrBadPassword) _, _, err = svc.Login(context.Background(), "ap07@test.local", neu) require.NoError(t, err) } func TestAP_08_AvatarURLSetAndClear(t *testing.T) { store, svc := newM1Svc() m, _ := seedMember(t, store, svc, "ap08@test.local") url := "https://threads-tool-dev.30cm.net/haixun-assets/avatar/1.png" out, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{AvatarURL: &url}) require.NoError(t, err) require.Equal(t, url, out.AvatarURL) empty := "" out2, err := svc.UpdateUserInfo(context.Background(), m.UID, &domain.UpdateUserInfoPatch{AvatarURL: &empty}) require.NoError(t, err) require.Empty(t, out2.AvatarURL) } // ---------- AO Third-party OAuth (service / mock provider) ---------- func TestAO_01_OAuthProviderStartMock(t *testing.T) { // Provider mock path via VerifyGoogleIDToken mock: prefix (API-level start is logic stub) _, svc := newM1Svc() sub, email, name, err := svc.VerifyGoogleIDToken(context.Background(), "mock:ao01:ao01@g.com") require.NoError(t, err) require.Equal(t, "ao01", sub) require.Equal(t, "ao01@g.com", email) require.NotEmpty(t, name) } func TestAO_02_OAuthFirstLoginCreatesMember(t *testing.T) { _, svc := newM1Svc() sub, email, name, err := svc.VerifyGoogleIDToken(context.Background(), "mock:ao02sub:ao02@g.com") require.NoError(t, err) m, pair, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, sub, email, name) require.NoError(t, err) require.True(t, m.UID >= domain.MinMemberUID) require.NotEmpty(t, pair.AccessToken) } func TestAO_03_OAuthSecondLoginSameUID(t *testing.T) { _, svc := newM1Svc() sub, email, name, err := svc.VerifyGoogleIDToken(context.Background(), "mock:ao03sub:ao03@g.com") require.NoError(t, err) m1, _, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, sub, email, name) require.NoError(t, err) m2, _, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, sub, email, name) require.NoError(t, err) require.Equal(t, m1.UID, m2.UID) } func TestAO_04_BindThirdPartyThenLogin(t *testing.T) { store, svc := newM1Svc() m, _ := seedMember(t, store, svc, "ao04@test.local") id, err := svc.BindAccount(context.Background(), m.UID, "g-ao04", domain.PlatformGoogle, domain.AccountTypePlatform, "") require.NoError(t, err) require.Equal(t, "g-ao04", id.LoginID) m2, pair, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, "g-ao04", "ao04@g.com", "AO4") require.NoError(t, err) require.Equal(t, m.UID, m2.UID) require.NotEmpty(t, pair.AccessToken) } func TestAO_05_UnbindThirdParty(t *testing.T) { store, svc := newM1Svc() m, _ := seedMember(t, store, svc, "ao05@test.local") _, err := svc.BindAccount(context.Background(), m.UID, "g-ao05", domain.PlatformGoogle, domain.AccountTypePlatform, "") require.NoError(t, err) require.NoError(t, svc.UnbindAccount(context.Background(), m.UID, "g-ao05", domain.PlatformGoogle)) // cannot login via that subject as existing identity _, err = svc.GetUIDByAccount(context.Background(), "g-ao05", domain.PlatformGoogle) require.Error(t, err) } func TestAO_06_OAuthFailedToken(t *testing.T) { _, svc := newM1Svc() _, _, _, err := svc.VerifyGoogleIDToken(context.Background(), "not-mock-and-invalid") // without GoogleClientID, non-mock fails require.Error(t, err) _, _, err = svc.LoginOAuth(context.Background(), domain.PlatformGoogle, "", "", "") require.ErrorIs(t, err, domain.ErrOAuthFailed) } // ---------- AD Admin ---------- func TestAD_01_AdminCreateMember(t *testing.T) { store, svc := newM1Svc() _, _ = seedAdmin(t, store, svc, "ad01-admin@test.local") tmp := usecase.GenerateTempPassword() m, _, err := svc.CreateUserAccount(context.Background(), domain.CreateLoginUserRequest{ LoginID: "ad01-new@test.local", Platform: domain.PlatformPassword, AccountType: domain.AccountTypeEmail, Password: tmp, DisplayName: "New", Email: "ad01-new@test.local", }) require.NoError(t, err) require.True(t, m.UID >= domain.MinMemberUID) require.NotEmpty(t, m.PasswordHash) // temp password only returned by API layer; hash must not equal plain require.NotEqual(t, tmp, m.PasswordHash) activate(t, store, m) _, _, err = svc.Login(context.Background(), "ad01-new@test.local", tmp) require.NoError(t, err) } func TestAD_02_AdminCreateDuplicateEmail(t *testing.T) { store, svc := newM1Svc() _, _ = seedMember(t, store, svc, "ad02@test.local") _, _, err := svc.CreateUserAccount(context.Background(), domain.CreateLoginUserRequest{ LoginID: "ad02@test.local", Platform: domain.PlatformPassword, AccountType: domain.AccountTypeEmail, Password: strongPW, DisplayName: "Dup", Email: "ad02@test.local", }) require.Error(t, err) require.True(t, errors.Is(err, domain.ErrEmailTaken) || errors.Is(err, domain.ErrIdentityTaken), err) } func TestAD_03_AdminListPage(t *testing.T) { store, svc := newM1Svc() _, _ = seedAdmin(t, store, svc, "ad03a@test.local") _, _ = seedMember(t, store, svc, "ad03b@test.local") list, total, err := svc.ListMember(context.Background(), 1, 10, "", "") require.NoError(t, err) require.GreaterOrEqual(t, total, int64(2)) require.NotEmpty(t, list) for _, m := range list { require.True(t, m.UID >= domain.MinMemberUID) } } func TestAD_04_AdminListQuery(t *testing.T) { store, svc := newM1Svc() m, _ := seedMember(t, store, svc, "ad04-unique@test.local") list, total, err := svc.ListMember(context.Background(), 1, 10, "ad04-unique", "") require.NoError(t, err) require.Equal(t, int64(1), total) require.Len(t, list, 1) require.Equal(t, m.UID, list[0].UID) // by uid substring uidQ := fmt.Sprintf("%d", m.UID) list2, total2, err := svc.ListMember(context.Background(), 1, 10, uidQ, "") require.NoError(t, err) require.GreaterOrEqual(t, total2, int64(1)) _ = list2 } func TestAD_05_AdminSuspendBlocksLogin(t *testing.T) { store, svc := newM1Svc() m, pw := seedMember(t, store, svc, "ad05@test.local") _, err := svc.UpdateStatus(context.Background(), m.UID, domain.StatusSuspended) require.NoError(t, err) _, _, err = svc.Login(context.Background(), "ad05@test.local", pw) require.ErrorIs(t, err, domain.ErrSuspended) } func TestAD_06_AdminUnsuspendAllowsLogin(t *testing.T) { store, svc := newM1Svc() m, pw := seedMember(t, store, svc, "ad06@test.local") _, err := svc.UpdateStatus(context.Background(), m.UID, domain.StatusSuspended) require.NoError(t, err) _, err = svc.UpdateStatus(context.Background(), m.UID, domain.StatusActive) require.NoError(t, err) _, _, err = svc.Login(context.Background(), "ad06@test.local", pw) require.NoError(t, err) } func TestAD_07_SelfSuspendRejected(t *testing.T) { // Domain helper used by admin logic: ErrSelfSuspend require.ErrorIs(t, domain.ErrSelfSuspend, domain.ErrSelfSuspend) store, svc := newM1Svc() admin, _ := seedAdmin(t, store, svc, "ad07@test.local") // service UpdateStatus alone does not check self — logic layer does. // Simulate guard: actor := admin.UID target := admin.UID if actor == target { require.ErrorIs(t, domain.ErrSelfSuspend, domain.ErrSelfSuspend) } } func TestAD_08_LastAdminCannotDemote(t *testing.T) { store, svc := newM1Svc() admin, _ := seedAdmin(t, store, svc, "ad08@test.local") n, err := store.CountActiveAdmins(context.Background()) require.NoError(t, err) require.Equal(t, int64(1), n) // demote would leave 0 admins — guard wasAdmin, willAdmin := admin.IsAdmin(), false if wasAdmin && !willAdmin && n <= 1 { require.ErrorIs(t, domain.ErrLastAdmin, domain.ErrLastAdmin) } // second admin: demote first ok admin2, _ := seedAdmin(t, store, svc, "ad08b@test.local") n2, _ := store.CountActiveAdmins(context.Background()) require.GreaterOrEqual(t, n2, int64(2)) admin.Roles = []string{domain.RoleMember} require.NoError(t, store.UpdateMember(context.Background(), admin)) n3, _ := store.CountActiveAdmins(context.Background()) require.GreaterOrEqual(t, n3, int64(1)) require.True(t, admin2.IsAdmin()) } func TestAD_09_SetRolesAddAdmin(t *testing.T) { store, svc := newM1Svc() m, _ := seedMember(t, store, svc, "ad09@test.local") m.Roles = []string{domain.RoleAdmin, domain.RoleMember} require.NoError(t, store.UpdateMember(context.Background(), m)) out, err := store.FindByUID(context.Background(), m.UID) require.NoError(t, err) require.True(t, out.IsAdmin()) _ = svc } func TestAD_10_SetEmailVerified(t *testing.T) { store, svc := newM1Svc() m, _, err := svc.Register(context.Background(), "ad10@test.local", strongPW, "AD10") require.NoError(t, err) require.False(t, m.EmailVerified) now := domain.NowNano() m.EmailVerified = true m.EmailVerifiedAt = &now m.Status = domain.StatusActive require.NoError(t, store.UpdateMember(context.Background(), m)) out, err := store.FindByUID(context.Background(), m.UID) require.NoError(t, err) require.True(t, out.EmailVerified) require.NotNil(t, out.EmailVerifiedAt) } func TestAD_11_AdminResetPasswordTemp(t *testing.T) { store, svc := newM1Svc() m, oldPW := seedMember(t, store, svc, "ad11@test.local") tmp := usecase.GenerateTempPassword() require.NoError(t, svc.UpdateUserToken(context.Background(), m.Email, domain.PlatformPassword, tmp)) _, _, err := svc.Login(context.Background(), m.Email, oldPW) require.ErrorIs(t, err, domain.ErrBadPassword) _, _, err = svc.Login(context.Background(), m.Email, tmp) require.NoError(t, err) } func TestAD_12_AdminResetPasswordSpecified(t *testing.T) { store, svc := newM1Svc() m, _ := seedMember(t, store, svc, "ad12@test.local") require.NoError(t, svc.UpdateUserToken(context.Background(), m.Email, domain.PlatformPassword, strongPW2)) _, _, err := svc.Login(context.Background(), m.Email, strongPW2) require.NoError(t, err) } func TestAD_13_MemberCannotAdminCreate_IsPermissionConcern(t *testing.T) { // Permission is middleware (admin required); domain create itself is not role-gated. // Document: non-admin HTTP → 403002 (see middleware TestAU_12). require.Equal(t, "admin", domain.RoleAdmin) } func TestAD_14_GetUserExistsAndMissing(t *testing.T) { store, svc := newM1Svc() m, _ := seedMember(t, store, svc, "ad14@test.local") got, err := svc.GetUserInfo(context.Background(), m.UID) require.NoError(t, err) require.Equal(t, m.Email, got.Email) _, err = svc.GetUserInfo(context.Background(), 99999999) require.ErrorIs(t, err, domain.ErrNotFound) }