import { beforeEach, describe, expect, it, vi } from 'vitest' import { encodeChildRef } from '../http/share-utils' import { hashPassword } from '../lib/password' import type { Platform } from '../platform/interface' import { saveShareToDrive } from './object' import { AppError, type AuditRepo, CreateShareError, type Matter, type MatterListResult, type MatterRepo, type OrgRepo, type QuotaRepo, type S3Gateway, type ShareListItem, type ShareRecipientRecord, type ShareRecord, type ShareRepo, type ShareResolution, type StorageRecord, type StorageRepo, } from './ports' import { createShare, downloadShareObject, listShareObjects, listShares, readShareReadme, revokeShare, type ShareDeps, saveShare, setSharePrivacy, verifySharePassword, viewShare, } from './share' import { confirmDownloadTraffic, type DownloadTrafficOutcome, meterDownloadTraffic } from './store/traffic-metering' // The end-to-end metering (quota consume → cloud egress report → refund) is // covered by cloud-traffic-metering.test.ts; the recipient fan-out and the copy // engine by share-notification/save-to-drive tests. Here we replace those three // collaborators with mocks so each share case feeds a chosen outcome and we can // assert the share routes' gates, ordering, DTO shaping, and presign-rollback in // isolation. Everything else (types, pure helpers) stays real. vi.mock('./store/traffic-metering', async (importOriginal) => ({ ...(await importOriginal()), meterDownloadTraffic: vi.fn(), confirmDownloadTraffic: vi.fn(async () => {}), reverseDownloadTraffic: vi.fn(async (deps, params) => deps.quota.refundTraffic(params.orgId, params.bytes)), })) vi.mock('./object', () => ({ saveShareToDrive: vi.fn() })) const PRESIGNED_URL = 'https://presigned.example.com/file' const CLOUD_BASE_URL = 'https://cloud.example.com' const meterOk: DownloadTrafficOutcome = { ok: true } const meterQuotaExceeded: DownloadTrafficOutcome = { ok: false, reason: 'quota_exceeded' } const meterInsufficientCredits: DownloadTrafficOutcome = { ok: false, reason: 'insufficient_credits' } // Asserts a usecase returned a failure AppError carrying the given HTTP status, // wire reason, and message — the AIP-193 fields the http boundary renders. function expectError( out: { ok: true } | { ok: false; error: AppError }, httpStatus: number, reason: string | undefined, message: string, ) { expect(out.ok).toBe(false) const { error } = out as { ok: false; error: AppError } expect(error).toBeInstanceOf(AppError) expect(error.httpStatus).toBe(httpStatus) expect(error.meta.reason).toBe(reason) expect(error.message).toBe(message) } const meter = vi.mocked(meterDownloadTraffic) const confirmDownload = vi.mocked(confirmDownloadTraffic) const saveToDrive = vi.mocked(saveShareToDrive) const platform = {} as Platform const sampleStorage = { id: 'st-1', bucket: 'b', egressCreditBillingEnabled: false } as StorageRecord const fileMatter = { id: 'm-1', orgId: 'o-1', name: 'file.bin', type: 'application/octet-stream', size: 1024, dirtype: 0, // DirType.FILE parent: '', object: 'some/key.bin', storageId: 'st-1', } as Matter const folderMatter = { id: 'fld-1', orgId: 'o-1', name: 'docs', type: 'folder', size: 0, dirtype: 1, // not FILE parent: 'root', object: '', storageId: 'st-1', } as Matter const childMatter = { ...fileMatter, id: 'm-child', name: 'child.txt' } as Matter const landingShare = { id: 's-1', token: 'sk_token1', kind: 'landing', matterId: 'm-1', orgId: 'o-1', creatorId: 'creator-1', passwordHash: null, expiresAt: null, downloadLimit: null, views: 3, downloads: 2, status: 'active', createdAt: new Date('2024-01-01T00:00:00Z'), } as ShareRecord const okResolution = ( over: { share?: ShareRecord; matter?: Matter; recipients?: ShareRecipientRecord[] } = {}, ): ShareResolution => ({ status: 'ok', share: landingShare, matter: fileMatter, recipients: [], ...over }) const trashedResolution = ( over: { share?: ShareRecord; matter?: Matter; recipients?: ShareRecipientRecord[] } = {}, ): ShareResolution => ({ status: 'matter_trashed', share: landingShare, matter: fileMatter, recipients: [], ...over }) function makeShareRepo(over: Partial = {}): ShareRepo { return { resolveByToken: async () => okResolution(), incrementViews: async () => {}, hasDownloadsAvailable: async () => true, incrementDownloadsAtomic: async () => ({ ok: true, downloads: 3 }), decrementDownloads: async () => {}, revokeByToken: async () => true, listForApi: async () => ({ items: [], nextBoundary: null }), listReceivedForApi: async () => ({ items: [], nextBoundary: null }), computeSourceBytes: async () => 1024, listDirectActiveChildren: async () => [], hasQuotaForBytes: async () => true, getCreatorIdentity: async () => ({ name: 'Creator Name', username: 'creator' }), getUserEmail: async () => 'creator@example.com', getMatterName: async () => 'file.bin', findShareChildMatter: async () => childMatter, create: async () => landingShare, listRecipientUserIds: async () => [], revokeByMatter: async () => {}, ...over, } as ShareRepo } const emptyMatterList: MatterListResult = { items: [], nextBoundary: null } function makeDeps( over: { share?: Partial matter?: Partial storages?: Partial s3?: Partial quota?: Partial org?: Partial } = {}, ) { const record = vi.fn(async () => {}) const incrementViews = vi.fn(async () => {}) const decrementDownloads = vi.fn(async () => {}) const refundTraffic = vi.fn(async () => {}) const presignDownload = vi.fn(async () => PRESIGNED_URL) // dispatchShareCreated now lives in this module, so its call cannot be mocked // at the module boundary; instead its collaborator ports are spies and the // createShare tests assert the observable fan-out (notification + email). const createNotification = vi.fn(async () => ({}) as never) const sendEmail = vi.fn(async () => {}) const isEmailConfigured = vi.fn(async () => true) const deps = { share: makeShareRepo({ incrementViews, decrementDownloads, ...over.share }), matter: { list: async () => emptyMatterList, ...over.matter } as MatterRepo, storages: { get: async () => sampleStorage, ...over.storages } as StorageRepo, s3: { presignDownload, ...over.s3 } as S3Gateway, quota: { consumeTrafficIfQuotaAllows: async () => true, refundTraffic, ...over.quota } as QuotaRepo, org: { canWriteToOrg: async () => true, ...over.org } as OrgRepo, audit: { record } as unknown as AuditRepo, notifications: { create: createNotification } as unknown as ShareDeps['notifications'], email: { isConfigured: isEmailConfigured, send: sendEmail } as unknown as ShareDeps['email'], shareNotifications: { getUserEmail: async () => null } as unknown as ShareDeps['shareNotifications'], // Cloud-metering ports are unused here — they are mocked. licenseBinding: {} as ShareDeps['licenseBinding'], licensingCloud: {} as ShareDeps['licensingCloud'], cloudTrafficReports: {} as ShareDeps['cloudTrafficReports'], storageUsage: {} as ShareDeps['storageUsage'], } as ShareDeps return { deps, record, incrementViews, decrementDownloads, refundTraffic, presignDownload, createNotification, sendEmail, isEmailConfigured, } } // dispatchShareCreated is launched fire-and-forget (.catch) by createShare; // flush pending microtasks so its fan-out has run before asserting. const flushDispatch = () => new Promise((resolve) => setImmediate(resolve)) beforeEach(() => { vi.clearAllMocks() meter.mockResolvedValue(meterOk) saveToDrive.mockResolvedValue({ saved: [], skipped: [] }) }) // ─── viewShare ─────────────────────────────────────────────────────────────── describe('viewShare', () => { it('returns matter_trashed when the matter is trashed', async () => { const { deps } = makeDeps({ share: { resolveByToken: async () => trashedResolution() } }) expectError( await viewShare(deps, { token: 't', viewerId: null, viewCookie: undefined, accessCookie: undefined }), 410, undefined, 'File no longer available', ) }) it('returns not_found for revoked/missing shares', async () => { const { deps } = makeDeps({ share: { resolveByToken: async () => ({ status: 'revoked' }) } }) expectError( await viewShare(deps, { token: 't', viewerId: null, viewCookie: undefined, accessCookie: undefined }), 404, undefined, 'Share not found or revoked', ) }) it('hides a direct share from a non-creator (not_found)', async () => { const direct = { ...landingShare, kind: 'direct' } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: direct }) } }) const out = await viewShare(deps, { token: 't', viewerId: 'someone', viewCookie: undefined, accessCookie: undefined, }) expectError(out, 404, undefined, 'Share not found or revoked') }) it('returns the viewer DTO and signals the view cookie for an anonymous viewer', async () => { const { deps, incrementViews } = makeDeps() const out = await viewShare(deps, { token: 'sk_token1', viewerId: null, viewCookie: undefined, accessCookie: undefined, }) expect(out.ok).toBe(true) if (!out.ok) throw new Error('expected ok') expect(out.setViewCookie).toBe(true) expect(incrementViews).toHaveBeenCalledWith('s-1') expect(out.dto).toMatchObject({ token: 'sk_token1', kind: 'landing', matter: { name: 'file.bin', isFolder: false }, creatorName: 'Creator Name', creatorUsername: 'creator', requiresPassword: false, expired: false, exhausted: false, accessibleByUser: false, rootRef: encodeChildRef('sk_token1', 'm-1'), }) expect('recipients' in out.dto).toBe(false) }) it('returns a null creator username for legacy users without one', async () => { const { deps } = makeDeps({ share: { getCreatorIdentity: async () => ({ name: 'Legacy User', username: null }) }, }) const out = await viewShare(deps, { token: 'sk_token1', viewerId: null, viewCookie: 'seen', accessCookie: undefined, }) if (!out.ok) throw new Error('expected ok') expect(out.dto.creatorName).toBe('Legacy User') expect(out.dto.creatorUsername).toBeNull() }) it('does not increment views (no cookie) when already seen', async () => { const { deps, incrementViews } = makeDeps() const out = await viewShare(deps, { token: 'sk_token1', viewerId: null, viewCookie: 'seen', accessCookie: undefined, }) if (!out.ok) throw new Error('expected ok') expect(out.setViewCookie).toBe(false) expect(incrementViews).not.toHaveBeenCalled() }) it('returns the richer creator DTO and never bumps views for the creator', async () => { const { deps, incrementViews } = makeDeps() const out = await viewShare(deps, { token: 'sk_token1', viewerId: 'creator-1', viewCookie: undefined, accessCookie: undefined, }) if (!out.ok) throw new Error('expected ok') expect(out.setViewCookie).toBe(false) expect(incrementViews).not.toHaveBeenCalled() expect(out.dto).toMatchObject({ id: 's-1', matterId: 'm-1', orgId: 'o-1', creatorId: 'creator-1', recipients: [], }) }) it('requiresPassword=true for a non-recipient viewer without the access cookie', async () => { const pw = { ...landingShare, passwordHash: 'hash' } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: pw }) } }) const out = await viewShare(deps, { token: 'sk_token1', viewerId: 'x', viewCookie: 'seen', accessCookie: undefined, }) if (!out.ok) throw new Error('expected ok') expect(out.dto.requiresPassword).toBe(true) }) it('requiresPassword=false once the access cookie is ok', async () => { const pw = { ...landingShare, passwordHash: 'hash' } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: pw }) } }) const out = await viewShare(deps, { token: 'sk_token1', viewerId: null, viewCookie: 'seen', accessCookie: 'ok' }) if (!out.ok) throw new Error('expected ok') expect(out.dto.requiresPassword).toBe(false) }) it('marks expired and exhausted from share state', async () => { const used = { ...landingShare, expiresAt: new Date('2000-01-01'), downloadLimit: 2, downloads: 2 } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: used }) } }) const out = await viewShare(deps, { token: 'sk_token1', viewerId: null, viewCookie: 'seen', accessCookie: undefined, now: new Date('2030-01-01'), }) if (!out.ok) throw new Error('expected ok') expect(out.dto.expired).toBe(true) expect(out.dto.exhausted).toBe(true) }) it('reports a folder matter as isFolder', async () => { const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ matter: folderMatter }) } }) const out = await viewShare(deps, { token: 'sk_token1', viewerId: null, viewCookie: 'seen', accessCookie: undefined, }) if (!out.ok) throw new Error('expected ok') expect(out.dto.matter.isFolder).toBe(true) }) }) // ─── verifySharePassword ───────────────────────────────────────────────────── describe('verifySharePassword', () => { it('returns not_found when the token does not resolve', async () => { const { deps } = makeDeps({ share: { resolveByToken: async () => ({ status: 'not_found' }) } }) expectError( await verifySharePassword(deps, { token: 't', password: 'x' }), 404, undefined, 'Share not found or revoked', ) }) it('returns not_found for a non-landing share', async () => { const direct = { ...landingShare, kind: 'direct', passwordHash: hashPassword('pw') } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: direct }) } }) expectError( await verifySharePassword(deps, { token: 't', password: 'pw' }), 404, undefined, 'Share not found or revoked', ) }) it('returns invalid_password when the share has no password', async () => { const { deps } = makeDeps() expectError(await verifySharePassword(deps, { token: 't', password: 'pw' }), 403, undefined, 'Invalid password') }) it('returns invalid_password on a wrong password', async () => { const pw = { ...landingShare, passwordHash: hashPassword('correct') } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: pw }) } }) expectError(await verifySharePassword(deps, { token: 't', password: 'wrong' }), 403, undefined, 'Invalid password') }) it('caps the cookie expiry at one day from now when no share expiry', async () => { const pw = { ...landingShare, passwordHash: hashPassword('correct'), expiresAt: null } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: pw }) } }) const now = new Date('2025-01-01T00:00:00Z') const out = await verifySharePassword(deps, { token: 't', password: 'correct', now }) if (!out.ok) throw new Error('expected ok') expect(out.setAccessCookieExpiry).toEqual(new Date('2025-01-02T00:00:00Z')) }) it('clamps the cookie expiry to the share expiry when it is sooner than a day', async () => { const soon = new Date('2025-01-01T06:00:00Z') const pw = { ...landingShare, passwordHash: hashPassword('correct'), expiresAt: soon } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: pw }) } }) const out = await verifySharePassword(deps, { token: 't', password: 'correct', now: new Date('2025-01-01T00:00:00Z'), }) if (!out.ok) throw new Error('expected ok') expect(out.setAccessCookieExpiry).toEqual(soon) }) }) // ─── listShareObjects ──────────────────────────────────────────────────────── describe('listShareObjects', () => { const baseParams = { token: 'sk_token1', viewerId: null, accessCookie: 'ok', relativePath: '', pageSize: 50, } it('returns matter_trashed / not_found from resolution', async () => { const trashed = makeDeps({ share: { resolveByToken: async () => trashedResolution() } }) expectError(await listShareObjects(trashed.deps, baseParams), 410, undefined, 'File no longer available') const revoked = makeDeps({ share: { resolveByToken: async () => ({ status: 'revoked' }) } }) expectError(await listShareObjects(revoked.deps, baseParams), 404, undefined, 'Share not found or revoked') }) it('returns not_found for a non-landing share', async () => { const direct = { ...landingShare, kind: 'direct' } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: direct, matter: folderMatter }) }, }) expectError(await listShareObjects(deps, baseParams), 404, undefined, 'Share not found or revoked') }) it('returns not_a_folder for a file share', async () => { const { deps } = makeDeps() // default matter is a file expectError(await listShareObjects(deps, baseParams), 400, undefined, 'Not a folder share') }) it('returns password_required when the gate blocks', async () => { const pw = { ...landingShare, passwordHash: 'hash' } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: pw, matter: folderMatter }) }, }) expectError( await listShareObjects(deps, { ...baseParams, accessCookie: undefined }), 401, undefined, 'Password required', ) }) it('returns expired before checking the path', async () => { const expired = { ...landingShare, expiresAt: new Date('2000-01-01') } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: expired, matter: folderMatter }) }, }) expectError( await listShareObjects(deps, { ...baseParams, relativePath: '../etc', now: new Date('2030-01-01') }), 410, undefined, 'Share has expired', ) }) it('returns invalid_path for a traversal attempt', async () => { const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ matter: folderMatter }) } }) expectError(await listShareObjects(deps, { ...baseParams, relativePath: 'a/../b' }), 400, undefined, 'Invalid path') }) it('lists children, maps refs, and builds the breadcrumb at the folder root', async () => { const list = vi.fn(async () => ({ items: [ { ...folderMatter, hasChildren: false }, { ...childMatter, hasChildren: false }, ], nextBoundary: null, })) const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ matter: folderMatter }) }, matter: { list }, }) const out = await listShareObjects(deps, baseParams) if (!out.ok) throw new Error('expected ok') // folderMatter.parent='root', name='docs' → queryParent='root/docs' expect(list).toHaveBeenCalledWith('o-1', { parent: 'root/docs', pageSize: 50, after: undefined }) expect(out.result.items).toEqual([ { ref: encodeChildRef('sk_token1', 'fld-1'), name: 'docs', type: 'folder', size: 0, isFolder: true }, { ref: encodeChildRef('sk_token1', 'm-child'), name: 'child.txt', type: fileMatter.type, size: 1024, isFolder: false, }, ]) expect(out.result.breadcrumb).toEqual([{ name: 'docs', path: '' }]) }) it('descends into a subfolder path and reflects it in the breadcrumb', async () => { const list = vi.fn(async () => emptyMatterList) const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ matter: folderMatter }) }, matter: { list }, }) const out = await listShareObjects(deps, { ...baseParams, relativePath: 'a/b' }) if (!out.ok) throw new Error('expected ok') expect(list).toHaveBeenCalledWith('o-1', { parent: 'root/docs/a/b', pageSize: 50, after: undefined }) expect(out.result.breadcrumb).toEqual([ { name: 'docs', path: '' }, { name: 'a', path: 'a' }, { name: 'b', path: 'a/b' }, ]) }) }) // ─── readShareReadme ──────────────────────────────────────────────────────── describe('readShareReadme', () => { const baseParams = { token: 'sk_token1', viewerId: null, accessCookie: 'ok' } const readme = { ...fileMatter, id: 'readme-1', name: 'README.md', type: 'text/markdown', size: 24, parent: 'root/docs', object: 'root/docs/README.md', } as Matter it('reads the root README.md as UTF-8 without consuming a download', async () => { const listDirectActiveChildren = vi.fn(async () => [readme]) const getObjectBytes = vi.fn(async () => new TextEncoder().encode('# Folder guide')) const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ matter: folderMatter }), listDirectActiveChildren, }, s3: { getObjectBytes }, }) const out = await readShareReadme(deps, baseParams) expect(out).toEqual({ ok: true, content: '# Folder guide' }) expect(listDirectActiveChildren).toHaveBeenCalledWith('o-1', 'root/docs') expect(getObjectBytes).toHaveBeenCalledWith(sampleStorage, 'root/docs/README.md') }) it('returns not found when the folder has no root README.md', async () => { const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ matter: folderMatter }), listDirectActiveChildren: async () => [childMatter], }, }) expectError(await readShareReadme(deps, baseParams), 404, undefined, 'README.md not found') }) it('enforces the share password gate', async () => { const protectedShare = { ...landingShare, passwordHash: 'hash' } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: protectedShare, matter: folderMatter }), }, }) expectError( await readShareReadme(deps, { ...baseParams, accessCookie: undefined }), 401, undefined, 'Password required', ) }) it('rejects README access for file shares', async () => { const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ matter: fileMatter }), }, }) expectError(await readShareReadme(deps, baseParams), 404, undefined, 'README.md not found') }) it('rejects README files larger than one MiB', async () => { const oversizedReadme = { ...readme, size: 1024 * 1024 + 1 } const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ matter: folderMatter }), listDirectActiveChildren: async () => [oversizedReadme], }, }) expectError(await readShareReadme(deps, baseParams), 413, undefined, 'README.md is too large') }) it('rejects README files that are not valid UTF-8', async () => { const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ matter: folderMatter }), listDirectActiveChildren: async () => [readme], }, s3: { getObjectBytes: async () => new Uint8Array([0xff]), }, }) expectError(await readShareReadme(deps, baseParams), 400, undefined, 'README.md must be UTF-8 text') }) }) // ─── downloadShareObject ───────────────────────────────────────────────────── describe('downloadShareObject', () => { const baseParams = { token: 'sk_token1', matterId: 'm-1', viewerId: null as string | null, accessCookie: 'ok' as string | undefined, cloudBaseUrl: CLOUD_BASE_URL, } it('presigns and returns the URL on the happy path (root file)', async () => { const { deps, presignDownload } = makeDeps() const out = await downloadShareObject(deps, baseParams) expect(out).toMatchObject({ ok: true, url: PRESIGNED_URL, receipt: { orgId: 'o-1', shareId: 's-1', matterId: 'm-1', bytes: 1024 }, }) expect(presignDownload).toHaveBeenCalledWith(sampleStorage, 'some/key.bin', 'file.bin', expect.any(Number)) }) it('passes cloudBaseUrl, source landing_share/sourceId, and a decrement onRejected to the meter', async () => { const { deps, decrementDownloads } = makeDeps() await downloadShareObject(deps, baseParams) expect(meter).toHaveBeenCalledWith( deps, expect.objectContaining({ cloudBaseUrl: CLOUD_BASE_URL, orgId: 'o-1', bytes: 1024, storage: sampleStorage, source: 'landing_share', sourceId: 's-1', onRejected: expect.any(Function), }), ) const onRejected = meter.mock.calls[0][1].onRejected! await onRejected() expect(decrementDownloads).toHaveBeenCalledWith('s-1') }) it('marks the viewer download traffic as issued', async () => { const { deps } = makeDeps() await downloadShareObject(deps, { ...baseParams, viewerId: 'viewer-9' }) expect(confirmDownload).toHaveBeenCalledWith(deps, { eventId: expect.any(String) }) }) it('marks anonymous download traffic as issued', async () => { const { deps } = makeDeps() await downloadShareObject(deps, { ...baseParams, viewerId: null }) expect(confirmDownload).toHaveBeenCalledWith(deps, { eventId: expect.any(String) }) }) it('fails when the activity record throws', async () => { confirmDownload.mockRejectedValueOnce(new Error('audit down')) const { deps } = makeDeps() await expect(downloadShareObject(deps, baseParams)).rejects.toThrow('audit down') }) it('returns matter_trashed / not_found from resolution', async () => { const trashed = makeDeps({ share: { resolveByToken: async () => trashedResolution() } }) expectError(await downloadShareObject(trashed.deps, baseParams), 410, undefined, 'File no longer available') const revoked = makeDeps({ share: { resolveByToken: async () => ({ status: 'revoked' }) } }) expectError(await downloadShareObject(revoked.deps, baseParams), 404, undefined, 'File not found or not accessible') }) it('returns not_found for a non-landing share', async () => { const direct = { ...landingShare, kind: 'direct' } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: direct }) } }) expectError(await downloadShareObject(deps, baseParams), 404, undefined, 'File not found or not accessible') }) it('returns invalid_ref when the decoded matterId is null', async () => { const { deps } = makeDeps() expectError(await downloadShareObject(deps, { ...baseParams, matterId: null }), 400, undefined, 'Invalid reference') }) it('returns password_required when the gate blocks', async () => { const pw = { ...landingShare, passwordHash: 'hash' } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: pw }) } }) expectError( await downloadShareObject(deps, { ...baseParams, accessCookie: undefined }), 401, undefined, 'Password required', ) }) it('returns expired when past the share expiry', async () => { const expired = { ...landingShare, expiresAt: new Date('2000-01-01') } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: expired }) } }) expectError(await downloadShareObject(deps, baseParams), 410, undefined, 'Share has expired') }) it('resolves a descendant ref via findShareChildMatter', async () => { const findShareChildMatter = vi.fn(async () => childMatter) const { deps, presignDownload } = makeDeps({ share: { resolveByToken: async () => okResolution({ matter: folderMatter }), findShareChildMatter }, }) const out = await downloadShareObject(deps, { ...baseParams, matterId: 'm-child' }) expect(out).toMatchObject({ ok: true, url: PRESIGNED_URL, receipt: { matterId: 'm-child' } }) expect(findShareChildMatter).toHaveBeenCalledWith(folderMatter, 'm-child') expect(presignDownload).toHaveBeenCalledWith(sampleStorage, childMatter.object, 'child.txt', expect.any(Number)) }) it('returns not_found when a descendant ref targets a file share root', async () => { const { deps } = makeDeps() // root is a file expectError( await downloadShareObject(deps, { ...baseParams, matterId: 'other' }), 404, undefined, 'File not found or not accessible', ) }) it('returns not_found when the descendant child is missing', async () => { const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ matter: folderMatter }), findShareChildMatter: async () => null, }, }) expectError( await downloadShareObject(deps, { ...baseParams, matterId: 'm-child' }), 404, undefined, 'File not found or not accessible', ) }) it('returns folder when the root itself is a folder requested directly', async () => { const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ matter: folderMatter }) } }) expectError( await downloadShareObject(deps, { ...baseParams, matterId: 'fld-1' }), 400, undefined, 'Cannot download a folder directly', ) }) it('returns limit_exceeded when no downloads remain', async () => { const { deps, presignDownload } = makeDeps({ share: { hasDownloadsAvailable: async () => false } }) expectError(await downloadShareObject(deps, baseParams), 410, undefined, 'Download limit exceeded') expect(presignDownload).not.toHaveBeenCalled() expect(meter).not.toHaveBeenCalled() }) it('returns limit_exceeded when the atomic increment loses the race', async () => { const { deps, presignDownload } = makeDeps({ share: { incrementDownloadsAtomic: async () => ({ ok: false, downloads: 0 }) }, }) expectError(await downloadShareObject(deps, baseParams), 410, undefined, 'Download limit exceeded') expect(presignDownload).not.toHaveBeenCalled() expect(meter).not.toHaveBeenCalled() }) it('returns storage_not_found when the storage is missing', async () => { const { deps } = makeDeps({ storages: { get: async () => null } }) expectError(await downloadShareObject(deps, baseParams), 404, undefined, 'Storage not found') }) it('returns quota_exceeded when the meter rejects on quota; presign never runs', async () => { meter.mockResolvedValue(meterQuotaExceeded) const { deps, presignDownload } = makeDeps() expectError(await downloadShareObject(deps, baseParams), 422, 'QUOTA_EXCEEDED', 'Traffic quota exceeded') expect(presignDownload).not.toHaveBeenCalled() }) it('returns insufficient_credits when the meter rejects on credits', async () => { meter.mockResolvedValue(meterInsufficientCredits) const { deps, presignDownload } = makeDeps() expectError(await downloadShareObject(deps, baseParams), 402, 'INSUFFICIENT_CREDITS', 'Insufficient credits') expect(presignDownload).not.toHaveBeenCalled() }) it('refunds traffic AND decrements the download when presign fails, then rethrows', async () => { const presignDownload = vi.fn(async () => { throw new Error('sign failed') }) const { deps, refundTraffic, decrementDownloads } = makeDeps({ s3: { presignDownload } }) await expect(downloadShareObject(deps, baseParams)).rejects.toThrow('sign failed') expect(refundTraffic).toHaveBeenCalledWith('o-1', 1024) expect(decrementDownloads).toHaveBeenCalledWith('s-1') }) it('meters bytes=0 when the matter has no size', async () => { const sizeless = { ...fileMatter, size: null } as Matter const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ matter: sizeless }) } }) await downloadShareObject(deps, baseParams) expect(meter).toHaveBeenCalledWith(deps, expect.objectContaining({ bytes: 0 })) }) }) // ─── listShares ────────────────────────────────────────────────────────────── describe('listShares', () => { const sentItem = { id: 's-1', token: 'sk_token1' } as ShareListItem it('lists the sent box via listForApi with status', async () => { const nextBoundary = { createdAt: new Date('2025-01-01'), id: 's-1' } const listForApi = vi.fn(async () => ({ items: [sentItem], nextBoundary })) const { deps } = makeDeps({ share: { listForApi } }) const after = { createdAt: new Date('2025-02-01'), id: 's-2' } const out = await listShares(deps, { userId: 'u1', box: 'sent', pageSize: 10, status: 'active', boundOrgId: 'o-1', after, }) expect(listForApi).toHaveBeenCalledWith('u1', { pageSize: 10, status: 'active', orgId: 'o-1', after }) expect(out).toEqual({ items: [sentItem], nextBoundary }) }) it('defaults to the sent box when box is undefined', async () => { const listForApi = vi.fn(async () => ({ items: [], nextBoundary: null })) const listReceivedForApi = vi.fn(async () => ({ items: [], nextBoundary: null })) const { deps } = makeDeps({ share: { listForApi, listReceivedForApi } }) await listShares(deps, { userId: 'u1', box: undefined, pageSize: 20 }) expect(listForApi).toHaveBeenCalled() expect(listReceivedForApi).not.toHaveBeenCalled() }) it('lists the received box via listReceivedForApi, threading the user email', async () => { const getUserEmail = vi.fn(async () => 'me@example.com') const listReceivedForApi = vi.fn(async () => ({ items: [sentItem], nextBoundary: null })) const { deps } = makeDeps({ share: { getUserEmail, listReceivedForApi } }) const out = await listShares(deps, { userId: 'u1', box: 'received', pageSize: 20 }) expect(getUserEmail).toHaveBeenCalledWith('u1') expect(listReceivedForApi).toHaveBeenCalledWith('u1', 'me@example.com', { pageSize: 20, orgId: undefined, after: undefined, }) expect(out).toEqual({ items: [sentItem], nextBoundary: null }) }) }) // ─── createShare ───────────────────────────────────────────────────────────── describe('createShare', () => { const baseInput = { matterId: 'm-1', kind: 'landing' as const } it('creates the share and returns the wire DTO', async () => { const create = vi.fn(async () => landingShare) const { deps } = makeDeps({ share: { create, getMatterName: async () => 'file.bin' } }) const out = await createShare(deps, platform, { orgId: 'o-1', userId: 'creator-1', input: { ...baseInput, password: 'pw', expiresAt: '2030-01-01T00:00:00Z', downloadLimit: 5 }, }) expect(out).toEqual({ ok: true, share: { token: 'sk_token1', kind: 'landing', expiresAt: null, downloadLimit: null }, }) expect(create).toHaveBeenCalledWith( expect.objectContaining({ matterId: 'm-1', orgId: 'o-1', creatorId: 'creator-1', kind: 'landing', password: 'pw', expiresAt: new Date('2030-01-01T00:00:00Z'), downloadLimit: 5, }), ) }) it('does not dispatch notifications when there are no recipients', async () => { const { deps, createNotification, sendEmail } = makeDeps() await createShare(deps, platform, { orgId: 'o-1', userId: 'creator-1', input: baseInput }) await flushDispatch() expect(createNotification).not.toHaveBeenCalled() expect(sendEmail).not.toHaveBeenCalled() }) it('dispatches share-created notifications when recipients are present', async () => { const { deps, createNotification, sendEmail } = makeDeps({ share: { getCreatorIdentity: async () => ({ name: 'Alice', username: 'alice' }), getMatterName: async () => 'doc.pdf', }, }) const recipients = [{ recipientUserId: 'u2' }, { recipientEmail: 'b@example.com' }] await createShare(deps, platform, { orgId: 'o-1', userId: 'creator-1', input: { ...baseInput, recipients }, }) await flushDispatch() expect(createNotification).toHaveBeenCalledWith( expect.objectContaining({ userId: 'u2', type: 'share_received', title: 'Alice shared "doc.pdf" with you', refId: 's-1', }), ) expect(sendEmail).toHaveBeenCalledWith( platform, expect.objectContaining({ to: 'b@example.com', subject: 'Alice shared "doc.pdf" with you' }), ) }) it('falls back to Unknown creator / empty matter name', async () => { const { deps, sendEmail } = makeDeps({ share: { getCreatorIdentity: async () => null, getMatterName: async () => null }, }) await createShare(deps, platform, { orgId: 'o-1', userId: 'creator-1', input: { ...baseInput, recipients: [{ recipientEmail: 'b@example.com' }] }, }) await flushDispatch() expect(sendEmail).toHaveBeenCalledWith( platform, expect.objectContaining({ to: 'b@example.com', subject: 'Unknown shared "" with you' }), ) }) it.each([ ['MATTER_NOT_FOUND', 404, 'Matter not found'], ['DIRECT_NO_FOLDER', 400, 'Direct shares cannot be folders'], ['DIRECT_NO_PASSWORD', 400, 'Direct shares cannot have a password'], ['DIRECT_NO_RECIPIENTS', 400, 'Direct shares cannot have recipients'], ] as const)('maps CreateShareError %s to a failure outcome without recording activity', async (code, status, message) => { const create = vi.fn(async () => { throw new CreateShareError(code) }) const { deps, record, createNotification, sendEmail } = makeDeps({ share: { create } }) const out = await createShare(deps, platform, { orgId: 'o-1', userId: 'creator-1', input: baseInput }) expectError(out, status, code, message) expect(record).not.toHaveBeenCalled() await flushDispatch() expect(createNotification).not.toHaveBeenCalled() expect(sendEmail).not.toHaveBeenCalled() }) it('rethrows a non-CreateShareError', async () => { const create = vi.fn(async () => { throw new Error('db down') }) const { deps } = makeDeps({ share: { create } }) await expect(createShare(deps, platform, { orgId: 'o-1', userId: 'creator-1', input: baseInput })).rejects.toThrow( 'db down', ) }) }) // ─── setSharePrivacy ───────────────────────────────────────────────────────── describe('setSharePrivacy', () => { it('returns forbidden when the bound workspace does not match the share org', async () => { const setPrivacy = vi.fn() const { deps } = makeDeps({ share: { setPrivacy } }) expectError( await setSharePrivacy(deps, { token: 'sk_token1', userId: 'creator-1', boundOrgId: 'other-org', private: true }), 403, undefined, 'Forbidden', ) expect(setPrivacy).not.toHaveBeenCalled() }) }) // ─── revokeShare ───────────────────────────────────────────────────────────── describe('revokeShare', () => { it('returns not_found when the token does not resolve', async () => { const { deps, record } = makeDeps({ share: { resolveByToken: async () => ({ status: 'not_found' }) } }) expectError(await revokeShare(deps, { token: 't', userId: 'creator-1' }), 404, undefined, 'Not found') expect(record).not.toHaveBeenCalled() }) it('returns not_found when the share is already revoked', async () => { const { deps, record } = makeDeps({ share: { resolveByToken: async () => ({ status: 'revoked' }) } }) expectError(await revokeShare(deps, { token: 't', userId: 'creator-1' }), 404, undefined, 'Not found') expect(record).not.toHaveBeenCalled() }) it('returns forbidden when the requester is not the creator', async () => { const { deps } = makeDeps() expectError(await revokeShare(deps, { token: 't', userId: 'someone-else' }), 403, undefined, 'Forbidden') }) it('returns forbidden when the bound workspace does not match the share org', async () => { const revokeByToken = vi.fn() const { deps } = makeDeps({ share: { revokeByToken } }) expectError( await revokeShare(deps, { token: 'sk_token1', userId: 'creator-1', boundOrgId: 'other-org' }), 403, undefined, 'Forbidden', ) expect(revokeByToken).not.toHaveBeenCalled() }) it('returns not_found when the scoped revoke loses the race', async () => { const { deps } = makeDeps({ share: { revokeByToken: async () => false } }) expectError(await revokeShare(deps, { token: 'sk_token1', userId: 'creator-1' }), 404, undefined, 'Not found') }) it('still revokes a share whose matter is trashed (trashing does not cascade)', async () => { const revokeByToken = vi.fn(async () => true) const { deps } = makeDeps({ share: { resolveByToken: async () => trashedResolution(), revokeByToken } }) const out = await revokeShare(deps, { token: 'sk_token1', userId: 'creator-1' }) expect(out.ok).toBe(true) if (!out.ok) throw new Error('expected ok') expect(out.dto).toMatchObject({ token: 'sk_token1', status: 'revoked', id: 's-1', creatorId: 'creator-1' }) expect(revokeByToken).toHaveBeenCalledWith('sk_token1', 'creator-1') }) it('returns forbidden for a non-creator even when the matter is trashed', async () => { const { deps } = makeDeps({ share: { resolveByToken: async () => trashedResolution() } }) expectError(await revokeShare(deps, { token: 'sk_token1', userId: 'someone-else' }), 403, undefined, 'Forbidden') }) it('revokes and returns the revoked creator view', async () => { const revokeByToken = vi.fn(async () => true) const { deps } = makeDeps({ share: { revokeByToken } }) const out = await revokeShare(deps, { token: 'sk_token1', userId: 'creator-1' }) expect(out.ok).toBe(true) if (!out.ok) throw new Error('expected ok') expect(out.dto).toMatchObject({ token: 'sk_token1', status: 'revoked', id: 's-1', creatorId: 'creator-1', recipients: [], }) expect(revokeByToken).toHaveBeenCalledWith('sk_token1', 'creator-1') }) }) // ─── saveShare ─────────────────────────────────────────────────────────────── describe('saveShare', () => { const baseParams = { token: 'sk_token1', currentUserId: 'u1', targetOrgId: 'o-2', targetParent: '', accessCookie: undefined, } it('returns matter_trashed when the share target was trashed', async () => { const { deps } = makeDeps({ share: { resolveByToken: async () => trashedResolution() } }) expectError(await saveShare(deps, baseParams), 410, undefined, 'Share target has been deleted') }) it('returns not_found for a revoked/missing share', async () => { const { deps } = makeDeps({ share: { resolveByToken: async () => ({ status: 'revoked' }) } }) expectError(await saveShare(deps, baseParams), 404, undefined, 'Share not found') }) it('returns direct_forbidden for a direct share', async () => { const direct = { ...landingShare, kind: 'direct' } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: direct }) } }) expectError( await saveShare(deps, baseParams), 400, 'DIRECT_SAVE_FORBIDDEN', 'Direct link shares cannot be saved. Ask the sender for a landing share.', ) }) it('returns password_required when the gate blocks a non-recipient', async () => { const pw = { ...landingShare, passwordHash: 'hash' } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: pw }) } }) expectError( await saveShare(deps, { ...baseParams, accessCookie: undefined }), 401, undefined, 'Authentication required for password-protected share', ) }) it('bypasses the password gate when the cookie is ok', async () => { const pw = { ...landingShare, passwordHash: 'hash' } as ShareRecord const { deps } = makeDeps({ share: { resolveByToken: async () => okResolution({ share: pw }) } }) const out = await saveShare(deps, { ...baseParams, accessCookie: 'ok' }) expect(out.ok).toBe(true) }) it('returns forbidden when the user cannot write to the target org', async () => { const { deps } = makeDeps({ org: { canWriteToOrg: async () => false } }) expectError(await saveShare(deps, baseParams), 403, undefined, 'Forbidden') }) it('returns forbidden before org write checks when the bound target workspace does not match', async () => { const canWriteToOrg = vi.fn() const { deps } = makeDeps({ org: { canWriteToOrg } }) expectError(await saveShare(deps, { ...baseParams, boundTargetOrgId: 'other-org' }), 403, undefined, 'Forbidden') expect(canWriteToOrg).not.toHaveBeenCalled() }) it('returns quota_exceeded when the target org lacks quota', async () => { const computeSourceBytes = vi.fn(async () => 5000) const hasQuotaForBytes = vi.fn(async () => false) const { deps } = makeDeps({ share: { computeSourceBytes, hasQuotaForBytes } }) expectError(await saveShare(deps, baseParams), 422, 'QUOTA_EXCEEDED', 'Quota exceeded') expect(hasQuotaForBytes).toHaveBeenCalledWith('o-2', 5000) expect(saveToDrive).not.toHaveBeenCalled() }) it('copies via saveShareToDrive and returns the result on success', async () => { const saved = [{ ...fileMatter, id: 'copy-1' }] as Matter[] saveToDrive.mockResolvedValue({ saved, skipped: [{ name: 'x', reason: 'quota' }] }) const { deps } = makeDeps() const out = await saveShare(deps, { ...baseParams, targetParent: 'dest' }) expect(out).toEqual({ ok: true, result: { saved, skipped: [{ name: 'x', reason: 'quota' }] } }) expect(saveToDrive).toHaveBeenCalledWith(deps, { matter: fileMatter, currentUserId: 'u1', targetOrgId: 'o-2', targetParent: 'dest', }) }) })