mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
1166 lines
46 KiB
TypeScript
1166 lines
46 KiB
TypeScript
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<typeof import('./store/traffic-metering')>()),
|
|
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> = {}): 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<ShareRepo>
|
|
matter?: Partial<MatterRepo>
|
|
storages?: Partial<StorageRepo>
|
|
s3?: Partial<S3Gateway>
|
|
quota?: Partial<QuotaRepo>
|
|
org?: Partial<OrgRepo>
|
|
} = {},
|
|
) {
|
|
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',
|
|
})
|
|
})
|
|
})
|