DodoTransfer/tests/integration/features.test.js
2026-11-05 10:28:10 +09:00

484 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const http = require('node:http');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'psitransfer-features-'));
process.env.PSITRANSFER_UPLOAD_DIR = tmpDir;
process.env.PSITRANSFER_UPLOAD_PASS = 'up-secret';
process.env.PSITRANSFER_ADMIN_PASS = 'ad-secret';
process.env.PSITRANSFER_ACCESS_LOG = '';
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 PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(64)]);
const ADMIN = { 'x-passwd': 'ad-secret' };
let server;
let port;
let token;
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', () => {
const buf = Buffer.concat(chunks);
let json;
try { json = JSON.parse(buf.toString()); } catch (e) { /* not json */ }
resolve({ status: res.statusCode, headers: res.headers, body: buf, text: buf.toString(), json });
});
}
);
req.on('error', reject);
if (body != null) req.write(body);
req.end();
});
}
const bearer = () => ({ Authorization: `Bearer ${ token }` });
const jsonBody = (obj) => [{ 'Content-Type': 'application/json' }, JSON.stringify(obj)];
/** Upload one file via Tus, returns { fid, key, created } or the failed creation response */
async function upload({ sid, name = 'file.txt', content = 'hello', retention = '3600', password, internalBypass, headers = bearer() }) {
const meta = { name, sid, retention };
if (password) meta.password = password;
if (internalBypass) meta.internalBypass = internalBypass;
const created = await request('POST', '/files/', {
...TUS, ...headers,
'Upload-Length': String(Buffer.byteLength(content)),
'Upload-Metadata': tusMeta.encode(meta),
});
if (created.status !== 201) return { created };
const fid = decodeURIComponent(created.headers.location.split('/').pop());
const patched = await request('PATCH', `/files/${ encodeURIComponent(fid) }`, {
...TUS, ...headers,
'Upload-Offset': '0',
'Content-Type': 'application/offset+octet-stream',
}, content);
assert.strictEqual(patched.status, 204);
return { fid, key: fid.split('++')[1], created };
}
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;
const login = await request('POST', '/login', ...jsonBody({ password: 'up-secret' }));
assert.strictEqual(login.status, 200);
token = login.json.token;
});
after(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// ---------------------------------------------------------------- login
test('login token: wrong password, right password, token reuse', async () => {
const wrong = await request('POST', '/login', ...jsonBody({ password: 'nope' }));
assert.strictEqual(wrong.status, 403);
assert.ok(token && token.includes('.'));
assert.strictEqual((await request('GET', '/config.json')).status, 401);
assert.strictEqual((await request('GET', '/config.json', { Authorization: 'Bearer garbage' })).status, 403);
const withToken = await request('GET', '/config.json', bearer());
assert.strictEqual(withToken.status, 200);
assert.ok(withToken.json.session.token, 'the token is renewed on use');
assert.ok(withToken.json.session.exp > Date.now() + 6 * 24 * 3600 * 1000, 'valid for about 7 days');
assert.deepStrictEqual(withToken.json.quickRetentions, ['one-time', '86400', '259200', '432000', '604800']);
assert.strictEqual(withToken.json.authRequired, true);
});
test('legacy x-passwd header still works for scripts', async () => {
const res = await request('GET', '/config.json', { 'x-passwd': 'up-secret' });
assert.strictEqual(res.status, 200);
const up = await upload({ sid: 'legacyheader', headers: { 'x-passwd': 'up-secret' }, password: 'pw' });
assert.ok(up.fid);
});
test('uploading needs a login', async () => {
const meta = tusMeta.encode({ name: 'x', sid: 'nologin', retention: '3600', password: 'p' });
const res = await request('POST', '/files/', { ...TUS, 'Upload-Length': '1', 'Upload-Metadata': meta });
assert.strictEqual(res.status, 401);
});
// ------------------------------------------------------- password rules
test('a password is required except for one-time downloads', async () => {
const noPw = await upload({ sid: 'needpw', retention: '3600' });
assert.strictEqual(noPw.created.status, 400);
assert.match(noPw.created.json.message, /password/i);
const oneTime = await upload({ sid: 'needpw1', retention: 'one-time' });
assert.ok(oneTime.fid, 'one-time may go without password');
const withPw = await upload({ sid: 'needpw2', retention: '3600', password: 'pw' });
assert.ok(withPw.fid);
});
test('metadata the server owns can not be forged by the client', async () => {
const up = await upload({ sid: 'forgery', password: 'pw' });
const meta = tusMeta.encode({ name: 'a.txt', sid: 'forgery', retention: '3600', password: 'pw', thumb: 'text/html', lastDownload: '1', bucketLocked: 'true' });
const res = await request('POST', '/files/', { ...TUS, ...bearer(), 'Upload-Length': '1', 'Upload-Metadata': meta });
assert.strictEqual(res.status, 201);
const list = await request('GET', '/forgery.json', { 'x-download-pass': 'pw' });
const forged = list.json.items.find(i => i.key !== up.key);
// unfinished uploads are not listed
assert.strictEqual(forged, undefined);
});
// -------------------------------------------------- listing and bypass
test('download list: password, wrong password, no password leak', async () => {
const { key } = await upload({ sid: 'plainlist', password: 'secret1', name: 'doc.txt' });
assert.strictEqual((await request('GET', '/plainlist.json')).status, 401);
assert.strictEqual((await request('GET', '/plainlist.json', { 'x-download-pass': 'bad' })).status, 401);
// a login alone does not skip a password unless the uploader allowed it
assert.strictEqual((await request('GET', '/plainlist.json', bearer())).status, 401);
const ok = await request('GET', '/plainlist.json', { 'x-download-pass': 'secret1' });
assert.strictEqual(ok.status, 200);
assert.strictEqual(ok.json.bypassed, false);
const item = ok.json.items[0];
assert.strictEqual(item.key, key);
assert.strictEqual(item.metadata.hasPassword, true);
assert.strictEqual(item.metadata.password, undefined, 'the hash is not sent');
assert.strictEqual(ok.json.session, null, 'no login, no token');
assert.ok(ok.json.expiresAt > Date.now());
assert.strictEqual(item.expiresAt, ok.json.expiresAt);
});
test('logged in users skip the password when the uploader allowed it', async () => {
await upload({ sid: 'bypassed', password: 'secret2', internalBypass: 'true' });
assert.strictEqual((await request('GET', '/bypassed.json')).status, 401);
assert.strictEqual((await request('GET', '/bypassed.json', { Authorization: 'Bearer garbage' })).status, 401);
const viaLogin = await request('GET', '/bypassed.json', bearer());
assert.strictEqual(viaLogin.status, 200);
assert.strictEqual(viaLogin.json.bypassed, true);
assert.ok(viaLogin.json.session.token, 'using the download page renews the login');
// everybody else still needs the password
const viaPass = await request('GET', '/bypassed.json', { 'x-download-pass': 'secret2' });
assert.strictEqual(viaPass.status, 200);
assert.strictEqual(viaPass.json.bypassed, false);
});
test('the bypass flag is ignored without a password', async () => {
const { fid } = await upload({ sid: 'bypassnopw', retention: 'one-time', internalBypass: 'true' });
const info = JSON.parse(fs.readFileSync(path.join(tmpDir, ...fid.split('++')) + '.json', 'utf8'));
assert.strictEqual(info.metadata.internalBypass, undefined);
assert.strictEqual(info.metadata.password, undefined);
});
test('archive of a bucket needs the token from the list', async () => {
await upload({ sid: 'arch1', password: 'pw', name: 'a.txt', content: 'AAA' });
await upload({ sid: 'arch1', password: 'pw', name: 'b.txt', content: 'BBB' });
const list = await request('GET', '/arch1.json', { 'x-download-pass': 'pw' });
const res = await request('GET', `/files/arch1++${ list.json.archiveToken }.zip`);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.headers['content-type'], 'application/zip');
assert.strictEqual(res.body.subarray(0, 2).toString(), 'PK');
const bad = await request('GET', '/files/arch1++' + '0'.repeat(32) + '.zip');
assert.strictEqual(bad.status, 404);
});
// -------------------------------------------------------------- preview
test('inline preview: type from the file name, not a download, no one-time', async () => {
const { fid, key } = await upload({ sid: 'preview1', password: 'pw', name: 'note.txt', content: '<b>hi</b>' });
const res = await request('GET', `/files/${ fid }?inline=1`);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.headers['content-type'], 'text/plain; charset=utf-8');
assert.strictEqual(res.headers['content-disposition'], 'inline');
assert.strictEqual(res.headers['x-content-type-options'], 'nosniff');
assert.match(res.headers['content-security-policy'], /sandbox/);
await tick();
let list = await request('GET', '/preview1.json', { 'x-download-pass': 'pw' });
assert.strictEqual(list.json.items[0].metadata.lastDownload, undefined, 'a preview is not a download');
assert.strictEqual(list.json.items[0].previewType, 'text');
const dl = await request('GET', `/files/${ fid }`);
assert.strictEqual(dl.status, 200);
assert.match(dl.headers['content-disposition'], /^attachment; filename="note.txt"/);
await tick();
list = await request('GET', '/preview1.json', { 'x-download-pass': 'pw' });
assert.ok(list.json.items.find(i => i.key === key).metadata.lastDownload, 'a download is counted');
});
test('inline preview: html and unknown types are refused, pdf has no sandbox', async () => {
const html = await upload({ sid: 'preview2', password: 'pw', name: 'page.html', content: '<script>alert(1)</script>' });
assert.strictEqual((await request('GET', `/files/${ html.fid }?inline=1`)).status, 404);
const bin = await upload({ sid: 'preview2', password: 'pw', name: 'data.bin' });
assert.strictEqual((await request('GET', `/files/${ bin.fid }?inline=1`)).status, 404);
const pdf = await upload({ sid: 'preview2', password: 'pw', name: 'doc.pdf', content: '%PDF-1.4' });
const res = await request('GET', `/files/${ pdf.fid }?inline=1`);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.headers['content-type'], 'application/pdf');
assert.strictEqual(res.headers['content-security-policy'], undefined);
});
test('inline preview never uses up a one-time file', async () => {
const { fid } = await upload({ sid: 'preview3', name: 'once.txt', retention: 'one-time', content: 'secret' });
assert.strictEqual((await request('GET', `/files/${ fid }?inline=1`)).status, 404);
const dl = await request('GET', `/files/${ fid }`);
assert.strictEqual(dl.status, 200);
assert.strictEqual(dl.text, 'secret');
});
// ------------------------------------------------------------ thumbnails
test('thumbnails: upload, serve, delete with the file', async () => {
const { fid, key } = await upload({ sid: 'thumbs1', password: 'pw', name: 'pic.png', content: 'x' });
assert.strictEqual((await request('PUT', `/files/${ encodeURIComponent(fid) }.thumb`, {}, PNG)).status, 401);
const bad = await request('PUT', `/files/${ encodeURIComponent(fid) }.thumb`, bearer(), Buffer.from('<html>not an image</html>'));
assert.strictEqual(bad.status, 400);
const ok = await request('PUT', `/files/${ encodeURIComponent(fid) }.thumb`, { ...bearer(), 'Content-Type': 'image/png' }, PNG);
assert.strictEqual(ok.status, 204);
const list = await request('GET', '/thumbs1.json', { 'x-download-pass': 'pw' });
const item = list.json.items[0];
assert.match(item.thumbUrl, /files\/thumbs1\+\+.+\.thumb$/);
const img = await request('GET', item.thumbUrl.replace(/^\//, '/'));
assert.strictEqual(img.status, 200);
assert.strictEqual(img.headers['content-type'], 'image/png');
assert.strictEqual(img.headers['x-content-type-options'], 'nosniff');
assert.ok(img.body.equals(PNG));
const thumbFile = path.join(tmpDir, 'thumbs1', key) + '.thumb';
assert.ok(fs.existsSync(thumbFile));
// too large
const big = await request('PUT', `/files/${ encodeURIComponent(fid) }.thumb`, bearer(), Buffer.concat([PNG, Buffer.alloc(70 * 1024)]));
assert.strictEqual(big.status, 413);
// locked buckets take no more thumbnails
assert.strictEqual((await request('PATCH', '/files/thumbs1?lock=yes', { ...TUS, ...bearer() })).status, 204);
const locked = await request('PUT', `/files/${ encodeURIComponent(fid) }.thumb`, bearer(), PNG);
assert.strictEqual(locked.status, 400);
// deleting the file deletes the thumbnail
const del = await request('DELETE', `/admin/buckets/thumbs1/files/${ key }`, ADMIN);
assert.strictEqual(del.status, 200);
assert.ok(!fs.existsSync(thumbFile));
assert.strictEqual((await request('GET', item.thumbUrl)).status, 404);
});
test('one-time files show no thumbnail', async () => {
const { fid } = await upload({ sid: 'thumbs2', name: 'pic.png', retention: 'one-time' });
const put = await request('PUT', `/files/${ encodeURIComponent(fid) }.thumb`, bearer(), PNG);
assert.strictEqual(put.status, 204);
const list = await request('GET', '/thumbs2.json');
assert.strictEqual(list.json.items[0].thumbUrl, null);
assert.strictEqual((await request('GET', `/files/${ fid }.thumb`)).status, 404);
assert.strictEqual((await request('GET', `/files/${ fid }`)).status, 200);
});
// ----------------------------------------------------------------- admin
test('admin area needs the admin password', async () => {
assert.strictEqual((await request('GET', '/admin/data.json')).status, 401);
assert.strictEqual((await request('GET', '/admin/data.json', { 'x-passwd': 'up-secret' })).status, 403);
assert.strictEqual((await request('GET', '/admin/data.json', bearer())).status, 403, 'a login token is no admin');
const ok = await request('GET', '/admin/data.json', ADMIN);
assert.strictEqual(ok.status, 200);
assert.ok(Array.isArray(ok.json.buckets));
assert.strictEqual((await request('GET', '/admin')).status, 200);
});
test('admin sees everything and removes single files', async () => {
const a = await upload({ sid: 'admin1', password: 'pw', name: 'a.txt', content: 'AAA' });
const b = await upload({ sid: 'admin1', password: 'pw', name: 'b.txt', content: 'BBB' });
const data = await request('GET', '/admin/data.json', ADMIN);
const bucket = data.json.buckets.find(x => x.sid === 'admin1');
assert.strictEqual(bucket.files.length, 2);
assert.strictEqual(bucket.files[0].hasPassword, true);
assert.ok(bucket.files[0].url, 'urls of password buckets are not hidden');
assert.strictEqual(bucket.files[0].password, undefined);
assert.strictEqual((await request('DELETE', `/admin/buckets/admin1/files/${ a.key }`)).status, 401);
assert.strictEqual((await request('DELETE', `/admin/buckets/admin1/files/not-a-uuid`, ADMIN)).status, 400);
assert.strictEqual((await request('DELETE', `/admin/buckets/admin1/files/${ a.key }`, ADMIN)).status, 200);
assert.strictEqual((await request('DELETE', `/admin/buckets/admin1/files/${ a.key }`, ADMIN)).status, 404);
// recipients see the remaining file only
const list = await request('GET', '/admin1.json', { 'x-download-pass': 'pw' });
assert.deepStrictEqual(list.json.items.map(i => i.key), [b.key]);
assert.strictEqual((await request('GET', `/files/${ a.fid }`)).status, 404);
// the last file takes the bucket with it
assert.strictEqual((await request('DELETE', `/admin/buckets/admin1/files/${ b.key }`, ADMIN)).status, 200);
assert.strictEqual((await request('GET', '/admin1.json')).status, 404);
});
test('admin removes a whole bucket', async () => {
await upload({ sid: 'admin2', password: 'pw', name: 'a.txt' });
await upload({ sid: 'admin2', password: 'pw', name: 'b.txt' });
const res = await request('DELETE', '/admin/buckets/admin2', ADMIN);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.json.removed, 2);
assert.strictEqual((await request('GET', '/admin2.json')).status, 404);
assert.strictEqual((await request('DELETE', '/admin/buckets/admin2', ADMIN)).status, 404);
assert.strictEqual(fs.existsSync(path.join(tmpDir, 'admin2')), false);
});
test('admin download uses a short lived link and keeps one-time files', async () => {
const { key } = await upload({ sid: 'admin3', name: 'once.txt', retention: 'one-time', content: 'only once' });
const noAuth = await request('POST', '/admin/download-link', ...jsonBody({ sid: 'admin3', key }));
assert.strictEqual(noAuth.status, 401);
const link = await request('POST', '/admin/download-link', { ...ADMIN, ...jsonBody({ sid: 'admin3', key })[0] }, jsonBody({ sid: 'admin3', key })[1]);
assert.strictEqual(link.status, 200);
const res = await request('GET', link.json.url);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.text, 'only once');
assert.match(res.headers['content-disposition'], /^attachment; filename="once.txt"/);
await tick();
// twice is fine and the recipient can still get the file once
assert.strictEqual((await request('GET', link.json.url)).status, 200);
assert.strictEqual((await request('GET', `/files/admin3++${ key }`)).status, 200);
await tick();
assert.strictEqual((await request('GET', `/files/admin3++${ key }`)).status, 404);
assert.strictEqual((await request('GET', '/admin/dl?t=garbage')).status, 403);
// a login token can not be used as download link
assert.strictEqual((await request('GET', `/admin/dl?t=${ encodeURIComponent(token) }`)).status, 403);
});
test('admin archive download of a bucket', async () => {
await upload({ sid: 'admin4', password: 'pw', name: 'a.txt', content: 'AAA' });
const link = await request('POST', '/admin/download-link', { ...ADMIN, 'Content-Type': 'application/json' }, JSON.stringify({ sid: 'admin4', format: 'tar.gz' }));
assert.strictEqual(link.status, 200);
const res = await request('GET', link.json.url);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.body[0], 0x1f);
assert.strictEqual(res.body[1], 0x8b);
});
// -------------------------------------------------------------- settings
test('admin settings change the brand for everybody', async () => {
const before = await request('GET', '/brand.json');
assert.strictEqual(before.json.name, 'Dodo Transfer');
assert.strictEqual((await request('PUT', '/admin/settings', { 'Content-Type': 'application/json' }, '{"name":"x"}')).status, 401);
const bad = await request('PUT', '/admin/settings', { ...ADMIN, 'Content-Type': 'application/json' }, '{"name":""}');
assert.strictEqual(bad.status, 400);
const ok = await request('PUT', '/admin/settings', { ...ADMIN, 'Content-Type': 'application/json' },
JSON.stringify({ name: 'Local Transfer', owner: 'Dev', from: 2025 }));
assert.strictEqual(ok.status, 200);
const brand = await request('GET', '/brand.json');
assert.strictEqual(brand.json.name, 'Local Transfer');
assert.match(brand.json.footer, /^© 2025(–\d{4})? Dev$/);
// the shell page carries the new name
const page = await request('GET', '/');
assert.match(page.text, /<title>Local Transfer<\/title>/);
assert.ok(!page.text.includes('Dodo Transfer'));
// names are escaped
await request('PUT', '/admin/settings', { ...ADMIN, 'Content-Type': 'application/json' }, JSON.stringify({ name: '</script><b>x' }));
const evil = await request('GET', '/');
assert.ok(!evil.text.includes('</script><b>'));
await request('PUT', '/admin/settings', { ...ADMIN, 'Content-Type': 'application/json' }, JSON.stringify({ name: 'Dodo Transfer', owner: '아진산업(주) 전장선행개발팀', from: 2026 }));
});
test('admin logo upload, serving and reset', async () => {
const defaultLogo = await request('GET', '/assets/brand-logo');
assert.strictEqual(defaultLogo.status, 200);
assert.strictEqual(defaultLogo.headers['content-type'], 'image/svg+xml');
const text = await request('POST', '/admin/settings/logo', ADMIN, Buffer.from('not an image'));
assert.strictEqual(text.status, 400);
assert.strictEqual((await request('POST', '/admin/settings/logo', {}, PNG)).status, 401);
const big = await request('POST', '/admin/settings/logo', ADMIN, Buffer.concat([PNG, Buffer.alloc(1024 * 1024)]));
assert.strictEqual(big.status, 413);
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect width="1" height="1"/></svg>');
const ok = await request('POST', '/admin/settings/logo', ADMIN, svg);
assert.strictEqual(ok.status, 200);
assert.strictEqual(ok.json.brand.customLogo, true);
const served = await request('GET', ok.json.brand.logoUrl);
assert.strictEqual(served.headers['content-type'], 'image/svg+xml');
assert.match(served.headers['content-security-policy'], /sandbox/);
assert.ok(served.body.equals(svg));
const reset = await request('DELETE', '/admin/settings/logo', ADMIN);
assert.strictEqual(reset.json.brand.customLogo, false);
assert.strictEqual((await request('GET', '/assets/brand-logo')).headers['content-type'], 'image/svg+xml');
});
// ---------------------------------------------------------------- misc
test('expiry is the earlier of retention and maxAge', async () => {
const db = require('../../lib/db');
const config = require('../../config');
const probe = new db(tmpDir, null);
const created = Date.now();
const file = (retention) => ({ metadata: { createdAt: String(created), retention } });
assert.strictEqual(probe.expiresAt(file('3600')), created + 3600_000);
assert.strictEqual(probe.expiresAt(file('one-time')), created + config.maxAge * 1000);
assert.strictEqual(probe.expiresAt(file(String(config.maxAge * 2))), created + config.maxAge * 1000);
});
test('the old misspelled lock flag is still honored', async () => {
const { fid } = await upload({ sid: 'oldlock', password: 'pw' });
const jsonFile = path.join(tmpDir, ...fid.split('++')) + '.json';
const info = JSON.parse(fs.readFileSync(jsonFile, 'utf8'));
info.metadata.buckedLocked = true;
fs.writeFileSync(jsonFile, JSON.stringify(info));
const db = require('../../lib/db');
const fresh = new db(tmpDir, new (require('../../lib/store'))(tmpDir));
fresh.init();
assert.strictEqual(fresh.isLocked('oldlock'), true);
});
test('hidden folders in the data dir are no buckets', async () => {
fs.mkdirSync(path.join(tmpDir, '.settings'), { recursive: true });
fs.writeFileSync(path.join(tmpDir, '.settings', 'settings.json'), '{}');
const db = require('../../lib/db');
const fresh = new db(tmpDir, new (require('../../lib/store'))(tmpDir));
fresh.init();
assert.strictEqual(fresh.get('.settings'), undefined);
});
test('health check answers without a login', async () => {
const res = await request('GET', '/healthz');
assert.strictEqual(res.status, 200);
assert.strictEqual(res.text, 'ok');
});
test('error pages are branded and escaped', async () => {
const res = await request('GET', '/does-not-exist');
assert.strictEqual(res.status, 404);
assert.match(res.text, /<title>Dodo Transfer<\/title>/);
});