tests/console-images.test.ts
Rama main · réplica de lectura del repositorio original.
import { randomUUID } from 'node:crypto'
import { afterAll, describe, expect, it } from 'vitest'
import { getAuthHeaders, getEndpointUrl, getSupabaseClient, ORG_ID, USER_ID, USER_ID_2, USER_ID_NONMEMBER } from './test-utils'
// Images live in R2 under `images/`. Locally the S3 endpoint is the Supabase
// Storage `capgo` bucket, which is where these objects must land; nothing may
// be written to the legacy Supabase `images` bucket any more.
const LOCAL_R2_BUCKET = 'capgo'
const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3, 4])
interface StorageResponse<T> {
data: T | null
error: { message: string, statusCode?: string } | null
}
const createdPaths: string[] = []
async function upload(path: string, options: { upsert?: boolean } = {}) {
const response = await fetch(getEndpointUrl('/private/console/images/upload'), {
method: 'POST',
headers: {
...await getAuthHeaders(),
'Content-Type': 'image/png',
'x-image-path': path,
'x-image-upsert': String(options.upsert ?? false),
},
body: PNG_BYTES,
})
expect(response.status).toBe(200)
return response.json() as Promise<StorageResponse<{ path: string }>>
}
async function sign(path: string) {
const response = await fetch(getEndpointUrl('/private/console/images/sign'), {
method: 'POST',
headers: { ...await getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ path, expiresIn: 60 }),
})
expect(response.status).toBe(200)
return response.json() as Promise<StorageResponse<{ signedUrl: string }>>
}
async function imageToken() {
const response = await fetch(getEndpointUrl('/private/console/images/token'), {
method: 'POST',
headers: await getAuthHeaders(),
})
expect(response.status).toBe(200)
const body = await response.json() as StorageResponse<{ token: string, expiresAt: number }>
expect(body.error).toBeNull()
return body.data!.token
}
function readUrl(path: string, token: string) {
const url = new URL(getEndpointUrl('/private/console/images/read'))
url.searchParams.set('path', path)
url.searchParams.set('t', token)
return url
}
async function remove(paths: string[]) {
const response = await fetch(getEndpointUrl('/private/console/images/remove'), {
method: 'POST',
headers: { ...await getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ paths }),
})
expect(response.status).toBe(200)
return response.json() as Promise<StorageResponse<{ name: string }[]>>
}
async function r2Has(path: string) {
const folder = path.split('/').slice(0, -1).join('/')
const name = path.split('/').at(-1)
const { data } = await getSupabaseClient().storage.from(LOCAL_R2_BUCKET).list(`images/${folder}`, { search: name })
return data?.some(entry => entry.name === name) ?? false
}
afterAll(async () => {
if (createdPaths.length > 0)
await getSupabaseClient().storage.from(LOCAL_R2_BUCKET).remove(createdPaths.map(path => `images/${path}`))
})
describe('console image endpoints (R2)', () => {
it('uploads, signs, reads and removes an avatar through R2', async () => {
const path = `${USER_ID}/r2-avatar-${randomUUID()}.png`
createdPaths.push(path)
const uploaded = await upload(path)
expect(uploaded.error).toBeNull()
expect(uploaded.data?.path).toBe(path)
expect(await r2Has(path)).toBe(true)
const { data: legacy } = await getSupabaseClient().storage.from('images').list(USER_ID, { search: path.split('/').at(-1) })
expect(legacy ?? []).toHaveLength(0)
const conflict = await upload(path)
expect(conflict.error?.statusCode).toBe('409')
// One token reads every image the user may see; the URL is built locally.
const token = await imageToken()
const read = await fetch(readUrl(path, token))
expect(read.status).toBe(200)
expect(read.headers.get('content-type')).toBe('image/png')
expect(read.headers.get('x-content-type-options')).toBe('nosniff')
expect(new Uint8Array(await read.arrayBuffer())).toEqual(PNG_BYTES)
const etag = read.headers.get('etag')
expect(etag).toBeTruthy()
const revalidated = await fetch(readUrl(path, token), { headers: { 'if-none-match': etag! } })
expect(revalidated.status).toBe(304)
expect((await fetch(readUrl(path, `${token}x`))).status).toBe(401)
// The token is the user's, not the path's: another user's private folder stays closed.
expect((await fetch(readUrl(`${USER_ID_NONMEMBER}/avatar.png`, token))).status).toBe(404)
// The legacy sign endpoint now returns the same kind of relay URL.
const signed = await sign(path)
expect(signed.error).toBeNull()
const signedUrl = new URL(signed.data!.signedUrl)
expect(signedUrl.pathname).toMatch(/\/images\/read$/)
expect(signedUrl.searchParams.get('t')).toBeTruthy()
const removed = await remove([path])
expect(removed.error).toBeNull()
expect(removed.data).toEqual([{ name: path }])
expect(await r2Has(path)).toBe(false)
const missing = await sign(path)
expect(missing.error?.statusCode).toBe('404')
})
it('uploads an org logo for an org the caller administers', async () => {
const path = `org/${ORG_ID}/logo/r2-logo-${randomUUID()}.png`
createdPaths.push(path)
const uploaded = await upload(path, { upsert: true })
expect(uploaded.error).toBeNull()
expect(await r2Has(path)).toBe(true)
})
it('reads a teammate avatar but not an outsider avatar', async () => {
// Seeded directly in the local R2 stand-in: USER_ID_2 shares ORG_ID with the
// caller (org.read_members); USER_ID_NONMEMBER shares no organization.
const teammate = `${USER_ID_2}/r2-teammate-${randomUUID()}.png`
const outsider = `${USER_ID_NONMEMBER}/r2-outsider-${randomUUID()}.png`
for (const path of [teammate, outsider]) {
createdPaths.push(path)
const { error } = await getSupabaseClient().storage.from(LOCAL_R2_BUCKET).upload(`images/${path}`, new Blob([PNG_BYTES], { type: 'image/png' }), { upsert: true })
expect(error).toBeNull()
}
const token = await imageToken()
expect((await fetch(readUrl(teammate, token))).status).toBe(200)
expect((await fetch(readUrl(outsider, token))).status).toBe(404)
})
it('refuses to write or delete another user folder', async () => {
const path = `${USER_ID_NONMEMBER}/r2-forbidden-${randomUUID()}.png`
const uploaded = await upload(path, { upsert: true })
expect(uploaded.error?.statusCode).toBe('403')
expect(await r2Has(path)).toBe(false)
const removed = await remove([path])
expect(removed.data).toEqual([])
})
})