Files
Haven/test/deleteParentChannel.test.js
ancsemi 8fc9290090 Deleting a parent channel deletes its sub-channels, after a second warning that names them
The parent link is ON DELETE SET NULL, so a parent's sub-channels survived its deletion and turned up as top-level channels nobody had created. The delete handler now removes the whole tree in one transaction, collects attachments from every channel in it, clears runtime state and notifies each room, and writes an audit entry per sub-channel. The client asks a second time when sub-channels exist, lists them, and suggests moving any worth keeping to another channel or promoting them to top level first. A server test covers the cascade and leaves an unrelated channel untouched.
2026-09-10 22:41:19 -04:00

108 lines
4.8 KiB
JavaScript

/**
* Deleting a parent channel takes its sub-channels with it.
*
* The parent link is ON DELETE SET NULL, so the sub-channels used to survive
* their parent's deletion as top-level channels nobody had created. The delete
* handler now removes them in the same transaction.
*
* Boots a server on a scratch port and data dir:
* node --test test/deleteParentChannel.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 PORT = 3395;
const BASE = `http://localhost:${PORT}`;
const DATA = path.join(os.tmpdir(), `haven-delete-parent-${Date.now()}`);
let server;
const sockets = [];
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));
function connect(token) {
const s = io(BASE, { auth: { token }, transports: ['websocket'], forceNew: true });
sockets.push(s);
return new Promise((res, rej) => { s.on('connect', () => res(s)); s.on('connect_error', rej); });
}
function next(sock, event, filter = () => true, ms = 4000) {
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);
});
}
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: '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');
});
// Close every client socket here as well as at the end of the test, so a
// failed assertion cannot leave one reconnecting forever and hang the runner.
test.after(() => {
for (const s of sockets) { try { s.close(); } catch {} }
server?.kill();
try { fs.rmSync(DATA, { recursive: true, force: true }); } catch {}
});
test('deleting a parent channel removes its sub-channels instead of promoting them', async () => {
const admin = await post('/api/auth/register', { username: 'admin', password: 'deletetest123', eulaVersion: '2.0', ageVerified: true });
assert.ok(admin.token, 'admin registered');
const A = await connect(admin.token);
const parentSeen = next(A, 'channels-list', (chs) => Array.isArray(chs) && chs.some((c) => c.name === 'parent'));
A.emit('create-channel', { name: 'parent' });
const parent = (await parentSeen).find((c) => c.name === 'parent');
assert.ok(parent && parent.code, 'parent created');
const subsSeen = next(A, 'channels-list', (chs) => Array.isArray(chs) &&
chs.some((c) => c.name === 'child-b' && c.parent_channel_id === parent.id));
A.emit('create-sub-channel', { parentCode: parent.code, name: 'child-a' });
await wait(150);
A.emit('create-sub-channel', { parentCode: parent.code, name: 'child-b' });
const withSubs = await subsSeen;
assert.ok(withSubs, 'sub-channels created');
assert.equal(withSubs.filter((c) => c.parent_channel_id === parent.id).length, 2, 'both sub-channels sit under the parent');
// A bystander channel proves the delete is scoped to the parent's own tree.
const otherSeen = next(A, 'channels-list', (chs) => Array.isArray(chs) && chs.some((c) => c.name === 'bystander'));
A.emit('create-channel', { name: 'bystander' });
assert.ok(await otherSeen, 'bystander created');
const gone = next(A, 'channels-list', (chs) => Array.isArray(chs) && !chs.some((c) => c.name === 'parent'));
A.emit('delete-channel', { code: parent.code });
const after = await gone;
assert.ok(after, 'a channel list arrived without the parent');
const names = after.map((c) => c.name);
assert.ok(!names.includes('child-a') && !names.includes('child-b'),
`sub-channels were deleted with the parent, got: ${names.join(', ')}`);
assert.ok(!after.some((c) => (c.name === 'child-a' || c.name === 'child-b') && !c.parent_channel_id),
'no sub-channel was promoted to top level');
assert.ok(names.includes('bystander'), 'the unrelated channel is untouched');
A.close();
});