import { Readable } from 'node:stream'; import { describe, expect, it } from 'vitest'; import { NextcloudCallGate } from './nextcloud-call-gate'; import { chunkName, download, downloadFolderZip, downloadSelectionZip, putFile, sanitizeRange, uploadAbort, uploadAssemble, uploadChunk, uploadStart, } from './nextcloud-dav-transfer'; import type { NcSession } from './nextcloud-files.types'; import type { NcTransportRequest, NcTransportResponse, NextcloudTransport } from './nextcloud-http'; const SESSION: NcSession = { baseUrl: 'https://cloud.example/nc', ncUserId: 'anna', authorization: 'Basic YW5uYTphcHAtcHctMTIz', credentialKey: 'k1', }; const UA = 'Tessera (Nextcloud-Dateien)'; const AUTH = 'Basic YW5uYTphcHAtcHctMTIz'; const UP = 'tessera-8f0c4b1e-3a5d-4c2e-9b7a-1d2e3f4a5b6c'; const DEST = 'https://cloud.example/nc/remote.php/dav/files/anna/Projekte/gro%C3%9F.bin'; function fake(status: number, headers: Record = {}) { const calls: NcTransportRequest[] = []; const transport: NextcloudTransport = async (req) => { calls.push(req); const res: NcTransportResponse = { statusCode: status, headers, body: Readable.from([]) }; return res; }; return { transport, calls }; } describe('nextcloud-dav-transfer — Hochladen', () => { it('putFile: PUT mit If-None-Match: *, content-length, X-OC-Mtime und demselben Strom als Koerper', async () => { const { transport, calls } = fake(201); const body = Readable.from([Buffer.from('hallo')]); const res = await putFile( transport, new NextcloudCallGate(), SESSION, ['Projekte', 'a.txt'], body, { size: 5, mtime: 1760000000, }, ); expect(res).toMatchObject({ ok: true, status: 201 }); expect(calls).toHaveLength(1); expect(calls[0].method).toBe('PUT'); expect(calls[0].url).toBe('https://cloud.example/nc/remote.php/dav/files/anna/Projekte/a.txt'); expect(calls[0].headers).toEqual({ 'user-agent': UA, 'content-length': '5', 'if-none-match': '*', 'x-oc-mtime': '1760000000', authorization: AUTH, }); // Identitaet: nichts wird gelesen oder kopiert, der Strom geht unveraendert durch. expect(calls[0].body).toBe(body); expect(calls[0].headersTimeoutMs).toBe(120_000); expect(calls[0].bodyTimeoutMs).toBe(30_000); }); it('putFile mit replaceEtag: If-Match statt If-None-Match, ohne Mtime keine Mtime-Kopfzeile', async () => { const { transport, calls } = fake(204); await putFile(transport, new NextcloudCallGate(), SESSION, ['a.txt'], Readable.from([]), { size: 0, replaceEtag: '"abc"', }); expect(calls[0].headers['if-match']).toBe('"abc"'); expect(calls[0].headers['if-none-match']).toBeUndefined(); expect(calls[0].headers['x-oc-mtime']).toBeUndefined(); expect(calls[0].headers['content-length']).toBe('0'); }); it('uploadStart: MKCOL des Upload-Ordners mit Destination aus der Basis der Sitzung', async () => { const { transport, calls } = fake(201); await uploadStart(transport, new NextcloudCallGate(), SESSION, UP, ['Projekte', 'groß.bin']); expect(calls[0].method).toBe('MKCOL'); expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}`); expect(calls[0].headers.destination).toBe(DEST); expect(calls[0].body).toBeNull(); }); it('uploadChunk: Teilstueck 3 heisst 00003, mit Destination, OC-Total-Length und content-length', async () => { const { transport, calls } = fake(201); const body = Readable.from([]); await uploadChunk( transport, new NextcloudCallGate(), SESSION, UP, 3, ['Projekte', 'groß.bin'], body, { size: 8388608, totalLength: 30000000, }, ); expect(calls[0].method).toBe('PUT'); expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}/00003`); expect(calls[0].headers).toEqual({ 'user-agent': UA, destination: DEST, 'oc-total-length': '30000000', 'content-length': '8388608', authorization: AUTH, }); expect(calls[0].body).toBe(body); expect(calls[0].headersTimeoutMs).toBe(120_000); expect(calls[0].bodyTimeoutMs).toBe(30_000); }); it('chunkName: fuenfstellig; ausserhalb 1..10000 oder nicht ganzzahlig wirft', () => { expect(chunkName(1)).toBe('00001'); expect(chunkName(10000)).toBe('10000'); expect(() => chunkName(0)).toThrow(); expect(() => chunkName(10001)).toThrow(); expect(() => chunkName(1.5)).toThrow(); }); it('uploadAssemble: MOVE .file mit Destination, Gesamtgroesse, Mtime und Overwrite: F', async () => { const { transport, calls } = fake(201); await uploadAssemble( transport, new NextcloudCallGate(), SESSION, UP, ['Projekte', 'groß.bin'], { totalLength: 30000000, mtime: 1760000000, }, ); expect(calls[0].method).toBe('MOVE'); expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}/.file`); expect(calls[0].headers).toEqual({ 'user-agent': UA, destination: DEST, 'oc-total-length': '30000000', overwrite: 'F', 'x-oc-mtime': '1760000000', authorization: AUTH, }); expect(calls[0].headersTimeoutMs).toBe(30 * 60_000); }); it('uploadAssemble: Overwrite T nur, wenn der Aufrufer ausdruecklich ersetzt', async () => { const { transport, calls } = fake(204); await uploadAssemble(transport, new NextcloudCallGate(), SESSION, UP, ['a'], { totalLength: 1, replace: true, }); expect(calls[0].headers.overwrite).toBe('T'); expect(calls[0].headers['x-oc-mtime']).toBeUndefined(); }); it('uploadAbort: DELETE des Upload-Ordners ohne Destination', async () => { const { transport, calls } = fake(204); await uploadAbort(transport, new NextcloudCallGate(), SESSION, UP); expect(calls[0].method).toBe('DELETE'); expect(calls[0].url).toBe(`https://cloud.example/nc/remote.php/dav/uploads/anna/${UP}`); expect(calls[0].headers.destination).toBeUndefined(); }); it('eine ungueltige Upload-Kennung wirft vor jedem Aufruf', async () => { const { transport, calls } = fake(204); await expect( uploadAbort(transport, new NextcloudCallGate(), SESSION, '../x'), ).rejects.toMatchObject({ response: { code: 'invalidPath' } }); expect(calls).toHaveLength(0); }); it('die Aufrufsperre gilt: ein toter Zugangsschluessel ruft nichts auf', async () => { const gate = new NextcloudCallGate(); gate.markDead('k1'); const { transport, calls } = fake(201); const res = await uploadStart(transport, gate, SESSION, UP, ['a']); expect(res).toEqual({ ok: false, kind: 'credential-dead' }); expect(calls).toHaveLength(0); }); }); describe('nextcloud-dav-transfer — Herunterladen', () => { it('download: GET mit durchgereichtem Range; der Koerper bleibt offen', async () => { const { transport, calls } = fake(206, { 'content-range': 'bytes 0-99/500' }); const res = await download( transport, new NextcloudCallGate(), SESSION, ['Projekte', 'a b.txt'], { range: 'bytes=0-99', }, ); expect(res).toMatchObject({ ok: true, status: 206 }); expect(calls[0].method).toBe('GET'); expect(calls[0].url).toBe( 'https://cloud.example/nc/remote.php/dav/files/anna/Projekte/a%20b.txt', ); expect(calls[0].headers.range).toBe('bytes=0-99'); expect(calls[0].headersTimeoutMs).toBe(30_000); expect(calls[0].bodyTimeoutMs).toBe(60_000); }); it('download: ohne Range keine Range-Kopfzeile; ein unsinniger Range wird verworfen', async () => { const { transport, calls } = fake(200); await download(transport, new NextcloudCallGate(), SESSION, ['a']); await download(transport, new NextcloudCallGate(), SESSION, ['a'], { range: 'bytes=0-1,5-9' }); await download(transport, new NextcloudCallGate(), SESSION, ['a'], { range: 'x\r\nhost: evil', }); expect(calls.map((c) => c.headers.range)).toEqual([undefined, undefined, undefined]); expect(sanitizeRange('bytes=100-')).toBe('bytes=100-'); expect(sanitizeRange('bytes=-50')).toBe('bytes=-50'); }); it('downloadFolderZip: GET auf den Ordner mit Schraegstrich und ?accept=zip', async () => { const { transport, calls } = fake(200); await downloadFolderZip(transport, new NextcloudCallGate(), SESSION, ['Projekte']); expect(calls[0].method).toBe('GET'); expect(calls[0].url).toBe( 'https://cloud.example/nc/remote.php/dav/files/anna/Projekte/?accept=zip', ); }); it('downloadFolderZip der Wurzel: .../files/anna/?accept=zip', async () => { const { transport, calls } = fake(200); await downloadFolderZip(transport, new NextcloudCallGate(), SESSION, []); expect(calls[0].url).toBe('https://cloud.example/nc/remote.php/dav/files/anna/?accept=zip'); }); it('downloadSelectionZip: ?accept=zip&files=, codiert', async () => { const { transport, calls } = fake(200); await downloadSelectionZip( transport, new NextcloudCallGate(), SESSION, ['Projekte'], ['a.txt', 'Ärger'], ); const url = new URL(calls[0].url); expect(url.pathname).toBe('/nc/remote.php/dav/files/anna/Projekte/'); expect(url.searchParams.get('accept')).toBe('zip'); expect(JSON.parse(url.searchParams.get('files') ?? '')).toEqual(['a.txt', 'Ärger']); expect(calls[0].url).toContain('files=%5B%22a.txt%22%2C%22%C3%84rger%22%5D'); }); it('downloadSelectionZip: ein Name wie .. wirft invalidPath, bevor ein Aufruf rausgeht', async () => { const { transport, calls } = fake(200); for (const bad of ['..', 'a/b', '', '.']) { await expect( downloadSelectionZip( transport, new NextcloudCallGate(), SESSION, ['Projekte'], ['ok.txt', bad], ), ).rejects.toMatchObject({ response: { code: 'invalidPath' } }); } expect(calls).toHaveLength(0); }); });