196 lines
6.5 KiB
JavaScript
196 lines
6.5 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const http = require('node:http');
|
|
const { createHash } = require('node:crypto');
|
|
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'psitransfer-onetime-'));
|
|
process.env.PSITRANSFER_UPLOAD_DIR = tmpDir;
|
|
// these tests upload without a password
|
|
process.env.PSITRANSFER_REQUIRE_BUCKET_PASSWORD = 'false';
|
|
|
|
const { test, before, after } = require('node:test');
|
|
const assert = require('node:assert');
|
|
|
|
const tusMeta = require('../../lib/tusboy/tus-metadata');
|
|
const app = require('../../lib/endpoints');
|
|
|
|
const TUS = { 'Tus-Resumable': '1.0.0' };
|
|
const PARALLEL = 10;
|
|
|
|
let server;
|
|
let port;
|
|
|
|
function request(method, pathname, headers = {}, body = null) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.request(
|
|
{ hostname: '127.0.0.1', port, method, path: pathname, headers, agent: false },
|
|
(res) => {
|
|
const chunks = [];
|
|
res.on('data', (c) => chunks.push(c));
|
|
res.on('end', () => {
|
|
resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) });
|
|
});
|
|
}
|
|
);
|
|
req.on('error', reject);
|
|
if (body != null) req.write(body);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
// Upload a file via Tus and return its fid (sid++key)
|
|
async function upload(sid, content, retention = 'one-time') {
|
|
const meta = { name: 'file.bin', sid, retention };
|
|
const created = await request('POST', '/files/', {
|
|
...TUS,
|
|
'Upload-Length': String(content.length),
|
|
'Upload-Metadata': tusMeta.encode(meta),
|
|
});
|
|
assert.strictEqual(created.status, 201);
|
|
const fid = decodeURIComponent(created.headers.location.split('/').pop());
|
|
const patched = await request('PATCH', `/files/${ encodeURIComponent(fid) }`, {
|
|
...TUS,
|
|
'Upload-Offset': '0',
|
|
'Content-Type': 'application/offset+octet-stream',
|
|
}, content);
|
|
assert.strictEqual(patched.status, 204);
|
|
return fid;
|
|
}
|
|
|
|
function archiveUrl(sid, fids, format = 'zip') {
|
|
const keyList = fids.map(fid => fid.split('++')[1]).join();
|
|
const hash = createHash('sha256').update(keyList).digest('hex').slice(0, 32);
|
|
return `/files/${ sid }++${ hash }.${ format }`;
|
|
}
|
|
|
|
// Wait until the server-side finish/close handlers have run
|
|
const tick = () => new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
before(async () => {
|
|
server = http.createServer(app);
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
port = server.address().port;
|
|
});
|
|
|
|
after(async () => {
|
|
server.closeAllConnections();
|
|
await new Promise((resolve) => server.close(resolve));
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('parallel downloads of a one-time file deliver it exactly once', async () => {
|
|
const content = Buffer.alloc(1024 * 1024, 'a');
|
|
const fid = await upload('onetimeparallel', content);
|
|
|
|
const results = await Promise.all(
|
|
Array.from({ length: PARALLEL }, () => request('GET', `/files/${ fid }`))
|
|
);
|
|
const ok = results.filter(r => r.status === 200);
|
|
assert.strictEqual(ok.length, 1);
|
|
assert.ok(ok[0].body.equals(content));
|
|
assert.strictEqual(results.filter(r => r.status === 404).length, PARALLEL - 1);
|
|
|
|
await tick();
|
|
assert.strictEqual((await request('GET', `/files/${ fid }`)).status, 404);
|
|
// server is still alive
|
|
assert.strictEqual((await request('GET', '/robots.txt')).status, 200);
|
|
});
|
|
|
|
test('parallel archive downloads of a one-time bucket deliver it exactly once', async () => {
|
|
const sid = 'onetimearchive';
|
|
const fids = [
|
|
await upload(sid, Buffer.alloc(512 * 1024, 'a')),
|
|
await upload(sid, Buffer.alloc(512 * 1024, 'b')),
|
|
];
|
|
const url = archiveUrl(sid, fids);
|
|
|
|
const results = await Promise.all(
|
|
Array.from({ length: PARALLEL }, () => request('GET', url))
|
|
);
|
|
assert.strictEqual(results.filter(r => r.status === 200).length, 1);
|
|
assert.strictEqual(results.filter(r => r.status === 404).length, PARALLEL - 1);
|
|
|
|
await tick();
|
|
for (const fid of fids) {
|
|
assert.strictEqual((await request('GET', `/files/${ fid }`)).status, 404);
|
|
}
|
|
assert.strictEqual((await request('GET', '/robots.txt')).status, 200);
|
|
});
|
|
|
|
test('archive download is refused while a one-time file is being downloaded', async () => {
|
|
const sid = 'onetimemixed';
|
|
const fids = [
|
|
await upload(sid, Buffer.alloc(32 * 1024 * 1024, 'a')),
|
|
await upload(sid, Buffer.from('permanent'), '3600'),
|
|
];
|
|
|
|
// start a single file download and keep it open
|
|
const pending = new Promise((resolve, reject) => {
|
|
const req = http.get({ hostname: '127.0.0.1', port, path: `/files/${ fids[0] }`, agent: false }, (res) => {
|
|
res.pause();
|
|
resolve({ req, res });
|
|
});
|
|
req.on('error', reject);
|
|
});
|
|
const { req, res } = await pending;
|
|
assert.strictEqual(res.statusCode, 200);
|
|
|
|
assert.strictEqual((await request('GET', archiveUrl(sid, fids))).status, 404);
|
|
// non one-time files are not affected
|
|
assert.strictEqual((await request('GET', `/files/${ fids[1] }`)).status, 200);
|
|
|
|
req.destroy();
|
|
await tick();
|
|
});
|
|
|
|
test('aborted download releases the one-time file', async () => {
|
|
const content = Buffer.alloc(32 * 1024 * 1024, 'a');
|
|
const fid = await upload('onetimeabort', content);
|
|
|
|
await new Promise((resolve, reject) => {
|
|
const req = http.get({ hostname: '127.0.0.1', port, path: `/files/${ fid }`, agent: false }, (res) => {
|
|
assert.strictEqual(res.statusCode, 200);
|
|
res.once('data', () => {
|
|
req.destroy();
|
|
resolve();
|
|
});
|
|
});
|
|
req.on('error', (e) => e.code === 'ECONNRESET' ? resolve() : reject(e));
|
|
});
|
|
await tick();
|
|
|
|
const res = await request('GET', `/files/${ fid }`);
|
|
assert.strictEqual(res.status, 200);
|
|
assert.ok(res.body.equals(content));
|
|
});
|
|
|
|
test('HEAD does not consume a one-time file', async () => {
|
|
const content = Buffer.from('head request');
|
|
const fid = await upload('onetimehead', content);
|
|
|
|
const head = await request('HEAD', `/files/${ fid }`);
|
|
assert.strictEqual(head.status, 200);
|
|
await tick();
|
|
|
|
const res = await request('GET', `/files/${ fid }`);
|
|
assert.strictEqual(res.status, 200);
|
|
assert.ok(res.body.equals(content));
|
|
|
|
await tick();
|
|
assert.strictEqual((await request('HEAD', `/files/${ fid }`)).status, 404);
|
|
});
|
|
|
|
test('completed range request consumes a one-time file', async () => {
|
|
const fid = await upload('onetimerange', Buffer.from('0123456789abcdef'));
|
|
|
|
const res = await request('GET', `/files/${ fid }`, { Range: 'bytes=0-9' });
|
|
assert.strictEqual(res.status, 206);
|
|
assert.strictEqual(res.body.toString(), '0123456789');
|
|
|
|
await tick();
|
|
assert.strictEqual((await request('GET', `/files/${ fid }`)).status, 404);
|
|
});
|