feat(core): Add endpoint PATCH /projects/:projectId/folders/:folderId (no-changelog) (#13454)

This commit is contained in:
Ricardo Espinoza 2025-02-26 07:01:22 -05:00 committed by GitHub
parent b50658cbc6
commit f7f5f5e95c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 217 additions and 5 deletions

View file

@ -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);
}
});
});
});

View file

@ -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(),
}) {}

View file

@ -0,0 +1,7 @@
import { Z } from 'zod-class';
import { folderNameSchema } from '../../schemas/folder.schema';
export class UpdateFolderDto extends Z.class({
name: folderNameSchema,
}) {}

View file

@ -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';

View file

@ -0,0 +1,3 @@
import { z } from 'zod';
export const folderNameSchema = z.string().trim().min(1).max(128);

View file

@ -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;

View file

@ -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';
@ -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();
}
}
}

View file

@ -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[] = [
@ -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[] = [
@ -67,6 +69,7 @@ export const PROJECT_EDITOR_SCOPES: Scope[] = [
'project:read',
'folder:create',
'folder:read',
'folder:update',
];
export const PROJECT_VIEWER_SCOPES: Scope[] = [

View file

@ -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';
@ -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);

View file

@ -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');
});
});