// Der Bauplan wird gegen einen echten Server gefahren, auf dem danach Leute // sitzen. Ein vertipptes Recht faellt dort nicht auf - es faellt auf, wenn // jemand es ausnutzt. Deshalb pruefen diese Tests vor allem das, was nicht // drinstehen darf. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { P, bits, names, str } from './permissions.ts'; import { AUTOMOD, AUTOMOD_BLOCK, CATEGORIES, CHANNELS, EVERYONE, ROLES, WELCOME } from './plan.ts'; import { findChannel, mergeOverwrites, normalizeName, rolePositions } from './apply.ts'; import { CH } from './api.ts'; const role = (name: string) => { const r = ROLES.find((x) => x.name === name); assert.ok(r, `Rolle ${name} fehlt im Bauplan`); return r; }; // ---------------------------------------------------------------- Bits test('Rechtebits entsprechen den Werten der Discord-API', () => { assert.equal(P.VIEW_CHANNEL, 1024n); assert.equal(P.SEND_MESSAGES, 2048n); assert.equal(P.ADMINISTRATOR, 8n); assert.equal(P.MENTION_EVERYONE, 131072n); // Jenseits von 2^31 - hier wuerde eine number-Rechnung still falsch werden. assert.equal(P.CREATE_PUBLIC_THREADS, 34359738368n); assert.equal(P.MODERATE_MEMBERS, 1099511627776n); assert.equal(P.SEND_POLLS, 562949953421312n); }); test('bits vereinigt, str liefert den Dezimalstring der API', () => { assert.equal(bits('VIEW_CHANNEL', 'SEND_MESSAGES'), 3072n); assert.equal(str(bits('VIEW_CHANNEL', 'SEND_MESSAGES')), '3072'); assert.equal(bits(), 0n); // Doppelt genannt bleibt einfach gesetzt. assert.equal(bits('VIEW_CHANNEL', 'VIEW_CHANNEL'), 1024n); }); test('names liest ein Bitfeld wieder auf', () => { assert.deepEqual(names(bits('KICK_MEMBERS', 'BAN_MEMBERS')), ['KICK_MEMBERS', 'BAN_MEMBERS']); }); // ---------------------------------------------------------------- Rollen test('@everyone bekommt kein Recht, mit dem sich der Server sprengen laesst', () => { for (const verboten of [ 'ADMINISTRATOR', 'MENTION_EVERYONE', 'MANAGE_GUILD', 'MANAGE_ROLES', 'MANAGE_CHANNELS', 'MANAGE_MESSAGES', 'MANAGE_WEBHOOKS', 'KICK_MEMBERS', 'BAN_MEMBERS', 'SEND_TTS_MESSAGES', ] as const) { assert.equal(EVERYONE & P[verboten], 0n, `@everyone darf nicht ${verboten}`); } }); test('@everyone kann das Noetige: lesen, schreiben, einladen', () => { for (const noetig of ['VIEW_CHANNEL', 'SEND_MESSAGES', 'READ_MESSAGE_HISTORY', 'CREATE_INSTANT_INVITE'] as const) { assert.equal(EVERYONE & P[noetig], P[noetig], `@everyone braucht ${noetig}`); } }); test('Mod darf moderieren, aber nicht umbauen', () => { const mod = role('Mod').permissions; for (const darf of ['KICK_MEMBERS', 'BAN_MEMBERS', 'MODERATE_MEMBERS', 'MANAGE_MESSAGES'] as const) { assert.equal(mod & P[darf], P[darf], `Mod braucht ${darf}`); } for (const nicht of ['ADMINISTRATOR', 'MANAGE_GUILD', 'MANAGE_ROLES', 'MANAGE_CHANNELS', 'MANAGE_WEBHOOKS'] as const) { assert.equal(mod & P[nicht], 0n, `Mod darf nicht ${nicht}`); } }); test('Administrator traegt genau eine Rolle, und das ist Abdu', () => { const mitAdmin = ROLES.filter((r) => (r.permissions & P.ADMINISTRATOR) === P.ADMINISTRATOR); assert.deepEqual(mitAdmin.map((r) => r.name), ['Abdu']); }); test('Abdu steht ganz oben in der Rangfolge', () => { // Die Reihenfolge in ROLES ist die Rangfolge auf dem Server. assert.equal(ROLES[0]?.name, 'Abdu'); }); test('Stammgast ist Auszeichnung, keine Macht', () => { const s = role('Stammgast'); assert.equal(s.hoist, true, 'muss in der Mitgliederliste sichtbar sein - das ist der ganze Zweck'); // Ueber @everyone hinaus nur Kosmetik und Sprachkanal-Vorrang. assert.equal(s.permissions & ~(EVERYONE | P.PRIORITY_SPEAKER), 0n); }); test('Live-Ping ist erwaehnbar, sonst nichts', () => { const l = role('Live-Ping'); assert.equal(l.mentionable, true, 'sonst kann die Go-Live-Meldung sie nicht anpingen'); assert.equal(l.permissions & ~EVERYONE, 0n); }); // ---------------------------------------------------------------- Kanaele test('Kanalnamen sind eindeutig und ueberleben Discords Normalisierung', () => { const seen = new Set(); for (const c of CHANNELS) { // Discord schreibt Textkanaele klein. Steht im Plan ein Grossbuchstabe, // weicht der gespeicherte Name ab und wir benennen bei jedem Lauf erneut um. if (c.type === CH.TEXT) { assert.equal(c.name, normalizeName(c.name, c.type), `${c.name}: Discord wuerde den Namen veraendern`); } assert.equal(seen.has(c.name), false, `${c.name} kommt doppelt vor`); seen.add(c.name); } }); test('jeder Eintrag hat einen eindeutigen key', () => { const keys = [...CATEGORIES.map((c) => c.key), ...CHANNELS.map((c) => c.key)]; assert.equal(new Set(keys).size, keys.length, 'keys muessen eindeutig sein'); }); test('der alte Name steht als alias drin, sonst entsteht ein zweiter Kanal', () => { // Beim Umstellen auf Emoji-Namen war genau das die Gefahr. for (const c of CHANNELS) { assert.ok(c.aliases?.length, `${c.key} hat keinen frueheren Namen hinterlegt`); } }); test('jeder Kanal haengt in einer Kategorie, die es gibt', () => { for (const c of CHANNELS) { assert.ok(c.category, `${c.name} hat keine Kategorie`); assert.ok(CATEGORIES.some((k) => k.key === c.category), `${c.name}: Kategorie ${c.category} ist unbekannt`); } }); test('Rechte-Ausnahmen verweisen nur auf Rollen aus dem Bauplan', () => { const bekannt = new Set(['@everyone', ...ROLES.map((r) => r.name)]); const alle = [...CHANNELS.flatMap((c) => c.overwrites ?? []), ...CATEGORIES.flatMap((c) => c.overwrites ?? [])]; for (const o of alle) assert.equal(bekannt.has(o.role), true, `unbekannte Rolle: ${o.role}`); }); test('die interne Kategorie ist fuer Mitglieder unsichtbar, fuer Mods nicht', () => { const ow = CATEGORIES.find((c) => c.key === 'intern')?.overwrites; assert.ok(ow); const jeder = ow.find((o) => o.role === '@everyone'); const mod = ow.find((o) => o.role === 'Mod'); assert.equal(jeder?.deny! & P.VIEW_CHANNEL, P.VIEW_CHANNEL); assert.equal(mod?.allow! & P.VIEW_CHANNEL, P.VIEW_CHANNEL); }); test('alle internen Kanaele liegen wirklich in der internen Kategorie', () => { for (const key of ['mod-chat', 'technik-log']) { const c = CHANNELS.find((x) => x.key === key); assert.equal(c?.category, 'intern', `${key} waere sonst oeffentlich`); } }); test('jeder Webhook-Platz kommt genau einmal vor', () => { // Zweimal derselbe Platz hiesse: der zweite Kanal ueberschreibt die URL des // ersten im Zugangsdaten-Speicher, und einer der beiden postet ins Leere. const slots = CHANNELS.flatMap((c) => (c.webhooks ?? []).map((w) => w.slot)); assert.deepEqual([...slots].sort(), ['clipsWebhook', 'goLiveWebhook', 'recapWebhook', 'technikWebhook']); assert.equal(new Set(slots).size, slots.length); }); test('der Entwurfsmodus postet in einen Kanal, den Mitglieder nicht sehen', () => { // `broadcaster.mode: draft` schickt dieselbe Nachricht wie im Betrieb, // nur ohne Ping. Laege der Kanal oeffentlich, waere jeder Probelauf eine // falsche Ankuendigung im Server. const kanal = CHANNELS.find((c) => (c.webhooks ?? []).some((w) => w.slot === 'technikWebhook')); assert.equal(kanal?.category, 'intern'); }); test('Webhooks haengen nur an Kanaelen, in denen Mitglieder nicht dazwischenfunken', () => { // Go-Live und Recap muessen obenauf bleiben; in #clips darf jeder posten. const ank = CHANNELS.find((c) => c.key === 'ankuendigungen'); const deny = ank?.overwrites?.find((o) => o.role === '@everyone')?.deny ?? 0n; assert.equal(deny & P.SEND_MESSAGES, P.SEND_MESSAGES); }); test('der Begruessungstext verweist nur auf Kanaele, die angelegt werden', () => { const keys = new Set(CHANNELS.map((c) => c.key)); for (const [, key] of WELCOME.matchAll(/\{(\w[\w-]*)\}/g)) { assert.equal(keys.has(key!), true, `${key} aus dem Willkommenstext gibt es nicht`); } }); // ---------------------------------------------------------------- Merge const ID = (name: string): string | undefined => ({ '@everyone': '1', Mod: '2', Stammgast: '3' })[name]; test('fremde Rechte-Ausnahmen bleiben stehen', () => { // "99" hat jemand von Hand gesetzt. Das Skript darf das nicht wegraeumen. const vorher = [{ id: '99', type: 0 as const, allow: '8', deny: '0' }]; const out = mergeOverwrites(vorher, [{ role: 'Mod', allow: 1024n }], ID); assert.ok(out); assert.equal(out.length, 2); assert.deepEqual(out.find((o) => o.id === '99'), vorher[0]); assert.deepEqual(out.find((o) => o.id === '2'), { id: '2', type: 0, allow: '1024', deny: '0' }); }); test('nichts zu tun heisst null - dann faellt das PATCH weg', () => { const vorher = [{ id: '1', type: 0 as const, allow: '0', deny: '2048' }]; assert.equal(mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID), null); }); test('abweichende Rechte werden ueberschrieben, nicht ergaenzt', () => { const vorher = [{ id: '1', type: 0 as const, allow: '64', deny: '0' }]; const out = mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID); assert.deepEqual(out, [{ id: '1', type: 0, allow: '0', deny: '2048' }]); }); test('eine Ausnahme fuer ein Mitglied wird nicht mit einer Rolle verwechselt', () => { // type 1 ist ein einzelnes Mitglied. Gleiche ID, anderer Typ - darf nicht // ueberschrieben werden. const vorher = [{ id: '2', type: 1 as const, allow: '64', deny: '0' }]; const out = mergeOverwrites(vorher, [{ role: 'Mod', allow: 1024n }], ID); assert.equal(out?.length, 2); assert.deepEqual(out?.[0], vorher[0]); }); test('Rollen, die es noch nicht gibt, werden uebergangen statt zu raten', () => { assert.equal(mergeOverwrites([], [{ role: 'Gibtsnicht', allow: 1024n }], ID), null); }); test('die Vorlage wird nicht verbogen', () => { const vorher = [{ id: '1', type: 0 as const, allow: '0', deny: '0' }]; const kopie = structuredClone(vorher); mergeOverwrites(vorher, [{ role: '@everyone', deny: 2048n }], ID); assert.deepEqual(vorher, kopie, 'mergeOverwrites darf die Eingabe nicht veraendern'); }); // ---------------------------------------------------------------- Namen test('normalizeName bildet ab, was Discord mit dem Namen macht', () => { // Am echten Server nachgemessen: kleinschreiben, Leerzeichen zu Bindestrich, // Emoji, Umlaute und U+2502 bleiben unveraendert. assert.equal(normalizeName('Aü Test Kanal', CH.TEXT), 'aü-test-kanal'); assert.equal(normalizeName('📢│ankündigungen', CH.TEXT), '📢│ankündigungen'); // Sprachkanaele und Kategorien behalten Grossbuchstaben und Leerzeichen. assert.equal(normalizeName('🔊 Zusammen zocken', CH.VOICE), '🔊 Zusammen zocken'); assert.equal(normalizeName('📌 Info', CH.CATEGORY), '📌 Info'); }); test('findChannel erkennt den Kanal am frueheren Namen', () => { const vorhanden = [ { id: '1', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null }, { id: '2', name: 'sonstwas', type: CH.TEXT, position: 1, parent_id: null }, ]; const plan = { name: '💬│allgemein', aliases: ['allgemein'], type: CH.TEXT }; assert.equal(findChannel(vorhanden, plan)?.id, '1'); }); test('findChannel bevorzugt den neuen Namen vor dem alten', () => { const vorhanden = [ { id: '1', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null }, { id: '2', name: '💬│allgemein', type: CH.TEXT, position: 1, parent_id: null }, ]; const plan = { name: '💬│allgemein', aliases: ['allgemein'], type: CH.TEXT }; assert.equal(findChannel(vorhanden, plan)?.id, '2'); }); test('findChannel verwechselt Sprach- und Textkanal nicht', () => { const vorhanden = [{ id: '1', name: 'clips', type: CH.VOICE, position: 0, parent_id: null }]; assert.equal(findChannel(vorhanden, { name: '🎬│clips', aliases: ['clips'], type: CH.TEXT }), undefined); }); // ---------------------------------------------------------------- Rangfolge test('rolePositions setzt die Rollen unter die Bot-Rolle', () => { // Bot auf Platz 5, drei Rollen -> 4, 3, 2. Platz 1 bleibt frei, 0 ist @everyone. assert.deepEqual(rolePositions(3, 5), { positions: [4, 3, 2] }); }); test('rolePositions nutzt den Platz genau aus', () => { assert.deepEqual(rolePositions(3, 4), { positions: [3, 2, 1] }); }); test('steht die Bot-Rolle zu weit unten, sagt rolePositions was zu tun ist', () => { // Der Normalfall direkt nach dem Einladen: Bot auf Platz 1, kein Platz darunter. const out = rolePositions(3, 1); assert.ok('problem' in out); assert.match(out.problem, /Platz fuer 0 von 3/); assert.match(out.problem, /nach oben ziehen/); }); test('rolePositions verlangt nie einen Platz ueber der Bot-Rolle', () => { // Genau das hat Discord mit "Missing Permissions" quittiert. for (let botTop = 1; botTop <= 8; botTop++) { const out = rolePositions(3, botTop); if ('problem' in out) continue; for (const p of out.positions) { assert.ok(p < botTop, `Platz ${p} liegt nicht unter der Bot-Rolle (${botTop})`); assert.ok(p > 0, `Platz ${p} wuerde @everyone verdraengen`); } } }); // ---------------------------------------------------------------- Deutsch test('was Mitglieder lesen, steht in richtigem Deutsch', () => { // Im Quelltext ist ae/oe/ue Konvention. In allem, was auf Discord sichtbar // ist, waere es ein Tippfehler - "Sei anstaendig" liest sich wie einer. const sichtbar = [ WELCOME, ...CHANNELS.map((c) => c.name), ...CHANNELS.map((c) => c.topic ?? ''), ...CATEGORIES.map((c) => c.name), ...ROLES.map((r) => r.name), ...AUTOMOD.map((r) => r.name), AUTOMOD_BLOCK, ].join('\n'); // Wortstaemme, die im Deutschen praktisch immer einen Umlaut tragen. for (const falsch of ['ae', 'oe', 'ue']) { const treffer = sichtbar.match(new RegExp('\\w*' + falsch + '\\w*', 'g')) ?? []; // "Neuer", "Feuer" und Aehnliches sind echte Vorkommen - nur die bekannten // Umschreibungen aus diesem Projekt duerfen nicht auftauchen. for (const wort of treffer) { assert.equal( /gehoert|anstaendig|Probelaeufe|Erwaehnung|aendert|laeuft|zusaetzlich|Aenderung/i.test(wort), false, `"${wort}" gehoert in richtiges Deutsch geschrieben`, ); } } }); test('Abdu traegt Administrator, sonst niemand', () => { for (const r of ROLES) { const admin = (r.permissions & P.ADMINISTRATOR) === P.ADMINISTRATOR; assert.equal(admin, r.name === 'Abdu', `${r.name}: Administrator=${admin}`); } }); test('die AutoMod-Regel kennt ihren frueheren Namen', () => { const regel = AUTOMOD.find((r) => r.name === 'Erwähnungs-Spam'); assert.ok(regel?.aliases?.includes('Erwaehnungs-Spam'), 'sonst steht die Regel nach der Umbenennung zweimal da'); });