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 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>
223 lines
9.1 KiB
TypeScript
223 lines
9.1 KiB
TypeScript
import { FREE_STORAGE_LIMIT } from '@shared/constants'
|
|
import type { CreateStorageInput } from '@shared/schemas'
|
|
import type { BindingState } from '@shared/types'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { ActivityRepo, LicenseBindingRepo, StorageRecord, StorageRepo } from '../ports'
|
|
import { AppError } from '../ports'
|
|
import { loadBindingState } from './licensing'
|
|
import { createStorage, deleteStorage, getStorage, listStorages, type StorageDeps, updateStorage } from './storage'
|
|
|
|
// loadBindingState derives features from a signed certificate — out of scope for
|
|
// a usecase unit test. Mock it so each case feeds a chosen edition; the real
|
|
// (pure) hasFeature then runs against it. The full cert→features→gate path is
|
|
// covered by storages.integration.test.ts.
|
|
vi.mock('./licensing', () => ({ loadBindingState: vi.fn() }))
|
|
|
|
const COMMUNITY: BindingState = { bound: false }
|
|
const PRO: BindingState = { bound: true, active: true, edition: 'pro' } // has storages_unlimited, not quota_store
|
|
const BUSINESS: BindingState = { bound: true, active: true, edition: 'business' } // has both
|
|
|
|
const edition = (state: BindingState) => vi.mocked(loadBindingState).mockResolvedValue(state)
|
|
|
|
const sampleStorage = { id: 'st-1', title: 'My S3' } as StorageRecord
|
|
|
|
const validInput: CreateStorageInput = {
|
|
title: 'My S3',
|
|
bucket: 'b',
|
|
endpoint: 'https://s3.example.com',
|
|
region: 'us-east-1',
|
|
accessKey: 'k',
|
|
secretKey: 's',
|
|
} as CreateStorageInput
|
|
|
|
function makeDeps(storages: Partial<StorageRepo> = {}) {
|
|
const record = vi.fn(async () => {})
|
|
const repo: StorageRepo = {
|
|
list: async () => ({ items: [], total: 0 }),
|
|
get: async () => null,
|
|
create: async () => sampleStorage,
|
|
count: async () => 0,
|
|
update: async () => null,
|
|
delete: async () => 'ok',
|
|
select: async () => sampleStorage,
|
|
...storages,
|
|
}
|
|
const deps: StorageDeps = {
|
|
storages: repo,
|
|
activity: { record } as unknown as ActivityRepo,
|
|
licenseBinding: {} as LicenseBindingRepo, // unused — loadBindingState is mocked
|
|
}
|
|
return { deps, record }
|
|
}
|
|
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
describe('storage usecase', () => {
|
|
it('listStorages forwards the repo result', async () => {
|
|
const { deps } = makeDeps({ list: async () => ({ items: [sampleStorage], total: 1 }) })
|
|
expect(await listStorages(deps)).toEqual({ items: [sampleStorage], total: 1 })
|
|
})
|
|
|
|
it('getStorage returns the record', async () => {
|
|
const { deps } = makeDeps({ get: async () => sampleStorage })
|
|
expect(await getStorage(deps, 'st-1')).toBe(sampleStorage)
|
|
})
|
|
|
|
it('getStorage returns null when missing', async () => {
|
|
const { deps } = makeDeps({ get: async () => null })
|
|
expect(await getStorage(deps, 'nope')).toBeNull()
|
|
})
|
|
|
|
describe('createStorage', () => {
|
|
it('creates and records activity when under the Community limit', async () => {
|
|
edition(COMMUNITY)
|
|
const create = vi.fn(async () => sampleStorage)
|
|
const { deps, record } = makeDeps({ count: async () => FREE_STORAGE_LIMIT - 1, create })
|
|
const out = await createStorage(deps, { userId: 'u1', orgId: 'o1', input: validInput })
|
|
expect(out).toEqual({ ok: true, storage: sampleStorage })
|
|
expect(create).toHaveBeenCalledWith(validInput)
|
|
expect(record).toHaveBeenCalledWith(
|
|
expect.objectContaining({ action: 'storage_create', targetId: 'st-1', orgId: 'o1', userId: 'u1' }),
|
|
)
|
|
})
|
|
|
|
it('blocks at the Community storage limit without storages_unlimited', async () => {
|
|
edition(COMMUNITY)
|
|
const { deps, record } = makeDeps({ count: async () => FREE_STORAGE_LIMIT })
|
|
const out = await createStorage(deps, { userId: 'u1', orgId: 'o1', input: validInput })
|
|
expect(out.ok).toBe(false)
|
|
if (!out.ok) {
|
|
expect(out.error).toBeInstanceOf(AppError)
|
|
expect(out.error.httpStatus).toBe(402)
|
|
expect(out.error.meta.reason).toBe('FEATURE_NOT_AVAILABLE')
|
|
expect(out.error.meta.metadata).toEqual({
|
|
feature: 'storages_unlimited',
|
|
currentCount: String(FREE_STORAGE_LIMIT),
|
|
limit: String(FREE_STORAGE_LIMIT),
|
|
})
|
|
}
|
|
expect(record).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('allows creating past the limit with storages_unlimited', async () => {
|
|
edition(PRO)
|
|
const create = vi.fn(async () => sampleStorage)
|
|
const { deps } = makeDeps({ count: async () => FREE_STORAGE_LIMIT + 5, create })
|
|
const out = await createStorage(deps, { userId: 'u1', orgId: 'o1', input: validInput })
|
|
expect(out.ok).toBe(true)
|
|
expect(create).toHaveBeenCalled()
|
|
})
|
|
|
|
it('blocks egress-credit billing when quota_store is absent', async () => {
|
|
edition(PRO) // pro has storages_unlimited but not quota_store
|
|
const { deps, record } = makeDeps({ count: async () => 0 })
|
|
const out = await createStorage(deps, {
|
|
userId: 'u1',
|
|
orgId: 'o1',
|
|
input: { ...validInput, egressCreditBillingEnabled: true },
|
|
})
|
|
expect(out.ok).toBe(false)
|
|
if (!out.ok) {
|
|
expect(out.error).toBeInstanceOf(AppError)
|
|
expect(out.error.httpStatus).toBe(402)
|
|
expect(out.error.meta.reason).toBe('FEATURE_NOT_AVAILABLE')
|
|
expect(out.error.meta.metadata).toEqual({ feature: 'quota_store' })
|
|
}
|
|
expect(record).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('allows egress-credit billing with quota_store (business)', async () => {
|
|
edition(BUSINESS)
|
|
const create = vi.fn(async () => sampleStorage)
|
|
const { deps } = makeDeps({ count: async () => 0, create })
|
|
const out = await createStorage(deps, {
|
|
userId: 'u1',
|
|
orgId: 'o1',
|
|
input: { ...validInput, egressCreditBillingEnabled: true },
|
|
})
|
|
expect(out.ok).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('updateStorage', () => {
|
|
it('updates and records activity', async () => {
|
|
edition(COMMUNITY)
|
|
const update = vi.fn(async () => sampleStorage)
|
|
const { deps, record } = makeDeps({ update })
|
|
const out = await updateStorage(deps, { userId: 'u1', orgId: 'o1', id: 'st-1', input: { title: 'New' } })
|
|
expect(out).toEqual({ ok: true, storage: sampleStorage })
|
|
expect(update).toHaveBeenCalledWith('st-1', { title: 'New' })
|
|
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'storage_update', targetId: 'st-1' }))
|
|
})
|
|
|
|
it('returns not_found for a missing storage', async () => {
|
|
edition(COMMUNITY)
|
|
const { deps, record } = makeDeps({ update: async () => null })
|
|
const out = await updateStorage(deps, { userId: 'u1', orgId: 'o1', id: 'x', input: { title: 'New' } })
|
|
expect(out.ok).toBe(false)
|
|
if (!out.ok) {
|
|
expect(out.error).toBeInstanceOf(AppError)
|
|
expect(out.error.httpStatus).toBe(404)
|
|
expect(out.error.message).toBe('Storage not found')
|
|
}
|
|
expect(record).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('gates egress-credit billing before the existence check (402 over 404)', async () => {
|
|
edition(PRO) // lacks quota_store
|
|
const update = vi.fn(async () => null)
|
|
const { deps } = makeDeps({ update })
|
|
const out = await updateStorage(deps, {
|
|
userId: 'u1',
|
|
orgId: 'o1',
|
|
id: 'x',
|
|
input: { egressCreditBillingEnabled: true },
|
|
})
|
|
expect(out.ok).toBe(false)
|
|
if (!out.ok) {
|
|
expect(out.error).toBeInstanceOf(AppError)
|
|
expect(out.error.httpStatus).toBe(402)
|
|
expect(out.error.meta.reason).toBe('FEATURE_NOT_AVAILABLE')
|
|
expect(out.error.meta.metadata).toEqual({ feature: 'quota_store' })
|
|
}
|
|
expect(update).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('deleteStorage', () => {
|
|
it('deletes and records activity with the storage name', async () => {
|
|
const del = vi.fn(async () => 'ok' as const)
|
|
const { deps, record } = makeDeps({ get: async () => sampleStorage, delete: del })
|
|
const out = await deleteStorage(deps, { userId: 'u1', orgId: 'o1', id: 'st-1' })
|
|
expect(out).toEqual({ ok: true })
|
|
expect(record).toHaveBeenCalledWith(
|
|
expect.objectContaining({ action: 'storage_delete', targetId: 'st-1', targetName: 'My S3' }),
|
|
)
|
|
})
|
|
|
|
it('returns not_found for a missing storage', async () => {
|
|
const { deps, record } = makeDeps({ get: async () => null, delete: async () => 'not_found' })
|
|
const out = await deleteStorage(deps, { userId: 'u1', orgId: 'o1', id: 'x' })
|
|
expect(out.ok).toBe(false)
|
|
if (!out.ok) {
|
|
expect(out.error).toBeInstanceOf(AppError)
|
|
expect(out.error.httpStatus).toBe(404)
|
|
expect(out.error.message).toBe('Storage not found')
|
|
}
|
|
expect(record).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns in_use when the storage is referenced', async () => {
|
|
const { deps, record } = makeDeps({ get: async () => sampleStorage, delete: async () => 'in_use' })
|
|
const out = await deleteStorage(deps, { userId: 'u1', orgId: 'o1', id: 'st-1' })
|
|
expect(out.ok).toBe(false)
|
|
if (!out.ok) {
|
|
expect(out.error).toBeInstanceOf(AppError)
|
|
expect(out.error.httpStatus).toBe(409)
|
|
expect(out.error.message).toBe('Storage is referenced by existing files')
|
|
}
|
|
expect(record).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|