Ctrl K
ComunidadGuías…

tests/build-start-log-token.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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()
    }
  })
})