tests/apikeys-expiration.test.ts
Rama main · réplica de lectura del repositorio original.
import type { Database } from '../src/types/database.types'
import { randomUUID } from 'node:crypto'
import { env } from 'node:process'
import { createClient } from '@supabase/supabase-js'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { appApiKeyBindings, BASE_URL, createDirectApiKeyWithBindings, executeSQL, fetchTestRequest, getAuthHeadersForCredentials, getSupabaseClient, normalizeLocalhostUrl, orgApiKeyBindings, resetAndSeedAppData, resetAppData, TEST_EMAIL, USER_EMAIL_APIKEY_EXPIRATION, USER_ID_APIKEY_EXPIRATION, USER_PASSWORD, warmEdgeEndpoint } from './test-utils.ts'
const id = randomUUID()
const BASE_ORG_ID = randomUUID()
const BASE_ORG_CUSTOMER_ID = `cus_test_expiration_base_${id}`
const POLICY_APPNAME = `com.app.expiration.policy.${id}`
// Orgs for testing expiration policies
const BASE_ORG_NAME = `Test Expiration Base Org ${id}`
const POLICY_ORG_ID = randomUUID()
const POLICY_ORG_CUSTOMER_ID = `cus_test_policy_${id}`
const POLICY_ORG_NAME = `Test Policy Org ${id}`
let authHeaders: Record<string, string>
let policyAppUuid: string
function keyName(name: string): string {
return `${name}-${id}`
}
function orgKeyBody(name: string, orgId = BASE_ORG_ID, extra: Record<string, unknown> = {}) {
return {
name: keyName(name),
bindings: orgApiKeyBindings(orgId),
...extra,
}
}
async function appKeyBody(name: string, appId = POLICY_APPNAME, extra: Record<string, unknown> = {}) {
return {
name: keyName(name),
bindings: await appApiKeyBindings(appId),
...extra,
}
}
async function seedPlainApiKey(name: string, expiresAt: string | null, orgId = BASE_ORG_ID) {
const key = randomUUID()
const data = await createDirectApiKeyWithBindings({
userId: USER_ID_APIKEY_EXPIRATION,
key,
name: keyName(name),
orgId,
roleName: 'org_admin',
expiresAt,
})
if (!data.key)
throw new Error(`Failed to seed API key ${name}`)
const capgo = getSupabaseClient()
const { data: memberships, error: membershipsError } = await capgo
.from('org_users')
.select('org_id')
.eq('user_id', USER_ID_APIKEY_EXPIRATION)
if (membershipsError)
throw membershipsError
const extraOrgIds = [...new Set((memberships ?? []).map(row => row.org_id).filter((membershipOrgId): membershipOrgId is string => !!membershipOrgId && membershipOrgId !== orgId))]
if (extraOrgIds.length > 0) {
const { data: orgPolicies, error: orgPoliciesError } = await capgo
.from('orgs')
.select('id, require_apikey_expiration, max_apikey_expiration_days')
.in('id', extraOrgIds)
if (orgPoliciesError)
throw orgPoliciesError
const expiresAtTime = expiresAt ? new Date(expiresAt).getTime() : null
const compatibleExtraOrgIds = (orgPolicies ?? [])
.filter((org) => {
if (org.require_apikey_expiration && expiresAt === null)
return false
if (org.max_apikey_expiration_days !== null && expiresAtTime !== null) {
return expiresAtTime <= Date.now() + org.max_apikey_expiration_days * 24 * 60 * 60 * 1000
}
return true
})
.map(org => org.id)
if (compatibleExtraOrgIds.length === 0)
return { id: data.id, key: data.key, expires_at: data.expires_at }
const { data: role, error: roleError } = await capgo
.from('roles')
.select('id')
.eq('name', 'org_admin')
.single()
if (roleError || !role)
throw roleError ?? new Error('Unable to resolve org_admin role')
const { error: bindingError } = await capgo.from('role_bindings').insert(compatibleExtraOrgIds.map(extraOrgId => ({
principal_type: 'apikey' as const,
principal_id: data.rbac_id,
role_id: role.id,
scope_type: 'org' as const,
org_id: extraOrgId,
granted_by: USER_ID_APIKEY_EXPIRATION,
reason: 'Expiration test full-org key binding',
is_direct: true,
})))
if (bindingError)
throw bindingError
}
return { id: data.id, key: data.key, expires_at: data.expires_at }
}
async function deleteSeededApiKeys(ids: number[]) {
if (ids.length === 0)
return
await getSupabaseClient()
.from('apikeys')
.delete()
.in('id', ids)
}
function apiFetch(path: string, init?: RequestInit) {
return fetchTestRequest(`${BASE_URL}${path}`, init)
}
function createAuthenticatedSupabaseClient(headers: Record<string, string>) {
const supabaseUrl = normalizeLocalhostUrl(env.SUPABASE_URL)
const supabaseAnonKey = env.SUPABASE_ANON_KEY
const authorization = headers.Authorization ?? headers.authorization
if (!supabaseUrl || !supabaseAnonKey || !authorization) {
throw new Error('Missing Supabase auth environment for authenticated client')
}
return createClient<Database>(supabaseUrl, supabaseAnonKey, {
global: {
headers: {
Authorization: authorization,
},
},
auth: {
persistSession: false,
},
})
}
function createApiKeySupabaseClient(apikey: string) {
const supabaseUrl = normalizeLocalhostUrl(env.SUPABASE_URL)
const supabaseAnonKey = env.SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Missing Supabase auth environment for API key client')
}
return createClient<Database>(supabaseUrl, supabaseAnonKey, {
global: {
headers: {
capgkey: apikey,
},
},
auth: {
persistSession: false,
},
})
}
async function expectApiKeyCanReadBaseOrg(apikey: string) {
const capgo = createApiKeySupabaseClient(apikey)
const { data, error } = await capgo
.from('orgs')
.select('id')
.eq('id', BASE_ORG_ID)
.single()
expect(error).toBeNull()
expect(data?.id).toBe(BASE_ORG_ID)
}
async function expectApiKeyCannotReadBaseOrg(apikey: string) {
const capgo = createApiKeySupabaseClient(apikey)
const { data, error } = await capgo
.from('orgs')
.select('id')
.eq('id', BASE_ORG_ID)
expect(error).toBeNull()
expect(data).toEqual([])
}
beforeAll(async () => {
authHeaders = await getAuthHeadersForCredentials(USER_EMAIL_APIKEY_EXPIRATION, USER_PASSWORD)
const { error: baseStripeError } = await getSupabaseClient().from('stripe_info').insert({
customer_id: BASE_ORG_CUSTOMER_ID,
status: 'succeeded',
product_id: 'prod_LQIregjtNduh4q',
subscription_id: `sub_base_${id}`,
trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(),
is_good_plan: true,
})
if (baseStripeError)
throw baseStripeError
const { error: baseOrgError } = await getSupabaseClient().from('orgs').insert({
id: BASE_ORG_ID,
name: BASE_ORG_NAME,
management_email: TEST_EMAIL,
created_by: USER_ID_APIKEY_EXPIRATION,
customer_id: BASE_ORG_CUSTOMER_ID,
})
if (baseOrgError)
throw baseOrgError
const { error: baseMemberError } = await getSupabaseClient().from('org_users').insert({
org_id: BASE_ORG_ID,
user_id: USER_ID_APIKEY_EXPIRATION,
rbac_role_name: 'org_super_admin',
})
if (baseMemberError)
throw baseMemberError
// Create a test org with specific expiration policies
const { error: stripeError } = await getSupabaseClient().from('stripe_info').insert({
customer_id: POLICY_ORG_CUSTOMER_ID,
status: 'succeeded',
product_id: 'prod_LQIregjtNduh4q',
subscription_id: `sub_${id}`,
trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(),
is_good_plan: true,
})
if (stripeError)
throw stripeError
const { error: orgError } = await getSupabaseClient().from('orgs').insert({
id: POLICY_ORG_ID,
name: POLICY_ORG_NAME,
management_email: TEST_EMAIL,
created_by: USER_ID_APIKEY_EXPIRATION,
customer_id: POLICY_ORG_CUSTOMER_ID,
require_apikey_expiration: true,
max_apikey_expiration_days: 30,
})
if (orgError)
throw orgError
const { error: policyMemberError } = await getSupabaseClient().from('org_users').insert({
org_id: POLICY_ORG_ID,
user_id: USER_ID_APIKEY_EXPIRATION,
rbac_role_name: 'org_super_admin',
})
if (policyMemberError)
throw policyMemberError
await resetAndSeedAppData(POLICY_APPNAME, {
orgId: POLICY_ORG_ID,
stripeCustomerId: POLICY_ORG_CUSTOMER_ID,
userId: USER_ID_APIKEY_EXPIRATION,
})
const { data: policyApp, error: policyAppError } = await getSupabaseClient()
.from('apps')
.select('id')
.eq('app_id', POLICY_APPNAME)
.single()
if (policyAppError || !policyApp?.id)
throw policyAppError ?? new Error('Missing policy app')
policyAppUuid = policyApp.id
// Load the apikey isolate before concurrent POSTs from this file.
await warmEdgeEndpoint('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({ name: keyName('warm-apikey-isolate') }),
})
})
afterAll(async () => {
await resetAppData(POLICY_APPNAME)
await getSupabaseClient().from('org_users').delete().eq('org_id', POLICY_ORG_ID)
await getSupabaseClient().from('orgs').delete().eq('id', POLICY_ORG_ID)
await getSupabaseClient().from('stripe_info').delete().eq('customer_id', POLICY_ORG_CUSTOMER_ID)
await getSupabaseClient().from('org_users').delete().eq('org_id', BASE_ORG_ID)
await getSupabaseClient().from('orgs').delete().eq('id', BASE_ORG_ID)
await getSupabaseClient().from('stripe_info').delete().eq('customer_id', BASE_ORG_CUSTOMER_ID)
})
describe('[POST] /apikey with expiration', () => {
it('create api key with valid expiration date', async () => {
const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() // 7 days from now
const response = await apiFetch('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(orgKeyBody('key-with-expiration', BASE_ORG_ID, { expires_at: futureDate })),
})
const data = await response.json<{ key: string, id: number, expires_at: string }>()
expect(response.status).toBe(200)
expect(data).toHaveProperty('key')
expect(data).toHaveProperty('id')
expect(data).toHaveProperty('expires_at')
expect(new Date(data.expires_at).getTime()).toBeCloseTo(new Date(futureDate).getTime(), -3)
})
it('create api key without expiration (null)', async () => {
const response = await apiFetch('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(orgKeyBody('key-no-expiration')),
})
const data = await response.json<{ key: string, id: number, expires_at: string | null }>()
expect(response.status).toBe(200)
expect(data).toHaveProperty('key')
expect(data.expires_at).toBeNull()
})
it('fail to create api key with past expiration date', async () => {
const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() // 1 day ago
const response = await apiFetch('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(orgKeyBody('key-past-expiration', BASE_ORG_ID, { expires_at: pastDate })),
})
const data = await response.json() as { error: string }
expect(response.status).toBe(400)
expect(data.error).toBe('invalid_expiration_date')
})
it('fail to create api key with invalid expiration date format', async () => {
const response = await apiFetch('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(orgKeyBody('key-invalid-date', BASE_ORG_ID, { expires_at: 'not-a-date' })),
})
const data = await response.json() as { error: string }
expect(response.status).toBe(400)
expect(data.error).toBe('invalid_expiration_date')
})
})
describe('[PUT] /apikey/:id with expiration', () => {
let testKeyId: number
beforeAll(async () => {
// Create a key to update
const response = await apiFetch('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(orgKeyBody('key-for-update-expiration')),
})
expect(response.status).toBe(200)
const data = await response.json<{ id: number }>()
testKeyId = data.id
})
it('update api key to add expiration date', async () => {
const futureDate = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString() // 14 days from now
const response = await apiFetch(`/apikey/${testKeyId}`, {
method: 'PUT',
headers: authHeaders,
body: JSON.stringify({
expires_at: futureDate,
}),
})
const data = await response.json<{ id: number, expires_at: string }>()
expect(response.status).toBe(200)
expect(data.id).toBe(testKeyId)
expect(new Date(data.expires_at).getTime()).toBeCloseTo(new Date(futureDate).getTime(), -3)
})
it('update api key to change expiration date', async () => {
const newFutureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() // 30 days from now
const response = await apiFetch(`/apikey/${testKeyId}`, {
method: 'PUT',
headers: authHeaders,
body: JSON.stringify({
expires_at: newFutureDate,
}),
})
const data = await response.json<{ id: number, expires_at: string }>()
expect(response.status).toBe(200)
expect(new Date(data.expires_at).getTime()).toBeCloseTo(new Date(newFutureDate).getTime(), -3)
})
it('update api key to remove expiration (set to null)', async () => {
const response = await apiFetch(`/apikey/${testKeyId}`, {
method: 'PUT',
headers: authHeaders,
body: JSON.stringify({
expires_at: null,
}),
})
const data = await response.json<{ id: number, expires_at: string | null }>()
expect(response.status).toBe(200)
expect(data.expires_at).toBeNull()
})
it('fail to update api key with past expiration date', async () => {
const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
const response = await apiFetch(`/apikey/${testKeyId}`, {
method: 'PUT',
headers: authHeaders,
body: JSON.stringify({
expires_at: pastDate,
}),
})
const data = await response.json() as { error: string }
expect(response.status).toBe(400)
expect(data.error).toBe('invalid_expiration_date')
})
})
describe('[GET] /apikey with expiration info', () => {
let keyWithExpiration: { id: number, key: string }
let keyWithoutExpiration: { id: number, key: string }
beforeAll(async () => {
// Create key with expiration
const response1 = await apiFetch('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(orgKeyBody('key-with-exp-get-test', BASE_ORG_ID, { expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() })),
})
expect(response1.status).toBe(200)
keyWithExpiration = await response1.json()
// Create key without expiration
const response2 = await apiFetch('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(orgKeyBody('key-without-exp-get-test')),
})
expect(response2.status).toBe(200)
keyWithoutExpiration = await response2.json()
})
it('get api key includes expires_at field when set', async () => {
const response = await apiFetch(`/apikey/${keyWithExpiration.id}`, {
method: 'GET',
headers: authHeaders,
})
const data = await response.json<{ id: number, expires_at: string | null }>()
expect(response.status).toBe(200)
expect(data).toHaveProperty('expires_at')
expect(data.expires_at).not.toBeNull()
})
it('get api key includes expires_at as null when not set', async () => {
const response = await apiFetch(`/apikey/${keyWithoutExpiration.id}`, {
method: 'GET',
headers: authHeaders,
})
const data = await response.json<{ id: number, expires_at: string | null }>()
expect(response.status).toBe(200)
expect(data).toHaveProperty('expires_at')
expect(data.expires_at).toBeNull()
})
it('list all api keys includes expires_at field', async () => {
const response = await apiFetch('/apikey', {
method: 'GET',
headers: authHeaders,
})
const data = await response.json() as Array<{ id: number, expires_at: string | null }>
expect(response.status).toBe(200)
expect(Array.isArray(data)).toBe(true)
// Find our test keys
const withExp = data.find(k => k.id === keyWithExpiration.id)
const withoutExp = data.find(k => k.id === keyWithoutExpiration.id)
expect(withExp).toBeDefined()
expect(withExp?.expires_at).not.toBeNull()
expect(withoutExp).toBeDefined()
expect(withoutExp?.expires_at).toBeNull()
})
})
describe('organization API key expiration policy', () => {
it('fail to create api key without expiration for org requiring expiration', async () => {
const response = await apiFetch('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(orgKeyBody('key-policy-test', POLICY_ORG_ID)),
})
const data = await response.json() as { error: string }
expect(response.status).toBe(400)
expect(data.error).toBe('expiration_required')
})
it('fail to create api key with expiration exceeding org max days', async () => {
// Org has max 30 days, try to create with 60 days
const tooFarDate = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString()
const response = await apiFetch('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(orgKeyBody('key-policy-exceeds-max', POLICY_ORG_ID, { expires_at: tooFarDate })),
})
const data = await response.json() as { error: string }
expect(response.status).toBe(400)
expect(data.error).toBe('expiration_exceeds_max')
})
it('create api key with valid expiration for org with policy', async () => {
// Org has max 30 days, create with 15 days
const validDate = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString()
const response = await apiFetch('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(orgKeyBody('key-policy-valid', POLICY_ORG_ID, { expires_at: validDate })),
})
const data = await response.json<{ key: string, id: number, expires_at: string }>()
expect(response.status).toBe(200)
expect(data).toHaveProperty('key')
expect(data.expires_at).not.toBeNull()
})
it('create api key without expiration for org without policy', async () => {
// Use this suite's dedicated org which does not enforce expiration policy
const response = await apiFetch('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(orgKeyBody('key-no-policy-org')),
})
const data = await response.json<{ key: string, id: number, expires_at: string | null }>()
expect(response.status).toBe(200)
expect(data).toHaveProperty('key')
// Should succeed even without expiration since org doesn't require it
})
it('fail to create app-scoped api key without expiration for org requiring expiration', async () => {
const response = await apiFetch('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({
name: keyName('key-policy-app-scope'),
bindings: [{
role_name: 'app_admin',
scope_type: 'app',
org_id: POLICY_ORG_ID,
app_id: policyAppUuid,
}],
}),
})
const data = await response.json() as { error: string }
expect(response.status).toBe(400)
expect(data.error).toBe('expiration_required')
})
it('fail to create app-scoped api key with expiration exceeding org max days', async () => {
const tooFarDate = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString()
const response = await apiFetch('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(await appKeyBody('key-policy-app-scope-too-far', POLICY_APPNAME, { expires_at: tooFarDate })),
})
const data = await response.json() as { error: string }
expect(response.status).toBe(400)
expect(data.error).toBe('expiration_exceeds_max')
})
it('fail to create app-scoped api key for unknown app ids', async () => {
const response = await apiFetch('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({
name: keyName('key-policy-app-scope-missing-app'),
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
bindings: [{
role_name: 'app_admin',
scope_type: 'app',
org_id: POLICY_ORG_ID,
app_id: randomUUID(),
}],
}),
})
const data = await response.json() as { error: string }
expect(response.status).toBe(404)
expect(data.error).toBe('binding_failed')
})
it('reject unsupported api key scope updates', async () => {
const createResponse = await apiFetch('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(orgKeyBody('key-policy-app-scope-update')),
})
expect(createResponse.status).toBe(200)
const createdKey = await createResponse.json<{ id: number }>()
const updateResponse = await apiFetch(`/apikey/${createdKey.id}`, {
method: 'PUT',
headers: authHeaders,
body: JSON.stringify({
unsupported_app_scope: [POLICY_APPNAME],
}),
})
const data = await updateResponse.json() as { error: string }
expect(updateResponse.status).toBe(400)
expect(data.error).toContain('no_valid_fields_provided_for_update')
})
it('reject app-scoped api keys from updating sibling keys', async () => {
const validDate = new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString()
const limitedKeyResponse = await apiFetch('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(await appKeyBody('key-policy-app-updater', POLICY_APPNAME, { expires_at: validDate })),
})
expect(limitedKeyResponse.status).toBe(200)
const limitedKey = await limitedKeyResponse.json<{ key: string }>()
const siblingKeyResponse = await apiFetch('/apikey', {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(orgKeyBody('key-policy-sibling-target')),
})
expect(siblingKeyResponse.status).toBe(200)
const siblingKey = await siblingKeyResponse.json<{ id: number }>()
const limitedAuthHeaders = {
'Content-Type': 'application/json',
'Authorization': limitedKey.key,
}
const updateResponse = await apiFetch(`/apikey/${siblingKey.id}`, {
method: 'PUT',
headers: limitedAuthHeaders,
body: JSON.stringify({
name: keyName('key-policy-escalation-attempt'),
}),
})
const data = await updateResponse.json() as { error: string }
expect(updateResponse.status).toBe(401)
expect(data.error).toBe('cannot_update_apikey')
})
it('reject direct apikey inserts that bypass the server creation path', async () => {
const capgo = createAuthenticatedSupabaseClient(authHeaders)
const { data, error } = await capgo
.from('apikeys')
.insert({
user_id: USER_ID_APIKEY_EXPIRATION,
key: null,
key_hash: '0'.repeat(64),
name: keyName('direct-insert-policy-bypass'),
expires_at: null,
})
.select()
.single()
expect(data).toBeNull()
expect(error?.message).toContain('row-level security policy')
})
})
describe('[PUT] /organization with API key policy', () => {
const updateOrgId = randomUUID()
const updateOrgCustomerId = `cus_test_update_${id}`
beforeAll(async () => {
// Create a test org for updating
const { error: stripeError } = await getSupabaseClient().from('stripe_info').insert({
customer_id: updateOrgCustomerId,
status: 'succeeded',
product_id: 'prod_LQIregjtNduh4q',
subscription_id: `sub_update_${id}`,
trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(),
is_good_plan: true,
})
if (stripeError)
throw stripeError
const { error: orgError } = await getSupabaseClient().from('orgs').insert({
id: updateOrgId,
name: `Test Update Org ${id}`,
management_email: TEST_EMAIL,
created_by: USER_ID_APIKEY_EXPIRATION,
customer_id: updateOrgCustomerId,
})
if (orgError)
throw orgError
// Add user as super_admin to be able to update the org
const { error: memberError } = await getSupabaseClient().from('org_users').insert({
org_id: updateOrgId,
user_id: USER_ID_APIKEY_EXPIRATION,
rbac_role_name: 'org_super_admin',
})
if (memberError)
throw memberError
})
afterAll(async () => {
await getSupabaseClient().from('org_users').delete().eq('org_id', updateOrgId)
await getSupabaseClient().from('orgs').delete().eq('id', updateOrgId)
await getSupabaseClient().from('stripe_info').delete().eq('customer_id', updateOrgCustomerId)
})
it('update organization to set max API key expiration days', async () => {
const response = await apiFetch('/organization', {
method: 'PUT',
headers: authHeaders,
body: JSON.stringify({
orgId: updateOrgId,
max_apikey_expiration_days: 90,
}),
})
expect(response.status).toBe(200)
// Verify the update
const { data, error } = await getSupabaseClient().from('orgs').select('max_apikey_expiration_days').eq('id', updateOrgId).single()
expect(error).toBeNull()
expect(data?.max_apikey_expiration_days).toBe(90)
})
it('update organization to remove max expiration (set to null)', async () => {
const response = await apiFetch('/organization', {
method: 'PUT',
headers: authHeaders,
body: JSON.stringify({
orgId: updateOrgId,
max_apikey_expiration_days: null,
}),
})
expect(response.status, await response.text()).toBe(200)
// Verify the update
const { data, error } = await getSupabaseClient().from('orgs').select('max_apikey_expiration_days').eq('id', updateOrgId).single()
expect(error).toBeNull()
expect(data?.max_apikey_expiration_days).toBeNull()
})
it('fail to set invalid max expiration days (negative)', async () => {
const response = await apiFetch('/organization', {
method: 'PUT',
headers: authHeaders,
body: JSON.stringify({
orgId: updateOrgId,
max_apikey_expiration_days: -1,
}),
})
expect(response.status).toBe(400)
})
it('fail to set invalid max expiration days (too large)', async () => {
const response = await apiFetch('/organization', {
method: 'PUT',
headers: authHeaders,
body: JSON.stringify({
orgId: updateOrgId,
max_apikey_expiration_days: 500,
}),
})
expect(response.status).toBe(400)
})
it('rejects direct Supabase writes with invalid max expiration days', async () => {
const capgo = createAuthenticatedSupabaseClient(authHeaders)
const { error } = await capgo
.from('orgs')
.update({ max_apikey_expiration_days: -1 })
.eq('id', updateOrgId)
expect(error).not.toBeNull()
expect(error?.code).toBe('23514')
})
// This test must be last because it enables require_apikey_expiration,
// which would block subsequent tests using a non-expiring API key
it('update organization to require API key expiration', async () => {
const response = await apiFetch('/organization', {
method: 'PUT',
headers: authHeaders,
body: JSON.stringify({
orgId: updateOrgId,
require_apikey_expiration: true,
}),
})
expect(response.status).toBe(200)
// Verify the update
const { data, error } = await getSupabaseClient().from('orgs').select('require_apikey_expiration').eq('id', updateOrgId).single()
expect(error).toBeNull()
expect(data?.require_apikey_expiration).toBe(true)
})
})
describe('api key expiration boundary conditions', () => {
const DAY_MS = 24 * 60 * 60 * 1000
it.concurrent.each([
['expired a day ago', -DAY_MS, false],
['expired a second ago', -1000, false],
['expiring in 7 days', 7 * DAY_MS, true],
['expiring in 30 seconds', 30_000, true],
])('api key %s authenticates only when not expired', async (label, offsetMs, canRead) => {
const data = await seedPlainApiKey(`key-${label.replaceAll(' ', '-')}`, new Date(Date.now() + offsetMs).toISOString())
try {
if (canRead)
await expectApiKeyCanReadBaseOrg(data.key)
else
await expectApiKeyCannotReadBaseOrg(data.key)
}
finally {
await deleteSeededApiKeys([data.id])
}
})
it.concurrent('api key with null expiration should not be expired', async () => {
const data = await seedPlainApiKey('key-no-expiration-test', null, BASE_ORG_ID)
try {
expect(data.expires_at).toBeNull()
const [{ expired }] = await executeSQL(
'SELECT public.is_apikey_expired($1::timestamptz) AS expired',
[data.expires_at],
)
expect(expired).toBe(false)
}
finally {
await deleteSeededApiKeys([data.id])
}
})
})