import type { SupabaseClient } from '@supabase/supabase-js' import { randomUUID } from 'node:crypto' import { createClient } from '@supabase/supabase-js' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { executeSQL, getSupabaseClient, PRODUCT_ID, SUPABASE_ANON_KEY, SUPABASE_BASE_URL, TEST_EMAIL, USER_EMAIL, USER_EMAIL_NONMEMBER, USER_ID, USER_PASSWORD, USER_PASSWORD_NONMEMBER, } from './test-utils.ts' // Unique identifiers per run so parallel/repeat runs never collide. const testRunId = randomUUID() const ORG_ID = testRunId const APP_ID = `com.compat_events.${testRunId.slice(0, 8)}` const STRIPE_CUSTOMER_ID = `cus_compat_events_${testRunId.slice(0, 8)}` // The compatibility_events table and acknowledge_compatibility_event RPC are added // by a migration that postdates the generated Supabase types, so the typed client // does not know about them yet. Use an untyped view of each client for those calls. type UntypedClient = SupabaseClient interface CompatibilityEventRow { id: number resolved_at: string | null resolved_by: string | null resolution_kind: string | null resolution_note: string | null } async function signInClient(email: string, password: string): Promise { if (!SUPABASE_BASE_URL || !SUPABASE_ANON_KEY) throw new Error('SUPABASE_URL and SUPABASE_ANON_KEY are required for compatibility_events tests') const publicClient = createClient(SUPABASE_BASE_URL, SUPABASE_ANON_KEY, { auth: { persistSession: false }, }) const { data, error } = await publicClient.auth.signInWithPassword({ email, password }) if (error || !data.session?.access_token) throw error ?? new Error(`Unable to sign in as ${email}`) return createClient(SUPABASE_BASE_URL, SUPABASE_ANON_KEY, { auth: { persistSession: false }, global: { headers: { Authorization: `Bearer ${data.session.access_token}` }, }, }) as UntypedClient } function compatTable(client: SupabaseClient) { return (client as UntypedClient).from('compatibility_events') } async function createUserOrgBinding(orgId: string, userId: string, roleName: string) { await executeSQL(` INSERT INTO public.role_bindings ( principal_type, principal_id, role_id, scope_type, org_id, granted_by, reason, is_direct ) SELECT public.rbac_principal_user(), $1::uuid, roles.id, public.rbac_scope_org(), $2::uuid, $1::uuid, 'Compatibility events test binding', true FROM public.roles roles WHERE roles.name = $3 AND roles.scope_type = public.rbac_scope_org() ON CONFLICT DO NOTHING `, [userId, orgId, roleName]) } // The uq_compatibility_events_dedup index covers // (app_id, channel_id, platform, current_version_id, previous_version_id) with // NULLS NOT DISTINCT, so a synthetic distinct channel_id per row keeps every // inserted test event unique even when the other columns repeat. let nextChannelId = 1 async function insertEvent(values: { platform: string channelName?: string currentVersionName?: string previousVersionName?: string source?: string }): Promise { const { data, error } = await compatTable(getSupabaseClient()) .insert({ org_id: ORG_ID, app_id: APP_ID, source: values.source ?? 'default_channel_version_changed', platform: values.platform, channel_id: nextChannelId++, channel_name: values.channelName ?? 'production', current_version_name: values.currentVersionName ?? '2.0.0', previous_version_name: values.previousVersionName ?? '1.0.0', }) .select('id') .single() if (error || !data) throw error ?? new Error('Failed to insert compatibility_event') return (data as { id: number }).id } async function getEventById(id: number): Promise { const { data, error } = await compatTable(getSupabaseClient()) .select('id, resolved_at, resolved_by, resolution_kind, resolution_note') .eq('id', id) .single() if (error || !data) throw error ?? new Error(`Compatibility event ${id} not found`) return data as CompatibilityEventRow } let memberClient: UntypedClient let nonMemberClient: UntypedClient beforeAll(async () => { const supabase = getSupabaseClient() // Clean any leftovers from a previous aborted run (best effort). await supabase.from('stripe_info').delete().eq('customer_id', STRIPE_CUSTOMER_ID) await supabase.from('role_bindings').delete().eq('org_id', ORG_ID) await supabase.from('org_users').delete().eq('org_id', ORG_ID) await supabase.from('orgs').delete().eq('id', ORG_ID) const { error: stripeError } = await supabase.from('stripe_info').insert({ subscription_id: `sub_compat_events_${testRunId.slice(0, 8)}`, customer_id: STRIPE_CUSTOMER_ID, status: 'succeeded' as const, product_id: PRODUCT_ID, is_good_plan: true, }) if (stripeError) throw stripeError const { error: orgError } = await supabase.from('orgs').insert({ id: ORG_ID, customer_id: STRIPE_CUSTOMER_ID, name: `Compatibility Events Test Org ${testRunId.slice(0, 8)}`, created_by: USER_ID, management_email: TEST_EMAIL, }) if (orgError) throw orgError const { error: orgUserError } = await supabase.from('org_users').insert({ org_id: ORG_ID, user_id: USER_ID, rbac_role_name: 'org_super_admin', }) if (orgUserError) throw orgUserError await createUserOrgBinding(ORG_ID, USER_ID, 'org_super_admin') const { error: appError } = await supabase.from('apps').insert({ owner_org: ORG_ID, app_id: APP_ID, name: 'Compatibility Events Test App', icon_url: 'https://example.com/icon.png', }) if (appError) throw appError memberClient = await signInClient(USER_EMAIL, USER_PASSWORD) nonMemberClient = await signInClient(USER_EMAIL_NONMEMBER, USER_PASSWORD_NONMEMBER) }, 120000) afterAll(async () => { const supabase = getSupabaseClient() // app delete cascades compatibility_events, but delete explicitly to be safe. await supabase.from('compatibility_events' as any).delete().eq('app_id', APP_ID) await supabase.from('apps').delete().eq('app_id', APP_ID) await supabase.from('role_bindings').delete().eq('org_id', ORG_ID) await supabase.from('org_users').delete().eq('org_id', ORG_ID) await supabase.from('orgs').delete().eq('id', ORG_ID) await supabase.from('stripe_info').delete().eq('customer_id', STRIPE_CUSTOMER_ID) }, 120000) describe('compatibility_events RLS read access', () => { it('lets an org member read their app\'s compatibility events but hides them from non-members', async () => { const eventId = await insertEvent({ platform: 'ios' }) const { data: memberRows, error: memberError } = await compatTable(memberClient) .select('id, resolved_at') .eq('app_id', APP_ID) expect(memberError).toBeNull() expect(memberRows).not.toBeNull() expect((memberRows as { id: number }[]).some(row => row.id === eventId)).toBe(true) const { data: nonMemberRows, error: nonMemberError } = await compatTable(nonMemberClient) .select('id') .eq('app_id', APP_ID) expect(nonMemberError).toBeNull() expect(nonMemberRows).toEqual([]) }) }) describe('acknowledge_compatibility_event RPC', () => { it('lets an authorized member accept an unresolved event with a note', async () => { const eventId = await insertEvent({ platform: 'android' }) const note = 'Reviewed and confirmed compatible' const { error } = await memberClient.rpc('acknowledge_compatibility_event', { event_id: eventId, note, }) expect(error).toBeNull() const row = await getEventById(eventId) expect(row.resolved_at).not.toBeNull() expect(row.resolved_by).toBe(USER_ID) expect(row.resolution_kind).toBe('accepted') expect(row.resolution_note).toBe(note) }) it('rejects an empty/whitespace note and leaves the event unresolved', async () => { const eventId = await insertEvent({ platform: 'electron' }) const { error } = await memberClient.rpc('acknowledge_compatibility_event', { event_id: eventId, note: ' ', }) expect(error).not.toBeNull() expect(error?.message).toContain('reason_required') const row = await getEventById(eventId) expect(row.resolved_at).toBeNull() expect(row.resolution_kind).toBeNull() }) it('raises and leaves the event unresolved when a non-member tries to accept it', async () => { const eventId = await insertEvent({ platform: 'ios', channelName: 'beta' }) const { error } = await nonMemberClient.rpc('acknowledge_compatibility_event', { event_id: eventId, note: 'Not allowed to do this', }) // The RPC raises so the client stops reporting success on a write that never // happened. The same 'not_authorized' error is used for unknown ids below, // so an unauthorized caller cannot use it as an event-existence oracle. expect(error).not.toBeNull() expect(error?.message).toContain('not_authorized') const row = await getEventById(eventId) expect(row.resolved_at).toBeNull() expect(row.resolved_by).toBeNull() }) it('raises the same error for an unknown event id (no existence oracle)', async () => { const { error } = await memberClient.rpc('acknowledge_compatibility_event', { event_id: 9_999_999_999, note: 'Does not exist', }) expect(error).not.toBeNull() expect(error?.message).toContain('not_authorized') }) }) describe('compatibility_events cascade', () => { it('removes compatibility events when their app is deleted', async () => { const supabase = getSupabaseClient() await insertEvent({ platform: 'android', channelName: 'cascade' }) const { data: before } = await compatTable(supabase).select('id').eq('app_id', APP_ID) expect((before as unknown[]).length).toBeGreaterThan(0) await supabase.from('apps').delete().eq('app_id', APP_ID).throwOnError() const { data: after, error } = await compatTable(supabase).select('id').eq('app_id', APP_ID) expect(error).toBeNull() expect(after).toEqual([]) }) })