Add channel and permission management, sounds, themes and the public server list

- Channels: create, edit, delete, link and move (drag and drop for channels and
  people), right-click on free space in the channel list
- Permission editor with inherited rules, allow/deny per permission, groups and
  members; menus grey out actions the server does not allow
- Notification sounds for connection, channel, chat, mute and push-to-talk events;
  custom files and TeamSpeak 3 style sound packs (settings.ini), stored in IndexedDB
- Color schemes: Dark, Light, Midnight, Frost and Windows 95
- Public server list with live UDP ping, fetched through Chromium's network stack
- Kick, ban and connection-lost card with Reconnect
- Right-click menus for text fields, links, images and message authors
- Formatting toolbar in the message box, shared with the description editor
- Resizable channel list, menu headers, registration from the self menu only
- Fix: client-side pacing now mirrors Murmur's whole-second leaky bucket exactly,
  so quick actions are no longer dropped silently

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 00:54:03 +02:00
co-authored by Claude Opus 5.5
parent aa679dddcc
commit 9a3aeb29ad
53 changed files with 2684 additions and 170 deletions
+247 -13
View File
@@ -130,7 +130,8 @@ try {
// Always on: a steady stream of 20 ms Opus packets
const stream = collect(25);
await setMode('Always on');
const packets = await within(stream, 'voice packets from the app');
// Generous: on a freshly started server and fake microphone the first packets can take a while
const packets = await within(stream, 'voice packets from the app', 20000);
assert.ok(packets.every(p => p.opus.length > 0 && !p.last));
const steps = packets.slice(1).map((p, i) => p.frame - packets[i].frame);
assert.ok(steps.every(d => d === 2), `sequence advances 2 frames per 20 ms packet: ${steps.join(',')}`);
@@ -200,6 +201,47 @@ try {
assert.equal(await page.locator('img[src^="https://evil"]').count(), 0);
console.log('ok: incoming text rendered and sanitized');
// Right-click menus for text: composer editing, links, messages, authors
const composer = page.locator('.chat .composer [contenteditable]');
const composerText = async () => (await composer.innerText()).trim();
const menuItem = (name: string) => page.getByRole('menuitem', { name, exact: true });
await composer.fill('abc 123');
await composer.click({ button: 'right' });
await menuItem('Select all').click();
await composer.click({ button: 'right' });
await menuItem('Cut').click();
assert.equal(await composerText(), '');
await composer.click({ button: 'right' });
await menuItem('Paste').click();
assert.equal(await composerText(), 'abc 123');
await composer.fill('');
await page.locator('.msg a[href="https://example.com/x"]').click({ button: 'right' });
await menuItem('Copy link address').click();
await composer.click({ button: 'right' });
await menuItem('Paste').click();
assert.equal(await composerText(), 'https://example.com/x');
await composer.fill('');
await page.locator('.msg .html', { hasText: 'hi alice' }).click({ button: 'right' });
await menuItem('Copy message text').waitFor();
await page.keyboard.press('Escape');
await page.locator('.msg header strong', { hasText: 'bob' }).first().click({ button: 'right' });
await menuItem('Send message').waitFor();
await page.keyboard.press('Escape');
console.log('ok: right-click menus for text fields, links, messages and authors');
// Formatting in the message box: toolbar behind the Aa button, sent as Mumble-compatible HTML
await page.locator('.chat .composer').getByRole('button', { name: 'Text formatting' }).click();
await composer.fill('bold words https://example.com/b');
await composer.press('Control+a');
const gotBold = new Promise<string>(res => bob.on('text', m => { if (m.html.includes('bold words')) res(m.html); }));
await page.locator('.chat .composer').getByRole('button', { name: 'Bold (Ctrl+B)' }).click();
await composer.press('Enter');
const bold = await within(gotBold, 'formatted message');
assert.match(bold, /(<b>|font-weight:bold)/, `bold arrives: ${bold}`);
assert.match(bold, /<a href="https:\/\/example\.com\/b">/, 'links still work in formatted messages');
await page.locator('.chat .composer').getByRole('button', { name: 'Text formatting' }).click();
console.log('ok: formatted message from the toolbar');
// YouTube links become a click-to-play card; nothing loads from YouTube before the click
bob.sendText({ channels: [0] }, 'watch <a href="https://youtu.be/dQw4w9WgXcQ?t=1m30s">https://youtu.be/dQw4w9WgXcQ?t=1m30s</a>');
const card = page.locator('.msg', { hasText: 'watch' }).locator('.yt');
@@ -217,6 +259,10 @@ try {
await page.locator('.dm', { hasText: 'bob' }).click();
await page.locator('.msg', { hasText: 'psst' }).waitFor();
console.log('ok: direct message shown in its own view');
const soundLog = () => page.evaluate(() => [...((window as any).__mumh5Sounds ?? [])] as string[]);
const played = await soundLog();
for (const ev of ['connected', 'message', 'privateMessage']) assert.ok(played.includes(ev), `sound for ${ev}: ${played}`);
console.log('ok: sounds for connect, channel and direct messages');
await page.locator('.channel .name').first().dblclick();
// Mute toggles propagate
@@ -243,6 +289,45 @@ try {
await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: quick audio options on right-click');
// Color schemes apply right away and are remembered
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Appearance' }).click();
await page.getByRole('radio', { name: 'Windows 95' }).click();
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), 'win95');
assert.equal(await page.locator('.rail').evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(0, 128, 128)');
await page.getByRole('radio', { name: 'Dark' }).click();
assert.equal(await page.evaluate(() => document.documentElement.dataset.theme), undefined);
await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: color schemes');
// Public server list, from a local list in the official XML format
const http = await import('node:http');
const listServer = http.createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/xml' });
res.end(`<?xml version="1.0"?><servers><server name="E2E Murmur" ip="${host}" port="${port}" country="Testland" country_code="TL" continent_code="EU" url="https://example.org" ca="0"/><server name="Offline one" ip="127.0.0.1" port="9" country="Nowhere" continent_code="NA"/></servers>`);
}).listen(0);
const listPort = (listServer.address() as { port: number }).port;
await page.getByTitle('Browse public servers').click();
const pub = page.getByRole('dialog', { name: 'Public servers' });
await pub.locator('.link', { hasText: 'List source' }).click();
await page.getByLabel('URL').fill(`http://127.0.0.1:${listPort}/v1/list`);
await page.getByRole('button', { name: 'Use this list' }).click();
const row = pub.locator('tr', { hasText: 'E2E Murmur' });
await row.getByText(/^\d+ \/ \d+$/).waitFor({ timeout: 8000 });
await row.getByText(/ms$/).waitFor();
await row.getByRole('button', { name: 'Add' }).click();
assert.equal(await page.getByLabel('Address').inputValue(), host);
assert.equal(await page.getByLabel('Port').inputValue(), port);
assert.equal(await page.getByLabel('Label').inputValue(), 'E2E Murmur');
await page.getByRole('button', { name: 'Cancel' }).click();
listServer.close();
console.log('ok: public server list with live ping and prefilled add');
// Server menus are titled with the server's name
await page.locator('.rail .tile').first().click({ button: 'right' });
await page.locator('.menu .head', { hasText: 'Test Server' }).waitFor();
await page.keyboard.press('Escape');
// Tray icon mirrors the voice state; its menu acts on it
const trayState = () => app.evaluate(() => (globalThis as any).__mumh5Tray && {
tooltip: (globalThis as any).__mumh5Tray.tooltip, items: (globalThis as any).__mumh5Tray.items, icon: (globalThis as any).__mumh5Tray.state.icon
@@ -262,12 +347,50 @@ try {
console.log('ok: tray icon follows voice state and its menu mutes');
await page.locator('.channel + .users, .users').getByTitle('Muted', { exact: true }).first().waitFor();
console.log('ok: self mute propagated and shown');
assert.ok((await soundLog()).includes('mute'), 'mute sound');
// Sound pack in TeamSpeak 3 format: settings.ini maps events to files exactly
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Sounds' }).click();
// Real 16-bit PCM WAVs (a short tone), like the files in a TeamSpeak pack
const wav = (name: string) => {
const rate = 22050, n = rate / 10, b = Buffer.alloc(44 + n * 2);
b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVEfmt ', 8); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
for (let i = 0; i < n; i++) b.writeInt16LE(Math.round(Math.sin(i / 8) * 8000), 44 + i * 2);
return { name, mimeType: 'audio/wav', buffer: b };
};
const ini = { name: 'settings.ini', mimeType: 'text/plain', buffer: Buffer.from([
'[soundfiles]',
'SOUND_CAPTURE_MUTED = play("mic_muted.wav")',
'CHAT_RECEIVED_MESSAGE_CHANNEL = play("chat_message_inbound.wav")',
'CHAT_RECEIVED_MESSAGE_CLIENT = play("chat_message_inbound.wav")',
'CLIENT_SWITCHED_TO_CURRENT_CHANNEL_APPEARS = play("${clientType}_switched_tocurrentchannel.wav")',
'CHANNEL_CREATED_BY_OTHER = '
].join('\n')) };
const packChooser = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Load sound pack...' }).click();
await (await packChooser).setFiles([ini, wav('mic_muted.wav'), wav('chat_message_inbound.wav'),
wav('blue_switched_tocurrentchannel.wav'), wav('neutral_switched_tocurrentchannel.wav'), wav('extra.wav')]);
const rep = page.locator('.report');
for (const line of ['mic_muted.wav: You muted', 'chat_message_inbound.wav: Channel message', 'chat_message_inbound.wav: Direct message',
'neutral_switched_tocurrentchannel.wav: Someone joined your channel']) await rep.getByText(line).waitFor();
await rep.getByText(/Not matched.*extra\.wav/).waitFor();
assert.ok(!(await rep.textContent())!.includes('blue_switched_tocurrentchannel.wav: '), 'neutral variant preferred');
// A custom sound really plays (blob URL, allowed by the security policy) and survives a reload
const before = (await soundLog()).filter(x => x === 'mute').length;
await page.locator('.events li', { hasText: 'You muted' }).getByRole('button', { name: /^Play/ }).click();
await page.waitForFunction(n => ((window as any).__mumh5Sounds ?? []).filter((x: string) => x === 'mute').length > n, before);
assert.equal(await page.getByText(/^Could not play/).count(), 0, 'no playback errors');
await page.getByRole('button', { name: 'Reset all to built-in' }).click();
await page.getByRole('button', { name: 'Done', exact: true }).click();
console.log('ok: TeamSpeak sound pack mapped from settings.ini');
if (process.env.UPLOAD_HOST) {
const png = path.join(userData, 'gradient.png');
writeFileSync(png, testPng());
const gotUpload = new Promise<string>(res => bob.on('text', m => res(m.html)));
await page.locator('.chat .composer input[type=file]').setInputFiles(png);
await page.locator('.chat .composer input[type=file]').first().setInputFiles(png);
const up = await within(gotUpload, 'upload message');
assert.match(up, /<a href="http[^"]+\/cu\/[A-Za-z0-9_-]{12}\/gradient\.png">gradient\.png<\/a>/);
assert.match(up, /<br\/><img src="data:image\/jpeg;base64,[^"]+"\/>$/);
@@ -306,7 +429,7 @@ try {
writeFileSync(txt, 'plain text');
let leaked = false;
const off = bob.on('text', () => { leaked = true; });
await page.locator('.chat .composer input[type=file]').setInputFiles(txt);
await page.locator('.chat .composer input[type=file]').first().setInputFiles(txt);
await page.getByText('notes.txt: file type not allowed on this upload host').waitFor();
await page.waitForTimeout(500);
off();
@@ -430,8 +553,8 @@ try {
admin.joinChannel(lobby.id);
await new Promise(r => setTimeout(r, 300));
const sideMsg = new Promise<number[]>(res => admin.on('text', m => { if (m.html === 'side hello') res(m.channels); }));
await page.locator('.panel textarea').fill('side hello');
await page.locator('.panel textarea').press('Enter');
await page.locator('.panel .composer [contenteditable]').fill('side hello');
await page.locator('.panel .composer [contenteditable]').press('Enter');
assert.deepEqual(await within(sideMsg, 'side chat message'), [lobby.id]);
await page.locator('.panel .msg', { hasText: 'side hello' }).waitFor();
assert.equal(await page.locator('.chat .msg', { hasText: 'side hello' }).count(), 0);
@@ -459,6 +582,101 @@ try {
const grown = (await page.locator('aside.panel').boundingBox())!;
assert.ok(Math.abs(grown.width - (panelBox.width + 154)) < 6, `panel resized ${panelBox.width} -> ${grown.width}`);
console.log('ok: side panel resizable');
// The channel list is resizable too (from its right edge)
const sideBox = (await page.locator('aside.sidebar').boundingBox())!;
const sideHandle = (await page.locator('aside.sidebar .resize').boundingBox())!;
await page.mouse.move(sideHandle.x + 4, sideHandle.y + 300);
await page.mouse.down();
await page.mouse.move(sideHandle.x + 104, sideHandle.y + 300, { steps: 5 });
await page.mouse.up();
const sideAfter = (await page.locator('aside.sidebar').boundingBox())!;
assert.ok(Math.abs(sideAfter.width - (sideBox.width + 100)) < 6, `sidebar resized ${sideBox.width} -> ${sideAfter.width}`);
await page.locator('aside.sidebar .resize').dblclick();
console.log('ok: channel list resizable');
// ─── Channel management and permissions, done by alice through the UI ───
// Give alice (by certificate hash) full rights first, as SuperUser. Not @all: Write
// overrides denies, and bob must stay an ordinary user for the permission checks.
const aliceHash = [...admin.users.values()].find(u => u.name === 'alice')!.hash;
const rootAcl = new Promise<any>(res => admin.on('acl', a => { if (a.channelId === 0) res(a); }));
admin.queryAcl(0);
const ra = await within(rootAcl, 'root ACL');
ra.acls.push({ applyHere: true, applySubs: true, inherited: false, userId: null, group: `$${aliceHash}`, grant: 0x1f0fff, deny: 0 });
admin.saveAcl(ra);
await page.waitForTimeout(1500);
const chByName = (n: string) => [...bob.channels.values()].find(c => c.name === n);
const bobSees = (label: string, cond: () => boolean) => within(new Promise<void>(res => {
if (cond()) return res();
const off = bob.on('channel', () => { if (cond()) { off(); res(); } });
const off2 = bob.on('channelRemove', () => { if (cond()) { off2(); res(); } });
}), label);
// Create from the context menu on free space in the channel list
const listBox = (await page.locator('.sidebar .scroll').boundingBox())!;
await page.mouse.click(listBox.x + listBox.width / 2, listBox.y + listBox.height - 20, { button: 'right' });
await page.getByRole('menuitem', { name: 'Create channel...' }).click();
await page.getByLabel('Name').fill('Team');
await page.getByRole('button', { name: 'Create channel' }).click();
await page.locator('.channel .name', { hasText: 'Team' }).waitFor();
console.log('ok: channel created');
// Edit name and position
await page.locator('.channel .name', { hasText: 'Team' }).click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Edit channel...' }).click();
await page.getByLabel('Name').fill('Team Room');
await page.getByLabel('Position').fill('7');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await bobSees('rename', () => chByName('Team Room')?.position === 7);
const teamId = chByName('Team Room')!.id;
console.log('ok: channel edited');
// Permissions: deny Enter for everyone
await page.locator('.channel .name', { hasText: 'Team Room' }).click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Permissions...' }).click();
const dlg = page.getByRole('dialog', { name: 'Channel: Team Room' });
await dlg.getByRole('listbox', { name: 'Rules' }).waitFor();
await dlg.getByRole('button', { name: 'Add', exact: true }).click();
await dlg.getByLabel('Deny Enter').check();
if (shots) await page.screenshot({ path: path.join(shots, 'acl.png') });
// Group with a registered member
await dlg.getByRole('tab', { name: 'Groups' }).click();
await dlg.getByLabel('New group name').fill('mods');
await dlg.getByRole('button', { name: 'Add', exact: true }).click();
await dlg.getByLabel('Member name').fill('SuperUser');
await dlg.getByRole('button', { name: 'Add member' }).click();
await dlg.locator('.members', { hasText: 'SuperUser' }).waitFor();
if (shots) await page.screenshot({ path: path.join(shots, 'groups.png') });
await dlg.getByRole('button', { name: 'Save', exact: true }).click();
await page.waitForTimeout(1500);
const deniedEnter = new Promise<number>(res => bob.on('permissionDenied', d => res(d.permission)));
bob.joinChannel(teamId);
assert.equal(await within(deniedEnter, 'enter denied'), 0x4);
const teamAcl = new Promise<any>(res => admin.on('acl', a => { if (a.channelId === teamId) res(a); }));
admin.queryAcl(teamId);
const ta = await within(teamAcl, 'team ACL');
assert.deepEqual(ta.groups.find((g: any) => g.name === 'mods')?.add, [0]);
console.log('ok: permissions and groups saved and enforced');
// Drag the channel into Games, and bob into Lobby
await page.locator('.channel .name', { hasText: 'Team Room' }).dragTo(page.locator('.channel .name', { hasText: 'Games' }));
const gamesId = chByName('Games')!.id;
await bobSees('channel moved', () => chByName('Team Room')?.parent === gamesId);
await page.locator('.sidebar .user', { hasText: 'bob' }).dragTo(page.locator('.channel', { hasText: 'Lobby' }).first());
await within(new Promise<void>(res => {
if (bob.self?.channelId === lobby.id) return res();
bob.on('user', u => { if (u.session === bob.session && u.channelId === lobby.id) res(); });
}), 'bob moved');
bob.joinChannel(0);
console.log('ok: drag and drop moves channels and people');
// Delete (Team Room is now inside Games, which stays collapsed while empty)
await page.locator('.channel', { hasText: 'Games' }).first().getByRole('button', { name: 'Expand' }).click();
await page.locator('.channel .name', { hasText: 'Team Room' }).click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Delete channel...' }).click();
await page.getByRole('button', { name: 'Delete channel' }).click();
await bobSees('deleted', () => !chByName('Team Room'));
console.log('ok: channel deleted');
}
// Without an upload host, images are sent inline within Mumble's image limit
@@ -471,7 +689,7 @@ try {
const big = path.join(userData, 'big.png');
writeFileSync(big, testPng(1600, 1200));
const gotInline = new Promise<string>(res => bob.on('text', m => { if (m.html.startsWith('<img')) res(m.html); }));
await page.locator('.chat .composer input[type=file]').setInputFiles(big);
await page.locator('.chat .composer input[type=file]').first().setInputFiles(big);
const inline = await within(gotInline, 'inline image', 15000);
assert.match(inline, /^<img src="data:image\/jpeg;base64,[^"]+"\/>$/);
assert.ok(inline.length <= 131072, `inline image within the server limit (${inline.length})`);
@@ -496,6 +714,7 @@ try {
assert.equal(await page.locator('.rail .tile.voice').getAttribute('title').then(t => t?.startsWith('Test Server')), true);
await page.getByText('Voice is on Test Server').waitFor();
console.log('ok: second server connected, deafened in the background');
assert.ok((await soundLog()).includes('userConnected'), 'sound when someone connects into your channel');
// Joining a channel on the second server moves voice there
await page.locator('.channel .name', { hasText: 'Root' }).dblclick();
@@ -561,15 +780,30 @@ try {
}), 'bob sees the new name');
console.log('ok: name changed by reconnecting');
// Self-registration: a guest has no badge and a Register button; afterwards the Registered badge shows
await page.locator('.me .profile').click();
await page.locator('.panel .register').waitFor();
if (await page.locator('.panel .badge-reg').count()) throw new Error('guest profile shows a badge');
await page.locator('.panel .register').click();
await page.locator('.panel .badge-reg.registered', { hasText: 'Registered' }).waitFor();
await page.locator('.sidebar .user', { hasText: newName }).locator('.reg').waitFor();
// Self-registration from the right-click menu on yourself; the profile shows no badge either way
const meRow = page.locator('.sidebar .user.self');
await meRow.click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Register on this server' }).click();
await meRow.locator('.reg').waitFor();
await meRow.click({ button: 'right' });
assert.equal(await page.getByRole('menuitem', { name: 'Register on this server' }).count(), 0, 'no register item once registered');
await page.keyboard.press('Escape');
console.log('ok: registered on the server');
// Kicked: the reason is shown with a Reconnect button
if (admin) {
const me = [...admin.users.values()].find(u => u.name === newName)!;
admin.kick(me.session, 'testing kicks');
const card = page.locator('.ended');
await card.getByText('You were kicked by SuperUser').waitFor();
await card.getByText('testing kicks').waitFor();
if (shots) await page.screenshot({ path: path.join(shots, 'kicked.png') });
await card.getByRole('button', { name: 'Reconnect' }).click();
await page.locator('.sidebar .status', { hasText: 'Connected' }).waitFor();
assert.equal(await card.count(), 0);
console.log('ok: kick shows the reason and reconnects');
}
if (shots) {
await page.waitForTimeout(300);
for (const [name, w, h] of [['wide', 1280, 800], ['medium', 900, 700], ['narrow', 400, 780]] as const) {
+12
View File
@@ -132,6 +132,18 @@ await wait(800);
await page.screenshot({ path: path.join(out, 'voice-settings.png') });
await page.getByRole('button', { name: 'Done', exact: true }).click();
// Every color scheme, main view with the member list
await page.locator('.panel .icon-btn[aria-label="Close"]').click().catch(() => {});
for (const t of ['light', 'midnight', 'frost', 'win95', 'dark']) {
await page.getByTitle('Settings').click();
await page.getByRole('tab', { name: 'Appearance' }).click();
await page.locator(`[data-theme-preview="${t}"]`).click();
if (t === 'win95') await page.screenshot({ path: path.join(out, 'settings-appearance.png') });
await page.getByRole('button', { name: 'Done', exact: true }).click();
await wait(400);
if (t !== 'dark') await page.screenshot({ path: path.join(out, `theme-${t}.png`) });
}
await page.setViewportSize({ width: 390, height: 844 });
await page.locator('.scrim').first().click().catch(() => {});
await wait(500);
+27
View File
@@ -0,0 +1,27 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parsePublicList, pingServer } from '../electron/publist.ts';
test('parses the public server list XML', () => {
const xml = `<?xml version="1.0" encoding="UTF-8"?><servers>
<server name="Tom &amp; Jerry" ca="1" continent_code="EU" country="Germany" country_code="DE" ip="voice.example.org" port="64738" region="Hessen" url="https://example.org"/>
<server name="No port" ip="10.0.0.1" country_code="US" continent_code="NA" url="javascript:alert(1)"/>
<server name="<C> Contra Clan" ca="0" continent_code="NA" country="United States" country_code="US" ip="mumble.poonbox.com" port="64738" url="www.poonbox.com"/>
<server name="" ip="bad"/>
</servers>`;
assert.deepEqual(parsePublicList(xml), [
{ name: 'Tom & Jerry', host: 'voice.example.org', port: 64738, url: 'https://example.org', country: 'Germany', countryCode: 'de', continent: 'eu', ca: true },
{ name: 'No port', host: '10.0.0.1', port: 64738, url: '', country: 'Unknown', countryCode: 'us', continent: 'na', ca: false },
{ name: '<C> Contra Clan', host: 'mumble.poonbox.com', port: 64738, url: 'http://www.poonbox.com', country: 'United States', countryCode: 'us', continent: 'na', ca: false }
]);
});
const target = process.env.MUMBLE_TEST_HOST;
test('UDP ping reports users and version', { skip: !target }, async () => {
const [host, port] = target!.split(':');
const r = await pingServer(host, Number(port));
assert.ok(r, 'server answered');
assert.match(r!.version, /^1\.\d+\.\d+$/);
assert.ok(r!.maxUsers > 0 && r!.ping >= 0);
assert.equal(await pingServer('127.0.0.1', 9, 500), null, 'no answer gives null');
});
+73 -2
View File
@@ -12,7 +12,7 @@ import { encodeVoice, decodeVoice } from '../src/core/voice-packet.ts';
const target = process.env.MUMBLE_TEST_HOST;
const codec = createCodec();
function connect(name: string): Promise<MumbleClient> {
function connect(name: string, password = ''): Promise<MumbleClient> {
const [host, port] = target!.split(':');
const id = generateIdentity(name);
const client = new MumbleClient(codec);
@@ -22,7 +22,7 @@ function connect(name: string): Promise<MumbleClient> {
onData: c => transport.onData?.(c),
onClose: r => transport.onClose?.(r)
});
client.connect(transport, { username: name, os: 'test' });
client.connect(transport, { username: name, password, os: 'test' });
return new Promise((resolve, reject) => {
client.on('synced', () => resolve(client));
client.on('close', reject);
@@ -161,3 +161,74 @@ test('irregular bursts match the server bucket (whole-second leaks)', { skip: !t
}
});
const superPw = process.env.MUMBLE_SUPERUSER_PASSWORD;
test('channel management and ACLs as SuperUser', { skip: !target || !superPw }, async () => {
const admin = await connect('SuperUser', superPw);
const guest = await connect('guest' + Date.now() % 1000);
try {
const name = 'acltest' + Date.now() % 100000;
const byName = (n: string) => [...admin.channels.values()].find(c => c.name === n);
const channelEvent = (pred: () => boolean) => until<void>(res => {
if (pred()) return res();
const off = admin.on('channel', () => { if (pred()) { off(); res(); } });
});
// Create, rename, describe, move
admin.createChannel(0, name);
await channelEvent(() => !!byName(name));
const id = byName(name)!.id;
admin.updateChannel(id, { name: name + 'x', description: 'about <b>this</b>', position: 3 });
await channelEvent(() => admin.channels.get(id)?.name === name + 'x');
assert.equal(admin.channels.get(id)!.description, 'about <b>this</b>');
admin.createChannel(0, name + 'sub');
await channelEvent(() => !!byName(name + 'sub'));
const sub = byName(name + 'sub')!.id;
admin.updateChannel(sub, { parent: id });
await channelEvent(() => admin.channels.get(sub)?.parent === id);
// Link and unlink
admin.setLinks(id, [0], []);
await channelEvent(() => !!admin.channels.get(id)?.links.has(0));
admin.setLinks(id, [], [0]);
await channelEvent(() => !admin.channels.get(id)?.links.has(0));
// ACL: deny Enter for everyone in the channel, and a group with a registered member
const acl = until<any>(res => admin.on('acl', a => { if (a.channelId === id) res(a); }));
admin.queryAcl(id);
const current = await acl;
assert.ok(current.acls.some((a: any) => a.inherited), 'inherited rules from root are listed');
current.acls.push({ applyHere: true, applySubs: true, inherited: false, userId: null, group: 'all', grant: 0, deny: 0x4 });
current.groups.push({ name: 'mods', inherited: false, inherit: true, inheritable: true, add: [0], remove: [], inheritedMembers: [] });
admin.saveAcl(current);
const saved = until<any>(res => admin.on('acl', a => { if (a.channelId === id) res(a); }));
setTimeout(() => admin.queryAcl(id), 300);
const after = await saved;
assert.ok(after.acls.some((a: any) => !a.inherited && a.group === 'all' && a.deny === 0x4), 'rule saved');
assert.deepEqual(after.groups.find((g: any) => g.name === 'mods')?.add, [0], 'group member saved');
// The rule is enforced: a guest may not enter
const denied = until<number>(res => guest.on('permissionDenied', d => res(d.permission)));
guest.joinChannel(id);
assert.equal(await denied, 0x4);
// Permissions: a guest may traverse the channel but not enter it; SuperUser gets no
// permission messages at all and is treated as allowed everything
const perms = until<number>(res => guest.on('permissions', (c, bits) => { if (c === id) res(bits); }));
guest.requestPermissions(id);
const bits = await perms;
assert.equal(bits & 0x4, 0, 'guest cannot enter');
assert.ok(bits & 0x2, 'guest can traverse');
assert.equal(guest.can(id, 0x4), false);
assert.equal(admin.can(id, 0x1), true);
// Remove (subchannel goes with it)
admin.removeChannel(id);
await until<void>(res => admin.on('channelRemove', c => { if (c === id) res(); }));
assert.ok(!admin.channels.has(sub));
} finally {
admin.disconnect();
guest.disconnect();
}
});