Files
zpan/server/http/webdav.integration.test.ts
T
2026-07-26 14:34:29 -04:00

2654 lines
110 KiB
TypeScript

import type { AddressInfo } from 'node:net'
import { createServer } from 'node:net'
import { Readable } from 'node:stream'
import { serve } from '@hono/node-server'
import { sql } from 'drizzle-orm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createClient } from 'webdav'
import { WEBDAV_RATE_LIMITER_BINDING } from '../../shared/api-key-templates.js'
import { S3Service } from '../adapters/gateways/s3.js'
import { storages } from '../db/schema.js'
import { currentTrafficPeriod } from '../domain/quota.js'
import { encodeDavPathSegment } from '../domain/webdav.js'
import { authedHeaders, createTestApp } from '../test/setup.js'
type TestApp = Awaited<ReturnType<typeof createTestApp>>
const storage = {
id: 'dav-storage',
bucket: 'dav-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
accessKey: 'key',
secretKey: 'secret',
}
beforeEach(() => {
vi.restoreAllMocks()
vi.spyOn(S3Service.prototype, 'presignDownload').mockResolvedValue('https://download.example.com/file.txt')
vi.spyOn(S3Service.prototype, 'getObjectBytes').mockResolvedValue(new TextEncoder().encode('hello webdav'))
vi.spyOn(S3Service.prototype, 'getObjectBody').mockImplementation(async () => streamBody('hello webdav'))
vi.spyOn(S3Service.prototype, 'putObject').mockImplementation(
async (_storage, _key, body, _contentType, contentLength) =>
contentLength ??
(body instanceof Uint8Array ? body.byteLength : (await new Response(body).arrayBuffer()).byteLength),
)
vi.spyOn(S3Service.prototype, 'copyObject').mockResolvedValue(undefined)
})
function streamBody(text: string): ReadableStream {
const bytes = new TextEncoder().encode(text)
return new ReadableStream({
start(controller) {
controller.enqueue(bytes)
controller.close()
},
})
}
async function seedStorage(db: TestApp['db']) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES (${storage.id}, ${storage.bucket}, ${storage.endpoint}, ${storage.region}, ${storage.accessKey}, ${storage.secretKey}, '', '', 0, 0, 'active', ${now}, ${now})
`)
}
async function seedTrafficPlan(db: TestApp['db'], orgId: string, bytes: number, used = 0) {
const now = Date.now()
await db.run(sql`
UPDATE org_quotas
SET traffic_used = ${used}, traffic_period = ${currentTrafficPeriod()}
WHERE org_id = ${orgId}
`)
await db.run(sql`
UPDATE org_quota_entitlements
SET status = 'revoked', updated_at = ${now}
WHERE org_id = ${orgId}
AND resource_type = 'traffic'
AND entitlement_type = 'plan'
AND status = 'active'
`)
await db.run(sql`
INSERT INTO org_quota_entitlements
(id, org_id, resource_type, entitlement_type, source, source_id, bytes, starts_at, expires_at, status, metadata, created_at, updated_at)
VALUES
(${`traffic-plan-${orgId}-${now}`}, ${orgId}, 'traffic', 'plan', 'test', ${`traffic-plan:${orgId}:${now}`}, ${bytes}, ${now}, NULL, 'active', '{"packageName":"Traffic Plan"}', ${now}, ${now})
`)
}
async function org(db: TestApp['db']) {
const rows = await db.all<{ id: string; name: string; slug: string }>(sql`
SELECT id, name, slug FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1
`)
if (!rows[0]) throw new Error('No personal org found')
return rows[0]
}
async function teamWorkspace(
db: TestApp['db'],
opts: { id: string; slug: string; userId?: string; name?: string },
): Promise<{ id: string; name: string; slug: string }> {
const now = Date.now()
const name = opts.name ?? opts.slug
await db.run(sql`
INSERT INTO organization (id, name, slug, metadata, created_at, updated_at)
VALUES (${opts.id}, ${name}, ${opts.slug}, '{}', ${now}, ${now})
`)
if (opts.userId) {
await db.run(sql`
INSERT INTO member (id, organization_id, user_id, role, created_at)
VALUES (${`mem-${opts.id}`}, ${opts.id}, ${opts.userId}, 'member', ${now})
`)
}
return { id: opts.id, name, slug: opts.slug }
}
async function userAccount(db: TestApp['db']) {
const rows = await db.all<{ id: string; email: string; username: string | null }>(
sql`SELECT id, email, username FROM user LIMIT 1`,
)
if (!rows[0]) throw new Error('No user found')
return rows[0]
}
function basicHeaders(username: string, password: string, extra?: Record<string, string>) {
return { Authorization: `Basic ${btoa(`${username}:${password}`)}`, ...extra }
}
function escapedDavHref(name: string, path = '') {
return `/dav/${encodeDavPathSegment(name)}/${path}`
}
function davPathForName(name: string, path = '') {
return `/dav/${encodeDavPathSegment(name)}/${path}`
}
async function apiKey(auth: TestApp['auth'], userId: string, permissions: Record<string, string[]>) {
// biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API is not fully typed
const result = (await (auth.api as any).createApiKey({
body: { configId: 'webdav', userId, permissions },
})) as { key: string }
return result.key
}
async function imageHostingApiKey(auth: TestApp['auth'], orgId: string, userId: string) {
// biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API is not fully typed
const result = (await (auth.api as any).createApiKey({
body: { configId: 'ihost', organizationId: orgId, userId },
})) as { key: string }
return result.key
}
async function file(
db: TestApp['db'],
orgId: string,
opts: { id: string; name: string; parent?: string; size?: number },
) {
const now = Date.now()
await db.run(sql`
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at)
VALUES (${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${opts.name}, 'text/plain', ${opts.size ?? 5}, 0, ${opts.parent ?? ''}, ${`objects/${opts.id}.txt`}, ${storage.id}, 'active', ${now}, ${now})
`)
}
async function folder(db: TestApp['db'], orgId: string, opts: { id: string; name: string; parent?: string }) {
const now = Date.now()
await db.run(sql`
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at)
VALUES (${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${opts.name}, 'folder', 0, 1, ${opts.parent ?? ''}, '', ${storage.id}, 'active', ${now}, ${now})
`)
}
describe('WebDAV API', () => {
it('uses the native limiter for every request and reuses successful auth briefly', async () => {
const limit = vi.fn(async () => ({ success: true }))
const { app, db, auth, deps } = await createTestApp({}, { [WEBDAV_RATE_LIMITER_BINDING]: { limit } })
await authedHeaders(app)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read'] })
const verify = vi.spyOn(deps.apiKeys, 'verifyApiKeyForPermission')
const headers = basicHeaders(account.email, key, { Depth: '0' })
const first = await app.request('/dav/', { method: 'PROPFIND', headers })
const second = await app.request('/dav/', { method: 'PROPFIND', headers })
expect(first.status).toBe(207)
expect(second.status).toBe(207)
expect(limit).toHaveBeenCalledTimes(2)
expect(verify).toHaveBeenCalledTimes(1)
expect(second.headers.get('Server-Timing')).toContain('webdav-auth:memory')
})
it('rejects native WebDAV rate limits before API-key verification', async () => {
const limit = vi.fn(async () => ({ success: false }))
const { app, deps } = await createTestApp({}, { [WEBDAV_RATE_LIMITER_BINDING]: { limit } })
const verify = vi.spyOn(deps.apiKeys, 'verifyApiKeyForPermission')
const response = await app.request('/dav/', {
method: 'PROPFIND',
headers: basicHeaders('user@example.com', 'secret'),
})
expect(response.status).toBe(429)
expect(response.headers.get('Retry-After')).toBe('60')
expect(limit).toHaveBeenCalledTimes(1)
expect(verify).not.toHaveBeenCalled()
})
it('does not count API-key WebDAV requests as Better Auth session activity', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
const account = await userAccount(db)
const previousActivity = Date.parse('2026-01-01T00:00:00.000Z')
await db.run(sql`UPDATE user SET last_active_at = ${previousActivity} WHERE id = ${account.id}`)
const key = await apiKey(auth, account.id, { webdav: ['read'] })
const headers = basicHeaders(account.email, key)
const rejected = await app.request('/dav/', { method: 'PROPFIND', headers: { ...headers, Depth: 'infinity' } })
expect(rejected.status).toBe(403)
const accepted = await app.request('/dav/', { method: 'OPTIONS', headers })
expect(accepted.status).toBe(204)
expect(await db.all(sql`SELECT last_active_at AS lastActiveAt FROM user WHERE id = ${account.id}`)).toEqual([
{ lastActiveAt: previousActivity },
])
})
it('rejects missing and insufficient API keys without accepting session cookies [spec: webdav/auth]', async () => {
const { app, db, auth } = await createTestApp()
const headers = await authedHeaders(app)
const { slug } = await org(db)
const account = await userAccount(db)
const readKey = await apiKey(auth, account.id, { webdav: ['read'] })
const missing = await app.request(`/dav/${slug}/`, { method: 'PROPFIND' })
expect(missing.status).toBe(401)
expect(missing.headers.get('WWW-Authenticate')).toBe('Basic realm="ZPan WebDAV"')
expect((await app.request(`/dav/${slug}/`, { method: 'PROPFIND', headers })).status).toBe(401)
expect(
(await app.request(`/dav/${slug}/`, { method: 'PROPFIND', headers: { Authorization: `Bearer ${readKey}` } }))
.status,
).toBe(401)
expect(
(
await app.request(`/dav/${slug}/new.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, readKey),
body: 'content',
})
).status,
).toBe(401)
})
it('rejects org-bound image-hosting API keys for WebDAV Basic Auth [spec: webdav/auth-key-scope]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
const workspace = await org(db)
const account = await userAccount(db)
const key = await imageHostingApiKey(auth, workspace.id, account.id)
const res = await app.request(`/dav/${workspace.slug}/`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key),
})
expect(res.status).toBe(401)
expect(res.headers.get('WWW-Authenticate')).toBe('Basic realm="ZPan WebDAV"')
})
it('PROPFIND lists the mount root, workspace root, and folder children [spec: webdav/propfind]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read'] })
await folder(db, workspace.id, { id: 'docs', name: 'Docs' })
await file(db, workspace.id, { id: 'readme', name: 'readme.txt', parent: 'Docs' })
await file(db, workspace.id, { id: 'special', name: 'Miss Americana & The Heartbreak Prince.txt', parent: 'Docs' })
const root = await app.request('/dav/', { method: 'PROPFIND', headers: basicHeaders(account.email, key) })
expect(root.status).toBe(207)
const rootXml = await root.text()
expect(rootXml).toContain(escapedDavHref(workspace.name))
expect(rootXml).not.toContain(`/dav/${encodeURIComponent(workspace.name).replaceAll("'", '&apos;')}/`)
const rootWithoutSlash = await app.request('/dav', {
method: 'PROPFIND',
headers: basicHeaders(account.email, key),
})
expect(rootWithoutSlash.status).toBe(207)
expect(await rootWithoutSlash.text()).toContain(escapedDavHref(workspace.name))
const docs = await app.request(`/dav/${workspace.slug}/Docs`, {
method: 'PROPFIND',
headers: basicHeaders(account.username ?? account.email, key),
})
expect(docs.status).toBe(207)
const xml = await docs.text()
expect(xml).toContain(escapedDavHref(workspace.name, 'Docs/'))
expect(xml).toContain(escapedDavHref(workspace.name, 'Docs/readme.txt'))
expect(xml).toContain(escapedDavHref(workspace.name, 'Docs/Miss%20Americana%20%26%20The%20Heartbreak%20Prince.txt'))
expect(xml).toContain('<D:displayname>Miss Americana &amp; The Heartbreak Prince.txt</D:displayname>')
const doubledMountSlash = await app.request(`/dav//${workspace.slug}/Docs`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key),
})
expect(doubledMountSlash.status).toBe(207)
const docsByDisplayName = await app.request(davPathForName(workspace.name, 'Docs'), {
method: 'PROPFIND',
headers: basicHeaders(account.email, key),
})
expect(docsByDisplayName.status).toBe(207)
for (const property of [
'displayname',
'creationdate',
'getetag',
'getcontentlength',
'getcontenttype',
'getlastmodified',
'resourcetype',
'supportedlock',
'lockdiscovery',
]) {
expect(xml).toContain(`<D:${property}`)
}
const byId = await app.request(`/dav/${workspace.id}/`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key),
})
expect(byId.status).toBe(207)
})
it('uses root-relative hrefs and destinations on the Public URL-derived DAV hostname [spec: webdav/custom-host]', async () => {
const { app, db, auth, deps } = await createTestApp()
await deps.systemOptions.set('site_public_origin', 'https://example.com')
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] })
await file(db, workspace.id, { id: 'custom-host-source', name: 'source.txt', size: 12 })
const root = await app.request('https://dav.example.com/dav/', {
method: 'PROPFIND',
headers: basicHeaders(account.email, key),
})
const rootXml = await root.text()
const workspacePath = encodeDavPathSegment(workspace.name)
expect(root.status).toBe(207)
expect(rootXml).toContain('<D:href>/</D:href>')
expect(rootXml).toContain(`<D:href>/${workspacePath}/</D:href>`)
expect(rootXml).not.toContain('/dav/')
const patch = await app.request(`https://dav.example.com/dav/${workspace.slug}/source.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<D:propertyupdate xmlns:D="DAV:" xmlns:Z="urn:zpan:test"><D:set><D:prop><Z:color>blue</Z:color></D:prop></D:set></D:propertyupdate>',
})
expect(await patch.text()).toContain(`<D:href>/${workspacePath}/source.txt</D:href>`)
// Simulate TLS termination: the proxy-facing request URL is http while the
// public Destination remains https on the same configured hostname.
const copy = await app.request(`http://dav.example.com/dav/${workspace.slug}/source.txt`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `https://dav.example.com/${workspacePath}/copied.txt`,
}),
})
expect(copy.status).toBe(201)
expect(copy.headers.get('Location')).toBe(`https://dav.example.com/${workspacePath}/copied.txt`)
const mainOrigin = await app.request(`https://pan.example.com/dav/${workspace.slug}/source.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0' }),
})
expect(await mainOrigin.text()).toContain(`<D:href>/dav/${workspacePath}/source.txt</D:href>`)
})
it('does not replace the site Public URL with the DAV hostname', async () => {
const { app, db, deps } = await createTestApp()
await deps.systemOptions.set('site_public_origin', 'https://example.com')
const res = await app.request('https://dav.example.com/dav/', { method: 'PROPFIND' })
expect(res.status).toBe(401)
const rows = await db.all<{ value: string }>(sql`SELECT value FROM system_options WHERE key = 'site_public_origin'`)
expect(rows).toEqual([{ value: 'https://example.com' }])
})
it('PROPFIND mount root lists all member workspaces and hides non-member workspaces [spec: webdav/propfind-workspaces]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
const workspace = await org(db)
const account = await userAccount(db)
const team = await teamWorkspace(db, { id: 'team-dav', slug: 'team-dav', userId: account.id, name: 'Team DAV' })
const hidden = await teamWorkspace(db, { id: 'hidden-dav', slug: 'hidden-dav', name: 'Hidden DAV' })
const key = await apiKey(auth, account.id, { webdav: ['read'] })
const root = await app.request('/dav/', { method: 'PROPFIND', headers: basicHeaders(account.email, key) })
expect(root.status).toBe(207)
const xml = await root.text()
expect(xml).toContain(escapedDavHref(workspace.name))
expect(xml).toContain(escapedDavHref(team.name))
expect(xml).not.toContain(`/dav/${hidden.slug}/`)
const hiddenRes = await app.request(`/dav/${hidden.slug}/`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key),
})
expect(hiddenRes.status).toBe(404)
})
it('PROPFIND supports prop, propname, allprop include, explicit depths, and rejects infinity [spec: webdav/propfind-modes]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read'] })
await folder(db, workspace.id, { id: 'docs', name: 'Docs' })
await file(db, workspace.id, { id: 'readme', name: 'readme.txt', parent: 'Docs' })
const prop = await app.request(`/dav/${workspace.slug}/Docs`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: `<?xml version="1.0"?>
<D:propfind xmlns:D="DAV:">
<D:prop><D:displayname/><D:quota-used-bytes/></D:prop>
</D:propfind>`,
})
expect(prop.status).toBe(207)
const propXml = await prop.text()
expect(propXml).toContain('<D:displayname>Docs</D:displayname>')
expect(propXml).toContain('HTTP/1.1 404 Not Found')
expect(propXml).not.toContain('readme.txt')
const propname = await app.request(`/dav/${workspace.slug}/Docs`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '1', 'Content-Type': 'application/xml' }),
body: '<D:propfind xmlns:D="DAV:"><D:propname/></D:propfind>',
})
expect(propname.status).toBe(207)
const propnameXml = await propname.text()
expect(propnameXml).toContain('<D:displayname/>')
expect(propnameXml).toContain(escapedDavHref(workspace.name, 'Docs/readme.txt'))
const defaultNamespace = await app.request(`/dav/${workspace.slug}/Docs`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: `<?xml version="1.0"?>
<propfind xmlns="DAV:">
<!-- default namespace property names are valid RFC 4918 XML -->
<prop><displayname/></prop>
</propfind>`,
})
expect(defaultNamespace.status).toBe(207)
expect(await defaultNamespace.text()).toContain('<D:displayname>Docs</D:displayname>')
const allprop = await app.request(`/dav/${workspace.slug}/Docs`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<D:propfind xmlns:D="DAV:"><D:allprop/><D:include><D:displayname/><D:quota-used-bytes/></D:include></D:propfind>',
})
expect(allprop.status).toBe(207)
expect(await allprop.text()).toContain('HTTP/1.1 404 Not Found')
const invalidRequestType = await app.request(`/dav/${workspace.slug}/Docs`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<propfind xmlns="DAV:"><prop/><allprop/></propfind>',
})
expect(invalidRequestType.status).toBe(400)
const infinity = await app.request(`/dav/${workspace.slug}/Docs`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: 'infinity' }),
})
expect(infinity.status).toBe(403)
expect(await infinity.text()).toContain('propfind-finite-depth')
})
it('PROPPATCH stores and removes dead properties visible to later PROPFIND [spec: webdav/proppatch]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] })
await file(db, workspace.id, { id: 'dead-props', name: 'dead-props.txt' })
const set = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: `<D:propertyupdate xmlns:D="DAV:" xmlns:Z="urn:zpan:test">
<D:set><D:prop><Z:color>blue</Z:color></D:prop></D:set>
</D:propertyupdate>`,
})
expect(set.status).toBe(207)
expect(await set.text()).toContain('HTTP/1.1 200 OK')
const find = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: '<D:propfind xmlns:D="DAV:" xmlns:Z="urn:zpan:test"><D:prop><Z:color/></D:prop></D:propfind>',
})
expect(find.status).toBe(207)
expect(await find.text()).toContain('blue</Z:color>')
const defaultDeadPropertyNamespace = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: `<D:propertyupdate xmlns:D="DAV:" xmlns:Z="urn:zpan:test">
<D:set><D:prop><Z:finish xmlns:Z="urn:zpan:test">matte</Z:finish></D:prop></D:set>
<D:set><D:prop><color xmlns="urn:zpan:test">red</color></D:prop></D:set>
<D:set><D:prop xmlns="urn:zpan:test"><pattern>striped</pattern></D:prop></D:set>
</D:propertyupdate>`,
})
expect(defaultDeadPropertyNamespace.status).toBe(207)
const defaultDeadPropertyFind = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: '<D:propfind xmlns:D="DAV:" xmlns="urn:zpan:test" xmlns:Z="urn:zpan:test"><D:prop><Z:finish/><color/><pattern/></D:prop></D:propfind>',
})
const defaultDeadPropertyXml = await defaultDeadPropertyFind.text()
expect(defaultDeadPropertyXml).toContain('<Z:finish xmlns:Z="urn:zpan:test">matte</Z:finish>')
expect(defaultDeadPropertyXml).toContain('<color xmlns="urn:zpan:test">red</color>')
expect(defaultDeadPropertyXml).toContain('<pattern xmlns="urn:zpan:test">striped</pattern>')
const invalid = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<D:propertyupdate xmlns:D="DAV:"><D:set><D:prop><D:getetag>bad</D:getetag></D:prop></D:set></D:propertyupdate>',
})
expect(invalid.status).toBe(403)
const badInstruction = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<D:propertyupdate xmlns:D="DAV:"><D:bad/></D:propertyupdate>',
})
expect(badInstruction.status).toBe(403)
const missingProp = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<D:propertyupdate xmlns:D="DAV:"><D:set/></D:propertyupdate>',
})
expect(missingProp.status).toBe(403)
const atomicFailure = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: `<propertyupdate xmlns="DAV:" xmlns:Z="urn:zpan:test">
<set><prop><Z:shape>circle</Z:shape></prop></set>
<set><prop><getetag>bad</getetag></prop></set>
</propertyupdate>`,
})
expect(atomicFailure.status).toBe(403)
const afterAtomicFailure = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: '<propfind xmlns="DAV:" xmlns:Z="urn:zpan:test"><prop><Z:shape/></prop></propfind>',
})
expect(await afterAtomicFailure.text()).toContain('HTTP/1.1 404 Not Found')
const remove = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<D:propertyupdate xmlns:D="DAV:" xmlns:Z="urn:zpan:test"><D:remove><D:prop><Z:color/></D:prop></D:remove></D:propertyupdate>',
})
expect(remove.status).toBe(207)
const removed = await app.request(`/dav/${workspace.slug}/dead-props.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: '<D:propfind xmlns:D="DAV:" xmlns:Z="urn:zpan:test"><D:prop><Z:color/></D:prop></D:propfind>',
})
expect(await removed.text()).toContain('HTTP/1.1 404 Not Found')
const rootSet = await app.request(`/dav/${workspace.slug}/`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<D:propertyupdate xmlns:D="DAV:" xmlns:Z="urn:zpan:test"><D:set><D:prop><Z:root>yes</Z:root></D:prop></D:set></D:propertyupdate>',
})
expect(rootSet.status).toBe(207)
const rootFind = await app.request(`/dav/${workspace.slug}/`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: '<D:propfind xmlns:D="DAV:" xmlns:Z="urn:zpan:test"><D:prop><Z:root/></D:prop></D:propfind>',
})
expect(await rootFind.text()).toContain('yes</Z:root>')
})
it('GET returns file bytes directly and HEAD returns coherent file headers [spec: webdav/get]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read'] })
await file(db, workspace.id, { id: 'readme', name: 'readme.txt', size: 12 })
const head = await app.request(`/dav/${workspace.slug}/readme.txt`, {
method: 'HEAD',
headers: basicHeaders(account.email, key),
})
expect(head.status).toBe(200)
expect(head.headers.get('Content-Type')).toBe('text/plain')
expect(head.headers.get('Content-Length')).toBe('12')
expect(head.headers.get('ETag')).toMatch(/^"readme-12-\d+"$/)
expect(head.headers.get('Last-Modified')).toBeTruthy()
const get = await app.request(`/dav/${workspace.slug}/readme.txt`, {
method: 'GET',
headers: basicHeaders(account.email, key),
})
expect(get.status).toBe(200)
expect(get.headers.get('Content-Type')).toBe('text/plain')
expect(get.headers.get('Content-Length')).toBe('12')
expect(get.headers.get('ETag')).toBe(head.headers.get('ETag'))
expect(get.headers.get('Last-Modified')).toBe(head.headers.get('Last-Modified'))
expect(get.headers.get('Location')).toBeNull()
expect(await get.text()).toBe('hello webdav')
expect(S3Service.prototype.presignDownload).not.toHaveBeenCalled()
expect(S3Service.prototype.getObjectBody).toHaveBeenCalledWith(
expect.objectContaining({ id: storage.id }),
'objects/readme.txt',
)
expect(S3Service.prototype.getObjectBytes).not.toHaveBeenCalled()
const events = await db.all<{ metadata: string }>(sql`
SELECT metadata FROM audit_events
WHERE action = 'webdav_download' AND target_id = 'readme'
`)
expect(events).toHaveLength(1)
expect(JSON.parse(events[0].metadata)).toMatchObject({
bytes: 12,
source: 'webdav_download',
status: 'issued',
matterId: 'readme',
})
})
it('returns a streamed GET before Workers background bookkeeping completes', async () => {
const { app, db, auth, deps } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read'] })
await file(db, workspace.id, { id: 'background-read', name: 'background.txt', size: 12 })
const originalRecord = deps.audit.record
let releaseAudit: (() => void) | undefined
const auditGate = new Promise<void>((resolve) => {
releaseAudit = resolve
})
vi.spyOn(deps.audit, 'record').mockImplementation(async (event) => {
if (event.action === 'webdav_download') await auditGate
await originalRecord(event)
})
const backgroundTasks: Promise<unknown>[] = []
const executionCtx = {
waitUntil(task: Promise<unknown>) {
backgroundTasks.push(task)
},
passThroughOnException() {},
props: undefined,
}
const response = await app.fetch(
new Request(`http://localhost/dav/${workspace.slug}/background.txt`, {
method: 'GET',
headers: basicHeaders(account.email, key),
}),
undefined,
executionCtx,
)
expect(response.status).toBe(200)
expect(backgroundTasks.length).toBeGreaterThan(0)
expect(await response.text()).toBe('hello webdav')
releaseAudit?.()
await Promise.all(backgroundTasks)
expect(
await db.all(sql`
SELECT action FROM audit_events
WHERE target_id = 'background-read' AND action = 'webdav_download'
`),
).toEqual([{ action: 'webdav_download' }])
})
it('GET consumes WebDAV traffic while HEAD does not [spec: webdav/get-traffic]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read'] })
await file(db, workspace.id, { id: 'traffic-full', name: 'traffic.txt', size: 12 })
await seedTrafficPlan(db, workspace.id, 1000, 25)
const head = await app.request(`/dav/${workspace.slug}/traffic.txt`, {
method: 'HEAD',
headers: basicHeaders(account.email, key),
})
expect(head.status).toBe(200)
const afterHead = await db.all<{ trafficUsed: number }>(
sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${workspace.id}`,
)
expect(afterHead[0].trafficUsed).toBe(25)
const get = await app.request(`/dav/${workspace.slug}/traffic.txt`, {
method: 'GET',
headers: basicHeaders(account.email, key),
})
expect(get.status).toBe(200)
await expect(get.text()).resolves.toBe('hello webdav')
const rows = await db.all<{ trafficUsed: number }>(
sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${workspace.id}`,
)
expect(rows[0].trafficUsed).toBe(37)
})
it('GET consumes only served WebDAV range bytes and rejects over-quota reads before S3 access', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read'] })
await file(db, workspace.id, { id: 'traffic-range', name: 'range-traffic.txt', size: 12 })
await seedTrafficPlan(db, workspace.id, 30, 25)
vi.mocked(S3Service.prototype.getObjectBody).mockResolvedValueOnce(streamBody('hello'))
const partial = await app.request(`/dav/${workspace.slug}/range-traffic.txt`, {
method: 'GET',
headers: basicHeaders(account.email, key, { Range: 'bytes=0-4' }),
})
expect(partial.status).toBe(206)
await expect(partial.text()).resolves.toBe('hello')
const afterPartial = await db.all<{ trafficUsed: number }>(
sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${workspace.id}`,
)
expect(afterPartial[0].trafficUsed).toBe(30)
const over = await app.request(`/dav/${workspace.slug}/range-traffic.txt`, {
method: 'GET',
headers: basicHeaders(account.email, key, { Range: 'bytes=5-6' }),
})
expect(over.status).toBe(422)
await expect(over.text()).resolves.toBe('Traffic quota exceeded')
expect(S3Service.prototype.getObjectBody).toHaveBeenCalledTimes(1)
const failures = await db.all<{ bytes: number; reason: string; source: string }>(sql`
SELECT json_extract(metadata, '$.bytes') AS bytes,
json_extract(metadata, '$.reason') AS reason,
json_extract(metadata, '$.source') AS source
FROM audit_events
WHERE action = 'download_failed' AND target_id = 'traffic-range'
`)
expect(failures).toEqual([{ bytes: 2, reason: 'quota_exceeded', source: 'webdav_download' }])
const invalid = await app.request(`/dav/${workspace.slug}/range-traffic.txt`, {
method: 'GET',
headers: basicHeaders(account.email, key, { Range: 'bytes=99-100' }),
})
expect(invalid.status).toBe(416)
const afterInvalid = await db.all<{ trafficUsed: number }>(
sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${workspace.id}`,
)
expect(afterInvalid[0].trafficUsed).toBe(30)
})
it('GET reports metered WebDAV traffic for cloud billing', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
await db.run(sql`
UPDATE storages
SET egress_credit_billing_enabled = 1, egress_credit_unit_bytes = 100, egress_credit_per_unit = 2
WHERE id = ${storage.id}
`)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read'] })
await file(db, workspace.id, { id: 'traffic-report', name: 'report.txt', size: 12 })
await seedTrafficPlan(db, workspace.id, 1000, 0)
const res = await app.request(`/dav/${workspace.slug}/report.txt`, {
method: 'GET',
headers: basicHeaders(account.email, key),
})
expect(res.status).toBe(200)
await res.text()
const reports = await db.all<{ source: string; sourceId: string; bytes: number; status: string }>(sql`
SELECT source, source_id AS sourceId, bytes, status
FROM cloud_traffic_reports
WHERE org_id = ${workspace.id}
`)
expect(reports).toEqual([
{ source: 'webdav_download', sourceId: 'traffic-report', bytes: 12, status: 'not_required' },
])
})
it('GET supports valid byte ranges and rejects invalid ranges [spec: webdav/get-range]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read'] })
await file(db, workspace.id, { id: 'range', name: 'range.txt', size: 12 })
vi.mocked(S3Service.prototype.getObjectBody).mockResolvedValueOnce(streamBody('hello'))
const partial = await app.request(`/dav/${workspace.slug}/range.txt`, {
method: 'GET',
headers: basicHeaders(account.email, key, { Range: 'bytes=0-4' }),
})
expect(partial.status).toBe(206)
expect(partial.headers.get('Content-Range')).toBe('bytes 0-4/12')
expect(partial.headers.get('Content-Length')).toBe('5')
expect(await partial.text()).toBe('hello')
expect(S3Service.prototype.getObjectBody).toHaveBeenCalledWith(
expect.objectContaining({ id: storage.id }),
'objects/range.txt',
'bytes=0-4',
)
const invalid = await app.request(`/dav/${workspace.slug}/range.txt`, {
method: 'GET',
headers: basicHeaders(account.email, key, { Range: 'bytes=99-100' }),
})
expect(invalid.status).toBe(416)
expect(invalid.headers.get('Content-Range')).toBe('bytes */12')
vi.mocked(S3Service.prototype.getObjectBody).mockResolvedValueOnce(streamBody('dav'))
const suffix = await app.request(`/dav/${workspace.slug}/range.txt`, {
method: 'GET',
headers: basicHeaders(account.email, key, { Range: 'bytes=-3' }),
})
expect(suffix.status).toBe(206)
expect(suffix.headers.get('Content-Range')).toBe('bytes 9-11/12')
expect(await suffix.text()).toBe('dav')
expect(S3Service.prototype.getObjectBytes).not.toHaveBeenCalled()
})
it('serves WebDAVFS ranges exactly for mounted media reads', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read'] })
await file(db, workspace.id, { id: 'mounted-media', name: 'audio.mp3', size: 2 * 1024 * 1024 })
vi.mocked(S3Service.prototype.getObjectBody).mockResolvedValueOnce(streamBody('chunk'))
const partial = await app.request(`/dav/${workspace.slug}/audio.mp3`, {
method: 'GET',
headers: basicHeaders(account.email, key, {
Range: 'bytes=419430-419430',
'User-Agent': 'WebDAVFS/3.0.0 (03008000) Darwin/24.6.0 (arm64)',
}),
})
expect(partial.status).toBe(206)
expect(partial.headers.get('Content-Range')).toBe('bytes 419430-419430/2097152')
expect(partial.headers.get('Content-Length')).toBe('1')
expect(partial.headers.get('Cache-Control')).toBe('no-store')
expect(partial.headers.get('ETag')).toBeNull()
expect(S3Service.prototype.getObjectBody).toHaveBeenCalledWith(
expect.objectContaining({ id: storage.id }),
'objects/mounted-media.txt',
'bytes=419430-419430',
)
})
it('GET supports multi-range requests and honors If-Range validators', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read'] })
await file(db, workspace.id, { id: 'if-range', name: 'video.mp4', size: 12 })
const head = await app.request(`/dav/${workspace.slug}/video.mp4`, {
method: 'HEAD',
headers: basicHeaders(account.email, key),
})
const etag = head.headers.get('ETag') ?? ''
vi.mocked(S3Service.prototype.getObjectBody).mockResolvedValueOnce(streamBody('hello'))
const matched = await app.request(`/dav/${workspace.slug}/video.mp4`, {
method: 'GET',
headers: basicHeaders(account.email, key, { Range: 'bytes=0-4', 'If-Range': etag }),
})
expect(matched.status).toBe(206)
expect(matched.headers.get('Content-Range')).toBe('bytes 0-4/12')
expect(await matched.text()).toBe('hello')
expect(S3Service.prototype.getObjectBody).toHaveBeenLastCalledWith(
expect.objectContaining({ id: storage.id }),
'objects/if-range.txt',
'bytes=0-4',
)
const stale = await app.request(`/dav/${workspace.slug}/video.mp4`, {
method: 'GET',
headers: basicHeaders(account.email, key, { Range: 'bytes=0-4', 'If-Range': '"stale"' }),
})
expect(stale.status).toBe(200)
expect(stale.headers.get('Content-Range')).toBeNull()
expect(await stale.text()).toBe('hello webdav')
expect(S3Service.prototype.getObjectBody).toHaveBeenLastCalledWith(
expect.objectContaining({ id: storage.id }),
'objects/if-range.txt',
)
vi.mocked(S3Service.prototype.getObjectBody)
.mockResolvedValueOnce(streamBody('he'))
.mockResolvedValueOnce(streamBody('o '))
const multi = await app.request(`/dav/${workspace.slug}/video.mp4`, {
method: 'GET',
headers: basicHeaders(account.email, key, { Range: 'bytes=0-1,4-5' }),
})
expect(multi.status).toBe(206)
expect(multi.headers.get('Content-Range')).toBeNull()
expect(multi.headers.get('Content-Type')).toBe('multipart/byteranges; boundary=zpan-webdav-if-range')
expect(multi.headers.get('Content-Length')).toBeTruthy()
expect(await multi.text()).toBe(
'--zpan-webdav-if-range\r\nContent-Type: text/plain\r\nContent-Range: bytes 0-1/12\r\n\r\nhe\r\n' +
'--zpan-webdav-if-range\r\nContent-Type: text/plain\r\nContent-Range: bytes 4-5/12\r\n\r\no \r\n' +
'--zpan-webdav-if-range--\r\n',
)
expect(S3Service.prototype.getObjectBody).toHaveBeenLastCalledWith(
expect.objectContaining({ id: storage.id }),
'objects/if-range.txt',
'bytes=4-5',
)
const unknownUnit = await app.request(`/dav/${workspace.slug}/video.mp4`, {
method: 'GET',
headers: basicHeaders(account.email, key, { Range: 'items=0-1' }),
})
expect(unknownUnit.status).toBe(200)
expect(unknownUnit.headers.get('Content-Range')).toBeNull()
})
it('honors ETag preconditions and changes ETag after overwrite [spec: webdav/etag-preconditions]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] })
await file(db, workspace.id, { id: 'precondition', name: 'precondition.txt', size: 12 })
const head = await app.request(`/dav/${workspace.slug}/precondition.txt`, {
method: 'HEAD',
headers: basicHeaders(account.email, key),
})
const etag = head.headers.get('ETag')
expect(etag).toBeTruthy()
const matched = await app.request(`/dav/${workspace.slug}/precondition.txt`, {
method: 'HEAD',
headers: basicHeaders(account.email, key, { 'If-Match': etag ?? '' }),
})
expect(matched.status).toBe(200)
const notModified = await app.request(`/dav/${workspace.slug}/precondition.txt`, {
method: 'GET',
headers: basicHeaders(account.email, key, { 'If-None-Match': etag ?? '' }),
})
expect(notModified.status).toBe(304)
const rejected = await app.request(`/dav/${workspace.slug}/precondition.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { 'If-Match': '"stale"', 'Content-Type': 'text/plain' }),
body: 'new bytes',
})
expect(rejected.status).toBe(412)
const noneMatchRejected = await app.request(`/dav/${workspace.slug}/precondition.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { 'If-None-Match': etag ?? '', 'Content-Type': 'text/plain' }),
body: 'new bytes',
})
expect(noneMatchRejected.status).toBe(412)
const overwrite = await app.request(`/dav/${workspace.slug}/precondition.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
'If-Match': etag ?? '',
'Content-Type': 'text/plain',
'Content-Length': '9',
}),
body: 'new bytes',
})
expect(overwrite.status).toBe(204)
const updated = await app.request(`/dav/${workspace.slug}/precondition.txt`, {
method: 'HEAD',
headers: basicHeaders(account.email, key),
})
expect(updated.headers.get('ETag')).not.toBe(etag)
})
it('does not return 304 for WebDAVFS media reads', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read'] })
await file(db, workspace.id, { id: 'webdavfs-cache', name: 'cached.mp3', size: 12 })
const head = await app.request(`/dav/${workspace.slug}/cached.mp3`, {
method: 'HEAD',
headers: basicHeaders(account.email, key),
})
const etag = head.headers.get('ETag') ?? ''
const cached = await app.request(`/dav/${workspace.slug}/cached.mp3`, {
method: 'GET',
headers: basicHeaders(account.email, key, {
'If-None-Match': etag,
'User-Agent': 'WebDAVFS/3.0.0 (03008000) Darwin/24.6.0 (arm64)',
}),
})
expect(cached.status).toBe(200)
expect(await cached.text()).toBe('hello webdav')
expect(S3Service.prototype.getObjectBody).toHaveBeenLastCalledWith(
expect.objectContaining({ id: storage.id }),
'objects/webdavfs-cache.txt',
)
})
it('honors HTTP date preconditions', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] })
await file(db, workspace.id, { id: 'date-precondition', name: 'date.txt', size: 12 })
const head = await app.request(`/dav/${workspace.slug}/date.txt`, {
method: 'HEAD',
headers: basicHeaders(account.email, key),
})
const lastModified = head.headers.get('Last-Modified') ?? ''
const stale = new Date(Date.parse(lastModified) - 1000).toUTCString()
const fresh = new Date(Date.parse(lastModified) + 1000).toUTCString()
const notModified = await app.request(`/dav/${workspace.slug}/date.txt`, {
method: 'GET',
headers: basicHeaders(account.email, key, { 'If-Modified-Since': fresh }),
})
expect(notModified.status).toBe(304)
const staleWrite = await app.request(`/dav/${workspace.slug}/date.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
'If-Unmodified-Since': stale,
'Content-Type': 'text/plain',
'Content-Length': '7',
}),
body: 'changed',
})
expect(staleWrite.status).toBe(412)
const freshWrite = await app.request(`/dav/${workspace.slug}/date.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
'If-Unmodified-Since': fresh,
'Content-Type': 'text/plain',
'Content-Length': '7',
}),
body: 'changed',
})
expect(freshWrite.status).toBe(204)
})
it('OPTIONS advertises DAV methods [spec: webdav/options]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read'] })
const res = await app.request('/dav/', { method: 'OPTIONS', headers: basicHeaders(account.email, key) })
expect(res.status).toBe(204)
expect(res.headers.get('DAV')).toBe('1, 2')
expect(res.headers.get('Allow')).toContain('PROPFIND')
expect(res.headers.get('Allow')).toContain('LOCK')
})
it('rejects API keys when verification throws', async () => {
const { app, auth } = await createTestApp()
// biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API is not fully typed
vi.spyOn(auth.api as any, 'verifyApiKey').mockRejectedValueOnce(new Error('verify failed'))
const res = await app.request('/dav/', { method: 'PROPFIND', headers: basicHeaders('bad@example.com', 'bad-key') })
expect(res.status).toBe(401)
expect(res.headers.get('WWW-Authenticate')).toBe('Basic realm="ZPan WebDAV"')
})
it('PUT creates a file matter and writes through configured storage [spec: webdav/put-create]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['write'] })
const res = await app.request(`/dav/${workspace.slug}/upload.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { 'Content-Type': 'text/plain', 'Content-Length': '9' }),
body: 'hello dav',
})
expect(res.status).toBe(201)
expect(S3Service.prototype.putObject).toHaveBeenCalledWith(
expect.objectContaining({ id: storage.id }),
expect.any(String),
expect.any(ReadableStream),
'text/plain',
9,
)
expect(S3Service.prototype.putObject).not.toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.any(Uint8Array),
expect.anything(),
expect.anything(),
)
const rows = await db.all<{ name: string; size: number; status: string }>(
sql`SELECT name, size, status FROM matters WHERE org_id = ${workspace.id} AND name = 'upload.txt'`,
)
expect(rows[0]).toEqual({ name: 'upload.txt', size: 9, status: 'active' })
const uploads = await db.all<{ bytes: number; source: string }>(sql`
SELECT json_extract(metadata, '$.bytes') AS bytes, json_extract(metadata, '$.source') AS source
FROM audit_events
WHERE action = 'upload_confirm' AND target_name = 'upload.txt'
`)
expect(uploads).toEqual([{ bytes: 9, source: 'webdav_upload' }])
})
it('PUT accepts requests without Content-Length and stores the measured size', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['write'] })
const res = await app.request(`/dav/${workspace.slug}/upload.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { 'Content-Type': 'text/plain' }),
body: 'hello dav',
})
expect(res.status).toBe(201)
expect(S3Service.prototype.putObject).toHaveBeenCalledWith(
expect.anything(),
expect.any(String),
expect.any(ReadableStream),
'text/plain',
undefined,
)
const rows = await db.all<{ name: string; size: number }>(
sql`SELECT name, size FROM matters WHERE org_id = ${workspace.id} AND name = 'upload.txt'`,
)
expect(rows[0]).toEqual({ name: 'upload.txt', size: 9 })
})
it('PUT updates an existing file matter and rejects collection writes [spec: webdav/put-update]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['write'] })
await file(db, workspace.id, { id: 'existing', name: 'existing', size: 20 })
await folder(db, workspace.id, { id: 'docs', name: 'Docs' })
const update = await app.request(`/dav/${workspace.slug}/existing`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
'Content-Type': 'application/octet-stream',
'Content-Length': '5',
}),
body: 'short',
})
expect(update.status).toBe(204)
const rows = await db.all<{ size: number; type: string }>(sql`SELECT size, type FROM matters WHERE id = 'existing'`)
expect(rows[0]).toEqual({ size: 5, type: 'application/octet-stream' })
const uploads = await db.all<{ bytes: number; matterId: string }>(sql`
SELECT json_extract(metadata, '$.bytes') AS bytes, json_extract(metadata, '$.matterId') AS matterId
FROM audit_events
WHERE action = 'upload_confirm' AND target_id = 'existing'
`)
expect(uploads).toEqual([{ bytes: 5, matterId: 'existing' }])
const ledger = await db.all<{ bytes: number }>(sql`
SELECT COALESCE(SUM(delta_bytes), 0) AS bytes
FROM storage_usage_ledger
WHERE org_id = ${workspace.id} AND storage_id = ${storage.id}
`)
expect(ledger[0].bytes).toBe(5)
const root = await app.request(`/dav/${workspace.slug}/`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { 'Content-Length': '4' }),
body: 'nope',
})
expect(root.status).toBe(405)
const folderWrite = await app.request(`/dav/${workspace.slug}/Docs`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { 'Content-Length': '4' }),
body: 'nope',
})
expect(folderWrite.status).toBe(409)
})
it('PUT rolls back quota reservation when storage write fails [spec: webdav/put-rollback]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['write'] })
vi.mocked(S3Service.prototype.putObject).mockRejectedValueOnce(new Error('s3 failed'))
const res = await app.request(`/dav/${workspace.slug}/will-fail.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { 'Content-Type': 'text/plain', 'Content-Length': '5' }),
body: 'bytes',
})
expect(res.status).toBe(500)
const rows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storage.id}`)
expect(rows[0]?.used).toBe(0)
})
it('MKCOL creates a folder matter [spec: webdav/mkcol]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['write'] })
const res = await app.request(`/dav/${workspace.slug}/Projects`, {
method: 'MKCOL',
headers: basicHeaders(account.email, key),
})
expect(res.status).toBe(201)
const rows = await db.all<{ dirtype: number }>(
sql`SELECT dirtype FROM matters WHERE org_id = ${workspace.id} AND name = 'Projects'`,
)
expect(rows[0]?.dirtype).toBe(1)
})
it('MKCOL rejects existing targets and missing parent collections [spec: webdav/mkcol-guards]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['write'] })
await folder(db, workspace.id, { id: 'projects', name: 'Projects' })
await file(db, workspace.id, { id: 'file-parent', name: 'file-parent.txt' })
const existing = await app.request(`/dav/${workspace.slug}/Projects`, {
method: 'MKCOL',
headers: basicHeaders(account.email, key),
})
expect(existing.status).toBe(405)
const missingParent = await app.request(`/dav/${workspace.slug}/Missing/Child`, {
method: 'MKCOL',
headers: basicHeaders(account.email, key),
})
expect(missingParent.status).toBe(409)
const fileParent = await app.request(`/dav/${workspace.slug}/file-parent.txt/Child`, {
method: 'MKCOL',
headers: basicHeaders(account.email, key),
})
expect(fileParent.status).toBe(405)
const unsupportedBody = await app.request(`/dav/${workspace.slug}/BodyCollection`, {
method: 'MKCOL',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<D:mkcol xmlns:D="DAV:"/>',
})
expect(unsupportedBody.status).toBe(415)
})
it('MOVE, COPY, and DELETE stay within org scope; DELETE trashes instead of purging [spec: webdav/org-scope]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const secondWorkspace = await teamWorkspace(db, {
id: 'second-dav',
slug: 'second-dav',
userId: account.id,
name: 'Second DAV',
})
const key = await apiKey(auth, account.id, { webdav: ['write'] })
await file(db, workspace.id, { id: 'move-me', name: 'move-me.txt' })
const move = await app.request(`/dav/${workspace.slug}/move-me.txt`, {
method: 'MOVE',
headers: basicHeaders(account.email, key, { Destination: `http://localhost/dav/${workspace.slug}/moved.txt` }),
})
expect(move.status).toBe(201)
const copy = await app.request(`/dav/${workspace.slug}/moved.txt`, {
method: 'COPY',
headers: basicHeaders(account.email, key, { Destination: `http://localhost/dav/${workspace.slug}/copied.txt` }),
})
expect(copy.status).toBe(201)
const badMove = await app.request(`/dav/${workspace.slug}/moved.txt`, {
method: 'MOVE',
headers: basicHeaders(account.email, key, { Destination: 'http://localhost/dav/other-workspace/nope.txt' }),
})
expect(badMove.status).toBe(404)
const crossWorkspaceMove = await app.request(`/dav/${workspace.slug}/moved.txt`, {
method: 'MOVE',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${secondWorkspace.slug}/cross-move.txt`,
}),
})
expect(crossWorkspaceMove.status).toBe(403)
const crossWorkspaceCopy = await app.request(`/dav/${workspace.slug}/moved.txt`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${secondWorkspace.slug}/cross-copy.txt`,
}),
})
expect(crossWorkspaceCopy.status).toBe(403)
const del = await app.request(`/dav/${workspace.slug}/copied.txt`, {
method: 'DELETE',
headers: basicHeaders(account.email, key),
})
expect(del.status).toBe(204)
const rows = await db.all<{ trashed_at: number | null }>(
sql`SELECT trashed_at FROM matters WHERE org_id = ${workspace.id} AND name = 'copied.txt'`,
)
// Soft delete: the row stays active with trashedAt set.
expect(rows[0]?.trashed_at).not.toBeNull()
})
it('MOVE and COPY reject invalid destinations', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['write'] })
await file(db, workspace.id, { id: 'source', name: 'source.txt' })
await file(db, workspace.id, { id: 'target', name: 'target.txt' })
const noDestination = await app.request(`/dav/${workspace.slug}/source.txt`, {
method: 'MOVE',
headers: basicHeaders(account.email, key),
})
expect(noDestination.status).toBe(400)
const crossOrigin = await app.request(`/dav/${workspace.slug}/source.txt`, {
method: 'MOVE',
headers: basicHeaders(account.email, key, {
Destination: `https://example.com/dav/${workspace.slug}/moved.txt`,
}),
})
expect(crossOrigin.status).toBe(400)
const existing = await app.request(`/dav/${workspace.slug}/source.txt`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/target.txt`,
Overwrite: 'F',
}),
})
expect(existing.status).toBe(412)
const root = await app.request(`/dav/${workspace.slug}/source.txt`, {
method: 'COPY',
headers: basicHeaders(account.email, key, { Destination: `http://localhost/dav/${workspace.slug}/` }),
})
expect(root.status).toBe(405)
})
it('COPY recursively copies collections and rejects copying into own descendant [spec: webdav/copy-recursive]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['write'] })
await folder(db, workspace.id, { id: 'src-folder', name: 'Source' })
await folder(db, workspace.id, { id: 'nested-folder', name: 'Nested', parent: 'Source' })
await file(db, workspace.id, { id: 'nested-file', name: 'note.txt', parent: 'Source/Nested', size: 12 })
const copied = await app.request(`/dav/${workspace.slug}/Source`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/Copied`,
Depth: 'infinity',
}),
})
expect(copied.status).toBe(201)
const rows = await db.all<{ name: string; parent: string }>(
sql`SELECT name, parent FROM matters WHERE org_id = ${workspace.id} AND status = 'active' AND parent LIKE 'Copied%' ORDER BY parent, name`,
)
expect(rows).toContainEqual({ name: 'Nested', parent: 'Copied' })
expect(rows).toContainEqual({ name: 'note.txt', parent: 'Copied/Nested' })
const descendant = await app.request(`/dav/${workspace.slug}/Source`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/Source/Child`,
}),
})
expect(descendant.status).toBe(403)
const badDepth = await app.request(`/dav/${workspace.slug}/Source`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/BadDepth`,
Depth: '1',
}),
})
expect(badDepth.status).toBe(400)
await folder(db, workspace.id, { id: 'existing-copy-root', name: 'ExistingCopy' })
const replacedCollection = await app.request(`/dav/${workspace.slug}/Source`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/ExistingCopy`,
Depth: '0',
}),
})
expect(replacedCollection.status).toBe(204)
})
it('COPY enforces destination locks and rolls back collection copy quota on storage failure', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['write'] })
await folder(db, workspace.id, { id: 'locked-target', name: 'LockedTarget' })
await file(db, workspace.id, { id: 'copy-locked-source', name: 'locked-source.txt', size: 12 })
const locked = await app.request(`/dav/${workspace.slug}/LockedTarget`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype><owner>tester</owner></lockinfo>',
})
expect(locked.status).toBe(200)
const blocked = await app.request(`/dav/${workspace.slug}/locked-source.txt`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/LockedTarget/locked-source.txt`,
}),
})
expect(blocked.status).toBe(423)
await folder(db, workspace.id, { id: 'rollback-source', name: 'RollbackSource' })
await file(db, workspace.id, { id: 'rollback-file', name: 'data.bin', parent: 'RollbackSource', size: 12 })
vi.mocked(S3Service.prototype.copyObject).mockRejectedValueOnce(new Error('copy failed'))
const failed = await app.request(`/dav/${workspace.slug}/RollbackSource`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/RollbackCopy`,
Depth: 'infinity',
}),
})
expect(failed.status).toBe(500)
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storage.id}`)
expect(storageRows[0]?.used).toBe(0)
const partialRows = await db.all<{ name: string }>(
sql`SELECT name FROM matters WHERE org_id = ${workspace.id} AND status = 'active' AND (name = 'RollbackCopy' OR parent LIKE 'RollbackCopy%')`,
)
expect(partialRows).toEqual([])
})
it('MOVE keeps collection descendant paths consistent and rejects descendant moves [spec: webdav/move-descendants]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['write'] })
await folder(db, workspace.id, { id: 'move-folder', name: 'MoveMe' })
await folder(db, workspace.id, { id: 'move-child', name: 'Child', parent: 'MoveMe' })
await file(db, workspace.id, { id: 'move-file', name: 'note.txt', parent: 'MoveMe/Child' })
const moved = await app.request(`/dav/${workspace.slug}/MoveMe`, {
method: 'MOVE',
headers: basicHeaders(account.email, key, { Destination: `http://localhost/dav/${workspace.slug}/Moved` }),
})
expect(moved.status).toBe(201)
const rows = await db.all<{ id: string; parent: string }>(
sql`SELECT id, parent FROM matters WHERE id IN ('move-child', 'move-file') ORDER BY id`,
)
expect(rows).toEqual([
{ id: 'move-child', parent: 'Moved' },
{ id: 'move-file', parent: 'Moved/Child' },
])
const descendant = await app.request(`/dav/${workspace.slug}/Moved`, {
method: 'MOVE',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/Moved/Child/Sub`,
}),
})
expect(descendant.status).toBe(403)
})
it('write methods enforce WebDAV If and lock preconditions before mutations [spec: webdav/lock-preconditions]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['write'] })
await file(db, workspace.id, { id: 'guarded-file', name: 'guarded.txt' })
await file(db, workspace.id, { id: 'move-guarded-file', name: 'move-guarded.txt' })
await file(db, workspace.id, { id: 'copy-guarded-file', name: 'copy-guarded.txt' })
await file(db, workspace.id, { id: 'delete-guarded-file', name: 'delete-guarded.txt' })
const proppatchIfFailed = await app.request(`/dav/${workspace.slug}/guarded.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { If: '(["stale"])', 'Content-Type': 'application/xml' }),
body: '<propertyupdate xmlns="DAV:" xmlns:Z="urn:zpan:test"><set><prop><Z:color>blue</Z:color></prop></set></propertyupdate>',
})
expect(proppatchIfFailed.status).toBe(412)
const mkcolIfFailed = await app.request(`/dav/${workspace.slug}/BlockedByIf`, {
method: 'MKCOL',
headers: basicHeaders(account.email, key, { If: '(["stale"])' }),
})
expect(mkcolIfFailed.status).toBe(412)
const deleteIfFailed = await app.request(`/dav/${workspace.slug}/delete-guarded.txt`, {
method: 'DELETE',
headers: basicHeaders(account.email, key, { If: '(["stale"])' }),
})
expect(deleteIfFailed.status).toBe(412)
const moveIfFailed = await app.request(`/dav/${workspace.slug}/move-guarded.txt`, {
method: 'MOVE',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/moved-guarded.txt`,
If: '(["stale"])',
}),
})
expect(moveIfFailed.status).toBe(412)
const moveToSelf = await app.request(`/dav/${workspace.slug}/move-guarded.txt`, {
method: 'MOVE',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/move-guarded.txt`,
}),
})
expect(moveToSelf.status).toBe(204)
const copyIfFailed = await app.request(`/dav/${workspace.slug}/copy-guarded.txt`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/copied-guarded.txt`,
If: '(["stale"])',
}),
})
expect(copyIfFailed.status).toBe(412)
const lock = await app.request(`/dav/${workspace.slug}/guarded.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype></lockinfo>',
})
expect(lock.status).toBe(200)
const deleteLocked = await app.request(`/dav/${workspace.slug}/guarded.txt`, {
method: 'DELETE',
headers: basicHeaders(account.email, key),
})
expect(deleteLocked.status).toBe(423)
})
it('DELETE on collections removes descendants from WebDAV listings', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] })
await folder(db, workspace.id, { id: 'delete-folder', name: 'DeleteMe' })
await file(db, workspace.id, { id: 'delete-file', name: 'gone.txt', parent: 'DeleteMe' })
const del = await app.request(`/dav/${workspace.slug}/DeleteMe`, {
method: 'DELETE',
headers: basicHeaders(account.email, key),
})
expect(del.status).toBe(204)
const listing = await app.request(`/dav/${workspace.slug}/DeleteMe`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key),
})
expect(listing.status).toBe(404)
const rows = await db.all<{ id: string; trashed_at: number | null }>(
sql`SELECT id, trashed_at FROM matters WHERE id IN ('delete-folder', 'delete-file') ORDER BY id`,
)
// Soft delete cascades trashedAt to the whole subtree (status stays active).
expect(rows).toHaveLength(2)
expect(rows.every((r) => r.trashed_at !== null)).toBe(true)
})
it('moves, copies, and deletes WebDAV dead properties and locks with namespace changes', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] })
await file(db, workspace.id, { id: 'state-file', name: 'state.txt' })
const patch = await app.request(`/dav/${workspace.slug}/state.txt`, {
method: 'PROPPATCH',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<propertyupdate xmlns="DAV:" xmlns:Z="urn:zpan:test"><set><prop><Z:color>green</Z:color></prop></set></propertyupdate>',
})
expect(patch.status).toBe(207)
const lock = await app.request(`/dav/${workspace.slug}/state.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype><owner>tester</owner></lockinfo>',
})
expect(lock.status).toBe(200)
const token = lock.headers.get('Lock-Token') ?? ''
const moved = await app.request(`/dav/${workspace.slug}/state.txt`, {
method: 'MOVE',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/moved-state.txt`,
'Lock-Token': token,
}),
})
expect(moved.status).toBe(201)
const movedProps = await app.request(`/dav/${workspace.slug}/moved-state.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: '<propfind xmlns="DAV:" xmlns:Z="urn:zpan:test"><prop><Z:color/><lockdiscovery/></prop></propfind>',
})
const movedXml = await movedProps.text()
expect(movedXml).toContain('green</Z:color>')
expect(movedXml).toContain(token.slice(1, -1))
const copied = await app.request(`/dav/${workspace.slug}/moved-state.txt`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/copied-state.txt`,
'Lock-Token': token,
}),
})
expect(copied.status).toBe(201)
const copiedProps = await app.request(`/dav/${workspace.slug}/copied-state.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: '<propfind xmlns="DAV:" xmlns:Z="urn:zpan:test"><prop><Z:color/><lockdiscovery/></prop></propfind>',
})
const copiedXml = await copiedProps.text()
expect(copiedXml).toContain('green</Z:color>')
expect(copiedXml).not.toContain(token.slice(1, -1))
const del = await app.request(`/dav/${workspace.slug}/moved-state.txt`, {
method: 'DELETE',
headers: basicHeaders(account.email, key, { 'Lock-Token': token }),
})
expect(del.status).toBe(204)
const recreate = await app.request(`/dav/${workspace.slug}/moved-state.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { 'Content-Type': 'text/plain', 'Content-Length': '3' }),
body: 'new',
})
expect(recreate.status).toBe(201)
const stale = await app.request(`/dav/${workspace.slug}/moved-state.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: '<propfind xmlns="DAV:" xmlns:Z="urn:zpan:test"><prop><Z:color/><lockdiscovery/></prop></propfind>',
})
const staleXml = await stale.text()
expect(staleXml).toContain('HTTP/1.1 404 Not Found')
expect(staleXml).not.toContain(token.slice(1, -1))
})
it('If header evaluates ETag matches, misses, and Not conditions', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] })
await file(db, workspace.id, { id: 'if-file', name: 'if.txt', size: 12 })
const head = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'HEAD',
headers: basicHeaders(account.email, key),
})
const firstEtag = head.headers.get('ETag') ?? ''
const taggedMatch = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
If: `<http://localhost/dav/${workspace.slug}/if.txt> ([${firstEtag}])`,
'Content-Type': 'text/plain',
'Content-Length': '6',
}),
body: 'tagged',
})
expect(taggedMatch.status).toBe(204)
const updatedHead = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'HEAD',
headers: basicHeaders(account.email, key),
})
const etag = updatedHead.headers.get('ETag') ?? ''
const matched = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
If: `([${etag}])`,
'Content-Type': 'text/plain',
'Content-Length': '7',
}),
body: 'matched',
})
expect(matched.status).toBe(204)
const missed = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { If: '(["stale"])', 'Content-Type': 'text/plain' }),
body: 'missed',
})
expect(missed.status).toBe(412)
const randomLockToken = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
If: '(<opaquelocktoken:random>)',
'Content-Type': 'text/plain',
}),
body: 'missed',
})
expect(randomLockToken.status).toBe(412)
const taggedExternalResource = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
If: '<https://example.com/dav/elsewhere> (<opaquelocktoken:random>)',
'Content-Type': 'text/plain',
}),
body: 'missed',
})
expect(taggedExternalResource.status).toBe(412)
const tokenTaggedResource = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
If: '<opaquelocktoken:random> (["stale"])',
'Content-Type': 'text/plain',
}),
body: 'missed',
})
expect(tokenTaggedResource.status).toBe(412)
const emptyStateList = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { If: '()', 'Content-Type': 'text/plain' }),
body: 'missed',
})
expect(emptyStateList.status).toBe(412)
const malformedTaggedUrl = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
If: '<http://[::1> (["stale"])',
'Content-Type': 'text/plain',
}),
body: 'missed',
})
expect(malformedTaggedUrl.status).toBe(412)
const invalidSyntax = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { If: 'Not a state list', 'Content-Type': 'text/plain' }),
body: 'missed',
})
expect(invalidSyntax.status).toBe(412)
const notMatched = await app.request(`/dav/${workspace.slug}/if.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
If: '(Not ["stale"])',
'Content-Type': 'text/plain',
'Content-Length': '11',
}),
body: 'not matched',
})
expect(notMatched.status).toBe(204)
})
it('LOCK and UNLOCK expose Class 2 state and enforce write tokens [spec: webdav/lock-unlock]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] })
await file(db, workspace.id, { id: 'lock-file', name: 'locked.txt', size: 12 })
await file(db, workspace.id, { id: 'other-lock-file', name: 'other-locked.txt', size: 12 })
const locked = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, {
Depth: '0',
Timeout: 'Second-600',
'Content-Type': 'application/xml',
}),
body: '<D:lockinfo xmlns:D="DAV:"><D:lockscope><D:exclusive/></D:lockscope><D:locktype><D:write/></D:locktype><D:owner>tester</D:owner></D:lockinfo>',
})
expect(locked.status).toBe(200)
const token = locked.headers.get('Lock-Token') ?? ''
expect(token).toMatch(/^<opaquelocktoken:/)
expect(await locked.text()).toContain('lockdiscovery')
const discovery = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0' }),
})
const discoveryXml = await discovery.text()
expect(discoveryXml).toContain('supportedlock')
expect(discoveryXml).toContain(token.slice(1, -1))
const rejected = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { 'Content-Type': 'text/plain' }),
body: 'blocked',
})
expect(rejected.status).toBe(423)
const accepted = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
'Lock-Token': token,
'Content-Type': 'text/plain',
'Content-Length': '7',
}),
body: 'allowed',
})
expect(accepted.status).toBe(204)
const acceptedWithIfToken = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, {
If: `(${token})`,
'Content-Type': 'text/plain',
'Content-Length': '13',
}),
body: 'allowed by if',
})
expect(acceptedWithIfToken.status).toBe(204)
const refreshed = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { If: `(${token})`, Timeout: 'Second-1200' }),
})
expect(refreshed.status).toBe(200)
expect(refreshed.headers.get('Lock-Token')).toBeNull()
const refreshWithBody = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { If: `(${token})`, 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype></lockinfo>',
})
expect(refreshWithBody.status).toBe(400)
const refreshWithMultipleTokens = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, {
If: `(${token})(<opaquelocktoken:extra>)`,
Timeout: 'Second-1200',
}),
})
expect(refreshWithMultipleTokens.status).toBe(400)
const conflictingLock = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype></lockinfo>',
})
expect(conflictingLock.status).toBe(423)
const wrongResourceRefresh = await app.request(`/dav/${workspace.slug}/other-locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { If: `(${token})`, Timeout: 'Second-1200' }),
})
expect(wrongResourceRefresh.status).toBe(412)
const badRefresh = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { If: '(<opaquelocktoken:missing>)' }),
})
expect(badRefresh.status).toBe(412)
const shared = await app.request(`/dav/${workspace.slug}/other-locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><shared/></lockscope><locktype><write/></locktype></lockinfo>',
})
expect(shared.status).toBe(422)
const malformedLock = await app.request(`/dav/${workspace.slug}/other-locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope></lockinfo>',
})
expect(malformedLock.status).toBe(422)
const unsupportedDepth = await app.request(`/dav/${workspace.slug}/other-locked.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { Depth: '1', 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype></lockinfo>',
})
expect(unsupportedDepth.status).toBe(400)
const missingLockTarget = await app.request(`/dav/${workspace.slug}/missing-lock-target.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype></lockinfo>',
})
expect(missingLockTarget.status).toBe(201)
const createdToken = missingLockTarget.headers.get('Lock-Token') ?? ''
expect(createdToken).toMatch(/^<opaquelocktoken:/)
expect(await missingLockTarget.text()).toContain('lockdiscovery')
const createdHead = await app.request(`/dav/${workspace.slug}/missing-lock-target.txt`, {
method: 'HEAD',
headers: basicHeaders(account.email, key),
})
expect(createdHead.status).toBe(200)
expect(createdHead.headers.get('Content-Length')).toBe('0')
const missingLockParent = await app.request(`/dav/${workspace.slug}/Missing/missing-lock-target.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype></lockinfo>',
})
expect(missingLockParent.status).toBe(409)
const missingUnlockToken = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'UNLOCK',
headers: basicHeaders(account.email, key),
})
expect(missingUnlockToken.status).toBe(400)
const missingUnlockTarget = await app.request(`/dav/${workspace.slug}/absent-unlock-target.txt`, {
method: 'UNLOCK',
headers: basicHeaders(account.email, key, { 'Lock-Token': token }),
})
expect(missingUnlockTarget.status).toBe(404)
const invalidUnlock = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'UNLOCK',
headers: basicHeaders(account.email, key, { 'Lock-Token': '<opaquelocktoken:bad>' }),
})
expect(invalidUnlock.status).toBe(409)
const unlocked = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'UNLOCK',
headers: basicHeaders(account.email, key, { 'Lock-Token': token }),
})
expect(unlocked.status).toBe(204)
const afterUnlock = await app.request(`/dav/${workspace.slug}/locked.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { 'Content-Type': 'text/plain', 'Content-Length': '5' }),
body: 'after',
})
expect(afterUnlock.status).toBe(204)
})
it('LOCK refresh accepts descendant URLs inside a depth-infinity lock scope only', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const secondWorkspace = await teamWorkspace(db, {
id: 'refresh-other-workspace',
slug: 'refresh-other-workspace',
userId: account.id,
name: 'Refresh Other Workspace',
})
const key = await apiKey(auth, account.id, { webdav: ['write'] })
await folder(db, workspace.id, { id: 'refresh-folder', name: 'RefreshScope' })
await file(db, workspace.id, { id: 'refresh-child', name: 'child.txt', parent: 'RefreshScope' })
await file(db, workspace.id, { id: 'refresh-outside', name: 'outside.txt' })
await folder(db, secondWorkspace.id, { id: 'refresh-other-folder', name: 'RefreshScope' })
await file(db, secondWorkspace.id, {
id: 'refresh-other-child',
name: 'child.txt',
parent: 'RefreshScope',
})
const locked = await app.request(`/dav/${workspace.slug}/RefreshScope`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype><owner>tester</owner></lockinfo>',
})
expect(locked.status).toBe(200)
const token = locked.headers.get('Lock-Token') ?? ''
const descendantRefresh = await app.request(`/dav/${workspace.slug}/RefreshScope/child.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { If: `(${token})`, Timeout: 'Second-1200' }),
})
expect(descendantRefresh.status).toBe(200)
expect(descendantRefresh.headers.get('Lock-Token')).toBeNull()
expect(await descendantRefresh.text()).toContain(token.slice(1, -1))
const outsideRefresh = await app.request(`/dav/${workspace.slug}/outside.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { If: `(${token})`, Timeout: 'Second-1200' }),
})
expect(outsideRefresh.status).toBe(412)
const otherWorkspaceRefresh = await app.request(`/dav/${secondWorkspace.slug}/RefreshScope/child.txt`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { If: `(${token})`, Timeout: 'Second-1200' }),
})
expect(otherWorkspaceRefresh.status).toBe(412)
const outsideUnlock = await app.request(`/dav/${workspace.slug}/outside.txt`, {
method: 'UNLOCK',
headers: basicHeaders(account.email, key, { 'Lock-Token': token }),
})
expect(outsideUnlock.status).toBe(409)
const descendantUnlock = await app.request(`/dav/${workspace.slug}/RefreshScope/child.txt`, {
method: 'UNLOCK',
headers: basicHeaders(account.email, key, { 'Lock-Token': token }),
})
expect(descendantUnlock.status).toBe(204)
const afterDescendantUnlock = await app.request(`/dav/${workspace.slug}/RefreshScope/child.txt`, {
method: 'PUT',
headers: basicHeaders(account.email, key, { 'Content-Type': 'text/plain', 'Content-Length': '12' }),
body: 'after unlock',
})
expect(afterDescendantUnlock.status).toBe(204)
})
it('PROPFIND lockdiscovery includes inherited depth-infinity locks', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] })
await folder(db, workspace.id, { id: 'discovery-folder', name: 'DiscoveryScope' })
await file(db, workspace.id, { id: 'discovery-child', name: 'child.txt', parent: 'DiscoveryScope' })
const locked = await app.request(`/dav/${workspace.slug}/DiscoveryScope`, {
method: 'LOCK',
headers: basicHeaders(account.email, key, { 'Content-Type': 'application/xml' }),
body: '<lockinfo xmlns="DAV:"><lockscope><exclusive/></lockscope><locktype><write/></locktype><owner>tester &amp; maintainer</owner></lockinfo>',
})
expect(locked.status).toBe(200)
const token = locked.headers.get('Lock-Token') ?? ''
const childProps = await app.request(`/dav/${workspace.slug}/DiscoveryScope/child.txt`, {
method: 'PROPFIND',
headers: basicHeaders(account.email, key, { Depth: '0', 'Content-Type': 'application/xml' }),
body: '<propfind xmlns="DAV:"><prop><lockdiscovery/></prop></propfind>',
})
expect(childProps.status).toBe(207)
const xml = await childProps.text()
expect(xml).toContain(token.slice(1, -1))
expect(xml).toContain('<D:depth>infinity</D:depth>')
expect(xml).toContain('<D:owner>tester &amp; maintainer</D:owner>')
})
it('returns WebDAV path errors for missing GET and DELETE targets', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read', 'write'] })
const missingGet = await app.request(`/dav/${workspace.slug}/missing.txt`, {
method: 'GET',
headers: basicHeaders(account.email, key),
})
expect(missingGet.status).toBe(404)
const missingDelete = await app.request(`/dav/${workspace.slug}/missing.txt`, {
method: 'DELETE',
headers: basicHeaders(account.email, key),
})
expect(missingDelete.status).toBe(404)
})
it('MOVE honors Overwrite header for existing destinations [spec: webdav/move-overwrite]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['write'] })
await file(db, workspace.id, { id: 'move-source', name: 'move-source.txt' })
await file(db, workspace.id, { id: 'move-target', name: 'move-target.txt' })
const blocked = await app.request(`/dav/${workspace.slug}/move-source.txt`, {
method: 'MOVE',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/move-target.txt`,
Overwrite: 'F',
}),
})
expect(blocked.status).toBe(412)
const replaced = await app.request(`/dav/${workspace.slug}/move-source.txt`, {
method: 'MOVE',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/move-target.txt`,
}),
})
expect(replaced.status).toBe(204)
const rows = await db.all<{ id: string; name: string; status: string; trashed_at: number | null }>(
sql`SELECT id, name, status, trashed_at FROM matters WHERE id IN ('move-source', 'move-target') ORDER BY id`,
)
// The moved row is live; the overwritten destination is trashed (active + trashedAt).
expect(rows).toEqual([
{ id: 'move-source', name: 'move-target.txt', status: 'active', trashed_at: null },
expect.objectContaining({ id: 'move-target', name: 'move-target.txt', status: 'active' }),
])
expect(rows[1].trashed_at).not.toBeNull()
})
it('COPY honors Overwrite header for existing destinations and copies collection roots', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['write'] })
await file(db, workspace.id, { id: 'copy-source', name: 'copy-source.txt', size: 12 })
await file(db, workspace.id, { id: 'copy-target', name: 'copy-target.txt' })
await folder(db, workspace.id, { id: 'copy-folder', name: 'Copy Folder' })
const blocked = await app.request(`/dav/${workspace.slug}/copy-source.txt`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/copy-target.txt`,
Overwrite: 'F',
}),
})
expect(blocked.status).toBe(412)
const replaced = await app.request(`/dav/${workspace.slug}/copy-source.txt`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/copy-target.txt`,
}),
})
expect(replaced.status).toBe(204)
const rows = await db.all<{ trashed_at: number | null }>(
sql`SELECT trashed_at FROM matters WHERE id = 'copy-target'`,
)
// The overwritten destination is trashed (active + trashedAt).
expect(rows[0]?.trashed_at).not.toBeNull()
const collection = await app.request(`/dav/${workspace.slug}/Copy%20Folder`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/Copied%20Folder`,
Depth: '0',
}),
})
expect(collection.status).toBe(201)
const folders = await db.all<{ name: string; parent: string }>(
sql`SELECT name, parent FROM matters WHERE org_id = ${workspace.id} AND name = 'Copied Folder'`,
)
expect(folders[0]).toEqual({ name: 'Copied Folder', parent: '' })
const collectionReplacement = await app.request(`/dav/${workspace.slug}/Copy%20Folder`, {
method: 'COPY',
headers: basicHeaders(account.email, key, {
Destination: `http://localhost/dav/${workspace.slug}/Copied%20Folder`,
Depth: '0',
}),
})
expect(collectionReplacement.status).toBe(204)
})
it('COPY rolls back quota reservation when storage copy fails [spec: webdav/copy-rollback]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
await seedStorage(db)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['write'] })
await file(db, workspace.id, { id: 'source', name: 'source.txt', size: 12 })
vi.mocked(S3Service.prototype.copyObject).mockRejectedValueOnce(new Error('copy failed'))
const res = await app.request(`/dav/${workspace.slug}/source.txt`, {
method: 'COPY',
headers: basicHeaders(account.email, key, { Destination: `http://localhost/dav/${workspace.slug}/copy.txt` }),
})
expect(res.status).toBe(500)
const rows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storage.id}`)
expect(rows[0]?.used).toBe(0)
})
it('rejects traversal, empty segments, and encoded path separators [spec: webdav/path-validation]', async () => {
const { app, db, auth } = await createTestApp()
await authedHeaders(app)
const workspace = await org(db)
const account = await userAccount(db)
const key = await apiKey(auth, account.id, { webdav: ['read'] })
for (const path of [
`/dav/${workspace.slug}/%252e%252e/x`,
`/dav/${workspace.slug}//x`,
`/dav/${workspace.slug}/a%2Fb`,
`/dav/${workspace.slug}/%E0%A4%A`,
]) {
const res = await app.request(path, { method: 'PROPFIND', headers: basicHeaders(account.email, key) })
expect(res.status, path).toBe(400)
}
})
})
describe('WebDAV over real HTTP (npm client)', () => {
const e2eStorage = {
id: 'webdav-e2e-storage',
bucket: 'webdav-e2e-bucket',
endpoint: 'https://s3.example.com',
region: 'us-east-1',
accessKey: 'key',
secretKey: 'secret',
filePath: '',
customHost: '',
capacity: 0,
used: 0,
status: 'active',
createdAt: new Date(),
updatedAt: new Date(),
}
const objects = new Map<string, Uint8Array>()
let app: TestApp['app']
let db: TestApp['db']
let auth: TestApp['auth']
let server: ReturnType<typeof serve>
let baseUrl: string
let workspaceName: string
let workspaceSlug: string
let username: string
let apiKey: string
beforeEach(async () => {
vi.restoreAllMocks()
objects.clear()
installS3MemoryBackend()
;({ app, db, auth } = await createTestApp())
await authedHeaders(app, 'webdav-e2e@example.com', 'password123456')
await db.insert(storages).values(e2eStorage)
const [user] = await db.all<{ id: string; email: string }>(
sql`SELECT id, email FROM user WHERE email = 'webdav-e2e@example.com'`,
)
const [workspace] = await db.all<{ id: string; name: string; slug: string }>(
sql`SELECT id, name, slug FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1`,
)
if (!user || !workspace) throw new Error('Failed to seed WebDAV e2e user')
username = user.email
workspaceName = workspace.name
workspaceSlug = workspace.slug
apiKey = (
await (auth.api as { createApiKey(input: unknown): Promise<{ key: string }> }).createApiKey({
body: { configId: 'webdav', userId: user.id, permissions: { webdav: ['read', 'write'] } },
})
).key
const port = await freePort()
server = serve({ fetch: app.fetch, port })
baseUrl = `http://127.0.0.1:${port}`
})
afterEach(async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()))
})
})
it('exercises Class 1 and Class 2 methods over real HTTP', async () => {
const unauth = await rawDav('OPTIONS', '/dav/')
expect(unauth.status).toBe(401)
expect(unauth.headers.get('WWW-Authenticate')).toBe('Basic realm="ZPan WebDAV"')
const options = await dav('OPTIONS', '/dav/')
expect(options.status).toBe(204)
expect(options.headers.get('DAV')).toContain('1')
expect(options.headers.get('DAV')).toContain('2')
expect(options.headers.get('Allow')).toContain('PROPFIND')
expect(options.headers.get('Allow')).toContain('LOCK')
const root = await dav('PROPFIND', '/dav/', {
headers: { Depth: '1', 'Content-Type': 'application/xml' },
body: allpropXml(),
})
expect(root.status).toBe(207)
expect(await root.text()).toContain(escapedDavHref(workspaceName))
const mkcol = await dav('MKCOL', ws('/Albums'))
expect(mkcol.status).toBe(201)
const smallBody = new TextEncoder().encode('0123456789abcdefghijklmnopqrstuvwxyz')
const putSmall = await dav('PUT', ws('/song.mp3'), {
headers: { 'Content-Type': 'audio/mpeg', 'Content-Length': String(smallBody.byteLength) },
body: smallBody,
})
expect(putSmall.status).toBe(201)
const largeSize = 2 * 1024 * 1024 + 123
const putLarge = await dav('PUT', ws('/Albums/large.bin'), {
headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(largeSize) },
body: patternedStream(largeSize),
})
expect(putLarge.status).toBe(201)
const head = await dav('HEAD', ws('/song.mp3'))
expect(head.status).toBe(200)
expect(head.headers.get('Accept-Ranges')).toBe('bytes')
expect(head.headers.get('Content-Length')).toBe(String(smallBody.byteLength))
const etag = head.headers.get('ETag')
expect(etag).toBeTruthy()
const full = await dav('GET', ws('/song.mp3'))
expect(full.status).toBe(200)
expect(await full.text()).toBe('0123456789abcdefghijklmnopqrstuvwxyz')
const range = await dav('GET', ws('/song.mp3'), { headers: { Range: 'bytes=10-15' } })
expect(range.status).toBe(206)
expect(range.headers.get('Content-Range')).toBe(`bytes 10-15/${smallBody.byteLength}`)
expect(await range.text()).toBe('abcdef')
const multiRange = await dav('GET', ws('/song.mp3'), { headers: { Range: 'bytes=0-1,10-11' } })
expect(multiRange.status).toBe(206)
expect(multiRange.headers.get('Content-Type')).toMatch(/^multipart\/byteranges; boundary=zpan-webdav-/)
expect(await multiRange.text()).toContain('Content-Range: bytes 10-11/36\r\n\r\nab')
const largeTail = await dav('GET', ws('/Albums/large.bin'), { headers: { Range: `bytes=${largeSize - 4}-` } })
expect(largeTail.status).toBe(206)
expect(new Uint8Array(await largeTail.arrayBuffer())).toEqual(patternedBytes(largeSize - 4, 4))
const webDavFsRange = await dav('GET', ws('/song.mp3'), {
headers: { Range: 'bytes=10-10', 'User-Agent': 'WebDAVFS/3.0.0 (03008000) Darwin/24.6.0 (arm64)' },
})
expect(webDavFsRange.status).toBe(206)
expect(webDavFsRange.headers.get('Content-Range')).toBe(`bytes 10-10/${smallBody.byteLength}`)
expect(webDavFsRange.headers.get('Cache-Control')).toBe('no-store')
expect(webDavFsRange.headers.get('ETag')).toBeNull()
expect(await webDavFsRange.text()).toBe('a')
const normalCached = await dav('GET', ws('/song.mp3'), { headers: { 'If-None-Match': etag ?? '' } })
expect(normalCached.status).toBe(304)
const mountedCached = await dav('GET', ws('/song.mp3'), {
headers: {
'If-None-Match': etag ?? '',
'User-Agent': 'WebDAVFS/3.0.0 (03008000) Darwin/24.6.0 (arm64)',
},
})
expect(mountedCached.status).toBe(200)
expect(mountedCached.headers.get('Cache-Control')).toBe('no-store')
expect(mountedCached.headers.get('ETag')).toBeNull()
expect(await mountedCached.text()).toBe('0123456789abcdefghijklmnopqrstuvwxyz')
const setProp = await dav('PROPPATCH', ws('/song.mp3'), {
headers: { 'Content-Type': 'application/xml' },
body: propertyUpdateXml('set'),
})
expect(setProp.status).toBe(207)
const propfind = await dav('PROPFIND', ws('/song.mp3'), {
headers: { Depth: '0', 'Content-Type': 'application/xml' },
body: propfindXml(),
})
expect(propfind.status).toBe(207)
expect(await propfind.text()).toContain('>five</Z:rating>')
const removeProp = await dav('PROPPATCH', ws('/song.mp3'), {
headers: { 'Content-Type': 'application/xml' },
body: propertyUpdateXml('remove'),
})
expect(removeProp.status).toBe(207)
const lock = await dav('LOCK', ws('/Albums'), {
headers: { Depth: 'infinity', Timeout: 'Second-1200', 'Content-Type': 'application/xml' },
body: lockInfoXml(),
})
expect(lock.status).toBe(200)
const lockToken = lock.headers.get('Lock-Token')
expect(lockToken).toMatch(/^<opaquelocktoken:/)
const lockedPut = await dav('PUT', ws('/Albums/locked.txt'), {
headers: { 'Content-Type': 'text/plain', 'Content-Length': '6' },
body: 'locked',
})
expect(lockedPut.status).toBe(423)
const unlockedPut = await dav('PUT', ws('/Albums/locked.txt'), {
headers: { 'Content-Type': 'text/plain', 'Content-Length': '6', If: `(${lockToken})` },
body: 'locked',
})
expect(unlockedPut.status).toBe(201)
const unlock = await dav('UNLOCK', ws('/Albums'), { headers: { 'Lock-Token': lockToken ?? '' } })
expect(unlock.status).toBe(204)
const copy = await dav('COPY', ws('/song.mp3'), {
headers: { Destination: `${baseUrl}${ws('/Albums/copy.mp3')}` },
})
expect(copy.status).toBe(201)
expect(await (await dav('GET', ws('/Albums/copy.mp3'))).text()).toBe('0123456789abcdefghijklmnopqrstuvwxyz')
const move = await dav('MOVE', ws('/Albums/copy.mp3'), {
headers: { Destination: `${baseUrl}${ws('/Albums/moved.mp3')}` },
})
expect(move.status).toBe(201)
expect((await dav('GET', ws('/Albums/copy.mp3'))).status).toBe(404)
expect(await (await dav('GET', ws('/Albums/moved.mp3'))).text()).toBe('0123456789abcdefghijklmnopqrstuvwxyz')
const listing = await dav('PROPFIND', ws('/Albums/'), {
headers: { Depth: '1', 'Content-Type': 'application/xml' },
body: allpropXml(),
})
expect(listing.status).toBe(207)
const listingXml = await listing.text()
expect(listingXml).toContain('large.bin')
expect(listingXml).toContain('locked.txt')
expect(listingXml).toContain('moved.mp3')
const del = await dav('DELETE', ws('/Albums/moved.mp3'))
expect(del.status).toBe(204)
expect((await dav('GET', ws('/Albums/moved.mp3'))).status).toBe(404)
})
it('works with the webdav npm client over real HTTP', async () => {
const client = createClient(`${baseUrl}/dav`, { username, password: apiKey })
const root = await client.getDirectoryContents('/')
expect(root).toEqual(
expect.arrayContaining([expect.objectContaining({ filename: `/${workspaceName}`, type: 'directory' })]),
)
const dir = `/${workspaceSlug}/Library Client`
await client.createDirectory(dir)
await client.putFileContents(`${dir}/client.txt`, 'hello mature client', { contentLength: true })
const stat = (await client.stat(`${dir}/client.txt`)) as { size: number }
expect(stat.size).toBe('hello mature client'.length)
const full = await client.getFileContents(`${dir}/client.txt`, { format: 'text' })
expect(full).toBe('hello mature client')
const partial = (await client.getFileContents(`${dir}/client.txt`, {
details: true,
format: 'text',
headers: { Range: 'bytes=6-11' },
})) as { status: number; data: string }
expect(partial.status).toBe(206)
expect(partial.data).toBe('mature')
const largeSize = 512 * 1024 + 7
await client.putFileContents(`${dir}/stream.bin`, patternedNodeStream(largeSize), { contentLength: largeSize })
expect(((await client.stat(`${dir}/stream.bin`)) as { size: number }).size).toBe(largeSize)
await client.copyFile(`${dir}/client.txt`, `${dir}/copy.txt`)
expect(await client.exists(`${dir}/copy.txt`)).toBe(true)
await client.moveFile(`${dir}/copy.txt`, `${dir}/moved.txt`)
expect(await client.exists(`${dir}/copy.txt`)).toBe(false)
expect(await client.exists(`${dir}/moved.txt`)).toBe(true)
const lock = await client.lock(dir, { timeout: 'Second-1200' })
expect(lock.token).toMatch(/^opaquelocktoken:/)
await client.unlock(dir, lock.token)
await client.deleteFile(dir)
expect(await client.exists(dir)).toBe(false)
})
function ws(path: string): string {
return `/dav/${workspaceSlug}${path}`
}
function dav(method: string, path: string, init: RequestInit = {}): Promise<Response> {
return rawDav(method, path, {
...init,
headers: {
Authorization: `Basic ${Buffer.from(`${username}:${apiKey}`).toString('base64')}`,
...init.headers,
},
})
}
function rawDav(method: string, path: string, init: RequestInit = {}): Promise<Response> {
return fetch(`${baseUrl}${path}`, {
...init,
method,
duplex: init.body instanceof ReadableStream ? 'half' : undefined,
} as RequestInit & { duplex?: 'half' })
}
function installS3MemoryBackend() {
vi.spyOn(S3Service.prototype, 'putObject').mockImplementation(async (_storage, key, body) => {
const bytes = body instanceof Uint8Array ? body : new Uint8Array(await new Response(body).arrayBuffer())
objects.set(key, bytes)
return bytes.byteLength
})
vi.spyOn(S3Service.prototype, 'getObjectBody').mockImplementation(async (_storage, key, range) => {
const bytes = objects.get(key)
if (!bytes) throw new Error(`Missing object ${key}`)
const sliced = sliceRange(bytes, range)
const body = new ArrayBuffer(sliced.byteLength)
new Uint8Array(body).set(sliced)
return new Response(body).body ?? new ReadableStream()
})
vi.spyOn(S3Service.prototype, 'copyObject').mockImplementation(async (_srcStorage, srcKey, _dstStorage, dstKey) => {
const bytes = objects.get(srcKey)
if (!bytes) throw new Error(`Missing object ${srcKey}`)
objects.set(dstKey, new Uint8Array(bytes))
})
vi.spyOn(S3Service.prototype, 'deleteObject').mockImplementation(async (_storage, key) => {
objects.delete(key)
})
vi.spyOn(S3Service.prototype, 'deleteObjects').mockImplementation(async (_storage, keys) => {
for (const key of keys) objects.delete(key)
})
}
function sliceRange(bytes: Uint8Array, range?: string): Uint8Array {
if (!range) return bytes
const match = /^bytes=(\d+)-(\d+)$/.exec(range)
if (!match) throw new Error(`Unexpected range ${range}`)
return bytes.slice(Number(match[1]), Number(match[2]) + 1)
}
async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const probe = createServer()
probe.listen(0, () => {
const address = probe.address() as AddressInfo
probe.close((error) => (error ? reject(error) : resolve(address.port)))
})
})
}
function patternedStream(size: number): ReadableStream<Uint8Array> {
let offset = 0
return new ReadableStream({
pull(controller) {
if (offset >= size) {
controller.close()
return
}
const length = Math.min(64 * 1024, size - offset)
controller.enqueue(patternedBytes(offset, length))
offset += length
},
})
}
function patternedNodeStream(size: number): Readable {
let offset = 0
return new Readable({
read() {
if (offset >= size) {
this.push(null)
return
}
const length = Math.min(64 * 1024, size - offset)
this.push(Buffer.from(patternedBytes(offset, length)))
offset += length
},
})
}
function patternedBytes(offset: number, length: number): Uint8Array {
const bytes = new Uint8Array(length)
for (let i = 0; i < length; i += 1) bytes[i] = (offset + i) % 251
return bytes
}
function allpropXml(): string {
return '<?xml version="1.0"?><D:propfind xmlns:D="DAV:"><D:allprop/></D:propfind>'
}
function propfindXml(): string {
return [
'<?xml version="1.0"?>',
'<D:propfind xmlns:D="DAV:" xmlns:Z="urn:zpan:e2e">',
'<D:prop><D:getetag/><Z:rating/></D:prop>',
'</D:propfind>',
].join('')
}
function propertyUpdateXml(action: 'set' | 'remove'): string {
const prop = '<D:prop><Z:rating xmlns:Z="urn:zpan:e2e">five</Z:rating></D:prop>'
return `<?xml version="1.0"?><D:propertyupdate xmlns:D="DAV:"><D:${action}>${prop}</D:${action}></D:propertyupdate>`
}
function lockInfoXml(): string {
return [
'<?xml version="1.0"?>',
'<D:lockinfo xmlns:D="DAV:">',
'<D:lockscope><D:exclusive/></D:lockscope>',
'<D:locktype><D:write/></D:locktype>',
'<D:owner>webdav-e2e</D:owner>',
'</D:lockinfo>',
].join('')
}
})