Merged PR 573: 音声ファイルアップロードAPI修正

## 概要
[Task3069: 音声ファイルアップロードAPI修正](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3069)
[Task3070: 音声ファイルダウンロードAPI修正](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3070)
[Task3071: テンプレートファイルダウンロードAPI修正](https://paruru.nds-tyo.co.jp:8443/tfs/ReciproCollection/fa4924a4-d079-4fab-9fb5-a9a11eb205f0/_workitems/edit/3071)
修正内容かぶるため、3本まとめてレビューお願いします。
- 元PBI or タスクへのリンク(内容・目的などはそちらにあるはず)
- 音声ファイルアップロードAPIを修正
 ・第五階層の場合のみチェックを追加
  ・アカウントがロックされている場合、エラー
  ・ユーザーにライセンスが未割当の場合、エラー
  ・ユーザーに紐づいたライセンスが有効期限切れの場合、エラー
- 音声ファイルダウンロード、テンプレートファイルダウンロードAPIを修正
 ・第五階層の場合のみチェックを追加
  ・ユーザーにライセンスが未割当の場合、エラー
  ・ユーザーに紐づいたライセンスが有効期限切れの場合、エラー
- 外部連携アプリ側の挙動の変化については考慮しない。
- ログ強化は別タスクで対応中。
- 影響範囲(他の機能にも影響があるか)
ファイル操作以外は影響なし。
旧式のユニットテストを修正。

## レビューポイント
- 音声ファイルアップロードのユニットテストを最新の状態にしたが、不足していないか。
~~- users.repositoryにユーザに紐づくライセンスが現在有効かどうかの判定を入れ込み、共通的に呼び出すようにしたが使いづらくないか(ライセンスが紐づいていない場合と有効期限切れの場合エラーとし、それ以外はtrueが帰る点について)~~

## UIの変更
- Before/Afterのスクショなど
- スクショ置き場

## 動作確認状況
- ローカルで確認

## 補足
- 相談、参考資料などがあれば
This commit is contained in:
maruyama.t 2023-11-14 13:16:46 +00:00
parent 40162ef3af
commit eb3c7e55bd
13 changed files with 850 additions and 90 deletions

View File

@ -2077,6 +2077,14 @@
} }
} }
}, },
"400": {
"description": "不正なパラメータ",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/ErrorResponse" }
}
}
},
"401": { "401": {
"description": "認証エラー", "description": "認証エラー",
"content": { "content": {

View File

@ -39,6 +39,7 @@ export const ErrorCodes = [
'E010501', // アカウント不在エラー 'E010501', // アカウント不在エラー
'E010502', // アカウント情報変更不可エラー 'E010502', // アカウント情報変更不可エラー
'E010503', // 代行操作不許可エラー 'E010503', // 代行操作不許可エラー
'E010504', // アカウントロックエラー
'E010601', // タスク変更不可エラー(タスクが変更できる状態でない、またはタスクが存在しない) 'E010601', // タスク変更不可エラー(タスクが変更できる状態でない、またはタスクが存在しない)
'E010602', // タスク変更権限不足エラー 'E010602', // タスク変更権限不足エラー
'E010603', // タスク不在エラー 'E010603', // タスク不在エラー
@ -54,6 +55,7 @@ export const ErrorCodes = [
'E010809', // ライセンス発行キャンセル不可エラー(ステータスが変えられている場合) 'E010809', // ライセンス発行キャンセル不可エラー(ステータスが変えられている場合)
'E010810', // ライセンス発行キャンセル不可エラー(発行から一定期間経過した場合) 'E010810', // ライセンス発行キャンセル不可エラー(発行から一定期間経過した場合)
'E010811', // ライセンス発行キャンセル不可エラー(発行したライセンスが割り当てされている場合) 'E010811', // ライセンス発行キャンセル不可エラー(発行したライセンスが割り当てされている場合)
'E010812', // ライセンス未割当エラー
'E010908', // タイピストグループ不在エラー 'E010908', // タイピストグループ不在エラー
'E011001', // ワークタイプ重複エラー 'E011001', // ワークタイプ重複エラー
'E011002', // ワークタイプ登録上限超過エラー 'E011002', // ワークタイプ登録上限超過エラー

View File

@ -28,6 +28,7 @@ export const errors: Errors = {
E010501: 'Account not Found Error.', E010501: 'Account not Found Error.',
E010502: 'Account information cannot be changed Error.', E010502: 'Account information cannot be changed Error.',
E010503: 'Delegation not allowed Error.', E010503: 'Delegation not allowed Error.',
E010504: 'Account is locked Error.',
E010601: 'Task is not Editable Error', E010601: 'Task is not Editable Error',
E010602: 'No task edit permissions Error', E010602: 'No task edit permissions Error',
E010603: 'Task not found Error.', E010603: 'Task not found Error.',
@ -43,6 +44,7 @@ export const errors: Errors = {
E010809: 'Already license order status changed Error', E010809: 'Already license order status changed Error',
E010810: 'Cancellation period expired error', E010810: 'Cancellation period expired error',
E010811: 'Already license allocated Error', E010811: 'Already license allocated Error',
E010812: 'License not allocated Error',
E010908: 'Typist Group not exist Error', E010908: 'Typist Group not exist Error',
E011001: 'This WorkTypeID already used Error', E011001: 'This WorkTypeID already used Error',
E011002: 'WorkTypeID create limit exceeded Error', E011002: 'WorkTypeID create limit exceeded Error',

View File

@ -185,6 +185,11 @@ export const overrideBlobstorageService = <TService>(
accountId: number, accountId: number,
country: string, country: string,
) => Promise<boolean>; ) => Promise<boolean>;
publishUploadSas?: (
context: Context,
accountId: number,
country: string,
) => Promise<string>;
publishTemplateUploadSas?: ( publishTemplateUploadSas?: (
context: Context, context: Context,
accountId: number, accountId: number,
@ -212,6 +217,12 @@ export const overrideBlobstorageService = <TService>(
writable: true, writable: true,
}); });
} }
if (overrides.publishUploadSas) {
Object.defineProperty(obj, obj.publishUploadSas.name, {
value: overrides.publishUploadSas,
writable: true,
});
}
if (overrides.publishTemplateUploadSas) { if (overrides.publishTemplateUploadSas) {
Object.defineProperty(obj, obj.publishTemplateUploadSas.name, { Object.defineProperty(obj, obj.publishTemplateUploadSas.name, {
value: overrides.publishTemplateUploadSas, value: overrides.publishTemplateUploadSas,

View File

@ -39,11 +39,15 @@ import { retrieveAuthorizationToken } from '../../common/http/helper';
import { Request } from 'express'; import { Request } from 'express';
import { makeContext } from '../../common/log'; import { makeContext } from '../../common/log';
import { makeErrorResponse } from '../../common/error/makeErrorResponse'; import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { LicensesService } from '../licenses/licenses.service';
@ApiTags('files') @ApiTags('files')
@Controller('files') @Controller('files')
export class FilesController { export class FilesController {
constructor(private readonly filesService: FilesService) {} constructor(
private readonly filesService: FilesService,
private readonly licensesService: LicensesService,
) {}
@ApiResponse({ @ApiResponse({
status: HttpStatus.OK, status: HttpStatus.OK,
@ -140,6 +144,11 @@ export class FilesController {
type: AudioUploadLocationResponse, type: AudioUploadLocationResponse,
description: '成功時のレスポンス', description: '成功時のレスポンス',
}) })
@ApiResponse({
status: HttpStatus.BAD_REQUEST,
description: '不正なパラメータ',
type: ErrorResponse,
})
@ApiResponse({ @ApiResponse({
status: HttpStatus.UNAUTHORIZED, status: HttpStatus.UNAUTHORIZED,
description: '認証エラー', description: '認証エラー',

View File

@ -1,13 +1,9 @@
import { HttpException, HttpStatus } from '@nestjs/common'; import { HttpException, HttpStatus } from '@nestjs/common';
import { makeErrorResponse } from '../../common/error/makeErrorResponse'; import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { import { makeBlobstorageServiceMockValue } from './test/files.service.mock';
makeBlobstorageServiceMockValue,
makeDefaultTasksRepositoryMockValue,
makeDefaultUsersRepositoryMockValue,
makeFilesServiceMock,
} from './test/files.service.mock';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { import {
createLicense,
createTask, createTask,
createUserGroupAndMember, createUserGroupAndMember,
getTaskFromJobNumber, getTaskFromJobNumber,
@ -16,6 +12,7 @@ import {
import { FilesService } from './files.service'; import { FilesService } from './files.service';
import { makeContext } from '../../common/log'; import { makeContext } from '../../common/log';
import { import {
makeHierarchicalAccounts,
makeTestAccount, makeTestAccount,
makeTestSimpleAccount, makeTestSimpleAccount,
makeTestUser, makeTestUser,
@ -37,89 +34,243 @@ import { TasksRepositoryService } from '../../repositories/tasks/tasks.repositor
import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service'; import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service';
import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage'; import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage';
import { getCheckoutPermissions, getTask } from '../tasks/test/utility'; import { getCheckoutPermissions, getTask } from '../tasks/test/utility';
import { DateWithZeroTime } from '../licenses/types/types';
import { LICENSE_ALLOCATED_STATUS, LICENSE_TYPE } from '../../constants';
describe('音声ファイルアップロードURL取得', () => { describe('publishUploadSas', () => {
it('アップロードSASトークンが乗っているURLを返却する', async () => { let source: DataSource | null = null;
const blobParam = makeBlobstorageServiceMockValue(); beforeEach(async () => {
const userRepoParam = makeDefaultUsersRepositoryMockValue(); source = new DataSource({
const taskRepoParam = makeDefaultTasksRepositoryMockValue(); type: 'sqlite',
const service = await makeFilesServiceMock( database: ':memory:',
blobParam, logging: false,
userRepoParam, entities: [__dirname + '/../../**/*.entity{.ts,.js}'],
taskRepoParam, synchronize: true, // trueにすると自動的にmigrationが行われるため注意
); });
return source.initialize();
expect(
await service.publishUploadSas(
makeContext('trackingId'),
'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx',
),
).toEqual('https://blob-storage?sas-token');
}); });
it('アカウント専用コンテナが無い場合でも、コンテナ作成しURLを返却する', async () => { afterEach(async () => {
const blobParam = makeBlobstorageServiceMockValue(); if (!source) return;
const userRepoParam = makeDefaultUsersRepositoryMockValue(); await source.destroy();
const taskRepoParam = makeDefaultTasksRepositoryMockValue(); source = null;
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');
}); });
it('ユーザー情報の取得に失敗した場合、例外エラーを返却する', async () => { it('音声アップロードSASトークンが乗っているURLを取得できる', async () => {
const blobParam = makeBlobstorageServiceMockValue(); if (!source) fail();
const taskRepoParam = makeDefaultTasksRepositoryMockValue(); const module = await makeTestingModule(source);
if (!module) fail();
const service = module.get<FilesService>(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( //SASトークンを返却する
blobParam, overrideBlobstorageService(service, {
{ containerExists: async () => true,
findUserByExternalId: new Error(''), 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>(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>(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>(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>(FilesService);
await expect( await expect(
service.publishUploadSas( service.publishUploadSas(makeContext('trackingId'), externalId),
makeContext('trackingId'),
'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx',
),
).rejects.toEqual( ).rejects.toEqual(
new HttpException(makeErrorResponse('E009999'), HttpStatus.UNAUTHORIZED), new HttpException(makeErrorResponse('E010812'), HttpStatus.BAD_REQUEST),
); );
}); });
it('アップロード時にユーザーに割り当てられたライセンスが有効期限切れの場合エラー(第五階層限定)', async () => {
it('コンテナ作成に失敗した場合、例外エラーを返却する', async () => { if (!source) fail();
const blobParam = makeBlobstorageServiceMockValue(); // 第五階層のアカウントまで作成し、そのアカウントに紐づくユーザーを作成する
const taskRepoParam = makeDefaultTasksRepositoryMockValue(); const { tier4Accounts: tier4Accounts } = await makeHierarchicalAccounts(
source,
const service = await makeFilesServiceMock(
blobParam,
{
findUserByExternalId: new Error(''),
},
taskRepoParam,
); );
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>(FilesService);
await expect( await expect(
service.publishUploadSas( service.publishUploadSas(makeContext('trackingId'), externalId),
makeContext('trackingId'),
'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxx',
),
).rejects.toEqual( ).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`); ).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>(FilesService);
expect(
await service.publishAudioFileDownloadSas(
makeContext('trackingId'),
externalId,
audioFileId,
),
).toEqual(`${url}?sas-token`);
});
it('Typistの場合、タスクのステータスが[Inprogress,Pending]以外でエラー', async () => { it('Typistの場合、タスクのステータスが[Inprogress,Pending]以外でエラー', async () => {
if (!source) fail(); if (!source) fail();
const { id: accountId } = await makeTestSimpleAccount(source); const { id: accountId } = await makeTestSimpleAccount(source);
@ -1109,6 +1329,133 @@ describe('音声ファイルダウンロードURL取得', () => {
new HttpException(makeErrorResponse('E010701'), HttpStatus.BAD_REQUEST), 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>(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>(FilesService);
await expect(
service.publishAudioFileDownloadSas(
makeContext('trackingId'),
externalId,
audioFileId,
),
).rejects.toEqual(
new HttpException(makeErrorResponse('E010805'), HttpStatus.BAD_REQUEST),
);
});
}); });
describe('テンプレートファイルダウンロードURL取得', () => { describe('テンプレートファイルダウンロードURL取得', () => {
@ -1175,7 +1522,76 @@ describe('テンプレートファイルダウンロードURL取得', () => {
), ),
).toEqual(`${url}?sas-token`); ).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>(FilesService);
expect(
await service.publishTemplateFileDownloadSas(
makeContext('trackingId'),
externalId,
audioFileId,
),
).toEqual(`${url}?sas-token`);
});
it('Typistの場合、タスクのステータスが[Inprogress,Pending]以外でエラー', async () => { it('Typistの場合、タスクのステータスが[Inprogress,Pending]以外でエラー', async () => {
if (!source) fail(); if (!source) fail();
const { id: accountId } = await makeTestSimpleAccount(source); const { id: accountId } = await makeTestSimpleAccount(source);
@ -1394,6 +1810,133 @@ describe('テンプレートファイルダウンロードURL取得', () => {
new HttpException(makeErrorResponse('E010701'), HttpStatus.BAD_REQUEST), 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>(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>(FilesService);
await expect(
service.publishTemplateFileDownloadSas(
makeContext('trackingId'),
externalId,
audioFileId,
),
).rejects.toEqual(
new HttpException(makeErrorResponse('E010805'), HttpStatus.BAD_REQUEST),
);
});
}); });
describe('publishTemplateFileUploadSas', () => { describe('publishTemplateFileUploadSas', () => {

View File

@ -7,6 +7,7 @@ import { AudioOptionItem, AudioUploadFinishedResponse } from './types/types';
import { import {
OPTION_ITEM_NUM, OPTION_ITEM_NUM,
TASK_STATUS, TASK_STATUS,
TIERS,
USER_ROLES, USER_ROLES,
} from '../../constants/index'; } from '../../constants/index';
import { User } from '../../repositories/users/entity/user.entity'; import { User } from '../../repositories/users/entity/user.entity';
@ -23,11 +24,19 @@ import {
} from '../../repositories/tasks/errors/types'; } from '../../repositories/tasks/errors/types';
import { Context } from '../../common/log'; import { Context } from '../../common/log';
import { TemplateFilesRepositoryService } from '../../repositories/template_files/template_files.repository.service'; 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 { Task } from '../../repositories/tasks/entity/task.entity';
import { UserGroupsRepositoryService } from '../../repositories/user_groups/user_groups.repository.service'; import { UserGroupsRepositoryService } from '../../repositories/user_groups/user_groups.repository.service';
import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage'; import { makeNotifyMessage } from '../../common/notify/makeNotifyMessage';
import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service'; import { NotificationhubService } from '../../gateways/notificationhub/notificationhub.service';
import {
LicenseExpiredError,
LicenseNotAllocatedError,
} from '../../repositories/licenses/errors/types';
import { DateWithZeroTime } from '../licenses/types/types';
@Injectable() @Injectable()
export class FilesService { export class FilesService {
@ -269,7 +278,20 @@ export class FilesService {
} }
const accountId = user.account_id; const accountId = user.account_id;
const country = user.account.country; 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ストレージにコンテナが存在するか確認 // 国に応じたリージョンのBlobストレージにコンテナが存在するか確認
await this.blobStorageService.containerExists( await this.blobStorageService.containerExists(
context, context,
@ -286,10 +308,28 @@ export class FilesService {
return url; return url;
} catch (e) { } catch (e) {
this.logger.error(`error=${e}`); this.logger.error(`error=${e}`);
throw new HttpException( switch (e.constructor) {
makeErrorResponse('E009999'), case AccountLockedError:
HttpStatus.INTERNAL_SERVER_ERROR, 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 { } finally {
this.logger.log( this.logger.log(
`[OUT] [${context.trackingId}] ${this.publishUploadSas.name}`, `[OUT] [${context.trackingId}] ${this.publishUploadSas.name}`,
@ -323,6 +363,16 @@ export class FilesService {
if (!user.account) { if (!user.account) {
throw new AccountNotFoundError('account not found.'); 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; accountId = user.account.id;
userId = user.id; userId = user.id;
country = user.account.country; country = user.account.country;
@ -330,14 +380,26 @@ export class FilesService {
authorId = user.author_id ?? undefined; authorId = user.author_id ?? undefined;
} catch (e) { } catch (e) {
this.logger.error(`error=${e}`); this.logger.error(`error=${e}`);
this.logger.log( this.logger.log(
`[OUT] [${context.trackingId}] ${this.publishAudioFileDownloadSas.name}`, `[OUT] [${context.trackingId}] ${this.publishAudioFileDownloadSas.name}`,
); );
throw new HttpException( switch (e.constructor) {
makeErrorResponse('E009999'), case LicenseExpiredError:
HttpStatus.INTERNAL_SERVER_ERROR, 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 { try {
@ -460,6 +522,16 @@ export class FilesService {
if (!user.account) { if (!user.account) {
throw new AccountNotFoundError('account not found.'); 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; accountId = user.account_id;
userId = user.id; userId = user.id;
country = user.account.country; country = user.account.country;
@ -470,10 +542,23 @@ export class FilesService {
this.logger.log( this.logger.log(
`[OUT] [${context.trackingId}] ${this.publishTemplateFileDownloadSas.name}`, `[OUT] [${context.trackingId}] ${this.publishTemplateFileDownloadSas.name}`,
); );
throw new HttpException( switch (e.constructor) {
makeErrorResponse('E009999'), case LicenseExpiredError:
HttpStatus.INTERNAL_SERVER_ERROR, 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 { try {
@ -562,6 +647,7 @@ export class FilesService {
makeErrorResponse('E010701'), makeErrorResponse('E010701'),
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
default: default:
throw new HttpException( throw new HttpException(
makeErrorResponse('E009999'), 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;
}
}
} }

View File

@ -19,6 +19,7 @@ export type BlobstorageServiceMockValue = {
export type UsersRepositoryMockValue = { export type UsersRepositoryMockValue = {
findUserByExternalId: User | Error; findUserByExternalId: User | Error;
isUserLicenseValid: boolean | Error;
}; };
export type TasksRepositoryMockValue = { export type TasksRepositoryMockValue = {
@ -91,13 +92,17 @@ export const makeBlobstorageServiceMock = (
}; };
export const makeUsersRepositoryMock = (value: UsersRepositoryMockValue) => { export const makeUsersRepositoryMock = (value: UsersRepositoryMockValue) => {
const { findUserByExternalId } = value; const { findUserByExternalId, isUserLicenseValid } = value;
return { return {
findUserByExternalId: findUserByExternalId:
findUserByExternalId instanceof Error findUserByExternalId instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(findUserByExternalId) ? jest.fn<Promise<void>, []>().mockRejectedValue(findUserByExternalId)
: jest.fn<Promise<User>, []>().mockResolvedValue(findUserByExternalId), : jest.fn<Promise<User>, []>().mockResolvedValue(findUserByExternalId),
isUserLicenseValid:
isUserLicenseValid instanceof Error
? jest.fn<Promise<void>, []>().mockRejectedValue(isUserLicenseValid)
: jest.fn<Promise<boolean>, []>().mockResolvedValue(isUserLicenseValid),
}; };
}; };
@ -169,6 +174,7 @@ export const makeDefaultUsersRepositoryMockValue =
user: null, user: null,
}, },
}, },
isUserLicenseValid: true,
}; };
}; };

View File

@ -43,6 +43,7 @@ import {
} from '../../tasks/test/tasks.service.mock'; } from '../../tasks/test/tasks.service.mock';
import { UserGroup } from '../../../repositories/user_groups/entity/user_group.entity'; import { UserGroup } from '../../../repositories/user_groups/entity/user_group.entity';
import { UserGroupMember } from '../../../repositories/user_groups/entity/user_group_member.entity'; import { UserGroupMember } from '../../../repositories/user_groups/entity/user_group_member.entity';
import { License } from '../../../repositories/licenses/entity/license.entity';
export const createTask = async ( export const createTask = async (
datasource: DataSource, datasource: DataSource,
@ -205,3 +206,33 @@ export const makeTestingModuleWithBlobAndNotification = async (
console.log(e); 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<void> => {
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;
};

View File

@ -1,6 +1,5 @@
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import { makeErrorResponse } from '../../common/error/makeErrorResponse'; import { makeErrorResponse } from '../../common/error/makeErrorResponse';
import { AccessToken } from '../../common/token';
import { UsersRepositoryService } from '../../repositories/users/users.repository.service'; import { UsersRepositoryService } from '../../repositories/users/users.repository.service';
import { AccountsRepositoryService } from '../../repositories/accounts/accounts.repository.service'; import { AccountsRepositoryService } from '../../repositories/accounts/accounts.repository.service';
import { AccountNotFoundError } from '../../repositories/accounts/errors/types'; import { AccountNotFoundError } from '../../repositories/accounts/errors/types';
@ -13,6 +12,7 @@ import {
import { LicensesRepositoryService } from '../../repositories/licenses/licenses.repository.service'; import { LicensesRepositoryService } from '../../repositories/licenses/licenses.repository.service';
import { UserNotFoundError } from '../../repositories/users/errors/types'; import { UserNotFoundError } from '../../repositories/users/errors/types';
import { import {
DateWithZeroTime,
GetAllocatableLicensesResponse, GetAllocatableLicensesResponse,
IssueCardLicensesResponse, IssueCardLicensesResponse,
} from './types/types'; } from './types/types';

View File

@ -4,3 +4,5 @@ export class AccountNotFoundError extends Error {}
export class DealerAccountNotFoundError extends Error {} export class DealerAccountNotFoundError extends Error {}
// 管理者ユーザ未存在エラー // 管理者ユーザ未存在エラー
export class AdminUserNotFoundError extends Error {} export class AdminUserNotFoundError extends Error {}
// アカウントロックエラー
export class AccountLockedError extends Error {}

View File

@ -33,3 +33,6 @@ export class CancellationPeriodExpiredError extends Error {}
// ライセンス発行キャンセル不可エラー(発行したライセンスが割り当てされている場合) // ライセンス発行キャンセル不可エラー(発行したライセンスが割り当てされている場合)
export class AlreadyLicenseAllocatedError extends Error {} export class AlreadyLicenseAllocatedError extends Error {}
// ライセンス未割当エラー
export class LicenseNotAllocatedError extends Error {}

View File

@ -648,6 +648,23 @@ export class UsersRepositoryService {
return originAccount.delegation_permission; return originAccount.delegation_permission;
}); });
} }
/**
*
* @param userId ID
* @returns License
*/
async findLicenseByUserId(userId: number): Promise<License | null> {
const allocatedLicense = await this.dataSource
.getRepository(License)
.findOne({
where: {
allocated_user_id: userId,
status: LICENSE_ALLOCATED_STATUS.ALLOCATED,
},
});
return allocatedLicense;
}
/** /**
* *