/** * The WebDAV adapter for Nextcloud and OpenCloud: what it puts on the wire, and * what it makes of what comes back. * * The cases here are the ones that cost real debugging against the two * products, so they are pinned rather than described: Nextcloud's `OC-FileId` * header is not its `oc:fileid` and a naive adapter re-uploads every file * forever; `OC-Checksum: SHA256` is fine on Nextcloud and a hard 400 on * OpenCloud; a PROPFIND answers one propstat block per status and the 404 block * is not data; an OpenCloud drive describes its own WebDAV URL on a host the * admin never entered. * * `safeFetch` and the capped readers are mocked, for the reason * dawarich.client.test.ts gives: the SSRF guard does a real DNS lookup and the * readers want a real stream. The responses are genuine `Response` objects, so * headers, status and body behave as they do in production. */ import type { DocumentConnectionRef, DocumentScopeRef, PushRequest, } from '../../../../src/nest/doc-sync/document-provider'; import { WebdavClient } from '../../../../src/nest/doc-sync/providers/webdav.client'; import { NextcloudDocumentProvider, OpencloudDocumentProvider, flavorOf, } from '../../../../src/nest/doc-sync/providers/webdav.provider'; import { SsrfBlockedError } from '../../../../src/utils/ssrfGuard'; import { Readable } from 'node:stream'; import { describe, it, expect, vi, beforeEach } from 'vitest'; const { safeFetchMock } = vi.hoisted(() => ({ safeFetchMock: vi.fn() })); vi.mock('../../../../src/utils/ssrfGuard', () => ({ safeFetch: safeFetchMock, SsrfBlockedError: class SsrfBlockedError extends Error { constructor(message: string) { super(message); this.name = 'SsrfBlockedError'; } }, })); vi.mock('../../../../src/utils/cappedFetch', async (importOriginal) => ({ ...(await importOriginal()), readCappedText: async (res: Response) => { const text = await res.text(); return { text: text.slice(0, capBytes), truncated: text.length > capBytes }; }, readCappedJson: async (res: Response) => { try { return JSON.parse(await res.text()) as unknown; } catch { return undefined; } }, })); /** Raised by the truncation test only; everything else stays under the cap. */ let capBytes = 1_000_000; const NC_ORIGIN = 'https://cloud.example.org'; const OC_ORIGIN = 'https://opencloud.example.org'; const NC_ROOT = '/remote.php/dav/files/admin'; const OC_DRIVE = 'storage-1$space-9'; const OC_ROOT = `/dav/spaces/${OC_DRIVE}`; const ncConn: DocumentConnectionRef = { connectionId: 7, createdAt: '2026-09-01 08:00:00', ownerId: 3, baseUrl: NC_ORIGIN, secrets: { app_password: 'app-pw' }, settings: { login_name: 'admin', base_path: '/TREK' }, allowInsecureTls: false, }; const ocConn: DocumentConnectionRef = { connectionId: 8, createdAt: '2026-09-01 08:00:00', ownerId: 3, baseUrl: OC_ORIGIN, secrets: { app_token: 'app-token' }, settings: { username: 'admin' }, allowInsecureTls: true, }; const ncScope: DocumentScopeRef = { linkId: 1, tripId: 42, scopeKey: 'fileid:60', remoteRootId: '60', remoteRootPath: '/TREK/trip 42', cursor: null, }; const ocScope: DocumentScopeRef = { linkId: 2, tripId: 42, scopeKey: `drive:${OC_DRIVE}`, remoteRootId: OC_DRIVE, remoteRootPath: `${OC_ORIGIN}${OC_ROOT}`, cursor: null, }; interface Recorded { url: string; method: string; headers: Record; body: string | undefined; rejectUnauthorized: boolean | undefined; } let calls: Recorded[] = []; /** `METHOD /path` → the answer, or a function of the request. */ type Answer = Response | ((req: Recorded) => Response); let routes: Map; function route(key: string, ...answers: Answer[]): void { routes.set(key, answers); } function reply(body: string | null, init: ResponseInit = {}): Response { return new Response(body, init); } /** A PROPFIND answer built from the props each entry declares. */ function multistatus( entries: Array<{ href: string; collection?: boolean; etag?: string; fileid?: string; size?: number; type?: string; modified?: string; checksums?: string; }>, ): Response { const body = entries .map((entry) => { const found: string[] = []; const missing: string[] = []; found.push(`${entry.etag ?? '"etag"'}`); found.push(`${entry.modified ?? 'Fri, 18 Sep 2026 15:15:54 GMT'}`); found.push(entry.collection ? '' : ''); if (entry.fileid) found.push(`${entry.fileid}`); if (entry.size === undefined) missing.push(''); else found.push(`${entry.size}`); if (entry.type === undefined) missing.push(''); else found.push(`${entry.type}`); if (entry.checksums === undefined) missing.push(''); else found.push(`${entry.checksums}`); const blocks = [ `${found.join('')}HTTP/1.1 200 OK`, ]; if (missing.length > 0) { blocks.push( `${missing.join('')}HTTP/1.1 404 Not Found`, ); } return `${entry.href}${blocks.join('')}`; }) .join(''); return reply( `${body}`, { status: 207, headers: { 'content-type': 'application/xml' } }, ); } function drive(id: string, name: string, driveType: string, webDavUrl: string): unknown { return { id, name, driveType, root: { id, webDavUrl } }; } function requests(method?: string): Recorded[] { return method ? calls.filter((call) => call.method === method) : calls; } function pathOf(call: Recorded): string { return new URL(call.url).pathname; } function pushRequest(overrides: Partial = {}): PushRequest { return { body: Readable.from([Buffer.from('bytes')]), fileName: 'reiseplan.pdf', mimeType: 'application/pdf', size: 5, sha256: 'a'.repeat(64), mtimeSeconds: 1_700_000_000, trekDocUid: 'doc-1', trekTripUid: 'trip-1', ...overrides, }; } let nextcloud: NextcloudDocumentProvider; let opencloud: OpencloudDocumentProvider; beforeEach(() => { capBytes = 1_000_000; calls = []; routes = new Map(); const client = new WebdavClient(); nextcloud = new NextcloudDocumentProvider(client); opencloud = new OpencloudDocumentProvider(client); safeFetchMock.mockImplementation( async (url: string, init: RequestInit, options?: { rejectUnauthorized?: boolean }) => { const headers: Record = {}; new Headers(init.headers).forEach((value, key) => { headers[key] = value; }); const record: Recorded = { url, method: init.method ?? 'GET', headers, body: typeof init.body === 'string' ? init.body : undefined, rejectUnauthorized: options?.rejectUnauthorized, }; calls.push(record); const key = `${record.method} ${new URL(url).pathname}`; const queued = routes.get(key); if (!queued || queued.length === 0) { return reply('', { status: 404 }); } const answer = queued.length > 1 ? queued.shift() : queued[0]; if (!answer) return reply(null, { status: 404 }); return typeof answer === 'function' ? answer(record) : answer.clone(); }, ); }); describe('flavour detection', () => { it('reads the product off the connection rather than off the registered id', () => { expect(flavorOf(ncConn)).toBe('nextcloud'); expect(flavorOf(ocConn)).toBe('opencloud'); expect(flavorOf({ ...ncConn, settings: {}, secrets: {} })).toBeNull(); }); it('answers the same capabilities whichever class asks, because the connection decides', () => { expect(nextcloud.capabilities(ocConn).push).toBe('none'); expect(opencloud.capabilities(ncConn).push).toBe('webhook-manual'); }); it('never claims a content hash it cannot stand behind', () => { const caps = nextcloud.capabilities(ncConn); expect(caps.contentHashInListing).toBe(false); expect(caps.stableId).toBe(true); expect(caps.remoteTrash).toBe(true); expect(caps.replaceInPlace).toBe(true); }); }); describe('probe', () => { it('checks the WebDAV home and upgrades push when the account may manage webhooks', async () => { route(`PROPFIND ${NC_ROOT}`, multistatus([{ href: NC_ROOT, collection: true, fileid: '1' }])); route('GET /ocs/v2.php/apps/webhook_listeners/api/v1/webhooks', reply('{"ocs":{"data":[]}}')); const result = await nextcloud.probe(ncConn); expect(result).toMatchObject({ success: true, data: { account: 'admin' } }); expect(result.success && result.data.capabilities.push).toBe('webhook-self-registered'); expect(calls[0].headers.authorization).toBe(`Basic ${Buffer.from('admin:app-pw').toString('base64')}`); }); it('keeps push at webhook-manual when the webhook endpoint is closed to this account', async () => { route(`PROPFIND ${NC_ROOT}`, multistatus([{ href: NC_ROOT, collection: true, fileid: '1' }])); route('GET /ocs/v2.php/apps/webhook_listeners/api/v1/webhooks', reply('{}', { status: 403 })); const result = await nextcloud.probe(ncConn); expect(result.success && result.data.capabilities.push).toBe('webhook-manual'); }); it('separates a wrong login name from a wrong password', async () => { route(`PROPFIND ${NC_ROOT}`, reply('', { status: 404 })); const missing = await nextcloud.probe(ncConn); expect(missing).toMatchObject({ success: false, error: { code: 'not_found' } }); expect(missing.success === false && missing.error.detail).toContain('admin'); calls = []; route(`PROPFIND ${NC_ROOT}`, reply('', { status: 401 })); expect(await nextcloud.probe(ncConn)).toMatchObject({ success: false, error: { code: 'unauthorized' } }); }); it('names the OpenCloud account from Graph and passes the TLS opt-in through', async () => { route('GET /graph/v1.0/me', reply('{"onPremisesSamAccountName":"admin","displayName":"Admin"}')); const result = await opencloud.probe(ocConn); expect(result).toMatchObject({ success: true, data: { account: 'admin' } }); expect(result.success && result.data.capabilities.push).toBe('none'); expect(calls[0].rejectUnauthorized).toBe(false); }); it('refuses a half-filled connection without going near the network', async () => { const result = await nextcloud.probe({ ...ncConn, secrets: {}, settings: { login_name: 'admin' } }); expect(result).toMatchObject({ success: false, error: { code: 'unauthorized' } }); expect(calls).toHaveLength(0); }); }); describe('scopes', () => { it('offers the folders under the base path and keys them by oc:fileid', async () => { route( `PROPFIND ${NC_ROOT}/TREK`, multistatus([ { href: `${NC_ROOT}/TREK/`, collection: true, fileid: '59' }, { href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60' }, { href: `${NC_ROOT}/TREK/notes.txt`, fileid: '61', size: 3, type: 'text/plain' }, ]), ); const result = await nextcloud.listScopes(ncConn); expect(result).toEqual({ success: true, data: [{ scopeKey: 'fileid:60', label: 'trip 42', remoteRootId: '60', remoteRootPath: '/TREK/trip 42' }], }); }); it('answers an empty picker rather than an error when the base folder does not exist yet', async () => { route(`PROPFIND ${NC_ROOT}/TREK`, reply('', { status: 404 })); expect(await nextcloud.listScopes(ncConn)).toEqual({ success: true, data: [] }); }); it('filters the picker by the query', async () => { route( `PROPFIND ${NC_ROOT}/TREK`, multistatus([ { href: `${NC_ROOT}/TREK/`, collection: true, fileid: '59' }, { href: `${NC_ROOT}/TREK/Japan/`, collection: true, fileid: '60' }, { href: `${NC_ROOT}/TREK/Peru/`, collection: true, fileid: '61' }, ]), ); const result = await nextcloud.listScopes(ncConn, 'jap'); expect(result.success && result.data.map((option) => option.label)).toEqual(['Japan']); }); it('re-bases an OpenCloud space on the entered origin and drops the virtual share drive', async () => { route( 'GET /graph/v1.0/me/drives', reply( JSON.stringify({ value: [ drive('shares$1', 'Shares', 'virtual', 'https://internal.lan:9200/dav/spaces/shares$1'), drive(OC_DRIVE, 'TREK Trip 42', 'project', `https://internal.lan:9200${OC_ROOT}`), ], }), ), ); const result = await opencloud.listScopes(ocConn); expect(result).toEqual({ success: true, data: [ { scopeKey: `drive:${OC_DRIVE}`, label: 'TREK Trip 42', remoteRootId: OC_DRIVE, remoteRootPath: `${OC_ORIGIN}${OC_ROOT}`, }, ], }); }); it('creates the base folder when it is missing and reads the new id back', async () => { route(`MKCOL ${NC_ROOT}/TREK/Japan%202026`, reply(null, { status: 409 }), reply(null, { status: 201 })); route(`MKCOL ${NC_ROOT}/TREK`, reply(null, { status: 201 })); route( `PROPFIND ${NC_ROOT}/TREK/Japan%202026`, multistatus([{ href: `${NC_ROOT}/TREK/Japan%202026/`, collection: true, fileid: '73' }]), ); const result = await nextcloud.createScope(ncConn, 'Japan 2026'); expect(result).toMatchObject({ success: true, data: { scopeKey: 'fileid:73', remoteRootPath: '/TREK/Japan 2026' }, }); expect(requests('MKCOL').map(pathOf)).toEqual([ `${NC_ROOT}/TREK/Japan%202026`, `${NC_ROOT}/TREK`, `${NC_ROOT}/TREK/Japan%202026`, ]); }); it('refuses to hand back a folder somebody else already made', async () => { route(`MKCOL ${NC_ROOT}/TREK/Japan`, reply(null, { status: 405 })); expect(await nextcloud.createScope(ncConn, 'Japan')).toMatchObject({ success: false, error: { code: 'conflict' }, }); }); it('replaces the characters Nextcloud refuses instead of failing the whole folder', async () => { route(`MKCOL ${NC_ROOT}/TREK/Japan-%202026`, reply(null, { status: 201 })); route( `PROPFIND ${NC_ROOT}/TREK/Japan-%202026`, multistatus([{ href: `${NC_ROOT}/TREK/Japan-%202026/`, collection: true, fileid: '74' }]), ); const result = await nextcloud.createScope(ncConn, 'Japan: 2026'); expect(result.success && result.data.label).toBe('Japan- 2026'); }); it('creates an OpenCloud space as a project drive', async () => { route( 'POST /graph/v1.0/drives', reply( JSON.stringify( drive('storage-1$new', 'Japan', 'project', `https://internal.lan:9200/dav/spaces/storage-1$new`), ), { status: 201, }, ), ); const result = await opencloud.createScope(ocConn, 'Japan'); expect(result).toMatchObject({ success: true, data: { scopeKey: 'drive:storage-1$new' } }); expect(JSON.parse(calls[0].body ?? '{}')).toEqual({ name: 'Japan', driveType: 'project' }); }); it('calls a folder with the same path but a different id scope_missing', async () => { route( `PROPFIND ${NC_ROOT}/TREK/trip%2042`, multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '99' }]), ); const result = await nextcloud.resolveScope(ncConn, ncScope); expect(result).toMatchObject({ success: false, error: { code: 'scope_missing' } }); expect(result.success === false && result.error.detail).toContain('99'); }); it('reports a deleted space as scope_missing rather than as a broken connection', async () => { route(`GET /graph/v1.0/drives/${encodeURIComponent(OC_DRIVE)}`, reply('{"error":{}}', { status: 404 })); expect(await opencloud.resolveScope(ocConn, ocScope)).toMatchObject({ success: false, error: { code: 'scope_missing' }, }); }); it('rejects a scope key that belongs to another provider', async () => { const result = await nextcloud.list(ncConn, { ...ncScope, scopeKey: 'drive:storage$x' }); expect(result).toMatchObject({ success: false, error: { code: 'scope_missing' } }); expect(calls).toHaveLength(0); }); }); describe('list', () => { const listing = () => multistatus([ { href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root-1"' }, { href: `${NC_ROOT}/TREK/trip%2042/sub/`, collection: true, fileid: '62', etag: '"sub-1"', }, { href: `${NC_ROOT}/TREK/trip%2042/pass.pdf`, fileid: '61', etag: '"doc-1"', size: 1234, type: 'application/pdf', modified: 'Tue, 14 Nov 2023 22:13:20 GMT', checksums: `SHA256:${'b'.repeat(64)}`, }, ]); it('enumerates documents only, never the root and never a sub-folder', async () => { route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, listing()); const result = await nextcloud.list(ncConn, ncScope); expect(result.success && result.data.documents).toEqual([ { remoteId: '61', name: 'pass.pdf', size: 1234, mimeType: 'application/pdf', remoteVersion: '"doc-1"', contentHash: 'b'.repeat(64), remoteModifiedAt: '2023-11-14T22:13:20.000Z', isDeleted: false, }, ]); expect(result.success && result.data.cursor).toBe('"root-1"'); expect(result.success && result.data.truncated).toBe(false); }); it('skips the walk entirely when the root ETag has not moved', async () => { route( `PROPFIND ${NC_ROOT}/TREK/trip%2042`, multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root-1"' }]), ); const result = await nextcloud.list(ncConn, { ...ncScope, cursor: '"root-1"' }); expect(result).toEqual({ success: true, data: { documents: [], cursor: '"root-1"', cursorUnchanged: true, truncated: false }, }); expect(calls).toHaveLength(1); expect(calls[0].headers.depth).toBe('0'); }); it('walks after the cursor probe shows a different root ETag', async () => { route( `PROPFIND ${NC_ROOT}/TREK/trip%2042`, multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root-2"' }]), listing(), ); const result = await nextcloud.list(ncConn, { ...ncScope, cursor: '"root-1"' }); expect(result.success && result.data.cursorUnchanged).toBe(false); expect(calls.map((call) => call.headers.depth)).toEqual(['0', '1']); }); it('reads a checksum only when it really is a SHA256, so OpenCloud reports none', async () => { route( `PROPFIND ${OC_ROOT}`, multistatus([ { href: `${OC_ROOT}/`, collection: true, fileid: `${OC_DRIVE}!root`, etag: '"root"' }, { href: `${OC_ROOT}/pass.pdf`, fileid: `${OC_DRIVE}!doc`, etag: '"doc"', size: 10, type: 'application/pdf', checksums: 'SHA1:2117 MD5:eebe ADLER32:5f9f', }, ]), ); const result = await opencloud.list(ocConn, ocScope); expect(result.success && result.data.documents[0]).toMatchObject({ remoteId: `${OC_DRIVE}!doc`, contentHash: null, }); }); it('reports a listing cut off at the size cap instead of failing the run', async () => { capBytes = 620; route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, listing()); const result = await nextcloud.list(ncConn, ncScope); expect(result.success && result.data.truncated).toBe(true); expect(result.success && result.data.documents.length).toBeLessThanOrEqual(1); }); it('turns a vanished folder into scope_missing rather than an empty trip', async () => { route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, reply('', { status: 404 })); expect(await nextcloud.list(ncConn, ncScope)).toMatchObject({ success: false, error: { code: 'scope_missing' }, }); }); it('keeps a document without an oc:fileid addressable instead of dropping it', async () => { route( `PROPFIND ${NC_ROOT}/TREK/trip%2042`, multistatus([ { href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root"' }, { href: `${NC_ROOT}/TREK/trip%2042/pass.pdf`, etag: '"doc"', size: 1, type: 'application/pdf' }, ]), ); const result = await nextcloud.list(ncConn, ncScope); expect(result.success && result.data.documents[0].remoteId).toBe('path:pass.pdf'); }); }); describe('push', () => { const uploaded = (req: Recorded) => reply(null, { status: 201, headers: { 'oc-fileid': '00000062oc9fy1g5e2cj', 'oc-etag': `"${req.headers['x-oc-mtime']}"` }, }); it('pins the modification time and the checksum, and normalises the id Nextcloud answers with', async () => { route(`PUT ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, uploaded); route( `PROPPATCH ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, etag: '"x"' }]), ); const result = await nextcloud.push(ncConn, ncScope, pushRequest()); const put = requests('PUT')[0]; expect(put.headers['x-oc-mtime']).toBe('1700000000'); expect(put.headers['oc-checksum']).toBe(`SHA256:${'a'.repeat(64)}`); expect(put.headers['content-length']).toBe('5'); // The header spells the id 00000062oc9fy1g5e2cj; the listing spells it 62, // and a mismatch here is a duplicate document on every run. expect(result).toMatchObject({ success: true, data: { remoteId: '62', remoteModifiedAt: '2023-11-14T22:13:20.000Z', deduplicated: false }, }); }); it('never sends a SHA256 checksum to OpenCloud, which answers 400 to one', async () => { route( `PUT ${OC_ROOT}/reiseplan.pdf`, reply(null, { status: 201, headers: { 'oc-fileid': 'd1', 'oc-etag': '"e1"' } }), ); route(`PROPPATCH ${OC_ROOT}/reiseplan.pdf`, multistatus([{ href: `${OC_ROOT}/reiseplan.pdf`, etag: '"e1"' }])); await opencloud.push(ocConn, ocScope, pushRequest()); expect(requests('PUT')[0].headers['oc-checksum']).toBeUndefined(); expect(requests('PUT')[0].headers['x-oc-mtime']).toBe('1700000000'); }); it('writes the TREK anchors as dead properties without letting a refusal undo the upload', async () => { route(`PUT ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, uploaded); route(`PROPPATCH ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, reply('', { status: 403 })); const result = await nextcloud.push(ncConn, ncScope, pushRequest()); expect(result.success).toBe(true); expect(requests('PROPPATCH')[0].body).toContain('doc-1'); expect(requests('PROPPATCH')[0].body).toContain('trip-1'); }); it('asks for the id and version a proxy stripped from the response', async () => { route(`PUT ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, reply(null, { status: 201 })); route( `PROPPATCH ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, etag: '"x"' }]), ); route( `PROPFIND ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, fileid: '62', etag: '"stored"', size: 5 }]), ); const result = await nextcloud.push(ncConn, ncScope, pushRequest()); expect(result).toMatchObject({ success: true, data: { remoteId: '62', remoteVersion: '"stored"' } }); }); it('sends If-Match when the core knows which version it is replacing, and reports 412 as a conflict', async () => { route( `PROPFIND ${NC_ROOT}/TREK/trip%2042`, multistatus([ { href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root"' }, { href: `${NC_ROOT}/TREK/trip%2042/pass.pdf`, fileid: '61', etag: '"doc-1"', size: 1, type: 'application/pdf' }, ]), ); route(`PUT ${NC_ROOT}/TREK/trip%2042/pass.pdf`, reply('', { status: 412 })); const result = await nextcloud.push( ncConn, ncScope, pushRequest({ remoteId: '61', expectedRemoteVersion: '"doc-1"', fileName: 'pass.pdf' }), ); expect(requests('PUT')[0].headers['if-match']).toBe('"doc-1"'); expect(result).toMatchObject({ success: false, error: { code: 'conflict', status: 412 } }); }); it('refuses to recreate a document that disappeared upstream', async () => { route( `PROPFIND ${NC_ROOT}/TREK/trip%2042`, multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root"' }]), ); const result = await nextcloud.push(ncConn, ncScope, pushRequest({ remoteId: '61' })); expect(result).toMatchObject({ success: false, error: { code: 'not_found' } }); expect(requests('PUT')).toHaveLength(0); }); it('maps a full quota to quota_exceeded rather than a generic failure', async () => { route(`PUT ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, reply('', { status: 507 })); expect(await nextcloud.push(ncConn, ncScope, pushRequest())).toMatchObject({ success: false, error: { code: 'quota_exceeded' }, }); }); }); describe('fetch, rename and trash', () => { const folder = () => multistatus([ { href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root"' }, { href: `${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`, fileid: '61', etag: '"doc-1"', size: 5, type: 'application/pdf', }, ]); it('streams a document back and reports the version it came with', async () => { route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder()); route( `GET ${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`, reply('the bytes', { headers: { 'content-length': '9', 'content-type': 'application/pdf', etag: '"doc-1"' } }), ); const result = await nextcloud.fetch(ncConn, ncScope, '61'); expect(result.success).toBe(true); if (!result.success) return; const chunks: Buffer[] = []; for await (const chunk of result.data.body) chunks.push(Buffer.from(chunk)); expect(Buffer.concat(chunks).toString()).toBe('the bytes'); expect(result.data).toMatchObject({ size: 9, mimeType: 'application/pdf', remoteVersion: '"doc-1"' }); }); it('re-uses the listing it already walked instead of re-walking per document', async () => { route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder()); route(`GET ${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`, reply('x', { headers: { etag: '"doc-1"' } })); await nextcloud.list(ncConn, ncScope); await nextcloud.fetch(ncConn, ncScope, '61'); expect(requests('PROPFIND')).toHaveLength(1); }); it('refuses a download announcing more than TREK transfers, and one that arrives without a body', async () => { route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder()); route( `GET ${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`, reply('x', { headers: { 'content-length': String(3 * 1024 ** 3) } }), reply(null, { status: 200 }), ); expect(await nextcloud.fetch(ncConn, ncScope, '61')).toMatchObject({ success: false, error: { code: 'too_large', status: 200 }, }); expect(await nextcloud.fetch(ncConn, ncScope, '61')).toMatchObject({ success: false, error: { code: 'provider_error', status: 200 }, }); }); it('says not_found for an id the folder no longer holds', async () => { route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder()); expect(await nextcloud.fetch(ncConn, ncScope, '999')).toMatchObject({ success: false, error: { code: 'not_found' }, }); }); it('renames inside the same folder and refuses to overwrite a namesake', async () => { route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder()); route( `MOVE ${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`, reply(null, { status: 201, headers: { 'oc-etag': '"doc-2"' } }), ); const result = await nextcloud.rename(ncConn, ncScope, '61', 'Reisepass 2026.pdf'); const move = requests('MOVE')[0]; expect(move.headers.destination).toBe(`${NC_ORIGIN}${NC_ROOT}/TREK/trip%2042/Reisepass%202026.pdf`); expect(move.headers.overwrite).toBe('F'); expect(result).toEqual({ success: true, data: { remoteVersion: '"doc-2"' } }); }); it('reports a rename onto an existing name as a conflict', async () => { route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder()); route(`MOVE ${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`, reply('', { status: 412 })); expect(await nextcloud.rename(ncConn, ncScope, '61', 'anders.pdf')).toMatchObject({ success: false, error: { code: 'conflict' }, }); }); it('deletes into the recycle bin and treats an already-deleted document as done', async () => { route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder()); route( `DELETE ${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`, reply(null, { status: 204 }), reply('', { status: 404 }), ); expect(await nextcloud.trash(ncConn, ncScope, '61')).toEqual({ success: true, data: undefined }); expect(await nextcloud.trash(ncConn, ncScope, '61')).toEqual({ success: true, data: undefined }); }); }); describe('webhooks', () => { const OCS = '/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks'; it('registers one unfiltered subscription per event, form-encoded, with the secret as a header', async () => { route(`GET ${OCS}`, reply('{"ocs":{"data":[]}}')); let next = 10; route(`POST ${OCS}`, () => reply(JSON.stringify({ ocs: { data: { id: next++ } } }))); const result = await nextcloud.registerWebhook(ncConn, ncScope, 'https://trek.example/hook', 's3cret'); expect(result).toEqual({ success: true, data: { subscriptionId: '10,11,12,13' } }); const bodies = requests('POST').map((call) => new URLSearchParams(call.body ?? '')); expect(requests('POST')[0].headers['content-type']).toBe('application/x-www-form-urlencoded'); expect(bodies[0].get('httpMethod')).toBe('POST'); expect(bodies[0].get('uri')).toBe('https://trek.example/hook'); expect(bodies[0].get('authMethod')).toBe('header'); // The header the endpoint actually reads. It used to be registered as // `X-TREK-Docsync-Signature` while doc-sync-webhook.controller looks for // `x-trek-docsync-secret`, so every webhook Nextcloud sent was dropped // without a trace: the subscription existed and the calls arrived. expect(bodies[0].get('authData[x-trek-docsync-secret]')).toBe('s3cret'); // A $regex filter is not merely ignored upstream: it throws inside the // listener and stops delivery for every other webhook on that event. expect(bodies.every((body) => [...body.keys()].every((key) => !key.startsWith('eventFilter')))).toBe(true); expect(bodies.map((body) => body.get('event'))).toEqual([ 'OCP\\Files\\Events\\Node\\NodeCreatedEvent', 'OCP\\Files\\Events\\Node\\NodeWrittenEvent', 'OCP\\Files\\Events\\Node\\NodeDeletedEvent', 'OCP\\Files\\Events\\Node\\NodeRenamedEvent', ]); }); it('answers a non-admin account with forbidden and leaves the link polling', async () => { route(`GET ${OCS}`, reply('{}', { status: 403 })); const result = await nextcloud.registerWebhook(ncConn, ncScope, 'https://trek.example/hook', 's'); expect(result).toMatchObject({ success: false, error: { code: 'forbidden' } }); expect(requests('POST')).toHaveLength(0); }); it('rolls back the subscriptions it already made when a later one fails', async () => { route(`GET ${OCS}`, reply('{"ocs":{"data":[]}}')); route( `POST ${OCS}`, reply(JSON.stringify({ ocs: { data: { id: 10 } } })), reply(JSON.stringify({ ocs: { data: { id: 11 } } })), reply('', { status: 500 }), ); route(`DELETE ${OCS}/10`, reply(null, { status: 200 })); route(`DELETE ${OCS}/11`, reply(null, { status: 200 })); const result = await nextcloud.registerWebhook(ncConn, ncScope, 'https://trek.example/hook', 's'); expect(result).toMatchObject({ success: false, error: { code: 'provider_error' } }); expect(requests('DELETE').map(pathOf)).toEqual([`${OCS}/10`, `${OCS}/11`]); }); it('tells an OpenCloud admin there is nothing to subscribe to instead of failing the connection', async () => { const result = await opencloud.registerWebhook(ocConn, ocScope, 'https://trek.example/hook', 's'); expect(result).toMatchObject({ success: false, error: { code: 'forbidden' } }); expect(calls).toHaveLength(0); }); it('removes every subscription the id list names, tolerating one that is already gone', async () => { route(`DELETE ${OCS}/10`, reply(null, { status: 200 })); route(`DELETE ${OCS}/11`, reply('', { status: 404 })); expect(await nextcloud.unregisterWebhook(ncConn, '10,11')).toEqual({ success: true, data: undefined }); expect(requests('DELETE')).toHaveLength(2); }); }); describe('an install below a path', () => { const NC_PREFIXED = 'https://home.example.org/nextcloud'; const OC_PREFIXED = 'https://opencloud.example.org/oc'; const prefixedNc: DocumentConnectionRef = { ...ncConn, baseUrl: NC_PREFIXED }; const prefixedOc: DocumentConnectionRef = { ...ocConn, baseUrl: OC_PREFIXED }; it('reaches a Nextcloud under a sub-path instead of calling its address a wrong password', async () => { route( `PROPFIND /nextcloud${NC_ROOT}`, multistatus([{ href: `/nextcloud${NC_ROOT}/`, collection: true, fileid: '1' }]), ); route('GET /nextcloud/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks', reply('{"ocs":{"data":[]}}')); const result = await nextcloud.probe(prefixedNc); expect(result).toMatchObject({ success: true, data: { account: 'admin' } }); expect(calls[0].url).toBe(`${NC_PREFIXED}${NC_ROOT}`); }); it('strips the file-app address people paste and keeps the install path underneath it', async () => { route( `PROPFIND /nextcloud${NC_ROOT}`, multistatus([{ href: `/nextcloud${NC_ROOT}/`, collection: true, fileid: '1' }]), ); route('GET /nextcloud/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks', reply('{}', { status: 403 })); const result = await nextcloud.probe({ ...ncConn, baseUrl: `${NC_PREFIXED}/apps/files/files/123?dir=/TREK` }); expect(result).toMatchObject({ success: true }); expect(calls[0].url).toBe(`${NC_PREFIXED}${NC_ROOT}`); }); it('keeps an install path that happens to contain apps, cutting only the pasted page behind it', async () => { const APPS_ROOT = 'https://home.example.org/apps/nextcloud'; route( `PROPFIND /apps/nextcloud${NC_ROOT}`, multistatus([{ href: `/apps/nextcloud${NC_ROOT}/`, collection: true, fileid: '1' }]), ); route('GET /apps/nextcloud/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks', reply('{}', { status: 403 })); expect(await nextcloud.probe({ ...ncConn, baseUrl: `${APPS_ROOT}/apps/dashboard/` })).toMatchObject({ success: true, }); expect(calls[0].url).toBe(`${APPS_ROOT}${NC_ROOT}`); calls = []; expect(await nextcloud.probe({ ...ncConn, baseUrl: APPS_ROOT })).toMatchObject({ success: true }); expect(calls[0].url).toBe(`${APPS_ROOT}${NC_ROOT}`); }); it('takes the prefix off the hrefs it is answered, so a download is not addressed twice over', async () => { route( `PROPFIND /nextcloud${NC_ROOT}/TREK/trip%2042`, multistatus([ { href: `/nextcloud${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root-1"' }, { href: `/nextcloud${NC_ROOT}/TREK/trip%2042/pass.pdf`, fileid: '61', etag: '"doc-1"', size: 4, type: 'application/pdf', }, ]), ); route(`GET /nextcloud${NC_ROOT}/TREK/trip%2042/pass.pdf`, reply('pdf!', { headers: { etag: '"doc-1"' } })); const listed = await nextcloud.list(prefixedNc, ncScope); expect(listed.success && listed.data.documents.map((doc) => doc.name)).toEqual(['pass.pdf']); expect(listed.success && listed.data.cursor).toBe('"root-1"'); const fetched = await nextcloud.fetch(prefixedNc, ncScope, '61'); expect(fetched).toMatchObject({ success: true, data: { remoteVersion: '"doc-1"' } }); expect(requests('GET').map((call) => call.url)).toEqual([`${NC_PREFIXED}${NC_ROOT}/TREK/trip%2042/pass.pdf`]); }); it('renames within the prefixed folder and points the Destination at the full base', async () => { route( `PROPFIND /nextcloud${NC_ROOT}/TREK/trip%2042`, multistatus([ { href: `/nextcloud${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60' }, { href: `/nextcloud${NC_ROOT}/TREK/trip%2042/pass.pdf`, fileid: '61', size: 4, type: 'application/pdf' }, ]), ); route( `MOVE /nextcloud${NC_ROOT}/TREK/trip%2042/pass.pdf`, reply(null, { status: 201, headers: { 'oc-etag': '"moved"' } }), ); const result = await nextcloud.rename(prefixedNc, ncScope, '61', 'passport.pdf'); expect(result).toEqual({ success: true, data: { remoteVersion: '"moved"' } }); expect(requests('MOVE')[0].headers.destination).toBe(`${NC_PREFIXED}${NC_ROOT}/TREK/trip%2042/passport.pdf`); }); it('re-bases an OpenCloud space below a path without spelling the path twice', async () => { route( 'GET /oc/graph/v1.0/me/drives', reply( JSON.stringify({ value: [drive(OC_DRIVE, 'TREK Trip 42', 'project', `https://internal.lan:9200/oc${OC_ROOT}`)], }), ), ); route( `PROPFIND /oc${OC_ROOT}`, multistatus([ { href: `/oc${OC_ROOT}/`, collection: true, fileid: `${OC_DRIVE}!root`, etag: '"root"' }, { href: `/oc${OC_ROOT}/pass.pdf`, fileid: `${OC_DRIVE}!doc`, etag: '"doc"', size: 10, type: 'application/pdf' }, ]), ); const scopes = await opencloud.listScopes(prefixedOc); expect(scopes.success && scopes.data[0].remoteRootPath).toBe(`${OC_PREFIXED}${OC_ROOT}`); const listed = await opencloud.list(prefixedOc, { ...ocScope, remoteRootPath: `${OC_PREFIXED}${OC_ROOT}` }); expect(listed.success && listed.data.documents[0]).toMatchObject({ remoteId: `${OC_DRIVE}!doc`, name: 'pass.pdf' }); expect(requests('PROPFIND').map((call) => call.url)).toEqual([`${OC_PREFIXED}${OC_ROOT}`]); }); it('leaves a root install exactly as it was', async () => { route(`PROPFIND ${NC_ROOT}`, multistatus([{ href: `${NC_ROOT}/`, collection: true, fileid: '1' }])); route('GET /ocs/v2.php/apps/webhook_listeners/api/v1/webhooks', reply('{}', { status: 403 })); expect(await nextcloud.probe({ ...ncConn, baseUrl: `${NC_ORIGIN}/index.php/apps/files/` })).toMatchObject({ success: true, }); expect(calls[0].url).toBe(`${NC_ORIGIN}${NC_ROOT}`); }); }); describe('failure classification', () => { it('separates a timeout, an untrusted certificate and a blocked host', async () => { const timeout = Object.assign(new TypeError('fetch failed'), { cause: Object.assign(new Error('The operation was aborted'), { name: 'TimeoutError' }), }); safeFetchMock.mockRejectedValueOnce(timeout); expect(await nextcloud.probe(ncConn)).toMatchObject({ success: false, error: { code: 'timeout' } }); const tls = Object.assign(new TypeError('fetch failed'), { cause: Object.assign(new Error('self-signed certificate'), { code: 'DEPTH_ZERO_SELF_SIGNED_CERT' }), }); safeFetchMock.mockRejectedValueOnce(tls); expect(await nextcloud.probe(ncConn)).toMatchObject({ success: false, error: { code: 'tls_untrusted' } }); safeFetchMock.mockRejectedValueOnce(new SsrfBlockedError('Requests to loopback are not allowed')); expect(await nextcloud.probe(ncConn)).toMatchObject({ success: false, error: { code: 'ssrf_blocked' } }); safeFetchMock.mockRejectedValueOnce(new TypeError('fetch failed')); expect(await nextcloud.probe(ncConn)).toMatchObject({ success: false, error: { code: 'unreachable' } }); }); it('keeps the upstream text in detail and out of the code', async () => { route(`PROPFIND ${NC_ROOT}`, reply('Username or password was incorrect', { status: 401 })); const result = await nextcloud.probe(ncConn); expect(result).toMatchObject({ success: false, error: { code: 'unauthorized' } }); expect(result.success === false && result.error.detail).toContain('incorrect'); }); it('refuses a body that is not a multistatus instead of reading it as an empty folder', async () => { route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, reply('Please log in', { status: 207 })); expect(await nextcloud.list(ncConn, ncScope)).toMatchObject({ success: false, error: { code: 'provider_error' }, }); }); });