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 { 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 { 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( `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>( `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>( `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; expect(row.displayName ?? row.displayname).toBe('Unique Role Name'); // Cleanup await context.queryRunner.release(); }); }); });