tests/apikeys.test.ts
Rama main · réplica de lectura del repositorio original.
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,
})
}
}
})
})