From 0e68f26c571df5268730608c4f7687f281788e04 Mon Sep 17 00:00:00 2001 From: "saito.k" Date: Thu, 30 May 2024 00:18:59 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=20906:=20=E3=83=A6=E3=83=BC?= =?UTF-8?q?=E3=82=B6=E3=83=BC=E8=AA=8D=E8=A8=BCAPI=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task4182: ユーザー認証API修正](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/4182) - 認証済みチェックをパスワード変更より先に行うように修正 - パスワード変更に失敗したら、認証済みフラグをfalseにするリカバリ処理追加 - リカバリに失敗したら手動復旧ログを出力 - メール送信に失敗したらエラーを返すように修正 - メール送信に失敗したらリカバリ処理を行うように修正 - リカバリに失敗したら手動復旧ログを出力 - テスト修正 - リカバリ処理を考慮したケースを追加 ## レビューポイント - リカバリ処理の記述 - メール送信でエラーが起きたときにエラーを握りつぶさないようにしたが問題ないか - メール送信で失敗したときにエラーを握りつぶすと、ユーザーは届かないメールを待つしかなくなる - 失敗を伝えて、リカバリをしてあげると再実行してもらうことができる。 ## クエリの変更 - クエリの変更はなし ## 動作確認状況 - ローカルで確認 - 行った修正がデグレを発生させていないことを確認できるか - 既存のテストケースをDBを使うテストに置き換え - 結果は変えずに通ることを確認 - テストケースを追加し、新たな観点でテストを作成 ## 補足 - 相談、参考資料などがあれば --- .../src/pages/UserVerifyPage/index.tsx | 5 +- dictation_server/src/common/test/overrides.ts | 25 + .../src/features/users/users.service.spec.ts | 717 +++++++++++++----- .../src/features/users/users.service.ts | 124 ++- .../users/users.repository.service.ts | 36 +- 5 files changed, 682 insertions(+), 225 deletions(-) diff --git a/dictation_client/src/pages/UserVerifyPage/index.tsx b/dictation_client/src/pages/UserVerifyPage/index.tsx index f573ffb..5e57b3b 100644 --- a/dictation_client/src/pages/UserVerifyPage/index.tsx +++ b/dictation_client/src/pages/UserVerifyPage/index.tsx @@ -15,11 +15,8 @@ const UserVerifyPage: React.FC = (): JSX.Element => { const jwt = query.get("verify") ?? ""; useEffect(() => { - if (!jwt) { - navigate("/mail-confirm/failed"); - } dispatch(userVerifyAsync({ jwt })); - }, [navigate, dispatch, jwt]); + }, [dispatch, jwt]); const verifyState = useSelector(VerifyStateSelector); diff --git a/dictation_server/src/common/test/overrides.ts b/dictation_server/src/common/test/overrides.ts index 4c65c7c..07e2025 100644 --- a/dictation_server/src/common/test/overrides.ts +++ b/dictation_server/src/common/test/overrides.ts @@ -35,6 +35,11 @@ export const overrideAdB2cService = ( externalIds: string[], ) => Promise; getUser?: (context: Context, externalId: string) => Promise; + changePassword?: ( + context: Context, + externalId: string, + password: string, + ) => Promise; }, ): void => { // テストコードでのみ許される強引な方法でprivateメンバ変数の参照を取得 @@ -69,6 +74,12 @@ export const overrideAdB2cService = ( writable: true, }); } + if (overrides.changePassword) { + Object.defineProperty(obj, obj.changePassword.name, { + value: overrides.changePassword, + writable: true, + }); + } }; /** @@ -122,6 +133,8 @@ export const overrideUsersRepositoryService = ( overrides: { createNormalUser?: (user: newUser) => Promise; deleteNormalUser?: (userId: number) => Promise; + updateUserVerified?: (context: Context, userId: number) => Promise; + updateUserUnverified?: (context: Context, userId: number) => Promise; }, ): void => { // テストコードでのみ許される強引な方法でprivateメンバ変数の参照を取得 @@ -138,6 +151,18 @@ export const overrideUsersRepositoryService = ( writable: true, }); } + if (overrides.updateUserVerified) { + Object.defineProperty(obj, obj.updateUserVerified.name, { + value: overrides.updateUserVerified, + writable: true, + }); + } + if (overrides.updateUserUnverified) { + Object.defineProperty(obj, obj.updateUserUnverified.name, { + value: overrides.updateUserUnverified, + writable: true, + }); + } }; /** diff --git a/dictation_server/src/features/users/users.service.spec.ts b/dictation_server/src/features/users/users.service.spec.ts index 5246e06..e85a332 100644 --- a/dictation_server/src/features/users/users.service.spec.ts +++ b/dictation_server/src/features/users/users.service.spec.ts @@ -8,7 +8,6 @@ import { makeDefaultUsersRepositoryMockValue, makeUsersServiceMock, } from './test/users.service.mock'; -import { EmailAlreadyVerifiedError } from '../../repositories/users/errors/types'; import { createLicense, createUserGroup, @@ -22,6 +21,7 @@ import { LICENSE_ALLOCATED_STATUS, LICENSE_EXPIRATION_THRESHOLD_DAYS, LICENSE_TYPE, + MANUAL_RECOVERY_REQUIRED, TASK_STATUS, USER_AUDIO_FORMAT, USER_LICENSE_EXPIRY_STATUS, @@ -59,6 +59,7 @@ import { createTask } from '../files/test/utility'; import { createCheckoutPermissions } from '../tasks/test/utility'; import { MultipleImportErrors } from './types/types'; import { TestLogger } from '../../common/test/logger'; +import { SendGridService } from '../../gateways/sendgrid/sendgrid.service'; describe('UsersService.confirmUser', () => { let source: DataSource | null = null; @@ -254,216 +255,564 @@ describe('UsersService.confirmUser', () => { }); describe('UsersService.confirmUserAndInitPassword', () => { + let source: DataSource | null = null; + beforeAll(async () => { + if (source == null) { + source = await (async () => { + const s = new DataSource({ + type: 'mysql', + host: 'test_mysql_db', + port: 3306, + username: 'user', + password: 'password', + database: 'odms', + entities: [__dirname + '/../../**/*.entity{.ts,.js}'], + synchronize: false, // trueにすると自動的にmigrationが行われるため注意 + logger: new TestLogger('none'), + logging: true, + }); + return await s.initialize(); + })(); + } + }); + + beforeEach(async () => { + if (source) { + await truncateAllTable(source); + } + }); + + afterAll(async () => { + await source?.destroy(); + source = null; + }); it('ユーザーが発行されたパスワードでログインできるようにする', async () => { - const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); - usersRepositoryMockValue.findUserById = { - id: 1, - external_id: 'TEST9999', - account_id: 1, - role: 'None', - accepted_eula_version: 'string', - accepted_privacy_notice_version: 'string', - accepted_dpa_version: 'string', - email_verified: false, - created_by: 'string;', - created_at: new Date(), - updated_by: 'string;', - updated_at: new Date(), - auto_renew: true, - notification: true, - encryption: false, - prompt: false, - account: null, - author_id: null, - deleted_at: null, - encryption_password: null, - license: null, - userGroupMembers: null, - }; - const licensesRepositoryMockValue = null; - const adb2cParam = makeDefaultAdB2cMockValue(); - const configMockValue = makeDefaultConfigValue(); - const sortCriteriaRepositoryMockValue = - makeDefaultSortCriteriaRepositoryMockValue(); - const sendGridMockValue = makeDefaultSendGridlValue(); - const service = await makeUsersServiceMock( - usersRepositoryMockValue, - licensesRepositoryMockValue, - adb2cParam, - sendGridMockValue, - configMockValue, - sortCriteriaRepositoryMockValue, + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(UsersService); + const adminExternalId = 'ADMIN0001'; + const { account } = await makeTestAccount( + source, + {}, + { external_id: adminExternalId }, ); + const { id: accountId } = account; + // ユーザー作成 + await makeTestUser(source, { + account_id: accountId, + external_id: 'externalId_user1', + role: USER_ROLES.NONE, + author_id: undefined, + auto_renew: true, + encryption: false, + encryption_password: undefined, + prompt: false, + email_verified: false, + }); + + overrideAdB2cService(service, { + changePassword: jest.fn(), + getUser: async () => { + return { + id: adminExternalId, + displayName: 'admin', + }; + }, + }); + let _subject: string = ''; + overrideSendgridService(service, { + sendMail: async ( + context: Context, + to: string[], + cc: string[], + from: string, + subject: string, + text: string, + html: string, + ) => { + _subject = subject; + }, + }); const token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; - expect( + await service.confirmUserAndInitPassword( + makeContext('trackingId', 'requestId'), + token, + ); + expect(_subject).toBe('Temporary password [U-113]'); + const user = await getUserFromExternalId(source, 'externalId_user1'); + expect(user?.email_verified).toBe(true); + }); + it('トークンの形式が不正な場合、形式不正エラーとなる。(メール認証API)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(UsersService); + const sendgridService = module.get(SendGridService); + const adB2cService = module.get(AdB2cService); + const adminExternalId = 'ADMIN0001'; + const { account } = await makeTestAccount( + source, + {}, + { external_id: adminExternalId }, + ); + const { id: accountId } = account; + // ユーザー作成 + await makeTestUser(source, { + account_id: accountId, + external_id: 'externalId_user1', + role: USER_ROLES.NONE, + author_id: undefined, + auto_renew: true, + encryption: false, + encryption_password: undefined, + prompt: false, + email_verified: false, + }); + + overrideAdB2cService(service, { + changePassword: jest.fn(), + getUser: async () => { + return { + id: adminExternalId, + displayName: 'admin', + }; + }, + }); + overrideSendgridService(service, { + sendMail: jest.fn(), + }); + + const token = 'invalid.id.token'; + + try { await service.confirmUserAndInitPassword( makeContext('trackingId', 'requestId'), token, - ), - ).toEqual(undefined); - }); - - it('トークンの形式が不正な場合、形式不正エラーとなる。(メール認証API)', async () => { - const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); - usersRepositoryMockValue.findUserById = { - id: 1, - external_id: 'TEST9999', - account_id: 1, - role: 'None', - accepted_eula_version: 'string', - accepted_privacy_notice_version: 'string', - accepted_dpa_version: 'string', - email_verified: false, - created_by: 'string;', - created_at: new Date(), - updated_by: 'string;', - updated_at: new Date(), - auto_renew: true, - notification: true, - encryption: false, - prompt: false, - account: null, - author_id: null, - deleted_at: null, - encryption_password: null, - license: null, - userGroupMembers: null, - }; - const licensesRepositoryMockValue = null; - const adb2cParam = makeDefaultAdB2cMockValue(); - const sendGridMockValue = makeDefaultSendGridlValue(); - const configMockValue = makeDefaultConfigValue(); - const sortCriteriaRepositoryMockValue = - makeDefaultSortCriteriaRepositoryMockValue(); - const service = await makeUsersServiceMock( - usersRepositoryMockValue, - licensesRepositoryMockValue, - adb2cParam, - sendGridMockValue, - configMockValue, - sortCriteriaRepositoryMockValue, - ); - const token = 'invalid.id.token'; - await expect( - service.confirmUserAndInitPassword( - makeContext('trackingId', 'requestId'), - token, - ), - ).rejects.toEqual( - new HttpException(makeErrorResponse('E000101'), HttpStatus.BAD_REQUEST), - ); + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E000101')); + } else { + fail(); + } + } + const user = await getUserFromExternalId(source, 'externalId_user1'); + // ユーザーが認証されていないことを確認 + expect(user?.email_verified).toBe(false); + // メールが送信されていないことを確認 + expect(sendgridService.sendMail).toBeCalledTimes(0); + // パスワードが変更されていないことを確認 + expect(adB2cService.changePassword).toBeCalledTimes(0); }); it('ユーザが既に認証済みだった場合、認証済みユーザエラーとなる。(メール認証API)', async () => { - const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); - usersRepositoryMockValue.findUserById = { - id: 1, - external_id: 'TEST9999', - account_id: 1, - role: 'None', - accepted_eula_version: 'string', - accepted_privacy_notice_version: 'string', - accepted_dpa_version: 'string', - email_verified: true, - created_by: 'string;', - created_at: new Date(), - updated_by: 'string;', - updated_at: new Date(), + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(UsersService); + const sendgridService = module.get(SendGridService); + const adB2cService = module.get(AdB2cService); + const adminExternalId = 'ADMIN0001'; + const { account } = await makeTestAccount( + source, + {}, + { external_id: adminExternalId }, + ); + const { id: accountId } = account; + // ユーザー作成 + await makeTestUser(source, { + account_id: accountId, + external_id: 'externalId_user1', + role: USER_ROLES.NONE, + author_id: undefined, auto_renew: true, - notification: true, encryption: false, + encryption_password: undefined, prompt: false, - account: null, - author_id: null, - deleted_at: null, - encryption_password: null, - license: null, - userGroupMembers: null, - }; - const licensesRepositoryMockValue = null; - const adb2cParam = makeDefaultAdB2cMockValue(); - const sendGridMockValue = makeDefaultSendGridlValue(); - const configMockValue = makeDefaultConfigValue(); - const sortCriteriaRepositoryMockValue = - makeDefaultSortCriteriaRepositoryMockValue(); - usersRepositoryMockValue.updateUserVerified = new EmailAlreadyVerifiedError( - `Email already verified user`, - ); + email_verified: true, // emailを認証済みにする + }); + + overrideAdB2cService(service, { + changePassword: jest.fn(), + getUser: async () => { + return { + id: adminExternalId, + displayName: 'admin', + }; + }, + }); + overrideSendgridService(service, { + sendMail: jest.fn(), + }); - const service = await makeUsersServiceMock( - usersRepositoryMockValue, - licensesRepositoryMockValue, - adb2cParam, - sendGridMockValue, - configMockValue, - sortCriteriaRepositoryMockValue, - ); const token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; - await expect( - service.confirmUserAndInitPassword( + + try { + await service.confirmUserAndInitPassword( makeContext('trackingId', 'requestId'), token, - ), - ).rejects.toEqual( - new HttpException(makeErrorResponse('E010202'), HttpStatus.BAD_REQUEST), + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E010202')); + } else { + fail(); + } + } + const user = await getUserFromExternalId(source, 'externalId_user1'); + // ユーザーが認証されていることを確認 + expect(user?.email_verified).toBe(true); + // メールが送信されていないことを確認 + expect(sendgridService.sendMail).toBeCalledTimes(0); + // パスワードが変更されていないことを確認 + expect(adB2cService.changePassword).toBeCalledTimes(0); + }); + it('ADB2Cユーザーのパスワード更新に失敗した場合、リカバリ処理を行い、メールを未認証のままにする。(メール認証API)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(UsersService); + const sendgridService = module.get(SendGridService); + const adminExternalId = 'ADMIN0001'; + const { account } = await makeTestAccount( + source, + {}, + { external_id: adminExternalId }, ); + const { id: accountId } = account; + // ユーザー作成 + await makeTestUser(source, { + account_id: accountId, + external_id: 'externalId_user1', + role: USER_ROLES.NONE, + author_id: undefined, + auto_renew: true, + encryption: false, + encryption_password: undefined, + prompt: false, + email_verified: false, + }); + + overrideAdB2cService(service, { + changePassword: async () => { + throw new Error('ADB2C Error'); + }, + getUser: async () => { + return { + id: adminExternalId, + displayName: 'admin', + }; + }, + }); + overrideSendgridService(service, { + sendMail: jest.fn(), + }); + + const token = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; + try { + await service.confirmUserAndInitPassword( + makeContext('trackingId', 'requestId'), + token, + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + const user = await getUserFromExternalId(source, 'externalId_user1'); + // ユーザーが認証されていないことを確認 + expect(user?.email_verified).toBe(false); + // メールが送信されていないことを確認 + expect(sendgridService.sendMail).toBeCalledTimes(0); + }); + it('ADB2Cユーザーのパスワード更新に失敗した場合、リカバリ処理を行うが、リカバリ処理に失敗すると認証のままになる(メール認証API)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(UsersService); + const sendgridService = module.get(SendGridService); + const adminExternalId = 'ADMIN0001'; + const { account } = await makeTestAccount( + source, + {}, + { external_id: adminExternalId }, + ); + const { id: accountId } = account; + // ユーザー作成 + await makeTestUser(source, { + account_id: accountId, + external_id: 'externalId_user1', + role: USER_ROLES.NONE, + author_id: undefined, + auto_renew: true, + encryption: false, + encryption_password: undefined, + prompt: false, + email_verified: false, + }); + + const loggerSpy = jest + .spyOn(service['logger'], 'error') + .mockImplementation(); + + overrideAdB2cService(service, { + changePassword: async () => { + throw new Error('ADB2C Error'); + }, + getUser: async () => { + return { + id: adminExternalId, + displayName: 'admin', + }; + }, + }); + overrideUsersRepositoryService(service, { + updateUserUnverified: async () => { + throw new Error('DB Error'); + }, + }); + overrideSendgridService(service, { + sendMail: jest.fn(), + }); + + const token = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; + try { + await service.confirmUserAndInitPassword( + makeContext('trackingId', 'requestId'), + token, + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + const user = await getUserFromExternalId(source, 'externalId_user1'); + // ユーザーが認証されたままであることを確認 + expect(user?.email_verified).toBe(true); + // メールが送信されていないことを確認 + expect(sendgridService.sendMail).toBeCalledTimes(0); + // loggerSpyがスパイしているlogger.logメソッドが出力したログを確認(目視確認用) + const logs = loggerSpy.mock.calls.map((call) => call[0]); + // 手動復旧が必要なエラーログが出力されていること + expect(logs.some((x) => x.startsWith(MANUAL_RECOVERY_REQUIRED))).toBe(true); }); it('DBネットワークエラーとなる場合、エラーとなる。(メール認証API)', async () => { - const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); - usersRepositoryMockValue.findUserById = { - id: 1, - external_id: 'TEST9999', - account_id: 1, - role: 'None', - accepted_eula_version: 'string', - accepted_privacy_notice_version: 'string', - accepted_dpa_version: 'string', - email_verified: false, - created_by: 'string;', - created_at: new Date(), - updated_by: 'string;', - updated_at: new Date(), - auto_renew: true, - notification: true, - encryption: false, - prompt: false, - account: null, - author_id: null, - deleted_at: null, - encryption_password: null, - license: null, - userGroupMembers: null, - }; - const licensesRepositoryMockValue = null; - const adb2cParam = makeDefaultAdB2cMockValue(); - const sendGridMockValue = makeDefaultSendGridlValue(); - usersRepositoryMockValue.updateUserVerified = new Error('DB error'); - const configMockValue = makeDefaultConfigValue(); - const sortCriteriaRepositoryMockValue = - makeDefaultSortCriteriaRepositoryMockValue(); - const service = await makeUsersServiceMock( - usersRepositoryMockValue, - licensesRepositoryMockValue, - adb2cParam, - sendGridMockValue, - configMockValue, - sortCriteriaRepositoryMockValue, + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(UsersService); + const sendgridService = module.get(SendGridService); + const adB2cService = module.get(AdB2cService); + const adminExternalId = 'ADMIN0001'; + const { account } = await makeTestAccount( + source, + {}, + { external_id: adminExternalId }, ); + const { id: accountId } = account; + // ユーザー作成 + await makeTestUser(source, { + account_id: accountId, + external_id: 'externalId_user1', + role: USER_ROLES.NONE, + author_id: undefined, + auto_renew: true, + encryption: false, + encryption_password: undefined, + prompt: false, + email_verified: false, + }); + + overrideAdB2cService(service, { + changePassword: jest.fn(), + getUser: async () => { + return { + id: adminExternalId, + displayName: 'admin', + }; + }, + }); + overrideSendgridService(service, { + sendMail: jest.fn(), + }); + // DBエラーを発生させる + overrideUsersRepositoryService(service, { + updateUserVerified: async () => { + throw new Error('DB Error'); + }, + }); + const token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; - await expect( - service.confirmUserAndInitPassword( + try { + await service.confirmUserAndInitPassword( makeContext('trackingId', 'requestId'), token, - ), - ).rejects.toEqual( - new HttpException( - makeErrorResponse('E009999'), - HttpStatus.INTERNAL_SERVER_ERROR, - ), + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + const user = await getUserFromExternalId(source, 'externalId_user1'); + // ユーザーが認証されていないことを確認 + expect(user?.email_verified).toBe(false); + // メールが送信されていないことを確認 + expect(sendgridService.sendMail).toBeCalledTimes(0); + // パスワードが変更されていないことを確認 + expect(adB2cService.changePassword).toBeCalledTimes(0); + }); + it('メール送信に失敗した場合、リカバリ処理を行い、メールを未認証の状態にする。(メール認証API)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(UsersService); + const adb2cService = module.get(AdB2cService); + const adminExternalId = 'ADMIN0001'; + const { account } = await makeTestAccount( + source, + {}, + { external_id: adminExternalId }, ); + const { id: accountId } = account; + // ユーザー作成 + await makeTestUser(source, { + account_id: accountId, + external_id: 'externalId_user1', + role: USER_ROLES.NONE, + author_id: undefined, + auto_renew: true, + encryption: false, + encryption_password: undefined, + prompt: false, + email_verified: false, + }); + + overrideAdB2cService(service, { + changePassword: jest.fn(), + getUser: async () => { + return { + id: adminExternalId, + displayName: 'admin', + }; + }, + }); + overrideSendgridService(service, { + sendMail: async () => { + throw new Error('SendGrid Error'); + }, + }); + + const token = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; + try { + await service.confirmUserAndInitPassword( + makeContext('trackingId', 'requestId'), + token, + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + const user = await getUserFromExternalId(source, 'externalId_user1'); + // ユーザーが認証されていないことを確認 + expect(user?.email_verified).toBe(false); + // ADB2Cのパスワードが変更されていることを確認(パスワードは変更されても、ユーザーにメールが届いていないので問題ない) + expect(adb2cService.changePassword).toBeCalledTimes(1); + }); + it('メール送信に失敗した場合、リカバリ処理を行うが、リカバリ処理に失敗するとADB2Cのパスワードが変更され、DB上も認証された状態になる(メール認証API)', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(UsersService); + const adB2cService = module.get(AdB2cService); + const adminExternalId = 'ADMIN0001'; + const { account } = await makeTestAccount( + source, + {}, + { external_id: adminExternalId }, + ); + const { id: accountId } = account; + // ユーザー作成 + await makeTestUser(source, { + account_id: accountId, + external_id: 'externalId_user1', + role: USER_ROLES.NONE, + author_id: undefined, + auto_renew: true, + encryption: false, + encryption_password: undefined, + prompt: false, + email_verified: false, + }); + const loggerSpy = jest + .spyOn(service['logger'], 'error') + .mockImplementation(); + + overrideAdB2cService(service, { + changePassword: jest.fn(), + getUser: async () => { + return { + id: adminExternalId, + displayName: 'admin', + }; + }, + }); + overrideUsersRepositoryService(service, { + updateUserUnverified: async () => { + throw new Error('DB Error'); + }, + }); + overrideSendgridService(service, { + sendMail: async () => { + throw new Error('SendGrid Error'); + }, + }); + + const token = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; + try { + await service.confirmUserAndInitPassword( + makeContext('trackingId', 'requestId'), + token, + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toEqual(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + const user = await getUserFromExternalId(source, 'externalId_user1'); + // ユーザーが認証されたままであることを確認 + expect(user?.email_verified).toBe(true); + // ADB2Cのパスワードが変更されていることを確認 + expect(adB2cService.changePassword).toBeCalledTimes(1); + // loggerSpyがスパイしているlogger.logメソッドが出力したログを確認(目視確認用) + const logs = loggerSpy.mock.calls.map((call) => call[0]); + // 手動復旧が必要なエラーログが出力されていること + expect(logs.some((x) => x.startsWith(MANUAL_RECOVERY_REQUIRED))).toBe(true); }); }); diff --git a/dictation_server/src/features/users/users.service.ts b/dictation_server/src/features/users/users.service.ts index 6896e98..699efe6 100644 --- a/dictation_server/src/features/users/users.service.ts +++ b/dictation_server/src/features/users/users.service.ts @@ -516,46 +516,10 @@ export class UsersService { ); } - // ランダムなパスワードを生成する - const ramdomPassword = makePassword(); const { accountId, userId, email } = decodedToken; - try { - // ユーザー情報からAzure AD B2CのIDを特定する - const user = await this.usersRepository.findUserById(context, userId); - const extarnalId = user.external_id; - // パスワードを変更する - await this.adB2cService.changePassword( - context, - extarnalId, - ramdomPassword, - ); // ユーザを認証済みにする await this.usersRepository.updateUserVerified(context, userId); - - // メール送信処理 - try { - const { external_id: primaryAdminUserExternalId } = - await this.getPrimaryAdminUser(context, accountId); - - const adb2cUser = await this.adB2cService.getUser( - context, - primaryAdminUserExternalId, - ); - - const { displayName: primaryAdminName } = - getUserNameAndMailAddress(adb2cUser); - - await this.sendgridService.sendMailWithU113( - context, - email, - primaryAdminName, - ramdomPassword, - ); - } catch (e) { - this.logger.error(`[${context.getTrackingId()}] error=${e}`); - // メール送信に関する例外はログだけ出して握りつぶす - } } catch (e) { this.logger.error(`[${context.getTrackingId()}] error=${e}`); if (e instanceof Error) { @@ -572,6 +536,62 @@ export class UsersService { ); } } + } + + // ランダムなパスワードを生成する + const ramdomPassword = makePassword(); + try { + // ユーザー情報からAzure AD B2CのIDを特定する + const user = await this.usersRepository.findUserById(context, userId); + const extarnalId = user.external_id; + // パスワードを変更する + await this.adB2cService.changePassword( + context, + extarnalId, + ramdomPassword, + ); + } catch (e) { + this.logger.error(`[${context.getTrackingId()}] error=${e}`); + this.logger.error( + `[${context.getTrackingId()}] change password failed. userId=${userId}`, + ); + // リカバリー処理 + // ユーザを未認証に戻す + await this.updateUserUnverified(context, userId); + + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + // メール送信処理 + try { + const { external_id: primaryAdminUserExternalId } = + await this.getPrimaryAdminUser(context, accountId); + + const adb2cUser = await this.adB2cService.getUser( + context, + primaryAdminUserExternalId, + ); + + const { displayName: primaryAdminName } = + getUserNameAndMailAddress(adb2cUser); + + await this.sendgridService.sendMailWithU113( + context, + email, + primaryAdminName, + ramdomPassword, + ); + } catch (e) { + // リカバリー処理 + // ユーザーを未認証に戻す + await this.updateUserUnverified(context, userId); + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); } finally { this.logger.log( `[OUT] [${context.getTrackingId()}] ${ @@ -1791,4 +1811,36 @@ export class UsersService { return primaryAdmin; } + + /** + * ユーザーを未認証にする + * @param context + * @param userId + * @returns void + */ + private async updateUserUnverified( + context: Context, + userId: number, + ): Promise { + this.logger.log( + `[IN] [${context.getTrackingId()}] ${ + this.updateUserUnverified.name + } | params: { userId: ${userId} };`, + ); + try { + await this.usersRepository.updateUserUnverified(context, userId); + this.logger.log( + `[${context.getTrackingId()}] update user unverified: ${userId}`, + ); + } catch (error) { + this.logger.error(`[${context.getTrackingId()}] error=${error}`); + this.logger.error( + `${MANUAL_RECOVERY_REQUIRED} [${context.getTrackingId()}] Failed to update user unverified: ${userId}`, + ); + } finally { + this.logger.log( + `[OUT] [${context.getTrackingId()}] ${this.updateUserUnverified.name}`, + ); + } + } } diff --git a/dictation_server/src/repositories/users/users.repository.service.ts b/dictation_server/src/repositories/users/users.repository.service.ts index 01b0ff9..32de564 100644 --- a/dictation_server/src/repositories/users/users.repository.service.ts +++ b/dictation_server/src/repositories/users/users.repository.service.ts @@ -399,7 +399,7 @@ export class UsersRepositoryService { } /** - * 管理ユーザーがメール認証済みなら認証情報を更新する + * ユーザーがメール認証済みなら認証情報を更新する * @param user * @returns update */ @@ -437,6 +437,40 @@ export class UsersRepositoryService { }); } + /** + * ユーザーをメール未認証にする + * @param user + * @param context + * @param id + * @returns void + */ + async updateUserUnverified(context: Context, id: number): Promise { + await this.dataSource.transaction(async (entityManager) => { + const userRepo = entityManager.getRepository(User); + const targetUser = await userRepo.findOne({ + where: { + id: id, + }, + comment: `${context.getTrackingId()}_${new Date().toUTCString()}`, + }); + + // 運用上ユーザがいないことはあり得ないが、プログラム上発生しうるのでエラーとして処理 + if (!targetUser) { + throw new UserNotFoundError(`User not Found.`); + } + + targetUser.email_verified = false; + + await updateEntity( + userRepo, + { id: targetUser.id }, + targetUser, + this.isCommentOut, + context, + ); + }); + } + /** * Emailを認証済みにして、トライアルライセンスを作成する * @param id