tests/cicd-setup-tracking.test.ts
Rama main · réplica de lectura del repositorio original.
import type { Database } from '~/types/database.types'
import { randomUUID } from 'node:crypto'
import { createClient } from '@supabase/supabase-js'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { isCiCdSetupDone, parseCiCdSetupState } from '../src/utils/cicdSetupState.ts'
import {
APIKEY_TEST_ALL,
BASE_URL,
executeSQL,
headers,
resetAndSeedAppData,
resetAppData,
SUPABASE_ANON_KEY,
SUPABASE_BASE_URL,
USER_EMAIL,
USER_EMAIL_NONMEMBER,
USER_ID_NONMEMBER,
USER_PASSWORD,
USER_PASSWORD_NONMEMBER,
} from './test-utils.ts'
const id = randomUUID().slice(0, 8)
const APP_UPSERT = `com.cicd.upsert.${id}`
const APP_KEY = `com.cicd.key.${id}`
const APP_RPC = `com.cicd.rpc.${id}`
function authClient() {
return createClient<Database>(SUPABASE_BASE_URL, SUPABASE_ANON_KEY, {
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
})
}
async function signedIn(email = USER_EMAIL, password = USER_PASSWORD) {
const client = authClient()
const { error } = await client.auth.signInWithPassword({ email, password })
if (error)
throw error
return client
}
async function cicd(appId: string) {
const [row] = await executeSQL<{ onboarding: unknown }>('SELECT onboarding FROM public.apps WHERE app_id = $1', [appId])
return parseCiCdSetupState(row?.onboarding)
}
async function upsertBundle(appId: string, name: string, extraHeaders: Record<string, string> = {}, storageProvider?: string) {
// Both local test runtimes set CAPGO_PREVENT_BACKGROUND_FUNCTIONS=true, so
// this response also completes the detection task for this exact request.
const response = await fetch(`${BASE_URL}/bundle/upsert`, {
method: 'POST',
headers: { ...headers, ...extraHeaders },
body: JSON.stringify({ app_id: appId, name, ...(storageProvider ? { storage_provider: storageProvider } : {}) }),
})
expect(response.status, await response.clone().text()).toBe(200)
}
beforeAll(async () => {
await Promise.all([APP_UPSERT, APP_KEY, APP_RPC].map(appId => resetAndSeedAppData(appId)))
})
afterAll(async () => {
await Promise.all([APP_UPSERT, APP_KEY, APP_RPC].map(appId => resetAppData(appId)))
})
describe('CI/CD setup tracking', () => {
it('marks live updates done when the CLI uploads a bundle from CI', async () => {
await upsertBundle(APP_UPSERT, '1.0.1-local')
expect(isCiCdSetupDone((await executeSQL('SELECT onboarding FROM public.apps WHERE app_id = $1', [APP_UPSERT]))[0]?.onboarding)).toBe(false)
// The initial row exists before transfer starts. An abandoned upload must not complete setup.
await upsertBundle(APP_UPSERT, '1.0.2-ci', { 'x-cli-ci': 'GitHub Actions' }, 'r2-direct')
expect((await cicd(APP_UPSERT)).otaDetectedAt).toBeNull()
// The CLI finalizes storage_provider after the zip and manifest are persisted.
await upsertBundle(APP_UPSERT, '1.0.2-ci', { 'x-cli-ci': 'GitHub Actions' }, 'r2')
expect((await cicd(APP_UPSERT)).otaDetectedAt).toBeTruthy()
const state = await cicd(APP_UPSERT)
expect(state).toEqual(expect.objectContaining({ detectedVia: 'ci', ciProvider: 'GitHub Actions', builderDetectedAt: null }))
// Only the first CI upload is recorded.
await upsertBundle(APP_UPSERT, '1.0.3-ci', { 'x-cli-ci': 'GitLab CI' }, 'r2')
expect(await cicd(APP_UPSERT)).toEqual(state)
const [key] = await executeSQL<{ rbac_id: string }>('SELECT rbac_id::text AS rbac_id FROM public.apikeys WHERE key = $1', [APIKEY_TEST_ALL])
const client = await signedIn()
const { error } = await client.rpc('record_app_cicd_setup', { p_app_id: APP_UPSERT, p_event: 'key_created', p_apikey_rbac_id: key!.rbac_id })
expect(error).toBeNull()
await executeSQL('SELECT public.record_app_cicd_deploy($1, $2, $3, $4)', [APP_UPSERT, 'builder', key!.rbac_id, null])
expect(await cicd(APP_UPSERT)).toEqual(expect.objectContaining({
detectedVia: 'ci', ciProvider: 'GitHub Actions', otaDetectedAt: state.otaDetectedAt, builderDetectedAt: expect.any(String),
otaDetectedVia: 'ci', otaCiProvider: 'GitHub Actions', builderDetectedVia: 'key', builderCiProvider: null,
}))
})
it('marks live updates done when a caller-owned registered key uploads, even without CI detection', async () => {
const [key] = await executeSQL<{ rbac_id: string }>('SELECT rbac_id::text AS rbac_id FROM public.apikeys WHERE key = $1', [APIKEY_TEST_ALL])
const client = await signedIn()
const { data, error } = await client.rpc('record_app_cicd_setup', {
p_app_id: APP_KEY,
p_event: 'key_created',
p_apikey_rbac_id: key!.rbac_id,
p_selection: { product: 'ota', strategy: 'pull_request', provider: 'gitlab', role: 'ignored', extra: 'x' },
})
expect(error).toBeNull()
const stored = (data as { cicd?: Record<string, unknown> }).cicd
expect(stored?.keys).toEqual([key!.rbac_id])
expect(stored?.selection).toEqual({ product: 'ota', strategy: 'pull_request', provider: 'gitlab' })
expect(stored?.key_created_at).toBeTruthy()
await upsertBundle(APP_KEY, '1.0.1-guide-key', {}, 'r2')
expect((await cicd(APP_KEY)).otaDetectedAt).toBeTruthy()
expect(await cicd(APP_KEY)).toEqual(expect.objectContaining({ detectedVia: 'key', ciProvider: null }))
await executeSQL('SELECT public.record_app_cicd_deploy($1, $2, $3, $4)', [APP_KEY, 'builder', null, 'GitHub Actions'])
expect(await cicd(APP_KEY)).toEqual(expect.objectContaining({ detectedVia: 'key', ciProvider: null, builderDetectedAt: expect.any(String), otaDetectedVia: 'key', builderDetectedVia: 'ci', builderCiProvider: 'GitHub Actions' }))
})
it('only lets app readers record their own keys and the done state', async () => {
const { error: anonError } = await authClient().rpc('record_app_cicd_setup', { p_app_id: APP_RPC, p_event: 'marked_done' })
expect(anonError).toBeTruthy()
const outsider = await signedIn(USER_EMAIL_NONMEMBER, USER_PASSWORD_NONMEMBER)
const { error: outsiderError } = await outsider.rpc('record_app_cicd_setup', { p_app_id: APP_RPC, p_event: 'marked_done' })
expect(outsiderError?.message).toContain('NO_PERMISSION')
const client = await signedIn()
const foreignKeyId = randomUUID()
await executeSQL('INSERT INTO public.apikeys (user_id, key, name, rbac_id) VALUES ($1, $2, $3, $4)', [USER_ID_NONMEMBER, `cicd-foreign-${randomUUID()}`, 'CI/CD foreign-owned test key', foreignKeyId])
try {
const { error: foreignKeyError } = await client.rpc('record_app_cicd_setup', { p_app_id: APP_RPC, p_event: 'key_created', p_apikey_rbac_id: foreignKeyId })
expect(foreignKeyError?.message).toContain('NO_PERMISSION')
const [row] = await executeSQL<{ keys: unknown }>(`SELECT onboarding -> 'cicd' -> 'keys' AS keys FROM public.apps WHERE app_id = $1`, [APP_RPC])
expect(row?.keys ?? []).not.toContain(foreignKeyId)
}
finally {
await executeSQL('DELETE FROM public.apikeys WHERE rbac_id = $1', [foreignKeyId])
}
const { error: eventError } = await client.rpc('record_app_cicd_setup', { p_app_id: APP_RPC, p_event: 'detected' })
expect(eventError?.message).toContain('INVALID_EVENT')
// Detection is backend-only: users cannot claim a CI upload.
const { error: deployError } = await client.rpc('record_app_cicd_deploy', { p_app_id: APP_RPC, p_product: 'ota', p_apikey_rbac_id: randomUUID(), p_ci_provider: 'GitHub Actions' })
expect(deployError).toBeTruthy()
expect((await cicd(APP_RPC)).otaDetectedAt).toBeNull()
const { data, error } = await client.rpc('record_app_cicd_setup', { p_app_id: APP_RPC, p_event: 'marked_done' })
expect(error).toBeNull()
const markedDoneAt = parseCiCdSetupState(data).markedDoneAt
expect(markedDoneAt).toBeTruthy()
expect(isCiCdSetupDone(data)).toBe(true)
const { data: again } = await client.rpc('record_app_cicd_setup', { p_app_id: APP_RPC, p_event: 'marked_done' })
expect(parseCiCdSetupState(again).markedDoneAt).toBe(markedDoneAt)
})
it.concurrent('restricts deployment tracking execution to the backend role', async () => {
const grants = await executeSQL<{ role: string, can_execute: boolean }>(`
SELECT role, pg_catalog.has_function_privilege(role, 'public.record_app_cicd_deploy(character varying,text,uuid,text)', 'EXECUTE') AS can_execute
FROM (VALUES ('anon'), ('authenticated'), ('service_role')) AS roles(role)
`)
expect(Object.fromEntries(grants.map(row => [row.role, row.can_execute]))).toEqual({ anon: false, authenticated: false, service_role: true })
const { error } = await authClient().rpc('record_app_cicd_deploy', { p_app_id: APP_RPC, p_product: 'ota', p_apikey_rbac_id: randomUUID(), p_ci_provider: 'GitHub Actions' })
expect(error?.code).toBe('42501')
})
it('keeps the other onboarding fields and caps tracked keys', async () => {
await executeSQL(`UPDATE public.apps SET onboarding = jsonb_build_object('getting_started_dismissed_at', '2026-10-01T00:00:00.000Z') WHERE app_id = $1`, [APP_RPC])
const keys = Array.from({ length: 12 }, () => randomUUID())
const [user] = await executeSQL<{ id: string }>('SELECT id::text AS id FROM auth.users WHERE email = $1', [USER_EMAIL])
for (const [index, rbacId] of keys.entries())
await executeSQL(`INSERT INTO public.apikeys (user_id, key, name, rbac_id) VALUES ($1, $2, $3, $4)`, [user!.id, `cicd-${id}-${index}-${randomUUID()}`, `cicd tracking ${index}`, rbacId])
try {
const client = await signedIn()
for (const rbacId of keys) {
const { error } = await client.rpc('record_app_cicd_setup', { p_app_id: APP_RPC, p_event: 'key_created', p_apikey_rbac_id: rbacId })
expect(error).toBeNull()
}
const [row] = await executeSQL<{ onboarding: { getting_started_dismissed_at?: string, cicd: { keys: string[] } } }>('SELECT onboarding FROM public.apps WHERE app_id = $1', [APP_RPC])
expect(row!.onboarding.getting_started_dismissed_at).toBe('2026-10-01T00:00:00.000Z')
expect(row!.onboarding.cicd.keys).toEqual(keys.slice(-10))
}
finally {
await executeSQL('DELETE FROM public.apikeys WHERE rbac_id = ANY($1::uuid[])', [keys])
}
})
})