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) { 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 }> } 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) }) })