Files
zpan/server/http/objects.integration.test.ts
T

3095 lines
129 KiB
TypeScript

import { createHash, randomUUID } from 'node:crypto'
import { createServer, type IncomingMessage, type Server } from 'node:http'
import { eq, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { S3Service } from '../adapters/gateways/s3.js'
import { createDownloadTokenGateway } from '../adapters/repos/download-tokens.js'
import { createMatterRepo } from '../adapters/repos/matter.js'
import { createQuotaRepo } from '../adapters/repos/quota.js'
import { createStorageUsageRepo } from '../adapters/repos/storage-usage.js'
import { cloudTrafficReports, downloadTasks, orgQuotaEntitlements, orgQuotas } from '../db/schema.js'
import { currentTrafficPeriod } from '../domain/quota.js'
import { adminHeaders, authedHeaders, createTestApp, seedBusinessLicense, seedProLicense } from '../test/setup.js'
import { type ConfirmUploadOptions, confirmUpload as confirmUploadUsecase } from '../usecases/object.js'
import type {
CopyMatterOptions,
CreateMatterInput,
Matter,
MatterListFilters,
UpdateMatterInput,
} from '../usecases/ports.js'
import { createCapacityRequestHash } from './objects.js'
type TestDbForMatter = Awaited<ReturnType<typeof createTestApp>>['db']
// Thin adapters preserving the former matter service signatures so these
// behavioral tests exercise the migrated MatterRepo + confirmUpload usecase
// unchanged.
function createMatter(db: TestDbForMatter, input: CreateMatterInput): Promise<Matter> {
return createMatterRepo(db).create(input)
}
function getMatter(db: TestDbForMatter, id: string, orgId: string) {
return createMatterRepo(db).get(id, orgId)
}
function getMatters(db: TestDbForMatter, orgId: string, ids: string[]) {
return createMatterRepo(db).getMany(orgId, ids)
}
function listMatters(db: TestDbForMatter, orgId: string, filters: MatterListFilters) {
return createMatterRepo(db).list(orgId, filters)
}
function updateMatter(db: TestDbForMatter, id: string, orgId: string, input: UpdateMatterInput) {
return createMatterRepo(db).update(id, orgId, input)
}
function copyMatter(
db: TestDbForMatter,
source: Matter,
targetParent: string,
newObject: string,
opts?: CopyMatterOptions,
) {
return createMatterRepo(db).copy(source, targetParent, newObject, opts)
}
async function deleteMatter(db: TestDbForMatter, id: string, orgId: string) {
const repo = createMatterRepo(db)
const existing = await repo.get(id, orgId)
if (!existing) return null
await repo.purge(orgId, [id])
return existing
}
function confirmUpload(db: TestDbForMatter, id: string, orgId: string, opts: ConfirmUploadOptions = {}) {
return confirmUploadUsecase(
{
matter: createMatterRepo(db),
quota: createQuotaRepo(db),
storageUsage: createStorageUsageRepo(db),
},
id,
orgId,
opts,
)
}
beforeEach(() => {
vi.restoreAllMocks()
vi.spyOn(S3Service.prototype, 'deleteObject').mockResolvedValue(undefined)
vi.spyOn(S3Service.prototype, 'deleteObjects').mockResolvedValue(undefined)
vi.spyOn(S3Service.prototype, 'presignUpload').mockResolvedValue('https://presigned-upload.example.com')
vi.spyOn(S3Service.prototype, 'presignDownload').mockResolvedValue('https://presigned-download.example.com')
vi.spyOn(S3Service.prototype, 'copyObject').mockResolvedValue(undefined)
// Single-PUT completion HEADs the object and matches the client's reported ETag.
// Tests that finalize a ≤5 GiB upload send parts:[{partNumber:1, etag:'abc'}].
vi.spyOn(S3Service.prototype, 'headObject').mockResolvedValue({
size: 100,
contentType: 'text/plain',
etag: 'abc',
})
vi.spyOn(S3Service.prototype, 'completeMultipartUpload').mockResolvedValue(undefined)
})
describe('capacity purchase challenges', () => {
it('creates a fresh request hash for repeated identical uploads', async () => {
const input = { name: 'same.bin', type: 'application/octet-stream', size: 1024 }
const first = await createCapacityRequestHash('org-1', input)
const second = await createCapacityRequestHash('org-1', input)
expect(first).toMatch(/^[0-9a-f]{64}$/)
expect(second).toMatch(/^[0-9a-f]{64}$/)
expect(second).not.toBe(first)
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
const validStorage = {
id: 'st-1',
bucket: 'test-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
accessKey: 'AKIAIOSFODNN7EXAMPLE',
secretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
}
async function insertStorage(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
opts: { id?: string; metered?: boolean; capacity?: number; used?: number; enabled?: boolean } = {},
) {
const now = Date.now()
const metered = opts.metered ? 1 : 0
const id = opts.id ?? validStorage.id
await db.run(sql`
INSERT INTO storages (
id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
capacity, used, enabled, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
egress_credit_per_unit, created_at, updated_at
)
VALUES (
${id}, ${validStorage.bucket},
${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey},
'', '', ${opts.capacity ?? 0}, ${opts.used ?? 0}, ${(opts.enabled ?? true) ? 1 : 0}, 'untested', ${metered}, ${100 * 1024 ** 2}, 1, ${now}, ${now}
)
`)
}
async function insertFolder(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
orgId: string,
opts: { id: string; name: string; parent?: string; trashedAt?: number },
) {
const now = Date.now()
await db.run(sql`
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, trashed_at, created_at, updated_at)
VALUES (${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${opts.name}, 'folder', 0, 1, ${opts.parent ?? ''}, '', ${validStorage.id}, 'active', ${opts.trashedAt ?? null}, ${now}, ${now})
`)
}
// Inserts a matter row. A "trashed" object is status='active' with trashedAt set;
// a "draft" is status='draft'. Pass `trashedAt` to drop the row in the recycle bin.
async function insertFile(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
orgId: string,
opts: { id: string; name: string; parent?: string; status?: string; size?: number; trashedAt?: number },
) {
const now = Date.now()
const status = opts.status ?? 'active'
const size = opts.size ?? 100
await db.run(sql`
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, trashed_at, created_at, updated_at)
VALUES (${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${opts.name}, 'text/plain', ${size}, 0, ${opts.parent ?? ''}, 'some/key.txt', ${validStorage.id}, ${status}, ${opts.trashedAt ?? null}, ${now}, ${now})
`)
}
// Drives the full file-upload flow against the in-memory app: POST /api/objects
// creates a draft + upload session, then POST .../completions finalizes it to a
// live object. Returns the created object id. The S3 spies make the single-PUT
// HEAD return etag 'abc', so completions sends a matching part.
async function _uploadFile(
app: Awaited<ReturnType<typeof createTestApp>>['app'],
headers: Record<string, string>,
body: { name: string; type?: string; size?: number; parent?: string; onConflict?: string },
): Promise<string> {
const createRes = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'text/plain', size: 100, parent: '', dirtype: 0, ...body }),
})
if (createRes.status !== 201) throw new Error(`create failed: ${createRes.status}`)
const created = (await createRes.json()) as { id: string; upload: { sessionId: string } }
const completeRes = await app.request(`/api/objects/${created.id}/uploads/${created.upload.sessionId}/completions`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parts: [{ partNumber: 1, etag: 'abc' }] }),
})
if (completeRes.status !== 200) throw new Error(`complete failed: ${completeRes.status}`)
return created.id
}
async function getOrgQuota(db: Awaited<ReturnType<typeof createTestApp>>['db'], orgId: string) {
const rows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId} LIMIT 1`)
return rows[0] ?? null
}
async function getOrgId(db: Awaited<ReturnType<typeof createTestApp>>['db']): Promise<string> {
const rows = await db.all<{ id: string }>(sql`
SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1
`)
return rows[0].id
}
describe('Objects API', () => {
it('returns 401 without auth [spec: objects/auth-required]', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/objects')
expect(res.status).toBe(401)
})
it('GET /api/objects returns empty list [spec: objects/list-empty]', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects', { headers })
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toEqual({ items: [], nextPageToken: null })
})
it('GET /api/objects returns the cursor page contract [spec: objects/list-pagination]', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects?page=2&pageSize=10', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as { items: unknown[]; nextPageToken: string | null }
expect(body).toEqual({ items: [], nextPageToken: null })
})
it('GET /api/objects enforces the shared page-size limit', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects?pageSize=500', { headers })
expect(res.status).toBe(400)
})
it('GET /api/objects lists folders with hasChildren', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFolder(db, orgId, { id: 'parent-folder', name: 'Parent' })
await insertFolder(db, orgId, { id: 'leaf-folder', name: 'Leaf' })
await insertFolder(db, orgId, { id: 'child-folder', name: 'Child', parent: 'Parent' })
await insertFile(db, orgId, { id: 'root-file', name: 'root.txt' })
await insertFile(db, orgId, { id: 'child-file', name: 'child.txt', parent: 'Leaf' })
const res = await app.request('/api/objects?path=&type=folder&page=1&pageSize=100', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as {
items: Array<{ id: string; hasChildren: boolean }>
nextPageToken: string | null
}
expect(body.nextPageToken).toBeNull()
expect(body.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'parent-folder', hasChildren: true }),
expect.objectContaining({ id: 'leaf-folder', hasChildren: false }),
]),
)
})
it('GET /api/objects composes folder and search filters', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFolder(db, orgId, { id: 'reports-folder', name: 'Reports' })
await insertFile(db, orgId, { id: 'reports-file', name: 'Reports.txt' })
const res = await app.request('/api/objects?type=folder&search=Reports', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as { items: Array<{ id: string }> }
expect(body.items).toHaveLength(1)
expect(body.items.map((item) => item.id)).toEqual(['reports-folder'])
})
it('POST /api/objects creates a folder [spec: objects/create-folder]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const res = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'My Folder', type: 'folder', dirtype: 1 }),
})
expect(res.status).toBe(201)
const body = (await res.json()) as Record<string, unknown>
expect(body.name).toBe('My Folder')
expect(body.dirtype).toBe(1)
expect(body.status).toBe('active')
expect(body.object).toBe('')
expect(body.id).toBeTruthy()
})
it('POST /api/objects returns 400 for invalid input [spec: objects/create-invalid]', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: '' }),
})
expect(res.status).toBe(400)
})
it('POST /api/objects returns 503 when no storage available [spec: objects/create-no-storage]', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'test.txt', type: 'text/plain' }),
})
expect(res.status).toBe(503)
const body = (await res.json()) as { error: { message: string; details: Array<{ reason: string }> } }
expect(body.error.message).toBe('No storage configured')
expect(body.error.details[0].reason).toBe('NO_STORAGE_CONFIGURED')
})
it('GET /api/objects lists active objects in root', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db, { metered: true })
const orgId = await getOrgId(db)
await insertFolder(db, orgId, { id: 'f1', name: 'Folder A' })
await insertFile(db, orgId, { id: 'm1', name: 'file.txt' })
const res = await app.request('/api/objects', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as { items: Array<Record<string, unknown>> }
expect(body.items).toHaveLength(2)
// Folders sort before files (dirtype DESC)
expect(body.items[0].name).toBe('Folder A')
expect(body.items[1].name).toBe('file.txt')
})
it('GET /api/objects filters by parent [spec: objects/list-by-parent]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFolder(db, orgId, { id: 'f1', name: 'Folder A' })
await insertFile(db, orgId, { id: 'm1', name: 'nested.txt', parent: 'Folder A' })
await insertFile(db, orgId, { id: 'm2', name: 'root.txt' })
const res = await app.request(`/api/objects?parent=${encodeURIComponent('Folder A')}`, { headers })
const body = (await res.json()) as { items: Array<Record<string, unknown>> }
expect(body.items).toHaveLength(1)
expect(body.items[0].name).toBe('nested.txt')
})
it('GET /api/objects lists live objects only — excludes drafts and trashed [spec: objects/list-live-only]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'active.txt', status: 'active' })
await insertFile(db, orgId, { id: 'm2', name: 'draft.txt', status: 'draft' })
await insertFile(db, orgId, { id: 'm3', name: 'trashed.txt', trashedAt: Date.now() })
const res = await app.request('/api/objects', { headers })
const body = (await res.json()) as { items: Array<Record<string, unknown>> }
expect(body.items).toHaveLength(1)
expect(body.items[0].name).toBe('active.txt')
})
it('GET /api/objects/:id returns folder detail [spec: objects/detail]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFolder(db, orgId, { id: 'f1', name: 'My Folder' })
const res = await app.request('/api/objects/f1', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.id).toBe('f1')
expect(body.name).toBe('My Folder')
// Folder should not have downloadUrl
expect(body).not.toHaveProperty('downloadUrl')
})
it('GET /api/objects/:id returns 404 for missing object [spec: objects/detail-missing]', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/nonexistent', { headers })
expect(res.status).toBe(404)
})
it('PATCH /api/objects/:id renames an object [spec: objects/rename]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFolder(db, orgId, { id: 'f1', name: 'Old Name' })
const res = await app.request('/api/objects/f1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New Name' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.name).toBe('New Name')
})
it('PATCH /api/objects/:id moves an object [spec: objects/move]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFolder(db, orgId, { id: 'f1', name: 'Target Folder' })
await insertFile(db, orgId, { id: 'm1', name: 'moveme.txt' })
const res = await app.request('/api/objects/m1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: 'Target Folder' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.parent).toBe('Target Folder')
})
it('PATCH /api/objects/:id returns 404 for missing object', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/nonexistent', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Nope' }),
})
expect(res.status).toBe(404)
})
it('POST /api/objects/:id/uploads/:sid/completions finalizes a draft to a live object [spec: objects/complete-upload]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const createRes = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'uploading.txt', type: 'text/plain', size: 100, parent: '', dirtype: 0 }),
})
expect(createRes.status).toBe(201)
const created = (await createRes.json()) as { id: string; status: string; upload: { sessionId: string } }
expect(created.status).toBe('draft')
const res = await app.request(`/api/objects/${created.id}/uploads/${created.upload.sessionId}/completions`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parts: [{ partNumber: 1, etag: 'abc' }] }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.status).toBe('active')
expect(body.trashedAt).toBeNull()
// The live object now appears in the listing.
const list = await app.request('/api/objects', { headers })
expect(((await list.json()) as { items: unknown[] }).items).toHaveLength(1)
void db
})
it('POST .../completions rejects a single-PUT whose ETag does not match the HEAD [spec: objects/complete-etag-mismatch]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
void db
const createRes = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'mismatch.txt', type: 'text/plain', size: 100, parent: '', dirtype: 0 }),
})
const created = (await createRes.json()) as { id: string; upload: { sessionId: string } }
const res = await app.request(`/api/objects/${created.id}/uploads/${created.upload.sessionId}/completions`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parts: [{ partNumber: 1, etag: 'wrong' }] }),
})
// ETag mismatch surfaces as an invalid upload-session state → 409.
expect(res.status).toBe(409)
const events = await db.all<{ metadata: string }>(sql`
SELECT metadata FROM audit_events
WHERE action = 'upload_failed' AND target_id = ${created.id}
`)
expect(events).toHaveLength(1)
expect(JSON.parse(events[0].metadata)).toMatchObject({
bytes: 100,
source: 'upload',
status: 'failed',
reason: 'etag_mismatch',
})
})
it('DELETE /api/objects/:id/uploads/:sid aborts the upload and discards the draft [spec: objects/abort-upload]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const createRes = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'cancel.txt', type: 'text/plain', size: 100, parent: '', dirtype: 0 }),
})
const created = (await createRes.json()) as { id: string; upload: { sessionId: string } }
const res = await app.request(`/api/objects/${created.id}/uploads/${created.upload.sessionId}`, {
method: 'DELETE',
headers,
})
expect(res.status).toBe(204)
// Single-PUT abort best-effort deletes the S3 object and removes the draft row.
expect(S3Service.prototype.deleteObject).toHaveBeenCalled()
const check = await app.request(`/api/objects/${created.id}`, { headers })
expect(check.status).toBe(404)
const repeated = await app.request(`/api/objects/${created.id}/uploads/${created.upload.sessionId}`, {
method: 'DELETE',
headers,
})
expect(repeated.status).toBe(204)
void db
})
it('DELETE /api/objects/:id soft-deletes a live object → 204 and moves it to trash [spec: objects/trash]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'a.txt' })
const res = await app.request('/api/objects/m1', { method: 'DELETE', headers })
expect(res.status).toBe(204)
// Gone from the live listing…
const list = await app.request('/api/objects', { headers })
expect(((await list.json()) as { items: unknown[] }).items).toHaveLength(0)
// …and present in the recycle bin with trashedAt set.
const trash = await app.request('/api/trash/objects', { headers })
const trashBody = (await trash.json()) as { items: Array<{ id: string; trashedAt: number }> }
expect(trashBody.items).toHaveLength(1)
expect(trashBody.items[0].id).toBe('m1')
expect(trashBody.items[0].trashedAt).toBeTruthy()
})
it('DELETE /api/objects/:id soft-deletes a folder, cascading its subtree to trash [spec: objects/trash-cascade]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFolder(db, orgId, { id: 'f1', name: 'Parent' })
await insertFile(db, orgId, { id: 'm1', name: 'child.txt', parent: 'Parent' })
await insertFolder(db, orgId, { id: 'f2', name: 'Sub', parent: 'Parent' })
await insertFile(db, orgId, { id: 'm2', name: 'deep.txt', parent: 'f2' })
const res = await app.request('/api/objects/f1', { method: 'DELETE', headers })
expect(res.status).toBe(204)
// Only the root folder shows in the trash root listing…
const trash = await app.request('/api/trash/objects', { headers })
expect(((await trash.json()) as { items: unknown[] }).items).toHaveLength(1)
// …but every descendant is flagged trashed: restore brings them all back.
const restoreRes = await app.request('/api/trash/objects/f1/restorations', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(restoreRes.status).toBe(200)
const childRes = await app.request('/api/objects/m2', { headers })
expect(((await childRes.json()) as { status: string }).status).toBe('active')
})
it('POST /api/trash/objects/:id/restorations restores a trashed object [spec: objects/restore]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'a.txt', trashedAt: Date.now() })
const res = await app.request('/api/trash/objects/m1/restorations', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.status).toBe('active')
expect(body.trashedAt).toBeNull()
})
it('GET /api/trash/objects/:id returns a trashed object; 404 for a live one [spec: objects/get-trashed]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'gone', name: 'gone.txt', trashedAt: Date.now() })
await insertFile(db, orgId, { id: 'live', name: 'live.txt' })
const trashedRes = await app.request('/api/trash/objects/gone', { headers })
expect(trashedRes.status).toBe(200)
expect(((await trashedRes.json()) as { id: string }).id).toBe('gone')
const liveRes = await app.request('/api/trash/objects/live', { headers })
expect(liveRes.status).toBe(404)
})
it('GET /api/trash/objects lists trashed folder roots only [spec: objects/list-trashed]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFolder(db, orgId, { id: 'media', name: 'Media' })
await insertFolder(db, orgId, { id: 'music', name: 'Music', parent: 'Media' })
await insertFolder(db, orgId, { id: 'album', name: 'Album', parent: 'Media/Music' })
await insertFile(db, orgId, { id: 'track', name: 'track.flac', parent: 'Media/Music/Album' })
const trashRes = await app.request('/api/objects/album', { method: 'DELETE', headers })
expect(trashRes.status).toBe(204)
const res = await app.request('/api/trash/objects', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as { items: Array<{ id: string }> }
expect(body.items).toHaveLength(1)
expect(body.items.map((item) => item.id)).toEqual(['album'])
})
it('DELETE /api/trash/objects/:id permanently purges a trashed folder [spec: objects/purge-folder]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFolder(db, orgId, { id: 'f1', name: 'Delete Me', trashedAt: Date.now() })
const res = await app.request('/api/trash/objects/f1', { method: 'DELETE', headers })
expect(res.status).toBe(204)
expect(await getMatter(db, 'f1', orgId)).toBeNull()
})
it('DELETE /api/trash/objects/:id purges a trashed folder with spaces and bracketed tags', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const folderName = 'Project Hail Mary (2026) [IMAX] [1080p] [WEBRip] [5.1] [YTS.BZ]'
const trashedAt = Date.now()
await insertFolder(db, orgId, { id: 'movie-folder', name: folderName, trashedAt })
await insertFile(db, orgId, { id: 'movie-file', name: 'movie.mkv', parent: folderName, trashedAt })
const res = await app.request('/api/trash/objects/movie-folder', { method: 'DELETE', headers })
expect(res.status).toBe(204)
expect(await getMatter(db, 'movie-folder', orgId)).toBeNull()
expect(await getMatter(db, 'movie-file', orgId)).toBeNull()
const tombstones = await db.all<{ id: string; purgedAt: number | null }>(sql`
SELECT id, purged_at AS purgedAt FROM matters
WHERE id IN ('movie-folder', 'movie-file')
ORDER BY id
`)
expect(tombstones).toHaveLength(2)
expect(tombstones.every((row) => typeof row.purgedAt === 'number')).toBe(true)
const ledger = await db.all<{ bytes: number }>(sql`
SELECT COALESCE(SUM(delta_bytes), 0) AS bytes
FROM storage_usage_ledger
WHERE org_id = ${orgId} AND storage_id = ${validStorage.id}
`)
expect(ledger[0].bytes).toBe(0)
})
it('DELETE /api/objects/:id is idempotent for an already-trashed object → 204', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'a.txt', trashedAt: Date.now() })
const res = await app.request('/api/objects/m1', { method: 'DELETE', headers })
expect(res.status).toBe(204)
})
it('DELETE /api/objects/:id returns 404 for missing object', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/nonexistent', {
method: 'DELETE',
headers,
})
expect(res.status).toBe(404)
})
it('POST /api/objects/copy copies a folder [spec: objects/copy-folder]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFolder(db, orgId, { id: 'target', name: 'Dest' })
await insertFolder(db, orgId, { id: 'f1', name: 'Original' })
const res = await app.request('/api/objects/f1/copies', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: 'Dest' }),
})
expect(res.status).toBe(201)
const body = (await res.json()) as Record<string, unknown>
expect(body.name).toBe('Original')
expect(body.id).not.toBe('f1')
expect(body.status).toBe('active')
expect(body.parent).toBe('Dest')
})
it('POST /api/objects/copy returns 404 for missing source', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/nonexistent/copies', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(res.status).toBe(404)
})
it('POST .../completions returns 404 for a missing upload session', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/nonexistent/uploads/no-session/completions', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parts: [{ partNumber: 1, etag: 'abc' }] }),
})
expect(res.status).toBe(404)
})
it('POST /api/objects creates a file draft with single-PUT upload instructions [spec: objects/create-file-presign]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const res = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'photo.jpg', type: 'image/jpeg', size: 2048 }),
})
expect(res.status).toBe(201)
const body = (await res.json()) as {
status: string
object: string
upload: {
sessionId: string
partSize: number
partCount: number
mode: string
urls: string[]
parts: Array<{ url: string }>
}
}
expect(body.status).toBe('draft')
expect(body.object).toBeTruthy()
// Small upload → single PutObject: one explicit part, partSize equals the file size.
expect(body.upload.sessionId).toBeTruthy()
expect(body.upload.mode).toBe('single')
expect(body.upload.partSize).toBe(2048)
expect(body.upload.partCount).toBe(1)
expect(body.upload.urls).toEqual(['https://presigned-upload.example.com'])
expect(body.upload.parts.map((part) => part.url)).toEqual(['https://presigned-upload.example.com'])
})
it('POST /api/objects/:id/uploads/:sid/parts re-signs a single-PUT session [spec: objects/presign-upload-parts-single]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const createRes = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'resume.txt', type: 'text/plain', size: 100, parent: '', dirtype: 0 }),
})
expect(createRes.status).toBe(201)
const created = (await createRes.json()) as {
id: string
upload: { sessionId: string; mode: string; partCount: number; partSize: number }
}
expect(created.upload.mode).toBe('single')
expect(created.upload.partCount).toBe(1)
const res = await app.request(`/api/objects/${created.id}/uploads/${created.upload.sessionId}/parts`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ partNumbers: [1] }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
uploadId: string | null
mode: string
partSize: number
partCount: number
requiredHeaders: Record<string, string>
parts: Array<{ partNumber: number; url: string; headers: Record<string, string> }>
}
expect(body).toMatchObject({
uploadId: null,
mode: 'single',
partSize: 100,
partCount: 1,
requiredHeaders: { 'content-type': 'text/plain' },
parts: [
{ partNumber: 1, url: 'https://presigned-upload.example.com', headers: { 'content-type': 'text/plain' } },
],
})
})
it('POST /api/objects with storageId uses that exact eligible storage', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
await insertStorage(db, { id: 'st-oldest' })
await insertStorage(db, { id: 'st-target' })
const res = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'target.txt', type: 'text/plain', size: 1, storageId: 'st-target' }),
})
expect(res.status).toBe(201)
const body = (await res.json()) as { id: string; storageId: string }
expect(body.storageId).toBe('st-target')
const rows = await db.all<{ storageId: string }>(
sql`SELECT storage_id as storageId FROM matters WHERE id = ${body.id}`,
)
expect(rows[0].storageId).toBe('st-target')
})
it('POST /api/objects with ineligible storageId fails before draft/session creation', async () => {
for (const storage of [
{ id: 'missing' },
{ id: 'inactive', enabled: false },
{ id: 'full', capacity: 1, used: 1 },
]) {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
if (storage.id !== 'missing') await insertStorage(db, storage)
const res = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: `${storage.id}.txt`, type: 'text/plain', size: 1, storageId: storage.id }),
})
expect(res.status).toBe(503)
const matters = await db.all<{ count: number }>(sql`SELECT COUNT(*) as count FROM matters`)
const sessions = await db.all<{ count: number }>(sql`SELECT COUNT(*) as count FROM object_upload_sessions`)
expect(matters[0].count).toBe(0)
expect(sessions[0].count).toBe(0)
}
})
it('POST /api/objects with storageId is admin-only and fails before draft/session creation', async () => {
const { app, db } = await createTestApp()
await adminHeaders(app)
const headers = await authedHeaders(app, 'editor@example.com')
await insertStorage(db, { id: 'st-target' })
const res = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'target.txt', type: 'text/plain', size: 1, storageId: 'st-target' }),
})
expect(res.status).toBe(403)
const matters = await db.all<{ count: number }>(sql`SELECT COUNT(*) as count FROM matters`)
const sessions = await db.all<{ count: number }>(sql`SELECT COUNT(*) as count FROM object_upload_sessions`)
expect(matters[0].count).toBe(0)
expect(sessions[0].count).toBe(0)
})
it('POST /api/objects rejects a file larger than 5 TiB [spec: objects/create-file-too-large]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const res = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'huge.bin', type: 'application/octet-stream', size: 5 * 1024 ** 4 + 1 }),
})
expect(res.status).toBe(400)
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
expect(body.error.details[0].reason).toBe('FILE_TOO_LARGE')
})
it('POST /api/objects/copy copies a file with S3 [spec: objects/copy-file]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'doc.txt' })
const res = await app.request('/api/objects/m1/copies', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '' }),
})
expect(res.status).toBe(201)
expect(S3Service.prototype.copyObject).toHaveBeenCalled()
})
it('DELETE /api/trash/objects/:id permanently deletes a trashed file with S3 cleanup [spec: objects/purge-file-s3]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'file.txt', trashedAt: Date.now() })
const res = await app.request('/api/trash/objects/m1', { method: 'DELETE', headers })
expect(res.status).toBe(204)
expect(S3Service.prototype.deleteObjects).toHaveBeenCalled()
expect(await getMatter(db, 'm1', orgId)).toBeNull()
})
it('DELETE /api/trash/objects/:id purges a folder with file children from S3', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const trashedAt = Date.now()
await insertFolder(db, orgId, { id: 'f1', name: 'Folder', trashedAt })
await insertFile(db, orgId, { id: 'm1', name: 'a.txt', parent: 'Folder', trashedAt })
await insertFile(db, orgId, { id: 'm2', name: 'b.txt', parent: 'Folder', trashedAt })
const res = await app.request('/api/trash/objects/f1', { method: 'DELETE', headers })
expect(res.status).toBe(204)
expect(S3Service.prototype.deleteObjects).toHaveBeenCalled()
expect(await getMatter(db, 'f1', orgId)).toBeNull()
expect(await getMatter(db, 'm1', orgId)).toBeNull()
expect(await getMatter(db, 'm2', orgId)).toBeNull()
})
it('DELETE /api/objects/:id returns 404 for a missing object (soft-delete)', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/nonexistent', { method: 'DELETE', headers })
expect(res.status).toBe(404)
})
it('DELETE /api/trash/objects/:id returns 404 for a live (non-trashed) object', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'a.txt' })
const res = await app.request('/api/trash/objects/m1', { method: 'DELETE', headers })
expect(res.status).toBe(404)
})
it('POST /api/trash/objects/:id/restorations returns 404 for a missing object', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/trash/objects/nonexistent/restorations', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(res.status).toBe(404)
})
it('POST /api/trash/objects/:id/restorations on a live object is a no-op (stays active)', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'a.txt' })
const res = await app.request('/api/trash/objects/m1/restorations', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.status).toBe('active')
expect(body.trashedAt).toBeNull()
})
it('GET /api/objects/:id returns downloadUrl for files [spec: objects/download-url]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'doc.txt' })
const res = await app.request('/api/objects/m1', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.downloadUrl).toBe('https://presigned-download.example.com')
const events = await db.all<{
userId: string
actorType: string
bytes: number
source: string
trafficEventId: string
}>(sql`
SELECT
user_id AS userId,
actor_type AS actorType,
json_extract(metadata, '$.bytes') AS bytes,
json_extract(metadata, '$.source') AS source,
json_extract(metadata, '$.trafficEventId') AS trafficEventId
FROM audit_events
WHERE action = 'object_download' AND target_id = 'm1'
`)
expect(events).toEqual([
{
userId: expect.any(String),
actorType: 'user',
bytes: 100,
source: 'object_download',
trafficEventId: expect.any(String),
},
])
})
it('GET /api/objects/:id reports Cloud traffic for bound instances before returning the URL [spec: objects/download-traffic]', async () => {
const { app, db } = await createTestApp({ ZPAN_CLOUD_URL: 'https://cloud.example' })
const headers = await authedHeaders(app)
await insertStorage(db, { metered: true })
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'doc.txt' })
await seedBusinessLicense(db)
vi.stubGlobal(
'fetch',
vi.fn(async (_url, init) => {
const body = JSON.parse(String(init?.body)) as { eventId: string }
return {
ok: true,
status: 200,
json: async () => ({ data: { accepted: true, duplicate: false, eventId: body.eventId } }),
} as Response
}),
)
const res = await app.request('/api/objects/m1', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.downloadUrl).toBe('https://presigned-download.example.com')
expect(fetch).toHaveBeenCalledTimes(1)
await expect(
db.select().from(cloudTrafficReports).where(eq(cloudTrafficReports.orgId, orgId)),
).resolves.toMatchObject([{ orgId, source: 'object_download', sourceId: 'm1', bytes: 100, status: 'reported' }])
})
})
describe('Matter service', () => {
it('createMatter applies defaults for optional fields', async () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', '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',
name: 'test.txt',
type: 'text/plain',
object: 'key.txt',
storageId: 's1',
status: 'draft',
})
expect(matter.size).toBe(0)
expect(matter.dirtype).toBe(0)
expect(matter.parent).toBe('')
expect(matter.id).toBeTruthy()
expect(matter.alias).toBeTruthy()
})
it('createMatter uses provided optional fields', async () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', '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',
name: 'doc.pdf',
type: 'application/pdf',
size: 1024,
dirtype: 1,
parent: 'folder-1',
object: '',
storageId: 's1',
status: 'active',
})
expect(matter.size).toBe(1024)
expect(matter.dirtype).toBe(1)
expect(matter.parent).toBe('folder-1')
})
it('listMatters returns paginated results', async () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
await createMatter(db, {
orgId: 'org-1',
name: 'a.txt',
type: 'text/plain',
object: 'a',
storageId: 's1',
status: 'active',
})
await createMatter(db, {
orgId: 'org-1',
name: 'b.txt',
type: 'text/plain',
object: 'b',
storageId: 's1',
status: 'active',
})
const page1 = await listMatters(db, 'org-1', { parent: '', pageSize: 1 })
expect(page1.items).toHaveLength(1)
expect(page1.nextBoundary).not.toBeNull()
const page2 = await listMatters(db, 'org-1', {
parent: '',
pageSize: 1,
after: page1.nextBoundary ?? undefined,
})
expect(page2.items).toHaveLength(1)
})
it('getMatter returns null for missing record', async () => {
const { db } = await createTestApp()
const result = await getMatter(db, 'nonexistent', 'org-1')
expect(result).toBeNull()
})
it('updateMatter returns null for missing record', async () => {
const { db } = await createTestApp()
const result = await updateMatter(db, 'nonexistent', 'org-1', { name: 'new' })
expect(result).toBeNull()
})
it('confirmUpload returns null for missing record', async () => {
const { db } = await createTestApp()
const { matter } = await confirmUpload(db, 'nonexistent', 'org-1')
expect(matter).toBeNull()
})
it('confirmUpload returns null for non-draft status', async () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', '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',
name: 'a.txt',
type: 'text/plain',
object: 'a',
storageId: 's1',
status: 'active',
})
const { matter: result } = await confirmUpload(db, matter.id, 'org-1')
expect(result).toBeNull()
})
it('deleteMatter hides and retains the purged record', async () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', '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',
name: 'a.txt',
type: 'text/plain',
object: 'a',
storageId: 's1',
status: 'active',
})
const deleted = await deleteMatter(db, matter.id, 'org-1')
expect(deleted).not.toBeNull()
expect(deleted!.id).toBe(matter.id)
const check = await getMatter(db, matter.id, 'org-1')
expect(check).toBeNull()
const retained = await db.all<{ purgedAt: number | null }>(
sql`SELECT purged_at AS purgedAt FROM matters WHERE id = ${matter.id}`,
)
expect(retained[0]?.purgedAt).toEqual(expect.any(Number))
})
it('deleteMatter returns null for missing record', async () => {
const { db } = await createTestApp()
const result = await deleteMatter(db, 'nonexistent', 'org-1')
expect(result).toBeNull()
})
it('copyMatter creates a new record from source', async () => {
const { db } = await createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', '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',
name: 'a.txt',
type: 'text/plain',
size: 42,
object: 'original/key',
storageId: 's1',
status: 'active',
})
const copy = await copyMatter(db, source, 'target-folder', 'copy/key')
expect(copy.id).not.toBe(source.id)
expect(copy.alias).not.toBe(source.alias)
expect(copy.name).toBe('a.txt')
expect(copy.size).toBe(42)
expect(copy.parent).toBe('target-folder')
expect(copy.object).toBe('copy/key')
expect(copy.status).toBe('active')
})
it('getMatters returns empty array for empty ids list', async () => {
const { db } = await createTestApp()
const result = await getMatters(db, 'org-1', [])
expect(result).toEqual([])
})
})
// ─── Name-conflict route layer ────────────────────────────────────────────────
describe('Objects API — name conflict (409 responses)', () => {
it('POST /api/objects returns 409 with NAME_CONFLICT code when folder name is already taken [spec: objects/create-conflict]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFolder(db, orgId, { id: 'f-exist', name: 'Duplicates' })
const res = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Duplicates', type: 'folder', dirtype: 1 }),
})
expect(res.status).toBe(409)
const body = (await res.json()) as {
error: { details: Array<{ reason: string; metadata: Record<string, string> }> }
}
expect(body.error.details[0].reason).toBe('NAME_CONFLICT')
expect(body.error.details[0].metadata.conflictingName).toBe('Duplicates')
expect(typeof body.error.details[0].metadata.conflictingId).toBe('string')
})
it('POST /api/objects with onConflict: rename succeeds and returns auto-renamed folder [spec: objects/create-conflict-rename]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFolder(db, orgId, { id: 'f-exist2', name: 'Reports' })
const res = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Reports', type: 'folder', dirtype: 1, onConflict: 'rename' }),
})
expect(res.status).toBe(201)
const body = (await res.json()) as Record<string, unknown>
expect(body.name).toBe('Reports (1)')
})
it('PATCH /api/objects/:id rename conflict returns 409 with NAME_CONFLICT code [spec: objects/rename-conflict]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'alpha.txt' })
await insertFile(db, orgId, { id: 'm2', name: 'beta.txt' })
const res = await app.request('/api/objects/m1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'beta.txt' }),
})
expect(res.status).toBe(409)
const body = (await res.json()) as {
error: { details: Array<{ reason: string; metadata: Record<string, string> }> }
}
expect(body.error.details[0].reason).toBe('NAME_CONFLICT')
expect(body.error.details[0].metadata.conflictingName).toBe('beta.txt')
})
it('PATCH /api/objects/:id rename with onConflict: rename succeeds', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'alpha.txt' })
await insertFile(db, orgId, { id: 'm2', name: 'beta.txt' })
const res = await app.request('/api/objects/m1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'beta.txt', onConflict: 'rename' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.name).toBe('beta (1).txt')
})
it('PATCH /api/objects/:id move with collision and no onConflict returns 409 [spec: objects/move-conflict]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'file.txt' })
await insertFile(db, orgId, { id: 'm2', name: 'file.txt', parent: 'Dest' })
const res = await app.request('/api/objects/m1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: 'Dest' }),
})
expect(res.status).toBe(409)
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
expect(body.error.details[0].reason).toBe('NAME_CONFLICT')
})
it('PATCH /api/objects/:id move with onConflict: rename resolves collision', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'file.txt' })
await insertFile(db, orgId, { id: 'm2', name: 'file.txt', parent: 'Dest' })
const res = await app.request('/api/objects/m1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: 'Dest', onConflict: 'rename' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.name).toBe('file (1).txt')
expect(body.parent).toBe('Dest')
})
it('POST .../completions returns 409 when an active sibling appeared during upload', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
// Start a real upload (creates the draft + session)…
const createRes = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'upload.txt', type: 'text/plain', size: 100, parent: '', dirtype: 0 }),
})
const created = (await createRes.json()) as { id: string; upload: { sessionId: string } }
// …then a same-named active sibling lands before completion.
await insertFile(db, orgId, { id: 'active1', name: 'upload.txt', status: 'active' })
const res = await app.request(`/api/objects/${created.id}/uploads/${created.upload.sessionId}/completions`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parts: [{ partNumber: 1, etag: 'abc' }] }),
})
expect(res.status).toBe(409)
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
expect(body.error.details[0].reason).toBe('NAME_CONFLICT')
})
it('POST /api/trash/objects/:id/restorations returns 409 when restore name is already taken [spec: objects/restore-conflict]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'trashed1', name: 'note.txt', trashedAt: Date.now() })
await insertFile(db, orgId, { id: 'active2', name: 'note.txt', status: 'active' })
const res = await app.request('/api/trash/objects/trashed1/restorations', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(res.status).toBe(409)
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
expect(body.error.details[0].reason).toBe('NAME_CONFLICT')
})
it('POST /api/trash/objects/:id/restorations with onConflict: rename restores with suffix', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'trashed2', name: 'note.txt', trashedAt: Date.now() })
await insertFile(db, orgId, { id: 'active3', name: 'note.txt', status: 'active' })
const res = await app.request('/api/trash/objects/trashed2/restorations', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ onConflict: 'rename' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.name).toBe('note (1).txt')
expect(body.status).toBe('active')
})
it('POST /api/objects/copy returns 409 when onConflict: fail and target has same name', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'src1', name: 'doc.txt' })
await insertFile(db, orgId, { id: 'dst1', name: 'doc.txt', parent: 'Dest' })
const res = await app.request('/api/objects/src1/copies', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: 'Dest', onConflict: 'fail' }),
})
expect(res.status).toBe(409)
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
expect(body.error.details[0].reason).toBe('NAME_CONFLICT')
})
it('POST /api/objects/copy auto-renames by default when target has same name', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'src2', name: 'photo.jpg' })
await insertFile(db, orgId, { id: 'dst2', name: 'photo.jpg', parent: 'Dest' })
const res = await app.request('/api/objects/src2/copies', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: 'Dest' }),
})
expect(res.status).toBe(201)
const body = (await res.json()) as Record<string, unknown>
expect(body.name).toBe('photo (1).jpg')
})
})
// ─── Cross-space transfer ─────────────────────────────────────────────────────
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
type TestApp = Awaited<ReturnType<typeof createTestApp>>['app']
type TestAuth = Awaited<ReturnType<typeof createTestApp>>['auth']
async function createWorkspaceApiKey(
auth: TestAuth,
orgId: string,
userId: string,
permissions: Record<string, string[]>,
): Promise<string> {
// biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API is not fully typed
const result = (await (auth.api as any).createApiKey({
body: {
configId: 'ihost',
organizationId: orgId,
userId,
permissions,
},
})) as { key: string }
return result.key
}
async function getUserIdByEmail(db: TestDb, email: string): Promise<string> {
const rows = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = ${email}`)
return rows[0].id
}
async function insertTeamOrg(db: TestDb, id: string): Promise<void> {
await db.run(sql`
INSERT INTO organization (id, name, slug, metadata)
VALUES (${id}, ${`Team ${id}`}, ${id}, '{"type":"team"}')
`)
await db.run(sql`
INSERT INTO org_quotas (id, org_id, quota, used, traffic_quota, traffic_used, traffic_period)
VALUES (${`quota-${id}`}, ${id}, 0, 0, 0, 0, '1970-01')
`)
}
async function insertMember(db: TestDb, orgId: string, userId: string, role: string): Promise<void> {
await db.run(sql`
INSERT INTO member (id, organization_id, user_id, role)
VALUES (${`member-${orgId}-${userId}`}, ${orgId}, ${userId}, ${role})
`)
}
async function insertStorageEntitlement(db: TestDb, orgId: string, bytes: number): Promise<void> {
const now = Date.now()
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, status, created_at, updated_at)
VALUES
(${`ent-${orgId}`}, ${orgId}, 'storage', 'grant', 'test', ${`test-${orgId}`}, ${bytes}, ${now}, 'active', ${now}, ${now})
`)
}
function transferRequest(
app: TestApp,
headers: Record<string, string>,
id: string,
body: { targetOrgId: string; targetParent?: string; mode: 'copy' | 'move' },
) {
return app.request(`/api/objects/${id}/transfers`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
describe('POST /api/objects/:id/transfers', () => {
it('allows a workspace API key with object scopes to list, read, and create objects', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const userId = await getUserIdByEmail(db, 'test@example.com')
await insertFile(db, orgId, { id: 'api-key-read', name: 'readme.txt' })
const key = await createWorkspaceApiKey(auth, orgId, userId, { objects: ['read', 'create'] })
const headers = { Authorization: `Bearer ${key}` }
const list = await app.request('/api/objects', { headers })
expect(list.status).toBe(200)
const listBody = (await list.json()) as { items: Array<{ id: string }> }
expect(listBody.items.map((item) => item.id)).toContain('api-key-read')
const read = await app.request('/api/objects/api-key-read', { headers })
expect(read.status).toBe(200)
const readBody = (await read.json()) as { id: string; name: string }
expect(readBody).toMatchObject({ id: 'api-key-read', name: 'readme.txt' })
const create = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Api Folder', type: 'folder', dirtype: 1, parent: '' }),
})
expect(create.status).toBe(201)
const createBody = (await create.json()) as { name: string; dirtype: number }
expect(createBody).toMatchObject({ name: 'Api Folder', dirtype: 1 })
})
it('returns 403 when a workspace API key is missing the required object scope', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
const orgId = await getOrgId(db)
const userId = await getUserIdByEmail(db, 'test@example.com')
const key = await createWorkspaceApiKey(auth, orgId, userId, { objects: ['create'] })
const res = await app.request('/api/objects', {
headers: { Authorization: `Bearer ${key}` },
})
expect(res.status).toBe(403)
})
it('rejects orgId override for a fixed-workspace API key', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const userId = await getUserIdByEmail(db, 'test@example.com')
await insertTeamOrg(db, 'team-api-override')
await insertMember(db, 'team-api-override', userId, 'viewer')
await insertFile(db, 'team-api-override', { id: 'team-api-file', name: 'team.txt' })
const key = await createWorkspaceApiKey(auth, orgId, userId, { objects: ['read'] })
const res = await app.request('/api/objects?orgId=team-api-override', {
headers: { Authorization: `Bearer ${key}` },
})
expect(res.status).toBe(403)
})
it('rejects cross-space transfer for a fixed-workspace API key', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const userId = await getUserIdByEmail(db, 'test@example.com')
await insertTeamOrg(db, 'team-api-transfer')
await insertMember(db, 'team-api-transfer', userId, 'editor')
await insertStorageEntitlement(db, 'team-api-transfer', 10_000_000)
await insertFile(db, orgId, { id: 'src-api-transfer', name: 'doc.txt' })
const key = await createWorkspaceApiKey(auth, orgId, userId, { objects: ['update'] })
const res = await transferRequest(app, { Authorization: `Bearer ${key}` }, 'src-api-transfer', {
targetOrgId: 'team-api-transfer',
mode: 'copy',
})
expect(res.status).toBe(403)
})
it('copies a file into a team space the user can edit [spec: objects/transfer-copy]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const userId = await getUserIdByEmail(db, 'test@example.com')
await insertTeamOrg(db, 'team-a')
await insertMember(db, 'team-a', userId, 'editor')
await insertStorageEntitlement(db, 'team-a', 10_000_000)
await insertFile(db, orgId, { id: 'src-copy', name: 'doc.txt' })
const res = await transferRequest(app, headers, 'src-copy', { targetOrgId: 'team-a', mode: 'copy' })
expect(res.status).toBe(201)
const body = (await res.json()) as { saved: Array<{ orgId: string; name: string }>; sourceDeleted: boolean }
expect(body.saved).toHaveLength(1)
expect(body.saved[0].orgId).toBe('team-a')
expect(body.sourceDeleted).toBe(false)
expect(S3Service.prototype.copyObject).toHaveBeenCalled()
const source = await getMatter(db, 'src-copy', orgId)
expect(source?.status).toBe('active')
})
it('moves a file into a team space, deleting the source and releasing its quota [spec: objects/transfer-move]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const userId = await getUserIdByEmail(db, 'test@example.com')
await insertTeamOrg(db, 'team-b')
await insertMember(db, 'team-b', userId, 'owner')
await insertStorageEntitlement(db, 'team-b', 10_000_000)
await insertFile(db, orgId, { id: 'src-move', name: 'photo.jpg', size: 1024 })
await db.run(sql`
UPDATE org_quotas
SET quota = ${1024 * 1024}, used = 1024, traffic_quota = 0, traffic_used = 0, traffic_period = '1970-01'
WHERE org_id = ${orgId}
`)
const res = await transferRequest(app, headers, 'src-move', { targetOrgId: 'team-b', mode: 'move' })
expect(res.status).toBe(201)
const body = (await res.json()) as { saved: Array<{ orgId: string }>; sourceDeleted: boolean }
expect(body.sourceDeleted).toBe(true)
// Source is purged, not trashed — its quota must be released, not double-counted.
const source = await getMatter(db, 'src-move', orgId)
expect(source).toBeNull()
expect((await getOrgQuota(db, orgId))?.used ?? 0).toBe(0)
const targetList = await listMatters(db, 'team-b', { parent: '', pageSize: 10 })
expect(targetList.items.map((m) => m.name)).toContain('photo.jpg')
})
it('copies a folder recursively into the target space [spec: objects/transfer-folder]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const userId = await getUserIdByEmail(db, 'test@example.com')
await insertTeamOrg(db, 'team-c')
await insertMember(db, 'team-c', userId, 'editor')
await insertStorageEntitlement(db, 'team-c', 10_000_000)
await insertFolder(db, orgId, { id: 'fold-1', name: 'Album' })
await insertFile(db, orgId, { id: 'in-fold', name: 'pic.png', parent: 'Album' })
const res = await transferRequest(app, headers, 'fold-1', { targetOrgId: 'team-c', mode: 'copy' })
expect(res.status).toBe(201)
const body = (await res.json()) as { saved: Array<{ name: string }> }
expect(body.saved.map((m) => m.name)).toEqual(expect.arrayContaining(['Album', 'pic.png']))
})
it('rejects transfer into a team the user is not a member of [spec: objects/transfer-permission]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertTeamOrg(db, 'team-strange')
await insertFile(db, orgId, { id: 'src-403', name: 'doc.txt' })
const res = await transferRequest(app, headers, 'src-403', { targetOrgId: 'team-strange', mode: 'copy' })
expect(res.status).toBe(403)
})
it("rejects transfer into another user's personal space", async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await authedHeaders(app, 'victim@example.com')
await insertStorage(db)
const orgId = await getOrgId(db)
const victimId = await getUserIdByEmail(db, 'victim@example.com')
const victimOrgs = await db.all<{ id: string }>(sql`
SELECT o.id
FROM organization o
INNER JOIN member m ON m.organization_id = o.id
WHERE m.user_id = ${victimId}
AND (o.slug LIKE 'personal-%' OR COALESCE(o.metadata, '') LIKE '%"type":"personal"%')
LIMIT 1
`)
await insertFile(db, orgId, { id: 'src-victim', name: 'doc.txt' })
const res = await transferRequest(app, headers, 'src-victim', { targetOrgId: victimOrgs[0].id, mode: 'copy' })
expect(res.status).toBe(403)
})
it('rejects transfer when the target space quota is exceeded [spec: objects/transfer-quota]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const userId = await getUserIdByEmail(db, 'test@example.com')
await insertTeamOrg(db, 'team-small')
await insertMember(db, 'team-small', userId, 'editor')
await insertStorageEntitlement(db, 'team-small', 10)
await insertFile(db, orgId, { id: 'src-big', name: 'big.bin' })
const res = await transferRequest(app, headers, 'src-big', { targetOrgId: 'team-small', mode: 'copy' })
expect(res.status).toBe(422)
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
expect(body.error.details[0].reason).toBe('QUOTA_EXCEEDED')
})
it('rejects transfer to the same space [spec: objects/transfer-same-space]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'src-same', name: 'doc.txt' })
const res = await transferRequest(app, headers, 'src-same', { targetOrgId: orgId, mode: 'copy' })
expect(res.status).toBe(400)
})
})
// ─── Quota enforcement ────────────────────────────────────────────────────────
// Scoped helpers below shadow the top-level fixtures (distinct storage id and a
// quota-focused insertStorage/insertFile signature) so they stay isolated.
describe('Objects API — quota enforcement', () => {
const validStorage = {
id: 'st-quota',
bucket: 'test-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
accessKey: 'AKIAIOSFODNN7EXAMPLE',
secretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
}
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db'], used = 0) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${validStorage.id}, ${validStorage.bucket},
${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey},
${validStorage.secretKey}, '', '', 0, ${used}, 'active', ${now}, ${now})
`)
}
async function insertFile(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
orgId: string,
opts: { id: string; name: string; size?: number; status?: string; trashedAt?: number },
) {
const now = Date.now()
const size = opts.size ?? 100
const status = opts.status ?? 'active'
await db.run(sql`
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, trashed_at, created_at, updated_at)
VALUES (${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${opts.name}, 'text/plain', ${size}, 0, '',
'some/key.txt', ${validStorage.id}, ${status}, ${opts.trashedAt ?? null}, ${now}, ${now})
`)
}
async function getOrgId(db: Awaited<ReturnType<typeof createTestApp>>['db']): Promise<string> {
const rows = await db.all<{ id: string }>(sql`
SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1
`)
return rows[0].id
}
async function setOrgQuota(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
orgId: string,
quota: number,
used = 0,
) {
const existing = await db.select().from(orgQuotas).where(eq(orgQuotas.orgId, orgId))
if (existing.length > 0) {
await db.update(orgQuotas).set({ quota, used }).where(eq(orgQuotas.orgId, orgId))
} else {
await db.insert(orgQuotas).values({
id: nanoid(),
orgId,
quota,
used,
trafficQuota: 0,
trafficUsed: 0,
trafficPeriod: currentTrafficPeriod(),
})
}
const now = Date.now()
await db.run(sql`
UPDATE org_quota_entitlements
SET status = 'revoked', updated_at = ${now}
WHERE org_id = ${orgId}
AND resource_type = 'storage'
AND entitlement_type = 'plan'
AND status = 'active'
`)
if (quota > 0) {
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
(${nanoid()}, ${orgId}, 'storage', 'plan', 'test', ${`test-storage-plan:${orgId}:${nanoid()}`}, ${quota}, ${now}, NULL, 'active', '{"packageName":"Test Plan"}', ${now}, ${now})
`)
}
}
async function addStorageEntitlement(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
orgId: string,
bytes: number,
) {
const now = new Date()
await db.insert(orgQuotaEntitlements).values({
id: nanoid(),
orgId,
resourceType: 'storage',
source: 'test',
sourceId: nanoid(),
bytes,
startsAt: now,
expiresAt: null,
status: 'active',
metadata: null,
createdAt: now,
updatedAt: now,
})
}
function stubCapacityStoreWithoutOffers() {
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
const url = String(input instanceof Request ? input.url : input)
if (url.includes('/products')) {
return Response.json({ items: [], total: 0, limit: 100, offset: 0 })
}
if (url.endsWith('/publication')) {
return Response.json({
storeId: 'store-test-binding',
mode: 'directory',
listingStatus: 'listed',
displayName: 'ZPan',
summary: null,
publicMetadata: {},
skillUrl: null,
termsUrl: null,
healthUrl: 'https://files.example/api/health',
healthStatus: 'healthy',
resources: [],
createdAt: '2026-07-30T00:00:00.000Z',
updatedAt: '2026-07-30T00:00:00.000Z',
})
}
throw new Error(`Unexpected Cloud request: ${url}`)
}),
)
}
it('surfaces capacity-offer lookup failures instead of reporting quota exhaustion', async () => {
const { app, db } = await createTestApp({ ZPAN_CLOUD_URL: 'https://cloud.example' })
await seedBusinessLicense(db)
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await setOrgQuota(db, orgId, 100, 90)
vi.stubGlobal(
'fetch',
vi.fn(async () => Response.json({ error: { code: 'cloud_unavailable' } }, { status: 503 })),
)
const res = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
dirtype: 0,
name: 'toobig.txt',
parent: '',
size: 50,
type: 'text/plain',
}),
})
expect(res.status).toBe(502)
await expect(res.json()).resolves.toMatchObject({
error: {
message: 'cloud_unavailable',
details: [{ reason: 'UNAVAILABLE' }],
},
})
})
// ─── POST /api/objects/copy — quota enforcement ──────────────────────────
describe('POST /api/objects/copy — quota enforcement', () => {
it('returns 422 when copying a file would exceed quota', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
// quota = 500, used = 450, file size = 100 → copy would exceed
await setOrgQuota(db, orgId, 500, 450)
await insertFile(db, orgId, { id: 'm-copy-over', name: 'big.txt', size: 100 })
const res = await app.request('/api/objects/m-copy-over/copies', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '' }),
})
expect(res.status).toBe(422)
const body = (await res.json()) as { error: { message: string; details: Array<{ reason: string }> } }
expect(body.error.message).toBe('Quota exceeded')
expect(body.error.details[0].reason).toBe('QUOTA_EXCEEDED')
})
it('returns 201 and increments orgQuotas.used when copy succeeds within quota', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
// quota = 1000, used = 100, file size = 100 → copy is fine
await setOrgQuota(db, orgId, 1000, 100)
await insertFile(db, orgId, { id: 'm-copy-ok', name: 'doc.txt', size: 100 })
const res = await app.request('/api/objects/m-copy-ok/copies', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '' }),
})
expect(res.status).toBe(201)
const quotaRows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
expect(quotaRows[0].used).toBe(200)
})
it('returns 201 and increments storages.used when copy succeeds', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db, 50)
const orgId = await getOrgId(db)
await setOrgQuota(db, orgId, 10000, 50)
await insertFile(db, orgId, { id: 'm-copy-st', name: 'img.png', size: 150 })
await app.request('/api/objects/m-copy-st/copies', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '' }),
})
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${validStorage.id}`)
expect(storageRows[0].used).toBe(200)
})
it('rolls back usage when S3 copy fails after quota reservation', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db, 100)
const orgId = await getOrgId(db)
await setOrgQuota(db, orgId, 1000, 100)
await insertFile(db, orgId, { id: 'm-copy-s3-fail', name: 'fail.txt', size: 200 })
vi.mocked(S3Service.prototype.copyObject).mockRejectedValueOnce(new Error('copy failed'))
const res = await app.request('/api/objects/m-copy-s3-fail/copies', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: 'Archive' }),
})
expect(res.status).toBe(500)
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${validStorage.id}`)
const quotaRows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
expect(storageRows[0].used).toBe(100)
expect(quotaRows[0].used).toBe(100)
})
it('rolls back usage when copy fails on name conflict after quota reservation', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db, 100)
const orgId = await getOrgId(db)
await setOrgQuota(db, orgId, 1000, 100)
await insertFile(db, orgId, { id: 'm-copy-conflict', name: 'conflict.txt', size: 200 })
const res = await app.request('/api/objects/m-copy-conflict/copies', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '', onConflict: 'fail' }),
})
expect(res.status).toBe(409)
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${validStorage.id}`)
const quotaRows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
expect(storageRows[0].used).toBe(100)
expect(quotaRows[0].used).toBe(100)
})
it('returns 201 without incrementing usage when copying a zero-size file', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
// quota is fully consumed, but zero-size should still pass
await setOrgQuota(db, orgId, 500, 500)
const now = Date.now()
await db.run(sql`
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at)
VALUES ('m-zero', ${orgId}, 'm-zero-alias', 'empty.txt', 'text/plain', 0, 0, '', '',
${validStorage.id}, 'active', ${now}, ${now})
`)
const res = await app.request('/api/objects/m-zero/copies', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '' }),
})
expect(res.status).toBe(201)
const quotaRows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
expect(quotaRows[0].used).toBe(500) // unchanged
})
it('returns 422 when no quota row or entitlement exists', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await db.delete(orgQuotaEntitlements).where(eq(orgQuotaEntitlements.orgId, orgId))
await db.delete(orgQuotas).where(eq(orgQuotas.orgId, orgId))
await insertFile(db, orgId, { id: 'm-copy-nolimit', name: 'nolimit.txt', size: 100 })
const res = await app.request('/api/objects/m-copy-nolimit/copies', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '' }),
})
expect(res.status).toBe(422)
})
it('returns 422 when effective quota is zero', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await setOrgQuota(db, orgId, 0, 99999)
await insertFile(db, orgId, { id: 'm-copy-qlimit', name: 'large.bin', size: 1000000 })
const res = await app.request('/api/objects/m-copy-qlimit/copies', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '' }),
})
expect(res.status).toBe(422)
})
it('returns 404 when source file does not exist', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/nonexistent/copies', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '' }),
})
expect(res.status).toBe(404)
})
})
describe('trash and purge — storage usage accounting', () => {
it('does not change usage when an active file is moved to trash', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db, 300)
const orgId = await getOrgId(db)
await setOrgQuota(db, orgId, 1000, 300)
await insertFile(db, orgId, { id: 'm-trash-usage', name: 'keep-accounted.txt', size: 300 })
// Soft delete keeps trashed bytes counted (they still occupy storage).
const res = await app.request('/api/objects/m-trash-usage', { method: 'DELETE', headers })
expect(res.status).toBe(204)
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${validStorage.id}`)
const quotaRows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
expect(storageRows[0].used).toBe(300)
expect(quotaRows[0].used).toBe(300)
})
it('purging a trashed root releases only its bytes and keeps active file usage', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db, 500)
const orgId = await getOrgId(db)
await setOrgQuota(db, orgId, 1000, 500)
await insertFile(db, orgId, { id: 'm-active-after-empty', name: 'active.txt', size: 300 })
await insertFile(db, orgId, { id: 'm-trashed-empty', name: 'trashed.txt', size: 200, trashedAt: Date.now() })
const res = await app.request('/api/trash/objects/m-trashed-empty', { method: 'DELETE', headers })
expect(res.status).toBe(204)
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${validStorage.id}`)
const quotaRows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
expect(storageRows[0].used).toBe(300)
expect(quotaRows[0].used).toBe(300)
})
it('purging a trashed root recalculates usage when counters had drifted below active file bytes', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db, 200)
const orgId = await getOrgId(db)
await setOrgQuota(db, orgId, 1000, 200)
await insertFile(db, orgId, { id: 'm-active-drift', name: 'active.txt', size: 300 })
await insertFile(db, orgId, { id: 'm-trashed-drift', name: 'trashed.txt', size: 200, trashedAt: Date.now() })
const res = await app.request('/api/trash/objects/m-trashed-drift', { method: 'DELETE', headers })
expect(res.status).toBe(204)
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${validStorage.id}`)
const quotaRows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
expect(storageRows[0].used).toBe(300)
expect(quotaRows[0].used).toBe(300)
})
})
describe('GET /api/objects/:id — traffic quota enforcement', () => {
it('returns download URL and consumes traffic quota when allowed', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm-download-ok', name: 'download.txt', size: 100 })
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
SET traffic_quota = 500, traffic_used = 25, traffic_period = ${trafficPeriod}
WHERE org_id = ${orgId}
`)
const res = await app.request('/api/objects/m-download-ok', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.downloadUrl).toBe('https://presigned-download.example.com')
const rows = await db.all<{ trafficUsed: number }>(
sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${orgId}`,
)
expect(rows[0].trafficUsed).toBe(125)
})
it('resets stale monthly traffic period before consuming traffic', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm-download-reset', name: 'download.txt', size: 100 })
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
SET traffic_quota = 500, traffic_used = 500, traffic_period = '1970-01'
WHERE org_id = ${orgId}
`)
const res = await app.request('/api/objects/m-download-reset', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.downloadUrl).toBe('https://presigned-download.example.com')
const rows = await db.all<{ trafficUsed: number; trafficPeriod: string }>(
sql`SELECT traffic_used AS trafficUsed, traffic_period AS trafficPeriod FROM org_quotas WHERE org_id = ${orgId}`,
)
expect(rows[0]).toEqual({ trafficUsed: 100, trafficPeriod })
})
it('refunds traffic when download URL signing fails', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm-download-sign-fail', name: 'download.txt', size: 100 })
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
SET traffic_quota = 500, traffic_used = 25, traffic_period = ${trafficPeriod}
WHERE org_id = ${orgId}
`)
vi.mocked(S3Service.prototype.presignDownload).mockRejectedValueOnce(new Error('sign failed'))
const res = await app.request('/api/objects/m-download-sign-fail', { headers })
expect(res.status).toBe(500)
const rows = await db.all<{ trafficUsed: number }>(
sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${orgId}`,
)
expect(rows[0].trafficUsed).toBe(25)
})
it('returns 422 when download traffic quota is exhausted', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm-download-over', name: 'download.txt', size: 100 })
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
SET traffic_quota = 0, traffic_used = 0, traffic_period = ${trafficPeriod}
WHERE org_id = ${orgId}
`)
const now = Date.now()
await db.run(sql`
UPDATE org_quota_entitlements
SET status = 'revoked', updated_at = ${now}
WHERE org_id = ${orgId}
AND resource_type = 'traffic'
AND entitlement_type = 'plan'
AND status = 'active'
`)
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
(${nanoid()}, ${orgId}, 'traffic', 'plan', 'test', ${`test-traffic-plan:${orgId}`}, 50, ${now}, NULL, 'active', '{"packageName":"Test Plan"}', ${now}, ${now})
`)
const res = await app.request('/api/objects/m-download-over', { headers })
expect(res.status).toBe(422)
const body = (await res.json()) as {
error: { message: string; status: string; details: Array<{ reason: string }> }
}
expect(body.error.message).toBe('Traffic quota exceeded')
expect(body.error.status).toBe('RESOURCE_EXHAUSTED')
expect(body.error.details[0].reason).toBe('QUOTA_EXCEEDED')
expect(S3Service.prototype.presignDownload).not.toHaveBeenCalled()
const rows = await db.all<{ trafficUsed: number }>(
sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${orgId}`,
)
expect(rows[0].trafficUsed).toBe(0)
const failures = await db.all<{ bytes: number; reason: string; source: string }>(sql`
SELECT json_extract(metadata, '$.bytes') AS bytes,
json_extract(metadata, '$.reason') AS reason,
json_extract(metadata, '$.source') AS source
FROM audit_events
WHERE action = 'download_failed' AND target_id = 'm-download-over'
`)
expect(failures).toEqual([{ bytes: 100, reason: 'quota_exceeded', source: 'object_download' }])
})
})
// ─── POST .../completions — quota enforcement via the activation path ──────────
// The completions endpoint finalizes a draft (draft → live), reserving quota
// through the same confirmUpload core. These tests drive the real upload flow:
// POST /api/objects (creates the draft + session) then POST .../completions.
describe('POST .../completions — quota enforcement', () => {
// Creates a file draft via the API and returns {id, sessionId}. The S3 spies
// make the single-PUT HEAD return etag 'abc'.
async function createDraft(
app: Awaited<ReturnType<typeof createTestApp>>['app'],
headers: Record<string, string>,
body: { name: string; size: number; onConflict?: string },
): Promise<{ id: string; sessionId: string }> {
const res = await createDraftResponse(app, headers, body)
if (res.status !== 201) throw new Error(`create failed: ${res.status} ${await res.text()}`)
const created = (await res.json()) as { id: string; upload: { sessionId: string } }
return { id: created.id, sessionId: created.upload.sessionId }
}
function createDraftResponse(
app: Awaited<ReturnType<typeof createTestApp>>['app'],
headers: Record<string, string>,
body: { name: string; size: number; onConflict?: string },
) {
return app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'text/plain', parent: '', dirtype: 0, ...body }),
})
}
// The conflict strategy is fixed at create time (stored on the session); the
// completions body carries only the uploaded parts.
function complete(
app: Awaited<ReturnType<typeof createTestApp>>['app'],
headers: Record<string, string>,
ref: { id: string; sessionId: string },
) {
return app.request(`/api/objects/${ref.id}/uploads/${ref.sessionId}/completions`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parts: [{ partNumber: 1, etag: 'abc' }] }),
})
}
it('returns 200 and increments usage when quota allows', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await setOrgQuota(db, orgId, 10000, 0)
const ref = await createDraft(app, headers, { name: 'uploading.txt', size: 350 })
const res = await complete(app, headers, ref)
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.status).toBe('active')
const quotaRows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
expect(quotaRows[0].used).toBe(350)
})
it('uses active storage entitlements when finalizing upload', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await setOrgQuota(db, orgId, 100, 90)
await addStorageEntitlement(db, orgId, 100)
const ref = await createDraft(app, headers, { name: 'entitled.txt', size: 50 })
const res = await complete(app, headers, ref)
expect(res.status).toBe(200)
const quotaRows = await db.all<{ used: number; quota: number }>(
sql`SELECT used, quota FROM org_quotas WHERE org_id = ${orgId}`,
)
expect(quotaRows[0]).toEqual({ used: 140, quota: 100 })
})
it('rejects upload preparation when storage entitlements are exhausted', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await setOrgQuota(db, orgId, 0, 90)
await addStorageEntitlement(db, orgId, 100)
stubCapacityStoreWithoutOffers()
const res = await createDraftResponse(app, headers, { name: 'limited.txt', size: 11 })
expect(res.status).toBe(422)
await expect(res.json()).resolves.toMatchObject({ error: { message: 'Quota exceeded' } })
})
it('returns 200 and increments storages.used when quota allows', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db, 100)
const orgId = await getOrgId(db)
await setOrgQuota(db, orgId, 10000, 100)
const ref = await createDraft(app, headers, { name: 'photo.jpg', size: 400 })
await complete(app, headers, ref)
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${validStorage.id}`)
expect(storageRows[0].used).toBe(500)
})
it('returns 422 before upload when the file would exceed quota', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
// quota = 100, used = 90, file size = 50 → exceeds
await setOrgQuota(db, orgId, 100, 90)
stubCapacityStoreWithoutOffers()
const res = await createDraftResponse(app, headers, { name: 'toobig.txt', size: 50 })
expect(res.status).toBe(422)
const body = (await res.json()) as { error: { message: string; details: Array<{ reason: string }> } }
expect(body.error.message).toBe('Quota exceeded')
expect(body.error.details[0].reason).toBe('QUOTA_EXCEEDED')
})
it('does not change usage when a file with size 0 is finalized', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db, 50)
const orgId = await getOrgId(db)
await setOrgQuota(db, orgId, 10000, 50)
// HEAD returns size 0 for the empty file; the reported etag still matches.
vi.mocked(S3Service.prototype.headObject).mockResolvedValue({ size: 0, contentType: 'text/plain', etag: 'abc' })
const ref = await createDraft(app, headers, { name: 'empty.txt', size: 0 })
await complete(app, headers, ref)
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${validStorage.id}`)
const quotaRows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
expect(storageRows[0].used).toBe(50)
expect(quotaRows[0].used).toBe(50)
})
it('returns 422 before upload when no quota row or entitlement exists', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await db.delete(orgQuotaEntitlements).where(eq(orgQuotaEntitlements.orgId, orgId))
await db.delete(orgQuotas).where(eq(orgQuotas.orgId, orgId))
const res = await createDraftResponse(app, headers, { name: 'nolimit.txt', size: 5000 })
expect(res.status).toBe(422)
await expect(res.json()).resolves.toMatchObject({ error: { details: [{ reason: 'QUOTA_EXCEEDED' }] } })
})
it('replaces a same-size file at full quota — net-neutral, incumbent purged', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
// Quota exactly full: the 100-byte incumbent fills the 100-byte quota.
await insertFile(db, orgId, { id: 'incumbent', name: 'doc.txt', size: 100 })
await setOrgQuota(db, orgId, 100, 100)
// Create the replacement draft with onConflict:'replace' (stored on the
// session; incumbent stays active — overwrite deferred to completion).
const ref = await createDraft(app, headers, { name: 'doc.txt', size: 100, onConflict: 'replace' })
// Complete: the incumbent's bytes are freed so this is net-neutral.
const completeRes = await complete(app, headers, ref)
expect(completeRes.status).toBe(200)
// Incumbent content is purged but its tombstone is retained; usage is unchanged.
const incumbent = await db.all<{ purgedAt: number | null }>(
sql`SELECT purged_at AS purgedAt FROM matters WHERE id = 'incumbent'`,
)
expect(incumbent[0]?.purgedAt).toEqual(expect.any(Number))
const quotaRows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
expect(quotaRows[0].used).toBe(100)
})
})
})
// ─── Multipart upload against a live S3-compatible mock ───────────────────────
// Self-contained: spins up an in-process S3 mock and uses its own storage row.
describe('object multipart upload API with S3-compatible storage', () => {
let server: Server | undefined
// Undo the file-level S3Service spies so this test exercises the real gateway
// against its in-process mock instead of the stubbed presign URLs.
beforeEach(() => {
vi.restoreAllMocks()
})
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db'], endpoint: string) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (
id, 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', 'test-bucket',
${endpoint}, 'auto', 'test-access-key', 'test-secret-key',
'$UID/$RAW_NAME', '', 0, 0, 'active', 0, ${100 * 1024 * 1024}, 1, ${now}, ${now}
)
`)
}
async function startMultipartS3Mock(): Promise<{ endpoint: string; server: Server }> {
const objects = new Map<string, Uint8Array>()
const uploads = new Map<string, { bucket: string; key: string; parts: Map<number, Uint8Array> }>()
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
const { bucket, key } = parsePath(url.pathname)
const objectKey = `${bucket}/${key}`
if (req.method === 'POST' && url.searchParams.has('uploads')) {
const uploadId = randomUUID()
uploads.set(uploadId, { bucket, key, parts: new Map() })
res.writeHead(200, { 'Content-Type': 'application/xml' })
res.end(`<CreateMultipartUploadResult><UploadId>${uploadId}</UploadId></CreateMultipartUploadResult>`)
return
}
if (req.method === 'PUT' && url.searchParams.has('uploadId') && url.searchParams.has('partNumber')) {
const upload = uploads.get(url.searchParams.get('uploadId') ?? '')
if (!upload) {
res.writeHead(404)
res.end('Upload not found')
return
}
const body = await readBody(req)
upload.parts.set(Number(url.searchParams.get('partNumber')), body)
res.writeHead(200, { etag: etag(body) })
res.end('')
return
}
if (req.method === 'POST' && url.searchParams.has('uploadId')) {
const uploadId = url.searchParams.get('uploadId') ?? ''
const upload = uploads.get(uploadId)
if (!upload) {
res.writeHead(404)
res.end('Upload not found')
return
}
const parts = [...upload.parts.entries()].sort(([left], [right]) => left - right)
const body = concat(parts.map(([, part]) => part))
objects.set(`${upload.bucket}/${upload.key}`, body)
uploads.delete(uploadId)
res.writeHead(200, { 'Content-Type': 'application/xml' })
res.end('<CompleteMultipartUploadResult />')
return
}
// Single PutObject (≤5 GiB upload): store the body and return its ETag.
if (req.method === 'PUT') {
const body = await readBody(req)
objects.set(objectKey, body)
res.writeHead(200, { etag: etag(body) })
res.end('')
return
}
// HeadObject — completions HEADs the object to confirm the single-PUT ETag.
if (req.method === 'HEAD') {
const object = objects.get(objectKey)
if (!object) {
res.writeHead(404)
res.end('')
return
}
res.writeHead(200, { 'Content-Length': object.byteLength, etag: etag(object) })
res.end('')
return
}
if (req.method === 'GET') {
const object = objects.get(objectKey)
if (!object) {
res.writeHead(404)
res.end('Not found')
return
}
res.writeHead(200, { 'Content-Length': object.byteLength, etag: etag(object) })
res.end(object)
return
}
res.writeHead(405)
res.end('Method not allowed')
} catch (error) {
res.writeHead(500)
res.end(error instanceof Error ? error.message : String(error))
}
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('S3 mock did not bind to a TCP port')
return { endpoint: `http://127.0.0.1:${address.port}`, server }
}
function parsePath(pathname: string): { bucket: string; key: string } {
const parts = pathname.split('/').filter(Boolean).map(decodeURIComponent)
return { bucket: parts[0] ?? '', key: parts.slice(1).join('/') }
}
async function readBody(req: IncomingMessage): Promise<Uint8Array> {
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
return new Uint8Array(Buffer.concat(chunks))
}
function concat(parts: Uint8Array[]): Uint8Array {
const body = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0))
let offset = 0
for (const part of parts) {
body.set(part, offset)
offset += part.byteLength
}
return body
}
function etag(body: Uint8Array): string {
return `"${createHash('md5').update(body).digest('hex')}"`
}
afterEach(async () => {
if (!server) return
await new Promise<void>((resolve, reject) => {
server?.close((error) => (error ? reject(error) : resolve()))
})
server = undefined
})
it('uploads a small object end-to-end via the single-PUT flow against a live S3 mock', async () => {
const s3 = await startMultipartS3Mock()
server = s3.server
const { app, db } = await createTestApp()
await insertStorage(db, s3.endpoint)
const headers = await authedHeaders(app)
// ≤5 GiB → one presigned PUT URL and partSize === size.
const createRes = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'upload-smoke.txt',
type: 'text/plain',
size: 11,
parent: '',
}),
})
expect(createRes.status).toBe(201)
const object = (await createRes.json()) as {
id: string
upload: { sessionId: string; partSize: number; parts: Array<{ url: string; headers: Record<string, string> }> }
}
expect(object.upload.parts).toHaveLength(1)
expect(object.upload.partSize).toBe(11)
// PUT the bytes directly to the presigned URL, read the ETag.
const putRes = await fetch(object.upload.parts[0].url, {
method: 'PUT',
headers: object.upload.parts[0].headers,
body: 'hello world',
})
expect(putRes.status).toBe(200)
const etagHeader = putRes.headers.get('etag')
expect(etagHeader).toBeTruthy()
// Finalize: completions HEADs the object and matches the reported ETag.
const completeRes = await app.request(`/api/objects/${object.id}/uploads/${object.upload.sessionId}/completions`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parts: [{ partNumber: 1, etag: etagHeader }] }),
})
expect(completeRes.status).toBe(200)
expect(((await completeRes.json()) as { status: string }).status).toBe('active')
const objectRes = await app.request(`/api/objects/${object.id}`, { headers })
expect(objectRes.status).toBe(200)
const active = (await objectRes.json()) as { downloadUrl: string }
const downloadRes = await fetch(active.downloadUrl)
expect(downloadRes.status).toBe(200)
await expect(downloadRes.text()).resolves.toBe('hello world')
})
})
// ─── Error-branch coverage (AIP-193 bodies) ───────────────────────────────────
// These exercise the inline `apiError(...)` guards in the handlers that the
// happy-path tests above don't reach: cross-org list authz, missing-storage
// resolution, the download-task-upload confirm guards, and the editor-access
// gate for an API key principal on session-only object routes.
// Creates an API key via the real better-auth plugin. Workspace-scoped keys use
// a workspace-capable template; user-workspace keys use WebDAV.
async function createUserApiKey(
auth: Awaited<ReturnType<typeof createTestApp>>['auth'],
userId: string,
opts: { orgId?: string; permissions?: Record<string, string[]> } = {},
): Promise<string> {
// biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API not fully typed
const result = (await (auth.api as any).createApiKey({
body: {
configId: opts.orgId ? 'ihost' : 'webdav',
userId,
...(opts.orgId ? { organizationId: opts.orgId } : {}),
permissions: opts.permissions,
},
})) as { key: string }
return result.key
}
const downloaderHeartbeat = {
version: '1.0.0',
hostname: 'host',
platform: 'linux',
arch: 'x64',
engine: 'aria2',
capabilities: ['http', 'magnet', 'torrent'],
maxConcurrentTasks: 2,
currentTasks: 0,
downloadBps: 0,
uploadBps: 0,
freeDiskBytes: 1024 * 1024 * 1024,
}
// Registers a downloader, creates a task, lets heartbeat claim it, and returns
// the upload token plus the task's target folder. The token authenticates as a
// `download-task-upload` principal scoped to that task/folder.
async function mintTaskUploadContext(
app: TestApp,
db: TestDb,
opts: { targetFolder: string },
): Promise<{ uploadToken: string; targetFolder: string; orgId: string }> {
const admin = await adminHeaders(app)
const codeRes = await app.request('/api/auth/device/code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: 'zpan-cli', scope: 'downloader:register' }),
})
const code = (await codeRes.json()) as { device_code: string; user_code: string }
await app.request(`/api/auth/device?user_code=${encodeURIComponent(code.user_code)}`, { headers: admin })
await app.request('/api/auth/device/approve', {
method: 'POST',
headers: { ...admin, 'Content-Type': 'application/json' },
body: JSON.stringify({ userCode: code.user_code }),
})
const tokenRes = await app.request('/api/auth/device/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: code.device_code,
client_id: 'zpan-cli',
}),
})
const cliToken = (await tokenRes.json()) as { access_token: string }
const downloaderHeaders = { Authorization: `Bearer ${cliToken.access_token}`, 'Content-Type': 'application/json' }
const createDownloaderRes = await app.request('/api/downloads/downloaders', {
method: 'POST',
headers: downloaderHeaders,
body: JSON.stringify({ name: 'object-error-downloader', heartbeat: downloaderHeartbeat }),
})
const downloader = (await createDownloaderRes.json()) as { token: string }
await app.request('/api/downloads/downloaders/me/heartbeats', {
method: 'POST',
headers: { Authorization: `Bearer ${downloader.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ ...downloaderHeartbeat, currentTasks: 0 }),
})
const user = await adminHeaders(app)
const createTaskRes = await app.request('/api/downloads/tasks', {
method: 'POST',
headers: { ...user, 'Content-Type': 'application/json' },
body: JSON.stringify({
source: { type: 'http', uri: 'https://example.com/file.txt' },
targetFolder: opts.targetFolder,
name: 'file.txt',
}),
})
expect(createTaskRes.status).toBe(201)
const assignedRes = await app.request('/api/downloads/downloaders/me/heartbeats', {
method: 'POST',
headers: { Authorization: `Bearer ${downloader.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ ...downloaderHeartbeat, currentTasks: 0 }),
})
const assigned = (await assignedRes.json()) as {
assignments: Array<{ status: { assignment?: { uploadToken?: string } } }>
}
const uploadToken = assigned.assignments[0]?.status.assignment?.uploadToken
if (!uploadToken) throw new Error('upload_token_missing')
const orgRows = await db.all<{ orgId: string }>(sql`SELECT org_id AS orgId FROM download_tasks LIMIT 1`)
return { uploadToken, targetFolder: opts.targetFolder, orgId: orgRows[0].orgId }
}
describe('Objects API — error branches', () => {
it('accepts a download-task-upload token scoped to a Unicode target folder', async () => {
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await insertStorage(db)
const targetFolder = 'Media/Music/欧阳娜娜/NANA II (2020)'
const { uploadToken } = await mintTaskUploadContext(app, db, { targetFolder })
const res = await app.request('/api/objects', {
method: 'POST',
headers: { Authorization: `Bearer ${uploadToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: '藏.mp3',
type: 'audio/mpeg',
size: 1,
parent: targetFolder,
}),
})
expect(res.status).toBe(201)
await expect(res.json()).resolves.toMatchObject({
name: '藏.mp3',
parent: targetFolder,
})
})
it('creates a folder with a workspace API key that has objects:create', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const userId = await getUserIdByEmail(db, 'test@example.com')
const orgId = await getOrgId(db)
const key = await createUserApiKey(auth, userId, { orgId, permissions: { objects: ['create'] } })
const res = await app.request('/api/objects', {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'api-key-folder', type: 'folder', dirtype: 1, parent: '' }),
})
expect(res.status).toBe(201)
await expect(res.json()).resolves.toMatchObject({ name: 'api-key-folder', orgId })
})
it('returns 403 when an object API key is missing the route scope', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
const userId = await getUserIdByEmail(db, 'test@example.com')
const orgId = await getOrgId(db)
const key = await createUserApiKey(auth, userId, { orgId, permissions: { objects: ['create'] } })
const res = await app.request('/api/objects', { headers: { Authorization: `Bearer ${key}` } })
expect(res.status).toBe(403)
const body = (await res.json()) as { error: { message: string; status: string } }
expect(body.error.message).toBe('Forbidden')
expect(body.error.status).toBe('PERMISSION_DENIED')
})
it('denies a fixed-workspace API key list orgId override before cross-org lookup', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
const userId = await getUserIdByEmail(db, 'test@example.com')
const orgId = await getOrgId(db)
await insertTeamOrg(db, 'team-fixed-list')
await insertMember(db, 'team-fixed-list', userId, 'owner')
const key = await createUserApiKey(auth, userId, { orgId, permissions: { objects: ['read'] } })
const res = await app.request('/api/objects?orgId=team-fixed-list', { headers: { Authorization: `Bearer ${key}` } })
expect(res.status).toBe(403)
})
it('allows same-workspace copy but denies cross-space transfer for a fixed-workspace API key', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const userId = await getUserIdByEmail(db, 'test@example.com')
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'fixed-copy-source', name: 'copy.txt' })
await insertTeamOrg(db, 'team-fixed-transfer')
await insertMember(db, 'team-fixed-transfer', userId, 'editor')
const key = await createUserApiKey(auth, userId, { orgId, permissions: { objects: ['update'] } })
const headers = { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }
const copy = await app.request('/api/objects/fixed-copy-source/copies', {
method: 'POST',
headers,
body: JSON.stringify({ parent: '' }),
})
expect(copy.status).toBe(201)
const transfer = await transferRequest(app, headers, 'fixed-copy-source', {
targetOrgId: 'team-fixed-transfer',
mode: 'copy',
})
expect(transfer.status).toBe(403)
})
it('returns 403 when listing an org the user cannot read via orgId override', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
await insertTeamOrg(db, 'team-foreign')
const res = await app.request('/api/objects?orgId=team-foreign', { headers })
expect(res.status).toBe(403)
const body = (await res.json()) as { error: { message: string; status: string } }
expect(body.error.message).toBe('Forbidden')
expect(body.error.status).toBe('PERMISSION_DENIED')
})
it('returns 404 when a file references a missing storage on GET', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
// File with a non-empty object key but no matching storage row.
await insertFile(db, orgId, { id: 'm-no-storage', name: 'orphan.txt' })
const res = await app.request('/api/objects/m-no-storage', { headers })
expect(res.status).toBe(404)
const body = (await res.json()) as { error: { message: string } }
expect(body.error.message).toBe('Storage not found')
})
it('returns 404 when copying a file whose storage is missing', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm-copy-orphan', name: 'orphan.txt' })
const res = await app.request('/api/objects/m-copy-orphan/copies', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '' }),
})
expect(res.status).toBe(404)
const body = (await res.json()) as { error: { message: string } }
expect(body.error.message).toBe('Storage not found')
})
it('returns 404 when transferring a missing object', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const userId = await getUserIdByEmail(db, 'test@example.com')
await insertTeamOrg(db, 'team-dest')
await insertMember(db, 'team-dest', userId, 'editor')
const res = await transferRequest(app, headers, 'does-not-exist', { targetOrgId: 'team-dest', mode: 'copy' })
expect(res.status).toBe(404)
const body = (await res.json()) as { error: { message: string } }
expect(body.error.message).toBe('Not found')
})
it('rejects a download-task-upload token that tries to soft-delete (trash) an object', async () => {
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await insertStorage(db)
const { uploadToken, orgId } = await mintTaskUploadContext(app, db, { targetFolder: 'Remote' })
await insertFile(db, orgId, { id: 'm-task-trash', name: 'file.txt', parent: 'Remote' })
// DELETE /objects/:id requires objects:delete; a download-task-upload token
// is authenticated but only carries upload lifecycle scopes.
const res = await app.request('/api/objects/m-task-trash', {
method: 'DELETE',
headers: { Authorization: `Bearer ${uploadToken}` },
})
expect(res.status).toBe(403)
})
it('rejects a download-task-upload completion outside the task target folder', async () => {
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await insertStorage(db)
const { uploadToken, orgId } = await mintTaskUploadContext(app, db, { targetFolder: 'Remote' })
// Draft sits outside the token's authorized folder, so the completion guard denies
// before any session lookup.
await insertFile(db, orgId, { id: 'm-task-outside', name: 'file.txt', parent: 'Elsewhere', status: 'draft' })
const res = await app.request('/api/objects/m-task-outside/uploads/any-session/completions', {
method: 'POST',
headers: { Authorization: `Bearer ${uploadToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ parts: [{ partNumber: 1, etag: 'abc' }] }),
})
expect(res.status).toBe(403)
const body = (await res.json()) as { error: { message: string } }
expect(body.error.message).toBe('Forbidden')
})
it('rejects download-task-upload re-sign outside the task target folder', async () => {
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await insertStorage(db)
const { uploadToken, orgId } = await mintTaskUploadContext(app, db, { targetFolder: 'Remote' })
await insertFile(db, orgId, {
id: 'm-task-presign-outside',
name: 'file.txt',
parent: 'Elsewhere',
status: 'draft',
})
const res = await app.request('/api/objects/m-task-presign-outside/uploads/any-session/parts', {
method: 'POST',
headers: { Authorization: `Bearer ${uploadToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ partNumbers: [1] }),
})
expect(res.status).toBe(403)
const body = (await res.json()) as { error: { message: string } }
expect(body.error.message).toBe('Forbidden')
})
it('rejects download-task-upload re-sign for another downloader upload session', async () => {
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await insertStorage(db)
const { uploadToken } = await mintTaskUploadContext(app, db, { targetFolder: 'Remote' })
const createRes = await app.request('/api/objects', {
method: 'POST',
headers: { Authorization: `Bearer ${uploadToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'owned-by-first.txt',
type: 'text/plain',
size: 100,
parent: 'Remote',
}),
})
expect(createRes.status).toBe(201)
const created = (await createRes.json()) as { id: string; upload: { sessionId: string } }
await db.run(
sql`UPDATE object_upload_sessions SET created_by = 'downloader:other' WHERE id = ${created.upload.sessionId}`,
)
const res = await app.request(`/api/objects/${created.id}/uploads/${created.upload.sessionId}/parts`, {
method: 'POST',
headers: { Authorization: `Bearer ${uploadToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ partNumbers: [1] }),
})
expect(res.status).toBe(403)
const body = (await res.json()) as { error: { message: string } }
expect(body.error.message).toBe('Forbidden')
})
it('rejects download-task-upload abort outside the task target folder', async () => {
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await insertStorage(db)
const { uploadToken, orgId } = await mintTaskUploadContext(app, db, { targetFolder: 'Remote' })
await insertFile(db, orgId, { id: 'm-task-abort-outside', name: 'file.txt', parent: 'Elsewhere', status: 'draft' })
const res = await app.request('/api/objects/m-task-abort-outside/uploads/any-session', {
method: 'DELETE',
headers: { Authorization: `Bearer ${uploadToken}` },
})
expect(res.status).toBe(403)
const body = (await res.json()) as { error: { message: string } }
expect(body.error.message).toBe('Forbidden')
})
it('allows repeated download-task-upload abort after the draft is gone', async () => {
const { app, db, platform } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await insertStorage(db)
const targetFolder = 'Remote'
const { uploadToken } = await mintTaskUploadContext(app, db, { targetFolder })
const createRes = await app.request('/api/objects', {
method: 'POST',
headers: { Authorization: `Bearer ${uploadToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'task-cancel.txt',
type: 'text/plain',
size: 1,
parent: targetFolder,
}),
})
expect(createRes.status).toBe(201)
const created = (await createRes.json()) as { id: string; upload: { sessionId: string } }
const first = await app.request(`/api/objects/${created.id}/uploads/${created.upload.sessionId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${uploadToken}` },
})
expect(first.status).toBe(204)
const tokenGateway = createDownloadTokenGateway()
const claims = await tokenGateway.verifyDownloadToken(platform, uploadToken)
if (!claims || claims.typ !== 'download-task-upload') throw new Error('task_upload_claims_missing')
const otherDownloaderId = 'other-downloader'
await db
.update(downloadTasks)
.set({ assignedDownloaderId: otherDownloaderId })
.where(eq(downloadTasks.id, claims.taskId))
const otherUploadToken = await tokenGateway.signDownloadToken(platform, {
...claims,
downloaderId: otherDownloaderId,
jti: randomUUID(),
})
const otherDownloader = await app.request(`/api/objects/${created.id}/uploads/${created.upload.sessionId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${otherUploadToken}` },
})
expect(otherDownloader.status).toBe(403)
await db
.update(downloadTasks)
.set({ assignedDownloaderId: claims.downloaderId })
.where(eq(downloadTasks.id, claims.taskId))
const second = await app.request(`/api/objects/${created.id}/uploads/${created.upload.sessionId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${uploadToken}` },
})
expect(second.status).toBe(204)
void db
})
})