Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/zod/custom-types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './json-string';
export * from './entity-name';
export * from './tenant-settings';
export * from './primitive';
export * from './string-array';
8 changes: 8 additions & 0 deletions src/components/zod/custom-types/string-array.ts
Original file line number Diff line number Diff line change
@@ -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));
};
5 changes: 5 additions & 0 deletions src/controllers/entries/delete-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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(),
}),
};

Expand All @@ -30,6 +33,8 @@ export const deleteEntryController: AppRouteHandler = async (req, res) => {
{
entryId: params.entryId,
lockToken: query.lockToken,
scope: query.scope,
types: query.types,
},
);

Expand Down
34 changes: 31 additions & 3 deletions src/services/entry/actions/delete-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,20 +35,32 @@ const validateArgs = makeSchemaValidator({
useLegacyLogin: {
type: 'boolean',
},
scope: {
type: 'string',
enum: ALLOWED_SCOPE_VALUES,
},
types: {
type: 'array',
items: {
type: 'string',
},
},
},
});

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),
Expand All @@ -66,6 +85,15 @@ export async function deleteEntry(
entryId,
isDeleted: false,
})
.where((builder) => {
if (scope) {
builder.andWhere({[`${Entry.tableName}.${EntryColumn.Scope}`]: scope});
}

if (types) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it work good without index on entries.type
I know that we also don't have index on isDeleted but it's boolean

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes index scan on entryId and then filters the result, it's 1 row maximum

https://explain.tensor.ru/archive/explain/69ee14e376c7bbf98863ee3c2327e878:0:2025-09-16

builder.whereIn([`${Entry.tableName}.${EntryColumn.Type}`], types);
}
})
.first()
.timeout(DEFAULT_QUERY_TIMEOUT);

Expand Down
118 changes: 118 additions & 0 deletions src/tests/int/env/opensource/suites/entries/delete-entry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import request from 'supertest';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add tests to /env/platform/suites/... also?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added


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);
});
});
151 changes: 151 additions & 0 deletions src/tests/int/env/platform/suites/entries/delete-entry.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading