diff --git a/packages/@n8n/api-types/src/dto/data-table/data-table-public.dto.ts b/packages/@n8n/api-types/src/dto/data-table/data-table-public.dto.ts index 4a9b69853fd..609d274d804 100644 --- a/packages/@n8n/api-types/src/dto/data-table/data-table-public.dto.ts +++ b/packages/@n8n/api-types/src/dto/data-table/data-table-public.dto.ts @@ -1,8 +1,10 @@ import '../../openapi-extend'; +import { jsonParse } from 'n8n-workflow'; import { z } from 'zod'; import { + clearDataTableRowsFieldDocs, createDataTableColumnFieldDocs, createDataTableFieldDocs, createDataTableRowsFieldDocs, @@ -11,13 +13,16 @@ import { dataTableListFieldDocs, dataTableRowFieldDocs, dataTableRowListFieldDocs, + deleteDataTableRowsQueryDocs, updateDataTableColumnFieldDocs, updateDataTableFieldDocs, + updateDataTableRowFieldDocs, upsertDataTableRowFieldDocs, } from './data-table-public.openapi'; +import { booleanFromString } from '../../schemas/boolean-from-string'; import { - FilterConditionSchema, dataTableFilterRecordSchema, + dataTableFilterSchema, dataTableFilterTypeSchema, } from '../../schemas/data-table-filter.schema'; import { @@ -173,15 +178,26 @@ export class InsertDataTableRowsResponsePublicDto { } } -const publicUpsertFilterSchema = z - .object({ - type: dataTableFilterTypeSchema.default('and'), - filters: z.array(dataTableFilterRecordSchema.extend({ condition: FilterConditionSchema })), - }) - .refine((filter) => filter.filters.length > 0, { message: 'filter must not be empty' }); +const legacyFilterConditionSchema = z.union([ + z.literal('eq'), + z.literal('neq'), + z.literal('like'), + z.literal('ilike'), + z.literal('gt'), + z.literal('gte'), + z.literal('lt'), + z.literal('lte'), +]); + +const publicUpsertUpdateFilterSchema = z.object({ + type: dataTableFilterTypeSchema.default('and'), + filters: z + .array(dataTableFilterRecordSchema.extend({ condition: legacyFilterConditionSchema })) + .min(1, 'filter must not be empty'), +}); export class UpsertDataTableRowPublicDto extends Z.class({ - filter: publicUpsertFilterSchema.openapi(upsertDataTableRowFieldDocs.filter), + filter: publicUpsertUpdateFilterSchema.openapi(upsertDataTableRowFieldDocs.filter), data: z .record(dataTableColumnNameSchema, dataTableColumnValueSchema) .refine((obj) => Object.keys(obj).length > 0, { message: 'data must not be empty' }) @@ -222,3 +238,108 @@ export class UpsertDataTableRowResponsePublicDto { return upsertDataTableRowResponseSchema.safeParse(data); } } + +export class UpdateDataTableRowPublicDto extends Z.class({ + filter: publicUpsertUpdateFilterSchema.openapi(updateDataTableRowFieldDocs.filter), + data: z + .record(dataTableColumnNameSchema, dataTableColumnValueSchema) + .refine((obj) => Object.keys(obj).length > 0, { message: 'data must not be empty' }) + .openapi(updateDataTableRowFieldDocs.data), + returnData: z.boolean().optional().default(false).openapi(updateDataTableRowFieldDocs.returnData), + dryRun: z.boolean().optional().default(false).openapi(updateDataTableRowFieldDocs.dryRun), +}) {} + +const updateDataTableRowResponseSchema = z.union([ + z.boolean().openapi({ description: 'Returned when returnData is false and dryRun is false' }), + z.array(dataTableRowWithStatePublicSchema).openapi({ + description: 'Returned when dryRun is true: one entry per row per before/after state', + }), + z + .array(dataTableRowPublicSchema) + .openapi({ description: 'Returned when returnData is true and dryRun is false' }), +]); + +export class UpdateDataTableRowResponsePublicDto { + static schema = updateDataTableRowResponseSchema; + + static parse(data: unknown) { + return updateDataTableRowResponseSchema.parse(data); + } + + static safeParse(data: unknown) { + return updateDataTableRowResponseSchema.safeParse(data); + } +} + +const publicRowFilterQueryValidator = z.string().transform((val, ctx) => { + let parsed: unknown; + try { + parsed = jsonParse(val); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid filter format', + path: ['filter'], + }); + return z.NEVER; + } + + const result = dataTableFilterSchema.safeParse(parsed); + if (!result.success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid filter fields', + path: ['filter'], + }); + return z.NEVER; + } + + if (result.data.filters.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'At least one filter condition is required for delete operations', + path: ['filter'], + }); + return z.NEVER; + } + + return result.data; +}); + +export class DeleteDataTableRowsPublicQueryDto extends Z.class({ + filter: publicRowFilterQueryValidator.openapi(deleteDataTableRowsQueryDocs.filter), + returnData: booleanFromString + .optional() + .default('false') + .openapi(deleteDataTableRowsQueryDocs.returnData), + dryRun: booleanFromString + .optional() + .default('false') + .openapi(deleteDataTableRowsQueryDocs.dryRun), +}) {} + +const deleteDataTableRowsResponseSchema = z.union([ + z.boolean().openapi({ description: 'Returned when returnData is false and dryRun is false' }), + z.array(dataTableRowWithStatePublicSchema).openapi({ + description: 'Returned when dryRun is true: one entry per row per before/after state', + }), + z + .array(dataTableRowPublicSchema) + .openapi({ description: 'Returned when returnData is true and dryRun is false' }), +]); + +export class DeleteDataTableRowsResponsePublicDto { + static schema = deleteDataTableRowsResponseSchema; + + static parse(data: unknown) { + return deleteDataTableRowsResponseSchema.parse(data); + } + + static safeParse(data: unknown) { + return deleteDataTableRowsResponseSchema.safeParse(data); + } +} + +export class ClearDataTableRowsResponsePublicDto extends Z.class({ + deletedCount: z.number().int().openapi(clearDataTableRowsFieldDocs.deletedCount), +}) {} diff --git a/packages/@n8n/api-types/src/dto/data-table/data-table-public.openapi.ts b/packages/@n8n/api-types/src/dto/data-table/data-table-public.openapi.ts index 39e4dc16e16..22c33b00195 100644 --- a/packages/@n8n/api-types/src/dto/data-table/data-table-public.openapi.ts +++ b/packages/@n8n/api-types/src/dto/data-table/data-table-public.openapi.ts @@ -155,8 +155,43 @@ export const upsertDataTableRowFieldDocs = { data: { description: 'Column values for the row' }, returnData: { description: - 'Set to true to return the upserted row(s) matched by the filter. Set to false to return ' + - 'true on success.', + 'Set to true to return the upserted row(s): the matching rows if updated, or the new ' + + 'row if one was inserted. Set to false to return true on success.', }, dryRun: { description: 'Set to true to preview the change without saving it.' }, } as const satisfies Record; + +export const updateDataTableRowFieldDocs = { + filter: { description: 'Filter conditions to match rows for update.' }, + data: { description: 'Column values to update' }, + returnData: { + description: + 'Set to true to return the updated row(s). Set to false to return true on success.', + }, + dryRun: { description: 'Set to true to preview the change without saving it.' }, +} as const satisfies Record; + +export const deleteDataTableRowsQueryDocs = { + filter: { + type: 'string', + format: 'jsonString', + param: { + description: + 'JSON string of filter conditions. Required to prevent accidental deletion of all data.', + example: + '{"type":"and","filters":[{"columnName":"status","condition":"eq","value":"archived"}]}', + }, + }, + returnData: { + param: { description: 'If true, return the deleted rows. If false, return true on success.' }, + }, + dryRun: { + param: { + description: 'If true, preview which rows would be deleted without actually deleting them.', + }, + }, +} as const satisfies Record; + +export const clearDataTableRowsFieldDocs = { + deletedCount: { description: 'The number of rows that were deleted' }, +} as const satisfies Record; diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 8e64c34782a..da808e6c7cd 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -426,6 +426,7 @@ export { TestOidcConfigResponseDto } from './oidc/test-oidc-config-response.dto' export { CreateDataTableDto } from './data-table/create-data-table.dto'; export { UpdateDataTableDto } from './data-table/update-data-table.dto'; export { + ClearDataTableRowsResponsePublicDto, CreateDataTableColumnPublicDto, CreateDataTablePublicDto, CreateDataTableRowsPublicDto, @@ -434,9 +435,13 @@ export { DataTableListPublicDto, DataTablePublicDto, DataTableRowListPublicDto, + DeleteDataTableRowsPublicQueryDto, + DeleteDataTableRowsResponsePublicDto, InsertDataTableRowsResponsePublicDto, UpdateDataTableColumnPublicDto, UpdateDataTablePublicDto, + UpdateDataTableRowPublicDto, + UpdateDataTableRowResponsePublicDto, UpsertDataTableRowPublicDto, UpsertDataTableRowResponsePublicDto, dataTablePublicSchema, diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index b6bbd51410e..5e6152825a9 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -137,7 +137,6 @@ export default defineConfig( // `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler // must fail CI. Entries are removed as each handler becomes a controller. files: [ - './src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts', './src/public-api/v1/handlers/ldap/ldap.handler.ts', './src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts', './src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts', diff --git a/packages/cli/oxlint.config.mts b/packages/cli/oxlint.config.mts index 34924d2ee52..a6a752eb2d1 100644 --- a/packages/cli/oxlint.config.mts +++ b/packages/cli/oxlint.config.mts @@ -130,7 +130,6 @@ export default defineConfig({ // `@PublicApiController` classes (API-70). NEVER add to this list — a new tuple handler // must fail CI. Entries are removed as each handler becomes a controller. files: [ - './src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts', './src/public-api/v1/handlers/evaluations/evaluations.handler.ts', './src/public-api/v1/handlers/ldap/ldap.handler.ts', './src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts', diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index 973d72d6a36..5e792592ecb 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -1,5 +1,4 @@ import type { - UpdateDataTableRowDto, PublicCreateDestination, UpdateOtelSettingsDto, TestOtelTraceDto, @@ -116,27 +115,6 @@ export interface IJsonSchema { required: string[]; } -// ---------------------------------- -// /data-tables -// ---------------------------------- - -export declare namespace DataTableRequest { - type UpdateRows = AuthenticatedRequest<{ dataTableId: string }, {}, UpdateDataTableRowDto, {}>; - - type Clear = AuthenticatedRequest<{ dataTableId: string }, {}, {}, {}>; - - type DeleteRows = AuthenticatedRequest< - { dataTableId: string }, - {}, - {}, - { - filter?: string; - returnData?: string | boolean; - dryRun?: string | boolean; - } - >; -} - // ---------------------------------- // /community-packages // ---------------------------------- diff --git a/packages/cli/src/public-api/v1/controllers/data-tables.public.controller.ts b/packages/cli/src/public-api/v1/controllers/data-tables.public.controller.ts index 18e0e70d401..11001283350 100644 --- a/packages/cli/src/public-api/v1/controllers/data-tables.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/data-tables.public.controller.ts @@ -1,4 +1,5 @@ import { + ClearDataTableRowsResponsePublicDto, columnIdParamSchema, CreateDataTableColumnPublicDto, CreateDataTablePublicDto, @@ -8,11 +9,15 @@ import { DataTableListPublicDto, DataTablePublicDto, DataTableRowListPublicDto, + DeleteDataTableRowsPublicQueryDto, + DeleteDataTableRowsResponsePublicDto, InsertDataTableRowsResponsePublicDto, PublicApiListDataTableQueryDto, PublicApiListDataTableRowsQueryDto, UpdateDataTableColumnPublicDto, UpdateDataTablePublicDto, + UpdateDataTableRowPublicDto, + UpdateDataTableRowResponsePublicDto, UpsertDataTableRowPublicDto, UpsertDataTableRowResponsePublicDto, dataTableIdParamSchema, @@ -147,6 +152,15 @@ const isRowWithState = ( row: DataTableRowReturn | DataTableRowReturnWithState, ): row is DataTableRowReturnWithState => Object.hasOwn(row, 'dryRunState'); +const toMutateRowsResponsePublic = ( + result: boolean | DataTableRowReturn[] | DataTableRowReturnWithState[], +) => + typeof result === 'boolean' + ? result + : result.map((row) => + isRowWithState(row) ? toDataTableRowWithStatePublic(row) : toDataTableRowPublic(row), + ); + @PublicApiController('/data-tables') export class DataTablesPublicController { constructor( @@ -505,11 +519,103 @@ export class DataTablesPublicController { dryRun, ); - return typeof result === 'boolean' - ? result - : result.map((row) => - isRowWithState(row) ? toDataTableRowWithStatePublic(row) : toDataTableRowPublic(row), - ); + return toMutateRowsResponsePublic(result); + } catch (error) { + return handleError(error); + } + } + + @Patch('/:dataTableId/rows/update') + @ApiKeyScope('dataTableRow:update') + @ProjectScope('dataTable:writeRow') + @ApiSummary('Update rows in a data table') + @ApiDescription('Update rows matching filter conditions in a data table.') + @ApiTags(tags) + @ApiResponse(200, UpdateDataTableRowResponsePublicDto) + @ApiErrorResponse(404) + async updateDataTableRows( + req: AuthenticatedRequest, + _res: Response, + @Param('dataTableId', dataTableIdParamSchema) dataTableId: string, + @Body body: UpdateDataTableRowPublicDto, + ) { + const { filter, data, returnData, dryRun } = body; + + try { + await assertRowReadAccessIfReturningRows(req.user, dataTableId, { dryRun, returnData }); + + const projectId = await this.dataTableService.getProjectIdForDataTable(dataTableId); + const result = await this.dataTableService.updateRows( + dataTableId, + projectId, + { filter, data }, + returnData, + dryRun, + ); + + return toMutateRowsResponsePublic(result); + } catch (error) { + return handleError(error); + } + } + + @Delete('/:dataTableId/rows/clear') + @ApiKeyScope('dataTableRow:delete') + @ProjectScope('dataTable:writeRow') + @ApiSummary('Clear all rows from a data table') + @ApiDescription( + 'Permanently delete all rows from a data table. The table structure is retained. This ' + + 'action cannot be undone.', + ) + @ApiTags(tags) + @ApiResponse(200, ClearDataTableRowsResponsePublicDto) + @ApiErrorResponse(404) + async clearDataTableRows( + _req: AuthenticatedRequest, + _res: Response, + @Param('dataTableId', dataTableIdParamSchema) dataTableId: string, + ): Promise { + try { + const projectId = await this.dataTableService.getProjectIdForDataTable(dataTableId); + + return await this.dataTableService.clearRows(dataTableId, projectId); + } catch (error) { + return handleError(error); + } + } + + @Delete('/:dataTableId/rows/delete') + @ApiKeyScope('dataTableRow:delete') + @ProjectScope('dataTable:writeRow') + @ApiSummary('Delete rows from a data table') + @ApiDescription( + 'Delete rows matching filter conditions from a data table. Filter is required to prevent ' + + 'accidental deletion of all data.', + ) + @ApiTags(tags) + @ApiResponse(200, DeleteDataTableRowsResponsePublicDto) + @ApiErrorResponse(404) + async deleteDataTableRows( + req: AuthenticatedRequest, + _res: Response, + @Param('dataTableId', dataTableIdParamSchema) dataTableId: string, + @Query query: DeleteDataTableRowsPublicQueryDto, + ) { + const { filter, returnData, dryRun } = query; + + try { + await assertRowReadAccessIfReturningRows(req.user, dataTableId, { dryRun, returnData }); + + const projectId = await this.dataTableService.getProjectIdForDataTable(dataTableId); + const result = await this.dataTableService.deleteRows( + dataTableId, + projectId, + { filter }, + returnData, + dryRun, + ); + + return toMutateRowsResponsePublic(result); } catch (error) { return handleError(error); } diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/__tests__/data-tables.handler.test.ts b/packages/cli/src/public-api/v1/handlers/data-tables/__tests__/data-tables.handler.test.ts deleted file mode 100644 index 9c5a22e2a8e..00000000000 --- a/packages/cli/src/public-api/v1/handlers/data-tables/__tests__/data-tables.handler.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { mockInstance } from '@n8n/backend-test-utils'; -import { Container } from '@n8n/di'; -import { GLOBAL_MEMBER_SCOPES, type Scope } from '@n8n/permissions'; -import type { Response } from 'express'; -import type { Mocked } from 'vitest'; - -import { BadRequestError, NotFoundError } from '@n8n/errors'; -import { DataTableAggregateService } from '@/modules/data-table/data-table-aggregate.service'; -import { DataTableService } from '@/modules/data-table/data-table.service'; -import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error'; -import type { DataTableRequest } from '@/public-api/types'; -import * as middlewares from '@/public-api/v1/shared/middlewares/global.middleware'; - -// Mock middleware before requiring handler -const mockMiddleware = vi.fn(async (_req, _res, next) => next()) as any; -vi.spyOn(middlewares, 'publicApiScope').mockReturnValue(mockMiddleware); -vi.spyOn(middlewares, 'projectScope').mockReturnValue(mockMiddleware); - -// Loaded after the middleware spies above are installed; typed loosely so the -// suite can invoke individual route entries by index. -let handler: Record unknown>>; - -beforeAll(async () => { - handler = (await import('../data-tables.rows.handler.js')) as unknown as typeof handler; -}); - -describe('DataTable Handler', () => { - let mockDataTableService: Mocked; - let mockDataTableAggregateService: Mocked; - let mockResponse: Partial; - - const projectId = 'test-project-id'; - const dataTableId = 'test-data-table-id'; - const userId = 'test-user-id'; - - const makeUser = (scopeSlugs: Scope[] = GLOBAL_MEMBER_SCOPES) => ({ - id: userId, - role: { slug: 'global:member', scopes: scopeSlugs.map((slug) => ({ slug })) }, - }); - - beforeEach(() => { - mockDataTableService = mockInstance(DataTableService); - mockDataTableAggregateService = mockInstance(DataTableAggregateService); - - vi.spyOn(Container, 'get').mockImplementation((serviceClass) => { - if (serviceClass === DataTableService) { - return mockDataTableService; - } - if (serviceClass === DataTableAggregateService) { - return mockDataTableAggregateService; - } - return {}; - }); - - mockDataTableService.getProjectIdForDataTable.mockResolvedValue(projectId); - mockDataTableService.getCachedSizeBytesByIds.mockResolvedValue(new Map()); - - mockResponse = { - json: vi.fn().mockReturnThis(), - status: vi.fn().mockReturnThis(), - send: vi.fn().mockReturnThis(), - }; - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - describe('updateDataTableRows', () => { - it('should update rows and return true when returnData is false', async () => { - // Arrange - const req = { - params: { dataTableId }, - body: { - filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] }, - data: { status: 'updated' }, - returnData: false, - dryRun: false, - }, - user: { id: userId }, - } as unknown as DataTableRequest.UpdateRows; - - mockDataTableService.updateRows.mockResolvedValue(true); - - // Act - await handler.updateDataTableRows[2](req, mockResponse as Response); - - // Assert - expect(mockDataTableService.updateRows).toHaveBeenCalledWith( - dataTableId, - projectId, - { - filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] }, - data: { status: 'updated' }, - }, - false, - false, - ); - expect(mockResponse.json).toHaveBeenCalledWith(true); - }); - - it('should update rows and return updated rows when returnData is true', async () => { - // Arrange - const req = { - params: { dataTableId }, - body: { - filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] }, - data: { status: 'updated' }, - returnData: true, - dryRun: false, - }, - user: makeUser(['dataTable:readRow']), - } as unknown as DataTableRequest.UpdateRows; - - const mockRow = { id: 1, status: 'updated', createdAt: new Date(), updatedAt: new Date() }; - mockDataTableService.updateRows.mockResolvedValue([mockRow] as any); - - // Act - await handler.updateDataTableRows[2](req, mockResponse as Response); - - // Assert - expect(mockResponse.json).toHaveBeenCalledWith([mockRow]); - }); - - it('should support dry run mode', async () => { - // Arrange - const req = { - params: { dataTableId }, - body: { - filter: { - type: 'and', - filters: [{ columnName: 'id', condition: 'eq', value: 1 }], - }, - data: { status: 'test' }, - returnData: true, - dryRun: true, - }, - user: makeUser(['dataTable:readRow']), - } as unknown as DataTableRequest.UpdateRows; - - const mockDryRunResult = [ - { id: 1, status: 'old', dryRunState: 'before' }, - { id: 1, status: 'test', dryRunState: 'after' }, - ]; - mockDataTableService.updateRows.mockResolvedValue(mockDryRunResult as any); - - // Act - await handler.updateDataTableRows[2](req, mockResponse as Response); - - // Assert - expect(mockDataTableService.updateRows).toHaveBeenCalledWith( - dataTableId, - projectId, - expect.any(Object), - true, - true, - ); - }); - }); - - describe('deleteDataTableRows', () => { - it('should delete rows and return true when returnData is false', async () => { - // Arrange - const filterStr = JSON.stringify({ - type: 'and', - filters: [{ columnName: 'status', condition: 'eq', value: 'archived' }], - }); - const req = { - params: { dataTableId }, - query: { - filter: filterStr, - returnData: 'false', - dryRun: 'false', - }, - user: { id: userId }, - } as unknown as DataTableRequest.DeleteRows; - - mockDataTableService.deleteRows.mockResolvedValue(true); - - // Act - await handler.deleteDataTableRows[2](req, mockResponse as Response); - - // Assert - expect(mockDataTableService.deleteRows).toHaveBeenCalledWith( - dataTableId, - projectId, - { - filter: JSON.parse(filterStr), - }, - false, - false, - ); - expect(mockResponse.json).toHaveBeenCalledWith(true); - }); - - it('should delete rows and return deleted rows when returnData is true', async () => { - // Arrange - const filterStr = JSON.stringify({ - type: 'and', - filters: [{ columnName: 'id', condition: 'eq', value: 1 }], - }); - const req = { - params: { dataTableId }, - query: { - filter: filterStr, - returnData: 'true', - dryRun: 'false', - }, - user: makeUser(['dataTable:readRow']), - } as unknown as DataTableRequest.DeleteRows; - - const mockRow = { id: 1, name: 'Deleted', createdAt: new Date(), updatedAt: new Date() }; - mockDataTableService.deleteRows.mockResolvedValue([mockRow] as any); - - // Act - await handler.deleteDataTableRows[2](req, mockResponse as Response); - - // Assert - expect(mockResponse.json).toHaveBeenCalledWith([mockRow]); - }); - - it('should throw BadRequestError when filter is missing', async () => { - // Arrange - const req = { - params: { dataTableId }, - query: {}, - user: { id: userId }, - } as unknown as DataTableRequest.DeleteRows; - - // Act - const handlerFn = handler.deleteDataTableRows[2]; - let caught: unknown; - try { - await handlerFn(req, mockResponse as Response); - } catch (error) { - caught = error; - } - - // Assert - expect(caught).toBeInstanceOf(BadRequestError); - expect(caught).toMatchObject({ - message: 'Required', - httpStatusCode: 400, - }); - }); - - it('should support dry run mode', async () => { - // Arrange - const filterStr = JSON.stringify({ - type: 'and', - filters: [{ columnName: 'status', condition: 'eq', value: 'test' }], - }); - const req = { - params: { dataTableId }, - query: { - filter: filterStr, - returnData: 'true', - dryRun: 'true', - }, - user: makeUser(['dataTable:readRow']), - } as unknown as DataTableRequest.DeleteRows; - - const mockRows = [{ id: 1, status: 'test', createdAt: new Date(), updatedAt: new Date() }]; - mockDataTableService.deleteRows.mockResolvedValue(mockRows as any); - - // Act - await handler.deleteDataTableRows[2](req, mockResponse as Response); - - // Assert - expect(mockDataTableService.deleteRows).toHaveBeenCalledWith( - dataTableId, - projectId, - expect.objectContaining({ filter: expect.any(Object) }), - true, - true, - ); - }); - }); - - describe('Security - Cross-Project Access', () => { - const otherUserDataTableId = 'other-user-data-table-id'; - - it('should throw NotFoundError when trying to update rows in another users data table', async () => { - // Arrange - const req = { - params: { dataTableId: otherUserDataTableId }, - body: { - filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] }, - data: { status: 'hacked' }, - returnData: false, - dryRun: false, - }, - user: { id: userId }, - } as unknown as DataTableRequest.UpdateRows; - - mockDataTableService.updateRows.mockRejectedValue( - new DataTableNotFoundError(otherUserDataTableId), - ); - - // Act - const handlerFn = handler.updateDataTableRows[2]; - let caught: unknown; - try { - await handlerFn(req, mockResponse as Response); - } catch (error) { - caught = error; - } - - // Assert - expect(caught).toBeInstanceOf(NotFoundError); - expect(caught).toMatchObject({ - message: expect.stringContaining(otherUserDataTableId), - httpStatusCode: 404, - }); - }); - - it('should throw NotFoundError when trying to delete rows from another users data table', async () => { - // Arrange - const filterStr = JSON.stringify({ - type: 'and', - filters: [{ columnName: 'id', condition: 'eq', value: 1 }], - }); - const req = { - params: { dataTableId: otherUserDataTableId }, - query: { - filter: filterStr, - returnData: 'false', - dryRun: 'false', - }, - user: { id: userId }, - } as unknown as DataTableRequest.DeleteRows; - - mockDataTableService.deleteRows.mockRejectedValue( - new DataTableNotFoundError(otherUserDataTableId), - ); - - // Act - const handlerFn = handler.deleteDataTableRows[2]; - let caught: unknown; - try { - await handlerFn(req, mockResponse as Response); - } catch (error) { - caught = error; - } - - // Assert - expect(caught).toBeInstanceOf(NotFoundError); - expect(caught).toMatchObject({ - message: expect.stringContaining(otherUserDataTableId), - httpStatusCode: 404, - }); - }); - - it('should not leak information about data table existence in error messages', async () => { - // Arrange - const nonExistentDataTableId = 'non-existent-table-id'; - const filterStr = JSON.stringify({ - type: 'and', - filters: [{ columnName: 'id', condition: 'eq', value: 1 }], - }); - const req = { - params: { dataTableId: nonExistentDataTableId }, - query: { filter: filterStr }, - user: { id: userId }, - } as unknown as DataTableRequest.DeleteRows; - - mockDataTableService.getProjectIdForDataTable.mockRejectedValue( - new DataTableNotFoundError(nonExistentDataTableId), - ); - - // Act - const handlerFn = handler.deleteDataTableRows[2]; - let caught: unknown; - try { - await handlerFn(req, mockResponse as Response); - } catch (error) { - caught = error; - } - - // Assert - // The error message should be the same whether: - // 1. The table doesn't exist at all - // 2. The table exists but belongs to another user's project - // This prevents information leakage - expect(caught).toBeInstanceOf(NotFoundError); - expect(caught).toMatchObject({ - message: expect.stringContaining(nonExistentDataTableId), - httpStatusCode: 404, - }); - }); - }); -}); diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/__tests__/data-tables.utils.test.ts b/packages/cli/src/public-api/v1/handlers/data-tables/__tests__/data-tables.utils.test.ts deleted file mode 100644 index be8d94418e8..00000000000 --- a/packages/cli/src/public-api/v1/handlers/data-tables/__tests__/data-tables.utils.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { stringifyQuery } from '../data-tables.utils'; - -describe('stringifyQuery', () => { - it('converts values to strings', () => { - expect(stringifyQuery({ limit: 10, sortBy: 'id', includeCount: true })).toEqual({ - limit: '10', - sortBy: 'id', - includeCount: 'true', - }); - }); - - it('drops undefined and null values', () => { - expect(stringifyQuery({ limit: 10, cursor: undefined, filter: null })).toEqual({ - limit: '10', - }); - }); - - it('returns an empty object for an empty query', () => { - expect(stringifyQuery({})).toEqual({}); - }); -}); diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts b/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts deleted file mode 100644 index 0f8cd0f6957..00000000000 --- a/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.rows.handler.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { UpdateDataTableRowDto, DeleteDataTableRowsDto } from '@n8n/api-types'; -import { Container } from '@n8n/di'; - -import { BadRequestError, NotFoundError } from '@n8n/errors'; -import { assertRowReadAccessIfReturningRows } from '@/modules/data-table/data-table-permissions'; -import { DataTableService } from '@/modules/data-table/data-table.service'; -import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error'; -import { DataTableValidationError } from '@/modules/data-table/errors/data-table-validation.error'; - -import type { DataTableRequest } from '../../../types'; -import type { PublicAPIEndpoint } from '../../shared/handler.types'; -import { publicApiScope, projectScope } from '../../shared/middlewares/global.middleware'; -import { stringifyQuery } from './data-tables.utils'; - -const handleError = (error: unknown) => { - if (error instanceof DataTableNotFoundError) { - throw new NotFoundError(error.message); - } - if (error instanceof DataTableValidationError) { - throw new BadRequestError(error.message); - } - - throw error; -}; - -type DataTableRowsHandlers = { - updateDataTableRows: PublicAPIEndpoint; - clearDataTableRows: PublicAPIEndpoint; - deleteDataTableRows: PublicAPIEndpoint; -}; - -const dataTableRowsHandlers: DataTableRowsHandlers = { - updateDataTableRows: [ - publicApiScope('dataTableRow:update'), - projectScope('dataTable:writeRow', 'dataTable'), - async (req, res) => { - try { - const { dataTableId } = req.params; - - const payload = UpdateDataTableRowDto.safeParse(req.body); - if (!payload.success) { - throw new BadRequestError(payload.error.errors[0]?.message || 'Invalid request body'); - } - - const projectId = - await Container.get(DataTableService).getProjectIdForDataTable(dataTableId); - const service = Container.get(DataTableService); - const { filter, data, returnData = false, dryRun = false } = payload.data; - const params = { filter, data }; - - await assertRowReadAccessIfReturningRows(req.user, dataTableId, { dryRun, returnData }); - - const result = await service.updateRows(dataTableId, projectId, params, returnData, dryRun); - - return res.json(result); - } catch (error) { - return handleError(error); - } - }, - ], - - clearDataTableRows: [ - publicApiScope('dataTableRow:delete'), - projectScope('dataTable:writeRow', 'dataTable'), - async (req, res) => { - try { - const { dataTableId } = req.params; - - const projectId = - await Container.get(DataTableService).getProjectIdForDataTable(dataTableId); - - const result = await Container.get(DataTableService).clearRows(dataTableId, projectId); - - return res.json(result); - } catch (error) { - return handleError(error); - } - }, - ], - - deleteDataTableRows: [ - publicApiScope('dataTableRow:delete'), - projectScope('dataTable:writeRow', 'dataTable'), - async (req, res) => { - try { - const { dataTableId } = req.params; - - const payload = DeleteDataTableRowsDto.safeParse(stringifyQuery(req.query)); - if (!payload.success) { - throw new BadRequestError(payload.error.errors[0]?.message || 'Invalid query parameters'); - } - - const projectId = - await Container.get(DataTableService).getProjectIdForDataTable(dataTableId); - const service = Container.get(DataTableService); - const { filter, returnData = false, dryRun = false } = payload.data; - const params = { filter }; - - await assertRowReadAccessIfReturningRows(req.user, dataTableId, { dryRun, returnData }); - - const result = await service.deleteRows(dataTableId, projectId, params, returnData, dryRun); - - return res.json(result); - } catch (error) { - return handleError(error); - } - }, - ], -}; - -export = dataTableRowsHandlers; diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.utils.ts b/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.utils.ts deleted file mode 100644 index 1a99f52c0af..00000000000 --- a/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.utils.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Convert all query parameter values to strings for DTO validation. - * Express/Supertest may parse some values as numbers/booleans. - */ -export const stringifyQuery = ( - query: Record, -): Record => { - const result: Record = {}; - for (const [key, value] of Object.entries(query)) { - if (value !== undefined && value !== null) { - result[key] = String(value); - } - } - return result; -}; diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/clearDataTableRows.generated.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/clearDataTableRows.generated.yml new file mode 100644 index 00000000000..e14fdf94745 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/clearDataTableRows.generated.yml @@ -0,0 +1,37 @@ +operationId: clearDataTableRows +tags: + - DataTable +summary: Clear all rows from a data table +description: Permanently delete all rows from a data table. The table structure is retained. This action cannot be undone. +x-required-scope: dataTableRow:delete +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + format: nanoid + pattern: ^[A-Za-z0-9]{16}$ + required: true + description: The ID of the data table + name: dataTableId + in: path +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + deletedCount: + type: integer + description: The number of rows that were deleted + required: + - deletedCount + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.clear.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.clear.yml deleted file mode 100644 index d4239a13f96..00000000000 --- a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.clear.yml +++ /dev/null @@ -1,28 +0,0 @@ -delete: - x-eov-operation-id: clearDataTableRows - x-required-scope: dataTableRow:delete - x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler - tags: - - DataTable - summary: Clear all rows from a data table - description: Permanently deletes all rows from a data table. The table structure will be retained. This action cannot be undone. - operationId: clear-data-table-rows - parameters: - - $ref: '../schemas/parameters/dataTableId.yml' - responses: - '200': - description: Rows cleared successfully - content: - application/json: - schema: - type: object - properties: - deletedCount: - type: integer - description: The number of rows that were deleted - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' - security: - - ApiKeyAuth: [] diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.delete.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.delete.yml deleted file mode 100644 index fd0d99cbb7e..00000000000 --- a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.delete.yml +++ /dev/null @@ -1,52 +0,0 @@ -delete: - x-eov-operation-id: deleteDataTableRows - x-required-scope: dataTableRow:delete - x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler - tags: - - DataTable - summary: Delete rows from a data table - description: Delete rows matching filter conditions from a data table. Filter is required to prevent accidental deletion of all data. - operationId: delete-data-table-rows - parameters: - - $ref: '../schemas/parameters/dataTableId.yml' - - name: filter - in: query - required: true - description: JSON string of filter conditions. Required to prevent accidental deletion of all data. - schema: - type: string - format: jsonString - example: '{"type":"and","filters":[{"columnName":"status","condition":"eq","value":"archived"}]}' - - name: returnData - in: query - description: If true, return the deleted rows; if false, return true on success - schema: - type: boolean - default: false - - name: dryRun - in: query - description: If true, preview which rows would be deleted without actually deleting them - schema: - type: boolean - default: false - responses: - '200': - description: Rows deleted successfully - content: - application/json: - schema: - oneOf: - - type: boolean - description: True when returnData is false - - type: array - items: - $ref: '../schemas/dataTableRow.yml' - description: Deleted rows when returnData is true - '400': - $ref: '../../../../shared/spec/responses/badRequest.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' - security: - - ApiKeyAuth: [] diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.update.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.update.yml deleted file mode 100644 index 87f9fe0dd7f..00000000000 --- a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.dataTableId.rows.update.yml +++ /dev/null @@ -1,50 +0,0 @@ -patch: - x-eov-operation-id: updateDataTableRows - x-required-scope: dataTableRow:update - x-eov-operation-handler: v1/handlers/data-tables/data-tables.rows.handler - tags: - - DataTable - summary: Update rows in a data table - description: Update rows matching filter conditions in a data table. - operationId: update-data-table-rows - parameters: - - $ref: '../schemas/parameters/dataTableId.yml' - requestBody: - required: true - content: - application/json: - schema: - $ref: '../schemas/updateRowsRequest.yml' - example: - filter: - type: 'and' - filters: - - columnName: 'status' - condition: 'eq' - value: 'pending' - data: - status: 'completed' - updatedBy: 'admin' - returnData: false - dryRun: false - responses: - '200': - description: Rows updated successfully - content: - application/json: - schema: - oneOf: - - type: boolean - description: True when returnData is false - - type: array - items: - $ref: '../schemas/dataTableRow.yml' - description: Updated rows when returnData is true - '400': - $ref: '../../../../shared/spec/responses/badRequest.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' - security: - - ApiKeyAuth: [] diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/deleteDataTableRows.generated.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/deleteDataTableRows.generated.yml new file mode 100644 index 00000000000..9e70a4ff3ba --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/deleteDataTableRows.generated.yml @@ -0,0 +1,114 @@ +operationId: deleteDataTableRows +tags: + - DataTable +summary: Delete rows from a data table +description: Delete rows matching filter conditions from a data table. Filter is required to prevent accidental deletion of all data. +x-required-scope: dataTableRow:delete +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + format: nanoid + pattern: ^[A-Za-z0-9]{16}$ + required: true + description: The ID of the data table + name: dataTableId + in: path + - schema: + type: string + format: jsonString + required: true + description: JSON string of filter conditions. Required to prevent accidental deletion of all data. + example: '{"type":"and","filters":[{"columnName":"status","condition":"eq","value":"archived"}]}' + name: filter + in: query + - schema: + type: string + enum: + - 'true' + - 'false' + default: 'false' + required: false + description: If true, return the deleted rows. If false, return true on success. + name: returnData + in: query + - schema: + type: string + enum: + - 'true' + - 'false' + default: 'false' + required: false + description: If true, preview which rows would be deleted without actually deleting them. + name: dryRun + in: query +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + anyOf: + - type: boolean + description: Returned when returnData is false and dryRun is false + - type: array + items: + type: object + properties: + id: + type: integer + nullable: true + description: The row ID (auto-generated) + createdAt: + type: string + nullable: true + format: date-time + description: The date and time the row was created + updatedAt: + type: string + nullable: true + format: date-time + description: The date and time the row was last updated + dryRunState: + type: string + enum: + - before + - after + description: Whether this entry shows the row state before or after the change + required: + - id + - createdAt + - updatedAt + - dryRunState + description: 'Returned when dryRun is true: one entry per row per before/after state' + - type: array + items: + type: object + properties: + id: + type: integer + description: The row ID (auto-generated) + createdAt: + type: string + format: date-time + description: The date and time the row was created + updatedAt: + type: string + format: date-time + description: The date and time the row was last updated + required: + - id + - createdAt + - updatedAt + description: A data table row with system columns (id, createdAt, updatedAt) and user-defined columns + description: Returned when returnData is true and dryRun is false + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/updateDataTableRows.generated.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/updateDataTableRows.generated.yml new file mode 100644 index 00000000000..4f264fd1bc8 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/updateDataTableRows.generated.yml @@ -0,0 +1,181 @@ +operationId: updateDataTableRows +tags: + - DataTable +summary: Update rows in a data table +description: Update rows matching filter conditions in a data table. +x-required-scope: dataTableRow:update +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + format: nanoid + pattern: ^[A-Za-z0-9]{16}$ + required: true + description: The ID of the data table + name: dataTableId + in: path +requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + filter: + type: object + properties: + type: + anyOf: + - type: string + enum: + - and + - type: string + enum: + - or + default: and + filters: + type: array + items: + type: object + properties: + columnName: + type: string + minLength: 1 + maxLength: 63 + pattern: ^[a-zA-Z][a-zA-Z0-9_]*$ + condition: + anyOf: + - type: string + enum: + - eq + - type: string + enum: + - neq + - type: string + enum: + - like + - type: string + enum: + - ilike + - type: string + enum: + - gt + - type: string + enum: + - gte + - type: string + enum: + - lt + - type: string + enum: + - lte + value: + anyOf: + - type: string + - type: number + - type: boolean + - type: string + - {} + - {} + required: + - columnName + - condition + - value + minItems: 1 + required: + - filters + description: Filter conditions to match rows for update. + data: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + - type: boolean + - {} + - type: string + - {} + description: Column values to update + returnData: + type: boolean + default: false + description: Set to true to return the updated row(s). Set to false to return true on success. + dryRun: + type: boolean + default: false + description: Set to true to preview the change without saving it. + required: + - filter + - data +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + anyOf: + - type: boolean + description: Returned when returnData is false and dryRun is false + - type: array + items: + type: object + properties: + id: + type: integer + nullable: true + description: The row ID (auto-generated) + createdAt: + type: string + nullable: true + format: date-time + description: The date and time the row was created + updatedAt: + type: string + nullable: true + format: date-time + description: The date and time the row was last updated + dryRunState: + type: string + enum: + - before + - after + description: Whether this entry shows the row state before or after the change + required: + - id + - createdAt + - updatedAt + - dryRunState + description: 'Returned when dryRun is true: one entry per row per before/after state' + - type: array + items: + type: object + properties: + id: + type: integer + description: The row ID (auto-generated) + createdAt: + type: string + format: date-time + description: The date and time the row was created + updatedAt: + type: string + format: date-time + description: The date and time the row was last updated + required: + - id + - createdAt + - updatedAt + description: A data table row with system columns (id, createdAt, updatedAt) and user-defined columns + description: Returned when returnData is true and dryRun is false + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml + '415': + $ref: ../../../../shared/spec/responses/unsupportedMediaType.yml diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/upsertDataTableRow.generated.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/upsertDataTableRow.generated.yml index ddf1f40ae6b..779657cc9e3 100644 --- a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/upsertDataTableRow.generated.yml +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/upsertDataTableRow.generated.yml @@ -71,12 +71,6 @@ requestBody: - type: string enum: - lte - - type: string - enum: - - isEmpty - - type: string - enum: - - isNotEmpty value: anyOf: - type: string @@ -89,6 +83,7 @@ requestBody: - columnName - condition - value + minItems: 1 required: - filters description: Filter conditions to match an existing row. If no row matches, n8n inserts a new row. @@ -106,7 +101,7 @@ requestBody: returnData: type: boolean default: false - description: Set to true to return the upserted row(s) matched by the filter. Set to false to return true on success. + description: 'Set to true to return the upserted row(s): the matching rows if updated, or the new row if one was inserted. Set to false to return true on success.' dryRun: type: boolean default: false diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTableRow.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTableRow.yml deleted file mode 100644 index a226281ce4c..00000000000 --- a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/dataTableRow.yml +++ /dev/null @@ -1,15 +0,0 @@ -type: object -properties: - id: - type: integer - description: The row ID (auto-generated) - createdAt: - type: string - format: date-time - description: The date and time the row was created - updatedAt: - type: string - format: date-time - description: The date and time the row was last updated -additionalProperties: true -description: A data table row with system columns (id, createdAt, updatedAt) and user-defined columns diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/updateRowsRequest.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/updateRowsRequest.yml deleted file mode 100644 index 2f9c674be63..00000000000 --- a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/updateRowsRequest.yml +++ /dev/null @@ -1,43 +0,0 @@ -type: object -properties: - filter: - type: object - properties: - type: - type: string - enum: [and, or] - default: and - filters: - type: array - minItems: 1 - items: - type: object - properties: - columnName: - type: string - condition: - type: string - enum: [eq, neq, like, ilike, gt, gte, lt, lte] - value: {} - required: - - columnName - - condition - - value - required: - - filters - description: Filter conditions to match rows for update - data: - type: object - additionalProperties: true - description: Column values to update - returnData: - type: boolean - default: false - description: If true, return the updated rows; if false, return true on success - dryRun: - type: boolean - default: false - description: If true, preview changes without persisting them -required: - - filter - - data diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index fc69173895a..2b0c140b647 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -92,6 +92,15 @@ paths: /data-tables/{dataTableId}/rows/upsert: post: $ref: ./handlers/data-tables/spec/paths/upsertDataTableRow.generated.yml + /data-tables/{dataTableId}/rows/update: + patch: + $ref: ./handlers/data-tables/spec/paths/updateDataTableRows.generated.yml + /data-tables/{dataTableId}/rows/clear: + delete: + $ref: ./handlers/data-tables/spec/paths/clearDataTableRows.generated.yml + /data-tables/{dataTableId}/rows/delete: + delete: + $ref: ./handlers/data-tables/spec/paths/deleteDataTableRows.generated.yml /discover: get: $ref: ./handlers/discover/spec/paths/getDiscover.generated.yml diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index 5c5ae674621..24312e2a599 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -85,12 +85,6 @@ paths: $ref: './handlers/ldap/spec/paths/settings.ldap.sync.yml' /workflows/{id}/{versionId}: $ref: './handlers/workflows/spec/paths/workflows.id.versionId.yml' - /data-tables/{dataTableId}/rows/update: - $ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.update.yml' - /data-tables/{dataTableId}/rows/clear: - $ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.clear.yml' - /data-tables/{dataTableId}/rows/delete: - $ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.rows.delete.yml' /n8n-packages/export: $ref: './handlers/n8n-packages/spec/paths/n8n-packages.export.yml' /n8n-packages/import: diff --git a/packages/cli/test/integration/public-api/data-tables.test.ts b/packages/cli/test/integration/public-api/data-tables.test.ts index 5ed5b9083fb..7743fa559a5 100644 --- a/packages/cli/test/integration/public-api/data-tables.test.ts +++ b/packages/cli/test/integration/public-api/data-tables.test.ts @@ -1257,6 +1257,73 @@ describe('PATCH /data-tables/:dataTableId/rows/update', () => { testWithAPIKey('patch', '/data-tables/123/rows/update', 'abcXYZ'), ); + test('should reject updating rows without dataTableRow:update', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'status', type: 'string' }], + data: [{ status: 'pending' }], + }); + const unscopedAgent = await createUnscopedAgent(); + + const response = await unscopedAgent.patch(`/data-tables/${dataTable.id}/rows/update`).send({ + filter: { + type: 'and', + filters: [{ columnName: 'status', condition: 'eq', value: 'pending' }], + }, + data: { status: 'completed' }, + }); + + expect(response.statusCode).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + + test('should reject a filter entry missing condition', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'status', type: 'string' }], + data: [{ status: 'pending' }], + }); + + const response = await authOwnerAgent.patch(`/data-tables/${dataTable.id}/rows/update`).send({ + filter: { + type: 'and', + filters: [{ columnName: 'status', value: 'pending' }], + }, + data: { status: 'completed' }, + }); + + expect(response.statusCode).toBe(400); + }); + + test('should reject an empty filters array', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'status', type: 'string' }], + data: [{ status: 'pending' }], + }); + + const response = await authOwnerAgent.patch(`/data-tables/${dataTable.id}/rows/update`).send({ + filter: { type: 'and', filters: [] }, + data: { status: 'completed' }, + }); + + expect(response.statusCode).toBe(400); + }); + + test('should reject isEmpty and isNotEmpty conditions', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'status', type: 'string' }], + data: [{ status: 'pending' }], + }); + + const response = await authOwnerAgent.patch(`/data-tables/${dataTable.id}/rows/update`).send({ + filter: { + type: 'and', + filters: [{ columnName: 'status', condition: 'isEmpty', value: null }], + }, + data: { status: 'completed' }, + }); + + expect(response.statusCode).toBe(400); + }); + test('should update rows with returnData false', async () => { const dataTable = await createDataTable(ownerPersonalProject, { columns: [ @@ -1392,6 +1459,37 @@ describe('POST /data-tables/:dataTableId/rows/upsert', () => { expect(response.statusCode).toBe(400); }); + test('should reject an empty filters array', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'email', type: 'string' }], + data: [{ email: 'test@example.com' }], + }); + + const response = await authOwnerAgent.post(`/data-tables/${dataTable.id}/rows/upsert`).send({ + filter: { type: 'and', filters: [] }, + data: { email: 'updated@example.com' }, + }); + + expect(response.statusCode).toBe(400); + }); + + test('should reject isEmpty and isNotEmpty conditions', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'email', type: 'string' }], + data: [{ email: 'test@example.com' }], + }); + + const response = await authOwnerAgent.post(`/data-tables/${dataTable.id}/rows/upsert`).send({ + filter: { + type: 'and', + filters: [{ columnName: 'email', condition: 'isNotEmpty', value: null }], + }, + data: { email: 'updated@example.com' }, + }); + + expect(response.statusCode).toBe(400); + }); + test('should upsert row with returnData false', async () => { const dataTable = await createDataTable(ownerPersonalProject, { columns: [ @@ -1489,6 +1587,85 @@ describe('DELETE /data-tables/:dataTableId/rows/delete', () => { testWithAPIKey('delete', '/data-tables/123/rows/delete', 'abcXYZ'), ); + test('should reject deleting rows without dataTableRow:delete', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'status', type: 'string' }], + data: [{ status: 'old' }], + }); + const unscopedAgent = await createUnscopedAgent(); + + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'status', condition: 'eq', value: 'old' }], + }); + + const response = await unscopedAgent + .delete(`/data-tables/${dataTable.id}/rows/delete`) + .query({ filter }); + + expect(response.statusCode).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + + test('should default a filter entry missing condition to eq', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'status', type: 'string' }], + data: [{ status: 'old' }, { status: 'active' }], + }); + + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'status', value: 'old' }], + }); + + const response = await authOwnerAgent + .delete(`/data-tables/${dataTable.id}/rows/delete`) + .query({ filter, returnData: 'true' }); + + expect(response.statusCode).toBe(200); + expect(response.body).toHaveLength(1); + expect(response.body[0]).toHaveProperty('status', 'old'); + }); + + test('should accept isEmpty condition', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'status', type: 'string' }], + data: [{ status: null }, { status: 'archived' }], + }); + + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'status', condition: 'isEmpty', value: null }], + }); + + const response = await authOwnerAgent + .delete(`/data-tables/${dataTable.id}/rows/delete`) + .query({ filter, returnData: 'true' }); + + expect(response.statusCode).toBe(200); + expect(response.body).toHaveLength(1); + }); + + test('should accept isNotEmpty condition', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'status', type: 'string' }], + data: [{ status: null }, { status: 'archived' }], + }); + + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'status', condition: 'isNotEmpty', value: null }], + }); + + const response = await authOwnerAgent + .delete(`/data-tables/${dataTable.id}/rows/delete`) + .query({ filter, returnData: 'true' }); + + expect(response.statusCode).toBe(200); + expect(response.body).toHaveLength(1); + expect(response.body[0]).toHaveProperty('status', 'archived'); + }); + test('should delete rows with returnData false', async () => { const dataTable = await createDataTable(ownerPersonalProject, { columns: [{ name: 'status', type: 'string' }], @@ -1584,6 +1761,95 @@ describe('DELETE /data-tables/:dataTableId/rows/delete', () => { "request/query must have required property 'filter'", ); }); + + test('should reject a non-boolean dryRun value instead of coercing it', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'status', type: 'string' }], + data: [{ status: 'old' }], + }); + + const filter = JSON.stringify({ + type: 'and', + filters: [{ columnName: 'status', condition: 'eq', value: 'old' }], + }); + + const response = await authOwnerAgent + .delete(`/data-tables/${dataTable.id}/rows/delete`) + .query({ filter, dryRun: '1' }); + + expect(response.statusCode).toBe(400); + + // Verify data was not actually deleted + const getResponse = await authOwnerAgent.get(`/data-tables/${dataTable.id}/rows`); + expect(getResponse.body.data).toHaveLength(1); + }); + + test('should reject an empty filters array with the delete-specific message', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'status', type: 'string' }], + data: [{ status: 'old' }], + }); + + const filter = JSON.stringify({ type: 'and', filters: [] }); + + const response = await authOwnerAgent + .delete(`/data-tables/${dataTable.id}/rows/delete`) + .query({ filter }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toContain( + 'At least one filter condition is required for delete operations', + ); + }); +}); + +describe('DELETE /data-tables/:dataTableId/rows/clear', () => { + test( + 'should fail due to missing API Key', + testWithAPIKey('delete', '/data-tables/123/rows/clear', null), + ); + + test( + 'should fail due to invalid API Key', + testWithAPIKey('delete', '/data-tables/123/rows/clear', 'abcXYZ'), + ); + + test('should reject clearing rows without dataTableRow:delete', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'status', type: 'string' }], + }); + const unscopedAgent = await createUnscopedAgent(); + + const response = await unscopedAgent.delete(`/data-tables/${dataTable.id}/rows/clear`); + + expect(response.statusCode).toBe(403); + }); + + test('should return 404 for non-existing data table', async () => { + const nonExistentId = 'abcd1234efgh5678'; // Valid nanoid format but doesn't exist + const response = await authOwnerAgent.delete(`/data-tables/${nonExistentId}/rows/clear`); + + expect(response.statusCode).toBe(404); + expect(response.body).toHaveProperty( + 'message', + `Could not find the data table: '${nonExistentId}'`, + ); + }); + + test('should clear all rows from a data table', async () => { + const dataTable = await createDataTable(ownerPersonalProject, { + columns: [{ name: 'status', type: 'string' }], + data: [{ status: 'active' }, { status: 'archived' }, { status: 'active' }], + }); + + const response = await authOwnerAgent.delete(`/data-tables/${dataTable.id}/rows/clear`); + + expect(response.statusCode).toBe(200); + expect(response.body).toStrictEqual({ deletedCount: 3 }); + + const getResponse = await authOwnerAgent.get(`/data-tables/${dataTable.id}/rows`); + expect(getResponse.body.data).toHaveLength(0); + }); }); describe('row-content authorization on write endpoints', () => {