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
546 lines
18 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|