diff --git a/dictation_server/src/common/test/overrides.ts b/dictation_server/src/common/test/overrides.ts index 81f8ea4..ec2b531 100644 --- a/dictation_server/src/common/test/overrides.ts +++ b/dictation_server/src/common/test/overrides.ts @@ -7,6 +7,8 @@ import { SendGridService } from '../../gateways/sendgrid/sendgrid.service'; import { User, newUser } from '../../repositories/users/entity/user.entity'; import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service'; +import { AccountsRepositoryService } from '../../repositories/accounts/accounts.repository.service'; +import { Account } from '../../repositories/accounts/entity/account.entity'; // ### ユニットテスト用コード以外では絶対に使用してはいけないダーティな手段を使用しているが、他の箇所では使用しないこと ### @@ -155,6 +157,11 @@ export const overrideBlobstorageService = ( accountId: number, country: string, ) => Promise; + deleteContainer?: ( + context: Context, + accountId: number, + country: string, + ) => Promise; }, ): void => { // テストコードでのみ許される強引な方法でprivateメンバ変数の参照を取得 @@ -165,4 +172,47 @@ export const overrideBlobstorageService = ( writable: true, }); } + if (overrides.deleteContainer) { + Object.defineProperty(obj, obj.deleteContainer.name, { + value: overrides.deleteContainer, + writable: true, + }); + } +}; + +/** + * accountsRepositoryのモックを作成して、TServiceが依存するサービス(AccountsRepositoryService)の参照を上書きする + * ※ serviceに指定するオブジェクトは`accountsRepository: AccountsRepositoryService`メンバ変数を持つ必要がある + * @param service 上書きしたいTService + * @param overrides accountsRepositoryの各種メソッドのモックが返す値(省略した場合は本物のメソッドが呼ばれる) + */ +export const overrideAccountsRepositoryService = ( + service: TService, + overrides: { + createAccount?: ( + companyName: string, + country: string, + dealerAccountId: number | undefined, + tier: number, + adminExternalUserId: string, + adminUserRole: string, + adminUserAcceptedTermsVersion: string, + ) => Promise<{ newAccount: Account; adminUser: User }>; + deleteAccount?: (accountId: number, userId: number) => Promise; + }, +): void => { + // テストコードでのみ許される強引な方法でprivateメンバ変数の参照を取得 + const obj = (service as any).accountRepository as AccountsRepositoryService; + if (overrides.deleteAccount) { + Object.defineProperty(obj, obj.deleteAccount.name, { + value: overrides.deleteAccount, + writable: true, + }); + } + if (overrides.createAccount) { + Object.defineProperty(obj, obj.createAccount.name, { + value: overrides.createAccount, + writable: true, + }); + } }; diff --git a/dictation_server/src/features/accounts/accounts.service.spec.ts b/dictation_server/src/features/accounts/accounts.service.spec.ts index 88bb200..e5c0fd2 100644 --- a/dictation_server/src/features/accounts/accounts.service.spec.ts +++ b/dictation_server/src/features/accounts/accounts.service.spec.ts @@ -21,19 +21,23 @@ import { getUserFromExternalID, getAccounts, getUsers, + getSortCriteria, createAccountAndAdminUser, } from './test/utility'; import { DataSource } from 'typeorm'; import { makeTestingModule } from '../../common/test/modules'; import { AccountsService } from './accounts.service'; -import { makeContext } from '../../common/log'; +import { Context, makeContext } from '../../common/log'; import { TIERS } from '../../constants'; import { License } from '../../repositories/licenses/entity/license.entity'; import { + overrideAccountsRepositoryService, overrideAdB2cService, overrideBlobstorageService, overrideSendgridService, } from '../../common/test/overrides'; +import { AdB2cService } from '../../gateways/adb2c/adb2c.service'; +import { BlobstorageService } from '../../gateways/blobstorage/blobstorage.service'; describe('createAccount', () => { let source: DataSource = null; @@ -247,10 +251,10 @@ describe('createAccount', () => { expect(users.length).toBe(0); }); - it('アカウントを作成がBlobStorageへの通信失敗によって失敗すると500エラーが発生する', async () => { + it('アカウントを作成がDBへの通信失敗によって500エラーが発生した場合、リカバリ処理としてADB2Cユーザーを削除され、500エラーが返却される', async () => { const module = await makeTestingModule(source); const service = module.get(AccountsService); - + const b2cService = module.get(AdB2cService); const externalId = 'test_external_id'; const companyName = 'test_company_name'; const country = 'US'; @@ -265,6 +269,135 @@ describe('createAccount', () => { createUser: async () => { return { sub: externalId }; }, + deleteUser: jest.fn(), + }); + overrideSendgridService(service, {}); + + overrideAccountsRepositoryService(service, { + createAccount: async () => { + throw new Error(); + }, + }); + + try { + await service.createAccount( + makeContext('uuid'), + companyName, + country, + dealerAccountId, + email, + password, + username, + role, + acceptedTermsVersion, + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + // DB内が想定通りになっているか確認 + // DBのデータ作成で失敗しているので、DB内は空 + const accounts = await getAccounts(source); + expect(accounts.length).toBe(0); + const users = await getUsers(source); + expect(users.length).toBe(0); + const sortCriteria = await getSortCriteria(source); + expect(sortCriteria.length).toBe(0); + // ADB2Cユーザー削除メソッドが呼ばれているか確認 + expect(b2cService.deleteUser).toBeCalledWith( + externalId, + makeContext('uuid'), + ); + }); + it('アカウントを作成がDBへの通信失敗によって500エラーが発生した場合、リカバリ処理が実行されるが、ADB2Cユーザー削除で失敗した場合、500エラーが返却される', async () => { + const module = await makeTestingModule(source); + const service = module.get(AccountsService); + const b2cService = module.get(AdB2cService); + const externalId = 'test_external_id'; + const companyName = 'test_company_name'; + const country = 'US'; + const dealerAccountId = 1; + const email = 'dummy@dummy.dummy'; + const password = 'dummy_password'; + const username = 'dummy_username'; + const role = 'none'; + const acceptedTermsVersion = '1.0.0'; + + overrideAdB2cService(service, { + createUser: async () => { + return { sub: externalId }; + }, + deleteUser: jest.fn().mockRejectedValue(new Error()), + }); + overrideSendgridService(service, {}); + + overrideAccountsRepositoryService(service, { + createAccount: async () => { + throw new Error(); + }, + }); + + try { + await service.createAccount( + makeContext('uuid'), + companyName, + country, + dealerAccountId, + email, + password, + username, + role, + acceptedTermsVersion, + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + // DB内が想定通りになっているか確認 + // DBのデータ作成で失敗しているので、DB内は空 + const accounts = await getAccounts(source); + expect(accounts.length).toBe(0); + const users = await getUsers(source); + expect(users.length).toBe(0); + const sortCriteria = await getSortCriteria(source); + expect(sortCriteria.length).toBe(0); + // ADB2Cユーザー削除メソッドが呼ばれているか確認 + expect(b2cService.deleteUser).toBeCalledWith( + externalId, + makeContext('uuid'), + ); + }); + + it('アカウントを作成がBlobStorageへの通信失敗によって500エラーが発生した場合、リカバリ処理としてADB2C,DB上のデータが削除され、500エラーが返却される', async () => { + const module = await makeTestingModule(source); + const service = module.get(AccountsService); + const b2cService = module.get(AdB2cService); + b2cService.deleteUser = jest.fn(); // リカバリ処理の確認のため、deleteUserをモック化 + const externalId = 'test_external_id'; + const companyName = 'test_company_name'; + const country = 'US'; + const dealerAccountId = 1; + const email = 'dummy@dummy.dummy'; + const password = 'dummy_password'; + const username = 'dummy_username'; + const role = 'none'; + const acceptedTermsVersion = '1.0.0'; + + overrideAdB2cService(service, { + createUser: async () => { + return { sub: externalId }; + }, + deleteUser: async () => { + return; + }, }); overrideSendgridService(service, {}); overrideBlobstorageService(service, { @@ -290,9 +423,270 @@ describe('createAccount', () => { expect(e.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); } else { - expect(true).toBe(false); // ここには来てはいけない + fail(); } } + // DB内が想定通りになっているか確認 + // リカバリ処理が走っているため、アカウント・ユーザーは削除されている + const accounts = await getAccounts(source); + expect(accounts.length).toBe(0); + const users = await getUsers(source); + expect(users.length).toBe(0); + const sortCriteria = await getSortCriteria(source); + expect(sortCriteria.length).toBe(0); + // ADB2Cユーザー削除メソッドが呼ばれているか確認 + expect(b2cService.deleteUser).toBeCalledWith( + externalId, + makeContext('uuid'), + ); + }); + + it('アカウントを作成がBlobStorageへの通信失敗によって500エラーが発生した場合、リカバリ処理が実行されるが、そのリカバリ処理に失敗した場合、500エラーが返却される', async () => { + const module = await makeTestingModule(source); + const service = module.get(AccountsService); + const b2cService = module.get(AdB2cService); + const externalId = 'test_external_id'; + const companyName = 'test_company_name'; + const country = 'US'; + const dealerAccountId = 1; + const email = 'dummy@dummy.dummy'; + const password = 'dummy_password'; + const username = 'dummy_username'; + const role = 'none'; + const acceptedTermsVersion = '1.0.0'; + + overrideAdB2cService(service, { + createUser: async () => { + return { sub: externalId }; + }, + deleteUser: jest.fn().mockRejectedValue(new Error()), + }); + overrideSendgridService(service, {}); + overrideBlobstorageService(service, { + createContainer: async () => { + throw new Error(); + }, + }); + overrideAccountsRepositoryService(service, { + deleteAccount: async () => { + throw new Error(); + }, + }); + + try { + await service.createAccount( + makeContext('uuid'), + companyName, + country, + dealerAccountId, + email, + password, + username, + role, + acceptedTermsVersion, + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + // DB内が想定通りになっているか確認 + // DB上のデータのリカバリ処理に失敗したため、DB上のデータは削除されない + const accounts = await getAccounts(source); + expect(accounts.length).toBe(1); + const users = await getUsers(source); + expect(users.length).toBe(1); + const sortCriteria = await getSortCriteria(source); + expect(sortCriteria.length).toBe(1); + // ADB2Cユーザー削除メソッドが呼ばれているか確認 + expect(b2cService.deleteUser).toBeCalledWith( + externalId, + makeContext('uuid'), + ); + }); + + it('アカウントを作成がSendGridへの通信失敗によって500エラーが発生した場合、リカバリ処理としてADB2C,DB上のデータとBlobストレージのコンテナが削除され、500エラーが返却される', async () => { + const module = await makeTestingModule(source); + const service = module.get(AccountsService); + const b2cService = module.get(AdB2cService); + const blobstorageService = + module.get(BlobstorageService); + const externalId = 'test_external_id'; + const companyName = 'test_company_name'; + const country = 'US'; + const dealerAccountId = 1; + const email = 'dummy@dummy.dummy'; + const password = 'dummy_password'; + const username = 'dummy_username'; + const role = 'none'; + const acceptedTermsVersion = '1.0.0'; + + overrideAdB2cService(service, { + createUser: async ( + _context: Context, + _email: string, + _password: string, + _username: string, + ) => { + // ユーザー作成時に指定したパラメータが正しく渡されていることを確認 + expect(email).toEqual(_email); + expect(username).toEqual(_username); + + return { sub: externalId }; + }, + deleteUser: jest.fn(), + }); + overrideSendgridService(service, { + sendMail: async () => { + throw new Error(); + }, + createMailContentFromEmailConfirm: async () => { + return { + html: 'dummy_html', + subject: 'dummy_subject', + text: 'dummy_text', + }; + }, + }); + overrideBlobstorageService(service, { + createContainer: async () => { + return; + }, + deleteContainer: jest.fn(), + }); + overrideAccountsRepositoryService(service, {}); + + try { + await service.createAccount( + makeContext('uuid'), + companyName, + country, + dealerAccountId, + email, + password, + username, + role, + acceptedTermsVersion, + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + // DB内が想定通りになっているか確認 + // リカバリ処理によってADB2C,DB上のデータとBlobストレージのコンテナが削除される + const accounts = await getAccounts(source); + expect(accounts.length).toBe(0); + const users = await getUsers(source); + expect(users.length).toBe(0); + const sortCriteria = await getSortCriteria(source); + expect(sortCriteria.length).toBe(0); + // ADB2Cユーザー削除メソッドが呼ばれているか確認 + expect(b2cService.deleteUser).toBeCalledWith( + externalId, + makeContext('uuid'), + ); + // Blobストレージのコンテナ削除メソッドが呼ばれているか確認 + expect(blobstorageService.deleteContainer).toBeCalledWith( + makeContext('uuid'), + 1, //新規作成したアカウントのID + country, + ); + }); + + it('アカウントを作成がSendGridへの通信失敗によって500エラーが発生した場合、リカバリ処理が実行されるが、そのリカバリ処理に失敗した場合、500エラーが返却される', async () => { + const module = await makeTestingModule(source); + const service = module.get(AccountsService); + const b2cService = module.get(AdB2cService); + const blobstorageService = + module.get(BlobstorageService); + const externalId = 'test_external_id'; + const companyName = 'test_company_name'; + const country = 'US'; + const dealerAccountId = 1; + const email = 'dummy@dummy.dummy'; + const password = 'dummy_password'; + const username = 'dummy_username'; + const role = 'none'; + const acceptedTermsVersion = '1.0.0'; + + overrideAdB2cService(service, { + createUser: async () => { + return { sub: externalId }; + }, + deleteUser: jest.fn().mockRejectedValue(new Error()), + }); + overrideSendgridService(service, { + sendMail: async () => { + throw new Error(); + }, + createMailContentFromEmailConfirm: async () => { + return { + html: 'dummy_html', + subject: 'dummy_subject', + text: 'dummy_text', + }; + }, + }); + overrideBlobstorageService(service, { + createContainer: async () => { + return; + }, + deleteContainer: jest + .fn() + .mockRejectedValue(new Error('BlobStorage Error')), + }); + overrideAccountsRepositoryService(service, { + deleteAccount: async () => { + throw new Error(); + }, + }); + + try { + await service.createAccount( + makeContext('uuid'), + companyName, + country, + dealerAccountId, + email, + password, + username, + role, + acceptedTermsVersion, + ); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + // DB内が想定通りになっているか確認 + // リカバリ処理によってADB2C,DB上のデータとBlobストレージのコンテナが削除されない + const accounts = await getAccounts(source); + expect(accounts.length).toBe(1); + const users = await getUsers(source); + expect(users.length).toBe(1); + const sortCriteria = await getSortCriteria(source); + expect(sortCriteria.length).toBe(1); + // ADB2Cユーザー削除メソッドが呼ばれているか確認 + expect(b2cService.deleteUser).toBeCalledWith( + externalId, + makeContext('uuid'), + ); + // Blobストレージのコンテナ削除メソッドが呼ばれているか確認 + expect(blobstorageService.deleteContainer).toBeCalledWith( + makeContext('uuid'), + 1, //新規作成したアカウントのID + country, + ); }); }); diff --git a/dictation_server/src/features/accounts/accounts.service.ts b/dictation_server/src/features/accounts/accounts.service.ts index 6e76dd5..0246e02 100644 --- a/dictation_server/src/features/accounts/accounts.service.ts +++ b/dictation_server/src/features/accounts/accounts.service.ts @@ -190,9 +190,10 @@ export class AccountsService { } catch (e) { this.logger.error(`error=${e}`); this.logger.error('create account failed'); - this.logger.error( - `[NOT IMPLEMENT] [RECOVER] delete account: ${externalUser.sub}`, - ); + //リカバリ処理 + // idpのユーザーを削除 + await this.deleteAdB2cUser(externalUser.sub, context); + throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, @@ -209,6 +210,13 @@ export class AccountsService { } catch (e) { this.logger.error(`error=${e}`); this.logger.error('create container failed'); + //リカバリ処理 + // idpのユーザーを削除 + await this.deleteAdB2cUser(externalUser.sub, context); + + // DBのアカウントを削除 + await this.deleteAccount(account.id, user.id, context); + throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, @@ -238,16 +246,18 @@ export class AccountsService { html, ); } catch (e) { - console.log(e); this.logger.error(`error=${e}`); - this.logger.error('create user failed'); - this.logger.error( - `[NOT IMPLEMENT] [RECOVER] delete account: ${account.id}`, - ); - this.logger.error( - `[NOT IMPLEMENT] [RECOVER] delete externalUser: ${externalUser.sub}`, - ); - this.logger.error(`[NOT IMPLEMENT] [RECOVER] delete user: ${user.id}`); + this.logger.error('send E-mail failed'); + //リカバリ処理 + // idpのユーザーを削除 + await this.deleteAdB2cUser(externalUser.sub, context); + + // DBのアカウントを削除 + await this.deleteAccount(account.id, user.id, context); + + // Blobコンテナを削除 + await this.deleteBlobContainer(account.id, country, context); + throw new HttpException( makeErrorResponse('E009999'), HttpStatus.INTERNAL_SERVER_ERROR, @@ -268,6 +278,67 @@ export class AccountsService { } } + // AdB2cのユーザーを削除 + // TODO「タスク 2452: リトライ処理を入れる箇所を検討し、実装する」の候補 + private async deleteAdB2cUser( + externalUserId: string, + context: Context, + ): Promise { + try { + await this.adB2cService.deleteUser(externalUserId, context); + this.logger.log( + `[${context.trackingId}] delete externalUser: ${externalUserId}`, + ); + } catch (error) { + this.logger.error(`error=${error}`); + this.logger.error( + `[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete externalUser: ${externalUserId}`, + ); + } + } + + // DBのアカウントを削除 + private async deleteAccount( + accountId: number, + userId: number, + context: Context, + ): Promise { + try { + await this.accountRepository.deleteAccount(accountId, userId); + this.logger.log( + `[${context.trackingId}] delete account: ${accountId}, user: ${userId}`, + ); + } catch (error) { + this.logger.error(`error=${error}`); + this.logger.error( + `[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete account: ${accountId}, user: ${userId}`, + ); + } + } + + // Blobコンテナを削除 + // TODO「タスク 2452: リトライ処理を入れる箇所を検討し、実装する」の候補 + private async deleteBlobContainer( + accountId: number, + country: string, + context: Context, + ): Promise { + try { + await this.blobStorageService.deleteContainer( + context, + accountId, + country, + ); + this.logger.log( + `[${context.trackingId}] delete container: ${accountId}, country: ${country}`, + ); + } catch (error) { + this.logger.error( + `[MANUAL_RECOVERY_REQUIRED] [${context.trackingId}] Failed to delete container: ${accountId}, country: ${country}`, + ); + } + } + /** * アクセストークンからアカウント情報を取得する * @param token diff --git a/dictation_server/src/features/accounts/test/utility.ts b/dictation_server/src/features/accounts/test/utility.ts index 6db288d..42d5027 100644 --- a/dictation_server/src/features/accounts/test/utility.ts +++ b/dictation_server/src/features/accounts/test/utility.ts @@ -5,6 +5,7 @@ import { License, LicenseOrder, } from '../../../repositories/licenses/entity/license.entity'; +import { SortCriteria } from '../../../repositories/sort_criteria/entity/sort_criteria.entity'; // TODO: [PBI 2379] 他のUtilityからコピペしてきたもの。後日整理される前提。 export const createAccountAndAdminUser = async ( @@ -162,6 +163,15 @@ export const getUsers = async (dataSource: DataSource): Promise => { return await dataSource.getRepository(User).find(); }; +/** + * テスト ユーティリティ: すべてのソート条件を取得する + * @param dataSource データソース + * @returns 該当ソート条件一覧 + */ +export const getSortCriteria = async (dataSource: DataSource) => { + return await dataSource.getRepository(SortCriteria).find(); +}; + export const createLicense = async ( datasource: DataSource, accountId: number, diff --git a/dictation_server/src/repositories/accounts/accounts.repository.service.ts b/dictation_server/src/repositories/accounts/accounts.repository.service.ts index 95c9f57..e58669a 100644 --- a/dictation_server/src/repositories/accounts/accounts.repository.service.ts +++ b/dictation_server/src/repositories/accounts/accounts.repository.service.ts @@ -160,14 +160,14 @@ export class AccountsRepositoryService { const accountsRepo = entityManager.getRepository(Account); const usersRepo = entityManager.getRepository(User); const sortCriteriaRepo = entityManager.getRepository(SortCriteria); - // アカウントを削除 - await accountsRepo.delete({ id: accountId }); - // プライマリ管理者を削除 - await usersRepo.delete({ id: userId }); // ソート条件を削除 await sortCriteriaRepo.delete({ user_id: userId, }); + // プライマリ管理者を削除 + await usersRepo.delete({ id: userId }); + // アカウントを削除 + await accountsRepo.delete({ id: accountId }); }); }