mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-21 13:20:33 +08:00
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>
This commit is contained in:
co-authored by
Alex Chen
parent
0138e7779e
commit
00f48cf355
@@ -1,211 +1,252 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Platform } from '../../platform/interface'
|
||||
import type { S3Gateway, StorageRecord, StorageRepo } from '../../usecases/ports'
|
||||
import { createImageUploadGateway, isImageMime } from './image-upload'
|
||||
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
|
||||
|
||||
function mockR2Bucket() {
|
||||
const AVATAR_PREFIX = '_system/avatars'
|
||||
const LOGO_PREFIX = '_system/org-logos'
|
||||
|
||||
function mockPlatform(env: Record<string, string | undefined> = {}): Platform {
|
||||
return { db: {} as Any, getEnv: (k: string) => env[k], getBinding: () => undefined } as unknown as Platform
|
||||
}
|
||||
|
||||
function activeBinding(refreshToken: string | null = 'refresh-token'): LicenseState {
|
||||
return {
|
||||
put: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
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 mockPlatform(opts: { r2?: ReturnType<typeof mockR2Bucket>; publicUrl?: string } = {}): Platform {
|
||||
return {
|
||||
db: {} as Any,
|
||||
getEnv: (key) => (key === 'PUBLIC_IMAGES_URL' ? opts.publicUrl : undefined),
|
||||
getBinding: <T>(key: string) => (key === 'PUBLIC_IMAGES' && opts.r2 ? (opts.r2 as unknown as T) : undefined),
|
||||
}
|
||||
function mockLicenseBinding(binding: LicenseState | null): LicenseBindingRepo {
|
||||
return { loadActiveLicenseBinding: vi.fn(async () => binding) } as unknown as LicenseBindingRepo
|
||||
}
|
||||
|
||||
// A StorageRepo whose `select('public')` either returns a storage or throws
|
||||
// (no public storage configured). Only `select` is exercised by the gateway.
|
||||
function mockStorages(opts: { storage?: StorageRecord; selectThrows?: boolean } = {}): StorageRepo {
|
||||
return {
|
||||
select: async () => {
|
||||
if (opts.selectThrows) throw new Error('no storage')
|
||||
return opts.storage as StorageRecord
|
||||
},
|
||||
} as unknown as StorageRepo
|
||||
// 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 mockS3() {
|
||||
return {
|
||||
putObject: vi.fn().mockResolvedValue(16),
|
||||
getPublicUrl: vi.fn().mockReturnValue('https://s3.example/bucket/key'),
|
||||
deleteObject: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as S3Gateway & {
|
||||
putObject: ReturnType<typeof vi.fn>
|
||||
getPublicUrl: ReturnType<typeof vi.fn>
|
||||
deleteObject: ReturnType<typeof vi.fn>
|
||||
}
|
||||
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]}`, { type })
|
||||
return new File([new Uint8Array(bytes)], `f.${type.split('/')[1] ?? 'bin'}`, { type })
|
||||
}
|
||||
|
||||
describe('isImageMime', () => {
|
||||
it('accepts png/jpeg/webp', () => {
|
||||
expect(isImageMime('image/png')).toBe(true)
|
||||
expect(isImageMime('image/jpeg')).toBe(true)
|
||||
expect(isImageMime('image/webp')).toBe(true)
|
||||
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 other mimes', () => {
|
||||
expect(isImageMime('image/gif')).toBe(false)
|
||||
expect(isImageMime('application/pdf')).toBe(false)
|
||||
expect(isImageMime('')).toBe(false)
|
||||
expect(isImageMime(undefined)).toBe(false)
|
||||
expect(isImageMime(42)).toBe(false)
|
||||
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 — R2 binding path', () => {
|
||||
it('uses R2 binding when PUBLIC_IMAGES + PUBLIC_IMAGES_URL both set', async () => {
|
||||
const r2 = mockR2Bucket()
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
|
||||
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(platform, '_system/avatars', 'u1', 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://pub-abc.r2.dev/_system/avatars/u1.png')
|
||||
expect(r2.put).toHaveBeenCalledOnce()
|
||||
expect(r2.put.mock.calls[0]?.[0]).toBe('_system/avatars/u1.png')
|
||||
expect(r2.put.mock.calls[0]?.[2]).toEqual({ httpMetadata: { contentType: 'image/png' } })
|
||||
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 mime to correct file extension (jpeg → jpg)', async () => {
|
||||
const r2 = mockR2Bucket()
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
|
||||
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)
|
||||
|
||||
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/jpeg'))
|
||||
await gw.uploadPublicImage(mockPlatform(), LOGO_PREFIX, 'team-1', makeFile('image/webp'))
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.url).toMatch(/\.jpg$/)
|
||||
expect(put.mock.calls[0]?.[0]).toEqual({ param: { scope: 'team', id: 'team-1' } })
|
||||
})
|
||||
|
||||
it('trims a trailing slash in PUBLIC_IMAGES_URL', async () => {
|
||||
const r2 = mockR2Bucket()
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev/' })
|
||||
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(platform, '_system/avatars', 'u1', makeFile('image/png'))
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.url).toBe('https://pub-abc.r2.dev/_system/avatars/u1.png')
|
||||
})
|
||||
|
||||
it('rejects invalid mime (gif) before touching R2', async () => {
|
||||
const r2 = mockR2Bucket()
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
|
||||
|
||||
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/gif'))
|
||||
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(r2.put).not.toHaveBeenCalled()
|
||||
expect(createAvatarUploadClient).not.toHaveBeenCalled()
|
||||
expect(put).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects file > 2 MiB before touching R2', async () => {
|
||||
const r2 = mockR2Bucket()
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
|
||||
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(platform, '_system/avatars', 'u1', makeFile('image/png', 3 * 1024 * 1024))
|
||||
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(r2.put).not.toHaveBeenCalled()
|
||||
expect(put).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to S3 path when binding is missing (PUBLIC_IMAGES_URL alone ignored)', async () => {
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform({ publicUrl: 'https://pub-abc.r2.dev' })
|
||||
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(platform, '_system/avatars', 'u1', makeFile('image/png'))
|
||||
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('falls back to S3 path when PUBLIC_IMAGES_URL is missing (binding alone ignored)', async () => {
|
||||
const r2 = mockR2Bucket()
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform({ r2 })
|
||||
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(platform, '_system/avatars', 'u1', makeFile('image/png'))
|
||||
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(r2.put).not.toHaveBeenCalled()
|
||||
if (!result.ok) expect(result.status).toBe(localStatus)
|
||||
})
|
||||
|
||||
it('returns 503 when neither binding nor public S3 storage is available', async () => {
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform()
|
||||
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(platform, '_system/avatars', 'u1', makeFile('image/png'))
|
||||
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)
|
||||
if (!result.ok) expect(result.status).toBe(500)
|
||||
})
|
||||
|
||||
it('uploads via S3 gateway when a public storage is configured', async () => {
|
||||
const s3 = mockS3()
|
||||
const storage = { id: 's1', bucket: 'b', endpoint: 'https://s3.example' } as unknown as StorageRecord
|
||||
const gw = createImageUploadGateway(s3, mockStorages({ storage }))
|
||||
const platform = mockPlatform()
|
||||
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(platform, '_system/avatars', 'u1', makeFile('image/png'))
|
||||
const result = await gw.uploadPublicImage(mockPlatform(), AVATAR_PREFIX, 'u1', makeFile('image/png'))
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.url).toBe('https://s3.example/bucket/key')
|
||||
expect(s3.putObject).toHaveBeenCalledOnce()
|
||||
expect(s3.putObject.mock.calls[0]?.[1]).toBe('_system/avatars/u1.png')
|
||||
expect(s3.getPublicUrl.mock.calls[0]?.[1]).toBe('_system/avatars/u1.png')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deletePublicImageVariants — R2 binding path', () => {
|
||||
it('deletes all 3 mime variants via R2 binding', async () => {
|
||||
const r2 = mockR2Bucket()
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
|
||||
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(platform, '_system/avatars', 'u1')
|
||||
await gw.deletePublicImageVariants(mockPlatform(), AVATAR_PREFIX, 'u1')
|
||||
|
||||
expect(r2.delete).toHaveBeenCalledTimes(3)
|
||||
const keys = r2.delete.mock.calls.map((c) => c[0] as string)
|
||||
expect(keys).toContain('_system/avatars/u1.png')
|
||||
expect(keys).toContain('_system/avatars/u1.jpg')
|
||||
expect(keys).toContain('_system/avatars/u1.webp')
|
||||
expect(del).toHaveBeenCalledOnce()
|
||||
expect(del.mock.calls[0]?.[0]).toEqual({ param: { scope: 'user', id: 'u1' } })
|
||||
})
|
||||
|
||||
it('deletes all 3 mime variants via S3 gateway when a public storage is configured', async () => {
|
||||
const s3 = mockS3()
|
||||
const storage = { id: 's1', bucket: 'b', endpoint: 'https://s3.example' } as unknown as StorageRecord
|
||||
const gw = createImageUploadGateway(s3, mockStorages({ storage }))
|
||||
const platform = mockPlatform()
|
||||
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 gw.deletePublicImageVariants(platform, '_system/avatars', 'u1')
|
||||
|
||||
expect(s3.deleteObject).toHaveBeenCalledTimes(3)
|
||||
const keys = s3.deleteObject.mock.calls.map((c) => c[1] as string)
|
||||
expect(keys).toContain('_system/avatars/u1.png')
|
||||
expect(keys).toContain('_system/avatars/u1.jpg')
|
||||
expect(keys).toContain('_system/avatars/u1.webp')
|
||||
await expect(gw.deletePublicImageVariants(mockPlatform(), AVATAR_PREFIX, 'u1')).resolves.toBeUndefined()
|
||||
expect(createAvatarUploadClient).not.toHaveBeenCalled()
|
||||
expect(del).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a no-op when no backend is configured', async () => {
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform()
|
||||
await expect(gw.deletePublicImageVariants(platform, '_system/avatars', 'u1')).resolves.toBeUndefined()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,102 +1,112 @@
|
||||
import { mimeToExt } from '../../lib/mime-utils'
|
||||
import {
|
||||
AVATAR_CONTENT_TYPES,
|
||||
type AvatarContentType,
|
||||
type AvatarScope,
|
||||
avatarUploadResponseSchema,
|
||||
deleteAvatar,
|
||||
MAX_AVATAR_BYTES,
|
||||
uploadAvatar,
|
||||
} from 'zpan-cloud-sdk'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../../../shared/constants'
|
||||
import type { Platform } from '../../platform/interface'
|
||||
import type {
|
||||
ImageMime,
|
||||
ImageUpload,
|
||||
ImageUploadResult,
|
||||
S3Gateway,
|
||||
StorageRecord,
|
||||
StorageRepo,
|
||||
import {
|
||||
AVATAR_PREFIX,
|
||||
type ImageUpload,
|
||||
type ImageUploadResult,
|
||||
type LicenseBindingRepo,
|
||||
type LicensingCloudGateway,
|
||||
LOGO_PREFIX,
|
||||
} from '../../usecases/ports'
|
||||
import { IMAGE_MIMES, MAX_IMAGE_SIZE } from '../../usecases/ports'
|
||||
|
||||
export function isImageMime(v: unknown): v is ImageMime {
|
||||
return typeof v === 'string' && (IMAGE_MIMES as readonly string[]).includes(v)
|
||||
export function isAvatarContentType(v: unknown): v is AvatarContentType {
|
||||
return typeof v === 'string' && (AVATAR_CONTENT_TYPES as readonly string[]).includes(v)
|
||||
}
|
||||
|
||||
export function imageKey(prefix: string, id: string, mime: ImageMime): string {
|
||||
return `${prefix}/${id}.${mimeToExt(mime)}`
|
||||
// Only the avatar + org-logo usecases call this port, and they pass these exact
|
||||
// prefixes — an unknown prefix is a programming error, so fail fast.
|
||||
function prefixToScope(prefix: string): AvatarScope {
|
||||
if (prefix === AVATAR_PREFIX) return 'user'
|
||||
if (prefix === LOGO_PREFIX) return 'team'
|
||||
throw new Error(`Unknown image prefix: ${prefix}`)
|
||||
}
|
||||
|
||||
// Minimal R2Bucket interface we actually call — typed locally so we don't
|
||||
// depend on @cloudflare/workers-types on non-CF builds.
|
||||
interface R2BucketLike {
|
||||
put(
|
||||
key: string,
|
||||
value: ArrayBuffer | ArrayBufferView | ReadableStream | Blob,
|
||||
options?: { httpMetadata?: { contentType?: string } },
|
||||
): Promise<unknown>
|
||||
delete(key: string): Promise<void>
|
||||
function cloudErrorCode(data: unknown): string | null {
|
||||
if (!data || typeof data !== 'object' || !('error' in data)) return null
|
||||
const error = (data as { error: unknown }).error
|
||||
if (typeof error === 'string') return error
|
||||
if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') return error.code
|
||||
return null
|
||||
}
|
||||
|
||||
type Backend =
|
||||
| { kind: 'r2'; bucket: R2BucketLike; publicUrlBase: string }
|
||||
| { kind: 's3'; storage: StorageRecord }
|
||||
| { kind: 'none' }
|
||||
// Maps a non-2xx Cloud avatar response to the local outcome status. Cloud error
|
||||
// codes: unsupported_media_type → 400, payload_too_large → 413,
|
||||
// license_inactive → 403, everything else (incl. malformed) → 500.
|
||||
async function cloudUploadError(res: {
|
||||
status: number
|
||||
json(): Promise<unknown>
|
||||
}): Promise<Extract<ImageUploadResult, { ok: false }>> {
|
||||
const code = cloudErrorCode(await res.json().catch(() => null))
|
||||
switch (code) {
|
||||
case 'unsupported_media_type':
|
||||
return { ok: false, status: 400, error: 'unsupported_media_type' }
|
||||
case 'payload_too_large':
|
||||
return { ok: false, status: 413, error: 'payload_too_large' }
|
||||
case 'license_inactive':
|
||||
return { ok: false, status: 403, error: 'license_inactive' }
|
||||
default:
|
||||
return { ok: false, status: 500, error: code ?? `cloud_request_failed_${res.status}` }
|
||||
}
|
||||
}
|
||||
|
||||
export function createImageUploadGateway(s3: S3Gateway, storages: StorageRepo): ImageUpload {
|
||||
// CF deployment with `PUBLIC_IMAGES` binding + `PUBLIC_IMAGES_URL` env var →
|
||||
// writes via R2 binding (zero-auth, zero-egress), reads via R2's public
|
||||
// domain (direct browser fetch, no Worker round-trip per image).
|
||||
// Everything else → falls back to the user-configured `mode='public'` S3
|
||||
// storage in the `storages` table.
|
||||
async function getBackend(platform: Platform): Promise<Backend> {
|
||||
const r2 = platform.getBinding<R2BucketLike>('PUBLIC_IMAGES')
|
||||
const publicUrl = platform.getEnv('PUBLIC_IMAGES_URL')
|
||||
if (r2 && publicUrl) {
|
||||
return { kind: 'r2', bucket: r2, publicUrlBase: publicUrl.replace(/\/$/, '') }
|
||||
}
|
||||
try {
|
||||
const storage = await storages.select('public')
|
||||
return { kind: 's3', storage }
|
||||
} catch {
|
||||
return { kind: 'none' }
|
||||
}
|
||||
// Host user avatars + team logos on the ZPan Cloud avatar service. Requires the
|
||||
// instance to be paired to Cloud (an active license binding with a refresh token);
|
||||
// an unbound instance can't host images, so upload returns `cloud_required` (503)
|
||||
// and delete is a best-effort no-op. Never throws on the unbound path.
|
||||
export function createImageUploadGateway(
|
||||
licenseBinding: LicenseBindingRepo,
|
||||
licensingCloud: LicensingCloudGateway,
|
||||
): ImageUpload {
|
||||
function cloudBaseUrl(platform: Platform): string {
|
||||
return platform.getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT
|
||||
}
|
||||
|
||||
return {
|
||||
async uploadPublicImage(platform, prefix, id, file): Promise<ImageUploadResult> {
|
||||
if (!isImageMime(file.type)) {
|
||||
return { ok: false, status: 400, error: 'Only PNG, JPG, and WebP images are allowed' }
|
||||
const contentType = file.type
|
||||
if (!isAvatarContentType(contentType)) {
|
||||
return { ok: false, status: 400, error: 'Only PNG, JPG, WebP, and GIF images are allowed' }
|
||||
}
|
||||
if (file.size > MAX_IMAGE_SIZE) {
|
||||
return { ok: false, status: 413, error: 'File too large. Max 2 MiB.' }
|
||||
if (file.size > MAX_AVATAR_BYTES) {
|
||||
return { ok: false, status: 413, error: 'File too large. Max 1 MiB.' }
|
||||
}
|
||||
|
||||
const backend = await getBackend(platform)
|
||||
if (backend.kind === 'none') {
|
||||
return { ok: false, status: 503, error: 'No public storage configured' }
|
||||
const binding = await licenseBinding.loadActiveLicenseBinding()
|
||||
if (!binding?.refreshToken) return { ok: false, status: 503, error: 'cloud_required' }
|
||||
|
||||
const client = licensingCloud.createAvatarUploadClient(cloudBaseUrl(platform), binding.refreshToken)
|
||||
try {
|
||||
const res = await uploadAvatar(client, { scope: prefixToScope(prefix), id, body: file, contentType })
|
||||
if (!res.ok) return cloudUploadError(res)
|
||||
const parsed = avatarUploadResponseSchema.safeParse(await res.json())
|
||||
if (!parsed.success) return { ok: false, status: 500, error: 'invalid_cloud_response' }
|
||||
return { ok: true, url: parsed.data.url }
|
||||
} catch {
|
||||
return { ok: false, status: 500, error: 'cloud_request_failed' }
|
||||
}
|
||||
|
||||
const key = imageKey(prefix, id, file.type)
|
||||
const bytes = new Uint8Array(await file.arrayBuffer())
|
||||
|
||||
if (backend.kind === 'r2') {
|
||||
await backend.bucket.put(key, bytes, { httpMetadata: { contentType: file.type } })
|
||||
return { ok: true, url: `${backend.publicUrlBase}/${key}` }
|
||||
}
|
||||
|
||||
await s3.putObject(backend.storage, key, bytes, file.type)
|
||||
return { ok: true, url: s3.getPublicUrl(backend.storage, key) }
|
||||
},
|
||||
|
||||
// Best-effort delete of every mime variant of a public image. DB clearing is
|
||||
// the caller's responsibility — this only touches the backend object store.
|
||||
// Best-effort delete of the Cloud-hosted image. DB clearing is the caller's
|
||||
// responsibility — this only removes the object from the Cloud avatar service.
|
||||
// An unbound instance has nothing to delete, so it is a silent no-op.
|
||||
async deletePublicImageVariants(platform, prefix, id): Promise<void> {
|
||||
const backend = await getBackend(platform)
|
||||
if (backend.kind === 'none') return
|
||||
|
||||
if (backend.kind === 'r2') {
|
||||
await Promise.allSettled(IMAGE_MIMES.map((mime) => backend.bucket.delete(imageKey(prefix, id, mime))))
|
||||
return
|
||||
}
|
||||
const binding = await licenseBinding.loadActiveLicenseBinding()
|
||||
if (!binding?.refreshToken) return
|
||||
|
||||
const client = licensingCloud.createAvatarUploadClient(cloudBaseUrl(platform), binding.refreshToken)
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
IMAGE_MIMES.map((mime) => s3.deleteObject(backend.storage, imageKey(prefix, id, mime))),
|
||||
)
|
||||
await deleteAvatar(client, { scope: prefixToScope(prefix), id })
|
||||
} catch (err) {
|
||||
console.warn('[image-upload] S3 cleanup skipped:', err)
|
||||
console.warn('[image-upload] cloud avatar delete skipped:', err)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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,
|
||||
@@ -241,4 +243,35 @@ describe('licensing-cloud', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { hc } from 'hono/client'
|
||||
import type { z } from 'zod'
|
||||
import { type CloudClient, createCloudClient } from 'zpan-cloud-sdk'
|
||||
import { type AppType, type CloudClient, createCloudClient } from 'zpan-cloud-sdk'
|
||||
import {
|
||||
type CloudInstanceInfo,
|
||||
CloudInvalidResponseError,
|
||||
@@ -22,6 +23,20 @@ export function createBoundCloudClient(baseUrl: string, refreshToken: string): C
|
||||
return createCloudClient({ baseUrl: cloudApiBaseUrl(baseUrl), token: refreshToken, headers: JSON_HEADERS })
|
||||
}
|
||||
|
||||
// A bound client for the Cloud avatar service. Avatar uploads stream raw image
|
||||
// bytes with an image `Content-Type`, so this client must NOT carry JSON_HEADERS.
|
||||
// It is also built with `hc` directly (not `createCloudClient`) so the bearer
|
||||
// token stays a PLAIN-OBJECT header: the SDK's `uploadAvatar` sets a per-request
|
||||
// `Content-Type`, and hono's `hc` drops a client-level `Headers` instance when it
|
||||
// merges per-request headers (its `deepMerge` spreads a `Headers` instance to
|
||||
// `{}`), which would strip Authorization. A plain object survives the merge, so
|
||||
// both the bearer token and the image content-type reach Cloud.
|
||||
export function createAvatarUploadClient(baseUrl: string, refreshToken: string): CloudClient {
|
||||
return hc<AppType>(cloudApiBaseUrl(baseUrl), {
|
||||
init: { headers: { authorization: `Bearer ${refreshToken}` } },
|
||||
})
|
||||
}
|
||||
|
||||
function createAnonymousCloudClient(baseUrl: string): CloudClient {
|
||||
return createCloudClient({ baseUrl: cloudApiBaseUrl(baseUrl), headers: JSON_HEADERS })
|
||||
}
|
||||
@@ -174,6 +189,7 @@ export function createLicensingCloudGateway(): LicensingCloudGateway {
|
||||
unbindCloudLicense,
|
||||
confirmCloudLicense,
|
||||
createBoundCloudClient,
|
||||
createAvatarUploadClient,
|
||||
requestCloudJson,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,6 @@ function makeStorage(overrides: Partial<Storage> = {}): Storage {
|
||||
return {
|
||||
id: 's1',
|
||||
title: 'Test',
|
||||
mode: 'private',
|
||||
bucket: 'my-bucket',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
|
||||
@@ -61,8 +61,8 @@ const STORAGE_ID = 'st-conflict'
|
||||
async function insertStorage(db: TestDb) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'Test', 'private', 'bucket', 'https://s3.example.com', 'us-east-1', 'K', 'S', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'Test', 'bucket', 'https://s3.example.com', 'us-east-1', 'K', 'S', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ function commitConflictPlan(db: TestDb, orgId: string, plan: ConflictPlan, userI
|
||||
async function insertStorage(db: TestDb, id = 'st-1') {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${id}, 'Test', 'private', 'bucket', 'https://s3.example.com', 'us-east-1', 'K', 'S', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${id}, 'Test', 'bucket', 'https://s3.example.com', 'us-east-1', 'K', 'S', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ async function insertStorage(db: TestDb, opts: { id?: string; used?: number } =
|
||||
const id = opts.id ?? 'st-1'
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${id}, 'Test S3', 'private', 'test-bucket', 'https://s3.example.com', 'us-east-1',
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${id}, 'Test S3', 'test-bucket', 'https://s3.example.com', 'us-east-1',
|
||||
'AKID', 'SECRET', '$UID/$RAW_NAME', '', 0, ${opts.used ?? 0}, 'active', ${now}, ${now})
|
||||
`)
|
||||
return id
|
||||
|
||||
@@ -8,7 +8,6 @@ describe('createStorage', () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await createStorageRepo(db).create({
|
||||
title: 'My Storage',
|
||||
mode: 'private',
|
||||
bucket: 'my-bucket',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
@@ -23,7 +22,6 @@ describe('createStorage', () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await createStorageRepo(db).create({
|
||||
title: 'My Storage',
|
||||
mode: 'private',
|
||||
bucket: 'my-bucket',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
@@ -38,7 +36,6 @@ describe('createStorage', () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await createStorageRepo(db).create({
|
||||
title: 'My Storage',
|
||||
mode: 'private',
|
||||
bucket: 'my-bucket',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
@@ -54,7 +51,6 @@ describe('createStorage', () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await createStorageRepo(db).create({
|
||||
title: 'My Storage',
|
||||
mode: 'private',
|
||||
bucket: 'my-bucket',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
@@ -69,7 +65,6 @@ describe('createStorage', () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await createStorageRepo(db).create({
|
||||
title: 'My Storage',
|
||||
mode: 'private',
|
||||
bucket: 'my-bucket',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
@@ -84,7 +79,6 @@ describe('createStorage', () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await createStorageRepo(db).create({
|
||||
title: 'My Storage',
|
||||
mode: 'public',
|
||||
bucket: 'my-bucket',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'auto',
|
||||
@@ -100,7 +94,6 @@ describe('createStorage', () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await createStorageRepo(db).create({
|
||||
title: 'Persisted',
|
||||
mode: 'private',
|
||||
bucket: 'my-bucket',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
@@ -118,7 +111,6 @@ describe('updateStorage', () => {
|
||||
async function seed(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
|
||||
return createStorageRepo(db).create({
|
||||
title: 'Original',
|
||||
mode: 'private',
|
||||
bucket: 'original-bucket',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
@@ -152,7 +144,6 @@ describe('updateStorage', () => {
|
||||
const created = await seed(db)
|
||||
const updated = await createStorageRepo(db).update(created.id, {
|
||||
title: 'Updated',
|
||||
mode: 'public',
|
||||
bucket: 'new-bucket',
|
||||
endpoint: 'https://r2.example.com',
|
||||
region: 'auto',
|
||||
@@ -163,7 +154,6 @@ describe('updateStorage', () => {
|
||||
status: 'disabled',
|
||||
})
|
||||
expect(updated?.title).toBe('Updated')
|
||||
expect(updated?.mode).toBe('public')
|
||||
expect(updated?.bucket).toBe('new-bucket')
|
||||
expect(updated?.endpoint).toBe('https://r2.example.com')
|
||||
expect(updated?.region).toBe('auto')
|
||||
@@ -203,7 +193,6 @@ describe('listStorages', () => {
|
||||
const { db } = await createTestApp()
|
||||
await createStorageRepo(db).create({
|
||||
title: 'First',
|
||||
mode: 'private',
|
||||
bucket: 'b1',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
@@ -213,7 +202,6 @@ describe('listStorages', () => {
|
||||
})
|
||||
await createStorageRepo(db).create({
|
||||
title: 'Second',
|
||||
mode: 'public',
|
||||
bucket: 'b2',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
@@ -238,7 +226,6 @@ describe('getStorage', () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await createStorageRepo(db).create({
|
||||
title: 'Findable',
|
||||
mode: 'private',
|
||||
bucket: 'b',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
@@ -254,12 +241,10 @@ describe('getStorage', () => {
|
||||
describe('selectStorage', () => {
|
||||
async function seedActive(
|
||||
db: Awaited<ReturnType<typeof createTestApp>>['db'],
|
||||
mode: 'private' | 'public',
|
||||
opts: { capacity?: number; used?: number; status?: string } = {},
|
||||
) {
|
||||
return createStorageRepo(db).create({
|
||||
title: 'Seed',
|
||||
mode,
|
||||
bucket: 'b',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
@@ -269,29 +254,16 @@ describe('selectStorage', () => {
|
||||
})
|
||||
}
|
||||
|
||||
it('returns an active private storage with unlimited capacity', async () => {
|
||||
it('returns an active storage with unlimited capacity', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await seedActive(db, 'private')
|
||||
const found = await createStorageRepo(db).select('private')
|
||||
const created = await seedActive(db)
|
||||
const found = await createStorageRepo(db).select()
|
||||
expect(found.id).toBe(created.id)
|
||||
})
|
||||
|
||||
it('returns an active public storage when requested', async () => {
|
||||
it('throws when no active storage exists', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await seedActive(db, 'public')
|
||||
const found = await createStorageRepo(db).select('public')
|
||||
expect(found.id).toBe(created.id)
|
||||
})
|
||||
|
||||
it('throws when no active storage exists for the requested mode', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage')
|
||||
})
|
||||
|
||||
it('throws when storage is present but mode does not match', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await seedActive(db, 'public')
|
||||
await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage')
|
||||
await expect(createStorageRepo(db).select()).rejects.toThrow('No available storage')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -306,7 +278,6 @@ describe('deleteStorage', () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await createStorageRepo(db).create({
|
||||
title: 'Deletable',
|
||||
mode: 'private',
|
||||
bucket: 'b',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
@@ -323,7 +294,6 @@ describe('deleteStorage', () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await createStorageRepo(db).create({
|
||||
title: 'In Use',
|
||||
mode: 'private',
|
||||
bucket: 'b',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
|
||||
@@ -32,7 +32,6 @@ export function createStorageRepo(db: Database): StorageRepo {
|
||||
const row: StorageRow = {
|
||||
id: nanoid(),
|
||||
title: input.title,
|
||||
mode: input.mode,
|
||||
bucket: input.bucket,
|
||||
endpoint: input.endpoint,
|
||||
region: input.region ?? 'auto',
|
||||
@@ -65,7 +64,6 @@ export function createStorageRepo(db: Database): StorageRepo {
|
||||
const now = new Date()
|
||||
const updated = {
|
||||
title: input.title ?? existing.title,
|
||||
mode: input.mode ?? existing.mode,
|
||||
bucket: input.bucket ?? existing.bucket,
|
||||
endpoint: input.endpoint ?? existing.endpoint,
|
||||
region: input.region ?? existing.region,
|
||||
@@ -95,17 +93,11 @@ export function createStorageRepo(db: Database): StorageRepo {
|
||||
return 'ok'
|
||||
},
|
||||
|
||||
async select(mode) {
|
||||
async select() {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(storages)
|
||||
.where(
|
||||
and(
|
||||
eq(storages.mode, mode),
|
||||
eq(storages.status, 'active'),
|
||||
or(eq(storages.capacity, 0), lt(storages.used, storages.capacity)),
|
||||
),
|
||||
)
|
||||
.where(and(eq(storages.status, 'active'), or(eq(storages.capacity, 0), lt(storages.used, storages.capacity))))
|
||||
.orderBy(asc(storages.createdAt))
|
||||
.limit(1)
|
||||
|
||||
|
||||
@@ -56,6 +56,8 @@ export function createDeps(platform: Platform): Deps {
|
||||
const s3 = new S3Service()
|
||||
const storages = createStorageRepo(db)
|
||||
const systemOptions = createSystemOptionsRepo(db)
|
||||
const licenseBinding = createLicenseBindingRepo(db)
|
||||
const licensingCloud = createLicensingCloudGateway()
|
||||
return {
|
||||
activity: createActivityRepo(db),
|
||||
announcements: createAnnouncementRepo(db),
|
||||
@@ -74,10 +76,10 @@ export function createDeps(platform: Platform): Deps {
|
||||
invites: createInviteRepo(db),
|
||||
imageHostingConfigs: createImageHostingConfigRepo(db),
|
||||
imageHosting: createImageHostingRepo(db),
|
||||
imageUpload: createImageUploadGateway(s3, storages),
|
||||
imageUpload: createImageUploadGateway(licenseBinding, licensingCloud),
|
||||
instance: createInstanceRepo(db),
|
||||
licenseBinding: createLicenseBindingRepo(db),
|
||||
licensingCloud: createLicensingCloudGateway(),
|
||||
licenseBinding,
|
||||
licensingCloud,
|
||||
matter: createMatterRepo(db),
|
||||
memberCount: createMemberCountRepo(db),
|
||||
notifications: createNotificationRepo(db),
|
||||
|
||||
@@ -58,7 +58,6 @@ export const webdavLocks = sqliteTable(
|
||||
export const storages = sqliteTable('storages', {
|
||||
id: text('id').primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
mode: text('mode').notNull(),
|
||||
bucket: text('bucket').notNull(),
|
||||
endpoint: text('endpoint').notNull(),
|
||||
region: text('region').notNull().default('auto'),
|
||||
|
||||
@@ -350,8 +350,8 @@ describe('background jobs API', () => {
|
||||
async function seedStorage(db: TestDb): Promise<void> {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('route-storage', 'Route Storage', 'private', 'bucket', 'https://s3.example.com', 'auto', 'ak', 'sk', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('route-storage', 'Route Storage', 'bucket', 'https://s3.example.com', 'auto', 'ak', 'sk', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -76,12 +76,12 @@ async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (
|
||||
id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
|
||||
egress_credit_per_unit, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'remote-download-storage', 'Remote Download Storage', 'private', 'test-bucket',
|
||||
'remote-download-storage', 'Remote Download Storage', 'test-bucket',
|
||||
'https://s3.example.com', 'auto', 'test-access-key', 'test-secret-key',
|
||||
'$UID/$RAW_NAME', '', 0, 0, 'active', 0, ${100 * 1024 * 1024}, 1, ${now}, ${now}
|
||||
)
|
||||
|
||||
@@ -999,7 +999,6 @@ describe('DELETE /api/image-hosting/config', () => {
|
||||
await db.insert(schema.storages).values({
|
||||
id: storageId,
|
||||
title: 'Test Storage',
|
||||
mode: 's3',
|
||||
bucket: 'test',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'auto',
|
||||
|
||||
@@ -15,7 +15,6 @@ beforeEach(() => {
|
||||
const validStorage = {
|
||||
id: 'st-ihost-1',
|
||||
title: 'Test S3',
|
||||
mode: 'private',
|
||||
bucket: 'test-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
@@ -29,8 +28,8 @@ type TestAuth = Awaited<ReturnType<typeof createTestApp>>['auth']
|
||||
async function insertStorage(db: TestDb) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.mode}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -117,8 +117,8 @@ async function signUp(app: ReturnType<typeof createApp>, db: Awaited<ReturnType<
|
||||
async function insertStorage(db: Awaited<ReturnType<typeof buildAppWithDb>>['db'], id: string) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${id}, 'CF S3', 'private', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${id}, 'CF S3', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,6 @@ afterEach(() => {
|
||||
const validStorage = {
|
||||
id: 'st-1',
|
||||
title: 'Test S3',
|
||||
mode: 'private',
|
||||
bucket: 'test-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
@@ -103,12 +102,12 @@ async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
const metered = opts.metered ? 1 : 0
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (
|
||||
id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
|
||||
egress_credit_per_unit, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
${validStorage.id}, ${validStorage.title}, ${validStorage.mode}, ${validStorage.bucket},
|
||||
${validStorage.id}, ${validStorage.title}, ${validStorage.bucket},
|
||||
${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey},
|
||||
'', '', 0, 0, 'active', ${metered}, ${100 * 1024 ** 2}, 1, ${now}, ${now}
|
||||
)
|
||||
@@ -839,8 +838,8 @@ describe('Matter service', () => {
|
||||
const { db } = await createTestApp()
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
|
||||
const matter = await createMatter(db, {
|
||||
@@ -862,8 +861,8 @@ describe('Matter service', () => {
|
||||
const { db } = await createTestApp()
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
|
||||
const matter = await createMatter(db, {
|
||||
@@ -886,8 +885,8 @@ describe('Matter service', () => {
|
||||
const { db } = await createTestApp()
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
|
||||
await createMatter(db, {
|
||||
@@ -937,8 +936,8 @@ describe('Matter service', () => {
|
||||
const { db } = await createTestApp()
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
const matter = await createMatter(db, {
|
||||
orgId: 'org-1',
|
||||
@@ -956,8 +955,8 @@ describe('Matter service', () => {
|
||||
const { db } = await createTestApp()
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
const matter = await createMatter(db, {
|
||||
orgId: 'org-1',
|
||||
@@ -986,8 +985,8 @@ describe('Matter service', () => {
|
||||
const { db } = await createTestApp()
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('s1', 'S3', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
const source = await createMatter(db, {
|
||||
orgId: 'org-1',
|
||||
@@ -1428,7 +1427,6 @@ describe('Objects API — quota enforcement', () => {
|
||||
const validStorage = {
|
||||
id: 'st-quota',
|
||||
title: 'Quota S3',
|
||||
mode: 'private',
|
||||
bucket: 'test-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
@@ -1439,8 +1437,8 @@ describe('Objects API — quota enforcement', () => {
|
||||
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db'], used = 0) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.mode}, ${validStorage.bucket},
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.bucket},
|
||||
${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey},
|
||||
${validStorage.secretKey}, '', '', 0, ${used}, 'active', ${now}, ${now})
|
||||
`)
|
||||
@@ -2067,12 +2065,12 @@ describe('object multipart upload API with S3-compatible storage', () => {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (
|
||||
id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
|
||||
egress_credit_per_unit, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'multipart-live-storage', 'Multipart Live Storage', 'private', 'test-bucket',
|
||||
'multipart-live-storage', 'Multipart Live Storage', 'test-bucket',
|
||||
${endpoint}, 'auto', 'test-access-key', 'test-secret-key',
|
||||
'$UID/$RAW_NAME', '', 0, 0, 'active', 0, ${100 * 1024 * 1024}, 1, ${now}, ${now}
|
||||
)
|
||||
|
||||
@@ -37,8 +37,8 @@ async function signUpAndGetIds(app: ReturnType<typeof createApp>, db: Awaited<Re
|
||||
async function insertStorage(db: Awaited<ReturnType<typeof buildApp>>['db']) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'CF S3', 'private', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'CF S3', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ beforeEach(() => {
|
||||
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'Test S3', 'private', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -601,8 +601,8 @@ describe('GET /r/:token — two-org isolation', () => {
|
||||
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'Test S3', 'private', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
await insertImageHosting(db, orgId, { id: 'ih-iso1', token: 'ih_isolationtest' })
|
||||
|
||||
|
||||
@@ -64,8 +64,8 @@ async function signUpAndGetIds(app: ReturnType<typeof createApp>, db: Awaited<Re
|
||||
async function insertStorage(db: Awaited<ReturnType<typeof buildApp>>['db']) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'CF S3', 'private', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'CF S3', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
const validStorage = {
|
||||
id: 'st-share-test',
|
||||
title: 'Test S3',
|
||||
mode: 'private',
|
||||
bucket: 'test-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
@@ -26,8 +25,8 @@ const validStorage = {
|
||||
async function insertStorage(db: TestDb) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.mode}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -1060,8 +1059,8 @@ describe('Public share routes', () => {
|
||||
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'Test S3', 'private', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
const validStorage = {
|
||||
id: 'st-audit-test',
|
||||
title: 'Audit Test S3',
|
||||
mode: 'private',
|
||||
bucket: 'test-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
@@ -29,8 +28,8 @@ const validStorage = {
|
||||
async function insertStorage(db: TestDb, id = validStorage.id) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${id}, ${validStorage.title}, ${validStorage.mode}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${id}, ${validStorage.title}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -396,7 +395,6 @@ describe('Storage audit events', () => {
|
||||
headers: { ...admin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'New Storage',
|
||||
mode: 'private',
|
||||
bucket: 'my-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
@@ -412,8 +410,6 @@ describe('Storage audit events', () => {
|
||||
expect(evt?.targetName).toBe('New Storage')
|
||||
// Must NOT store secret keys or access keys in metadata
|
||||
assertNoSecrets(evt?.metadata ?? null)
|
||||
const meta = JSON.parse(evt?.metadata ?? '{}') as { mode: string }
|
||||
expect(meta.mode).toBe('private')
|
||||
})
|
||||
|
||||
it('records storage_update when admin updates a storage', async () => {
|
||||
@@ -426,7 +422,6 @@ describe('Storage audit events', () => {
|
||||
headers: { ...admin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'Original Storage',
|
||||
mode: 'private',
|
||||
bucket: 'my-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
@@ -460,7 +455,6 @@ describe('Storage audit events', () => {
|
||||
headers: { ...admin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'Deletable Storage',
|
||||
mode: 'private',
|
||||
bucket: 'del-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
|
||||
@@ -35,7 +35,6 @@ async function adminHeaders(app: ReturnType<typeof buildApp>) {
|
||||
|
||||
const validStorage = {
|
||||
title: 'CF Test S3',
|
||||
mode: 'private',
|
||||
bucket: 'cf-test-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
|
||||
@@ -6,7 +6,6 @@ import { adminHeaders, authedHeaders, createTestApp } from '../../test/setup.js'
|
||||
|
||||
const validStorage = {
|
||||
title: 'Test S3',
|
||||
mode: 'private',
|
||||
bucket: 'test-bucket',
|
||||
endpoint: 'https://s3.amazonaws.com',
|
||||
region: 'us-east-1',
|
||||
@@ -57,7 +56,6 @@ describe('Admin Storages API', () => {
|
||||
expect(res.status).toBe(201)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.title).toBe('Test S3')
|
||||
expect(body.mode).toBe('private')
|
||||
expect(body.bucket).toBe('test-bucket')
|
||||
expect(body.status).toBe('active')
|
||||
expect(body.capacity).toBe(0)
|
||||
@@ -228,7 +226,6 @@ async function insertStorage(
|
||||
db: Awaited<ReturnType<typeof createTestApp>>['db'],
|
||||
opts: {
|
||||
id: string
|
||||
mode: 'private' | 'public'
|
||||
status?: string
|
||||
capacity?: number
|
||||
used?: number
|
||||
@@ -241,99 +238,75 @@ async function insertStorage(
|
||||
const status = opts.status ?? 'active'
|
||||
const title = `Storage ${opts.id}`
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${opts.id}, ${title}, ${opts.mode}, 'bucket', 'https://s3.example.com', 'us-east-1', 'key', 'secret', '$UID/$RAW_NAME', '', ${capacity}, ${used}, ${status}, ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${opts.id}, ${title}, 'bucket', 'https://s3.example.com', 'us-east-1', 'key', 'secret', '$UID/$RAW_NAME', '', ${capacity}, ${used}, ${status}, ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
describe('selectStorage service', () => {
|
||||
it('returns the single active storage when capacity is unlimited (0) [spec: storages/select-active]', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, used: 0 })
|
||||
await insertStorage(db, { id: 's1', capacity: 0, used: 0 })
|
||||
|
||||
const storage = await createStorageRepo(db).select('private')
|
||||
const storage = await createStorageRepo(db).select()
|
||||
expect(storage.id).toBe('s1')
|
||||
})
|
||||
|
||||
it('returns storage when used is below capacity', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 50 })
|
||||
await insertStorage(db, { id: 's1', capacity: 100, used: 50 })
|
||||
|
||||
const storage = await createStorageRepo(db).select('private')
|
||||
const storage = await createStorageRepo(db).select()
|
||||
expect(storage.id).toBe('s1')
|
||||
})
|
||||
|
||||
it('skips storage where used equals capacity', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 100, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', mode: 'private', capacity: 200, used: 50, createdAt: 2 })
|
||||
await insertStorage(db, { id: 's1', capacity: 100, used: 100, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', capacity: 200, used: 50, createdAt: 2 })
|
||||
|
||||
const storage = await createStorageRepo(db).select('private')
|
||||
const storage = await createStorageRepo(db).select()
|
||||
expect(storage.id).toBe('s2')
|
||||
})
|
||||
|
||||
it('skips storage where used exceeds capacity', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 100, used: 110, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', mode: 'private', capacity: 0, used: 0, createdAt: 2 })
|
||||
await insertStorage(db, { id: 's1', capacity: 100, used: 110, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', capacity: 0, used: 0, createdAt: 2 })
|
||||
|
||||
const storage = await createStorageRepo(db).select('private')
|
||||
const storage = await createStorageRepo(db).select()
|
||||
expect(storage.id).toBe('s2')
|
||||
})
|
||||
|
||||
it('picks the oldest active storage first (sequential fill order)', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, used: 0, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', mode: 'private', capacity: 0, used: 0, createdAt: 2 })
|
||||
await insertStorage(db, { id: 's1', capacity: 0, used: 0, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', capacity: 0, used: 0, createdAt: 2 })
|
||||
|
||||
const storage = await createStorageRepo(db).select('private')
|
||||
const storage = await createStorageRepo(db).select()
|
||||
expect(storage.id).toBe('s1')
|
||||
})
|
||||
|
||||
it('ignores disabled storages', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', status: 'disabled', capacity: 0, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', mode: 'private', status: 'active', capacity: 0, createdAt: 2 })
|
||||
await insertStorage(db, { id: 's1', status: 'disabled', capacity: 0, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', status: 'active', capacity: 0, createdAt: 2 })
|
||||
|
||||
const storage = await createStorageRepo(db).select('private')
|
||||
const storage = await createStorageRepo(db).select()
|
||||
expect(storage.id).toBe('s2')
|
||||
})
|
||||
|
||||
it('ignores storages of a different mode', async () => {
|
||||
it('throws when no active storage exists', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'public', capacity: 0 })
|
||||
|
||||
await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage')
|
||||
await expect(createStorageRepo(db).select()).rejects.toThrow('No available storage')
|
||||
})
|
||||
|
||||
it('throws when no active storage exists for the requested mode', async () => {
|
||||
it('throws when all storages are at full capacity', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', capacity: 50, used: 50 })
|
||||
await insertStorage(db, { id: 's2', capacity: 100, used: 100 })
|
||||
|
||||
await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage')
|
||||
})
|
||||
|
||||
it('throws when all storages of the mode are at full capacity', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 50, used: 50 })
|
||||
await insertStorage(db, { id: 's2', mode: 'private', capacity: 100, used: 100 })
|
||||
|
||||
await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage')
|
||||
})
|
||||
|
||||
it('returns a public storage when mode is public', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'public', capacity: 0 })
|
||||
|
||||
const storage = await createStorageRepo(db).select('public')
|
||||
expect(storage.id).toBe('s1')
|
||||
})
|
||||
|
||||
it('does not return a public storage when private mode is requested', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', mode: 'private', capacity: 0, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', mode: 'public', capacity: 0, createdAt: 2 })
|
||||
|
||||
const storage = await createStorageRepo(db).select('private')
|
||||
expect(storage.id).toBe('s1')
|
||||
await expect(createStorageRepo(db).select()).rejects.toThrow('No available storage')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,6 @@ const storageSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
mode: z.string(),
|
||||
bucket: z.string(),
|
||||
endpoint: z.string(),
|
||||
region: z.string(),
|
||||
|
||||
@@ -42,12 +42,12 @@ async function insertStorage(db: Database) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (
|
||||
id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
|
||||
egress_credit_per_unit, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
${STORAGE_ID}, 'Cloud Traffic S3', 'private', 'test-bucket', 'https://s3.amazonaws.com',
|
||||
${STORAGE_ID}, 'Cloud Traffic S3', 'test-bucket', 'https://s3.amazonaws.com',
|
||||
'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', true, ${100 * 1024 ** 2}, 1, ${now}, ${now}
|
||||
)
|
||||
`)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { S3Service } from '../adapters/gateways/s3.js'
|
||||
import { createTeamInviteRepo } from '../adapters/repos/team-invite.js'
|
||||
import * as authSchema from '../db/auth-schema.js'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import { createTestApp, seedBusinessLicense } from '../test/setup.js'
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
type TestApp = Awaited<ReturnType<typeof createTestApp>>['app']
|
||||
@@ -659,12 +658,35 @@ describe('GET /api/teams/:teamId/activity — isolation', () => {
|
||||
|
||||
// ─── Org logo (PUT/DELETE /:teamId/logo) ─────────────────────────────────────
|
||||
|
||||
async function insertPublicStorage(db: TestDb) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('st-logo', 'Public', 'public', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKID', 'secret', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
const CLOUD_LOGO_URL = 'https://avatars.zpan.cloud/team/logo.png'
|
||||
|
||||
// Stub the Cloud avatar service and capture each request so tests can assert the
|
||||
// /avatars/team/:id path, the image content type, and the bearer auth that reach
|
||||
// Cloud. `seedBusinessLicense` makes the instance Cloud-paired.
|
||||
function stubCloudAvatarFetch() {
|
||||
const calls: { url: string; method: string; contentType: string | null; authorization: string | null }[] = []
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||
const u = String(url)
|
||||
if (u.includes('/avatars/')) {
|
||||
const headers = new Headers(init?.headers)
|
||||
calls.push({
|
||||
url: u,
|
||||
method: init?.method ?? 'GET',
|
||||
contentType: headers.get('content-type'),
|
||||
authorization: headers.get('authorization'),
|
||||
})
|
||||
if (init?.method === 'DELETE') return new Response(null, { status: 204 })
|
||||
return new Response(JSON.stringify({ url: CLOUD_LOGO_URL, key: 'avatars/team/logo' }), {
|
||||
status: 201,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
return new Response('unexpected fetch', { status: 404 })
|
||||
}),
|
||||
)
|
||||
return calls
|
||||
}
|
||||
|
||||
function makeFile(type: string, bytes = 16): File {
|
||||
@@ -674,7 +696,7 @@ function makeFile(type: string, bytes = 16): File {
|
||||
describe('PUT /api/teams/:teamId/logo', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(S3Service.prototype, 'putObject').mockResolvedValue(16)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns 401 without auth', async () => {
|
||||
@@ -690,7 +712,6 @@ describe('PUT /api/teams/:teamId/logo', () => {
|
||||
const { headers, userId } = await signUpAndGetUser(app, `m-${nanoid()}@example.com`)
|
||||
const orgId = await insertOrg(db)
|
||||
await insertMember(db, orgId, userId, 'member')
|
||||
await insertPublicStorage(db)
|
||||
|
||||
const form = new FormData()
|
||||
form.set('file', makeFile('image/png'))
|
||||
@@ -698,33 +719,37 @@ describe('PUT /api/teams/:teamId/logo', () => {
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 400 when mime is invalid (gif)', async () => {
|
||||
it('returns 400 for an unsupported mime, before any Cloud call', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
|
||||
const orgId = await insertOrg(db)
|
||||
await insertMember(db, orgId, userId, 'owner')
|
||||
await insertPublicStorage(db)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
|
||||
const form = new FormData()
|
||||
form.set('file', makeFile('image/gif'))
|
||||
form.set('file', makeFile('application/pdf'))
|
||||
const res = await app.request(`/api/teams/${orgId}/logo`, { method: 'PUT', headers, body: form })
|
||||
expect(res.status).toBe(400)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 413 when file > 2 MiB', async () => {
|
||||
it('returns 413 when file > 1 MiB, before any Cloud call', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
|
||||
const orgId = await insertOrg(db)
|
||||
await insertMember(db, orgId, userId, 'owner')
|
||||
await insertPublicStorage(db)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
|
||||
const form = new FormData()
|
||||
form.set('file', makeFile('image/png', 3 * 1024 * 1024))
|
||||
form.set('file', makeFile('image/png', 2 * 1024 * 1024))
|
||||
const res = await app.request(`/api/teams/${orgId}/logo`, { method: 'PUT', headers, body: form })
|
||||
expect(res.status).toBe(413)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 503 when no public storage is configured', async () => {
|
||||
it('returns 503 cloud_required when the instance is not paired to Cloud', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
|
||||
const orgId = await insertOrg(db)
|
||||
@@ -736,32 +761,37 @@ describe('PUT /api/teams/:teamId/logo', () => {
|
||||
expect(res.status).toBe(503)
|
||||
})
|
||||
|
||||
it('uploads + writes organization.logo + returns URL (owner)', async () => {
|
||||
it('hosts the logo on Cloud + writes organization.logo + returns URL (owner)', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
|
||||
const orgId = await insertOrg(db)
|
||||
await insertMember(db, orgId, userId, 'owner')
|
||||
await insertPublicStorage(db)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
|
||||
const form = new FormData()
|
||||
form.set('file', makeFile('image/jpeg'))
|
||||
const res = await app.request(`/api/teams/${orgId}/logo`, { method: 'PUT', headers, body: form })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { url: string }
|
||||
expect(body.url).toContain(`_system/org-logos/${orgId}`)
|
||||
expect(body.url).toContain('.jpg')
|
||||
expect(S3Service.prototype.putObject).toHaveBeenCalledTimes(1)
|
||||
expect(body.url).toBe(CLOUD_LOGO_URL)
|
||||
|
||||
const put = calls.find((c) => c.method === 'PUT')
|
||||
expect(put?.url).toContain(`/avatars/team/${orgId}`)
|
||||
expect(put?.contentType).toBe('image/jpeg')
|
||||
expect(put?.authorization).toBe('Bearer test-refresh-token')
|
||||
|
||||
const rows = await db.all<{ logo: string | null }>(sql`SELECT logo FROM organization WHERE id = ${orgId}`)
|
||||
expect(rows[0]?.logo).toBe(body.url)
|
||||
expect(rows[0]?.logo).toBe(CLOUD_LOGO_URL)
|
||||
})
|
||||
|
||||
it('succeeds for admin role (not just owner)', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
const { headers, userId } = await signUpAndGetUser(app, `a-${nanoid()}@example.com`)
|
||||
const orgId = await insertOrg(db)
|
||||
await insertMember(db, orgId, userId, 'admin')
|
||||
await insertPublicStorage(db)
|
||||
stubCloudAvatarFetch()
|
||||
|
||||
const form = new FormData()
|
||||
form.set('file', makeFile('image/png'))
|
||||
@@ -773,7 +803,7 @@ describe('PUT /api/teams/:teamId/logo', () => {
|
||||
describe('DELETE /api/teams/:teamId/logo', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(S3Service.prototype, 'deleteObject').mockResolvedValue(undefined)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns 401 without auth', async () => {
|
||||
@@ -792,34 +822,37 @@ describe('DELETE /api/teams/:teamId/logo', () => {
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('clears organization.logo + removes all mime variants from S3', async () => {
|
||||
it('clears organization.logo + deletes the Cloud logo', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
|
||||
const orgId = await insertOrg(db)
|
||||
await insertMember(db, orgId, userId, 'owner')
|
||||
await insertPublicStorage(db)
|
||||
|
||||
await db.run(sql`UPDATE organization SET logo = 'https://example.com/old.png' WHERE id = ${orgId}`)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
|
||||
const res = await app.request(`/api/teams/${orgId}/logo`, { method: 'DELETE', headers })
|
||||
expect(res.status).toBe(204)
|
||||
|
||||
const rows = await db.all<{ logo: string | null }>(sql`SELECT logo FROM organization WHERE id = ${orgId}`)
|
||||
expect(rows[0]?.logo).toBeNull()
|
||||
expect(S3Service.prototype.deleteObject).toHaveBeenCalledTimes(3)
|
||||
const del = calls.find((c) => c.method === 'DELETE')
|
||||
expect(del?.url).toContain(`/avatars/team/${orgId}`)
|
||||
})
|
||||
|
||||
it('succeeds when no public storage (DB cleared, S3 skipped)', async () => {
|
||||
it('succeeds when the instance is not paired to Cloud (DB cleared, Cloud delete skipped)', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers, userId } = await signUpAndGetUser(app, `o-${nanoid()}@example.com`)
|
||||
const orgId = await insertOrg(db)
|
||||
await insertMember(db, orgId, userId, 'owner')
|
||||
await db.run(sql`UPDATE organization SET logo = 'https://example.com/old.png' WHERE id = ${orgId}`)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
|
||||
const res = await app.request(`/api/teams/${orgId}/logo`, { method: 'DELETE', headers })
|
||||
expect(res.status).toBe(204)
|
||||
const rows = await db.all<{ logo: string | null }>(sql`SELECT logo FROM organization WHERE id = ${orgId}`)
|
||||
expect(rows[0]?.logo).toBeNull()
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
expired,
|
||||
forbidden,
|
||||
type InviteLinkInfo,
|
||||
internalError,
|
||||
noStorage,
|
||||
notFound,
|
||||
type PendingInvitation,
|
||||
@@ -134,9 +135,11 @@ function failureError(failure: { status: 400 | 404; error: string }) {
|
||||
}
|
||||
|
||||
// Maps the image-upload gateway outcome ({ status, error }) to its error factory.
|
||||
function imageUploadError(status: 400 | 413 | 503, error: string) {
|
||||
function imageUploadError(status: 400 | 403 | 413 | 500 | 503, error: string) {
|
||||
if (status === 413) return payloadTooLarge(error)
|
||||
if (status === 503) return noStorage(error)
|
||||
if (status === 403) return forbidden(error)
|
||||
if (status === 500) return internalError(error)
|
||||
return badRequest(error)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { S3Service } from '../adapters/gateways/s3.js'
|
||||
import { buildBreadcrumb } from '../domain/breadcrumb.js'
|
||||
import { authedHeaders, createTestApp } from '../test/setup.js'
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
import { authedHeaders, createTestApp, seedBusinessLicense } from '../test/setup.js'
|
||||
|
||||
async function adminHeaders(app: ReturnType<typeof import('../app')['createApp']>) {
|
||||
// Sign up first user (gets promoted to admin via hook)
|
||||
@@ -374,12 +371,36 @@ describe('User entitlements API (admin)', () => {
|
||||
|
||||
// ─── User avatar (PUT/DELETE /api/users/me/avatar) ────────────────────────────
|
||||
|
||||
async function insertPublicStorage(db: TestDb) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('st-me', 'Public', 'public', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKID', 'secret', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
const CLOUD_AVATAR_URL = 'https://avatars.zpan.cloud/user/u1.webp'
|
||||
|
||||
// Stub the Cloud avatar service and capture each avatar request so tests can
|
||||
// assert the /avatars/:scope/:id path, the image content type, and the bearer
|
||||
// auth that reach Cloud. `seedBusinessLicense` makes the instance Cloud-paired
|
||||
// (active license binding, refresh token 'test-refresh-token').
|
||||
function stubCloudAvatarFetch() {
|
||||
const calls: { url: string; method: string; contentType: string | null; authorization: string | null }[] = []
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url: string | URL, init?: RequestInit) => {
|
||||
const u = String(url)
|
||||
if (u.includes('/avatars/')) {
|
||||
const headers = new Headers(init?.headers)
|
||||
calls.push({
|
||||
url: u,
|
||||
method: init?.method ?? 'GET',
|
||||
contentType: headers.get('content-type'),
|
||||
authorization: headers.get('authorization'),
|
||||
})
|
||||
if (init?.method === 'DELETE') return new Response(null, { status: 204 })
|
||||
return new Response(JSON.stringify({ url: CLOUD_AVATAR_URL, key: 'avatars/user/u1' }), {
|
||||
status: 201,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
}
|
||||
return new Response('unexpected fetch', { status: 404 })
|
||||
}),
|
||||
)
|
||||
return calls
|
||||
}
|
||||
|
||||
function makeFile(type: string, bytes = 16): File {
|
||||
@@ -389,7 +410,7 @@ function makeFile(type: string, bytes = 16): File {
|
||||
describe('PUT /api/users/me/avatar', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(S3Service.prototype, 'putObject').mockResolvedValue(16)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns 401 without auth [spec: avatar/auth-required]', async () => {
|
||||
@@ -420,27 +441,31 @@ describe('PUT /api/users/me/avatar', () => {
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 when mime is not PNG/JPG/WebP [spec: avatar/mime-validated]', async () => {
|
||||
it('returns 400 for an unsupported mime, before any Cloud call [spec: avatar/mime-validated]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await insertPublicStorage(db)
|
||||
await seedBusinessLicense(db)
|
||||
const headers = await authedHeaders(app)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
const form = new FormData()
|
||||
form.set('file', makeFile('image/gif'))
|
||||
form.set('file', makeFile('application/pdf'))
|
||||
const res = await app.request('/api/users/me/avatar', { method: 'PUT', headers, body: form })
|
||||
expect(res.status).toBe(400)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 413 when file exceeds 2 MiB [spec: avatar/size-limit]', async () => {
|
||||
it('returns 413 when the file exceeds 1 MiB, before any Cloud call [spec: avatar/size-limit]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await insertPublicStorage(db)
|
||||
await seedBusinessLicense(db)
|
||||
const headers = await authedHeaders(app)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
const form = new FormData()
|
||||
form.set('file', makeFile('image/png', 3 * 1024 * 1024))
|
||||
form.set('file', makeFile('image/png', 2 * 1024 * 1024))
|
||||
const res = await app.request('/api/users/me/avatar', { method: 'PUT', headers, body: form })
|
||||
expect(res.status).toBe(413)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns 503 when no public storage is configured [spec: avatar/needs-storage]', async () => {
|
||||
it('returns 503 cloud_required when the instance is not paired to Cloud [spec: avatar/needs-cloud]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
const form = new FormData()
|
||||
@@ -449,47 +474,33 @@ describe('PUT /api/users/me/avatar', () => {
|
||||
expect(res.status).toBe(503)
|
||||
})
|
||||
|
||||
it('uploads the file to S3, writes user.image, returns the URL [spec: avatar/upload]', async () => {
|
||||
it('hosts the avatar on Cloud, writes user.image, returns the URL [spec: avatar/upload]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await insertPublicStorage(db)
|
||||
await seedBusinessLicense(db)
|
||||
const headers = await authedHeaders(app)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
const form = new FormData()
|
||||
form.set('file', makeFile('image/webp'))
|
||||
|
||||
const res = await app.request('/api/users/me/avatar', { method: 'PUT', headers, body: form })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { url: string }
|
||||
expect(body.url).toContain('_system/avatars/')
|
||||
expect(body.url).toContain('.webp')
|
||||
expect(S3Service.prototype.putObject).toHaveBeenCalledTimes(1)
|
||||
expect(body.url).toBe(CLOUD_AVATAR_URL)
|
||||
|
||||
const put = calls.find((c) => c.method === 'PUT')
|
||||
expect(put?.url).toMatch(/\/avatars\/user\//)
|
||||
expect(put?.contentType).toBe('image/webp')
|
||||
expect(put?.authorization).toBe('Bearer test-refresh-token')
|
||||
|
||||
const rows = await db.all<{ image: string | null }>(sql`SELECT image FROM user LIMIT 1`)
|
||||
expect(rows[0]?.image).toBe(body.url)
|
||||
})
|
||||
|
||||
it('is idempotent — re-PUT with same mime returns the same URL [spec: avatar/idempotent]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await insertPublicStorage(db)
|
||||
const headers = await authedHeaders(app)
|
||||
|
||||
const form1 = new FormData()
|
||||
form1.set('file', makeFile('image/png'))
|
||||
const res1 = await app.request('/api/users/me/avatar', { method: 'PUT', headers, body: form1 })
|
||||
const body1 = (await res1.json()) as { url: string }
|
||||
|
||||
const form2 = new FormData()
|
||||
form2.set('file', makeFile('image/png'))
|
||||
const res2 = await app.request('/api/users/me/avatar', { method: 'PUT', headers, body: form2 })
|
||||
const body2 = (await res2.json()) as { url: string }
|
||||
|
||||
expect(body1.url).toBe(body2.url)
|
||||
expect(rows[0]?.image).toBe(CLOUD_AVATAR_URL)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/users/me/avatar', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(S3Service.prototype, 'deleteObject').mockResolvedValue(undefined)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns 401 without auth', async () => {
|
||||
@@ -498,30 +509,34 @@ describe('DELETE /api/users/me/avatar', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('clears user.image and removes all mime variants from S3 [spec: avatar/delete]', async () => {
|
||||
it('clears user.image and deletes the Cloud avatar [spec: avatar/delete]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await insertPublicStorage(db)
|
||||
await seedBusinessLicense(db)
|
||||
const headers = await authedHeaders(app)
|
||||
await db.run(sql`UPDATE user SET image = 'https://example.com/old.png'`)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
|
||||
const res = await app.request('/api/users/me/avatar', { method: 'DELETE', headers })
|
||||
expect(res.status).toBe(204)
|
||||
|
||||
const rows = await db.all<{ image: string | null }>(sql`SELECT image FROM user LIMIT 1`)
|
||||
expect(rows[0]?.image).toBeNull()
|
||||
// 3 mime variants attempted (png, jpg, webp)
|
||||
expect(S3Service.prototype.deleteObject).toHaveBeenCalledTimes(3)
|
||||
|
||||
const del = calls.find((c) => c.method === 'DELETE')
|
||||
expect(del?.url).toMatch(/\/avatars\/user\//)
|
||||
})
|
||||
|
||||
it('succeeds when no public storage exists (DB cleared, S3 skipped) [spec: avatar/delete-no-storage]', async () => {
|
||||
it('succeeds when the instance is not paired to Cloud (DB cleared, Cloud delete skipped) [spec: avatar/delete-unbound]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await db.run(sql`UPDATE user SET image = 'https://example.com/old.png'`)
|
||||
const calls = stubCloudAvatarFetch()
|
||||
|
||||
const res = await app.request('/api/users/me/avatar', { method: 'DELETE', headers })
|
||||
expect(res.status).toBe(204)
|
||||
const rows = await db.all<{ image: string | null }>(sql`SELECT image FROM user LIMIT 1`)
|
||||
expect(rows[0]?.image).toBeNull()
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+12
-2
@@ -1,7 +1,15 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { requireAdmin, requireAuth } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { badRequest, noStorage, notFound, payloadTooLarge, unsupportedMediaType } from '../usecases/ports'
|
||||
import {
|
||||
badRequest,
|
||||
forbidden,
|
||||
internalError,
|
||||
noStorage,
|
||||
notFound,
|
||||
payloadTooLarge,
|
||||
unsupportedMediaType,
|
||||
} from '../usecases/ports'
|
||||
import { getUserQuota } from '../usecases/quota'
|
||||
import {
|
||||
getPublicProfile,
|
||||
@@ -52,9 +60,11 @@ function failureError(failure: { status: 400 | 404; error: string }) {
|
||||
}
|
||||
|
||||
// Maps the image-upload gateway outcome ({ status, error }) to its error factory.
|
||||
function imageUploadError(status: 400 | 413 | 503, error: string) {
|
||||
function imageUploadError(status: 400 | 403 | 413 | 500 | 503, error: string) {
|
||||
if (status === 413) return payloadTooLarge(error)
|
||||
if (status === 503) return noStorage(error)
|
||||
if (status === 403) return forbidden(error)
|
||||
if (status === 500) return internalError(error)
|
||||
return badRequest(error)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ type TestApp = Awaited<ReturnType<typeof createTestApp>>
|
||||
const storage = {
|
||||
id: 'dav-storage',
|
||||
title: 'DAV Storage',
|
||||
mode: 'private',
|
||||
bucket: 'dav-bucket',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
@@ -48,8 +47,8 @@ function streamBody(text: string): ReadableStream {
|
||||
async function seedStorage(db: TestApp['db']) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${storage.id}, ${storage.title}, ${storage.mode}, ${storage.bucket}, ${storage.endpoint}, ${storage.region}, ${storage.accessKey}, ${storage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${storage.id}, ${storage.title}, ${storage.bucket}, ${storage.endpoint}, ${storage.region}, ${storage.accessKey}, ${storage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -2044,7 +2043,6 @@ describe('WebDAV over real HTTP (npm client)', () => {
|
||||
const e2eStorage = {
|
||||
id: 'webdav-e2e-storage',
|
||||
title: 'WebDAV E2E Storage',
|
||||
mode: 'private',
|
||||
bucket: 'webdav-e2e-bucket',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
|
||||
@@ -33,8 +33,8 @@ async function signUpAndGetOrgId(app: ReturnType<typeof createApp>, db: TestDb)
|
||||
async function insertStorage(db: TestDb) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'CF Domain S3', 'private', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'CF Domain S3', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ async function getOrgId(db: TestDb): Promise<string> {
|
||||
async function insertStorage(db: TestDb) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'Test S3', 'private', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +160,6 @@ const APP_SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS storages (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
bucket TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
region TEXT NOT NULL DEFAULT 'auto',
|
||||
|
||||
@@ -830,8 +830,8 @@ describe('archive processing', () => {
|
||||
async function seedStorage(db: TestDb): Promise<void> {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'Archive Storage', 'private', 'bucket', 'https://s3.example.com', 'auto', 'ak', 'sk', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${STORAGE_ID}, 'Archive Storage', 'bucket', 'https://s3.example.com', 'auto', 'ak', 'sk', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ async function runCompressionJob(
|
||||
})
|
||||
}
|
||||
|
||||
const targetStorage = await deps.storages.select('private')
|
||||
const targetStorage = await deps.storages.select()
|
||||
const key = buildObjectKey({ uid: userId, orgId, rawExt: '.zip' })
|
||||
let objectWritten = false
|
||||
let outputBytes = 0
|
||||
@@ -205,7 +205,7 @@ async function runExtractionJob(
|
||||
const progress = createArchiveProgressReporter(deps, orgId, jobId, sourceHead.size, plan.fileCount)
|
||||
await progress.report(true)
|
||||
const targetFolder = request.targetFolder ?? zipMatter.parent
|
||||
const targetStorage = await deps.storages.select('private')
|
||||
const targetStorage = await deps.storages.select()
|
||||
const writtenKeys: string[] = []
|
||||
const createdMatterIds: string[] = []
|
||||
const folderParents = new Map<string, string>()
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
// Fakes for the ports the image-hosting usecase touches. Each test overrides the
|
||||
// handful of methods it exercises; the rest throw so an unexpected call is loud.
|
||||
|
||||
const sampleStorage = { id: 'st-1', title: 'S3', mode: 'private' } as StorageRecord
|
||||
const sampleStorage = { id: 'st-1', title: 'S3' } as StorageRecord
|
||||
|
||||
const sampleConfig: ImageHostingConfigRecord = {
|
||||
orgId: 'o1',
|
||||
@@ -158,7 +158,7 @@ describe('image-hosting usecase', () => {
|
||||
const out = await uploadImageHosting(deps, { orgId: 'o1', path: 'a.png', mime: 'image/png', bytes })
|
||||
expect(out.ok).toBe(true)
|
||||
if (out.ok) expect(out.row.path).toBe('a.png')
|
||||
expect(select).toHaveBeenCalledWith('private')
|
||||
expect(select).toHaveBeenCalledWith()
|
||||
expect(create).toHaveBeenCalledWith(expect.objectContaining({ orgId: 'o1', status: 'draft', size: 100 }))
|
||||
expect(putObject).toHaveBeenCalledTimes(1)
|
||||
expect(setActive).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -115,7 +115,7 @@ export async function uploadImageHosting(
|
||||
): Promise<UploadImageHostingOutcome> {
|
||||
let storage: StorageRecord
|
||||
try {
|
||||
storage = await deps.storages.select('private')
|
||||
storage = await deps.storages.select()
|
||||
} catch {
|
||||
return { ok: false, error: noStorage() }
|
||||
}
|
||||
@@ -152,7 +152,7 @@ export async function presignImageHostingUpload(
|
||||
): Promise<PresignImageHostingOutcome> {
|
||||
let storage: StorageRecord
|
||||
try {
|
||||
storage = await deps.storages.select('private')
|
||||
storage = await deps.storages.select()
|
||||
} catch {
|
||||
return { ok: false, error: noStorage() }
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@ const saveShareToDrive = (db: Database, input: SaveShareInput) => saveShareToDri
|
||||
|
||||
async function seedStorage(db: ReturnType<typeof buildDb>, id: string) {
|
||||
await db.run(
|
||||
`INSERT OR IGNORE INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('${id}', 'CF Test S3', 'private', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKIA...', 'secret...', '', '', 0, 0, 'active', ${Date.now()}, ${Date.now()})`,
|
||||
`INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('${id}', 'CF Test S3', 'cf-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKIA...', 'secret...', '', '', 0, 0, 'active', ${Date.now()}, ${Date.now()})`,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,8 +53,8 @@ type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
async function insertStorage(db: TestDb, id = STORAGE_ID) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${id}, 'Test S3', 'private', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKIAIOSFODNN7EXAMPLE', 'wJalrXUtnFEMI', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${id}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AKIAIOSFODNN7EXAMPLE', 'wJalrXUtnFEMI', '', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -824,8 +824,8 @@ describe('trash purge', () => {
|
||||
async function insertStorage(db: TestDb) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('st-1', 'Test S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'AKID', 'SECRET', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('st-1', 'Test S3', 'b', 'https://s3.example.com', 'us-east-1', 'AKID', 'SECRET', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ vi.mock('./store/traffic-metering', () => ({ meterDownloadTraffic: vi.fn() }))
|
||||
const storage = {
|
||||
id: 'st-1',
|
||||
title: 'S3',
|
||||
mode: 'private',
|
||||
egressCreditBillingEnabled: false,
|
||||
egressCreditUnitBytes: 0,
|
||||
egressCreditPerUnit: 0,
|
||||
|
||||
@@ -175,7 +175,7 @@ export async function createObject(
|
||||
|
||||
let storage: StorageRecord
|
||||
try {
|
||||
storage = await deps.storages.select('private')
|
||||
storage = await deps.storages.select()
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'No available storage') {
|
||||
return { ok: false, error: noStorage() }
|
||||
@@ -1067,7 +1067,7 @@ export async function copyMatterToOrg(deps: SaveToDriveDeps, input: CopyMatterTo
|
||||
const sourceStorage = await deps.storages.get(sourceMatter.storageId)
|
||||
if (!sourceStorage) throw new Error('Source storage not found')
|
||||
|
||||
const targetStorage = await deps.storages.select('private')
|
||||
const targetStorage = await deps.storages.select()
|
||||
|
||||
if (sourceMatter.dirtype === DirType.FILE) {
|
||||
const newMatter = await saveFile(
|
||||
|
||||
@@ -38,6 +38,7 @@ export const expired = (message = 'Expired') => new AppError(410, message)
|
||||
export const conflict = (message: string, reason?: string) => new AppError(409, message, { reason })
|
||||
export const badRequest = (message: string, reason?: string) => new AppError(400, message, { reason })
|
||||
export const badGateway = (message: string, reason?: string) => new AppError(502, message, { reason })
|
||||
export const internalError = (message = 'Internal error', reason?: string) => new AppError(500, message, { reason })
|
||||
|
||||
export const payloadTooLarge = (message = 'Payload too large') =>
|
||||
new AppError(413, message, { reason: ErrorReason.PAYLOAD_TOO_LARGE })
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import type { Platform } from '../../platform/interface'
|
||||
|
||||
export const IMAGE_MIMES = ['image/png', 'image/jpeg', 'image/webp'] as const
|
||||
export type ImageMime = (typeof IMAGE_MIMES)[number]
|
||||
export const MAX_IMAGE_SIZE = 2 * 1024 * 1024 // 2 MiB
|
||||
// User avatars (`AVATAR_PREFIX`) and team/org logos (`LOGO_PREFIX`) are hosted on
|
||||
// the ZPan Cloud avatar service. The prefix the usecase passes encodes which one
|
||||
// it is; the gateway maps it to the Cloud avatar scope (`user` / `team`).
|
||||
export const AVATAR_PREFIX = '_system/avatars'
|
||||
export const LOGO_PREFIX = '_system/org-logos'
|
||||
|
||||
export type ImageUploadResult = { ok: true; url: string } | { ok: false; status: 400 | 413 | 503; error: string }
|
||||
// Cloud-hosting outcome. A failure carries the HTTP status the http layer renders:
|
||||
// 400 unsupported image type · 413 too large · 403 license inactive ·
|
||||
// 500 unexpected cloud error · 503 instance not paired to Cloud (cloud_required).
|
||||
export type ImageUploadResult =
|
||||
| { ok: true; url: string }
|
||||
| { ok: false; status: 400 | 403 | 413 | 500 | 503; error: string }
|
||||
|
||||
// Stream-proxy public images (avatars, org logos) to the workspace's public
|
||||
// image backend: an R2 binding on CF (zero-auth, zero-egress) or the
|
||||
// user-configured public S3 storage everywhere else. The platform is passed
|
||||
// per call because the R2 binding + public-URL env are request-scoped.
|
||||
// Upload/delete public images (avatars, org logos) via the Cloud avatar service.
|
||||
// `platform` is passed per call because the active license binding + cloud base
|
||||
// URL are request-scoped. Validation (mime/size) and Cloud error mapping live in
|
||||
// the gateway; the unbound instance surfaces as `{ status: 503, 'cloud_required' }`.
|
||||
export interface ImageUpload {
|
||||
uploadPublicImage(platform: Platform, prefix: string, id: string, file: File): Promise<ImageUploadResult>
|
||||
deletePublicImageVariants(platform: Platform, prefix: string, id: string): Promise<void>
|
||||
|
||||
@@ -85,6 +85,9 @@ export interface LicensingCloudGateway {
|
||||
unbindCloudLicense(baseUrl: string, licenseId: string, refreshToken: string): Promise<void>
|
||||
confirmCloudLicense(baseUrl: string, licenseId: string, refreshToken: string): Promise<void>
|
||||
createBoundCloudClient(baseUrl: string, refreshToken: string): CloudClient
|
||||
// A bound client for the Cloud avatar service: no forced JSON content-type, and
|
||||
// an auth header that survives the SDK's per-request Content-Type override.
|
||||
createAvatarUploadClient(baseUrl: string, refreshToken: string): CloudClient
|
||||
requestCloudJson<T, U = T>(
|
||||
response: Promise<{ status: number; ok: boolean; json(): Promise<T>; text(): Promise<string> }>,
|
||||
responseSchema?: z.ZodType<U>,
|
||||
|
||||
@@ -17,5 +17,7 @@ export interface StorageRepo {
|
||||
count(): Promise<number>
|
||||
update(id: string, input: UpdateStorageInput): Promise<StorageRecord | null>
|
||||
delete(id: string): Promise<DeleteStorageResult>
|
||||
select(mode: 'private' | 'public'): Promise<StorageRecord>
|
||||
// Picks the oldest active storage with available capacity (uploads land here).
|
||||
// Throws 'No available storage' when none qualifies.
|
||||
select(): Promise<StorageRecord>
|
||||
}
|
||||
|
||||
@@ -19,11 +19,10 @@ const BUSINESS: BindingState = { bound: true, active: true, edition: 'business'
|
||||
|
||||
const edition = (state: BindingState) => vi.mocked(loadBindingState).mockResolvedValue(state)
|
||||
|
||||
const sampleStorage = { id: 'st-1', title: 'My S3', mode: 'private' } as StorageRecord
|
||||
const sampleStorage = { id: 'st-1', title: 'My S3' } as StorageRecord
|
||||
|
||||
const validInput: CreateStorageInput = {
|
||||
title: 'My S3',
|
||||
mode: 'private',
|
||||
bucket: 'b',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
|
||||
@@ -92,7 +92,6 @@ export async function createStorage(
|
||||
targetType: 'storage',
|
||||
targetId: storage.id,
|
||||
targetName: storage.title,
|
||||
metadata: { mode: storage.mode },
|
||||
})
|
||||
return { ok: true, storage }
|
||||
}
|
||||
@@ -118,7 +117,6 @@ export async function updateStorage(
|
||||
targetType: 'storage',
|
||||
targetId: storage.id,
|
||||
targetName: storage.title,
|
||||
metadata: { mode: storage.mode },
|
||||
})
|
||||
return { ok: true, storage }
|
||||
}
|
||||
@@ -139,7 +137,6 @@ export async function deleteStorage(
|
||||
targetType: 'storage',
|
||||
targetId: id,
|
||||
targetName: existing?.title ?? id,
|
||||
metadata: { mode: existing?.mode },
|
||||
})
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
+21
-22
@@ -16,25 +16,24 @@
|
||||
// failure outward unchanged so the http layer maps {status} directly.
|
||||
|
||||
import type { Platform } from '../platform/interface'
|
||||
import type {
|
||||
ActivityEventWithUser,
|
||||
ActivityRepo,
|
||||
EntitlementResult,
|
||||
ImageUpload,
|
||||
ImageUploadResult,
|
||||
InviteLinkInfo,
|
||||
OrgRepo,
|
||||
PendingInvitation,
|
||||
QuotaEntitlementItem,
|
||||
TeamInviteRepo,
|
||||
TeamRepo,
|
||||
TeamSummary,
|
||||
UserAdminRepo,
|
||||
UserOperationFailure,
|
||||
import {
|
||||
type ActivityEventWithUser,
|
||||
type ActivityRepo,
|
||||
type EntitlementResult,
|
||||
type ImageUpload,
|
||||
type ImageUploadResult,
|
||||
type InviteLinkInfo,
|
||||
LOGO_PREFIX,
|
||||
type OrgRepo,
|
||||
type PendingInvitation,
|
||||
type QuotaEntitlementItem,
|
||||
type TeamInviteRepo,
|
||||
type TeamRepo,
|
||||
type TeamSummary,
|
||||
type UserAdminRepo,
|
||||
type UserOperationFailure,
|
||||
} from './ports'
|
||||
|
||||
const LOGO_PREFIX = '_system/org-logos'
|
||||
|
||||
export type TeamDeps = {
|
||||
teams: TeamRepo
|
||||
teamInvites: TeamInviteRepo
|
||||
@@ -137,14 +136,14 @@ export async function listActivity(
|
||||
|
||||
// ─── User-facing: org logo ───────────────────────────────────────────────────
|
||||
|
||||
// Logo writes require owner or admin. The MIME/size validation (and the
|
||||
// no-public-storage case) is owned by imageUpload, which returns
|
||||
// { ok:false, status } for the 400/413/503 outcomes; setTeamLogo threads that
|
||||
// status outward unchanged. A failed role check is the only 403 it raises itself.
|
||||
// Logo writes require owner or admin. The MIME/size validation (and the unbound
|
||||
// instance case) is owned by imageUpload, which returns { ok:false, status } for
|
||||
// the 400/403/413/500/503 outcomes; setTeamLogo threads that status outward
|
||||
// unchanged. A failed role check is the only 403 it raises itself.
|
||||
export type SetTeamLogoOutcome =
|
||||
| { ok: true; url: string }
|
||||
| { ok: false; reason: 'forbidden' }
|
||||
| { ok: false; reason: 'upload_failed'; status: 400 | 413 | 503; error: string }
|
||||
| { ok: false; reason: 'upload_failed'; status: 400 | 403 | 413 | 500 | 503; error: string }
|
||||
|
||||
export async function setTeamLogo(
|
||||
deps: Pick<TeamDeps, 'org' | 'teams' | 'imageUpload' | 'activity'>,
|
||||
|
||||
+20
-17
@@ -14,15 +14,16 @@
|
||||
// outward unchanged so the http layer maps {status} directly.
|
||||
|
||||
import type { Platform } from '../platform/interface'
|
||||
import type {
|
||||
ActivityRepo,
|
||||
EntitlementResult,
|
||||
ImageUpload,
|
||||
ProfileRepo,
|
||||
PublicUser,
|
||||
QuotaEntitlementItem,
|
||||
UserAdminRepo,
|
||||
UserOperationFailure,
|
||||
import {
|
||||
type ActivityRepo,
|
||||
AVATAR_PREFIX,
|
||||
type EntitlementResult,
|
||||
type ImageUpload,
|
||||
type ProfileRepo,
|
||||
type PublicUser,
|
||||
type QuotaEntitlementItem,
|
||||
type UserAdminRepo,
|
||||
type UserOperationFailure,
|
||||
} from './ports'
|
||||
|
||||
export type UserDeps = {
|
||||
@@ -155,20 +156,22 @@ export async function revokeUserEntitlement(
|
||||
|
||||
// ── self: the authenticated user's own avatar ────────────────────────────────
|
||||
|
||||
const AVATAR_PREFIX = '_system/avatars'
|
||||
|
||||
export type AvatarDeps = {
|
||||
imageUpload: ImageUpload
|
||||
profiles: ProfileRepo
|
||||
}
|
||||
|
||||
// The image gateway can reject with a status (400 bad mime, 413 too large, 503
|
||||
// no public storage). The http layer turns this into the error body + status;
|
||||
// the decision of *which* status lives in the gateway, surfaced verbatim here.
|
||||
export type UpdateAvatarOutcome = { ok: true; url: string } | { ok: false; status: 400 | 413 | 503; error: string }
|
||||
// The image gateway can reject with a status (400 bad mime, 413 too large, 403
|
||||
// license inactive, 500 cloud error, 503 instance not paired to Cloud). The http
|
||||
// layer turns this into the error body + status; the decision of *which* status
|
||||
// lives in the gateway, surfaced verbatim here.
|
||||
export type UpdateAvatarOutcome =
|
||||
| { ok: true; url: string }
|
||||
| { ok: false; status: 400 | 403 | 413 | 500 | 503; error: string }
|
||||
|
||||
// `platform` is a request-bound capability (R2 binding + public-URL env are
|
||||
// request-scoped), so it is a plain function param — not stored on AvatarDeps.
|
||||
// `platform` is a request-bound capability (the active license binding + cloud
|
||||
// base URL are request-scoped), so it is a plain function param — not stored on
|
||||
// AvatarDeps.
|
||||
export async function updateAvatar(
|
||||
deps: AvatarDeps,
|
||||
params: { platform: Platform; userId: string; file: File },
|
||||
|
||||
@@ -48,7 +48,6 @@ vi.mock('./store/traffic-metering', () => ({ meterDownloadTraffic: vi.fn() }))
|
||||
const storage = {
|
||||
id: 'st-1',
|
||||
title: 'S3',
|
||||
mode: 'private',
|
||||
egressCreditBillingEnabled: false,
|
||||
egressCreditUnitBytes: 0,
|
||||
egressCreditPerUnit: 0,
|
||||
|
||||
@@ -250,9 +250,7 @@ export async function putWebDavFile(
|
||||
},
|
||||
): Promise<PutWebDavOutcome> {
|
||||
const { orgId, userId, target, fileName, parent, contentType, contentLength, body } = params
|
||||
const storage = target.matter
|
||||
? await deps.storages.get(target.matter.storageId)
|
||||
: await deps.storages.select('private')
|
||||
const storage = target.matter ? await deps.storages.get(target.matter.storageId) : await deps.storages.select()
|
||||
if (!storage) return { ok: false, reason: 'no_storage' }
|
||||
|
||||
const objectKey =
|
||||
@@ -350,7 +348,7 @@ export async function createWebDavCollection(
|
||||
deps: Pick<Deps, 'matter' | 'storages'>,
|
||||
params: { orgId: string; userId: string; name: string; parent: string },
|
||||
): Promise<void> {
|
||||
const storage = await deps.storages.select('private')
|
||||
const storage = await deps.storages.select()
|
||||
await deps.matter.create({
|
||||
orgId: params.orgId,
|
||||
userId: params.userId,
|
||||
@@ -613,7 +611,7 @@ export async function createWebDavLock(
|
||||
const { orgId, userId, target } = params
|
||||
const created = !target.matter && Boolean(target.name)
|
||||
if (created) {
|
||||
const storage = await deps.storages.select('private')
|
||||
const storage = await deps.storages.select()
|
||||
const objectKey = buildObjectKey({ uid: userId, orgId, rawExt: fileExt(target.name) })
|
||||
await deps.s3.putObject(storage, objectKey, new Uint8Array(), 'application/octet-stream')
|
||||
await deps.matter.create({
|
||||
|
||||
Reference in New Issue
Block a user