tests/cli-preview-lifecycle.test.ts
Rama main · réplica de lectura del repositorio original.
import { randomUUID } from 'node:crypto'
import { createRequire } from 'node:module'
import { pathToFileURL } from 'node:url'
import { createClient } from '@supabase/supabase-js'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import {
appApiKeyBindings,
BASE_URL,
createIsolatedSeedAppOptions,
executeSQL,
getAuthHeaders,
resetAndSeedAppData,
resetAppData,
SUPABASE_ANON_KEY,
SUPABASE_BASE_URL,
} from './test-utils.ts'
vi.mock('../cli/src/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('../cli/src/utils')>()
return {
...actual,
createCapgoClient: async (apikey: string, apiHost?: string) => {
if (!apiHost)
throw new Error('CLI preview lifecycle test requires a local Capgo API host')
return { apikey, apiHost, filesHost: apiHost }
},
getConfig: async () => ({ config: {} }),
getRemoteFileConfig: async () => ({
alertUploadSize: 1_000_000,
maxChunkSize: 1_000_000,
maxUploadLength: 1_000_000,
partialUpload: false,
partialUploadForced: false,
TUSUpload: false,
TUSUploadForced: false,
}),
sendEvent: async () => {},
}
})
vi.mock('../cli/src/api/app', async importOriginal => ({
...await importOriginal<typeof import('../cli/src/api/app')>(),
check2FAComplianceForApp: async () => {},
}))
vi.mock('../cli/src/api/update', async importOriginal => ({
...await importOriginal<typeof import('../cli/src/api/update')>(),
checkAlerts: async () => {},
}))
vi.mock('../cli/src/analytics/track', async importOriginal => ({
...await importOriginal<typeof import('../cli/src/analytics/track')>(),
trackEvent: async () => {},
}))
const { uploadBundleInternal } = await import('../cli/src/bundle/upload.ts')
const { addChannelInternal } = await import('../cli/src/channel/add.ts')
const { deleteChannelInternal } = await import('../cli/src/channel/delete.ts')
const { setChannelInternal } = await import('../cli/src/channel/set.ts')
const id = randomUUID()
const APPNAME = `com.cli.preview.lifecycle.${id}`
const CHANNEL_NAME = `preview-${id.slice(0, 8)}`
const SECOND_CHANNEL_NAME = `preview-other-${id.slice(0, 8)}`
const MAIN_CHANNEL_NAME = `main-${id.slice(0, 8)}`
const DEFAULT_CHANNEL_NAME = `preview-default-${id.slice(0, 8)}`
const PUBLIC_POST_CHANNEL_NAME = `preview-public-post-${id.slice(0, 8)}`
const BUNDLE_NAME = `1.0.0-preview-${id.slice(0, 8)}`
const LEGACY_CHANNEL_NAME = `preview-legacy-${id.slice(0, 8)}`
const LEGACY_BUNDLE_NAME = `1.0.0-legacy-${id.slice(0, 8)}`
const LEGACY_PARTIAL_CHANNEL_NAME = `preview-legacy-partial-${id.slice(0, 8)}`
const LEGACY_PARTIAL_BUNDLE_NAME = `1.0.0-legacy-partial-${id.slice(0, 8)}`
const seedOptions = createIsolatedSeedAppOptions()
interface ApiKeyResponse {
id: number
key: string
rbac_id: string
}
function requestTrace(input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) {
const request = input instanceof Request ? input : undefined
const url = request?.url ?? (input instanceof URL ? input.href : input)
return {
method: (init?.method ?? request?.method ?? 'GET').toUpperCase(),
path: new URL(url).pathname,
}
}
let authHeaders: Record<string, string>
const apiKeyIds: number[] = []
async function createAppApiKey(name: string, roleName = 'app_preview'): Promise<ApiKeyResponse> {
const createResponse = await fetch(`${BASE_URL}/apikey`, {
method: 'POST',
headers: authHeaders,
body: JSON.stringify({
name,
bindings: await appApiKeyBindings(APPNAME, roleName),
}),
})
expect(createResponse.status).toBe(200)
const apiKey = await createResponse.json<ApiKeyResponse>()
apiKeyIds.push(apiKey.id)
expect(apiKey.key).toBeTruthy()
return apiKey
}
beforeAll(async () => {
authHeaders = await getAuthHeaders()
await resetAndSeedAppData(APPNAME, seedOptions)
})
afterAll(async () => {
try {
for (const apiKeyId of apiKeyIds) {
const deleteResponse = await fetch(`${BASE_URL}/apikey/${apiKeyId}`, {
method: 'DELETE',
headers: authHeaders,
})
expect(deleteResponse.status).toBe(200)
}
}
finally {
await resetAppData(APPNAME)
}
})
describe('cli app preview lifecycle', () => {
it('creates and exclusively manages its own preview channel and bundle', async () => {
const [app] = await executeSQL(
`SELECT owner_org::text AS owner_org, user_id::text AS user_id
FROM public.apps
WHERE app_id = $1`,
[APPNAME],
)
expect(app).toEqual(expect.objectContaining({
owner_org: expect.any(String),
user_id: expect.any(String),
}))
await executeSQL(
`INSERT INTO public.channels (name, app_id, owner_org, created_by, version, public)
VALUES ($1, $2, $3::uuid, $4::uuid, NULL, true)`,
[MAIN_CHANNEL_NAME, APPNAME, app.owner_org, app.user_id],
)
const apiKey = await createAppApiKey(`cli-app-preview-${id}`)
const bindings = await executeSQL(
`SELECT
role_bindings.id::text AS id,
role_bindings.scope_type,
role_bindings.org_id::text AS org_id,
role_bindings.app_id::text AS app_id,
apps.id::text AS expected_app_id,
roles.name AS role_name
FROM public.role_bindings
INNER JOIN public.roles ON roles.id = role_bindings.role_id
LEFT JOIN public.apps ON apps.id = role_bindings.app_id
WHERE role_bindings.principal_type = public.rbac_principal_apikey()
AND role_bindings.principal_id = $1::uuid
ORDER BY role_bindings.scope_type, roles.name`,
[apiKey.rbac_id],
)
expect(bindings).toEqual([
expect.objectContaining({
id: expect.any(String),
scope_type: 'app',
org_id: app.owner_org,
role_name: 'app_preview',
app_id: expect.any(String),
expected_app_id: expect.any(String),
}),
])
expect(bindings[0]?.app_id).toBe(bindings[0]?.expected_app_id)
const previewPermissionRows = await executeSQL(
`SELECT permissions.key
FROM public.role_bindings
INNER JOIN public.roles ON roles.id = role_bindings.role_id
INNER JOIN public.role_permissions ON role_permissions.role_id = roles.id
INNER JOIN public.permissions ON permissions.id = role_permissions.permission_id
WHERE role_bindings.principal_type = public.rbac_principal_apikey()
AND role_bindings.principal_id = $1::uuid
AND roles.name = 'app_preview'
ORDER BY permissions.key`,
[apiKey.rbac_id],
)
expect(previewPermissionRows.map(permission => permission.key)).toEqual([
'app.create_channel',
'app.read',
'app.read_bundles',
'app.upload_bundle',
])
const apiKeyClient = createClient(SUPABASE_BASE_URL, SUPABASE_ANON_KEY, {
auth: {
persistSession: false,
},
global: {
headers: {
capgkey: apiKey.key,
},
},
})
const [
{ data: appExists, error: appExistsError },
{ data: canCreateChannel, error: createChannelPermissionError },
{ data: canPromoteAtAppScope, error: appScopePromotionPermissionError },
] = await Promise.all([
apiKeyClient.rpc('exist_app_v2', { appid: APPNAME }).single(),
apiKeyClient.rpc('cli_check_permission', {
apikey: apiKey.key,
permission_key: 'app.create_channel',
org_id: null,
app_id: APPNAME,
channel_id: null,
}),
apiKeyClient.rpc('cli_check_permission', {
apikey: apiKey.key,
permission_key: 'channel.promote_bundle',
org_id: null,
app_id: APPNAME,
channel_id: null,
}),
])
expect(appExistsError).toBeNull()
expect(appExists).toBe(true)
expect(createChannelPermissionError).toBeNull()
expect(canCreateChannel).toBe(true)
expect(appScopePromotionPermissionError).toBeNull()
expect(canPromoteAtAppScope).toBe(false)
const cliOptions = {
apikey: apiKey.key,
apiHost: `${SUPABASE_BASE_URL}/functions/v1`,
}
await expect(addChannelInternal(DEFAULT_CHANNEL_NAME, APPNAME, {
...cliOptions,
default: true,
}, true)).rejects.toThrow('Cannot create channel')
const publicChannelResponse = await fetch(`${BASE_URL}/channel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'capgkey': apiKey.key,
},
body: JSON.stringify({
app_id: APPNAME,
channel: PUBLIC_POST_CHANNEL_NAME,
public: true,
}),
})
expect(publicChannelResponse.status).toBe(400)
await expect(publicChannelResponse.json()).resolves.toMatchObject({ error: 'cannot_access_app' })
const blockedPublicChannels = await executeSQL(
`SELECT COUNT(*)::integer AS count
FROM public.channels
WHERE app_id = $1 AND name = ANY($2::varchar[])`,
[APPNAME, [DEFAULT_CHANNEL_NAME, PUBLIC_POST_CHANNEL_NAME]],
)
expect(Number(blockedPublicChannels[0]?.count ?? 0)).toBe(0)
const { upload, requests } = await (async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch')
try {
const upload = await uploadBundleInternal(APPNAME, {
...cliOptions,
path: '.',
bundle: BUNDLE_NAME,
channel: CHANNEL_NAME,
external: 'https://example.invalid/preview.zip',
codeCheck: false,
ignoreMetadataCheck: true,
ignoreChecksumCheck: true,
}, true)
return {
upload,
requests: fetchSpy.mock.calls.map(([input, init]) => requestTrace(input, init)),
}
}
finally {
fetchSpy.mockRestore()
}
})()
expect(upload).toMatchObject({
success: true,
appId: APPNAME,
bundle: BUNDLE_NAME,
updatedChannels: [CHANNEL_NAME],
})
const channelPostIndex = requests.findIndex(request => request.method === 'POST' && request.path === '/functions/v1/channel')
expect(channelPostIndex).toBeGreaterThanOrEqual(0)
expect(requests).not.toContainEqual({ method: 'POST', path: '/rest/v1/rpc/get_app_versions' })
expect(requests.slice(channelPostIndex + 1)).not.toContainEqual({ method: 'GET', path: '/functions/v1/private/cli/channels' })
const [createdChannel] = await executeSQL(
`SELECT id, rbac_id::text AS rbac_id
FROM public.channels
WHERE app_id = $1 AND name = $2`,
[APPNAME, CHANNEL_NAME],
)
expect(createdChannel).toEqual(expect.objectContaining({
id: expect.any(String),
rbac_id: expect.any(String),
}))
const childBindings = await executeSQL(
`SELECT
child_binding.scope_type,
child_binding.org_id::text AS org_id,
child_binding.app_id::text AS app_id,
child_binding.channel_id::text AS channel_id,
child_binding.parent_binding_id::text AS parent_binding_id,
child_role.name AS role_name
FROM public.role_bindings AS child_binding
INNER JOIN public.roles AS child_role ON child_role.id = child_binding.role_id
WHERE child_binding.principal_type = public.rbac_principal_apikey()
AND child_binding.principal_id = $1::uuid
AND child_binding.channel_id = $2::uuid`,
[apiKey.rbac_id, createdChannel.rbac_id],
)
expect(childBindings).toEqual([
expect.objectContaining({
scope_type: 'channel',
org_id: app.owner_org,
app_id: bindings[0]?.app_id,
channel_id: createdChannel.rbac_id,
parent_binding_id: bindings[0]?.id,
role_name: 'channel_preview',
}),
])
await expect(deleteChannelInternal(MAIN_CHANNEL_NAME, APPNAME, {
...cliOptions,
deleteBundle: false,
successIfNotFound: false,
}, true)).rejects.toThrow('channel.delete')
const otherApiKey = await createAppApiKey(`cli-app-preview-other-${id}`)
const otherCliOptions = {
apikey: otherApiKey.key,
apiHost: `${SUPABASE_BASE_URL}/functions/v1`,
}
await expect(addChannelInternal(SECOND_CHANNEL_NAME, APPNAME, otherCliOptions, true))
.resolves
.toMatchObject({ name: SECOND_CHANNEL_NAME })
await expect(deleteChannelInternal(SECOND_CHANNEL_NAME, APPNAME, {
...cliOptions,
deleteBundle: false,
successIfNotFound: false,
}, true)).rejects.toThrow('channel.delete')
const [bundle] = await executeSQL(
`SELECT id, created_by_apikey_rbac_id::text AS created_by_apikey_rbac_id
FROM public.app_versions
WHERE app_id = $1 AND name = $2`,
[APPNAME, BUNDLE_NAME],
)
expect(bundle).toEqual(expect.objectContaining({
id: expect.anything(),
created_by_apikey_rbac_id: apiKey.rbac_id,
}))
const bundleId = Number(bundle?.id)
expect(Number.isSafeInteger(bundleId)).toBe(true)
const [promotedChannel] = await executeSQL(
`SELECT version
FROM public.channels
WHERE app_id = $1 AND name = $2`,
[APPNAME, CHANNEL_NAME],
)
expect(Number(promotedChannel?.version)).toBe(bundleId)
await expect(deleteChannelInternal(CHANNEL_NAME, APPNAME, {
...cliOptions,
deleteBundle: true,
successIfNotFound: false,
}, true)).resolves.toBe(true)
const remainingChannels = await executeSQL(
`SELECT COUNT(*)::integer AS count
FROM public.channels
WHERE app_id = $1 AND name = $2`,
[APPNAME, CHANNEL_NAME],
)
expect(Number(remainingChannels[0]?.count ?? 0)).toBe(0)
const [deletedBundle] = await executeSQL(
`SELECT deleted, created_by_apikey_rbac_id::text AS created_by_apikey_rbac_id
FROM public.app_versions
WHERE id = $1`,
[bundle?.id],
)
expect(deletedBundle).toEqual({
deleted: true,
created_by_apikey_rbac_id: apiKey.rbac_id,
})
const remainingMainChannels = await executeSQL(
`SELECT COUNT(*)::integer AS count
FROM public.channels
WHERE app_id = $1 AND name = $2`,
[APPNAME, MAIN_CHANNEL_NAME],
)
expect(Number(remainingMainChannels[0]?.count ?? 0)).toBe(1)
}, 60_000)
it('reads back legacy channel creation responses without metadata', async () => {
const apiKey = await createAppApiKey(`cli-app-preview-legacy-${id}`)
const cliOptions = {
apikey: apiKey.key,
apiHost: `${SUPABASE_BASE_URL}/functions/v1`,
}
const originalFetch = globalThis.fetch
const { upload, requests } = await (async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const response = await originalFetch(input, init)
const request = requestTrace(input, init)
if (response.ok && request.method === 'POST' && request.path === '/functions/v1/channel') {
return new Response(JSON.stringify({ status: 'ok' }), {
status: response.status,
headers: { 'content-type': 'application/json' },
})
}
return response
})
try {
const upload = await uploadBundleInternal(APPNAME, {
...cliOptions,
path: '.',
bundle: LEGACY_BUNDLE_NAME,
channel: LEGACY_CHANNEL_NAME,
bundleUrl: true,
external: 'https://example.invalid/legacy-preview.zip',
codeCheck: false,
ignoreMetadataCheck: true,
ignoreChecksumCheck: true,
}, true)
return {
upload,
requests: fetchSpy.mock.calls.map(([input, init]) => requestTrace(input, init)),
}
}
finally {
fetchSpy.mockRestore()
}
})()
expect(upload).toMatchObject({
success: true,
appId: APPNAME,
bundle: LEGACY_BUNDLE_NAME,
updatedChannels: [LEGACY_CHANNEL_NAME],
})
const channelPostIndex = requests.findIndex(request => request.method === 'POST' && request.path === '/functions/v1/channel')
expect(channelPostIndex).toBeGreaterThanOrEqual(0)
expect(requests).not.toContainEqual({ method: 'POST', path: '/rest/v1/rpc/get_app_versions' })
expect(requests.slice(channelPostIndex + 1)).toContainEqual({ method: 'GET', path: '/functions/v1/private/cli/channels' })
await expect(deleteChannelInternal(LEGACY_CHANNEL_NAME, APPNAME, {
...cliOptions,
deleteBundle: true,
successIfNotFound: false,
}, true)).resolves.toBe(true)
}, 60_000)
it('retains a legacy channel ID when public metadata and readback are unavailable', async () => {
const apiKey = await createAppApiKey(`cli-app-preview-legacy-partial-${id}`)
const cliOptions = {
apikey: apiKey.key,
apiHost: `${SUPABASE_BASE_URL}/functions/v1`,
}
// Resolve from the CLI package so the spy targets the module the CLI uses.
const { log } = await import(pathToFileURL(createRequire(new URL('../cli/package.json', import.meta.url)).resolve('@clack/prompts')).href)
const logInfo = vi.spyOn(log, 'info')
const originalFetch = globalThis.fetch
let createdChannelId: number | undefined
try {
const { upload, requests } = await (async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const request = requestTrace(input, init)
if (createdChannelId != null && request.method === 'GET' && request.path === '/functions/v1/private/cli/channels') {
return new Response(JSON.stringify({ message: 'Readback unavailable' }), {
status: 503,
headers: { 'content-type': 'application/json' },
})
}
const response = await originalFetch(input, init)
if (response.ok && request.method === 'POST' && request.path === '/functions/v1/channel') {
const createdChannel = await response.clone().json() as { id?: unknown }
createdChannelId = Number(createdChannel.id)
return new Response(JSON.stringify({ id: createdChannelId }), {
status: response.status,
headers: { 'content-type': 'application/json' },
})
}
return response
})
try {
const upload = await uploadBundleInternal(APPNAME, {
...cliOptions,
path: '.',
bundle: LEGACY_PARTIAL_BUNDLE_NAME,
channel: LEGACY_PARTIAL_CHANNEL_NAME,
bundleUrl: true,
external: 'https://example.invalid/legacy-partial-preview.zip',
codeCheck: false,
ignoreMetadataCheck: true,
ignoreChecksumCheck: true,
}, true)
return {
upload,
requests: fetchSpy.mock.calls.map(([input, init]) => requestTrace(input, init)),
}
}
finally {
fetchSpy.mockRestore()
}
})()
expect(Number.isSafeInteger(createdChannelId)).toBe(true)
expect(upload).toMatchObject({
success: true,
appId: APPNAME,
bundle: LEGACY_PARTIAL_BUNDLE_NAME,
updatedChannels: [LEGACY_PARTIAL_CHANNEL_NAME],
})
expect(requests).toContainEqual({ method: 'GET', path: '/functions/v1/private/cli/channels' })
expect(logInfo).toHaveBeenCalledWith(expect.stringContaining(`Link device to this bundle to try it: `))
expect(logInfo).toHaveBeenCalledWith(expect.stringContaining(`/app/${APPNAME}/channel/${createdChannelId}`))
}
finally {
logInfo.mockRestore()
}
await expect(deleteChannelInternal(LEGACY_PARTIAL_CHANNEL_NAME, APPNAME, {
...cliOptions,
deleteBundle: true,
successIfNotFound: false,
}, true)).resolves.toBe(true)
}, 60_000)
it('keeps generic app-admin bundle cleanup behavior unchanged', async () => {
const genericChannelName = `admin-${id.slice(0, 8)}`
const genericBundleName = `1.0.0-admin-${id.slice(0, 8)}`
const [app] = await executeSQL(
`SELECT owner_org::text AS owner_org
FROM public.apps
WHERE app_id = $1`,
[APPNAME],
)
const apiKey = await createAppApiKey(`cli-app-admin-${id}`, 'app_admin')
const apiKeyClient = createClient(SUPABASE_BASE_URL, SUPABASE_ANON_KEY, {
auth: { persistSession: false },
global: { headers: { capgkey: apiKey.key } },
})
const cliOptions = {
apikey: apiKey.key,
apiHost: `${SUPABASE_BASE_URL}/functions/v1`,
}
const { data: canDeleteBundle, error: canDeleteBundleError } = await apiKeyClient.rpc('cli_check_permission', {
apikey: apiKey.key,
permission_key: 'bundle.delete',
org_id: null,
app_id: APPNAME,
channel_id: null,
})
expect(canDeleteBundleError).toBeNull()
expect(canDeleteBundle).toBe(true)
await expect(addChannelInternal(genericChannelName, APPNAME, cliOptions, true))
.resolves
.toMatchObject({ name: genericChannelName })
const { data: uploaderUserId, error: uploaderUserError } = await apiKeyClient
.rpc('request_actor_user_id')
expect(uploaderUserError).toBeNull()
expect(uploaderUserId).toEqual(expect.any(String))
const { data: bundle, error: bundleError } = await apiKeyClient
.from('app_versions')
.insert({
app_id: APPNAME,
checksum: `admin-checksum-${id}`,
name: genericBundleName,
native_packages: [],
owner_org: app.owner_org,
storage_provider: 'r2-direct',
user_id: uploaderUserId,
})
.select('id')
.single()
expect(bundleError).toBeNull()
expect(bundle).toEqual(expect.objectContaining({ id: expect.any(Number) }))
await expect(setChannelInternal(genericChannelName, APPNAME, {
...cliOptions,
bundle: genericBundleName,
ignoreMetadataCheck: true,
}, true)).resolves.toBe(true)
await expect(deleteChannelInternal(genericChannelName, APPNAME, {
...cliOptions,
deleteBundle: true,
successIfNotFound: false,
}, true)).resolves.toBe(true)
const [deletedBundle] = await executeSQL(
`SELECT deleted
FROM public.app_versions
WHERE id = $1`,
[bundle?.id],
)
expect(deletedBundle).toEqual({ deleted: true })
const remainingChannels = await executeSQL(
`SELECT COUNT(*)::integer AS count
FROM public.channels
WHERE app_id = $1 AND name = $2`,
[APPNAME, genericChannelName],
)
expect(Number(remainingChannels[0]?.count ?? 0)).toBe(0)
}, 60_000)
})