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
@@ -0,0 +1,28 @@
import { GlobalConfig } from '@n8n/config';
import { Container } from '@n8n/di';
export const REST_PATH_SEGMENT = Container.get(GlobalConfig).endpoints.rest;
export const PUBLIC_API_REST_PATH_SEGMENT = Container.get(GlobalConfig).publicApi.path;
export const SUCCESS_RESPONSE_BODY = {
data: {
success: true,
},
} as const;
export const LOGGED_OUT_RESPONSE_BODY = {
data: {
loggedOut: true,
},
};
export const COMMUNITY_PACKAGE_VERSION = {
CURRENT: '0.1.0',
UPDATED: '0.2.0',
};
export const COMMUNITY_NODE_VERSION = {
CURRENT: 1,
UPDATED: 2,
};
@@ -0,0 +1,173 @@
import type { CredentialPayload } from '@n8n/backend-test-utils';
import type { Project, User, ICredentialsDb } from '@n8n/db';
import {
CredentialsEntity,
CredentialsRepository,
ProjectRepository,
SharedCredentialsRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import type { CredentialSharingRole } from '@n8n/permissions';
export async function encryptCredentialData(
credential: CredentialsEntity,
): Promise<ICredentialsDb> {
const { createCredentialsFromCredentialsEntity } = await import('@/credentials-helper');
const coreCredential = createCredentialsFromCredentialsEntity(credential, true);
// @ts-ignore
coreCredential.setData(credential.data);
return Object.assign(credential, coreCredential.getDataToSave());
}
export async function decryptCredentialData(credential: ICredentialsDb): Promise<unknown> {
const { createCredentialsFromCredentialsEntity } = await import('@/credentials-helper');
const coreCredential = createCredentialsFromCredentialsEntity(credential);
return coreCredential.getData();
}
const emptyAttributes = {
name: 'test',
type: 'test',
data: '',
};
export async function createManyCredentials(
amount: number,
attributes: Partial<CredentialsEntity> = emptyAttributes,
) {
return await Promise.all(
Array(amount)
.fill(0)
.map(async () => await createCredentials(attributes)),
);
}
export async function createCredentials(
attributes: Partial<CredentialsEntity> = emptyAttributes,
project?: Project,
) {
const credentialsRepository = Container.get(CredentialsRepository);
const credentials = await credentialsRepository.save(credentialsRepository.create(attributes));
if (project) {
await Container.get(SharedCredentialsRepository).save(
Container.get(SharedCredentialsRepository).create({
project,
credentials,
role: 'credential:owner',
}),
);
}
return credentials;
}
/**
* Save a credential to the test DB, sharing it with a user.
*/
export async function saveCredential(
credentialPayload: CredentialPayload,
options:
| { user: User; role: CredentialSharingRole }
| {
project: Project;
role: CredentialSharingRole;
},
) {
const role = options.role;
const newCredential = new CredentialsEntity();
Object.assign(newCredential, credentialPayload);
await encryptCredentialData(newCredential);
const savedCredential = await Container.get(CredentialsRepository).save(newCredential);
savedCredential.data = newCredential.data;
if ('user' in options) {
const user = options.user;
const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
user.id,
);
await Container.get(SharedCredentialsRepository).save({
user,
credentials: savedCredential,
role,
project: personalProject,
});
} else {
const project = options.project;
await Container.get(SharedCredentialsRepository).save({
credentials: savedCredential,
role,
project,
});
}
return savedCredential;
}
export async function shareCredentialWithUsers(credential: CredentialsEntity, users: User[]) {
const newSharedCredentials = await Promise.all(
users.map(async (user) => {
const personalProject = await Container.get(
ProjectRepository,
).getPersonalProjectForUserOrFail(user.id);
return Container.get(SharedCredentialsRepository).create({
credentialsId: credential.id,
role: 'credential:user',
projectId: personalProject.id,
});
}),
);
return await Container.get(SharedCredentialsRepository).save(newSharedCredentials);
}
export async function shareCredentialWithProjects(
credential: CredentialsEntity,
projects: Project[],
) {
const newSharedCredentials = await Promise.all(
projects.map(async (project) => {
return Container.get(SharedCredentialsRepository).create({
credentialsId: credential.id,
role: 'credential:user',
projectId: project.id,
});
}),
);
return await Container.get(SharedCredentialsRepository).save(newSharedCredentials);
}
export function affixRoleToSaveCredential(role: CredentialSharingRole) {
return async (
credentialPayload: CredentialPayload,
options: { user: User } | { project: Project },
) => await saveCredential(credentialPayload, { ...options, role });
}
export async function getAllCredentials() {
return await Container.get(CredentialsRepository).find();
}
export const getCredentialById = async (id: string) =>
await Container.get(CredentialsRepository).findOneBy({ id });
export async function getAllSharedCredentials() {
return await Container.get(SharedCredentialsRepository).find();
}
export async function getCredentialSharings(credential: CredentialsEntity) {
return await Container.get(SharedCredentialsRepository).findBy({
credentialsId: credential.id,
});
}
@@ -0,0 +1,43 @@
import type { CreateDataTableColumnDto } from '@n8n/api-types';
import { randomName } from '@n8n/backend-test-utils';
import type { Project } from '@n8n/db';
import { Container } from '@n8n/di';
import type { DataTableRows } from 'n8n-workflow';
import { DataTableColumnRepository } from '@/modules/data-table/data-table-column.repository';
import { DataTableRowsRepository } from '@/modules/data-table/data-table-rows.repository';
import { DataTableRepository } from '@/modules/data-table/data-table.repository';
export const createDataTable = async (
project: Project,
options: {
name?: string;
columns?: CreateDataTableColumnDto[];
data?: DataTableRows;
updatedAt?: Date;
} = {},
) => {
const dataTableRepository = Container.get(DataTableRepository);
const dataTable = await dataTableRepository.createDataTable(
project.id,
options.name ?? randomName(),
options.columns ?? [],
);
if (options.updatedAt) {
await dataTableRepository.update(dataTable.id, {
updatedAt: options.updatedAt,
});
dataTable.updatedAt = options.updatedAt;
}
if (options.data) {
const dataTableColumnRepository = Container.get(DataTableColumnRepository);
const columns = await dataTableColumnRepository.getColumns(dataTable.id);
const dataTableRowsRepository = Container.get(DataTableRowsRepository);
await dataTableRowsRepository.insertRows(dataTable.id, options.data, columns, 'count');
}
return dataTable;
};
@@ -0,0 +1,71 @@
import { TestRunRepository, TestCaseExecutionRepository } from '@n8n/db';
import type {
TestRun,
TestCaseExecution,
AggregatedTestRunMetrics,
TestCaseExecutionErrorCode,
TestRunErrorCode,
} from '@n8n/db';
import { Container } from '@n8n/di';
import type { IDataObject } from 'n8n-workflow';
/**
* Creates a test run for a workflow
*/
export const createTestRun = async (
workflowId: string,
options: {
status?: TestRun['status'];
runAt?: Date | null;
completedAt?: Date | null;
metrics?: AggregatedTestRunMetrics;
errorCode?: TestRunErrorCode;
errorDetails?: IDataObject;
} = {},
) => {
const testRunRepository = Container.get(TestRunRepository);
const testRun = testRunRepository.create({
workflow: { id: workflowId },
status: options.status ?? 'new',
runAt: options.runAt ?? null,
completedAt: options.completedAt ?? null,
metrics: options.metrics ?? {},
errorCode: options.errorCode,
errorDetails: options.errorDetails,
});
return await testRunRepository.save(testRun);
};
/**
* Creates a test case execution for a test run
*/
export const createTestCaseExecution = async (
testRunId: string,
options: {
status?: TestCaseExecution['status'];
runAt?: Date | null;
completedAt?: Date | null;
metrics?: Record<string, number>;
errorCode?: TestCaseExecutionErrorCode;
errorDetails?: IDataObject;
executionId?: string;
pastExecutionId?: string;
} = {},
) => {
const testCaseExecutionRepository = Container.get(TestCaseExecutionRepository);
const testCaseExecution = testCaseExecutionRepository.create({
testRun: { id: testRunId },
status: options.status ?? 'success',
runAt: options.runAt ?? null,
completedAt: options.completedAt ?? null,
metrics: options.metrics ?? {},
errorCode: options.errorCode,
errorDetails: options.errorDetails,
executionId: options.executionId,
});
return await testCaseExecutionRepository.save(testCaseExecution);
};
@@ -0,0 +1,136 @@
import { mockInstance } from '@n8n/backend-test-utils';
import type { ExecutionEntity, ExecutionData } from '@n8n/db';
import {
ExecutionDataRepository,
ExecutionMetadataRepository,
ExecutionRepository,
AnnotationTagRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import type { AnnotationVote, ExecutionStatus, IWorkflowBase } from 'n8n-workflow';
import { ExecutionService } from '@/executions/execution.service';
import { Telemetry } from '@/telemetry';
mockInstance(Telemetry);
export async function createManyExecutions(
amount: number,
workflow: IWorkflowBase,
callback: (workflow: IWorkflowBase) => Promise<ExecutionEntity>,
) {
const executionsRequests = [...Array(amount)].map(async (_) => await callback(workflow));
return await Promise.all(executionsRequests);
}
/**
* Store a execution in the DB and assign it to a workflow.
*/
export async function createExecution(
attributes: Partial<
Omit<ExecutionEntity, 'metadata'> &
ExecutionData & { metadata: Array<{ key: string; value: string }> }
>,
workflow: IWorkflowBase,
) {
const {
data,
finished,
mode,
startedAt,
stoppedAt,
waitTill,
status,
deletedAt,
metadata,
createdAt,
} = attributes;
const execution = await Container.get(ExecutionRepository).save({
finished: finished ?? true,
mode: mode ?? 'manual',
createdAt: createdAt ?? new Date(),
startedAt: startedAt === undefined ? new Date() : startedAt,
...(workflow !== undefined && { workflowId: workflow.id }),
stoppedAt: stoppedAt ?? new Date(),
waitTill: waitTill ?? null,
status: status ?? 'success',
deletedAt,
});
if (metadata?.length) {
const metadataToSave = metadata.map(({ key, value }) => ({
key,
value,
execution: { id: execution.id },
}));
await Container.get(ExecutionMetadataRepository).save(metadataToSave);
}
await Container.get(ExecutionDataRepository).save({
data: data ?? '[]',
workflowData: workflow ?? {},
executionId: execution.id,
});
return execution;
}
/**
* Store a successful execution in the DB and assign it to a workflow.
*/
export async function createSuccessfulExecution(workflow: IWorkflowBase) {
return await createExecution({ finished: true, status: 'success' }, workflow);
}
/**
* Store an error execution in the DB and assign it to a workflow.
*/
export async function createErrorExecution(workflow: IWorkflowBase) {
return await createExecution(
{ finished: false, stoppedAt: new Date(), status: 'error' },
workflow,
);
}
/**
* Store a waiting execution in the DB and assign it to a workflow.
*/
export async function createWaitingExecution(workflow: IWorkflowBase) {
return await createExecution(
{ finished: false, waitTill: new Date(), status: 'waiting' },
workflow,
);
}
/**
* Store an execution with a given status in the DB and assign it to a workflow.
*/
export async function createdExecutionWithStatus(workflow: IWorkflowBase, status: ExecutionStatus) {
const execution: Partial<ExecutionEntity> = {
status,
finished: status === 'success' ? true : false,
stoppedAt: ['crashed', 'error'].includes(status) ? new Date() : undefined,
waitTill: status === 'waiting' ? new Date() : undefined,
};
return await createExecution(execution, workflow);
}
export async function annotateExecution(
executionId: string,
annotation: { vote?: AnnotationVote | null; tags?: string[] },
sharedWorkflowIds: string[],
) {
await Container.get(ExecutionService).annotate(executionId, annotation, sharedWorkflowIds);
}
export async function getAllExecutions() {
return await Container.get(ExecutionRepository).find();
}
export async function createAnnotationTags(annotationTags: string[]) {
const tagRepository = Container.get(AnnotationTagRepository);
return await tagRepository.save(annotationTags.map((name) => tagRepository.create({ name })));
}
@@ -0,0 +1,29 @@
import { randomName } from '@n8n/backend-test-utils';
import type { Folder, Project, TagEntity } from '@n8n/db';
import { FolderRepository } from '@n8n/db';
import { Container } from '@n8n/di';
export const createFolder = async (
project: Project,
options: {
name?: string;
parentFolder?: Folder;
tags?: TagEntity[];
updatedAt?: Date;
createdAt?: Date;
} = {},
) => {
const folderRepository = Container.get(FolderRepository);
const folder = await folderRepository.save(
folderRepository.create({
name: options.name ?? randomName(),
homeProject: project,
parentFolder: options.parentFolder ?? null,
tags: options.tags ?? [],
updatedAt: options.updatedAt ?? new Date(),
createdAt: options.updatedAt ?? new Date(),
}),
);
return folder;
};
@@ -0,0 +1,185 @@
import { RoleCacheService } from '@/services/role-cache.service';
import { Role, RoleRepository, Scope, ScopeRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import type { Scope as ScopeType } from '@n8n/permissions';
/**
* Creates a test role with given parameters
*/
export async function createRole(overrides: Partial<Role> = {}): Promise<Role> {
const roleRepository = Container.get(RoleRepository);
const roleCacheService = Container.get(RoleCacheService);
const defaultRole: Partial<Role> = {
slug: `test-role-${Math.random().toString(36).substring(7)}`,
displayName: `Test Role ${Math.random().toString(36).substring(7)}`,
description: 'A test role for integration testing',
systemRole: false,
roleType: 'project',
scopes: [],
};
const roleData = { ...defaultRole, ...overrides };
const role = Object.assign(new Role(), roleData);
const createdRole = await roleRepository.save(role);
// Force refresh the role cache to include the newly created role (important when running concurrent tests)
await roleCacheService.refreshCache();
return createdRole;
}
/**
* Creates a system role (cannot be deleted/modified)
*/
export async function createSystemRole(overrides: Partial<Role> = {}): Promise<Role> {
return await createRole({
systemRole: true,
slug: `system-role-${Math.random().toString(36).substring(7)}`,
displayName: 'System Test Role',
...overrides,
});
}
/**
* Creates a custom role with specific scopes
*/
export async function createCustomRoleWithScopes(
scopes: Scope[],
overrides: Partial<Role> = {},
): Promise<Role> {
return await createRole({
scopes,
systemRole: false,
...overrides,
});
}
/**
* Creates a custom role with specific scope slugs (using existing permission system scopes)
*/
export async function createCustomRoleWithScopeSlugs(
scopeSlugs: string[],
overrides: Partial<Role> = {},
): Promise<Role> {
const scopeRepository = Container.get(ScopeRepository);
// Find existing scopes by their slugs
const scopes = await scopeRepository.findByList(scopeSlugs);
if (scopes.length !== scopeSlugs.length) {
const missingScopes = scopeSlugs.filter((slug) => !scopes.some((scope) => scope.slug === slug));
throw new Error(
`Could not find all scopes. Expected ${scopeSlugs.length}, found ${scopes.length}, missing: ${missingScopes.join(', ')}`,
);
}
return await createRole({
scopes,
systemRole: false,
...overrides,
});
}
/**
* Creates a test scope with given parameters
*/
export async function createScope(overrides: Partial<Scope> = {}): Promise<Scope> {
const scopeRepository = Container.get(ScopeRepository);
const defaultScope: Partial<Scope> = {
slug: `test:scope:${Math.random().toString(36).substring(7)}` as ScopeType,
displayName: 'Test Scope',
description: 'A test scope for integration testing',
};
const scopeData = { ...defaultScope, ...overrides };
const scope = Object.assign(new Scope(), scopeData);
return await scopeRepository.save(scope);
}
/**
* Creates multiple test scopes
*/
export async function createScopes(
count: number,
baseOverrides: Partial<Scope> = {},
): Promise<Scope[]> {
const scopes: Scope[] = [];
for (let i = 0; i < count; i++) {
const scope = await createScope({
slug: `test:scope:${i}:${Math.random().toString(36).substring(7)}` as ScopeType,
displayName: `Test Scope ${i}`,
...baseOverrides,
});
scopes.push(scope);
}
return scopes;
}
/**
* Creates predefined test scopes for common test scenarios
*/
export async function createTestScopes(): Promise<{
readScope: Scope;
writeScope: Scope;
deleteScope: Scope;
adminScope: Scope;
}> {
const [readScope, writeScope, deleteScope, adminScope] = await Promise.all([
createScope({
slug: 'test:read' as ScopeType,
displayName: 'Test Read',
description: 'Test read access',
}),
createScope({
slug: 'test:write' as ScopeType,
displayName: 'Test Write',
description: 'Test write access',
}),
createScope({
slug: 'test:delete' as ScopeType,
displayName: 'Test Delete',
description: 'Test delete access',
}),
createScope({
slug: 'test:admin' as ScopeType,
displayName: 'Test Admin',
description: 'Test admin access',
}),
]);
return { readScope, writeScope, deleteScope, adminScope };
}
/**
* Cleans up test roles and scopes
*/
export async function cleanupRolesAndScopes(): Promise<void> {
const roleRepository = Container.get(RoleRepository);
const scopeRepository = Container.get(ScopeRepository);
// Delete test roles (excluding system roles for safety)
const testRoles = await roleRepository
.createQueryBuilder('role')
.where('role.slug LIKE :testPattern', { testPattern: 'test-role-%' })
.orWhere('role.slug LIKE :systemPattern', { systemPattern: 'system-role-%' })
.getMany();
for (const role of testRoles) {
await roleRepository.delete({ slug: role.slug });
}
// Delete test scopes
const testScopes = await scopeRepository
.createQueryBuilder('scope')
.where('scope.slug LIKE :pattern', { pattern: 'test:%' })
.getMany();
for (const scope of testScopes) {
await scopeRepository.delete({ slug: scope.slug });
}
}
@@ -0,0 +1,53 @@
import { randomName } from '@n8n/backend-test-utils';
import type { TagEntity, WorkflowEntity } from '@n8n/db';
import { generateNanoId, TagRepository, WorkflowTagMappingRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import type { IWorkflowBase } from 'n8n-workflow';
export async function createTag(attributes: Partial<TagEntity> = {}, workflow?: IWorkflowBase) {
const { name } = attributes;
const tag = await Container.get(TagRepository).save({
id: generateNanoId(),
name: name ?? randomName(),
...attributes,
});
if (workflow) {
const mappingRepository = Container.get(WorkflowTagMappingRepository);
const mapping = mappingRepository.create({ tagId: tag.id, workflowId: workflow.id });
await mappingRepository.save(mapping);
}
return tag;
}
export async function updateTag(tag: TagEntity, attributes: Partial<TagEntity>) {
const tagRepository = Container.get(TagRepository);
const updatedTag = tagRepository.merge(tag, attributes);
return await tagRepository.save(updatedTag);
}
export async function assignTagToWorkflow(tag: TagEntity, workflow: WorkflowEntity) {
const mappingRepository = Container.get(WorkflowTagMappingRepository);
// Check if mapping already exists
const existingMapping = await mappingRepository.findOne({
where: {
tagId: tag.id,
workflowId: workflow.id,
},
});
if (existingMapping) {
return existingMapping;
}
// Create new mapping
const mapping = mappingRepository.create({
tagId: tag.id,
workflowId: workflow.id,
});
return await mappingRepository.save(mapping);
}
@@ -0,0 +1,214 @@
import { randomEmail, randomName, randomValidPassword } from '@n8n/backend-test-utils';
import {
AuthIdentity,
AuthIdentityRepository,
GLOBAL_ADMIN_ROLE,
GLOBAL_CHAT_USER_ROLE,
GLOBAL_MEMBER_ROLE,
GLOBAL_OWNER_ROLE,
type Role,
UserRepository,
} from '@n8n/db';
import { type User } from '@n8n/db';
import { Container } from '@n8n/di';
import type { ApiKeyScope } from '@n8n/permissions';
import { getApiKeyScopesForRole } from '@n8n/permissions';
import { hash } from 'bcryptjs';
import { MfaService } from '@/mfa/mfa.service';
import { TOTPService } from '@/mfa/totp.service';
import { PublicApiKeyService } from '@/services/public-api-key.service';
import type { DeepPartial } from '@n8n/typeorm';
type ApiKeyOptions = {
expiresAt?: number | null;
scopes?: ApiKeyScope[];
};
// pre-computed bcrypt hash for the string 'password', using `await hash('password', 10)`
const passwordHash = '$2a$10$njedH7S6V5898mj6p0Jr..IGY9Ms.qNwR7RbSzzX9yubJocKfvGGK';
// A null password value means that no password will be set in the database
// rendering the user as pending, an undefined value means we default
// to 'password' as password.
// Also we are hashing the plaintext password here if necessary
async function handlePasswordSetup(password: string | null | undefined): Promise<string | null> {
if (password === undefined) {
return passwordHash;
} else if (password === null) {
return null;
}
return await hash(password, 1);
}
/** Store a new user object, defaulting to a `member` */
export async function newUser(attributes: DeepPartial<User> = {}): Promise<User> {
const { email, password, firstName, lastName, role, lastActiveAt, ...rest } = attributes;
return Container.get(UserRepository).create({
email: email ?? randomEmail(),
password: await handlePasswordSetup(password),
firstName: firstName ?? randomName(),
lastName: lastName ?? randomName(),
role: role ?? GLOBAL_MEMBER_ROLE,
lastActiveAt: lastActiveAt ?? new Date(),
...rest,
});
}
/** Store a user object in the DB */
export async function createUser(attributes: DeepPartial<User> = {}): Promise<User> {
const userInstance = await newUser(attributes);
const { user } = await Container.get(UserRepository).createUserWithProject(userInstance);
return user;
}
export async function createLdapUser(attributes: DeepPartial<User>, ldapId: string): Promise<User> {
const user = await createUser(attributes);
await Container.get(AuthIdentityRepository).save(AuthIdentity.create(user, ldapId, 'ldap'));
return user;
}
export async function createUserWithMfaEnabled(
data: { numberOfRecoveryCodes: number } = { numberOfRecoveryCodes: 10 },
) {
const email = randomEmail();
const password = randomValidPassword();
const toptService = new TOTPService();
const secret = toptService.generateSecret();
const mfaService = Container.get(MfaService);
const recoveryCodes = mfaService.generateRecoveryCodes(data.numberOfRecoveryCodes);
const { encryptedSecret, encryptedRecoveryCodes } = mfaService.encryptSecretAndRecoveryCodes(
secret,
recoveryCodes,
);
const user = await createUser({
mfaEnabled: true,
password,
email,
});
await Container.get(UserRepository).update(user.id, {
mfaSecret: encryptedSecret,
mfaRecoveryCodes: encryptedRecoveryCodes,
});
user.mfaSecret = encryptedSecret;
user.mfaRecoveryCodes = encryptedRecoveryCodes;
return {
user,
rawPassword: password,
rawSecret: secret,
rawRecoveryCodes: recoveryCodes,
};
}
export const addApiKey = async (
user: User,
{ expiresAt = null, scopes = [] }: { expiresAt?: number | null; scopes?: ApiKeyScope[] } = {},
) => {
return await Container.get(PublicApiKeyService).createPublicApiKeyForUser(user, {
label: randomName(),
expiresAt,
scopes: scopes.length ? scopes : getApiKeyScopesForRole(user),
});
};
export async function createOwnerWithApiKey({ expiresAt = null, scopes = [] }: ApiKeyOptions = {}) {
const owner = await createOwner();
const apiKey = await addApiKey(owner, { expiresAt, scopes });
owner.apiKeys = [apiKey];
return owner;
}
export async function createMemberWithApiKey({
expiresAt = null,
scopes = [],
}: ApiKeyOptions = {}) {
const member = await createMember();
const apiKey = await addApiKey(member, { expiresAt, scopes });
member.apiKeys = [apiKey];
return member;
}
export async function createAdminWithApiKey({ expiresAt = null, scopes = [] }: ApiKeyOptions = {}) {
const member = await createAdmin();
const apiKey = await addApiKey(member, { expiresAt, scopes });
member.apiKeys = [apiKey];
return member;
}
export async function createOwner() {
return await createUser({ role: GLOBAL_OWNER_ROLE });
}
export async function createMember() {
return await createUser({ role: GLOBAL_MEMBER_ROLE });
}
export async function createAdmin() {
return await createUser({ role: GLOBAL_ADMIN_ROLE });
}
export async function createChatUser() {
return await createUser({ role: GLOBAL_CHAT_USER_ROLE });
}
export async function createUserShell(role: Role): Promise<User> {
const shell: DeepPartial<User> = { role };
if (role.slug !== GLOBAL_OWNER_ROLE.slug) {
shell.email = randomEmail();
}
const { user } = await Container.get(UserRepository).createUserWithProject(shell);
return user;
}
/**
* Create many users in the DB, defaulting to a `member`.
*/
export async function createManyUsers(
amount: number,
attributes: DeepPartial<User> = {},
): Promise<User[]> {
const result = await Promise.all(
Array(amount)
.fill(0)
.map(async () => {
const userInstance = await newUser(attributes);
return await Container.get(UserRepository).createUserWithProject(userInstance);
}),
);
return result.map((result) => result.user);
}
export const getAllUsers = async () =>
await Container.get(UserRepository).find({
relations: ['authIdentities', 'role'],
});
export const getUserById = async (id: string) =>
await Container.get(UserRepository).findOneOrFail({
where: { id },
relations: ['authIdentities', 'role'],
});
export const getLdapIdentities = async () =>
await Container.get(AuthIdentityRepository).find({
where: { providerType: 'ldap' },
relations: { user: true },
});
export async function getGlobalOwner() {
return await Container.get(UserRepository).findOneOrFail({
where: { role: { slug: GLOBAL_OWNER_ROLE.slug } },
relations: ['role'],
});
}
@@ -0,0 +1,55 @@
import type { Project } from '@n8n/db';
import { generateNanoId, VariablesRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { randomString } from 'n8n-workflow';
import { VariablesService } from '@/environments.ee/variables/variables.service.ee';
export async function createVariable(key = randomString(5), value = randomString(5)) {
const result = await Container.get(VariablesRepository).save({
id: generateNanoId(),
key,
value,
});
await Container.get(VariablesService).updateCache();
return result;
}
export async function createProjectVariable(
key = randomString(5),
value = randomString(5),
project: Project,
) {
const result = await Container.get(VariablesRepository).save({
id: generateNanoId(),
key,
value,
project,
});
await Container.get(VariablesService).updateCache();
return result;
}
export async function getVariableByIdOrFail(id: string) {
return await Container.get(VariablesRepository).findOneOrFail({
where: { id },
relations: ['project'],
});
}
export async function getVariableByKey(key: string) {
return await Container.get(VariablesRepository).findOne({
where: {
key,
},
});
}
export async function getVariableById(id: string) {
return await Container.get(VariablesRepository).findOne({
where: {
id,
},
});
}
@@ -0,0 +1,46 @@
import type { WorkflowHistory } from '@n8n/db';
import { WorkflowHistoryRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { v4 as uuid } from 'uuid';
export async function createWorkflowHistoryItem(
workflowId: string,
data?: Partial<WorkflowHistory>,
) {
return await Container.get(WorkflowHistoryRepository).save({
authors: 'John Smith',
connections: {},
nodes: [
{
id: 'uuid-1234',
name: 'Start',
parameters: {},
position: [-20, 260],
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
},
],
versionId: uuid(),
workflowPublishHistory: [],
autosaved: false,
...(data ?? {}),
workflowId,
});
}
export async function createManyWorkflowHistoryItems(
workflowId: string,
count: number,
time?: Date,
) {
const baseTime = (time ?? new Date()).valueOf();
return await Promise.all(
[...Array(count)].map(
async (_, i) =>
await createWorkflowHistoryItem(workflowId, {
createdAt: new Date(baseTime + i),
updatedAt: new Date(baseTime + i),
}),
),
);
}
@@ -0,0 +1,32 @@
import type { WorkflowHistory, WorkflowPublishHistory } from '@n8n/db';
import { WorkflowPublishHistoryRepository } from '@n8n/db';
import { Container } from '@n8n/di';
export async function createWorkflowPublishHistoryItem(
workflowHistory: Pick<WorkflowHistory, 'workflowId' | 'versionId'>,
data?: Partial<WorkflowPublishHistory>,
) {
return await Container.get(WorkflowPublishHistoryRepository).save({
workflowId: workflowHistory.workflowId,
versionId: workflowHistory.versionId,
event: 'activated',
userId: null,
...(data ?? {}),
});
}
export async function createManyWorkflowPublishHistoryItems(
workflowHistory: WorkflowHistory,
count: number,
time?: Date,
) {
const baseTime = (time ?? new Date()).valueOf();
return await Promise.all(
[...Array(count)].map(
async (_, i) =>
await createWorkflowPublishHistoryItem(workflowHistory, {
createdAt: new Date(baseTime + i),
}),
),
);
}
@@ -0,0 +1,21 @@
import { StatisticsNames, type WorkflowStatistics } from '@n8n/db';
import { WorkflowStatisticsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import type { Workflow } from 'n8n-workflow';
export async function createWorkflowStatisticsItem(
workflowId: Workflow['id'],
data?: Partial<WorkflowStatistics>,
) {
const entity = Container.get(WorkflowStatisticsRepository).create({
count: 0,
latestEvent: new Date().toISOString(),
name: StatisticsNames.manualSuccess,
...(data ?? {}),
workflowId,
});
await Container.get(WorkflowStatisticsRepository).insert(entity);
return entity;
}
@@ -0,0 +1,200 @@
/**
* Reusable validation helper functions for execution context assertions.
* These functions provide consistent and composable validation patterns
* for testing execution context propagation across workflows.
*/
import type { IExecutionContext } from 'n8n-workflow';
/**
* Validates the basic structure and required fields of an execution context.
*
* @param context - The execution context to validate
* @param expectedVersion - Expected context version (default: 1)
*/
export function validateBasicContextStructure(
context: IExecutionContext,
expectedVersion = 1,
): void {
expect(context).toBeDefined();
expect(context.version).toBe(expectedVersion);
expect(context.establishedAt).toBeDefined();
expect(typeof context.establishedAt).toBe('number');
expect(context.establishedAt).toBeGreaterThan(0);
}
/**
* Validates that a context has the expected source mode.
*
* @param context - The execution context to validate
* @param expectedSource - Expected execution source ('manual', 'trigger', 'integrated', 'internal')
*/
export function validateContextSource(context: IExecutionContext, expectedSource: string): void {
expect(context.source).toBeDefined();
expect(context.source).toBe(expectedSource);
}
/**
* Validates that a context is a root context (no parent execution).
* Root contexts are typically created by manual or scheduled executions.
*
* @param context - The execution context to validate
* @param expectedSource - Expected execution source for the root context
*/
export function validateRootContext(context: IExecutionContext, expectedSource: string): void {
validateBasicContextStructure(context);
validateContextSource(context, expectedSource);
expect(context.parentExecutionId).toBeUndefined();
}
/**
* Validates that a context is a child context with a parent execution ID.
*
* @param context - The child execution context to validate
* @param expectedParentExecutionId - Expected parent execution ID
*/
export function validateChildContextParentage(
context: IExecutionContext,
expectedParentExecutionId: string,
): void {
expect(context).toBeDefined();
expect(context.parentExecutionId).toBe(expectedParentExecutionId);
}
/**
* Validates that a child context properly inherits credentials from its parent.
*
* @param childContext - The child execution context
* @param parentContext - The parent execution context
*/
export function validateCredentialInheritance(
childContext: IExecutionContext,
parentContext: IExecutionContext,
): void {
if (parentContext.credentials) {
expect(childContext.credentials).toBe(parentContext.credentials);
}
}
/**
* Validates that a child context has a fresh (equal or later) establishedAt timestamp
* compared to its parent context.
*
* @param childContext - The child execution context
* @param parentContext - The parent execution context
*/
export function validateFreshTimestamp(
childContext: IExecutionContext,
parentContext: IExecutionContext,
): void {
expect(childContext.establishedAt).toBeDefined();
expect(typeof childContext.establishedAt).toBe('number');
expect(childContext.establishedAt).toBeGreaterThanOrEqual(parentContext.establishedAt);
}
/**
* Validates that a child context has the same version as its parent.
*
* @param childContext - The child execution context
* @param parentContext - The parent execution context
*/
export function validateVersionInheritance(
childContext: IExecutionContext,
parentContext: IExecutionContext,
): void {
expect(childContext.version).toBe(parentContext.version);
}
/**
* Validates that a child context source is one of the expected sub-workflow sources.
*
* @param context - The execution context to validate
* @param allowedSources - Array of allowed source modes (default: ['trigger', 'integrated', 'internal'])
*/
export function validateSubWorkflowSource(
context: IExecutionContext,
allowedSources = ['trigger', 'integrated', 'internal'],
): void {
expect(context.source).toBeDefined();
expect(allowedSources).toContain(context.source);
}
/**
* Comprehensive validation that a child context properly inherits from its parent.
* This combines multiple validation patterns into a single function.
*
* @param childContext - The child execution context
* @param parentContext - The parent execution context
* @param parentExecutionId - The parent execution ID
*/
export function validateChildContextInheritance(
childContext: IExecutionContext,
parentContext: IExecutionContext,
parentExecutionId: string,
): void {
validateBasicContextStructure(childContext);
validateChildContextParentage(childContext, parentExecutionId);
validateCredentialInheritance(childContext, parentContext);
validateFreshTimestamp(childContext, parentContext);
validateVersionInheritance(childContext, parentContext);
validateSubWorkflowSource(childContext);
}
/**
* Validates a timestamp chain across multiple execution contexts.
* Ensures that each context in the chain has a timestamp greater than or equal
* to the previous context's timestamp.
*
* @param contexts - Array of execution contexts in chronological order
*/
export function validateTimestampChain(contexts: IExecutionContext[]): void {
for (let i = 0; i < contexts.length - 1; i++) {
expect(contexts[i].establishedAt).toBeLessThanOrEqual(contexts[i + 1].establishedAt);
}
}
/**
* Validates that all provided contexts have the same version number.
*
* @param contexts - Array of execution contexts to validate
*/
export function validateConsistentVersions(contexts: IExecutionContext[]): void {
const firstVersion = contexts[0].version;
for (const context of contexts) {
expect(context.version).toBe(firstVersion);
}
}
/**
* Validates a complete context inheritance chain from root to leaf.
* This validates that each child properly inherits from its parent,
* timestamps form a valid chain, and versions are consistent.
*
* @param contextChain - Array of objects containing context and parent execution ID
* First element should be the root (parentExecutionId can be undefined)
*/
export function validateContextInheritanceChain(
contextChain: Array<{
context: IExecutionContext;
parentExecutionId?: string;
}>,
): void {
// Validate root context
const root = contextChain[0];
validateBasicContextStructure(root.context);
expect(root.context.parentExecutionId).toBeUndefined();
// Validate each child in the chain
for (let i = 1; i < contextChain.length; i++) {
const parent = contextChain[i - 1];
const child = contextChain[i];
expect(child.parentExecutionId).toBeDefined();
validateChildContextInheritance(child.context, parent.context, child.parentExecutionId!);
}
// Validate timestamp chain
const contexts = contextChain.map((c) => c.context);
validateTimestampChain(contexts);
validateConsistentVersions(contexts);
}
@@ -0,0 +1,33 @@
import type { LdapConfig } from '@n8n/constants';
import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@n8n/constants';
import { SettingsRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { jsonParse } from 'n8n-workflow';
export const defaultLdapConfig = {
...LDAP_DEFAULT_CONFIGURATION,
loginEnabled: true,
loginLabel: '',
ldapIdAttribute: 'uid',
firstNameAttribute: 'givenName',
lastNameAttribute: 'sn',
emailAttribute: 'mail',
loginIdAttribute: 'mail',
baseDn: 'baseDn',
bindingAdminDn: 'adminDn',
bindingAdminPassword: 'adminPassword',
};
export const createLdapConfig = async (
attributes: Partial<LdapConfig> = {},
): Promise<LdapConfig> => {
const { value: ldapConfig } = await Container.get(SettingsRepository).save({
key: LDAP_FEATURE_NAME,
value: JSON.stringify({
...defaultLdapConfig,
...attributes,
}),
loadOnStartup: true,
});
return await jsonParse(ldapConfig);
};
@@ -0,0 +1,70 @@
import type { LicenseProvider, LicenseState } from '@n8n/backend-common';
import type { BooleanLicenseFeature, NumericLicenseFeature } from '@n8n/constants';
import type { License } from '@/license';
export interface LicenseMockDefaults {
features?: BooleanLicenseFeature[];
quotas?: Partial<{ [K in NumericLicenseFeature]: number }>;
}
export class LicenseMocker {
private _enabledFeatures: Set<BooleanLicenseFeature> = new Set();
private _defaultFeatures: Set<BooleanLicenseFeature> = new Set();
private _featureQuotas: Map<NumericLicenseFeature, number> = new Map();
private _defaultQuotas: Map<NumericLicenseFeature, number> = new Map();
mock(license: License) {
license.isLicensed = this.isFeatureEnabled.bind(this);
license.getValue = this.getFeatureValue.bind(this);
}
mockLicenseState(licenseState: LicenseState) {
const licenseProvider: LicenseProvider = {
isLicensed: this.isFeatureEnabled.bind(this),
getValue: this.getFeatureValue.bind(this),
};
licenseState.setLicenseProvider(licenseProvider);
}
reset() {
this._enabledFeatures = new Set(this._defaultFeatures);
this._featureQuotas = new Map(this._defaultQuotas);
}
setDefaults(defaults: LicenseMockDefaults) {
this._defaultFeatures = new Set(defaults.features ?? []);
this._defaultQuotas = new Map(
Object.entries(defaults.quotas ?? {}) as Array<[NumericLicenseFeature, number]>,
);
}
isFeatureEnabled(feature: BooleanLicenseFeature): boolean {
return this._enabledFeatures.has(feature);
}
getFeatureValue(feature: string): boolean | number | undefined {
if (this._featureQuotas.has(feature as NumericLicenseFeature)) {
return this._featureQuotas.get(feature as NumericLicenseFeature);
} else if (this._enabledFeatures.has(feature as BooleanLicenseFeature)) {
return true;
}
return undefined;
}
enable(feature: BooleanLicenseFeature) {
this._enabledFeatures.add(feature);
}
disable(feature: BooleanLicenseFeature) {
this._enabledFeatures.delete(feature);
}
setQuota(feature: NumericLicenseFeature, quota: number) {
this._featureQuotas.set(feature, quota);
}
}
@@ -0,0 +1,32 @@
/**
* Retries the given assertion until it passes or the timeout is reached
*
* @example
* await retryUntil(
* () => expect(service.someState).toBe(true)
* );
*/
export const retryUntil = async <T>(
assertion: () => Promise<T> | T,
{ intervalMs = 200, timeoutMs = 5000 } = {},
): Promise<T> => {
return await new Promise<T>((resolve, reject) => {
const startTime = Date.now();
const tryAgain = () => {
setTimeout(async () => {
try {
resolve(await assertion());
} catch (error) {
if (Date.now() - startTime > timeoutMs) {
reject(error);
} else {
tryAgain();
}
}
}, intervalMs);
};
tryAgain();
});
};
@@ -0,0 +1,88 @@
import type { CredentialPayload } from '@n8n/backend-test-utils';
import type { BooleanLicenseFeature, NumericLicenseFeature } from '@n8n/constants';
import type { CredentialsEntity, Project, User, ICredentialsDb } from '@n8n/db';
import type { Application } from 'express';
import type { Server } from 'http';
import type TestAgent from 'supertest/lib/agent';
import type { LicenseMocker } from './license';
type EndpointGroup =
| 'health'
| 'me'
| 'users'
| 'auth'
| 'oauth2'
| 'owner'
| 'passwordReset'
| 'credentials'
| 'workflows'
| 'publicApi'
| 'community-packages'
| 'ldap'
| 'saml'
| 'sourceControl'
| 'eventBus'
| 'license'
| 'variables'
| 'annotationTags'
| 'tags'
| 'externalSecrets'
| 'mfa'
| 'metrics'
| 'executions'
| 'workflowHistory'
| 'binaryData'
| 'invitations'
| 'debug'
| 'project'
| 'role'
| 'dynamic-node-parameters'
| 'apiKeys'
| 'evaluation'
| 'ai'
| 'folder'
| 'insights'
| 'module-settings'
| 'security-settings'
| 'data-table'
| 'third-party-licenses'
| 'mcp';
type ModuleName =
| 'insights'
| 'external-secrets'
| 'community-packages'
| 'data-table'
| 'mcp'
| 'dynamic-credentials'
| 'log-streaming'
| 'ldap'
| 'redaction'
| 'source-control';
export interface SetupProps {
endpointGroups?: EndpointGroup[];
enabledFeatures?: BooleanLicenseFeature[];
quotas?: Partial<{ [K in NumericLicenseFeature]: number }>;
modules?: ModuleName[];
}
export type SuperAgentTest = TestAgent;
export interface TestServer {
app: Application;
httpServer: Server;
authAgentFor: (user: User) => TestAgent;
publicApiAgentFor: (user: User) => TestAgent;
publicApiAgentWithApiKey: (apiKey: string) => TestAgent;
publicApiAgentWithoutApiKey: () => TestAgent;
authlessAgent: TestAgent;
restlessAgent: TestAgent;
license: LicenseMocker;
}
export type SaveCredentialFunction = (
credentialPayload: CredentialPayload,
options: { user: User } | { project: Project },
) => Promise<CredentialsEntity & ICredentialsDb>;
@@ -0,0 +1,48 @@
import { randomName } from '@n8n/backend-test-utils';
import { Container } from '@n8n/di';
import { NODE_PACKAGE_PREFIX } from '@/constants';
import { InstalledNodesRepository } from '@/modules/community-packages/installed-nodes.repository';
import { InstalledPackages } from '@/modules/community-packages/installed-packages.entity';
import { InstalledPackagesRepository } from '@/modules/community-packages/installed-packages.repository';
import { COMMUNITY_NODE_VERSION, COMMUNITY_PACKAGE_VERSION } from '../constants';
export const mockPackageName = () => NODE_PACKAGE_PREFIX + randomName();
export const mockPackage = () =>
Container.get(InstalledPackagesRepository).create({
packageName: mockPackageName(),
installedVersion: COMMUNITY_PACKAGE_VERSION.CURRENT,
installedNodes: [],
});
export const mockNode = (packageName: string) => {
const nodeName = randomName();
return Container.get(InstalledNodesRepository).create({
name: nodeName,
type: `${packageName}.${nodeName}`,
latestVersion: COMMUNITY_NODE_VERSION.CURRENT,
package: { packageName },
});
};
export const emptyPackage = async () => {
const installedPackage = new InstalledPackages();
installedPackage.installedNodes = [];
return installedPackage;
};
export function mockPackagePair(): InstalledPackages[] {
const pkgA = mockPackage();
const nodeA = mockNode(pkgA.packageName);
pkgA.installedNodes = [nodeA];
const pkgB = mockPackage();
const nodeB1 = mockNode(pkgB.packageName);
const nodeB2 = mockNode(pkgB.packageName);
pkgB.installedNodes = [nodeB1, nodeB2];
return [pkgA, pkgB];
}
@@ -0,0 +1,206 @@
import type { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { WorkflowEntity } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import {
BinaryDataConfig,
BinaryDataService,
InstanceSettings,
UnrecognizedNodeTypeError,
type DirectoryLoader,
type ErrorReporter,
} from 'n8n-core';
import { Ftp } from 'n8n-nodes-base/credentials/Ftp.credentials';
import { GithubApi } from 'n8n-nodes-base/credentials/GithubApi.credentials';
import { HttpBasicAuth } from 'n8n-nodes-base/credentials/HttpBasicAuth.credentials';
import { HttpHeaderAuth } from 'n8n-nodes-base/credentials/HttpHeaderAuth.credentials';
import { OpenAiApi } from 'n8n-nodes-base/credentials/OpenAiApi.credentials';
import { Cron } from 'n8n-nodes-base/nodes/Cron/Cron.node';
import { FormTrigger } from 'n8n-nodes-base/nodes/Form/FormTrigger.node';
import { ManualTrigger } from 'n8n-nodes-base/nodes/ManualTrigger/ManualTrigger.node';
import { ScheduleTrigger } from 'n8n-nodes-base/nodes/Schedule/ScheduleTrigger.node';
import { Set } from 'n8n-nodes-base/nodes/Set/Set.node';
import type { INodeTypeData, INode } from 'n8n-workflow';
import type request from 'supertest';
import { v4 as uuid } from 'uuid';
import { AUTH_COOKIE_NAME } from '@/constants';
import { ExecutionService } from '@/executions/execution.service';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { Push } from '@/push';
export { setupTestServer } from './test-server';
// ----------------------------------
// initializers
// ----------------------------------
/**
* Initialize node types.
*/
export async function initActiveWorkflowManager() {
mockInstance(BinaryDataConfig);
mockInstance(InstanceSettings, {
isMultiMain: false,
n8nFolder: '/tmp/n8n-test',
});
mockInstance(Push);
mockInstance(ExecutionService);
const { ActiveWorkflowManager } = await import('@/active-workflow-manager');
const activeWorkflowManager = Container.get(ActiveWorkflowManager);
await activeWorkflowManager.init();
return activeWorkflowManager;
}
/**
* Initialize node types.
*/
export async function initCredentialsTypes(): Promise<void> {
Container.get(LoadNodesAndCredentials).loaded.credentials = {
githubApi: {
type: new GithubApi(),
sourcePath: '',
},
ftp: {
type: new Ftp(),
sourcePath: '',
},
openAiApi: {
type: new OpenAiApi(),
sourcePath: '',
},
httpHeaderAuth: {
type: new HttpHeaderAuth(),
sourcePath: '',
},
httpBasicAuth: {
type: new HttpBasicAuth(),
sourcePath: '',
},
};
}
/**
* Initialize node types.
*/
export async function initNodeTypes(customNodes?: INodeTypeData) {
const defaultNodes: INodeTypeData = {
'n8n-nodes-base.manualTrigger': {
type: new ManualTrigger(),
sourcePath: '',
},
'n8n-nodes-base.cron': {
type: new Cron(),
sourcePath: '',
},
'n8n-nodes-base.set': {
type: new Set(),
sourcePath: '',
},
'n8n-nodes-base.scheduleTrigger': {
type: new ScheduleTrigger(),
sourcePath: '',
},
'n8n-nodes-base.formTrigger': {
type: new FormTrigger(),
sourcePath: '',
},
};
ScheduleTrigger.prototype.trigger = async () => ({});
const nodes = customNodes ?? defaultNodes;
const loader = mock<DirectoryLoader>();
loader.getNode.mockImplementation((nodeType) => {
const node = nodes[`n8n-nodes-base.${nodeType}`];
if (!node) throw new UnrecognizedNodeTypeError('n8n-nodes-base', nodeType);
return node;
});
const loadNodesAndCredentials = Container.get(LoadNodesAndCredentials);
loadNodesAndCredentials.loaders = { 'n8n-nodes-base': loader };
loadNodesAndCredentials.loaded.nodes = nodes;
}
/**
* Initialize a BinaryDataService for test runs.
*/
export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'default') {
const config = mock<BinaryDataConfig>({
mode,
availableModes: [mode],
localStoragePath: '',
});
const logger = mock<Logger>();
const errorReporter = mock<ErrorReporter>();
const binaryDataService = new BinaryDataService(config, errorReporter, logger);
await binaryDataService.init();
Container.set(BinaryDataService, binaryDataService);
}
/**
* Extract the value (token) of the auth cookie in a response.
*/
export function getAuthToken(response: request.Response, authCookieName = AUTH_COOKIE_NAME) {
const cookiesHeader = response.headers['set-cookie'];
if (!cookiesHeader) return undefined;
const cookies = Array.isArray(cookiesHeader) ? cookiesHeader : [cookiesHeader];
const authCookie = cookies.find((c) => c.startsWith(`${authCookieName}=`));
if (!authCookie) return undefined;
const match = authCookie.match(new RegExp(`(^| )${authCookieName}=(?<token>[^;]+)`));
if (!match?.groups) return undefined;
return match.groups.token;
}
// ----------------------------------
// community nodes
// ----------------------------------
export * from './community-nodes';
// ----------------------------------
// workflow
// ----------------------------------
export function makeWorkflow(options?: {
withPinData: boolean;
withCredential?: { id: string; name: string };
}) {
const workflow = new WorkflowEntity();
const node: INode = {
id: uuid(),
name: 'Cron',
type: 'n8n-nodes-base.cron',
parameters: {},
typeVersion: 1,
position: [740, 240],
};
if (options?.withCredential) {
node.credentials = {
spotifyApi: options.withCredential,
};
}
workflow.name = 'My Workflow';
workflow.active = false;
workflow.activeVersionId = null;
workflow.connections = {};
workflow.nodes = [node];
if (options?.withPinData) {
workflow.pinData = MOCK_PINDATA;
}
return workflow;
}
export const MOCK_PINDATA = { Spotify: [{ json: { myKey: 'myValue' } }] };
@@ -0,0 +1,33 @@
import type { INodeTypeData } from 'n8n-workflow';
export function mockNodeTypesData(
nodeNames: string[],
options?: {
addTrigger?: boolean;
},
) {
return nodeNames.reduce<INodeTypeData>((acc, nodeName) => {
const fullName = nodeName.indexOf('.') === -1 ? `n8n-nodes-base.${nodeName}` : nodeName;
return (
(acc[fullName] = {
sourcePath: '',
type: {
description: {
displayName: nodeName,
name: nodeName,
group: [],
description: '',
version: 1,
defaults: {},
inputs: [],
outputs: [],
properties: [],
},
trigger: options?.addTrigger ? async () => undefined : undefined,
},
}),
acc
);
}, {});
}
@@ -0,0 +1,39 @@
import { TaskRunnersConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import request from 'supertest';
import type TestAgent from 'supertest/lib/agent';
import { TaskBrokerServer } from '@/task-runners/task-broker/task-broker-server';
export interface TestTaskBrokerServer {
server: TaskBrokerServer;
agent: TestAgent;
config: TaskRunnersConfig;
}
/**
* Sets up a Task Broker Server for testing purposes. The server needs
* to be started and stopped manually.
*
* @example
* const { server, agent, config } = setupBrokerTestServer();
*
* beforeAll(async () => await server.start());
* afterAll(async () => await server.stop());
*/
export const setupBrokerTestServer = (
config: Partial<TaskRunnersConfig> = {},
): TestTaskBrokerServer => {
const runnerConfig = Container.get(TaskRunnersConfig);
Object.assign(runnerConfig, config);
runnerConfig.port = 0; // Use any port
const taskBrokerServer = Container.get(TaskBrokerServer);
const agent = request.agent(taskBrokerServer.app);
return {
server: taskBrokerServer,
agent,
config: runnerConfig,
};
};
@@ -0,0 +1,39 @@
import { testDb, mockInstance } from '@n8n/backend-test-utils';
import type { CommandClass } from '@n8n/decorators';
import argvParser from 'yargs-parser';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { TelemetryEventRelay } from '@/events/relays/telemetry.event-relay';
mockInstance(MessageEventBus);
export const setupTestCommand = <T extends CommandClass>(Command: T) => {
// mock SIGINT/SIGTERM registration
process.once = jest.fn();
process.exit = jest.fn() as never;
beforeAll(async () => {
await testDb.init();
});
beforeEach(() => {
jest.clearAllMocks();
mockInstance(TelemetryEventRelay);
});
afterAll(async () => {
await testDb.terminate();
jest.restoreAllMocks();
});
const run = async (argv: string[] = []) => {
const command = new Command();
command.flags = argvParser(argv);
await command.init?.();
await command.run();
return command;
};
return { run };
};
@@ -0,0 +1,353 @@
import { LicenseState, ModuleRegistry } from '@n8n/backend-common';
import { mockInstance, mockLogger, testModules, testDb } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { APIRequest, User } from '@n8n/db';
import { Container } from '@n8n/di';
import cookieParser from 'cookie-parser';
import express from 'express';
import type superagent from 'superagent';
import request from 'supertest';
import { URL } from 'url';
import { AuthHandlerRegistry } from '@/auth/auth-handler.registry';
import { AuthService } from '@/auth/auth.service';
import { AUTH_COOKIE_NAME } from '@/constants';
import { ControllerRegistry } from '@/controller.registry';
import { License } from '@/license';
import { rawBodyReader, bodyParser } from '@/middlewares';
import { PostHogClient } from '@/posthog';
import { Push } from '@/push';
import { Telemetry } from '@/telemetry';
import { resolveHealthEndpointPath } from '@/utils/health-endpoint.util';
import { LicenseMocker } from '@test-integration/license';
import { PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from '../constants';
import type { SetupProps, TestServer } from '../types';
/**
* Plugin to prefix a path segment into a request URL pathname.
*
* Example: http://127.0.0.1:62100/me/password → http://127.0.0.1:62100/rest/me/password
*/
function prefix(pathSegment: string) {
return async function (request: superagent.SuperAgentRequest) {
const url = new URL(request.url);
// enforce consistency at call sites
if (url.pathname[0] !== '/') {
throw new Error('Pathname must start with a forward slash');
}
url.pathname = pathSegment + url.pathname;
request.url = url.toString();
return await request;
};
}
const browserId = 'test-browser-id';
function createAgent(
app: express.Application,
options?: { auth: boolean; user?: User; noRest?: boolean },
) {
const agent = request.agent(app);
const withRestSegment = !options?.noRest;
if (withRestSegment) void agent.use(prefix(REST_PATH_SEGMENT));
if (options?.auth && options?.user) {
const token = Container.get(AuthService).issueJWT(
options.user,
options.user.mfaEnabled,
browserId,
);
agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
}
return agent;
}
const userDoesNotHaveApiKey = (user: User) => {
return !user.apiKeys || !Array.from(user.apiKeys) || user.apiKeys.length === 0;
};
const publicApiAgent = (
app: express.Application,
{ user, apiKey, version = 1 }: { user?: User; apiKey?: string; version?: number },
) => {
if (user && apiKey) {
throw new Error('Cannot provide both user and API key');
}
if (user && userDoesNotHaveApiKey(user)) {
throw new Error('User does not have an API key');
}
const agentApiKey = apiKey ?? user?.apiKeys[0].apiKey;
const agent = request.agent(app);
void agent.use(prefix(`${PUBLIC_API_REST_PATH_SEGMENT}/v${version}`));
if (!user && !apiKey) return agent;
void agent.set({ 'X-N8N-API-KEY': agentApiKey });
return agent;
};
export const setupTestServer = ({
endpointGroups,
enabledFeatures,
quotas,
modules,
}: SetupProps): TestServer => {
const app = express();
app.use(rawBodyReader);
app.use(cookieParser());
app.set('query parser', 'extended');
app.use((req: APIRequest, _, next) => {
req.browserId = browserId;
next();
});
// Mock all telemetry and logging
mockLogger();
mockInstance(PostHogClient);
mockInstance(Push);
mockInstance(Telemetry);
const testServer: TestServer = {
app,
httpServer: app.listen(0),
authAgentFor: (user: User) => createAgent(app, { auth: true, user }),
authlessAgent: createAgent(app),
restlessAgent: createAgent(app, { auth: false, noRest: true }),
publicApiAgentFor: (user) => publicApiAgent(app, { user }),
publicApiAgentWithApiKey: (apiKey) => publicApiAgent(app, { apiKey }),
publicApiAgentWithoutApiKey: () => publicApiAgent(app, {}),
license: new LicenseMocker(),
};
// eslint-disable-next-line complexity
beforeAll(async () => {
if (modules) await testModules.loadModules(modules);
await testDb.init();
Container.get(GlobalConfig).userManagement.jwtSecret = 'My JWT secret';
testServer.license.mock(Container.get(License));
testServer.license.mockLicenseState(Container.get(LicenseState));
if (enabledFeatures) {
testServer.license.setDefaults({
features: enabledFeatures,
quotas,
});
}
if (!endpointGroups) return;
app.use(bodyParser);
const enablePublicAPI = endpointGroups?.includes('publicApi');
if (enablePublicAPI) {
const { loadPublicApiVersions } = await import('@/public-api');
const { apiRouters } = await loadPublicApiVersions(PUBLIC_API_REST_PATH_SEGMENT);
app.use(...apiRouters);
}
if (endpointGroups?.includes('health')) {
const globalConfig = Container.get(GlobalConfig);
const healthPath = resolveHealthEndpointPath(globalConfig);
const readinessPath = `${healthPath}/readiness`;
app.get(readinessPath, async (_req, res) => {
testDb.isReady()
? res.status(200).send({ status: 'ok' })
: res.status(503).send({ status: 'error' });
});
}
if (endpointGroups.length) {
for (const group of endpointGroups) {
switch (group) {
case 'annotationTags':
await import('@/controllers/annotation-tags.controller.ee');
break;
case 'credentials':
await import('@/credentials/credentials.controller');
break;
case 'workflows':
await import('@/workflows/workflows.controller');
break;
case 'executions':
await import('@/executions/executions.controller');
break;
case 'variables':
await import('@/environments.ee/variables/variables.controller.ee');
break;
case 'license':
await import('@/license/license.controller');
break;
case 'metrics': {
const { PrometheusMetricsService } = await import(
'@/metrics/prometheus-metrics.service'
);
await Container.get(PrometheusMetricsService).init(app);
break;
}
case 'eventBus':
await import('@/modules/log-streaming.ee/log-streaming.controller');
break;
case 'auth':
await import('@/controllers/auth.controller');
break;
case 'oauth2':
await import('@/controllers/oauth/oauth2-credential.controller');
break;
case 'mfa':
await import('@/controllers/mfa.controller');
break;
case 'ldap': {
const { LdapService } = await import('@/modules/ldap.ee/ldap.service.ee');
await import('@/modules/ldap.ee/ldap.controller.ee');
testServer.license.enable('feat:ldap');
await Container.get(LdapService).init();
break;
}
case 'saml': {
const { SamlService } = await import('@/modules/sso-saml/saml.service.ee');
await Container.get(SamlService).init();
await import('@/modules/sso-saml/saml.controller.ee');
const { setSamlLoginEnabled } = await import('@/modules/sso-saml/saml-helpers');
await setSamlLoginEnabled(true);
break;
}
case 'sourceControl':
await import('@/modules/source-control.ee/source-control.controller.ee');
break;
case 'community-packages':
await import('@/modules/community-packages/community-packages.controller');
break;
case 'me':
await import('@/controllers/me.controller');
break;
case 'passwordReset':
await import('@/controllers/password-reset.controller');
break;
case 'owner':
await import('@/controllers/owner.controller');
break;
case 'users':
await import('@/controllers/users.controller');
break;
case 'invitations':
await import('@/controllers/invitation.controller');
break;
case 'tags':
await import('@/controllers/tags.controller');
break;
case 'workflowHistory':
await import('@/workflows/workflow-history/workflow-history.controller');
break;
case 'binaryData':
await import('@/controllers/binary-data.controller');
break;
case 'debug':
await import('@/controllers/debug.controller');
break;
case 'project':
await import('@/controllers/project.controller');
break;
case 'role':
await import('@/controllers/role.controller');
break;
case 'dynamic-node-parameters':
await import('@/controllers/dynamic-node-parameters.controller');
break;
case 'apiKeys':
await import('@/controllers/api-keys.controller');
break;
case 'evaluation':
await import('@/evaluation.ee/test-runs.controller.ee');
break;
case 'ai':
await import('@/controllers/ai.controller');
break;
case 'folder':
await import('@/controllers/folder.controller');
break;
case 'externalSecrets':
await import('@/modules/external-secrets.ee/external-secrets.module');
break;
case 'insights':
await import('@/modules/insights/insights.module');
break;
case 'data-table':
await import('@/modules/data-table/data-table.module');
break;
case 'mcp':
await import('@/modules/mcp/mcp.module');
break;
case 'module-settings':
await import('@/controllers/module-settings.controller');
break;
case 'security-settings':
await import('@/controllers/security-settings.controller');
break;
case 'third-party-licenses':
await import('@/controllers/third-party-licenses.controller');
break;
}
}
await Container.get(ModuleRegistry).initModules('main');
Container.get(ControllerRegistry).activate(app);
await Container.get(AuthHandlerRegistry).init();
}
});
afterAll(async () => {
await testDb.terminate();
testServer.httpServer.close();
});
beforeEach(() => {
testServer.license.reset();
});
return testServer;
};
@@ -0,0 +1,21 @@
import type { User, PublicUser } from '@n8n/db';
export const validateUser = (user: PublicUser) => {
expect(typeof user.id).toBe('string');
expect(user.email).toBeDefined();
expect(user.firstName).toBeDefined();
expect(user.lastName).toBeDefined();
expect(typeof user.isOwner).toBe('boolean');
expect(user.isPending).toBe(false);
expect(user.signInType).toBe('email');
expect(user.settings).toBe(null);
expect(user.personalizationAnswers).toBeNull();
expect(user.password).toBeUndefined();
expect(user.role).toBeDefined();
expect(typeof (user as any).mfaEnabled).toBe('boolean');
};
export type UserInvitationResult = {
user: Pick<User, 'id' | 'email'> & { inviteAcceptUrl: string; emailSent: boolean };
error?: string;
};
@@ -0,0 +1,307 @@
/**
* Reusable workflow fixtures for execution context propagation tests.
* These fixtures create minimal workflow structures needed for testing.
*/
import { NodeConnectionTypes } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
/**
* Creates a minimal child workflow with Execute Workflow Trigger.
* This is the simplest sub-workflow that can be called by another workflow.
*/
export function createSubWorkflowFixture() {
return {
nodes: [
{
parameters: {
workflowInputs: {
values: [{ name: 'test' }],
},
},
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
position: [0, 0] as [number, number],
id: uuid(),
name: 'Trigger',
},
],
connections: {},
pinData: {},
};
}
/**
* Creates a workflow with Manual Trigger + Execute Workflow node.
* This workflow can call another workflow (sub-workflow).
*/
export function createParentWorkflowFixture(childWorkflowId: string) {
return {
nodes: [
{
parameters: {},
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
id: uuid(),
name: 'Trigger',
},
{
parameters: {
workflowId: {
__rl: true,
value: childWorkflowId,
mode: 'list',
cachedResultUrl: `/workflow/${childWorkflowId}`,
cachedResultName: 'Child Workflow',
},
workflowInputs: {
mappingMode: 'defineBelow',
value: { test: 'test' },
matchingColumns: ['level'],
schema: [
{
id: 'test',
displayName: 'test',
required: false,
defaultMatch: false,
display: true,
canBeUsedToMatch: true,
type: 'string',
removed: false,
},
],
attemptToConvertTypes: false,
convertFieldsToString: true,
},
options: {},
},
type: 'n8n-nodes-base.executeWorkflow',
typeVersion: 1.3,
position: [208, 0] as [number, number],
id: uuid(),
name: 'Execute Workflow',
},
],
connections: {
Trigger: {
main: [
[
{
node: 'Execute Workflow',
type: NodeConnectionTypes.Main,
index: 0,
},
],
],
},
},
pinData: {},
};
}
/**
* Creates a middle-tier workflow with Execute Workflow Trigger + Execute Workflow node.
* This workflow can be called by a parent and can call a child (for nested scenarios).
*/
export function createMiddleWorkflowFixture(childWorkflowId: string) {
return {
nodes: [
{
parameters: {
workflowInputs: {
values: [{ name: 'test' }],
},
},
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
position: [0, 0] as [number, number],
id: uuid(),
name: 'Trigger',
},
{
parameters: {
workflowId: {
__rl: true,
value: childWorkflowId,
mode: 'list',
cachedResultUrl: `/workflow/${childWorkflowId}`,
cachedResultName: 'Grandchild Workflow',
},
workflowInputs: {
mappingMode: 'defineBelow',
value: { test: 'test' },
matchingColumns: ['level'],
schema: [
{
id: 'test',
displayName: 'test',
required: false,
defaultMatch: false,
display: true,
canBeUsedToMatch: true,
type: 'string',
removed: false,
},
],
attemptToConvertTypes: false,
convertFieldsToString: true,
},
options: {},
},
type: 'n8n-nodes-base.executeWorkflow',
typeVersion: 1.3,
position: [208, 0] as [number, number],
id: uuid(),
name: 'Execute Workflow',
},
],
connections: {
Trigger: {
main: [
[
{
node: 'Execute Workflow',
type: NodeConnectionTypes.Main,
index: 0,
},
],
],
},
},
pinData: {},
};
}
/**
* Creates a simple workflow with just Manual Trigger.
* Useful for testing context isolation.
*/
export function createSimpleWorkflowFixture() {
return {
nodes: [
{
parameters: {},
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
id: uuid(),
name: 'Trigger',
},
],
connections: {},
pinData: {},
};
}
/**
* Creates an error workflow with Error Trigger node.
* This workflow gets executed when another workflow fails (if configured).
*/
export function createErrorWorkflowFixture() {
return {
nodes: [
{
parameters: {},
type: 'n8n-nodes-base.errorTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
id: uuid(),
name: 'Trigger',
},
],
connections: {},
pinData: {},
};
}
/**
* Creates a workflow that throws an error using DebugHelper node.
* Useful for testing error workflow propagation.
*/
export function createFailingWorkflowFixture() {
return {
nodes: [
{
parameters: {},
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
id: uuid(),
name: 'Trigger',
},
{
parameters: {
throwErrorType: 'Error',
throwErrorMessage: 'Test error',
},
type: 'n8n-nodes-base.debugHelper',
typeVersion: 1,
position: [208, 0] as [number, number],
id: uuid(),
name: 'DebugHelper',
},
],
connections: {
Trigger: {
main: [
[
{
node: 'DebugHelper',
type: NodeConnectionTypes.Main,
index: 0,
},
],
],
},
},
pinData: {},
};
}
/**
* Creates a workflow that fails and is configured to trigger an error workflow.
* @param errorWorkflowId - The ID of the error workflow to trigger on failure
*/
export function createWorkflowWithErrorHandlerFixture(errorWorkflowId: string) {
return {
nodes: [
{
parameters: {},
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
id: uuid(),
name: 'Trigger',
},
{
parameters: {
throwErrorType: 'Error',
throwErrorMessage: 'Test error for error workflow',
},
type: 'n8n-nodes-base.debugHelper',
typeVersion: 1,
position: [208, 0] as [number, number],
id: uuid(),
name: 'DebugHelper',
},
],
connections: {
Trigger: {
main: [
[
{
node: 'DebugHelper',
type: NodeConnectionTypes.Main,
index: 0,
},
],
],
},
},
settings: {
errorWorkflow: errorWorkflowId,
},
pinData: {},
};
}
@@ -0,0 +1,101 @@
import { WorkflowEntity, WorkflowHistory } from '@n8n/db';
import type { INode } from 'n8n-workflow';
export const FIRST_CREDENTIAL_ID = '1';
export const SECOND_CREDENTIAL_ID = '2';
export const THIRD_CREDENTIAL_ID = '3';
const NODE_WITH_NO_CRED = '0133467b-df4a-473d-9295-fdd9d01fa45a';
const NODE_WITH_ONE_CRED = '4673f869-f2dc-4a33-b053-ca3193bc5226';
const NODE_WITH_TWO_CRED = '9b4208bd-8f10-4a6a-ad3b-da47a326f7da';
const nodeWithNoCredentials: INode = {
id: NODE_WITH_NO_CRED,
name: 'Node with no Credential',
typeVersion: 1,
type: 'n8n-nodes-base.fakeNode',
position: [0, 0],
credentials: {},
parameters: {},
};
const nodeWithOneCredential: INode = {
id: NODE_WITH_ONE_CRED,
name: 'Node with a single credential',
typeVersion: 1,
type: '',
position: [0, 0],
credentials: {
test: {
id: FIRST_CREDENTIAL_ID,
name: 'First fake credential',
},
},
parameters: {},
};
const nodeWithTwoCredentials: INode = {
id: NODE_WITH_TWO_CRED,
name: 'Node with two credentials',
typeVersion: 1,
type: '',
position: [0, 0],
credentials: {
mcTest: {
id: SECOND_CREDENTIAL_ID,
name: 'Second fake credential',
},
mcTest2: {
id: THIRD_CREDENTIAL_ID,
name: 'Third fake credential',
},
},
parameters: {},
};
export function getWorkflow(options?: {
addNodeWithoutCreds?: boolean;
addNodeWithOneCred?: boolean;
addNodeWithTwoCreds?: boolean;
}) {
const workflow = new WorkflowEntity();
workflow.nodes = [];
if (options?.addNodeWithoutCreds) {
workflow.nodes.push(nodeWithNoCredentials);
}
if (options?.addNodeWithOneCred) {
workflow.nodes.push(nodeWithOneCredential);
}
if (options?.addNodeWithTwoCreds) {
workflow.nodes.push(nodeWithTwoCredentials);
}
return workflow;
}
export function getWorkflowHistory(
workflow: WorkflowEntity,
overrides: Partial<WorkflowHistory> | undefined = {},
): WorkflowHistory {
const workflowHistory = new WorkflowHistory();
Object.assign(workflowHistory, {
versionId: 'default-version',
workflowId: workflow.id,
nodes: [],
connections: {},
authors: 'Test Author',
name: null,
description: null,
autosaved: false,
workflow,
workflowPublishHistory: [],
...overrides,
});
return workflowHistory;
}