4d4a0b31cb
- 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>
274 lines
9.9 KiB
TypeScript
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);
|
|
});
|
|
});
|