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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,986 @@
import {
createManyWorkflows,
createTeamProject,
createWorkflow,
mockInstance,
shareWorkflowWithUsers,
testDb,
} from '@n8n/backend-test-utils';
import type { ExecutionEntity, User } from '@n8n/db';
import { Container } from '@n8n/di';
import { UnexpectedError, type ExecutionStatus } from 'n8n-workflow';
import {
createAnnotationTags,
createdExecutionWithStatus,
createErrorExecution,
createExecution,
createManyExecutions,
createSuccessfulExecution,
} from '../shared/db/executions';
import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/';
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
import { ExecutionService } from '@/executions/execution.service';
import { Telemetry } from '@/telemetry';
import { QueuedExecutionRetryError } from '@/errors/queued-execution-retry.error';
import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error';
let owner: User;
let user1: User;
let user2: User;
let authOwnerAgent: SuperAgentTest;
let authUser1Agent: SuperAgentTest;
let authUser2Agent: SuperAgentTest;
let workflowRunner: ActiveWorkflowManager;
mockInstance(Telemetry);
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
beforeAll(async () => {
owner = await createOwnerWithApiKey();
user1 = await createMemberWithApiKey();
user2 = await createMemberWithApiKey();
// TODO: mock BinaryDataService instead
await utils.initBinaryDataService();
await utils.initNodeTypes();
workflowRunner = await utils.initActiveWorkflowManager();
});
beforeEach(async () => {
await testDb.truncate([
'SharedCredentials',
'SharedWorkflow',
'WorkflowEntity',
'CredentialsEntity',
'ExecutionEntity',
'ExecutionAnnotation',
'AnnotationTagEntity',
'AnnotationTagMapping',
'Settings',
]);
authOwnerAgent = testServer.publicApiAgentFor(owner);
authUser1Agent = testServer.publicApiAgentFor(user1);
authUser2Agent = testServer.publicApiAgentFor(user2);
});
afterEach(async () => {
await workflowRunner?.removeAll();
});
const testWithAPIKey =
(method: 'get' | 'post' | 'put' | 'delete', url: string, apiKey: string | null) => async () => {
void authOwnerAgent.set({ 'X-N8N-API-KEY': apiKey });
const response = await authOwnerAgent[method](url);
expect(response.statusCode).toBe(401);
};
describe('GET /executions/:id', () => {
test('should fail due to missing API Key', testWithAPIKey('get', '/executions/1', null));
test('should fail due to invalid API Key', testWithAPIKey('get', '/executions/1', 'abcXYZ'));
test('owner should be able to get an execution owned by him', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createSuccessfulExecution(workflow);
const response = await authOwnerAgent.get(`/executions/${execution.id}`);
expect(response.statusCode).toBe(200);
const {
id,
finished,
mode,
retryOf,
retrySuccessId,
startedAt,
stoppedAt,
workflowId,
waitTill,
} = response.body;
expect(id).toBeDefined();
expect(finished).toBe(true);
expect(mode).toEqual(execution.mode);
expect(retrySuccessId).toBeNull();
expect(retryOf).toBeNull();
expect(startedAt).not.toBeNull();
expect(stoppedAt).not.toBeNull();
expect(workflowId).toBe(execution.workflowId);
expect(waitTill).toBeNull();
});
test('owner should be able to read executions of other users', async () => {
const workflow = await createWorkflow({}, user1);
const execution = await createSuccessfulExecution(workflow);
const response = await authOwnerAgent.get(`/executions/${execution.id}`);
expect(response.statusCode).toBe(200);
});
test('member should be able to fetch his own executions', async () => {
const workflow = await createWorkflow({}, user1);
const execution = await createSuccessfulExecution(workflow);
const response = await authUser1Agent.get(`/executions/${execution.id}`);
expect(response.statusCode).toBe(200);
});
test('member should not be able to fetch custom data when includeData is not set', async () => {
const workflow = await createWorkflow({}, user1);
const execution = await createExecution(
{
finished: true,
status: 'success',
metadata: [
{ key: 'test1', value: 'value1' },
{ key: 'test2', value: 'value2' },
],
},
workflow,
);
const response = await authUser1Agent.get(`/executions/${execution.id}`);
expect(response.statusCode).toBe(200);
expect(response.body.customData).toBeUndefined();
});
test('member should be able to fetch custom data when includeData=true', async () => {
const workflow = await createWorkflow({}, user1);
const execution = await createExecution(
{
finished: true,
status: 'success',
metadata: [
{ key: 'test1', value: 'value1' },
{ key: 'test2', value: 'value2' },
],
},
workflow,
);
const response = await authUser1Agent.get(`/executions/${execution.id}?includeData=true`);
expect(response.statusCode).toBe(200);
expect(response.body.customData).toEqual({
test1: 'value1',
test2: 'value2',
});
});
test('member should not get an execution of another user without the workflow being shared', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createSuccessfulExecution(workflow);
const response = await authUser1Agent.get(`/executions/${execution.id}`);
expect(response.statusCode).toBe(404);
});
test('member should be able to fetch executions of workflows shared with him', async () => {
testServer.license.enable('feat:sharing');
const workflow = await createWorkflow({}, user1);
const execution = await createSuccessfulExecution(workflow);
await shareWorkflowWithUsers(workflow, [user2]);
const response = await authUser2Agent.get(`/executions/${execution.id}`);
expect(response.statusCode).toBe(200);
});
});
describe('DELETE /executions/:id', () => {
test('should fail due to missing API Key', testWithAPIKey('delete', '/executions/1', null));
test('should fail due to invalid API Key', testWithAPIKey('delete', '/executions/1', 'abcXYZ'));
test('should delete an execution', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createSuccessfulExecution(workflow);
const response = await authOwnerAgent.delete(`/executions/${execution.id}`);
expect(response.statusCode).toBe(200);
const {
id,
finished,
mode,
retryOf,
retrySuccessId,
startedAt,
stoppedAt,
workflowId,
waitTill,
} = response.body;
expect(id).toBeDefined();
expect(finished).toBe(true);
expect(mode).toEqual(execution.mode);
expect(retrySuccessId).toBeNull();
expect(retryOf).toBeNull();
expect(startedAt).not.toBeNull();
expect(stoppedAt).not.toBeNull();
expect(workflowId).toBe(execution.workflowId);
expect(waitTill).toBeNull();
await authOwnerAgent.get(`/executions/${execution.id}`).expect(404);
});
});
describe('POST /executions/:id/retry', () => {
test('should fail due to missing API Key', testWithAPIKey('post', '/executions/1/retry', null));
test(
'should fail due to invalid API Key',
testWithAPIKey('post', '/executions/1/retry', 'abcXYZ'),
);
test('should retry an execution', async () => {
const mockedExecutionResponse = { status: 'waiting' } as any;
const executionServiceSpy = jest
.spyOn(Container.get(ExecutionService), 'retry')
.mockResolvedValue(mockedExecutionResponse);
const workflow = await createWorkflow({}, user1);
const execution = await createSuccessfulExecution(workflow);
const response = await authUser1Agent.post(`/executions/${execution.id}/retry`);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual(mockedExecutionResponse);
executionServiceSpy.mockRestore();
});
test('should return 404 when execution is not found', async () => {
const nonExistentExecutionId = 99999999;
const response = await authUser1Agent.post(`/executions/${nonExistentExecutionId}/retry`);
expect(response.statusCode).toBe(404);
expect(response.body.message).toBe('Not Found');
});
test('should return 409 when trying to retry a queued execution', async () => {
const executionServiceSpy = jest
.spyOn(Container.get(ExecutionService), 'retry')
.mockRejectedValue(new QueuedExecutionRetryError());
const workflow = await createWorkflow({}, user1);
const execution = await createExecution({ status: 'new', finished: false }, workflow);
const response = await authUser1Agent.post(`/executions/${execution.id}/retry`);
expect(response.statusCode).toBe(409);
expect(response.body.message).toBe(
'Execution is queued to run (not yet started) so it cannot be retried',
);
executionServiceSpy.mockRestore();
});
test('should return 409 when trying to retry an aborted execution without execution data', async () => {
const executionServiceSpy = jest
.spyOn(Container.get(ExecutionService), 'retry')
.mockRejectedValue(new AbortedExecutionRetryError());
const workflow = await createWorkflow({}, user1);
const execution = await createExecution(
{
status: 'error',
finished: false,
data: JSON.stringify({ executionData: null }),
},
workflow,
);
const response = await authUser1Agent.post(`/executions/${execution.id}/retry`);
expect(response.statusCode).toBe(409);
expect(response.body.message).toBe(
'The execution was aborted before starting, so it cannot be retried',
);
executionServiceSpy.mockRestore();
});
test('should return 400 when trying to retry a finished execution', async () => {
const executionServiceSpy = jest
.spyOn(Container.get(ExecutionService), 'retry')
.mockRejectedValue(new UnexpectedError('The execution succeeded, so it cannot be retried.'));
const workflow = await createWorkflow({}, user1);
const execution = await createExecution(
{
status: 'success',
finished: true,
data: JSON.stringify({ executionData: null }),
},
workflow,
);
const response = await authUser1Agent.post(`/executions/${execution.id}/retry`);
expect(response.statusCode).toBe(400);
expect(response.body.message).toBe('The execution succeeded, so it cannot be retried.');
executionServiceSpy.mockRestore();
});
});
describe('GET /executions', () => {
test('should fail due to missing API Key', testWithAPIKey('get', '/executions', null));
test('should fail due to invalid API Key', testWithAPIKey('get', '/executions', 'abcXYZ'));
test('should paginate two executions', async () => {
const workflow = await createWorkflow({}, owner);
const firstSuccessfulExecution = await createSuccessfulExecution(workflow);
const secondSuccessfulExecution = await createSuccessfulExecution(workflow);
await createErrorExecution(workflow);
const firstExecutionResponse = await authOwnerAgent.get('/executions').query({
status: 'success',
limit: 1,
});
expect(firstExecutionResponse.statusCode).toBe(200);
expect(firstExecutionResponse.body.data.length).toBe(1);
expect(firstExecutionResponse.body.nextCursor).toBeDefined();
const secondExecutionResponse = await authOwnerAgent.get('/executions').query({
status: 'success',
limit: 1,
cursor: firstExecutionResponse.body.nextCursor,
});
expect(secondExecutionResponse.statusCode).toBe(200);
expect(secondExecutionResponse.body.data.length).toBe(1);
expect(secondExecutionResponse.body.nextCursor).toBeNull();
const successfulExecutions = [firstSuccessfulExecution, secondSuccessfulExecution];
const executions = [...firstExecutionResponse.body.data, ...secondExecutionResponse.body.data];
for (let i = 0; i < executions.length; i++) {
const {
id,
finished,
mode,
retryOf,
retrySuccessId,
startedAt,
stoppedAt,
workflowId,
waitTill,
status,
} = executions[i];
expect(id).toBeDefined();
expect(finished).toBe(true);
expect(mode).toEqual(successfulExecutions[i].mode);
expect(retrySuccessId).toBeNull();
expect(retryOf).toBeNull();
expect(startedAt).not.toBeNull();
expect(stoppedAt).not.toBeNull();
expect(workflowId).toBe(successfulExecutions[i].workflowId);
expect(waitTill).toBeNull();
expect(status).toBe(successfulExecutions[i].status);
}
});
describe('with query status', () => {
type AllowedQueryStatus = 'canceled' | 'error' | 'running' | 'success' | 'waiting';
test.each`
queryStatus | entityStatus
${'canceled'} | ${'canceled'}
${'error'} | ${'error'}
${'error'} | ${'crashed'}
${'running'} | ${'running'}
${'success'} | ${'success'}
${'waiting'} | ${'waiting'}
`(
'should retrieve all $queryStatus executions',
async ({
queryStatus,
entityStatus,
}: { queryStatus: AllowedQueryStatus; entityStatus: ExecutionStatus }) => {
const workflow = await createWorkflow({}, owner);
await createdExecutionWithStatus(workflow, queryStatus === 'success' ? 'error' : 'success');
if (queryStatus !== 'running') {
// ensure there is a running execution that gets excluded unless filtering by `running`
await createdExecutionWithStatus(workflow, 'running');
}
const expectedExecution = await createdExecutionWithStatus(workflow, entityStatus);
const response = await authOwnerAgent.get('/executions').query({
status: queryStatus,
});
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(1);
expect(response.body.nextCursor).toBe(null);
const { id, status } = response.body.data[0];
expect(id).toBeDefined();
expect(status).toBe(expectedExecution.status);
},
);
});
test('should retrieve all executions of specific workflow', async () => {
const [workflow, workflow2] = await createManyWorkflows(2, {}, owner);
const savedExecutions = await createManyExecutions(2, workflow, createSuccessfulExecution);
await createManyExecutions(2, workflow2, createSuccessfulExecution);
const response = await authOwnerAgent.get('/executions').query({
workflowId: workflow.id,
});
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(2);
expect(response.body.nextCursor).toBe(null);
for (const execution of response.body.data) {
const {
id,
finished,
mode,
retryOf,
retrySuccessId,
startedAt,
stoppedAt,
workflowId,
waitTill,
status,
} = execution;
expect(savedExecutions.some((exec) => exec.id === id)).toBe(true);
expect(finished).toBe(true);
expect(mode).toBeDefined();
expect(retrySuccessId).toBeNull();
expect(retryOf).toBeNull();
expect(startedAt).not.toBeNull();
expect(stoppedAt).not.toBeNull();
expect(workflowId).toBe(workflow.id);
expect(waitTill).toBeNull();
expect(status).toBe(execution.status);
}
});
test('should return executions filtered by project ID', async () => {
/**
* Arrange
*/
const [firstProject, secondProject] = await Promise.all([
createTeamProject(),
createTeamProject(),
]);
const [firstWorkflow, secondWorkflow] = await Promise.all([
createWorkflow({}, firstProject),
createWorkflow({}, secondProject),
]);
const [firstExecution, secondExecution, _] = await Promise.all([
createExecution({}, firstWorkflow),
createExecution({}, firstWorkflow),
createExecution({}, secondWorkflow),
]);
/**
* Act
*/
const response = await authOwnerAgent.get('/executions').query({
projectId: firstProject.id,
});
/**
* Assert
*/
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(2);
expect(response.body.nextCursor).toBeNull();
expect(response.body.data.map((execution: ExecutionEntity) => execution.id)).toEqual(
expect.arrayContaining([firstExecution.id, secondExecution.id]),
);
});
test('owner should retrieve all executions regardless of ownership', async () => {
const [firstWorkflowForUser1, secondWorkflowForUser1] = await createManyWorkflows(2, {}, user1);
await createManyExecutions(2, firstWorkflowForUser1, createSuccessfulExecution);
await createManyExecutions(2, secondWorkflowForUser1, createSuccessfulExecution);
const [firstWorkflowForUser2, secondWorkflowForUser2] = await createManyWorkflows(2, {}, user2);
await createManyExecutions(2, firstWorkflowForUser2, createSuccessfulExecution);
await createManyExecutions(2, secondWorkflowForUser2, createSuccessfulExecution);
const response = await authOwnerAgent.get('/executions');
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(8);
expect(response.body.nextCursor).toBe(null);
});
test('member should not see executions of workflows not shared with him', async () => {
const [firstWorkflowForUser1, secondWorkflowForUser1] = await createManyWorkflows(2, {}, user1);
await createManyExecutions(2, firstWorkflowForUser1, createSuccessfulExecution);
await createManyExecutions(2, secondWorkflowForUser1, createSuccessfulExecution);
const [firstWorkflowForUser2, secondWorkflowForUser2] = await createManyWorkflows(2, {}, user2);
await createManyExecutions(2, firstWorkflowForUser2, createSuccessfulExecution);
await createManyExecutions(2, secondWorkflowForUser2, createSuccessfulExecution);
const response = await authUser1Agent.get('/executions');
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(4);
expect(response.body.nextCursor).toBe(null);
});
test('member should also see executions of workflows shared with him', async () => {
testServer.license.enable('feat:sharing');
const [firstWorkflowForUser1, secondWorkflowForUser1] = await createManyWorkflows(2, {}, user1);
await createManyExecutions(2, firstWorkflowForUser1, createSuccessfulExecution);
await createManyExecutions(2, secondWorkflowForUser1, createSuccessfulExecution);
const [firstWorkflowForUser2, secondWorkflowForUser2] = await createManyWorkflows(2, {}, user2);
await createManyExecutions(2, firstWorkflowForUser2, createSuccessfulExecution);
await createManyExecutions(2, secondWorkflowForUser2, createSuccessfulExecution);
await shareWorkflowWithUsers(firstWorkflowForUser2, [user1]);
const response = await authUser1Agent.get('/executions');
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(6);
expect(response.body.nextCursor).toBe(null);
});
});
describe('GET /executions/:id/tags', () => {
test('should fail due to missing API Key', testWithAPIKey('get', '/executions/1/tags', null));
test('should fail due to invalid API Key', testWithAPIKey('get', '/executions/1/tags', 'abcXYZ'));
test('should return 404 for non-existent execution', async () => {
const response = await authOwnerAgent.get('/executions/999/tags');
expect(response.statusCode).toBe(404);
});
test('should return empty array for execution with no tags', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createSuccessfulExecution(workflow);
const response = await authOwnerAgent.get(`/executions/${execution.id}/tags`);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual([]);
});
test('member should not get tags from execution in inaccessible workflow', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createSuccessfulExecution(workflow);
const response = await authUser1Agent.get(`/executions/${execution.id}/tags`);
expect(response.statusCode).toBe(404);
});
});
describe('PUT /executions/:id/tags', () => {
test('should fail due to missing API Key', testWithAPIKey('put', '/executions/1/tags', null));
test('should fail due to invalid API Key', testWithAPIKey('put', '/executions/1/tags', 'abcXYZ'));
test('should return 404 for non-existent execution', async () => {
const response = await authOwnerAgent.put('/executions/999/tags').send([]);
expect(response.statusCode).toBe(404);
});
test('should set tags on execution', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createSuccessfulExecution(workflow);
const [tag] = await createAnnotationTags(['dataset']);
const response = await authOwnerAgent
.put(`/executions/${execution.id}/tags`)
.send([{ id: tag.id }]);
expect(response.statusCode).toBe(200);
expect(response.body).toHaveLength(1);
expect(response.body[0].name).toBe('dataset');
expect(response.body[0].id).toBe(tag.id);
});
test('should replace existing tags', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createSuccessfulExecution(workflow);
const [tag1, tag2] = await createAnnotationTags(['tag1', 'tag2']);
// Set first tag
await authOwnerAgent.put(`/executions/${execution.id}/tags`).send([{ id: tag1.id }]);
// Replace with second tag
const response = await authOwnerAgent
.put(`/executions/${execution.id}/tags`)
.send([{ id: tag2.id }]);
expect(response.statusCode).toBe(200);
expect(response.body).toHaveLength(1);
expect(response.body[0].name).toBe('tag2');
});
test('should clear tags with empty array', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createSuccessfulExecution(workflow);
const [tag] = await createAnnotationTags(['dataset']);
// Set tag first
await authOwnerAgent.put(`/executions/${execution.id}/tags`).send([{ id: tag.id }]);
// Clear with empty array
const response = await authOwnerAgent.put(`/executions/${execution.id}/tags`).send([]);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual([]);
});
test('should return 404 for non-existent tag IDs', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createSuccessfulExecution(workflow);
const response = await authOwnerAgent
.put(`/executions/${execution.id}/tags`)
.send([{ id: 'nonexistent-tag-id' }]);
expect(response.statusCode).toBe(404);
expect(response.body.message).toBe('Some tags not found');
});
test('member should not update tags on execution in inaccessible workflow', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createSuccessfulExecution(workflow);
const response = await authUser1Agent.put(`/executions/${execution.id}/tags`).send([]);
expect(response.statusCode).toBe(404);
});
test('GET should return tags after PUT', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createSuccessfulExecution(workflow);
const [tag1, tag2] = await createAnnotationTags(['important', 'reviewed']);
// Set tags
await authOwnerAgent
.put(`/executions/${execution.id}/tags`)
.send([{ id: tag1.id }, { id: tag2.id }]);
// GET should return the same tags
const response = await authOwnerAgent.get(`/executions/${execution.id}/tags`);
expect(response.statusCode).toBe(200);
expect(response.body).toHaveLength(2);
expect(response.body.map((t: { name: string }) => t.name).sort()).toEqual([
'important',
'reviewed',
]);
});
});
describe('POST /executions/:id/stop', () => {
test('should fail due to missing API Key', testWithAPIKey('post', '/executions/1/stop', null));
test(
'should fail due to invalid API Key',
testWithAPIKey('post', '/executions/1/stop', 'abcXYZ'),
);
test('should stop a running execution', async () => {
const mockedStopResponse = {
mode: 'manual',
startedAt: new Date().toISOString(),
stoppedAt: new Date().toISOString(),
finished: false,
status: 'canceled',
} as any;
const executionServiceSpy = jest
.spyOn(Container.get(ExecutionService), 'stop')
.mockResolvedValue({
...mockedStopResponse,
startedAt: new Date(mockedStopResponse.startedAt),
stoppedAt: new Date(mockedStopResponse.stoppedAt),
});
const workflow = await createWorkflow({}, user1);
const execution = await createExecution({ status: 'running', finished: false }, workflow);
const response = await authUser1Agent.post(`/executions/${execution.id}/stop`);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual(mockedStopResponse);
expect(executionServiceSpy).toHaveBeenCalled();
// The execution ID from the route parameter is passed to the service
const calledExecutionId = executionServiceSpy.mock.calls[0][0];
// URL parameters come as strings, so we expect string conversion
expect(String(calledExecutionId)).toBe(execution.id.toString());
executionServiceSpy.mockRestore();
});
test('should return 404 when execution is not found', async () => {
const nonExistentExecutionId = 99999999;
const response = await authUser1Agent.post(`/executions/${nonExistentExecutionId}/stop`);
expect(response.statusCode).toBe(404);
expect(response.body.message).toBe('Not Found');
});
test('member should not be able to stop execution of workflow not shared with them', async () => {
const workflow = await createWorkflow({}, owner);
const execution = await createExecution({ status: 'running', finished: false }, workflow);
const response = await authUser1Agent.post(`/executions/${execution.id}/stop`);
expect(response.statusCode).toBe(404);
expect(response.body.message).toBe('Not Found');
});
test('should allow stopping execution of shared workflow', async () => {
testServer.license.enable('feat:sharing');
const mockedStopResponse = {
mode: 'manual',
startedAt: new Date().toISOString(),
stoppedAt: new Date().toISOString(),
finished: false,
status: 'canceled',
} as any;
const executionServiceSpy = jest
.spyOn(Container.get(ExecutionService), 'stop')
.mockResolvedValue({
...mockedStopResponse,
startedAt: new Date(mockedStopResponse.startedAt),
stoppedAt: new Date(mockedStopResponse.stoppedAt),
});
const workflow = await createWorkflow({}, user1);
const execution = await createExecution({ status: 'running', finished: false }, workflow);
await shareWorkflowWithUsers(workflow, [user2]);
const response = await authUser2Agent.post(`/executions/${execution.id}/stop`);
expect(response.statusCode).toBe(200);
expect(response.body).toEqual(mockedStopResponse);
executionServiceSpy.mockRestore();
});
});
describe('POST /executions/stop', () => {
test('should fail due to missing API Key', testWithAPIKey('post', '/executions/stop', null));
test('should fail due to invalid API Key', testWithAPIKey('post', '/executions/stop', 'abcXYZ'));
test('should return 400 when status is not provided', async () => {
const response = await authUser1Agent.post('/executions/stop').send({});
expect(response.statusCode).toBe(400);
// OpenAPI validation catches this before our handler validation
expect(response.body.message).toContain('status');
});
test('should return 400 when status is empty array', async () => {
const response = await authUser1Agent.post('/executions/stop').send({ status: [] });
expect(response.statusCode).toBe(400);
expect(response.body.message).toContain('Status filter is required');
expect(response.body.example).toBeDefined();
});
test('should stop multiple running executions', async () => {
const executionServiceSpy = jest
.spyOn(Container.get(ExecutionService), 'stopMany')
.mockResolvedValue(3);
await createWorkflow({}, user1);
const response = await authUser1Agent
.post('/executions/stop')
.send({ status: ['running', 'waiting'] });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ stopped: 3 });
expect(executionServiceSpy).toHaveBeenCalledWith(
{
workflowId: 'all',
status: ['running', 'waiting'],
startedAfter: undefined,
startedBefore: undefined,
},
expect.any(Array),
);
executionServiceSpy.mockRestore();
});
test('should stop executions filtered by workflowId', async () => {
const executionServiceSpy = jest
.spyOn(Container.get(ExecutionService), 'stopMany')
.mockResolvedValue(2);
const workflow = await createWorkflow({}, user1);
const response = await authUser1Agent
.post('/executions/stop')
.send({ status: ['running'], workflowId: workflow.id });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ stopped: 2 });
expect(executionServiceSpy).toHaveBeenCalledWith(
{
workflowId: workflow.id,
status: ['running'],
startedAfter: undefined,
startedBefore: undefined,
},
expect.any(Array),
);
executionServiceSpy.mockRestore();
});
test('should stop executions with date filters', async () => {
const executionServiceSpy = jest
.spyOn(Container.get(ExecutionService), 'stopMany')
.mockResolvedValue(1);
await createWorkflow({}, user1);
const startedAfter = '2024-01-01T00:00:00.000Z';
const startedBefore = '2024-12-31T23:59:59.999Z';
const response = await authUser1Agent.post('/executions/stop').send({
status: ['running'],
startedAfter,
startedBefore,
});
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ stopped: 1 });
expect(executionServiceSpy).toHaveBeenCalledWith(
{
workflowId: 'all',
status: ['running'],
startedAfter,
startedBefore,
},
expect.any(Array),
);
executionServiceSpy.mockRestore();
});
test('should validate workflowId access when provided', async () => {
// Create a workflow for user1
const workflow = await createWorkflow({}, user1);
const executionServiceSpy = jest
.spyOn(Container.get(ExecutionService), 'stopMany')
.mockResolvedValue(1);
// User1 should be able to stop executions in their own workflow
const response = await authUser1Agent
.post('/executions/stop')
.send({ status: ['running'], workflowId: workflow.id });
expect(response.statusCode).toBe(200);
expect(executionServiceSpy).toHaveBeenCalled();
expect(executionServiceSpy.mock.calls[0][0].workflowId).toBe(workflow.id);
executionServiceSpy.mockRestore();
});
test('should return 0 stopped when user has no workflows', async () => {
const executionServiceSpy = jest.spyOn(Container.get(ExecutionService), 'stopMany');
// Create a new user with no workflows
const userWithNoWorkflows = await createMemberWithApiKey();
const authAgentWithNoWorkflows = testServer.publicApiAgentFor(userWithNoWorkflows);
const response = await authAgentWithNoWorkflows
.post('/executions/stop')
.send({ status: ['running'] });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ stopped: 0 });
// stopMany should not be called if user has no workflows
expect(executionServiceSpy).not.toHaveBeenCalled();
executionServiceSpy.mockRestore();
});
test('owner should be able to stop executions across all workflows', async () => {
// Create some workflows so owner has workflows to access
await createManyWorkflows(2, {}, owner);
const executionServiceSpy = jest
.spyOn(Container.get(ExecutionService), 'stopMany')
.mockResolvedValue(5);
const response = await authOwnerAgent
.post('/executions/stop')
.send({ status: ['running', 'waiting'] });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ stopped: 5 });
executionServiceSpy.mockRestore();
});
test('member should only stop executions in their accessible workflows', async () => {
testServer.license.enable('feat:sharing');
const executionServiceSpy = jest
.spyOn(Container.get(ExecutionService), 'stopMany')
.mockResolvedValue(2);
const [workflow1, workflow2] = await createManyWorkflows(2, {}, user1);
const workflow3 = await createWorkflow({}, user2);
// Share workflow3 with user1
await shareWorkflowWithUsers(workflow3, [user1]);
const response = await authUser1Agent.post('/executions/stop').send({ status: ['running'] });
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ stopped: 2 });
// Verify that the service was called with workflow IDs accessible to user1
const calledWithWorkflowIds = executionServiceSpy.mock.calls[0][1];
expect(calledWithWorkflowIds).toContain(workflow1.id);
expect(calledWithWorkflowIds).toContain(workflow2.id);
expect(calledWithWorkflowIds).toContain(workflow3.id);
executionServiceSpy.mockRestore();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,329 @@
import { testDb } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { TagRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { createTag } from '../shared/db/tags';
import { createMemberWithApiKey, createOwnerWithApiKey } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/';
let owner: User;
let member: User;
let authOwnerAgent: SuperAgentTest;
let authMemberAgent: SuperAgentTest;
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
beforeAll(async () => {
owner = await createOwnerWithApiKey();
member = await createMemberWithApiKey();
});
beforeEach(async () => {
await testDb.truncate(['TagEntity']);
authOwnerAgent = testServer.publicApiAgentFor(owner);
authMemberAgent = testServer.publicApiAgentFor(member);
});
const testWithAPIKey =
(method: 'get' | 'post' | 'put' | 'delete', url: string, apiKey: string | null) => async () => {
void authOwnerAgent.set({ 'X-N8N-API-KEY': apiKey });
const response = await authOwnerAgent[method](url);
expect(response.statusCode).toBe(401);
};
describe('GET /tags', () => {
test('should fail due to missing API Key', testWithAPIKey('get', '/tags', null));
test('should fail due to invalid API Key', testWithAPIKey('get', '/tags', 'abcXYZ'));
test('should return all tags', async () => {
await Promise.all([createTag({}), createTag({}), createTag({})]);
const response = await authMemberAgent.get('/tags');
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(3);
expect(response.body.nextCursor).toBeNull();
for (const tag of response.body.data) {
const { id, name, createdAt, updatedAt } = tag;
expect(id).toBeDefined();
expect(name).toBeDefined();
expect(createdAt).toBeDefined();
expect(updatedAt).toBeDefined();
}
});
test('should return all tags with pagination', async () => {
await Promise.all([createTag({}), createTag({}), createTag({})]);
const response = await authMemberAgent.get('/tags?limit=1');
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(1);
expect(response.body.nextCursor).not.toBeNull();
const response2 = await authMemberAgent.get(`/tags?limit=1&cursor=${response.body.nextCursor}`);
expect(response2.statusCode).toBe(200);
expect(response2.body.data.length).toBe(1);
expect(response2.body.nextCursor).not.toBeNull();
expect(response2.body.nextCursor).not.toBe(response.body.nextCursor);
const responses = [...response.body.data, ...response2.body.data];
for (const tag of responses) {
const { id, name, createdAt, updatedAt } = tag;
expect(id).toBeDefined();
expect(name).toBeDefined();
expect(createdAt).toBeDefined();
expect(updatedAt).toBeDefined();
}
// check that we really received a different result
expect(response.body.data[0].id).not.toBe(response2.body.data[0].id);
});
});
describe('GET /tags/:id', () => {
test('should fail due to missing API Key', testWithAPIKey('get', '/tags/gZqmqiGAuo1dHT7q', null));
test(
'should fail due to invalid API Key',
testWithAPIKey('get', '/tags/gZqmqiGAuo1dHT7q', 'abcXYZ'),
);
test('should fail due to non-existing tag', async () => {
const response = await authOwnerAgent.get('/tags/gZqmqiGAuo1dHT7q');
expect(response.statusCode).toBe(404);
});
test('should retrieve tag', async () => {
// create tag
const tag = await createTag({});
const response = await authMemberAgent.get(`/tags/${tag.id}`);
expect(response.statusCode).toBe(200);
const { id, name, createdAt, updatedAt } = response.body;
expect(id).toEqual(tag.id);
expect(name).toEqual(tag.name);
expect(createdAt).toEqual(tag.createdAt.toISOString());
expect(updatedAt).toEqual(tag.updatedAt.toISOString());
});
});
describe('DELETE /tags/:id', () => {
test(
'should fail due to missing API Key',
testWithAPIKey('delete', '/tags/gZqmqiGAuo1dHT7q', null),
);
test(
'should fail due to invalid API Key',
testWithAPIKey('delete', '/tags/gZqmqiGAuo1dHT7q', 'abcXYZ'),
);
test('should fail due to non-existing tag', async () => {
const response = await authOwnerAgent.delete('/tags/gZqmqiGAuo1dHT7q');
expect(response.statusCode).toBe(404);
});
test('owner should delete the tag', async () => {
// create tag
const tag = await createTag({});
const response = await authOwnerAgent.delete(`/tags/${tag.id}`);
expect(response.statusCode).toBe(200);
const { id, name, createdAt, updatedAt } = response.body;
expect(id).toEqual(tag.id);
expect(name).toEqual(tag.name);
expect(createdAt).toEqual(tag.createdAt.toISOString());
expect(updatedAt).toEqual(tag.updatedAt.toISOString());
// make sure the tag actually deleted from the db
const deletedTag = await Container.get(TagRepository).findOneBy({
id: tag.id,
});
expect(deletedTag).toBeNull();
});
test('non-owner should not delete tag', async () => {
// create tag
const tag = await createTag({});
const response = await authMemberAgent.delete(`/tags/${tag.id}`);
expect(response.statusCode).toBe(403);
const { message } = response.body;
expect(message).toEqual('Forbidden');
// make sure the tag was not deleted from the db
const notDeletedTag = await Container.get(TagRepository).findOneBy({
id: tag.id,
});
expect(notDeletedTag).not.toBeNull();
});
});
describe('POST /tags', () => {
test('should fail due to missing API Key', testWithAPIKey('post', '/tags', null));
test('should fail due to invalid API Key', testWithAPIKey('post', '/tags', 'abcXYZ'));
test('should fail due to invalid body', async () => {
const response = await authOwnerAgent.post('/tags').send({});
expect(response.statusCode).toBe(400);
});
test('should create tag', async () => {
const payload = {
name: 'Tag 1',
};
const response = await authMemberAgent.post('/tags').send(payload);
expect(response.statusCode).toBe(201);
const { id, name, createdAt, updatedAt } = response.body;
expect(id).toBeDefined();
expect(name).toBe(payload.name);
expect(createdAt).toBeDefined();
expect(updatedAt).toEqual(createdAt);
// check if created tag in DB
const tag = await Container.get(TagRepository).findOne({
where: {
id,
},
});
expect(tag?.name).toBe(name);
expect(tag?.createdAt.toISOString()).toEqual(createdAt);
expect(tag?.updatedAt.toISOString()).toEqual(updatedAt);
});
test('should not create tag if tag with same name exists', async () => {
const tag = {
name: 'Tag 1',
};
// create tag
await createTag(tag);
const response = await authMemberAgent.post('/tags').send(tag);
expect(response.statusCode).toBe(409);
const { message } = response.body;
expect(message).toBe('Tag already exists');
});
});
describe('PUT /tags/:id', () => {
test('should fail due to missing API Key', testWithAPIKey('put', '/tags/gZqmqiGAuo1dHT7q', null));
test(
'should fail due to invalid API Key',
testWithAPIKey('put', '/tags/gZqmqiGAuo1dHT7q', 'abcXYZ'),
);
test('should fail due to non-existing tag', async () => {
const response = await authOwnerAgent.put('/tags/gZqmqiGAuo1dHT7q').send({
name: 'testing',
});
expect(response.statusCode).toBe(404);
});
test('should fail due to invalid body', async () => {
const response = await authOwnerAgent.put('/tags/gZqmqiGAuo1dHT7q').send({});
expect(response.statusCode).toBe(400);
});
test('should update tag', async () => {
const tag = await createTag({});
const payload = {
name: 'New name',
};
const response = await authOwnerAgent.put(`/tags/${tag.id}`).send(payload);
const { id, name, updatedAt } = response.body;
expect(response.statusCode).toBe(200);
expect(id).toBe(tag.id);
expect(name).toBe(payload.name);
expect(updatedAt).not.toBe(tag.updatedAt.toISOString());
// check updated tag in DB
const dbTag = await Container.get(TagRepository).findOne({
where: {
id,
},
});
expect(dbTag?.name).toBe(payload.name);
expect(dbTag?.updatedAt.getTime()).toBeGreaterThan(tag.updatedAt.getTime());
});
test('should fail if there is already a tag with a the new name', async () => {
const toUpdateTag = await createTag({});
const otherTag = await createTag({ name: 'Some name' });
const payload = {
name: otherTag.name,
};
const response = await authOwnerAgent.put(`/tags/${toUpdateTag.id}`).send(payload);
expect(response.statusCode).toBe(409);
const { message } = response.body;
expect(message).toBe('Tag already exists');
// check tags haven't be updated in DB
const toUpdateTagFromDb = await Container.get(TagRepository).findOne({
where: {
id: toUpdateTag.id,
},
});
expect(toUpdateTagFromDb?.name).toEqual(toUpdateTag.name);
expect(toUpdateTagFromDb?.createdAt.toISOString()).toEqual(toUpdateTag.createdAt.toISOString());
expect(toUpdateTagFromDb?.updatedAt.toISOString()).toEqual(toUpdateTag.updatedAt.toISOString());
const otherTagFromDb = await Container.get(TagRepository).findOne({
where: {
id: otherTag.id,
},
});
expect(otherTagFromDb?.name).toEqual(otherTag.name);
expect(otherTagFromDb?.createdAt.toISOString()).toEqual(otherTag.createdAt.toISOString());
expect(otherTagFromDb?.updatedAt.toISOString()).toEqual(otherTag.updatedAt.toISOString());
});
});
@@ -0,0 +1,246 @@
import {
createTeamProject,
linkUserToProject,
testDb,
mockInstance,
} from '@n8n/backend-test-utils';
import { GLOBAL_MEMBER_ROLE, type User } from '@n8n/db';
import { v4 as uuid } from 'uuid';
import validator from 'validator';
import { License } from '@/license';
import {
createMember,
createMemberWithApiKey,
createOwnerWithApiKey,
createUser,
createUserShell,
} from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/';
mockInstance(License, {
getUsersLimit: jest.fn().mockReturnValue(-1),
});
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
beforeEach(async () => {
await testDb.truncate([
'SharedCredentials',
'SharedWorkflow',
'WorkflowEntity',
'CredentialsEntity',
'User',
]);
});
describe('With license unlimited quota:users', () => {
describe('GET /users', () => {
test('should fail due to missing API Key', async () => {
const authOwnerAgent = testServer.publicApiAgentWithoutApiKey();
await authOwnerAgent.get('/users').expect(401);
});
test('should fail due to invalid API Key', async () => {
const authOwnerAgent = testServer.publicApiAgentWithApiKey('invalid-key');
await authOwnerAgent.get('/users').expect(401);
});
test('should return all users', async () => {
const owner = await createOwnerWithApiKey();
const authOwnerAgent = testServer.publicApiAgentFor(owner);
await createUser();
const response = await authOwnerAgent.get('/users').expect(200);
expect(response.body.data.length).toBe(2);
expect(response.body.nextCursor).toBeNull();
for (const user of response.body.data) {
const {
id,
email,
firstName,
lastName,
personalizationAnswers,
role,
password,
isPending,
createdAt,
updatedAt,
} = user;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBeDefined();
expect(firstName).toBeDefined();
expect(lastName).toBeDefined();
expect(personalizationAnswers).toBeUndefined();
expect(password).toBeUndefined();
expect(isPending).toBe(false);
expect(role).toBeUndefined();
expect(createdAt).toBeDefined();
expect(updatedAt).toBeDefined();
}
});
it('should return users filtered by project ID', async () => {
/**
* Arrange
*/
const [owner, firstMember, secondMember, thirdMember] = await Promise.all([
createOwnerWithApiKey(),
createMember(),
createMember(),
createMember(),
]);
const [firstProject, secondProject] = await Promise.all([
createTeamProject(),
createTeamProject(),
]);
await Promise.all([
linkUserToProject(firstMember, firstProject, 'project:admin'),
linkUserToProject(secondMember, firstProject, 'project:viewer'),
linkUserToProject(thirdMember, secondProject, 'project:admin'),
]);
/**
* Act
*/
const response = await testServer.publicApiAgentFor(owner).get('/users').query({
projectId: firstProject.id,
});
/**
* Assert
*/
expect(response.status).toBe(200);
expect(response.body.data.length).toBe(2);
expect(response.body.nextCursor).toBeNull();
expect(response.body.data.map((user: User) => user.id)).toEqual(
expect.arrayContaining([firstMember.id, secondMember.id]),
);
});
});
describe('GET /users/:id', () => {
test('should fail due to missing API Key', async () => {
const owner = await createOwnerWithApiKey();
const authOwnerAgent = testServer.publicApiAgentWithoutApiKey();
await authOwnerAgent.get(`/users/${owner.id}`).expect(401);
});
test('should fail due to invalid API Key', async () => {
const owner = await createOwnerWithApiKey();
const authOwnerAgent = testServer.publicApiAgentWithApiKey('invalid-key');
await authOwnerAgent.get(`/users/${owner.id}`).expect(401);
});
test('should fail due to member trying to access owner only endpoint', async () => {
const member = await createMemberWithApiKey();
const authMemberAgent = testServer.publicApiAgentFor(member);
await authMemberAgent.get(`/users/${member.id}`).expect(403);
});
test('should return 404 for non-existing id ', async () => {
const owner = await createOwnerWithApiKey();
const authOwnerAgent = testServer.publicApiAgentFor(owner);
await authOwnerAgent.get(`/users/${uuid()}`).expect(404);
});
test('should return a pending user', async () => {
const owner = await createOwnerWithApiKey();
const { id: memberId } = await createUserShell(GLOBAL_MEMBER_ROLE);
const authOwnerAgent = testServer.publicApiAgentFor(owner);
const response = await authOwnerAgent.get(`/users/${memberId}`).expect(200);
const {
id,
email,
firstName,
lastName,
personalizationAnswers,
role,
password,
isPending,
createdAt,
updatedAt,
} = response.body;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBeDefined();
expect(firstName).toBeDefined();
expect(lastName).toBeDefined();
expect(personalizationAnswers).toBeUndefined();
expect(password).toBeUndefined();
expect(role).toBeUndefined();
expect(createdAt).toBeDefined();
expect(isPending).toBeDefined();
expect(isPending).toBeTruthy();
expect(updatedAt).toBeDefined();
});
});
describe('GET /users/:email', () => {
test('with non-existing email should return 404', async () => {
const owner = await createOwnerWithApiKey();
const authOwnerAgent = testServer.publicApiAgentFor(owner);
await authOwnerAgent.get('/users/jhondoe@gmail.com').expect(404);
});
test('should return a user', async () => {
const owner = await createOwnerWithApiKey();
const authOwnerAgent = testServer.publicApiAgentFor(owner);
const response = await authOwnerAgent.get(`/users/${owner.email}`).expect(200);
const {
id,
email,
firstName,
lastName,
personalizationAnswers,
role,
password,
isPending,
createdAt,
updatedAt,
} = response.body;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBeDefined();
expect(firstName).toBeDefined();
expect(lastName).toBeDefined();
expect(personalizationAnswers).toBeUndefined();
expect(password).toBeUndefined();
expect(isPending).toBe(false);
expect(role).toBeUndefined();
expect(createdAt).toBeDefined();
expect(updatedAt).toBeDefined();
});
});
});
describe('With license without quota:users', () => {
let authOwnerAgent: SuperAgentTest;
beforeEach(async () => {
mockInstance(License, { getUsersLimit: jest.fn().mockReturnValue(null) });
const owner = await createOwnerWithApiKey();
authOwnerAgent = testServer.publicApiAgentFor(owner);
});
test('GET /users should fail due to invalid license', async () => {
await authOwnerAgent.get('/users').expect(403);
});
test('GET /users/:id should fail due to invalid license', async () => {
await authOwnerAgent.get(`/users/${uuid()}`).expect(403);
});
});
@@ -0,0 +1,457 @@
import { testDb, mockInstance } from '@n8n/backend-test-utils';
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
import { Telemetry } from '@/telemetry';
import { createRole } from '@test-integration/db/roles';
import {
createMember,
createMemberWithApiKey,
createOwnerWithApiKey,
getUserById,
} from '@test-integration/db/users';
import { setupTestServer } from '@test-integration/utils';
describe('Users in Public API', () => {
const testServer = setupTestServer({ endpointGroups: ['publicApi'] });
mockInstance(Telemetry);
beforeAll(async () => {
await testDb.init();
});
beforeEach(async () => {
await testDb.truncate(['User']);
});
describe('GET /users', () => {
it('if not authenticated, should reject', async () => {
/**
* Act
*/
const response = await testServer.publicApiAgentWithApiKey('').get('/users');
/**
* Assert
*/
expect(response.status).toBe(401);
});
it('should return users with roles', async () => {
/**
* Arrange
*/
const owner = await createOwnerWithApiKey();
const includeRole = true;
await createMember();
await createMember();
await createMember();
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(owner)
.get('/users')
.query({ includeRole });
/**
* Assert
*/
expect(response.status).toBe(200);
const { data: users } = response.body;
expect(users).toHaveLength(4);
users.forEach((user: any) => {
expect(user).toHaveProperty('id');
expect(user).toHaveProperty('email');
expect(user).toHaveProperty('firstName');
expect(user).toHaveProperty('lastName');
expect(user).toHaveProperty('createdAt');
expect(user).toHaveProperty('updatedAt');
expect(user).toHaveProperty('isPending');
expect(user).toHaveProperty('role');
});
const members = users.filter((user: any) => user.role === 'global:member');
expect(members).toHaveLength(3);
const owners = users.filter((user: any) => user.role === 'global:owner');
expect(owners).toHaveLength(1);
});
});
describe('GET /users/:id', () => {
it('if not authenticated, should reject', async () => {
/**
* Arrange
*/
const member = await createMember();
/**
* Act
*/
const response = await testServer.publicApiAgentWithApiKey('').get(`/users/${member.id}`);
/**
* Assert
*/
expect(response.status).toBe(401);
});
it('should return a user with role', async () => {
/**
* Arrange
*/
const owner = await createOwnerWithApiKey();
const member = await createMember();
const includeRole = true;
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(owner)
.get(`/users/${member.id}`)
.query({ includeRole });
/**
* Assert
*/
expect(response.status).toBe(200);
const returnedUser = response.body;
expect(returnedUser).toHaveProperty('id', member.id);
expect(returnedUser).toHaveProperty('email', member.email);
expect(returnedUser).toHaveProperty('firstName', member.firstName);
expect(returnedUser).toHaveProperty('lastName', member.lastName);
expect(returnedUser).toHaveProperty('createdAt');
expect(returnedUser).toHaveProperty('updatedAt');
expect(returnedUser).toHaveProperty('isPending', member.isPending);
expect(returnedUser).toHaveProperty('role', 'global:member');
});
});
describe('POST /users', () => {
it('if not authenticated, should reject', async () => {
/**
* Arrange
*/
const payload = { email: 'test@test.com', role: 'global:admin' };
/**
* Act
*/
const response = await testServer.publicApiAgentWithApiKey('').post('/users').send(payload);
/**
* Assert
*/
expect(response.status).toBe(401);
});
it('if missing scope, should reject', async () => {
/**
* Arrange
*/
testServer.license.enable('feat:advancedPermissions');
const member = await createMemberWithApiKey();
const payload = [{ email: 'test@test.com', role: 'global:admin' }];
/**
* Act
*/
const response = await testServer.publicApiAgentFor(member).post('/users').send(payload);
/**
* Assert
*/
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message', 'Forbidden');
});
it('should fail if role does not exist', async () => {
/**
* Arrange
*/
testServer.license.enable('feat:advancedPermissions');
const owner = await createOwnerWithApiKey();
const payload = [{ email: 'test@test.com', role: 'non-existing-role' }];
/**
* Act
*/
const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload);
/**
* Assert
*/
expect(response.status).toBe(400);
expect(response.body).toHaveProperty('message', 'Role non-existing-role does not exist');
});
it('should create a user', async () => {
/**
* Arrange
*/
testServer.license.enable('feat:advancedPermissions');
const owner = await createOwnerWithApiKey();
const payload = [{ email: 'test@test.com', role: 'global:admin' }];
/**
* Act
*/
const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload);
/**
* Assert
*/
expect(response.status).toBe(201);
expect(response.body).toHaveLength(1);
const [result] = response.body;
const { user: returnedUser, error } = result;
const payloadUser = payload[0];
expect(returnedUser).toHaveProperty('email', payload[0].email);
expect(typeof returnedUser.inviteAcceptUrl).toBe('string');
expect(typeof returnedUser.emailSent).toBe('boolean');
expect(error).toBe('');
const storedUser = await getUserById(returnedUser.id);
expect(returnedUser.id).toBe(storedUser.id);
expect(returnedUser.email).toBe(storedUser.email);
expect(returnedUser.email).toBe(payloadUser.email);
expect(storedUser.role.slug).toBe(payloadUser.role);
});
it('should create a user with an existing custom role', async () => {
/**
* Arrange
*/
testServer.license.enable('feat:advancedPermissions');
const owner = await createOwnerWithApiKey();
const customRole = 'custom:role';
await createRole({ slug: customRole, displayName: 'Custom role', roleType: 'global' });
const payload = [{ email: 'test@test.com', role: customRole }];
/**
* Act
*/
const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload);
/**
* Assert
*/
expect(response.status).toBe(201);
});
});
describe('DELETE /users/:id', () => {
it('if not authenticated, should reject', async () => {
/**
* Arrange
*/
const member = await createMember();
/**
* Act
*/
const response = await testServer.publicApiAgentWithApiKey('').delete(`/users/${member.id}`);
/**
* Assert
*/
expect(response.status).toBe(401);
});
it('if missing scope, should reject', async () => {
/**
* Arrange
*/
testServer.license.enable('feat:advancedPermissions');
const member = await createMemberWithApiKey();
const secondMember = await createMember();
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(member)
.delete(`/users/${secondMember.id}`);
/**
* Assert
*/
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message', 'Forbidden');
});
it('should delete a user', async () => {
/**
* Arrange
*/
testServer.license.enable('feat:advancedPermissions');
const owner = await createOwnerWithApiKey();
const member = await createMember();
/**
* Act
*/
const response = await testServer.publicApiAgentFor(owner).delete(`/users/${member.id}`);
/**
* Assert
*/
expect(response.status).toBe(204);
await expect(getUserById(member.id)).rejects.toThrow();
});
});
describe('PATCH /users/:id/role', () => {
it('if not authenticated, should reject', async () => {
/**
* Arrange
*/
const member = await createMember();
/**
* Act
*/
const response = await testServer
.publicApiAgentWithApiKey('')
.patch(`/users/${member.id}/role`);
/**
* Assert
*/
expect(response.status).toBe(401);
});
it('if not licensed, should reject', async () => {
/**
* Arrange
*/
const owner = await createOwnerWithApiKey();
const member = await createMember();
const payload = { newRoleName: 'global:admin' };
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(owner)
.patch(`/users/${member.id}/role`)
.send(payload);
/**
* Assert
*/
expect(response.status).toBe(403);
expect(response.body).toHaveProperty(
'message',
new FeatureNotLicensedError('feat:advancedPermissions').message,
);
});
it('if missing scope, should reject', async () => {
/**
* Arrange
*/
testServer.license.enable('feat:advancedPermissions');
const member = await createMemberWithApiKey();
const secondMember = await createMember();
const payload = { newRoleName: 'global:admin' };
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(member)
.patch(`/users/${secondMember.id}/role`)
.send(payload);
/**
* Assert
*/
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message', 'Forbidden');
});
it('should return a 400 on invalid payload', async () => {
/**
* Arrange
*/
testServer.license.enable('feat:advancedPermissions');
const owner = await createOwnerWithApiKey();
const member = await createMember();
const payload = { newRoleName: 'invalid' };
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(owner)
.patch(`/users/${member.id}/role`)
.send(payload);
/**
* Assert
*/
expect(response.status).toBe(400);
});
it("should change a user's role", async () => {
/**
* Arrange
*/
testServer.license.enable('feat:advancedPermissions');
const owner = await createOwnerWithApiKey();
const member = await createMember();
const payload = { newRoleName: 'global:admin' };
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(owner)
.patch(`/users/${member.id}/role`)
.send(payload);
/**
* Assert
*/
expect(response.status).toBe(204);
const storedUser = await getUserById(member.id);
expect(storedUser.role.slug).toBe(payload.newRoleName);
});
it('should change a user role to an existing custom role', async () => {
/**
* Arrange
*/
testServer.license.enable('feat:advancedPermissions');
const owner = await createOwnerWithApiKey();
const member = await createMember();
const customRole = 'custom:role';
await createRole({ slug: customRole, displayName: 'Custom role', roleType: 'global' });
const payload = { newRoleName: customRole };
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(owner)
.patch(`/users/${member.id}/role`)
.send(payload);
/**
* Assert
*/
expect(response.status).toBe(204);
const storedUser = await getUserById(member.id);
expect(storedUser.role.slug).toBe(payload.newRoleName);
});
});
});
@@ -0,0 +1,285 @@
import { createTeamProject, testDb } from '@n8n/backend-test-utils';
import type { Project, User, Variables } from '@n8n/db';
import { createOwnerWithApiKey } from '@test-integration/db/users';
import {
createProjectVariable,
createVariable,
getVariableByIdOrFail,
} from '@test-integration/db/variables';
import { setupTestServer } from '@test-integration/utils';
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
describe('Variables in Public API', () => {
let owner: User;
let project: Project;
const testServer = setupTestServer({ endpointGroups: ['publicApi'] });
const licenseErrorMessage = new FeatureNotLicensedError('feat:variables').message;
beforeAll(async () => {
await testDb.init();
});
beforeEach(async () => {
await testDb.truncate(['Variables', 'User']);
owner = await createOwnerWithApiKey();
project = await createTeamProject();
});
describe('GET /variables', () => {
it('if licensed, should return all variables with pagination', async () => {
/**
* Arrange
*/
testServer.license.enable('feat:variables');
const variables = await Promise.all([
createVariable(),
createVariable(),
createVariable(),
createProjectVariable('projectKey', 'projectValue', project),
]);
/**
* Act
*/
const response = await testServer.publicApiAgentFor(owner).get('/variables');
/**
* 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(variables.length);
variables.forEach(({ id, key, value, project }) => {
expect(response.body.data).toContainEqual(expect.objectContaining({ id, key, value }));
if (project) {
const projectResponse = response.body.data.find((v: Variables) => v.id === id).project;
expect(projectResponse).toBeDefined();
expect(projectResponse).toEqual(
expect.objectContaining({ id: project.id, name: project.name }),
);
}
});
});
it('if licensed, should be able to filter variables by projectId and state', async () => {
/**
* Arrange
*/
testServer.license.enable('feat:variables');
await Promise.all([
createVariable(),
createProjectVariable('projectKey', 'projectValue', project),
createProjectVariable('emptyVar', '', project),
createVariable('emptyVar', ''),
]);
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(owner)
.get('/variables')
.query({ projectId: project.id, state: 'empty' });
/**
* 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(1);
expect(response.body.data[0]).toEqual(
expect.objectContaining({
key: 'emptyVar',
value: '',
project: expect.objectContaining({ id: project.id }),
}),
);
});
it('if not licensed, should reject', async () => {
/**
* Act
*/
const response = await testServer.publicApiAgentFor(owner).get('/variables');
/**
* Assert
*/
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message', licenseErrorMessage);
});
});
describe('POST /variables', () => {
it('if licensed, should create a new variable', async () => {
/**
* Arrange
*/
testServer.license.enable('feat:variables');
const variablePayload = { key: 'key', value: 'value' };
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(owner)
.post('/variables')
.send(variablePayload);
/**
* Assert
*/
expect(response.status).toBe(201);
await expect(getVariableByIdOrFail(response.body.id)).resolves.toEqual(
expect.objectContaining(variablePayload),
);
});
it('if licensed, should create a variable linked to a project', async () => {
/**
* Arrange
*/
testServer.license.enable('feat:variables');
const variablePayload = { key: 'key', value: 'value', projectId: project.id };
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(owner)
.post('/variables')
.send(variablePayload);
/**
* Assert
*/
expect(response.status).toBe(201);
await expect(getVariableByIdOrFail(response.body.id)).resolves.toEqual(
expect.objectContaining({
key: 'key',
value: 'value',
project: expect.objectContaining({ id: project.id }),
}),
);
});
it('if not licensed, should reject', async () => {
/**
* Arrange
*/
const variablePayload = { key: 'key', value: 'value' };
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(owner)
.post('/variables')
.send(variablePayload);
/**
* Assert
*/
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message', licenseErrorMessage);
});
});
describe('PUT /variables/:id', () => {
const variablePayload = { key: 'updatedKey', value: 'updatedValue' };
let variable: Variables;
beforeEach(async () => {
variable = await createVariable();
});
it('if licensed, should update a variable', async () => {
testServer.license.enable('feat:variables');
const response = await testServer
.publicApiAgentFor(owner)
.put(`/variables/${variable.id}`)
.send(variablePayload);
expect(response.status).toBe(204);
const updatedVariable = await getVariableByIdOrFail(variable.id);
expect(updatedVariable).toEqual(expect.objectContaining(variablePayload));
});
it('if licensed, should update a variable to link it to a project', async () => {
testServer.license.enable('feat:variables');
const response = await testServer
.publicApiAgentFor(owner)
.put(`/variables/${variable.id}`)
.send({ ...variablePayload, projectId: project.id });
expect(response.status).toBe(204);
const updatedVariable = await getVariableByIdOrFail(variable.id);
expect(updatedVariable).toEqual(
expect.objectContaining({
...variablePayload,
project: expect.objectContaining({ id: project.id }),
}),
);
});
it('if not licensed, should reject', async () => {
const response = await testServer
.publicApiAgentFor(owner)
.put(`/variables/${variable.id}`)
.send(variablePayload);
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message', licenseErrorMessage);
});
});
describe('DELETE /variables/:id', () => {
let variable: Variables;
beforeEach(async () => {
variable = await createVariable();
});
it('if licensed, should delete a variable', async () => {
/**
* Arrange
*/
testServer.license.enable('feat:variables');
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(owner)
.delete(`/variables/${variable.id}`);
/**
* Assert
*/
expect(response.status).toBe(204);
await expect(getVariableByIdOrFail(variable.id)).rejects.toThrow();
});
it('if not licensed, should reject', async () => {
/**
* Act
*/
const response = await testServer
.publicApiAgentFor(owner)
.delete(`/variables/${variable.id}`);
/**
* Assert
*/
expect(response.status).toBe(403);
expect(response.body).toHaveProperty('message', licenseErrorMessage);
});
});
});
File diff suppressed because it is too large Load Diff