Ctrl K
ComunidadGuías…

tests/apikeys.test.ts

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

Archivo original ↗Preguntar a KiBot
import { randomUUID } from 'node:crypto'
import { createClient } from '@supabase/supabase-js'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import {
  APIKEY_MANAGEMENT_APIKEY_MANAGER,
  APIKEY_MANAGEMENT_APIKEY_MANAGER_ID,
  APIKEY_MANAGEMENT_ORG_SUPER_ADMIN,
  appApiKeyBindings,
  BASE_URL,
  executeSQL,
  fetchTestRequest,
  getAuthHeaders,
  getAuthHeadersForCredentials,
  getSupabaseClient,
  ORG_ID_APIKEY_MANAGEMENT,
  orgApiKeyBindings,
  resetAndSeedAppData,
  resetAppData,
  USER_EMAIL_APIKEY_MANAGEMENT,
  USER_ID,
  USER_PASSWORD,
} from './test-utils.ts'

const id = randomUUID()
const APPNAME = `com.app.key.${id}`
let authHeaders: Record<string, string>
let warmupApiKeyId: number | null = null

const TRANSIENT_GATEWAY_MARKERS = [
  'An invalid response was received from the upstream server',
  'Your worker restarted mid-request',
]

function isTransientGateway502503(status: number, body: string): boolean {
  if (status !== 502 && status !== 503)
    return false
  return TRANSIENT_GATEWAY_MARKERS.some(marker => body.includes(marker))
}

function orgKeyBody(name: string, extra: Record<string, unknown> = {}) {
  return {
    name,
    bindings: orgApiKeyBindings(),
    ...extra,
  }
}

async function appKeyBody(name: string, appId = APPNAME, extra: Record<string, unknown> = {}) {
  return {
    name,
    bindings: await appApiKeyBindings(appId),
    ...extra,
  }
}

async function warmEdgeEndpointWithDeadline(
  path: string,
  options: RequestInit,
  deadlineMs: number,
): Promise<void> {
  const url = `${BASE_URL}${path}`
  let lastStatus = 0
  for (let attempt = 1; attempt <= 5; attempt++) {
    if (Date.now() >= deadlineMs)
      throw new Error(`[warmEdgeEndpoint] timed out before attempt ${attempt} url=${url}`)

    const response = await withFetchDeadline(deadlineMs, async (signal) => {
      const response = await fetch(url, { ...options, signal })
      await response.text().catch((error) => {
        if (signal.aborted)
          throw error
      })
      return response
    })
    lastStatus = response.status
    if (response.status !== 502 && response.status !== 503)
      return

    if (attempt === 5)
      break

    const delayMs = Math.min(500 * attempt, Math.max(0, deadlineMs - Date.now()))
    if (delayMs <= 0)
      throw new Error(`[warmEdgeEndpoint] timed out waiting to retry url=${url}`)
    await new Promise(resolve => setTimeout(resolve, delayMs))
  }
  throw new Error(`[warmEdgeEndpoint] isolate still returning ${lastStatus} after 5 attempts url=${url}`)
}

async function withFetchDeadline<T>(
  deadlineMs: number,
  run: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
  const remainingMs = deadlineMs - Date.now()
  if (remainingMs <= 0)
    throw new Error('Request timed out before fetch started')

  const controller = new AbortController()
  const timeout = setTimeout(() => controller.abort(), remainingMs)
  try {
    return await run(controller.signal)
  }
  finally {
    clearTimeout(timeout)
  }
}

async function listApiKeyIdsByName(
  name: string,
  headers: Record<string, string>,
  deadlineMs: number,
): Promise<number[] | null> {
  const listed = await withFetchDeadline(deadlineMs, async (signal) => {
    const listResponse = await fetch(`${BASE_URL}/apikey`, { headers, signal })
    if (!listResponse.ok)
      return null
    return await listResponse.json() as Array<{ id: number, name: string }>
  })
  if (listed === null)
    return null
  return listed.filter(key => key.name === name).map(key => key.id)
}

async function deleteNewApiKeysByName(
  name: string,
  headers: Record<string, string>,
  deadlineMs: number,
  existingIds: ReadonlySet<number>,
): Promise<boolean> {
  try {
    const ids = await listApiKeyIdsByName(name, headers, deadlineMs)
    if (ids === null)
      return false

    const newIds = ids.filter(id => !existingIds.has(id))
    if (newIds.length === 0)
      return true

    const results = await Promise.allSettled(newIds.map(async (id) => {
      await withFetchDeadline(deadlineMs, async (signal) => {
        const deleteResponse = await fetch(`${BASE_URL}/apikey/${id}`, { method: 'DELETE', headers, signal })
        if (!deleteResponse.ok)
          throw new Error(`DELETE /apikey/${id} failed with ${deleteResponse.status}`)
      })
    }))
    return results.every(result => result.status === 'fulfilled')
  }
  catch {
    return false
  }
}

let postApiKeyQueue: Promise<unknown> = Promise.resolve()

async function postApiKey(
  body: unknown,
  headers: Record<string, string> = authHeaders,
  rawBody = false,
) {
  const run = async () => {
    const keyName = !rawBody && typeof body === 'object' && body !== null && 'name' in body
      ? String((body as { name: unknown }).name)
      : undefined
    const requestInit: RequestInit = {
      method: 'POST',
      headers,
      body: rawBody ? String(body) : JSON.stringify(body),
    }
    const url = `${BASE_URL}/apikey`
    const deadline = Date.now() + 15000

    while (true) {
      const remainingMs = deadline - Date.now()
      if (remainingMs <= 0)
        throw new Error('POST /apikey timed out after 15s waiting for gateway response')

      let existingIdsForName: Set<number> | undefined
      if (keyName) {
        try {
          const preflightDeadline = Math.min(deadline, Date.now() + 5000)
          const idsBeforePost = await listApiKeyIdsByName(keyName, headers, preflightDeadline)
          if (idsBeforePost !== null)
            existingIdsForName = new Set(idsBeforePost)
        }
        catch {
          // Without a snapshot, send the POST without create-safe retries.
        }
      }

      const postRemainingMs = deadline - Date.now()
      if (postRemainingMs <= 0)
        throw new Error('POST /apikey timed out after 15s waiting for gateway response')

      const controller = new AbortController()
      const timeout = setTimeout(() => controller.abort(), postRemainingMs)

      let response: Response
      let responseBody = ''
      try {
        response = await fetch(url, { ...requestInit, signal: controller.signal })
        if (response.status === 502 || response.status === 503)
          responseBody = await response.clone().text()
      }
      catch (error) {
        if (error instanceof Error && error.name === 'AbortError')
          throw new Error('POST /apikey timed out after 15s waiting for gateway response')
        throw error
      }
      finally {
        clearTimeout(timeout)
      }

      if (response.status !== 502 && response.status !== 503)
        return response

      if (!isTransientGateway502503(response.status, responseBody))
        return response

      // Create-safe retry: a 502 may have persisted the key without returning 200.
      if (keyName) {
        if (existingIdsForName === undefined)
          return response
        if (!await deleteNewApiKeysByName(keyName, headers, deadline, existingIdsForName))
          return response
      }

      if (Date.now() >= deadline)
        return response

      await new Promise(resolve => setTimeout(resolve, Math.min(300, Math.max(0, deadline - Date.now()))))
    }
  }

  const result = postApiKeyQueue.then(run, run)
  postApiKeyQueue = result.then(() => undefined, () => undefined)
  return result
}

beforeAll(async () => {
  authHeaders = await getAuthHeaders()
  await resetAndSeedAppData(APPNAME)
  // Load the apikey isolate before concurrent POSTs from this file.
  const warmupDeadline = Date.now() + 15000
  await warmEdgeEndpointWithDeadline('/apikey', { method: 'GET', headers: authHeaders }, warmupDeadline)
  const warmupPostResponse = await fetch(`${BASE_URL}/apikey`, {
    method: 'POST',
    headers: {
      ...authHeaders,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(orgKeyBody(`warmup-post-${id.slice(0, 8)}`)),
  })
  if (warmupPostResponse.ok) {
    const warmupName = `warmup-post-${id.slice(0, 8)}`
    const warmupBody = await warmupPostResponse.text()
    try {
      const warmupData = JSON.parse(warmupBody) as { id?: number }
      if (typeof warmupData.id === 'number') {
        warmupApiKeyId = warmupData.id
      }
    }
    catch (error) {
      console.warn('apikey beforeAll warmup parse failed', error)
    }
    if (warmupApiKeyId !== null) {
      try {
        const deleteResponse = await fetch(`${BASE_URL}/apikey/${warmupApiKeyId}`, {
          method: 'DELETE',
          headers: authHeaders,
        })
        if (!deleteResponse.ok) {
          console.warn(`apikey beforeAll warmup cleanup delete ${warmupApiKeyId} status=${deleteResponse.status}`)
        }
      }
      catch (error) {
        console.warn(`apikey beforeAll warmup cleanup delete ${warmupApiKeyId} failed`, error)
      }
    }
    else {
      try {
        const [deletedKey] = await executeSQL(
          `DELETE FROM public.apikeys WHERE name = $1 RETURNING id`,
          [warmupName],
        )
        if (deletedKey?.id) {
          warmupApiKeyId = Number(deletedKey.id)
        }
      }
      catch (error) {
        console.warn(`apikey beforeAll warmup sql cleanup ${warmupName} failed`, error)
      }
    }
  }
  else {
    await warmEdgeEndpointWithDeadline('/apikey', {
      method: 'POST',
      headers: {
        ...authHeaders,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ name: `warmup-post-fallback-${id.slice(0, 8)}` }),
    }, warmupDeadline)
  }
})

afterAll(async () => {
  if (warmupApiKeyId !== null) {
    await fetch(`${BASE_URL}/apikey/${warmupApiKeyId}`, {
      method: 'DELETE',
      headers: authHeaders,
    }).catch(() => undefined)
    try {
      const { error } = await getSupabaseClient().from('apikeys').delete().eq('id', warmupApiKeyId)
      if (error) {
        console.warn(`apikey afterAll warmup sql cleanup ${warmupApiKeyId}`, error)
      }
    }
    catch (error) {
      console.warn(`apikey afterAll warmup sql cleanup ${warmupApiKeyId} failed`, error)
    }
    warmupApiKeyId = null
  }
  await resetAppData(APPNAME)
})

describe('[GET] /apikey operations', () => {
  it('get api keys for the user without key material', async () => {
    const response = await fetch(`${BASE_URL}/apikey`, {
      method: 'GET',
      headers: authHeaders,
    })

    const data = await response.json() as Array<Record<string, unknown>>
    expect(response.status).toBe(200)
    expect(Array.isArray(data)).toBe(true)
    expect(data.every(apikey => !('key' in apikey) && !('key_hash' in apikey))).toBe(true)
    expect(data.every(apikey => typeof apikey.is_hashed_key === 'boolean')).toBe(true)
  })

  it('get specific api key by id without key material', async () => {
    // Using seeded API key ID 10 (dedicated test key)
    const response = await fetch(`${BASE_URL}/apikey/10`, {
      method: 'GET',
      headers: authHeaders,
    })

    const data = await response.json() as Record<string, unknown>
    expect(response.status).toBe(200)
    expect(data).toHaveProperty('id', 10)
    expect(data).not.toHaveProperty('key')
    expect(typeof data.is_hashed_key).toBe('boolean')
    expect(data).not.toHaveProperty('key_hash')
  })

  it('get legacy plain api key token without key material', async () => {
    const legacyKey = `legacy-key-${randomUUID()}`
    const [insertedKey] = await executeSQL(
      `
      WITH skip_apikey_trigger AS (
        SELECT set_config('capgo.skip_apikey_trigger', 'true', true)
      )
      INSERT INTO public.apikeys (user_id, key, key_hash, name)
      SELECT $1, $2, NULL, 'legacy-key-lookup-test'
      FROM skip_apikey_trigger
      RETURNING id
      `,
      [USER_ID, legacyKey],
    )
    expect(insertedKey).toHaveProperty('id')

    try {
      const response = await fetch(`${BASE_URL}/apikey/${legacyKey}`, {
        method: 'GET',
        headers: authHeaders,
      })

      const data = await response.json() as Record<string, unknown>
      expect(response.status).toBe(200)
      expect(data).toHaveProperty('id', Number(insertedKey?.id))
      expect(data).toHaveProperty('is_hashed_key', false)
      expect(data).not.toHaveProperty('key')
      expect(data).not.toHaveProperty('key_hash')
    }
    finally {
      if (insertedKey?.id)
        await getSupabaseClient().from('apikeys').delete().eq('id', Number(insertedKey.id))
    }
  })

  it('get api key with invalid id', async () => {
    const response = await fetchTestRequest(`${BASE_URL}/apikey/424242`, {
      method: 'GET',
      headers: authHeaders,
    })
    const data = await response.json() as { error: string }
    expect(data).toHaveProperty('error', 'failed_to_get_apikey')
    expect(response.status).toBe(404)
  })
})

describe('[POST] /apikey operations', () => {
  it('create api key', async () => {
    const keyName = 'test-key-creation'
    const response = await postApiKey(orgKeyBody(keyName))
    const data = await response.json<{ key: string, id: number }>()
    expect(response.status).toBe(200)
    expect(data).toHaveProperty('key')
    expect(data).toHaveProperty('id')
    expect(typeof data.key).toBe('string')
    expect(typeof data.id).toBe('number')

    // Verify the created key
    const verifyResponse = await fetch(`${BASE_URL}/apikey/${data.id}`, { headers: authHeaders })
    const verifyData = await verifyResponse.json() as { name: string }
    expect(verifyData.name).toBe(keyName)
  })

  it.concurrent('creates an app-only preview key bound to its owning organization', async () => {
    const appBindings = await appApiKeyBindings(APPNAME, 'app_preview')
    const response = await postApiKey({
      name: `app-preview-key-${id.slice(0, 8)}`,
      bindings: appBindings,
    })
    expect(response.status).toBe(200)
    const data = await response.json<{ id: number, rbac_id: string }>()

    try {
      const bindings = await executeSQL(
        `
        SELECT role_bindings.scope_type, role_bindings.org_id, role_bindings.app_id, roles.name AS role_name
        FROM public.role_bindings
        INNER JOIN public.roles ON roles.id = role_bindings.role_id
        WHERE role_bindings.principal_type = public.rbac_principal_apikey()
          AND role_bindings.principal_id = $1::uuid
        ORDER BY role_bindings.scope_type, roles.name
        `,
        [data.rbac_id],
      )

      expect(bindings).toEqual([expect.objectContaining({
        scope_type: 'app',
        org_id: appBindings[0].org_id,
        app_id: appBindings[0].app_id,
        role_name: 'app_preview',
      })])
    }
    finally {
      await fetch(`${BASE_URL}/apikey/${data.id}`, {
        method: 'DELETE',
        headers: authHeaders,
      })
    }
  })

  it('app-limited key cannot create another API key', async () => {
    const limitedCreatorResponse = await postApiKey(await appKeyBody('app-key-creator'))
    expect(limitedCreatorResponse.status).toBe(200)
    const limitedCreatorData = await limitedCreatorResponse.json<{ id: number, key: string }>()

    const limitedHeaders = {
      'Content-Type': 'application/json',
      'capgkey': limitedCreatorData.key,
    }

    const escalationResponse = await postApiKey(await appKeyBody('blocked-key-creation'), limitedHeaders)
    const escalationData = await escalationResponse.json() as { error: string }
    expect(escalationResponse.status).toBe(400)
    expect(escalationData).toHaveProperty('error', 'cannot_create_apikey')

    await fetch(`${BASE_URL}/apikey/${limitedCreatorData.id}`, {
      method: 'DELETE',
      headers: authHeaders,
    })
  })

  it('app-limited key cannot manage sibling API keys', async () => {
    const createdKeyIds: number[] = []

    try {
      const limitedResponse = await postApiKey(await appKeyBody('app-management-blocked'))
      expect(limitedResponse.status).toBe(200)
      const limitedData = await limitedResponse.json<{ id: number, key: string }>()
      createdKeyIds.push(limitedData.id)

      const siblingResponse = await postApiKey(orgKeyBody('sibling-management-target'))
      expect(siblingResponse.status).toBe(200)
      const siblingData = await siblingResponse.json<{ id: number }>()
      createdKeyIds.push(siblingData.id)

      const limitedHeaders = {
        'Content-Type': 'application/json',
        'capgkey': limitedData.key,
      }

      const listResponse = await fetch(`${BASE_URL}/apikey`, {
        method: 'GET',
        headers: limitedHeaders,
      })
      expect(listResponse.status).toBe(401)
      await expect(listResponse.json()).resolves.toHaveProperty('error', 'cannot_list_apikeys')

      const getResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, {
        method: 'GET',
        headers: limitedHeaders,
      })
      expect(getResponse.status).toBe(401)
      await expect(getResponse.json()).resolves.toHaveProperty('error', 'cannot_get_apikey')

      const updateResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, {
        method: 'PUT',
        headers: limitedHeaders,
        body: JSON.stringify({
          name: 'sibling-renamed-by-limited-key',
        }),
      })
      expect(updateResponse.status).toBe(401)
      await expect(updateResponse.json()).resolves.toHaveProperty('error', 'cannot_update_apikey')

      const deleteResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, {
        method: 'DELETE',
        headers: limitedHeaders,
      })
      expect(deleteResponse.status).toBe(401)
      await expect(deleteResponse.json()).resolves.toHaveProperty('error', 'cannot_delete_apikey')

      const verifyResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, { headers: authHeaders })
      expect(verifyResponse.status).toBe(200)
      const verifyData = await verifyResponse.json<{ name: string }>()
      expect(verifyData.name).toBe('sibling-management-target')
    }
    finally {
      for (const keyId of createdKeyIds.reverse()) {
        await fetch(`${BASE_URL}/apikey/${keyId}`, {
          method: 'DELETE',
          headers: authHeaders,
        })
      }
    }
  })

  it.concurrent('org-scoped non-admin API key cannot manage or upgrade API keys', async () => {
    const createdKeyIds: number[] = []
    const orgId = orgApiKeyBindings()[0].org_id

    try {
      const managerResponse = await postApiKey(orgKeyBody('org-management-blocked', {
        bindings: orgApiKeyBindings(orgId, 'org_member'),
      }))
      expect(managerResponse.status).toBe(200)
      const managerData = await managerResponse.json<{ id: number, key: string }>()
      createdKeyIds.push(managerData.id)

      const siblingResponse = await postApiKey(orgKeyBody('org-sibling-management-target'))
      expect(siblingResponse.status).toBe(200)
      const siblingData = await siblingResponse.json<{ id: number }>()
      createdKeyIds.push(siblingData.id)

      const managerHeaders = {
        'Content-Type': 'application/json',
        'capgkey': managerData.key,
      }

      const listResponse = await fetch(`${BASE_URL}/apikey`, {
        method: 'GET',
        headers: managerHeaders,
      })
      expect(listResponse.status).toBe(401)
      await expect(listResponse.json()).resolves.toHaveProperty('error', 'cannot_list_apikeys')

      const getResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, {
        method: 'GET',
        headers: managerHeaders,
      })
      expect(getResponse.status).toBe(401)
      await expect(getResponse.json()).resolves.toHaveProperty('error', 'cannot_get_apikey')

      const updateResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, {
        method: 'PUT',
        headers: managerHeaders,
        body: JSON.stringify({
          name: 'org-sibling-renamed-by-api-key',
        }),
      })
      expect(updateResponse.status).toBe(401)
      await expect(updateResponse.json()).resolves.toHaveProperty('error', 'cannot_update_apikey')

      const selfUpgradeResponse = await fetch(`${BASE_URL}/apikey/${managerData.id}`, {
        method: 'PUT',
        headers: managerHeaders,
        body: JSON.stringify({
          bindings: orgApiKeyBindings(orgId, 'org_super_admin'),
        }),
      })
      expect(selfUpgradeResponse.status).toBe(401)
      await expect(selfUpgradeResponse.json()).resolves.toHaveProperty('error', 'cannot_update_apikey')

      const regenerateResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, {
        method: 'PUT',
        headers: managerHeaders,
        body: JSON.stringify({
          regenerate: true,
        }),
      })
      expect(regenerateResponse.status).toBe(401)
      await expect(regenerateResponse.json()).resolves.toHaveProperty('error', 'cannot_update_apikey')

      const deleteResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, {
        method: 'DELETE',
        headers: managerHeaders,
      })
      expect(deleteResponse.status).toBe(401)
      await expect(deleteResponse.json()).resolves.toHaveProperty('error', 'cannot_delete_apikey')

      const verifySiblingResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, { headers: authHeaders })
      expect(verifySiblingResponse.status).toBe(200)
      const verifySiblingData = await verifySiblingResponse.json<{ name: string }>()
      expect(verifySiblingData.name).toBe('org-sibling-management-target')

      const verifyManagerResponse = await fetch(`${BASE_URL}/apikey/${managerData.id}`, { headers: authHeaders })
      expect(verifyManagerResponse.status).toBe(200)
      const verifyManagerData = await verifyManagerResponse.json<{ name: string }>()
      expect(verifyManagerData.name).toBe('org-management-blocked')
    }
    finally {
      for (const keyId of createdKeyIds.reverse()) {
        await fetch(`${BASE_URL}/apikey/${keyId}`, {
          method: 'DELETE',
          headers: authHeaders,
        })
      }
    }
  })

  it.concurrent('org super admin API key can manage sibling API keys without self-upgrade', async () => {
    const createdKeyIds: number[] = []
    const dedicatedAuthHeaders = await getAuthHeadersForCredentials(USER_EMAIL_APIKEY_MANAGEMENT, USER_PASSWORD)
    const superAdminKeyHeaders = {
      'Content-Type': 'application/json',
      'capgkey': APIKEY_MANAGEMENT_ORG_SUPER_ADMIN,
    }

    try {
      const siblingResponse = await postApiKey(orgKeyBody('org-super-admin-key-sibling-management-target', {
        bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT),
      }), dedicatedAuthHeaders)
      expect(siblingResponse.status).toBe(200)
      const siblingData = await siblingResponse.json<{ id: number }>()
      createdKeyIds.push(siblingData.id)

      const listResponse = await fetch(`${BASE_URL}/apikey`, {
        method: 'GET',
        headers: superAdminKeyHeaders,
      })
      expect(listResponse.status).toBe(200)
      const listData = await listResponse.json<Array<{ id: number }>>()
      expect(listData.some(apikey => apikey.id === siblingData.id)).toBe(true)

      const getResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, {
        method: 'GET',
        headers: superAdminKeyHeaders,
      })
      expect(getResponse.status).toBe(200)
      await expect(getResponse.json()).resolves.toHaveProperty('id', siblingData.id)

      const updateResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, {
        method: 'PUT',
        headers: superAdminKeyHeaders,
        body: JSON.stringify({
          name: 'org-super-admin-key-renamed-sibling',
        }),
      })
      expect(updateResponse.status).toBe(200)
      await expect(updateResponse.json()).resolves.toHaveProperty('name', 'org-super-admin-key-renamed-sibling')

      const bindingUpdateResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, {
        method: 'PUT',
        headers: superAdminKeyHeaders,
        body: JSON.stringify({
          bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_super_admin'),
        }),
      })
      expect(bindingUpdateResponse.status).toBe(401)
      await expect(bindingUpdateResponse.json()).resolves.toHaveProperty('error', 'cannot_update_apikey')

      const regenerateResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, {
        method: 'PUT',
        headers: superAdminKeyHeaders,
        body: JSON.stringify({
          regenerate: true,
        }),
      })
      expect(regenerateResponse.status).toBe(200)
      await expect(regenerateResponse.json()).resolves.toHaveProperty('id', siblingData.id)

      const selfUpdateResponse = await fetch(`${BASE_URL}/apikey/112`, {
        method: 'PUT',
        headers: superAdminKeyHeaders,
        body: JSON.stringify({
          name: 'org-super-admin-key-self-update-blocked',
        }),
      })
      expect(selfUpdateResponse.status).toBe(401)
      await expect(selfUpdateResponse.json()).resolves.toHaveProperty('error', 'cannot_update_apikey')

      const selfBindingUpdateResponse = await fetch(`${BASE_URL}/apikey/112`, {
        method: 'PUT',
        headers: superAdminKeyHeaders,
        body: JSON.stringify({
          bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_super_admin'),
        }),
      })
      expect(selfBindingUpdateResponse.status).toBe(401)
      await expect(selfBindingUpdateResponse.json()).resolves.toHaveProperty('error', 'cannot_update_apikey')

      const selfDeleteResponse = await fetch(`${BASE_URL}/apikey/112`, {
        method: 'DELETE',
        headers: superAdminKeyHeaders,
      })
      expect(selfDeleteResponse.status).toBe(401)
      await expect(selfDeleteResponse.json()).resolves.toHaveProperty('error', 'cannot_delete_apikey')

      const deleteResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, {
        method: 'DELETE',
        headers: superAdminKeyHeaders,
      })
      expect(deleteResponse.status).toBe(200)
      await expect(deleteResponse.json()).resolves.toHaveProperty('status', 'ok')

      const verifySiblingResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, { headers: dedicatedAuthHeaders })
      expect(verifySiblingResponse.status).toBe(404)
    }
    finally {
      for (const keyId of createdKeyIds.reverse()) {
        await fetch(`${BASE_URL}/apikey/${keyId}`, {
          method: 'DELETE',
          headers: dedicatedAuthHeaders,
        })
      }
    }
  })

  it.concurrent('apikey_manager API key can manage sibling keys but cannot create them', async () => {
    const dedicatedAuthHeaders = await getAuthHeadersForCredentials(USER_EMAIL_APIKEY_MANAGEMENT, USER_PASSWORD)
    const managerKeyHeaders = {
      'Content-Type': 'application/json',
      'capgkey': APIKEY_MANAGEMENT_APIKEY_MANAGER,
    }
    const createdKeyIds: number[] = []

    try {
      const siblingResponse = await postApiKey(orgKeyBody('apikey-manager-sibling-target', {
        bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_member'),
      }), dedicatedAuthHeaders)
      expect(siblingResponse.status).toBe(200)
      const siblingData = await siblingResponse.json<{ id: number }>()
      createdKeyIds.push(siblingData.id)

      const listResponse = await fetch(`${BASE_URL}/apikey`, { method: 'GET', headers: managerKeyHeaders })
      expect(listResponse.status).toBe(200)
      const listData = await listResponse.json<Array<{ id: number }>>()
      expect(listData.some(apikey => apikey.id === siblingData.id)).toBe(true)

      const createResponse = await postApiKey(orgKeyBody('apikey-manager-created-sibling', {
        bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_member'),
      }), managerKeyHeaders)
      expect(createResponse.status).toBe(400)
      await expect(createResponse.json()).resolves.toHaveProperty('error', 'cannot_create_apikey')

      const updateResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, {
        method: 'PUT',
        headers: managerKeyHeaders,
        body: JSON.stringify({ name: 'apikey-manager-renamed-sibling' }),
      })
      expect(updateResponse.status).toBe(200)

      const bindingUpdateResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, {
        method: 'PUT',
        headers: managerKeyHeaders,
        body: JSON.stringify({
          bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_super_admin'),
        }),
      })
      expect(bindingUpdateResponse.status).toBe(401)
      await expect(bindingUpdateResponse.json()).resolves.toHaveProperty('error', 'cannot_update_apikey')

      const privilegedCreateResponse = await postApiKey(orgKeyBody('apikey-manager-blocked-privileged-create', {
        bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_super_admin'),
      }), managerKeyHeaders)
      expect(privilegedCreateResponse.status).toBe(400)
      await expect(privilegedCreateResponse.json()).resolves.toHaveProperty('error', 'cannot_create_apikey')

      const appAdminCreateResponse = await postApiKey({
        name: 'apikey-manager-blocked-app-admin-create',
        bindings: [{
          role_name: 'app_admin',
          scope_type: 'app',
          org_id: ORG_ID_APIKEY_MANAGEMENT,
          app_id: APPNAME,
        }],
      }, managerKeyHeaders)
      expect(appAdminCreateResponse.status).toBe(400)
      await expect(appAdminCreateResponse.json()).resolves.toHaveProperty('error', 'cannot_create_apikey')

      const allowSystemRoleBypassResponse = await postApiKey({
        name: 'apikey-manager-blocked-allow-system-role-bypass',
        bindings: [{
          role_name: 'app_admin',
          scope_type: 'app',
          org_id: ORG_ID_APIKEY_MANAGEMENT,
          app_id: APPNAME,
          allowSystemRole: true,
        }],
      }, managerKeyHeaders)
      expect(allowSystemRoleBypassResponse.status).toBe(400)
      await expect(allowSystemRoleBypassResponse.json()).resolves.toHaveProperty('error', 'cannot_create_apikey')

      const selfUpdateResponse = await fetch(`${BASE_URL}/apikey/${APIKEY_MANAGEMENT_APIKEY_MANAGER_ID}`, {
        method: 'PUT',
        headers: managerKeyHeaders,
        body: JSON.stringify({ name: 'apikey-manager-self-update-blocked' }),
      })
      expect(selfUpdateResponse.status).toBe(401)
      await expect(selfUpdateResponse.json()).resolves.toHaveProperty('error', 'cannot_update_apikey')

      const deleteResponse = await fetch(`${BASE_URL}/apikey/${siblingData.id}`, {
        method: 'DELETE',
        headers: managerKeyHeaders,
      })
      expect(deleteResponse.status).toBe(200)
      createdKeyIds.splice(createdKeyIds.indexOf(siblingData.id), 1)
    }
    finally {
      for (const keyId of createdKeyIds.reverse()) {
        await fetch(`${BASE_URL}/apikey/${keyId}`, {
          method: 'DELETE',
          headers: dedicatedAuthHeaders,
        })
      }
    }
  })

  it.concurrent('apikey_manager cannot regenerate an org_super_admin sibling API key', async () => {
    const dedicatedAuthHeaders = await getAuthHeadersForCredentials(USER_EMAIL_APIKEY_MANAGEMENT, USER_PASSWORD)
    const managerKeyHeaders = {
      'Content-Type': 'application/json',
      'capgkey': APIKEY_MANAGEMENT_APIKEY_MANAGER,
    }
    const superAdminKeyHeaders = {
      'Content-Type': 'application/json',
      'capgkey': APIKEY_MANAGEMENT_ORG_SUPER_ADMIN,
    }
    const createdKeyIds: number[] = []

    try {
      const hashedSiblingResponse = await postApiKey(orgKeyBody('apikey-manager-blocked-super-admin-hashed', {
        bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_super_admin'),
        hashed: true,
      }), dedicatedAuthHeaders)
      expect(hashedSiblingResponse.status).toBe(200)
      const hashedSibling = await hashedSiblingResponse.json<{ id: number, key: string }>()
      createdKeyIds.push(hashedSibling.id)

      const hashedRegenerateResponse = await fetch(`${BASE_URL}/apikey/${hashedSibling.id}`, {
        method: 'PUT',
        headers: managerKeyHeaders,
        body: JSON.stringify({ regenerate: true }),
      })
      expect(hashedRegenerateResponse.status).toBe(403)
      const hashedRegenerateBody = await hashedRegenerateResponse.json() as Record<string, unknown>
      expect(hashedRegenerateBody).toHaveProperty('error', 'forbidden_binding')
      expect(hashedRegenerateBody).not.toHaveProperty('key')

      const oldHashedAuthResponse = await fetch(`${BASE_URL}/apikey`, {
        method: 'GET',
        headers: { 'Content-Type': 'application/json', 'Authorization': hashedSibling.key },
      })
      expect(oldHashedAuthResponse.status).toBe(200)

      const plainSiblingResponse = await postApiKey(orgKeyBody('apikey-manager-blocked-super-admin-plain', {
        bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_super_admin'),
        hashed: false,
      }), dedicatedAuthHeaders)
      expect(plainSiblingResponse.status).toBe(200)
      const plainSibling = await plainSiblingResponse.json<{ id: number, key: string }>()
      createdKeyIds.push(plainSibling.id)

      const plainRegenerateResponse = await fetch(`${BASE_URL}/apikey/${plainSibling.id}`, {
        method: 'PUT',
        headers: managerKeyHeaders,
        body: JSON.stringify({ regenerate: true }),
      })
      expect(plainRegenerateResponse.status).toBe(403)
      const plainRegenerateBody = await plainRegenerateResponse.json() as Record<string, unknown>
      expect(plainRegenerateBody).toHaveProperty('error', 'forbidden_binding')
      expect(plainRegenerateBody).not.toHaveProperty('key')

      const jwtRegenerateResponse = await fetch(`${BASE_URL}/apikey/${hashedSibling.id}`, {
        method: 'PUT',
        headers: dedicatedAuthHeaders,
        body: JSON.stringify({ regenerate: true }),
      })
      expect(jwtRegenerateResponse.status).toBe(200)
      await expect(jwtRegenerateResponse.json()).resolves.toHaveProperty('id', hashedSibling.id)

      const superAdminRegenerateResponse = await fetch(`${BASE_URL}/apikey/${plainSibling.id}`, {
        method: 'PUT',
        headers: superAdminKeyHeaders,
        body: JSON.stringify({ regenerate: true }),
      })
      expect(superAdminRegenerateResponse.status).toBe(200)
      await expect(superAdminRegenerateResponse.json()).resolves.toHaveProperty('id', plainSibling.id)
    }
    finally {
      for (const keyId of createdKeyIds.reverse()) {
        try {
          const response = await fetch(`${BASE_URL}/apikey/${keyId}`, {
            method: 'DELETE',
            headers: dedicatedAuthHeaders,
          })
          if (!response.ok) {
            console.warn(`apikey manager super-admin rotate cleanup delete ${keyId} status=${response.status}`)
          }
        }
        catch (error) {
          console.warn(`apikey manager super-admin rotate cleanup delete ${keyId} failed`, error)
        }
      }
    }
  })

  it.concurrent('rejects API key POST creation even for an org super admin key', async () => {
    const superAdminKeyHeaders = {
      'Content-Type': 'application/json',
      'capgkey': APIKEY_MANAGEMENT_ORG_SUPER_ADMIN,
    }

    const createResponse = await postApiKey(orgKeyBody('org-super-admin-key-creation-blocked', {
      bindings: orgApiKeyBindings(ORG_ID_APIKEY_MANAGEMENT, 'org_member'),
    }), superAdminKeyHeaders)

    expect(createResponse.status).toBe(400)
    await expect(createResponse.json()).resolves.toHaveProperty('error', 'cannot_create_apikey')
  })

  it.each([
    ['missing', {}],
    ['empty', { name: '' }],
  ])('create api key with %s name', async (_label, body) => {
    const response = await postApiKey(body, authHeaders)
    expect(response.status).toBe(400)
    const data = await response.json() as { error: string }
    expect(data).toHaveProperty('error', 'name_is_required')
  })

  it('create api key with invalid binding scope', async () => {
    const response = await postApiKey({
      name: 'test-key',
      bindings: [{ role_name: 'org_admin', scope_type: 'invalid', org_id: randomUUID() }],
    }, authHeaders)
    expect(response.status).toBe(400)
    const data = await response.json() as { error: string }
    expect(data).toHaveProperty('error', 'invalid_bindings')
  })

  it('create api key with non-existent org_id', async () => {
    const nonExistentOrgId = randomUUID()
    const response = await postApiKey({
      name: 'test-key',
      bindings: orgApiKeyBindings(nonExistentOrgId),
    }, authHeaders)
    expect(response.status).toBe(403)
    const data = await response.json() as { error: string }
    expect(data).toHaveProperty('error', 'forbidden_binding')
  })

  it('create api key with non-existent app_id', async () => {
    const nonExistentAppId = randomUUID()
    const response = await postApiKey({
      name: 'test-key',
      bindings: [{
        role_name: 'app_admin',
        scope_type: 'app',
        org_id: orgApiKeyBindings()[0].org_id,
        app_id: nonExistentAppId,
      }],
    }, authHeaders)
    expect(response.status).toBe(404)
    const data = await response.json() as { error: string }
    expect(data).toHaveProperty('error', 'binding_failed')
  })

  it('create api key with invalid JSON body', async () => {
    const response = await postApiKey('invalid json', authHeaders, true)
    expect(response.status).toBeGreaterThanOrEqual(400)
  })
})

describe('[PUT] /apikey/:id operations', () => {
  it('update api key name', async () => {
    // Using seeded API key ID 11 (dedicated test key for update name)
    const newName = 'updated-test-key-name'
    const response = await fetch(`${BASE_URL}/apikey/11`, {
      method: 'PUT',
      headers: authHeaders,
      body: JSON.stringify({
        name: newName,
      }),
    })

    const data = await response.json()
    expect(response.status).toBe(200)
    expect(data).toHaveProperty('name', newName)

    // Verify the update
    const verifyResponse = await fetch(`${BASE_URL}/apikey/11`, { headers: authHeaders })
    const verifyData = await verifyResponse.json() as { name: string }
    expect(verifyData.name).toBe(newName)
  })

  it.concurrent('metadata updates do not return plain or hashed key material', async () => {
    const createdKeyIds: number[] = []

    try {
      for (const hashed of [false, true]) {
        const suffix = hashed ? 'hashed' : 'plain'
        const createResponse = await postApiKey(orgKeyBody(`temp-metadata-no-leak-${suffix}-${randomUUID()}`, { hashed }))
        expect(createResponse.status).toBe(200)
        const createData = await createResponse.json<{ id: number }>()
        createdKeyIds.push(createData.id)

        const newName = `temp-metadata-updated-${suffix}-${randomUUID()}`
        const updateResponse = await fetch(`${BASE_URL}/apikey/${createData.id}`, {
          method: 'PUT',
          headers: authHeaders,
          body: JSON.stringify({ name: newName }),
        })
        const updateData = await updateResponse.json() as Record<string, unknown>

        expect(updateResponse.status).toBe(200)
        expect(updateData).toHaveProperty('id', createData.id)
        expect(updateData).toHaveProperty('name', newName)
        expect(updateData).not.toHaveProperty('key')
        expect(updateData).not.toHaveProperty('key_hash')
      }
    }
    finally {
      for (const keyId of createdKeyIds.reverse()) {
        await fetch(`${BASE_URL}/apikey/${keyId}`, {
          method: 'DELETE',
          headers: authHeaders,
        })
      }
    }
  })

  it.concurrent('updates api key role bindings', async () => {
    let createData: { id: number, rbac_id: string } | undefined

    try {
      const createResponse = await postApiKey(orgKeyBody(`temp-key-update-bindings-${randomUUID()}`))
      expect(createResponse.status).toBe(200)
      createData = await createResponse.json<{ id: number, rbac_id: string }>()
      const createdKey = createData

      const appBindings = await appApiKeyBindings(APPNAME, 'app_reader')
      // Cloudflare shards can 403 immediately after POST while the caller's
      // org.update_user_roles check still sees the pre-create binding state.
      // Retry only that race (and gateway deaths). Permanent 401/404/409 fail now.
      const putDeadline = Date.now() + 4000
      while (true) {
        const updateResponse = await fetch(`${BASE_URL}/apikey/${createdKey.id}`, {
          method: 'PUT',
          headers: authHeaders,
          body: JSON.stringify({
            bindings: appBindings,
          }),
        })
        if (updateResponse.status === 200)
          break
        const body = await updateResponse.text().catch(() => '')
        const message = `PUT /apikey/${createdKey.id} status=${updateResponse.status} body=${body.slice(0, 400)}`
        const retryable = updateResponse.status === 403
          || updateResponse.status === 502
          || updateResponse.status === 503
        if (!retryable || Date.now() >= putDeadline)
          throw new Error(message)
        await new Promise(resolve => setTimeout(resolve, 150))
      }

      await vi.waitFor(async () => {
        const { data: bindings, error } = await getSupabaseClient()
          .from('role_bindings')
          .select('scope_type, app_id, roles(name)')
          .eq('principal_type', 'apikey')
          .eq('principal_id', createdKey.rbac_id)

        expect(error).toBeNull()
        expect(bindings).toEqual([
          expect.objectContaining({
            scope_type: 'app',
            app_id: appBindings[0].app_id,
            roles: expect.objectContaining({ name: 'app_reader' }),
          }),
        ])
      }, { timeout: 4000, interval: 150 })
    }
    finally {
      if (createData) {
        await fetch(`${BASE_URL}/apikey/${createData.id}`, { method: 'DELETE', headers: authHeaders })
      }
    }
  })

  it('update api key with invalid id', async () => {
    const response = await fetch(`${BASE_URL}/apikey/424242`, {
      method: 'PUT',
      headers: authHeaders,
      body: JSON.stringify({ name: 'wont-work' }),
    })
    expect(response.status).toBe(404)
    const data = await response.json() as { error: string }
    expect(data).toHaveProperty('error')
  })

  it.each([
    ['an unsupported field', { unsupported_field: 'invalid' }],
    ['an unsupported app scope field', { unsupported_app_scope: 'not_an_array' }],
    ['an unsupported org scope field', { unsupported_org_scope: 'not_an_array' }],
    ['no fields', {}],
  ])('update api key with %s has no valid fields', async (label, body) => {
    const createResponse = await postApiKey(orgKeyBody(`temp-no-valid-fields-${label.replaceAll(' ', '-')}`), authHeaders)
    const createData = await createResponse.json<{ id: number }>()

    const response = await fetch(`${BASE_URL}/apikey/${createData.id}`, {
      method: 'PUT',
      headers: authHeaders,
      body: JSON.stringify(body),
    })
    expect(response.status).toBe(400)
    const data = await response.json() as { error: string }
    expect(data.error).toContain('no_valid_fields_provided_for_update')
  })

  it('regenerate plain api key (key changes and old key no longer works)', async () => {
    const createResponse = await postApiKey(orgKeyBody('temp-plain-key-regenerate', { hashed: false }), authHeaders)
    const createData = await createResponse.json<{ id: number, key: string }>()
    expect(createResponse.status).toBe(200)
    expect(typeof createData.key).toBe('string')

    const oldKey = createData.key

    const regenerateResponse = await fetch(`${BASE_URL}/apikey/${createData.id}`, {
      method: 'PUT',
      headers: authHeaders,
      body: JSON.stringify({ regenerate: true }),
    })
    const regenerateData = await regenerateResponse.json<{ id: number, key: string }>()
    expect(regenerateResponse.status).toBe(200)
    expect(regenerateData.id).toBe(createData.id)
    expect(typeof regenerateData.key).toBe('string')
    expect(regenerateData.key).not.toBe(oldKey)

    // Old key must no longer authenticate.
    const oldAuthHeaders = { 'Content-Type': 'application/json', 'Authorization': oldKey }
    const oldAuthResponse = await fetch(`${BASE_URL}/apikey`, { method: 'GET', headers: oldAuthHeaders })
    expect(oldAuthResponse.status).toBe(401)

    // New key must authenticate and keep its RBAC management permission.
    const newAuthHeaders = { 'Content-Type': 'application/json', 'Authorization': regenerateData.key }
    const newAuthResponse = await fetch(`${BASE_URL}/apikey`, { method: 'GET', headers: newAuthHeaders })
    expect(newAuthResponse.status).toBe(200)
    await expect(newAuthResponse.json()).resolves.toEqual(expect.any(Array))

    await fetch(`${BASE_URL}/apikey/${createData.id}`, { method: 'DELETE', headers: authHeaders })
  })

  it('regenerate hashed api key (key changes and remains hashed in DB)', async () => {
    const createResponse = await postApiKey(orgKeyBody('temp-hashed-key-regenerate', { hashed: true }), authHeaders)
    const createData = await createResponse.json<{ id: number, key: string, key_hash: string }>()
    expect(createResponse.status).toBe(200)

    const oldKey = createData.key
    const oldHash = createData.key_hash

    const regenerateResponse = await fetch(`${BASE_URL}/apikey/${createData.id}`, {
      method: 'PUT',
      headers: authHeaders,
      body: JSON.stringify({ regenerate: true }),
    })
    const regenerateData = await regenerateResponse.json<{ id: number, key: string, key_hash: string }>()
    expect(regenerateResponse.status).toBe(200)
    expect(regenerateData.id).toBe(createData.id)
    expect(regenerateData.key).not.toBe(oldKey)
    expect(regenerateData.key_hash).not.toBe(oldHash)

    const { data: verifyData, error: verifyError } = await getSupabaseClient()
      .from('apikeys')
      .select('key, key_hash')
      .eq('id', createData.id)
      .single()
    expect(verifyError).toBeNull()
    expect(verifyData?.key).toBeNull()
    expect(verifyData?.key_hash).toBe(regenerateData.key_hash)

    // Old key must no longer authenticate.
    const oldAuthHeaders = { 'Content-Type': 'application/json', 'Authorization': oldKey }
    const oldAuthResponse = await fetch(`${BASE_URL}/apikey`, { method: 'GET', headers: oldAuthHeaders })
    expect(oldAuthResponse.status).toBe(401)

    // New key must authenticate and keep its RBAC management permission.
    const newAuthHeaders = { 'Content-Type': 'application/json', 'Authorization': regenerateData.key }
    const newAuthResponse = await fetch(`${BASE_URL}/apikey`, { method: 'GET', headers: newAuthHeaders })
    expect(newAuthResponse.status).toBe(200)
    await expect(newAuthResponse.json()).resolves.toEqual(expect.any(Array))

    await fetch(`${BASE_URL}/apikey/${createData.id}`, { method: 'DELETE', headers: authHeaders })
  })

  it('regenerate and update name in a single request', async () => {
    const createResponse = await postApiKey(orgKeyBody('temp-key-regenerate-and-rename', { hashed: false }), authHeaders)
    const createData = await createResponse.json<{ id: number, key: string }>()
    expect(createResponse.status).toBe(200)

    const newName = 'temp-key-regenerated-renamed'
    const regenerateResponse = await fetch(`${BASE_URL}/apikey/${createData.id}`, {
      method: 'PUT',
      headers: authHeaders,
      body: JSON.stringify({ regenerate: true, name: newName }),
    })
    const regenerateData = await regenerateResponse.json() as { id: number, name: string, key: string }
    expect(regenerateResponse.status).toBe(200)
    expect(regenerateData.name).toBe(newName)
    expect(regenerateData.key).not.toBe(createData.key)

    await fetch(`${BASE_URL}/apikey/${createData.id}`, { method: 'DELETE', headers: authHeaders })
  })

  it('regenerate non-existent key returns 404', async () => {
    const response = await fetch(`${BASE_URL}/apikey/424242`, {
      method: 'PUT',
      headers: authHeaders,
      body: JSON.stringify({ regenerate: true }),
    })
    expect(response.status).toBe(404)
  })
})

describe('[DELETE] /apikey/:id operations', () => {
  it('delete api key', async () => {
    // Create a key specifically for deletion
    const createResponse = await postApiKey(orgKeyBody('key-to-delete'), authHeaders)
    const createData = await createResponse.json<{ id: number }>()

    const response = await fetch(`${BASE_URL}/apikey/${createData.id}`, {
      method: 'DELETE',
      headers: authHeaders,
    })

    const data = await response.json() as { status: string }
    expect(response.status).toBe(200)
    expect(data).toHaveProperty('status', 'ok')

    // Verify deletion
    const verifyResponse = await fetch(`${BASE_URL}/apikey/${createData.id}`, { headers: authHeaders })
    expect(verifyResponse.status).toBe(404)
  })

  it('delete api key with invalid id', async () => {
    const response = await fetch(`${BASE_URL}/apikey/424242`, {
      method: 'DELETE',
      headers: authHeaders,
    })
    expect(response.status).toBe(404)
    const data = await response.json() as { error: string }
    expect(data).toHaveProperty('error')
  })

  it('delete already deleted api key', async () => {
    // Create and delete a key, then try to delete again
    const createResponse = await postApiKey(orgKeyBody('key-to-double-delete'), authHeaders)
    const createData = await createResponse.json<{ id: number }>()

    // First deletion
    await fetch(`${BASE_URL}/apikey/${createData.id}`, {
      method: 'DELETE',
      headers: authHeaders,
    })

    // Second deletion attempt
    const response = await fetch(`${BASE_URL}/apikey/${createData.id}`, {
      method: 'DELETE',
      headers: authHeaders,
    })
    expect(response.status).toBe(404)
    const data = await response.json() as { error: string }
    expect(data).toHaveProperty('error')
  })
})

describe('[POST] /apikey hashed key operations', () => {
  it('create hashed api key', async () => {
    const keyName = 'test-hashed-key'
    const response = await postApiKey({
      name: keyName,
      hashed: true,
      bindings: orgApiKeyBindings(),
    }, authHeaders)
    const data = await response.json<{ key: string, key_hash: string, id: number }>()
    expect(response.status).toBe(200)
    expect(data).toHaveProperty('key')
    expect(data).toHaveProperty('key_hash')
    expect(data).toHaveProperty('id')
    expect(typeof data.key).toBe('string')
    expect(typeof data.key_hash).toBe('string')
    // The key should be a UUID format
    expect(data.key).toMatch(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i)
    // The key_hash should be a SHA-256 hex string (64 characters)
    expect(data.key_hash).toMatch(/^[\da-f]{64}$/i)

    // Verify the created key exists but key column in DB should be null
    const { data: verifyData, error: verifyError } = await getSupabaseClient()
      .from('apikeys')
      .select('name, key, key_hash')
      .eq('id', data.id)
      .single()
    expect(verifyError).toBeNull()
    expect(verifyData?.name).toBe(keyName)
    // In the database, the key should be null for hashed keys
    expect(verifyData?.key).toBeNull()
    expect(verifyData?.key_hash).toBe(data.key_hash)

    // Public GET must not expose key material.
    const publicResponse = await fetch(`${BASE_URL}/apikey/${data.id}`, { headers: authHeaders })
    const publicData = await publicResponse.json() as Record<string, unknown>
    expect(publicData).toHaveProperty('is_hashed_key', true)
    expect(publicResponse.status).toBe(200)
    expect(publicData).not.toHaveProperty('key')
    expect(publicData).not.toHaveProperty('key_hash')

    // Cleanup
    await fetch(`${BASE_URL}/apikey/${data.id}`, {
      method: 'DELETE',
      headers: authHeaders,
    })
  })

  it('create plain api key (hashed: false)', async () => {
    const keyName = 'test-plain-key-explicit'
    const response = await postApiKey({
      name: keyName,
      hashed: false,
      bindings: orgApiKeyBindings(),
    }, authHeaders)
    const data = await response.json<{ key: string, key_hash: string | null, id: number }>()
    expect(response.status).toBe(200)
    expect(data).toHaveProperty('key')
    expect(typeof data.key).toBe('string')
    // Plain key should not have key_hash set
    expect(data.key_hash).toBeNull()

    // Verify the key is stored in plain
    const { data: verifyData, error: verifyError } = await getSupabaseClient()
      .from('apikeys')
      .select('key, key_hash')
      .eq('id', data.id)
      .single()
    expect(verifyError).toBeNull()
    expect(verifyData?.key).toBe(data.key)
    expect(verifyData?.key_hash).toBeNull()

    // Public GET must not expose key material.
    const publicResponse = await fetch(`${BASE_URL}/apikey/${data.id}`, { headers: authHeaders })
    const publicData = await publicResponse.json() as Record<string, unknown>
    expect(publicResponse.status).toBe(200)
    expect(publicData).toHaveProperty('is_hashed_key', false)
    expect(publicData).not.toHaveProperty('key')
    expect(publicData).not.toHaveProperty('key_hash')

    // Cleanup
    await fetch(`${BASE_URL}/apikey/${data.id}`, {
      method: 'DELETE',
      headers: authHeaders,
    })
  })

  it('create hashed api key with V2 bindings', async () => {
    const response = await postApiKey({
      name: 'hashed-key-with-options',
      hashed: true,
      bindings: orgApiKeyBindings(),
    }, authHeaders)
    const data = await response.json<{ key: string, key_hash: string, id: number }>()
    expect(response.status).toBe(200)
    expect(data).toHaveProperty('key')
    expect(data).toHaveProperty('key_hash')

    // Cleanup
    await fetch(`${BASE_URL}/apikey/${data.id}`, {
      method: 'DELETE',
      headers: authHeaders,
    })
  })

  it('hashed key can be used for authentication', async () => {
    // Create a hashed key
    const createResponse = await postApiKey({
      name: 'hashed-key-for-auth-test',
      hashed: true,
      bindings: orgApiKeyBindings(),
    }, authHeaders)
    const createData = await createResponse.json<{ key: string, id: number }>()
    expect(createResponse.status).toBe(200)

    // Use the plain key value to authenticate (the system should hash it and find the key)
    const createdKeyHeaders = {
      'Content-Type': 'application/json',
      'Authorization': createData.key,
    }

    // Try to list API keys using the hashed key for auth.
    const listResponse = await fetch(`${BASE_URL}/apikey`, {
      method: 'GET',
      headers: createdKeyHeaders,
    })
    expect(listResponse.status).toBe(200)
    await expect(listResponse.json()).resolves.toEqual(expect.any(Array))

    // Cleanup - use original headers since new key might have restrictions
    await fetch(`${BASE_URL}/apikey/${createData.id}`, {
      method: 'DELETE',
      headers: authHeaders,
    })
  })
})

describe('[POST] /apikey hashed key with expiration', () => {
  it('create hashed api key with expiration date', async () => {
    const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() // 7 days from now
    const response = await postApiKey({
      name: 'hashed-key-with-expiration',
      hashed: true,
      expires_at: futureDate,
      bindings: orgApiKeyBindings(),
    }, authHeaders)
    const data = await response.json<{ key: string, key_hash: string, id: number, expires_at: string }>()
    expect(response.status).toBe(200)
    expect(data).toHaveProperty('key')
    expect(data).toHaveProperty('key_hash')
    expect(data).toHaveProperty('expires_at')
    // Key should be UUID format
    expect(data.key).toMatch(/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i)
    // key_hash should be SHA-256 hex (64 chars)
    expect(data.key_hash).toMatch(/^[\da-f]{64}$/i)
    // expires_at should match what we sent
    expect(new Date(data.expires_at).getTime()).toBeCloseTo(new Date(futureDate).getTime(), -3)

    // Verify in DB: key should be null, key_hash and expires_at should be set
    const { data: verifyData, error: verifyError } = await getSupabaseClient()
      .from('apikeys')
      .select('key, key_hash, expires_at')
      .eq('id', data.id)
      .single()
    expect(verifyError).toBeNull()
    expect(verifyData?.key).toBeNull()
    expect(verifyData?.key_hash).toBe(data.key_hash)
    expect(verifyData?.expires_at).not.toBeNull()

    // Cleanup
    await fetch(`${BASE_URL}/apikey/${data.id}`, {
      method: 'DELETE',
      headers: authHeaders,
    })
  })

  it('hashed key with expiration can be used for authentication', async () => {
    const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
    const createResponse = await postApiKey({
      name: 'hashed-key-expiration-auth-test',
      hashed: true,
      expires_at: futureDate,
      bindings: orgApiKeyBindings(),
    }, authHeaders)
    const createData = await createResponse.json<{ key: string, id: number }>()
    expect(createResponse.status).toBe(200)

    // Use the plain key value to authenticate
    const createdKeyHeaders = {
      'Content-Type': 'application/json',
      'Authorization': createData.key,
    }

    const listResponse = await fetch(`${BASE_URL}/apikey`, {
      method: 'GET',
      headers: createdKeyHeaders,
    })
    expect(listResponse.status).toBe(200)
    await expect(listResponse.json()).resolves.toEqual(expect.any(Array))

    // Cleanup
    await fetch(`${BASE_URL}/apikey/${createData.id}`, {
      method: 'DELETE',
      headers: authHeaders,
    })
  })

  it('expired hashed key should be rejected for authentication', async () => {
    // Create a hashed key with future expiration
    const createResponse = await postApiKey({
      name: 'hashed-key-to-expire',
      hashed: true,
      expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
      bindings: orgApiKeyBindings(),
    }, authHeaders)
    const createData = await createResponse.json<{ key: string, id: number }>()
    expect(createResponse.status).toBe(200)

    // Manually set the key to expired via direct DB update
    const { error } = await getSupabaseClient().from('apikeys').update({ expires_at: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() }).eq('id', createData.id)
    expect(error).toBeNull()

    // Try to use the expired hashed key for authentication
    const expiredKeyHeaders = {
      'Content-Type': 'application/json',
      'Authorization': createData.key,
    }

    const listResponse = await fetch(`${BASE_URL}/apikey`, {
      method: 'GET',
      headers: expiredKeyHeaders,
    })
    // Should be rejected as unauthorized
    expect(listResponse.status).toBe(401)
  })
})

describe('[RLS] hashed API key with direct Supabase SDK', () => {
  it('hashed key works with RLS via Supabase SDK (simulating CLI usage)', async () => {
    // Create a hashed key via API
    const createResponse = await postApiKey({
      name: 'hashed-key-rls-test',
      hashed: true,
      bindings: orgApiKeyBindings(),
    }, authHeaders)
    const createData = await createResponse.json<{ key: string, id: number }>()
    expect(createResponse.status).toBe(200)

    // Now use the hashed key directly with Supabase SDK (bypassing our API)
    // This simulates how the CLI uses the SDK with capgkey header
    const supabaseWithHashedKey = createClient(
      process.env.SUPABASE_URL!,
      process.env.SUPABASE_ANON_KEY!,
      {
        global: {
          headers: {
            capgkey: createData.key, // The plain key that user received
          },
        },
      },
    )

    // Try to query apps table - this goes through RBAC-backed RLS.
    const { data: apps, error: appsError } = await supabaseWithHashedKey
      .from('apps')
      .select('app_id, name')
      .limit(5)

    expect(appsError).toBeNull()
    expect(Array.isArray(apps)).toBe(true)

    // Also test calling an RPC that uses request-scoped RBAC identity.
    const { data: orgs, error: orgsError } = await supabaseWithHashedKey
      .rpc('get_orgs_v7')

    expect(orgsError).toBeNull()
    expect(Array.isArray(orgs)).toBe(true)

    // Cleanup
    await fetch(`${BASE_URL}/apikey/${createData.id}`, {
      method: 'DELETE',
      headers: authHeaders,
    })
  })

  it('plain key still works with RLS via Supabase SDK', async () => {
    // Create a plain (non-hashed) key via API
    const createResponse = await postApiKey({
      name: 'plain-key-rls-test',
      hashed: false,
      bindings: orgApiKeyBindings(),
    }, authHeaders)
    const createData = await createResponse.json<{ key: string, id: number }>()
    expect(createResponse.status).toBe(200)

    // Use plain key with Supabase SDK
    const supabaseWithPlainKey = createClient(
      process.env.SUPABASE_URL!,
      process.env.SUPABASE_ANON_KEY!,
      {
        global: {
          headers: {
            capgkey: createData.key,
          },
        },
      },
    )

    // Try to query apps table
    const { data: apps, error: appsError } = await supabaseWithPlainKey
      .from('apps')
      .select('app_id, name')
      .limit(5)

    expect(appsError).toBeNull()
    expect(Array.isArray(apps)).toBe(true)

    // Cleanup
    await fetch(`${BASE_URL}/apikey/${createData.id}`, {
      method: 'DELETE',
      headers: authHeaders,
    })
  })

  it.concurrent('plain key cannot update apikeys table directly through RLS', async () => {
    let createdKeyId: number | undefined

    try {
      const createResponse = await postApiKey({
        name: 'plain-key-rls-apikey-update-blocked',
        hashed: false,
        bindings: orgApiKeyBindings(),
      })
      const createData = await createResponse.json<{ key: string, id: number }>()
      expect(createResponse.status).toBe(200)
      createdKeyId = createData.id

      const supabaseWithPlainKey = createClient(
        process.env.SUPABASE_URL!,
        process.env.SUPABASE_ANON_KEY!,
        {
          global: {
            headers: {
              capgkey: createData.key,
            },
          },
        },
      )

      const { data: updateData, error: updateError } = await supabaseWithPlainKey
        .from('apikeys')
        .update({ name: 'plain-key-rls-update-should-not-stick' })
        .eq('id', createData.id)
        .select('id, name')

      if (updateError) {
        expect(updateError.message).toMatch(/permission denied|row-level|not allowed|denied/i)
      }
      else {
        expect(updateData).toEqual([])
      }

      const verifyResponse = await fetch(`${BASE_URL}/apikey/${createData.id}`, { headers: authHeaders })
      expect(verifyResponse.status).toBe(200)
      const verifyData = await verifyResponse.json<{ name: string }>()
      expect(verifyData.name).toBe('plain-key-rls-apikey-update-blocked')
    }
    finally {
      if (createdKeyId !== undefined) {
        await fetch(`${BASE_URL}/apikey/${createdKeyId}`, {
          method: 'DELETE',
          headers: authHeaders,
        })
      }
    }
  })
})