tests/build-start-log-token.test.ts
Rama main · réplica de lectura del repositorio original.
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<typeof import('../supabase/functions/_backend/utils/pg_rls.ts')>()
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<string, unknown> | null, prefetchError?: Error, updateRows?: Array<Record<string, unknown>> } = {}) {
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()
}
})
})