mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-27 09:05:01 +00:00
406 lines
16 KiB
JavaScript
406 lines
16 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const test = require('node:test');
|
|
const Database = require('better-sqlite3');
|
|
|
|
const {
|
|
createTempChannelDeleteCallback,
|
|
generateUniqueChannelCode,
|
|
persistChannelCodeRotation,
|
|
rotateLiveChannelState,
|
|
schedulePendingVoiceLeave
|
|
} = require('../src/channelRotation');
|
|
|
|
test('channel code generation avoids every shared invite namespace', () => {
|
|
const db = new Database(':memory:');
|
|
try {
|
|
db.exec(`
|
|
CREATE TABLE channels (id INTEGER PRIMARY KEY, code TEXT UNIQUE);
|
|
CREATE TABLE server_settings (key TEXT PRIMARY KEY, value TEXT);
|
|
CREATE TABLE invite_codes (id INTEGER PRIMARY KEY, code TEXT UNIQUE);
|
|
INSERT INTO channels (id, code) VALUES (1, '22222222');
|
|
INSERT INTO server_settings (key, value) VALUES ('server_code', '33333333'), ('vanity_code', '44444444');
|
|
INSERT INTO invite_codes (id, code) VALUES (1, '55555555');
|
|
`);
|
|
const candidates = ['invalid!', '11111111', '22222222', '33333333', '44444444', '55555555', '66666666'];
|
|
const generated = generateUniqueChannelCode(db, () => candidates.shift(), '11111111');
|
|
assert.equal(generated, '66666666');
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
test('channel rotation updates the channel and durable code references atomically', () => {
|
|
const db = new Database(':memory:');
|
|
try {
|
|
db.exec(`
|
|
CREATE TABLE channels (
|
|
id INTEGER PRIMARY KEY,
|
|
code TEXT UNIQUE,
|
|
code_rotation_counter INTEGER DEFAULT 0,
|
|
code_last_rotated TEXT,
|
|
afk_sub_code TEXT
|
|
);
|
|
CREATE TABLE user_channel_prefs (
|
|
user_id INTEGER,
|
|
channel_code TEXT,
|
|
muted INTEGER,
|
|
updated_at TEXT,
|
|
PRIMARY KEY (user_id, channel_code)
|
|
);
|
|
CREATE TABLE server_settings (key TEXT PRIMARY KEY, value TEXT);
|
|
INSERT INTO channels (id, code, code_rotation_counter) VALUES (1, '11111111', 5);
|
|
INSERT INTO channels (id, code, afk_sub_code) VALUES (2, 'aaaaaaaa', '11111111');
|
|
INSERT INTO user_channel_prefs (user_id, channel_code, muted) VALUES (3, '11111111', 1);
|
|
INSERT INTO server_settings (key, value) VALUES ('automod_log_channel', '11111111');
|
|
`);
|
|
|
|
assert.equal(persistChannelCodeRotation(db, 1, '11111111', '22222222'), true);
|
|
|
|
const rotated = db.prepare('SELECT code, code_rotation_counter, code_last_rotated FROM channels WHERE id = 1').get();
|
|
assert.equal(rotated.code, '22222222');
|
|
assert.equal(rotated.code_rotation_counter, 0);
|
|
assert.ok(rotated.code_last_rotated);
|
|
assert.equal(db.prepare('SELECT afk_sub_code FROM channels WHERE id = 2').get().afk_sub_code, '22222222');
|
|
assert.equal(db.prepare('SELECT channel_code FROM user_channel_prefs WHERE user_id = 3').get().channel_code, '22222222');
|
|
assert.equal(db.prepare("SELECT value FROM server_settings WHERE key = 'automod_log_channel'").get().value, '22222222');
|
|
db.prepare("INSERT INTO user_channel_prefs (user_id, channel_code, muted) VALUES (3, '33333333', 0)").run();
|
|
assert.throws(
|
|
() => persistChannelCodeRotation(db, 1, '22222222', '33333333'),
|
|
/UNIQUE constraint failed/
|
|
);
|
|
assert.equal(db.prepare('SELECT code FROM channels WHERE id = 1').get().code, '22222222');
|
|
assert.equal(db.prepare('SELECT afk_sub_code FROM channels WHERE id = 2').get().afk_sub_code, '22222222');
|
|
assert.equal(db.prepare("SELECT value FROM server_settings WHERE key = 'automod_log_channel'").get().value, '22222222');
|
|
assert.throws(
|
|
() => persistChannelCodeRotation(db, 1, 'stale-code', '33333333'),
|
|
/changed before rotation completed/
|
|
);
|
|
assert.equal(db.prepare('SELECT code FROM channels WHERE id = 1').get().code, '22222222');
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
test('channel rotation migrates text, voice, media, stream, and pending state', () => {
|
|
const oldCode = '11111111';
|
|
const newCode = '22222222';
|
|
const roomMoves = [];
|
|
const textSocket = {
|
|
currentChannel: oldCode,
|
|
leave(room) { roomMoves.push(['text-leave', room]); },
|
|
join(room) { roomMoves.push(['text-join', room]); }
|
|
};
|
|
const voiceSocket = {
|
|
user: { isBot: true, channelCode: oldCode },
|
|
leave(room) { roomMoves.push(['voice-leave', room]); },
|
|
join(room) { roomMoves.push(['voice-join', room]); }
|
|
};
|
|
const idleBotEvents = [];
|
|
const idleBotSocket = {
|
|
user: { isBot: true, channelId: 9, channelCode: oldCode },
|
|
emit(event, payload) { idleBotEvents.push({ event, payload }); }
|
|
};
|
|
let emitted;
|
|
const io = {
|
|
sockets: {
|
|
adapter: { rooms: new Map([
|
|
[`channel:${oldCode}`, new Set(['text-socket'])],
|
|
[`voice:${oldCode}`, new Set(['voice-socket'])]
|
|
]) },
|
|
sockets: new Map([
|
|
['text-socket', textSocket],
|
|
['voice-socket', voiceSocket],
|
|
['idle-bot-socket', idleBotSocket]
|
|
])
|
|
},
|
|
to(firstRoom) {
|
|
return {
|
|
to(secondRoom) {
|
|
return {
|
|
emit(event, payload) { emitted = { firstRoom, secondRoom, event, payload }; }
|
|
};
|
|
}
|
|
};
|
|
}
|
|
};
|
|
const pendingVoice = { timer: null, oldSocketId: 'voice-socket', code: oldCode };
|
|
const pendingTempTimer = { id: 'temp-timer' };
|
|
let botAudioRotation;
|
|
const state = {
|
|
channelUsers: new Map([[oldCode, new Map([[1, { id: 1 }]])]]),
|
|
voiceUsers: new Map([[oldCode, new Map([[1, { id: 1 }]])]]),
|
|
activeMusic: new Map([[oldCode, { id: 'track' }]]),
|
|
musicQueues: new Map([[oldCode, ['next']]]),
|
|
activeScreenSharers: new Map([[oldCode, new Set([1])]]),
|
|
activeScreenSessions: new Map([[oldCode, new Map([[
|
|
1,
|
|
{ transport: 'browser', sessionId: null },
|
|
]])]]),
|
|
activeWebcamUsers: new Map([[oldCode, new Set([1])]]),
|
|
streamViewers: new Map([[`${oldCode}:1`, new Set([2])]]),
|
|
pendingVoiceLeave: new Map([[`1:${oldCode}`, pendingVoice]]),
|
|
pendingTempDelete: new Map([[oldCode, pendingTempTimer]]),
|
|
botAudioManager: {
|
|
renameChannel(from, to) { botAudioRotation = { from, to }; }
|
|
}
|
|
};
|
|
|
|
rotateLiveChannelState(io, state, 9, oldCode, newCode);
|
|
|
|
assert.equal(textSocket.currentChannel, newCode);
|
|
assert.ok(roomMoves.some(move => move[0] === 'text-join' && move[1] === `channel:${newCode}`));
|
|
assert.ok(roomMoves.some(move => move[0] === 'voice-join' && move[1] === `voice:${newCode}`));
|
|
assert.equal(voiceSocket.user.channelCode, newCode);
|
|
assert.equal(idleBotSocket.user.channelCode, newCode);
|
|
assert.deepEqual(idleBotEvents, [{
|
|
event: 'channel-code-rotated',
|
|
payload: { channelId: 9, oldCode, newCode }
|
|
}]);
|
|
for (const name of ['channelUsers', 'voiceUsers', 'activeMusic', 'musicQueues', 'activeScreenSharers', 'activeScreenSessions', 'activeWebcamUsers']) {
|
|
assert.equal(state[name].has(oldCode), false, `${name} kept the old code`);
|
|
assert.equal(state[name].has(newCode), true, `${name} missed the new code`);
|
|
}
|
|
assert.equal(state.streamViewers.has(`${newCode}:1`), true);
|
|
assert.equal(state.pendingVoiceLeave.has(`1:${newCode}`), true);
|
|
assert.equal(pendingVoice.code, newCode);
|
|
assert.equal(state.pendingTempDelete.get(newCode), pendingTempTimer);
|
|
assert.deepEqual(botAudioRotation, { from: oldCode, to: newCode });
|
|
assert.deepEqual(emitted, {
|
|
firstRoom: `channel:${newCode}`,
|
|
secondRoom: `voice:${newCode}`,
|
|
event: 'channel-code-rotated',
|
|
payload: { channelId: 9, oldCode, newCode }
|
|
});
|
|
|
|
assert.throws(
|
|
() => rotateLiveChannelState(io, state, 9, newCode, newCode),
|
|
/must differ/
|
|
);
|
|
assert.equal(state.voiceUsers.has(newCode), true);
|
|
});
|
|
|
|
test('a pending voice eviction uses the rotated code when its timer fires', () => {
|
|
const oldCode = '11111111';
|
|
const newCode = '22222222';
|
|
let callback;
|
|
let leave;
|
|
const socket = { id: 'old-socket', user: { id: 1, username: 'alice' } };
|
|
const voiceUsers = new Map([[oldCode, new Map([[1, { id: 1, socketId: socket.id }]])]]);
|
|
const pendingVoiceLeave = new Map();
|
|
schedulePendingVoiceLeave({
|
|
pendingVoiceLeave,
|
|
voiceUsers,
|
|
socket,
|
|
userId: 1,
|
|
code: oldCode,
|
|
oldSocketId: socket.id,
|
|
handleVoiceLeave: (_socket, code, options) => { leave = { code, options }; },
|
|
setTimer: fn => { callback = fn; return { id: 'timer' }; },
|
|
log: () => {}
|
|
});
|
|
|
|
const io = {
|
|
sockets: { adapter: { rooms: new Map() }, sockets: new Map() },
|
|
to() { return { to() { return { emit() {} }; } }; }
|
|
};
|
|
const state = {
|
|
channelUsers: new Map(),
|
|
voiceUsers,
|
|
activeMusic: new Map(),
|
|
musicQueues: new Map(),
|
|
activeScreenSharers: new Map(),
|
|
activeWebcamUsers: new Map(),
|
|
streamViewers: new Map(),
|
|
pendingVoiceLeave,
|
|
pendingTempDelete: new Map()
|
|
};
|
|
rotateLiveChannelState(io, state, 9, oldCode, newCode);
|
|
callback();
|
|
|
|
assert.deepEqual(leave, { code: newCode, options: { softDisconnect: true } });
|
|
assert.equal(pendingVoiceLeave.size, 0);
|
|
});
|
|
|
|
test('a temporary-channel timer deletes by stable id after code rotation', () => {
|
|
const db = new Database(':memory:');
|
|
try {
|
|
db.exec(`
|
|
CREATE TABLE channels (id INTEGER PRIMARY KEY, code TEXT UNIQUE, is_temp_voice INTEGER);
|
|
CREATE TABLE messages (id INTEGER PRIMARY KEY, channel_id INTEGER, is_archived INTEGER DEFAULT 0, thread_id INTEGER);
|
|
CREATE TABLE reactions (message_id INTEGER);
|
|
CREATE TABLE pinned_messages (channel_id INTEGER);
|
|
CREATE TABLE channel_members (channel_id INTEGER);
|
|
INSERT INTO channels (id, code, is_temp_voice) VALUES (1, '11111111', 1);
|
|
`);
|
|
const channelUsers = new Map([['22222222', new Map()]]);
|
|
const voiceUsers = new Map([['22222222', new Map()]]);
|
|
const activeScreenSharers = new Map([['22222222', new Set([1])]]);
|
|
const activeScreenSessions = new Map([['22222222', new Map([[1, { transport: 'browser' }]])]]);
|
|
const pendingTempDelete = new Map([['22222222', { id: 'timer' }]]);
|
|
let deleted;
|
|
let stoppedAudio;
|
|
const callback = createTempChannelDeleteCallback({
|
|
db,
|
|
io: { emit: (event, payload) => { deleted = { event, payload }; } },
|
|
state: {
|
|
channelUsers, voiceUsers, activeScreenSharers, activeScreenSessions,
|
|
pendingTempDelete,
|
|
botAudioManager: {
|
|
stopChannel(code, reason) { stoppedAudio = { code, reason }; }
|
|
}
|
|
},
|
|
channelId: 1,
|
|
log: () => {}
|
|
});
|
|
|
|
db.prepare("UPDATE channels SET code = '22222222' WHERE id = 1").run();
|
|
callback();
|
|
|
|
assert.equal(db.prepare('SELECT 1 FROM channels WHERE id = 1').get(), undefined);
|
|
assert.deepEqual(deleted, {
|
|
event: 'channel-deleted',
|
|
payload: { code: '22222222', reason: 'temp-empty' }
|
|
});
|
|
assert.equal(channelUsers.has('22222222'), false);
|
|
assert.equal(voiceUsers.has('22222222'), false);
|
|
assert.equal(activeScreenSharers.has('22222222'), false);
|
|
assert.equal(activeScreenSessions.has('22222222'), false);
|
|
assert.equal(pendingTempDelete.has('22222222'), false);
|
|
assert.deepEqual(stoppedAudio, { code: '22222222', reason: 'channel-deleted' });
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
test('temporary-channel deletion rolls back completely on failure', () => {
|
|
const db = new Database(':memory:');
|
|
try {
|
|
db.exec(`
|
|
CREATE TABLE channels (id INTEGER PRIMARY KEY, code TEXT UNIQUE, is_temp_voice INTEGER);
|
|
CREATE TABLE messages (id INTEGER PRIMARY KEY, channel_id INTEGER, is_archived INTEGER DEFAULT 0, thread_id INTEGER);
|
|
CREATE TABLE reactions (message_id INTEGER);
|
|
CREATE TABLE pinned_messages (channel_id INTEGER);
|
|
CREATE TABLE channel_members (channel_id INTEGER);
|
|
INSERT INTO channels (id, code, is_temp_voice) VALUES (1, '11111111', 1);
|
|
INSERT INTO messages (id, channel_id) VALUES (1, 1);
|
|
INSERT INTO channel_members (channel_id) VALUES (1);
|
|
CREATE TRIGGER reject_temp_delete BEFORE DELETE ON channels
|
|
BEGIN
|
|
SELECT RAISE(ABORT, 'delete blocked');
|
|
END;
|
|
`);
|
|
const pendingTempDelete = new Map([['11111111', { id: 'timer' }]]);
|
|
const warnings = [];
|
|
const callback = createTempChannelDeleteCallback({
|
|
db,
|
|
io: { emit() {} },
|
|
state: {
|
|
channelUsers: new Map([['11111111', new Map()]]),
|
|
voiceUsers: new Map([['11111111', new Map()]]),
|
|
pendingTempDelete
|
|
},
|
|
channelId: 1,
|
|
log: () => {},
|
|
warn: (...args) => warnings.push(args.join(' '))
|
|
});
|
|
|
|
callback();
|
|
|
|
assert.ok(db.prepare('SELECT 1 FROM channels WHERE id = 1').get());
|
|
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM messages WHERE channel_id = 1').get().count, 1);
|
|
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM channel_members WHERE channel_id = 1').get().count, 1);
|
|
assert.equal(pendingTempDelete.has('11111111'), false);
|
|
assert.match(warnings[0], /delete blocked/);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
test('an expired temp-delete timer clears its marker when the room is occupied', () => {
|
|
const db = new Database(':memory:');
|
|
try {
|
|
db.exec(`
|
|
CREATE TABLE channels (id INTEGER PRIMARY KEY, code TEXT UNIQUE, is_temp_voice INTEGER);
|
|
CREATE TABLE messages (id INTEGER PRIMARY KEY, channel_id INTEGER, is_archived INTEGER DEFAULT 0, thread_id INTEGER);
|
|
CREATE TABLE reactions (message_id INTEGER);
|
|
CREATE TABLE pinned_messages (channel_id INTEGER);
|
|
CREATE TABLE channel_members (channel_id INTEGER);
|
|
INSERT INTO channels (id, code, is_temp_voice) VALUES (1, '11111111', 1);
|
|
`);
|
|
const pendingTempDelete = new Map([['11111111', { id: 'timer' }]]);
|
|
const callback = createTempChannelDeleteCallback({
|
|
db,
|
|
io: { emit() {} },
|
|
state: {
|
|
channelUsers: new Map(),
|
|
voiceUsers: new Map([['11111111', new Map([[1, { id: 1 }]])]]),
|
|
pendingTempDelete
|
|
},
|
|
channelId: 1,
|
|
log: () => {}
|
|
});
|
|
|
|
assert.equal(callback(), false);
|
|
assert.ok(db.prepare('SELECT 1 FROM channels WHERE id = 1').get());
|
|
assert.equal(pendingTempDelete.has('11111111'), false);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
test('temporary-channel grace is armed before stale-user pruning', () => {
|
|
const source = fs.readFileSync(path.join(__dirname, '../src/socketHandlers/index.js'), 'utf8');
|
|
const voiceSource = fs.readFileSync(path.join(__dirname, '../src/socketHandlers/voice.js'), 'utf8');
|
|
const handleVoiceLeave = source.slice(
|
|
source.indexOf('function handleVoiceLeave'),
|
|
source.indexOf('// ── Push notification helper')
|
|
);
|
|
assert.ok(handleVoiceLeave.indexOf('pendingTempDelete.set(code, timer)') < handleVoiceLeave.indexOf('broadcastVoiceUsers(code)'));
|
|
assert.match(source, /ch && ch\.is_temp_voice && !pendingTempDelete\.has\(code\)/);
|
|
assert.match(source, /if \(pendingTempDelete\.has\(ch\.code\)\) continue/);
|
|
assert.match(source, /pendingVoiceLeave\.has\(`\$\{userId\}:\$\{ch\.code\}`\)/);
|
|
assert.match(voiceSource, /if \(pendingTempDelete\?\.has\(code\)\)[\s\S]+Grace-period deletion cancelled/);
|
|
assert.match(source, /persistChannelCodeRotation\([\s\S]+automod\.invalidate\(\)/);
|
|
});
|
|
|
|
test('a temporary channel holding protected messages is kept, and only the rest is cleared', () => {
|
|
const db = new Database(':memory:');
|
|
try {
|
|
db.exec(`
|
|
CREATE TABLE channels (id INTEGER PRIMARY KEY, code TEXT UNIQUE, is_temp_voice INTEGER, expires_at TEXT);
|
|
CREATE TABLE messages (id INTEGER PRIMARY KEY, channel_id INTEGER, is_archived INTEGER DEFAULT 0, thread_id INTEGER);
|
|
CREATE TABLE reactions (message_id INTEGER);
|
|
CREATE TABLE pinned_messages (message_id INTEGER, channel_id INTEGER);
|
|
CREATE TABLE channel_members (channel_id INTEGER);
|
|
INSERT INTO channels (id, code, is_temp_voice, expires_at) VALUES (1, '11111111', 1, '2026-01-01 00:00:00');
|
|
INSERT INTO messages (id, channel_id, is_archived) VALUES (1, 1, 1), (2, 1, 0);
|
|
INSERT INTO channel_members (channel_id) VALUES (1);
|
|
`);
|
|
const pendingTempDelete = new Map([['11111111', { id: 'timer' }]]);
|
|
const emitted = [];
|
|
const callback = createTempChannelDeleteCallback({
|
|
db,
|
|
io: {
|
|
emit: (event) => emitted.push(event),
|
|
to: () => ({ emit: (event) => emitted.push(event) }),
|
|
},
|
|
state: { channelUsers: new Map(), voiceUsers: new Map(), pendingTempDelete },
|
|
channelId: 1,
|
|
log: () => {}
|
|
});
|
|
|
|
assert.equal(callback(), false);
|
|
const ch = db.prepare('SELECT is_temp_voice, expires_at FROM channels WHERE id = 1').get();
|
|
assert.deepEqual({ ...ch }, { is_temp_voice: 0, expires_at: null });
|
|
assert.deepEqual(db.prepare('SELECT id FROM messages ORDER BY id').all().map(r => r.id), [1]);
|
|
assert.equal(pendingTempDelete.has('11111111'), false);
|
|
assert.ok(!emitted.includes('channel-deleted'));
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|