Skip to content

Commit

Permalink
feat(core): Add endpoint PATCH `/projects/:projectId/folders/:folderI…
Browse files Browse the repository at this point in the history
…d` (no-changelog) (#13454)
  • Loading branch information
RicardoE105 authored Feb 26, 2025
1 parent b50658c commit f7f5f5e
Show file tree
Hide file tree
Showing 10 changed files with 217 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { UpdateFolderDto } from '../update-folder.dto';

describe('UpdateFolderDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'name without parentId',
request: {
name: 'test',
},
},
])('should validate $name', ({ request }) => {
const result = UpdateFolderDto.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'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = UpdateFolderDto.safeParse(request);

expect(result.success).toBe(false);

if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});
4 changes: 3 additions & 1 deletion packages/@n8n/api-types/src/dto/folders/create-folder.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { z } from 'zod';
import { Z } from 'zod-class';

import { folderNameSchema } from '../../schemas/folder.schema';

export class CreateFolderDto extends Z.class({
name: z.string().trim().min(1).max(128),
name: folderNameSchema,
parentFolderId: z.string().optional(),
}) {}
7 changes: 7 additions & 0 deletions packages/@n8n/api-types/src/dto/folders/update-folder.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Z } from 'zod-class';

import { folderNameSchema } from '../../schemas/folder.schema';

export class UpdateFolderDto extends Z.class({
name: folderNameSchema,
}) {}
1 change: 1 addition & 0 deletions packages/@n8n/api-types/src/dto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,4 @@ 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';
export { UpdateFolderDto } from './folders/update-folder.dto';
3 changes: 3 additions & 0 deletions packages/@n8n/api-types/src/schemas/folder.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { z } from 'zod';

export const folderNameSchema = z.string().trim().min(1).max(128);
2 changes: 1 addition & 1 deletion packages/@n8n/permissions/src/constants.ee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@ export const RESOURCES = {
variable: [...DEFAULT_OPERATIONS] as const,
workersView: ['manage'] as const,
workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const,
folder: ['create', 'read'] as const,
folder: ['create', 'read', 'update'] as const,
} as const;
23 changes: 21 additions & 2 deletions packages/cli/src/controllers/folder.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CreateFolderDto } from '@n8n/api-types';
import { CreateFolderDto, UpdateFolderDto } from '@n8n/api-types';
import { Response } from 'express';

import { Post, RestController, ProjectScope, Body, Get } from '@/decorators';
import { Post, RestController, ProjectScope, Body, Get, Patch } 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';
Expand Down Expand Up @@ -48,4 +48,23 @@ export class ProjectController {
throw new InternalServerError();
}
}

@Patch('/:folderId')
@ProjectScope('folder:update')
async updateFolder(
req: AuthenticatedRequest<{ projectId: string; folderId: string }>,
_res: Response,
@Body payload: UpdateFolderDto,
) {
const { projectId, folderId } = req.params;

try {
await this.folderService.updateFolder(folderId, projectId, payload);
} catch (e) {
if (e instanceof FolderNotFoundError) {
throw new NotFoundError(e.message);
}
throw new InternalServerError();
}
}
}
3 changes: 3 additions & 0 deletions packages/cli/src/permissions.ee/project-roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [
'project:delete',
'folder:create',
'folder:read',
'folder:update',
];

export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
Expand All @@ -49,6 +50,7 @@ export const PERSONAL_PROJECT_OWNER_SCOPES: Scope[] = [
'project:read',
'folder:create',
'folder:read',
'folder:update',
];

export const PROJECT_EDITOR_SCOPES: Scope[] = [
Expand All @@ -67,6 +69,7 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [
'project:read',
'folder:create',
'folder:read',
'folder:update',
];

export const PROJECT_VIEWER_SCOPES: Scope[] = [
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/services/folder.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CreateFolderDto } from '@n8n/api-types';
import type { CreateFolderDto, UpdateFolderDto } from '@n8n/api-types';
import { Service } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import type { EntityManager } from '@n8n/typeorm';
Expand Down Expand Up @@ -39,6 +39,11 @@ export class FolderService {
return folder;
}

async updateFolder(folderId: string, projectId: string, { name }: UpdateFolderDto) {
await this.getFolderInProject(folderId, projectId);
return await this.folderRepository.update({ id: folderId }, { name });
}

async getFolderInProject(folderId: string, projectId: string, em?: EntityManager) {
try {
return await this.folderRepository.findOneOrFailFolderInProject(folderId, projectId, em);
Expand Down
130 changes: 130 additions & 0 deletions packages/cli/test/integration/folder/folder.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,3 +279,133 @@ describe('GET /projects/:projectId/folders/:folderId/tree', () => {
);
});
});

describe('PATCH /projects/:projectId/folders/:folderId', () => {
test('should not update folder when project does not exist', async () => {
const payload = {
name: 'Updated Folder Name',
};

await authOwnerAgent
.patch('/projects/non-existing-id/folders/some-folder-id')
.send(payload)
.expect(403);
});

test('should not update folder when folder does not exist', async () => {
const project = await createTeamProject('test project', owner);

const payload = {
name: 'Updated Folder Name',
};

await authOwnerAgent
.patch(`/projects/${project.id}/folders/non-existing-folder`)
.send(payload)
.expect(404);
});

test('should not update folder when name is empty', async () => {
const project = await createTeamProject(undefined, owner);
const folder = await createFolder(project, { name: 'Original Name' });

const payload = {
name: '',
};

await authOwnerAgent
.patch(`/projects/${project.id}/folders/${folder.id}`)
.send(payload)
.expect(400);

const folderInDb = await folderRepository.findOneBy({ id: folder.id });
expect(folderInDb?.name).toBe('Original Name');
});

test('should not update folder if user has project:viewer role in team project', async () => {
const project = await createTeamProject(undefined, owner);
const folder = await createFolder(project, { name: 'Original Name' });
await linkUserToProject(member, project, 'project:viewer');

const payload = {
name: 'Updated Folder Name',
};

await authMemberAgent
.patch(`/projects/${project.id}/folders/${folder.id}`)
.send(payload)
.expect(403);

const folderInDb = await folderRepository.findOneBy({ id: folder.id });
expect(folderInDb?.name).toBe('Original Name');
});

test("should not allow updating folder in another user's personal project", async () => {
const ownerPersonalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
const folder = await createFolder(ownerPersonalProject, { name: 'Original Name' });

const payload = {
name: 'Updated Folder Name',
};

await authMemberAgent
.patch(`/projects/${ownerPersonalProject.id}/folders/${folder.id}`)
.send(payload)
.expect(403);

const folderInDb = await folderRepository.findOneBy({ id: folder.id });
expect(folderInDb?.name).toBe('Original Name');
});

test('should update folder if user has project:editor role in team project', async () => {
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(200);

const folderInDb = await folderRepository.findOneBy({ id: folder.id });
expect(folderInDb?.name).toBe('Updated Folder Name');
});

test('should update folder if user has project:admin role in team project', async () => {
const project = await createTeamProject(undefined, owner);
const folder = await createFolder(project, { name: 'Original Name' });

const payload = {
name: 'Updated Folder Name',
};

await authOwnerAgent
.patch(`/projects/${project.id}/folders/${folder.id}`)
.send(payload)
.expect(200);

const folderInDb = await folderRepository.findOneBy({ id: folder.id });
expect(folderInDb?.name).toBe('Updated Folder Name');
});

test('should update folder in personal project', async () => {
const personalProject = await projectRepository.getPersonalProjectForUserOrFail(owner.id);
const folder = await createFolder(personalProject, { name: 'Original Name' });

const payload = {
name: 'Updated Folder Name',
};

await authOwnerAgent
.patch(`/projects/${personalProject.id}/folders/${folder.id}`)
.send(payload)
.expect(200);

const folderInDb = await folderRepository.findOneBy({ id: folder.id });
expect(folderInDb?.name).toBe('Updated Folder Name');
});
});

0 comments on commit f7f5f5e

Please sign in to comment.