diff --git a/packages/@n8n/api-types/src/dto/folders/__tests__/create-folder-request.dto.test.ts b/packages/@n8n/api-types/src/dto/folders/__tests__/create-folder-request.dto.test.ts new file mode 100644 index 0000000000..0659590743 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/folders/__tests__/create-folder-request.dto.test.ts @@ -0,0 +1,65 @@ +import { CreateFolderDto } from '../create-folder.dto'; + +describe('CreateFolderDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'name without parentId', + request: { + name: 'test', + }, + }, + { + name: 'name and parentFolderId', + request: { + name: 'test', + parentFolderId: '2Hw01NJ7biAj_LU6', + }, + }, + ])('should validate $name', ({ request }) => { + const result = CreateFolderDto.safeParse(request); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'missing name', + request: {}, + expectedErrorPath: ['name'], + }, + { + name: 'empty name', + request: { + name: '', + }, + expectedErrorPath: ['name'], + }, + + { + name: 'parentFolderId and no name', + request: { + parentFolderId: '', + }, + expectedErrorPath: ['name'], + }, + { + name: 'invalid parentFolderId', + request: { + name: 'test', + parentFolderId: 1, + }, + expectedErrorPath: ['parentFolderId'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = CreateFolderDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/folders/create-folder.dto.ts b/packages/@n8n/api-types/src/dto/folders/create-folder.dto.ts new file mode 100644 index 0000000000..d0c59eaf54 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/folders/create-folder.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class CreateFolderDto extends Z.class({ + name: z.string().trim().min(1).max(128), + parentFolderId: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 10295fc6d9..dd3fd20fac 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -54,3 +54,5 @@ export { RetrieveTagQueryDto } from './tag/retrieve-tag-query.dto'; export { UpdateApiKeyRequestDto } from './api-keys/update-api-key-request.dto'; export { CreateApiKeyRequestDto } from './api-keys/create-api-key-request.dto'; + +export { CreateFolderDto } from './folders/create-folder.dto'; diff --git a/packages/cli/src/controllers/folder.controller.ts b/packages/cli/src/controllers/folder.controller.ts new file mode 100644 index 0000000000..0af6d16779 --- /dev/null +++ b/packages/cli/src/controllers/folder.controller.ts @@ -0,0 +1,32 @@ +import { CreateFolderDto } from '@n8n/api-types'; +import { Response } from 'express'; + +import { Post, RestController, ProjectScope, Body } from '@/decorators'; +import { FolderNotFoundError } from '@/errors/folder-not-found.error'; +import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { AuthenticatedRequest } from '@/requests'; +import { FolderService } from '@/services/folder.service'; + +@RestController('/projects/:projectId/folders') +export class ProjectController { + constructor(private readonly folderService: FolderService) {} + + @Post('/') + @ProjectScope('folder:create') + async createFolder( + req: AuthenticatedRequest<{ projectId: string }>, + _res: Response, + @Body payload: CreateFolderDto, + ) { + try { + const folder = await this.folderService.createFolder(payload, req.params.projectId); + return folder; + } catch (e) { + if (e instanceof FolderNotFoundError) { + throw new NotFoundError(e.message); + } + throw new InternalServerError(); + } + } +} diff --git a/packages/cli/src/databases/repositories/folder.repository.ts b/packages/cli/src/databases/repositories/folder.repository.ts index e331bb8463..536dea8291 100644 --- a/packages/cli/src/databases/repositories/folder.repository.ts +++ b/packages/cli/src/databases/repositories/folder.repository.ts @@ -226,4 +226,15 @@ export class FolderRepository extends Repository { query.skip(options.skip ?? 0).take(options.take); } } + + async findOneOrFailFolderInProject(folderId: string, projectId: string): Promise { + return await this.manager.findOneOrFail(Folder, { + where: { + id: folderId, + homeProject: { + id: projectId, + }, + }, + }); + } } diff --git a/packages/cli/src/errors/folder-not-found.error.ts b/packages/cli/src/errors/folder-not-found.error.ts new file mode 100644 index 0000000000..1066410b54 --- /dev/null +++ b/packages/cli/src/errors/folder-not-found.error.ts @@ -0,0 +1,9 @@ +import { OperationalError } from 'n8n-workflow'; + +export class FolderNotFoundError extends OperationalError { + constructor(folderId: string) { + super(`Could not find the folder: ${folderId}`, { + level: 'warning', + }); + } +} diff --git a/packages/cli/src/errors/response-errors/internal-server.error.ts b/packages/cli/src/errors/response-errors/internal-server.error.ts index 2a6a8d6b77..2ebe5ca67c 100644 --- a/packages/cli/src/errors/response-errors/internal-server.error.ts +++ b/packages/cli/src/errors/response-errors/internal-server.error.ts @@ -1,7 +1,7 @@ import { ResponseError } from './abstract/response.error'; export class InternalServerError extends ResponseError { - constructor(message: string, cause?: unknown) { - super(message, 500, 500, undefined, cause); + constructor(message?: string, cause?: unknown) { + super(message ? message : 'Internal Server Error', 500, 500, undefined, cause); } } diff --git a/packages/cli/src/permissions.ee/project-roles.ts b/packages/cli/src/permissions.ee/project-roles.ts index d05507c47c..985c12e68b 100644 --- a/packages/cli/src/permissions.ee/project-roles.ts +++ b/packages/cli/src/permissions.ee/project-roles.ts @@ -25,6 +25,7 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [ 'project:read', 'project:update', 'project:delete', + 'folder:create', ]; export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ @@ -45,6 +46,7 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [ 'credential:move', 'project:list', 'project:read', + 'folder:create', ]; export const PROJECT_EDITOR_SCOPES: Scope[] = [ @@ -61,6 +63,7 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [ 'credential:list', 'project:list', 'project:read', + 'folder:create', ]; export const PROJECT_VIEWER_SCOPES: Scope[] = [ diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index e505951dd4..352f582091 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -51,6 +51,7 @@ import '@/controllers/project.controller'; import '@/controllers/role.controller'; import '@/controllers/tags.controller'; import '@/controllers/translation.controller'; +import '@/controllers/folder.controller'; import '@/controllers/users.controller'; import '@/controllers/user-settings.controller'; import '@/controllers/workflow-statistics.controller'; diff --git a/packages/cli/src/services/folder.service.ts b/packages/cli/src/services/folder.service.ts new file mode 100644 index 0000000000..b419566677 --- /dev/null +++ b/packages/cli/src/services/folder.service.ts @@ -0,0 +1,34 @@ +import type { CreateFolderDto } from '@n8n/api-types'; +import { Service } from '@n8n/di'; + +import { FolderRepository } from '@/databases/repositories/folder.repository'; +import { FolderNotFoundError } from '@/errors/folder-not-found.error'; + +@Service() +export class FolderService { + constructor(private readonly folderRepository: FolderRepository) {} + + async createFolder({ parentFolderId, name }: CreateFolderDto, projectId: string) { + let parentFolder = null; + if (parentFolderId) { + try { + parentFolder = await this.folderRepository.findOneOrFailFolderInProject( + parentFolderId, + projectId, + ); + } catch { + throw new FolderNotFoundError(parentFolderId); + } + } + + const folderEntity = this.folderRepository.create({ + name, + homeProject: { id: projectId }, + parentFolder, + }); + + const { homeProject, ...folder } = await this.folderRepository.save(folderEntity); + + return folder; + } +} diff --git a/packages/cli/test/integration/folder/folder.controller.test.ts b/packages/cli/test/integration/folder/folder.controller.test.ts new file mode 100644 index 0000000000..04fc0831e8 --- /dev/null +++ b/packages/cli/test/integration/folder/folder.controller.test.ts @@ -0,0 +1,205 @@ +import { Container } from '@n8n/di'; + +import type { User } from '@/databases/entities/user'; +import { FolderRepository } from '@/databases/repositories/folder.repository'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { createFolder } from '@test-integration/db/folders'; + +import { createTeamProject, linkUserToProject } from '../shared/db/projects'; +import { createOwner, createMember } from '../shared/db/users'; +import * as testDb from '../shared/test-db'; +import type { SuperAgentTest } from '../shared/types'; +import * as utils from '../shared/utils/'; + +let owner: User; +let member: User; +let authOwnerAgent: SuperAgentTest; +let authMemberAgent: SuperAgentTest; + +const testServer = utils.setupTestServer({ + endpointGroups: ['folder'], +}); + +let projectRepository: ProjectRepository; +let folderRepository: FolderRepository; + +beforeEach(async () => { + await testDb.truncate(['Folder', 'SharedWorkflow', 'Tag', 'Project', 'ProjectRelation']); + + projectRepository = Container.get(ProjectRepository); + folderRepository = Container.get(FolderRepository); + + owner = await createOwner(); + member = await createMember(); + authOwnerAgent = testServer.authAgentFor(owner); + authMemberAgent = testServer.authAgentFor(member); +}); + +describe('POST /projects/:projectId/folders', () => { + test('should not create folder when project does not exist', async () => { + const payload = { + name: 'Test Folder', + }; + + await authOwnerAgent.post('/projects/non-existing-id/folders').send(payload).expect(403); + }); + + test('should not create folder when name is empty', async () => { + const project = await createTeamProject(undefined, owner); + const payload = { + name: '', + }; + + await authOwnerAgent.post(`/projects/${project.id}/folders`).send(payload).expect(400); + }); + + test('should not create folder if user has project:viewer role in team project', async () => { + 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); + + const foldersInDb = await folderRepository.find(); + expect(foldersInDb).toHaveLength(0); + }); + + test("should not allow creating folder in another user's personal project", async () => { + const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); + const payload = { + name: 'Test Folder', + }; + + await authMemberAgent + .post(`/projects/${ownerPersonalProject.id}/folders`) + .send(payload) + .expect(403); + }); + + test('should create folder if user has project:editor role in team project', async () => { + const project = await createTeamProject(undefined, owner); + await linkUserToProject(member, project, 'project:editor'); + + const payload = { + name: 'Test Folder', + }; + + await authMemberAgent.post(`/projects/${project.id}/folders`).send(payload).expect(200); + + const foldersInDb = await folderRepository.find(); + expect(foldersInDb).toHaveLength(1); + }); + + test('should create folder if user has project:admin role in team project', async () => { + const project = await createTeamProject(undefined, owner); + + const payload = { + name: 'Test Folder', + }; + + await authOwnerAgent.post(`/projects/${project.id}/folders`).send(payload).expect(200); + + const foldersInDb = await folderRepository.find(); + expect(foldersInDb).toHaveLength(1); + }); + + test('should not allow creating folder with parent that exists in another project', async () => { + const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); + const memberTeamProject = await createTeamProject('test project', member); + const ownerRootFolderInPersonalProject = await createFolder(ownerPersonalProject); + await createFolder(memberTeamProject); + + const payload = { + name: 'Test Folder', + parentFolderId: ownerRootFolderInPersonalProject.id, + }; + + await authMemberAgent + .post(`/projects/${memberTeamProject.id}/folders`) + .send(payload) + .expect(404); + }); + + test('should create folder in root of specified project', async () => { + const project = await createTeamProject('test', owner); + const payload = { + name: 'Test Folder', + }; + + const response = await authOwnerAgent.post(`/projects/${project.id}/folders`).send(payload); + + expect(response.body.data).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: payload.name, + parentFolder: null, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + ); + + const folderInDb = await folderRepository.findOneBy({ id: response.body.id }); + expect(folderInDb).toBeDefined(); + expect(folderInDb?.name).toBe(payload.name); + }); + + test('should create folder in specified project within another folder', async () => { + const project = await createTeamProject('test', owner); + const folder = await createFolder(project); + + const payload = { + name: 'Test Folder', + parentFolderId: folder.id, + }; + + const response = await authOwnerAgent.post(`/projects/${project.id}/folders`).send(payload); + + expect(response.body.data).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: payload.name, + parentFolder: expect.objectContaining({ + id: folder.id, + name: folder.name, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + ); + + const folderInDb = await folderRepository.findOneBy({ id: response.body.data.id }); + + expect(folderInDb).toBeDefined(); + expect(folderInDb?.name).toBe(payload.name); + }); + + test('should create folder in personal project', async () => { + const personalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id); + const payload = { + name: 'Personal Folder', + }; + + const response = await authOwnerAgent + .post(`/projects/${personalProject.id}/folders`) + .send(payload) + .expect(200); + + expect(response.body.data).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: payload.name, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + ); + + const folderInDb = await folderRepository.findOneBy({ id: response.body.id }); + expect(folderInDb).toBeDefined(); + expect(folderInDb?.name).toBe(payload.name); + }); +}); diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 2a789e4f00..d1110f8692 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -43,7 +43,8 @@ type EndpointGroup = | 'dynamic-node-parameters' | 'apiKeys' | 'evaluation' - | 'ai'; + | 'ai' + | 'folder'; export interface SetupProps { endpointGroups?: EndpointGroup[]; diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index aab78ef33e..b242915c92 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -288,6 +288,9 @@ export const setupTestServer = ({ case 'ai': await import('@/controllers/ai.controller'); + + case 'folder': + await import('@/controllers/folder.controller'); } } diff --git a/packages/editor-ui/src/permissions.test.ts b/packages/editor-ui/src/permissions.test.ts index 42857fa9a2..4d1e55647b 100644 --- a/packages/editor-ui/src/permissions.test.ts +++ b/packages/editor-ui/src/permissions.test.ts @@ -58,6 +58,7 @@ describe('permissions', () => { 'workflow:read', 'workflow:share', 'workflow:update', + 'folder:create', ]; const permissionRecord: PermissionsRecord = { @@ -116,7 +117,9 @@ describe('permissions', () => { share: true, update: true, }, - folder: {}, + folder: { + create: true, + }, }; expect(getResourcePermissions(scopes)).toEqual(permissionRecord);