From bd4aaa8ae1c69eaef0f6bbe9cb409d0f22dc86d6 Mon Sep 17 00:00:00 2001 From: "makabe.t" Date: Mon, 10 Apr 2023 04:44:16 +0000 Subject: [PATCH] =?UTF-8?q?Merged=20PR=2063:=20API=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=EF=BC=88=E3=83=A1=E3=83=BC=E3=83=AB=E8=AA=8D=E8=A8=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 概要 [Task1497: API実装(メール認証)](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/1497) - メール認証APIとテストを実装しました。 - IDトークンの型を現状と合わせて修正しました - `family_name`と`given_name`を削除しました。 - auth.serviceのテストも併せて修正しました。 - テストケースのIDトークンを環境変数の鍵で生成するように修正しました。 ## レビューポイント - DBのユーザを検証済みにする処理について、トランザクション内で取得と更新をしていますがトランザクションの使い方として問題ないでしょうか? - 本APIで使用するカスタムエラーを`common/error/types`に暫定的においていますがどこに配置するのが適切でしょうか? - `common/error/types`に配置する、もしくは`common`配下にカスタムエラー用のフォルダを作成してその下に配置するのが良いかと考えています。 - テストのモックでエラーを発生させる際に、テストケース内でエラーを設定していますがモックファイル内でエラー用のモックを設定するべきでしょうか? ## UIの変更 - なし ## 動作確認状況 - ローカルで確認 - テストが通ることを確認 --- dictation_server/package.json | 1 + dictation_server/src/common/error/code.ts | 1 + dictation_server/src/common/error/message.ts | 1 + .../src/common/token/typeguard.ts | 2 - dictation_server/src/common/token/types.ts | 2 - .../accounts/accounts.controller.spec.ts | 8 +- .../accounts/accounts.service.spec.ts | 9 ++- .../src/features/auth/auth.service.spec.ts | 27 ++++--- .../features/auth/test/auth.service.mock.ts | 22 ++++-- .../features/users/test/users.service.mock.ts | 78 +++++++++++++++++++ .../features/users/users.controller.spec.ts | 8 +- .../src/features/users/users.controller.ts | 3 +- .../src/features/users/users.module.ts | 3 +- .../src/features/users/users.service.spec.ts | 78 ++++++++++++++++--- .../src/features/users/users.service.ts | 67 +++++++++++++++- .../users/users.repository.service.ts | 60 +++++++++++++- 16 files changed, 327 insertions(+), 43 deletions(-) create mode 100644 dictation_server/src/features/users/test/users.service.mock.ts diff --git a/dictation_server/package.json b/dictation_server/package.json index 48b4c6f..0bab16e 100644 --- a/dictation_server/package.json +++ b/dictation_server/package.json @@ -14,6 +14,7 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", + "tc": "tsc --noEmit", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", diff --git a/dictation_server/src/common/error/code.ts b/dictation_server/src/common/error/code.ts index 8f152c6..87fe6b2 100644 --- a/dictation_server/src/common/error/code.ts +++ b/dictation_server/src/common/error/code.ts @@ -21,5 +21,6 @@ export const ErrorCodes = [ 'E000105', // トークン発行元エラー 'E000106', // トークンアルゴリズムエラー 'E010201', // 未認証ユーザエラー + 'E010202', // 認証済ユーザエラー 'E010301', // メールアドレス登録済みエラー ] as const; diff --git a/dictation_server/src/common/error/message.ts b/dictation_server/src/common/error/message.ts index 66be471..0a10188 100644 --- a/dictation_server/src/common/error/message.ts +++ b/dictation_server/src/common/error/message.ts @@ -10,5 +10,6 @@ export const errors: Errors = { E000105: 'Token invalid issuer Error.', E000106: 'Token invalid algorithm Error.', E010201: 'Email not verified user Error.', + E010202: 'Email already verified user Error.', E010301: 'This email user already created Error', }; diff --git a/dictation_server/src/common/token/typeguard.ts b/dictation_server/src/common/token/typeguard.ts index ae2076a..d32ecad 100644 --- a/dictation_server/src/common/token/typeguard.ts +++ b/dictation_server/src/common/token/typeguard.ts @@ -10,8 +10,6 @@ export const isIDToken = ( if (token.emails === undefined) return false; if (token.exp === undefined) return false; if (token.iat === undefined) return false; - if (token.family_name === undefined) return false; - if (token.given_name === undefined) return false; if (token.nonce === undefined) return false; return true; diff --git a/dictation_server/src/common/token/types.ts b/dictation_server/src/common/token/types.ts index 3e7d69b..f815686 100644 --- a/dictation_server/src/common/token/types.ts +++ b/dictation_server/src/common/token/types.ts @@ -10,8 +10,6 @@ export type AccessToken = { export type IDToken = { emails: string[]; - family_name: string; - given_name: string; nonce: string; sub: string; // AzureAD B2C ID exp: number; // 有効期限 diff --git a/dictation_server/src/features/accounts/accounts.controller.spec.ts b/dictation_server/src/features/accounts/accounts.controller.spec.ts index 6308252..078cd5f 100644 --- a/dictation_server/src/features/accounts/accounts.controller.spec.ts +++ b/dictation_server/src/features/accounts/accounts.controller.spec.ts @@ -1,13 +1,19 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AccountsController } from './accounts.controller'; +import { AccountsService } from './accounts.service'; describe('AccountsController', () => { let controller: AccountsController; + const mockAccountService = {}; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [AccountsController], - }).compile(); + providers: [AccountsService], + }) + .overrideProvider(AccountsService) + .useValue(mockAccountService) + .compile(); controller = module.get(AccountsController); }); diff --git a/dictation_server/src/features/accounts/accounts.service.spec.ts b/dictation_server/src/features/accounts/accounts.service.spec.ts index 01304e2..628fd16 100644 --- a/dictation_server/src/features/accounts/accounts.service.spec.ts +++ b/dictation_server/src/features/accounts/accounts.service.spec.ts @@ -7,7 +7,14 @@ describe('AccountsService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [AccountsService], - }).compile(); + }) + .useMocker(() => { + return { + createAccount: undefined, + update: undefined, + }; + }) + .compile(); service = module.get(AccountsService); }); diff --git a/dictation_server/src/features/auth/auth.service.spec.ts b/dictation_server/src/features/auth/auth.service.spec.ts index 2a153db..bfe6431 100644 --- a/dictation_server/src/features/auth/auth.service.spec.ts +++ b/dictation_server/src/features/auth/auth.service.spec.ts @@ -1,7 +1,6 @@ import { HttpException, HttpStatus } from '@nestjs/common'; import { makeErrorResponse } from '../../common/error/makeErrorResponse'; import { - makeAdB2cServiceMock, makeAuthServiceMock, makeDefaultAdB2cMockValue, makeDefaultCryptoMockValue, @@ -13,7 +12,7 @@ describe('AuthService', () => { const cryptoParam = makeDefaultCryptoMockValue(); const service = await makeAuthServiceMock(adb2cParam, cryptoParam); const token = - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwiaXNzIjoiaXNzdXNlciJ9.dURTqFfUc94qYNFS65xMqMT3tJi-uH8weJ5GU74F-UvhLKJmqVoNIbXCm-ASg2mZ5SxeX683q2MMrlzxNcNel-QIFTj0zWem4eLKuarsYRMTU8x27jMss0b_JkFtiy5KE2052_fLeW2aZBb4AjieIW34-c0Ol1rrih-W9Pk6ZEOwf14Ju0IAgdUw66-HuLOUprZjavvFp2rwuAC7gqEujg3wtjO7VR82NNoVv2dxByTdrzG_eOk8QKTJYBoM50ztT6WFs-qWetZIoQDxMJRA9ObfEVyJ4Bq2b4qelfuhAqbQWRNfCvqhoRPMJI6Y7-R94FuO1zg4d4Gm3J0qHj7r8Q'; + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwidmVyIjoiMS4wIiwiaXNzIjoiaXNzdWVyIiwic3ViIjoic3ViIiwiYXVkIjoiYXVkIiwibm9uY2UiOiJkZWZhdWx0Tm9uY2UiLCJpYXQiOjEwMDAwMDAwMDAsImF1dGhfdGltZSI6MTAwMDAwMDAwMCwiZW1haWxzIjpbInh4eEB4eC5jb20iXSwidGZwIjoic2lnbmluX3VzZXJmbG93In0.RyieW-VHsHPQOjXbbhRc307AYJOc1sq2hrcu4SW1-K0pvLlkplepxvx02a3vCwQrnBYrIP5w6HExG-S_JgW5nYyWr6DeY11mA484n9KA8GeAcAXV37StH1gfWUJvfGb4C8BaMbMM9Ix4Z9NGwKA9vjNwevfmBZnz9lQUePgv6BJNmyvCt8ElJ01O-1WODbZuojJ4xXymA1OqluzfbphPOsqWTSNmTn0emkLjjnlMQf1iwM4C_kvvr8dUCFg0_UGDfQVJnzPEKB38UqnhLnC5WacrddDwQ0kBuGKZgZ_63Q_7fOvqAZivqLK7BPmbPxi6mx3R1S9Eq2ugzpY1LfJOjA'; expect(await service.getVerifiedIdToken(token)).toEqual(idTokenPayload); }); @@ -34,7 +33,7 @@ describe('AuthService', () => { const cryptoParam = makeDefaultCryptoMockValue(); const service = await makeAuthServiceMock(adb2cParam, cryptoParam); const token = - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtpZCJ9.eyJleHAiOjEwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwiaXNzIjoiaXNzdWVyIn0.pk9-POXmo3ie7wfqizRr2NSX7YGEJed3krdMImzKp5qeRfsEJwM6zTSzjL_gbLQyMEDg3IoyWzVVUha4sdPcummtt8gJ6N25s_H9taFY-xJjx_-wmttGQvXD44apyUF_c_Xg0l7MzNQEqpnzsLdDcBITSN-Rk-bT_n9U4dxhYaWukOPfAPf7DzSxT9TCpTaCTPIAKp2JXVU41J9uv5WklhNjMSv9L5jWT-IstEC71-DO6jD6yFpAOLIG6Aq-C7rQolt_Cny9qKugu6hd7_Aj-YrW9773khUOyFotJ7Qs9g-qSEMy7M9ljvai2eu5Otfja3hlS5wF1VH_ENsXye6C0A'; + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtpZCJ9.eyJleHAiOjEwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwidmVyIjoiMS4wIiwiaXNzIjoiaXNzdWVyIiwic3ViIjoic3ViIiwiYXVkIjoiYXVkIiwibm9uY2UiOiJkZWZhdWx0Tm9uY2UiLCJpYXQiOjEwMDAwMDAwMDAsImF1dGhfdGltZSI6MTAwMDAwMDAwMCwiZW1haWxzIjpbInh4eEB4eC5jb20iXSwidGZwIjoic2lnbmluX3VzZXJmbG93In0.r9x61Mf1S2qFgU_QDKB6tRFBmTQXyOEtpoacOlL_bQzFz1t3GsxMy6SJIvQQ-LtDgylQ1UCdMFiRuy4V8nyLuME0fR-9IkKsboGvwllHB_Isai3XFoja0jpDHMVby1m0B3Z9xOTb7YsaQGyEH-qs1TtnRm6Ny98h4Po80nK8HGefQZHBOlfQN_B1LiHwI3nLXV18NL-4olKXj2NloNRYtnWM0PaqDQcGvZFaSNvtrSYpo9ddD906QWDGVOQ7WvGSUgdNCoxX8Lb3r2-VSj6n84jpb-Y1Fz-GhLluNglAsBhasnJfUIvCIO3iG5pRyTYjHFAVHmzjr8xMOmhS3s41Jw'; await expect(service.getVerifiedIdToken(token)).rejects.toEqual( new HttpException(makeErrorResponse('E000102'), HttpStatus.UNAUTHORIZED), @@ -46,7 +45,7 @@ describe('AuthService', () => { const cryptoParam = makeDefaultCryptoMockValue(); const service = await makeAuthServiceMock(adb2cParam, cryptoParam); const token = - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6OTAwMDAwMDAwMCwiaXNzIjoiaXNzdWVyIn0.pSk3U8Wn_XTm6KxvdQpqQuGROjanLxLGsnRGg35jO4iPVysHKVWya7Leik3aTtK8lGCSoapodQoV4EXbP5CzBf5O83l9tKaF2pwEO_C9QePlLJIJMaCmqRFrr1ozcVFQNwYAr81KNmXBuEEG5duT36Fk2A9-PLDtwg816J4nMEnd1umgCSRTKOdxh265ybDxX1Pe8KDtzlCi8Wjj-lhFwxskpKD3xlFl2plhW9_P7eq-DJXtm1fvLk27zxwOq8uaaxu6sfXoeu1D4qi4aZ0MF-5eaOezc8KH-aYgU9o5yQCZH6tJJDrZvzi7GAFTd-c952V9jgPZpifEoDMRJXS5zA'; + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6OTAwMDAwMDAwMCwidmVyIjoiMS4wIiwiaXNzIjoiaXNzdWVyIiwic3ViIjoic3ViIiwiYXVkIjoiYXVkIiwibm9uY2UiOiJkZWZhdWx0Tm9uY2UiLCJpYXQiOjEwMDAwMDAwMDAsImF1dGhfdGltZSI6MTAwMDAwMDAwMCwiZW1haWxzIjpbInh4eEB4eC5jb20iXSwidGZwIjoic2lnbmluX3VzZXJmbG93In0.fX2Gbd7fDPNE3Lw-xbum_5CVqQYqEmMhv_v5u8A-U81pmPD2P5rsJEJx66ns1taFLVaE3j9_OzotxrqjqqQqbACkagGcN5wvA3_ZIxyqmhrKYFJc53ZcO7d0pFWiQlluNBI_pnFNDlSMB2Ut8Th5aiPy2uamBM9wC99bcjo7HkHvTKBf6ljU6rPKoD51qGDWqNxjoH-hdSJ29wprvyxyk_yX6dp-cxXUj5DIgXYQuIZF71rdiPtGlAiyTBns8rS2QlEEXapZVlvYrK4mkpUXVDA7ifD8q6gAC2BStqHeys7CGp2MbV4ZwKCVbAUbMs6Tboh8rADZvQhuTEq7qlhZ-w'; await expect(service.getVerifiedIdToken(token)).rejects.toEqual( new HttpException(makeErrorResponse('E000103'), HttpStatus.UNAUTHORIZED), @@ -58,7 +57,7 @@ describe('AuthService', () => { const cryptoParam = makeDefaultCryptoMockValue(); const service = await makeAuthServiceMock(adb2cParam, cryptoParam); const token = - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwiaXNzIjoiaXNzdWVyIn0.sign'; + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwidmVyIjoiMS4wIiwiaXNzIjoiaXNzdXNlciIsInN1YiI6InN1YiIsImF1ZCI6ImF1ZCIsIm5vbmNlIjoiZGVmYXVsdE5vbmNlIiwiaWF0IjoxMDAwMDAwMDAwLCJhdXRoX3RpbWUiOjEwMDAwMDAwMDAsImVtYWlscyI6WyJ4eHhAeHguY29tIl0sInRmcCI6InNpZ25pbl91c2VyZmxvdyJ9.sign'; await expect(service.getVerifiedIdToken(token)).rejects.toEqual( new HttpException(makeErrorResponse('E000104'), HttpStatus.UNAUTHORIZED), @@ -70,7 +69,7 @@ describe('AuthService', () => { const cryptoParam = makeDefaultCryptoMockValue(); const service = await makeAuthServiceMock(adb2cParam, cryptoParam); const token = - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwiaXNzIjoiaW52YWxpZCJ9.jmwBBc2r55YOr88W_4aRzFccmtmQfQRLob7YGM4bOPMGK-ExXJQG24CiMpODZEvVTtQKucEd2cBB6zzLYGCiKRHig8iM_EV57klMwKrczdL2ov8Hc3IyA407idDxDi0KyyveEctxEUdl3PgbY54CVc6URhUAzGdcQ2mKMRVe-Zxb7CrFYqwiHUCmCnSRFUYj_9kkI3epkdsXPsLyDApDEbLEeD1ztnoyYrbfmdce6flpf-h1r2VK4dMz8m4GMOLQHM9gWSzRUkUsN3hGOKTofUxCKC4CArfSP1vZ5k9FZrjqOZn1m6bxKalCox2n96GqLtuFV3-sOTnCqgHj0fQEJA'; + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwidmVyIjoiMS4wIiwiaXNzIjoiaW52bGlkX2lzc3VlciIsInN1YiI6InN1YiIsImF1ZCI6ImF1ZCIsIm5vbmNlIjoiZGVmYXVsdE5vbmNlIiwiaWF0IjoxMDAwMDAwMDAwLCJhdXRoX3RpbWUiOjEwMDAwMDAwMDAsImVtYWlscyI6WyJ4eHhAeHguY29tIl0sInRmcCI6InNpZ25pbl91c2VyZmxvdyJ9.0bp3e1mDG78PX3lo8zgOLXGenIqG_Vi6kw7CbwauAQM-cnUZ_aVCoJ_dAv_QmPElOQKcCkRrAvAZ91FwuHDlBGuuDqx8OwqN0VaD-4NPouoAswj-9HNvBm8gUn-pGaXkvWt_72UdCJavZJjDj_RHur8y8kFt5Qeab3mUP2x-uNcV2Q2x3M_IIfcRiIZkRZm_azKfiVIy7tzoUFLDss97y938aR8imMVxazoSQvj7RWIWylgeRr9yVt7qYl18cnEVL0IGtslFbqhfNsiEmRCMsttm5kXs7E9B0bhhUe_xbJW9VumQ6G7dgMrswevp_jRgbpWJoZsgErtqIRl9Tc9ikA'; await expect(service.getVerifiedIdToken(token)).rejects.toEqual( new HttpException(makeErrorResponse('E000105'), HttpStatus.UNAUTHORIZED), @@ -83,7 +82,7 @@ describe('AuthService', () => { const cryptoParam = makeDefaultCryptoMockValue(); const service = await makeAuthServiceMock(adb2cParam, cryptoParam); const token = - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwiaXNzIjoiaXNzdXNlciJ9.dURTqFfUc94qYNFS65xMqMT3tJi-uH8weJ5GU74F-UvhLKJmqVoNIbXCm-ASg2mZ5SxeX683q2MMrlzxNcNel-QIFTj0zWem4eLKuarsYRMTU8x27jMss0b_JkFtiy5KE2052_fLeW2aZBb4AjieIW34-c0Ol1rrih-W9Pk6ZEOwf14Ju0IAgdUw66-HuLOUprZjavvFp2rwuAC7gqEujg3wtjO7VR82NNoVv2dxByTdrzG_eOk8QKTJYBoM50ztT6WFs-qWetZIoQDxMJRA9ObfEVyJ4Bq2b4qelfuhAqbQWRNfCvqhoRPMJI6Y7-R94FuO1zg4d4Gm3J0qHj7r8Q'; + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwidmVyIjoiMS4wIiwiaXNzIjoiaXNzdWVyIiwic3ViIjoic3ViIiwiYXVkIjoiYXVkIiwibm9uY2UiOiJkZWZhdWx0Tm9uY2UiLCJpYXQiOjEwMDAwMDAwMDAsImF1dGhfdGltZSI6MTAwMDAwMDAwMCwiZW1haWxzIjpbInh4eEB4eC5jb20iXSwidGZwIjoic2lnbmluX3VzZXJmbG93In0.RyieW-VHsHPQOjXbbhRc307AYJOc1sq2hrcu4SW1-K0pvLlkplepxvx02a3vCwQrnBYrIP5w6HExG-S_JgW5nYyWr6DeY11mA484n9KA8GeAcAXV37StH1gfWUJvfGb4C8BaMbMM9Ix4Z9NGwKA9vjNwevfmBZnz9lQUePgv6BJNmyvCt8ElJ01O-1WODbZuojJ4xXymA1OqluzfbphPOsqWTSNmTn0emkLjjnlMQf1iwM4C_kvvr8dUCFg0_UGDfQVJnzPEKB38UqnhLnC5WacrddDwQ0kBuGKZgZ_63Q_7fOvqAZivqLK7BPmbPxi6mx3R1S9Eq2ugzpY1LfJOjA'; await expect(service.getVerifiedIdToken(token)).rejects.toEqual( new HttpException( @@ -98,7 +97,7 @@ describe('AuthService', () => { const cryptoParam = makeDefaultCryptoMockValue(); const service = await makeAuthServiceMock(adb2cParam, cryptoParam); const token = - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwiaXNzIjoiaXNzdXNlciJ9.dURTqFfUc94qYNFS65xMqMT3tJi-uH8weJ5GU74F-UvhLKJmqVoNIbXCm-ASg2mZ5SxeX683q2MMrlzxNcNel-QIFTj0zWem4eLKuarsYRMTU8x27jMss0b_JkFtiy5KE2052_fLeW2aZBb4AjieIW34-c0Ol1rrih-W9Pk6ZEOwf14Ju0IAgdUw66-HuLOUprZjavvFp2rwuAC7gqEujg3wtjO7VR82NNoVv2dxByTdrzG_eOk8QKTJYBoM50ztT6WFs-qWetZIoQDxMJRA9ObfEVyJ4Bq2b4qelfuhAqbQWRNfCvqhoRPMJI6Y7-R94FuO1zg4d4Gm3J0qHj7r8Q'; + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwidmVyIjoiMS4wIiwiaXNzIjoiaXNzdWVyIiwic3ViIjoic3ViIiwiYXVkIjoiYXVkIiwibm9uY2UiOiJkZWZhdWx0Tm9uY2UiLCJpYXQiOjEwMDAwMDAwMDAsImF1dGhfdGltZSI6MTAwMDAwMDAwMCwiZW1haWxzIjpbInh4eEB4eC5jb20iXSwidGZwIjoic2lnbmluX3VzZXJmbG93In0.RyieW-VHsHPQOjXbbhRc307AYJOc1sq2hrcu4SW1-K0pvLlkplepxvx02a3vCwQrnBYrIP5w6HExG-S_JgW5nYyWr6DeY11mA484n9KA8GeAcAXV37StH1gfWUJvfGb4C8BaMbMM9Ix4Z9NGwKA9vjNwevfmBZnz9lQUePgv6BJNmyvCt8ElJ01O-1WODbZuojJ4xXymA1OqluzfbphPOsqWTSNmTn0emkLjjnlMQf1iwM4C_kvvr8dUCFg0_UGDfQVJnzPEKB38UqnhLnC5WacrddDwQ0kBuGKZgZ_63Q_7fOvqAZivqLK7BPmbPxi6mx3R1S9Eq2ugzpY1LfJOjA'; await expect(service.getVerifiedIdToken(token)).rejects.toEqual( new HttpException( @@ -116,7 +115,7 @@ describe('AuthService', () => { const cryptoParam = makeDefaultCryptoMockValue(); const service = await makeAuthServiceMock(adb2cParam, cryptoParam); const token = - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwiaXNzIjoiaXNzdXNlciJ9.dURTqFfUc94qYNFS65xMqMT3tJi-uH8weJ5GU74F-UvhLKJmqVoNIbXCm-ASg2mZ5SxeX683q2MMrlzxNcNel-QIFTj0zWem4eLKuarsYRMTU8x27jMss0b_JkFtiy5KE2052_fLeW2aZBb4AjieIW34-c0Ol1rrih-W9Pk6ZEOwf14Ju0IAgdUw66-HuLOUprZjavvFp2rwuAC7gqEujg3wtjO7VR82NNoVv2dxByTdrzG_eOk8QKTJYBoM50ztT6WFs-qWetZIoQDxMJRA9ObfEVyJ4Bq2b4qelfuhAqbQWRNfCvqhoRPMJI6Y7-R94FuO1zg4d4Gm3J0qHj7r8Q'; + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtpZCJ9.eyJleHAiOjkwMDAwMDAwMDAsIm5iZiI6MTAwMDAwMDAwMCwidmVyIjoiMS4wIiwiaXNzIjoiaXNzdWVyIiwic3ViIjoic3ViIiwiYXVkIjoiYXVkIiwibm9uY2UiOiJkZWZhdWx0Tm9uY2UiLCJpYXQiOjEwMDAwMDAwMDAsImF1dGhfdGltZSI6MTAwMDAwMDAwMCwiZW1haWxzIjpbInh4eEB4eC5jb20iXSwidGZwIjoic2lnbmluX3VzZXJmbG93In0.RyieW-VHsHPQOjXbbhRc307AYJOc1sq2hrcu4SW1-K0pvLlkplepxvx02a3vCwQrnBYrIP5w6HExG-S_JgW5nYyWr6DeY11mA484n9KA8GeAcAXV37StH1gfWUJvfGb4C8BaMbMM9Ix4Z9NGwKA9vjNwevfmBZnz9lQUePgv6BJNmyvCt8ElJ01O-1WODbZuojJ4xXymA1OqluzfbphPOsqWTSNmTn0emkLjjnlMQf1iwM4C_kvvr8dUCFg0_UGDfQVJnzPEKB38UqnhLnC5WacrddDwQ0kBuGKZgZ_63Q_7fOvqAZivqLK7BPmbPxi6mx3R1S9Eq2ugzpY1LfJOjA'; await expect(service.getVerifiedIdToken(token)).rejects.toEqual( new HttpException( @@ -130,5 +129,13 @@ describe('AuthService', () => { const idTokenPayload = { exp: 9000000000, nbf: 1000000000, - iss: 'issuser', + ver: '1.0', + iss: 'issuer', + sub: 'sub', + aud: 'aud', + nonce: 'defaultNonce', + iat: 1000000000, + auth_time: 1000000000, + emails: ['xxx@xx.com'], + tfp: 'signin_userflow', }; diff --git a/dictation_server/src/features/auth/test/auth.service.mock.ts b/dictation_server/src/features/auth/test/auth.service.mock.ts index c7006f8..30abd07 100644 --- a/dictation_server/src/features/auth/test/auth.service.mock.ts +++ b/dictation_server/src/features/auth/test/auth.service.mock.ts @@ -3,6 +3,8 @@ import { AdB2cService } from '../../../gateways/adb2c/adb2c.service'; import { CryptoService } from '../../../gateways/crypto/crypto.service'; import { JwkSignKey, B2cMetadata } from '../../../common/token'; import { AuthService } from '../auth.service'; +import { ConfigService } from '@nestjs/config'; +import { UsersRepositoryService } from '../../../repositories/users/users.repository.service'; export type AdB2cMockValue = { getMetaData: B2cMetadata | Error; @@ -26,6 +28,10 @@ export const makeAuthServiceMock = async ( return makeAdB2cServiceMock(adB2cMockValue); case CryptoService: return makeCryptoServiceMock(cryptoMockValue); + case ConfigService: + return {}; + case UsersRepositoryService: + return {}; } }) .compile(); @@ -36,7 +42,7 @@ export const makeAuthServiceMock = async ( export const makeDefaultAdB2cMockValue = (): AdB2cMockValue => { return { getMetaData: { - issuer: 'issuser', + issuer: 'issuer', }, getSignKeySets: [ { @@ -72,13 +78,13 @@ export const makeDefaultCryptoMockValue = (): CryptoMockValue => { return { getPublicKeyFromJwk: [ '-----BEGIN PUBLIC KEY-----', - 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsTVLNpW0/FzVCU7qo1DD', - 'jOkYWx6s/jE56YOOc3UzaaG/zb1FGyfRoUUgS4DnQxPNz9oM63RpQlhvG6UCwx23', - 'tL7p3PS0ZCsLeggcyLctbJAzLy/afF9ABoreorqp/AaEs+Vdwbykb+M+nB2Sxsc5', - '7Tli2x8NiOZr5dafs3vMuIIKNsBaFAugFrd2ApxXR04jBRAorZRRFPtECE7D+hxD', - 'alw5DCd0mmdY0vrbRsgkbej0ZzzqzukJVXTMjy1YScqi3I9gLx2hLVmpK76Gtxn2', - '1AIcn8P3rKZmDyPH+9KNfWC8+ubF+VuY6nItlCgiSyTKErAp6M9pyRHKbPpdUM3a', - 'IQIDAQAB', + 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5IZZNgDew9eGmuFTezwd', + 'HYLSaJvUPPIKYoiOeVLD1paWNI517Vkaoh0ngprcKOdv6T1N07V4igK7mOim2zY3', + 'yCTR6wcWR3PfFJrl9vh5SOo79koZoJb27YiM4jtxfx2dezzp0T2GoNR5rRolPUbW', + 'FJXnDe0DVXYXpJLb4LAlF2XAyYX0SYKUVUsJnzm5k4xbXtnwPwVbpm0EdswBE6qS', + 'fiL9zWk9dvHoKzSnfSDzDFoFcEoVchawzYXf/MM1YR4wo5XyzECc6Q5Ah4z522//', + 'mBNNaDHv83Yuw3mGShT73iJ0JQdkTturshv2Ecma38r6ftrIwNYXw4VVatJM8+GO', + 'OQIDAQAB', '-----END PUBLIC KEY-----', ].join('\n'), }; diff --git a/dictation_server/src/features/users/test/users.service.mock.ts b/dictation_server/src/features/users/test/users.service.mock.ts new file mode 100644 index 0000000..0266c59 --- /dev/null +++ b/dictation_server/src/features/users/test/users.service.mock.ts @@ -0,0 +1,78 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersService } from '../users.service'; +import { UsersRepositoryService } from '../../../repositories/users/users.repository.service'; +import { CryptoService } from '../../../gateways/crypto/crypto.service'; + +export type CryptoMockValue = { + getPublicKey: string | Error; +}; + +export type UsersRepositoryMockValue = { + updateUserVerified: undefined | Error; +}; + +export const makeUsersServiceMock = async ( + cryptoMockValue: CryptoMockValue, + usersRepositoryMockValue: UsersRepositoryMockValue, +): Promise => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UsersService], + }) + .useMocker((token) => { + switch (token) { + case CryptoService: + return makeCryptoServiceMock(cryptoMockValue); + case UsersRepositoryService: + return makeUsersRepositoryMock(usersRepositoryMockValue); + } + }) + .compile(); + + return module.get(UsersService); +}; + +export const makeCryptoServiceMock = (value: CryptoMockValue) => { + const { getPublicKey } = value; + + return { + getPublicKey: + getPublicKey instanceof Error + ? jest.fn, []>().mockRejectedValue(getPublicKey) + : jest.fn, []>().mockResolvedValue(getPublicKey), + }; +}; + +export const makeUsersRepositoryMock = (value: UsersRepositoryMockValue) => { + const { updateUserVerified } = value; + + return { + updateUserVerified: + updateUserVerified instanceof Error + ? jest.fn, []>().mockRejectedValue(updateUserVerified) + : jest.fn, []>().mockResolvedValue(updateUserVerified), + }; +}; + +export const makeDefaultCryptoMockValue = (): CryptoMockValue => { + return { + getPublicKey: [ + '-----BEGIN PUBLIC KEY-----', + 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5IZZNgDew9eGmuFTezwd', + 'HYLSaJvUPPIKYoiOeVLD1paWNI517Vkaoh0ngprcKOdv6T1N07V4igK7mOim2zY3', + 'yCTR6wcWR3PfFJrl9vh5SOo79koZoJb27YiM4jtxfx2dezzp0T2GoNR5rRolPUbW', + 'FJXnDe0DVXYXpJLb4LAlF2XAyYX0SYKUVUsJnzm5k4xbXtnwPwVbpm0EdswBE6qS', + 'fiL9zWk9dvHoKzSnfSDzDFoFcEoVchawzYXf/MM1YR4wo5XyzECc6Q5Ah4z522//', + 'mBNNaDHv83Yuw3mGShT73iJ0JQdkTturshv2Ecma38r6ftrIwNYXw4VVatJM8+GO', + 'OQIDAQAB', + '-----END PUBLIC KEY-----', + ].join('\n'), + }; +}; + +// 個別のテストケースに対応してそれぞれのMockを用意するのは無駄が多いのでテストケース内で個別の値を設定する +export const makeDefaultUsersRepositoryMockValue = + (): UsersRepositoryMockValue => { + return { + updateUserVerified: undefined, + }; + }; diff --git a/dictation_server/src/features/users/users.controller.spec.ts b/dictation_server/src/features/users/users.controller.spec.ts index 3e27c39..f7c7bbe 100644 --- a/dictation_server/src/features/users/users.controller.spec.ts +++ b/dictation_server/src/features/users/users.controller.spec.ts @@ -1,13 +1,19 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; describe('UsersController', () => { let controller: UsersController; + const mockUserService = {}; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [UsersController], - }).compile(); + providers: [UsersService], + }) + .overrideProvider(UsersService) + .useValue(mockUserService) + .compile(); controller = module.get(UsersController); }); diff --git a/dictation_server/src/features/users/users.controller.ts b/dictation_server/src/features/users/users.controller.ts index df9d7a5..c3be8af 100644 --- a/dictation_server/src/features/users/users.controller.ts +++ b/dictation_server/src/features/users/users.controller.ts @@ -27,8 +27,7 @@ export class UsersController { }) @ApiOperation({ operationId: 'confirmUser' }) async confirmUser(@Body() body: ConfirmRequest): Promise { - console.log(JSON.stringify(body)); - + await this.usersService.confirmUser(body.token); return {}; } } diff --git a/dictation_server/src/features/users/users.module.ts b/dictation_server/src/features/users/users.module.ts index 0177a65..f52b278 100644 --- a/dictation_server/src/features/users/users.module.ts +++ b/dictation_server/src/features/users/users.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { CryptoModule } from '../../gateways/crypto/crypto.module'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; +import { UsersRepositoryModule } from '../../repositories/users/users.repository.module'; @Module({ - imports: [CryptoModule], + imports: [CryptoModule, UsersRepositoryModule], controllers: [UsersController], providers: [UsersService], }) diff --git a/dictation_server/src/features/users/users.service.spec.ts b/dictation_server/src/features/users/users.service.spec.ts index 62815ba..e27bc3f 100644 --- a/dictation_server/src/features/users/users.service.spec.ts +++ b/dictation_server/src/features/users/users.service.spec.ts @@ -1,18 +1,72 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { UsersService } from './users.service'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; +import { + makeDefaultCryptoMockValue, + makeDefaultUsersRepositoryMockValue, + makeUsersServiceMock, +} from './test/users.service.mock'; +import { EmailAlreadyVerifiedError } from '../../repositories/users/users.repository.service'; describe('UsersService', () => { - let service: UsersService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [UsersService], - }).compile(); - - service = module.get(UsersService); + it('ユーザの仮登録時に払い出されるトークンにより、未認証のユーザが認証済みになる', async () => { + const cryptoMockValue = makeDefaultCryptoMockValue(); + const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + const service = await makeUsersServiceMock( + cryptoMockValue, + usersRepositoryMockValue, + ); + const token = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; + expect(await service.confirmUser(token)).toEqual(undefined); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('トークンの形式が不正な場合、形式不正エラーとなる。', async () => { + const cryptoMockValue = makeDefaultCryptoMockValue(); + const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + const service = await makeUsersServiceMock( + cryptoMockValue, + usersRepositoryMockValue, + ); + const token = 'invalid.id.token'; + await expect(service.confirmUser(token)).rejects.toEqual( + new HttpException(makeErrorResponse('E000101'), HttpStatus.BAD_REQUEST), + ); + }); + + it('ユーザが既に認証済みだった場合、認証済みユーザエラーとなる。', async () => { + const cryptoMockValue = makeDefaultCryptoMockValue(); + const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + + usersRepositoryMockValue.updateUserVerified = + new EmailAlreadyVerifiedError(); + + const service = await makeUsersServiceMock( + cryptoMockValue, + usersRepositoryMockValue, + ); + const token = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; + await expect(service.confirmUser(token)).rejects.toEqual( + new HttpException(makeErrorResponse('E010202'), HttpStatus.BAD_REQUEST), + ); + }); + + it('DBネットワークエラーとなる場合、エラーとなる。', async () => { + const cryptoMockValue = makeDefaultCryptoMockValue(); + const usersRepositoryMockValue = makeDefaultUsersRepositoryMockValue(); + usersRepositoryMockValue.updateUserVerified = new Error('DB error'); + + const service = await makeUsersServiceMock( + cryptoMockValue, + usersRepositoryMockValue, + ); + const token = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50SWQiOjEsInVzZXJJZCI6MiwiZW1haWwiOiJ4eHhAeHh4Lnh4eCIsImlhdCI6MTAwMDAwMDAwMCwiZXhwIjo5MDAwMDAwMDAwfQ.26L6BdNg-3TbyKT62PswlJ6RPMkcTtHzlDXW2Uo9XbMPVSrl2ObcuS6EcXjFFN2DEfNTKbqX_zevIWMpHOAdLNgGhk528nLrBrNvPASqtTjvW9muxMXpjUdjRVkmVbOylBHWW3YpWL9JEbJQ7rAzWDfaIdPhMovdaxumnZt_UwnlnrdaVPLACW7tkH_laEcAU507iSiM4mqxxG8FuTs34t6PEdwRuzZAQPN2IOPYNSvGNdJYryPacSeSNZ_z1xeBYXLOLQfOBZzyTReYDOhXdikhrNUbxjgnZQlSXBCVMlZ9PH42bHfp-LJIeJzW0yqnF6oLklvJP-fo8eW0k5iDOw'; + await expect(service.confirmUser(token)).rejects.toEqual( + new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); }); }); diff --git a/dictation_server/src/features/users/users.service.ts b/dictation_server/src/features/users/users.service.ts index ef0d82d..9a5b7c7 100644 --- a/dictation_server/src/features/users/users.service.ts +++ b/dictation_server/src/features/users/users.service.ts @@ -1,4 +1,67 @@ -import { Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; +import { isVerifyError, verify } from '../../common/jwt'; +import { CryptoService } from '../../gateways/crypto/crypto.service'; +import { + UsersRepositoryService, + EmailAlreadyVerifiedError, +} from '../../repositories/users/users.repository.service'; +import { makeErrorResponse } from '../../common/error/makeErrorResponse'; @Injectable() -export class UsersService {} +export class UsersService { + constructor( + private readonly cryptoService: CryptoService, + private readonly usersRepository: UsersRepositoryService, + ) {} + private readonly logger = new Logger(UsersService.name); + + /** + * Confirms user + * @param token ユーザ仮登録時に払いだされるトークン + */ + async confirmUser(token: string): Promise { + this.logger.log(`[IN] ${this.confirmUser.name}`); + + const pubKey = await this.cryptoService.getPublicKey(); + + const decodedToken = verify<{ + accountId: number; + userId: number; + email: string; + }>(token, pubKey); + if (isVerifyError(decodedToken)) { + throw new HttpException( + makeErrorResponse('E000101'), + HttpStatus.BAD_REQUEST, + ); + } + + try { + // トランザクションで取得と更新をまとめる + const userId = decodedToken.userId; + await this.usersRepository.updateUserVerified(userId); + } catch (e) { + console.log(e); + if (e instanceof Error) { + switch (e.constructor) { + case EmailAlreadyVerifiedError: + throw new HttpException( + makeErrorResponse('E010202'), + HttpStatus.BAD_REQUEST, + ); + default: + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + this.logger.error(`error=${e}`); + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/dictation_server/src/repositories/users/users.repository.service.ts b/dictation_server/src/repositories/users/users.repository.service.ts index 302fad2..33e585d 100644 --- a/dictation_server/src/repositories/users/users.repository.service.ts +++ b/dictation_server/src/repositories/users/users.repository.service.ts @@ -1,7 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, UpdateResult } from 'typeorm'; import { User } from './entity/user.entity'; +// UsersRepositoryServiceで発生するエラーを定義 +export class EmailAlreadyVerifiedError extends Error {} +export class UserNotFoundError extends Error {} + @Injectable() export class UsersRepositoryService { constructor(private dataSource: DataSource) {} @@ -44,4 +48,58 @@ export class UsersRepositoryService { } return user; } + + async findUserById(id: number): Promise { + const user = await this.dataSource.getRepository(User).findOne({ + where: { + id: id, + }, + }); + + if (!user) { + return undefined; + } + return user; + } + + /** + * 特定の情報でユーザーを更新する + * @param user + * @returns update + */ + async update(user: User): Promise { + return await this.dataSource.transaction(async (entityManager) => { + const repo = entityManager.getRepository(User); + return await repo.update({ id: user.id }, user); + }); + } + + /** + * 管理ユーザーがメール認証済みなら認証情報を更新する + * @param user + * @returns update + */ + async updateUserVerified(id: number): Promise { + return await this.dataSource.transaction(async (entityManager) => { + const repo = entityManager.getRepository(User); + const targetUser = await repo.findOne({ + where: { + id: id, + }, + }); + + // 運用上ユーザがいないことはあり得ないが、プログラム上発生しうるのでエラーとして処理 + if (!targetUser) { + throw new UserNotFoundError(); + } + + if (targetUser.email_verified) { + throw new EmailAlreadyVerifiedError(); + } + + targetUser.email_verified = true; + + return await repo.update({ id: targetUser.id }, targetUser); + }); + } }