import { randomUUID } from 'node:crypto' import { z } from 'zod' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { safeParseSchema } from '../supabase/functions/_backend/utils/schema_validation.ts' import { BASE_URL, createDirectApiKeyWithBindings, executeSQL, fetchTestRequest, getAuthHeaders, getSupabaseClient, TEST_EMAIL, USER_ID } from './test-utils.ts' const ORG_ID = randomUUID() const globalId = randomUUID() const name = `Test Audit Organization ${globalId}` const customerId = `cus_audit_${ORG_ID}` const APIKEY_AUDIT_APP_ID = `com.audit.logs.${globalId.replace(/-/g, '')}` // Schema for audit log response const auditLogSchema = z.object({ id: z.number(), created_at: z.string(), table_name: z.string(), record_id: z.string(), operation: z.string(), user_id: z.string().nullable(), org_id: z.string(), old_record: z.unknown(), new_record: z.unknown(), changed_fields: z.array(z.string()).nullable(), actor_type: z.enum(['user', 'apikey', 'system', 'unknown']), actor_user_id: z.string().nullable(), actor_user_email: z.string().nullable(), actor_apikey_id: z.number().nullable(), actor_apikey_name: z.string().nullable(), }) const auditLogsResponseSchema = z.object({ data: z.array(auditLogSchema), total: z.number(), page: z.number(), limit: z.number(), }) interface AuditLog { id: number created_at: string table_name: string record_id: string operation: string user_id: string | null org_id: string old_record: unknown new_record: unknown changed_fields: string[] | null actor_type: 'user' | 'apikey' | 'system' | 'unknown' actor_user_id: string | null actor_user_email: string | null actor_apikey_id: number | null actor_apikey_name: string | null } function parseAuditLogsResponse(value: unknown) { return safeParseSchema(auditLogsResponseSchema, value) } let authHeaders: Record let apiKeyAuthHeaders: Record let apiKeyId: number | null = null let actorUserEmail: string async function waitForAuditLog( url: string, matcher: (log: AuditLog) => boolean, maxAttempts = 8, delayMs = 200, ) { let lastStatus = 0 let lastBody: unknown = null for (let attempt = 0; attempt < maxAttempts; attempt++) { const response = await fetchTestRequest(url, { headers: authHeaders, }) lastStatus = response.status lastBody = await response.json() if (response.status === 200) { const safe = parseAuditLogsResponse(lastBody) if (safe.success) { const matchedLog = safe.data.data.find(matcher) if (matchedLog) return matchedLog } } if (attempt < maxAttempts - 1) await new Promise(resolve => setTimeout(resolve, delayMs * (attempt + 1))) } throw new Error(`Audit log did not reach expected state. Last status: ${lastStatus}. Last body: ${JSON.stringify(lastBody)}`) } beforeAll(async () => { authHeaders = await getAuthHeaders() // 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 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, key: randomUUID(), name: `audit-api-key-${globalId}`, orgId: ORG_ID, roleName: 'org_admin', appId: APIKEY_AUDIT_APP_ID, appRoleName: 'app_admin', }) if (!apiKeyData?.id || !apiKeyData.key) { throw new Error('Failed to create isolated audit API key') } apiKeyId = apiKeyData.id apiKeyAuthHeaders = { 'Content-Type': 'application/json', 'Authorization': apiKeyData.key, } }) afterAll(async () => { await executeSQL('DELETE FROM public.audit_logs WHERE org_id = $1::uuid', [ORG_ID]) if (apiKeyId !== null) 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', () => { it('get audit logs for organization', async () => { const response = await fetchTestRequest(`${BASE_URL}/organization/audit?orgId=${ORG_ID}`, { headers: authHeaders, }) expect(response.status).toBe(200) const responseData = await response.json() const safe = parseAuditLogsResponse(responseData) expect(safe.success).toBe(true) if (safe.success) { expect(safe.data.page).toBe(0) expect(safe.data.limit).toBe(50) expect(Array.isArray(safe.data.data)).toBe(true) } }) it('get audit logs with pagination', async () => { const response = await fetchTestRequest(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&page=0&limit=10`, { headers: authHeaders, }) expect(response.status).toBe(200) const responseData = await response.json() const safe = parseAuditLogsResponse(responseData) expect(safe.success).toBe(true) if (safe.success) { expect(safe.data.page).toBe(0) expect(safe.data.limit).toBe(10) } }) it('get audit logs filtered by table name', async () => { const response = await fetchTestRequest(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&tableName=orgs`, { headers: authHeaders, }) expect(response.status).toBe(200) const responseData = await response.json() const safe = parseAuditLogsResponse(responseData) expect(safe.success).toBe(true) if (safe.success) { // All returned logs should be for the 'orgs' table for (const log of safe.data.data) { expect(log.table_name).toBe('orgs') } } }) it('get audit logs filtered by operation', async () => { const response = await fetchTestRequest(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&operation=INSERT`, { headers: authHeaders, }) expect(response.status).toBe(200) const responseData = await response.json() const safe = parseAuditLogsResponse(responseData) expect(safe.success).toBe(true) if (safe.success) { // All returned logs should be INSERT operations for (const log of safe.data.data) { expect(log.operation).toBe('INSERT') } } }) it('get audit logs with combined filters', async () => { const response = await fetchTestRequest(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&tableName=orgs&operation=INSERT`, { headers: authHeaders, }) expect(response.status).toBe(200) const responseData = await response.json() const safe = parseAuditLogsResponse(responseData) expect(safe.success).toBe(true) if (safe.success) { for (const log of safe.data.data) { expect(log.table_name).toBe('orgs') expect(log.operation).toBe('INSERT') } } }) it('limit is capped at 100', async () => { const response = await fetchTestRequest(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&limit=200`, { headers: authHeaders, }) expect(response.status).toBe(200) const responseData = await response.json() const safe = parseAuditLogsResponse(responseData) expect(safe.success).toBe(true) if (safe.success) { // Server should cap limit at 100 expect(safe.data.limit).toBe(100) } }) it('get audit logs with missing orgId returns error', async () => { const response = await fetchTestRequest(`${BASE_URL}/organization/audit`, { headers: authHeaders, }) expect(response.status).toBe(400) const responseData = await response.json() as { error: string } expect(responseData.error).toBe('invalid_body') }) it('get audit logs with invalid orgId returns error', async () => { const invalidOrgId = randomUUID() const response = await fetchTestRequest(`${BASE_URL}/organization/audit?orgId=${invalidOrgId}`, { headers: authHeaders, }) expect(response.status).toBe(400) const responseData = await response.json() as { error: string } expect(responseData.error).toBe('invalid_org_id') }) it('audit logs keep org and user snapshots without foreign keys', async () => { const orphanOrgId = randomUUID() const orphanUserId = randomUUID() const rows = await executeSQL( ` INSERT INTO public.audit_logs ( table_name, record_id, operation, user_id, org_id, actor_type, actor_user_id, actor_user_email ) VALUES ('orgs', $1::text, 'DELETE', $2::uuid, $1::uuid, 'user', $2::uuid, $3::text) RETURNING id, org_id, user_id, actor_type, actor_user_email `, [orphanOrgId, orphanUserId, 'deleted-user@example.com'], ) expect(rows).toHaveLength(1) expect(rows[0].org_id).toBe(orphanOrgId) expect(rows[0].user_id).toBe(orphanUserId) expect(rows[0].actor_type).toBe('user') expect(rows[0].actor_user_email).toBe('deleted-user@example.com') await executeSQL('DELETE FROM public.audit_logs WHERE id = $1', [rows[0].id]) }) }) describe('audit log triggers', () => { it('organization UPDATE creates audit log with changed_fields', async () => { const newName = `Updated Audit Organization ${randomUUID()}` // Update the organization const { error: updateError } = await getSupabaseClient() .from('orgs') .update({ name: newName }) .eq('id', ORG_ID) expect(updateError).toBeNull() // Wait a bit for the trigger to execute await new Promise(resolve => setTimeout(resolve, 100)) // Fetch audit logs for this org const response = await fetchTestRequest(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&tableName=orgs&operation=UPDATE`, { headers: authHeaders, }) expect(response.status).toBe(200) const responseData = await response.json() const safe = parseAuditLogsResponse(responseData) expect(safe.success).toBe(true) if (safe.success) expect(safe.data.data.length).toBeGreaterThan(0) if (safe.success && safe.data.data.length > 0) { const latestUpdate = safe.data.data[0] expect(latestUpdate.operation).toBe('UPDATE') expect(latestUpdate.table_name).toBe('orgs') expect(latestUpdate.record_id).toBe(ORG_ID) expect(latestUpdate.org_id).toBe(ORG_ID) expect(latestUpdate.actor_type).toBe('system') expect(latestUpdate.actor_user_id).toBeNull() expect(latestUpdate.actor_user_email).toBeNull() // Changed fields should include 'name' and 'updated_at' expect(Array.isArray(latestUpdate.changed_fields)).toBe(true) expect(latestUpdate.changed_fields).toContain('name') // old_record should have the old name expect(latestUpdate.old_record).toBeTruthy() // new_record should have the new name expect(latestUpdate.new_record).toBeTruthy() if (latestUpdate.new_record && typeof latestUpdate.new_record === 'object') { expect((latestUpdate.new_record as Record).name).toBe(newName) } } }) it('organization and app bookkeeping update rows do not create audit logs', async () => { const bookkeepingOrgId = randomUUID() const bookkeepingAppId = `com.audit.bookkeeping.${bookkeepingOrgId.replace(/-/g, '')}` const bookkeepingCustomerId = `cus_audit_bookkeeping_${bookkeepingOrgId}` 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)`, [bookkeepingCustomerId, `sub_bookkeeping_${bookkeepingOrgId}`, trialAt], ) await executeSQL( `INSERT INTO public.orgs ( id, name, management_email, created_by, customer_id ) VALUES ($1::uuid, $2, $3, $4::uuid, $5)`, [bookkeepingOrgId, `Audit Bookkeeping Org ${bookkeepingOrgId}`, TEST_EMAIL, USER_ID, bookkeepingCustomerId], ) await executeSQL( `INSERT INTO public.apps (app_id, name, icon_url, owner_org) VALUES ($1, $2, 'https://example.com/icon.png', $3::uuid)`, [bookkeepingAppId, `Audit Bookkeeping App ${bookkeepingOrgId}`, bookkeepingOrgId], ) const [orgRow] = await executeSQL( 'SELECT id::text AS id FROM public.orgs WHERE id = $1::uuid', [bookkeepingOrgId], ) const [appRow] = await executeSQL( 'SELECT app_id FROM public.apps WHERE app_id = $1', [bookkeepingAppId], ) expect(orgRow?.id).toBe(bookkeepingOrgId) expect(appRow?.app_id).toBe(bookkeepingAppId) async function countOrgUpdates() { const rows = await executeSQL( `SELECT COUNT(*)::integer AS count FROM public.audit_logs WHERE org_id = $1::uuid AND table_name = 'orgs' AND operation = 'UPDATE'`, [bookkeepingOrgId], ) return rows[0]?.count ?? 0 } async function countAppUpdates() { const rows = await executeSQL( `SELECT COUNT(*)::integer AS count FROM public.audit_logs WHERE org_id = $1::uuid AND table_name = 'apps' AND record_id = $2 AND operation = 'UPDATE'`, [bookkeepingOrgId, bookkeepingAppId], ) return rows[0]?.count ?? 0 } const beforeOrg = await countOrgUpdates() const beforeApp = await countAppUpdates() const { error: orgStatsError } = await getSupabaseClient() .from('orgs') .update({ stats_updated_at: new Date().toISOString() }) .eq('id', bookkeepingOrgId) expect(orgStatsError).toBeNull() const { error: orgRefreshError } = await getSupabaseClient() .from('orgs') .update({ stats_refresh_requested_at: new Date().toISOString(), updated_at: new Date().toISOString(), }) .eq('id', bookkeepingOrgId) expect(orgRefreshError).toBeNull() const { error: orgUpdatedAtError } = await getSupabaseClient() .from('orgs') .update({ updated_at: new Date().toISOString() }) .eq('id', bookkeepingOrgId) expect(orgUpdatedAtError).toBeNull() const { error: appStatsError } = await getSupabaseClient() .from('app_stats_refresh_state') .update({ stats_updated_at: new Date().toISOString() }) .eq('app_id', bookkeepingAppId) expect(appStatsError).toBeNull() const { error: appRefreshError } = await getSupabaseClient() .from('app_stats_refresh_state') .update({ stats_refresh_requested_at: new Date().toISOString(), }) .eq('app_id', bookkeepingAppId) expect(appRefreshError).toBeNull() await executeSQL( `UPDATE public.apps SET updated_at = now() WHERE app_id = $1`, [bookkeepingAppId], ) await new Promise(resolve => setTimeout(resolve, 100)) expect(await countOrgUpdates()).toBe(beforeOrg) expect(await countAppUpdates()).toBe(beforeApp) await executeSQL('DELETE FROM public.audit_logs WHERE org_id = $1::uuid', [bookkeepingOrgId]) await executeSQL('DELETE FROM public.apps WHERE app_id = $1', [bookkeepingAppId]) await executeSQL('DELETE FROM public.orgs WHERE id = $1::uuid', [bookkeepingOrgId]) await executeSQL('DELETE FROM public.stripe_info WHERE customer_id = $1', [bookkeepingCustomerId]) }) it('org_users INSERT creates audit log', async () => { // Get another user to add to the org const { data: anotherUser, error: userError } = await getSupabaseClient() .from('users') .select('id') .neq('id', USER_ID) .limit(1) .single() expect(userError).toBeNull() expect(anotherUser).toBeTruthy() if (!anotherUser) { console.warn('Skipping test: Could not find another user') return } // Add user to org const { error: insertError } = await getSupabaseClient() .from('org_users') .insert({ org_id: ORG_ID, user_id: anotherUser.id, rbac_role_name: 'org_member', }) expect(insertError).toBeNull() // Wait a bit for the trigger to execute await new Promise(resolve => setTimeout(resolve, 100)) // Fetch audit logs const response = await fetchTestRequest(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&tableName=org_users&operation=INSERT`, { headers: authHeaders, }) expect(response.status).toBe(200) const responseData = await response.json() const safe = parseAuditLogsResponse(responseData) expect(safe.success).toBe(true) if (safe.success && safe.data.data.length > 0) { const latestInsert = safe.data.data[0] expect(latestInsert.operation).toBe('INSERT') expect(latestInsert.table_name).toBe('org_users') expect(latestInsert.org_id).toBe(ORG_ID) expect(latestInsert.old_record).toBeNull() // INSERT has no old record expect(latestInsert.new_record).toBeTruthy() } // Clean up: delete the org_user await getSupabaseClient() .from('org_users') .delete() .eq('org_id', ORG_ID) .eq('user_id', anotherUser.id) }) it('org_users DELETE creates audit log', async () => { // Get another user to add and then remove const { data: anotherUser, error: userError } = await getSupabaseClient() .from('users') .select('id') .neq('id', USER_ID) .limit(1) .single() expect(userError).toBeNull() expect(anotherUser).toBeTruthy() if (!anotherUser) { console.warn('Skipping test: Could not find another user') return } // Add user to org const { error: insertError } = await getSupabaseClient() .from('org_users') .insert({ org_id: ORG_ID, user_id: anotherUser.id, rbac_role_name: 'org_member', }) expect(insertError).toBeNull() // Wait for insert trigger await new Promise(resolve => setTimeout(resolve, 100)) // Delete the org_user const { error: deleteError } = await getSupabaseClient() .from('org_users') .delete() .eq('org_id', ORG_ID) .eq('user_id', anotherUser.id) expect(deleteError).toBeNull() // Wait for delete trigger await new Promise(resolve => setTimeout(resolve, 100)) // Fetch audit logs for DELETE const response = await fetchTestRequest(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&tableName=org_users&operation=DELETE`, { headers: authHeaders, }) expect(response.status).toBe(200) const responseData = await response.json() const safe = parseAuditLogsResponse(responseData) expect(safe.success).toBe(true) if (safe.success && safe.data.data.length > 0) { const latestDelete = safe.data.data[0] expect(latestDelete.operation).toBe('DELETE') expect(latestDelete.table_name).toBe('org_users') expect(latestDelete.org_id).toBe(ORG_ID) expect(latestDelete.new_record).toBeNull() // DELETE has no new record expect(latestDelete.old_record).toBeTruthy() } }) }) // These tests verify that audit logs are created when using API key authentication // This was a bug where CLI/API users were not logged because request identity resolution missed API keys. describe('audit logs for app_versions via API key', () => { const testVersionName = `99.0.0-audit-test-${randomUUID()}` let createdVersionId: number | null = null afterAll(async () => { // Clean up: delete the test version if it was created if (createdVersionId) { await getSupabaseClient().from('app_versions').delete().eq('id', createdVersionId) // Also clean up any audit logs for this version await getSupabaseClient().from('audit_logs').delete().eq('record_id', createdVersionId.toString()) } }) it('app_version INSERT via API creates audit log with user_id from API key', async () => { // Create a bundle via the API (uses API key authentication) const response = await fetchTestRequest(`${BASE_URL}/bundle`, { method: 'POST', headers: apiKeyAuthHeaders, body: JSON.stringify({ app_id: APIKEY_AUDIT_APP_ID, version: testVersionName, external_url: 'https://example.com/test-audit-bundle.zip', checksum: 'abc123def456', }), }) expect(response.status).toBe(200) const responseData = await response.json() as { status: string, bundle: { id: number } } expect(responseData.status).toBe('success') expect(responseData.bundle).toBeTruthy() createdVersionId = responseData.bundle.id // Wait for the trigger to execute await new Promise(resolve => setTimeout(resolve, 200)) // Fetch audit logs for the dedicated test org const auditResponse = await fetchTestRequest(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&tableName=app_versions&operation=INSERT`, { headers: authHeaders, }) expect(auditResponse.status).toBe(200) const auditData = await auditResponse.json() const safe = parseAuditLogsResponse(auditData) expect(safe.success).toBe(true) if (safe.success) { // Find the audit log for our created version const versionAuditLog = safe.data.data.find( log => log.record_id === createdVersionId?.toString(), ) expect(versionAuditLog).toBeTruthy() if (versionAuditLog) { expect(versionAuditLog.operation).toBe('INSERT') expect(versionAuditLog.table_name).toBe('app_versions') expect(versionAuditLog.org_id).toBe(ORG_ID) // This is the key assertion: user_id should be set from the API key expect(versionAuditLog.user_id).toBe(USER_ID) expect(versionAuditLog.actor_type).toBe('apikey') expect(versionAuditLog.actor_user_id).toBe(USER_ID) expect(versionAuditLog.actor_user_email).toBe(actorUserEmail) expect(versionAuditLog.actor_apikey_id).toBe(apiKeyId) expect(versionAuditLog.actor_apikey_name).toContain('audit-api-key-') expect(versionAuditLog.old_record).toBeNull() expect(versionAuditLog.new_record).toBeTruthy() if (versionAuditLog.new_record && typeof versionAuditLog.new_record === 'object') { expect((versionAuditLog.new_record as Record).name).toBe(testVersionName) // Fat columns must stay out of audit TOAST to protect primary DB memory. expect((versionAuditLog.new_record as Record).manifest).toBeUndefined() expect((versionAuditLog.new_record as Record).native_packages).toBeUndefined() } } } }) it('app_version UPDATE via API creates audit log with user_id from API key', async () => { // Skip if we don't have a version to update if (!createdVersionId) { console.warn('Skipping UPDATE test: no version was created') return } // Update the bundle via the API - note: endpoint requires version_id (number), not version name const response = await fetchTestRequest(`${BASE_URL}/bundle/metadata`, { method: 'POST', headers: apiKeyAuthHeaders, body: JSON.stringify({ app_id: APIKEY_AUDIT_APP_ID, version_id: createdVersionId, comment: 'Updated via API key test', }), }) expect(response.status).toBe(200) const updateAuditLog = await waitForAuditLog( `${BASE_URL}/organization/audit?orgId=${ORG_ID}&tableName=app_versions&operation=UPDATE`, log => log.record_id === createdVersionId?.toString() && log.changed_fields?.includes('comment') === true, ) expect(updateAuditLog.operation).toBe('UPDATE') expect(updateAuditLog.table_name).toBe('app_versions') expect(updateAuditLog.org_id).toBe(ORG_ID) // user_id should be set from the API key expect(updateAuditLog.user_id).toBe(USER_ID) expect(updateAuditLog.actor_type).toBe('apikey') expect(updateAuditLog.actor_user_id).toBe(USER_ID) expect(updateAuditLog.actor_user_email).toBe(actorUserEmail) expect(updateAuditLog.actor_apikey_id).toBe(apiKeyId) expect(updateAuditLog.old_record).toBeTruthy() expect(updateAuditLog.new_record).toBeTruthy() // changed_fields should include 'comment' expect(Array.isArray(updateAuditLog.changed_fields)).toBe(true) expect(updateAuditLog.changed_fields).toContain('comment') }) it('app_version soft-DELETE via API creates UPDATE audit log with user_id from API key', async () => { // Skip if we don't have a version to delete if (!createdVersionId) { console.warn('Skipping DELETE test: no version was created') return } const versionIdToDelete = createdVersionId // Delete the bundle via the API - note: this is a soft-delete (sets deleted=true) const response = await fetchTestRequest(`${BASE_URL}/bundle`, { method: 'DELETE', headers: apiKeyAuthHeaders, body: JSON.stringify({ app_id: APIKEY_AUDIT_APP_ID, version: testVersionName, }), }) expect(response.status).toBe(200) // Wait for the trigger to execute await new Promise(resolve => setTimeout(resolve, 200)) // Fetch audit logs for UPDATE operations (soft-delete creates UPDATE, not DELETE) const auditResponse = await fetchTestRequest(`${BASE_URL}/organization/audit?orgId=${ORG_ID}&tableName=app_versions&operation=UPDATE`, { headers: authHeaders, }) expect(auditResponse.status).toBe(200) const auditData = await auditResponse.json() const safe = parseAuditLogsResponse(auditData) expect(safe.success).toBe(true) if (safe.success) { // Find the audit log for our soft-deleted version:app_versions!channels_version_fkey(look for 'deleted' in changed_fields) const deleteAuditLog = safe.data.data.find( log => log.record_id === versionIdToDelete.toString() && log.changed_fields?.includes('deleted'), ) expect(deleteAuditLog).toBeTruthy() if (deleteAuditLog) { expect(deleteAuditLog.operation).toBe('UPDATE') expect(deleteAuditLog.table_name).toBe('app_versions') expect(deleteAuditLog.org_id).toBe(ORG_ID) // user_id should be set from the API key expect(deleteAuditLog.user_id).toBe(USER_ID) expect(deleteAuditLog.actor_type).toBe('apikey') expect(deleteAuditLog.actor_user_id).toBe(USER_ID) expect(deleteAuditLog.actor_user_email).toBe(actorUserEmail) expect(deleteAuditLog.actor_apikey_id).toBe(apiKeyId) // Both old and new record should exist for UPDATE expect(deleteAuditLog.old_record).toBeTruthy() expect(deleteAuditLog.new_record).toBeTruthy() // changed_fields should include 'deleted' expect(deleteAuditLog.changed_fields).toContain('deleted') // Verify the deleted flag was set to true if (deleteAuditLog.new_record && typeof deleteAuditLog.new_record === 'object') { expect((deleteAuditLog.new_record as Record).deleted).toBe(true) } } } }) }) describe('audit logs for channel promotions via API key bundle flow', () => { const sourceVersionName = `99.0.0-audit-channel-source-${randomUUID()}` const targetVersionName = `99.0.0-audit-channel-target-${randomUUID()}` const channelName = `audit-channel-${randomUUID()}` let sourceVersionId: number | null = null let targetVersionId: number | null = null let channelId: number | null = null beforeAll(async () => { const supabase = getSupabaseClient() const { data: insertedVersions, error: versionError } = await supabase .from('app_versions') .insert([ { app_id: APIKEY_AUDIT_APP_ID, name: sourceVersionName, owner_org: ORG_ID, }, { app_id: APIKEY_AUDIT_APP_ID, name: targetVersionName, owner_org: ORG_ID, }, ]) .select('id,name') if (versionError || !insertedVersions || insertedVersions.length !== 2) { throw new Error(`Failed to create audit channel versions: ${versionError?.message ?? 'missing versions'}`) } sourceVersionId = insertedVersions.find(version => version.name === sourceVersionName)?.id ?? null targetVersionId = insertedVersions.find(version => version.name === targetVersionName)?.id ?? null if (sourceVersionId === null || targetVersionId === null) { throw new Error('Failed to resolve audit channel version IDs') } const { data: insertedChannel, error: channelError } = await supabase .from('channels') .insert({ name: channelName, app_id: APIKEY_AUDIT_APP_ID, version: sourceVersionId, created_by: USER_ID, owner_org: ORG_ID, }) .select('id') .single() if (channelError || !insertedChannel) { throw new Error(`Failed to create audit channel: ${channelError?.message ?? 'missing channel'}`) } channelId = insertedChannel.id }) afterAll(async () => { const supabase = getSupabaseClient() if (channelId !== null) { await supabase.from('channels').delete().eq('id', channelId) await supabase.from('audit_logs').delete().eq('record_id', channelId.toString()).eq('table_name', 'channels') } if (sourceVersionId !== null) { await supabase.from('app_versions').delete().eq('id', sourceVersionId) } if (targetVersionId !== null) { await supabase.from('app_versions').delete().eq('id', targetVersionId) } }) it('channel UPDATE via API key bundle promotion keeps audit user_id attribution', async () => { if (!channelId || !targetVersionId) { throw new Error('Audit channel promotion test setup did not complete') } const promotionChannelId = channelId const response = await fetchTestRequest(`${BASE_URL}/bundle`, { method: 'PUT', headers: apiKeyAuthHeaders, body: JSON.stringify({ app_id: APIKEY_AUDIT_APP_ID, version_id: targetVersionId, channel_id: promotionChannelId, }), }) expect(response.status).toBe(200) const promotionAuditLog = await waitForAuditLog( `${BASE_URL}/organization/audit?orgId=${ORG_ID}&tableName=channels&operation=UPDATE`, log => log.record_id === promotionChannelId.toString() && log.changed_fields?.includes('version') === true, ) expect(promotionAuditLog.operation).toBe('UPDATE') expect(promotionAuditLog.table_name).toBe('channels') expect(promotionAuditLog.org_id).toBe(ORG_ID) expect(promotionAuditLog.user_id).toBe(USER_ID) expect(promotionAuditLog.actor_type).toBe('apikey') expect(promotionAuditLog.actor_user_id).toBe(USER_ID) expect(promotionAuditLog.actor_user_email).toBe(actorUserEmail) expect(promotionAuditLog.actor_apikey_id).toBe(apiKeyId) expect(promotionAuditLog.changed_fields).toContain('version') if (promotionAuditLog.new_record && typeof promotionAuditLog.new_record === 'object') { expect((promotionAuditLog.new_record as Record).version).toBe(targetVersionId) } }) })