Files
Haven/test/e2eFileCleanup.test.js

117 lines
5.0 KiB
JavaScript

/**
* Files sent in end-to-end encrypted DMs are cleaned up with their messages (#5699).
*
* The server cannot read an encrypted message to find the file it points at,
* so the sender names the file alongside it. Only the sender's own uploads
* are accepted there, and deleting the DM notes those files for the sweep
* that moves them to deleted-attachments.
*
* Boots a server on a scratch port and data dir:
* node --test test/e2eFileCleanup.test.js
*/
const test = require('node:test');
const assert = require('node:assert');
const http = require('node:http');
const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const { spawn } = require('node:child_process');
const { io } = require('socket.io-client');
const Database = require('better-sqlite3');
const PORT = 3402;
const BASE = `http://localhost:${PORT}`;
const DATA = path.join(os.tmpdir(), `haven-e2e-files-${Date.now()}`);
let server;
const post = (p, body) => new Promise((res, rej) => {
const d = JSON.stringify(body);
const r = http.request({ host: 'localhost', port: PORT, path: p, method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(d) } },
(x) => { let b = ''; x.on('data', (c) => (b += c)); x.on('end', () => { try { res(JSON.parse(b)); } catch { res({ raw: b }); } }); });
r.on('error', rej); r.write(d); r.end();
});
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const register = (username) => post('/api/auth/register', { username, password: 'e2e-files-test-123', eulaVersion: '2.0', ageVerified: true });
function connect(token) {
const s = io(BASE, { auth: { token }, transports: ['websocket'], forceNew: true });
return new Promise((res, rej) => { s.on('connect', () => res(s)); s.on('connect_error', rej); });
}
function next(sock, event, filter = () => true, ms = 5000) {
return new Promise((res) => {
const t = setTimeout(() => { sock.off(event, h); res(null); }, ms);
const h = (data) => { if (!filter(data)) return; clearTimeout(t); sock.off(event, h); res(data); };
sock.on(event, h);
});
}
async function upload(token) {
const fd = new FormData();
fd.append('scope', 'dm');
fd.append('file', new Blob([Buffer.from('not really encrypted, just bytes')], { type: 'application/octet-stream' }), 'e2e-file.enc');
const r = await fetch(`${BASE}/api/upload-file`, { method: 'POST', headers: { Authorization: 'Bearer ' + token }, body: fd });
return r.json();
}
const readDb = (sql, ...args) => {
const db = new Database(path.join(DATA, 'haven.db'), { readonly: true, fileMustExist: true });
try { return db.prepare(sql).all(...args); } finally { db.close(); }
};
test.before(async () => {
fs.mkdirSync(DATA, { recursive: true });
server = spawn(process.execPath, ['server.js'], {
cwd: path.join(__dirname, '..'),
env: { ...process.env, PORT: String(PORT), HAVEN_DATA_DIR: DATA, ADMIN_USERNAME: 'nobody-admin', FORCE_HTTP: 'true' },
stdio: 'ignore',
});
for (let i = 0; i < 60; i++) {
try {
await new Promise((res, rej) => http.get(`${BASE}/api/health`, (r) => (r.statusCode === 200 ? res() : rej())).on('error', rej));
return;
} catch { await wait(500); }
}
throw new Error('server did not start');
});
test.after(() => { server?.kill(); try { fs.rmSync(DATA, { recursive: true, force: true }); } catch {} });
test('an encrypted DM file is noted for cleanup when the DM is deleted', async () => {
const alice = await register('alice');
const bob = await register('bob');
assert.ok(alice.token && bob.token, 'both registered');
const A = await connect(alice.token);
const B = await connect(bob.token);
const opened = next(A, 'dm-opened');
A.emit('start-dm', { targetUserId: bob.user.id });
const dm = await opened;
assert.ok(dm && dm.code, 'DM opened');
const mine = await upload(alice.token);
const theirs = await upload(bob.token);
assert.ok(mine.url && theirs.url, `uploads: ${JSON.stringify(mine)} ${JSON.stringify(theirs)}`);
const myRel = mine.url.replace('/uploads/', '');
const arrived = next(A, 'new-message', (d) => d.channelCode === dm.code);
A.emit('send-message', {
code: dm.code,
content: JSON.stringify({ v: 1, ct: 'ciphertext-stand-in', iv: 'x' }),
encrypted: true,
// Someone else's file and a path escape are ignored; only her own counts.
files: [mine.url, theirs.url, '/uploads/../haven.db'],
});
const msg = await arrived;
assert.ok(msg, 'the encrypted message went through');
const stored = readDb('SELECT e2e_files FROM messages WHERE id = ?', msg.message.id)[0];
assert.deepStrictEqual(JSON.parse(stored.e2e_files), [myRel], 'only the sender\'s own upload is recorded');
A.emit('delete-dm', { code: dm.code });
let released = [];
for (let i = 0; i < 20 && !released.length; i++) {
await wait(200);
released = readDb('SELECT rel_path FROM released_uploads').map(r => r.rel_path);
}
assert.deepStrictEqual(released, [myRel], 'deleting the DM notes its file for cleanup');
A.disconnect(); B.disconnect();
});