From 871fe12f34598d8d6285b9b56ee4c67975d821ef Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 29 Jan 2026 14:16:17 -0800 Subject: [PATCH] fix tests --- .../external-db-sync/sequencer/route.ts | 15 +++- apps/backend/src/lib/external-db-sync.ts | 87 ++++++++++++++++++- .../api/v1/external-db-sync-advanced.test.ts | 40 ++++----- .../api/v1/external-db-sync-basics.test.ts | 18 ++-- .../api/v1/external-db-sync-race.test.ts | 10 +-- 5 files changed, 133 insertions(+), 37 deletions(-) diff --git a/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts b/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts index ba3822224..e1df6ea18 100644 --- a/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts +++ b/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts @@ -11,6 +11,14 @@ import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { captureError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function assertUuid(value: unknown, label: string): asserts value is string { + if (typeof value !== "string" || value.trim().length === 0 || !UUID_REGEX.test(value)) { + throw new StatusError(500, `${label} must be a valid UUID. Received: ${JSON.stringify(value)}`); + } +} + // Assigns sequence IDs to rows that need them and queues sync requests for affected tenants. // Processes up to 1000 rows at a time from each table. async function backfillSequenceIds() { @@ -37,6 +45,7 @@ async function backfillSequenceIds() { // Enqueue sync for each affected tenant for (const { tenancyId } of projectUserTenants) { + assertUuid(tenancyId, "projectUserTenants.tenancyId"); await enqueueTenantSync(tenancyId); } @@ -63,6 +72,7 @@ async function backfillSequenceIds() { `; for (const { tenancyId } of contactChannelTenants) { + assertUuid(tenancyId, "contactChannelTenants.tenancyId"); await enqueueTenantSync(tenancyId); } @@ -87,6 +97,7 @@ async function backfillSequenceIds() { `; for (const { tenancyId } of deletedRowTenants) { + assertUuid(tenancyId, "deletedRowTenants.tenancyId"); await enqueueTenantSync(tenancyId); } } @@ -94,6 +105,7 @@ async function backfillSequenceIds() { // Queues a sync request for a specific tenant if one isn't already pending. // Prevents duplicate sync requests by checking for unfulfilled requests. async function enqueueTenantSync(tenancyId: string) { + assertUuid(tenancyId, "tenancyId"); await globalPrismaClient.$executeRaw` INSERT INTO "OutgoingRequest" ("id", "createdAt", "qstashOptions", "startedFulfillingAt") SELECT @@ -101,7 +113,7 @@ async function enqueueTenantSync(tenancyId: string) { NOW(), json_build_object( 'url', '/api/latest/internal/external-db-sync/sync-engine', - 'body', json_build_object('tenancyId', ${tenancyId}) + 'body', json_build_object('tenancyId', ${tenancyId}::uuid) ), NULL WHERE NOT EXISTS ( @@ -173,4 +185,3 @@ export const GET = createSmartRouteHandler({ }; }, }); - diff --git a/apps/backend/src/lib/external-db-sync.ts b/apps/backend/src/lib/external-db-sync.ts index e7eeffa03..44dcad8c0 100644 --- a/apps/backend/src/lib/external-db-sync.ts +++ b/apps/backend/src/lib/external-db-sync.ts @@ -6,6 +6,62 @@ import { captureError, StackAssertionError, throwErr } from "@stackframe/stack-s import { omit } from "@stackframe/stack-shared/dist/utils/objects"; import { Client } from 'pg'; +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function assertNonEmptyString(value: unknown, label: string): asserts value is string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new StackAssertionError(`${label} must be a non-empty string.`); + } +} + +function assertUuid(value: unknown, label: string): asserts value is string { + assertNonEmptyString(value, label); + if (!UUID_REGEX.test(value)) { + throw new StackAssertionError(`${label} must be a valid UUID. Received: ${JSON.stringify(value)}`); + } +} + +type PgErrorLike = { + code?: string; + constraint?: string; + message?: string; +}; + +function isDuplicateTypeError(error: unknown): error is PgErrorLike { + if (!error || typeof error !== "object") return false; + const pgError = error as PgErrorLike; + return pgError.code === "23505" && pgError.constraint === "pg_type_typname_nsp_index"; +} + +async function ensureExternalSchema( + externalClient: Client, + tableSchemaSql: string, + tableName: string, +) { + try { + await externalClient.query(tableSchemaSql); + } catch (error) { + if (!isDuplicateTypeError(error)) throw error; + + // Concurrent CREATE TABLE can race and hit a duplicate type error. + // If the table now exists, we can safely continue. + const existsResult = await externalClient.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + ); + `, [tableName]); + if (existsResult.rows[0]?.exists === true) { + return; + } + + throw new StackAssertionError( + `Duplicate type error while creating table ${JSON.stringify(tableName)}, but table does not exist.` + ); + } +} + async function pushRowsToExternalDb( externalClient: Client, tableName: string, @@ -14,6 +70,12 @@ async function pushRowsToExternalDb( expectedTenancyId: string, mappingId: string, ) { + assertNonEmptyString(tableName, "tableName"); + assertNonEmptyString(mappingId, "mappingId"); + assertUuid(expectedTenancyId, "expectedTenancyId"); + if (!Array.isArray(newRows)) { + throw new StackAssertionError(`newRows must be an array for table ${JSON.stringify(tableName)}.`); + } if (newRows.length === 0) return; // Just for our own sanity, make sure that we have the right number of positional parameters // The last parameter is mapping_name for metadata tracking @@ -73,12 +135,22 @@ async function syncMapping( tenancyId: string, dbType: 'postgres', ) { + assertNonEmptyString(mappingId, "mappingId"); + assertNonEmptyString(mapping.targetTable, "mapping.targetTable"); + assertUuid(tenancyId, "tenancyId"); const fetchQuery = mapping.internalDbFetchQuery; const updateQuery = mapping.externalDbUpdateQueries[dbType]; const tableName = mapping.targetTable; + assertNonEmptyString(fetchQuery, "internalDbFetchQuery"); + assertNonEmptyString(updateQuery, "externalDbUpdateQueries"); + if (!fetchQuery.includes("$1") || !fetchQuery.includes("$2")) { + throw new StackAssertionError( + `internalDbFetchQuery must reference $1 (tenancyId) and $2 (lastSequenceId). Mapping: ${mappingId}` + ); + } const tableSchema = mapping.targetTableSchemas[dbType]; - await externalClient.query(tableSchema); + await ensureExternalSchema(externalClient, tableSchema, tableName); let lastSequenceId = -1; const metadataResult = await externalClient.query( @@ -88,10 +160,19 @@ async function syncMapping( if (metadataResult.rows.length > 0) { lastSequenceId = Number(metadataResult.rows[0].last_synced_sequence_id); } + if (!Number.isFinite(lastSequenceId)) { + throw new StackAssertionError( + `Invalid last_synced_sequence_id for mapping ${mappingId}: ${JSON.stringify(metadataResult.rows[0]?.last_synced_sequence_id)}` + ); + } const BATCH_LIMIT = 1000; while (true) { + assertUuid(tenancyId, "tenancyId"); + if (!Number.isFinite(lastSequenceId)) { + throw new StackAssertionError(`lastSequenceId must be a finite number for mapping ${mappingId}.`); + } const rows = await internalPrisma.$queryRawUnsafe(fetchQuery, tenancyId, lastSequenceId); if (rows.length === 0) { @@ -132,6 +213,8 @@ async function syncDatabase( internalPrisma: PrismaClientTransaction, tenancyId: string, ) { + assertNonEmptyString(dbId, "dbId"); + assertUuid(tenancyId, "tenancyId"); if (dbConfig.type !== 'postgres') { throw new StackAssertionError( `Unsupported database type '${dbConfig.type}' for external DB ${dbId}. Only 'postgres' is currently supported.` @@ -143,6 +226,7 @@ async function syncDatabase( `Invalid configuration for external DB ${dbId}: 'connectionString' is missing.` ); } + assertNonEmptyString(dbConfig.connectionString, `external DB ${dbId} connectionString`); const externalClient = new Client({ connectionString: dbConfig.connectionString, @@ -176,6 +260,7 @@ async function syncDatabase( export async function syncExternalDatabases(tenancy: Tenancy) { + assertUuid(tenancy?.id, "tenancy.id"); const externalDatabases = tenancy.config.dbSync.externalDatabases; const internalPrisma = await getPrismaClientForTenancy(tenancy); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts index 90653ea93..b7e39aa29 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts @@ -57,7 +57,7 @@ describe.sequential('External DB Sync - Advanced Tests', () => { } }); - const userA = await User.create({ emailAddress: 'user-a@example.com' }); + const userA = await User.create({ primary_email: 'user-a@example.com' }); await niceBackendFetch(`/api/v1/users/${userA.userId}`, { accessType: 'admin', method: 'PATCH', @@ -79,7 +79,7 @@ describe.sequential('External DB Sync - Advanced Tests', () => { } }); - const userB = await User.create({ emailAddress: 'user-b@example.com' }); + const userB = await User.create({ primary_email: 'user-b@example.com' }); await niceBackendFetch(`/api/v1/users/${userB.userId}`, { accessType: 'admin', method: 'PATCH', @@ -178,9 +178,9 @@ describe.sequential('External DB Sync - Advanced Tests', () => { const client = dbManager.getClient(dbName); - const user1 = await User.create({ emailAddress: 'seq1@example.com' }); - const user2 = await User.create({ emailAddress: 'seq2@example.com' }); - const user3 = await User.create({ emailAddress: 'seq3@example.com' }); + const user1 = await User.create({ primary_email: 'seq1@example.com' }); + const user2 = await User.create({ primary_email: 'seq2@example.com' }); + const user3 = await User.create({ primary_email: 'seq3@example.com' }); await niceBackendFetch(`/api/v1/users/${user1.userId}`, { accessType: 'admin', @@ -219,7 +219,7 @@ describe.sequential('External DB Sync - Advanced Tests', () => { const seq1 = Number(metadata1.rows[0].last_synced_sequence_id); expect(seq1).toBeGreaterThan(0); - const user4 = await User.create({ emailAddress: 'seq4@example.com' }); + const user4 = await User.create({ primary_email: 'seq4@example.com' }); await niceBackendFetch(`/api/v1/users/${user4.userId}`, { accessType: 'admin', method: 'PATCH', @@ -260,7 +260,7 @@ describe.sequential('External DB Sync - Advanced Tests', () => { } }); - const user1 = await User.create({ emailAddress: 'user1@example.com' }); + const user1 = await User.create({ primary_email: 'user1@example.com' }); await niceBackendFetch(`/api/v1/users/${user1.userId}`, { accessType: 'admin', method: 'PATCH', @@ -276,7 +276,7 @@ describe.sequential('External DB Sync - Advanced Tests', () => { expect(res.rows[0].display_name).toBe('User 1'); const user1Id = res.rows[0].id; - const user2 = await User.create({ emailAddress: 'user2@example.com' }); + const user2 = await User.create({ primary_email: 'user2@example.com' }); await niceBackendFetch(`/api/v1/users/${user2.userId}`, { accessType: 'admin', method: 'PATCH', @@ -316,7 +316,7 @@ describe.sequential('External DB Sync - Advanced Tests', () => { }); const specialName = "O'Connor 🚀 用户 \"Test\""; - const user = await User.create({ emailAddress: 'special@example.com' }); + const user = await User.create({ primary_email: 'special@example.com' }); await niceBackendFetch(`/api/v1/users/${user.userId}`, { accessType: 'admin', method: 'PATCH', @@ -456,9 +456,9 @@ describe.sequential('External DB Sync - Advanced Tests', () => { } }); - const user1 = await User.create({ emailAddress: 'seq1@example.com' }); - const user2 = await User.create({ emailAddress: 'seq2@example.com' }); - const user3 = await User.create({ emailAddress: 'seq3@example.com' }); + const user1 = await User.create({ primary_email: 'seq1@example.com' }); + const user2 = await User.create({ primary_email: 'seq2@example.com' }); + const user3 = await User.create({ primary_email: 'seq3@example.com' }); await niceBackendFetch(`/api/v1/users/${user1.userId}`, { accessType: 'admin', @@ -505,7 +505,7 @@ describe.sequential('External DB Sync - Advanced Tests', () => { method: 'DELETE', }); - const user4 = await User.create({ emailAddress: 'seq4@example.com' }); + const user4 = await User.create({ primary_email: 'seq4@example.com' }); await niceBackendFetch(`/api/v1/users/${user4.userId}`, { accessType: 'admin', method: 'PATCH', @@ -568,7 +568,7 @@ describe.sequential('External DB Sync - Advanced Tests', () => { const superClient = dbManager.getClient(dbName); - const user = await User.create({ emailAddress: 'write-protect@example.com' }); + const user = await User.create({ primary_email: 'write-protect@example.com' }); await niceBackendFetch(`/api/v1/users/${user.userId}`, { accessType: 'admin', method: 'PATCH', @@ -645,7 +645,7 @@ $$;`); const client = dbManager.getClient(dbName); - const user = await User.create({ emailAddress: 'multi-update@example.com' }); + const user = await User.create({ primary_email: 'multi-update@example.com' }); await niceBackendFetch(`/api/v1/users/${user.userId}`, { accessType: 'admin', @@ -695,7 +695,7 @@ $$;`); const client = dbManager.getClient(dbName); - const user = await User.create({ emailAddress: 'delete-before-sync@example.com' }); + const user = await User.create({ primary_email: 'delete-before-sync@example.com' }); await niceBackendFetch(`/api/v1/users/${user.userId}`, { accessType: 'admin', method: 'PATCH', @@ -748,7 +748,7 @@ $$;`); const client = dbManager.getClient(dbName); const email = 'recreate-after-delete@example.com'; - const firstUser = await User.create({ emailAddress: email }); + const firstUser = await User.create({ primary_email: email }); await niceBackendFetch(`/api/v1/users/${firstUser.userId}`, { accessType: 'admin', method: 'PATCH', @@ -772,7 +772,7 @@ $$;`); await waitForSyncedDeletion(client, email); await verifyNotInExternalDb(client, email); - const secondUser = await User.create({ emailAddress: email }); + const secondUser = await User.create({ primary_email: email }); await niceBackendFetch(`/api/v1/users/${secondUser.userId}`, { accessType: 'admin', method: 'PATCH', @@ -825,7 +825,7 @@ $$;`); const client = dbManager.getClient(dbName); const email = 'lifecycle-test@example.com'; - const user1 = await User.create({ emailAddress: email }); + const user1 = await User.create({ primary_email: email }); await niceBackendFetch(`/api/v1/users/${user1.userId}`, { accessType: 'admin', method: 'PATCH', @@ -875,7 +875,7 @@ $$;`); res = await client.query(`SELECT * FROM "users" WHERE "primary_email" = $1`, [email]); expect(res.rows.length).toBe(0); - const user2 = await User.create({ emailAddress: email }); + const user2 = await User.create({ primary_email: email }); await niceBackendFetch(`/api/v1/users/${user2.userId}`, { accessType: 'admin', method: 'PATCH', diff --git a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts index 3b40ff723..460c5ff42 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts @@ -47,7 +47,7 @@ describe.sequential('External DB Sync - Basic Tests', () => { const client = dbManager.getClient(dbName); - const user = await User.create({ emailAddress: 'insert-only@example.com' }); + const user = await User.create({ primary_email: 'insert-only@example.com' }); await niceBackendFetch(`/api/v1/users/${user.userId}`, { accessType: 'admin', method: 'PATCH', @@ -80,7 +80,7 @@ describe.sequential('External DB Sync - Basic Tests', () => { const client = dbManager.getClient(dbName); - const user = await User.create({ emailAddress: 'update-only@example.com' }); + const user = await User.create({ primary_email: 'update-only@example.com' }); await niceBackendFetch(`/api/v1/users/${user.userId}`, { accessType: 'admin', method: 'PATCH', @@ -126,7 +126,7 @@ describe.sequential('External DB Sync - Basic Tests', () => { const client = dbManager.getClient(dbName); - const user = await User.create({ emailAddress: 'delete-only@example.com' }); + const user = await User.create({ primary_email: 'delete-only@example.com' }); await niceBackendFetch(`/api/v1/users/${user.userId}`, { accessType: 'admin', method: 'PATCH', @@ -184,7 +184,7 @@ describe.sequential('External DB Sync - Basic Tests', () => { description: 'Testing that data only appears after sync is triggered' }); - const user = await User.create({ emailAddress: 'sync-verify@example.com' }); + const user = await User.create({ primary_email: 'sync-verify@example.com' }); await niceBackendFetch(`/api/v1/users/${user.userId}`, { accessType: 'admin', method: 'PATCH', @@ -225,7 +225,7 @@ describe.sequential('External DB Sync - Basic Tests', () => { const client = dbManager.getClient(dbName); - const user = await User.create({ emailAddress: 'crud-test@example.com' }); + const user = await User.create({ primary_email: 'crud-test@example.com' }); await niceBackendFetch(`/api/v1/users/${user.userId}`, { accessType: 'admin', method: 'PATCH', @@ -274,7 +274,7 @@ describe.sequential('External DB Sync - Basic Tests', () => { } }); - const user = await User.create({ emailAddress: 'auto-create@example.com' }); + const user = await User.create({ primary_email: 'auto-create@example.com' }); await niceBackendFetch(`/api/v1/users/${user.userId}`, { accessType: 'admin', method: 'PATCH', @@ -320,7 +320,7 @@ describe.sequential('External DB Sync - Basic Tests', () => { } }); - const user = await User.create({ emailAddress: 'resilience@example.com' }); + const user = await User.create({ primary_email: 'resilience@example.com' }); await niceBackendFetch(`/api/v1/users/${user.userId}`, { accessType: 'admin', method: 'PATCH', @@ -357,7 +357,7 @@ describe.sequential('External DB Sync - Basic Tests', () => { const client = dbManager.getClient(dbName); - const user = await User.create({ emailAddress: 'primary@example.com' }); + const user = await User.create({ primary_email: 'primary@example.com' }); await niceBackendFetch(`/api/v1/users/${user.userId}`, { accessType: 'admin', method: 'PATCH', @@ -410,7 +410,7 @@ describe.sequential('External DB Sync - Basic Tests', () => { const client = dbManager.getClient(dbName); - const user = await User.create({ emailAddress: 'update-test@example.com' }); + const user = await User.create({ primary_email: 'update-test@example.com' }); await niceBackendFetch(`/api/v1/users/${user.userId}`, { accessType: 'admin', method: 'PATCH', diff --git a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts index 4ebc4d42a..5cf98e517 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts @@ -49,7 +49,7 @@ describe.sequential('External DB Sync - Race Condition Tests', () => { }); const client = dbManager.getClient(dbName); - const user = await User.create({ emailAddress: 'parallel-sync@example.com' }); + const user = await User.create({ primary_email: 'parallel-sync@example.com' }); await niceBackendFetch(`/api/v1/users/${user.userId}`, { accessType: 'admin', @@ -97,7 +97,7 @@ describe.sequential('External DB Sync - Race Condition Tests', () => { }); const client = dbManager.getClient(dbName); - const user = await User.create({ emailAddress: 'update-delete@example.com' }); + const user = await User.create({ primary_email: 'update-delete@example.com' }); await niceBackendFetch(`/api/v1/users/${user.userId}`, { accessType: 'admin', @@ -284,7 +284,7 @@ describe.sequential('External DB Sync - Race Condition Tests', () => { }); const externalClient = dbManager.getClient(dbName); - const user = await User.create({ emailAddress: `${dbName}@example.com` }); + const user = await User.create({ primary_email: `${dbName}@example.com` }); // Make sure the users row exists await waitForTable(externalClient, 'users'); @@ -570,8 +570,8 @@ describe.sequential('External DB Sync - Race Condition Tests', () => { const externalClient = dbManager.getClient(dbName); - const user1 = await User.create({ emailAddress: 'row1@example.com' }); - const user2 = await User.create({ emailAddress: 'row2@example.com' }); + const user1 = await User.create({ primary_email: 'row1@example.com' }); + const user2 = await User.create({ primary_email: 'row2@example.com' }); await waitForTable(externalClient, 'users');