Files
n8n/packages/cli/test/migration/1769784356000-expand-subject-id-column-length.test.ts
T
alighasami 3d5eaf9445
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
first commit
2026-03-17 16:22:57 +03:30

546 lines
18 KiB
TypeScript

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();
});
});
});