diff --git a/src/components/zod/custom-types/index.ts b/src/components/zod/custom-types/index.ts index d673f939..9771f347 100644 --- a/src/components/zod/custom-types/index.ts +++ b/src/components/zod/custom-types/index.ts @@ -7,3 +7,4 @@ export * from './json-string'; export * from './entity-name'; export * from './tenant-settings'; export * from './primitive'; +export * from './string-array'; diff --git a/src/components/zod/custom-types/string-array.ts b/src/components/zod/custom-types/string-array.ts new file mode 100644 index 00000000..ce88eb4d --- /dev/null +++ b/src/components/zod/custom-types/string-array.ts @@ -0,0 +1,8 @@ +import {z} from 'zod'; + +export const stringArray = ({min = 0, max = Infinity}: {min?: number; max?: number}) => { + return z + .union([z.string(), z.array(z.string())]) + .transform((val) => (typeof val === 'string' ? [val] : val)) + .pipe(z.array(z.string()).min(min).max(max)); +}; diff --git a/src/controllers/entries/delete-entry.ts b/src/controllers/entries/delete-entry.ts index 566bba38..3a4b4d05 100644 --- a/src/controllers/entries/delete-entry.ts +++ b/src/controllers/entries/delete-entry.ts @@ -2,6 +2,7 @@ import {AppRouteHandler} from '@gravity-ui/expresskit'; import {prepareResponseAsync} from '../../components/response-presenter'; import {makeReqParser, z, zc} from '../../components/zod'; +import {EntryScope} from '../../db/models/new/entry/types'; import {LogEventType} from '../../registry/common/utils/log-event/types'; import {deleteEntry} from '../../services/entry'; @@ -11,6 +12,8 @@ const requestSchema = { }), query: z.object({ lockToken: z.string().optional(), + scope: z.nativeEnum(EntryScope).optional(), + types: zc.stringArray({min: 1, max: 100}).optional(), }), }; @@ -30,6 +33,8 @@ export const deleteEntryController: AppRouteHandler = async (req, res) => { { entryId: params.entryId, lockToken: query.lockToken, + scope: query.scope, + types: query.types, }, ); diff --git a/src/services/entry/actions/delete-entry.ts b/src/services/entry/actions/delete-entry.ts index 9adbb1f2..447ef795 100644 --- a/src/services/entry/actions/delete-entry.ts +++ b/src/services/entry/actions/delete-entry.ts @@ -2,11 +2,18 @@ import {AppError} from '@gravity-ui/nodekit'; import {transaction} from 'objection'; import {makeSchemaValidator} from '../../../components/validation-schema-compiler'; -import {BiTrackingLogs, DEFAULT_QUERY_TIMEOUT, RETURN_COLUMNS, US_ERRORS} from '../../../const'; +import { + ALLOWED_SCOPE_VALUES, + BiTrackingLogs, + DEFAULT_QUERY_TIMEOUT, + RETURN_COLUMNS, + US_ERRORS, +} from '../../../const'; import Entry from '../../../db/models/entry'; import Lock from '../../../db/models/lock'; +import {EntryColumn} from '../../../db/models/new/entry'; import {WorkbookPermission} from '../../../entities/workbook'; -import {DlsActions, EntryColumns, UsPermissions} from '../../../types/models'; +import {DlsActions, EntryColumns, EntryScope, UsPermissions} from '../../../types/models'; import Utils, {makeUserId} from '../../../utils'; import {ServiceArgs} from '../../new/types'; import {getWorkbook} from '../../new/workbook/get-workbook'; @@ -28,6 +35,16 @@ const validateArgs = makeSchemaValidator({ useLegacyLogin: { type: 'boolean', }, + scope: { + type: 'string', + enum: ALLOWED_SCOPE_VALUES, + }, + types: { + type: 'array', + items: { + type: 'string', + }, + }, }, }); @@ -35,13 +52,15 @@ export type DeleteEntryData = { entryId: string; lockToken?: string; useLegacyLogin?: boolean; + scope?: EntryScope; + types?: string[]; }; export async function deleteEntry( {ctx, skipValidation = false}: ServiceArgs, args: DeleteEntryData, ) { - const {entryId, lockToken, useLegacyLogin = false} = args; + const {entryId, lockToken, useLegacyLogin = false, scope, types} = args; ctx.log('DELETE_ENTRY_REQUEST', { entryId: Utils.encodeId(entryId), @@ -66,6 +85,15 @@ export async function deleteEntry( entryId, isDeleted: false, }) + .where((builder) => { + if (scope) { + builder.andWhere({[`${Entry.tableName}.${EntryColumn.Scope}`]: scope}); + } + + if (types) { + builder.whereIn([`${Entry.tableName}.${EntryColumn.Type}`], types); + } + }) .first() .timeout(DEFAULT_QUERY_TIMEOUT); diff --git a/src/tests/int/env/opensource/suites/entries/delete-entry.test.ts b/src/tests/int/env/opensource/suites/entries/delete-entry.test.ts new file mode 100644 index 00000000..91b0e5b7 --- /dev/null +++ b/src/tests/int/env/opensource/suites/entries/delete-entry.test.ts @@ -0,0 +1,118 @@ +import request from 'supertest'; + +import {routes} from '../../../../routes'; +import {app, auth} from '../../auth'; +import {createMockWorkbook, createMockWorkbookEntry} from '../../helpers'; +import {OpensourceRole} from '../../roles'; + +let workbookId: string; +let workbookEntryId: string; + +const notExistingEntryId = 'fvsb9zbfkqos2'; + +describe('Delete entry', () => { + test('[Setup test data] Create workbook and entry for deletion', async () => { + const workbook = await createMockWorkbook({title: 'Workbook for deletion'}); + workbookId = workbook.workbookId; + + const workbookEntry = await createMockWorkbookEntry({ + name: 'Entry for deletion', + workbookId: workbook.workbookId, + scope: 'dash', + type: 'wizard-widget', + }); + + workbookEntryId = workbookEntry.entryId; + }); + + test('Delete entry without auth error', async () => { + await request(app).delete(`${routes.entries}/${workbookEntryId}`).expect(401); + }); + + test('Delete non-existing entry error', async () => { + await auth(request(app).delete(`${routes.entries}/${notExistingEntryId}`), { + role: OpensourceRole.Editor, + }).expect(404); + }); + + test('Delete entry with read-only permissions error', async () => { + await auth(request(app).delete(`${routes.entries}/${workbookEntryId}`), { + role: OpensourceRole.Viewer, + }).expect(403); + }); + + test('Delete entry with wrong filter by scope. Entry is not found', async () => { + await auth( + request(app).delete(`${routes.entries}/${workbookEntryId}`).query({ + scope: 'dataset', + }), + { + role: OpensourceRole.Editor, + }, + ).expect(404); + }); + + test('Delete entry with wrong filter by types. Entry is not found', async () => { + await auth( + request(app) + .delete(`${routes.entries}/${workbookEntryId}`) + .query({ + types: ['wizard-chart', 'wizard-node'], + }), + { + role: OpensourceRole.Editor, + }, + ).expect(404); + }); + + test('Successfully delete entry with filters by scope and types', async () => { + const newEntry = await createMockWorkbookEntry({ + name: 'Entry for scope and types test', + workbookId: workbookId, + scope: 'dash', + type: 'wizard-widget', + }); + + const response = await auth( + request(app) + .delete(`${routes.entries}/${newEntry.entryId}`) + .query({ + scope: 'dash', + types: ['wizard-widget'], + }), + { + role: OpensourceRole.Editor, + }, + ).expect(200); + + expect(response.body.isDeleted).toBe(true); + }); + + test('Successfully delete entry', async () => { + const response = await auth(request(app).delete(`${routes.entries}/${workbookEntryId}`), { + role: OpensourceRole.Editor, + }).expect(200); + + expect(response.body.isDeleted).toBe(true); + + await auth(request(app).get(`${routes.entries}/${workbookEntryId}`), { + role: OpensourceRole.Editor, + }).expect(404); + }); + + test('Recover deleted entry', async () => { + const response = await auth(request(app).post(`${routes.entries}/${workbookEntryId}`), { + role: OpensourceRole.Editor, + }) + .send({ + mode: 'recover', + }) + .expect(200); + + expect(response.body.isDeleted).toBeFalsy(); + + await auth(request(app).get(`${routes.entries}/${workbookEntryId}`), { + role: OpensourceRole.Editor, + }).expect(200); + }); +}); diff --git a/src/tests/int/env/platform/suites/entries/delete-entry.test.ts b/src/tests/int/env/platform/suites/entries/delete-entry.test.ts new file mode 100644 index 00000000..95dc0649 --- /dev/null +++ b/src/tests/int/env/platform/suites/entries/delete-entry.test.ts @@ -0,0 +1,151 @@ +import request from 'supertest'; + +import {routes} from '../../../../routes'; +import {app, auth, getWorkbookBinding} from '../../auth'; +import {createMockWorkbook, createMockWorkbookEntry} from '../../helpers'; + +let workbookId: string; +let workbookEntryId: string; + +const notExistingEntryId = 'fvsb9zbfkqos2'; + +describe('Delete entry', () => { + beforeAll(async () => { + const workbook = await createMockWorkbook({title: 'Workbook for deletion'}); + workbookId = workbook.workbookId; + + const workbookEntry = await createMockWorkbookEntry({ + name: 'Entry for deletion', + workbookId: workbook.workbookId, + scope: 'dash', + type: 'wizard-widget', + }); + + workbookEntryId = workbookEntry.entryId; + }); + + test('Delete entry without auth error', async () => { + await request(app).delete(`${routes.entries}/${workbookEntryId}`).expect(401); + }); + + test('Delete non-existing entry error', async () => { + await auth(request(app).delete(`${routes.entries}/${notExistingEntryId}`), { + accessBindings: [ + getWorkbookBinding(workbookId, 'limitedView'), + getWorkbookBinding(workbookId, 'view'), + getWorkbookBinding(workbookId, 'update'), + ], + }).expect(404); + }); + + test('Delete entry with read-only permissions error', async () => { + await auth(request(app).delete(`${routes.entries}/${workbookEntryId}`), { + accessBindings: [ + getWorkbookBinding(workbookId, 'limitedView'), + getWorkbookBinding(workbookId, 'view'), + ], + }).expect(403); + }); + + test('Delete entry with wrong filter by scope. Entry is not found', async () => { + await auth( + request(app).delete(`${routes.entries}/${workbookEntryId}`).query({ + scope: 'dataset', + }), + { + accessBindings: [ + getWorkbookBinding(workbookId, 'limitedView'), + getWorkbookBinding(workbookId, 'view'), + getWorkbookBinding(workbookId, 'update'), + ], + }, + ).expect(404); + }); + + test('Delete entry with wrong filter by types. Entry is not found', async () => { + await auth( + request(app) + .delete(`${routes.entries}/${workbookEntryId}`) + .query({ + types: ['wizard-chart', 'wizard-node'], + }), + { + accessBindings: [ + getWorkbookBinding(workbookId, 'limitedView'), + getWorkbookBinding(workbookId, 'view'), + getWorkbookBinding(workbookId, 'update'), + ], + }, + ).expect(404); + }); + + test('Successfully delete entry with filters by scope and types', async () => { + const newEntry = await createMockWorkbookEntry({ + name: 'Entry for scope and types test', + workbookId: workbookId, + scope: 'dash', + type: 'wizard-widget', + }); + + const response = await auth( + request(app) + .delete(`${routes.entries}/${newEntry.entryId}`) + .query({ + scope: 'dash', + types: ['wizard-widget'], + }), + { + accessBindings: [ + getWorkbookBinding(workbookId, 'limitedView'), + getWorkbookBinding(workbookId, 'view'), + getWorkbookBinding(workbookId, 'update'), + ], + }, + ).expect(200); + + expect(response.body.isDeleted).toBe(true); + }); + + test('Successfully delete entry', async () => { + const response = await auth(request(app).delete(`${routes.entries}/${workbookEntryId}`), { + accessBindings: [ + getWorkbookBinding(workbookId, 'limitedView'), + getWorkbookBinding(workbookId, 'view'), + getWorkbookBinding(workbookId, 'update'), + ], + }).expect(200); + + expect(response.body.isDeleted).toBe(true); + + await auth(request(app).get(`${routes.entries}/${workbookEntryId}`), { + accessBindings: [ + getWorkbookBinding(workbookId, 'limitedView'), + getWorkbookBinding(workbookId, 'view'), + getWorkbookBinding(workbookId, 'update'), + ], + }).expect(404); + }); + + test('Recover deleted entry', async () => { + const response = await auth(request(app).post(`${routes.entries}/${workbookEntryId}`), { + accessBindings: [ + getWorkbookBinding(workbookId, 'limitedView'), + getWorkbookBinding(workbookId, 'view'), + getWorkbookBinding(workbookId, 'update'), + ], + }) + .send({ + mode: 'recover', + }) + .expect(200); + + expect(response.body.isDeleted).toBeFalsy(); + + await auth(request(app).get(`${routes.entries}/${workbookEntryId}`), { + accessBindings: [ + getWorkbookBinding(workbookId, 'limitedView'), + getWorkbookBinding(workbookId, 'view'), + ], + }).expect(200); + }); +});