n8n/packages/cli/test/integration/project.api.test.ts
Andreas Fitzek 2a0e2fb47a
Some checks are pending
CI: Master (Build, Test, Lint) / Build for Github Cache (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (22.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (24.14.1) (push) Waiting to run
CI: Master (Build, Test, Lint) / Unit tests (25.x) (push) Waiting to run
CI: Master (Build, Test, Lint) / Lint (push) Waiting to run
CI: Master (Build, Test, Lint) / Performance (push) Waiting to run
CI: Master (Build, Test, Lint) / Notify Slack on failure (push) Blocked by required conditions
fix(core): Restore peer project discovery in share dropdowns (#29537)
2026-04-29 19:45:07 +00:00

1518 lines
50 KiB
TypeScript

import {
createTeamProject,
linkUserToProject,
getPersonalProject,
findProject,
getProjectRelations,
createWorkflow,
shareWorkflowWithProjects,
randomCredentialPayload,
testDb,
mockInstance,
} from '@n8n/backend-test-utils';
import type { Project } from '@n8n/db';
import {
FolderRepository,
ProjectRelationRepository,
ProjectRepository,
SharedCredentialsRepository,
SharedWorkflowRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import {
getRoleScopes,
PROJECT_OWNER_ROLE_SLUG,
type GlobalRole,
type ProjectRole,
type Scope,
} from '@n8n/permissions';
import { EntityNotFoundError } from '@n8n/typeorm';
import { createFolder } from '@test-integration/db/folders';
import {
getCredentialById,
saveCredential,
shareCredentialWithProjects,
} from './shared/db/credentials';
import { createChatUser, createMember, createOwner, createUser } from './shared/db/users';
import * as utils from './shared/utils/';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
import { getWorkflowById } from '@/public-api/v1/handlers/workflows/workflows.service';
const testServer = utils.setupTestServer({
endpointGroups: ['project'],
enabledFeatures: [
'feat:advancedPermissions',
'feat:projectRole:admin',
'feat:projectRole:editor',
'feat:projectRole:viewer',
],
quotas: {
'quota:maxTeamProjects': -1,
},
});
// The `ActiveWorkflowRunner` keeps the event loop alive, which in turn leads to jest not shutting down cleanly.
// We don't need it for the tests here, so we can mock it and make the tests exit cleanly.
mockInstance(ActiveWorkflowManager);
beforeEach(async () => {
await testDb.truncate(['User', 'Project']);
});
describe('GET /projects/', () => {
test('member should only get their own personal project and team projects they are a part of', async () => {
const [testUser1, testUser2, testUser3] = await Promise.all([
createUser(),
createUser(),
createUser(),
]);
const [teamProject1, teamProject2] = await Promise.all([
createTeamProject(undefined, testUser1),
createTeamProject(),
]);
const [personalProject1, personalProject2, personalProject3] = await Promise.all([
getPersonalProject(testUser1),
getPersonalProject(testUser2),
getPersonalProject(testUser3),
]);
const memberAgent = testServer.authAgentFor(testUser1);
const resp = await memberAgent.get('/projects/');
expect(resp.status).toBe(200);
const respProjects = resp.body.data as Project[];
expect(respProjects.length).toBe(2);
// testUser1 should see their own personal project
const ownPersonalProject = respProjects.find((p) => p.id === personalProject1.id);
expect(ownPersonalProject).not.toBeUndefined();
expect(ownPersonalProject!.name).toBe(testUser1.createPersonalProjectName());
// testUser1 should NOT see other users' personal projects
expect(respProjects.find((p) => p.id === personalProject2.id)).toBeUndefined();
expect(respProjects.find((p) => p.id === personalProject3.id)).toBeUndefined();
// testUser1 should see team projects they belong to
expect(respProjects.find((p) => p.id === teamProject1.id)).not.toBeUndefined();
// testUser1 should NOT see team projects they don't belong to
expect(respProjects.find((p) => p.id === teamProject2.id)).toBeUndefined();
});
test('owner should get all projects', async () => {
const [ownerUser, testUser1, testUser2, testUser3] = await Promise.all([
createOwner(),
createUser(),
createUser(),
createUser(),
]);
const [teamProject1, teamProject2] = await Promise.all([
createTeamProject(undefined, testUser1),
createTeamProject(),
]);
const [ownerProject, personalProject1, personalProject2, personalProject3] = await Promise.all([
getPersonalProject(ownerUser),
getPersonalProject(testUser1),
getPersonalProject(testUser2),
getPersonalProject(testUser3),
]);
const memberAgent = testServer.authAgentFor(ownerUser);
const resp = await memberAgent.get('/projects/');
expect(resp.status).toBe(200);
const respProjects = resp.body.data as Project[];
expect(respProjects.length).toBe(6);
expect(
[ownerProject, personalProject1, personalProject2, personalProject3].every((v, i) => {
const p = respProjects.find((p) => p.id === v.id);
if (!p) {
return false;
}
const u = [ownerUser, testUser1, testUser2, testUser3][i];
return p.name === u.createPersonalProjectName();
}),
).toBe(true);
expect(respProjects.find((p) => p.id === teamProject1.id)).not.toBeUndefined();
expect(respProjects.find((p) => p.id === teamProject2.id)).not.toBeUndefined();
});
});
describe('GET /projects/sharing-candidates', () => {
test('member sees own personal project plus all peer personal projects', async () => {
const [member1, member2, member3] = await Promise.all([
createMember(),
createMember(),
createMember(),
]);
const [teamProject1, teamProject2] = await Promise.all([
createTeamProject(undefined, member1),
createTeamProject(),
]);
const [personal1, personal2, personal3] = await Promise.all([
getPersonalProject(member1),
getPersonalProject(member2),
getPersonalProject(member3),
]);
const resp = await testServer
.authAgentFor(member1)
.get('/projects/sharing-candidates')
.query({ take: 50, skip: 0 });
expect(resp.status).toBe(200);
const respProjects = resp.body.data as Project[];
// Own + peer personal projects appear (3 personal projects total)
expect(respProjects.find((p) => p.id === personal1.id)).not.toBeUndefined();
expect(respProjects.find((p) => p.id === personal2.id)).not.toBeUndefined();
expect(respProjects.find((p) => p.id === personal3.id)).not.toBeUndefined();
// Team project the caller is a member of appears
expect(respProjects.find((p) => p.id === teamProject1.id)).not.toBeUndefined();
// Team project the caller is NOT a member of does not appear
expect(respProjects.find((p) => p.id === teamProject2.id)).toBeUndefined();
});
test('member does not see peer team projects they are not a member of', async () => {
const [member1, member2] = await Promise.all([createMember(), createMember()]);
const peerOnlyTeam = await createTeamProject(undefined, member2);
const resp = await testServer
.authAgentFor(member1)
.get('/projects/sharing-candidates')
.query({ take: 50, skip: 0 });
expect(resp.status).toBe(200);
const respProjects = resp.body.data as Project[];
expect(respProjects.find((p) => p.id === peerOnlyTeam.id)).toBeUndefined();
});
test('search filter narrows results across personal and relation branches', async () => {
const [member1, peer] = await Promise.all([
createUser({ firstName: 'Alice', lastName: 'Anderson' }),
createUser({ firstName: 'Bob', lastName: 'Banana' }),
]);
const matchingTeam = await createTeamProject('Banana Republic', member1);
const nonMatchingTeam = await createTeamProject('Other Project', member1);
const resp = await testServer
.authAgentFor(member1)
.get('/projects/sharing-candidates')
.query({ take: 50, skip: 0, search: 'banana' });
expect(resp.status).toBe(200);
const respProjects = resp.body.data as Project[];
const peerPersonal = await getPersonalProject(peer);
// Matches by team name
expect(respProjects.find((p) => p.id === matchingTeam.id)).not.toBeUndefined();
// Matches peer personal project (name contains "Banana")
expect(respProjects.find((p) => p.id === peerPersonal.id)).not.toBeUndefined();
// Non-matching team does not appear
expect(respProjects.find((p) => p.id === nonMatchingTeam.id)).toBeUndefined();
});
test('type=team filter excludes peer personal projects', async () => {
const [member1, member2] = await Promise.all([createMember(), createMember()]);
const team = await createTeamProject(undefined, member1);
const peerPersonal = await getPersonalProject(member2);
const resp = await testServer
.authAgentFor(member1)
.get('/projects/sharing-candidates')
.query({ take: 50, skip: 0, type: 'team' });
expect(resp.status).toBe(200);
const respProjects = resp.body.data as Project[];
expect(respProjects.find((p) => p.id === team.id)).not.toBeUndefined();
expect(respProjects.find((p) => p.id === peerPersonal.id)).toBeUndefined();
});
test('owner sees all projects via the admin path', async () => {
const [owner, peer1, peer2] = await Promise.all([
createOwner(),
createMember(),
createMember(),
]);
const [team1, team2] = await Promise.all([createTeamProject(), createTeamProject()]);
const [ownerPersonal, peer1Personal, peer2Personal] = await Promise.all([
getPersonalProject(owner),
getPersonalProject(peer1),
getPersonalProject(peer2),
]);
const resp = await testServer
.authAgentFor(owner)
.get('/projects/sharing-candidates')
.query({ take: 50, skip: 0 });
expect(resp.status).toBe(200);
const respProjects = resp.body.data as Project[];
// All five projects accessible to the admin
for (const expected of [ownerPersonal, peer1Personal, peer2Personal, team1, team2]) {
expect(respProjects.find((p) => p.id === expected.id)).not.toBeUndefined();
}
});
test('caller without user:list global scope receives 403', async () => {
const chatUser = await createChatUser();
const resp = await testServer
.authAgentFor(chatUser)
.get('/projects/sharing-candidates')
.query({ take: 50, skip: 0 });
expect(resp.status).toBe(403);
});
});
describe('Project members endpoints', () => {
test('POST /projects/:projectId/users adds a member and emits telemetry', async () => {
const owner = await createOwner();
const member = await createUser();
const project = await createTeamProject('Team Project', owner);
const ownerAgent = testServer.authAgentFor(owner);
const res = await ownerAgent
.post(`/projects/${project.id}/users`)
.send({ relations: [{ userId: member.id, role: 'project:viewer' }] });
expect(res.status).toBe(201);
const relations = await getProjectRelations({ projectId: project.id });
expect(relations.some((r) => r.userId === member.id && r.role.slug === 'project:viewer')).toBe(
true,
);
});
test('POST /projects/:projectId/users returns 409 for existing member with different role', async () => {
const owner = await createOwner();
const member = await createUser();
const project = await createTeamProject('Team Project', owner);
// First add as viewer
await linkUserToProject(member, project, 'project:viewer');
const ownerAgent = testServer.authAgentFor(owner);
const res = await ownerAgent
.post(`/projects/${project.id}/users`)
.send({ relations: [{ userId: member.id, role: 'project:editor' }] });
expect(res.status).toBe(409);
const relations = await getProjectRelations({ projectId: project.id });
expect(relations.some((r) => r.userId === member.id && r.role.slug === 'project:viewer')).toBe(
true,
);
});
test('POST /projects/:projectId/users returns 200 when member already exists with same role (no-op)', async () => {
const owner = await createOwner();
const member = await createUser();
const project = await createTeamProject('Team Project', owner);
// First add as viewer
await linkUserToProject(member, project, 'project:viewer');
const ownerAgent = testServer.authAgentFor(owner);
const res = await ownerAgent
.post(`/projects/${project.id}/users`)
.send({ relations: [{ userId: member.id, role: 'project:viewer' }] });
expect(res.status).toBe(200);
const relations = await getProjectRelations({ projectId: project.id });
expect(relations.some((r) => r.userId === member.id && r.role.slug === 'project:viewer')).toBe(
true,
);
});
test("PATCH /projects/:projectId/users/:userId changes a member's role", async () => {
const owner = await createOwner();
const member = await createUser();
const project = await createTeamProject('Team Project', owner);
await linkUserToProject(member, project, 'project:viewer');
const ownerAgent = testServer.authAgentFor(owner);
const res = await ownerAgent
.patch(`/projects/${project.id}/users/${member.id}`)
.send({ role: 'project:editor' });
expect(res.status).toBe(204);
const relations = await getProjectRelations({ projectId: project.id });
expect(relations.some((r) => r.userId === member.id && r.role.slug === 'project:editor')).toBe(
true,
);
});
describe('PATCH /projects/:projectId/users/:userId when project roles are managed by provisioning', () => {
let provisioningService: ProvisioningService;
let savedConfig: Record<string, unknown>;
beforeEach(async () => {
provisioningService = Container.get(ProvisioningService);
await provisioningService.getConfig();
// @ts-expect-error - provisioningConfig is private
savedConfig = { ...provisioningService.provisioningConfig };
});
afterEach(() => {
// @ts-expect-error - provisioningConfig is private
provisioningService.provisioningConfig = { ...savedConfig };
});
test('should return 403 when SSO provider controls project roles', async () => {
// @ts-expect-error - provisioningConfig is private
provisioningService.provisioningConfig.scopesProvisionProjectRoles = true;
const owner = await createOwner();
const member = await createUser();
const project = await createTeamProject('Team Project', owner);
await linkUserToProject(member, project, 'project:viewer');
const ownerAgent = testServer.authAgentFor(owner);
await ownerAgent
.patch(`/projects/${project.id}/users/${member.id}`)
.send({ role: 'project:editor' })
.expect(403);
});
test('should return 403 when expression-based role mapping is active', async () => {
// @ts-expect-error - provisioningConfig is private
provisioningService.provisioningConfig.scopesUseExpressionMapping = true;
const owner = await createOwner();
const member = await createUser();
const project = await createTeamProject('Team Project', owner);
await linkUserToProject(member, project, 'project:viewer');
const ownerAgent = testServer.authAgentFor(owner);
await ownerAgent
.patch(`/projects/${project.id}/users/${member.id}`)
.send({ role: 'project:editor' })
.expect(403);
});
});
test('DELETE /projects/:projectId/users/:userId removes a member', async () => {
const owner = await createOwner();
const member = await createUser();
const project = await createTeamProject('Team Project', owner);
await linkUserToProject(member, project, 'project:viewer');
const ownerAgent = testServer.authAgentFor(owner);
const res = await ownerAgent.delete(`/projects/${project.id}/users/${member.id}`);
expect(res.status).toBe(204);
const relations = await getProjectRelations({ projectId: project.id });
expect(relations.some((r) => r.userId === member.id)).toBe(false);
});
});
describe('GET /projects/count', () => {
test('should return correct number of projects', async () => {
const [firstUser] = await Promise.all([
createUser(),
createUser(),
createUser(),
createUser(),
createTeamProject(),
createTeamProject(),
createTeamProject(),
]);
const resp = await testServer.authAgentFor(firstUser).get('/projects/count');
expect(resp.body.data.personal).toBe(4);
expect(resp.body.data.team).toBe(3);
});
});
describe('GET /projects/my-projects', () => {
test('member should get all projects they are apart of', async () => {
//
// ARRANGE
//
const [testUser1, testUser2, testUser3] = await Promise.all([
createUser(),
createUser(),
createUser(),
]);
const [teamProject1, teamProject2] = await Promise.all([
createTeamProject(undefined, testUser1),
createTeamProject(undefined, testUser2),
]);
const [personalProject1, personalProject2, personalProject3] = await Promise.all([
getPersonalProject(testUser1),
getPersonalProject(testUser2),
getPersonalProject(testUser3),
]);
//
// ACT
//
const resp = await testServer.authAgentFor(testUser1).get('/projects/my-projects').expect(200);
const respProjects: Array<Project & { role: ProjectRole | GlobalRole; scopes?: Scope[] }> =
resp.body.data;
//
// ASSERT
//
expect(respProjects.length).toBe(2);
const projectsExpected = [
[
personalProject1,
{
role: PROJECT_OWNER_ROLE_SLUG,
scopes: ['project:list', 'project:read', 'credential:create'],
},
],
[
teamProject1,
{
role: 'project:admin',
scopes: [
'project:list',
'project:read',
'project:update',
'project:delete',
'credential:create',
],
},
],
] as const;
for (const [project, expected] of projectsExpected) {
const p = respProjects.find((p) => p.id === project.id)!;
expect(p.role).toBe(expected.role);
expect(expected.scopes.every((s) => p.scopes?.includes(s))).toBe(true);
}
expect(respProjects).not.toContainEqual(expect.objectContaining({ id: personalProject2.id }));
expect(respProjects).not.toContainEqual(expect.objectContaining({ id: personalProject3.id }));
expect(respProjects).not.toContainEqual(expect.objectContaining({ id: teamProject2.id }));
});
test('owner should get all projects they are apart of', async () => {
//
// ARRANGE
//
const [ownerUser, testUser1, testUser2, testUser3] = await Promise.all([
createOwner(),
createUser(),
createUser(),
createUser(),
]);
const [teamProject1, teamProject2, teamProject3, teamProject4] = await Promise.all([
// owner has no relation ship
createTeamProject(undefined, testUser1),
// owner is admin
createTeamProject(undefined, ownerUser),
// owner is viewer
createTeamProject(undefined, testUser2),
// this project has no relationship at all
createTeamProject(),
]);
await linkUserToProject(ownerUser, teamProject3, 'project:editor');
const [ownerProject, personalProject1, personalProject2, personalProject3] = await Promise.all([
getPersonalProject(ownerUser),
getPersonalProject(testUser1),
getPersonalProject(testUser2),
getPersonalProject(testUser3),
]);
//
// ACT
//
const resp = await testServer.authAgentFor(ownerUser).get('/projects/my-projects').expect(200);
const respProjects: Array<Project & { role: ProjectRole | GlobalRole; scopes?: Scope[] }> =
resp.body.data;
//
// ASSERT
//
expect(respProjects.length).toBe(5);
const projectsExpected = [
[
ownerProject,
{
role: PROJECT_OWNER_ROLE_SLUG,
scopes: [
'project:list',
'project:create',
'project:read',
'project:update',
'project:delete',
'credential:create',
],
},
],
[
teamProject1,
{
role: 'global:owner',
scopes: [
'project:list',
'project:create',
'project:read',
'project:update',
'project:delete',
'credential:create',
],
},
],
[
teamProject2,
{
role: 'project:admin',
scopes: [
'project:list',
'project:create',
'project:read',
'project:update',
'project:delete',
'credential:create',
],
},
],
[
teamProject3,
{
role: 'project:editor',
scopes: [
'project:list',
'project:create',
'project:read',
'project:update',
'project:delete',
'credential:create',
],
},
],
[
teamProject4,
{
role: 'global:owner',
scopes: [
'project:list',
'project:create',
'project:read',
'project:update',
'project:delete',
'credential:create',
],
},
],
] as const;
for (const [project, expected] of projectsExpected) {
const p = respProjects.find((p) => p.id === project.id)!;
expect(p.role).toBe(expected.role);
expect(expected.scopes.every((s) => p.scopes?.includes(s))).toBe(true);
}
expect(respProjects).not.toContainEqual(expect.objectContaining({ id: personalProject1.id }));
expect(respProjects).not.toContainEqual(expect.objectContaining({ id: personalProject2.id }));
expect(respProjects).not.toContainEqual(expect.objectContaining({ id: personalProject3.id }));
});
});
describe('GET /projects/personal', () => {
test("should return the user's personal project", async () => {
const user = await createUser();
const project = await getPersonalProject(user);
const memberAgent = testServer.authAgentFor(user);
const resp = await memberAgent.get('/projects/personal');
expect(resp.status).toBe(200);
const respProject = resp.body.data as Project & { scopes: Scope[] };
expect(respProject.id).toEqual(project.id);
expect(respProject.scopes).not.toBeUndefined();
});
test("should return 404 if user doesn't have a personal project", async () => {
const user = await createUser();
const project = await getPersonalProject(user);
await testDb.truncate(['Project']);
const memberAgent = testServer.authAgentFor(user);
const resp = await memberAgent.get('/projects/personal');
expect(resp.status).toBe(404);
const respProject = resp.body?.data as Project;
expect(respProject?.id).not.toEqual(project.id);
});
});
describe('POST /projects/', () => {
test('should create a team project', async () => {
const ownerUser = await createOwner();
const ownerAgent = testServer.authAgentFor(ownerUser);
const resp = await ownerAgent.post('/projects/').send({ name: 'Test Team Project' });
expect(resp.status).toBe(200);
const respProject = resp.body.data as Project;
expect(respProject.name).toEqual('Test Team Project');
expect(async () => {
await findProject(respProject.id);
}).not.toThrow();
expect(resp.body.data.role).toBe('project:admin');
for (const scope of getRoleScopes('project:admin')) {
expect(resp.body.data.scopes).toContain(scope);
}
});
test('should create a team project with context parameter', async () => {
const ownerUser = await createOwner();
const ownerAgent = testServer.authAgentFor(ownerUser);
const resp = await ownerAgent.post('/projects/').send({
name: 'Test Team Project with Context',
uiContext: 'universal_button',
});
expect(resp.status).toBe(200);
const respProject = resp.body.data as Project;
expect(respProject.name).toEqual('Test Team Project with Context');
expect(async () => {
await findProject(respProject.id);
}).not.toThrow();
expect(resp.body.data.role).toBe('project:admin');
});
test('should allow to create a team projects if below the quota', async () => {
testServer.license.setQuota('quota:maxTeamProjects', 1);
const ownerUser = await createOwner();
const ownerAgent = testServer.authAgentFor(ownerUser);
await ownerAgent.post('/projects/').send({ name: 'Test Team Project' }).expect(200);
expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(1);
});
test('should fail to create a team project if at quota', async () => {
testServer.license.setQuota('quota:maxTeamProjects', 1);
await Promise.all([createTeamProject()]);
const ownerUser = await createOwner();
const ownerAgent = testServer.authAgentFor(ownerUser);
await ownerAgent.post('/projects/').send({ name: 'Test Team Project' }).expect(400, {
code: 400,
message:
'Attempted to create a new project but quota is already exhausted. You may have a maximum of 1 team projects.',
});
expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(1);
});
test('should fail to create a team project if above the quota', async () => {
testServer.license.setQuota('quota:maxTeamProjects', 1);
await Promise.all([createTeamProject(), createTeamProject()]);
const ownerUser = await createOwner();
const ownerAgent = testServer.authAgentFor(ownerUser);
await ownerAgent.post('/projects/').send({ name: 'Test Team Project' }).expect(400, {
code: 400,
message:
'Attempted to create a new project but quota is already exhausted. You may have a maximum of 1 team projects.',
});
expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(2);
});
test('should respect the quota when trying to create multiple projects in parallel (no race conditions)', async () => {
expect(await Container.get(ProjectRepository).count({ where: { type: 'team' } })).toBe(0);
const maxTeamProjects = 3;
testServer.license.setQuota('quota:maxTeamProjects', maxTeamProjects);
const ownerUser = await createOwner();
const ownerAgent = testServer.authAgentFor(ownerUser);
await expect(Container.get(ProjectRepository).count({ where: { type: 'team' } })).resolves.toBe(
0,
);
await Promise.all([
ownerAgent.post('/projects/').send({ name: 'Test Team Project 1' }),
ownerAgent.post('/projects/').send({ name: 'Test Team Project 2' }),
ownerAgent.post('/projects/').send({ name: 'Test Team Project 3' }),
ownerAgent.post('/projects/').send({ name: 'Test Team Project 4' }),
ownerAgent.post('/projects/').send({ name: 'Test Team Project 5' }),
ownerAgent.post('/projects/').send({ name: 'Test Team Project 6' }),
]);
await expect(Container.get(ProjectRepository).count({ where: { type: 'team' } })).resolves.toBe(
maxTeamProjects,
);
});
});
describe('PATCH /projects/:projectId', () => {
test('should update a team project name', async () => {
const ownerUser = await createOwner();
const ownerAgent = testServer.authAgentFor(ownerUser);
const teamProject = await createTeamProject();
const resp = await ownerAgent.patch(`/projects/${teamProject.id}`).send({ name: 'New Name' });
expect(resp.status).toBe(200);
const updatedProject = await findProject(teamProject.id);
expect(updatedProject.name).toEqual('New Name');
});
test('should not allow viewers to edit team project name', async () => {
const testUser = await createUser();
const teamProject = await createTeamProject();
await linkUserToProject(testUser, teamProject, 'project:viewer');
const memberAgent = testServer.authAgentFor(testUser);
const resp = await memberAgent.patch(`/projects/${teamProject.id}`).send({ name: 'New Name' });
expect(resp.status).toBe(403);
const updatedProject = await findProject(teamProject.id);
expect(updatedProject.name).not.toEqual('New Name');
});
test('should not allow owners to edit personal project name', async () => {
const user = await createUser();
const personalProject = await getPersonalProject(user);
const ownerUser = await createOwner();
const ownerAgent = testServer.authAgentFor(ownerUser);
const resp = await ownerAgent
.patch(`/projects/${personalProject.id}`)
.send({ name: 'New Name' });
expect(resp.status).toBe(404);
const updatedProject = await findProject(personalProject.id);
expect(updatedProject.name).not.toEqual('New Name');
});
describe('member management', () => {
test('should add or remove users from a project', async () => {
const [ownerUser, testUser1, testUser2, testUser3] = await Promise.all([
createOwner(),
createUser(),
createUser(),
createUser(),
]);
const [teamProject1, teamProject2] = await Promise.all([
createTeamProject(undefined, testUser1),
createTeamProject(undefined, testUser2),
]);
const [_credential1, credential2] = await Promise.all([
saveCredential(randomCredentialPayload(), {
role: 'credential:owner',
project: teamProject1,
}),
saveCredential(randomCredentialPayload(), {
role: 'credential:owner',
project: teamProject2,
}),
saveCredential(randomCredentialPayload(), {
role: 'credential:owner',
project: teamProject2,
}),
]);
await shareCredentialWithProjects(credential2, [teamProject1]);
await linkUserToProject(ownerUser, teamProject2, 'project:editor');
await linkUserToProject(testUser2, teamProject2, 'project:editor');
const memberAgent = testServer.authAgentFor(testUser1);
// Add two members to teamProject1
const addResp = await memberAgent.post(`/projects/${teamProject1.id}/users`).send({
relations: [
{ userId: testUser3.id, role: 'project:editor' },
{ userId: ownerUser.id, role: 'project:viewer' },
],
});
expect(addResp.status).toBe(201);
const [tp1Relations, tp2Relations] = await Promise.all([
getProjectRelations({ projectId: teamProject1.id }),
getProjectRelations({ projectId: teamProject2.id }),
]);
expect(tp1Relations.length).toBe(3);
expect(tp2Relations.length).toBe(2);
expect(tp1Relations.find((p) => p.userId === testUser1.id)).not.toBeUndefined();
expect(tp1Relations.find((p) => p.userId === testUser2.id)).toBeUndefined();
expect(tp1Relations.find((p) => p.userId === testUser1.id)?.role.slug).toBe('project:admin');
expect(tp1Relations.find((p) => p.userId === testUser3.id)?.role.slug).toBe('project:editor');
expect(tp1Relations.find((p) => p.userId === ownerUser.id)?.role.slug).toBe('project:viewer');
// Check we haven't modified the other team project
expect(tp2Relations.find((p) => p.userId === testUser2.id)).not.toBeUndefined();
expect(tp2Relations.find((p) => p.userId === testUser1.id)).toBeUndefined();
expect(tp2Relations.find((p) => p.userId === testUser2.id)?.role.slug).toBe('project:editor');
expect(tp2Relations.find((p) => p.userId === ownerUser.id)?.role.slug).toBe('project:editor');
});
test.each([['project:viewer'], ['project:editor']] as const)(
'`%s`s should not be able to add, update or remove users from a project',
async (role) => {
//
// ARRANGE
//
const [actor, projectEditor, userToBeInvited] = await Promise.all([
createUser(),
createUser(),
createUser(),
]);
const teamProject1 = await createTeamProject();
await linkUserToProject(actor, teamProject1, role);
await linkUserToProject(projectEditor, teamProject1, 'project:editor');
//
// ACT
//
const response = await testServer
.authAgentFor(actor)
.patch(`/projects/${teamProject1.id}`)
.send({
name: teamProject1.name,
relations: [
// update the viewer to be the project admin
{ userId: actor.id, role: 'project:admin' },
// add a user to the project
{ userId: userToBeInvited.id, role: 'project:editor' },
// implicitly remove the project editor
],
});
//.expect(403);
//
// ASSERT
//
expect(response.status).toBe(403);
expect(response.body).toMatchObject({
message: 'User is missing a scope required to perform this action',
});
const tp1Relations = await getProjectRelations({ projectId: teamProject1.id });
expect(tp1Relations.length).toBe(2);
expect(tp1Relations).toMatchObject(
expect.arrayContaining([
expect.objectContaining({
userId: actor.id,
role: expect.objectContaining({ slug: role }),
}),
expect.objectContaining({
userId: projectEditor.id,
role: expect.objectContaining({ slug: 'project:editor' }),
}),
]),
);
},
);
test.each([
['project:viewer', 'feat:projectRole:viewer'],
['project:editor', 'feat:projectRole:editor'],
] as const)(
"should not be able to add a user with the role %s if it's not licensed",
async (role, feature) => {
testServer.license.disable(feature);
const [projectAdmin, userToBeInvited] = await Promise.all([createUser(), createUser()]);
const teamProject = await createTeamProject('Team Project', projectAdmin);
await testServer
.authAgentFor(projectAdmin)
.post(`/projects/${teamProject.id}/users`)
.send({
relations: [{ userId: userToBeInvited.id, role }],
})
.expect(400);
const tpRelations = await getProjectRelations({ projectId: teamProject.id });
expect(tpRelations.length).toBe(1);
expect(tpRelations).toMatchObject(
expect.arrayContaining([
expect.objectContaining({
userId: projectAdmin.id,
role: expect.objectContaining({ slug: 'project:admin' }),
}),
]),
);
},
);
test("should not edit a relation of a project when changing a user's role to an unlicensed role", async () => {
testServer.license.disable('feat:projectRole:editor');
const [testUser1, testUser2, testUser3] = await Promise.all([
createUser(),
createUser(),
createUser(),
]);
const teamProject = await createTeamProject(undefined, testUser2);
await linkUserToProject(testUser1, teamProject, 'project:admin');
await linkUserToProject(testUser3, teamProject, 'project:admin');
const memberAgent = testServer.authAgentFor(testUser2);
const resp = await memberAgent
.patch(`/projects/${teamProject.id}/users/${testUser1.id}`)
.send({ role: 'project:editor' });
expect(resp.status).toBe(400);
const tpRelations = await getProjectRelations({ projectId: teamProject.id });
expect(tpRelations.length).toBe(3);
expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined();
expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined();
expect(tpRelations.find((p) => p.userId === testUser1.id)?.role?.slug).toBe('project:admin');
expect(tpRelations.find((p) => p.userId === testUser2.id)?.role?.slug).toBe('project:admin');
expect(tpRelations.find((p) => p.userId === testUser3.id)?.role?.slug).toBe('project:admin');
});
test("should edit a relation of a project when changing a user's role to an licensed role but unlicensed roles are present", async () => {
testServer.license.disable('feat:projectRole:viewer');
const [testUser1, testUser2, testUser3] = await Promise.all([
createUser(),
createUser(),
createUser(),
]);
const teamProject = await createTeamProject(undefined, testUser2);
await linkUserToProject(testUser1, teamProject, 'project:viewer');
await linkUserToProject(testUser3, teamProject, 'project:editor');
const memberAgent = testServer.authAgentFor(testUser2);
const resp1 = await memberAgent
.patch(`/projects/${teamProject.id}/users/${testUser2.id}`)
.send({ role: 'project:admin' });
expect(resp1.status).toBe(204);
const resp2 = await memberAgent
.patch(`/projects/${teamProject.id}/users/${testUser3.id}`)
.send({ role: 'project:admin' });
expect(resp2.status).toBe(204);
const tpRelations = await getProjectRelations({ projectId: teamProject.id });
expect(tpRelations.length).toBe(3);
expect(tpRelations.find((p) => p.userId === testUser1.id)).not.toBeUndefined();
expect(tpRelations.find((p) => p.userId === testUser2.id)).not.toBeUndefined();
expect(tpRelations.find((p) => p.userId === testUser3.id)).not.toBeUndefined();
expect(tpRelations.find((p) => p.userId === testUser1.id)?.role?.slug).toBe('project:viewer');
expect(tpRelations.find((p) => p.userId === testUser2.id)?.role?.slug).toBe('project:admin');
expect(tpRelations.find((p) => p.userId === testUser3.id)?.role?.slug).toBe('project:admin');
});
test('should not add or remove users from a personal project', async () => {
const [testUser1, testUser2] = await Promise.all([createUser(), createUser()]);
const personalProject = await getPersonalProject(testUser1);
const memberAgent = testServer.authAgentFor(testUser1);
const resp = await memberAgent.post(`/projects/${personalProject.id}/users`).send({
relations: [{ userId: testUser2.id, role: 'project:admin' }],
});
expect(resp.status).toBe(403);
const p1Relations = await getProjectRelations({ projectId: personalProject.id });
expect(p1Relations.length).toBe(1);
});
});
});
describe('GET /project/:projectId', () => {
test('should get project details and relations', async () => {
const [ownerUser, testUser1, testUser2, _testUser3] = await Promise.all([
createOwner(),
createUser(),
createUser(),
createUser(),
]);
const [teamProject1, teamProject2] = await Promise.all([
createTeamProject(undefined, testUser2),
createTeamProject(),
]);
await linkUserToProject(testUser1, teamProject1, 'project:editor');
await linkUserToProject(ownerUser, teamProject2, 'project:editor');
await linkUserToProject(testUser2, teamProject2, 'project:editor');
const memberAgent = testServer.authAgentFor(testUser1);
const resp = await memberAgent.get(`/projects/${teamProject1.id}`);
expect(resp.status).toBe(200);
expect(resp.body.data.id).toBe(teamProject1.id);
expect(resp.body.data.name).toBe(teamProject1.name);
expect(resp.body.data.relations.length).toBe(2);
expect(resp.body.data.relations).toContainEqual({
id: testUser1.id,
email: testUser1.email,
firstName: testUser1.firstName,
lastName: testUser1.lastName,
role: 'project:editor',
});
expect(resp.body.data.relations).toContainEqual({
id: testUser2.id,
email: testUser2.email,
firstName: testUser2.firstName,
lastName: testUser2.lastName,
role: 'project:admin',
});
});
test('should have correct folder scopes when, as an admin / owner, I fetch a project created by a different user', async () => {
const [ownerUser, testUser1] = await Promise.all([createOwner(), createUser()]);
const createdProject = await createTeamProject(undefined, testUser1);
const memberAgent = testServer.authAgentFor(ownerUser);
const resp = await memberAgent.get(`/projects/${createdProject.id}`);
expect(resp.status).toBe(200);
expect(resp.body.data.id).toBe(createdProject.id);
expect(resp.body.data.name).toBe(createdProject.name);
expect(resp.body.data.scopes).toEqual(
expect.arrayContaining([
'folder:read',
'folder:update',
'folder:delete',
'folder:create',
'folder:list',
]),
);
});
});
describe('DELETE /project/:projectId', () => {
test('allows the project:owner to delete a project', async () => {
const member = await createMember();
const project = await createTeamProject(undefined, member);
await testServer.authAgentFor(member).delete(`/projects/${project.id}`).expect(200);
const projectInDB = findProject(project.id);
await expect(projectInDB).rejects.toThrowError(EntityNotFoundError);
});
test('allows the instance owner to delete a team project their are not related to', async () => {
const owner = await createOwner();
const member = await createMember();
const project = await createTeamProject(undefined, member);
await testServer.authAgentFor(owner).delete(`/projects/${project.id}`).expect(200);
await expect(findProject(project.id)).rejects.toThrowError(EntityNotFoundError);
});
test('does not allow instance members to delete their personal project', async () => {
const member = await createMember();
const project = await getPersonalProject(member);
await testServer.authAgentFor(member).delete(`/projects/${project.id}`).expect(403);
const projectInDB = await findProject(project.id);
expect(projectInDB).toHaveProperty('id', project.id);
});
test('does not allow instance owners to delete their personal projects', async () => {
const owner = await createOwner();
const project = await getPersonalProject(owner);
await testServer.authAgentFor(owner).delete(`/projects/${project.id}`).expect(403);
const projectInDB = await findProject(project.id);
expect(projectInDB).toHaveProperty('id', project.id);
});
test.each(['project:editor', 'project:viewer'] as ProjectRole[])(
'does not allow users with the role %s to delete a project',
async (role) => {
const member = await createMember();
const project = await createTeamProject();
await linkUserToProject(member, project, role);
await testServer.authAgentFor(member).delete(`/projects/${project.id}`).expect(403);
const projectInDB = await findProject(project.id);
expect(projectInDB).toHaveProperty('id', project.id);
},
);
test('deletes all workflows and credentials it owns as well as the sharings into other projects', async () => {
//
// ARRANGE
//
const member = await createMember();
const otherProject = await createTeamProject(undefined, member);
const sharedWorkflow1 = await createWorkflow({}, otherProject);
const sharedWorkflow2 = await createWorkflow({}, otherProject);
const sharedCredential = await saveCredential(randomCredentialPayload(), {
project: otherProject,
role: 'credential:owner',
});
const projectToBeDeleted = await createTeamProject(undefined, member);
const ownedWorkflow = await createWorkflow({}, projectToBeDeleted);
const ownedCredential = await saveCredential(randomCredentialPayload(), {
project: projectToBeDeleted,
role: 'credential:owner',
});
await shareCredentialWithProjects(sharedCredential, [otherProject]);
await shareWorkflowWithProjects(sharedWorkflow1, [
{ project: otherProject, role: 'workflow:editor' },
]);
await shareWorkflowWithProjects(sharedWorkflow2, [
{ project: otherProject, role: 'workflow:editor' },
]);
//
// ACT
//
await testServer.authAgentFor(member).delete(`/projects/${projectToBeDeleted.id}`).expect(200);
//
// ASSERT
//
// Make sure the project and owned workflow and credential where deleted.
await expect(getWorkflowById(ownedWorkflow.id)).resolves.toBeNull();
await expect(getCredentialById(ownedCredential.id)).resolves.toBeNull();
await expect(findProject(projectToBeDeleted.id)).rejects.toThrowError(EntityNotFoundError);
// Make sure the shared workflow and credential were not deleted
await expect(getWorkflowById(sharedWorkflow1.id)).resolves.not.toBeNull();
await expect(getCredentialById(sharedCredential.id)).resolves.not.toBeNull();
// Make sure the sharings for them have been deleted
await expect(
Container.get(SharedWorkflowRepository).findOneByOrFail({
projectId: projectToBeDeleted.id,
workflowId: sharedWorkflow1.id,
}),
).rejects.toThrowError(EntityNotFoundError);
await expect(
Container.get(SharedCredentialsRepository).findOneByOrFail({
projectId: projectToBeDeleted.id,
credentialsId: sharedCredential.id,
}),
).rejects.toThrowError(EntityNotFoundError);
});
test('unshares all workflows and credentials that were shared with the project', async () => {
//
// ARRANGE
//
const member = await createMember();
const projectToBeDeleted = await createTeamProject(undefined, member);
const ownedWorkflow1 = await createWorkflow({}, projectToBeDeleted);
const ownedWorkflow2 = await createWorkflow({}, projectToBeDeleted);
const ownedCredential = await saveCredential(randomCredentialPayload(), {
project: projectToBeDeleted,
role: 'credential:owner',
});
const otherProject = await createTeamProject(undefined, member);
await shareCredentialWithProjects(ownedCredential, [otherProject]);
await shareWorkflowWithProjects(ownedWorkflow1, [
{ project: otherProject, role: 'workflow:editor' },
]);
await shareWorkflowWithProjects(ownedWorkflow2, [
{ project: otherProject, role: 'workflow:editor' },
]);
//
// ACT
//
await testServer.authAgentFor(member).delete(`/projects/${projectToBeDeleted.id}`).expect(200);
//
// ASSERT
//
// Make sure the project and owned workflow and credential where deleted.
await expect(getWorkflowById(ownedWorkflow1.id)).resolves.toBeNull();
await expect(getWorkflowById(ownedWorkflow2.id)).resolves.toBeNull();
await expect(getCredentialById(ownedCredential.id)).resolves.toBeNull();
await expect(findProject(projectToBeDeleted.id)).rejects.toThrowError(EntityNotFoundError);
// Make sure the sharings for them into the other project have been deleted
await expect(
Container.get(SharedWorkflowRepository).findOneByOrFail({
projectId: projectToBeDeleted.id,
workflowId: ownedWorkflow1.id,
}),
).rejects.toThrowError(EntityNotFoundError);
await expect(
Container.get(SharedWorkflowRepository).findOneByOrFail({
projectId: projectToBeDeleted.id,
workflowId: ownedWorkflow2.id,
}),
).rejects.toThrowError(EntityNotFoundError);
await expect(
Container.get(SharedCredentialsRepository).findOneByOrFail({
projectId: projectToBeDeleted.id,
credentialsId: ownedCredential.id,
}),
).rejects.toThrowError(EntityNotFoundError);
});
test('deletes the project relations', async () => {
//
// ARRANGE
//
const member = await createMember();
const editor = await createMember();
const viewer = await createMember();
const project = await createTeamProject(undefined, member);
await linkUserToProject(editor, project, 'project:editor');
await linkUserToProject(viewer, project, 'project:viewer');
//
// ACT
//
await testServer.authAgentFor(member).delete(`/projects/${project.id}`).expect(200);
//
// ASSERT
//
await expect(
Container.get(ProjectRelationRepository).findOneByOrFail({
projectId: project.id,
userId: member.id,
}),
).rejects.toThrowError(EntityNotFoundError);
await expect(
Container.get(ProjectRelationRepository).findOneByOrFail({
projectId: project.id,
userId: editor.id,
}),
).rejects.toThrowError(EntityNotFoundError);
await expect(
Container.get(ProjectRelationRepository).findOneByOrFail({
projectId: project.id,
userId: viewer.id,
}),
).rejects.toThrowError(EntityNotFoundError);
});
// Tests related to migrating workflows and credentials to new project:
test('should fail if the project to delete does not exist', async () => {
const member = await createMember();
await testServer.authAgentFor(member).delete('/projects/1234').expect(403);
});
test('should fail to delete if project to migrate to and the project to delete are the same', async () => {
const member = await createMember();
const project = await createTeamProject(undefined, member);
await testServer
.authAgentFor(member)
.delete(`/projects/${project.id}`)
.query({ transferId: project.id })
.expect(400);
});
test('does not migrate credentials and projects if the user does not have the permissions to create workflows or credentials in the target project', async () => {
//
// ARRANGE
//
const member = await createMember();
const projectToBeDeleted = await createTeamProject(undefined, member);
const targetProject = await createTeamProject();
await linkUserToProject(member, targetProject, 'project:viewer');
//
// ACT
//
await testServer
.authAgentFor(member)
.delete(`/projects/${projectToBeDeleted.id}`)
.query({ transferId: targetProject.id })
//
// ASSERT
//
.expect(404);
});
test('migrates folders, workflows and credentials to another project if `migrateToProject` is passed', async () => {
//
// ARRANGE
//
const member = await createMember();
const projectToBeDeleted = await createTeamProject(undefined, member);
const targetProject = await createTeamProject(undefined, member);
const otherProject = await createTeamProject(undefined, member);
// these should be re-owned to the targetProject
const ownedCredential = await saveCredential(randomCredentialPayload(), {
project: projectToBeDeleted,
role: 'credential:owner',
});
const ownedWorkflow = await createWorkflow({}, projectToBeDeleted);
// these should stay intact
await shareCredentialWithProjects(ownedCredential, [otherProject]);
await shareWorkflowWithProjects(ownedWorkflow, [
{ project: otherProject, role: 'workflow:editor' },
]);
await createFolder(projectToBeDeleted, { name: 'folder1' });
await createFolder(projectToBeDeleted, { name: 'folder2' });
await createFolder(targetProject, { name: 'folder1' });
await createFolder(otherProject, { name: 'folder3' });
//
// ACT
//
await testServer
.authAgentFor(member)
.delete(`/projects/${projectToBeDeleted.id}`)
.query({ transferId: targetProject.id })
.expect(200);
//
// ASSERT
//
// projectToBeDeleted is deleted
await expect(findProject(projectToBeDeleted.id)).rejects.toThrowError(EntityNotFoundError);
// ownedWorkflow has not been deleted
await expect(getWorkflowById(ownedWorkflow.id)).resolves.toBeDefined();
// ownedCredential has not been deleted
await expect(getCredentialById(ownedCredential.id)).resolves.toBeDefined();
// there is a sharing for ownedWorkflow and targetProject
await expect(
Container.get(SharedCredentialsRepository).findOneByOrFail({
credentialsId: ownedCredential.id,
projectId: targetProject.id,
role: 'credential:owner',
}),
).resolves.toBeDefined();
// there is a sharing for ownedCredential and targetProject
await expect(
Container.get(SharedWorkflowRepository).findOneByOrFail({
workflowId: ownedWorkflow.id,
projectId: targetProject.id,
role: 'workflow:owner',
}),
).resolves.toBeDefined();
// there is a sharing for ownedWorkflow and otherProject
await expect(
Container.get(SharedWorkflowRepository).findOneByOrFail({
workflowId: ownedWorkflow.id,
projectId: otherProject.id,
role: 'workflow:editor',
}),
).resolves.toBeDefined();
// there is a sharing for ownedCredential and otherProject
await expect(
Container.get(SharedCredentialsRepository).findOneByOrFail({
credentialsId: ownedCredential.id,
projectId: otherProject.id,
role: 'credential:user',
}),
).resolves.toBeDefined();
// folders are in the target project
const foldersInTargetProject = await Container.get(FolderRepository).findBy({
homeProject: { id: targetProject.id },
});
const foldersInDeletedProject = await Container.get(FolderRepository).findBy({
homeProject: { id: projectToBeDeleted.id },
});
expect(foldersInDeletedProject).toHaveLength(0);
expect(foldersInTargetProject).toHaveLength(3);
expect(foldersInTargetProject.map((f) => f.name)).toEqual(
expect.arrayContaining(['folder1', 'folder1', 'folder2']),
);
});
// This test is testing behavior that is explicitly not enabled right now,
// but we want this to work if we in the future allow sharing of credentials
// and/or workflows between team projects.
test('should upgrade a projects role if the workflow/credential is already shared with it', async () => {
//
// ARRANGE
//
const member = await createMember();
const project = await createTeamProject(undefined, member);
const credential = await saveCredential(randomCredentialPayload(), {
project,
role: 'credential:owner',
});
const workflow = await createWorkflow({}, project);
const projectToMigrateTo = await createTeamProject(undefined, member);
await shareWorkflowWithProjects(workflow, [
{ project: projectToMigrateTo, role: 'workflow:editor' },
]);
await shareCredentialWithProjects(credential, [projectToMigrateTo]);
//
// ACT
//
await testServer
.authAgentFor(member)
.delete(`/projects/${project.id}`)
.query({ transferId: projectToMigrateTo.id })
.expect(200);
//
// ASSERT
//
await expect(
Container.get(SharedCredentialsRepository).findOneByOrFail({
credentialsId: credential.id,
projectId: projectToMigrateTo.id,
role: 'credential:owner',
}),
).resolves.toBeDefined();
await expect(
Container.get(SharedWorkflowRepository).findOneByOrFail({
workflowId: workflow.id,
projectId: projectToMigrateTo.id,
role: 'workflow:owner',
}),
).resolves.toBeDefined();
});
});