Files
zpan/server/adapters/gateways/image-upload.test.ts
T
Jasper Van f24c6e2021 feat(avatar): self-host avatars on R2 on Workers + fix avatar refresh/fallback (#468)
* fix(avatar): refresh session after change and show fallback on remove

Two avatar-display bugs surfaced post-#456:
- After uploading/removing an avatar the UI showed the old image until a full
  reload. refreshSession() now calls getSession({ disableCookieCache: true }) to
  re-read user.image past the 5-min session cookie cache, then
  $store.notify('$sessionSignal') so useSession() actually refetches and
  re-renders (an external endpoint never toggles better-auth's session signal).
- Removing an avatar left a blank circle: the conditional `{user.image && <AvatarImage>}`
  unmounts the radix Image, which keeps a stale "loaded" status so the Fallback
  stays hidden. Always render <AvatarImage src={user?.image ?? undefined}> so radix
  re-runs its loading status (src -> undefined => "error") and shows the initials.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(avatar): self-host avatars on R2 when deployed on Workers

The image-upload gateway now branches per request on the AVATARS R2 binding:
- binding present (Cloudflare) -> upload straight to R2 (key `scope/id`, content
  type in R2 metadata, a content-hash `?v=` cache-buster) and return either an
  AVATARS_PUBLIC_URL (R2 custom domain) URL or a relative /api/avatar-blobs URL.
- binding absent (Node/Docker, or a Worker without it) -> the existing ZPan Cloud
  avatar service, unchanged.

Adds a public GET /api/avatar-blobs/:scope/:id route that streams the blob from
the AVATARS binding (so local miniflare, which gives R2 no public URL, can serve
avatars too). AVATARS_BINDING / R2BucketLike live in platform/interface so both
the adapter and the http route can use them without crossing the arch boundary.
wrangler.toml declares the AVATARS bucket (prod + staging).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(deploy): provision the zpan-avatars R2 bucket + AVATARS_PUBLIC_URL

The AVATARS R2 binding added for self-hosted avatars needs the bucket to exist on
deploy. Mirror the resource-provisioning pattern (D1/Queue): create zpan-avatars if
missing, enable its managed public URL, and upsert AVATARS_PUBLIC_URL so prod serves
avatars straight from R2 (zero Worker egress). Without the secret the app still works
via its /api/avatar-blobs route.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(avatar): reuse the original PUBLIC_IMAGES bucket/binding/env names

Keep the same Cloudflare resource names as before #456 removed them so the existing
`zpan-public-images` bucket is reused (not orphaned) and the API token scopes still
apply: R2 binding PUBLIC_IMAGES, bucket zpan-public-images(-staging), public-URL
secret PUBLIC_IMAGES_URL. Pure rename of the AVATARS naming I'd introduced — no
behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 02:05:09 -04:00

358 lines
14 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import type { Platform } from '../../platform/interface'
import type { LicenseBindingRepo, LicenseState, LicensingCloudGateway } from '../../usecases/ports'
import { createImageUploadGateway, isAvatarContentType } from './image-upload'
// biome-ignore lint/suspicious/noExplicitAny: test stub intentionally opaque
type Any = any
const AVATAR_PREFIX = '_system/avatars'
const LOGO_PREFIX = '_system/org-logos'
function mockPlatform(env: Record<string, string | undefined> = {}, avatarsBucket?: unknown): Platform {
return {
db: {} as Any,
getEnv: (k: string) => env[k],
getBinding: (k: string) => (k === 'PUBLIC_IMAGES' ? avatarsBucket : undefined),
} as unknown as Platform
}
// In-memory R2 stand-in capturing put/get/delete the gateway issues.
function mockR2Bucket() {
const store = new Map<string, { body: ArrayBuffer; contentType?: string }>()
const put = vi.fn(async (key: string, value: ArrayBuffer, opts?: { httpMetadata?: { contentType?: string } }) => {
store.set(key, { body: value, contentType: opts?.httpMetadata?.contentType })
})
const get = vi.fn(async (key: string) => {
const o = store.get(key)
return o ? { arrayBuffer: async () => o.body, httpMetadata: { contentType: o.contentType } } : null
})
const del = vi.fn(async (key: string) => {
store.delete(key)
})
return { bucket: { put, get, delete: del }, put, get, delete: del, store }
}
function activeBinding(refreshToken: string | null = 'refresh-token'): LicenseState {
return {
id: 'binding-1',
cloudBindingId: 'cb',
cloudStoreId: null,
instanceId: 'instance-1',
cloudAccountId: 'account-1',
cloudAccountEmail: null,
status: 'active',
refreshToken,
cachedCert: null,
cachedExpiresAt: null,
boundAt: 0,
disconnectedAt: null,
lastRefreshAt: null,
lastRefreshError: null,
}
}
function mockLicenseBinding(binding: LicenseState | null): LicenseBindingRepo {
return { loadActiveLicenseBinding: vi.fn(async () => binding) } as unknown as LicenseBindingRepo
}
// A fake Cloud client capturing the avatar PUT/DELETE the SDK helper issues. The
// gateway calls the REAL `uploadAvatar`/`deleteAvatar` SDK helpers against it, so
// these spies see exactly what the SDK forwards (param + per-request init).
function mockAvatarClient(putResponse: () => Response = () => json201()) {
const put = vi.fn(async (_args: unknown, _opt: unknown) => putResponse())
const del = vi.fn(async (_args: unknown) => new Response(null, { status: 204 }))
const client = { avatars: { ':scope': { ':id': { $put: put, $delete: del } } } }
return { client, put, del }
}
function json201(
body: unknown = { url: 'https://cloud.example/avatars/user/u1.png', key: 'avatars/user/u1' },
): Response {
return new Response(JSON.stringify(body), { status: 201, headers: { 'content-type': 'application/json' } })
}
function cloudError(status: number, code: string): Response {
return new Response(JSON.stringify({ error: code }), { status, headers: { 'content-type': 'application/json' } })
}
function mockLicensingCloud(client: unknown) {
const createAvatarUploadClient = vi.fn(() => client)
return { gateway: { createAvatarUploadClient } as unknown as LicensingCloudGateway, createAvatarUploadClient }
}
function makeFile(type: string, bytes = 16): File {
return new File([new Uint8Array(bytes)], `f.${type.split('/')[1] ?? 'bin'}`, { type })
}
describe('isAvatarContentType', () => {
it('accepts the Cloud avatar content types (incl. gif)', () => {
expect(isAvatarContentType('image/png')).toBe(true)
expect(isAvatarContentType('image/jpeg')).toBe(true)
expect(isAvatarContentType('image/webp')).toBe(true)
expect(isAvatarContentType('image/gif')).toBe(true)
})
it('rejects unsupported mimes', () => {
expect(isAvatarContentType('application/pdf')).toBe(false)
expect(isAvatarContentType('image/bmp')).toBe(false)
expect(isAvatarContentType('')).toBe(false)
expect(isAvatarContentType(undefined)).toBe(false)
expect(isAvatarContentType(42)).toBe(false)
})
})
describe('uploadPublicImage — Cloud avatar service', () => {
it('uploads a user avatar via the Cloud avatar service with the image content type', async () => {
const { client, put } = mockAvatarClient()
const { gateway, createAvatarUploadClient } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
const file = makeFile('image/png')
const result = await gw.uploadPublicImage(
mockPlatform({ ZPAN_CLOUD_URL: 'https://cloud.example' }),
AVATAR_PREFIX,
'u1',
file,
)
expect(result.ok).toBe(true)
if (result.ok) expect(result.url).toBe('https://cloud.example/avatars/user/u1.png')
// Bound client built from the active binding's refresh token + the cloud base url.
expect(createAvatarUploadClient).toHaveBeenCalledWith('https://cloud.example', 'refresh-token')
// The SDK PUT targets /avatars/user/u1 and sends the IMAGE content type (not JSON).
expect(put).toHaveBeenCalledOnce()
expect(put.mock.calls[0]?.[0]).toEqual({ param: { scope: 'user', id: 'u1' } })
const init = (put.mock.calls[0]?.[1] as { init: { body: unknown; headers: Record<string, string> } }).init
expect(init.headers['content-type']).toBe('image/png')
expect(init.body).toBe(file)
})
it('maps the org-logo prefix to the team scope', async () => {
const { client, put } = mockAvatarClient()
const { gateway } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
await gw.uploadPublicImage(mockPlatform(), LOGO_PREFIX, 'team-1', makeFile('image/webp'))
expect(put.mock.calls[0]?.[0]).toEqual({ param: { scope: 'team', id: 'team-1' } })
})
it('rejects an unsupported mime with 400 before any Cloud call', async () => {
const { client, put } = mockAvatarClient()
const { gateway, createAvatarUploadClient } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
const result = await gw.uploadPublicImage(mockPlatform(), AVATAR_PREFIX, 'u1', makeFile('application/pdf'))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.status).toBe(400)
expect(createAvatarUploadClient).not.toHaveBeenCalled()
expect(put).not.toHaveBeenCalled()
})
it('rejects a file larger than 1 MiB with 413 before any Cloud call', async () => {
const { client, put } = mockAvatarClient()
const { gateway } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
const result = await gw.uploadPublicImage(
mockPlatform(),
AVATAR_PREFIX,
'u1',
makeFile('image/png', 2 * 1024 * 1024),
)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.status).toBe(413)
expect(put).not.toHaveBeenCalled()
})
it('returns 503 cloud_required when the instance is not paired to Cloud', async () => {
const { client, put } = mockAvatarClient()
const { gateway, createAvatarUploadClient } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(null), gateway)
const result = await gw.uploadPublicImage(mockPlatform(), AVATAR_PREFIX, 'u1', makeFile('image/png'))
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.status).toBe(503)
expect(result.error).toBe('cloud_required')
}
expect(createAvatarUploadClient).not.toHaveBeenCalled()
expect(put).not.toHaveBeenCalled()
})
it('returns 503 cloud_required when the active binding has no refresh token', async () => {
const { client } = mockAvatarClient()
const { gateway } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding(null)), gateway)
const result = await gw.uploadPublicImage(mockPlatform(), AVATAR_PREFIX, 'u1', makeFile('image/png'))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.status).toBe(503)
})
it.each([
['unsupported_media_type', 415, 400],
['payload_too_large', 413, 413],
['license_inactive', 403, 403],
['something_else', 500, 500],
])('maps Cloud error %s to local status %i', async (code, cloudStatus, localStatus) => {
const { client } = mockAvatarClient(() => cloudError(cloudStatus, code))
const { gateway } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
const result = await gw.uploadPublicImage(mockPlatform(), AVATAR_PREFIX, 'u1', makeFile('image/png'))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.status).toBe(localStatus)
})
it('returns 500 when the Cloud 201 body is malformed', async () => {
const { client } = mockAvatarClient(() => json201({ nope: true }))
const { gateway } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
const result = await gw.uploadPublicImage(mockPlatform(), AVATAR_PREFIX, 'u1', makeFile('image/png'))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.status).toBe(500)
})
it('returns 500 when the Cloud request throws', async () => {
const put = vi.fn(async () => {
throw new Error('network down')
})
const client = { avatars: { ':scope': { ':id': { $put: put, $delete: vi.fn() } } } }
const { gateway } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
const result = await gw.uploadPublicImage(mockPlatform(), AVATAR_PREFIX, 'u1', makeFile('image/png'))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.status).toBe(500)
})
})
describe('deletePublicImageVariants — Cloud avatar service', () => {
it('deletes the Cloud-hosted avatar for the scope/id', async () => {
const { client, del } = mockAvatarClient()
const { gateway } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
await gw.deletePublicImageVariants(mockPlatform(), AVATAR_PREFIX, 'u1')
expect(del).toHaveBeenCalledOnce()
expect(del.mock.calls[0]?.[0]).toEqual({ param: { scope: 'user', id: 'u1' } })
})
it('is a no-op when the instance is not paired to Cloud', async () => {
const { client, del } = mockAvatarClient()
const { gateway, createAvatarUploadClient } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(null), gateway)
await expect(gw.deletePublicImageVariants(mockPlatform(), AVATAR_PREFIX, 'u1')).resolves.toBeUndefined()
expect(createAvatarUploadClient).not.toHaveBeenCalled()
expect(del).not.toHaveBeenCalled()
})
it('swallows Cloud delete failures (best-effort)', async () => {
const del = vi.fn(async () => {
throw new Error('boom')
})
const client = { avatars: { ':scope': { ':id': { $put: vi.fn(), $delete: del } } } }
const { gateway } = mockLicensingCloud(client)
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
await expect(gw.deletePublicImageVariants(mockPlatform(), LOGO_PREFIX, 'team-1')).resolves.toBeUndefined()
})
})
describe('uploadPublicImage — PUBLIC_IMAGES R2 binding (self-hosted, no Cloud)', () => {
it('uploads straight to R2 and returns the instance serve URL, never calling Cloud', async () => {
const r2 = mockR2Bucket()
const { gateway, createAvatarUploadClient } = mockLicensingCloud({})
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
const result = await gw.uploadPublicImage(mockPlatform({}, r2.bucket), AVATAR_PREFIX, 'u1', makeFile('image/png'))
expect(result.ok).toBe(true)
if (result.ok) expect(result.url).toMatch(/^\/api\/avatar-blobs\/user\/u1\?v=[0-9a-f]{12}$/)
expect(r2.put).toHaveBeenCalledOnce()
expect(r2.put.mock.calls[0]?.[0]).toBe('user/u1')
expect(r2.put.mock.calls[0]?.[2]).toEqual({ httpMetadata: { contentType: 'image/png' } })
// The R2 binding short-circuits the Cloud path entirely.
expect(createAvatarUploadClient).not.toHaveBeenCalled()
})
it('uses PUBLIC_IMAGES_URL (R2 custom domain) and the team scope for org logos', async () => {
const r2 = mockR2Bucket()
const { gateway } = mockLicensingCloud({})
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
const result = await gw.uploadPublicImage(
mockPlatform({ PUBLIC_IMAGES_URL: 'https://cdn.example.com/' }, r2.bucket),
LOGO_PREFIX,
'team-1',
makeFile('image/webp'),
)
expect(result.ok).toBe(true)
if (result.ok) expect(result.url).toMatch(/^https:\/\/cdn\.example\.com\/team\/team-1\?v=[0-9a-f]{12}$/)
expect(r2.put.mock.calls[0]?.[0]).toBe('team/team-1')
})
it('still validates mime/size before touching R2', async () => {
const r2 = mockR2Bucket()
const { gateway } = mockLicensingCloud({})
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
const bad = await gw.uploadPublicImage(
mockPlatform({}, r2.bucket),
AVATAR_PREFIX,
'u1',
makeFile('application/pdf'),
)
expect(bad.ok).toBe(false)
if (!bad.ok) expect(bad.status).toBe(400)
const big = await gw.uploadPublicImage(
mockPlatform({}, r2.bucket),
AVATAR_PREFIX,
'u1',
makeFile('image/png', 2 * 1024 * 1024),
)
expect(big.ok).toBe(false)
if (!big.ok) expect(big.status).toBe(413)
expect(r2.put).not.toHaveBeenCalled()
})
})
describe('deletePublicImageVariants — PUBLIC_IMAGES R2 binding', () => {
it('deletes straight from R2 for the scope/id, never calling Cloud', async () => {
const r2 = mockR2Bucket()
const { gateway, createAvatarUploadClient } = mockLicensingCloud({})
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
await gw.deletePublicImageVariants(mockPlatform({}, r2.bucket), AVATAR_PREFIX, 'u1')
expect(r2.delete).toHaveBeenCalledWith('user/u1')
expect(createAvatarUploadClient).not.toHaveBeenCalled()
})
it('swallows R2 delete failures (best-effort)', async () => {
const r2 = mockR2Bucket()
r2.delete.mockRejectedValueOnce(new Error('boom'))
const { gateway } = mockLicensingCloud({})
const gw = createImageUploadGateway(mockLicenseBinding(activeBinding()), gateway)
await expect(
gw.deletePublicImageVariants(mockPlatform({}, r2.bucket), LOGO_PREFIX, 'team-1'),
).resolves.toBeUndefined()
})
})