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 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 = {}) { return { name, bindings: orgApiKeyBindings(), ...extra, } } async function appKeyBody(name: string, appId = APPNAME, extra: Record = {}) { return { name, bindings: await appApiKeyBindings(appId), ...extra, } } async function warmEdgeEndpointWithDeadline( path: string, options: RequestInit, deadlineMs: number, ): Promise { 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( deadlineMs: number, run: (signal: AbortSignal) => Promise, ): Promise { 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, deadlineMs: number, ): Promise { 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, deadlineMs: number, existingIds: ReadonlySet, ): Promise { 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 = Promise.resolve() async function postApiKey( body: unknown, headers: Record = 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 | 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> 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 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 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>() 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>() 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 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 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 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 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 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, }) } } }) })