Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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: 0, 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);
});
});
Loading