tests/app-versions-deleted-at-rbac.test.ts
Rama main · réplica de lectura del repositorio original.
import { randomUUID } from 'node:crypto'
import { Pool } from 'pg'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
createDirectApiKeyWithBindings,
executeSQL,
POSTGRES_URL,
USER_ID,
withAnonymousCapgkey,
} from './test-utils.ts'
const scopeId = randomUUID().replaceAll('-', '')
const orgId = randomUUID()
const stripeCustomerId = `cus_deleted_at_rbac_${scopeId}`
const appId = `com.deletedat.rbac.${scopeId}`
const otherAppId = `${appId}.other`
let pool: Pool
let uploaderKey: string
let deleterKey: string
let uploaderKeyId: number
let deleterKeyId: number
async function insertVersion(name: string): Promise<number> {
const rows = await executeSQL<{ id: number }>(
`INSERT INTO public.app_versions (app_id, name, owner_org, user_id, storage_provider, deleted)
VALUES ($1, $2, $3::uuid, $4::uuid, 'r2-direct', false)
RETURNING id`,
[appId, name, orgId, USER_ID],
)
const id = rows[0]?.id
if (id == null)
throw new Error(`Failed to insert app_version ${name}`)
return Number(id)
}
async function readVersion(id: number) {
const rows = await executeSQL<{ deleted: boolean, deleted_at: string | null, comment: string | null }>(
'SELECT deleted, deleted_at, comment FROM public.app_versions WHERE id = $1',
[id],
)
return rows[0]
}
describe('app_versions deleted_at requires bundle.delete', () => {
beforeAll(async () => {
pool = new Pool({ connectionString: POSTGRES_URL })
await executeSQL(
'SELECT public.reset_and_seed_app_data($1, $2::uuid, NULL, NULL, $3, NULL)',
[appId, orgId, stripeCustomerId],
)
const uploader = await createDirectApiKeyWithBindings({
userId: USER_ID,
key: randomUUID(),
name: `uploader-${appId}`,
orgId,
roleName: 'org_member',
appId,
appRoleName: 'app_uploader',
})
if (!uploader.key)
throw new Error('Uploader API key was not created')
uploaderKey = uploader.key
uploaderKeyId = uploader.id
await executeSQL(
`INSERT INTO public.apps (app_id, icon_url, user_id, name, owner_org)
VALUES ($1, '', $2::uuid, $3, $4::uuid)
ON CONFLICT (app_id) DO NOTHING`,
[otherAppId, USER_ID, otherAppId, orgId],
)
const [otherApp] = await executeSQL<{ id: string }>(
'SELECT id::text FROM public.apps WHERE app_id = $1 LIMIT 1',
[otherAppId],
)
if (!otherApp?.id)
throw new Error(`Unable to resolve app ${otherAppId}`)
const [otherAppAdminRole] = await executeSQL<{ id: string }>(
`SELECT id::text FROM public.roles
WHERE name = 'app_admin' AND scope_type = 'app'
LIMIT 1`,
)
if (!otherAppAdminRole?.id)
throw new Error('Unable to resolve app_admin role')
await executeSQL(
`INSERT INTO public.role_bindings (
principal_type, principal_id, role_id, scope_type, org_id, app_id,
granted_by, reason, is_direct
) VALUES (
'apikey', $1::uuid, $2::uuid, 'app', $3::uuid, $4::uuid,
$5::uuid, 'Scope regression: bundle.delete on destination app only', true
)`,
[uploader.rbac_id, otherAppAdminRole.id, orgId, otherApp.id, USER_ID],
)
const deleter = await createDirectApiKeyWithBindings({
userId: USER_ID,
key: randomUUID(),
name: `deleter-${appId}`,
orgId,
roleName: 'org_member',
appId,
appRoleName: 'app_admin',
})
if (!deleter.key)
throw new Error('Deleter API key was not created')
deleterKey = deleter.key
deleterKeyId = deleter.id
}, 60_000)
afterAll(async () => {
const keyIds = [uploaderKeyId, deleterKeyId].filter((id): id is number => id != null)
if (keyIds.length > 0)
await executeSQL('DELETE FROM public.apikeys WHERE id = ANY($1::bigint[])', [keyIds])
await executeSQL('SELECT public.reset_app_data($1)', [appId])
await executeSQL('DELETE FROM public.deleted_apps WHERE app_id = $1', [appId])
await executeSQL('DELETE FROM public.apps WHERE app_id = $1', [otherAppId])
await executeSQL('DELETE FROM public.orgs WHERE id = $1::uuid', [orgId])
await executeSQL('DELETE FROM public.stripe_info WHERE customer_id = $1', [stripeCustomerId])
await pool.end()
}, 60_000)
it.concurrent('keeps an upload-scoped API key from setting deleted_at', async () => {
const versionId = await insertVersion(`upload-deleted-at-${randomUUID()}`)
await expect(withAnonymousCapgkey(pool, uploaderKey, async (client) => {
await client.query(
'UPDATE public.app_versions SET deleted_at = now() WHERE id = $1',
[versionId],
)
})).rejects.toThrow(/PERMISSION_DENIED_BUNDLE_DELETE/)
const row = await readVersion(versionId)
expect(row?.deleted_at).toBeNull()
expect(row?.deleted).toBe(false)
})
it.concurrent('keeps an upload-scoped API key from setting deleted=true', async () => {
const versionId = await insertVersion(`upload-deleted-flag-${randomUUID()}`)
await expect(withAnonymousCapgkey(pool, uploaderKey, async (client) => {
await client.query(
'UPDATE public.app_versions SET deleted = true WHERE id = $1',
[versionId],
)
})).rejects.toThrow(/PERMISSION_DENIED_BUNDLE_DELETE/)
const row = await readVersion(versionId)
expect(row?.deleted).toBe(false)
expect(row?.deleted_at).toBeNull()
})
it.concurrent('keeps an upload-scoped API key from mixing deleted_at into a benign update', async () => {
const versionId = await insertVersion(`upload-mixed-delete-${randomUUID()}`)
const comment = `mixed-comment-${randomUUID()}`
await expect(withAnonymousCapgkey(pool, uploaderKey, async (client) => {
await client.query(
'UPDATE public.app_versions SET comment = $2, deleted_at = now() WHERE id = $1',
[versionId, comment],
)
})).rejects.toThrow(/PERMISSION_DENIED_BUNDLE_DELETE/)
const row = await readVersion(versionId)
expect(row?.comment).toBeNull()
expect(row?.deleted_at).toBeNull()
expect(row?.deleted).toBe(false)
})
it.concurrent('keeps an upload-scoped API key from clearing deleted_at', async () => {
const versionId = await insertVersion(`upload-clear-deleted-at-${randomUUID()}`)
await executeSQL(
'UPDATE public.app_versions SET deleted_at = now() WHERE id = $1',
[versionId],
)
await expect(withAnonymousCapgkey(pool, uploaderKey, async (client) => {
await client.query(
'UPDATE public.app_versions SET deleted_at = NULL WHERE id = $1',
[versionId],
)
})).rejects.toThrow(/PERMISSION_DENIED_BUNDLE_DELETE/)
const row = await readVersion(versionId)
expect(row?.deleted_at).toBeTruthy()
})
it.concurrent('checks bundle.delete against the original app scope when app_id changes', async () => {
const versionId = await insertVersion(`scope-move-delete-${randomUUID()}`)
await expect(withAnonymousCapgkey(pool, uploaderKey, async (client) => {
await client.query(
'UPDATE public.app_versions SET app_id = $2, deleted_at = now() WHERE id = $1',
[versionId, otherAppId],
)
})).rejects.toThrow(/PERMISSION_DENIED_BUNDLE_DELETE/)
const rows = await executeSQL<{ app_id: string, deleted_at: string | null, deleted: boolean }>(
'SELECT app_id, deleted_at, deleted FROM public.app_versions WHERE id = $1',
[versionId],
)
expect(rows[0]?.app_id).toBe(appId)
expect(rows[0]?.deleted_at).toBeNull()
expect(rows[0]?.deleted).toBe(false)
})
it.concurrent('still lets an upload-scoped API key update non-deletion fields', async () => {
const versionId = await insertVersion(`upload-comment-${randomUUID()}`)
const comment = `uploader-comment-${randomUUID()}`
const result = await withAnonymousCapgkey(pool, uploaderKey, async (client) => {
return client.query(
'UPDATE public.app_versions SET comment = $2 WHERE id = $1 RETURNING id, comment',
[versionId, comment],
)
})
expect(result.rowCount).toBe(1)
expect(result.rows[0]?.comment).toBe(comment)
const row = await readVersion(versionId)
expect(row?.comment).toBe(comment)
expect(row?.deleted_at).toBeNull()
})
it.concurrent('lets an API key with bundle.delete set deleted_at', async () => {
const versionId = await insertVersion(`admin-deleted-at-${randomUUID()}`)
const result = await withAnonymousCapgkey(pool, deleterKey, async (client) => {
return client.query(
'UPDATE public.app_versions SET deleted_at = now() WHERE id = $1 RETURNING id, deleted_at',
[versionId],
)
})
expect(result.rowCount).toBe(1)
expect(result.rows[0]?.deleted_at).toBeTruthy()
const row = await readVersion(versionId)
expect(row?.deleted_at).toBeTruthy()
})
it.concurrent('lets service_role set deleted_at without a user-context grant', async () => {
const versionId = await insertVersion(`service-deleted-at-${randomUUID()}`)
const client = await pool.connect()
try {
await client.query('BEGIN')
await client.query(
'SELECT set_config($1, $2, true)',
['request.jwt.claim.role', 'service_role'],
)
await client.query(
'SELECT set_config($1, $2, true)',
['request.jwt.claims', JSON.stringify({ role: 'service_role' })],
)
await client.query('SET LOCAL ROLE service_role')
const result = await client.query<{ id: number, deleted_at: string | null }>(
'UPDATE public.app_versions SET deleted_at = now() WHERE id = $1 RETURNING id, deleted_at',
[versionId],
)
await client.query('COMMIT')
expect(result.rowCount).toBe(1)
expect(result.rows[0]?.deleted_at).toBeTruthy()
}
catch (error) {
await client.query('ROLLBACK')
throw error
}
finally {
client.release()
}
})
})