Files
zpan/server/adapters/gateways/licensing-cloud.test.ts
T
agent-kanban-local[bot]andAlex Chen 00f48cf355 feat(avatars): host avatars + team logos on Cloud via SDK 2.4.0; remove public-bucket mode (#467)
* feat(avatars): host avatars + team logos on Cloud via SDK 2.4.0; remove public-bucket mode

Host user avatars and org logos on the ZPan Cloud avatar service
(zpan-cloud-sdk ^2.4.0) instead of a public S3/R2 bucket, then remove the
now-dead storages.mode / public-bucket concept entirely (#456 parts 2-3).

- image-upload gateway: upload/delete via SDK uploadAvatar/deleteAvatar against
  a bound Cloud client; validate mime (AVATAR_CONTENT_TYPES) + size
  (MAX_AVATAR_BYTES) before the call; map cloud error codes to 400/403/413/500;
  unbound instance returns 503 cloud_required (delete is a best-effort no-op).
- licensing-cloud: createAvatarUploadClient builds the client with a plain-object
  bearer header so both the image content-type and Authorization survive hono's
  per-request header merge (a Headers instance would be dropped).
- drop storages.mode (migration via drizzle-kit), StorageRepo.select() no longer
  takes a mode, remove StorageMode / Storage.mode / mode schema+audit+UI+i18n and
  the PUBLIC_IMAGES bucket + PUBLIC_IMAGES_URL wiring.

Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a

* ci(deploy): drop dead PUBLIC_IMAGES R2 provisioning from CF deploy

The Cloud avatar migration removed the PUBLIC_IMAGES binding from
wrangler.toml, so the deploy workflow's R2 public-images steps are dead and
must go too — otherwise every CF deploy keeps re-provisioning a public-read
zpan-public-images bucket (the footgun #456 eliminates) and sets an unused
PUBLIC_IMAGES_URL secret. Removes the bucket-create, managed-public-URL, and
secret steps (steps.r2 was only consumed by the secret step). Also drops a
stale storage-modes line from the v2.0 roadmap.

Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a

---------

Co-authored-by: Alex Chen <alex-chen@mails.agent-kanban.dev>
2026-06-20 00:16:07 -04:00

278 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { deleteAvatar, uploadAvatar } from 'zpan-cloud-sdk'
import { CloudInvalidResponseError, CloudNetworkError, CloudUnboundError } from '../../usecases/ports'
import {
createAvatarUploadClient,
createBoundCloudClient,
createPairing,
pollPairing,
refreshEntitlement,
requestCloudJson,
unbindCloudLicense,
} from './licensing-cloud'
const BASE_URL = 'https://cloud.zpan.space'
function makeResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
json: async () => body,
text: async () => JSON.stringify(body),
} as unknown as Response
}
function headerValue(headers: HeadersInit | undefined, name: string): string | null {
if (headers instanceof Headers) return headers.get(name)
if (Array.isArray(headers)) return new Headers(headers).get(name)
return headers?.[name] ?? null
}
describe('licensing-cloud', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('createPairing', () => {
it('sends POST to /api/pairings with instance info', async () => {
const payload = {
code: 'ABC-123',
pairingUrl: 'https://cloud.zpan.space/pair',
expiresAt: '2026-01-01T00:00:00Z',
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await createPairing(BASE_URL, {
id: 'inst-1',
name: 'My ZPan',
url: 'https://zpan.example.com',
version: '0.0.1',
})
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://cloud.zpan.space/api/pairings')
expect(init.method).toBe('POST')
expect(headerValue(init.headers, 'content-type')).toBe('application/json')
const body = JSON.parse(init.body as string)
expect(body).toEqual({
instance: {
id: 'inst-1',
name: 'My ZPan',
url: 'https://zpan.example.com',
version: '0.0.1',
},
})
expect(result).toEqual(payload)
})
it('throws on non-OK response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Bad Request' }, 400))
await expect(
createPairing(BASE_URL, { id: 'inst-1', name: 'ZPan', url: 'https://zpan.example.com', version: '0.0.1' }),
).rejects.toThrow('Cloud pairing failed')
})
it('throws CloudNetworkError on fetch failure', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error'))
await expect(
createPairing(BASE_URL, { id: 'inst-1', name: 'ZPan', url: 'https://zpan.example.com', version: '0.0.1' }),
).rejects.toThrow(CloudNetworkError)
})
})
describe('pollPairing', () => {
it('sends GET to /api/pairings/:code', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ status: 'pending' }))
await pollPairing(BASE_URL, 'ABC-123')
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://cloud.zpan.space/api/pairings/ABC-123')
expect(headerValue(init.headers, 'content-type')).toBe('application/json')
})
it('returns pending status', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ status: 'pending' }))
const result = await pollPairing(BASE_URL, 'CODE-1')
expect(result.status).toBe('pending')
})
it('returns approved status with refreshToken and entitlement', async () => {
const payload = {
status: 'approved',
refreshToken: 'rt-token',
certificate: 'v4.public.token',
}
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await pollPairing(BASE_URL, 'CODE-2')
expect(result.status).toBe('approved')
expect(result.refreshToken).toBe('rt-token')
expect(result.certificate).toBe('v4.public.token')
})
it('throws on non-OK response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Not Found' }, 404))
await expect(pollPairing(BASE_URL, 'BAD')).rejects.toThrow('Cloud poll failed')
})
it('throws CloudNetworkError on fetch failure', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Timeout'))
await expect(pollPairing(BASE_URL, 'CODE')).rejects.toThrow(CloudNetworkError)
})
})
describe('refreshEntitlement', () => {
it('sends POST to /api/entitlements with Bearer token', async () => {
const payload = { refreshToken: 'new-rt', certificate: 'v4.public.newtoken' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await refreshEntitlement(BASE_URL, 'old-rt')
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://cloud.zpan.space/api/entitlements')
expect(init.method).toBe('POST')
expect(headerValue(init.headers, 'Authorization')).toBe('Bearer old-rt')
expect(init.body).toBeUndefined()
expect(result.refreshToken).toBe('new-rt')
expect(result.certificate).toBe('v4.public.newtoken')
})
it('sends instance info when refreshing entitlement', async () => {
const payload = { refreshToken: 'new-rt', certificate: 'v4.public.newtoken' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
await refreshEntitlement(BASE_URL, 'old-rt', {
id: 'inst-1',
name: 'My ZPan',
url: 'https://zpan.example.com',
version: '0.0.1',
})
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(JSON.parse(init.body as string)).toEqual({
instance: {
id: 'inst-1',
name: 'My ZPan',
url: 'https://zpan.example.com',
version: '0.0.1',
},
})
})
it('throws CloudUnboundError on 401', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Unbound' }, 401))
await expect(refreshEntitlement(BASE_URL, 'old-rt')).rejects.toThrow(CloudUnboundError)
})
it('throws on other non-OK response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Server Error' }, 500))
await expect(refreshEntitlement(BASE_URL, 'old-rt')).rejects.toThrow('Cloud refresh failed')
})
it('throws on missing certificate', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ refreshToken: 'new-rt' }))
await expect(refreshEntitlement(BASE_URL, 'old-rt')).rejects.toThrow(CloudInvalidResponseError)
})
it('throws CloudNetworkError on fetch failure', async () => {
vi.mocked(fetch).mockRejectedValueOnce(new Error('Connection refused'))
await expect(refreshEntitlement(BASE_URL, 'old-rt')).rejects.toThrow(CloudNetworkError)
})
it('throws CloudNetworkError for non-Error fetch failures', async () => {
vi.mocked(fetch).mockRejectedValueOnce('offline')
const result = refreshEntitlement(BASE_URL, 'old-rt')
await expect(result).rejects.toThrow(CloudNetworkError)
await expect(result).rejects.toThrow('Cloud network error')
})
})
describe('unbindCloudLicense', () => {
it('sends DELETE to the Cloud license route with Bearer token', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, 204))
await unbindCloudLicense(BASE_URL, 'binding_1', 'rt-bound')
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://cloud.zpan.space/api/licenses/binding_1')
expect(init.method).toBe('DELETE')
expect(headerValue(init.headers, 'Authorization')).toBe('Bearer rt-bound')
expect(init.body).toBeUndefined()
})
it('throws on non-OK response', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unbound' }, 401))
await expect(unbindCloudLicense(BASE_URL, 'binding_1', 'rt-bound')).rejects.toThrow('Cloud unbind failed')
})
})
describe('requestCloudJson', () => {
it('unwraps SDK responses from bound clients', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ state: 'revoked' }))
const client = createBoundCloudClient(BASE_URL, 'rt-bound')
const result = await requestCloudJson(
client.stores[':storeId']['gift-cards'][':code'].$patch({
param: { storeId: 'store_1', code: 'ZS123' },
json: { disabled: true },
}),
)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://cloud.zpan.space/api/stores/store_1/gift-cards/ZS123')
expect(init.method).toBe('PATCH')
expect(headerValue(init.headers, 'Authorization')).toBe('Bearer rt-bound')
expect(JSON.parse(init.body as string)).toEqual({ disabled: true })
expect(result).toEqual({ state: 'revoked' })
})
})
describe('createAvatarUploadClient', () => {
it('uploadAvatar sends the image content type AND the bearer token (both survive the per-request merge)', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ url: 'https://cloud/x.png', key: 'k' }, 201))
const client = createAvatarUploadClient(BASE_URL, 'rt-avatar')
const body = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' })
await uploadAvatar(client, { scope: 'user', id: 'u1', body, contentType: 'image/png' })
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://cloud.zpan.space/api/avatars/user/u1')
expect(init.method).toBe('PUT')
// The image content type must reach Cloud (not application/json) AND the
// Authorization header must NOT be dropped by hono's per-request header merge.
expect(new Headers(init.headers).get('content-type')).toBe('image/png')
expect(new Headers(init.headers).get('authorization')).toBe('Bearer rt-avatar')
expect(init.body).toBe(body)
})
it('deleteAvatar sends an authenticated DELETE to /avatars/:scope/:id', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, 204))
const client = createAvatarUploadClient(BASE_URL, 'rt-avatar')
await deleteAvatar(client, { scope: 'team', id: 'team-1' })
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://cloud.zpan.space/api/avatars/team/team-1')
expect(init.method).toBe('DELETE')
expect(new Headers(init.headers).get('authorization')).toBe('Bearer rt-avatar')
})
})
})