Files
tessera-ctl/apps/api/src/nextcloud-files/nextcloud-dav-transfer.spec.ts
T
schalli 4d4a0b31cb feat(nextcloud-files): Hoch- und Herunterladen als Datenstrom, große Dateien in Stücken, ZIP
- Hochladen ohne Pufferung: ganze Datei oder Chunked Upload v2 in 8-MiB-Stücken, Zusammenbau im Hintergrund mit abfragbarem Zustand
- Überschreibschutz (If-None-Match / Entity-Tag-Prüfung), Download mit Range, Content-Disposition und Schutzkopfzeilen von Tessera selbst
- Ordner- und Auswahl-ZIP; jeder Nextcloud-Fehler wird vor dem ersten Byte abgebildet, nie 401/403
- Browser-Hochlader mit Fortschritt, Wiederholung, Abbruch und Aufräumen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:30:02 +02:00

274 lines
9.9 KiB
TypeScript

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<string, string> = {}) {
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=<JSON-Liste der Namen>, 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);
});
});