import { createTeamProject, getPersonalProject, linkUserToProject, createWorkflow, randomCredentialPayload, mockInstance, } from '@n8n/backend-test-utils'; import type { Project, User } from '@n8n/db'; import { PROJECT_ADMIN_ROLE_SLUG, PROJECT_EDITOR_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG, } from '@n8n/permissions'; import { UserManagementMailer } from '@/user-management/email'; import { cleanupRolesAndScopes } from '../shared/db/roles'; import { createOwner, createMember } from '../shared/db/users'; import type { SuperAgentTest } from '../shared/types'; import * as utils from '../shared/utils/'; /** * Built-in Role Matrix Testing * * Tests the behavior of built-in project roles: * - Project Admin: Full project access (all permissions) * - Project Editor: Full CRUD access (create, read, update, delete) * - Project Viewer: Read-only access (read and list only) * - Personal Project Owner: Full access within personal projects */ const testServer = utils.setupTestServer({ endpointGroups: ['workflows', 'credentials'], enabledFeatures: ['feat:sharing', 'feat:customRoles'], quotas: { 'quota:maxTeamProjects': -1, }, }); let owner: User; let member1: User; let member2: User; let member3: User; // Projects for different test scenarios let teamProjectA: Project; let member1PersonalProject: Project; // Authentication agents let ownerAgent: SuperAgentTest; let member1Agent: SuperAgentTest; let member2Agent: SuperAgentTest; let member3Agent: SuperAgentTest; describe('Built-in Role Matrix Testing', () => { beforeAll(async () => { mockInstance(UserManagementMailer, { invite: jest.fn(), passwordReset: jest.fn(), }); await utils.initCredentialsTypes(); // Create standard users owner = await createOwner(); member1 = await createMember(); member2 = await createMember(); member3 = await createMember(); // Get personal projects member1PersonalProject = await getPersonalProject(member1); // Create team projects for testing teamProjectA = await createTeamProject('Team Project A', owner); // Create authentication agents ownerAgent = testServer.authAgentFor(owner); member1Agent = testServer.authAgentFor(member1); member2Agent = testServer.authAgentFor(member2); member3Agent = testServer.authAgentFor(member3); }); afterAll(async () => { await cleanupRolesAndScopes(); }); describe('Project Admin Role - Full Project Access', () => { beforeEach(async () => { // Link member1 to teamProjectA as project admin await linkUserToProject(member1, teamProjectA, PROJECT_ADMIN_ROLE_SLUG); }); test('project admin should have full workflow access', async () => { // Create a workflow in the project via HTTP endpoint (project admin has workflow:create scope) const workflowPayload = { name: 'Test Admin Workflow', active: false, nodes: [ { id: 'uuid-1234', parameters: {}, name: 'Start', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [240, 300], }, ], connections: {}, staticData: null, settings: { saveExecutionProgress: true, }, projectId: teamProjectA.id, }; const adminWorkflowResponse = await member1Agent .post('/workflows') .send(workflowPayload) .expect(200); const workflow = adminWorkflowResponse.body.data; expect(workflow.name).toBe('Test Admin Workflow'); // Test workflow list access const listResponse = await member1Agent.get('/workflows').expect(200); expect(listResponse.body.data).toHaveLength(1); expect(listResponse.body.data[0].name).toBe('Test Admin Workflow'); // Test workflow read access const getResponse = await member1Agent.get(`/workflows/${workflow.id}`).expect(200); expect(getResponse.body.data.name).toBe('Test Admin Workflow'); // Test workflow update access const updateResponse = await member1Agent .patch(`/workflows/${workflow.id}`) .send({ name: 'Updated Admin Workflow', versionId: workflow.versionId }) .expect(200); expect(updateResponse.body.data.name).toBe('Updated Admin Workflow'); // Test workflow delete access await member1Agent.post(`/workflows/${workflow.id}/archive`).send().expect(200); await member1Agent.delete(`/workflows/${workflow.id}`).send().expect(200); }); test('project admin should have full credential access', async () => { // Create a credential payload const credentialPayload = randomCredentialPayload(); // Test credential create access const adminCredentialResponse = await member1Agent .post('/credentials') .send({ ...credentialPayload, projectId: teamProjectA.id }) .expect(200); const credentialId = adminCredentialResponse.body.data.id; expect(adminCredentialResponse.body.data.name).toBe(credentialPayload.name); // Test credential list access const listResponse = await member1Agent.get('/credentials').expect(200); expect(listResponse.body.data).toHaveLength(1); expect(listResponse.body.data[0].name).toBe(credentialPayload.name); // Test credential read access const getResponse = await member1Agent.get(`/credentials/${credentialId}`).expect(200); expect(getResponse.body.data.name).toBe(credentialPayload.name); // Test credential update access const updateResponse = await member1Agent .patch(`/credentials/${credentialId}`) .send({ ...credentialPayload, name: 'Updated Admin Credential' }) .expect(200); expect(updateResponse.body.data.name).toBe('Updated Admin Credential'); // Test credential delete access await member1Agent.delete(`/credentials/${credentialId}`).expect(200); }); }); describe('Project Editor Role - Full CRUD Access', () => { beforeEach(async () => { // Link member2 to teamProjectA as project editor await linkUserToProject(member2, teamProjectA, PROJECT_EDITOR_ROLE_SLUG); }); test('project editor should have full workflow CRUD access', async () => { // Create a workflow in the project via HTTP endpoint (project editor has workflow:create scope) const workflowPayload = { name: 'Test Editor Workflow', active: false, nodes: [ { id: 'uuid-1234', parameters: {}, name: 'Start', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [240, 300], }, ], connections: {}, staticData: null, settings: { saveExecutionProgress: true, }, projectId: teamProjectA.id, }; const editorWorkflowResponse = await member2Agent .post('/workflows') .send(workflowPayload) .expect(200); const workflow = editorWorkflowResponse.body.data; expect(workflow.name).toBe('Test Editor Workflow'); // Test workflow list access const listResponse = await member2Agent.get('/workflows').expect(200); expect(listResponse.body.data).toHaveLength(1); // Test workflow read access const getResponse = await member2Agent.get(`/workflows/${workflow.id}`).expect(200); expect(getResponse.body.data.name).toBe('Test Editor Workflow'); // Test workflow update access const updateResponse = await member2Agent .patch(`/workflows/${workflow.id}`) .send({ name: 'Updated Editor Workflow', versionId: workflow.versionId }) .expect(200); expect(updateResponse.body.data.name).toBe('Updated Editor Workflow'); // Test workflow delete access (should succeed - editors have delete permission) await member2Agent.post(`/workflows/${workflow.id}/archive`).send().expect(200); await member2Agent.delete(`/workflows/${workflow.id}`).send().expect(200); }); test('project editor should have full credential CRUD access', async () => { // Create a credential payload const credentialPayload = randomCredentialPayload(); // Test credential create access const editorCredentialResponse = await member2Agent .post('/credentials') .send({ ...credentialPayload, projectId: teamProjectA.id }) .expect(200); const credentialId = editorCredentialResponse.body.data.id; expect(editorCredentialResponse.body.data.name).toBe(credentialPayload.name); // Test credential list access const listResponse = await member2Agent.get('/credentials').expect(200); expect(listResponse.body.data).toHaveLength(1); // Test credential read access const getResponse = await member2Agent.get(`/credentials/${credentialId}`).expect(200); expect(getResponse.body.data.name).toBe(credentialPayload.name); // Test credential update access const updateResponse = await member2Agent .patch(`/credentials/${credentialId}`) .send({ ...credentialPayload, name: 'Updated Editor Credential' }) .expect(200); expect(updateResponse.body.data.name).toBe('Updated Editor Credential'); // Test credential delete access (should succeed - editors have delete permission) await member2Agent.delete(`/credentials/${credentialId}`).expect(200); }); }); describe('Project Viewer Role - Read-Only Access (No Create/Update/Delete)', () => { beforeEach(async () => { // Link member3 to teamProjectA as project viewer await linkUserToProject(member3, teamProjectA, PROJECT_VIEWER_ROLE_SLUG); }); test('project viewer should have read-only workflow access (no create/update/delete permissions)', async () => { // Create a workflow in the project using helper const workflow = await createWorkflow({ name: 'Read-Only Test Workflow' }, teamProjectA); // Test workflow list access const listResponse = await member3Agent.get('/workflows').expect(200); expect(listResponse.body.data).toHaveLength(1); expect(listResponse.body.data[0].name).toBe('Read-Only Test Workflow'); // Test workflow read access const getResponse = await member3Agent.get(`/workflows/${workflow.id}`).expect(200); expect(getResponse.body.data.name).toBe('Read-Only Test Workflow'); // Test workflow create access (should be forbidden) const workflowPayload = { name: 'New Viewer Workflow', active: false, nodes: [ { id: 'uuid-1234', parameters: {}, name: 'Start', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [240, 300], }, ], connections: {}, projectId: teamProjectA.id, }; // Project viewer doesn't have workflow:create permissions in this team project await member3Agent.post('/workflows').send(workflowPayload).expect(400); // Test workflow update access (should be forbidden) await member3Agent .patch(`/workflows/${workflow.id}`) .send({ name: 'Updated Viewer Workflow', versionId: workflow.versionId }) .expect(403); // Test workflow delete access (should be forbidden) await member3Agent.post(`/workflows/${workflow.id}/archive`).send().expect(403); }); test('project viewer should have read-only credential access (no create/update/delete permissions)', async () => { // Create a credential via owner first const credentialPayload = randomCredentialPayload(); const ownerCredentialResponse = await ownerAgent .post('/credentials') .send({ ...credentialPayload, projectId: teamProjectA.id }) .expect(200); const credentialId = ownerCredentialResponse.body.data.id; // Test credential list access const listResponse = await member3Agent.get('/credentials').expect(200); expect(listResponse.body.data).toHaveLength(1); expect(listResponse.body.data[0].name).toBe(credentialPayload.name); // Test credential read access const getResponse = await member3Agent.get(`/credentials/${credentialId}`).expect(200); expect(getResponse.body.data.name).toBe(credentialPayload.name); // Test credential create access (should be forbidden) await member3Agent .post('/credentials') .send({ ...randomCredentialPayload(), projectId: teamProjectA.id }) .expect(400); // Test credential update access (should be forbidden) await member3Agent .patch(`/credentials/${credentialId}`) .send({ ...credentialPayload, name: 'Updated Viewer Credential' }) .expect(403); // Test credential delete access (should be forbidden) await member3Agent.delete(`/credentials/${credentialId}`).expect(403); }); }); describe('Personal Project Owner Role - Personal Project Full Access', () => { test('personal project owner should have full access in their own project', async () => { // Create a workflow in member1's personal project via HTTP endpoint (personal owner has workflow:create scope) const workflowPayload = { name: 'Test Personal Workflow', active: false, nodes: [ { id: 'uuid-1234', parameters: {}, name: 'Start', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [240, 300], }, ], connections: {}, staticData: null, settings: { saveExecutionProgress: true, }, projectId: member1PersonalProject.id, }; const personalWorkflowResponse = await member1Agent .post('/workflows') .send(workflowPayload) .expect(200); const workflow = personalWorkflowResponse.body.data; expect(workflow.name).toBe('Test Personal Workflow'); // Test workflow list access - filter for personal project workflows only const listResponse = await member1Agent.get('/workflows').expect(200); const personalWorkflows = listResponse.body.data.filter( (wf: any) => wf.homeProject && wf.homeProject.id === member1PersonalProject.id, ); expect(personalWorkflows).toHaveLength(1); expect(personalWorkflows[0].name).toBe('Test Personal Workflow'); // Test workflow operations await member1Agent.get(`/workflows/${workflow.id}`).expect(200); await member1Agent .patch(`/workflows/${workflow.id}`) .send({ name: 'Updated Personal Workflow', versionId: workflow.versionId }) .expect(200); await member1Agent.post(`/workflows/${workflow.id}/archive`).send().expect(200); await member1Agent.delete(`/workflows/${workflow.id}`).send().expect(200); // Test credential operations const credentialPayload = randomCredentialPayload(); const personalCredentialResponse = await member1Agent .post('/credentials') .send({ ...credentialPayload, projectId: member1PersonalProject.id }) .expect(200); const credentialId = personalCredentialResponse.body.data.id; expect(personalCredentialResponse.body.data.name).toBe(credentialPayload.name); await member1Agent.get(`/credentials/${credentialId}`).expect(200); await member1Agent .patch(`/credentials/${credentialId}`) .send({ ...credentialPayload, name: 'Updated Personal Credential' }) .expect(200); await member1Agent.delete(`/credentials/${credentialId}`).expect(200); }); }); });