diff --git a/scripts/repro-shard5-postgrest-flakes.ts b/scripts/repro-shard5-postgrest-flakes.ts new file mode 100644 index 0000000000..c668175c80 --- /dev/null +++ b/scripts/repro-shard5-postgrest-flakes.ts @@ -0,0 +1,172 @@ +/** + * Reproduce backend shard 5/6 PostgREST/Kong upstream flakes without Vitest retries. + * + * CI evidence: + * - stats createAppVersions via PostgREST → "An invalid response was received from the upstream server" + * - organization-api capgkey auth via PostgREST → intermittent 401 invalid_apikey + * + * This script forces the upstream failure mode by pausing the local PostgREST + * container during the old-path hammer, then proves direct SQL still succeeds. + * + * Usage (Supabase must be running): + * bun run supabase:with-env -- bun scripts/repro-shard5-postgrest-flakes.ts + */ +import { randomUUID } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { createClient } from '@supabase/supabase-js' +import pg from 'pg' + +const SUPABASE_URL = process.env.SUPABASE_URL +const SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY + ?? process.env.SUPABASE_SERVICE_ROLE_KEY + ?? process.env.SERVICE_ROLE_KEY +const DB_URL = process.env.SUPABASE_DB_URL ?? process.env.DB_URL + +if (!SUPABASE_URL || !SERVICE_KEY || !DB_URL) { + console.error('Missing SUPABASE_URL / service key / DB URL. Use: bun run supabase:with-env -- bun scripts/repro-shard5-postgrest-flakes.ts') + process.exit(2) +} + +const ORG_ID = '046a36ac-e03c-4590-9257-bd6c9dba9ee8' +const USER_ID = '6aa76066-55ef-4238-ade6-0b32334a4097' +const PARALLEL = Number(process.env.REPRO_PARALLEL ?? 20) +const REQUEST_TIMEOUT_MS = Number(process.env.REPRO_TIMEOUT_MS ?? 2000) + +const supabase = createClient(SUPABASE_URL, SERVICE_KEY, { + auth: { persistSession: false }, + global: { + fetch: (input, init) => fetch(input, { + ...init, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }), + }, +}) +const pool = new pg.Pool({ connectionString: DB_URL, max: 10 }) + +function findPostgrestContainer(): string | null { + const listed = spawnSync('docker', ['ps', '--format', '{{.Names}}'], { encoding: 'utf8' }) + if (listed.status !== 0) + return null + const names = listed.stdout.split('\n').map(s => s.trim()).filter(Boolean) + return names.find(name => name.includes('rest') && name.includes('capgo')) + ?? names.find(name => name.includes('rest')) + ?? null +} + +function dockerAction(action: 'pause' | 'unpause', container: string) { + const result = spawnSync('docker', [action, container], { encoding: 'utf8' }) + if (result.status !== 0) { + throw new Error(`docker ${action} ${container} failed: ${result.stderr || result.stdout}`) + } +} + +async function ensureApp(appId: string) { + await pool.query( + `INSERT INTO public.apps (app_id, icon_url, name, owner_org, user_id) + VALUES ($1, '', $1, $2::uuid, $3::uuid) + ON CONFLICT (app_id) DO NOTHING`, + [appId, ORG_ID, USER_ID], + ) +} + +async function cleanupApp(appId: string) { + await pool.query('DELETE FROM public.app_versions WHERE app_id = $1', [appId]) + await pool.query('DELETE FROM public.apps WHERE app_id = $1', [appId]) +} + +async function postgrestCreateVersion(appId: string, version: string) { + try { + const { data, error } = await supabase.from('app_versions').upsert({ + app_id: appId, + name: version, + owner_org: ORG_ID, + }, { + onConflict: 'app_id,name', + }).select('id,name').single() + if (error || !data) + return { ok: false as const, error: error?.message ?? 'no data' } + return { ok: true as const } + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { ok: false as const, error: message } + } +} + +async function sqlCreateVersion(appId: string, version: string) { + const result = await pool.query( + `INSERT INTO public.app_versions (app_id, name, owner_org) + VALUES ($1, $2, $3::uuid) + ON CONFLICT (name, app_id) DO UPDATE SET updated_at = now() + RETURNING id, name`, + [appId, version, ORG_ID], + ) + if (!result.rows[0]) + return { ok: false as const, error: 'no data' } + return { ok: true as const } +} + +async function runBatch( + label: string, + fn: (appId: string, version: string) => Promise<{ ok: boolean, error?: string }>, +) { + const appId = `com.repro.shard5.${randomUUID().slice(0, 8)}` + await ensureApp(appId) + const results = await Promise.all( + Array.from({ length: PARALLEL }, (_, i) => { + const version = `1.0.0-repro-${i}-${randomUUID().slice(0, 8)}` + return fn(appId, version) + }), + ) + const failures = results.filter(r => !r.ok) + const errors = new Map() + for (const failure of failures) { + const key = failure.error ?? 'unknown' + errors.set(key, (errors.get(key) ?? 0) + 1) + } + console.log(`\n[${label}] failures=${failures.length}/${PARALLEL} (${((failures.length / PARALLEL) * 100).toFixed(1)}%)`) + for (const [message, count] of [...errors.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5)) + console.log(` - ${count}x ${message}`) + await cleanupApp(appId) + return failures.length +} + +const container = findPostgrestContainer() +if (!container) { + console.error('Could not find local Postgrest docker container') + process.exit(2) +} +console.log(`Using PostgREST container: ${container}`) + +let postgrestFailures = 0 +try { + dockerAction('pause', container) + await Bun.sleep(300) + postgrestFailures = await runBatch('PostgREST upsert while upstream paused (old path)', postgrestCreateVersion) +} +finally { + try { + dockerAction('unpause', container) + } + catch { + // already running + } + await Bun.sleep(300) +} + +const sqlFailures = await runBatch('Direct SQL upsert (fixed path)', sqlCreateVersion) + +await pool.end() + +if (postgrestFailures < PARALLEL) { + console.error('\nExpected PostgREST path to fail 100% while upstream is paused.') + process.exit(1) +} + +if (sqlFailures > 0) { + console.error('\nSQL path failed — fix is incomplete.') + process.exit(1) +} + +console.log('\nRepro locked: PostgREST path fails 100% on upstream outage; direct SQL stays green.') +process.exit(0) diff --git a/supabase/functions/_backend/public/organization/put.ts b/supabase/functions/_backend/public/organization/put.ts index 35e1d09c41..338a8c9d0d 100644 --- a/supabase/functions/_backend/public/organization/put.ts +++ b/supabase/functions/_backend/public/organization/put.ts @@ -250,25 +250,38 @@ function buildUpdateFields(body: OrganizationPutBody, sanitizedName?: string) { } async function sanitizeOrgNameForSync( - supabase: ReturnType, + c: Context, name: string, ) { - const { data, error } = await supabase.rpc('strip_html', { input: name }) + // Direct SQL avoids Kong/PostgREST upstream flakes under parallel test load. + const pgPool = getPgClient(c) + let client: PgTransactionClient | null = null + try { + client = await pgPool.connect() as PgTransactionClient + const result = await client.query<{ strip_html: string | null }>( + 'SELECT public.strip_html($1) AS strip_html', + [name], + ) + const data = result.rows[0]?.strip_html + if (data === null || data === undefined) { + throw simpleError('cannot_update_org', 'Cannot update org', { + error: 'cannot_sanitize_org_name', + }) + } - if (error || data === null) { - throw simpleError('cannot_update_org', 'Cannot update org', { - error: error?.message ?? 'cannot_sanitize_org_name', - }) - } + const sanitizedName = data.trim() + if (!sanitizedName) { + throw simpleError('invalid_body', 'Invalid body', { + error: 'sanitized_name_empty', + }) + } - const sanitizedName = data.trim() - if (!sanitizedName) { - throw simpleError('invalid_body', 'Invalid body', { - error: 'sanitized_name_empty', - }) + return sanitizedName + } + finally { + client?.release() + await closeClient(c, pgPool) } - - return sanitizedName } async function enforceSelf2faRequirement(authUserId: string, c: Context) { @@ -361,20 +374,28 @@ function buildExpectedCurrentFields( } async function getOrgForNameSync( - supabase: ReturnType, + c: Context, orgId: string, ): Promise { - const { error, data } = await supabase - .from('orgs') - .select('*') - .eq('id', orgId) - .single() - - if (error) { - throw simpleError('cannot_get_org', 'Cannot get org', { error: error.message }) + // Direct SQL avoids Kong/PostgREST upstream flakes under parallel test load. + const pgPool = getPgClient(c) + let client: PgTransactionClient | null = null + try { + client = await pgPool.connect() as PgTransactionClient + const result = await client.query( + 'SELECT * FROM public.orgs WHERE id = $1::uuid LIMIT 1', + [orgId], + ) + const data = result.rows[0] + if (!data) { + throw simpleError('cannot_get_org', 'Cannot get org', { error: 'org_not_found' }) + } + return data + } + finally { + client?.release() + await closeClient(c, pgPool) } - - return data } function getErrorDetail(error: unknown) { @@ -417,12 +438,12 @@ export async function put( validateMaxExpirationDays(body.max_apikey_expiration_days) validateRequiredEncryptionKey(body.required_encryption_key) const sanitizedOrgName = body.name !== undefined - ? await sanitizeOrgNameForSync(supabase, body.name) + ? await sanitizeOrgNameForSync(c, body.name) : undefined const updateFields = buildUpdateFields(body, sanitizedOrgName) const shouldSyncStripeName = body.name !== undefined const currentOrg = shouldSyncStripeName - ? await getOrgForNameSync(supabase, body.orgId) + ? await getOrgForNameSync(c, body.orgId) : null const dataOrg: Database['public']['Tables']['orgs']['Row'] = await updateOrg(c, auth, body.orgId, updateFields, { diff --git a/supabase/functions/_backend/utils/hono_middleware.ts b/supabase/functions/_backend/utils/hono_middleware.ts index ab2eb08f9a..a2c0f83b88 100644 --- a/supabase/functions/_backend/utils/hono_middleware.ts +++ b/supabase/functions/_backend/utils/hono_middleware.ts @@ -82,9 +82,11 @@ async function checkKeyPg( return null } - // Convert to the expected format + // Convert to the expected format. + // drizzle execute can return numeric ids as strings; keep number so + // authApikey.id === existingApikey.id self-update checks work. return { - id: apiKey.id, + id: Number(apiKey.id), created_at: apiKey.created_at, user_id: apiKey.user_id, key: apiKey.key, @@ -449,7 +451,11 @@ async function foundAPIKey(c: Context, capgkeyString: string) { const subkey_id = await getSubkeyId(c) cloudlog({ requestId: c.get('requestId'), message: 'Capgkey provided', capgkeyPrefix: maskSecret(capgkeyString) }) - const apikey = await resolveApiKey(c, capgkeyString, false) + // Prefer direct Postgres on primary. PostgREST/Kong under parallel Vitest load + // returns upstream errors that were previously misclassified as invalid_apikey 401 + // (flaky organization-api on backend shard 5/6). readOnly=false avoids replica lag + // right after key creation. + const apikey = await resolveApiKey(c, capgkeyString, true, false) if (!apikey) { cloudlog({ requestId: c.get('requestId'), message: 'Invalid apikey', capgkeyPrefix: maskSecret(capgkeyString) }) // Record failed auth attempt - await to ensure accurate counting diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index 35b5bbca1e..3ca4918f17 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -1,5 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js' import type { Context } from 'hono' +import { HTTPException } from 'hono/http-exception' import type { BillingPlanBentoState } from './billing_bento_tags.ts' import type { AuthInfo } from './hono.ts' import type { Database } from './supabase.types.ts' @@ -7,7 +8,7 @@ import type { DeviceWithoutCreatedAt, NativeVersionUsage, Order, ReadDevicesPara import { createClient } from '@supabase/supabase-js' import { buildBillingPlanBentoTags } from './billing_bento_tags.ts' import { buildNormalizedDeviceForWrite, hasComparableDeviceChanged, nullableString } from './deviceComparison.ts' -import { simpleError } from './hono.ts' +import { simpleError, quickError } from './hono.ts' import { cloudlog, cloudlogErr } from './logging.ts' import { closeClient, getPgClient } from './pg.ts' import { emptyStatsInsights, normalizeStatsInsightsResult } from './statsInsights.ts' @@ -1759,7 +1760,17 @@ export async function checkKey(c: Context, authorization: string | undefined, su .rpc('find_apikey_by_value', { key_value: authorization }) .single() - if (error || !data) { + if (error) { + // Kong/PostgREST overload must not look like a bad key (flaky 401s in CI). + const message = error.message ?? '' + if (message.includes('invalid response was received from the upstream server')) { + cloudlog({ requestId: c.get('requestId'), message: 'Apikey lookup upstream failure', authorizationPrefix: authorization?.substring(0, 8), error }) + throw quickError(503, 'upstream_unavailable', 'Upstream unavailable', { error: message }) + } + cloudlog({ requestId: c.get('requestId'), message: 'Invalid apikey', authorizationPrefix: authorization?.substring(0, 8), error }) + return null + } + if (!data) { cloudlog({ requestId: c.get('requestId'), message: 'Invalid apikey', authorizationPrefix: authorization?.substring(0, 8), error }) return null } @@ -1773,6 +1784,8 @@ export async function checkKey(c: Context, authorization: string | undefined, su return data } catch (error) { + if (error instanceof HTTPException) + throw error cloudlog({ requestId: c.get('requestId'), message: 'checkKey error', error }) return null } diff --git a/tests/audit-logs.test.ts b/tests/audit-logs.test.ts index 90322b9e2e..8d1989b4d5 100644 --- a/tests/audit-logs.test.ts +++ b/tests/audit-logs.test.ts @@ -99,56 +99,47 @@ async function waitForAuditLog( beforeAll(async () => { authHeaders = await getAuthHeaders() - const { data: actorUser, error: actorUserError } = await getSupabaseClient() - .from('users') - .select('email') - .eq('id', USER_ID) - .single() - if (actorUserError || !actorUser) - throw actorUserError ?? new Error('Failed to load audit actor user') + // Seed via SQL to avoid Kong/PostgREST upstream flakes under CF shard load. + const [actorUser] = await executeSQL( + 'SELECT email FROM public.users WHERE id = $1::uuid LIMIT 1', + [USER_ID], + ) + if (!actorUser?.email) { + throw new Error(`Failed to load audit actor user for id=${USER_ID}: no rows`) + } actorUserEmail = actorUser.email - // Create stripe_info for this test org - const { error: stripeError } = await getSupabaseClient().from('stripe_info').insert({ - customer_id: customerId, - status: 'succeeded', - product_id: 'prod_LQIregjtNduh4q', - subscription_id: `sub_${globalId}`, - trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), - is_good_plan: true, - }) - if (stripeError) - throw stripeError - - // Create test organization (this should trigger an INSERT audit log via the trigger) - const { error } = await getSupabaseClient().from('orgs').insert({ - id: ORG_ID, - name, - management_email: TEST_EMAIL, - created_by: USER_ID, - customer_id: customerId, - }) - if (error) - throw error - - // Ensure the creator is a member; org creation side-effects can be async in CI. - // The /organization/audit endpoint requires super_admin rights. - const { error: memberError } = await getSupabaseClient().from('org_users').insert({ - org_id: ORG_ID, - user_id: USER_ID, - rbac_role_name: 'org_super_admin', - }) - if (memberError) - throw memberError - - const { error: appError } = await getSupabaseClient().from('apps').insert({ - app_id: APIKEY_AUDIT_APP_ID, - name: `Audit API App ${globalId}`, - icon_url: 'https://example.com/icon.png', - owner_org: ORG_ID, - }) - if (appError) - throw appError + const trialAt = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString() + await executeSQL( + `INSERT INTO public.stripe_info ( + customer_id, status, product_id, subscription_id, trial_at, is_good_plan + ) VALUES ($1, 'succeeded', 'prod_LQIregjtNduh4q', $2, $3::timestamptz, true)`, + [customerId, `sub_${globalId}`, trialAt], + ) + + // Org insert still triggers INSERT audit logs via DB triggers. + await executeSQL( + `INSERT INTO public.orgs ( + id, name, management_email, created_by, customer_id + ) VALUES ($1::uuid, $2, $3, $4::uuid, $5)`, + [ORG_ID, name, TEST_EMAIL, USER_ID, customerId], + ) + + await executeSQL( + `INSERT INTO public.org_users (org_id, user_id, rbac_role_name) + SELECT $1::uuid, $2::uuid, 'org_super_admin' + WHERE NOT EXISTS ( + SELECT 1 FROM public.org_users + WHERE org_id = $1::uuid AND user_id = $2::uuid + )`, + [ORG_ID, USER_ID], + ) + + await executeSQL( + `INSERT INTO public.apps (app_id, name, icon_url, owner_org) + VALUES ($1, $2, 'https://example.com/icon.png', $3::uuid)`, + [APIKEY_AUDIT_APP_ID, `Audit API App ${globalId}`, ORG_ID], + ) const apiKeyData = await createDirectApiKeyWithBindings({ userId: USER_ID, @@ -171,17 +162,14 @@ beforeAll(async () => { }) afterAll(async () => { - // Clean up: delete audit logs first (they reference the org) - await getSupabaseClient().from('audit_logs').delete().eq('org_id', ORG_ID) - - // Clean up test organization and stripe_info + await executeSQL('DELETE FROM public.audit_logs WHERE org_id = $1::uuid', [ORG_ID]) if (apiKeyId !== null) - await getSupabaseClient().from('apikeys').delete().eq('id', apiKeyId) - await getSupabaseClient().from('app_versions').delete().eq('app_id', APIKEY_AUDIT_APP_ID) - await getSupabaseClient().from('apps').delete().eq('app_id', APIKEY_AUDIT_APP_ID) - await getSupabaseClient().from('org_users').delete().eq('org_id', ORG_ID) - await getSupabaseClient().from('orgs').delete().eq('id', ORG_ID) - await getSupabaseClient().from('stripe_info').delete().eq('customer_id', customerId) + await executeSQL('DELETE FROM public.apikeys WHERE id = $1', [apiKeyId]) + await executeSQL('DELETE FROM public.app_versions WHERE app_id = $1', [APIKEY_AUDIT_APP_ID]) + await executeSQL('DELETE FROM public.apps WHERE app_id = $1', [APIKEY_AUDIT_APP_ID]) + await executeSQL('DELETE FROM public.org_users WHERE org_id = $1::uuid', [ORG_ID]) + await executeSQL('DELETE FROM public.orgs WHERE id = $1::uuid', [ORG_ID]) + await executeSQL('DELETE FROM public.stripe_info WHERE customer_id = $1', [customerId]) }, 60_000) describe('[GET] /organization/audit', () => { diff --git a/tests/organization-api.test.ts b/tests/organization-api.test.ts index dfbefc95fc..776b03e557 100644 --- a/tests/organization-api.test.ts +++ b/tests/organization-api.test.ts @@ -39,28 +39,20 @@ let organizationApiKeyId = 0 beforeAll(async () => { authHeaders = await getAuthHeaders() - // Create stripe_info for this test org - const { error: stripeError } = await getSupabaseClient().from('stripe_info').insert({ - customer_id: customerId, - status: 'succeeded', - product_id: 'prod_LQIregjtNduh4q', - subscription_id: `sub_${globalId}`, - trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), - is_good_plan: true, - }) - if (stripeError) - throw stripeError - - const { error } = await getSupabaseClient().from('orgs').insert({ - id: ORG_ID, - name, - management_email: TEST_EMAIL, - created_by: USER_ID, - customer_id: customerId, - website, - }) - if (error) - throw error + // Seed via SQL to avoid Kong/PostgREST upstream flakes under shard load. + const trialAt = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString() + await executeSQL( + `INSERT INTO public.stripe_info ( + customer_id, status, product_id, subscription_id, trial_at, is_good_plan + ) VALUES ($1, 'succeeded', 'prod_LQIregjtNduh4q', $2, $3::timestamptz, true)`, + [customerId, `sub_${globalId}`, trialAt], + ) + await executeSQL( + `INSERT INTO public.orgs ( + id, name, management_email, created_by, customer_id, website + ) VALUES ($1::uuid, $2, $3, $4::uuid, $5, $6)`, + [ORG_ID, name, TEST_EMAIL, USER_ID, customerId, website], + ) const createdKey = await createDirectApiKeyWithBindings({ userId: USER_ID, @@ -80,38 +72,37 @@ beforeAll(async () => { }) async function createUserOrgBinding(orgId: string, userId: string, roleName = 'org_member', grantedBy = USER_ID) { - const { data: role, error: roleError } = await getSupabaseClient() - .from('roles') - .select('id') - .eq('name', roleName) - .eq('scope_type', 'org') - .single() - if (roleError) - throw roleError - - const { error: bindingError } = await getSupabaseClient() - .from('role_bindings') - .insert({ - principal_type: 'user', - principal_id: userId, - role_id: role!.id, - scope_type: 'org', - org_id: orgId, - granted_by: grantedBy, - reason: 'Test RBAC binding', - is_direct: true, - }) - if (bindingError && bindingError.code !== '23505') - throw bindingError + const [role] = await executeSQL( + `SELECT id FROM public.roles WHERE name = $1 AND scope_type = 'org' LIMIT 1`, + [roleName], + ) + if (!role?.id) + throw new Error(`Unable to resolve org role ${roleName}`) + + try { + await executeSQL( + `INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, + granted_by, reason, is_direct + ) VALUES ( + 'user', $1::uuid, $2::uuid, 'org', $3::uuid, $4::uuid, + 'Test RBAC binding', true + )`, + [userId, role.id, orgId, grantedBy], + ) + } + catch (error: any) { + if (error?.code !== '23505') + throw error + } } afterAll(async () => { - // Clean up test organization, org_users relation, and stripe_info if (organizationApiKeyId) { - await getSupabaseClient().from('apikeys').delete().eq('id', organizationApiKeyId) + await executeSQL('DELETE FROM public.apikeys WHERE id = $1', [organizationApiKeyId]) } - await getSupabaseClient().from('orgs').delete().eq('id', ORG_ID) - await getSupabaseClient().from('stripe_info').delete().eq('customer_id', customerId) + await executeSQL('DELETE FROM public.orgs WHERE id = $1::uuid', [ORG_ID]) + await executeSQL('DELETE FROM public.stripe_info WHERE customer_id = $1', [customerId]) }) describe('read-only API keys cannot access destructive organization routes', () => { @@ -126,25 +117,21 @@ describe('read-only API keys cannot access destructive organization routes', () } beforeAll(async () => { - const { error: stripeError } = await getSupabaseClient().from('stripe_info').insert({ - customer_id: readOnlyCustomerId, - status: 'succeeded', - product_id: 'prod_LQIregjtNduh4q', - subscription_id: `sub_${readOnlyGlobalId}`, - trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), - is_good_plan: true, - }) - expect(stripeError).toBeNull() - - const { error: orgError } = await getSupabaseClient().from('orgs').insert({ - id: readOnlyOrgId, - name: readOnlyName, - management_email: TEST_EMAIL, - created_by: USER_ID, - customer_id: readOnlyCustomerId, - require_apikey_expiration: false, - }) - expect(orgError).toBeNull() + // Seed via SQL — PostgREST/Kong flakes under CF shard load with + // "An invalid response was received from the upstream server". + const trialAt = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString() + await executeSQL( + `INSERT INTO public.stripe_info ( + customer_id, status, product_id, subscription_id, trial_at, is_good_plan + ) VALUES ($1, 'succeeded', 'prod_LQIregjtNduh4q', $2, $3::timestamptz, true)`, + [readOnlyCustomerId, `sub_${readOnlyGlobalId}`, trialAt], + ) + await executeSQL( + `INSERT INTO public.orgs ( + id, name, management_email, created_by, customer_id, require_apikey_expiration + ) VALUES ($1::uuid, $2, $3, $4::uuid, $5, false)`, + [readOnlyOrgId, readOnlyName, TEST_EMAIL, USER_ID, readOnlyCustomerId], + ) const createdKey = await createDirectApiKeyWithBindings({ userId: USER_ID, @@ -165,10 +152,10 @@ describe('read-only API keys cannot access destructive organization routes', () afterAll(async () => { if (readOnlyKeyId) { - await getSupabaseClient().from('apikeys').delete().eq('id', readOnlyKeyId) + await executeSQL('DELETE FROM public.apikeys WHERE id = $1', [readOnlyKeyId]) } - await getSupabaseClient().from('orgs').delete().eq('id', readOnlyOrgId) - await getSupabaseClient().from('stripe_info').delete().eq('customer_id', readOnlyCustomerId) + await executeSQL('DELETE FROM public.orgs WHERE id = $1::uuid', [readOnlyOrgId]) + await executeSQL('DELETE FROM public.stripe_info WHERE customer_id = $1', [readOnlyCustomerId]) }) it.concurrent('rejects POST /organization/members', async () => { @@ -1226,29 +1213,27 @@ describe('[POST] /organization/members', () => { describe('[DELETE] /organization/members', () => { it('delete organization member', async () => { - const { data: userData, error: userError } = await getSupabaseClient().from('users').select().eq('email', USER_ADMIN_EMAIL).single() - expect(userError).toBeNull() + const [userData] = await executeSQL( + 'SELECT id, email FROM public.users WHERE email = $1 LIMIT 1', + [USER_ADMIN_EMAIL], + ) expect(userData).toBeTruthy() expect(userData?.email).toBe(USER_ADMIN_EMAIL) - const { error } = await getSupabaseClient().from('org_users').insert({ - org_id: ORG_ID, - user_id: userData!.id, - rbac_role_name: 'org_member', - }) - expect(error).toBeNull() + await executeSQL( + `INSERT INTO public.org_users (org_id, user_id, rbac_role_name) + VALUES ($1::uuid, $2::uuid, 'org_member')`, + [ORG_ID, userData.id], + ) - await createUserOrgBinding(ORG_ID, userData!.id, 'org_member') + await createUserOrgBinding(ORG_ID, userData.id, 'org_member') - const { data: rbacData, error: rbacFetchError } = await getSupabaseClient() - .from('role_bindings') - .select() - .eq('principal_type', 'user') - .eq('principal_id', userData!.id) - .eq('org_id', ORG_ID) - expect(rbacFetchError).toBeNull() - expect(rbacData).toBeTruthy() - expect(rbacData!.length).toBeGreaterThan(0) + const rbacData = await executeSQL( + `SELECT id FROM public.role_bindings + WHERE principal_type = 'user' AND principal_id = $1::uuid AND org_id = $2::uuid`, + [userData.id, ORG_ID], + ) + expect(rbacData.length).toBeGreaterThan(0) const response = await fetch(`${BASE_URL}/organization/members?orgId=${ORG_ID}&email=${USER_ADMIN_EMAIL}`, { headers, @@ -1264,12 +1249,17 @@ describe('[DELETE] /organization/members', () => { throw safe.error expect(safe.data.status).toBe('ok') - const { data, error: orgUserError } = await getSupabaseClient().from('org_users').select().eq('org_id', ORG_ID).eq('user_id', userData!.id).single() - expect(orgUserError).toBeTruthy() - expect(data).toBeNull() + const orgUsers = await executeSQL( + 'SELECT id FROM public.org_users WHERE org_id = $1::uuid AND user_id = $2::uuid', + [ORG_ID, userData.id], + ) + expect(orgUsers).toHaveLength(0) - // Verify role_bindings were also cleaned up - const { data: rbacDataAfterDelete } = await getSupabaseClient().from('role_bindings').select().eq('principal_type', 'user').eq('principal_id', userData!.id).eq('org_id', ORG_ID) + const rbacDataAfterDelete = await executeSQL( + `SELECT id FROM public.role_bindings + WHERE principal_type = 'user' AND principal_id = $1::uuid AND org_id = $2::uuid`, + [userData.id, ORG_ID], + ) expect(rbacDataAfterDelete).toHaveLength(0) }) diff --git a/tests/organization-put-stripe-sync.unit.test.ts b/tests/organization-put-stripe-sync.unit.test.ts index 314a8915dc..d45d76e35c 100644 --- a/tests/organization-put-stripe-sync.unit.test.ts +++ b/tests/organization-put-stripe-sync.unit.test.ts @@ -116,12 +116,16 @@ function createOrgRow(overrides: Partial & Pick Promise<{ data: Partial | null, error: { message: string } | null }>> } +const pendingOrganizationSelects: Array<{ + data: OrgRow + maybeSingle: () => Promise<{ data: OrgRow | null, error: { message: string } | null }> +}> = [] const pendingOrganizationUpdates: OrganizationUpdateBuilder[] = [] let organizationUpdateQueryMock: ReturnType @@ -170,6 +178,25 @@ function recordDirectOrganizationUpdate(builder: OrganizationUpdateBuilder, text function mockOrganizationUpdates() { const query = vi.fn(async (text: string, params?: unknown[]) => { + if (text.includes('strip_html')) { + const raw = String(params?.[0] ?? '') + // Fixture map only — avoid regex "sanitization" that CodeQL flags. + const fixtures: Record = { + ' New Name ': ' New Name ', + 'New Name': 'New Name', + '': '', + } + return { rows: [{ strip_html: fixtures[raw] ?? raw }] } + } + if (text.startsWith('SELECT * FROM public.orgs')) { + const builder = pendingOrganizationSelects.shift() + if (!builder) + return { rows: [] } + const { data, error } = await builder.maybeSingle() + if (error) + throw new Error(error.message) + return { rows: data ? [data] : [] } + } if (text.startsWith('UPDATE public.orgs')) { const builder = pendingOrganizationUpdates.shift() if (!builder) @@ -212,6 +239,7 @@ describe('organization put Stripe sync', () => { beforeEach(() => { vi.clearAllMocks() pendingOrganizationUpdates.length = 0 + pendingOrganizationSelects.length = 0 closeClientMock.mockResolvedValue(undefined) mockOrganizationUpdates() checkPermissionMock.mockResolvedValue(true) @@ -470,7 +498,6 @@ describe('organization put Stripe sync', () => { expect(response.status).toBe(200) expect(getStripeCustomerNameMock).toHaveBeenCalledWith(expect.anything(), 'cus_123') - expect(from).toHaveBeenCalledTimes(1) expect(getOrganizationUpdateCalls()).toHaveLength(1) }) @@ -547,7 +574,6 @@ describe('organization put Stripe sync', () => { error: 'connection reset', stripeSyncState: 'unknown', }) - expect(from).toHaveBeenCalledTimes(1) expect(getOrganizationUpdateCalls()).toHaveLength(1) }) diff --git a/tests/private-analytics-validation.unit.test.ts b/tests/private-analytics-validation.unit.test.ts index bbe15725b5..ab0a37584a 100644 --- a/tests/private-analytics-validation.unit.test.ts +++ b/tests/private-analytics-validation.unit.test.ts @@ -161,7 +161,13 @@ describe('private analytics route validation', () => { [], '2.0.0', undefined, - 'android', + { + platform: 'android', + updatedAt: { + gt: undefined, + lte: undefined, + }, + }, ) }) }) diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 020547a35c..d4008fc1a4 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -292,7 +292,18 @@ export async function createDirectApiKeyWithBindings(options: { expiresAt?: string | null hashed?: boolean }) { - const supabase = getSupabaseClient() + // Direct SQL avoids Kong/PostgREST upstream flakes under parallel CI shards. + // Inserts run as DB owner, so apikeys_force_server_key does not rewrite the key. + // Auth middleware only treats Authorization values as API keys when they are UUIDs, + // so plain keys must be UUIDs (PostgREST+authenticator used to force that). + const userId = options.userId ?? USER_ID + const roleName = options.roleName ?? 'org_admin' + const appRoleName = options.appRoleName ?? 'app_admin' + const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + // Always persist/auth with a UUID secret. middlewareAuth only treats + // Authorization values as API keys when isUUID(header) is true. + const plainKey = uuidRe.test(options.key) ? options.key : randomUUID() + let apiKey: { id: number key: string | null @@ -301,114 +312,113 @@ export async function createDirectApiKeyWithBindings(options: { expires_at: string | null } | null = null - if (options.hashed) { - const [insertedKey] = await executeSQL( - `INSERT INTO public.apikeys (user_id, key, key_hash, name, expires_at) - VALUES ($1, NULL, encode(extensions.digest($2, 'sha256'), 'hex'), $3, $4) - RETURNING id, key, rbac_id, user_id, expires_at`, - [options.userId ?? USER_ID, options.key, options.name, options.expiresAt ?? null], - ) - apiKey = insertedKey - ? { - id: Number(insertedKey.id), - key: insertedKey.key, - rbac_id: insertedKey.rbac_id, - user_id: insertedKey.user_id, - expires_at: insertedKey.expires_at, - } - : null - } - else { - const { data, error: apiKeyError } = await supabase - .from('apikeys') - .insert({ - user_id: options.userId ?? USER_ID, - key: options.key, - key_hash: null, - name: options.name, - expires_at: options.expiresAt ?? null, - }) - .select('id, key, rbac_id, user_id, expires_at') - .single() - - if (apiKeyError) - throw apiKeyError - - apiKey = data - } + try { + if (options.hashed) { + const [insertedKey] = await executeSQL( + `INSERT INTO public.apikeys (user_id, key, key_hash, name, expires_at) + VALUES ($1::uuid, NULL, encode(extensions.digest($2, 'sha256'), 'hex'), $3, $4) + RETURNING id, key, rbac_id, user_id, expires_at`, + [userId, plainKey, options.name, options.expiresAt ?? null], + ) + apiKey = insertedKey + ? { + id: Number(insertedKey.id), + // Return plaintext secret for hashed keys (column key is null). + key: plainKey, + rbac_id: String(insertedKey.rbac_id), + user_id: String(insertedKey.user_id), + expires_at: insertedKey.expires_at, + } + : null + } + else { + const [insertedKey] = await executeSQL( + `INSERT INTO public.apikeys (user_id, key, key_hash, name, expires_at) + VALUES ($1::uuid, $2, NULL, $3, $4) + RETURNING id, key, rbac_id, user_id, expires_at`, + [userId, plainKey, options.name, options.expiresAt ?? null], + ) + apiKey = insertedKey + ? { + id: Number(insertedKey.id), + key: insertedKey.key, + rbac_id: String(insertedKey.rbac_id), + user_id: String(insertedKey.user_id), + expires_at: insertedKey.expires_at, + } + : null + } - if (!apiKey) - throw new Error('Unable to create API key') + if (!apiKey) + throw new Error('Unable to create API key') - try { - const bindingRows: Database['public']['Tables']['role_bindings']['Insert'][] = [] - const { data: orgRole, error: orgRoleError } = await supabase - .from('roles') - .select('id') - .eq('name', options.roleName ?? 'org_admin') - .single() - if (orgRoleError || !orgRole) - throw orgRoleError ?? new Error('Unable to resolve org role') - - bindingRows.push({ - principal_type: 'apikey', - principal_id: apiKey.rbac_id, - role_id: orgRole.id, - scope_type: 'org', - org_id: options.orgId, - granted_by: apiKey.user_id, - reason: 'Test API key binding', - is_direct: true, - }) + const [orgRole] = await executeSQL( + `SELECT id FROM public.roles + WHERE name = $1 AND scope_type = 'org' + LIMIT 1`, + [roleName], + ) + if (!orgRole?.id) + throw new Error(`Unable to resolve org role ${roleName}`) + + await executeSQL( + `INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, + granted_by, reason, is_direct + ) VALUES ( + 'apikey', $1::uuid, $2::uuid, 'org', $3::uuid, $4::uuid, + 'Test API key binding', true + )`, + [apiKey.rbac_id, orgRole.id, options.orgId, apiKey.user_id], + ) if (options.appId) { - const { data: app, error: appError } = await supabase - .from('apps') - .select('id, owner_org') - .eq('app_id', options.appId) - .single() - if (appError || !app?.id || !app.owner_org) - throw appError ?? new Error(`Unable to resolve app ${options.appId}`) - if (app.owner_org !== options.orgId) + const [app] = await executeSQL( + 'SELECT id, owner_org FROM public.apps WHERE app_id = $1 LIMIT 1', + [options.appId], + ) + if (!app?.id || !app.owner_org) + throw new Error(`Unable to resolve app ${options.appId}`) + if (String(app.owner_org) !== options.orgId) throw new Error(`App ${options.appId} belongs to org ${app.owner_org}, expected ${options.orgId}`) - const { data: appRole, error: appRoleError } = await supabase - .from('roles') - .select('id') - .eq('name', options.appRoleName ?? 'app_admin') - .single() - if (appRoleError || !appRole) - throw appRoleError ?? new Error('Unable to resolve app role') - - bindingRows.push({ - principal_type: 'apikey', - principal_id: apiKey.rbac_id, - role_id: appRole.id, - scope_type: 'app', - org_id: options.orgId, - app_id: app.id, - granted_by: apiKey.user_id, - reason: 'Test API key app binding', - is_direct: true, - }) + const [appRole] = await executeSQL( + `SELECT id FROM public.roles + WHERE name = $1 AND scope_type = 'app' + LIMIT 1`, + [appRoleName], + ) + if (!appRole?.id) + throw new Error(`Unable to resolve app role ${appRoleName}`) + + await executeSQL( + `INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, app_id, + granted_by, reason, is_direct + ) VALUES ( + 'apikey', $1::uuid, $2::uuid, 'app', $3::uuid, $4::uuid, + $5::uuid, 'Test API key app binding', true + )`, + [apiKey.rbac_id, appRole.id, options.orgId, app.id, apiKey.user_id], + ) } - const { error: bindingError } = await supabase - .from('role_bindings') - .insert(bindingRows) - if (bindingError) - throw bindingError - return apiKey } catch (error) { - const { error: cleanupError } = await supabase.from('apikeys').delete().eq('id', apiKey.id) - if (cleanupError) - console.warn(`Failed to clean up API key ${apiKey.id} after binding setup error:`, cleanupError) + if (apiKey?.id) { + try { + await executeSQL('DELETE FROM public.apikeys WHERE id = $1', [apiKey.id]) + } + catch (cleanupError) { + console.warn(`Failed to clean up API key ${apiKey.id} after binding setup error:`, cleanupError) + } + } throw error } } + let cachedAuthHeaders: Record | null = null let authHeadersPromise: Promise> | null = null @@ -565,20 +575,60 @@ export async function createAppVersions( appId: string, values: Partial = {}, ) { - const supabase = getSupabaseClient() - const { error, data } = await supabase.from('app_versions').upsert({ - app_id: appId, - name: version, - owner_org: ORG_ID, - ...values, - }, { - onConflict: 'app_id,name', - }).select('id,name').single() - if (error) - console.error(`Error creating app_version for ${version}:`, error) + // Bypass PostgREST/Kong for seed writes. Under shard parallelism Kong returns + // "An invalid response was received from the upstream server" and Vitest sees + // intermittent "no data" failures (backend shard 5/6 + stats.download_fail). + // On conflict: DO NOTHING — never UPDATE ready bundles (trigger UPDATE OF + // storage_provider/r2_path/session_key raises bundle_already_ready). + const ownerOrg = values.owner_org ?? (await executeSQL( + 'SELECT owner_org FROM public.apps WHERE app_id = $1 LIMIT 1', + [appId], + ))[0]?.owner_org ?? ORG_ID + + const deleted = values.deleted ?? false + const externalUrl = values.external_url ?? null + const checksum = values.checksum ?? null + const sessionKey = values.session_key ?? null + const storageProvider = values.storage_provider ?? 'r2' + const minUpdateVersion = values.min_update_version ?? null + const r2Path = values.r2_path ?? null + const link = values.link ?? null + const comment = values.comment ?? null + const userId = values.user_id ?? null + + const inserted = await executeSQL( + `INSERT INTO public.app_versions ( + app_id, name, owner_org, deleted, external_url, checksum, session_key, + storage_provider, min_update_version, r2_path, link, comment, user_id + ) VALUES ( + $1, $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::uuid + ) + ON CONFLICT (name, app_id) DO NOTHING + RETURNING id, name`, + [ + appId, + version, + ownerOrg, + deleted, + externalUrl, + checksum, + sessionKey, + storageProvider, + minUpdateVersion, + r2Path, + link, + comment, + userId, + ], + ) + + const data = inserted[0] ?? (await executeSQL( + 'SELECT id, name FROM public.app_versions WHERE app_id = $1 AND name = $2 LIMIT 1', + [appId, version], + ))[0] if (!data) throw new Error(`Error creating app_version for ${version}: no data`) - return data + return { id: Number(data.id), name: String(data.name) } } export function getBaseData(appId: string): Partial> {