tests/build-credentials.test.ts
Rama main · réplica de lectura del repositorio original.
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { BASE_URL, createDirectApiKeyWithBindings, getSupabaseClient, resetAndSeedAppData, resetAppData, USER_ID_2 } from './test-utils.ts'
// Dedicated app/org per run: these tests write stored credentials for their own app only.
describe('/build/credentials stored native build credentials', () => {
const id = randomUUID()
const appId = `com.test.buildcreds.${id}`
const orgId = randomUUID()
const userId = USER_ID_2
const stripeCustomerId = `cus_buildcreds_${id.replaceAll('-', '').slice(0, 16)}`
const certSecret = `cert-secret-${id}`
const keystoreSecret = `keystore-secret-${id}`
const provisioningMap = JSON.stringify({ [appId]: { profile: Buffer.from('profile').toString('base64'), name: 'Test Profile' } })
let adminKey = ''
let developerKey = ''
let apikeyIds: number[] = []
function headers(key: string) {
return { 'Authorization': key, 'Content-Type': 'application/json' }
}
async function putCredentials(key: string, body: Record<string, unknown>) {
return fetch(`${BASE_URL}/build/credentials`, { method: 'PUT', headers: headers(key), body: JSON.stringify(body) })
}
beforeAll(async () => {
await resetAndSeedAppData(appId, { orgId, userId, stripeCustomerId })
const admin = await createDirectApiKeyWithBindings({
userId,
key: randomUUID(),
name: `test-build-credentials-admin-${id}`,
orgId,
roleName: 'org_member',
appId,
appRoleName: 'app_admin',
})
const developer = await createDirectApiKeyWithBindings({
userId,
key: randomUUID(),
name: `test-build-credentials-developer-${id}`,
orgId,
roleName: 'org_member',
appId,
appRoleName: 'app_developer',
})
if (!admin?.key || !developer?.key)
throw new Error('Seeded API keys missing generated key values')
adminKey = admin.key
developerKey = developer.key
apikeyIds = [admin.id, developer.id]
})
afterAll(async () => {
const supabase = getSupabaseClient()
await supabase.from('app_build_credentials').delete().eq('app_id', appId)
await supabase.from('build_credentials_upload_token_uses').delete().eq('app_id', appId)
if (apikeyIds.length)
await supabase.from('apikeys').delete().in('id', apikeyIds)
await resetAppData(appId)
})
it('stores, summarizes, merges, uploads by signed URL and deletes credentials', async () => {
// Empty state is readable with app.build_native.
let response = await fetch(`${BASE_URL}/build/credentials?app_id=${encodeURIComponent(appId)}`, { headers: headers(developerKey) })
expect(response.status).toBe(200)
let status = await response.json() as { platforms: Array<{ platform: string, configured: boolean, summary?: Record<string, unknown> }> }
expect(status.platforms).toEqual([{ platform: 'ios', configured: false }, { platform: 'android', configured: false }])
// Writing needs app.update_settings: app_developer is denied.
response = await putCredentials(developerKey, { app_id: appId, platform: 'ios', credentials: { BUILD_CERTIFICATE_BASE64: certSecret } })
expect(response.status).toBe(403)
expect(await response.json()).toHaveProperty('error', 'permission_denied')
// Invalid key names are rejected with the --build-env message.
response = await putCredentials(adminKey, { app_id: appId, platform: 'ios', credentials: { JOB_ID: 'x' } })
expect(response.status).toBe(400)
expect(await response.json()).toMatchObject({ error: 'invalid_build_credentials', message: '--build-env JOB_ID: reserved by Capgo; use the dedicated build option instead' })
// Loader/interpreter env names would run in every later build: never stored.
response = await putCredentials(adminKey, { app_id: appId, platform: 'ios', credentials: { NODE_OPTIONS: '--require /tmp/x.js', DYLD_INSERT_LIBRARIES: '/tmp/x.dylib', SENTRY_AUTH_TOKEN: 'ok' } })
expect(response.status).toBe(400)
expect(await response.json()).toMatchObject({ error: 'invalid_build_credentials', message: expect.stringContaining('DYLD_INSERT_LIBRARIES, NODE_OPTIONS: cannot be stored on Capgo') })
// Partial iOS set is stored; summary reports what is still missing, never values.
response = await putCredentials(adminKey, {
app_id: appId,
platform: 'ios',
credentials: { BUILD_CERTIFICATE_BASE64: certSecret, P12_PASSWORD: 'p12', CAPGO_IOS_PROVISIONING_MAP: provisioningMap, CAPGO_IOS_DISTRIBUTION: 'ad_hoc' },
})
expect(response.status).toBe(200)
let text = await response.text()
expect(text).not.toContain(certSecret)
let write = JSON.parse(text) as { summary: { keys: string[], missing: string[], ios_distribution: string } }
expect(write.summary.ios_distribution).toBe('ad_hoc')
expect(write.summary.missing).toEqual(['APP_STORE_CONNECT_TEAM_ID (or --app-store-connect-team-id)'])
// Merge adds the team id while keeping the stored certificate.
response = await putCredentials(adminKey, { app_id: appId, platform: 'ios', mode: 'merge', credentials: { APP_STORE_CONNECT_TEAM_ID: 'TEAM123' } })
expect(response.status).toBe(200)
write = await response.json() as typeof write
expect(write.summary.keys).toEqual(expect.arrayContaining(['BUILD_CERTIFICATE_BASE64', 'APP_STORE_CONNECT_TEAM_ID']))
expect(write.summary.missing).toEqual([])
// The database row only holds ciphertext.
const { data: row } = await getSupabaseClient().from('app_build_credentials').select('ciphertext, key_version, owner_org').eq('app_id', appId).eq('platform', 'ios').single()
expect(row?.owner_org).toBe(orgId)
expect(row?.ciphertext).toMatch(/^v1:/)
expect(row?.ciphertext).not.toContain(certSecret)
// Summary GET for a developer, still no secrets.
response = await fetch(`${BASE_URL}/build/credentials?app_id=${encodeURIComponent(appId)}`, { headers: headers(developerKey) })
text = await response.text()
expect(text).not.toContain(certSecret)
status = JSON.parse(text)
expect(status.platforms[0]).toMatchObject({ platform: 'ios', configured: true })
// Signed upload URL: token-only PUT of the whole CLI credentials file shape.
response = await fetch(`${BASE_URL}/build/credentials/upload-url`, { method: 'POST', headers: headers(adminKey), body: JSON.stringify({ app_id: appId, platform: 'android' }) })
expect(response.status).toBe(200)
const upload = await response.json() as { upload_url: string, method: string, expires_at: string }
expect(upload.method).toBe('PUT')
const token = upload.upload_url.split('/build/credentials/upload/')[1]!
response = await fetch(`${BASE_URL}/build/credentials/upload/${token}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
[appId]: { android: { ANDROID_KEYSTORE_FILE: keystoreSecret, KEYSTORE_KEY_ALIAS: 'alias', KEYSTORE_STORE_PASSWORD: 'pw', PLAY_CONFIG_JSON: 'play' } },
'com.unrelated.app': { android: { ANDROID_KEYSTORE_FILE: 'ignored' } },
}),
})
expect(response.status).toBe(200)
text = await response.text()
expect(text).not.toContain(keystoreSecret)
expect(JSON.parse(text)).toMatchObject({ platform: 'android', summary: { missing: [], play_store_upload: true } })
// Single use: replaying the same URL is refused, even with a valid body.
response = await fetch(`${BASE_URL}/build/credentials/upload/${token}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ android: { ANDROID_KEYSTORE_FILE: 'replayed', KEYSTORE_KEY_ALIAS: 'alias', KEYSTORE_STORE_PASSWORD: 'pw' } }),
})
expect(response.status).toBe(409)
expect(await response.json()).toHaveProperty('error', 'upload_url_used')
// A rejected body does not burn the URL: the fixed file can be re-sent.
response = await fetch(`${BASE_URL}/build/credentials/upload-url`, { method: 'POST', headers: headers(adminKey), body: JSON.stringify({ app_id: appId, platform: 'android', mode: 'merge' }) })
const retryToken = (await response.json() as { upload_url: string }).upload_url.split('/build/credentials/upload/')[1]!
response = await fetch(`${BASE_URL}/build/credentials/upload/${retryToken}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ LD_PRELOAD: '/tmp/x.so' }) })
expect(response.status).toBe(400)
expect(await response.json()).toMatchObject({ error: 'invalid_build_credentials', message: expect.stringContaining('LD_PRELOAD: cannot be stored on Capgo') })
response = await fetch(`${BASE_URL}/build/credentials/upload/${retryToken}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ PLAY_STORE_TRACK: 'beta' }) })
expect(response.status).toBe(200)
response = await fetch(`${BASE_URL}/build/credentials/upload/${token.slice(0, -2)}xx`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' })
expect(response.status).toBe(401)
// Audit rows carry summaries only.
const { data: audits } = await getSupabaseClient().from('audit_logs').select('operation, new_record').eq('table_name', 'app_build_credentials').eq('org_id', orgId)
expect(audits?.length).toBeGreaterThanOrEqual(3)
expect(JSON.stringify(audits)).not.toContain(certSecret)
expect(JSON.stringify(audits)).not.toContain(keystoreSecret)
// Delete is admin-only and 404s once gone.
response = await fetch(`${BASE_URL}/build/credentials`, { method: 'DELETE', headers: headers(developerKey), body: JSON.stringify({ app_id: appId, platform: 'android' }) })
expect(response.status).toBe(403)
response = await fetch(`${BASE_URL}/build/credentials`, { method: 'DELETE', headers: headers(adminKey), body: JSON.stringify({ app_id: appId, platform: 'android' }) })
expect(response.status).toBe(200)
response = await fetch(`${BASE_URL}/build/credentials`, { method: 'DELETE', headers: headers(adminKey), body: JSON.stringify({ app_id: appId, platform: 'android' }) })
expect(response.status).toBe(404)
})
it('POST /build/request with use_stored_credentials requires app admin rights', async () => {
// app_developer has app.build_native but not app.update_settings: build scripts in the
// uploaded project could read the stored signing secrets, so the request is refused.
const response = await fetch(`${BASE_URL}/build/request`, {
method: 'POST',
headers: headers(developerKey),
body: JSON.stringify({ app_id: appId, platform: 'ios', use_stored_credentials: true, build_options: {}, build_credentials: {} }),
})
expect(response.status).toBe(403)
const body = await response.json() as { error: string, message: string }
expect(body.error).toBe('permission_denied')
expect(body.message).toContain('requires app admin rights (app.update_settings)')
expect(body.message).toContain('Ask an app admin to run this build, or provide the signing credentials directly')
})
it('uploading or starting a build that uses stored credentials requires app admin rights', async () => {
// An admin requested this build with the stored secrets; a developer must not supply its project.
const jobId = `stored-creds-job-${id}`
const sessionKey = randomUUID()
const { error } = await getSupabaseClient().from('build_requests').insert({
app_id: appId,
owner_org: orgId,
requested_by: userId,
platform: 'ios',
status: 'pending',
builder_job_id: jobId,
upload_session_key: sessionKey,
upload_path: `orgs/${orgId}/apps/${appId}/native-builds/${sessionKey}.zip`,
upload_url: `${BASE_URL}/build/upload/${jobId}`,
upload_expires_at: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
uses_stored_credentials: true,
})
expect(error).toBeNull()
try {
for (const path of [`/build/upload_url/${jobId}`, `/build/start/${jobId}`]) {
const response = await fetch(`${BASE_URL}${path}`, { method: 'POST', headers: headers(developerKey), body: JSON.stringify({ app_id: appId }) })
expect(response.status, path).toBe(403)
const body = await response.json() as { error: string, message: string }
expect(body.error, path).toBe('permission_denied')
expect(body.message, path).toContain('requires app admin rights (app.update_settings)')
}
}
finally {
await getSupabaseClient().from('build_requests').delete().eq('builder_job_id', jobId)
}
})
it('POST /build/request with use_stored_credentials validates the merged set', async () => {
// No stored android credentials (other test deletes them; ios-only seed here keeps this independent).
let response = await fetch(`${BASE_URL}/build/request`, {
method: 'POST',
headers: headers(adminKey),
body: JSON.stringify({ app_id: appId, platform: 'android', use_stored_credentials: true, build_options: {}, build_credentials: {} }),
})
expect(response.status).toBe(400)
expect(await response.json()).toHaveProperty('error', 'build_credentials_not_found')
// Stored iOS set + request override that makes it invalid -> CLI-style message.
const put = await putCredentials(adminKey, {
app_id: appId,
platform: 'ios',
credentials: { BUILD_CERTIFICATE_BASE64: certSecret, CAPGO_IOS_PROVISIONING_MAP: provisioningMap, CAPGO_IOS_DISTRIBUTION: 'ad_hoc', APP_STORE_CONNECT_TEAM_ID: 'TEAM123' },
})
expect(put.status).toBe(200)
response = await fetch(`${BASE_URL}/build/request`, {
method: 'POST',
headers: headers(adminKey),
body: JSON.stringify({ app_id: appId, platform: 'ios', use_stored_credentials: true, build_options: { submitToStoreReview: true }, build_credentials: {} }),
})
expect(response.status).toBe(400)
const body = await response.json() as { error: string, message: string }
expect(body.error).toBe('invalid_build_credentials')
expect(body.message).toContain('--submit-to-store-review on iOS requires --ios-distribution app_store')
expect(body.message).not.toContain(certSecret)
})
})