first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
import {
|
||||
createTeamProject,
|
||||
getPersonalProject,
|
||||
linkUserToProject,
|
||||
createWorkflow,
|
||||
randomCredentialPayload,
|
||||
testDb,
|
||||
mockInstance,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import type { Project, User, Role } from '@n8n/db';
|
||||
|
||||
import { UserManagementMailer } from '@/user-management/email';
|
||||
|
||||
import { createCustomRoleWithScopeSlugs, cleanupRolesAndScopes } from '../shared/db/roles';
|
||||
import { createOwner, createMember } from '../shared/db/users';
|
||||
import type { SuperAgentTest } from '../shared/types';
|
||||
import * as utils from '../shared/utils/';
|
||||
|
||||
const testServer = utils.setupTestServer({
|
||||
endpointGroups: ['workflows', 'credentials'],
|
||||
enabledFeatures: ['feat:sharing', 'feat:customRoles'],
|
||||
quotas: {
|
||||
'quota:maxTeamProjects': -1,
|
||||
},
|
||||
});
|
||||
|
||||
// Foundation users and projects
|
||||
let owner: User;
|
||||
let testUser: User;
|
||||
let teamProject: Project;
|
||||
let testUserPersonalProject: Project;
|
||||
|
||||
// Custom role definitions (8 total - covering all combinations)
|
||||
let workflowReadOnlyRole: Role;
|
||||
let workflowAllOperationsRole: Role;
|
||||
let credentialReadOnlyRole: Role;
|
||||
let credentialAllOperationsRole: Role;
|
||||
|
||||
// Authentication agents
|
||||
let ownerAgent: SuperAgentTest;
|
||||
let testUserAgent: SuperAgentTest;
|
||||
|
||||
// Test data - created fresh for each test
|
||||
let testWorkflowId: string;
|
||||
let testWorkflowVersionId: string;
|
||||
let testCredentialId: string;
|
||||
|
||||
describe('Resource Access Control Matrix Tests', () => {
|
||||
beforeAll(async () => {
|
||||
mockInstance(UserManagementMailer, {
|
||||
invite: jest.fn(),
|
||||
passwordReset: jest.fn(),
|
||||
});
|
||||
|
||||
// Create foundation users
|
||||
owner = await createOwner();
|
||||
testUser = await createMember();
|
||||
|
||||
// Get projects
|
||||
testUserPersonalProject = await getPersonalProject(testUser);
|
||||
teamProject = await createTeamProject('Access Control Test Project', owner);
|
||||
|
||||
// Create authentication agents
|
||||
ownerAgent = testServer.authAgentFor(owner);
|
||||
testUserAgent = testServer.authAgentFor(testUser);
|
||||
|
||||
await utils.initCredentialsTypes();
|
||||
|
||||
// Create custom roles with specific scopes
|
||||
workflowReadOnlyRole = await createCustomRoleWithScopeSlugs(
|
||||
['workflow:read', 'workflow:list'],
|
||||
{
|
||||
roleType: 'project',
|
||||
displayName: 'Workflow Read-Only',
|
||||
description: 'Can only read and list workflows',
|
||||
},
|
||||
);
|
||||
|
||||
workflowAllOperationsRole = await createCustomRoleWithScopeSlugs(
|
||||
[
|
||||
'workflow:share',
|
||||
'workflow:execute',
|
||||
'workflow:read',
|
||||
'workflow:list',
|
||||
'workflow:create',
|
||||
'workflow:update',
|
||||
'workflow:delete',
|
||||
],
|
||||
{
|
||||
roleType: 'project',
|
||||
displayName: 'Workflow All Operations',
|
||||
description: 'Full workflow access (CRUD + list)',
|
||||
},
|
||||
);
|
||||
|
||||
credentialReadOnlyRole = await createCustomRoleWithScopeSlugs(
|
||||
['credential:read', 'credential:list'],
|
||||
{
|
||||
roleType: 'project',
|
||||
displayName: 'Credential Read-Only',
|
||||
description: 'Can only read and list credentials',
|
||||
},
|
||||
);
|
||||
|
||||
credentialAllOperationsRole = await createCustomRoleWithScopeSlugs(
|
||||
[
|
||||
'credential:read',
|
||||
'credential:list',
|
||||
'credential:create',
|
||||
'credential:update',
|
||||
'credential:delete',
|
||||
],
|
||||
{
|
||||
roleType: 'project',
|
||||
displayName: 'Credential All Operations',
|
||||
description: 'Full credential access (CRUD + list)',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up any existing shared resources
|
||||
await testDb.truncate(['SharedWorkflow', 'SharedCredentials', 'ProjectRelation']);
|
||||
|
||||
// Create fresh test data for each test
|
||||
const workflow = await createWorkflow({ name: 'Matrix Test Workflow' }, teamProject);
|
||||
testWorkflowId = workflow.id;
|
||||
testWorkflowVersionId = workflow.versionId;
|
||||
|
||||
// Create test credential via owner
|
||||
const credentialPayload = randomCredentialPayload();
|
||||
const credentialResponse = await ownerAgent
|
||||
.post('/credentials')
|
||||
.send({ ...credentialPayload, projectId: teamProject.id })
|
||||
.expect(200);
|
||||
testCredentialId = credentialResponse.body.data.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.truncate(['User', 'ProjectRelation']);
|
||||
await cleanupRolesAndScopes();
|
||||
});
|
||||
|
||||
describe('Foundation Setup Validation', () => {
|
||||
test('should have created all required custom roles', () => {
|
||||
expect(workflowReadOnlyRole.scopes).toHaveLength(2);
|
||||
expect(workflowAllOperationsRole.scopes).toHaveLength(7);
|
||||
|
||||
expect(credentialReadOnlyRole.scopes).toHaveLength(2);
|
||||
expect(credentialAllOperationsRole.scopes).toHaveLength(5);
|
||||
});
|
||||
|
||||
test('should have functional test setup', async () => {
|
||||
expect(testWorkflowId).toBeDefined();
|
||||
expect(testCredentialId).toBeDefined();
|
||||
expect(teamProject).toBeDefined();
|
||||
|
||||
// Verify owner can access test data
|
||||
await ownerAgent.get(`/workflows/${testWorkflowId}`).expect(200);
|
||||
await ownerAgent.get(`/credentials/${testCredentialId}`).expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workflow Access Control Matrix', () => {
|
||||
describe('Workflow Read-Only Role', () => {
|
||||
beforeEach(async () => {
|
||||
await linkUserToProject(testUser, teamProject, workflowReadOnlyRole.slug);
|
||||
});
|
||||
|
||||
test('POST /workflows should return 400', async () => {
|
||||
const workflowPayload = {
|
||||
name: 'New Workflow',
|
||||
active: false,
|
||||
nodes: [
|
||||
{
|
||||
id: 'uuid-1234',
|
||||
parameters: {},
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [240, 300],
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
projectId: teamProject.id,
|
||||
};
|
||||
|
||||
await testUserAgent.post('/workflows').send(workflowPayload).expect(400);
|
||||
});
|
||||
|
||||
test('GET /workflows should return 200', async () => {
|
||||
const response = await testUserAgent.get('/workflows').expect(200);
|
||||
expect(Array.isArray(response.body.data)).toBe(true);
|
||||
expect(response.body.data.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('GET /workflows/new should return 403', async () => {
|
||||
await testUserAgent.get(`/workflows/new?projectId=${teamProject.id}`).expect(403);
|
||||
});
|
||||
|
||||
test('GET /workflows/:id should return 200', async () => {
|
||||
const response = await testUserAgent.get(`/workflows/${testWorkflowId}`).expect(200);
|
||||
expect(response.body.data.name).toBe('Matrix Test Workflow');
|
||||
});
|
||||
|
||||
test('PATCH /workflows/:id should return 403', async () => {
|
||||
await testUserAgent
|
||||
.patch(`/workflows/${testWorkflowId}`)
|
||||
.send({ name: 'Updated Name', versionId: testWorkflowVersionId })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
test('DELETE /workflows/:id should return 403', async () => {
|
||||
await testUserAgent.delete(`/workflows/${testWorkflowId}`).expect(403);
|
||||
});
|
||||
|
||||
test('POST /workflows/:id/archive should return 403', async () => {
|
||||
await testUserAgent.post(`/workflows/${testWorkflowId}/archive`).send().expect(403);
|
||||
});
|
||||
|
||||
test('POST /workflows/:id/unarchive should return 403', async () => {
|
||||
await testUserAgent.post(`/workflows/${testWorkflowId}/unarchive`).send().expect(403);
|
||||
});
|
||||
|
||||
test('POST /workflows/:id/run should return 403', async () => {
|
||||
const runPayload = {
|
||||
workflowData: { id: testWorkflowId, name: 'Test', nodes: [], connections: {} },
|
||||
};
|
||||
await testUserAgent.post(`/workflows/${testWorkflowId}/run`).send(runPayload).expect(403);
|
||||
});
|
||||
|
||||
test('PUT /workflows/:id/share should return 403', async () => {
|
||||
await testUserAgent
|
||||
.put(`/workflows/${testWorkflowId}/share`)
|
||||
.send({ shareWithIds: [testUserPersonalProject.id] })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
test('PUT /workflows/:id/transfer should return 403', async () => {
|
||||
await testUserAgent
|
||||
.put(`/workflows/${testWorkflowId}/transfer`)
|
||||
.send({ destinationProjectId: testUserPersonalProject.id })
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workflow All-Operations Role', () => {
|
||||
beforeEach(async () => {
|
||||
await linkUserToProject(testUser, teamProject, workflowAllOperationsRole.slug);
|
||||
});
|
||||
|
||||
test('POST /workflows should return 200', async () => {
|
||||
const workflowPayload = {
|
||||
name: 'All-Ops Workflow',
|
||||
active: false,
|
||||
nodes: [
|
||||
{
|
||||
id: 'uuid-1234',
|
||||
parameters: {},
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [240, 300],
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
projectId: teamProject.id,
|
||||
};
|
||||
|
||||
const response = await testUserAgent.post('/workflows').send(workflowPayload).expect(200);
|
||||
expect(response.body.data.name).toBe('All-Ops Workflow');
|
||||
});
|
||||
|
||||
test('GET /workflows should return 200', async () => {
|
||||
const response = await testUserAgent.get('/workflows').expect(200);
|
||||
expect(Array.isArray(response.body.data)).toBe(true);
|
||||
expect(response.body.data.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('GET /workflows/new should return 200', async () => {
|
||||
await testUserAgent.get(`/workflows/new?projectId=${teamProject.id}`).expect(200);
|
||||
});
|
||||
|
||||
test('GET /workflows/:id should return 200', async () => {
|
||||
const response = await testUserAgent.get(`/workflows/${testWorkflowId}`).expect(200);
|
||||
expect(response.body.data.name).toBe('Matrix Test Workflow');
|
||||
});
|
||||
|
||||
test('PATCH /workflows/:id should return 200', async () => {
|
||||
const response = await testUserAgent
|
||||
.patch(`/workflows/${testWorkflowId}`)
|
||||
.send({ name: 'Updated by All-Ops', versionId: testWorkflowVersionId })
|
||||
.expect(200);
|
||||
expect(response.body.data.name).toBe('Updated by All-Ops');
|
||||
});
|
||||
|
||||
test('POST /workflows/:id/archive should return 200', async () => {
|
||||
// All-operations role (includes workflow:delete) can successfully archive workflows
|
||||
// Archive operation is allowed when user has full CRUD permissions
|
||||
await testUserAgent.post(`/workflows/${testWorkflowId}/archive`).send().expect(200);
|
||||
});
|
||||
|
||||
test('DELETE /workflows/:id should return 200', async () => {
|
||||
await testUserAgent.post(`/workflows/${testWorkflowId}/archive`).send().expect(200);
|
||||
await testUserAgent.delete(`/workflows/${testWorkflowId}`).expect(200);
|
||||
});
|
||||
|
||||
test('POST /workflows/:id/unarchive should return 200', async () => {
|
||||
await testUserAgent.post(`/workflows/${testWorkflowId}/archive`).send().expect(200);
|
||||
await testUserAgent.post(`/workflows/${testWorkflowId}/unarchive`).send().expect(200);
|
||||
});
|
||||
|
||||
test('PUT /workflows/:id/share should return 200', async () => {
|
||||
// Sharing requires workflow:share scope (not included in CRUD operations)
|
||||
await testUserAgent
|
||||
.put(`/workflows/${testWorkflowId}/share`)
|
||||
.send({ shareWithIds: [testUserPersonalProject.id] })
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Credential Access Control Matrix', () => {
|
||||
describe('Credential Read-Only Role', () => {
|
||||
beforeEach(async () => {
|
||||
await linkUserToProject(testUser, teamProject, credentialReadOnlyRole.slug);
|
||||
});
|
||||
|
||||
test('GET /credentials should return 200', async () => {
|
||||
const response = await testUserAgent.get('/credentials').expect(200);
|
||||
expect(Array.isArray(response.body.data)).toBe(true);
|
||||
expect(response.body.data.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('GET /credentials/new should return 200', async () => {
|
||||
await testUserAgent.get('/credentials/new').expect(200);
|
||||
});
|
||||
|
||||
test('GET /credentials/:id should return 200', async () => {
|
||||
const response = await testUserAgent.get(`/credentials/${testCredentialId}`).expect(200);
|
||||
expect(response.body.data).toBeDefined();
|
||||
});
|
||||
|
||||
test('POST /credentials should return 400', async () => {
|
||||
const credentialPayload = randomCredentialPayload();
|
||||
await testUserAgent
|
||||
.post('/credentials')
|
||||
.send({ ...credentialPayload, projectId: teamProject.id })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
test('PATCH /credentials/:id should return 403', async () => {
|
||||
const updatePayload = { name: 'Updated Credential Name' };
|
||||
await testUserAgent
|
||||
.patch(`/credentials/${testCredentialId}`)
|
||||
.send(updatePayload)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
test('DELETE /credentials/:id should return 403', async () => {
|
||||
await testUserAgent.delete(`/credentials/${testCredentialId}`).expect(403);
|
||||
});
|
||||
|
||||
test('PUT /credentials/:id/share should return 403', async () => {
|
||||
await testUserAgent
|
||||
.put(`/credentials/${testCredentialId}/share`)
|
||||
.send({ shareWithIds: [testUserPersonalProject.id] })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
test('PUT /credentials/:id/transfer should return 403', async () => {
|
||||
await testUserAgent
|
||||
.put(`/credentials/${testCredentialId}/transfer`)
|
||||
.send({ destinationProjectId: testUserPersonalProject.id })
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Credential All-Operations Role', () => {
|
||||
beforeEach(async () => {
|
||||
await linkUserToProject(testUser, teamProject, credentialAllOperationsRole.slug);
|
||||
});
|
||||
|
||||
test('GET /credentials should return 200', async () => {
|
||||
const response = await testUserAgent.get('/credentials').expect(200);
|
||||
expect(Array.isArray(response.body.data)).toBe(true);
|
||||
expect(response.body.data.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('GET /credentials/new should return 200', async () => {
|
||||
await testUserAgent.get('/credentials/new').expect(200);
|
||||
});
|
||||
|
||||
test('GET /credentials/:id should return 200', async () => {
|
||||
const response = await testUserAgent.get(`/credentials/${testCredentialId}`).expect(200);
|
||||
expect(response.body.data).toBeDefined();
|
||||
});
|
||||
|
||||
test('POST /credentials should return 200', async () => {
|
||||
const credentialPayload = randomCredentialPayload();
|
||||
const response = await testUserAgent
|
||||
.post('/credentials')
|
||||
.send({ ...credentialPayload, projectId: teamProject.id })
|
||||
.expect(200);
|
||||
expect(response.body.data.name).toBe(credentialPayload.name);
|
||||
});
|
||||
|
||||
test('PATCH /credentials/:id should return 200', async () => {
|
||||
const original = await testUserAgent.get(`/credentials/${testCredentialId}`).expect(200);
|
||||
|
||||
const updatePayload = { ...original.body.data, name: 'Updated by All-Ops', data: {} };
|
||||
|
||||
const response = await testUserAgent
|
||||
.patch(`/credentials/${testCredentialId}`)
|
||||
.send(updatePayload)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data.name).toBe('Updated by All-Ops');
|
||||
});
|
||||
|
||||
test('DELETE /credentials/:id should return 200', async () => {
|
||||
await testUserAgent.delete(`/credentials/${testCredentialId}`).expect(200);
|
||||
});
|
||||
|
||||
test('PUT /credentials/:id/share should return 403', async () => {
|
||||
// Sharing requires credential:share scope (not included in CRUD operations)
|
||||
await testUserAgent
|
||||
.put(`/credentials/${testCredentialId}/share`)
|
||||
.send({ shareWithIds: [testUserPersonalProject.id] })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
test('PUT /credentials/:id/transfer should return 403', async () => {
|
||||
// Transfer requires credential:move scope (not included in CRUD operations)
|
||||
await testUserAgent
|
||||
.put(`/credentials/${testCredentialId}/transfer`)
|
||||
.send({ destinationProjectId: testUserPersonalProject.id })
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user