import { jwtVerify } from 'jose' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { startBuild } from '../supabase/functions/_backend/public/build/start.ts' const { mockAdminQuery, mockRlsQuery, mockCheckPermission, mockGetEnv, mockPersistBuilderBuildOutcome, mockReserveNativeBuildSlot, mockSendEventToTracking } = vi.hoisted(() => ({ mockAdminQuery: vi.fn(), mockRlsQuery: vi.fn(), mockCheckPermission: vi.fn(), mockGetEnv: vi.fn(), mockPersistBuilderBuildOutcome: vi.fn(), mockReserveNativeBuildSlot: vi.fn(), mockSendEventToTracking: vi.fn(), })) vi.mock('../supabase/functions/_backend/utils/builder_onboarding_checklist.ts', () => ({ persistBuilderBuildOutcome: mockPersistBuilderBuildOutcome, })) vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ getPgClient: async () => ({ query: mockAdminQuery }), closeClient: () => {}, })) vi.mock('../supabase/functions/_backend/utils/pg_rls.ts', async (importOriginal) => { const actual = await importOriginal() return { ...actual, withRlsContext: async (_c: unknown, _principal: unknown, run: (client: unknown) => unknown) => run({ query: mockRlsQuery }), } }) vi.mock('../supabase/functions/_backend/utils/rbac.ts', () => ({ checkPermission: mockCheckPermission, })) vi.mock('../supabase/functions/_backend/public/build/concurrency.ts', async () => { const { HTTPException } = await import('hono/http-exception') return { reserveNativeBuildSlot: mockReserveNativeBuildSlot, isNativeBuildConcurrencyLimitError: (error: unknown) => { return error instanceof HTTPException && error.status === 429 && !!(error.cause && typeof error.cause === 'object' && 'error' in error.cause && (error.cause as { error?: unknown }).error === 'native_build_concurrency_limit_exceeded') }, } }) vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({ getEnv: mockGetEnv, })) vi.mock('../supabase/functions/_backend/utils/tracking.ts', () => ({ sendEventToTracking: mockSendEventToTracking, })) describe('build start direct log token', () => { const requestId = 'req-build-start-log-token' const jobId = 'job-log-token-123' const appId = 'com.test.build.logs' const userId = '6f0d1a2e-59ed-4769-b9d7-4d9615b28fe5' const jwtSecret = 'super-secret-jwt-key' const publicUrl = 'https://api.capgo.test' const builderUrl = 'https://builder.capgo.test' const builderApiKey = 'builder-api-key' const failedRow = { status: 'pending', platform: 'ios', build_mode: 'release', owner_org: '3eb4f870-720d-46b9-843f-2e6d57d54001', requested_by: userId, } // Admin (service-role) statements: the markBuildAsFailed prefetch goes through // queryJson (`WITH q AS (...)`); every UPDATE ... RETURNING returns `updateRows`, // whose length decides whether the CAS writer won and emits the lifecycle event. function configureAdmin(opts: { prefetchRow?: Record | null, prefetchError?: Error, updateRows?: Array> } = {}) { const updates: Array<{ text: string, params: unknown[] }> = [] mockAdminQuery.mockImplementation(async (text: string, params: unknown[]) => { if (text.startsWith('WITH q AS')) { if (opts.prefetchError) throw opts.prefetchError const row = opts.prefetchRow === undefined ? failedRow : opts.prefetchRow return { rows: row ? [{ row }] : [] } } expect(text).toContain('UPDATE public.build_requests') updates.push({ text, params }) const rows = opts.updateRows ?? [{ id: 'row-1' }] return { rows, rowCount: rows.length } }) return updates } beforeEach(() => { mockAdminQuery.mockReset() mockRlsQuery.mockReset() mockCheckPermission.mockReset() mockGetEnv.mockReset() mockPersistBuilderBuildOutcome.mockReset() mockReserveNativeBuildSlot.mockReset() mockSendEventToTracking.mockReset() mockSendEventToTracking.mockResolvedValue(undefined) mockPersistBuilderBuildOutcome.mockResolvedValue(true) mockRlsQuery.mockImplementation(async (text: string) => { expect(text).toContain('FROM public.build_requests') return { rows: [{ row: { id: '3eb4f870-720d-46b9-843f-2e6d57d54000', app_id: appId, owner_org: '3eb4f870-720d-46b9-843f-2e6d57d54001', requested_by: userId, status: 'pending', platform: 'ios', build_mode: 'release', }, }], } }) // Default: CAS guard succeeds, one row returned, lifecycle event should fire. configureAdmin() mockCheckPermission.mockResolvedValue(true) mockReserveNativeBuildSlot.mockResolvedValue({ activeBuilds: 0, limit: 2, planName: 'Solo', upgradeUrl: 'https://console.capgo.app/settings/organization/plans', status: 'starting', }) mockGetEnv.mockImplementation((_, key: string) => { if (key === 'BUILDER_URL') return builderUrl if (key === 'BUILDER_API_KEY') return builderApiKey if (key === 'JWT_SECRET') return jwtSecret if (key === 'PUBLIC_URL') return publicUrl return '' }) }) afterEach(() => { vi.restoreAllMocks() }) it('returns a verifiable direct log token for the builder', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ status: 'running', }), { status: 200, headers: { 'Content-Type': 'application/json', }, })) const context = { get: vi.fn().mockImplementation((key: string) => { if (key === 'requestId') return requestId return undefined }), json: (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json', }, }), } try { const response = await startBuild( context as any, jobId, appId, { key: 'cli-api-key', user_id: userId, } as any, ) expect(response.status).toBe(200) const body = await response.json() as { status: string job_id: string logs_url?: string logs_token?: string } expect(body.status).toBe('running') expect(body.job_id).toBe(jobId) expect(body.logs_url).toBe(`${publicUrl}/build_logs_direct/${jobId}`) expect(body.logs_token).toBeTruthy() const verification = await jwtVerify( body.logs_token!, new TextEncoder().encode(jwtSecret), { issuer: 'capgo', audience: 'build-logs', subject: userId, }, ) expect(verification.protectedHeader).toMatchObject({ alg: 'HS256', typ: 'JWT', }) expect(verification.payload.job_id).toBe(jobId) expect(verification.payload.app_id).toBe(appId) expect(typeof verification.payload.iat).toBe('number') expect(typeof verification.payload.exp).toBe('number') expect(verification.payload.exp).toBeGreaterThan(verification.payload.iat!) expect(fetchMock).toHaveBeenCalledWith(`${builderUrl}/jobs/${jobId}/start`, { method: 'POST', headers: { 'x-api-key': builderApiKey, }, }) expect(mockReserveNativeBuildSlot).toHaveBeenCalledWith(context, { buildRequestId: '3eb4f870-720d-46b9-843f-2e6d57d54000', orgId: '3eb4f870-720d-46b9-843f-2e6d57d54001', appId, jobId, userId, }) expect(mockSendEventToTracking).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ event: 'Build Started' }), ) expect(mockPersistBuilderBuildOutcome).not.toHaveBeenCalled() } finally { fetchMock.mockRestore() } }) it('emits Build Failed when the builder rejects the start request', async () => { // Builder rejection (start.ts:213) calls markBuildAsFailed, which now: // 1. fetches the row to capture previousStatus + platform/build_mode/owner_org // 2. updates status to 'failed' with a CAS guard // 3. emits 'Build Failed' lifecycle event // Without step 3 (the bug this guards against), the builder-rejection path // would silently update status='failed' but never appear in the lifecycle funnel. // markBuildAsFailed reads the row, then runs the CAS UPDATE (default admin mock). configureAdmin() const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('builder is offline', { status: 500, })) const context = { get: vi.fn().mockImplementation((key: string) => { if (key === 'requestId') return requestId return undefined }), json: (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json', }, }), } try { await expect( startBuild(context as any, jobId, appId, { key: 'cli-api-key', user_id: userId } as any), ).rejects.toThrow() // Lifecycle funnel must include the terminal Build Failed transition. expect(mockSendEventToTracking).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ event: 'Build Failed', tags: expect.objectContaining({ app_id: appId, platform: 'ios', build_mode: 'release', failure_category: expect.any(String), }), }), ) expect(mockPersistBuilderBuildOutcome).toHaveBeenCalledWith(context, { appId, platform: 'ios', status: 'failed', }) } finally { fetchMock.mockRestore() } }) it('marks the build failed when concurrency limit is reached', async () => { const { HTTPException } = await import('hono/http-exception') const concurrencyMessage = 'Your Solo plan allows 2 concurrent native builds. You already have 2 active. Wait for a build to finish, or upgrade your plan: https://console.capgo.app/settings/organization/plans' mockReserveNativeBuildSlot.mockRejectedValue(new HTTPException(429, { message: concurrencyMessage, cause: { error: 'native_build_concurrency_limit_exceeded', message: concurrencyMessage, moreInfo: { activeBuilds: 2, limit: 2, planName: 'Solo', upgrade_url: 'https://console.capgo.app/settings/organization/plans', reason: 'native_build_concurrency', }, suppressDiscordAlert: true, }, })) const updates = configureAdmin() const fetchMock = vi.spyOn(globalThis, 'fetch') const context = { get: vi.fn().mockImplementation((key: string) => { if (key === 'requestId') return requestId return undefined }), json: (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json', }, }), } try { await expect( startBuild(context as any, jobId, appId, { key: 'cli-api-key', user_id: userId } as any), ).rejects.toMatchObject({ status: 429, message: concurrencyMessage, }) expect(fetchMock).not.toHaveBeenCalled() expect(updates).toHaveLength(1) expect(updates[0].text).toContain('SET status = \'failed\', last_error = $3') expect(updates[0].text).toContain('RETURNING id') expect(updates[0].params[2]).toBe(concurrencyMessage) expect(mockSendEventToTracking).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ event: 'Build Failed', tags: expect.objectContaining({ app_id: appId, }), }), ) } finally { fetchMock.mockRestore() } }) it('does not update the checklist when an immediate failure loses the CAS race', async () => { configureAdmin({ updateRows: [] }) const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('builder is offline', { status: 500 })) const context = { get: vi.fn().mockReturnValue(requestId), json: (data: unknown, status = 200) => new Response(JSON.stringify(data), { status }), } try { await expect(startBuild(context as any, jobId, appId, { key: 'cli-api-key', user_id: userId } as any)).rejects.toThrow() expect(mockPersistBuilderBuildOutcome).not.toHaveBeenCalled() } finally { fetchMock.mockRestore() } }) it('does not overwrite a terminal build when the failure prefetch fails', async () => { const updates = configureAdmin({ prefetchError: new Error('prefetch failed'), updateRows: [] }) const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('builder is offline', { status: 500 })) const context = { get: vi.fn().mockReturnValue(requestId), json: (data: unknown, status = 200) => new Response(JSON.stringify(data), { status }), } try { await expect(startBuild(context as any, jobId, appId, { key: 'cli-api-key', user_id: userId } as any)).rejects.toThrow() expect(updates).toHaveLength(1) expect(updates[0].text).toContain('NOT (status = ANY($5::text[]))') expect(updates[0].params[4]).toEqual(['succeeded', 'failed', 'expired', 'released', 'cancelled']) expect(mockPersistBuilderBuildOutcome).not.toHaveBeenCalled() } finally { fetchMock.mockRestore() } }) it('skips Build Started emission when CAS guard finds no matching row (lost race)', async () => { // Override the default update mock: zero rows returned from RETURNING id // simulates another writer having already advanced the row's status before // this request's UPDATE landed. The CAS guard `status = previousStatus` // matched no rows, so emitBuildTransitionEvent must NOT be called — the // winning writer is responsible for emitting. configureAdmin({ updateRows: [] }) const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ status: 'running', }), { status: 200, headers: { 'Content-Type': 'application/json', }, })) const context = { get: vi.fn().mockImplementation((key: string) => { if (key === 'requestId') return requestId return undefined }), json: (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json', }, }), } try { const response = await startBuild( context as any, jobId, appId, { key: 'cli-api-key', user_id: userId, } as any, ) // Request still succeeds end-to-end — the CAS loss is silent. expect(response.status).toBe(200) const body = await response.json() as { status: string, job_id: string } expect(body.status).toBe('running') expect(body.job_id).toBe(jobId) // Lifecycle event must NOT fire on the CAS-lost branch. expect(mockSendEventToTracking).not.toHaveBeenCalled() } finally { fetchMock.mockRestore() } }) })