mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(core): Add endpoint POST /projects/:projectId/folders
(no-changelog) (#13446)
This commit is contained in:
parent
0eae14e27a
commit
c850cca648
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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(),
|
||||
}) {}
|
|
@ -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';
|
||||
|
|
32
packages/cli/src/controllers/folder.controller.ts
Normal file
32
packages/cli/src/controllers/folder.controller.ts
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -226,4 +226,15 @@ export class FolderRepository extends Repository<FolderWithWorkflowsCount> {
|
|||
query.skip(options.skip ?? 0).take(options.take);
|
||||
}
|
||||
}
|
||||
|
||||
async findOneOrFailFolderInProject(folderId: string, projectId: string): Promise<Folder> {
|
||||
return await this.manager.findOneOrFail(Folder, {
|
||||
where: {
|
||||
id: folderId,
|
||||
homeProject: {
|
||||
id: projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
9
packages/cli/src/errors/folder-not-found.error.ts
Normal file
9
packages/cli/src/errors/folder-not-found.error.ts
Normal file
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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[] = [
|
||||
|
|
|
@ -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';
|
||||
|
|
34
packages/cli/src/services/folder.service.ts
Normal file
34
packages/cli/src/services/folder.service.ts
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
205
packages/cli/test/integration/folder/folder.controller.test.ts
Normal file
205
packages/cli/test/integration/folder/folder.controller.test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -43,7 +43,8 @@ type EndpointGroup =
|
|||
| 'dynamic-node-parameters'
|
||||
| 'apiKeys'
|
||||
| 'evaluation'
|
||||
| 'ai';
|
||||
| 'ai'
|
||||
| 'folder';
|
||||
|
||||
export interface SetupProps {
|
||||
endpointGroups?: EndpointGroup[];
|
||||
|
|
|
@ -288,6 +288,9 @@ export const setupTestServer = ({
|
|||
|
||||
case 'ai':
|
||||
await import('@/controllers/ai.controller');
|
||||
|
||||
case 'folder':
|
||||
await import('@/controllers/folder.controller');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue