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

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

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

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

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

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

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

---------

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

1015 lines
40 KiB
TypeScript

import { DirType } from '@shared/constants'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
abortUpload,
authorizeTaskUploadConfirm,
completeUpload,
copyObject,
createObject,
deleteObject,
getObject,
getTrashObject,
hasEditorAccess,
listObjects,
listTrashedObjects,
type ObjectActor,
restoreObject,
transferObject,
trashObject,
updateObject,
} from './object'
import {
type ActivityRepo,
AppError,
type CloudTrafficReportRepo,
type ConflictPlan,
type DownloaderRepo,
type DownloadTaskRecord,
type DownloadTaskRepo,
type LicenseBindingRepo,
type LicensingCloudGateway,
type Matter,
type MatterRepo,
type ObjectUploadSessionRecord,
type ObjectUploadSessionRepo,
type OrgRepo,
type QuotaRepo,
type S3Gateway,
type ShareRepo,
type StorageRecord,
type StorageRepo,
type StorageUsageRepo,
} from './ports'
import { meterDownloadTraffic } from './store/traffic-metering'
// Download metering is an end-to-end usecase of its own (quota + cloud report +
// refund); object.getObject only orchestrates around it. Mock it and assert the
// outcome mapping + presign rollback here; cloud-traffic-metering.test.ts owns
// the metering internals.
vi.mock('./store/traffic-metering', () => ({ meterDownloadTraffic: vi.fn() }))
const storage = {
id: 'st-1',
title: 'S3',
egressCreditBillingEnabled: false,
egressCreditUnitBytes: 0,
egressCreditPerUnit: 0,
} as unknown as StorageRecord
// Fixed so two file() calls compare equal (tests deep-equal a fixture against a
// usecase result; argless new Date() per call would flake under load).
const FIXED_DATE = new Date('2024-01-01T00:00:00.000Z')
function file(id: string, overrides: Partial<Matter> = {}): Matter {
return {
id,
orgId: 'o1',
alias: `${id}-alias`,
name: `${id}.txt`,
type: 'text/plain',
size: 100,
dirtype: DirType.FILE,
parent: '',
object: `key/${id}`,
storageId: 'st-1',
status: 'active',
trashedAt: null,
createdAt: FIXED_DATE,
updatedAt: FIXED_DATE,
...overrides,
}
}
const folder = (id: string, overrides: Partial<Matter> = {}): Matter =>
file(id, { dirtype: DirType.USER_FOLDER, size: 0, object: '', ...overrides })
const user: ObjectActor = { kind: 'user', userId: 'u1' }
// A permissive default deps; tests override only the ports they exercise.
function makeDeps(
overrides: {
matter?: Partial<MatterRepo>
storages?: Partial<StorageRepo>
s3?: Partial<S3Gateway>
quota?: Partial<QuotaRepo>
storageUsage?: Partial<StorageUsageRepo>
activity?: Partial<ActivityRepo>
share?: Partial<ShareRepo>
org?: Partial<OrgRepo>
objectUploadSessions?: Partial<ObjectUploadSessionRepo>
downloaders?: Partial<DownloaderRepo>
downloadTasks?: Partial<DownloadTaskRepo>
} = {},
) {
const record = vi.fn(async () => {})
const deps = {
matter: {
get: async () => null,
list: async () => ({ items: [], total: 0, page: 1, pageSize: 20 }),
create: async (input: Parameters<MatterRepo['create']>[0]) => file('new', input as Partial<Matter>),
update: async () => null,
copy: async (source: Matter, parent: string, object: string) =>
file('copy', { parent, object, name: source.name }),
cancelDraft: async () => null,
trash: async () => null,
restore: async () => null,
collectForPurge: async () => null,
purge: async () => {},
planConflictResolution: async (_o: string, _p: string, name: string): Promise<ConflictPlan> => ({
finalName: name,
toTrash: null,
}),
commitConflictPlan: async () => {},
activateDraft: async () => true,
...overrides.matter,
} as unknown as MatterRepo,
storages: {
get: async () => storage,
select: async () => storage,
...overrides.storages,
} as unknown as StorageRepo,
s3: {
presignUpload: async () => 'https://upload.example',
presignDownload: async () => 'https://download.example',
copyObject: async () => {},
deleteObject: async () => {},
deleteObjects: async () => {},
...overrides.s3,
} as unknown as S3Gateway,
// Type placeholders for getObject's metering ports — meterDownloadTraffic is mocked.
cloudTrafficReports: {} as unknown as CloudTrafficReportRepo,
licenseBinding: {} as unknown as LicenseBindingRepo,
licensingCloud: {} as unknown as LicensingCloudGateway,
quota: {
incrementUsageIfEffectiveQuotaAllows: async () => true,
refundTraffic: async () => {},
...overrides.quota,
} as unknown as QuotaRepo,
storageUsage: {
reconcile: async () => {},
rollbackReservations: async () => {},
...overrides.storageUsage,
} as unknown as StorageUsageRepo,
activity: { record, ...overrides.activity } as unknown as ActivityRepo,
share: {
cascadeDeleteByMatter: async () => {},
computeSourceBytes: async () => 0,
hasQuotaForBytes: async () => true,
...overrides.share,
} as unknown as ShareRepo,
org: {
getMemberRole: async () => null,
isPersonalOrg: async () => true,
canReadOrg: async () => true,
canWriteToOrg: async () => true,
...overrides.org,
} as unknown as OrgRepo,
objectUploadSessions: {
create: async (input: Parameters<ObjectUploadSessionRepo['create']>[0]) =>
({
id: 'sess-1',
objectId: input.objectId,
uploadId: input.uploadId,
partSize: input.partSize,
status: 'active',
storageKey: input.storageKey,
expiresAt: new Date(Date.now() + 3_600_000),
createdAt: new Date(),
updatedAt: new Date(),
}) as ObjectUploadSessionRecord,
get: async () => null,
setStatus: async () => {},
...overrides.objectUploadSessions,
} as unknown as ObjectUploadSessionRepo,
downloaders: { ...overrides.downloaders } as unknown as DownloaderRepo,
downloadTasks: {
findRecord: async () =>
({ id: 't1', assignedDownloaderId: 'd1', status: 'uploading' }) as unknown as DownloadTaskRecord,
...overrides.downloadTasks,
} as unknown as DownloadTaskRepo,
}
return { deps, record }
}
beforeEach(() => vi.clearAllMocks())
function expectError(out: unknown, httpStatus: number, message: string, reason?: string) {
const e = (out as { ok: false; error: AppError }).error
expect(e).toBeInstanceOf(AppError)
expect(e.httpStatus).toBe(httpStatus)
expect(e.message).toBe(message)
if (reason) expect(e.meta.reason).toBe(reason)
}
describe('object usecase', () => {
describe('listObjects', () => {
it('lists the active org without an override', async () => {
const list = vi.fn(async () => ({ items: [file('m1')], total: 1, page: 1, pageSize: 20 }))
const { deps } = makeDeps({ matter: { list } })
const out = await listObjects(deps, {
orgId: 'o1',
userId: 'u1',
filters: { parent: '', page: 1, pageSize: 20 },
})
expect(out).toEqual({ ok: true, result: { items: [file('m1')], total: 1, page: 1, pageSize: 20 } })
expect(list).toHaveBeenCalledWith('o1', { parent: '', page: 1, pageSize: 20 })
})
it('browses an override org the user can read', async () => {
const list = vi.fn(async () => ({ items: [], total: 0, page: 1, pageSize: 20 }))
const canReadOrg = vi.fn(async () => true)
const { deps } = makeDeps({ matter: { list }, org: { canReadOrg } })
const out = await listObjects(deps, {
orgId: 'o1',
userId: 'u1',
orgOverride: 'o2',
filters: { parent: '', page: 1, pageSize: 20 },
})
expect(out.ok).toBe(true)
expect(canReadOrg).toHaveBeenCalledWith('u1', 'o2')
expect(list).toHaveBeenCalledWith('o2', expect.anything())
})
it('forbids an override org the user cannot read', async () => {
const list = vi.fn()
const { deps } = makeDeps({ matter: { list }, org: { canReadOrg: async () => false } })
const out = await listObjects(deps, {
orgId: 'o1',
userId: 'u1',
orgOverride: 'o2',
filters: { parent: '', page: 1, pageSize: 20 },
})
expectError(out, 403, 'Forbidden')
expect(list).not.toHaveBeenCalled()
})
})
describe('hasEditorAccess', () => {
it('grants editor+ member roles', async () => {
const { deps } = makeDeps({ org: { getMemberRole: async () => 'editor' } })
expect(await hasEditorAccess(deps, { orgId: 'o1', userId: 'u1' })).toBe(true)
})
it('denies viewer role', async () => {
const { deps } = makeDeps({ org: { getMemberRole: async () => 'viewer' } })
expect(await hasEditorAccess(deps, { orgId: 'o1', userId: 'u1' })).toBe(false)
})
it('falls back to personal-org ownership when no member row', async () => {
const { deps } = makeDeps({ org: { getMemberRole: async () => null, isPersonalOrg: async () => true } })
expect(await hasEditorAccess(deps, { orgId: 'o1', userId: 'u1' })).toBe(true)
})
it('returns false without an org or user', async () => {
const { deps } = makeDeps()
expect(await hasEditorAccess(deps, { orgId: null, userId: 'u1' })).toBe(false)
expect(await hasEditorAccess(deps, { orgId: 'o1', userId: null })).toBe(false)
})
})
describe('createObject', () => {
it('creates a folder without presigning an upload', async () => {
const create = vi.fn(async () => folder('f1', { name: 'My Folder' }))
const presignUpload = vi.fn()
const { deps } = makeDeps({ matter: { create }, s3: { presignUpload } })
const out = await createObject(deps, {
orgId: 'o1',
actor: user,
input: { name: 'My Folder', type: 'folder', dirtype: DirType.USER_FOLDER, parent: '' },
})
expect(out).toEqual({ ok: true, matter: folder('f1', { name: 'My Folder' }) })
expect(create).toHaveBeenCalledWith(
expect.objectContaining({ type: 'folder', size: 0, status: 'active', object: '', userId: 'u1' }),
)
expect(presignUpload).not.toHaveBeenCalled()
})
it('creates a small file draft and returns single-PUT upload instructions', async () => {
const draft = file('d1', { status: 'draft', object: 'o1/u1/key.jpg', name: 'photo.jpg', type: 'image/jpeg' })
const { deps } = makeDeps({
matter: { create: async () => draft },
s3: { presignUpload: async () => 'https://up' },
})
const out = await createObject(deps, {
orgId: 'o1',
actor: user,
input: { name: 'photo.jpg', type: 'image/jpeg', size: 2048, dirtype: DirType.FILE, parent: '' },
})
expect(out.ok).toBe(true)
if (out.ok && 'upload' in out) {
// ≤5 GiB → single PutObject: one URL, partSize equals the file size.
expect(out.upload.sessionId).toBe('sess-1')
expect(out.upload.urls).toEqual(['https://up'])
expect(out.upload.partSize).toBe(2048)
expect(out.matter.status).toBe('draft')
} else {
throw new Error('expected upload outcome')
}
})
it('creates a large file draft and returns multipart upload instructions', async () => {
const fiveGiB = 5 * 1024 * 1024 * 1024
const size = fiveGiB + 1 // just over 5 GiB → two 5 GiB parts
const draft = file('big', { status: 'draft', object: 'o1/u1/big.bin', name: 'big.bin', size })
const createMultipartUpload = vi.fn(async () => 'mp-1')
const presignUploadPart = vi.fn(async () => 'https://part')
const { deps } = makeDeps({
matter: { create: async () => draft },
s3: { createMultipartUpload, presignUploadPart } as Partial<S3Gateway>,
})
const out = await createObject(deps, {
orgId: 'o1',
actor: user,
input: { name: 'big.bin', type: 'application/octet-stream', size, dirtype: DirType.FILE, parent: '' },
})
expect(out.ok).toBe(true)
if (out.ok && 'upload' in out) {
expect(out.upload.partSize).toBe(fiveGiB)
expect(out.upload.urls).toHaveLength(2)
expect(createMultipartUpload).toHaveBeenCalled()
} else {
throw new Error('expected upload outcome')
}
})
it('rejects a file larger than 5 TiB before creating a draft', async () => {
const create = vi.fn()
const { deps } = makeDeps({ matter: { create } })
const out = await createObject(deps, {
orgId: 'o1',
actor: user,
input: {
name: 'huge.bin',
type: 'application/octet-stream',
size: 5 * 1024 * 1024 * 1024 * 1024 + 1,
dirtype: DirType.FILE,
parent: '',
},
})
expectError(out, 400, 'File exceeds the 5 TiB maximum', 'FILE_TOO_LARGE')
expect(create).not.toHaveBeenCalled()
})
it('returns no_storage when no storage is configured', async () => {
const { deps } = makeDeps({
storages: {
select: async () => {
throw new Error('No available storage')
},
},
})
const out = await createObject(deps, {
orgId: 'o1',
actor: user,
input: { name: 'x.txt', type: 'text/plain', dirtype: DirType.FILE, parent: '' },
})
expectError(out, 503, 'No storage configured', 'NO_STORAGE_CONFIGURED')
})
it('rejects an agent upload outside its target folder', async () => {
const create = vi.fn()
const { deps } = makeDeps({ matter: { create } })
const out = await createObject(deps, {
orgId: 'o1',
actor: {
kind: 'download-task-upload',
downloaderId: 'd1',
taskId: 't1',
targetFolder: 'Inbox',
createdByUserId: 'creator',
},
input: { name: 'x.txt', type: 'text/plain', dirtype: DirType.FILE, parent: 'Other' },
})
expectError(out, 403, 'Target folder is outside task authorization')
expect(create).not.toHaveBeenCalled()
})
it('logs an agent upload as the downloader and keys it to the task creator', async () => {
const create = vi.fn(async (input: Parameters<MatterRepo['create']>[0]) => file('m', input as Partial<Matter>))
const { deps } = makeDeps({ matter: { create } })
await createObject(deps, {
orgId: 'o1',
actor: {
kind: 'download-task-upload',
downloaderId: 'd1',
taskId: 't1',
targetFolder: 'Inbox',
createdByUserId: 'creator',
},
input: { name: 'x.txt', type: 'text/plain', dirtype: DirType.FILE, parent: 'Inbox' },
})
const arg = create.mock.calls[0][0]
expect(arg.userId).toBe('downloader:d1')
// object key uses the creator's uid
expect(arg.object).toContain('o1/creator/')
})
})
// An active upload session record as the repo returns it. uploadId=null is a
// single PutObject; a string is an S3 multipart upload.
function session(overrides: Partial<ObjectUploadSessionRecord> = {}): ObjectUploadSessionRecord {
return {
id: 'sess-1',
orgId: 'o1',
objectId: 'd1',
storageId: 'st-1',
storageKey: 'key/d1',
uploadId: null,
partSize: 100,
status: 'active',
onConflict: 'fail',
createdBy: 'u1',
expiresAt: new Date(Date.now() + 3_600_000),
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as ObjectUploadSessionRecord
}
describe('completeUpload', () => {
it('finalizes a single-PUT draft after HEAD confirms the reported ETag', async () => {
const draft = file('d1', { status: 'draft', size: 100 })
const setStatus = vi.fn(async () => {})
const headObject = vi.fn(async () => ({ size: 100, contentType: 'text/plain', etag: 'abc' }))
const { deps } = makeDeps({
matter: { get: async () => draft, activateDraft: async () => true },
s3: { headObject } as Partial<S3Gateway>,
objectUploadSessions: { get: async () => session(), setStatus },
})
const out = await completeUpload(deps, {
orgId: 'o1',
objectId: 'd1',
sessionId: 'sess-1',
parts: [{ partNumber: 1, etag: 'abc' }],
actorId: 'u1',
})
expect(out.ok).toBe(true)
if (out.ok) expect(out.matter.status).toBe('active')
expect(headObject).toHaveBeenCalledWith(storage, 'key/d1')
expect(setStatus).toHaveBeenCalledWith('sess-1', 'completed')
})
it('tolerates quoted ETags from the client (strips quotes before comparing)', async () => {
const draft = file('d1', { status: 'draft', size: 100 })
const { deps } = makeDeps({
matter: { get: async () => draft, activateDraft: async () => true },
s3: { headObject: async () => ({ size: 100, contentType: 'text/plain', etag: 'abc' }) } as Partial<S3Gateway>,
objectUploadSessions: { get: async () => session(), setStatus: async () => {} },
})
const out = await completeUpload(deps, {
orgId: 'o1',
objectId: 'd1',
sessionId: 'sess-1',
parts: [{ partNumber: 1, etag: '"abc"' }],
actorId: 'u1',
})
expect(out.ok).toBe(true)
})
it('rejects a single-PUT completion whose ETag does not match the HEAD', async () => {
const draft = file('d1', { status: 'draft', size: 100 })
const { deps } = makeDeps({
matter: { get: async () => draft },
s3: { headObject: async () => ({ size: 100, contentType: 'text/plain', etag: 'real' }) } as Partial<S3Gateway>,
objectUploadSessions: { get: async () => session(), setStatus: async () => {} },
})
await expect(
completeUpload(deps, {
orgId: 'o1',
objectId: 'd1',
sessionId: 'sess-1',
parts: [{ partNumber: 1, etag: 'wrong' }],
actorId: 'u1',
}),
).rejects.toMatchObject({ code: 'invalid_state' })
})
it('completes a multipart draft via CompleteMultipartUpload', async () => {
const draft = file('d1', { status: 'draft', size: 100 })
const completeMultipartUpload = vi.fn(async () => {})
const { deps } = makeDeps({
matter: { get: async () => draft, activateDraft: async () => true },
s3: { completeMultipartUpload } as Partial<S3Gateway>,
objectUploadSessions: { get: async () => session({ uploadId: 'mp-1' }), setStatus: async () => {} },
})
const out = await completeUpload(deps, {
orgId: 'o1',
objectId: 'd1',
sessionId: 'sess-1',
parts: [{ partNumber: 1, etag: 'e1' }],
actorId: 'u1',
})
expect(out.ok).toBe(true)
expect(completeMultipartUpload).toHaveBeenCalledWith(storage, 'key/d1', 'mp-1', [{ partNumber: 1, etag: 'e1' }])
})
it('throws not_found when the upload session is missing', async () => {
const draft = file('d1', { status: 'draft' })
const { deps } = makeDeps({
matter: { get: async () => draft },
objectUploadSessions: { get: async () => null },
})
await expect(
completeUpload(deps, {
orgId: 'o1',
objectId: 'd1',
sessionId: 'sess-1',
parts: [{ partNumber: 1, etag: 'x' }],
actorId: 'u1',
}),
).rejects.toMatchObject({ code: 'not_found' })
})
it('returns quota_exceeded when the activation reservation is rejected', async () => {
const draft = file('d1', { status: 'draft', size: 50 })
const { deps } = makeDeps({
matter: { get: async () => draft },
s3: { headObject: async () => ({ size: 50, contentType: 'text/plain', etag: 'abc' }) } as Partial<S3Gateway>,
objectUploadSessions: { get: async () => session(), setStatus: async () => {} },
quota: { incrementUsageIfEffectiveQuotaAllows: async () => false },
})
const out = await completeUpload(deps, {
orgId: 'o1',
objectId: 'd1',
sessionId: 'sess-1',
parts: [{ partNumber: 1, etag: 'abc' }],
actorId: 'u1',
})
expectError(out, 422, 'Quota exceeded', 'QUOTA_EXCEEDED')
})
it('propagates a name conflict thrown during activation', async () => {
const draft = file('d1', { status: 'draft' })
const { deps } = makeDeps({
matter: {
get: async () => draft,
planConflictResolution: async () => {
throw new (await import('./ports')).NameConflictError('upload.txt', 'active1')
},
},
s3: { headObject: async () => ({ size: 100, contentType: 'text/plain', etag: 'abc' }) } as Partial<S3Gateway>,
objectUploadSessions: { get: async () => session(), setStatus: async () => {} },
})
await expect(
completeUpload(deps, {
orgId: 'o1',
objectId: 'd1',
sessionId: 'sess-1',
parts: [{ partNumber: 1, etag: 'abc' }],
actorId: 'u1',
}),
).rejects.toMatchObject({ name: 'NameConflictError' })
})
})
describe('abortUpload', () => {
it('aborts a single-PUT session: best-effort S3 delete + discards the draft', async () => {
const setStatus = vi.fn(async () => {})
const cancelDraft = vi.fn(async () => file('d1', { status: 'draft' }))
const deleteObjectFn = vi.fn(async () => {})
const { deps } = makeDeps({
matter: { get: async () => file('d1', { status: 'draft' }), cancelDraft },
s3: { deleteObject: deleteObjectFn },
objectUploadSessions: { get: async () => session(), setStatus },
})
await abortUpload(deps, { orgId: 'o1', objectId: 'd1', sessionId: 'sess-1', actorId: 'u1' })
expect(deleteObjectFn).toHaveBeenCalledWith(storage, 'key/d1')
expect(setStatus).toHaveBeenCalledWith('sess-1', 'aborted')
expect(cancelDraft).toHaveBeenCalledWith('d1', 'o1', 'u1')
})
it('aborts a multipart session via AbortMultipartUpload', async () => {
const abortMultipartUpload = vi.fn(async () => {})
const { deps } = makeDeps({
matter: { get: async () => file('d1', { status: 'draft' }), cancelDraft: async () => file('d1') },
s3: { abortMultipartUpload } as Partial<S3Gateway>,
objectUploadSessions: { get: async () => session({ uploadId: 'mp-1' }), setStatus: async () => {} },
})
await abortUpload(deps, { orgId: 'o1', objectId: 'd1', sessionId: 'sess-1', actorId: 'u1' })
expect(abortMultipartUpload).toHaveBeenCalledWith(storage, 'key/d1', 'mp-1')
})
it('is idempotent for an already-aborted session', async () => {
const cancelDraft = vi.fn(async () => null)
const { deps } = makeDeps({
matter: { get: async () => file('d1', { status: 'draft' }), cancelDraft },
objectUploadSessions: { get: async () => session({ status: 'aborted' }) },
})
await abortUpload(deps, { orgId: 'o1', objectId: 'd1', sessionId: 'sess-1', actorId: 'u1' })
expect(cancelDraft).not.toHaveBeenCalled()
})
it('rejects aborting an already-completed session', async () => {
const { deps } = makeDeps({
matter: { get: async () => file('d1', { status: 'draft' }) },
objectUploadSessions: { get: async () => session({ status: 'completed' }) },
})
await expect(
abortUpload(deps, { orgId: 'o1', objectId: 'd1', sessionId: 'sess-1', actorId: 'u1' }),
).rejects.toMatchObject({ code: 'invalid_state' })
})
})
describe('listTrashedObjects / getTrashObject', () => {
it('paginates trashed roots', async () => {
const roots = [file('t1', { trashedAt: 2 }), file('t2', { trashedAt: 1 })]
const { deps } = makeDeps({ matter: { listTrashedRoots: async () => roots } })
const out = await listTrashedObjects(deps, { orgId: 'o1', page: 1, pageSize: 1 })
expect(out.result.total).toBe(2)
expect(out.result.items).toEqual([roots[0]])
})
it('returns a trashed object', async () => {
const { deps } = makeDeps({ matter: { get: async () => file('t1', { trashedAt: 5 }) } })
const out = await getTrashObject(deps, { orgId: 'o1', objectId: 't1' })
expect(out.ok).toBe(true)
if (out.ok) expect(out.matter.trashedAt).toBe(5)
})
it('returns not_found for a live (non-trashed) object', async () => {
const { deps } = makeDeps({ matter: { get: async () => file('m1', { trashedAt: null }) } })
expectError(await getTrashObject(deps, { orgId: 'o1', objectId: 'm1' }), 404, 'Not found')
})
it('returns not_found for a missing object', async () => {
const { deps } = makeDeps({ matter: { get: async () => null } })
expectError(await getTrashObject(deps, { orgId: 'o1', objectId: 'x' }), 404, 'Not found')
})
})
describe('getObject', () => {
it('returns a folder without metering or presigning', async () => {
const presignDownload = vi.fn()
const { deps } = makeDeps({ matter: { get: async () => folder('f1') }, s3: { presignDownload } })
const out = await getObject(deps, { orgId: 'o1', objectId: 'f1', cloudBaseUrl: 'https://cloud' })
expect(out).toEqual({ ok: true, matter: folder('f1') })
expect(meterDownloadTraffic).not.toHaveBeenCalled()
expect(presignDownload).not.toHaveBeenCalled()
})
it('returns not_found for a missing object', async () => {
const { deps } = makeDeps({ matter: { get: async () => null } })
const out = await getObject(deps, { orgId: 'o1', objectId: 'x', cloudBaseUrl: 'https://cloud' })
expectError(out, 404, 'Not found')
})
it('returns storage_not_found when a file has no storage row', async () => {
const { deps } = makeDeps({ matter: { get: async () => file('m1') }, storages: { get: async () => null } })
const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', cloudBaseUrl: 'https://cloud' })
expectError(out, 404, 'Storage not found')
})
it('meters egress then presigns the download URL for a file', async () => {
vi.mocked(meterDownloadTraffic).mockResolvedValue({ ok: true })
const presignDownload = vi.fn(async () => 'https://signed')
const { deps } = makeDeps({ matter: { get: async () => file('m1', { size: 100 }) }, s3: { presignDownload } })
const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', cloudBaseUrl: 'https://cloud' })
expect(out).toMatchObject({ ok: true, downloadUrl: 'https://signed' })
expect(meterDownloadTraffic).toHaveBeenCalledWith(
deps,
expect.objectContaining({
cloudBaseUrl: 'https://cloud',
orgId: 'o1',
bytes: 100,
source: 'object_download',
sourceId: 'm1',
}),
)
})
it('maps a quota_exceeded metering outcome and never presigns', async () => {
vi.mocked(meterDownloadTraffic).mockResolvedValue({ ok: false, reason: 'quota_exceeded' })
const presignDownload = vi.fn()
const { deps } = makeDeps({ matter: { get: async () => file('m1') }, s3: { presignDownload } })
const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', cloudBaseUrl: 'https://cloud' })
expectError(out, 422, 'Traffic quota exceeded', 'QUOTA_EXCEEDED')
expect(presignDownload).not.toHaveBeenCalled()
})
it('maps an insufficient_credits metering outcome', async () => {
vi.mocked(meterDownloadTraffic).mockResolvedValue({ ok: false, reason: 'insufficient_credits' })
const { deps } = makeDeps({ matter: { get: async () => file('m1') } })
const out = await getObject(deps, { orgId: 'o1', objectId: 'm1', cloudBaseUrl: 'https://cloud' })
expectError(out, 402, 'Insufficient credits', 'INSUFFICIENT_CREDITS')
expect((out as { ok: false; error: AppError }).error.meta.metadata).toEqual({ resource: 'storage_egress' })
})
it('refunds the traffic and rethrows when presign fails after metering', async () => {
vi.mocked(meterDownloadTraffic).mockResolvedValue({ ok: true })
const refundTraffic = vi.fn(async () => {})
const { deps } = makeDeps({
matter: { get: async () => file('m1', { size: 250 }) },
quota: { refundTraffic },
s3: {
presignDownload: async () => {
throw new Error('sign failed')
},
},
})
await expect(getObject(deps, { orgId: 'o1', objectId: 'm1', cloudBaseUrl: 'https://cloud' })).rejects.toThrow(
'sign failed',
)
expect(refundTraffic).toHaveBeenCalledWith('o1', 250)
})
})
describe('updateObject', () => {
it('renames and returns the matter', async () => {
const update = vi.fn(async () => file('m1', { name: 'New.txt' }))
const { deps } = makeDeps({ matter: { update } })
const out = await updateObject(deps, {
orgId: 'o1',
objectId: 'm1',
actorId: 'u1',
input: { name: 'New.txt' },
})
expect(out).toEqual({ ok: true, matter: file('m1', { name: 'New.txt' }) })
expect(update).toHaveBeenCalledWith(
'm1',
'o1',
{ name: 'New.txt', parent: undefined, onConflict: undefined },
'u1',
)
})
it('returns not_found when the matter is missing', async () => {
const { deps } = makeDeps({ matter: { update: async () => null } })
const out = await updateObject(deps, {
orgId: 'o1',
objectId: 'x',
actorId: 'u1',
input: { name: 'X' },
})
expectError(out, 404, 'Not found')
})
})
describe('trashObject / restoreObject', () => {
it('trashes a matter', async () => {
// Trash = active row with trashedAt set (no 'trashed' status).
const { deps } = makeDeps({ matter: { trash: async () => file('m1', { status: 'active', trashedAt: 1 }) } })
const out = await trashObject(deps, { orgId: 'o1', objectId: 'm1', actorId: 'u1' })
expect(out.ok).toBe(true)
if (out.ok) {
expect(out.matter.status).toBe('active')
expect(out.matter.trashedAt).toBe(1)
}
})
it('trash returns not_found when missing', async () => {
const { deps } = makeDeps({ matter: { trash: async () => null } })
expectError(await trashObject(deps, { orgId: 'o1', objectId: 'x', actorId: 'u1' }), 404, 'Not found')
})
it('restores with the default fail strategy', async () => {
const restore = vi.fn(async () => file('m1', { status: 'active' }))
const { deps } = makeDeps({ matter: { restore } })
const out = await restoreObject(deps, { orgId: 'o1', objectId: 'm1', actorId: 'u1' })
expect(out.ok).toBe(true)
expect(restore).toHaveBeenCalledWith('o1', 'm1', 'u1', 'fail')
})
it('restore passes the given onConflict strategy', async () => {
const restore = vi.fn(async () => file('m1', { status: 'active' }))
const { deps } = makeDeps({ matter: { restore } })
await restoreObject(deps, { orgId: 'o1', objectId: 'm1', actorId: 'u1', onConflict: 'rename' })
expect(restore).toHaveBeenCalledWith('o1', 'm1', 'u1', 'rename')
})
it('restore returns not_found when missing', async () => {
const { deps } = makeDeps({ matter: { restore: async () => null } })
expectError(await restoreObject(deps, { orgId: 'o1', objectId: 'x', actorId: 'u1' }), 404, 'Not found')
})
})
describe('deleteObject (purge)', () => {
it('purges a trashed subtree and records activity', async () => {
const subtree = [folder('f1', { trashedAt: 1 }), file('m1', { trashedAt: 1, parent: 'f1' })]
const purge = vi.fn(async () => {})
const deleteObjects = vi.fn(async () => {})
const { deps, record } = makeDeps({
matter: { collectForPurge: async () => subtree, purge },
s3: { deleteObjects },
})
const out = await deleteObject(deps, { orgId: 'o1', objectId: 'f1', userId: 'u1' })
expect(out).toEqual({ ok: true, id: 'f1', purged: 2 })
expect(purge).toHaveBeenCalledWith('o1', ['f1', 'm1'])
expect(deleteObjects).toHaveBeenCalled()
expect(record).toHaveBeenCalledWith(
expect.objectContaining({ action: 'object_purge', targetType: 'folder', metadata: { count: 2 } }),
)
})
it('returns not_found when the object does not exist', async () => {
const { deps } = makeDeps({
matter: { collectForPurge: (async () => null) as unknown as MatterRepo['collectForPurge'] },
})
expect(await deleteObject(deps, { orgId: 'o1', objectId: 'x', userId: 'u1' })).toEqual({
ok: false,
reason: 'not_found',
})
})
it('returns not_trashed when the object is still active', async () => {
const { deps, record } = makeDeps({
matter: { collectForPurge: async () => [folder('f1', { status: 'active' })] },
})
const out = await deleteObject(deps, { orgId: 'o1', objectId: 'f1', userId: 'u1' })
expect(out).toEqual({ ok: false, reason: 'not_trashed' })
expect(record).not.toHaveBeenCalled()
})
})
describe('copyObject', () => {
it('copies a file: reserves quota, copies the S3 object, creates the matter', async () => {
const source = file('src', { size: 200, object: 'key/src' })
const copyObjectS3 = vi.fn(async () => {})
const increment = vi.fn(async () => true)
const copy = vi.fn(async () => file('cp', { parent: 'Dest', name: source.name }))
const { deps } = makeDeps({
matter: { get: async () => source, copy },
s3: { copyObject: copyObjectS3 },
quota: { incrementUsageIfEffectiveQuotaAllows: increment },
})
const out = await copyObject(deps, { orgId: 'o1', userId: 'u1', input: { copyFrom: 'src', parent: 'Dest' } })
expect(out.ok).toBe(true)
expect(increment).toHaveBeenCalledWith('o1', 'st-1', 200, true)
expect(copyObjectS3).toHaveBeenCalled()
expect(copy).toHaveBeenCalled()
})
it('returns not_found for a missing source', async () => {
const { deps } = makeDeps({ matter: { get: async () => null } })
expectError(
await copyObject(deps, { orgId: 'o1', userId: 'u1', input: { copyFrom: 'x', parent: '' } }),
404,
'Not found',
)
})
it('returns storage_not_found for an object-backed source with no storage', async () => {
const source = file('src', { object: 'key/src' })
const { deps } = makeDeps({ matter: { get: async () => source }, storages: { get: async () => null } })
const out = await copyObject(deps, { orgId: 'o1', userId: 'u1', input: { copyFrom: 'src', parent: '' } })
expectError(out, 404, 'Storage not found')
})
it('rolls back the S3 copy when the matter copy fails', async () => {
const source = file('src', { size: 200, object: 'key/src' })
const deleteObjectFn = vi.fn(async () => {})
const rollback = vi.fn(async () => {})
const { deps } = makeDeps({
matter: {
get: async () => source,
copy: async () => {
throw new Error('copy failed')
},
},
s3: { copyObject: async () => {}, deleteObject: deleteObjectFn },
storageUsage: { rollbackReservations: rollback },
})
await expect(
copyObject(deps, { orgId: 'o1', userId: 'u1', input: { copyFrom: 'src', parent: '' } }),
).rejects.toThrow('copy failed')
// onRollback deletes the freshly copied S3 object; reservation rolled back too.
expect(deleteObjectFn).toHaveBeenCalled()
expect(rollback).toHaveBeenCalled()
})
})
describe('transferObject', () => {
it('rejects a transfer to the same space', async () => {
const { deps } = makeDeps()
const out = await transferObject(deps, {
orgId: 'o1',
userId: 'u1',
objectId: 'm1',
input: { targetOrgId: 'o1', targetParent: '', mode: 'copy' },
})
expectError(out, 400, 'Target must be a different space', 'SAME_ORG')
})
it('returns not_found for a missing/inactive source', async () => {
const { deps } = makeDeps({ matter: { get: async () => null } })
const out = await transferObject(deps, {
orgId: 'o1',
userId: 'u1',
objectId: 'm1',
input: { targetOrgId: 'o2', targetParent: '', mode: 'copy' },
})
expectError(out, 404, 'Not found')
})
it('forbids a move without editor access on the source space', async () => {
const { deps } = makeDeps({
matter: { get: async () => file('m1', { status: 'active' }) },
org: { getMemberRole: async () => 'viewer', isPersonalOrg: async () => false },
})
const out = await transferObject(deps, {
orgId: 'o1',
userId: 'u1',
objectId: 'm1',
input: { targetOrgId: 'o2', targetParent: '', mode: 'move' },
})
expectError(out, 403, 'Forbidden')
})
it('forbids a transfer into a target the user cannot write', async () => {
const { deps } = makeDeps({
matter: { get: async () => file('m1', { status: 'active' }) },
org: { canWriteToOrg: async () => false },
})
const out = await transferObject(deps, {
orgId: 'o1',
userId: 'u1',
objectId: 'm1',
input: { targetOrgId: 'o2', targetParent: '', mode: 'copy' },
})
expectError(out, 403, 'Forbidden')
})
it('rejects a transfer that exceeds the target quota', async () => {
const { deps } = makeDeps({
matter: { get: async () => file('m1', { status: 'active' }) },
share: { computeSourceBytes: async () => 1000, hasQuotaForBytes: async () => false },
})
const out = await transferObject(deps, {
orgId: 'o1',
userId: 'u1',
objectId: 'm1',
input: { targetOrgId: 'o2', targetParent: '', mode: 'copy' },
})
expectError(out, 422, 'Quota exceeded', 'QUOTA_EXCEEDED')
})
it('copies into the target without deleting the source', async () => {
const source = file('m1', { status: 'active', object: 'key/m1' })
const collectForPurge = vi.fn()
const { deps } = makeDeps({
matter: { get: async () => source, collectForPurge },
storages: {
get: async () => storage,
select: async () => storage,
},
s3: { copyObject: async () => {} },
})
const out = await transferObject(deps, {
orgId: 'o1',
userId: 'u1',
objectId: 'm1',
input: { targetOrgId: 'o2', targetParent: '', mode: 'copy' },
})
expect(out.ok).toBe(true)
if (out.ok) expect(out.result.sourceDeleted).toBe(false)
expect(collectForPurge).not.toHaveBeenCalled()
})
it('moves: copies, purges the source subtree, and records moved_to_org', async () => {
const source = file('m1', { status: 'active', object: 'key/m1' })
const purge = vi.fn(async () => {})
const { deps, record } = makeDeps({
matter: {
get: async () => source,
collectForPurge: async () => [source],
purge,
},
storages: { get: async () => storage, select: async () => storage },
s3: { copyObject: async () => {}, deleteObjects: async () => {} },
})
const out = await transferObject(deps, {
orgId: 'o1',
userId: 'u1',
objectId: 'm1',
input: { targetOrgId: 'o2', targetParent: '', mode: 'move' },
})
expect(out.ok).toBe(true)
if (out.ok) expect(out.result.sourceDeleted).toBe(true)
expect(purge).toHaveBeenCalledWith('o1', ['m1'])
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'moved_to_org' }))
})
})
describe('authorizeTaskUploadConfirm', () => {
const taskParams = {
orgId: 'o1',
objectId: 'm1',
taskId: 't1',
downloaderId: 'd1',
targetFolder: 'Inbox',
}
it('forbids confirming an object outside the target folder', async () => {
const { deps } = makeDeps({ matter: { get: async () => file('m1', { parent: 'Other' }) } })
expectError(await authorizeTaskUploadConfirm(deps, taskParams), 403, 'Forbidden')
})
it('authorizes a confirm within the target folder for a live task', async () => {
const { deps } = makeDeps({
matter: { get: async () => file('m1', { parent: 'Inbox' }) },
downloadTasks: {
findRecord: async () =>
({ id: 't1', assignedDownloaderId: 'd1', status: 'uploading' }) as unknown as DownloadTaskRecord,
},
})
expect(await authorizeTaskUploadConfirm(deps, taskParams)).toEqual({ ok: true })
})
})
})