Ctrl K
ComunidadGuías…

tests/compatibility-events.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<any, 'public', any>

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<UntypedClient> {
  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<any>) {
  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<number> {
  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<CompatibilityEventRow> {
  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([])
  })
})