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,475 @@
import {
createTestMigrationContext,
initDbUpToMigration,
runSingleMigration,
undoLastSingleMigration,
type TestMigrationContext,
} from '@n8n/backend-test-utils';
import { DbConnection } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
const MIGRATION_NAME = 'UniqueRoleNames1760020838000';
interface RoleData {
slug: string;
displayName: string;
createdAt: Date;
systemRole?: boolean;
roleType?: string;
description?: string | null;
}
interface RoleRow {
slug: string;
displayName: string;
createdAt: Date;
}
describe('UniqueRoleNames Migration', () => {
let dataSource: DataSource;
beforeAll(async () => {
// Initialize DB connection without running migrations
const dbConnection = Container.get(DbConnection);
await dbConnection.init();
dataSource = Container.get(DataSource);
// Clear database to start with clean slate
const context = createTestMigrationContext(dataSource);
await context.queryRunner.clearDatabase();
await context.queryRunner.release();
// Run migrations up to (but not including) target migration
await initDbUpToMigration(MIGRATION_NAME);
});
afterAll(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.close();
});
/**
* Helper function to insert a test role with controlled timestamp
*/
async function insertTestRole(context: TestMigrationContext, roleData: RoleData): Promise<void> {
const tableName = context.escape.tableName('role');
const slugColumn = context.escape.columnName('slug');
const displayNameColumn = context.escape.columnName('displayName');
const createdAtColumn = context.escape.columnName('createdAt');
const updatedAtColumn = context.escape.columnName('updatedAt');
const systemRoleColumn = context.escape.columnName('systemRole');
const roleTypeColumn = context.escape.columnName('roleType');
const descriptionColumn = context.escape.columnName('description');
const systemRole = roleData.systemRole ?? false;
const roleType = roleData.roleType ?? 'project';
const description = roleData.description ?? null;
await context.runQuery(
`INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${createdAtColumn}, ${updatedAtColumn}, ${systemRoleColumn}, ${roleTypeColumn}, ${descriptionColumn}) VALUES (:slug, :displayName, :createdAt, :updatedAt, :systemRole, :roleType, :description)`,
{
slug: roleData.slug,
displayName: roleData.displayName,
createdAt: roleData.createdAt,
updatedAt: roleData.createdAt,
systemRole,
roleType,
description,
},
);
}
/**
* Helper function to retrieve all roles ordered by creation date
*/
async function getAllRoles(context: TestMigrationContext): Promise<RoleRow[]> {
const tableName = context.escape.tableName('role');
const slugColumn = context.escape.columnName('slug');
const displayNameColumn = context.escape.columnName('displayName');
const createdAtColumn = context.escape.columnName('createdAt');
const roles = await context.runQuery<RoleRow[]>(
`SELECT ${slugColumn}, ${displayNameColumn}, ${createdAtColumn} FROM ${tableName} ORDER BY ${createdAtColumn} ASC`,
{},
);
return roles;
}
describe('Schema Migration', () => {
it('should create unique index and correctly rename all duplicate roles', async () => {
// Create migration context for schema queries
const context = createTestMigrationContext(dataSource);
// Test Scenario 1: 3 roles with same displayName "Duplicate Name"
await insertTestRole(context, {
slug: 'test-role-oldest',
displayName: 'Duplicate Name',
createdAt: new Date('2024-01-01T00:00:00.000Z'),
});
await insertTestRole(context, {
slug: 'test-role-middle',
displayName: 'Duplicate Name',
createdAt: new Date('2024-01-02T00:00:00.000Z'),
});
await insertTestRole(context, {
slug: 'test-role-newest',
displayName: 'Duplicate Name',
createdAt: new Date('2024-01-03T00:00:00.000Z'),
});
// Test Scenario 2: 2 duplicate "Editor" roles
await insertTestRole(context, {
slug: 'editor-first',
displayName: 'Editor',
createdAt: new Date('2025-01-01T00:00:00.000Z'),
});
await insertTestRole(context, {
slug: 'editor-second',
displayName: 'Editor',
createdAt: new Date('2025-01-02T00:00:00.000Z'),
});
// Test Scenario 3: 5 duplicate "Manager" roles
for (let i = 1; i <= 5; i++) {
await insertTestRole(context, {
slug: `manager-${i}`,
displayName: 'Manager',
createdAt: new Date(`2025-02-0${i}T00:00:00.000Z`),
});
}
// Test Scenario 4: Multiple independent duplicate groups
// Group 1: 3 "Admin" roles
await insertTestRole(context, {
slug: 'admin-1',
displayName: 'Admin',
createdAt: new Date('2025-03-01T00:00:00.000Z'),
});
await insertTestRole(context, {
slug: 'admin-2',
displayName: 'Admin',
createdAt: new Date('2025-03-02T00:00:00.000Z'),
});
await insertTestRole(context, {
slug: 'admin-3',
displayName: 'Admin',
createdAt: new Date('2025-03-03T00:00:00.000Z'),
});
// Group 2: 2 "Reviewer" roles
await insertTestRole(context, {
slug: 'reviewer-1',
displayName: 'Reviewer',
createdAt: new Date('2025-03-04T00:00:00.000Z'),
});
await insertTestRole(context, {
slug: 'reviewer-2',
displayName: 'Reviewer',
createdAt: new Date('2025-03-05T00:00:00.000Z'),
});
// Check conflict with generated display name conflict
await insertTestRole(context, {
slug: 'reviewer-3',
displayName: 'Reviewer 2',
createdAt: new Date('2025-03-05T00:00:00.000Z'),
});
// Group 3: 1 "Viewer" role (no duplicates)
await insertTestRole(context, {
slug: 'viewer-1',
displayName: 'Viewer',
createdAt: new Date('2025-03-06T00:00:00.000Z'),
});
// Verify pre-migration state - all roles exist with original displayNames
const beforeRoles = await getAllRoles(context);
expect(beforeRoles.filter((r) => r.displayName === 'Duplicate Name')).toHaveLength(3);
expect(beforeRoles.filter((r) => r.displayName === 'Editor')).toHaveLength(2);
expect(beforeRoles.filter((r) => r.displayName === 'Manager')).toHaveLength(5);
expect(beforeRoles.filter((r) => r.displayName === 'Admin')).toHaveLength(3);
expect(beforeRoles.filter((r) => r.displayName === 'Reviewer')).toHaveLength(2);
expect(beforeRoles.filter((r) => r.displayName === 'Viewer')).toHaveLength(1);
// Run the migration
await runSingleMigration(MIGRATION_NAME);
// Release old query runner before creating new one
await context.queryRunner.release();
// Create fresh context after migration
const postMigrationContext = createTestMigrationContext(dataSource);
const tableName = postMigrationContext.escape.tableName('role');
const displayNameColumn = postMigrationContext.escape.columnName('displayName');
const slugColumn = postMigrationContext.escape.columnName('slug');
const indexName = postMigrationContext.escape.indexName('UniqueRoleDisplayName');
// Verify all duplicate roles were renamed correctly
const afterRoles = await getAllRoles(postMigrationContext);
// Test Scenario 1: 3 "Duplicate Name" roles
const oldestRole = afterRoles.find((r) => r.slug === 'test-role-oldest');
const middleRole = afterRoles.find((r) => r.slug === 'test-role-middle');
const newestRole = afterRoles.find((r) => r.slug === 'test-role-newest');
expect(oldestRole?.displayName).toBe('Duplicate Name'); // Oldest keeps original
expect(middleRole?.displayName).toBe('Duplicate Name 2'); // Second gets " 2"
expect(newestRole?.displayName).toBe('Duplicate Name 3'); // Third gets " 3"
// Test Scenario 2: 2 "Editor" roles
const editorFirst = afterRoles.find((r) => r.slug === 'editor-first');
const editorSecond = afterRoles.find((r) => r.slug === 'editor-second');
expect(editorFirst?.displayName).toBe('Editor'); // Oldest keeps original
expect(editorSecond?.displayName).toBe('Editor 2'); // Second gets " 2"
// Test Scenario 3: 5 "Manager" roles
const manager1 = afterRoles.find((r) => r.slug === 'manager-1');
const manager2 = afterRoles.find((r) => r.slug === 'manager-2');
const manager3 = afterRoles.find((r) => r.slug === 'manager-3');
const manager4 = afterRoles.find((r) => r.slug === 'manager-4');
const manager5 = afterRoles.find((r) => r.slug === 'manager-5');
expect(manager1?.displayName).toBe('Manager'); // Oldest keeps original
expect(manager2?.displayName).toBe('Manager 2');
expect(manager3?.displayName).toBe('Manager 3');
expect(manager4?.displayName).toBe('Manager 4');
expect(manager5?.displayName).toBe('Manager 5');
// Test Scenario 4: Multiple independent groups
const admin1 = afterRoles.find((r) => r.slug === 'admin-1');
const admin2 = afterRoles.find((r) => r.slug === 'admin-2');
const admin3 = afterRoles.find((r) => r.slug === 'admin-3');
expect(admin1?.displayName).toBe('Admin');
expect(admin2?.displayName).toBe('Admin 2');
expect(admin3?.displayName).toBe('Admin 3');
const reviewer1 = afterRoles.find((r) => r.slug === 'reviewer-1');
const reviewer2 = afterRoles.find((r) => r.slug === 'reviewer-2');
expect(reviewer1?.displayName).toBe('Reviewer');
expect(reviewer2?.displayName).toBe('Reviewer 3');
const reviewer3 = afterRoles.find((r) => r.slug === 'reviewer-3');
expect(reviewer3?.displayName).toBe('Reviewer 2');
const viewer1 = afterRoles.find((r) => r.slug === 'viewer-1');
expect(viewer1?.displayName).toBe('Viewer'); // Unchanged (no duplicates)
// Verify unique index exists based on database type
if (postMigrationContext.isSqlite) {
const indexes = await postMigrationContext.queryRunner.query(
`PRAGMA index_list(${tableName})`,
);
const uniqueIndex = indexes.find(
(idx: { name: string; unique: number }) =>
idx.name.includes('UniqueRoleDisplayName') && idx.unique === 1,
);
expect(uniqueIndex).toBeDefined();
} else if (postMigrationContext.isPostgres) {
// For PostgreSQL, we need the actual table/index names without quotes
// The escaped indexName has quotes, so we need to strip them
const actualTableName = postMigrationContext.tablePrefix + 'role';
const actualIndexName = indexName.replace(/"/g, ''); // Remove quotes from escaped name
const result = await postMigrationContext.runQuery(
'SELECT indexname FROM pg_indexes WHERE tablename = :tableName AND indexname = :indexName',
{ tableName: actualTableName, indexName: actualIndexName },
);
expect(result).toHaveLength(1);
// Verify index is unique
const uniqueCheck = await postMigrationContext.runQuery<
Array<{ index_name: string; indisunique: boolean }>
>(
`SELECT i.relname as index_name, ix.indisunique
FROM pg_class t
JOIN pg_index ix ON t.oid = ix.indrelid
JOIN pg_class i ON i.oid = ix.indexrelid
WHERE t.relname = :tableName AND i.relname = :indexName`,
{ tableName: actualTableName, indexName: actualIndexName },
);
expect(uniqueCheck[0].indisunique).toBe(true);
}
// Verify index enforces uniqueness by attempting duplicate insert
await postMigrationContext.runQuery(
`INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${postMigrationContext.escape.columnName('createdAt')}, ${postMigrationContext.escape.columnName('updatedAt')}, ${postMigrationContext.escape.columnName('systemRole')}, ${postMigrationContext.escape.columnName('roleType')}) VALUES (:slug, :displayName, :createdAt, :updatedAt, :systemRole, :roleType)`,
{
slug: 'test-duplicate-attempt',
displayName: 'Unique Test Name',
createdAt: new Date(),
updatedAt: new Date(),
systemRole: false,
roleType: 'project',
},
);
const attemptDuplicateInsert = async () => {
return await postMigrationContext.queryRunner.query(
`INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${postMigrationContext.escape.columnName('createdAt')}, ${postMigrationContext.escape.columnName('updatedAt')}, ${postMigrationContext.escape.columnName('systemRole')}, ${postMigrationContext.escape.columnName('roleType')}) VALUES (?, ?, ?, ?, ?, ?)`,
[
'test-duplicate-attempt-2',
'Unique Test Name',
new Date(),
new Date(),
false,
'project',
],
);
};
await expect(attemptDuplicateInsert()).rejects.toThrow();
// Cleanup
await postMigrationContext.queryRunner.release();
});
it('should remove unique index on rollback', async () => {
// NOTE: This test skips duplicate scenarios since migration already ran in previous test
// We're testing rollback functionality independently
// Run up() migration first (already done in test set up)
// runSingleMigration checks if already executed and skips if needed
await runSingleMigration(MIGRATION_NAME);
// Create fresh context
const upContext = createTestMigrationContext(dataSource);
const tableName = upContext.escape.tableName('role');
const indexName = upContext.escape.indexName('UniqueRoleDisplayName');
// Verify unique index exists
if (upContext.isSqlite) {
const indexes = await upContext.queryRunner.query(`PRAGMA index_list(${tableName})`);
const uniqueIndex = indexes.find(
(idx: { name: string; unique: number }) =>
idx.name.includes('UniqueRoleDisplayName') && idx.unique === 1,
);
expect(uniqueIndex).toBeDefined();
} else if (upContext.isPostgres) {
// For PostgreSQL, we need the actual table/index names without quotes
// The escaped indexName has quotes, so we need to strip them
const actualTableName = upContext.tablePrefix + 'role';
const actualIndexName = indexName.replace(/"/g, ''); // Remove quotes from escaped name
const result = await upContext.runQuery(
'SELECT indexname FROM pg_indexes WHERE tablename = :tableName AND indexname = :indexName',
{ tableName: actualTableName, indexName: actualIndexName },
);
expect(result).toHaveLength(1);
}
await upContext.queryRunner.release();
await undoLastSingleMigration();
// Create fresh context after rollback
const postRollbackContext = createTestMigrationContext(dataSource);
// Verify index is removed (DB-specific queries)
if (postRollbackContext.isSqlite) {
const indexes = await postRollbackContext.runQuery<Array<{ name: string; unique: number }>>(
`PRAGMA index_list(${tableName})`,
);
const uniqueIndex = indexes.find(
(idx: { name: string; unique: number }) =>
idx.name.includes('UniqueRoleDisplayName') && idx.unique === 1,
);
expect(uniqueIndex).toBeUndefined();
} else if (postRollbackContext.isPostgres) {
const result = await postRollbackContext.runQuery(
'SELECT indexname FROM pg_indexes WHERE tablename = :tableName AND indexname = :indexName',
{
tableName: postRollbackContext.tablePrefix + 'role',
indexName: indexName.replace(/"/g, ''),
},
);
expect(result).toHaveLength(0);
}
// Verify duplicate displayNames can be inserted again
// Insert 2 roles with same displayName to confirm duplicates allowed
const slugColumn = postRollbackContext.escape.columnName('slug');
const displayNameColumn = postRollbackContext.escape.columnName('displayName');
const createdAtColumn = postRollbackContext.escape.columnName('createdAt');
const updatedAtColumn = postRollbackContext.escape.columnName('updatedAt');
const systemRoleColumn = postRollbackContext.escape.columnName('systemRole');
const roleTypeColumn = postRollbackContext.escape.columnName('roleType');
await postRollbackContext.runQuery(
`INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${createdAtColumn}, ${updatedAtColumn}, ${systemRoleColumn}, ${roleTypeColumn}) VALUES (:slug, :displayName, :createdAt, :updatedAt, :systemRole, :roleType)`,
{
slug: 'rollback-test-1',
displayName: 'Duplicate After Rollback',
createdAt: new Date(),
updatedAt: new Date(),
systemRole: false,
roleType: 'project',
},
);
await postRollbackContext.runQuery(
`INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${createdAtColumn}, ${updatedAtColumn}, ${systemRoleColumn}, ${roleTypeColumn}) VALUES (:slug, :displayName, :createdAt, :updatedAt, :systemRole, :roleType)`,
{
slug: 'rollback-test-2',
displayName: 'Duplicate After Rollback',
createdAt: new Date(),
updatedAt: new Date(),
systemRole: false,
roleType: 'project',
},
);
// Verify both roles were inserted successfully
const duplicateRoles = await postRollbackContext.runQuery(
`SELECT ${slugColumn} as slug, ${displayNameColumn} as displayName FROM ${tableName} WHERE ${displayNameColumn} = :displayName`,
{ displayName: 'Duplicate After Rollback' },
);
expect(duplicateRoles).toHaveLength(2);
// Cleanup
await postRollbackContext.queryRunner.release();
});
});
describe('Post-Migration Capacity', () => {
it('should accept unique displayNames after migration', async () => {
const context = createTestMigrationContext(dataSource);
const tableName = context.escape.tableName('role');
const slugColumn = context.escape.columnName('slug');
const displayNameColumn = context.escape.columnName('displayName');
const createdAtColumn = context.escape.columnName('createdAt');
const updatedAtColumn = context.escape.columnName('updatedAt');
const systemRoleColumn = context.escape.columnName('systemRole');
const roleTypeColumn = context.escape.columnName('roleType');
// Insert role with unique displayName
await context.runQuery(
`INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${createdAtColumn}, ${updatedAtColumn}, ${systemRoleColumn}, ${roleTypeColumn}) VALUES (:slug, :displayName, :createdAt, :updatedAt, :systemRole, :roleType)`,
{
slug: 'unique-role-test',
displayName: 'Unique Role Name',
createdAt: new Date(),
updatedAt: new Date(),
systemRole: false,
roleType: 'project',
},
);
// Verify retrieval using SQL
const results = await context.runQuery<Array<{ slug: string; displayName: string }>>(
`SELECT ${slugColumn}, ${displayNameColumn} FROM ${tableName} WHERE ${slugColumn} = :slug`,
{ slug: 'unique-role-test' },
);
expect(results).toHaveLength(1);
// Access using the actual database column name
const row = results[0] as Record<string, unknown>;
expect(row.displayName ?? row.displayname).toBe('Unique Role Name');
// Cleanup
await context.queryRunner.release();
});
});
});
@@ -0,0 +1,619 @@
import {
createTestMigrationContext,
initDbUpToMigration,
runSingleMigration,
type TestMigrationContext,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { DbConnection } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import { randomUUID } from 'node:crypto';
const MIGRATION_NAME = 'ActivateExecuteWorkflowTriggerWorkflows1763048000000';
interface WorkflowData {
id: string;
name: string;
nodes: object[];
connections: object;
active: boolean;
versionId: string;
createdAt: Date;
updatedAt: Date;
}
interface WorkflowRow {
id: string;
name: string;
active: boolean;
nodes: string;
versionId: string;
activeVersionId: string | null;
}
describe('ActivateExecuteWorkflowTriggerWorkflows Migration', () => {
let dataSource: DataSource;
beforeAll(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.init();
dataSource = Container.get(DataSource);
// Clear database to start with clean slate
const context = createTestMigrationContext(dataSource);
await context.queryRunner.clearDatabase();
await context.queryRunner.release();
await initDbUpToMigration(MIGRATION_NAME);
});
afterAll(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.close();
});
async function insertTestWorkflow(
context: TestMigrationContext,
workflowData: WorkflowData,
): Promise<void> {
const tableName = context.escape.tableName('workflow_entity');
const historyTableName = context.escape.tableName('workflow_history');
const idColumn = context.escape.columnName('id');
const workflowIdColumn = context.escape.columnName('workflowId');
const nameColumn = context.escape.columnName('name');
const nodesColumn = context.escape.columnName('nodes');
const connectionsColumn = context.escape.columnName('connections');
const activeColumn = context.escape.columnName('active');
const versionIdColumn = context.escape.columnName('versionId');
const createdAtColumn = context.escape.columnName('createdAt');
const updatedAtColumn = context.escape.columnName('updatedAt');
// Insert workflow_entity record first (workflow_history references it)
await context.runQuery(
`INSERT INTO ${tableName} (${idColumn}, ${nameColumn}, ${nodesColumn}, ${connectionsColumn}, ${activeColumn}, ${versionIdColumn}, ${createdAtColumn}, ${updatedAtColumn}) VALUES (:id, :name, :nodes, :connections, :active, :versionId, :createdAt, :updatedAt)`,
{
id: workflowData.id,
name: workflowData.name,
nodes: JSON.stringify(workflowData.nodes),
connections: JSON.stringify(workflowData.connections),
active: workflowData.active,
versionId: workflowData.versionId,
createdAt: workflowData.createdAt,
updatedAt: workflowData.updatedAt,
},
);
// Insert workflow_history record (required for activeVersionId foreign key)
const authorsColumn = context.escape.columnName('authors');
await context.runQuery(
`INSERT INTO ${historyTableName} (${versionIdColumn}, ${workflowIdColumn}, ${nodesColumn}, ${connectionsColumn}, ${authorsColumn}, ${createdAtColumn}, ${updatedAtColumn}) VALUES (:versionId, :workflowId, :nodes, :connections, :authors, :createdAt, :updatedAt)`,
{
versionId: workflowData.versionId,
workflowId: workflowData.id,
nodes: JSON.stringify(workflowData.nodes),
connections: JSON.stringify(workflowData.connections),
authors: 'test-user',
createdAt: workflowData.createdAt,
updatedAt: workflowData.updatedAt,
},
);
}
async function getWorkflowById(
context: TestMigrationContext,
id: string,
): Promise<WorkflowRow | undefined> {
const tableName = context.escape.tableName('workflow_entity');
const idColumn = context.escape.columnName('id');
const nameColumn = context.escape.columnName('name');
const activeColumn = context.escape.columnName('active');
const nodesColumn = context.escape.columnName('nodes');
const versionIdColumn = context.escape.columnName('versionId');
const activeVersionIdColumn = context.escape.columnName('activeVersionId');
// For PostgreSQL, cast JSON column to text to get string representation
const nodesSelect = context.isPostgres ? `${nodesColumn}::text` : nodesColumn;
const workflows = await context.runQuery<
Array<{
id: string;
name: string;
active: boolean;
nodes: string;
versionId: string;
activeVersionId: string | null;
}>
>(
`SELECT ${idColumn}, ${nameColumn}, ${activeColumn}, ${nodesSelect}, ${versionIdColumn}, ${activeVersionIdColumn} FROM ${tableName} WHERE ${idColumn} = :id`,
{ id },
);
return workflows[0] as WorkflowRow | undefined;
}
describe('Up Migration', () => {
const workflowIds = {
passthrough: randomUUID(),
jsonExample: randomUUID(),
legacy: randomUUID(),
version1NoParams: randomUUID(),
version11MissingType: randomUUID(),
invalid: randomUUID(),
errorTrigger: randomUUID(),
multipleTriggers: randomUUID(),
normalWorkflow: randomUUID(),
bothTriggers: randomUUID(),
disabledExecuteWorkflowTrigger: randomUUID(),
disabledErrorTrigger: randomUUID(),
invalidJson: randomUUID(),
};
beforeAll(async () => {
const context = createTestMigrationContext(dataSource);
await insertTestWorkflow(context, {
id: workflowIds.passthrough,
name: 'Test Execute Workflow Trigger',
nodes: [
{
id: randomUUID(),
name: 'Execute Workflow Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: { inputSource: 'passthrough' },
typeVersion: 1,
position: [0, 0],
},
],
connections: {},
active: false,
versionId: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
});
await insertTestWorkflow(context, {
id: workflowIds.jsonExample,
name: 'Test Execute Workflow Trigger JSON',
nodes: [
{
id: randomUUID(),
name: 'Execute Workflow Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: { inputSource: 'jsonExample' },
typeVersion: 1,
position: [0, 0],
},
],
connections: {},
active: false,
versionId: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
});
await insertTestWorkflow(context, {
id: workflowIds.legacy,
name: 'Test Legacy Execute Workflow Trigger',
nodes: [
{
id: randomUUID(),
name: 'Execute Workflow Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: {
workflowInputs: {
values: [{ name: 'input1', type: 'string' }],
},
},
typeVersion: 1,
position: [0, 0],
},
],
connections: {},
active: false,
versionId: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
});
await insertTestWorkflow(context, {
id: workflowIds.invalid,
name: 'Test Invalid Execute Workflow Trigger',
nodes: [
{
id: randomUUID(),
name: 'Execute Workflow Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: {
workflowInputs: {
values: [{ type: 'string' }], // missing 'name' field
},
},
typeVersion: 1,
position: [0, 0],
},
],
connections: {},
active: false,
versionId: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
});
await insertTestWorkflow(context, {
id: workflowIds.version1NoParams,
name: 'Test Version 1 No Parameters',
nodes: [
{
id: randomUUID(),
name: 'Execute Workflow Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
],
connections: {},
active: false,
versionId: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
});
await insertTestWorkflow(context, {
id: workflowIds.version11MissingType,
name: 'Test Version 1.1 Missing Type',
nodes: [
{
id: randomUUID(),
name: 'Execute Workflow Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: {
workflowInputs: {
values: [{ name: 'chatInput' }, { name: 'sessionId' }, { name: 'env' }],
},
},
typeVersion: 1.1,
position: [0, 0],
},
],
connections: {},
active: false,
versionId: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
});
await insertTestWorkflow(context, {
id: workflowIds.errorTrigger,
name: 'Test Error Trigger',
nodes: [
{
id: randomUUID(),
name: 'Error Trigger',
type: 'n8n-nodes-base.errorTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
],
connections: {},
active: false,
versionId: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
});
await insertTestWorkflow(context, {
id: workflowIds.multipleTriggers,
name: 'Test Multiple Triggers',
nodes: [
{
id: randomUUID(),
name: 'Execute Workflow Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: { inputSource: 'passthrough' },
typeVersion: 1,
position: [0, 0],
},
{
id: randomUUID(),
name: 'Schedule Trigger',
type: 'n8n-nodes-base.scheduleTrigger',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
{
id: randomUUID(),
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
parameters: {},
typeVersion: 1,
position: [400, 0],
},
],
connections: {},
active: false,
versionId: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
});
await insertTestWorkflow(context, {
id: workflowIds.normalWorkflow,
name: 'Test Normal Workflow',
nodes: [
{
id: randomUUID(),
name: 'Schedule Trigger',
type: 'n8n-nodes-base.scheduleTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
},
],
connections: {},
active: false,
versionId: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
});
await insertTestWorkflow(context, {
id: workflowIds.bothTriggers,
name: 'Test Both Triggers',
nodes: [
{
id: randomUUID(),
name: 'Execute Workflow Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: { inputSource: 'passthrough' },
typeVersion: 1,
position: [0, 0],
},
{
id: randomUUID(),
name: 'Error Trigger',
type: 'n8n-nodes-base.errorTrigger',
parameters: {},
typeVersion: 1,
position: [200, 0],
},
],
connections: {},
active: false,
versionId: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
});
await insertTestWorkflow(context, {
id: workflowIds.disabledExecuteWorkflowTrigger,
name: 'Test Disabled Execute Workflow Trigger',
nodes: [
{
id: randomUUID(),
name: 'Execute Workflow Trigger',
type: 'n8n-nodes-base.executeWorkflowTrigger',
parameters: { inputSource: 'passthrough' },
typeVersion: 1,
position: [0, 0],
disabled: true,
},
],
connections: {},
active: false,
versionId: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
});
await insertTestWorkflow(context, {
id: workflowIds.disabledErrorTrigger,
name: 'Test Disabled Error Trigger',
nodes: [
{
id: randomUUID(),
name: 'Error Trigger',
type: 'n8n-nodes-base.errorTrigger',
parameters: {},
typeVersion: 1,
position: [0, 0],
disabled: true,
},
],
connections: {},
active: false,
versionId: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
});
// Insert workflow with invalid JSON containing unescaped control characters
// Note: PostgreSQL enforces strict JSON validation and won't allow invalid JSON,
// so we skip this test data for PostgreSQL
if (!context.isPostgres) {
const tableName = context.escape.tableName('workflow_entity');
const idColumn = context.escape.columnName('id');
const nameColumn = context.escape.columnName('name');
const nodesColumn = context.escape.columnName('nodes');
const connectionsColumn = context.escape.columnName('connections');
const activeColumn = context.escape.columnName('active');
const versionIdColumn = context.escape.columnName('versionId');
const createdAtColumn = context.escape.columnName('createdAt');
const updatedAtColumn = context.escape.columnName('updatedAt');
await context.runQuery(
`INSERT INTO ${tableName} (${idColumn}, ${nameColumn}, ${nodesColumn}, ${connectionsColumn}, ${activeColumn}, ${versionIdColumn}, ${createdAtColumn}, ${updatedAtColumn}) VALUES (:id, :name, :nodes, :connections, :active, :versionId, :createdAt, :updatedAt)`,
{
id: workflowIds.invalidJson,
name: 'Test Invalid JSON with Control Characters',
// Invalid JSON with unescaped newline (simulating the production issue)
nodes:
'[{"id":"test","type":"n8n-nodes-base.executeWorkflowTrigger","parameters":{"inputSource":"passthrough","description":"MEASUREMENT DETAILS: \\"N/A\\"\\nMEASUREMENT TYPE: \\"N/A\\"\n"},"typeVersion":1,"position":[0,0]}]',
connections: '{}',
active: false,
versionId: randomUUID(),
createdAt: new Date(),
updatedAt: new Date(),
},
);
}
await runSingleMigration(MIGRATION_NAME);
await context.queryRunner.release();
});
it('should activate workflows with Execute Workflow Trigger (passthrough)', async () => {
const context = createTestMigrationContext(dataSource);
const workflow = await getWorkflowById(context, workflowIds.passthrough);
expect(workflow?.active).toBeTruthy();
expect(workflow?.activeVersionId).toBeTruthy();
await context.queryRunner.release();
});
it('should activate workflows with Execute Workflow Trigger (jsonExample)', async () => {
const context = createTestMigrationContext(dataSource);
const workflow = await getWorkflowById(context, workflowIds.jsonExample);
expect(workflow?.active).toBeTruthy();
await context.queryRunner.release();
});
it('should activate workflows with legacy Execute Workflow Trigger (with parameters)', async () => {
const context = createTestMigrationContext(dataSource);
const workflow = await getWorkflowById(context, workflowIds.legacy);
expect(workflow?.active).toBeTruthy();
await context.queryRunner.release();
});
it('should activate workflows with Version 1 Execute Workflow Trigger (no parameters)', async () => {
const context = createTestMigrationContext(dataSource);
const workflow = await getWorkflowById(context, workflowIds.version1NoParams);
expect(workflow?.active).toBeTruthy();
expect(workflow?.activeVersionId).toBeTruthy();
await context.queryRunner.release();
});
it('should activate workflows with Version 1.1 Execute Workflow Trigger (missing type fields)', async () => {
const context = createTestMigrationContext(dataSource);
const workflow = await getWorkflowById(context, workflowIds.version11MissingType);
expect(workflow?.active).toBeTruthy();
expect(workflow?.activeVersionId).toBeTruthy();
await context.queryRunner.release();
});
it('should NOT activate Execute Workflow Trigger without valid parameters', async () => {
const context = createTestMigrationContext(dataSource);
const workflow = await getWorkflowById(context, workflowIds.invalid);
expect(workflow?.active).toBeFalsy();
expect(workflow?.activeVersionId).toBeNull();
await context.queryRunner.release();
});
it('should activate workflows with Error Trigger', async () => {
const context = createTestMigrationContext(dataSource);
const workflow = await getWorkflowById(context, workflowIds.errorTrigger);
expect(workflow?.active).toBeTruthy();
expect(workflow?.activeVersionId).toBeTruthy();
await context.queryRunner.release();
});
it('should disable other trigger nodes in activated workflows', async () => {
const context = createTestMigrationContext(dataSource);
const workflow = await getWorkflowById(context, workflowIds.multipleTriggers);
expect(workflow?.active).toBeTruthy();
const nodes = JSON.parse(workflow!.nodes);
const executeWorkflowTrigger = nodes.find(
(n: { type: string }) => n.type === 'n8n-nodes-base.executeWorkflowTrigger',
);
const scheduleTrigger = nodes.find(
(n: { type: string }) => n.type === 'n8n-nodes-base.scheduleTrigger',
);
const webhook = nodes.find((n: { type: string }) => n.type === 'n8n-nodes-base.webhook');
expect(executeWorkflowTrigger.disabled).toBeUndefined();
expect(scheduleTrigger.disabled).toBe(true);
expect(webhook.disabled).toBeUndefined(); // Webhooks are not disabled
await context.queryRunner.release();
});
it('should NOT activate workflows without Execute Workflow or Error Trigger', async () => {
const context = createTestMigrationContext(dataSource);
const workflow = await getWorkflowById(context, workflowIds.normalWorkflow);
expect(workflow?.active).toBeFalsy();
expect(workflow?.activeVersionId).toBeNull();
await context.queryRunner.release();
});
it('should activate workflow with both Execute Workflow and Error Trigger', async () => {
const context = createTestMigrationContext(dataSource);
const workflow = await getWorkflowById(context, workflowIds.bothTriggers);
expect(workflow?.active).toBeTruthy();
await context.queryRunner.release();
});
it('should NOT activate workflow with disabled Execute Workflow Trigger', async () => {
const context = createTestMigrationContext(dataSource);
const workflow = await getWorkflowById(context, workflowIds.disabledExecuteWorkflowTrigger);
expect(workflow?.active).toBeFalsy();
expect(workflow?.activeVersionId).toBeNull();
await context.queryRunner.release();
});
it('should NOT activate workflow with disabled Error Trigger', async () => {
const context = createTestMigrationContext(dataSource);
const workflow = await getWorkflowById(context, workflowIds.disabledErrorTrigger);
expect(workflow?.active).toBeFalsy();
expect(workflow?.activeVersionId).toBeNull();
await context.queryRunner.release();
});
// PostgreSQL enforces strict JSON validation and won't allow invalid JSON to be inserted,
// so we skip this test for PostgreSQL
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
const testFn = Container.get(GlobalConfig).database.type === 'postgresdb' ? it.skip : it;
testFn(
'should skip workflows with invalid JSON containing unescaped control characters',
async () => {
const context = createTestMigrationContext(dataSource);
const workflow = await getWorkflowById(context, workflowIds.invalidJson);
// Workflow should remain inactive and unchanged
expect(workflow?.active).toBeFalsy();
expect(workflow?.activeVersionId).toBeNull();
// Verify the invalid JSON is still in the database (unchanged)
expect(workflow?.nodes).toContain('MEASUREMENT DETAILS');
await context.queryRunner.release();
},
);
});
});
@@ -0,0 +1,393 @@
import {
createTestMigrationContext,
initDbUpToMigration,
runSingleMigration,
undoLastSingleMigration,
type TestMigrationContext,
} from '@n8n/backend-test-utils';
import { DbConnection } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
const MIGRATION_NAME = 'AddWorkflowPublishScopeToProjectRoles1766064542000';
interface ScopeData {
slug: string;
displayName: string;
description: string;
}
interface RoleData {
slug: string;
displayName: string;
roleType: string;
systemRole?: boolean;
}
interface RoleScopeData {
roleSlug: string;
scopeSlug: string;
}
interface RoleScopeRow {
roleSlug: string;
scopeSlug: string;
}
describe('AddWorkflowPublishScopeToProjectRoles Migration', () => {
let dataSource: DataSource;
beforeAll(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.init();
dataSource = Container.get(DataSource);
// Clear database to start with clean slate
const context = createTestMigrationContext(dataSource);
await context.queryRunner.clearDatabase();
await context.queryRunner.release();
await initDbUpToMigration(MIGRATION_NAME);
});
afterAll(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.close();
});
/**
* Helper function to insert a test scope
*/
async function insertTestScope(
context: TestMigrationContext,
scopeData: ScopeData,
): Promise<void> {
const tableName = context.escape.tableName('scope');
const slugColumn = context.escape.columnName('slug');
const displayNameColumn = context.escape.columnName('displayName');
const descriptionColumn = context.escape.columnName('description');
const existingScope = await context.runQuery<unknown[]>(
`SELECT ${slugColumn} FROM ${tableName} WHERE ${slugColumn} = :slug`,
{ slug: scopeData.slug },
);
if (existingScope.length === 0) {
await context.runQuery(
`INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${descriptionColumn}) VALUES (:slug, :displayName, :description)`,
{
slug: scopeData.slug,
displayName: scopeData.displayName,
description: scopeData.description,
},
);
}
}
/**
* Helper function to insert a test role
*/
async function insertTestRole(context: TestMigrationContext, roleData: RoleData): Promise<void> {
const tableName = context.escape.tableName('role');
const slugColumn = context.escape.columnName('slug');
const displayNameColumn = context.escape.columnName('displayName');
const roleTypeColumn = context.escape.columnName('roleType');
const systemRoleColumn = context.escape.columnName('systemRole');
const createdAtColumn = context.escape.columnName('createdAt');
const updatedAtColumn = context.escape.columnName('updatedAt');
const systemRole = roleData.systemRole ?? false;
await context.runQuery(
`INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${roleTypeColumn}, ${systemRoleColumn}, ${createdAtColumn}, ${updatedAtColumn}) VALUES (:slug, :displayName, :roleType, :systemRole, :createdAt, :updatedAt)`,
{
slug: roleData.slug,
displayName: roleData.displayName,
roleType: roleData.roleType,
systemRole,
createdAt: new Date(),
updatedAt: new Date(),
},
);
}
/**
* Helper function to link a role to a scope
*/
async function insertTestRoleScope(
context: TestMigrationContext,
roleScopeData: RoleScopeData,
): Promise<void> {
const tableName = context.escape.tableName('role_scope');
const roleSlugColumn = context.escape.columnName('roleSlug');
const scopeSlugColumn = context.escape.columnName('scopeSlug');
await context.runQuery(
`INSERT INTO ${tableName} (${roleSlugColumn}, ${scopeSlugColumn}) VALUES (:roleSlug, :scopeSlug)`,
{ roleSlug: roleScopeData.roleSlug, scopeSlug: roleScopeData.scopeSlug },
);
}
/**
* Helper function to get all scopes for a given role
*/
async function getRoleScopesByRole(
context: TestMigrationContext,
roleSlug: string,
): Promise<RoleScopeRow[]> {
const tableName = context.escape.tableName('role_scope');
const roleSlugColumn = context.escape.columnName('roleSlug');
const scopeSlugColumn = context.escape.columnName('scopeSlug');
const scopes = await context.runQuery<Array<{ roleSlug: string; scopeSlug: string }>>(
`SELECT ${roleSlugColumn}, ${scopeSlugColumn} FROM ${tableName} WHERE ${roleSlugColumn} = :roleSlug`,
{ roleSlug },
);
return scopes as RoleScopeRow[];
}
/**
* Helper function to get all role_scope entries for a given scope
*/
async function getRoleScopesByScope(
context: TestMigrationContext,
scopeSlug: string,
): Promise<RoleScopeRow[]> {
const tableName = context.escape.tableName('role_scope');
const roleSlugColumn = context.escape.columnName('roleSlug');
const scopeSlugColumn = context.escape.columnName('scopeSlug');
const roleScopeEntries = await context.runQuery<Array<{ roleSlug: string; scopeSlug: string }>>(
`SELECT ${roleSlugColumn}, ${scopeSlugColumn} FROM ${tableName} WHERE ${scopeSlugColumn} = :scopeSlug`,
{ scopeSlug },
);
return roleScopeEntries as RoleScopeRow[];
}
describe('up migration', () => {
it('adds the workflow:publish scope to any project role that has the workflow:update scope', async () => {
const context = createTestMigrationContext(dataSource);
// Insert prerequisite scopes
await insertTestScope(context, {
slug: 'workflow:update',
displayName: 'Update Workflow',
description: 'Allows updating workflows.',
});
await insertTestScope(context, {
slug: 'workflow:read',
displayName: 'Read Workflow',
description: 'Allows reading workflows.',
});
// Insert test roles (unique slugs for test independence)
// test1-project-editor: has workflow:update → should get workflow:publish
await insertTestRole(context, {
slug: 'test1-project-editor',
displayName: 'Test1 Project Editor',
roleType: 'project',
});
// test1-project-admin: has workflow:update + workflow:read → should get workflow:publish
await insertTestRole(context, {
slug: 'test1-project-admin',
displayName: 'Test1 Project Admin',
roleType: 'project',
});
// test1-project-viewer: has workflow:read only → should NOT get workflow:publish
await insertTestRole(context, {
slug: 'test1-project-viewer',
displayName: 'Test1 Project Viewer',
roleType: 'project',
});
// test1-global-admin: has workflow:update but is NOT a project role → should NOT get workflow:publish
await insertTestRole(context, {
slug: 'test1-global-admin',
displayName: 'Test1 Global Admin',
roleType: 'global',
});
// Link roles to scopes
await insertTestRoleScope(context, {
roleSlug: 'test1-project-editor',
scopeSlug: 'workflow:update',
});
await insertTestRoleScope(context, {
roleSlug: 'test1-project-admin',
scopeSlug: 'workflow:update',
});
await insertTestRoleScope(context, {
roleSlug: 'test1-project-admin',
scopeSlug: 'workflow:read',
});
await insertTestRoleScope(context, {
roleSlug: 'test1-project-viewer',
scopeSlug: 'workflow:read',
});
await insertTestRoleScope(context, {
roleSlug: 'test1-global-admin',
scopeSlug: 'workflow:update',
});
// Verify pre-migration state
const editorScopesBefore = await getRoleScopesByRole(context, 'test1-project-editor');
expect(editorScopesBefore).toHaveLength(1);
expect(editorScopesBefore[0].scopeSlug).toBe('workflow:update');
const adminScopesBefore = await getRoleScopesByRole(context, 'test1-project-admin');
expect(adminScopesBefore).toHaveLength(2);
const publishScopesBefore = await getRoleScopesByScope(context, 'workflow:publish');
expect(publishScopesBefore).toHaveLength(0);
// Run migration
await runSingleMigration(MIGRATION_NAME);
// Release old context
await context.queryRunner.release();
// Create fresh context after migration
const postContext = createTestMigrationContext(dataSource);
// Verify post-migration state
// test1-project-editor should have workflow:publish
const editorScopesAfter = await getRoleScopesByRole(postContext, 'test1-project-editor');
expect(editorScopesAfter).toHaveLength(2);
expect(editorScopesAfter.map((s) => s.scopeSlug).sort()).toEqual([
'workflow:publish',
'workflow:update',
]);
// test1-project-admin should have workflow:publish
const adminScopesAfter = await getRoleScopesByRole(postContext, 'test1-project-admin');
expect(adminScopesAfter).toHaveLength(3);
expect(adminScopesAfter.map((s) => s.scopeSlug).sort()).toEqual([
'workflow:publish',
'workflow:read',
'workflow:update',
]);
// test1-project-viewer should NOT have workflow:publish
const viewerScopesAfter = await getRoleScopesByRole(postContext, 'test1-project-viewer');
expect(viewerScopesAfter).toHaveLength(1);
expect(viewerScopesAfter[0].scopeSlug).toBe('workflow:read');
// test1-global-admin should NOT have workflow:publish
const globalAdminScopesAfter = await getRoleScopesByRole(postContext, 'test1-global-admin');
expect(globalAdminScopesAfter).toHaveLength(1);
expect(globalAdminScopesAfter[0].scopeSlug).toBe('workflow:update');
// Cleanup
await postContext.queryRunner.release();
});
it('does nothing if the project role to update has a conflict while updating', async () => {
const context = createTestMigrationContext(dataSource);
// Insert scopes
await insertTestScope(context, {
slug: 'workflow:update',
displayName: 'Update Workflow',
description: 'Allows updating workflows.',
});
await insertTestScope(context, {
slug: 'workflow:publish',
displayName: 'Publish Workflow',
description: 'Allows publishing and unpublishing workflows.',
});
// Insert project role (unique slug for test independence)
await insertTestRole(context, {
slug: 'test2-project-editor-existing',
displayName: 'Test2 Project Editor Existing',
roleType: 'project',
});
// Link role to BOTH workflow:update AND workflow:publish (already has both)
await insertTestRoleScope(context, {
roleSlug: 'test2-project-editor-existing',
scopeSlug: 'workflow:update',
});
await insertTestRoleScope(context, {
roleSlug: 'test2-project-editor-existing',
scopeSlug: 'workflow:publish',
});
// Verify pre-migration: role already has both scopes
const scopesBefore = await getRoleScopesByRole(context, 'test2-project-editor-existing');
expect(scopesBefore).toHaveLength(2);
expect(scopesBefore.map((s) => s.scopeSlug).sort()).toEqual([
'workflow:publish',
'workflow:update',
]);
// Run migration (should handle conflict gracefully)
await runSingleMigration(MIGRATION_NAME);
// Release old context
await context.queryRunner.release();
// Create fresh context after migration
const postContext = createTestMigrationContext(dataSource);
// Verify post-migration: role still has both scopes, no duplicates
const scopesAfter = await getRoleScopesByRole(postContext, 'test2-project-editor-existing');
expect(scopesAfter).toHaveLength(2);
expect(scopesAfter.map((s) => s.scopeSlug).sort()).toEqual([
'workflow:publish',
'workflow:update',
]);
// Verify exactly ONE workflow:publish entry for this role
const publishScopes = scopesAfter.filter((s) => s.scopeSlug === 'workflow:publish');
expect(publishScopes).toHaveLength(1);
// Cleanup
await postContext.queryRunner.release();
});
});
describe('down migration', () => {
it('removes the workflow:publish scope from any project role', async () => {
// First run up migration to set up data
await runSingleMigration(MIGRATION_NAME);
const context = createTestMigrationContext(dataSource);
// Verify workflow:publish entries exist
const publishScopesBefore = await getRoleScopesByScope(context, 'workflow:publish');
expect(publishScopesBefore.length).toBeGreaterThan(0);
// Also verify workflow:update entries exist (should remain after rollback)
const updateScopesBefore = await getRoleScopesByScope(context, 'workflow:update');
expect(updateScopesBefore.length).toBeGreaterThan(0);
await context.queryRunner.release();
// Run rollback
await undoLastSingleMigration();
// Create fresh context after rollback
const postContext = createTestMigrationContext(dataSource);
// Verify workflow:publish entries are removed
const publishScopesAfter = await getRoleScopesByScope(postContext, 'workflow:publish');
expect(publishScopesAfter).toHaveLength(0);
// Verify workflow:update entries remain intact
const updateScopesAfter = await getRoleScopesByScope(postContext, 'workflow:update');
expect(updateScopesAfter.length).toBe(updateScopesBefore.length);
// Cleanup
await postContext.queryRunner.release();
});
});
});
@@ -0,0 +1,619 @@
import {
createTestMigrationContext,
initDbUpToMigration,
runSingleMigration,
undoLastSingleMigration,
type TestMigrationContext,
} from '@n8n/backend-test-utils';
import { DbConnection } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import { nanoid } from 'nanoid';
const MIGRATION_NAME = 'ChangeWorkflowStatisticsFKToNoAction1767018516000';
/**
* Generate parameter placeholders for a given context and count.
* PostgreSQL uses $1, $2, ... while SQLite use ?
*/
function getParamPlaceholders(context: TestMigrationContext, count: number): string {
if (context.isPostgres) {
return Array.from({ length: count }, (_, i) => `$${i + 1}`).join(', ');
}
return Array.from({ length: count }, () => '?').join(', ');
}
/**
* Generate a single parameter placeholder for WHERE clauses
*/
function getParamPlaceholder(context: TestMigrationContext, index = 1): string {
return context.isPostgres ? `$${index}` : '?';
}
describe('ChangeWorkflowStatisticsFKToNoAction Migration', () => {
let dataSource: DataSource;
beforeAll(async () => {
// Initialize DB connection
const dbConnection = Container.get(DbConnection);
await dbConnection.init();
dataSource = Container.get(DataSource);
});
beforeEach(async () => {
// Clear database before each test to ensure isolation
// Note: Migration tests must run sequentially (maxWorkers: 1) to avoid conflicts
const context = createTestMigrationContext(dataSource);
await context.queryRunner.clearDatabase();
await context.queryRunner.release();
// Run migrations up to (but not including) target migration
await initDbUpToMigration(MIGRATION_NAME);
});
afterAll(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.close();
});
/**
* Helper function to insert a test workflow
*/
async function insertTestWorkflow(
context: TestMigrationContext,
workflowId: string,
workflowName = 'Test Workflow',
): Promise<void> {
const tableName = context.escape.tableName('workflow_entity');
const idColumn = context.escape.columnName('id');
const nameColumn = context.escape.columnName('name');
const activeColumn = context.escape.columnName('active');
const nodesColumn = context.escape.columnName('nodes');
const connectionsColumn = context.escape.columnName('connections');
const createdAtColumn = context.escape.columnName('createdAt');
const updatedAtColumn = context.escape.columnName('updatedAt');
const triggerCountColumn = context.escape.columnName('triggerCount');
const versionIdColumn = context.escape.columnName('versionId');
const versionId = nanoid();
await context.runQuery(
`INSERT INTO ${tableName} (${idColumn}, ${nameColumn}, ${activeColumn}, ${nodesColumn}, ${connectionsColumn}, ${createdAtColumn}, ${updatedAtColumn}, ${triggerCountColumn}, ${versionIdColumn}) VALUES (:id, :name, :active, :nodes, :connections, :createdAt, :updatedAt, :triggerCount, :versionId)`,
{
id: workflowId,
name: workflowName,
active: false,
nodes: '[]',
connections: '{}',
createdAt: new Date(),
updatedAt: new Date(),
triggerCount: 0,
versionId,
},
);
}
/**
* Helper function to insert workflow statistics (before migration, without workflowName)
*/
async function insertWorkflowStatistics(
context: TestMigrationContext,
workflowId: string,
name: string,
count: number,
): Promise<void> {
const tableName = context.escape.tableName('workflow_statistics');
const workflowIdColumn = context.escape.columnName('workflowId');
const nameColumn = context.escape.columnName('name');
const countColumn = context.escape.columnName('count');
const latestEventColumn = context.escape.columnName('latestEvent');
const rootCountColumn = context.escape.columnName('rootCount');
await context.runQuery(
`INSERT INTO ${tableName} (${workflowIdColumn}, ${nameColumn}, ${countColumn}, ${latestEventColumn}, ${rootCountColumn}) VALUES (:workflowId, :name, :count, :latestEvent, :rootCount)`,
{ workflowId, name, count, latestEvent: new Date(), rootCount: 0 },
);
}
/**
* Helper function to insert workflow statistics (after migration, with workflowName)
*/
async function insertWorkflowStatisticsWithName(
context: TestMigrationContext,
workflowId: string,
name: string,
count: number,
workflowName: string,
): Promise<void> {
const tableName = context.escape.tableName('workflow_statistics');
const workflowIdColumn = context.escape.columnName('workflowId');
const nameColumn = context.escape.columnName('name');
const countColumn = context.escape.columnName('count');
const latestEventColumn = context.escape.columnName('latestEvent');
const rootCountColumn = context.escape.columnName('rootCount');
const workflowNameColumn = context.escape.columnName('workflowName');
await context.runQuery(
`INSERT INTO ${tableName} (${workflowIdColumn}, ${nameColumn}, ${countColumn}, ${latestEventColumn}, ${rootCountColumn}, ${workflowNameColumn}) VALUES (:workflowId, :name, :count, :latestEvent, :rootCount, :workflowName)`,
{ workflowId, name, count, latestEvent: new Date(), rootCount: 0, workflowName },
);
}
/**
* Helper function to get workflow statistics by workflowId
*/
async function getWorkflowStatistics(
context: TestMigrationContext,
workflowId: string,
): Promise<
Array<{ workflowId: string; name: string; count: number; workflowName?: string | null }>
> {
const tableName = context.escape.tableName('workflow_statistics');
const workflowIdColumn = context.escape.columnName('workflowId');
const nameColumn = context.escape.columnName('name');
const countColumn = context.escape.columnName('count');
// Cast count to integer for consistent return type
const countSelect = context.isPostgres ? `${countColumn}::int` : countColumn;
return await context.runQuery(
`SELECT ${workflowIdColumn}, ${nameColumn}, ${countSelect} as ${countColumn} FROM ${tableName} WHERE ${workflowIdColumn} = :workflowId`,
{ workflowId },
);
}
/**
* Helper function to get workflow statistics by workflowId (after migration, includes workflowName)
*/
async function getWorkflowStatisticsWithName(
context: TestMigrationContext,
workflowId: string,
): Promise<
Array<{ workflowId: string; name: string; count: number; workflowName: string | null }>
> {
const tableName = context.escape.tableName('workflow_statistics');
const workflowIdColumn = context.escape.columnName('workflowId');
const nameColumn = context.escape.columnName('name');
const countColumn = context.escape.columnName('count');
const workflowNameColumn = context.escape.columnName('workflowName');
// Cast count to integer for consistent return type
const countSelect = context.isPostgres ? `${countColumn}::int` : countColumn;
return await context.runQuery(
`SELECT ${workflowIdColumn}, ${nameColumn}, ${countSelect} as ${countColumn}, ${workflowNameColumn} FROM ${tableName} WHERE ${workflowIdColumn} = :workflowId`,
{ workflowId },
);
}
/**
* Helper function to get workflow statistics by name (for finding orphaned rows)
*/
async function getWorkflowStatisticsByName(
context: TestMigrationContext,
name: string,
): Promise<
Array<{ workflowId: string | null; name: string; count: number; workflowName: string | null }>
> {
const tableName = context.escape.tableName('workflow_statistics');
const workflowIdColumn = context.escape.columnName('workflowId');
const nameColumn = context.escape.columnName('name');
const countColumn = context.escape.columnName('count');
const workflowNameColumn = context.escape.columnName('workflowName');
// Cast count to integer for consistent return type
const countSelect = context.isPostgres ? `${countColumn}::int` : countColumn;
return await context.runQuery(
`SELECT ${workflowIdColumn}, ${nameColumn}, ${countSelect} as ${countColumn}, ${workflowNameColumn} FROM ${tableName} WHERE ${nameColumn} = :name`,
{ name },
);
}
/**
* Helper function to delete a workflow
*/
async function deleteWorkflow(context: TestMigrationContext, workflowId: string): Promise<void> {
const tableName = context.escape.tableName('workflow_entity');
const idColumn = context.escape.columnName('id');
const placeholder = getParamPlaceholder(context);
await context.queryRunner.query(`DELETE FROM ${tableName} WHERE ${idColumn} = ${placeholder}`, [
workflowId,
]);
}
it('should preserve statistics after workflow deletion (no FK constraint) and CASCADE after rollback', async () => {
// Debug: Check schema BEFORE migration
const contextBefore = createTestMigrationContext(dataSource);
await contextBefore.queryRunner.release();
// Run the actual migration
await runSingleMigration(MIGRATION_NAME);
// Create context AFTER migration runs (since runSingleMigration reinitializes the connection)
dataSource = Container.get(DataSource);
let context = createTestMigrationContext(dataSource);
// Test that statistics are preserved when workflow is deleted (no FK constraint behavior)
const testWorkflowId = nanoid();
const uniqueStatName = `test_stat_${nanoid()}`;
const testWorkflowName = 'My Test Workflow';
await insertTestWorkflow(context, testWorkflowId, testWorkflowName);
await insertWorkflowStatisticsWithName(
context,
testWorkflowId,
uniqueStatName,
3,
testWorkflowName,
);
// Verify statistics exist
const beforeDelete = await getWorkflowStatistics(context, testWorkflowId);
expect(beforeDelete).toHaveLength(1);
expect(beforeDelete[0].workflowId).toBe(testWorkflowId);
// Delete workflow - statistics should remain unchanged (no FK constraint)
await deleteWorkflow(context, testWorkflowId);
// Verify statistics still exist with the same workflowId (orphaned reference)
// workflowName should be preserved for identifying the deleted workflow
const afterDelete = await getWorkflowStatisticsByName(context, uniqueStatName);
expect(afterDelete).toHaveLength(1);
expect(afterDelete[0].workflowId).toBe(testWorkflowId); // workflowId is unchanged
expect(afterDelete[0].count).toBe(3);
expect(afterDelete[0].workflowName).toBe(testWorkflowName);
// Cleanup statistics for this test
const statsTable = context.escape.tableName('workflow_statistics');
const nameCol = context.escape.columnName('name');
const placeholder = getParamPlaceholder(context);
await context.queryRunner.query(`DELETE FROM ${statsTable} WHERE ${nameCol} = ${placeholder}`, [
uniqueStatName,
]);
await context.queryRunner.release();
// Rollback the migration
await undoLastSingleMigration();
// Create new context after rollback
dataSource = Container.get(DataSource);
context = createTestMigrationContext(dataSource);
// Test CASCADE behavior after rollback
const cascadeWorkflowId = nanoid();
await insertTestWorkflow(context, cascadeWorkflowId);
await insertWorkflowStatistics(context, cascadeWorkflowId, 'manual_success', 2);
// Verify statistics exist
const beforeCascadeDelete = await getWorkflowStatistics(context, cascadeWorkflowId);
expect(beforeCascadeDelete).toHaveLength(1);
// Delete workflow - should CASCADE
await deleteWorkflow(context, cascadeWorkflowId);
// Verify statistics were deleted
const afterCascadeDelete = await getWorkflowStatistics(context, cascadeWorkflowId);
expect(afterCascadeDelete).toHaveLength(0);
await context.queryRunner.release();
});
it('should preserve statistics with different workflowIds after deleting multiple workflows', async () => {
// Run the actual migration
await runSingleMigration(MIGRATION_NAME);
// Create context AFTER migration runs
dataSource = Container.get(DataSource);
const context = createTestMigrationContext(dataSource);
// Create multiple workflows with the same statistic name but different workflow names
const workflowId1 = nanoid();
const workflowId2 = nanoid();
const workflowId3 = nanoid();
const workflowName1 = 'Workflow Alpha';
const workflowName2 = 'Workflow Beta';
const workflowName3 = 'Workflow Gamma';
await insertTestWorkflow(context, workflowId1, workflowName1);
await insertTestWorkflow(context, workflowId2, workflowName2);
await insertTestWorkflow(context, workflowId3, workflowName3);
// All workflows have 'manual_success' statistics (inserted after migration with workflowName)
await insertWorkflowStatisticsWithName(
context,
workflowId1,
'manual_success',
10,
workflowName1,
);
await insertWorkflowStatisticsWithName(
context,
workflowId2,
'manual_success',
20,
workflowName2,
);
await insertWorkflowStatisticsWithName(
context,
workflowId3,
'manual_success',
30,
workflowName3,
);
// Delete all workflows - statistics should remain unchanged (no FK constraint)
await deleteWorkflow(context, workflowId1);
await deleteWorkflow(context, workflowId2);
await deleteWorkflow(context, workflowId3);
// Verify all statistics still exist with their original workflowId values (orphaned references)
const orphanedStats = await getWorkflowStatisticsByName(context, 'manual_success');
expect(orphanedStats.length).toBeGreaterThanOrEqual(3);
// Find our specific test statistics by workflowId
const stat1 = orphanedStats.find((s) => s.workflowId === workflowId1);
const stat2 = orphanedStats.find((s) => s.workflowId === workflowId2);
const stat3 = orphanedStats.find((s) => s.workflowId === workflowId3);
// Verify workflowIds are preserved (not set to NULL)
expect(stat1).toBeDefined();
expect(stat1?.workflowId).toBe(workflowId1);
expect(stat1?.count).toBe(10);
expect(stat1?.workflowName).toBe(workflowName1);
expect(stat2).toBeDefined();
expect(stat2?.workflowId).toBe(workflowId2);
expect(stat2?.count).toBe(20);
expect(stat2?.workflowName).toBe(workflowName2);
expect(stat3).toBeDefined();
expect(stat3?.workflowId).toBe(workflowId3);
expect(stat3?.count).toBe(30);
expect(stat3?.workflowName).toBe(workflowName3);
// Cleanup - delete our test statistics
const statsTable = context.escape.tableName('workflow_statistics');
const workflowIdCol = context.escape.columnName('workflowId');
const placeholder = getParamPlaceholder(context);
await context.queryRunner.query(
`DELETE FROM ${statsTable} WHERE ${workflowIdCol} IN (${placeholder}, ${getParamPlaceholder(context, 2)}, ${getParamPlaceholder(context, 3)})`,
[workflowId1, workflowId2, workflowId3],
);
await context.queryRunner.release();
// Rollback for next test
await undoLastSingleMigration();
});
it('should preserve existing workflow statistics data and populate workflowName during migration', async () => {
// The database is already in pre-migration state from the previous test's rollback
let context = createTestMigrationContext(dataSource);
const workflowId = nanoid();
const testWorkflowName = 'Statistics Test Workflow';
// Create workflow and statistics before migration
await insertTestWorkflow(context, workflowId, testWorkflowName);
await insertWorkflowStatistics(context, workflowId, 'manual_success', 100);
await insertWorkflowStatistics(context, workflowId, 'production_success', 200);
await insertWorkflowStatistics(context, workflowId, 'production_error', 5);
// Verify initial data (workflowName column doesn't exist yet)
const beforeMigration = await getWorkflowStatistics(context, workflowId);
expect(beforeMigration).toHaveLength(3);
await context.queryRunner.release();
// Run the actual migration
await runSingleMigration(MIGRATION_NAME);
// Create new context after migration (since runSingleMigration reinitializes the connection)
dataSource = Container.get(DataSource);
context = createTestMigrationContext(dataSource);
// Verify data is preserved after migration and workflowName is populated
const afterMigration = await getWorkflowStatisticsWithName(context, workflowId);
expect(afterMigration).toHaveLength(3);
// Verify specific values including workflowName
const manualSuccess = afterMigration.find((s) => s.name === 'manual_success');
const productionSuccess = afterMigration.find((s) => s.name === 'production_success');
const productionError = afterMigration.find((s) => s.name === 'production_error');
expect(manualSuccess?.count).toBe(100);
expect(manualSuccess?.workflowName).toBe(testWorkflowName);
expect(productionSuccess?.count).toBe(200);
expect(productionSuccess?.workflowName).toBe(testWorkflowName);
expect(productionError?.count).toBe(5);
expect(productionError?.workflowName).toBe(testWorkflowName);
// Cleanup - delete statistics first (they have FK), then workflow
const statsTable = context.escape.tableName('workflow_statistics');
const workflowIdCol = context.escape.columnName('workflowId');
const placeholder = getParamPlaceholder(context);
await context.queryRunner.query(
`DELETE FROM ${statsTable} WHERE ${workflowIdCol} = ${placeholder}`,
[workflowId],
);
await deleteWorkflow(context, workflowId);
await context.queryRunner.release();
});
it('should delete orphaned statistics during rollback before restoring FK constraint', async () => {
// Run the migration first (beforeEach only runs up to, not including, the target migration)
await runSingleMigration(MIGRATION_NAME);
// Create context AFTER migration runs
dataSource = Container.get(DataSource);
let context = createTestMigrationContext(dataSource);
// Create workflows with statistics using unique stat names to avoid conflicts
const orphanedWorkflowId1 = nanoid();
const orphanedWorkflowId2 = nanoid();
const keepWorkflowId = nanoid();
const uniqueStatName1 = `orphan_stat_1_${nanoid()}`;
const uniqueStatName2 = `orphan_stat_2_${nanoid()}`;
const uniqueStatName3 = `keep_stat_${nanoid()}`;
await insertTestWorkflow(context, orphanedWorkflowId1, 'Workflow to Delete 1');
await insertTestWorkflow(context, orphanedWorkflowId2, 'Workflow to Delete 2');
await insertTestWorkflow(context, keepWorkflowId, 'Workflow to Keep');
await insertWorkflowStatisticsWithName(
context,
orphanedWorkflowId1,
uniqueStatName1,
100,
'Workflow to Delete 1',
);
await insertWorkflowStatisticsWithName(
context,
orphanedWorkflowId2,
uniqueStatName2,
200,
'Workflow to Delete 2',
);
await insertWorkflowStatisticsWithName(
context,
keepWorkflowId,
uniqueStatName3,
300,
'Workflow to Keep',
);
// Delete some workflows to create orphaned statistics
await deleteWorkflow(context, orphanedWorkflowId1);
await deleteWorkflow(context, orphanedWorkflowId2);
// Verify orphaned statistics still exist after workflow deletion (no FK constraint)
const beforeRollback1 = await getWorkflowStatisticsByName(context, uniqueStatName1);
expect(beforeRollback1).toHaveLength(1);
expect(beforeRollback1[0].workflowId).toBe(orphanedWorkflowId1);
const beforeRollback2 = await getWorkflowStatisticsByName(context, uniqueStatName2);
expect(beforeRollback2).toHaveLength(1);
expect(beforeRollback2[0].workflowId).toBe(orphanedWorkflowId2);
// Verify non-orphaned statistics still exist
const beforeRollbackKeep = await getWorkflowStatistics(context, keepWorkflowId);
expect(beforeRollbackKeep).toHaveLength(1);
await context.queryRunner.release();
// Rollback the migration - orphaned statistics should be deleted
await undoLastSingleMigration();
// Create new context after rollback
dataSource = Container.get(DataSource);
context = createTestMigrationContext(dataSource);
// Verify orphaned statistics were deleted during rollback (workflowName column no longer exists)
const statsTable = context.escape.tableName('workflow_statistics');
const nameCol = context.escape.columnName('name');
const workflowIdCol = context.escape.columnName('workflowId');
const placeholder = getParamPlaceholder(context);
const afterRollback1 = await context.queryRunner.query(
`SELECT ${workflowIdCol} as "workflowId" FROM ${statsTable} WHERE ${nameCol} = ${placeholder}`,
[uniqueStatName1],
);
expect(afterRollback1).toHaveLength(0);
const afterRollback2 = await context.queryRunner.query(
`SELECT ${workflowIdCol} as "workflowId" FROM ${statsTable} WHERE ${nameCol} = ${placeholder}`,
[uniqueStatName2],
);
expect(afterRollback2).toHaveLength(0);
// Verify non-orphaned statistics still exist after rollback
const afterRollbackKeep = await getWorkflowStatistics(context, keepWorkflowId);
expect(afterRollbackKeep).toHaveLength(1);
expect(afterRollbackKeep[0].workflowId).toBe(keepWorkflowId);
expect(afterRollbackKeep[0].count).toBe(300);
// Cleanup - reuse the existing variable declarations
await context.queryRunner.query(
`DELETE FROM ${statsTable} WHERE ${workflowIdCol} = ${placeholder}`,
[keepWorkflowId],
);
await deleteWorkflow(context, keepWorkflowId);
await context.queryRunner.release();
});
it('should reset overflowing values to 0 during rollback before converting BIGINT to INTEGER (PostgreSQL only)', async () => {
// Run the migration to enable BIGINT columns
await runSingleMigration(MIGRATION_NAME);
// Create context AFTER migration runs
dataSource = Container.get(DataSource);
let context = createTestMigrationContext(dataSource);
// Skip this test for SQLite - SQLite handles INTEGER overflow differently
if (!context.isPostgres) {
await context.queryRunner.release();
return;
}
const workflowId = nanoid();
const testWorkflowName = 'Overflow Test Workflow';
const uniqueStatName = `overflow_stat_${nanoid()}`;
await insertTestWorkflow(context, workflowId, testWorkflowName);
// Insert statistics with values exceeding INTEGER max (2147483647)
const tableName = context.escape.tableName('workflow_statistics');
const workflowIdColumn = context.escape.columnName('workflowId');
const nameColumn = context.escape.columnName('name');
const countColumn = context.escape.columnName('count');
const rootCountColumn = context.escape.columnName('rootCount');
const latestEventColumn = context.escape.columnName('latestEvent');
const workflowNameColumn = context.escape.columnName('workflowName');
const overflowValue = '3000000000'; // Exceeds INTEGER max
const placeholders = getParamPlaceholders(context, 6);
await context.queryRunner.query(
`INSERT INTO ${tableName} (${workflowIdColumn}, ${nameColumn}, ${countColumn}, ${rootCountColumn}, ${latestEventColumn}, ${workflowNameColumn}) VALUES (${placeholders})`,
[workflowId, uniqueStatName, overflowValue, overflowValue, new Date(), testWorkflowName],
);
// Verify the large values were inserted
const placeholder = getParamPlaceholder(context);
const beforeRollback = await context.queryRunner.query(
`SELECT ${countColumn} as "count", ${rootCountColumn} as "rootCount" FROM ${tableName} WHERE ${nameColumn} = ${placeholder}`,
[uniqueStatName],
);
expect(beforeRollback).toHaveLength(1);
expect(Number(beforeRollback[0].count)).toBeGreaterThan(2147483647);
expect(Number(beforeRollback[0].rootCount)).toBeGreaterThan(2147483647);
await context.queryRunner.release();
// Rollback the migration - should reset overflowing values to 0 on PostgreSQL
await undoLastSingleMigration();
// Create new context after rollback
dataSource = Container.get(DataSource);
context = createTestMigrationContext(dataSource);
// Verify values were reset to 0 on PostgreSQL (preventing overflow errors)
const afterRollback = await context.queryRunner.query(
`SELECT ${countColumn} as "count", ${rootCountColumn} as "rootCount" FROM ${tableName} WHERE ${nameColumn} = ${placeholder}`,
[uniqueStatName],
);
expect(afterRollback).toHaveLength(1);
expect(afterRollback[0].count).toBe(0);
expect(afterRollback[0].rootCount).toBe(0);
// Cleanup
await context.queryRunner.query(
`DELETE FROM ${tableName} WHERE ${nameColumn} = ${placeholder}`,
[uniqueStatName],
);
await deleteWorkflow(context, workflowId);
await context.queryRunner.release();
});
});
@@ -0,0 +1,545 @@
import {
createTestMigrationContext,
initDbUpToMigration,
runSingleMigration,
type TestMigrationContext,
} from '@n8n/backend-test-utils';
import { DbConnection } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import { nanoid } from 'nanoid';
const MIGRATION_NAME = 'ExpandSubjectIDColumnLength1769784356000';
interface DynamicCredentialEntry {
credential_id: string;
subject_id: string;
resolver_id: string;
data: string;
}
/**
* Generate parameter placeholders for a given context and count.
* PostgreSQL uses $1, $2, ... while MySQL/SQLite use ?
*/
function getParamPlaceholders(context: TestMigrationContext, count: number): string {
if (context.isPostgres) {
return Array.from({ length: count }, (_, i) => `$${i + 1}`).join(', ');
}
return Array.from({ length: count }, () => '?').join(', ');
}
/**
* Generate a single parameter placeholder for WHERE clauses
*/
function getParamPlaceholder(context: TestMigrationContext, index = 1): string {
return context.isPostgres ? `$${index}` : '?';
}
describe('ExpandSubjectIDColumnLength Migration', () => {
let dataSource: DataSource;
beforeEach(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.init();
dataSource = Container.get(DataSource);
const context = createTestMigrationContext(dataSource);
await context.queryRunner.clearDatabase();
await initDbUpToMigration(MIGRATION_NAME);
});
afterEach(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.close();
});
/**
* Helper to get column data type from database schema
*/
async function getColumnType(
context: TestMigrationContext,
tableName: string,
columnName: string,
): Promise<string> {
if (context.isPostgres) {
const result = await context.queryRunner.query(
`SELECT data_type, character_maximum_length
FROM information_schema.columns
WHERE table_name = $1 AND column_name = $2`,
[`${context.tablePrefix}${tableName}`, columnName],
);
return `${result[0]?.data_type}(${result[0]?.character_maximum_length})`;
} else if (context.isSqlite) {
const result = await context.queryRunner.query(
`PRAGMA table_info(${context.escape.tableName(tableName)})`,
);
const column = result.find((col: { name: string }) => col.name === columnName);
return column?.type || 'unknown';
}
return 'unknown';
}
/**
* Helper to check if primary key constraint exists
*/
async function getPrimaryKeyColumns(
context: TestMigrationContext,
tableName: string,
): Promise<string[]> {
if (context.isPostgres) {
const result = await context.queryRunner.query(
`SELECT a.attname as column_name
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
[`${context.tablePrefix}${tableName}`],
);
return result.map((row: { column_name: string }) => row.column_name);
} else if (context.isSqlite) {
const result = await context.queryRunner.query(
`PRAGMA table_info(${context.escape.tableName(tableName)})`,
);
return result
.filter((col: { pk: number }) => col.pk > 0)
.sort((a: { pk: number }, b: { pk: number }) => a.pk - b.pk)
.map((col: { name: string }) => col.name);
}
return [];
}
/**
* Helper to check if foreign key constraints exist
*/
async function getForeignKeys(
context: TestMigrationContext,
tableName: string,
): Promise<Array<{ from: string; table: string; to: string }>> {
if (context.isPostgres) {
const result = await context.queryRunner.query(
`SELECT
kcu.column_name as "from",
ccu.table_name as "table",
ccu.column_name as "to"
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage AS ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = $1`,
[`${context.tablePrefix}${tableName}`],
);
return result.map((row: { from: string; table: string; to: string }) => ({
from: row.from,
table: row.table.replace(context.tablePrefix, ''),
to: row.to,
}));
} else if (context.isSqlite) {
const result = await context.queryRunner.query(
`PRAGMA foreign_key_list(${context.escape.tableName(tableName)})`,
);
return result.map((fk: { from: string; table: string; to: string }) => ({
from: fk.from,
table: fk.table,
to: fk.to,
}));
}
return [];
}
/**
* Helper to insert test credentials (prerequisite)
*/
async function insertTestCredential(
context: TestMigrationContext,
credentialId: string,
): Promise<void> {
const tableName = context.escape.tableName('credentials_entity');
const idColumn = context.escape.columnName('id');
const nameColumn = context.escape.columnName('name');
const dataColumn = context.escape.columnName('data');
const typeColumn = context.escape.columnName('type');
const createdAtColumn = context.escape.columnName('createdAt');
const updatedAtColumn = context.escape.columnName('updatedAt');
const placeholders = getParamPlaceholders(context, 6);
await context.queryRunner.query(
`INSERT INTO ${tableName} (${idColumn}, ${nameColumn}, ${dataColumn}, ${typeColumn}, ${createdAtColumn}, ${updatedAtColumn})
VALUES (${placeholders})`,
[credentialId, 'Test Credential', '{}', 'testType', new Date(), new Date()],
);
}
/**
* Helper to insert test resolver (prerequisite)
*/
async function insertTestResolver(
context: TestMigrationContext,
resolverId: string,
): Promise<void> {
const tableName = context.escape.tableName('dynamic_credential_resolver');
const idColumn = context.escape.columnName('id');
const nameColumn = context.escape.columnName('name');
const typeColumn = context.escape.columnName('type');
const configColumn = context.escape.columnName('config');
const createdAtColumn = context.escape.columnName('createdAt');
const updatedAtColumn = context.escape.columnName('updatedAt');
const placeholders = getParamPlaceholders(context, 6);
await context.queryRunner.query(
`INSERT INTO ${tableName} (${idColumn}, ${nameColumn}, ${typeColumn}, ${configColumn}, ${createdAtColumn}, ${updatedAtColumn})
VALUES (${placeholders})`,
[resolverId, 'Test Resolver', 'TestResolverClass', '', new Date(), new Date()],
);
}
/**
* Helper to insert dynamic credential entry
*/
async function insertDynamicCredentialEntry(
context: TestMigrationContext,
entry: DynamicCredentialEntry,
): Promise<void> {
const tableName = context.escape.tableName('dynamic_credential_entry');
const credentialIdColumn = context.escape.columnName('credential_id');
const subjectIdColumn = context.escape.columnName('subject_id');
const resolverIdColumn = context.escape.columnName('resolver_id');
const dataColumn = context.escape.columnName('data');
const createdAtColumn = context.escape.columnName('createdAt');
const updatedAtColumn = context.escape.columnName('updatedAt');
const placeholders = getParamPlaceholders(context, 6);
await context.queryRunner.query(
`INSERT INTO ${tableName} (${credentialIdColumn}, ${subjectIdColumn}, ${resolverIdColumn}, ${dataColumn}, ${createdAtColumn}, ${updatedAtColumn})
VALUES (${placeholders})`,
[
entry.credential_id,
entry.subject_id,
entry.resolver_id,
entry.data,
new Date(),
new Date(),
],
);
}
/**
* Helper to get dynamic credential entries
*/
async function getDynamicCredentialEntry(
context: TestMigrationContext,
credentialId: string,
subjectId: string,
resolverId: string,
): Promise<DynamicCredentialEntry | null> {
const tableName = context.escape.tableName('dynamic_credential_entry');
const credentialIdColumn = context.escape.columnName('credential_id');
const subjectIdColumn = context.escape.columnName('subject_id');
const resolverIdColumn = context.escape.columnName('resolver_id');
const dataColumn = context.escape.columnName('data');
const result = await context.queryRunner.query(
`SELECT ${credentialIdColumn} as credential_id,
${subjectIdColumn} as subject_id,
${resolverIdColumn} as resolver_id,
${dataColumn} as data
FROM ${tableName}
WHERE ${credentialIdColumn} = ${getParamPlaceholder(context, 1)}
AND ${subjectIdColumn} = ${getParamPlaceholder(context, 2)}
AND ${resolverIdColumn} = ${getParamPlaceholder(context, 3)}`,
[credentialId, subjectId, resolverId],
);
return result[0] || null;
}
describe('up migration', () => {
it('should preserve all data during migration', async () => {
const context = createTestMigrationContext(dataSource);
// Create test data
const credentialId = nanoid(16);
const resolverId = nanoid(16);
// Test with various subject_id values
const testEntries: DynamicCredentialEntry[] = [
{
credential_id: credentialId,
subject_id: 'short-id',
resolver_id: resolverId,
data: JSON.stringify({ test: 'data1' }),
},
{
credential_id: credentialId,
subject_id: '1234567890123456', // Max length for varchar(16)
resolver_id: resolverId,
data: JSON.stringify({ test: 'data2' }),
},
];
// Insert prerequisites
await insertTestCredential(context, credentialId);
await insertTestResolver(context, resolverId);
// Insert test entries
for (const entry of testEntries) {
await insertDynamicCredentialEntry(context, entry);
}
// Verify pre-migration data
const beforeMigration1 = await getDynamicCredentialEntry(
context,
testEntries[0].credential_id,
testEntries[0].subject_id,
testEntries[0].resolver_id,
);
expect(beforeMigration1).toBeDefined();
expect(beforeMigration1?.data).toBe(testEntries[0].data);
const beforeMigration2 = await getDynamicCredentialEntry(
context,
testEntries[1].credential_id,
testEntries[1].subject_id,
testEntries[1].resolver_id,
);
expect(beforeMigration2).toBeDefined();
expect(beforeMigration2?.data).toBe(testEntries[1].data);
await context.queryRunner.release();
// Run migration
await runSingleMigration(MIGRATION_NAME);
// Create fresh context after migration
const postContext = createTestMigrationContext(dataSource);
// Verify data is preserved
const afterMigration1 = await getDynamicCredentialEntry(
postContext,
testEntries[0].credential_id,
testEntries[0].subject_id,
testEntries[0].resolver_id,
);
expect(afterMigration1).toBeDefined();
expect(afterMigration1?.subject_id).toBe(testEntries[0].subject_id);
expect(afterMigration1?.data).toBe(testEntries[0].data);
const afterMigration2 = await getDynamicCredentialEntry(
postContext,
testEntries[1].credential_id,
testEntries[1].subject_id,
testEntries[1].resolver_id,
);
expect(afterMigration2).toBeDefined();
expect(afterMigration2?.subject_id).toBe(testEntries[1].subject_id);
expect(afterMigration2?.data).toBe(testEntries[1].data);
await postContext.queryRunner.release();
});
it('should change subject_id column type from varchar(16) to varchar(2048)', async () => {
await runSingleMigration(MIGRATION_NAME);
const context = createTestMigrationContext(dataSource);
// Check column type after migration
const columnType = await getColumnType(context, 'dynamic_credential_entry', 'subject_id');
if (context.isPostgres) {
expect(columnType).toBe('character varying(2048)');
} else if (context.isSqlite) {
expect(columnType.toUpperCase()).toBe('VARCHAR(2048)');
}
await context.queryRunner.release();
});
it('should preserve composite primary key constraint', async () => {
await runSingleMigration(MIGRATION_NAME);
const context = createTestMigrationContext(dataSource);
// Check primary key columns
const pkColumns = await getPrimaryKeyColumns(context, 'dynamic_credential_entry');
expect(pkColumns).toHaveLength(3);
expect(pkColumns).toContain('credential_id');
expect(pkColumns).toContain('subject_id');
expect(pkColumns).toContain('resolver_id');
await context.queryRunner.release();
});
it('should preserve foreign key constraints', async () => {
await runSingleMigration(MIGRATION_NAME);
const context = createTestMigrationContext(dataSource);
// Check foreign keys
const foreignKeys = await getForeignKeys(context, 'dynamic_credential_entry');
// Should have 2 foreign keys
expect(foreignKeys.length).toBeGreaterThanOrEqual(2);
// Check credential_id FK
const credentialFk = foreignKeys.find((fk) => fk.from === 'credential_id');
expect(credentialFk).toBeDefined();
expect(credentialFk?.table).toBe('credentials_entity');
expect(credentialFk?.to).toBe('id');
// Check resolver_id FK
const resolverFk = foreignKeys.find((fk) => fk.from === 'resolver_id');
expect(resolverFk).toBeDefined();
expect(resolverFk?.table).toBe('dynamic_credential_resolver');
expect(resolverFk?.to).toBe('id');
await context.queryRunner.release();
});
it('should allow inserting subject_id values longer than 16 characters after migration', async () => {
await runSingleMigration(MIGRATION_NAME);
const context = createTestMigrationContext(dataSource);
const credentialId = nanoid(16);
const resolverId = nanoid(16);
await insertTestCredential(context, credentialId);
await insertTestResolver(context, resolverId);
// Insert entry with long subject_id (> 16 characters)
const longSubjectId = 'this-is-a-very-long-subject-id-that-exceeds-16-characters-by-a-lot';
const entry: DynamicCredentialEntry = {
credential_id: credentialId,
subject_id: longSubjectId,
resolver_id: resolverId,
data: JSON.stringify({ test: 'long-id-data' }),
};
await insertDynamicCredentialEntry(context, entry);
// Verify the long subject_id was stored correctly
const retrieved = await getDynamicCredentialEntry(
context,
credentialId,
longSubjectId,
resolverId,
);
expect(retrieved).toBeDefined();
expect(retrieved?.subject_id).toBe(longSubjectId);
expect(retrieved?.subject_id.length).toBeGreaterThan(16);
await context.queryRunner.release();
});
it('should maintain primary key uniqueness constraint', async () => {
await runSingleMigration(MIGRATION_NAME);
const context = createTestMigrationContext(dataSource);
const credentialId = nanoid(16);
const resolverId = nanoid(16);
const subjectId = 'duplicate-test-id';
await insertTestCredential(context, credentialId);
await insertTestResolver(context, resolverId);
// Insert first entry
await insertDynamicCredentialEntry(context, {
credential_id: credentialId,
subject_id: subjectId,
resolver_id: resolverId,
data: JSON.stringify({ test: 'first' }),
});
// Try to insert duplicate - should fail
await expect(
insertDynamicCredentialEntry(context, {
credential_id: credentialId,
subject_id: subjectId,
resolver_id: resolverId,
data: JSON.stringify({ test: 'duplicate' }),
}),
).rejects.toThrow();
await context.queryRunner.release();
});
it('should copy all data correctly in batches (101 rows to test batch copying)', async () => {
const context = createTestMigrationContext(dataSource);
const credentialId = nanoid(16);
const resolverId = nanoid(16);
await insertTestCredential(context, credentialId);
await insertTestResolver(context, resolverId);
// Generate 101 test entries to verify batch copying (copyTable uses batches of 10)
const testEntries: DynamicCredentialEntry[] = [];
for (let i = 0; i < 101; i++) {
testEntries.push({
credential_id: credentialId,
subject_id: `subject-${i.toString().padStart(3, '0')}`,
resolver_id: resolverId,
data: JSON.stringify({ index: i, test: `data-${i}` }),
});
}
// Insert all test entries before migration
for (const entry of testEntries) {
await insertDynamicCredentialEntry(context, entry);
}
// Verify all entries exist before migration
const tableName = context.escape.tableName('dynamic_credential_entry');
const credentialIdColumn = context.escape.columnName('credential_id');
const placeholder = getParamPlaceholder(context);
const beforeCount = await context.queryRunner.query(
`SELECT COUNT(*) as count FROM ${tableName} WHERE ${credentialIdColumn} = ${placeholder}`,
[credentialId],
);
expect(Number(beforeCount[0].count)).toBe(101);
await context.queryRunner.release();
// Run migration
await runSingleMigration(MIGRATION_NAME);
// Create fresh context after migration
const postContext = createTestMigrationContext(dataSource);
// Verify all 101 entries still exist after migration
const afterCount = await postContext.queryRunner.query(
`SELECT COUNT(*) as count FROM ${tableName} WHERE ${credentialIdColumn} = ${placeholder}`,
[credentialId],
);
expect(Number(afterCount[0].count)).toBe(101);
// Verify each entry's data is intact
for (const originalEntry of testEntries) {
const retrieved = await getDynamicCredentialEntry(
postContext,
originalEntry.credential_id,
originalEntry.subject_id,
originalEntry.resolver_id,
);
expect(retrieved).toBeDefined();
expect(retrieved?.subject_id).toBe(originalEntry.subject_id);
expect(retrieved?.data).toBe(originalEntry.data);
// Verify the parsed data
const parsedData = JSON.parse(retrieved?.data || '{}');
const originalData = JSON.parse(originalEntry.data);
expect(parsedData.index).toBe(originalData.index);
expect(parsedData.test).toBe(originalData.test);
}
await postContext.queryRunner.release();
});
});
});
@@ -0,0 +1,364 @@
import {
createTestMigrationContext,
initDbUpToMigration,
runSingleMigration,
undoLastSingleMigration,
type TestMigrationContext,
} from '@n8n/backend-test-utils';
import { DbConnection } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
const MIGRATION_NAME = 'AddWorkflowUnpublishScopeToCustomRoles1769900001000';
interface ScopeData {
slug: string;
displayName: string;
description: string;
}
interface RoleData {
slug: string;
displayName: string;
roleType: string;
systemRole?: boolean;
}
interface RoleScopeData {
roleSlug: string;
scopeSlug: string;
}
interface RoleScopeRow {
roleSlug: string;
scopeSlug: string;
}
describe('AddWorkflowUnpublishScopeToCustomRoles Migration', () => {
let dataSource: DataSource;
beforeEach(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.init();
dataSource = Container.get(DataSource);
const context = createTestMigrationContext(dataSource);
await context.queryRunner.clearDatabase();
await initDbUpToMigration(MIGRATION_NAME);
});
afterEach(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.close();
});
async function insertTestScope(
context: TestMigrationContext,
scopeData: ScopeData,
): Promise<void> {
const tableName = context.escape.tableName('scope');
const slugColumn = context.escape.columnName('slug');
const displayNameColumn = context.escape.columnName('displayName');
const descriptionColumn = context.escape.columnName('description');
const existingScope = await context.runQuery<unknown[]>(
`SELECT ${slugColumn} FROM ${tableName} WHERE ${slugColumn} = :slug`,
{ slug: scopeData.slug },
);
if (existingScope.length === 0) {
await context.runQuery(
`INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${descriptionColumn}) VALUES (:slug, :displayName, :description)`,
{
slug: scopeData.slug,
displayName: scopeData.displayName,
description: scopeData.description,
},
);
}
}
async function insertTestRole(context: TestMigrationContext, roleData: RoleData): Promise<void> {
const tableName = context.escape.tableName('role');
const slugColumn = context.escape.columnName('slug');
const displayNameColumn = context.escape.columnName('displayName');
const roleTypeColumn = context.escape.columnName('roleType');
const systemRoleColumn = context.escape.columnName('systemRole');
const createdAtColumn = context.escape.columnName('createdAt');
const updatedAtColumn = context.escape.columnName('updatedAt');
const systemRole = roleData.systemRole ?? false;
const insertSql = context.isPostgres
? `INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${roleTypeColumn}, ${systemRoleColumn}, ${createdAtColumn}, ${updatedAtColumn}) VALUES (:slug, :displayName, :roleType, :systemRole, :createdAt, :updatedAt) ON CONFLICT (${slugColumn}) DO NOTHING`
: `INSERT OR IGNORE INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${roleTypeColumn}, ${systemRoleColumn}, ${createdAtColumn}, ${updatedAtColumn}) VALUES (:slug, :displayName, :roleType, :systemRole, :createdAt, :updatedAt)`;
await context.runQuery(insertSql, {
slug: roleData.slug,
displayName: roleData.displayName,
roleType: roleData.roleType,
systemRole,
createdAt: new Date(),
updatedAt: new Date(),
});
}
async function insertTestRoleScope(
context: TestMigrationContext,
roleScopeData: RoleScopeData,
): Promise<void> {
const tableName = context.escape.tableName('role_scope');
const roleSlugColumn = context.escape.columnName('roleSlug');
const scopeSlugColumn = context.escape.columnName('scopeSlug');
await context.runQuery(
`INSERT INTO ${tableName} (${roleSlugColumn}, ${scopeSlugColumn}) VALUES (:roleSlug, :scopeSlug)`,
{ roleSlug: roleScopeData.roleSlug, scopeSlug: roleScopeData.scopeSlug },
);
}
async function getRoleScopesByRole(
context: TestMigrationContext,
roleSlug: string,
): Promise<RoleScopeRow[]> {
const tableName = context.escape.tableName('role_scope');
const roleSlugColumn = context.escape.columnName('roleSlug');
const scopeSlugColumn = context.escape.columnName('scopeSlug');
const rows = await context.runQuery<RoleScopeRow[]>(
`SELECT ${roleSlugColumn} AS "roleSlug", ${scopeSlugColumn} AS "scopeSlug" FROM ${tableName} WHERE ${roleSlugColumn} = :roleSlug`,
{ roleSlug },
);
return rows;
}
async function getRoleScopesByScope(
context: TestMigrationContext,
scopeSlug: string,
): Promise<RoleScopeRow[]> {
const tableName = context.escape.tableName('role_scope');
const roleSlugColumn = context.escape.columnName('roleSlug');
const scopeSlugColumn = context.escape.columnName('scopeSlug');
const rows = await context.runQuery<RoleScopeRow[]>(
`SELECT ${roleSlugColumn} AS "roleSlug", ${scopeSlugColumn} AS "scopeSlug" FROM ${tableName} WHERE ${scopeSlugColumn} = :scopeSlug`,
{ scopeSlug },
);
return rows;
}
async function getScopeBySlug(
context: TestMigrationContext,
slug: string,
): Promise<{ slug: string } | null> {
const tableName = context.escape.tableName('scope');
const slugColumn = context.escape.columnName('slug');
const rows = await context.runQuery<Array<Record<string, string>>>(
`SELECT ${slugColumn} AS "slug" FROM ${tableName} WHERE ${slugColumn} = :slug`,
{ slug },
);
return rows[0] ? (rows[0] as { slug: string }) : null;
}
describe('up migration', () => {
it('should create workflow:unpublish scope when it does not exist', async () => {
const context = createTestMigrationContext(dataSource);
expect(await getScopeBySlug(context, 'workflow:unpublish')).toBeNull();
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
const postContext = createTestMigrationContext(dataSource);
const scope = await getScopeBySlug(postContext, 'workflow:unpublish');
expect(scope).not.toBeNull();
expect(scope?.slug).toBe('workflow:unpublish');
await postContext.queryRunner.release();
});
it('should add workflow:unpublish to roles that have workflow:publish except project:personalOwner', async () => {
const context = createTestMigrationContext(dataSource);
await insertTestScope(context, {
slug: 'workflow:publish',
displayName: 'Publish Workflow',
description: 'Allows publishing workflows.',
});
await insertTestScope(context, {
slug: 'workflow:read',
displayName: 'Read Workflow',
description: 'Allows reading workflows.',
});
await insertTestRole(context, {
slug: 'custom-editor',
displayName: 'Custom Editor',
roleType: 'project',
});
await insertTestRole(context, {
slug: 'project:personalOwner',
displayName: 'Personal Owner',
roleType: 'project',
});
await insertTestRoleScope(context, {
roleSlug: 'custom-editor',
scopeSlug: 'workflow:publish',
});
await insertTestRoleScope(context, {
roleSlug: 'project:personalOwner',
scopeSlug: 'workflow:publish',
});
const customEditorBefore = await getRoleScopesByRole(context, 'custom-editor');
expect(customEditorBefore.map((s) => s.scopeSlug)).toEqual(['workflow:publish']);
const unpublishBefore = await getRoleScopesByScope(context, 'workflow:unpublish');
expect(unpublishBefore).toHaveLength(0);
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
const postContext = createTestMigrationContext(dataSource);
const customEditorAfter = await getRoleScopesByRole(postContext, 'custom-editor');
expect(customEditorAfter.map((s) => s.scopeSlug).sort()).toEqual([
'workflow:publish',
'workflow:unpublish',
]);
const personalOwnerAfter = await getRoleScopesByRole(postContext, 'project:personalOwner');
expect(personalOwnerAfter.map((s) => s.scopeSlug)).toEqual(['workflow:publish']);
expect(personalOwnerAfter.some((s) => s.scopeSlug === 'workflow:unpublish')).toBe(false);
const unpublishAfter = await getRoleScopesByScope(postContext, 'workflow:unpublish');
expect(unpublishAfter).toHaveLength(1);
expect(unpublishAfter[0].roleSlug).toBe('custom-editor');
await postContext.queryRunner.release();
});
it('should not add workflow:unpublish to project:personalOwner even when it has workflow:publish', async () => {
const context = createTestMigrationContext(dataSource);
await insertTestScope(context, {
slug: 'workflow:publish',
displayName: 'Publish Workflow',
description: 'Allows publishing workflows.',
});
await insertTestRole(context, {
slug: 'project:personalOwner',
displayName: 'Personal Owner',
roleType: 'project',
});
await insertTestRoleScope(context, {
roleSlug: 'project:personalOwner',
scopeSlug: 'workflow:publish',
});
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
const postContext = createTestMigrationContext(dataSource);
const personalOwnerScopes = await getRoleScopesByRole(postContext, 'project:personalOwner');
expect(personalOwnerScopes.map((s) => s.scopeSlug)).toEqual(['workflow:publish']);
expect(personalOwnerScopes.some((s) => s.scopeSlug === 'workflow:unpublish')).toBe(false);
await postContext.queryRunner.release();
});
it('should not duplicate workflow:unpublish for roles that already have it', async () => {
const context = createTestMigrationContext(dataSource);
await insertTestScope(context, {
slug: 'workflow:publish',
displayName: 'Publish Workflow',
description: 'Allows publishing workflows.',
});
await insertTestScope(context, {
slug: 'workflow:unpublish',
displayName: 'Unpublish Workflow',
description: 'Allows unpublishing workflows.',
});
await insertTestRole(context, {
slug: 'already-has-unpublish',
displayName: 'Already Has Unpublish',
roleType: 'project',
});
await insertTestRoleScope(context, {
roleSlug: 'already-has-unpublish',
scopeSlug: 'workflow:publish',
});
await insertTestRoleScope(context, {
roleSlug: 'already-has-unpublish',
scopeSlug: 'workflow:unpublish',
});
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
const postContext = createTestMigrationContext(dataSource);
const scopes = await getRoleScopesByRole(postContext, 'already-has-unpublish');
const unpublishCount = scopes.filter((s) => s.scopeSlug === 'workflow:unpublish').length;
expect(unpublishCount).toBe(1);
await postContext.queryRunner.release();
});
});
describe('down migration', () => {
it('should remove all workflow:unpublish role_scope entries', async () => {
const context = createTestMigrationContext(dataSource);
await insertTestScope(context, {
slug: 'workflow:publish',
displayName: 'Publish Workflow',
description: 'Allows publishing workflows.',
});
await insertTestRole(context, {
slug: 'role-with-publish',
displayName: 'Role With Publish',
roleType: 'project',
});
await insertTestRoleScope(context, {
roleSlug: 'role-with-publish',
scopeSlug: 'workflow:publish',
});
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
const afterUp = createTestMigrationContext(dataSource);
const unpublishAfterUp = await getRoleScopesByScope(afterUp, 'workflow:unpublish');
expect(unpublishAfterUp.length).toBeGreaterThan(0);
await afterUp.queryRunner.release();
await undoLastSingleMigration();
dataSource = Container.get(DataSource);
const postContext = createTestMigrationContext(dataSource);
const unpublishAfterDown = await getRoleScopesByScope(postContext, 'workflow:unpublish');
expect(unpublishAfterDown).toHaveLength(0);
const roleScopesAfterDown = await getRoleScopesByRole(postContext, 'role-with-publish');
expect(roleScopesAfterDown.map((s) => s.scopeSlug)).toEqual(['workflow:publish']);
await postContext.queryRunner.release();
});
});
});
@@ -0,0 +1,330 @@
import {
createTestMigrationContext,
initDbUpToMigration,
runSingleMigration,
type TestMigrationContext,
} from '@n8n/backend-test-utils';
import { DbConnection } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import { randomUUID } from 'node:crypto';
const MIGRATION_NAME = 'CreateChatHubToolsTable1770000000000';
function makeTool(
overrides: Partial<{ id: string; name: string; type: string; typeVersion: number }> = {},
) {
return {
id: overrides.id ?? randomUUID(),
name: overrides.name ?? 'Google Search',
type: overrides.type ?? '@n8n/n8n-nodes-langchain.toolSerpApi',
typeVersion: overrides.typeVersion ?? 1,
parameters: { query: 'test' },
position: [0, 0],
};
}
describe('CreateChatHubToolsTable Migration', () => {
let dataSource: DataSource;
beforeAll(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.init();
dataSource = Container.get(DataSource);
const context = createTestMigrationContext(dataSource);
await context.queryRunner.clearDatabase();
await context.queryRunner.release();
await initDbUpToMigration(MIGRATION_NAME);
});
afterAll(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.close();
});
async function insertUser(context: TestMigrationContext, id: string): Promise<void> {
const tableName = context.escape.tableName('user');
await context.runQuery(
`INSERT INTO ${tableName} ("id", "email", "firstName", "lastName", "password", "roleSlug", "createdAt", "updatedAt") VALUES (:id, :email, :firstName, :lastName, :password, :roleSlug, :createdAt, :updatedAt)`,
{
id,
email: `${id}@test.com`,
firstName: 'Test',
lastName: 'User',
password: 'hashed',
roleSlug: 'global:member',
createdAt: new Date(),
updatedAt: new Date(),
},
);
}
async function insertSession(
context: TestMigrationContext,
data: { id: string; ownerId: string; tools: object[] },
): Promise<void> {
const tableName = context.escape.tableName('chat_hub_sessions');
const now = new Date();
await context.runQuery(
`INSERT INTO ${tableName} ("id", "title", "ownerId", "tools", "lastMessageAt", "createdAt", "updatedAt") VALUES (:id, :title, :ownerId, :tools, :lastMessageAt, :createdAt, :updatedAt)`,
{
id: data.id,
title: 'Test Session',
ownerId: data.ownerId,
tools: JSON.stringify(data.tools),
lastMessageAt: now,
createdAt: now,
updatedAt: now,
},
);
}
async function insertAgent(
context: TestMigrationContext,
data: { id: string; ownerId: string; tools: object[] },
): Promise<void> {
const tableName = context.escape.tableName('chat_hub_agents');
await context.runQuery(
`INSERT INTO ${tableName} ("id", "name", "systemPrompt", "ownerId", "provider", "model", "tools", "createdAt", "updatedAt") VALUES (:id, :name, :systemPrompt, :ownerId, :provider, :model, :tools, :createdAt, :updatedAt)`,
{
id: data.id,
name: 'Test Agent',
systemPrompt: 'You are helpful',
ownerId: data.ownerId,
provider: 'openai',
model: 'gpt-4',
tools: JSON.stringify(data.tools),
createdAt: new Date(),
updatedAt: new Date(),
},
);
}
describe('Up Migration', () => {
const userId1 = randomUUID();
const userId2 = randomUUID();
const sessionWithTools = randomUUID();
const sessionWithEmptyTools = randomUUID();
const sessionWithDuplicateTools = randomUUID();
const sessionWithInvalidTool = randomUUID();
const agentWithTools = randomUUID();
const agentSharingToolWithSession = randomUUID();
const tool1 = makeTool({ name: 'Google Search' });
const tool2 = makeTool({ name: 'Wikipedia' });
beforeAll(async () => {
const context = createTestMigrationContext(dataSource);
await insertUser(context, userId1);
await insertUser(context, userId2);
// Session with two valid tools
await insertSession(context, {
id: sessionWithTools,
ownerId: userId1,
tools: [tool1, tool2],
});
// Session with empty tools array (should be excluded by WHERE clause)
await insertSession(context, {
id: sessionWithEmptyTools,
ownerId: userId1,
tools: [],
});
// Session with the same tool name appearing twice (somehow) — should deduplicate
await insertSession(context, {
id: sessionWithDuplicateTools,
ownerId: userId1,
tools: [tool1, { ...tool1, id: randomUUID() }],
});
// Session with a tool missing required fields — should be skipped
await insertSession(context, {
id: sessionWithInvalidTool,
ownerId: userId1,
tools: [{ id: randomUUID(), name: 'Bad Tool' }], // missing type & typeVersion
});
// Agent with one tool
await insertAgent(context, {
id: agentWithTools,
ownerId: userId1,
tools: [tool2],
});
// Agent for a different user with same tool name — should create separate tool row
await insertAgent(context, {
id: agentSharingToolWithSession,
ownerId: userId2,
tools: [makeTool({ name: 'Google Search' })],
});
await runSingleMigration(MIGRATION_NAME);
await context.queryRunner.release();
});
it('should create chat_hub_tools table', async () => {
const context = createTestMigrationContext(dataSource);
const toolsTable = context.escape.tableName('chat_hub_tools');
const rows = await context.runQuery<unknown[]>(`SELECT * FROM ${toolsTable}`);
expect(rows.length).toBeGreaterThan(0);
await context.queryRunner.release();
});
it('should migrate session tools to chat_hub_tools and create join table entries', async () => {
const context = createTestMigrationContext(dataSource);
const sessionToolsTable = context.escape.tableName('chat_hub_session_tools');
const rows = await context.runQuery<Array<{ sessionId: string; toolId: string }>>(
`SELECT "sessionId", "toolId" FROM ${sessionToolsTable} WHERE "sessionId" = :sessionId`,
{ sessionId: sessionWithTools },
);
// Should have 2 tools linked
expect(rows).toHaveLength(2);
await context.queryRunner.release();
});
it('should deduplicate tools with the same name across sessions for the same user', async () => {
const context = createTestMigrationContext(dataSource);
const toolsTable = context.escape.tableName('chat_hub_tools');
const sessionToolsTable = context.escape.tableName('chat_hub_session_tools');
// Only one tool row should exist for (userId1, "Google Search")
const toolRows = await context.runQuery<Array<{ id: string }>>(
`SELECT "id" FROM ${toolsTable} WHERE "ownerId" = :ownerId AND "name" = :name`,
{ ownerId: userId1, name: 'Google Search' },
);
expect(toolRows).toHaveLength(1);
const sharedToolId = toolRows[0].id;
// Both sessionWithTools and sessionWithDuplicateTools have "Google Search"
// with different original IDs, but both should reference the same tool row
const session1Links = await context.runQuery<Array<{ toolId: string }>>(
`SELECT "toolId" FROM ${sessionToolsTable} WHERE "sessionId" = :sessionId AND "toolId" = :toolId`,
{ sessionId: sessionWithTools, toolId: sharedToolId },
);
expect(session1Links).toHaveLength(1);
const session2Links = await context.runQuery<Array<{ toolId: string }>>(
`SELECT "toolId" FROM ${sessionToolsTable} WHERE "sessionId" = :sessionId AND "toolId" = :toolId`,
{ sessionId: sessionWithDuplicateTools, toolId: sharedToolId },
);
expect(session2Links).toHaveLength(1);
await context.queryRunner.release();
});
it('should create separate tool rows for different users with the same tool name', async () => {
const context = createTestMigrationContext(dataSource);
const toolsTable = context.escape.tableName('chat_hub_tools');
const rows = await context.runQuery<Array<{ ownerId: string }>>(
`SELECT "ownerId" FROM ${toolsTable} WHERE "name" = :name`,
{ name: 'Google Search' },
);
// Two different users each get their own tool row
expect(rows).toHaveLength(2);
await context.queryRunner.release();
});
it('should reuse the same tool row across sessions and agents for the same user', async () => {
const context = createTestMigrationContext(dataSource);
const sessionToolsTable = context.escape.tableName('chat_hub_session_tools');
const agentToolsTable = context.escape.tableName('chat_hub_agent_tools');
// Get all toolIds linked to sessionWithTools (has both "Google Search" and "Wikipedia")
const sessionRows = await context.runQuery<Array<{ toolId: string }>>(
`SELECT "toolId" FROM ${sessionToolsTable} WHERE "sessionId" = :sessionId`,
{ sessionId: sessionWithTools },
);
// Get toolId linked to agentWithTools (has only "Wikipedia")
const agentRows = await context.runQuery<Array<{ toolId: string }>>(
`SELECT "toolId" FROM ${agentToolsTable} WHERE "agentId" = :agentId`,
{ agentId: agentWithTools },
);
// The agent's "Wikipedia" toolId should match one of the session's toolIds
const sessionToolIds = new Set(sessionRows.map((r) => r.toolId));
const agentToolId = agentRows[0].toolId;
expect(sessionToolIds.has(agentToolId)).toBe(true);
await context.queryRunner.release();
});
it('should skip tools with missing required fields', async () => {
const context = createTestMigrationContext(dataSource);
const sessionToolsTable = context.escape.tableName('chat_hub_session_tools');
const rows = await context.runQuery<Array<{ toolId: string }>>(
`SELECT "toolId" FROM ${sessionToolsTable} WHERE "sessionId" = :sessionId`,
{ sessionId: sessionWithInvalidTool },
);
// The invalid tool should have been skipped
expect(rows).toHaveLength(0);
await context.queryRunner.release();
});
it('should handle duplicate tools within a single session', async () => {
const context = createTestMigrationContext(dataSource);
const sessionToolsTable = context.escape.tableName('chat_hub_session_tools');
const rows = await context.runQuery<Array<{ toolId: string }>>(
`SELECT "toolId" FROM ${sessionToolsTable} WHERE "sessionId" = :sessionId`,
{ sessionId: sessionWithDuplicateTools },
);
// Duplicate name → only one join entry
expect(rows).toHaveLength(1);
await context.queryRunner.release();
});
it('should store full tool definition in the definition column', async () => {
const context = createTestMigrationContext(dataSource);
const toolsTable = context.escape.tableName('chat_hub_tools');
const definitionColumn = context.isPostgres ? '"definition"::text' : '"definition"';
const rows = await context.runQuery<Array<{ definition: string }>>(
`SELECT ${definitionColumn} as "definition" FROM ${toolsTable} WHERE "ownerId" = :ownerId AND "name" = :name`,
{ ownerId: userId1, name: 'Google Search' },
);
const definition = JSON.parse(rows[0].definition);
// Should contain extra fields from the original INode definition
expect(definition.parameters).toEqual({ query: 'test' });
expect(definition.type).toBe('@n8n/n8n-nodes-langchain.toolSerpApi');
await context.queryRunner.release();
});
it('should drop tools column from sessions and agents tables', async () => {
const context = createTestMigrationContext(dataSource);
if (context.isSqlite) {
const sessionsInfo = await context.queryRunner.query(
`PRAGMA table_info(${context.escape.tableName('chat_hub_sessions')})`,
);
expect(sessionsInfo.find((col: { name: string }) => col.name === 'tools')).toBeUndefined();
const agentsInfo = await context.queryRunner.query(
`PRAGMA table_info(${context.escape.tableName('chat_hub_agents')})`,
);
expect(agentsInfo.find((col: { name: string }) => col.name === 'tools')).toBeUndefined();
} else if (context.isPostgres) {
const sessionsResult = await context.queryRunner.query(
"SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND column_name = 'tools'",
[`${context.tablePrefix}chat_hub_sessions`],
);
expect(sessionsResult).toHaveLength(0);
const agentsResult = await context.queryRunner.query(
"SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND column_name = 'tools'",
[`${context.tablePrefix}chat_hub_agents`],
);
expect(agentsResult).toHaveLength(0);
}
await context.queryRunner.release();
});
});
});
@@ -0,0 +1,178 @@
import {
createTestMigrationContext,
initDbUpToMigration,
runSingleMigration,
undoLastSingleMigration,
type TestMigrationContext,
} from '@n8n/backend-test-utils';
import { DbConnection } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import { nanoid } from 'nanoid';
const MIGRATION_NAME = 'AddScalingFieldsToTestRun1771417407753';
describe('AddScalingFieldsToTestRun Migration', () => {
let dataSource: DataSource;
beforeAll(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.init();
dataSource = Container.get(DataSource);
});
beforeEach(async () => {
const context = createTestMigrationContext(dataSource);
await context.queryRunner.clearDatabase();
await context.queryRunner.release();
await initDbUpToMigration(MIGRATION_NAME);
});
afterAll(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.close();
});
async function insertTestWorkflow(context: TestMigrationContext, workflowId: string) {
const tableName = context.escape.tableName('workflow_entity');
await context.runQuery(
`INSERT INTO ${tableName} (${context.escape.columnName('id')}, ${context.escape.columnName('name')}, ${context.escape.columnName('active')}, ${context.escape.columnName('nodes')}, ${context.escape.columnName('connections')}, ${context.escape.columnName('createdAt')}, ${context.escape.columnName('updatedAt')}, ${context.escape.columnName('triggerCount')}, ${context.escape.columnName('versionId')})
VALUES (:id, :name, :active, :nodes, :connections, :createdAt, :updatedAt, :triggerCount, :versionId)`,
{
id: workflowId,
name: 'Test Workflow',
active: false,
nodes: '[]',
connections: '{}',
createdAt: new Date(),
updatedAt: new Date(),
triggerCount: 0,
versionId: nanoid(),
},
);
}
async function insertTestRun(
context: TestMigrationContext,
testRunId: string,
workflowId: string,
) {
const tableName = context.escape.tableName('test_run');
await context.runQuery(
`INSERT INTO ${tableName} (${context.escape.columnName('id')}, ${context.escape.columnName('status')}, ${context.escape.columnName('workflowId')}, ${context.escape.columnName('createdAt')}, ${context.escape.columnName('updatedAt')})
VALUES (:id, :status, :workflowId, :createdAt, :updatedAt)`,
{
id: testRunId,
status: 'new',
workflowId,
createdAt: new Date(),
updatedAt: new Date(),
},
);
}
async function getColumnMeta(context: TestMigrationContext, columnName: string) {
if (context.isSqlite) {
const rows: Array<{ name: string; notnull: number; dflt_value: string | null }> =
await context.queryRunner.query(
`PRAGMA table_info(${context.escape.tableName('test_run')})`,
);
return rows.find((r) => r.name === columnName);
}
const rows: Array<{
column_name: string;
is_nullable: string;
column_default: string | null;
}> = await context.queryRunner.query(
'SELECT column_name, is_nullable, column_default FROM information_schema.columns WHERE table_name = $1 AND column_name = $2',
[`${context.tablePrefix}test_run`, columnName],
);
return rows[0];
}
describe('up', () => {
it('should add runningInstanceId as a nullable column', async () => {
await runSingleMigration(MIGRATION_NAME);
const context = createTestMigrationContext(dataSource);
const col = await getColumnMeta(context, 'runningInstanceId');
expect(col).toBeDefined();
if (context.isSqlite) expect((col as any).notnull).toBe(0);
else expect((col as any).is_nullable).toBe('YES');
await context.queryRunner.release();
});
it('should add cancelRequested as NOT NULL with default FALSE', async () => {
await runSingleMigration(MIGRATION_NAME);
const context = createTestMigrationContext(dataSource);
const col = await getColumnMeta(context, 'cancelRequested');
expect(col).toBeDefined();
if (context.isSqlite) expect((col as any).notnull).toBe(1);
else expect((col as any).is_nullable).toBe('NO');
await context.queryRunner.release();
});
it('should set defaults on existing rows', async () => {
const context = createTestMigrationContext(dataSource);
const workflowId = nanoid(16);
const testRunId = nanoid(16);
await insertTestWorkflow(context, workflowId);
await insertTestRun(context, testRunId, workflowId);
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
const postContext = createTestMigrationContext(dataSource);
const tableName = postContext.escape.tableName('test_run');
const rows: Array<{ runningInstanceId: string | null; cancelRequested: boolean | null }> =
await postContext.runQuery(
`SELECT * FROM ${tableName} WHERE ${postContext.escape.columnName('id')} = :id`,
{ id: testRunId },
);
expect(rows[0].runningInstanceId).toBeNull();
expect(Boolean(rows[0].cancelRequested)).toBe(false);
await postContext.queryRunner.release();
});
it('should reject NULL for cancelRequested', async () => {
await runSingleMigration(MIGRATION_NAME);
const context = createTestMigrationContext(dataSource);
const workflowId = nanoid(16);
await insertTestWorkflow(context, workflowId);
const tableName = context.escape.tableName('test_run');
await expect(
context.runQuery(
`INSERT INTO ${tableName} (${context.escape.columnName('id')}, ${context.escape.columnName('status')}, ${context.escape.columnName('workflowId')}, ${context.escape.columnName('cancelRequested')}, ${context.escape.columnName('createdAt')}, ${context.escape.columnName('updatedAt')})
VALUES (:id, :status, :workflowId, :cancelRequested, :createdAt, :updatedAt)`,
{
id: nanoid(16),
status: 'new',
workflowId,
cancelRequested: null,
createdAt: new Date(),
updatedAt: new Date(),
},
),
).rejects.toThrow();
await context.queryRunner.release();
});
});
describe('down', () => {
it('should remove both columns', async () => {
await runSingleMigration(MIGRATION_NAME);
await undoLastSingleMigration();
const context = createTestMigrationContext(dataSource);
expect(await getColumnMeta(context, 'runningInstanceId')).toBeUndefined();
expect(await getColumnMeta(context, 'cancelRequested')).toBeUndefined();
await context.queryRunner.release();
});
});
});
@@ -0,0 +1,265 @@
import {
createTestMigrationContext,
initDbUpToMigration,
runSingleMigration,
type TestMigrationContext,
} from '@n8n/backend-test-utils';
import { DbConnection } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import { Cipher } from 'n8n-core';
const MIGRATION_NAME = 'MigrateExternalSecretsToEntityStorage1771500000000';
const EXTERNAL_SECRETS_DB_KEY = 'feature.externalSecrets';
describe('MigrateExternalSecretsToEntityStorage Migration', () => {
let dataSource: DataSource;
let cipher: Cipher;
beforeAll(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.init();
dataSource = Container.get(DataSource);
cipher = Container.get(Cipher);
});
beforeEach(async () => {
const context = createTestMigrationContext(dataSource);
await context.queryRunner.clearDatabase();
await context.queryRunner.release();
await initDbUpToMigration(MIGRATION_NAME);
});
afterAll(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.close();
});
async function insertSettingsBlob(context: TestMigrationContext, value: string): Promise<void> {
const tableName = context.escape.tableName('settings');
const keyCol = context.escape.columnName('key');
const valueCol = context.escape.columnName('value');
const loadOnStartupCol = context.escape.columnName('loadOnStartup');
await context.runQuery(
`INSERT INTO ${tableName} (${keyCol}, ${valueCol}, ${loadOnStartupCol}) VALUES (:key, :value, :loadOnStartup)`,
{ key: EXTERNAL_SECRETS_DB_KEY, value, loadOnStartup: true },
);
}
async function getProviderConnections(
context: TestMigrationContext,
): Promise<Array<{ providerKey: string; type: string; encryptedSettings: string }>> {
const tableName = context.escape.tableName('secrets_provider_connection');
const providerKeyCol = context.escape.columnName('providerKey');
const typeCol = context.escape.columnName('type');
const encryptedSettingsCol = context.escape.columnName('encryptedSettings');
return await context.runQuery(
`SELECT ${providerKeyCol} AS "providerKey", ${typeCol} AS "type", ${encryptedSettingsCol} AS "encryptedSettings" FROM ${tableName}`,
);
}
async function insertProviderConnection(
context: TestMigrationContext,
providerKey: string,
type: string,
encryptedSettings: string,
): Promise<void> {
const tableName = context.escape.tableName('secrets_provider_connection');
const providerKeyCol = context.escape.columnName('providerKey');
const typeCol = context.escape.columnName('type');
const encryptedSettingsCol = context.escape.columnName('encryptedSettings');
await context.runQuery(
`INSERT INTO ${tableName} (${providerKeyCol}, ${typeCol}, ${encryptedSettingsCol}) VALUES (:providerKey, :type, :encryptedSettings)`,
{ providerKey, type, encryptedSettings },
);
}
describe('up migration', () => {
it('should skip when no external secrets settings exist', async () => {
const context = createTestMigrationContext(dataSource);
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
const postContext = createTestMigrationContext(dataSource);
const connections = await getProviderConnections(postContext);
expect(connections).toHaveLength(0);
await postContext.queryRunner.release();
});
it('should migrate connected providers to secrets_provider_connection', async () => {
const context = createTestMigrationContext(dataSource);
const settings = {
awsSecretsManager: {
connected: true,
connectedAt: '2024-01-01T00:00:00.000Z',
settings: { region: 'us-east-1', accessKeyId: 'AKIA...' },
},
gcpSecretsManager: {
connected: true,
connectedAt: '2024-02-01T00:00:00.000Z',
settings: { projectId: 'my-project' },
},
};
const encrypted = cipher.encrypt(JSON.stringify(settings));
await insertSettingsBlob(context, encrypted);
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
const postContext = createTestMigrationContext(dataSource);
const connections = await getProviderConnections(postContext);
expect(connections).toHaveLength(2);
const aws = connections.find((c) => c.providerKey === 'awsSecretsManager');
expect(aws).toBeDefined();
expect(aws!.type).toBe('awsSecretsManager');
const awsDecrypted = JSON.parse(cipher.decrypt(aws!.encryptedSettings));
expect(awsDecrypted).toEqual({ region: 'us-east-1', accessKeyId: 'AKIA...' });
const gcp = connections.find((c) => c.providerKey === 'gcpSecretsManager');
expect(gcp).toBeDefined();
expect(gcp!.type).toBe('gcpSecretsManager');
const gcpDecrypted = JSON.parse(cipher.decrypt(gcp!.encryptedSettings));
expect(gcpDecrypted).toEqual({ projectId: 'my-project' });
await postContext.queryRunner.release();
});
it('should skip disconnected providers', async () => {
const context = createTestMigrationContext(dataSource);
const settings = {
awsSecretsManager: {
connected: true,
connectedAt: '2024-01-01T00:00:00.000Z',
settings: { region: 'us-east-1' },
},
gcpSecretsManager: {
connected: false,
connectedAt: null,
settings: { projectId: 'my-project' },
},
};
const encrypted = cipher.encrypt(JSON.stringify(settings));
await insertSettingsBlob(context, encrypted);
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
const postContext = createTestMigrationContext(dataSource);
const connections = await getProviderConnections(postContext);
expect(connections).toHaveLength(1);
expect(connections[0].providerKey).toBe('awsSecretsManager');
await postContext.queryRunner.release();
});
it('should skip providers that already exist in secrets_provider_connection', async () => {
const context = createTestMigrationContext(dataSource);
const existingEncryptedSettings = cipher.encrypt({ region: 'eu-west-1' });
await insertProviderConnection(
context,
'awsSecretsManager',
'awsSecretsManager',
existingEncryptedSettings,
);
const settings = {
awsSecretsManager: {
connected: true,
connectedAt: '2024-01-01T00:00:00.000Z',
settings: { region: 'us-east-1' },
},
};
const encrypted = cipher.encrypt(JSON.stringify(settings));
await insertSettingsBlob(context, encrypted);
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
const postContext = createTestMigrationContext(dataSource);
const connections = await getProviderConnections(postContext);
expect(connections).toHaveLength(1);
// Should still have the original settings, not the migrated ones
const decrypted = JSON.parse(cipher.decrypt(connections[0].encryptedSettings));
expect(decrypted).toEqual({ region: 'eu-west-1' });
await postContext.queryRunner.release();
});
it('should skip when settings blob is empty', async () => {
const context = createTestMigrationContext(dataSource);
const encrypted = cipher.encrypt(JSON.stringify({}));
await insertSettingsBlob(context, encrypted);
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
const postContext = createTestMigrationContext(dataSource);
const connections = await getProviderConnections(postContext);
expect(connections).toHaveLength(0);
await postContext.queryRunner.release();
});
it('should skip when settings blob cannot be decrypted', async () => {
const context = createTestMigrationContext(dataSource);
await insertSettingsBlob(context, 'not-valid-encrypted-data');
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
const postContext = createTestMigrationContext(dataSource);
const connections = await getProviderConnections(postContext);
expect(connections).toHaveLength(0);
await postContext.queryRunner.release();
});
it('should handle providers with null settings', async () => {
const context = createTestMigrationContext(dataSource);
const settings = {
awsSecretsManager: {
connected: true,
connectedAt: '2024-01-01T00:00:00.000Z',
settings: null as unknown as Record<string, unknown>,
},
};
const encrypted = cipher.encrypt(JSON.stringify(settings));
await insertSettingsBlob(context, encrypted);
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
const postContext = createTestMigrationContext(dataSource);
const connections = await getProviderConnections(postContext);
expect(connections).toHaveLength(1);
const decrypted = JSON.parse(cipher.decrypt(connections[0].encryptedSettings));
expect(decrypted).toEqual({});
await postContext.queryRunner.release();
});
});
});
@@ -0,0 +1,412 @@
import {
createTestMigrationContext,
initDbUpToMigration,
runSingleMigration,
undoLastSingleMigration,
type TestMigrationContext,
} from '@n8n/backend-test-utils';
import { DbConnection } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
const MIGRATION_NAME = 'AddUnshareScopeToCustomRoles1771500000001';
interface ScopeData {
slug: string;
displayName: string;
description: string;
}
interface RoleData {
slug: string;
displayName: string;
roleType: string;
systemRole?: boolean;
}
interface RoleScopeData {
roleSlug: string;
scopeSlug: string;
}
interface RoleScopeRow {
roleSlug: string;
scopeSlug: string;
}
describe('AddUnshareScopeToCustomRoles Migration', () => {
let dataSource: DataSource;
beforeEach(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.init();
dataSource = Container.get(DataSource);
const context = createTestMigrationContext(dataSource);
await context.queryRunner.clearDatabase();
await initDbUpToMigration(MIGRATION_NAME);
});
afterEach(async () => {
const dbConnection = Container.get(DbConnection);
await dbConnection.close();
});
async function insertTestScope(
context: TestMigrationContext,
scopeData: ScopeData,
): Promise<void> {
const tableName = context.escape.tableName('scope');
const slugColumn = context.escape.columnName('slug');
const displayNameColumn = context.escape.columnName('displayName');
const descriptionColumn = context.escape.columnName('description');
const existingScope = await context.runQuery<unknown[]>(
`SELECT ${slugColumn} FROM ${tableName} WHERE ${slugColumn} = :slug`,
{ slug: scopeData.slug },
);
if (existingScope.length === 0) {
await context.runQuery(
`INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${descriptionColumn}) VALUES (:slug, :displayName, :description)`,
{
slug: scopeData.slug,
displayName: scopeData.displayName,
description: scopeData.description,
},
);
}
}
async function insertTestRole(context: TestMigrationContext, roleData: RoleData): Promise<void> {
const tableName = context.escape.tableName('role');
const slugColumn = context.escape.columnName('slug');
const displayNameColumn = context.escape.columnName('displayName');
const roleTypeColumn = context.escape.columnName('roleType');
const systemRoleColumn = context.escape.columnName('systemRole');
const createdAtColumn = context.escape.columnName('createdAt');
const updatedAtColumn = context.escape.columnName('updatedAt');
const systemRole = roleData.systemRole ?? false;
const insertSql = context.isPostgres
? `INSERT INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${roleTypeColumn}, ${systemRoleColumn}, ${createdAtColumn}, ${updatedAtColumn}) VALUES (:slug, :displayName, :roleType, :systemRole, :createdAt, :updatedAt) ON CONFLICT (${slugColumn}) DO NOTHING`
: `INSERT OR IGNORE INTO ${tableName} (${slugColumn}, ${displayNameColumn}, ${roleTypeColumn}, ${systemRoleColumn}, ${createdAtColumn}, ${updatedAtColumn}) VALUES (:slug, :displayName, :roleType, :systemRole, :createdAt, :updatedAt)`;
await context.runQuery(insertSql, {
slug: roleData.slug,
displayName: roleData.displayName,
roleType: roleData.roleType,
systemRole,
createdAt: new Date(),
updatedAt: new Date(),
});
}
async function insertTestRoleScope(
context: TestMigrationContext,
roleScopeData: RoleScopeData,
): Promise<void> {
const tableName = context.escape.tableName('role_scope');
const roleSlugColumn = context.escape.columnName('roleSlug');
const scopeSlugColumn = context.escape.columnName('scopeSlug');
await context.runQuery(
`INSERT INTO ${tableName} (${roleSlugColumn}, ${scopeSlugColumn}) VALUES (:roleSlug, :scopeSlug)`,
{ roleSlug: roleScopeData.roleSlug, scopeSlug: roleScopeData.scopeSlug },
);
}
async function getRoleScopesByRole(
context: TestMigrationContext,
roleSlug: string,
): Promise<RoleScopeRow[]> {
const tableName = context.escape.tableName('role_scope');
const roleSlugColumn = context.escape.columnName('roleSlug');
const scopeSlugColumn = context.escape.columnName('scopeSlug');
return await context.runQuery<RoleScopeRow[]>(
`SELECT ${roleSlugColumn} AS "roleSlug", ${scopeSlugColumn} AS "scopeSlug" FROM ${tableName} WHERE ${roleSlugColumn} = :roleSlug`,
{ roleSlug },
);
}
async function getRoleScopesByScope(
context: TestMigrationContext,
scopeSlug: string,
): Promise<RoleScopeRow[]> {
const tableName = context.escape.tableName('role_scope');
const roleSlugColumn = context.escape.columnName('roleSlug');
const scopeSlugColumn = context.escape.columnName('scopeSlug');
return await context.runQuery<RoleScopeRow[]>(
`SELECT ${roleSlugColumn} AS "roleSlug", ${scopeSlugColumn} AS "scopeSlug" FROM ${tableName} WHERE ${scopeSlugColumn} = :scopeSlug`,
{ scopeSlug },
);
}
async function getScopeBySlug(
context: TestMigrationContext,
slug: string,
): Promise<{ slug: string } | null> {
const tableName = context.escape.tableName('scope');
const slugColumn = context.escape.columnName('slug');
const rows = await context.runQuery<Array<Record<string, string>>>(
`SELECT ${slugColumn} AS "slug" FROM ${tableName} WHERE ${slugColumn} = :slug`,
{ slug },
);
return rows[0] ? (rows[0] as { slug: string }) : null;
}
describe('up migration', () => {
it('should create workflow:unshare and credential:unshare scopes', async () => {
const context = createTestMigrationContext(dataSource);
expect(await getScopeBySlug(context, 'workflow:unshare')).toBeNull();
expect(await getScopeBySlug(context, 'credential:unshare')).toBeNull();
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
const postContext = createTestMigrationContext(dataSource);
const workflowScope = await getScopeBySlug(postContext, 'workflow:unshare');
expect(workflowScope).not.toBeNull();
expect(workflowScope?.slug).toBe('workflow:unshare');
const credentialScope = await getScopeBySlug(postContext, 'credential:unshare');
expect(credentialScope).not.toBeNull();
expect(credentialScope?.slug).toBe('credential:unshare');
await postContext.queryRunner.release();
});
it('should add unshare scopes to roles that have share scopes, excluding project:personalOwner', async () => {
const context = createTestMigrationContext(dataSource);
// Set up scopes
await insertTestScope(context, {
slug: 'workflow:share',
displayName: 'Share Workflow',
description: 'Allows sharing workflows.',
});
await insertTestScope(context, {
slug: 'credential:share',
displayName: 'Share Credential',
description: 'Allows sharing credentials.',
});
// Set up roles
await insertTestRole(context, {
slug: 'custom-editor',
displayName: 'Custom Editor',
roleType: 'project',
});
await insertTestRole(context, {
slug: 'project:personalOwner',
displayName: 'Personal Owner',
roleType: 'project',
});
// Assign share scopes to both roles
await insertTestRoleScope(context, {
roleSlug: 'custom-editor',
scopeSlug: 'workflow:share',
});
await insertTestRoleScope(context, {
roleSlug: 'custom-editor',
scopeSlug: 'credential:share',
});
await insertTestRoleScope(context, {
roleSlug: 'project:personalOwner',
scopeSlug: 'workflow:share',
});
await insertTestRoleScope(context, {
roleSlug: 'project:personalOwner',
scopeSlug: 'credential:share',
});
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
const postContext = createTestMigrationContext(dataSource);
// custom-editor should get both unshare scopes
const customEditorScopes = await getRoleScopesByRole(postContext, 'custom-editor');
const customEditorScopeSlugs = customEditorScopes.map((s) => s.scopeSlug).sort();
expect(customEditorScopeSlugs).toEqual([
'credential:share',
'credential:unshare',
'workflow:share',
'workflow:unshare',
]);
// project:personalOwner should NOT get unshare scopes from migration
const personalOwnerScopes = await getRoleScopesByRole(postContext, 'project:personalOwner');
const personalOwnerScopeSlugs = personalOwnerScopes.map((s) => s.scopeSlug).sort();
expect(personalOwnerScopeSlugs).toEqual(['credential:share', 'workflow:share']);
expect(personalOwnerScopeSlugs).not.toContain('workflow:unshare');
expect(personalOwnerScopeSlugs).not.toContain('credential:unshare');
await postContext.queryRunner.release();
});
it('should not add workflow:unshare to roles without workflow:share', async () => {
const context = createTestMigrationContext(dataSource);
await insertTestScope(context, {
slug: 'credential:share',
displayName: 'Share Credential',
description: 'Allows sharing credentials.',
});
await insertTestScope(context, {
slug: 'workflow:read',
displayName: 'Read Workflow',
description: 'Allows reading workflows.',
});
await insertTestRole(context, {
slug: 'cred-only-sharer',
displayName: 'Credential Only Sharer',
roleType: 'project',
});
// Only has credential:share, not workflow:share
await insertTestRoleScope(context, {
roleSlug: 'cred-only-sharer',
scopeSlug: 'credential:share',
});
await insertTestRoleScope(context, {
roleSlug: 'cred-only-sharer',
scopeSlug: 'workflow:read',
});
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
const postContext = createTestMigrationContext(dataSource);
const scopes = await getRoleScopesByRole(postContext, 'cred-only-sharer');
const scopeSlugs = scopes.map((s) => s.scopeSlug).sort();
// Should have credential:unshare but NOT workflow:unshare
expect(scopeSlugs).toContain('credential:unshare');
expect(scopeSlugs).not.toContain('workflow:unshare');
await postContext.queryRunner.release();
});
it('should not duplicate unshare scopes for roles that already have them', async () => {
const context = createTestMigrationContext(dataSource);
await insertTestScope(context, {
slug: 'workflow:share',
displayName: 'Share Workflow',
description: 'Allows sharing workflows.',
});
await insertTestScope(context, {
slug: 'workflow:unshare',
displayName: 'Unshare Workflow',
description: 'Allows removing workflow shares.',
});
await insertTestRole(context, {
slug: 'already-has-unshare',
displayName: 'Already Has Unshare',
roleType: 'project',
});
await insertTestRoleScope(context, {
roleSlug: 'already-has-unshare',
scopeSlug: 'workflow:share',
});
await insertTestRoleScope(context, {
roleSlug: 'already-has-unshare',
scopeSlug: 'workflow:unshare',
});
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
const postContext = createTestMigrationContext(dataSource);
const scopes = await getRoleScopesByRole(postContext, 'already-has-unshare');
const unshareCount = scopes.filter((s) => s.scopeSlug === 'workflow:unshare').length;
expect(unshareCount).toBe(1);
await postContext.queryRunner.release();
});
});
describe('down migration', () => {
it('should remove all unshare role_scope entries', async () => {
const context = createTestMigrationContext(dataSource);
await insertTestScope(context, {
slug: 'workflow:share',
displayName: 'Share Workflow',
description: 'Allows sharing workflows.',
});
await insertTestScope(context, {
slug: 'credential:share',
displayName: 'Share Credential',
description: 'Allows sharing credentials.',
});
await insertTestRole(context, {
slug: 'role-with-share',
displayName: 'Role With Share',
roleType: 'project',
});
await insertTestRoleScope(context, {
roleSlug: 'role-with-share',
scopeSlug: 'workflow:share',
});
await insertTestRoleScope(context, {
roleSlug: 'role-with-share',
scopeSlug: 'credential:share',
});
await context.queryRunner.release();
await runSingleMigration(MIGRATION_NAME);
dataSource = Container.get(DataSource);
// Verify up migration added the scopes
const afterUp = createTestMigrationContext(dataSource);
const workflowUnshareAfterUp = await getRoleScopesByScope(afterUp, 'workflow:unshare');
expect(workflowUnshareAfterUp.length).toBeGreaterThan(0);
const credentialUnshareAfterUp = await getRoleScopesByScope(afterUp, 'credential:unshare');
expect(credentialUnshareAfterUp.length).toBeGreaterThan(0);
await afterUp.queryRunner.release();
// Run down migration
await undoLastSingleMigration();
dataSource = Container.get(DataSource);
const postContext = createTestMigrationContext(dataSource);
const workflowUnshareAfterDown = await getRoleScopesByScope(postContext, 'workflow:unshare');
expect(workflowUnshareAfterDown).toHaveLength(0);
const credentialUnshareAfterDown = await getRoleScopesByScope(
postContext,
'credential:unshare',
);
expect(credentialUnshareAfterDown).toHaveLength(0);
// Original share scopes should still be there
const roleScopesAfterDown = await getRoleScopesByRole(postContext, 'role-with-share');
const slugs = roleScopesAfterDown.map((s) => s.scopeSlug).sort();
expect(slugs).toEqual(['credential:share', 'workflow:share']);
await postContext.queryRunner.release();
});
});
});
@@ -0,0 +1,99 @@
import { initDbUpToMigration, runSingleMigration } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { DbConnection } from '@n8n/db';
import { Container } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import { UnexpectedError } from 'n8n-workflow';
describe('Migration Test Helpers', () => {
let dataSource: DataSource;
/**
* Get the properly qualified migrations table name for the current database
*/
function getMigrationsTableName(): string {
const globalConfig = Container.get(GlobalConfig);
const dbType = globalConfig.database.type;
const tablePrefix = globalConfig.database.tablePrefix;
if (dbType === 'postgresdb') {
const schema = globalConfig.database.postgresdb.schema;
return `${schema}."${tablePrefix}migrations"`;
}
return `"${tablePrefix}migrations"`;
}
beforeEach(async () => {
// Initialize connection without running migrations
const dbConnection = Container.get(DbConnection);
await dbConnection.init();
dataSource = Container.get(DataSource);
});
afterEach(async () => {
// Clean up the migrations table
const globalConfig = Container.get(GlobalConfig);
if (globalConfig.database.type === 'postgresdb') {
try {
await dataSource.query(`TRUNCATE ${getMigrationsTableName()} CASCADE`);
} catch {
// Ignore errors if table doesn't exist
}
}
const dbConnection = Container.get(DbConnection);
await dbConnection.close();
});
describe('initDbUpToMigration', () => {
it('should throw error if migration not found', async () => {
await expect(initDbUpToMigration('NonExistentMigration')).rejects.toThrow(
new UnexpectedError('Migration "NonExistentMigration" not found'),
);
});
it('should stop before specified migration', async () => {
const migrations = dataSource.options.migrations as Array<{ name: string }>;
expect(migrations.length).toBeGreaterThan(1);
const secondMigrationName = migrations[1].name;
console.log('Running migrations up to ' + secondMigrationName);
await initDbUpToMigration(secondMigrationName);
console.log('Migrations executed up to ' + secondMigrationName);
// Verify only first migration was executed
const executed = await dataSource.query(
`SELECT * FROM ${getMigrationsTableName()} ORDER BY timestamp`,
);
expect(executed).toHaveLength(1);
expect(executed[0].name).toBe(migrations[0].name);
});
});
describe('runSingleMigration', () => {
it('should throw error if migration not found', async () => {
await expect(runSingleMigration('NonExistentMigration')).rejects.toThrow(
new UnexpectedError('Migration "NonExistentMigration" not found'),
);
});
it('should run specific migration', async () => {
const migrations = dataSource.options.migrations as Array<{ name: string }>;
expect(migrations.length).toBeGreaterThan(1);
const secondMigrationName = migrations[1].name;
console.log('Running migrations up to ' + secondMigrationName);
await initDbUpToMigration(secondMigrationName);
console.log('Migrations executed up to ' + secondMigrationName);
await runSingleMigration(secondMigrationName);
const executed = await dataSource.query(
`SELECT * FROM ${getMigrationsTableName()} ORDER BY timestamp`,
);
expect(executed).toHaveLength(2);
expect(executed[0].name).toBe(migrations[0].name);
expect(executed[1].name).toBe(secondMigrationName);
});
});
});