first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+619
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
+393
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+619
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+364
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+265
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user