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

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

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

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

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

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

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

---------

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

2049 lines
87 KiB
TypeScript

import { eq, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { S3Service } from '../adapters/gateways/s3.js'
import { createShareRepo } from '../adapters/repos/share'
import { shareRecipients, shares } from '../db/schema.js'
import { currentTrafficPeriod } from '../domain/quota.js'
import { authedHeaders, createTestApp, seedProLicense } from '../test/setup.js'
type TestApp = Awaited<ReturnType<typeof createTestApp>>['app']
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
// ─── Helpers ──────────────────────────────────────────────────────────────────
const validStorage = {
id: 'st-share-test',
title: 'Test S3',
bucket: 'test-bucket',
endpoint: 'https://s3.amazonaws.com',
region: 'us-east-1',
accessKey: 'AKIAIOSFODNN7EXAMPLE',
secretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
}
async function insertStorage(db: TestDb) {
const now = Date.now()
await db.run(sql`
INSERT OR IGNORE INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${validStorage.id}, ${validStorage.title}, ${validStorage.bucket}, ${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
`)
}
async function setStoragePlanEntitlement(db: TestDb, orgId: string, bytes: number) {
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'
`)
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()}`}, ${bytes}, ${now}, NULL, 'active', '{"packageName":"Test Plan"}', ${now}, ${now})
`)
}
async function insertFile(
db: TestDb,
orgId: string,
opts: { id: string; name: string; parent?: string; status?: string },
) {
const now = Date.now()
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, created_at, updated_at)
VALUES (${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${opts.name}, 'text/plain', 100, 0, ${opts.parent ?? ''}, 'some/key.txt', ${validStorage.id}, ${status}, ${now}, ${now})
`)
}
async function insertFolder(db: TestDb, orgId: string, opts: { id: string; name: string; parent?: string }) {
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 (${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${opts.name}, 'folder', 0, 1, ${opts.parent ?? ''}, '', ${validStorage.id}, 'active', ${now}, ${now})
`)
}
async function getOrgId(db: TestDb): 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 createShare(app: TestApp, headers: Record<string, string>, body: Record<string, unknown>) {
return app.request('/api/shares', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
async function getShareIdByToken(db: TestDb, token: string): Promise<string> {
const rows = await db.select({ id: shares.id }).from(shares).where(eq(shares.token, token))
return rows[0]?.id ?? ''
}
// ─── POST /api/shares auth guard ─────────────────────────────────────────────
describe('POST /api/shares (auth guard)', () => {
it('returns 401 without auth [spec: shares/auth-required]', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/shares', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ matterId: 'x', kind: 'landing' }),
})
expect(res.status).toBe(401)
})
})
// ─── POST /api/shares ─────────────────────────────────────────────────────────
describe('POST /api/shares', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('creates a landing share without password and returns 201 with correct shape [spec: shares/create-landing]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'f1', name: 'doc.txt' })
const res = await createShare(app, headers, { matterId: 'f1', kind: 'landing' })
expect(res.status).toBe(201)
const body = (await res.json()) as Record<string, unknown>
expect(typeof body.token).toBe('string')
expect(body.kind).toBe('landing')
expect((body.urls as Record<string, string>).landing).toMatch(/^\/s\//)
expect((body.urls as Record<string, string>).direct).toBeUndefined()
// Internal primary key is not exposed in the creation response.
expect(body.id).toBeUndefined()
})
it('creates a landing share with password and stores passwordHash in DB [spec: shares/create-password]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'f2', name: 'secret.txt' })
const res = await createShare(app, headers, { matterId: 'f2', kind: 'landing', password: 'hunter2' })
expect(res.status).toBe(201)
const body = (await res.json()) as Record<string, unknown>
const token = body.token as string
const rows = await db.select({ passwordHash: shares.passwordHash }).from(shares).where(eq(shares.token, token))
expect(rows[0]?.passwordHash).not.toBeNull()
expect(rows[0]?.passwordHash).not.toBe('')
})
it('creates a landing share with recipients and inserts share_recipients rows [spec: shares/create-recipients]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'f3', name: 'report.txt' })
const recipients = [{ recipientEmail: 'alice@example.com' }, { recipientEmail: 'bob@example.com' }]
const res = await createShare(app, headers, { matterId: 'f3', kind: 'landing', recipients })
expect(res.status).toBe(201)
const body = (await res.json()) as Record<string, unknown>
const shareId = await getShareIdByToken(db, body.token as string)
const rows = await db.select().from(shareRecipients).where(eq(shareRecipients.shareId, shareId))
expect(rows).toHaveLength(2)
})
it('creates a direct share for a file and returns direct url [spec: shares/create-direct]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'f4', name: 'photo.jpg' })
const res = await createShare(app, headers, { matterId: 'f4', kind: 'direct' })
expect(res.status).toBe(201)
const body = (await res.json()) as Record<string, unknown>
expect(body.kind).toBe('direct')
expect((body.urls as Record<string, string>).direct).toMatch(/^\/r\/ds_/)
expect((body.urls as Record<string, string>).landing).toBeUndefined()
})
it('returns 400 with DIRECT_NO_FOLDER when creating direct share for a folder [spec: shares/direct-no-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: 'fo1', name: 'My Folder' })
const res = await createShare(app, headers, { matterId: 'fo1', kind: 'direct' })
expect(res.status).toBe(400)
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
expect(body.error.details[0].reason).toBe('DIRECT_NO_FOLDER')
})
it('returns 400 with DIRECT_NO_PASSWORD when creating direct share with password [spec: shares/direct-no-password]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'f5', name: 'file.txt' })
const res = await createShare(app, headers, { matterId: 'f5', kind: 'direct', password: 'secret' })
expect(res.status).toBe(400)
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
expect(body.error.details[0].reason).toBe('DIRECT_NO_PASSWORD')
})
it('returns 404 when matterId does not belong to current org [spec: shares/create-cross-org]', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await createShare(app, headers, { matterId: 'nonexistent-matter', kind: 'landing' })
expect(res.status).toBe(404)
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
expect(body.error.details[0].reason).toBe('MATTER_NOT_FOUND')
})
it('sets expiresAt when provided in request [spec: shares/create-expiry]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'f6', name: 'expire.txt' })
const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
const res = await createShare(app, headers, {
matterId: 'f6',
kind: 'landing',
expiresAt: futureDate,
})
expect(res.status).toBe(201)
const body = (await res.json()) as Record<string, unknown>
expect(body.expiresAt).not.toBeNull()
})
it('sets downloadLimit when provided in request [spec: shares/create-download-limit]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'f7', name: 'limited.txt' })
const res = await createShare(app, headers, {
matterId: 'f7',
kind: 'landing',
downloadLimit: 5,
})
expect(res.status).toBe(201)
const body = (await res.json()) as Record<string, unknown>
expect(body.downloadLimit).toBe(5)
})
it('returns 400 with DIRECT_NO_RECIPIENTS when creating direct share with recipients [spec: shares/direct-no-recipients]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const matterId = nanoid()
await insertFile(db, orgId, { id: matterId, name: 'file.txt' })
const res = await createShare(app, headers, {
matterId,
kind: 'direct',
recipients: [{ recipientEmail: 'someone@example.com' }],
})
expect(res.status).toBe(400)
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
expect(body.error.details[0].reason).toBe('DIRECT_NO_RECIPIENTS')
})
it('returns 500 when createShare throws an unexpected error', async () => {
const { app, db, deps } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const matterId = nanoid()
await insertFile(db, orgId, { id: matterId, name: 'file.txt' })
vi.spyOn(deps.share, 'create').mockRejectedValueOnce(new Error('unexpected db error'))
const res = await createShare(app, headers, { matterId, kind: 'landing' })
expect(res.status).toBe(500)
})
it('returns 201 even when dispatchShareCreated rejects [spec: shares/create-notify-best-effort]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const matterId = nanoid()
await insertFile(db, orgId, { id: matterId, name: 'file.txt' })
const notifService = await import('../usecases/share.js')
vi.spyOn(notifService, 'dispatchShareCreated').mockRejectedValueOnce(new Error('dispatch failed'))
const res = await createShare(app, headers, {
matterId,
kind: 'landing',
recipients: [{ recipientEmail: 'someone@example.com' }],
})
expect(res.status).toBe(201)
})
})
// ─── GET /api/shares auth guard ───────────────────────────────────────────────
describe('GET /api/shares (auth guard)', () => {
it('returns 401 without auth', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/shares')
expect(res.status).toBe(401)
})
})
// ─── GET /api/shares ──────────────────────────────────────────────────────────
describe('GET /api/shares', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('returns empty list for a new user with no shares [spec: shares/list-empty]', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/shares', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as { items: unknown[]; total: number; page: number; pageSize: number }
expect(body.items).toHaveLength(0)
expect(body.total).toBe(0)
})
it('returns shares with pagination fields in response [spec: shares/list-pagination]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'pg1', name: 'a.txt' })
await insertFile(db, orgId, { id: 'pg2', name: 'b.txt' })
await createShare(app, headers, { matterId: 'pg1', kind: 'landing' })
await createShare(app, headers, { matterId: 'pg2', kind: 'landing' })
const res = await app.request('/api/shares?page=1&pageSize=1', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as { items: unknown[]; total: number; page: number; pageSize: number }
expect(body.page).toBe(1)
expect(body.pageSize).toBe(1)
expect(body.items).toHaveLength(1)
expect(body.total).toBe(2)
})
it('does not return shares belonging to another user [spec: shares/list-isolation]', async () => {
const { app, db } = await createTestApp()
await insertStorage(db)
// User A creates a share
const headersA = await authedHeaders(app, `a-${nanoid()}@example.com`)
const orgIdA = await getOrgId(db)
await insertFile(db, orgIdA, { id: 'oa1', name: 'only-a.txt' })
await createShare(app, headersA, { matterId: 'oa1', kind: 'landing' })
// User B lists their shares
const headersB = await authedHeaders(app, `b-${nanoid()}@example.com`)
const res = await app.request('/api/shares', { headers: headersB })
expect(res.status).toBe(200)
const body = (await res.json()) as { items: unknown[]; total: number }
expect(body.total).toBe(0)
expect(body.items).toHaveLength(0)
})
it('each list item has matter and recipientCount fields', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'rm1', name: 'has-recipients.txt' })
await createShare(app, headers, {
matterId: 'rm1',
kind: 'landing',
recipients: [{ recipientEmail: 'x@example.com' }],
})
const res = await app.request('/api/shares', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as { items: Array<Record<string, unknown>> }
const item = body.items[0]
expect(item).toHaveProperty('matter')
const matter = item.matter as Record<string, unknown>
expect(typeof matter.name).toBe('string')
expect(typeof matter.type).toBe('string')
expect(typeof matter.dirtype).toBe('number')
expect(typeof item.recipientCount).toBe('number')
expect(item.recipientCount).toBe(1)
})
it('filters shares by status=active [spec: shares/list-filter-status]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'st1', name: 'active.txt' })
await insertFile(db, orgId, { id: 'st2', name: 'revoked.txt' })
await createShare(app, headers, { matterId: 'st1', kind: 'landing' })
const res2 = await createShare(app, headers, { matterId: 'st2', kind: 'landing' })
const body2 = (await res2.json()) as Record<string, unknown>
// Revoke the second share
await revokeRequest(app, body2.token as string, headers)
const resActive = await app.request('/api/shares?status=active', { headers })
const activeBody = (await resActive.json()) as { items: unknown[]; total: number }
expect(activeBody.total).toBe(1)
const resRevoked = await app.request('/api/shares?status=revoked', { headers })
const revokedBody = (await resRevoked.json()) as { items: unknown[]; total: number }
expect(revokedBody.total).toBe(1)
})
})
// ─── GET /api/shares/:token (creator vs visitor views) ───────────────────────
describe('GET /api/shares/:token', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('returns full detail including recipients when viewer is the creator [spec: shares/detail-creator]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'gd1', name: 'get-detail.txt' })
const createRes = await createShare(app, headers, {
matterId: 'gd1',
kind: 'landing',
recipients: [{ recipientEmail: 'r@example.com' }],
})
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const res = await app.request(`/api/shares/${token}`, { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.token).toBe(token)
expect(Array.isArray(body.recipients)).toBe(true)
expect((body.recipients as unknown[]).length).toBe(1)
const matter = body.matter as Record<string, unknown>
expect(matter.name).toBe('get-detail.txt')
expect(typeof body.id).toBe('string')
expect(typeof body.orgId).toBe('string')
expect(typeof body.rootRef).toBe('string')
})
it('returns landing view without recipients or internal ids for non-creator [spec: shares/detail-non-creator]', async () => {
const { app, db } = await createTestApp()
await insertStorage(db)
const headersA = await authedHeaders(app, `ownerA-${nanoid()}@example.com`)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'ga1', name: 'owned-by-a.txt' })
const createRes = await createShare(app, headersA, { matterId: 'ga1', kind: 'landing' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const headersB = await authedHeaders(app, `otherB-${nanoid()}@example.com`)
const res = await app.request(`/api/shares/${token}`, { headers: headersB })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.token).toBe(token)
expect(body.recipients).toBeUndefined()
expect(body.id).toBeUndefined()
expect(body.orgId).toBeUndefined()
expect(body.matterId).toBeUndefined()
expect(body.creatorId).toBeUndefined()
})
it('returns 404 for a non-existent token [spec: shares/detail-not-found]', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/shares/does-not-exist')
expect(res.status).toBe(404)
})
it('returns 404 for direct share when viewer is not the creator', async () => {
const { app, db } = await createTestApp()
await insertStorage(db)
const headersA = await authedHeaders(app, `downer-${nanoid()}@example.com`)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'dgd1', name: 'direct.bin' })
const createRes = await createShare(app, headersA, { matterId: 'dgd1', kind: 'direct' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const res = await app.request(`/api/shares/${token}`)
expect(res.status).toBe(404)
})
it('returns direct share detail with creator-only fields when viewer is the creator', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'dgd2', name: 'direct2.bin' })
const createRes = await createShare(app, headers, { matterId: 'dgd2', kind: 'direct' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const res = await app.request(`/api/shares/${token}`, { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.kind).toBe('direct')
expect(typeof body.id).toBe('string')
expect(typeof body.orgId).toBe('string')
expect(typeof body.creatorId).toBe('string')
expect(typeof body.createdAt).toBe('string')
expect(Array.isArray(body.recipients)).toBe(true)
})
it('does not increment views when viewer is the creator [spec: shares/no-self-view-count]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'nv1', name: 'no-view-count.txt' })
const createRes = await createShare(app, headers, { matterId: 'nv1', kind: 'landing' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
await app.request(`/api/shares/${token}`, { headers })
await app.request(`/api/shares/${token}`, { headers })
const rows = await db.select({ views: shares.views }).from(shares).where(eq(shares.token, token))
expect(rows[0]?.views).toBe(0)
})
})
// ─── POST /api/shares/:token/objects (save-to-drive) ─────────────────────────
describe('POST /api/shares/:token/objects', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.spyOn(S3Service.prototype, 'copyObject').mockResolvedValue(undefined)
vi.spyOn(S3Service.prototype, 'streamCopy').mockResolvedValue(undefined)
})
it('returns 404 when share token does not exist', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const res = await app.request('/api/shares/nonexistent-token/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ targetOrgId: orgId, targetParent: '' }),
})
expect(res.status).toBe(404)
})
it('returns 400 with DIRECT_SAVE_FORBIDDEN for direct shares [spec: shares/save-direct-forbidden]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'sv-direct', name: 'direct-file.txt' })
const createRes = await createShare(app, headers, { matterId: 'sv-direct', kind: 'direct' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const res = await app.request(`/api/shares/${token}/objects`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ targetOrgId: orgId, targetParent: '' }),
})
expect(res.status).toBe(400)
const body = (await res.json()) as { error: { details: Array<{ reason: string }> } }
expect(body.error.details[0].reason).toBe('DIRECT_SAVE_FORBIDDEN')
})
it('returns 410 when the shared matter has been trashed [spec: shares/save-trashed-gone]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'sv-trashed', name: 'will-trash.txt' })
const createRes = await createShare(app, headers, { matterId: 'sv-trashed', kind: 'landing' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
await db.run(sql`UPDATE matters SET trashed_at = ${Date.now()} WHERE id = 'sv-trashed'`)
const res = await app.request(`/api/shares/${token}/objects`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ targetOrgId: orgId, targetParent: '' }),
})
expect(res.status).toBe(410)
})
it('saves a landing share file to personal drive and returns 201 [spec: shares/save-to-drive]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'sv-ok', name: 'shareable.txt' })
const createRes = await createShare(app, headers, { matterId: 'sv-ok', kind: 'landing' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const res = await app.request(`/api/shares/${token}/objects`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ targetOrgId: orgId, targetParent: '' }),
})
expect(res.status).toBe(201)
})
it('returns 401 without auth', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/shares/sometoken/objects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetOrgId: 'org1', targetParent: '' }),
})
expect(res.status).toBe(401)
})
it('returns 422 QUOTA_EXCEEDED when target org quota is exhausted [spec: shares/save-quota-exceeded]', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'sv-quota', name: 'big-file.txt' })
await db.run(sql`UPDATE org_quotas SET used = 1 WHERE org_id = ${orgId}`)
await setStoragePlanEntitlement(db, orgId, 1)
const createRes = await createShare(app, headers, { matterId: 'sv-quota', kind: 'landing' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const res = await app.request(`/api/shares/${token}/objects`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ targetOrgId: orgId, targetParent: '' }),
})
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('returns 403 when targetOrgId is not a personal org and user has no member role [spec: shares/save-target-permission]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'sv-forbidden', name: 'forbidden.txt' })
const createRes = await createShare(app, headers, { matterId: 'sv-forbidden', kind: 'landing' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const fakeTeamOrgId = `team-org-${nanoid()}`
await db.run(sql`
INSERT INTO organization (id, name, slug, metadata, created_at)
VALUES (${fakeTeamOrgId}, 'Team Org', ${fakeTeamOrgId}, '{"type":"team"}', ${Date.now()})
`)
const res = await app.request(`/api/shares/${token}/objects`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ targetOrgId: fakeTeamOrgId, targetParent: '' }),
})
expect(res.status).toBe(403)
})
it("returns 403 when targetOrgId is another user's personal org", 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)
await insertFile(db, orgId, { id: 'sv-victim', name: 'victim.txt' })
const victims = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = 'victim@example.com'`)
const victimOrgs = await db.all<{ id: string }>(
sql`SELECT id FROM organization WHERE slug = ${`personal-${victims[0].id}`}`,
)
const createRes = await createShare(app, headers, { matterId: 'sv-victim', kind: 'landing' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const res = await app.request(`/api/shares/${token}/objects`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ targetOrgId: victimOrgs[0].id, targetParent: '' }),
})
expect(res.status).toBe(403)
})
it('allows password-protected share save when the user is a listed recipient [spec: shares/save-recipient-bypass]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'sv-recip', name: 'recipient-file.txt' })
const emailB = `recip-user-b-${nanoid()}@example.com`
const signupRes = await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'User B', email: emailB, password: 'password123456' }),
})
const signupBody = (await signupRes.json()) as { user?: { id: string } }
const userBId = signupBody.user?.id ?? ''
const cookiesB = signupRes.headers.getSetCookie().join('; ')
const headersB = { Cookie: cookiesB }
const orgIdB = await getOrgId(db)
const createRes = await createShare(app, headers, {
matterId: 'sv-recip',
kind: 'landing',
password: 'secretPass123',
recipients: [{ recipientUserId: userBId }],
})
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const res = await app.request(`/api/shares/${token}/objects`, {
method: 'POST',
headers: { ...headersB, 'Content-Type': 'application/json' },
body: JSON.stringify({ targetOrgId: orgIdB, targetParent: '' }),
})
expect(res.status).not.toBe(401)
})
it('returns 401 for password-protected share when user is not a recipient and no cookie', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'sv-pw', name: 'protected.txt' })
const createRes = await createShare(app, headers, {
matterId: 'sv-pw',
kind: 'landing',
password: 'topSecret123',
})
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const headersB = await authedHeaders(app, `pw-user-b-${nanoid()}@example.com`)
const res = await app.request(`/api/shares/${token}/objects`, {
method: 'POST',
headers: { ...headersB, 'Content-Type': 'application/json' },
body: JSON.stringify({ targetOrgId: orgId, targetParent: '' }),
})
expect(res.status).toBe(401)
})
it('bypasses 401 for password-protected share when non-recipient has valid sharetk cookie [spec: shares/save-cookie-bypass]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'sv-cookie-auth', name: 'cookie-auth.txt' })
const createRes = await createShare(app, headers, {
matterId: 'sv-cookie-auth',
kind: 'landing',
password: 'cookiePass123',
})
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const headersB = await authedHeaders(app, `cookie-norecip-${nanoid()}@example.com`)
const cookieHeader = `${headersB.Cookie}; sharetk_${token}=ok`
const res = await app.request(`/api/shares/${token}/objects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookieHeader },
body: JSON.stringify({ targetOrgId: orgId, targetParent: '' }),
})
expect(res.status).not.toBe(401)
})
it('still rejects password-protected save when cookie has wrong value', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'sv-bad-cookie', name: 'bad-cookie.txt' })
const createRes = await createShare(app, headers, {
matterId: 'sv-bad-cookie',
kind: 'landing',
password: 'cookiePass123',
})
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const headersB = await authedHeaders(app, `cookie-bad-${nanoid()}@example.com`)
const cookieHeader = `${headersB.Cookie}; sharetk_${token}=anything-but-ok`
const res = await app.request(`/api/shares/${token}/objects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookieHeader },
body: JSON.stringify({ targetOrgId: orgId, targetParent: '' }),
})
expect(res.status).toBe(401)
})
it('returns 403 when user has a viewer role in the target org [spec: shares/save-viewer-forbidden]', async () => {
const { app, db } = await createTestApp()
const ownerHeaders = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'sv-viewer-role', name: 'viewer-role.txt' })
const createRes = await createShare(app, ownerHeaders, { matterId: 'sv-viewer-role', kind: 'landing' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const viewerEmail = `viewer-user-${nanoid()}@example.com`
const signupRes = await app.request('/api/auth/sign-up/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Viewer User', email: viewerEmail, password: 'password123456' }),
})
const signupBody = (await signupRes.json()) as { user?: { id: string } }
const viewerId = signupBody.user?.id ?? ''
const viewerHeaders = { Cookie: signupRes.headers.getSetCookie().join('; ') }
const teamOrgId = `viewer-team-${nanoid()}`
await db.run(sql`
INSERT INTO organization (id, name, slug, metadata, created_at)
VALUES (${teamOrgId}, 'Viewer Team', ${teamOrgId}, '{"type":"team"}', ${Date.now()})
`)
await db.run(sql`
INSERT INTO member (id, organization_id, user_id, role, created_at)
VALUES (${nanoid()}, ${teamOrgId}, ${viewerId}, 'viewer', ${Date.now()})
`)
const res = await app.request(`/api/shares/${token}/objects`, {
method: 'POST',
headers: { ...viewerHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({ targetOrgId: teamOrgId, targetParent: '' }),
})
expect(res.status).toBe(403)
})
})
// ─── PUT /api/shares/:token/status ───────────────────────────────────────────
const revokeRequest = (app: Awaited<ReturnType<typeof createTestApp>>['app'], token: string, headers: HeadersInit) =>
app.request(`/api/shares/${token}/status`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'revoked' }),
})
describe('PUT /api/shares/:token/status (auth guard)', () => {
it('returns 401 without auth', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/shares/some-token/status', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'revoked' }),
})
expect(res.status).toBe(401)
})
})
describe('PUT /api/shares/:token/status', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('creator can revoke their share, gets the revoked view, and DB status flips [spec: shares/revoke]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'del1', name: 'delete-me.txt' })
const createRes = await createShare(app, headers, { matterId: 'del1', kind: 'landing' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const res = await revokeRequest(app, token, headers)
expect(res.status).toBe(200)
const body = (await res.json()) as { token: string; status: string; id?: string }
expect(body.token).toBe(token)
expect(body.status).toBe('revoked')
expect(body.id).toBeTruthy()
const rows = await db.select({ status: shares.status }).from(shares).where(eq(shares.token, token))
expect(rows[0]?.status).toBe('revoked')
})
it('returns 403 when non-creator tries to revoke a share [spec: shares/revoke-non-creator]', async () => {
const { app, db } = await createTestApp()
await insertStorage(db)
const headersA = await authedHeaders(app, `del-owner-${nanoid()}@example.com`)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'del2', name: 'other-share.txt' })
const createRes = await createShare(app, headersA, { matterId: 'del2', kind: 'landing' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
const headersB = await authedHeaders(app, `del-other-${nanoid()}@example.com`)
const res = await revokeRequest(app, token, headersB)
expect(res.status).toBe(403)
})
it('returns 404 for non-existent share token', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await revokeRequest(app, 'does-not-exist', headers)
expect(res.status).toBe(404)
})
it('returns 404 when revoking an already-revoked share', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'del3', name: 'twice.txt' })
const createRes = await createShare(app, headers, { matterId: 'del3', kind: 'landing' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
expect((await revokeRequest(app, token, headers)).status).toBe(200)
expect((await revokeRequest(app, token, headers)).status).toBe(404)
})
it('creator can still revoke a share whose matter was trashed (not purged) [spec: shares/revoke-trashed-matter]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'del4', name: 'trashed-then-revoked.txt' })
const createRes = await createShare(app, headers, { matterId: 'del4', kind: 'landing' })
const token = ((await createRes.json()) as Record<string, unknown>).token as string
// Soft-delete the matter without purging it — the share row stays active.
await db.run(sql`UPDATE matters SET trashed_at = ${Date.now()} WHERE id = 'del4'`)
const res = await revokeRequest(app, token, headers)
expect(res.status).toBe(200)
const body = (await res.json()) as { token: string; status: string }
expect(body.token).toBe(token)
expect(body.status).toBe('revoked')
const rows = await db.select({ status: shares.status }).from(shares).where(eq(shares.token, token))
expect(rows[0]?.status).toBe('revoked')
})
})
describe('GET /api/shares?box=received', () => {
async function getUserId(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
}
it('lists shares addressed to the user by id and by email, hiding unrelated shares [spec: shares/received-list]', async () => {
const { app, db } = await createTestApp()
const creatorHeaders = await authedHeaders(app)
const recipientHeaders = await authedHeaders(app, 'recipient@example.com')
const bystanderHeaders = await authedHeaders(app, 'bystander@example.com')
await insertStorage(db)
const orgId = await getOrgId(db)
const recipientId = await getUserId(db, 'recipient@example.com')
await insertFile(db, orgId, { id: 'rcv-1', name: 'for-user.txt' })
await insertFile(db, orgId, { id: 'rcv-2', name: 'for-email.txt' })
await insertFile(db, orgId, { id: 'rcv-3', name: 'for-nobody.txt' })
const byUser = await createShare(app, creatorHeaders, {
matterId: 'rcv-1',
kind: 'landing',
recipients: [{ recipientUserId: recipientId }],
})
expect(byUser.status).toBe(201)
const byEmail = await createShare(app, creatorHeaders, {
matterId: 'rcv-2',
kind: 'landing',
recipients: [{ recipientEmail: 'recipient@example.com' }],
})
expect(byEmail.status).toBe(201)
const unrelated = await createShare(app, creatorHeaders, { matterId: 'rcv-3', kind: 'landing' })
expect(unrelated.status).toBe(201)
const res = await app.request('/api/shares?box=received', { headers: recipientHeaders })
expect(res.status).toBe(200)
const body = (await res.json()) as {
items: Array<{ matter: { name: string }; creatorName?: string }>
total: number
}
expect(body.total).toBe(2)
expect(body.items.map((item) => item.matter.name).sort()).toEqual(['for-email.txt', 'for-user.txt'])
expect(body.items[0].creatorName).toBeTruthy()
const bystander = await app.request('/api/shares?box=received', { headers: bystanderHeaders })
const bystanderBody = (await bystander.json()) as { total: number }
expect(bystanderBody.total).toBe(0)
})
it('excludes revoked shares from the received list [spec: shares/received-excludes-revoked]', async () => {
const { app, db } = await createTestApp()
const creatorHeaders = await authedHeaders(app)
const recipientHeaders = await authedHeaders(app, 'revoked-recipient@example.com')
await insertStorage(db)
const orgId = await getOrgId(db)
const recipientId = await getUserId(db, 'revoked-recipient@example.com')
await insertFile(db, orgId, { id: 'rcv-revoked', name: 'gone.txt' })
const created = await createShare(app, creatorHeaders, {
matterId: 'rcv-revoked',
kind: 'landing',
recipients: [{ recipientUserId: recipientId }],
})
const token = ((await created.json()) as Record<string, unknown>).token as string
const revoke = await revokeRequest(app, token, creatorHeaders)
expect(revoke.status).toBe(200)
const res = await app.request('/api/shares?box=received', { headers: recipientHeaders })
const body = (await res.json()) as { total: number }
expect(body.total).toBe(0)
})
})
// ─── Public (visitor-facing) share routes ────────────────────────────────────
// Wrapped so the visitor-focused fixtures (own STORAGE_ID, presignDownload mock,
// single-arg getUserId, larger insertFile/insertFolder) stay isolated from the
// creator-facing helpers above.
describe('Public share routes', () => {
const MOCK_PRESIGN_URL = 'https://presigned-download.example.com/file'
const STORAGE_ID = 'st-public-test'
beforeEach(() => {
vi.restoreAllMocks()
vi.spyOn(S3Service.prototype, 'presignDownload').mockResolvedValue(MOCK_PRESIGN_URL)
})
// ─── DB helpers ───────────────────────────────────────────────────────────────
async function insertStorage(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${STORAGE_ID}, 'Test S3', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1', 'AK', 'SK', '', '', 0, 0, 'active', ${now}, ${now})
`)
}
async function setTrafficPlanEntitlement(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
orgId: string,
bytes: number,
) {
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
(${`test-traffic-plan-${now}`}, ${orgId}, 'traffic', 'plan', 'test', ${`test-traffic-plan:${orgId}:${now}`}, ${bytes}, ${now}, NULL, 'active', '{"packageName":"Test Plan"}', ${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 getUserId(db: Awaited<ReturnType<typeof createTestApp>>['db']): Promise<string> {
const rows = await db.all<{ id: string }>(sql`SELECT id FROM user LIMIT 1`)
return rows[0].id
}
async function insertFile(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
orgId: string,
opts: { id: string; name: string; parent?: string; status?: 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}, 'text/plain', 1024, 0, ${opts.parent ?? ''}, 'some/key.txt', ${STORAGE_ID}, ${opts.status ?? 'active'}, ${opts.trashedAt ?? null}, ${now}, ${now})
`)
}
async function insertFolder(
db: Awaited<ReturnType<typeof createTestApp>>['db'],
orgId: string,
opts: { id: string; name: string; parent?: string; status?: 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 ?? ''}, '', ${STORAGE_ID}, ${opts.status ?? 'active'}, ${opts.trashedAt ?? null}, ${now}, ${now})
`)
}
async function fetchRootRef(app: Awaited<ReturnType<typeof createTestApp>>['app'], token: string): Promise<string> {
const res = await app.request(`/api/shares/${token}`)
const body = (await res.json()) as { rootRef: string }
return body.rootRef
}
// ─── GET /api/shares/:token ───────────────────────────────────────────────────
describe('GET /api/shares/:token', () => {
it('returns 404 for unknown token', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/shares/unknown-token')
expect(res.status).toBe(404)
})
it('returns share metadata for visitor and strips internal ids', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'f1', name: 'photo.jpg' })
const share = await createShareRepo(db).create({ matterId: 'f1', orgId, creatorId, kind: 'landing' })
// Call without auth headers to simulate a public visitor
void headers
const res = await app.request(`/api/shares/${share.token}`)
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.kind).toBe('landing')
const matter = body.matter as Record<string, unknown>
expect(matter.name).toBe('photo.jpg')
expect(matter.type).toBe('text/plain')
expect(matter.isFolder).toBe(false)
expect(body.requiresPassword).toBe(false)
expect(body.expired).toBe(false)
expect(body.exhausted).toBe(false)
expect(body.accessibleByUser).toBe(false)
expect(typeof body.rootRef).toBe('string')
// Non-creator must not see internal ids
expect(body.matterId).toBeUndefined()
expect(body.orgId).toBeUndefined()
expect(body.recipients).toBeUndefined()
expect(body.id).toBeUndefined()
})
it('increments views for a public visitor', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'f1v', name: 'visit-count.txt' })
const share = await createShareRepo(db).create({ matterId: 'f1v', orgId, creatorId, kind: 'landing' })
const res = await app.request(`/api/shares/${share.token}`)
expect(res.status).toBe(200)
const rows = await db.all<{ views: number }>(sql`SELECT views FROM shares WHERE id = ${share.id}`)
expect(rows[0]?.views).toBe(1)
})
it('deduplicates repeated visitor view requests within a short window', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'f1vd', name: 'visit-dedupe.txt' })
const share = await createShareRepo(db).create({ matterId: 'f1vd', orgId, creatorId, kind: 'landing' })
const first = await app.request(`/api/shares/${share.token}`)
expect(first.status).toBe(200)
const viewCookie = first.headers.get('set-cookie')
expect(viewCookie).toContain(`sharevw_${share.token}=seen`)
const second = await app.request(`/api/shares/${share.token}`, {
headers: viewCookie ? { Cookie: viewCookie } : undefined,
})
expect(second.status).toBe(200)
const rows = await db.all<{ views: number }>(sql`SELECT views FROM shares WHERE id = ${share.id}`)
expect(rows[0]?.views).toBe(1)
})
it('returns 404 for direct share kind (visitor)', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'f2', name: 'direct.txt' })
const share = await createShareRepo(db).create({ matterId: 'f2', orgId, creatorId, kind: 'direct' })
const res = await app.request(`/api/shares/${share.token}`)
expect(res.status).toBe(404)
})
it('returns 410 for trashed matter', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'f3', name: 'gone.txt', trashedAt: Date.now() })
const now = Date.now()
await db.run(sql`
INSERT INTO shares (id, token, kind, matter_id, org_id, creator_id, views, downloads, status, created_at)
VALUES ('sh-trash', 'token-trash', 'landing', 'f3', ${orgId}, ${creatorId}, 0, 0, 'active', ${now})
`)
const res = await app.request('/api/shares/token-trash')
expect(res.status).toBe(410)
})
it('returns 404 for revoked share', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'f4', name: 'revoked.txt' })
const share = await createShareRepo(db).create({ matterId: 'f4', orgId, creatorId, kind: 'landing' })
await db.run(sql`UPDATE shares SET status = 'revoked' WHERE id = ${share.id}`)
const res = await app.request(`/api/shares/${share.token}`)
expect(res.status).toBe(404)
})
it('returns requiresPassword=true when password set and no cookie', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'f5', name: 'secret.txt' })
const share = await createShareRepo(db).create({
matterId: 'f5',
orgId,
creatorId,
kind: 'landing',
password: 'hunter2',
})
const res = await app.request(`/api/shares/${share.token}`)
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.requiresPassword).toBe(true)
})
it('returns exhausted=true when download limit reached', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'f6', name: 'limited.txt' })
const share = await createShareRepo(db).create({
matterId: 'f6',
orgId,
creatorId,
kind: 'landing',
downloadLimit: 3,
})
await db.run(sql`UPDATE shares SET downloads = 3 WHERE id = ${share.id}`)
const res = await app.request(`/api/shares/${share.token}`)
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.exhausted).toBe(true)
})
})
// ─── POST /api/shares/:token/sessions ────────────────────────────────────────
describe('POST /api/shares/:token/sessions', () => {
it('returns 200 and sets cookie on correct password', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'vf1', name: 'vault.txt' })
const share = await createShareRepo(db).create({
matterId: 'vf1',
orgId,
creatorId,
kind: 'landing',
password: 'correcthorse',
})
const res = await app.request(`/api/shares/${share.token}/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: 'correcthorse' }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ ok: true })
const cookieHeader = res.headers.get('set-cookie')
expect(cookieHeader).toContain(`sharetk_${share.token}=ok`)
expect(cookieHeader).toContain('HttpOnly')
})
it('returns 403 on wrong password', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'vf2', name: 'vault2.txt' })
const share = await createShareRepo(db).create({
matterId: 'vf2',
orgId,
creatorId,
kind: 'landing',
password: 'correcthorse',
})
const res = await app.request(`/api/shares/${share.token}/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: 'wrongpassword' }),
})
expect(res.status).toBe(403)
const body = (await res.json()) as { error: { message: string; status: string } }
expect(body.error.message).toBe('Invalid password')
expect(body.error.status).toBe('PERMISSION_DENIED')
})
it('returns 404 when verifying a password for an unknown token', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/shares/no-such-token/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: 'whatever' }),
})
expect(res.status).toBe(404)
const body = (await res.json()) as { error: { message: string; status: string } }
expect(body.error.message).toBe('Share not found or revoked')
expect(body.error.status).toBe('NOT_FOUND')
})
it('returns 404 when verifying a password for a direct (non-landing) share', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'vf3', name: 'direct-verify.bin' })
const share = await createShareRepo(db).create({ matterId: 'vf3', orgId, creatorId, kind: 'direct' })
const res = await app.request(`/api/shares/${share.token}/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: 'whatever' }),
})
expect(res.status).toBe(404)
const body = (await res.json()) as { error: { message: string } }
expect(body.error.message).toBe('Share not found or revoked')
})
})
// ─── GET /api/shares/:token/objects/:ref — root file download ────────────────
describe('GET /api/shares/:token/objects/:ref — root file', () => {
it('returns 302 redirect with no-store cache header for public share', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'dl1', name: 'file.txt' })
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
SET traffic_quota = 2048, traffic_used = 256, traffic_period = ${trafficPeriod}
WHERE org_id = ${orgId}
`)
const share = await createShareRepo(db).create({ matterId: 'dl1', orgId, creatorId, kind: 'landing' })
const rootRef = await fetchRootRef(app, share.token)
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}`, { redirect: 'manual' })
expect(res.status).toBe(302)
expect(res.headers.get('location')).toBe(MOCK_PRESIGN_URL)
expect(res.headers.get('cache-control')).toBe('no-store')
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(1280)
})
it('returns JSON downloadUrl for public share when requested by preview', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'dl-url1', name: 'report.docx' })
const share = await createShareRepo(db).create({ matterId: 'dl-url1', orgId, creatorId, kind: 'landing' })
const rootRef = await fetchRootRef(app, share.token)
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}?downloadUrl=1`, {
redirect: 'manual',
})
expect(res.status).toBe(200)
expect(res.headers.get('cache-control')).toBe('no-store')
await expect(res.json()).resolves.toEqual({ downloadUrl: MOCK_PRESIGN_URL })
})
it('consumes traffic quota when public share download URL is issued', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'dl-traffic-ok', name: 'traffic.txt' })
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
SET traffic_quota = 2048, traffic_used = 256, traffic_period = ${trafficPeriod}
WHERE org_id = ${orgId}
`)
const share = await createShareRepo(db).create({ matterId: 'dl-traffic-ok', orgId, creatorId, kind: 'landing' })
const rootRef = await fetchRootRef(app, share.token)
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}?downloadUrl=1`, {
redirect: 'manual',
})
expect(res.status).toBe(200)
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(1280)
})
it('refunds traffic and download count when public share signing fails', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'dl-sign-fail', name: 'traffic.txt' })
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
SET traffic_quota = 2048, traffic_used = 256, traffic_period = ${trafficPeriod}
WHERE org_id = ${orgId}
`)
vi.mocked(S3Service.prototype.presignDownload).mockRejectedValueOnce(new Error('sign failed'))
const share = await createShareRepo(db).create({ matterId: 'dl-sign-fail', orgId, creatorId, kind: 'landing' })
const rootRef = await fetchRootRef(app, share.token)
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}?downloadUrl=1`, {
redirect: 'manual',
})
expect(res.status).toBe(500)
const trafficRows = await db.all<{ trafficUsed: number }>(
sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${orgId}`,
)
expect(trafficRows[0].trafficUsed).toBe(256)
const shareRows = await db.all<{ downloads: number }>(sql`SELECT downloads FROM shares WHERE id = ${share.id}`)
expect(shareRows[0].downloads).toBe(0)
})
it('returns 422 when public share traffic quota is exhausted', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'dl-traffic', name: 'traffic.txt' })
const trafficPeriod = currentTrafficPeriod()
await db.run(sql`
UPDATE org_quotas
SET traffic_quota = 0, traffic_used = 0, traffic_period = ${trafficPeriod}
WHERE org_id = ${orgId}
`)
await setTrafficPlanEntitlement(db, orgId, 512)
const share = await createShareRepo(db).create({
matterId: 'dl-traffic',
orgId,
creatorId,
kind: 'landing',
downloadLimit: 1,
})
const rootRef = await fetchRootRef(app, share.token)
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}?downloadUrl=1`, {
redirect: 'manual',
})
expect(res.status).toBe(422)
const quotaBody = (await res.json()) as { error: { message: string; details: Array<{ reason: string }> } }
expect(quotaBody.error.message).toBe('Traffic quota exceeded')
expect(quotaBody.error.details[0].reason).toBe('QUOTA_EXCEEDED')
expect(S3Service.prototype.presignDownload).not.toHaveBeenCalled()
const shareRows = await db.all<{ downloads: number }>(sql`SELECT downloads FROM shares WHERE id = ${share.id}`)
expect(shareRows[0].downloads).toBe(0)
})
it('returns 401 when password required and no cookie', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'dl2', name: 'secret.txt' })
const share = await createShareRepo(db).create({
matterId: 'dl2',
orgId,
creatorId,
kind: 'landing',
password: 'secret123',
})
const rootRef = await fetchRootRef(app, share.token)
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}`, { redirect: 'manual' })
expect(res.status).toBe(401)
})
it('returns 302 when password set and valid cookie provided', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'dl3', name: 'guarded.txt' })
const share = await createShareRepo(db).create({
matterId: 'dl3',
orgId,
creatorId,
kind: 'landing',
password: 'secret123',
})
const rootRef = await fetchRootRef(app, share.token)
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}`, {
redirect: 'manual',
headers: { Cookie: `sharetk_${share.token}=ok` },
})
expect(res.status).toBe(302)
})
it('returns 302 for logged-in recipient without cookie (免密)', async () => {
const { app, db } = await createTestApp()
const userHeaders = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const userId = await getUserId(db)
await insertFile(db, orgId, { id: 'dl4', name: 'recipient.txt' })
const share = await createShareRepo(db).create({
matterId: 'dl4',
orgId,
creatorId: userId,
kind: 'landing',
password: 'secret456',
recipients: [{ recipientUserId: userId }],
})
const rootRef = await fetchRootRef(app, share.token)
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}`, {
redirect: 'manual',
headers: userHeaders,
})
expect(res.status).toBe(302)
})
it('returns 410 when download limit exhausted; counter not incremented past limit', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'dl5', name: 'limited.txt' })
const share = await createShareRepo(db).create({
matterId: 'dl5',
orgId,
creatorId,
kind: 'landing',
downloadLimit: 2,
})
await db.run(sql`UPDATE shares SET downloads = 2 WHERE id = ${share.id}`)
const rootRef = await fetchRootRef(app, share.token)
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}`, { redirect: 'manual' })
expect(res.status).toBe(410)
const rows = await db.all<{ downloads: number }>(sql`SELECT downloads FROM shares WHERE id = ${share.id}`)
expect(rows[0].downloads).toBe(2)
})
it('returns 410 when share is expired', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'dl6', name: 'expired.txt' })
const pastDate = new Date(Date.now() - 1000)
const share = await createShareRepo(db).create({
matterId: 'dl6',
orgId,
creatorId,
kind: 'landing',
expiresAt: pastDate,
})
const rootRef = await fetchRootRef(app, share.token)
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}`, { redirect: 'manual' })
expect(res.status).toBe(410)
const body = (await res.json()) as { error: { message: string } }
expect(body.error.message).toBe('Share has expired')
})
it('returns 410 with AIP-193 body when the shared matter is trashed', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'dl-trash', name: 'gone.txt' })
// The matter is reachable (status active) when the share is created, then
// gets trashed — resolveByToken returns matter_trashed for the download.
const share = await createShareRepo(db).create({ matterId: 'dl-trash', orgId, creatorId, kind: 'landing' })
const rootRef = await fetchRootRef(app, share.token)
await db.run(sql`UPDATE matters SET trashed_at = ${Date.now()} WHERE id = 'dl-trash'`)
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}`, { redirect: 'manual' })
expect(res.status).toBe(410)
const body = (await res.json()) as { error: { code: number; message: string; status: string } }
expect(body.error.code).toBe(410)
expect(body.error.message).toBe('File no longer available')
expect(body.error.status).toBe('NOT_FOUND')
})
it('returns 400 when downloading a folder share root ref directly', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFolder(db, orgId, { id: 'dl-folder', name: 'A Folder' })
const share = await createShareRepo(db).create({ matterId: 'dl-folder', orgId, creatorId, kind: 'landing' })
const rootRef = await fetchRootRef(app, share.token)
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}`, { redirect: 'manual' })
expect(res.status).toBe(400)
const body = (await res.json()) as { error: { message: string; status: string } }
expect(body.error.message).toBe('Cannot download a folder directly')
expect(body.error.status).toBe('INVALID_ARGUMENT')
})
it('returns 404 when the shared file references a missing storage', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
// No storage row inserted — the matter points at a storage_id that does
// not exist, so storage lookup fails after the access gates pass.
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
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 ('dl-no-storage', ${orgId}, 'dl-no-storage-alias', 'orphan.txt', 'text/plain', 1024, 0, '', 'some/key.txt', 'st-missing', 'active', ${now}, ${now})
`)
const share = await createShareRepo(db).create({ matterId: 'dl-no-storage', orgId, creatorId, kind: 'landing' })
const rootRef = await fetchRootRef(app, share.token)
const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}`, { redirect: 'manual' })
expect(res.status).toBe(404)
const body = (await res.json()) as { error: { message: string; status: string } }
expect(body.error.message).toBe('Storage not found')
expect(body.error.status).toBe('NOT_FOUND')
})
})
// ─── GET /api/shares/:token/objects — list folder children ───────────────────
describe('GET /api/shares/:token/objects', () => {
it('returns 404 for invalid token', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/shares/no-such-token/objects')
expect(res.status).toBe(404)
})
it('returns 410 for trashed matter', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFolder(db, orgId, { id: 'trashed-dir', name: 'Gone', trashedAt: Date.now() })
const now = Date.now()
await db.run(sql`
INSERT INTO shares (id, token, kind, matter_id, org_id, creator_id, views, downloads, status, created_at)
VALUES ('sh-trash-ch', 'token-trash-ch', 'landing', 'trashed-dir', ${orgId}, ${creatorId}, 0, 0, 'active', ${now})
`)
const res = await app.request('/api/shares/token-trash-ch/objects')
expect(res.status).toBe(410)
})
it('returns 400 for non-folder share', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'ch1', name: 'flat.txt' })
const share = await createShareRepo(db).create({ matterId: 'ch1', orgId, creatorId, kind: 'landing' })
const res = await app.request(`/api/shares/${share.token}/objects`)
expect(res.status).toBe(400)
const body = (await res.json()) as { error: { message: string } }
expect(body.error.message).toBe('Not a folder share')
})
it('returns 410 with AIP-193 body when listing objects of an expired folder share', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFolder(db, orgId, { id: 'ch-expired', name: 'Expired Folder' })
const share = await createShareRepo(db).create({
matterId: 'ch-expired',
orgId,
creatorId,
kind: 'landing',
expiresAt: new Date(Date.now() - 1000),
})
const res = await app.request(`/api/shares/${share.token}/objects`)
expect(res.status).toBe(410)
const body = (await res.json()) as { error: { code: number; message: string; status: string } }
expect(body.error.code).toBe(410)
expect(body.error.message).toBe('Share has expired')
expect(body.error.status).toBe('NOT_FOUND')
})
it('returns items and breadcrumb for folder share', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFolder(db, orgId, { id: 'dir1', name: 'Photos' })
await insertFile(db, orgId, { id: 'img1', name: 'cat.jpg', parent: 'Photos' })
await insertFolder(db, orgId, { id: 'dir2', name: 'vacation', parent: 'Photos' })
const share = await createShareRepo(db).create({ matterId: 'dir1', orgId, creatorId, kind: 'landing' })
const res = await app.request(`/api/shares/${share.token}/objects`)
expect(res.status).toBe(200)
const body = (await res.json()) as {
items: Array<Record<string, unknown>>
breadcrumb: Array<Record<string, unknown>>
}
expect(body.items).toHaveLength(2)
expect(body.breadcrumb).toEqual([{ name: 'Photos', path: '' }])
const names = body.items.map((i) => i.name)
expect(names).toContain('cat.jpg')
expect(names).toContain('vacation')
// Each item exposes an opaque ref, not the raw matter id
expect(body.items.every((i) => typeof i.ref === 'string' && i.ref !== 'img1' && i.ref !== 'dir2')).toBe(true)
})
it('returns subfolder contents with breadcrumb when parent= provided', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFolder(db, orgId, { id: 'root1', name: 'Docs' })
await insertFolder(db, orgId, { id: 'sub1', name: 'Reports', parent: 'Docs' })
await insertFile(db, orgId, { id: 'rpt1', name: 'q1.pdf', parent: 'Docs/Reports' })
const share = await createShareRepo(db).create({ matterId: 'root1', orgId, creatorId, kind: 'landing' })
const res = await app.request(`/api/shares/${share.token}/objects?parent=Reports`)
expect(res.status).toBe(200)
const body = (await res.json()) as {
items: Array<Record<string, unknown>>
breadcrumb: Array<Record<string, unknown>>
}
expect(body.items).toHaveLength(1)
expect(body.items[0].name).toBe('q1.pdf')
expect(body.breadcrumb).toEqual([
{ name: 'Docs', path: '' },
{ name: 'Reports', path: 'Reports' },
])
})
it('returns 401 for password-protected share without cookie', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFolder(db, orgId, { id: 'locked1', name: 'Private' })
const share = await createShareRepo(db).create({
matterId: 'locked1',
orgId,
creatorId,
kind: 'landing',
password: 'pass123',
})
const res = await app.request(`/api/shares/${share.token}/objects`)
expect(res.status).toBe(401)
})
it('returns 400 when parent contains traversal segments', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFolder(db, orgId, { id: 'traversal-dir', name: 'Safe' })
const share = await createShareRepo(db).create({ matterId: 'traversal-dir', orgId, creatorId, kind: 'landing' })
const res = await app.request(`/api/shares/${share.token}/objects?parent=../etc`)
expect(res.status).toBe(400)
const body = (await res.json()) as { error: { message: string } }
expect(body.error.message).toBe('Invalid path')
})
it('respects explicit page and pageSize query params', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFolder(db, orgId, { id: 'pg-dir', name: 'Paged' })
const share = await createShareRepo(db).create({ matterId: 'pg-dir', orgId, creatorId, kind: 'landing' })
const res = await app.request(`/api/shares/${share.token}/objects?page=2&pageSize=10`)
expect(res.status).toBe(200)
const body = (await res.json()) as { page: number; pageSize: number }
expect(body.page).toBe(2)
expect(body.pageSize).toBe(10)
})
it('falls back to defaults when page/pageSize are non-numeric', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFolder(db, orgId, { id: 'nan-dir', name: 'NaN' })
const share = await createShareRepo(db).create({ matterId: 'nan-dir', orgId, creatorId, kind: 'landing' })
const res = await app.request(`/api/shares/${share.token}/objects?page=abc&pageSize=xyz`)
expect(res.status).toBe(200)
const body = (await res.json()) as { page: number; pageSize: number }
expect(body.page).toBe(1)
expect(body.pageSize).toBe(50)
})
})
// ─── GET /api/shares/:token/objects/:ref — descendant ref ────────────────────
describe('GET /api/shares/:token/objects/:ref — descendant', () => {
it('redirects for valid descendant ref', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFolder(db, orgId, { id: 'fld1', name: 'Archive' })
await insertFile(db, orgId, { id: 'arc1', name: 'old.zip', parent: 'Archive' })
const share = await createShareRepo(db).create({ matterId: 'fld1', orgId, creatorId, kind: 'landing' })
const childrenRes = await app.request(`/api/shares/${share.token}/objects`)
const childrenBody = (await childrenRes.json()) as { items: Array<{ ref: string }> }
const ref = childrenBody.items[0].ref
const res = await app.request(`/api/shares/${share.token}/objects/${ref}`, { redirect: 'manual' })
expect(res.status).toBe(302)
expect(res.headers.get('location')).toBe(MOCK_PRESIGN_URL)
expect(res.headers.get('cache-control')).toBe('no-store')
})
it('returns 400 for invalid/forged ref', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFolder(db, orgId, { id: 'fld2', name: 'Safe' })
const share = await createShareRepo(db).create({ matterId: 'fld2', orgId, creatorId, kind: 'landing' })
const res = await app.request(`/api/shares/${share.token}/objects/invalid-ref`, { redirect: 'manual' })
expect(res.status).toBe(400)
})
it('returns 404 when ref decodes to a matter outside the folder subtree', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFolder(db, orgId, { id: 'fld3', name: 'Folder3' })
await insertFile(db, orgId, { id: 'out1', name: 'outside.txt' })
const share = await createShareRepo(db).create({ matterId: 'fld3', orgId, creatorId, kind: 'landing' })
const { createHmac } = await import('node:crypto')
const sig = createHmac('sha256', share.token).update('out1').digest('hex').slice(0, 16)
const fakeRef = Buffer.from(`out1.${sig}`).toString('base64url')
const res = await app.request(`/api/shares/${share.token}/objects/${fakeRef}`, { redirect: 'manual' })
expect(res.status).toBe(404)
})
})
// ─── GET /r/:token — unified redirect for direct shares (ds_) ────────────────
describe('GET /r/:token (ds_ direct shares)', () => {
it('returns 302 redirect for valid direct share', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'dlx1', name: 'direct.bin' })
const share = await createShareRepo(db).create({ matterId: 'dlx1', orgId, creatorId, kind: 'direct' })
const res = await app.request(`/r/${share.token}`, { redirect: 'manual' })
expect(res.status).toBe(302)
expect(res.headers.get('location')).toBe(MOCK_PRESIGN_URL)
expect(res.headers.get('cache-control')).toContain('no-store')
})
it('returns 404 for landing share token at /r/', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'dlx2', name: 'landing.txt' })
const share = await createShareRepo(db).create({ matterId: 'dlx2', orgId, creatorId, kind: 'landing' })
const res = await app.request(`/r/${share.token}`, { redirect: 'manual' })
expect(res.status).toBe(404)
})
it('returns 404 for unknown prefix token', async () => {
const { app } = await createTestApp()
const res = await app.request('/r/nosuchthing', { redirect: 'manual' })
expect(res.status).toBe(404)
})
it('returns 410 for trashed matter', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'dlx3', name: 'trashed.txt', trashedAt: Date.now() })
const now = Date.now()
await db.run(sql`
INSERT INTO shares (id, token, kind, matter_id, org_id, creator_id, views, downloads, status, created_at)
VALUES ('sh-dltrash', 'ds_token-dltrash', 'direct', 'dlx3', ${orgId}, ${creatorId}, 0, 0, 'active', ${now})
`)
const res = await app.request('/r/ds_token-dltrash', { redirect: 'manual' })
expect(res.status).toBe(410)
})
it('returns 410 when limit exhausted', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'dlx4', name: 'limited.bin' })
const share = await createShareRepo(db).create({
matterId: 'dlx4',
orgId,
creatorId,
kind: 'direct',
downloadLimit: 1,
})
await db.run(sql`UPDATE shares SET downloads = 1 WHERE id = ${share.id}`)
const res = await app.request(`/r/${share.token}`, { redirect: 'manual' })
expect(res.status).toBe(410)
})
it('does not require auth (verified: no auth header needed)', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'dlx5', name: 'public.bin' })
const share = await createShareRepo(db).create({ matterId: 'dlx5', orgId, creatorId, kind: 'direct' })
const res = await app.request(`/r/${share.token}`, { redirect: 'manual' })
expect(res.status).toBe(302)
})
})
// ─── No auth required on public share routes ─────────────────────────────────
describe('public routes require no auth', () => {
it('GET /api/shares/:token succeeds without any auth headers', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
const creatorId = await getUserId(db)
await insertFile(db, orgId, { id: 'pub1', name: 'open.txt' })
const share = await createShareRepo(db).create({ matterId: 'pub1', orgId, creatorId, kind: 'landing' })
const res = await app.request(`/api/shares/${share.token}`)
expect(res.status).toBe(200)
})
})
})