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:
+73
-2
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user