import { DirType } from '@shared/constants' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Database } from '../platform/interface' import type { ApiKeyAuth, ApiKeyGateway, CloudTrafficReportRepo, DavLock, DownloadTaskRecord, DownloadTaskRepo, LicenseBindingRepo, LicensingCloudGateway, Matter, MatterRepo, QuotaRepo, S3Gateway, StorageRecord, StorageRepo, StorageUsageRepo, UserAdminRepo, WebDavPathRepo, WebDavStateRepo, WebDavTarget, WebDavWorkspace, } from './ports' import { ApiKeyRateLimitError, NameConflictError } from './ports' import { meterDownloadTraffic } from './store/traffic-metering' import { applyWebDavDeadProperties, copyWebDavCollection, copyWebDavFile, createWebDavCollection, createWebDavLock, deleteWebDavMatter, ensureParentCollection, meterWebDavDownload, moveWebDavMatter, putWebDavFile, refundWebDavTraffic, resolveWebDavAuth, resolveWebDavDownload, } from './webdav' // The download meter is its own end-to-end usecase (quota + cloud report + // refund); the webdav usecase only orchestrates around it. Mock it and assert // the wiring + outcome mapping here; cloud-traffic-metering.test.ts owns the // internals. vi.mock('./store/traffic-metering', () => ({ meterDownloadTraffic: vi.fn(), confirmDownloadTraffic: vi.fn(async () => {}), reverseDownloadTraffic: vi.fn(async (deps, params) => deps.quota.refundTraffic(params.orgId, params.bytes)), })) const storage = { id: 'st-1', egressCreditBillingEnabled: false, egressCreditUnitBytes: 0, egressCreditPerUnit: 0, } as unknown as StorageRecord const workspace: WebDavWorkspace = { id: 'ws-1', name: 'Workspace', slug: 'workspace', pathSegment: 'workspace', } function file(id: string, overrides: Partial = {}): Matter { return { id, orgId: 'ws-1', alias: `${id}-alias`, name: `${id}.txt`, type: 'text/plain', size: 100, dirtype: DirType.FILE, parent: '', object: `objects/${id}.txt`, storageId: 'st-1', status: 'active', trashedAt: null, purgedAt: null, createdAt: new Date('2020-01-01'), updatedAt: new Date('2020-01-01'), ...overrides, } } const folder = (id: string, overrides: Partial = {}): Matter => file(id, { dirtype: DirType.USER_FOLDER, size: 0, object: '', type: 'folder', ...overrides }) function target(overrides: Partial = {}): WebDavTarget { return { workspace, mountRoot: false, parent: '', name: 'new.txt', matter: null, ...overrides } } const lock: DavLock = { id: 'lk-1', token: 'opaquelocktoken:abc', orgId: 'ws-1', resourcePath: 'new.txt', owner: 'tester', depth: '0', expiresAt: new Date(Date.now() + 3600_000), createdAt: new Date(), updatedAt: new Date(), } // A permissive default deps; tests override only the ports they exercise. function makeDeps( overrides: { apiKeys?: Partial userAdmin?: Partial matter?: Partial storages?: Partial s3?: Partial quota?: Partial storageUsage?: Partial webdavPath?: Partial webdavState?: Partial downloadTasks?: Partial } = {}, ) { const deps = { apiKeys: { verifyApiKeyForPermission: async () => ({ id: 'k1', configId: 'webdav', referenceId: 'u1', permissions: null }), ...overrides.apiKeys, } as unknown as ApiKeyGateway, userAdmin: { isBanned: async () => false, findActiveUserIdByUsername: async () => 'u1', ...overrides.userAdmin, } as unknown as UserAdminRepo, matter: { create: async (input: Parameters[0]) => file('new', input as Partial), copy: async (source: Matter, parent: string, object: string) => file('copy', { parent, object, name: source.name }), update: async () => file('m1'), trash: async () => file('m1', { trashedAt: Date.now() }), trashByIds: async () => {}, restoreActiveByIds: async () => {}, touch: async () => {}, applyUpload: async () => {}, listActiveDescendants: async () => [], get: async () => null, ...overrides.matter, } as unknown as MatterRepo, storages: { get: async () => storage, select: async () => storage, ...overrides.storages, } as unknown as StorageRepo, s3: { getObjectBody: async () => new Uint8Array(), putObject: async (_s: unknown, _k: unknown, body: unknown, _t: unknown, len?: number) => len ?? (body instanceof Uint8Array ? body.byteLength : 0), copyObject: async () => {}, deleteObject: async () => {}, ...overrides.s3, } as unknown as S3Gateway, // Type placeholders for meterWebDavDownload's metering ports — the metering // usecase itself (meterDownloadTraffic) is mocked. cloudTrafficReports: {} as unknown as CloudTrafficReportRepo, licenseBinding: {} as unknown as LicenseBindingRepo, licensingCloud: {} as unknown as LicensingCloudGateway, audit: { record: async () => {} }, quota: { refundTraffic: async () => {}, incrementUsageIfEffectiveQuotaAllows: async () => true, ...overrides.quota, } as unknown as QuotaRepo, storageUsage: { reconcile: async () => {}, rollbackReservations: async () => {}, ...overrides.storageUsage, } as unknown as StorageUsageRepo, webdavPath: { resolveWebDavPath: async () => target(), resolveExistingWebDavPath: async () => target(), listChildren: async () => [], listUserWorkspaces: async () => [workspace], ...overrides.webdavPath, } as unknown as WebDavPathRepo, webdavState: { activeLocks: async () => [], activeLocksForResources: async () => new Map(), listDeadPropertiesForResources: async () => new Map(), conflictingLocks: async () => [], applyDeadPropertyUpdate: async () => {}, copyDeadProperties: async () => {}, deleteWebDavState: async () => {}, moveWebDavState: async () => {}, createLock: async () => lock, tryCreateLock: async () => lock, refreshLock: async () => lock, removeLock: async () => true, purgeExpiredLocks: async () => {}, ...overrides.webdavState, } as unknown as WebDavStateRepo, downloadTasks: { findActiveTargetWithin: async () => null, ...overrides.downloadTasks, } as unknown as DownloadTaskRepo, } return deps } const authParams = { auth: {} as ApiKeyAuth, db: {} as Database, username: 'user@example.com', password: 'secret', requiredScopes: [{ resource: 'objects', action: 'read' }], configId: 'webdav', } beforeEach(() => vi.clearAllMocks()) describe('webdav usecase', () => { describe('resolveWebDavAuth', () => { it('resolves the userId when the key verifies and the username matches', async () => { const verifyApiKeyForPermission = vi.fn(async () => ({ id: 'k1', configId: 'webdav', referenceId: 'u9', scope: { mode: 'user-workspaces' as const }, permissions: null, })) const findActiveUserIdByUsername = vi.fn(async () => 'u9') const deps = makeDeps({ apiKeys: { verifyApiKeyForPermission }, userAdmin: { findActiveUserIdByUsername }, }) const out = await resolveWebDavAuth(deps, authParams) expect(out).toEqual({ ok: true, userId: 'u9', keyId: 'k1', configId: 'webdav', permissions: null }) expect(verifyApiKeyForPermission).toHaveBeenCalledWith({}, {}, 'secret', 'objects', 'read', 'webdav') expect(findActiveUserIdByUsername).toHaveBeenCalledWith('user@example.com') }) it('is unauthorized when the key does not verify', async () => { const deps = makeDeps({ apiKeys: { verifyApiKeyForPermission: async () => null } }) expect(await resolveWebDavAuth(deps, authParams)).toEqual({ ok: false, reason: 'unauthorized' }) }) it('is unauthorized when the key owner is disabled', async () => { const deps = makeDeps({ userAdmin: { findActiveUserIdByUsername: async () => null } }) expect(await resolveWebDavAuth(deps, authParams)).toEqual({ ok: false, reason: 'unauthorized' }) }) it('is unauthorized when the username does not match the key owner', async () => { const deps = makeDeps({ userAdmin: { findActiveUserIdByUsername: async () => 'other-user' } }) expect(await resolveWebDavAuth(deps, authParams)).toEqual({ ok: false, reason: 'unauthorized' }) }) it('surfaces a rate-limit with its retry window', async () => { const deps = makeDeps({ apiKeys: { verifyApiKeyForPermission: async () => { throw new ApiKeyRateLimitError('slow down', 5000) }, }, }) expect(await resolveWebDavAuth(deps, authParams)).toEqual({ ok: false, reason: 'rate_limited', retryAfterMs: 5000, message: 'slow down', }) }) it('treats any other verification error as unauthorized', async () => { const deps = makeDeps({ apiKeys: { verifyApiKeyForPermission: async () => { throw new Error('boom') }, }, }) expect(await resolveWebDavAuth(deps, authParams)).toEqual({ ok: false, reason: 'unauthorized' }) }) }) describe('ensureParentCollection', () => { it('is a no-op for an empty parent', async () => { const resolveWebDavPath = vi.fn() const deps = makeDeps({ webdavPath: { resolveWebDavPath } }) await ensureParentCollection(deps, { userId: 'u1', workspaceSlug: 'workspace', parent: '' }) expect(resolveWebDavPath).not.toHaveBeenCalled() }) it('throws 409 when the parent collection does not exist', async () => { const deps = makeDeps({ webdavPath: { resolveWebDavPath: async () => target({ matter: null }) } }) await expect( ensureParentCollection(deps, { userId: 'u1', workspaceSlug: 'workspace', parent: 'Missing' }), ).rejects.toMatchObject({ status: 409 }) }) it('throws 405 when the parent resolves to a file', async () => { const deps = makeDeps({ webdavPath: { resolveWebDavPath: async () => target({ matter: file('p1') }) } }) await expect( ensureParentCollection(deps, { userId: 'u1', workspaceSlug: 'workspace', parent: 'file.txt' }), ).rejects.toMatchObject({ status: 405 }) }) it('passes for an existing folder parent', async () => { const deps = makeDeps({ webdavPath: { resolveWebDavPath: async () => target({ matter: folder('p1') }) } }) await expect( ensureParentCollection(deps, { userId: 'u1', workspaceSlug: 'workspace', parent: 'Docs' }), ).resolves.toBeUndefined() }) }) describe('applyWebDavDeadProperties', () => { it('applies the update and touches the matter when present', async () => { const applyDeadPropertyUpdate = vi.fn(async () => {}) const touch = vi.fn(async () => {}) const deps = makeDeps({ webdavState: { applyDeadPropertyUpdate }, matter: { touch } }) const operations = [ { action: 'set' as const, property: { namespace: 'urn:x', name: 'color', value: 'red' } }, ] await applyWebDavDeadProperties(deps, { orgId: 'ws-1', resourcePath: 'a.txt', operations, matterId: 'm1' }) expect(applyDeadPropertyUpdate).toHaveBeenCalledWith('ws-1', 'a.txt', operations) expect(touch).toHaveBeenCalledWith('ws-1', 'm1') }) it('does not touch when there is no matter (workspace root)', async () => { const touch = vi.fn(async () => {}) const deps = makeDeps({ matter: { touch } }) await applyWebDavDeadProperties(deps, { orgId: 'ws-1', resourcePath: '', operations: [], matterId: null }) expect(touch).not.toHaveBeenCalled() }) }) describe('resolveWebDavDownload', () => { it('returns not_found when the matter is missing', async () => { const deps = makeDeps({ webdavPath: { resolveExistingWebDavPath: async () => target({ matter: null, name: 'missing.txt' }) }, }) expect(await resolveWebDavDownload(deps, { userId: 'u1', rawPath: '/dav/workspace/missing.txt' })).toEqual({ ok: false, reason: 'not_found', }) }) it('returns workspace_not_found when no workspace resolves', async () => { const deps = makeDeps({ webdavPath: { resolveExistingWebDavPath: async () => ({ ...target({ matter: file('m1') }), workspace: null }), }, }) expect(await resolveWebDavDownload(deps, { userId: 'u1', rawPath: '/dav/x' })).toEqual({ ok: false, reason: 'workspace_not_found', }) }) it('returns not_a_file for a collection', async () => { const deps = makeDeps({ webdavPath: { resolveExistingWebDavPath: async () => target({ matter: folder('d1') }) }, }) expect(await resolveWebDavDownload(deps, { userId: 'u1', rawPath: '/dav/workspace/d1' })).toEqual({ ok: false, reason: 'not_a_file', }) }) it('returns storage_not_found when the file storage row is gone', async () => { const deps = makeDeps({ webdavPath: { resolveExistingWebDavPath: async () => target({ matter: file('m1') }) }, storages: { get: async () => null }, }) expect(await resolveWebDavDownload(deps, { userId: 'u1', rawPath: '/dav/workspace/m1.txt' })).toEqual({ ok: false, reason: 'storage_not_found', }) }) it('resolves matter + workspace + storage for a file', async () => { const m = file('m1') const deps = makeDeps({ webdavPath: { resolveExistingWebDavPath: async () => target({ matter: m }) } }) const out = await resolveWebDavDownload(deps, { userId: 'u1', rawPath: '/dav/workspace/m1.txt' }) expect(out).toEqual({ ok: true, matter: m, workspace, storage }) }) }) describe('meterWebDavDownload', () => { it('records zero-byte reads without consuming quota bytes', async () => { vi.mocked(meterDownloadTraffic).mockResolvedValue({ ok: true }) const deps = makeDeps() const out = await meterWebDavDownload(deps, { cloudBaseUrl: 'https://cloud', orgId: 'ws-1', userId: 'u1', matterId: 'm1', matterName: 'm1.txt', storage, bytes: 0, trafficEventId: 'traffic-1', }) expect(out).toEqual({ ok: true }) expect(meterDownloadTraffic).toHaveBeenCalledWith(deps, expect.objectContaining({ bytes: 0 })) }) it('meters with the webdav_download source and forwards the outcome', async () => { vi.mocked(meterDownloadTraffic).mockResolvedValue({ ok: true }) const deps = makeDeps() const out = await meterWebDavDownload(deps, { cloudBaseUrl: 'https://cloud', orgId: 'ws-1', userId: 'u1', matterId: 'm1', matterName: 'm1.txt', storage, bytes: 12, trafficEventId: 'traffic-1', }) expect(out).toEqual({ ok: true }) expect(meterDownloadTraffic).toHaveBeenCalledWith( deps, expect.objectContaining({ cloudBaseUrl: 'https://cloud', orgId: 'ws-1', bytes: 12, storage, source: 'webdav_download', sourceId: 'm1', }), ) }) it('forwards a quota_exceeded outcome', async () => { vi.mocked(meterDownloadTraffic).mockResolvedValue({ ok: false, reason: 'quota_exceeded' }) const deps = makeDeps() expect( await meterWebDavDownload(deps, { cloudBaseUrl: 'c', orgId: 'ws-1', userId: 'u1', matterId: 'm1', matterName: 'm1.txt', storage, bytes: 12, trafficEventId: 'traffic-1', }), ).toEqual({ ok: false, reason: 'quota_exceeded' }) }) it('forwards an insufficient_credits outcome', async () => { vi.mocked(meterDownloadTraffic).mockResolvedValue({ ok: false, reason: 'insufficient_credits' }) const deps = makeDeps() expect( await meterWebDavDownload(deps, { cloudBaseUrl: 'c', orgId: 'ws-1', userId: 'u1', matterId: 'm1', matterName: 'm1.txt', storage, bytes: 12, trafficEventId: 'traffic-1', }), ).toEqual({ ok: false, reason: 'insufficient_credits' }) }) it('refundWebDavTraffic calls quota.refundTraffic', async () => { const refundTraffic = vi.fn(async () => {}) const deps = makeDeps({ quota: { refundTraffic } }) await refundWebDavTraffic(deps, { orgId: 'ws-1', bytes: 250, trafficEventId: 'traffic-1' }) expect(refundTraffic).toHaveBeenCalledWith('ws-1', 250) }) }) describe('putWebDavFile', () => { const putParams = (overrides: Partial[1]> = {}) => ({ orgId: 'ws1', userId: 'u1', target: target(), fileName: 'new.txt', parent: '', contentType: 'text/plain', contentLength: 9 as number | null, body: new Uint8Array(9), createdBy: { type: 'api_key' as const, ref: 'key-1', issuer: null }, ...overrides, }) it('returns no_storage when no storage is available', async () => { const deps = makeDeps({ storages: { select: async () => null as unknown as StorageRecord } }) expect(await putWebDavFile(deps, putParams())).toEqual({ ok: false, reason: 'no_storage' }) }) it('creates a new file matter and returns 201', async () => { const create = vi.fn(async () => file('new')) const putObject = vi.fn(async (..._args: Parameters) => 9) const deps = makeDeps({ matter: { create }, s3: { putObject } }) const out = await putWebDavFile(deps, putParams()) expect(out).toEqual({ ok: true, status: 201, matterId: 'new', storageId: 'st-1', bytes: 9 }) const storageKey = putObject.mock.calls[0]?.[1] expect(storageKey).toMatch(/^ws1\/u1\/\d{8}\/[A-Za-z0-9]{17}\.txt$/) expect(putObject).toHaveBeenCalledWith(storage, storageKey, expect.any(Uint8Array), 'text/plain', 9) expect(create).toHaveBeenCalledWith( expect.objectContaining({ name: 'new.txt', size: 9, dirtype: DirType.FILE, status: 'active', object: storageKey, createdByActorType: 'api_key', createdByActorRef: 'key-1', createdByActorIssuer: null, }), ) }) it('overwrites an existing file in place and returns 204', async () => { const applyUpload = vi.fn(async () => {}) const deleteObject = vi.fn(async () => {}) const existing = file('m1', { object: 'objects/m1.txt', size: 20 }) const deps = makeDeps({ matter: { applyUpload }, s3: { deleteObject, putObject: async () => 5 } }) const out = await putWebDavFile(deps, putParams({ target: target({ matter: existing }), contentLength: 5 })) expect(out).toEqual({ ok: true, status: 204, matterId: 'm1', storageId: 'st-1', bytes: 5 }) // Same object key is reused (known length + existing object), so no delete. expect(applyUpload).toHaveBeenCalledWith('ws1', existing, { type: 'text/plain', size: 5, object: 'objects/m1.txt', }) expect(deleteObject).not.toHaveBeenCalled() }) it('overwrites an existing object for compatible legacy owner IDs', async () => { const existing = file('m1', { object: 'legacy_/existing-file-.txt', size: 20 }) const deps = makeDeps({ s3: { putObject: async () => 5 } }) await expect( putWebDavFile( deps, putParams({ target: target({ matter: existing }), contentLength: 5, orgId: 'legacy_org', userId: 'legacy-user', }), ), ).resolves.toMatchObject({ ok: true, status: 204 }) expect(existing.object).toBe('legacy_/existing-file-.txt') }) it.each([ ['organization ID', { orgId: 'invalid/org' }], ['user ID', { userId: 'invalid:user' }], ])('rejects an unsafe %s before overwriting an existing S3 object', async (component, overrides) => { const putObject = vi.fn(async () => 5) const existing = file('m1', { object: 'legacy_/existing-file-.txt', size: 20 }) const deps = makeDeps({ s3: { putObject } }) await expect( putWebDavFile(deps, putParams({ target: target({ matter: existing }), contentLength: 5, ...overrides })), ).rejects.toThrow(`Invalid ${component} for object storage key`) expect(putObject).not.toHaveBeenCalled() expect(existing.object).toBe('legacy_/existing-file-.txt') }) it('reconciles usage when an overwrite shrinks the file', async () => { const reconcile = vi.fn(async () => {}) const existing = file('m1', { object: 'objects/m1.txt', size: 20 }) const deps = makeDeps({ storageUsage: { reconcile }, s3: { putObject: async () => 5 } }) await putWebDavFile(deps, putParams({ target: target({ matter: existing }), contentLength: 5 })) expect(reconcile).toHaveBeenCalledWith('ws1', ['st-1']) }) it('reserves the measured size after a streamed (no Content-Length) upload', async () => { const create = vi.fn(async () => file('new')) const reservations: number[] = [] const incrementUsageIfEffectiveQuotaAllows = vi.fn(async (_o: string, _s: string, bytes: number) => { reservations.push(bytes) return true }) const deps = makeDeps({ matter: { create }, s3: { putObject: async () => 42 }, quota: { incrementUsageIfEffectiveQuotaAllows }, }) const out = await putWebDavFile(deps, putParams({ contentLength: null, body: new Uint8Array() })) expect(out).toEqual({ ok: true, status: 201, matterId: 'new', storageId: 'st-1', bytes: 42 }) // Known delta is 0 (unknown length) → reserved only after measuring 42 bytes. expect(reservations).toEqual([42]) expect(create).toHaveBeenCalledWith(expect.objectContaining({ size: 42 })) }) it('rolls back the S3 write when persisting the matter fails', async () => { const deleteObject = vi.fn(async () => {}) const deps = makeDeps({ s3: { putObject: async () => 9, deleteObject }, matter: { create: async () => { throw new Error('db down') }, }, }) await expect(putWebDavFile(deps, putParams())).rejects.toThrow('db down') expect(deleteObject).toHaveBeenCalledWith(storage, expect.any(String)) }) it('propagates a quota error from the reservation', async () => { const deps = makeDeps({ quota: { incrementUsageIfEffectiveQuotaAllows: async () => false } }) await expect(putWebDavFile(deps, putParams())).rejects.toMatchObject({ name: 'StorageQuotaExceededError' }) }) }) describe('createWebDavCollection', () => { it('creates a folder matter under the selected private storage', async () => { const create = vi.fn(async () => folder('Projects')) const deps = makeDeps({ matter: { create } }) await createWebDavCollection(deps, { orgId: 'ws-1', userId: 'u1', name: 'Projects', parent: 'Docs', createdBy: { type: 'api_key', ref: 'key-1', issuer: null }, }) expect(create).toHaveBeenCalledWith( expect.objectContaining({ name: 'Projects', type: 'folder', dirtype: DirType.USER_FOLDER, parent: 'Docs', object: '', status: 'active', createdByActorType: 'api_key', createdByActorRef: 'key-1', }), ) }) }) describe('deleteWebDavMatter', () => { it('clears dav state then trashes the matter', async () => { const order: string[] = [] const deleteWebDavState = vi.fn(async () => { order.push('state') }) const trash = vi.fn(async () => { order.push('trash') return file('m1', { trashedAt: Date.now() }) }) const deps = makeDeps({ webdavState: { deleteWebDavState }, matter: { trash } }) await deleteWebDavMatter(deps, { orgId: 'ws-1', resourcePath: 'gone.txt', matterId: 'm1', userId: 'u1' }) expect(deleteWebDavState).toHaveBeenCalledWith('ws-1', 'gone.txt') expect(trash).toHaveBeenCalledWith('ws-1', 'm1') expect(order).toEqual(['state', 'trash']) }) it('rejects deleting a folder used by an active download task before changing dav state', async () => { const deleteWebDavState = vi.fn(async () => {}) const trash = vi.fn(async () => folder('target')) const deps = makeDeps({ matter: { get: async () => folder('target', { name: 'Downloads' }), trash }, webdavState: { deleteWebDavState }, downloadTasks: { findActiveTargetWithin: async () => ({ id: 'task-1', targetFolder: 'Downloads/Movies' }) as DownloadTaskRecord, }, }) await expect( deleteWebDavMatter(deps, { orgId: 'ws-1', resourcePath: 'Downloads', matterId: 'target', userId: 'u1', }), ).rejects.toMatchObject({ httpStatus: 409, meta: { reason: 'DIRECTORY_IN_USE', metadata: { taskId: 'task-1', targetFolder: 'Downloads/Movies' }, }, }) expect(deleteWebDavState).not.toHaveBeenCalled() expect(trash).not.toHaveBeenCalled() }) }) describe('moveWebDavMatter', () => { it('renames/reparents the source and moves its dav state', async () => { const update = vi.fn(async () => file('m1')) const moveWebDavState = vi.fn(async () => {}) const trash = vi.fn() const deps = makeDeps({ matter: { update, trash }, webdavState: { moveWebDavState } }) await moveWebDavMatter(deps, { orgId: 'ws-1', userId: 'u1', sourceMatterId: 'm1', sourceResourcePath: 'src.txt', targetName: 'dst.txt', targetParent: 'Docs', targetResourcePath: 'Docs/dst.txt', replacedMatterId: null, }) expect(update).toHaveBeenCalledWith('m1', 'ws-1', { name: 'dst.txt', parent: 'Docs' }) expect(moveWebDavState).toHaveBeenCalledWith('ws-1', 'src.txt', 'Docs/dst.txt') expect(trash).not.toHaveBeenCalled() }) it('trashes an overwritten destination before moving', async () => { const trash = vi.fn(async () => file('t1', { trashedAt: Date.now() })) const deleteWebDavState = vi.fn(async () => {}) const deps = makeDeps({ matter: { trash }, webdavState: { deleteWebDavState } }) await moveWebDavMatter(deps, { orgId: 'ws-1', userId: 'u1', sourceMatterId: 'm1', sourceResourcePath: 'src.txt', targetName: 'dst.txt', targetParent: '', targetResourcePath: 'dst.txt', replacedMatterId: 't1', }) expect(deleteWebDavState).toHaveBeenCalledWith('ws-1', 'dst.txt') expect(trash).toHaveBeenCalledWith('ws-1', 't1') }) }) describe('copyWebDavFile', () => { const copyParams = (overrides: Partial[1]> = {}) => ({ orgId: 'ws1', userId: 'u1', sourceMatter: file('src', { size: 12 }), sourceResourcePath: 'src.txt', targetName: 'dst.txt', targetParent: '', targetResourcePath: 'dst.txt', replacedMatterId: null, replacingTarget: false, createdBy: { type: 'api_key' as const, ref: 'key-1', issuer: null }, ...overrides, }) it('copies the object + matter + dead properties and returns 201', async () => { const copyObject = vi.fn(async (..._args: Parameters) => {}) const copyDeadProperties = vi.fn(async () => {}) const copy = vi.fn(async (s: Matter, parent: string, object: string) => file('copy', { parent, object, name: s.name }), ) const deps = makeDeps({ s3: { copyObject }, matter: { copy }, webdavState: { copyDeadProperties } }) const sourceMatter = file('src', { size: 12, object: 'legacy_/webdav-source-.txt' }) const out = await copyWebDavFile(deps, copyParams({ sourceMatter })) expect(out).toEqual({ ok: true, status: 201, location: 'dst.txt' }) const destinationKey = copyObject.mock.calls[0]?.[3] expect(sourceMatter.object).toBe('legacy_/webdav-source-.txt') expect(destinationKey).toMatch(/^ws1\/u1\/\d{8}\/[A-Za-z0-9]{17}\.txt$/) expect(copyObject).toHaveBeenCalledWith(storage, sourceMatter.object, storage, destinationKey) expect(copy).toHaveBeenCalledWith(expect.any(Object), '', destinationKey, { onConflict: 'fail', createdByActorType: 'api_key', createdByActorRef: 'key-1', createdByActorIssuer: null, }) expect(copyDeadProperties).toHaveBeenCalledWith('ws1', 'src.txt', 'dst.txt') }) it('returns 204 and trashes the destination when overwriting', async () => { const trash = vi.fn(async () => file('t1', { trashedAt: Date.now() })) const deps = makeDeps({ matter: { trash } }) const out = await copyWebDavFile(deps, copyParams({ replacedMatterId: 't1', replacingTarget: true })) expect(out).toEqual({ ok: true, status: 204, location: 'dst.txt' }) expect(trash).toHaveBeenCalledWith('ws1', 't1') }) it('returns storage_not_found when an object-backed source has no storage', async () => { const deps = makeDeps({ storages: { get: async () => null } }) expect(await copyWebDavFile(deps, copyParams())).toEqual({ ok: false, reason: 'storage_not_found' }) }) it('rolls back the copied object when the matter copy fails', async () => { const deleteObject = vi.fn(async () => {}) const deps = makeDeps({ s3: { copyObject: async () => {}, deleteObject }, matter: { copy: async () => { throw new NameConflictError('dst.txt', 'x') }, }, }) await expect(copyWebDavFile(deps, copyParams())).rejects.toBeInstanceOf(NameConflictError) expect(deleteObject).toHaveBeenCalledWith(storage, expect.any(String)) }) it('copies a zero-object source (e.g. empty file) without touching S3', async () => { const copyObject = vi.fn(async () => {}) const deps = makeDeps({ s3: { copyObject } }) const out = await copyWebDavFile(deps, copyParams({ sourceMatter: file('src', { object: '', size: 0 }) })) expect(out.ok).toBe(true) expect(copyObject).not.toHaveBeenCalled() }) }) describe('copyWebDavCollection', () => { const tree = () => { const child = folder('child', { name: 'Nested', parent: 'Source' }) const grandchild = file('gc', { name: 'note.txt', parent: 'Source/Nested', size: 12 }) return { child, grandchild } } const collParams = (overrides: Partial[1]> = {}) => ({ orgId: 'ws1', userId: 'u1', sourceMatter: folder('src', { name: 'Source' }), sourceRoot: 'Source', targetName: 'Copied', targetParent: '', targetResourcePath: 'Copied', targetMatter: null, replacingTarget: false, depth: 'infinity' as const, createdBy: { type: 'api_key' as const, ref: 'key-1', issuer: null }, ...overrides, }) it('recursively copies the root + descendants, rewriting parents and dead properties', async () => { const { child, grandchild } = tree() const copies: Array<{ name: string; parent: string; object: string }> = [] const copy = vi.fn(async (s: Matter, parent: string, object: string) => { copies.push({ name: s.name, parent, object }) return file('c', { name: s.name, parent, object }) }) const copyDeadProperties = vi.fn(async () => {}) const deps = makeDeps({ matter: { copy, listActiveDescendants: async () => [grandchild] }, webdavPath: { listChildren: async () => [child] }, webdavState: { copyDeadProperties }, }) const out = await copyWebDavCollection(deps, collParams()) expect(out).toEqual({ ok: true, status: 201, location: 'Copied' }) // Root first (renamed to the target name, matching the original), then // child under Copied, then grandchild under Copied/Nested. expect(copies).toEqual([ { name: 'Copied', parent: '', object: '' }, { name: 'Nested', parent: 'Copied', object: '' }, { name: 'note.txt', parent: 'Copied/Nested', object: expect.stringMatching(/^ws1\/u1\/\d{8}\/[A-Za-z0-9]{17}\.txt$/), }, ]) expect(copy).toHaveBeenCalledWith(expect.any(Object), '', '', { onConflict: 'fail', createdByActorType: 'api_key', createdByActorRef: 'key-1', createdByActorIssuer: null, }) }) it('copies only the root when depth=0', async () => { const copy = vi.fn(async (s: Matter, parent: string) => file('c', { name: s.name, parent })) const listChildren = vi.fn(async () => [tree().child]) const deps = makeDeps({ matter: { copy }, webdavPath: { listChildren } }) const out = await copyWebDavCollection(deps, collParams({ depth: '0' })) expect(out.ok).toBe(true) // depth=0 ignores children for the copy ordering. expect(copy).toHaveBeenCalledTimes(1) }) it('returns storage_not_found when a descendant file has no storage', async () => { const { grandchild } = tree() const deps = makeDeps({ matter: { listActiveDescendants: async () => [grandchild] }, storages: { get: async () => null }, }) expect(await copyWebDavCollection(deps, collParams())).toEqual({ ok: false, reason: 'storage_not_found' }) }) it('trashes created rows and restores the overwritten destination when a copy fails', async () => { const { grandchild } = tree() const trashByIds = vi.fn(async () => {}) const restoreActiveByIds = vi.fn(async () => {}) const targetMatter = folder('existing', { name: 'Copied' }) let copyCount = 0 const copy = vi.fn(async (s: Matter, parent: string) => { copyCount += 1 if (copyCount === 2) throw new Error('copy failed') return file(`c${copyCount}`, { name: s.name, parent }) }) const deps = makeDeps({ matter: { copy, trashByIds, restoreActiveByIds, listActiveDescendants: async () => [grandchild] }, webdavPath: { listChildren: async () => [] }, }) await expect(copyWebDavCollection(deps, collParams({ targetMatter, replacingTarget: true }))).rejects.toThrow( 'copy failed', ) expect(trashByIds).toHaveBeenCalledWith('ws1', ['c1']) expect(restoreActiveByIds).toHaveBeenCalledWith('ws1', expect.arrayContaining(['existing'])) }) }) describe('createWebDavLock', () => { it('acquires a lock on an existing resource without creating anything', async () => { const create = vi.fn() const putObject = vi.fn() const tryCreateLock = vi.fn(async () => lock) const deps = makeDeps({ matter: { create }, s3: { putObject }, webdavState: { tryCreateLock } }) const out = await createWebDavLock(deps, { orgId: 'ws-1', userId: 'u1', resourcePath: 'doc.txt', target: target({ matter: file('m1'), name: 'doc.txt' }), owner: 'tester', depth: '0', timeoutSeconds: 600, createdBy: { type: 'api_key', ref: 'key-1', issuer: null }, }) expect(out).toEqual({ lock, created: false }) expect(create).not.toHaveBeenCalled() expect(putObject).not.toHaveBeenCalled() expect(tryCreateLock).toHaveBeenCalledWith({ orgId: 'ws-1', resourcePath: 'doc.txt', owner: 'tester', depth: '0', timeoutSeconds: 600, }) }) it('lazily creates an empty file when locking an unmapped URL (created=true)', async () => { const putObject = vi.fn(async (..._args: Parameters) => 0) const create = vi.fn(async () => file('new', { size: 0, type: 'application/octet-stream' })) const deps = makeDeps({ s3: { putObject }, matter: { create } }) const out = await createWebDavLock(deps, { orgId: 'ws1', userId: 'u1', resourcePath: 'new.txt', target: target({ matter: null, name: 'new.txt' }), owner: 'tester', depth: 'infinity', timeoutSeconds: 3600, createdBy: { type: 'api_key', ref: 'key-1', issuer: null }, }) expect(out).not.toBeNull() if (!out) throw new Error('Expected lock to be created') expect(out.created).toBe(true) const storageKey = putObject.mock.calls[0]?.[1] expect(storageKey).toMatch(/^ws1\/u1\/\d{8}\/[A-Za-z0-9]{17}\.txt$/) expect(putObject).toHaveBeenCalledWith(storage, storageKey, expect.any(Uint8Array), 'application/octet-stream') expect(create).toHaveBeenCalledWith( expect.objectContaining({ name: 'new.txt', size: 0, dirtype: DirType.FILE, status: 'active', object: storageKey, createdByActorType: 'api_key', createdByActorRef: 'key-1', }), ) }) }) })