diff --git a/packages/@n8n/api-types/src/dto/data-table/list-data-table-query.dto.ts b/packages/@n8n/api-types/src/dto/data-table/list-data-table-query.dto.ts index bec6450dd72..96b4bf05774 100644 --- a/packages/@n8n/api-types/src/dto/data-table/list-data-table-query.dto.ts +++ b/packages/@n8n/api-types/src/dto/data-table/list-data-table-query.dto.ts @@ -17,48 +17,48 @@ const VALID_SORT_OPTIONS = [ export type ListDataTableQuerySortOptions = (typeof VALID_SORT_OPTIONS)[number]; -const FILTER_OPTIONS = { - id: z.union([z.string(), z.array(z.string())]).optional(), - name: z.union([z.string(), z.array(z.string())]).optional(), - projectId: z.union([z.string(), z.array(z.string())]).optional(), - // todo: can probably include others here as well? -}; +const filterSchema = z + .object({ + id: z.union([z.string(), z.array(z.string())]).optional(), + name: z.union([z.string(), z.array(z.string())]).optional(), + projectId: z.union([z.string(), z.array(z.string())]).optional(), + // todo: can probably include others here as well? + }) + .strict(); -// Filter schema - only allow specific properties -const filterSchema = z.object(FILTER_OPTIONS).strict(); -// --------------------- -// Parameter Validators -// --------------------- +// Public API restricts projectId to a single string +const publicApiFilterSchema = filterSchema.extend({ projectId: z.string().optional() }).strict(); -// Filter parameter validation -const filterValidator = z - .string() - .optional() - .transform((val, ctx) => { - if (!val) return undefined; - try { - const parsed: unknown = jsonParse(val); +const makeFilterValidator = >(schema: T) => + z + .string() + .optional() + .transform((val, ctx): z.infer | undefined => { + if (!val) return undefined; try { - return filterSchema.parse(parsed); - } catch (e) { + const result = schema.safeParse(jsonParse(val)); + if (!result.success) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid filter fields', + path: ['filter'], + }); + return z.NEVER; + } + return result.data; + } catch { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'Invalid filter fields', + message: 'Invalid filter format', path: ['filter'], }); return z.NEVER; } - } catch (e) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Invalid filter format', - path: ['filter'], - }); - return z.NEVER; - } - }); + }); + +const filterValidator = makeFilterValidator(filterSchema); +const publicApiFilterValidator = makeFilterValidator(publicApiFilterSchema); -// SortBy parameter validation const sortByValidator = z .enum(VALID_SORT_OPTIONS, { message: `sortBy must be one of: ${VALID_SORT_OPTIONS.join(', ')}` }) .optional(); @@ -71,6 +71,6 @@ export class ListDataTableQueryDto extends Z.class({ export class PublicApiListDataTableQueryDto extends Z.class({ ...publicApiPaginationSchema, - filter: filterValidator, + filter: publicApiFilterValidator, sortBy: sortByValidator, }) {} diff --git a/packages/@n8n/api-types/src/dto/data-table/public-api-create-data-table.dto.ts b/packages/@n8n/api-types/src/dto/data-table/public-api-create-data-table.dto.ts new file mode 100644 index 00000000000..f65cc8a4230 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/data-table/public-api-create-data-table.dto.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +import { CreateDataTableColumnDto } from './create-data-table-column.dto'; +import { dataTableNameSchema } from '../../schemas/data-table.schema'; +import { Z } from '../../zod-class'; + +export class PublicApiCreateDataTableDto extends Z.class({ + name: dataTableNameSchema, + columns: z.array(CreateDataTableColumnDto.schema), + fileId: z.string().optional(), + hasHeaders: z.boolean().optional(), + projectId: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 91ef09b3187..056eda37a48 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -168,6 +168,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 { PublicApiCreateDataTableDto } from './data-table/public-api-create-data-table.dto'; export { UpdateDataTableRowDto } from './data-table/update-data-table-row.dto'; export { DeleteDataTableRowsDto } from './data-table/delete-data-table-rows.dto'; export { UpsertDataTableRowDto } from './data-table/upsert-data-table-row.dto'; diff --git a/packages/@n8n/api-types/src/schemas/data-table.schema.ts b/packages/@n8n/api-types/src/schemas/data-table.schema.ts index bef472fe893..00e281e21bc 100644 --- a/packages/@n8n/api-types/src/schemas/data-table.schema.ts +++ b/packages/@n8n/api-types/src/schemas/data-table.schema.ts @@ -45,11 +45,11 @@ export type DataTableColumn = z.infer; export type DataTableListFilter = { id?: string | string[]; projectId?: string | string[]; - name?: string; + name?: string | string[]; }; export type DataTableListOptions = Partial & { - filter: { projectId: string }; + filter: DataTableListFilter; }; export type DataTableListSortBy = ListDataTableQueryDto['sortBy']; diff --git a/packages/cli/src/modules/data-table/__tests__/data-table.service.test.ts b/packages/cli/src/modules/data-table/__tests__/data-table.service.test.ts index c985c8d593b..6b496bbe610 100644 --- a/packages/cli/src/modules/data-table/__tests__/data-table.service.test.ts +++ b/packages/cli/src/modules/data-table/__tests__/data-table.service.test.ts @@ -5,11 +5,11 @@ import { ProjectRelationRepository, type User } from '@n8n/db'; import type { DataTableInfoById } from 'n8n-workflow'; import type { DataTableColumn } from '../data-table-column.entity'; +import type { DataTable } from '../data-table.entity'; import { DataTableColumnRepository } from '../data-table-column.repository'; import { DataTableCsvImportService } from '../data-table-csv-import.service'; import { DataTableRowsRepository } from '../data-table-rows.repository'; import { DataTableSizeValidator } from '../data-table-size-validator.service'; -import type { DataTable } from '../data-table.entity'; import { DataTableRepository } from '../data-table.repository'; import { DataTableService } from '../data-table.service'; import { DataTableColumnNotFoundError } from '../errors/data-table-column-not-found.error'; diff --git a/packages/cli/src/public-api/types.ts b/packages/cli/src/public-api/types.ts index 3b0df54a7f6..20e24bdcdca 100644 --- a/packages/cli/src/public-api/types.ts +++ b/packages/cli/src/public-api/types.ts @@ -2,7 +2,7 @@ import type { AuthenticatedRequest, TagEntity, WorkflowEntity } from '@n8n/db'; import type { ExecutionStatus, ICredentialDataDecryptedObject } from 'n8n-workflow'; import type { AddDataTableRowsDto, - CreateDataTableDto, + PublicApiCreateDataTableDto, UpdateDataTableDto, UpdateDataTableRowDto, UpsertDataTableRowDto, @@ -257,7 +257,7 @@ export declare namespace DataTableRequest { } >; - type Create = AuthenticatedRequest<{}, {}, CreateDataTableDto, {}>; + type Create = AuthenticatedRequest<{}, {}, PublicApiCreateDataTableDto, {}>; type Get = AuthenticatedRequest<{ dataTableId: string }, {}, {}, {}>; 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 index e75fc1adc6f..cad18cb60ce 100644 --- 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 @@ -1,11 +1,12 @@ import { mockInstance } from '@n8n/backend-test-utils'; -import { ProjectRepository } from '@n8n/db'; +import { ProjectRelationRepository, ProjectRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import type { Response } from 'express'; import { DataTableRepository } from '@/modules/data-table/data-table.repository'; import { DataTableService } from '@/modules/data-table/data-table.service'; import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error'; +import { ProjectService } from '@/services/project.service.ee'; import type { DataTableRequest } from '@/public-api/types'; import * as middlewares from '@/public-api/v1/shared/middlewares/global.middleware'; @@ -15,22 +16,29 @@ jest.spyOn(middlewares, 'publicApiScope').mockReturnValue(mockMiddleware); jest.spyOn(middlewares, 'projectScope').mockReturnValue(mockMiddleware); jest.spyOn(middlewares, 'validCursor').mockReturnValue(mockMiddleware); +const mainHandler = require('../data-tables.handler'); const handler = require('../data-tables.rows.handler'); describe('DataTable Handler', () => { let mockDataTableService: jest.Mocked; let mockDataTableRepository: jest.Mocked; let mockProjectRepository: jest.Mocked; + let mockProjectRelationRepository: jest.Mocked; + let mockProjectService: jest.Mocked; let mockResponse: Partial; const projectId = 'test-project-id'; const dataTableId = 'test-data-table-id'; const userId = 'test-user-id'; + const makeUser = (role = 'global:member') => ({ id: userId, role: { slug: role } }); + beforeEach(() => { mockDataTableService = mockInstance(DataTableService); mockDataTableRepository = mockInstance(DataTableRepository); mockProjectRepository = mockInstance(ProjectRepository); + mockProjectRelationRepository = mockInstance(ProjectRelationRepository); + mockProjectService = mockInstance(ProjectService); jest.spyOn(Container, 'get').mockImplementation((serviceClass) => { if (serviceClass === DataTableService) { @@ -42,6 +50,12 @@ describe('DataTable Handler', () => { if (serviceClass === ProjectRepository) { return mockProjectRepository as any; } + if (serviceClass === ProjectRelationRepository) { + return mockProjectRelationRepository as any; + } + if (serviceClass === ProjectService) { + return mockProjectService as any; + } return {} as any; }); @@ -54,14 +68,59 @@ describe('DataTable Handler', () => { id: projectId, } as any); + mockProjectRelationRepository.find.mockResolvedValue([]); + mockResponse = { json: jest.fn().mockReturnThis(), status: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), }; jest.clearAllMocks(); }); + describe('createDataTable', () => { + it('should create in personal project when no projectId provided', async () => { + const req = { + body: { name: 'test-table', columns: [{ name: 'col1', type: 'string' }] }, + user: makeUser(), + } as unknown as DataTableRequest.Create; + mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({ + id: projectId, + } as never); + mockDataTableService.createDataTable.mockResolvedValue({ + id: dataTableId, + name: 'test-table', + columns: [], + project: { id: projectId }, + } as never); + + await mainHandler.createDataTable[1](req, mockResponse as Response); + + expect(mockProjectRepository.getPersonalProjectForUserOrFail).toHaveBeenCalledWith(userId); + expect(mockDataTableService.createDataTable).toHaveBeenCalledWith( + projectId, + expect.not.objectContaining({ projectId: expect.anything() }), + ); + expect(mockResponse.status).toHaveBeenCalledWith(201); + }); + }); + + describe('listDataTables', () => { + it('should include personal and team projects for regular user', async () => { + const req = { query: {}, user: makeUser() } as unknown as DataTableRequest.List; + mockProjectRepository.getPersonalProjectForUserOrFail.mockResolvedValue({ + id: projectId, + } as never); + mockDataTableService.getManyAndCount.mockResolvedValue({ data: [], count: 0 } as never); + + await mainHandler.listDataTables[2](req, mockResponse as Response); + + const callArgs = mockDataTableService.getManyAndCount.mock.calls[0][0]; + expect(callArgs.filter?.projectId).toContain(projectId); + }); + }); + describe('getDataTableRows', () => { it('should retrieve rows successfully', async () => { // Arrange diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.handler.ts b/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.handler.ts index 567a9349e3d..f275bc77551 100644 --- a/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.handler.ts @@ -1,9 +1,8 @@ import { PublicApiListDataTableQueryDto, - CreateDataTableDto, + PublicApiCreateDataTableDto, UpdateDataTableDto, } from '@n8n/api-types'; -import { ProjectRepository, ProjectRelationRepository } from '@n8n/db'; import { DataTableRepository } from '@/modules/data-table/data-table.repository'; import { Container } from '@n8n/di'; import type express from 'express'; @@ -19,6 +18,14 @@ import { DataTableService } from '@/modules/data-table/data-table.service'; import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error'; import { DataTableNameConflictError } from '@/modules/data-table/errors/data-table-name-conflict.error'; import { DataTableValidationError } from '@/modules/data-table/errors/data-table-validation.error'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { + getProjectIdForDataTable, + getDataTableListFilter, + resolveProjectIdForCreate, +} from './data-tables.service'; +import { ProjectService } from '@/services/project.service.ee'; const handleError = (error: unknown, res: express.Response): express.Response => { if (error instanceof DataTableNotFoundError) { @@ -30,6 +37,9 @@ const handleError = (error: unknown, res: express.Response): express.Response => if (error instanceof DataTableValidationError) { return res.status(400).json({ message: error.message }); } + if (error instanceof ForbiddenError) { + return res.status(error.httpStatusCode).json({ message: error.message }); + } if (error instanceof Error) { return res.status(400).json({ message: error.message }); } @@ -50,23 +60,6 @@ const stringifyQuery = (query: Record): Record => { - const dataTable = await Container.get(DataTableRepository).findOne({ - where: { id: dataTableId }, - relations: ['project'], - }); - - if (!dataTable) { - throw new DataTableNotFoundError(dataTableId); - } - - return dataTable.project.id; -}; - export = { listDataTables: [ publicApiScope('dataTable:list'), @@ -83,35 +76,26 @@ export = { const { offset, limit, filter, sortBy } = payload.data; const providedFilter = filter ?? {}; - const { projectId: _ignoredProjectId, ...restFilter } = providedFilter; + const { projectId: requestedProjectId, ...restFilter } = providedFilter; const isGlobalOwnerOrAdmin = ['global:owner', 'global:admin'].includes(req.user.role.slug); - let finalFilter: any; - if (isGlobalOwnerOrAdmin) { - finalFilter = restFilter; - } else { - const personalProject = await Container.get( - ProjectRepository, - ).getPersonalProjectForUserOrFail(req.user.id); - - const projectRelations = await Container.get(ProjectRelationRepository).find({ - where: { userId: req.user.id }, - relations: ['project'], - }); - - const teamProjectIds = projectRelations - .filter((rel) => rel.project.type === 'team') - .map((rel) => rel.projectId); - - const allAccessibleProjectIds = [personalProject.id, ...teamProjectIds]; - - finalFilter = { - ...restFilter, - projectId: allAccessibleProjectIds, - }; + if (requestedProjectId && !isGlobalOwnerOrAdmin) { + const projectWithScope = await Container.get(ProjectService).getProjectWithScope( + req.user, + requestedProjectId, + ['dataTable:listProject'], + ); + if (!projectWithScope) return res.json({ data: [], nextCursor: null }); } + const finalFilter = await getDataTableListFilter( + req.user.id, + isGlobalOwnerOrAdmin, + requestedProjectId, + restFilter, + ); + const result = await Container.get(DataTableService).getManyAndCount({ skip: offset, take: limit, @@ -138,22 +122,17 @@ export = { createDataTable: [ publicApiScope('dataTable:create'), async (req: DataTableRequest.Create, res: express.Response): Promise => { + const payload = PublicApiCreateDataTableDto.safeParse(req.body); + if (!payload.success) { + throw new BadRequestError(payload.error.errors[0]?.message || 'Invalid request body'); + } + + const { projectId: requestedProjectId, ...dto } = payload.data; + + const projectId = await resolveProjectIdForCreate(req.user, requestedProjectId); + try { - const payload = CreateDataTableDto.safeParse(req.body); - if (!payload.success) { - return res.status(400).json({ - message: payload.error.errors[0]?.message || 'Invalid request body', - }); - } - - const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail( - req.user.id, - ); - - const result = await Container.get(DataTableService).createDataTable( - project.id, - payload.data, - ); + const result = await Container.get(DataTableService).createDataTable(projectId, dto); const { project: _project, ...dataTable } = result; diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.service.ts b/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.service.ts new file mode 100644 index 00000000000..19612697d28 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/data-tables/data-tables.service.ts @@ -0,0 +1,82 @@ +import { type DataTableListFilter } from '@n8n/api-types'; +import { ProjectRepository, ProjectRelationRepository, type User } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { DataTableRepository } from '@/modules/data-table/data-table.repository'; +import { DataTableNotFoundError } from '@/modules/data-table/errors/data-table-not-found.error'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { ProjectService } from '@/services/project.service.ee'; + +/** + * Gets the project ID for a data table. + * Called AFTER projectScope middleware has validated access. + */ +export async function getProjectIdForDataTable(dataTableId: string): Promise { + const dataTable = await Container.get(DataTableRepository).findOne({ + where: { id: dataTableId }, + relations: ['project'], + }); + + if (!dataTable) { + throw new DataTableNotFoundError(dataTableId); + } + + return dataTable.project.id; +} + +export async function getDataTableListFilter( + userId: string, + isGlobalOwnerOrAdmin: boolean, + requestedProjectId: string | string[] | undefined, + restFilter: Omit, +): Promise { + if (requestedProjectId) { + return { ...restFilter, projectId: requestedProjectId }; + } + + if (isGlobalOwnerOrAdmin) { + return restFilter; + } + + const personalProject = + await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(userId); + + const projectRelations = await Container.get(ProjectRelationRepository).find({ + where: { userId }, + relations: ['project'], + }); + + const teamProjectIds = projectRelations + .filter((rel) => rel.project.type === 'team') + .map((rel) => rel.projectId); + + return { + ...restFilter, + projectId: [personalProject.id, ...teamProjectIds], + }; +} + +export async function resolveProjectIdForCreate( + user: User, + requestedProjectId?: string, +): Promise { + if (!requestedProjectId) { + const project = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(user.id); + return project.id; + } + + const exists = await Container.get(ProjectRepository).findOneBy({ id: requestedProjectId }); + if (!exists) { + throw new BadRequestError(`Project with ID "${requestedProjectId}" not found`); + } + + const project = await Container.get(ProjectService).getProjectWithScope( + user, + requestedProjectId, + ['dataTable:create'], + ); + if (!project) throw new ForbiddenError(); + + return project.id; +} diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.yml index bea52d173fb..44f8b4c9231 100644 --- a/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.yml +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/paths/data-tables.yml @@ -42,7 +42,7 @@ post: tags: - DataTable summary: Create a new data table - description: Create a new data table in your workspace. + description: Create a new data table in your personal project or a team project you have access to. operationId: create-data-table requestBody: required: true @@ -50,15 +50,28 @@ post: application/json: schema: $ref: '../schemas/createDataTableRequest.yml' - example: - name: 'customers' - columns: - - name: 'email' - type: 'string' - - name: 'status' - type: 'string' - - name: 'age' - type: 'number' + examples: + personalProject: + summary: Default (personal project) + value: + name: 'customers' + columns: + - name: 'email' + type: 'string' + - name: 'status' + type: 'string' + - name: 'age' + type: 'number' + scopedProject: + summary: Explicit project + value: + name: 'customers' + projectId: 'a1b2c3d4' + columns: + - name: 'email' + type: 'string' + - name: 'status' + type: 'string' responses: '201': description: Data table created successfully @@ -70,6 +83,8 @@ post: $ref: '../../../../shared/spec/responses/badRequest.yml' '401': $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' '409': $ref: '../../../../shared/spec/responses/conflict.yml' security: diff --git a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/createDataTableRequest.yml b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/createDataTableRequest.yml index 3d2f9bdbc87..44ce2cd0324 100644 --- a/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/createDataTableRequest.yml +++ b/packages/cli/src/public-api/v1/handlers/data-tables/spec/schemas/createDataTableRequest.yml @@ -22,6 +22,10 @@ properties: required: - name - type + projectId: + type: string + description: | + ID of the project to create the table in. When omitted, the table is created in the user's personal project. required: - name - columns diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index 0cbc8af4586..1be98a14ca8 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -591,7 +591,10 @@ export class ProjectService { if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) { const projectRoles = await this.roleService.rolesWithScope('project', scopes); - where.type = 'team'; + // if we're not checking specific projects, restrict to team projects + if (!projectIds) { + where.type = 'team'; + } where.projectRelations = { role: In(projectRoles), userId: user.id, 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 36f83028d29..f929b03a89c 100644 --- a/packages/cli/test/integration/public-api/data-tables.test.ts +++ b/packages/cli/test/integration/public-api/data-tables.test.ts @@ -1540,3 +1540,93 @@ describe('Filter Parameter Validation', () => { }); }); }); + +describe('POST /data-tables with projectId', () => { + beforeEach(() => { + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + }); + + test('should create a table in the specified team project when user is a member', async () => { + const teamProject = await createTeamProject('Team Create'); + await linkUserToProject(member, teamProject, 'project:editor'); + + const response = await authMemberAgent.post('/data-tables').send({ + name: 'team-table', + columns: [{ name: 'col1', type: 'string' }], + projectId: teamProject.id, + }); + + expect(response.statusCode).toBe(201); + expect(response.body).toHaveProperty('id'); + expect(response.body).toHaveProperty('name', 'team-table'); + expect(response.body).toHaveProperty('projectId', teamProject.id); + expect(response.body).not.toHaveProperty('project'); + }); + + test('should return 403 when user is not a member of the specified project', async () => { + const teamProject = await createTeamProject('Team No Access'); + // member is NOT added to teamProject + + const response = await authMemberAgent.post('/data-tables').send({ + name: 'unauthorized-table', + columns: [{ name: 'col1', type: 'string' }], + projectId: teamProject.id, + }); + + expect(response.statusCode).toBe(403); + }); +}); + +describe('GET /data-tables with projectId filter', () => { + beforeEach(() => { + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + }); + + test('should return only tables from the specified team project when user is a member', async () => { + const teamProject = await createTeamProject('Team Filter'); + await linkUserToProject(member, teamProject, 'project:viewer'); + + await createDataTable(memberPersonalProject, { + name: 'personal-table', + columns: [{ name: 'col', type: 'string' }], + }); + await createDataTable(teamProject, { + name: 'team-table', + columns: [{ name: 'col', type: 'string' }], + }); + + const filter = JSON.stringify({ projectId: teamProject.id }); + const response = await authMemberAgent.get('/data-tables').query({ filter }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].name).toBe('team-table'); + }); + + test('should return empty results when filtering by a project the user is not a member of', async () => { + const teamProject = await createTeamProject('Team No Filter Access'); + // member is NOT added to teamProject + + const filter = JSON.stringify({ projectId: teamProject.id }); + const response = await authMemberAgent.get('/data-tables').query({ filter }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(0); + }); + + test('should allow filtering by the user personal project id', async () => { + await createDataTable(memberPersonalProject, { + name: 'personal-only-table', + columns: [{ name: 'col', type: 'string' }], + }); + + const filter = JSON.stringify({ projectId: memberPersonalProject.id }); + const response = await authMemberAgent.get('/data-tables').query({ filter }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].name).toBe('personal-only-table'); + }); +});