mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-21 04:59:47 +08:00
* 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>
278 lines
10 KiB
TypeScript
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')
|
|
})
|
|
})
|
|
})
|