diff --git a/dictation_server/src/api/odms/openapi.json b/dictation_server/src/api/odms/openapi.json index 7a3ff67..cea7c44 100644 --- a/dictation_server/src/api/odms/openapi.json +++ b/dictation_server/src/api/odms/openapi.json @@ -2077,6 +2077,14 @@ } } }, + "400": { + "description": "不正なパラメータ", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + }, "401": { "description": "認証エラー", "content": { diff --git a/dictation_server/src/common/error/code.ts b/dictation_server/src/common/error/code.ts index d7dec4b..f8f7225 100644 --- a/dictation_server/src/common/error/code.ts +++ b/dictation_server/src/common/error/code.ts @@ -39,6 +39,7 @@ export const ErrorCodes = [ 'E010501', // アカウント不在エラー 'E010502', // アカウント情報変更不可エラー 'E010503', // 代行操作不許可エラー + 'E010504', // アカウントロックエラー 'E010601', // タスク変更不可エラー(タスクが変更できる状態でない、またはタスクが存在しない) 'E010602', // タスク変更権限不足エラー 'E010603', // タスク不在エラー @@ -54,6 +55,7 @@ export const ErrorCodes = [ 'E010809', // ライセンス発行キャンセル不可エラー(ステータスが変えられている場合) 'E010810', // ライセンス発行キャンセル不可エラー(発行から一定期間経過した場合) 'E010811', // ライセンス発行キャンセル不可エラー(発行したライセンスが割り当てされている場合) + 'E010812', // ライセンス未割当エラー 'E010908', // タイピストグループ不在エラー 'E011001', // ワークタイプ重複エラー 'E011002', // ワークタイプ登録上限超過エラー diff --git a/dictation_server/src/common/error/message.ts b/dictation_server/src/common/error/message.ts index 38c5e98..4786a64 100644 --- a/dictation_server/src/common/error/message.ts +++ b/dictation_server/src/common/error/message.ts @@ -28,6 +28,7 @@ export const errors: Errors = { E010501: 'Account not Found Error.', E010502: 'Account information cannot be changed Error.', E010503: 'Delegation not allowed Error.', + E010504: 'Account is locked Error.', E010601: 'Task is not Editable Error', E010602: 'No task edit permissions Error', E010603: 'Task not found Error.', @@ -43,6 +44,7 @@ export const errors: Errors = { E010809: 'Already license order status changed Error', E010810: 'Cancellation period expired error', E010811: 'Already license allocated Error', + E010812: 'License not allocated Error', E010908: 'Typist Group not exist Error', E011001: 'This WorkTypeID already used Error', E011002: 'WorkTypeID create limit exceeded Error', diff --git a/dictation_server/src/common/test/overrides.ts b/dictation_server/src/common/test/overrides.ts index 7455cd7..bfa8c84 100644 --- a/dictation_server/src/common/test/overrides.ts +++ b/dictation_server/src/common/test/overrides.ts @@ -185,6 +185,11 @@ export const overrideBlobstorageService = ( accountId: number, country: string, ) => Promise; + publishUploadSas?: ( + context: Context, + accountId: number, + country: string, + ) => Promise; publishTemplateUploadSas?: ( context: Context, accountId: number, @@ -212,6 +217,12 @@ export const overrideBlobstorageService = ( writable: true, }); } + if (overrides.publishUploadSas) { + Object.defineProperty(obj, obj.publishUploadSas.name, { + value: overrides.publishUploadSas, + writable: true, + }); + } if (overrides.publishTemplateUploadSas) { Object.defineProperty(obj, obj.publishTemplateUploadSas.name, { value: overrides.publishTemplateUploadSas, diff --git a/dictation_server/src/features/files/files.controller.ts b/dictation_server/src/features/files/files.controller.ts index e3a772e..f9ec041 100644 --- a/dictation_server/src/features/files/files.controller.ts +++ b/dictation_server/src/features/files/files.controller.ts @@ -39,11 +39,15 @@ import { retrieveAuthorizationToken } from '../../common/http/helper'; import { Request } from 'express'; import { makeContext } from '../../common/log'; import { makeErrorResponse } from '../../common/error/makeErrorResponse'; +import { LicensesService } from '../licenses/licenses.service'; @ApiTags('files') @Controller('files') export class FilesController { - constructor(private readonly filesService: FilesService) {} + constructor( + private readonly filesService: FilesService, + private readonly licensesService: LicensesService, + ) {} @ApiResponse({ status: HttpStatus.OK, @@ -140,6 +144,11 @@ export class FilesController { type: AudioUploadLocationResponse, description: '成功時のレスポンス', }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: '不正なパラメータ', + type: ErrorResponse, + }) @ApiResponse({ status: HttpStatus.UNAUTHORIZED, description: '認証エラー', diff --git a/dictation_server/src/features/files/files.service.spec.ts b/dictation_server/src/features/files/files.service.spec.ts index 35f5a1b..367abf3 100644 --- a/dictation_server/src/features/files/files.service.spec.ts +++ b/dictation_server/src/features/files/files.service.spec.ts @@ -1,13 +1,9 @@ import { HttpException, HttpStatus } from '@nestjs/common'; import { makeErrorResponse } from '../../common/error/makeErrorResponse'; -import { - makeBlobstorageServiceMockValue, - makeDefaultTasksRepositoryMockValue, - makeDefaultUsersRepositoryMockValue, - makeFilesServiceMock, -} from './test/files.service.mock'; +import { makeBlobstorageServiceMockValue } from './test/files.service.mock'; import { DataSource } from 'typeorm'; import { + createLicense, createTask, createUserGroupAndMember, getTaskFromJobNumber, @@ -16,6 +12,7 @@ import { import { FilesService } from './files.service'; import { makeContext } from '../../common/log'; import { + makeHierarchicalAccounts, makeTestAccount, makeTestSimpleAccount, makeTestUser, @@ -37,89 +34,243 @@ import { TasksRepositoryService } from '../../repositories/tasks/tasks.repositor import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service'; import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage'; import { getCheckoutPermissions, getTask } from '../tasks/test/utility'; +import { DateWithZeroTime } from '../licenses/types/types'; +import { LICENSE_ALLOCATED_STATUS, LICENSE_TYPE } from '../../constants'; -describe('音声ファイルアップロードURL取得', () => { - it('アップロードSASトークンが乗っているURLを返却する', async () => { - const blobParam = makeBlobstorageServiceMockValue(); - const userRepoParam = makeDefaultUsersRepositoryMockValue(); - const taskRepoParam = makeDefaultTasksRepositoryMockValue(); - const service = await makeFilesServiceMock( - blobParam, - userRepoParam, - taskRepoParam, - ); - - expect( - await service.publishUploadSas( - makeContext('trackingId'), - 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', - ), - ).toEqual('https://blob-storage?sas-token'); +describe('publishUploadSas', () => { + let source: DataSource | null = null; + beforeEach(async () => { + source = new DataSource({ + type: 'sqlite', + database: ':memory:', + logging: false, + entities: [__dirname + '/../../**/*.entity{.ts,.js}'], + synchronize: true, // trueにすると自動的にmigrationが行われるため注意 + }); + return source.initialize(); }); - it('アカウント専用コンテナが無い場合でも、コンテナ作成しURLを返却する', async () => { - const blobParam = makeBlobstorageServiceMockValue(); - const userRepoParam = makeDefaultUsersRepositoryMockValue(); - const taskRepoParam = makeDefaultTasksRepositoryMockValue(); - - blobParam.containerExists = false; - - const service = await makeFilesServiceMock( - blobParam, - userRepoParam, - taskRepoParam, - ); - - expect( - await service.publishUploadSas( - makeContext('trackingId'), - 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', - ), - ).toEqual('https://blob-storage?sas-token'); + afterEach(async () => { + if (!source) return; + await source.destroy(); + source = null; }); - it('ユーザー情報の取得に失敗した場合、例外エラーを返却する', async () => { - const blobParam = makeBlobstorageServiceMockValue(); - const taskRepoParam = makeDefaultTasksRepositoryMockValue(); + it('音声アップロードSASトークンが乗っているURLを取得できる', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(FilesService); + // 第五階層のアカウント作成 + const { account: account } = await makeTestAccount(source, { tier: 5 }); + const { external_id: externalId, id: userId } = await makeTestUser(source, { + account_id: account.id, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'AUTHOR_ID', + }); + // 本日の日付を作成 + let today = new Date(); + today.setDate(today.getDate()); + today = new DateWithZeroTime(today); + // 有効期限内のライセンスを作成して紐づける + await createLicense( + source, + 1, + today, + account.id, + LICENSE_TYPE.NORMAL, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + userId, + null, + null, + null, + ); + const context = makeContext(externalId); + const baseUrl = `https://saodmsusdev.blob.core.windows.net/account-${account.id}/${userId}`; - const service = await makeFilesServiceMock( - blobParam, - { - findUserByExternalId: new Error(''), + //SASトークンを返却する + overrideBlobstorageService(service, { + containerExists: async () => true, + publishUploadSas: async () => `${baseUrl}?sas-token`, + }); + + const url = await service.publishUploadSas(context, externalId); + + expect(url).toBe(`${baseUrl}?sas-token`); + }); + + it('blobストレージにコンテナが存在しない場合はエラーとなる', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(FilesService); + // 第四階層のアカウント作成 + const { admin } = await makeTestAccount(source, { tier: 4 }); + + const context = makeContext(admin.external_id); + + //Blobコンテナ存在チェックに失敗するようにする + overrideBlobstorageService(service, { + containerExists: async () => false, + publishUploadSas: async () => '', + }); + + try { + await service.publishUploadSas(context, admin.external_id); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + }); + + it('SASトークンの取得に失敗した場合はエラーとなる', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(FilesService); + // 第四階層のアカウント作成 + const { admin } = await makeTestAccount(source, { tier: 4 }); + + const context = makeContext(admin.external_id); + + //BlobのSASトークン生成に失敗するようにする + overrideBlobstorageService(service, { + containerExists: async () => true, + publishUploadSas: async () => { + throw new Error('blob failed'); }, - taskRepoParam, + }); + + try { + await service.publishUploadSas(context, admin.external_id); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toBe(HttpStatus.INTERNAL_SERVER_ERROR); + expect(e.getResponse()).toEqual(makeErrorResponse('E009999')); + } else { + fail(); + } + } + }); + it('アカウントがロックされている場合、エラーとなる', async () => { + if (!source) fail(); + const module = await makeTestingModule(source); + if (!module) fail(); + const service = module.get(FilesService); + // 第五階層のアカウント作成 + const { admin } = await makeTestAccount(source, { tier: 5, locked: true }); + + const context = makeContext(admin.external_id); + + try { + await service.publishUploadSas(context, admin.external_id); + } catch (e) { + if (e instanceof HttpException) { + expect(e.getStatus()).toBe(HttpStatus.BAD_REQUEST); + expect(e.getResponse()).toEqual(makeErrorResponse('E010504')); + } else { + fail(); + } + } + }); + it('アップロード時にユーザーにライセンスが未割当の場合エラーとなる(第五階層限定)', async () => { + if (!source) fail(); + // 第五階層のアカウントまで作成し、そのアカウントに紐づくユーザーを作成する(ライセンスは作成しない) + const { tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts( + source, ); + const tier5Accounts = await makeTestAccount(source, { + parent_account_id: tier4Accounts[0].account.id, + tier: 5, + }); + const { external_id: externalId, id: userId } = await makeTestUser(source, { + account_id: tier5Accounts.account.id, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'AUTHOR_ID', + }); + const url = `https://saodmsusdev.blob.core.windows.net/account-${tier5Accounts.account.id}/${userId}`; + + const blobParam = makeBlobstorageServiceMockValue(); + blobParam.publishUploadSas = `${url}?sas-token`; + blobParam.fileExists = false; + + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); + if (!module) fail(); + const service = module.get(FilesService); await expect( - service.publishUploadSas( - makeContext('trackingId'), - 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', - ), + service.publishUploadSas(makeContext('trackingId'), externalId), ).rejects.toEqual( - new HttpException(makeErrorResponse('E009999'), HttpStatus.UNAUTHORIZED), + new HttpException(makeErrorResponse('E010812'), HttpStatus.BAD_REQUEST), ); }); - - it('コンテナ作成に失敗した場合、例外エラーを返却する', async () => { - const blobParam = makeBlobstorageServiceMockValue(); - const taskRepoParam = makeDefaultTasksRepositoryMockValue(); - - const service = await makeFilesServiceMock( - blobParam, - { - findUserByExternalId: new Error(''), - }, - taskRepoParam, + it('アップロード時にユーザーに割り当てられたライセンスが有効期限切れの場合エラー(第五階層限定)', async () => { + if (!source) fail(); + // 第五階層のアカウントまで作成し、そのアカウントに紐づくユーザーを作成する + const { tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts( + source, ); - blobParam.publishUploadSas = new Error('Azure service down'); + const tier5Accounts = await makeTestAccount(source, { + parent_account_id: tier4Accounts[0].account.id, + tier: 5, + }); + const { + external_id: externalId, + id: userId, + author_id: authorId, + } = await makeTestUser(source, { + account_id: tier5Accounts.account.id, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'AUTHOR_ID', + }); + // 昨日の日付を作成 + let yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday = new DateWithZeroTime(yesterday); + // 期限切れのライセンスを作成して紐づける + await createLicense( + source, + 1, + yesterday, + tier5Accounts.account.id, + LICENSE_TYPE.NORMAL, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + userId, + null, + null, + null, + ); + const url = `https://saodmsusdev.blob.core.windows.net/account-${tier5Accounts.account.id}/${userId}`; + + const blobParam = makeBlobstorageServiceMockValue(); + blobParam.publishUploadSas = `${url}?sas-token`; + blobParam.fileExists = false; + + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); + if (!module) fail(); + const service = module.get(FilesService); await expect( - service.publishUploadSas( - makeContext('trackingId'), - 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx', - ), + service.publishUploadSas(makeContext('trackingId'), externalId), ).rejects.toEqual( - new HttpException(makeErrorResponse('E009999'), HttpStatus.UNAUTHORIZED), + new HttpException(makeErrorResponse('E010805'), HttpStatus.BAD_REQUEST), ); }); }); @@ -879,7 +1030,76 @@ describe('音声ファイルダウンロードURL取得', () => { ), ).toEqual(`${url}?sas-token`); }); + it('ダウンロードSASトークンが乗っているURLを取得できる(第五階層の場合ライセンスのチェックを行う)', async () => { + if (!source) fail(); + // 第五階層のアカウントまで作成し、そのアカウントに紐づくユーザーを作成する + const { tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts( + source, + ); + const tier5Accounts = await makeTestAccount(source, { + parent_account_id: tier4Accounts[0].account.id, + tier: 5, + }); + const { + external_id: externalId, + id: userId, + author_id: authorId, + } = await makeTestUser(source, { + account_id: tier5Accounts.account.id, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'AUTHOR_ID', + }); + // 本日の日付を作成 + let today = new Date(); + today.setDate(today.getDate()); + today = new DateWithZeroTime(today); + // 有効期限内のライセンスを作成して紐づける + await createLicense( + source, + 1, + today, + tier5Accounts.account.id, + LICENSE_TYPE.NORMAL, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + userId, + null, + null, + null, + ); + const url = `https://saodmsusdev.blob.core.windows.net/account-${tier5Accounts.account.id}/${userId}`; + const { audioFileId } = await createTask( + source, + tier5Accounts.account.id, + url, + 'test.zip', + 'InProgress', + undefined, + authorId ?? '', + ); + + const blobParam = makeBlobstorageServiceMockValue(); + blobParam.publishDownloadSas = `${url}?sas-token`; + blobParam.fileExists = true; + + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); + if (!module) fail(); + const service = module.get(FilesService); + + expect( + await service.publishAudioFileDownloadSas( + makeContext('trackingId'), + externalId, + audioFileId, + ), + ).toEqual(`${url}?sas-token`); + }); it('Typistの場合、タスクのステータスが[Inprogress,Pending]以外でエラー', async () => { if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); @@ -1109,6 +1329,133 @@ describe('音声ファイルダウンロードURL取得', () => { new HttpException(makeErrorResponse('E010701'), HttpStatus.BAD_REQUEST), ); }); + it('ダウンロード時にユーザーにライセンスが未割当の場合エラーとなる(第五階層限定)', async () => { + if (!source) fail(); + // 第五階層のアカウントまで作成し、そのアカウントに紐づくユーザーを作成する(ライセンスは作成しない) + const { tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts( + source, + ); + const tier5Accounts = await makeTestAccount(source, { + parent_account_id: tier4Accounts[0].account.id, + tier: 5, + }); + const { + external_id: externalId, + id: userId, + author_id: authorId, + } = await makeTestUser(source, { + account_id: tier5Accounts.account.id, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'AUTHOR_ID', + }); + const url = `https://saodmsusdev.blob.core.windows.net/account-${tier5Accounts.account.id}/${userId}`; + + const { audioFileId } = await createTask( + source, + tier5Accounts.account.id, + url, + 'test.zip', + 'InProgress', + undefined, + authorId ?? '', + ); + + const blobParam = makeBlobstorageServiceMockValue(); + blobParam.publishDownloadSas = `${url}?sas-token`; + blobParam.fileExists = false; + + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); + if (!module) fail(); + const service = module.get(FilesService); + + await expect( + service.publishAudioFileDownloadSas( + makeContext('trackingId'), + externalId, + audioFileId, + ), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010812'), HttpStatus.BAD_REQUEST), + ); + }); + it('ダウンロード時にユーザーに割り当てられたライセンスが有効期限切れの場合エラー(第五階層限定)', async () => { + if (!source) fail(); + // 第五階層のアカウントまで作成し、そのアカウントに紐づくユーザーを作成する + const { tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts( + source, + ); + const tier5Accounts = await makeTestAccount(source, { + parent_account_id: tier4Accounts[0].account.id, + tier: 5, + }); + const { + external_id: externalId, + id: userId, + author_id: authorId, + } = await makeTestUser(source, { + account_id: tier5Accounts.account.id, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'AUTHOR_ID', + }); + // 昨日の日付を作成 + let yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday = new DateWithZeroTime(yesterday); + // 期限切れのライセンスを作成して紐づける + await createLicense( + source, + 1, + yesterday, + tier5Accounts.account.id, + LICENSE_TYPE.NORMAL, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + userId, + null, + null, + null, + ); + const url = `https://saodmsusdev.blob.core.windows.net/account-${tier5Accounts.account.id}/${userId}`; + + const { audioFileId } = await createTask( + source, + tier5Accounts.account.id, + url, + 'test.zip', + 'InProgress', + undefined, + authorId ?? '', + ); + + const blobParam = makeBlobstorageServiceMockValue(); + blobParam.publishDownloadSas = `${url}?sas-token`; + blobParam.fileExists = false; + + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); + if (!module) fail(); + const service = module.get(FilesService); + + await expect( + service.publishAudioFileDownloadSas( + makeContext('trackingId'), + externalId, + audioFileId, + ), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010805'), HttpStatus.BAD_REQUEST), + ); + }); }); describe('テンプレートファイルダウンロードURL取得', () => { @@ -1175,7 +1522,76 @@ describe('テンプレートファイルダウンロードURL取得', () => { ), ).toEqual(`${url}?sas-token`); }); + it('ダウンロードSASトークンが乗っているURLを取得できる(第五階層の場合ライセンスのチェックを行う)', async () => { + if (!source) fail(); + // 第五階層のアカウントまで作成し、そのアカウントに紐づくユーザーを作成する + const { tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts( + source, + ); + const tier5Accounts = await makeTestAccount(source, { + parent_account_id: tier4Accounts[0].account.id, + tier: 5, + }); + const { + external_id: externalId, + id: userId, + author_id: authorId, + } = await makeTestUser(source, { + account_id: tier5Accounts.account.id, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'AUTHOR_ID', + }); + // 本日の日付を作成 + let yesterday = new Date(); + yesterday.setDate(yesterday.getDate()); + yesterday = new DateWithZeroTime(yesterday); + // 有効期限内のライセンスを作成して紐づける + await createLicense( + source, + 1, + yesterday, + tier5Accounts.account.id, + LICENSE_TYPE.NORMAL, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + userId, + null, + null, + null, + ); + const url = `https://saodmsusdev.blob.core.windows.net/account-${tier5Accounts.account.id}/${userId}`; + const { audioFileId } = await createTask( + source, + tier5Accounts.account.id, + url, + 'test.zip', + 'InProgress', + undefined, + authorId ?? '', + ); + + const blobParam = makeBlobstorageServiceMockValue(); + blobParam.publishDownloadSas = `${url}?sas-token`; + blobParam.fileExists = true; + + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); + if (!module) fail(); + const service = module.get(FilesService); + + expect( + await service.publishTemplateFileDownloadSas( + makeContext('trackingId'), + externalId, + audioFileId, + ), + ).toEqual(`${url}?sas-token`); + }); it('Typistの場合、タスクのステータスが[Inprogress,Pending]以外でエラー', async () => { if (!source) fail(); const { id: accountId } = await makeTestSimpleAccount(source); @@ -1394,6 +1810,133 @@ describe('テンプレートファイルダウンロードURL取得', () => { new HttpException(makeErrorResponse('E010701'), HttpStatus.BAD_REQUEST), ); }); + it('ダウンロード時にユーザーにライセンスが未割当の場合エラーとなる(第五階層限定)', async () => { + if (!source) fail(); + // 第五階層のアカウントまで作成し、そのアカウントに紐づくユーザーを作成する(ライセンスは作成しない) + const { tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts( + source, + ); + const tier5Accounts = await makeTestAccount(source, { + parent_account_id: tier4Accounts[0].account.id, + tier: 5, + }); + const { + external_id: externalId, + id: userId, + author_id: authorId, + } = await makeTestUser(source, { + account_id: tier5Accounts.account.id, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'AUTHOR_ID', + }); + const url = `https://saodmsusdev.blob.core.windows.net/account-${tier5Accounts.account.id}/${userId}`; + + const { audioFileId } = await createTask( + source, + tier5Accounts.account.id, + url, + 'test.zip', + 'InProgress', + undefined, + authorId ?? '', + ); + + const blobParam = makeBlobstorageServiceMockValue(); + blobParam.publishDownloadSas = `${url}?sas-token`; + blobParam.fileExists = false; + + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); + if (!module) fail(); + const service = module.get(FilesService); + + await expect( + service.publishTemplateFileDownloadSas( + makeContext('trackingId'), + externalId, + audioFileId, + ), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010812'), HttpStatus.BAD_REQUEST), + ); + }); + it('ダウンロード時にユーザーに割り当てられたライセンスが有効期限切れの場合エラー(第五階層限定)', async () => { + if (!source) fail(); + // 第五階層のアカウントまで作成し、そのアカウントに紐づくユーザーを作成する + const { tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts( + source, + ); + const tier5Accounts = await makeTestAccount(source, { + parent_account_id: tier4Accounts[0].account.id, + tier: 5, + }); + const { + external_id: externalId, + id: userId, + author_id: authorId, + } = await makeTestUser(source, { + account_id: tier5Accounts.account.id, + external_id: 'author-user-external-id', + role: 'author', + author_id: 'AUTHOR_ID', + }); + // 昨日の日付を作成 + let yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday = new DateWithZeroTime(yesterday); + // 期限切れのライセンスを作成して紐づける + await createLicense( + source, + 1, + yesterday, + tier5Accounts.account.id, + LICENSE_TYPE.NORMAL, + LICENSE_ALLOCATED_STATUS.ALLOCATED, + userId, + null, + null, + null, + ); + const url = `https://saodmsusdev.blob.core.windows.net/account-${tier5Accounts.account.id}/${userId}`; + + const { audioFileId } = await createTask( + source, + tier5Accounts.account.id, + url, + 'test.zip', + 'InProgress', + undefined, + authorId ?? '', + ); + + const blobParam = makeBlobstorageServiceMockValue(); + blobParam.publishDownloadSas = `${url}?sas-token`; + blobParam.fileExists = false; + + const notificationParam = makeDefaultNotificationhubServiceMockValue(); + const module = await makeTestingModuleWithBlobAndNotification( + source, + blobParam, + notificationParam, + ); + if (!module) fail(); + const service = module.get(FilesService); + + await expect( + service.publishTemplateFileDownloadSas( + makeContext('trackingId'), + externalId, + audioFileId, + ), + ).rejects.toEqual( + new HttpException(makeErrorResponse('E010805'), HttpStatus.BAD_REQUEST), + ); + }); }); describe('publishTemplateFileUploadSas', () => { diff --git a/dictation_server/src/features/files/files.service.ts b/dictation_server/src/features/files/files.service.ts index a4b8ea4..9453d2c 100644 --- a/dictation_server/src/features/files/files.service.ts +++ b/dictation_server/src/features/files/files.service.ts @@ -7,6 +7,7 @@ import { AudioOptionItem, AudioUploadFinishedResponse } from './types/types'; import { OPTION_ITEM_NUM, TASK_STATUS, + TIERS, USER_ROLES, } from '../../constants/index'; import { User } from '../../repositories/users/entity/user.entity'; @@ -23,11 +24,19 @@ import { } from '../../repositories/tasks/errors/types'; import { Context } from '../../common/log'; import { TemplateFilesRepositoryService } from '../../repositories/template_files/template_files.repository.service'; -import { AccountNotFoundError } from '../../repositories/accounts/errors/types'; +import { + AccountNotFoundError, + AccountLockedError, +} from '../../repositories/accounts/errors/types'; import { Task } from '../../repositories/tasks/entity/task.entity'; import { UserGroupsRepositoryService } from '../../repositories/user_groups/user_groups.repository.service'; import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage'; import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service'; +import { + LicenseExpiredError, + LicenseNotAllocatedError, +} from '../../repositories/licenses/errors/types'; +import { DateWithZeroTime } from '../licenses/types/types'; @Injectable() export class FilesService { @@ -269,7 +278,20 @@ export class FilesService { } const accountId = user.account_id; const country = user.account.country; - + // 第五階層のみチェック + if (user.account.tier === TIERS.TIER5) { + // アカウントがロックされている場合、エラー + if (user.account.locked) { + throw new AccountLockedError('account is locked.'); + } + // ライセンスの有効性をチェック + const { licenseError } = await this.checkLicenseValidityByUserId( + user.id, + ); + if (licenseError) { + throw licenseError; + } + } // 国に応じたリージョンのBlobストレージにコンテナが存在するか確認 await this.blobStorageService.containerExists( context, @@ -286,10 +308,28 @@ export class FilesService { return url; } catch (e) { this.logger.error(`error=${e}`); - throw new HttpException( - makeErrorResponse('E009999'), - HttpStatus.INTERNAL_SERVER_ERROR, - ); + switch (e.constructor) { + case AccountLockedError: + throw new HttpException( + makeErrorResponse('E010504'), + HttpStatus.BAD_REQUEST, + ); + case LicenseExpiredError: + throw new HttpException( + makeErrorResponse('E010805'), + HttpStatus.BAD_REQUEST, + ); + case LicenseNotAllocatedError: + throw new HttpException( + makeErrorResponse('E010812'), + HttpStatus.BAD_REQUEST, + ); + default: + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } finally { this.logger.log( `[OUT] [${context.trackingId}] ${this.publishUploadSas.name}`, @@ -323,6 +363,16 @@ export class FilesService { if (!user.account) { throw new AccountNotFoundError('account not found.'); } + // 第五階層のみチェック + if (user.account.tier === TIERS.TIER5) { + // ライセンスの有効性をチェック + const { licenseError } = await this.checkLicenseValidityByUserId( + user.id, + ); + if (licenseError) { + throw licenseError; + } + } accountId = user.account.id; userId = user.id; country = user.account.country; @@ -330,14 +380,26 @@ export class FilesService { authorId = user.author_id ?? undefined; } catch (e) { this.logger.error(`error=${e}`); - this.logger.log( `[OUT] [${context.trackingId}] ${this.publishAudioFileDownloadSas.name}`, ); - throw new HttpException( - makeErrorResponse('E009999'), - HttpStatus.INTERNAL_SERVER_ERROR, - ); + switch (e.constructor) { + case LicenseExpiredError: + throw new HttpException( + makeErrorResponse('E010805'), + HttpStatus.BAD_REQUEST, + ); + case LicenseNotAllocatedError: + throw new HttpException( + makeErrorResponse('E010812'), + HttpStatus.BAD_REQUEST, + ); + default: + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } try { @@ -460,6 +522,16 @@ export class FilesService { if (!user.account) { throw new AccountNotFoundError('account not found.'); } + // 第五階層のみチェック + if (user.account.tier === TIERS.TIER5) { + // ライセンスの有効性をチェック + const { licenseError } = await this.checkLicenseValidityByUserId( + user.id, + ); + if (licenseError) { + throw licenseError; + } + } accountId = user.account_id; userId = user.id; country = user.account.country; @@ -470,10 +542,23 @@ export class FilesService { this.logger.log( `[OUT] [${context.trackingId}] ${this.publishTemplateFileDownloadSas.name}`, ); - throw new HttpException( - makeErrorResponse('E009999'), - HttpStatus.INTERNAL_SERVER_ERROR, - ); + switch (e.constructor) { + case LicenseExpiredError: + throw new HttpException( + makeErrorResponse('E010805'), + HttpStatus.BAD_REQUEST, + ); + case LicenseNotAllocatedError: + throw new HttpException( + makeErrorResponse('E010812'), + HttpStatus.BAD_REQUEST, + ); + default: + throw new HttpException( + makeErrorResponse('E009999'), + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } try { @@ -562,6 +647,7 @@ export class FilesService { makeErrorResponse('E010701'), HttpStatus.BAD_REQUEST, ); + default: throw new HttpException( makeErrorResponse('E009999'), @@ -679,4 +765,44 @@ export class FilesService { ); } } + + /** + * ユーザーに割り当てられているライセンスの有効性をチェックする。 + * ライセンスが割り当てられていない場合、またはライセンスが有効期限切れの場合、エラー返却する。 + * @param userId + * @returns licenseError? + */ + // TODO: TASK3084で共通部品化する + private async checkLicenseValidityByUserId( + userId: number, + ): Promise<{ licenseError?: Error }> { + try { + const allocatedLicense = await this.usersRepository.findLicenseByUserId( + userId, + ); + + if (!allocatedLicense) { + return { + licenseError: new LicenseNotAllocatedError( + 'license is not allocated.', + ), + }; + } else { + const currentDate = new DateWithZeroTime(); + if ( + allocatedLicense.expiry_date && + allocatedLicense.expiry_date < currentDate + ) { + return { + licenseError: new LicenseExpiredError('license is expired.'), + }; + } + } + + return {}; // エラーがない場合は空のオブジェクトを返す + } catch (e) { + // リポジトリ層のエラーやその他の例外をハンドリング + return e; + } + } } diff --git a/dictation_server/src/features/files/test/files.service.mock.ts b/dictation_server/src/features/files/test/files.service.mock.ts index 979b9fb..a5b8f06 100644 --- a/dictation_server/src/features/files/test/files.service.mock.ts +++ b/dictation_server/src/features/files/test/files.service.mock.ts @@ -19,6 +19,7 @@ export type BlobstorageServiceMockValue = { export type UsersRepositoryMockValue = { findUserByExternalId: User | Error; + isUserLicenseValid: boolean | Error; }; export type TasksRepositoryMockValue = { @@ -91,13 +92,17 @@ export const makeBlobstorageServiceMock = ( }; export const makeUsersRepositoryMock = (value: UsersRepositoryMockValue) => { - const { findUserByExternalId } = value; + const { findUserByExternalId, isUserLicenseValid } = value; return { findUserByExternalId: findUserByExternalId instanceof Error ? jest.fn, []>().mockRejectedValue(findUserByExternalId) : jest.fn, []>().mockResolvedValue(findUserByExternalId), + isUserLicenseValid: + isUserLicenseValid instanceof Error + ? jest.fn, []>().mockRejectedValue(isUserLicenseValid) + : jest.fn, []>().mockResolvedValue(isUserLicenseValid), }; }; @@ -169,6 +174,7 @@ export const makeDefaultUsersRepositoryMockValue = user: null, }, }, + isUserLicenseValid: true, }; }; diff --git a/dictation_server/src/features/files/test/utility.ts b/dictation_server/src/features/files/test/utility.ts index 80846d7..12bb21c 100644 --- a/dictation_server/src/features/files/test/utility.ts +++ b/dictation_server/src/features/files/test/utility.ts @@ -43,6 +43,7 @@ import { } from '../../tasks/test/tasks.service.mock'; import { UserGroup } from '../../../repositories/user_groups/entity/user_group.entity'; import { UserGroupMember } from '../../../repositories/user_groups/entity/user_group_member.entity'; +import { License } from '../../../repositories/licenses/entity/license.entity'; export const createTask = async ( datasource: DataSource, @@ -205,3 +206,33 @@ export const makeTestingModuleWithBlobAndNotification = async ( console.log(e); } }; + +export const createLicense = async ( + datasource: DataSource, + licenseId: number, + expiry_date: Date | null, + accountId: number, + type: string, + status: string, + allocated_user_id: number | null, + order_id: number | null, + deleted_at: Date | null, + delete_order_id: number | null, +): Promise => { + const { identifiers } = await datasource.getRepository(License).insert({ + id: licenseId, + expiry_date: expiry_date, + account_id: accountId, + type: type, + status: status, + allocated_user_id: allocated_user_id, + order_id: order_id, + deleted_at: deleted_at, + delete_order_id: delete_order_id, + created_by: 'test_runner', + created_at: new Date(), + updated_by: 'updater', + updated_at: new Date(), + }); + identifiers.pop() as License; +}; diff --git a/dictation_server/src/features/licenses/licenses.service.ts b/dictation_server/src/features/licenses/licenses.service.ts index 3aa04f1..25c9c41 100644 --- a/dictation_server/src/features/licenses/licenses.service.ts +++ b/dictation_server/src/features/licenses/licenses.service.ts @@ -1,6 +1,5 @@ import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { makeErrorResponse } from '../../common/error/makeErrorResponse'; -import { AccessToken } from '../../common/token'; import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; import { AccountsRepositoryService } from '../../repositories/accounts/accounts.repository.service'; import { AccountNotFoundError } from '../../repositories/accounts/errors/types'; @@ -13,6 +12,7 @@ import { import { LicensesRepositoryService } from '../../repositories/licenses/licenses.repository.service'; import { UserNotFoundError } from '../../repositories/users/errors/types'; import { + DateWithZeroTime, GetAllocatableLicensesResponse, IssueCardLicensesResponse, } from './types/types'; diff --git a/dictation_server/src/repositories/accounts/errors/types.ts b/dictation_server/src/repositories/accounts/errors/types.ts index c273574..d35f16f 100644 --- a/dictation_server/src/repositories/accounts/errors/types.ts +++ b/dictation_server/src/repositories/accounts/errors/types.ts @@ -4,3 +4,5 @@ export class AccountNotFoundError extends Error {} export class DealerAccountNotFoundError extends Error {} // 管理者ユーザ未存在エラー export class AdminUserNotFoundError extends Error {} +// アカウントロックエラー +export class AccountLockedError extends Error {} diff --git a/dictation_server/src/repositories/licenses/errors/types.ts b/dictation_server/src/repositories/licenses/errors/types.ts index 552e24b..331ece1 100644 --- a/dictation_server/src/repositories/licenses/errors/types.ts +++ b/dictation_server/src/repositories/licenses/errors/types.ts @@ -33,3 +33,6 @@ export class CancellationPeriodExpiredError extends Error {} // ライセンス発行キャンセル不可エラー(発行したライセンスが割り当てされている場合) export class AlreadyLicenseAllocatedError extends Error {} + +// ライセンス未割当エラー +export class LicenseNotAllocatedError extends Error {} diff --git a/dictation_server/src/repositories/users/users.repository.service.ts b/dictation_server/src/repositories/users/users.repository.service.ts index 0331bcc..83268f7 100644 --- a/dictation_server/src/repositories/users/users.repository.service.ts +++ b/dictation_server/src/repositories/users/users.repository.service.ts @@ -648,6 +648,23 @@ export class UsersRepositoryService { return originAccount.delegation_permission; }); } + /** + * ユーザーに割り当てられているライセンスを取得する + * @param userId ユーザーID + * @returns License + */ + async findLicenseByUserId(userId: number): Promise { + const allocatedLicense = await this.dataSource + .getRepository(License) + .findOne({ + where: { + allocated_user_id: userId, + status: LICENSE_ALLOCATED_STATUS.ALLOCATED, + }, + }); + + return allocatedLicense; + } /** * ユーザーに紐づく各種情報を取得する