Files

68 lines
6.8 KiB
JavaScript

#!/usr/bin/env node
// The Seafile pass-through (/sf/*): sign-in passes through, slow or flaky Seafile is handled, closed downloads stop the upstream, uploads stream.
// Uses a fake Seafile. Run: node tools/files-test.js
const { spawn } = require('child_process');
const http = require('http');
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
const root = path.join(__dirname, '..');
let failed = 0;
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
const wait = ms => new Promise(r => setTimeout(r, ms));
const SECRET = 'files-test-secret-'.repeat(3), UID = 'ufiles';
(async () => {
const seen = []; let flaky = 0, upstreamClosed = false, uploaded = 0;
const fake = http.createServer((rq, rs) => {
const u = new URL(rq.url, 'http://x'); seen.push(rq.method + ' ' + rq.url);
const json = (c, o) => { rs.writeHead(c, { 'content-type': 'application/json' }); rs.end(JSON.stringify(o)); };
if (u.pathname === '/api2/repos/') return json(200, [{ id: 'r1', name: 'Docs' }]);
if (u.pathname === '/api2/flaky/') { if (++flaky <= 2) { rs.writeHead(503); return rs.end('busy'); } return json(200, { ok: true, after: flaky }); }
if (u.pathname === '/api2/always503/') { rs.writeHead(503, { 'retry-after': '5' }); return rs.end('down'); }
if (u.pathname === '/api2/hang/') return; // never answers
if (u.pathname === '/api2/slowstart/') return setTimeout(() => json(200, { late: true }), 200);
if (u.pathname === '/api2/echo-auth/') return json(200, { auth: rq.headers.authorization || null });
if (u.pathname === '/seafhttp/files/big') { // a long download the browser may walk away from
rs.writeHead(200, { 'content-type': 'application/octet-stream' }); rs.on('close', () => { upstreamClosed = true; });
const t = setInterval(() => rs.write(Buffer.alloc(16384)), 20); rs.on('close', () => clearInterval(t)); return;
}
if (u.pathname === '/seafhttp/files/small') { rs.writeHead(200, { 'content-type': 'text/plain', 'content-disposition': 'attachment; filename="a.txt"', etag: '"e1"' }); return rs.end('hello'); }
if (u.pathname === '/seafhttp/upload-api/tok' && rq.method === 'POST') { let n = 0; rq.on('data', d => n += d.length); rq.on('end', () => { uploaded = n; json(200, [{ name: 'x' }]); }); return; }
if (u.pathname === '/api2/redir/') { rs.writeHead(302, { location: 'http://' + rq.headers.host + '/api2/repos/' }); return rs.end(); }
rs.writeHead(404); rs.end();
});
await new Promise(r => fake.listen(0, '127.0.0.1', r));
const fakeUrl = `http://127.0.0.1:${fake.address().port}`;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-files-')), port = 20000 + Math.floor(Math.random() * 20000), base = `http://localhost:${port}`;
fs.writeFileSync(path.join(dir, 'users.json'), JSON.stringify({ users: [{ id: UID, name: 'Admin', role: 'admin', tabs: [], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: 1 }] }));
const exp = Date.now() + 864e5, cookie = `hub_session=${UID}.1.${exp}.${crypto.createHmac('sha256', SECRET).update(`s${UID}.1.${exp}`).digest('base64url')}`;
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: SECRET, SEAFILE_URL: fakeUrl, SEAFILE_TIMEOUT_MS: '1500' } });
let log = ''; child.stdout.on('data', d => log += d); child.stderr.on('data', d => log += d);
for (let i = 0; i < 60; i++) { try { await fetch(base + '/login'); break; } catch { await wait(100); } }
const get = (p, o = {}) => fetch(base + p, { redirect: 'manual', ...o, headers: { cookie, ...(o.headers || {}) } });
let r = await get('/sf/api2/repos/', { headers: { authorization: 'Token T1' } }); ok('a Seafile call passes through', r.status === 200 && (await r.json())[0].name === 'Docs');
r = await get('/sf/api2/echo-auth/', { headers: { authorization: 'Token T1' } }); ok('with the person\'s Seafile token, which is not changed', (await r.json()).auth === 'Token T1');
r = await fetch(base + '/sf/api2/repos/'); ok('and only for someone signed in to the hub', r.status === 401, r.status);
let n0 = seen.length; r = await get('/sf/api2/flaky/'); const fj = await r.json();
ok('a Seafile that answers 503 twice is retried and the third answer is used', r.status === 200 && fj.ok === true && seen.length - n0 === 3, [r.status, seen.length - n0]);
n0 = seen.length; r = await get('/sf/api2/always503/'); ok('one that stays down gives its 503 after three tries (not a crash)', r.status === 503 && seen.length - n0 === 3, [r.status, seen.length - n0]);
const t0 = Date.now(); r = await get('/sf/api2/hang/'); const hj = await r.json();
ok('one that never answers gives a clear 504 after the time limit, instead of hanging', r.status === 504 && /too long/i.test(hj.error) && Date.now() - t0 < 12000, [r.status, hj, Date.now() - t0]);
r = await get('/sf/api2/slowstart/'); ok('a slow but working answer is still delivered', r.status === 200 && (await r.json()).late === true);
r = await get('/sf/api2/redir/'); ok('redirects stay on the hub address', r.status === 302 && r.headers.get('location') === '/sf/api2/repos/', r.headers.get('location'));
r = await get('/sf/seafhttp/files/small'); ok('a download comes through with its headers (name, tag)', r.status === 200 && (await r.text()) === 'hello' && /a\.txt/.test(r.headers.get('content-disposition')) && r.headers.get('etag') === '"e1"');
r = await get('/sf/seafhttp/files/small?hubinline=1'); ok('a preview is shown in the page instead of downloaded', !r.headers.get('content-disposition'));
const ctl = new AbortController(); r = await get('/sf/seafhttp/files/big', { signal: ctl.signal }); const rd = r.body.getReader(); await rd.read(); ctl.abort(); try { await rd.cancel(); } catch {}
for (let i = 0; i < 30 && !upstreamClosed; i++) await wait(100);
ok('when the browser walks away from a big download, the hub stops reading from Seafile', upstreamClosed === true);
const body = Buffer.alloc(3 * 1024 * 1024, 7); r = await get('/sf/seafhttp/upload-api/tok', { method: 'POST', body, headers: { 'content-type': 'application/octet-stream' } });
ok('an upload is streamed through (all bytes arrive)', r.status === 200 && uploaded === body.length, [r.status, uploaded]);
fake.close(); await wait(100);
r = await get('/sf/api2/repos/', { headers: { authorization: 'Token T1' } }); const dj = await r.json();
ok('with Seafile switched off, the answer says so (502), and the hub keeps running', r.status === 502 && /unreachable/i.test(dj.error) && child.exitCode === null, [r.status, dj]);
child.kill(); fs.rmSync(dir, { recursive: true, force: true });
console.log(failed ? `\n${failed} check(s) failed` : '\nAll Files checks passed'); process.exit(failed ? 1 : 0);
})().catch(e => { console.error('TEST CRASHED:', e); process.exit(1); });