n8n/packages/cli/test/integration/public-api/projects.test.ts

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

784 lines
21 KiB
TypeScript
Raw Normal View History

import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import { Telemetry } from '@/telemetry';
import { mockInstance } from '@test/mocking';
import {
createTeamProject,
getProjectByNameOrFail,
linkUserToProject,
getAllProjectRelations,
2025-01-13 08:01:10 -08:00
getProjectRoleForUser,
} from '@test-integration/db/projects';
import {
createMemberWithApiKey,
createOwnerWithApiKey,
createMember,
} from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
import * as testDb from '../shared/test-db';
describe('Projects in Public API', () => {
const testServer = setupTestServer({ endpointGroups: ['publicApi'] });
mockInstance(Telemetry);
beforeAll(async () => {
await testDb.init();
});
beforeEach(async () => {
await testDb.truncate(['Project', 'User']);
});
describe('GET /projects', () => {
it('if licensed, should return all projects with pagination', async () => {
/**
* Arrange
*/
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
const owner = await createOwnerWithApiKey();
const projects = await Promise.all([
createTeamProject(),
createTeamProject(),
createTeamProject(),
]);
/**
* Act
*/
const response = await testServer.publicApiAgentFor(owner).get('/projects');
/**
* Assert
*/
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('data');
expect(response.body).toHaveProperty('nextCursor');
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBe(projects.length + 1); // +1 for the owner's personal project
projects.forEach(({ id, name }) => {
expect(response.body.data).toContainEqual(expect.objectContaining({ id, name }));
});
});
it('if not authenticated, should reject', async () => {
/**
* Act
*/
const response = await testServer.publicApiAgentWithoutApiKey().get('/projects');
/**
* Assert
*/
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required");
});
it('if not licensed, should reject', async () => {
/**
* Arrange
*/
const owner = await createOwnerWithApiKey();
/**
* Act
*/
const response = await testServer.publicApiAgentFor(owner).get('/projects');
/**
* Assert
*/
expect(response.status).toBe(403);
expect(response.body).toHaveProperty(
'message',
new FeatureNotLicensedError('feat:projectRole:admin').message,
);
});
it('if missing scope, should reject', async () => {
/**
* Arrange
*/
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
const member = await createMemberWithApiKey();
/**
* Act
*/
const response = await testServer.publicApiAgentFor(member).get('/projects');
/**
* Assert
*/
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message', 'Forbidden');
});
});
describe('POST /projects', () => {
it('if licensed, should create a new project', async () => {
/**
* Arrange
*/
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
const owner = await createOwnerWithApiKey();
const projectPayload = { name: 'some-project' };
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(owner)
.post('/projects')
.send(projectPayload);
/**
* Assert
*/
expect(response.status).toBe(201);
expect(response.body).toEqual({
name: 'some-project',
icon: null,
type: 'team',
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
role: 'project:admin',
scopes: expect.any(Array),
});
await expect(getProjectByNameOrFail(projectPayload.name)).resolves.not.toThrow();
});
it('if not authenticated, should reject', async () => {
/**
* Arrange
*/
const projectPayload = { name: 'some-project' };
/**
* Act
*/
const response = await testServer
.publicApiAgentWithoutApiKey()
.post('/projects')
.send(projectPayload);
/**
* Assert
*/
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required");
});
it('if not licensed, should reject', async () => {
/**
* Arrange
*/
const owner = await createOwnerWithApiKey();
const projectPayload = { name: 'some-project' };
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(owner)
.post('/projects')
.send(projectPayload);
/**
* Assert
*/
expect(response.status).toBe(403);
expect(response.body).toHaveProperty(
'message',
new FeatureNotLicensedError('feat:projectRole:admin').message,
);
});
it('if missing scope, should reject', async () => {
/**
* Arrange
*/
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
const member = await createMemberWithApiKey();
const projectPayload = { name: 'some-project' };
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(member)
.post('/projects')
.send(projectPayload);
/**
* Assert
*/
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message', 'Forbidden');
});
});
describe('DELETE /projects/:id', () => {
it('if licensed, should delete a project', async () => {
/**
* Arrange
*/
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
const owner = await createOwnerWithApiKey();
const project = await createTeamProject();
/**
* Act
*/
const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`);
/**
* Assert
*/
expect(response.status).toBe(204);
await expect(getProjectByNameOrFail(project.id)).rejects.toThrow();
});
it('if not authenticated, should reject', async () => {
/**
* Arrange
*/
const project = await createTeamProject();
/**
* Act
*/
const response = await testServer
.publicApiAgentWithoutApiKey()
.delete(`/projects/${project.id}`);
/**
* Assert
*/
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required");
});
it('if not licensed, should reject', async () => {
/**
* Arrange
*/
const owner = await createOwnerWithApiKey();
const project = await createTeamProject();
/**
* Act
*/
const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`);
/**
* Assert
*/
expect(response.status).toBe(403);
expect(response.body).toHaveProperty(
'message',
new FeatureNotLicensedError('feat:projectRole:admin').message,
);
});
it('if missing scope, should reject', async () => {
/**
* Arrange
*/
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
const owner = await createMemberWithApiKey();
const project = await createTeamProject();
/**
* Act
*/
const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`);
/**
* Assert
*/
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message', 'Forbidden');
});
});
describe('PUT /projects/:id', () => {
it('if licensed, should update a project', async () => {
/**
* Arrange
*/
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
const owner = await createOwnerWithApiKey();
const project = await createTeamProject('old-name');
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(owner)
.put(`/projects/${project.id}`)
.send({ name: 'new-name' });
/**
* Assert
*/
expect(response.status).toBe(204);
await expect(getProjectByNameOrFail('new-name')).resolves.not.toThrow();
});
it('if not authenticated, should reject', async () => {
/**
* Arrange
*/
const project = await createTeamProject();
/**
* Act
*/
const response = await testServer
.publicApiAgentWithoutApiKey()
.put(`/projects/${project.id}`)
.send({ name: 'new-name' });
/**
* Assert
*/
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required");
});
it('if not licensed, should reject', async () => {
/**
* Arrange
*/
const owner = await createOwnerWithApiKey();
const project = await createTeamProject();
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(owner)
.put(`/projects/${project.id}`)
.send({ name: 'new-name' });
/**
* Assert
*/
expect(response.status).toBe(403);
expect(response.body).toHaveProperty(
'message',
new FeatureNotLicensedError('feat:projectRole:admin').message,
);
});
it('if missing scope, should reject', async () => {
/**
* Arrange
*/
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
const member = await createMemberWithApiKey();
const project = await createTeamProject();
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(member)
.put(`/projects/${project.id}`)
.send({ name: 'new-name' });
/**
* Assert
*/
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message', 'Forbidden');
});
});
describe('POST /projects/:id/users', () => {
it('if not authenticated, should reject with 401', async () => {
const project = await createTeamProject();
const response = await testServer
.publicApiAgentWithoutApiKey()
.post(`/projects/${project.id}/users`);
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required");
});
it('if not licensed, should reject with a 403', async () => {
const owner = await createOwnerWithApiKey();
const project = await createTeamProject();
const member = await createMember();
const payload = {
2025-01-09 11:50:19 -08:00
relations: [
{
userId: member.id,
role: 'project:viewer',
},
],
};
const response = await testServer
.publicApiAgentFor(owner)
.post(`/projects/${project.id}/users`)
.send(payload);
expect(response.status).toBe(403);
expect(response.body).toHaveProperty(
'message',
new FeatureNotLicensedError('feat:projectRole:admin').message,
);
});
it('if missing scope, should reject with 403', async () => {
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
const member = await createMemberWithApiKey();
const project = await createTeamProject();
const payload = {
2025-01-09 11:50:19 -08:00
relations: [
{
userId: member.id,
role: 'project:viewer',
},
],
};
const response = await testServer
.publicApiAgentFor(member)
.post(`/projects/${project.id}/users`)
.send(payload);
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message', 'Forbidden');
});
describe('when user has correct license', () => {
beforeEach(() => {
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
});
it('should reject with 404 if no project found', async () => {
const owner = await createOwnerWithApiKey();
const member = await createMember();
const payload = {
2025-01-09 11:50:19 -08:00
relations: [
{
userId: member.id,
role: 'project:viewer',
},
],
};
const response = await testServer
.publicApiAgentFor(owner)
2025-01-09 11:50:19 -08:00
.post('/projects/123456/users')
.send(payload);
expect(response.status).toBe(404);
2025-01-09 11:50:19 -08:00
expect(response.body).toHaveProperty('message', 'Project not found.');
});
it('should add expected users to project', async () => {
testServer.license.enable('feat:projectRole:viewer');
testServer.license.enable('feat:projectRole:editor');
const owner = await createOwnerWithApiKey();
const project = await createTeamProject('shared-project', owner);
const member = await createMember();
const member2 = await createMember();
const projectBefore = await getAllProjectRelations({
projectId: project.id,
});
const payload = {
2025-01-09 11:50:19 -08:00
relations: [
{
userId: member.id,
role: 'project:viewer',
},
{
userId: member2.id,
role: 'project:editor',
},
],
};
const response = await testServer
.publicApiAgentFor(owner)
.post(`/projects/${project.id}/users`)
.send(payload);
const projectAfter = await getAllProjectRelations({
projectId: project.id,
});
expect(response.status).toBe(201);
expect(projectBefore.length).toEqual(1);
expect(projectBefore[0].userId).toEqual(owner.id);
expect(projectAfter.length).toEqual(3);
expect(projectAfter[0]).toEqual(
expect.objectContaining({ userId: owner.id, role: 'project:admin' }),
);
expect(projectAfter[1]).toEqual(
expect.objectContaining({ userId: member.id, role: 'project:viewer' }),
);
expect(projectAfter[2]).toEqual(
expect.objectContaining({ userId: member2.id, role: 'project:editor' }),
);
});
it('should reject with 400 if license does not include user role', async () => {
const owner = await createOwnerWithApiKey();
const project = await createTeamProject('shared-project', owner);
const member = await createMember();
const payload = {
2025-01-09 11:50:19 -08:00
relations: [
{
userId: member.id,
role: 'project:viewer',
},
],
};
const response = await testServer
.publicApiAgentFor(owner)
.post(`/projects/${project.id}/users`)
.send(payload);
expect(response.status).toBe(400);
expect(response.body).toHaveProperty(
'message',
'Your instance is not licensed to use role "project:viewer".',
);
});
});
});
2025-01-13 08:01:10 -08:00
describe('PATCH /projects/:id/users/:userId', () => {
it('if not authenticated, should reject with 401', async () => {
const response = await testServer
.publicApiAgentWithoutApiKey()
.patch('/projects/123/users/456')
.send({ role: 'project:viewer' });
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required");
});
it('if not licensed, should reject with a 403', async () => {
const owner = await createOwnerWithApiKey();
const response = await testServer
.publicApiAgentFor(owner)
.patch('/projects/123/users/456')
.send({ role: 'project:viewer' });
expect(response.status).toBe(403);
expect(response.body).toHaveProperty(
'message',
new FeatureNotLicensedError('feat:projectRole:admin').message,
);
});
it('if missing scope, should reject with 403', async () => {
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
const member = await createMemberWithApiKey();
const response = await testServer
.publicApiAgentFor(member)
.patch('/projects/123/users/456')
.send({ role: 'project:viewer' });
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message', 'Forbidden');
});
describe('when user has correct license', () => {
beforeEach(() => {
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
});
it("should change a user's role in a project", async () => {
const owner = await createOwnerWithApiKey();
const project = await createTeamProject('shared-project', owner);
const member = await createMember();
expect(await getProjectRoleForUser(project.id, member.id)).toBeUndefined();
await linkUserToProject(member, project, 'project:viewer');
expect(await getProjectRoleForUser(project.id, member.id)).toBe('project:viewer');
await testServer
.publicApiAgentFor(owner)
.patch(`/projects/${project.id}/users/${member.id}`)
.send({ role: 'project:editor' })
.expect(204);
expect(await getProjectRoleForUser(project.id, member.id)).toBe('project:editor');
});
it('should reject with 404 if no project found', async () => {
const owner = await createOwnerWithApiKey();
const member = await createMember();
const response = await testServer
.publicApiAgentFor(owner)
.patch(`/projects/123456/users/${member.id}`)
.send({ role: 'project:editor' })
.expect(404);
expect(response.body).toHaveProperty('message', 'Project not found.');
});
it('should reject with 404 if user is not in the project', async () => {
const owner = await createOwnerWithApiKey();
const project = await createTeamProject('shared-project', owner);
const member = await createMember();
expect(await getProjectRoleForUser(project.id, member.id)).toBeUndefined();
const response = await testServer
.publicApiAgentFor(owner)
.patch(`/projects/${project.id}/users/${member.id}`)
.send({ role: 'project:editor' })
.expect(404);
expect(response.body).toHaveProperty('message', 'Project not found.');
});
});
});
describe('DELETE /projects/:id/users/:userId', () => {
it('if not authenticated, should reject with 401', async () => {
const project = await createTeamProject();
const member = await createMember();
const response = await testServer
.publicApiAgentWithoutApiKey()
.delete(`/projects/${project.id}/users/${member.id}`);
expect(response.status).toBe(401);
expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required");
});
it('if not licensed, should reject with a 403', async () => {
const owner = await createOwnerWithApiKey();
const project = await createTeamProject();
const member = await createMember();
const response = await testServer
.publicApiAgentFor(owner)
.delete(`/projects/${project.id}/users/${member.id}`);
expect(response.status).toBe(403);
expect(response.body).toHaveProperty(
'message',
new FeatureNotLicensedError('feat:projectRole:admin').message,
);
});
it('if missing scope, should reject with 403', async () => {
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
const member = await createMemberWithApiKey();
const project = await createTeamProject();
const response = await testServer
.publicApiAgentFor(member)
.delete(`/projects/${project.id}/users/${member.id}`);
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message', 'Forbidden');
});
describe('when user has correct license', () => {
beforeEach(() => {
testServer.license.setQuota('quota:maxTeamProjects', -1);
testServer.license.enable('feat:projectRole:admin');
});
it('should remove given user from project', async () => {
const owner = await createOwnerWithApiKey();
const project = await createTeamProject('shared-project', owner);
const member = await createMember();
await linkUserToProject(member, project, 'project:viewer');
const projectBefore = await getAllProjectRelations({
projectId: project.id,
});
const response = await testServer
.publicApiAgentFor(owner)
.delete(`/projects/${project.id}/users/${member.id}`);
const projectAfter = await getAllProjectRelations({
projectId: project.id,
});
expect(response.status).toBe(204);
expect(projectBefore.length).toEqual(2);
expect(projectBefore[0].userId).toEqual(owner.id);
expect(projectBefore[1].userId).toEqual(member.id);
expect(projectAfter.length).toEqual(1);
expect(projectAfter[0].userId).toEqual(owner.id);
});
it('should reject with 404 if no project found', async () => {
const owner = await createOwnerWithApiKey();
const member = await createMember();
const response = await testServer
.publicApiAgentFor(owner)
.delete(`/projects/123456/users/${member.id}`);
expect(response.status).toBe(404);
expect(response.body).toHaveProperty('message', 'Project not found.');
});
it('should remain unchanged if user if not in project', async () => {
const owner = await createOwnerWithApiKey();
const project = await createTeamProject('shared-project', owner);
const member = await createMember();
const projectBefore = await getAllProjectRelations({
projectId: project.id,
});
const response = await testServer
.publicApiAgentFor(owner)
.delete(`/projects/${project.id}/users/${member.id}`);
const projectAfter = await getAllProjectRelations({
projectId: project.id,
});
expect(response.status).toBe(204);
expect(projectBefore.length).toEqual(1);
expect(projectBefore[0].userId).toEqual(owner.id);
expect(projectAfter.length).toEqual(1);
expect(projectAfter[0].userId).toEqual(owner.id);
});
});
});
});