Ctrl K
ComunidadGuías…

tests/apikey-atomic-bindings.test.ts

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

Archivo original ↗Preguntar a KiBot
import { randomUUID } from 'node:crypto'
import { env } from 'node:process'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { executeSQL, getAuthHeaders, getEndpointUrl, getSupabaseClient, USER_ID } from './test-utils.ts'

const USE_CLOUDFLARE = env.USE_CLOUDFLARE_WORKERS === 'true'

let authHeaders: Record<string, string>

// Dedicated seed data for this test file (isolated from other parallel test files)
const TEST_ID = randomUUID()
const TEST_ORG_ID = randomUUID()
const TEST_ORG_NAME = `Atomic Bindings Test Org ${TEST_ID.slice(0, 8)}`
const TEST_ORG_EMAIL = `atomic-bindings-${TEST_ID.slice(0, 8)}@capgo.app`
const STRIPE_CUSTOMER_ID = `cus_atomic_bindings_${TEST_ID.slice(0, 8)}`

const createdKeyIds: number[] = []

async function setupTestOrg() {
  const supabase = getSupabaseClient()

  // Create a dedicated org with RBAC enabled
  const { error: orgError } = await supabase.from('orgs').insert({
    id: TEST_ORG_ID,
    created_by: USER_ID,
    name: TEST_ORG_NAME,
    management_email: TEST_ORG_EMAIL,
  })
  if (orgError)
    throw orgError

  // The org needs a stripe_info entry for the apikey creation path
  const { error: stripeError } = await supabase.from('stripe_info').insert({
    customer_id: STRIPE_CUSTOMER_ID,
    product_id: 'prod_LQIregjtNduh4q',
    subscription_id: null,
    is_good_plan: true,
  })
  if (stripeError)
    throw stripeError

  const { error: orgToStripeError } = await supabase.from('orgs').update({
    customer_id: STRIPE_CUSTOMER_ID,
  }).eq('id', TEST_ORG_ID)
  if (orgToStripeError)
    throw orgToStripeError

  // Add the test user as super_admin in the org so the RBAC checks can create keys there.
  const { error: ouError } = await supabase.from('org_users').insert({
    org_id: TEST_ORG_ID,
    user_id: USER_ID,
    rbac_role_name: 'org_super_admin',
  })
  if (ouError)
    throw ouError
}

async function cleanupTestData() {
  const supabase = getSupabaseClient()
  // Delete created API keys
  for (const keyId of createdKeyIds) {
    await supabase.from('apikeys').delete().eq('id', keyId)
  }
  // Delete role bindings for the test org
  await supabase.from('role_bindings').delete().eq('org_id', TEST_ORG_ID)
  // Delete org membership
  await supabase.from('org_users').delete().eq('org_id', TEST_ORG_ID)
  // Delete org
  await supabase.from('orgs').delete().eq('id', TEST_ORG_ID)
  // Delete stripe info
  await supabase.from('stripe_info').delete().eq('customer_id', STRIPE_CUSTOMER_ID)
}

beforeAll(async () => {
  authHeaders = await getAuthHeaders()
  await setupTestOrg()
})

afterAll(async () => {
  await cleanupTestData()
})

// Atomic API key + bindings tests use /private/ route which is Supabase-only
describe.skipIf(USE_CLOUDFLARE)('[POST] /apikey with atomic bindings', () => {
  it('creates an API key with bindings', async () => {
    const response = await fetch(getEndpointUrl('/apikey'), {
      method: 'POST',
      headers: authHeaders,
      body: JSON.stringify({
        name: `atomic-bindings-key-${TEST_ID.slice(0, 8)}`,
        bindings: [
          {
            role_name: 'org_member',
            scope_type: 'org',
            org_id: TEST_ORG_ID,
            reason: 'atomic creation test',
          },
        ],
      }),
    })

    const data = await response.json() as { id: number, key: string | null, rbac_id: string }
    expect(response.status).toBe(200)
    expect(data).toHaveProperty('id')
    expect(data.rbac_id).toBeTruthy()
    createdKeyIds.push(data.id)

    // Verify the binding was created in the database
    const supabase = getSupabaseClient()
    const { data: bindings, error: bindingsError } = await supabase
      .from('role_bindings')
      .select('*')
      .eq('principal_type', 'apikey')
      .eq('principal_id', data.rbac_id)
      .eq('org_id', TEST_ORG_ID)

    expect(bindingsError).toBeNull()
    expect(bindings).toHaveLength(1)
    expect(bindings![0].scope_type).toBe('org')
    expect(bindings![0].reason).toBe('atomic creation test')
  })

  it.concurrent('creates an org.create API key permission for org admin keys', async () => {
    const response = await fetch(getEndpointUrl('/apikey'), {
      method: 'POST',
      headers: authHeaders,
      body: JSON.stringify({
        name: `org-create-permission-key-${TEST_ID.slice(0, 8)}`,
        bindings: [
          {
            role_name: 'org_admin',
            scope_type: 'org',
            org_id: TEST_ORG_ID,
          },
        ],
        global_permissions: ['org.create'],
      }),
    })

    const data = await response.json() as { id: number, rbac_id: string, global_permissions: string[] }
    expect(response.status).toBe(200)
    expect(data.global_permissions).toContain('org.create')
    createdKeyIds.push(data.id)

    const permissionRows = await executeSQL(
      `SELECT permission_key
       FROM public.apikey_global_permissions
       WHERE apikey_rbac_id = $1::uuid`,
      [data.rbac_id],
    )
    expect(permissionRows).toEqual([{ permission_key: 'org.create' }])
  })

  it.concurrent('rejects org.create API key permission without an org admin binding', async () => {
    const response = await fetch(getEndpointUrl('/apikey'), {
      method: 'POST',
      headers: authHeaders,
      body: JSON.stringify({
        name: `invalid-org-create-permission-key-${TEST_ID.slice(0, 8)}`,
        bindings: [
          {
            role_name: 'org_member',
            scope_type: 'org',
            org_id: TEST_ORG_ID,
          },
        ],
        global_permissions: ['org.create'],
      }),
    })

    const data = await response.json() as { error: string }
    expect(response.status).toBe(400)
    expect(data.error).toBe('invalid_global_permissions')
  })

  it.concurrent('updates org.create API key permission from the API key editor payload', async () => {
    const createResponse = await fetch(getEndpointUrl('/apikey'), {
      method: 'POST',
      headers: authHeaders,
      body: JSON.stringify({
        name: `update-org-create-permission-key-${TEST_ID.slice(0, 8)}`,
        bindings: [
          {
            role_name: 'org_admin',
            scope_type: 'org',
            org_id: TEST_ORG_ID,
          },
        ],
      }),
    })

    const createData = await createResponse.json() as { id: number, rbac_id: string }
    expect(createResponse.status).toBe(200)
    createdKeyIds.push(createData.id)

    const grantResponse = await fetch(getEndpointUrl('/apikey'), {
      method: 'PUT',
      headers: authHeaders,
      body: JSON.stringify({
        id: createData.id,
        bindings: [
          {
            role_name: 'org_admin',
            scope_type: 'org',
            org_id: TEST_ORG_ID,
          },
        ],
        global_permissions: ['org.create'],
      }),
    })

    const grantData = await grantResponse.json() as { global_permissions: string[] }
    expect(grantResponse.status).toBe(200)
    expect(grantData.global_permissions).toContain('org.create')

    const revokeResponse = await fetch(getEndpointUrl('/apikey'), {
      method: 'PUT',
      headers: authHeaders,
      body: JSON.stringify({
        id: createData.id,
        bindings: [
          {
            role_name: 'org_admin',
            scope_type: 'org',
            org_id: TEST_ORG_ID,
          },
        ],
        global_permissions: [],
      }),
    })

    const revokeData = await revokeResponse.json() as { global_permissions: string[] }
    expect(revokeResponse.status).toBe(200)
    expect(revokeData.global_permissions).toEqual([])

    const permissionRows = await executeSQL(
      `SELECT permission_key
       FROM public.apikey_global_permissions
       WHERE apikey_rbac_id = $1::uuid`,
      [createData.rbac_id],
    )
    expect(permissionRows).toEqual([])

    const regrantResponse = await fetch(getEndpointUrl('/apikey'), {
      method: 'PUT',
      headers: authHeaders,
      body: JSON.stringify({
        id: createData.id,
        bindings: [
          {
            role_name: 'org_admin',
            scope_type: 'org',
            org_id: TEST_ORG_ID,
          },
        ],
        global_permissions: ['org.create'],
      }),
    })
    expect(regrantResponse.status).toBe(200)

    const downgradeResponse = await fetch(getEndpointUrl('/apikey'), {
      method: 'PUT',
      headers: authHeaders,
      body: JSON.stringify({
        id: createData.id,
        bindings: [
          {
            role_name: 'org_member',
            scope_type: 'org',
            org_id: TEST_ORG_ID,
          },
        ],
      }),
    })
    expect(downgradeResponse.status).toBe(200)

    const downgradedPermissionRows = await executeSQL(
      `SELECT permission_key
       FROM public.apikey_global_permissions
       WHERE apikey_rbac_id = $1::uuid`,
      [createData.rbac_id],
    )
    expect(downgradedPermissionRows).toEqual([])
  })

  it('requires explicit RBAC bindings', async () => {
    const response = await fetch(getEndpointUrl('/apikey'), {
      method: 'POST',
      headers: authHeaders,
      body: JSON.stringify({
        name: `missing-bindings-key-${TEST_ID.slice(0, 8)}`,
      }),
    })

    const data = await response.json() as { error: string }
    expect(response.status).toBe(400)
    expect(data.error).toBe('bindings_required')
  })

  it('rolls back the API key when a binding fails', async () => {
    const uniqueKeyName = `rollback-test-key-${TEST_ID.slice(0, 8)}-${Date.now()}`

    const response = await fetch(getEndpointUrl('/apikey'), {
      method: 'POST',
      headers: authHeaders,
      body: JSON.stringify({
        name: uniqueKeyName,
        bindings: [
          {
            role_name: 'nonexistent_role_that_should_fail',
            scope_type: 'org',
            org_id: TEST_ORG_ID,
          },
        ],
      }),
    })

    expect(response.status).not.toBe(200)

    // Verify the specific key was rolled back (not present in DB)
    const supabase = getSupabaseClient()
    const { data: matchingKeys } = await supabase
      .from('apikeys')
      .select('id')
      .eq('user_id', USER_ID)
      .eq('name', uniqueKeyName)

    expect(matchingKeys).toHaveLength(0)
  })

  it('creates multiple bindings in a single call', async () => {
    const response = await fetch(getEndpointUrl('/apikey'), {
      method: 'POST',
      headers: authHeaders,
      body: JSON.stringify({
        name: `multi-binding-key-${TEST_ID.slice(0, 8)}`,
        bindings: [
          {
            role_name: 'org_member',
            scope_type: 'org',
            org_id: TEST_ORG_ID,
            reason: 'multi-binding test 1',
          },
          {
            role_name: 'org_member',
            scope_type: 'org',
            org_id: TEST_ORG_ID,
            reason: 'multi-binding test 2',
          },
        ],
      }),
    })

    // This may fail with a duplicate constraint if org_member can only be assigned once per scope.
    // In that case, the key should be rolled back.
    if (response.status === 200) {
      const data = await response.json() as { id: number, rbac_id: string }
      createdKeyIds.push(data.id)

      const supabase = getSupabaseClient()
      const { data: bindings } = await supabase
        .from('role_bindings')
        .select('*')
        .eq('principal_type', 'apikey')
        .eq('principal_id', data.rbac_id)
        .eq('org_id', TEST_ORG_ID)

      expect(bindings!.length).toBeGreaterThanOrEqual(2)
    }
    else {
      // Duplicate binding caused rollback - that's also valid behavior
      const data = await response.json() as { error: string }
      expect(data).toHaveProperty('error')
    }
  })

  it('validates binding shape before creation', async () => {
    const response = await fetch(getEndpointUrl('/apikey'), {
      method: 'POST',
      headers: authHeaders,
      body: JSON.stringify({
        name: `invalid-binding-shape-${TEST_ID.slice(0, 8)}`,
        bindings: [
          {
            // missing role_name
            scope_type: 'org',
            org_id: TEST_ORG_ID,
          },
        ],
      }),
    })

    const data = await response.json() as { error: string }
    expect(response.status).toBe(400)
    expect(data.error).toBe('invalid_bindings')
  })

  it('v2 key can authenticate', async () => {
    // First create a V2 key with role bindings.
    const createResponse = await fetch(getEndpointUrl('/apikey'), {
      method: 'POST',
      headers: authHeaders,
      body: JSON.stringify({
        name: `rbac-auth-test-key-${TEST_ID.slice(0, 8)}`,
        bindings: [
          {
            role_name: 'org_member',
            scope_type: 'org',
            org_id: TEST_ORG_ID,
          },
        ],
      }),
    })

    expect(createResponse.status).toBe(200)
    const createData = await createResponse.json() as { id: number, key: string }
    expect(createData.key).toBeTruthy()
    createdKeyIds.push(createData.id)

    // Use the RBAC-only key to authenticate. The request should reach the
    // API-key guard and be rejected as scoped, not as an invalid credential.
    const apiKeyHeaders = {
      'Content-Type': 'application/json',
      'Authorization': createData.key,
    }
    const getResponse = await fetch(getEndpointUrl('/apikey'), {
      method: 'GET',
      headers: apiKeyHeaders,
    })

    const getBody = await getResponse.json() as { error?: string }

    expect(getResponse.status, `Auth failed with body: ${JSON.stringify(getBody)}`).toBe(401)
    expect(getBody.error).toBe('cannot_list_apikeys')
  })
})

// Role bindings endpoint now supports API key auth
describe.skipIf(USE_CLOUDFLARE)('[GET] /private/role_bindings with API key auth', () => {
  it('accepts JWT auth on role_bindings endpoint', async () => {
    const response = await fetch(getEndpointUrl(`/private/role_bindings/${TEST_ORG_ID}`), {
      method: 'GET',
      headers: authHeaders,
    })

    expect(response.status).toBe(200)
    const data = await response.json() as any[]
    expect(Array.isArray(data)).toBe(true)
  })

  it('rejects API key auth on role_bindings endpoint', async () => {
    // Create an API key first
    const createResponse = await fetch(getEndpointUrl('/apikey'), {
      method: 'POST',
      headers: authHeaders,
      body: JSON.stringify({
        name: `role-binding-auth-test-${TEST_ID.slice(0, 8)}`,
        bindings: [
          {
            role_name: 'org_admin',
            scope_type: 'org',
            org_id: TEST_ORG_ID,
          },
        ],
      }),
    })

    expect(createResponse.status).toBe(200)
    const createData = await createResponse.json() as { id: number, key: string }
    createdKeyIds.push(createData.id)

    // Try using this API key on role_bindings endpoint
    const response = await fetch(getEndpointUrl(`/private/role_bindings/${TEST_ORG_ID}`), {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': createData.key,
      },
    })

    expect(response.status).toBe(403)
    const data = await response.json() as { error: string }
    expect(data.error).toBe('API keys cannot manage role bindings')
  })
})