diff --git a/packages/cli/src/controllers/folder.controller.ts b/packages/cli/src/controllers/folder.controller.ts index 0a583c3eae..51746e52d3 100644 --- a/packages/cli/src/controllers/folder.controller.ts +++ b/packages/cli/src/controllers/folder.controller.ts @@ -17,6 +17,7 @@ import { Query, Put, Param, + Licensed, } from '@n8n/decorators'; import { Response } from 'express'; import { UserError } from 'n8n-workflow'; @@ -37,6 +38,7 @@ export class ProjectController { @Post('/') @ProjectScope('folder:create') + @Licensed('feat:folders') async createFolder( req: AuthenticatedRequest<{ projectId: string }>, _res: Response, @@ -55,6 +57,7 @@ export class ProjectController { @Get('/:folderId/tree') @ProjectScope('folder:read') + @Licensed('feat:folders') async getFolderTree( req: AuthenticatedRequest<{ projectId: string; folderId: string }>, _res: Response, @@ -74,6 +77,7 @@ export class ProjectController { @Get('/:folderId/credentials') @ProjectScope('folder:read') + @Licensed('feat:folders') async getFolderUsedCredentials( req: AuthenticatedRequest<{ projectId: string; folderId: string }>, _res: Response, @@ -97,6 +101,7 @@ export class ProjectController { @Patch('/:folderId') @ProjectScope('folder:update') + @Licensed('feat:folders') async updateFolder( req: AuthenticatedRequest<{ projectId: string; folderId: string }>, _res: Response, @@ -118,6 +123,7 @@ export class ProjectController { @Delete('/:folderId') @ProjectScope('folder:delete') + @Licensed('feat:folders') async deleteFolder( req: AuthenticatedRequest<{ projectId: string; folderId: string }>, _res: Response, @@ -139,6 +145,7 @@ export class ProjectController { @Get('/') @ProjectScope('folder:list') + @Licensed('feat:folders') async listFolders( req: AuthenticatedRequest<{ projectId: string }>, res: Response, @@ -153,6 +160,7 @@ export class ProjectController { @Get('/:folderId/content') @ProjectScope('folder:read') + @Licensed('feat:folders') async getFolderContent(req: AuthenticatedRequest<{ projectId: string; folderId: string }>) { const { projectId, folderId } = req.params; @@ -174,6 +182,7 @@ export class ProjectController { @Put('/:folderId/transfer') @ProjectScope('folder:move') + @Licensed('feat:folders') async transferFolderToProject( req: AuthenticatedRequest, _res: unknown, diff --git a/packages/cli/test/integration/folder/folder.controller.test.ts b/packages/cli/test/integration/folder/folder.controller.test.ts index 18ca545cba..1889d432b4 100644 --- a/packages/cli/test/integration/folder/folder.controller.test.ts +++ b/packages/cli/test/integration/folder/folder.controller.test.ts @@ -49,6 +49,8 @@ let workflowRepository: WorkflowRepository; const activeWorkflowManager = mockInstance(ActiveWorkflowManager); beforeEach(async () => { + testServer.license.enable('feat:folders'); + await testDb.truncate(['Folder', 'SharedWorkflow', 'TagEntity', 'Project', 'ProjectRelation']); projectRepository = Container.get(ProjectRepository); @@ -66,6 +68,18 @@ beforeEach(async () => { }); describe('POST /projects/:projectId/folders', () => { + test('should now create folder if license does not allow it', async () => { + testServer.license.disable('feat:folders'); + const project = await createTeamProject(undefined, owner); + await linkUserToProject(member, project, 'project:viewer'); + + const payload = { + name: 'Test Folder', + }; + + await authMemberAgent.post(`/projects/${project.id}/folders`).send(payload).expect(403); + }); + test('should not create folder when project does not exist', async () => { const payload = { name: 'Test Folder', @@ -235,6 +249,32 @@ describe('POST /projects/:projectId/folders', () => { }); describe('GET /projects/:projectId/folders/:folderId/tree', () => { + test('should not retrieve folder tree if license does not allow it', async () => { + testServer.license.disable('feat:folders'); + + const project = await createTeamProject('test', owner); + const rootFolder = await createFolder(project, { name: 'Root' }); + + const childFolder1 = await createFolder(project, { + name: 'Child 1', + parentFolder: rootFolder, + }); + + await createFolder(project, { + name: 'Child 2', + parentFolder: rootFolder, + }); + + const grandchildFolder = await createFolder(project, { + name: 'Grandchild', + parentFolder: childFolder1, + }); + + await authOwnerAgent + .get(`/projects/${project.id}/folders/${grandchildFolder.id}/tree`) + .expect(403); + }); + test('should not get folder tree when project does not exist', async () => { await authOwnerAgent.get('/projects/non-existing-id/folders/some-folder-id/tree').expect(403); }); @@ -311,6 +351,68 @@ describe('GET /projects/:projectId/folders/:folderId/tree', () => { }); describe('GET /projects/:projectId/folders/:folderId/credentials', () => { + test('should not retrieve folder tree if license does not allow it', async () => { + testServer.license.disable('feat:folders'); + + const project = await createTeamProject('test', owner); + const rootFolder = await createFolder(project, { name: 'Root' }); + + const childFolder1 = await createFolder(project, { + name: 'Child 1', + parentFolder: rootFolder, + }); + + await createFolder(project, { + name: 'Child 2', + parentFolder: rootFolder, + }); + + const grandchildFolder = await createFolder(project, { + name: 'Grandchild', + parentFolder: childFolder1, + }); + + for (const folder of [rootFolder, childFolder1, grandchildFolder]) { + const credential = await createCredentials( + { + name: `Test credential ${folder.name}`, + data: '', + type: 'test', + }, + project, + ); + + await createWorkflow( + { + name: 'Test Workflow', + parentFolder: folder, + active: false, + nodes: [ + { + parameters: {}, + type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', + typeVersion: 1.2, + position: [0, 0], + id: faker.string.uuid(), + name: 'OpenAI Chat Model', + credentials: { + openAiApi: { + id: credential.id, + name: credential.name, + }, + }, + }, + ], + }, + owner, + ); + } + + await authOwnerAgent + .get(`/projects/${project.id}/folders/${childFolder1.id}/credentials`) + .expect(403); + }); + test('should not get folder credentials when project does not exist', async () => { await authOwnerAgent .get('/projects/non-existing-id/folders/some-folder-id/credentials') @@ -416,6 +518,23 @@ describe('GET /projects/:projectId/folders/:folderId/credentials', () => { }); describe('PATCH /projects/:projectId/folders/:folderId', () => { + test('should not update folder if license does not allow it', async () => { + testServer.license.disable('feat:folders'); + + const project = await createTeamProject(undefined, owner); + const folder = await createFolder(project, { name: 'Original Name' }); + await linkUserToProject(member, project, 'project:editor'); + + const payload = { + name: 'Updated Folder Name', + }; + + await authMemberAgent + .patch(`/projects/${project.id}/folders/${folder.id}`) + .send(payload) + .expect(403); + }); + test('should not update folder when project does not exist', async () => { const payload = { name: 'Updated Folder Name', @@ -868,6 +987,18 @@ describe('PATCH /projects/:projectId/folders/:folderId', () => { }); describe('DELETE /projects/:projectId/folders/:folderId', () => { + test('should not delete folder if license does not allow it', async () => { + testServer.license.disable('feat:folders'); + + const project = await createTeamProject(undefined, owner); + const folder = await createFolder(project); + + await authOwnerAgent + .delete(`/projects/${project.id}/folders/${folder.id}`) + .send({}) + .expect(403); + }); + test('should not delete folder when project does not exist', async () => { await authOwnerAgent .delete('/projects/non-existing-id/folders/some-folder-id') @@ -1159,6 +1290,16 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => { }); describe('GET /projects/:projectId/folders', () => { + test('should not retrieve folder if license does not allow it', async () => { + testServer.license.disable('feat:folders'); + + const project = await createTeamProject('test project', owner); + await linkUserToProject(member, project, 'project:viewer'); + await createFolder(project, { name: 'Test Folder' }); + + await authMemberAgent.get(`/projects/${project.id}/folders`).expect(403); + }); + test('should not list folders when project does not exist', async () => { await authOwnerAgent.get('/projects/non-existing-id/folders').expect(403); }); @@ -1570,6 +1711,16 @@ describe('GET /projects/:projectId/folders', () => { }); describe('GET /projects/:projectId/folders/content', () => { + test('should not retrieve folder content if license does not allow it', async () => { + testServer.license.disable('feat:folders'); + + const project = await createTeamProject('test project', owner); + await linkUserToProject(member, project, 'project:viewer'); + const folder = await createFolder(project, { name: 'Test Folder' }); + + await authMemberAgent.get(`/projects/${project.id}/folders/${folder.id}/content`).expect(403); + }); + test('should not list folders when project does not exist', async () => { await authOwnerAgent .get('/projects/non-existing-id/folders/no-existing-id/content') @@ -1634,6 +1785,31 @@ describe('GET /projects/:projectId/folders/content', () => { }); describe('PUT /projects/:projectId/folders/:folderId/transfer', () => { + test('should not transfer folder if license does not allow it', async () => { + testServer.license.disable('feat:folders'); + + const admin = await createUser({ role: 'global:admin' }); + const sourceProject = await createTeamProject('source project', admin); + const destinationProject = await createTeamProject('destination project', member); + const sourceFolder1 = await createFolder(sourceProject, { name: 'Source Folder 1' }); + + const credential = await saveCredential(randomCredentialPayload(), { + project: sourceProject, + role: 'credential:owner', + }); + + // ACT + await testServer + .authAgentFor(owner) + .put(`/projects/${sourceProject.id}/folders/${sourceFolder1.id}/transfer`) + .send({ + destinationProjectId: destinationProject.id, + destinationParentFolderId: '0', + shareCredentials: [credential.id], + }) + .expect(403); + }); + test('cannot transfer into the same project', async () => { const sourceProject = await createTeamProject('source project', member); const destinationProject = await createTeamProject('Team Project', member);