// Der ganze Durchlauf gegen einen nachgebauten Server. Kein Netz, kein Token. // // Die Frage, die hier beantwortet wird, ist nicht "kompiliert es", sondern: // Was passiert beim zweiten Lauf? Ein Einrichtungsskript, das beim zweiten Mal // alles doppelt anlegt, richtet mehr Schaden an, als es Arbeit spart. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { CH, type ApiChannel, type ApiGuild, type ApiOverwrite, type ApiRole, type ApiWebhook, type Rest } from './api.ts'; import { applyPlan, type Step } from './apply.ts'; import { CATEGORIES, CHANNELS, EVERYONE, ROLES } from './plan.ts'; import { str } from './permissions.ts'; const GUILD = '100'; /** Ein Discord-Server im Arbeitsspeicher. Kennt nur die Routen, die wir nutzen. */ class FakeDiscord implements Rest { guild: ApiGuild; channels: ApiChannel[] = []; webhooks: ApiWebhook[] = []; automod: Array<{ id: string; name: string; actions?: unknown[] }> = []; messages = new Map>(); calls: string[] = []; private next = 1000; /** Platz der Bot-Rolle. Discord haengt sie beim Einladen auf 1. */ botRolePosition: number; readonly botUserId = '5000'; readonly botRoleId = '5001'; constructor(opts: { botRolePosition?: number } = {}) { this.botRolePosition = opts.botRolePosition ?? 8; this.guild = { id: GUILD, name: 'abdulive', owner_id: '1', features: [], roles: [ { id: GUILD, name: '@everyone', permissions: '0', position: 0, color: 0, hoist: false, mentionable: false, managed: false, }, { id: this.botRoleId, name: 'abdulive_ Bot', permissions: '8', position: this.botRolePosition, color: 0, hoist: false, mentionable: false, managed: true, }, ], }; } private id(): string { return String(this.next++); } async get(path: string): Promise { this.calls.push(`GET ${path}`); if (path === '/users/@me') return { id: this.botUserId, username: 'abdulive_ Bot', bot: true } as T; if (path === `/guilds/${GUILD}/members/${this.botUserId}`) return { roles: [this.botRoleId] } as T; if (path === `/guilds/${GUILD}/roles`) return structuredClone(this.guild.roles) as T; if (path === `/guilds/${GUILD}`) return structuredClone(this.guild) as T; if (path === `/guilds/${GUILD}/channels`) return structuredClone(this.channels) as T; if (path === `/guilds/${GUILD}/auto-moderation/rules`) return structuredClone(this.automod) as T; const wh = /^\/channels\/(\d+)\/webhooks$/.exec(path); if (wh) return this.webhooks.filter((w) => w.channel_id === wh[1]) as T; const msg = /^\/channels\/(\d+)\/messages\?limit=(\d+)$/.exec(path); if (msg) return structuredClone(this.messages.get(msg[1]!) ?? []).slice(0, Number(msg[2])) as T; throw new Error(`GET ${path} nicht nachgebaut`); } async post(path: string, body: unknown): Promise { this.calls.push(`POST ${path}`); const b = body as Record; if (path === `/guilds/${GUILD}/roles`) { const role = { id: this.id(), position: 1, managed: false, ...b } as unknown as ApiRole; this.guild.roles.push(role); return structuredClone(role) as T; } if (path === `/guilds/${GUILD}/channels`) { const ch = { id: this.id(), position: this.channels.length, parent_id: null, permission_overwrites: [], ...b, } as unknown as ApiChannel; this.channels.push(ch); return structuredClone(ch) as T; } if (path === `/guilds/${GUILD}/auto-moderation/rules`) { const src = body as Record; const rule = { id: this.id(), name: String(src['name']), actions: src['actions'] as unknown[] }; this.automod.push(rule); return structuredClone(rule) as T; } const wh = /^\/channels\/(\d+)\/webhooks$/.exec(path); if (wh) { const made: ApiWebhook = { id: this.id(), name: String(b['name']), channel_id: wh[1]!, token: 'tok-' + this.id() }; this.webhooks.push(made); return structuredClone(made) as T; } const msg = /^\/channels\/(\d+)\/messages$/.exec(path); if (msg) { const made = { id: this.id(), content: String((b as Record)['content'] ?? ''), author: { id: this.botUserId }, components: ((b as Record)['components'] ?? []) as unknown[], }; const list = this.messages.get(msg[1]!) ?? []; list.push(made); this.messages.set(msg[1]!, list); return structuredClone(made) as T; } throw new Error(`POST ${path} nicht nachgebaut`); } async patch(path: string, body: unknown): Promise { this.calls.push(`PATCH ${path}`); if (path === `/guilds/${GUILD}`) { // Wie der echte Server: gesetzte Einstellungen stehen danach auch drin. // Ohne das liesse sich nicht pruefen, ob ein zweiter Lauf sie in Ruhe // laesst. Object.assign(this.guild, body as Record); return structuredClone(this.guild) as T; } if (path === `/guilds/${GUILD}/roles`) { // Rangfolge for (const e of body as Array<{ id: string; position: number }>) { const r = this.guild.roles.find((x) => x.id === e.id); if (!r) continue; if (e.position >= this.botRolePosition) throw new Error('Missing Permissions'); r.position = e.position; } return structuredClone(this.guild.roles) as T; } const role = /^\/guilds\/\d+\/roles\/(\d+)$/.exec(path); if (role) { const r = this.guild.roles.find((x) => x.id === role[1]); if (r) Object.assign(r, body); return structuredClone(r) as T; } const rule = /^\/guilds\/\d+\/auto-moderation\/rules\/(\d+)$/.exec(path); if (rule) { const r = this.automod.find((x) => x.id === rule[1]); if (r) Object.assign(r, body); return structuredClone(r) as T; } const msg = /^\/channels\/(\d+)\/messages\/(\d+)$/.exec(path); if (msg) { const m = (this.messages.get(msg[1]!) ?? []).find((x) => x.id === msg[2]); if (m) { const src = body as Record; m.content = String(src['content'] ?? ''); m.components = (src['components'] ?? []) as unknown[]; } return structuredClone(m) as T; } const ch = /^\/channels\/(\d+)$/.exec(path); if (ch) { const c = this.channels.find((x) => x.id === ch[1]); if (c) Object.assign(c, body); return structuredClone(c) as T; } throw new Error(`PATCH ${path} nicht nachgebaut`); } } const run = (api: Rest, dryRun = false) => applyPlan(api, GUILD, { dryRun, saveSecrets: false }); /** Der Kanal aus dem Bauplan, so wie er im nachgebauten Server gelandet ist. */ const live = (api: FakeDiscord, key: string): ApiChannel | undefined => { const plan = CHANNELS.find((c) => c.key === key); assert.ok(plan, `key ${key} gibt es im Bauplan nicht`); return api.channels.find((c) => c.name === plan.name && c.type === plan.type); }; const katName = (key: string): string => { const k = CATEGORIES.find((c) => c.key === key); assert.ok(k); return k.name; }; const counts = (steps: Step[]): Record => { const out: Record = {}; for (const s of steps) out[s.change] = (out[s.change] ?? 0) + 1; return out; }; // ---------------------------------------------------------------- Erstlauf test('der erste Lauf legt Rollen, Kategorien, Kanaele und Webhooks an', async () => { const api = new FakeDiscord(); const res = await run(api); assert.equal(res.guild, 'abdulive'); for (const r of ROLES) { assert.ok(api.guild.roles.find((x) => x.name === r.name), `Rolle ${r.name} fehlt`); } assert.equal(api.channels.filter((c) => c.type === CH.CATEGORY).length, 3); for (const c of CHANNELS) { assert.ok(api.channels.find((x) => x.name === c.name), `Kanal ${c.name} fehlt`); } assert.equal(res.webhooks, 4); assert.equal(api.automod.length, 2); assert.equal(counts(res.steps)['uebersprungen'] ?? 0, 0); }); test('@everyone bekommt die Grundrechte aus dem Bauplan', async () => { const api = new FakeDiscord(); await run(api); const everyone = api.guild.roles.find((r) => r.id === GUILD); assert.equal(everyone?.permissions, str(EVERYONE)); }); test('Kanaele haengen unter der richtigen Kategorie', async () => { const api = new FakeDiscord(); await run(api); const kat = (key: string) => api.channels.find((c) => c.name === katName(key) && c.type === CH.CATEGORY)?.id; for (const plan of CHANNELS) { const c = live(api, plan.key); assert.equal(c?.parent_id, kat(plan.category!), `${plan.name} haengt falsch`); } }); test('der interne Bereich ist fuer @everyone gesperrt und fuer Mod offen', async () => { const api = new FakeDiscord(); await run(api); const intern = api.channels.find((c) => c.name === katName('intern')); const mod = api.guild.roles.find((r) => r.name === 'Mod'); const ow = intern?.permission_overwrites ?? []; const jeder = ow.find((o) => o.id === GUILD); assert.ok(jeder && (BigInt(jeder.deny) & 1024n) === 1024n, 'VIEW_CHANNEL muss fuer @everyone gesperrt sein'); const fuerMod = ow.find((o) => o.id === mod?.id); assert.ok(fuerMod && (BigInt(fuerMod.allow) & 1024n) === 1024n, 'Mod muss hineinsehen koennen'); }); test('die Begruessung wird gepostet und die Kanalnamen sind aufgeloest', async () => { const api = new FakeDiscord(); await run(api); const willkommen = live(api, 'willkommen'); const posts = api.messages.get(willkommen!.id) ?? []; assert.equal(posts.length, 1); assert.match(posts[0]!.content, /<#\d+> - alles andere/, 'die Kanalnamen muessen aufgeloest sein'); }); // ---------------------------------------------------------------- Zweitlauf test('der zweite Lauf aendert nichts', async () => { const api = new FakeDiscord(); await run(api); const nachErstlauf = { rollen: api.guild.roles.length, kanaele: api.channels.length, webhooks: api.webhooks.length, automod: api.automod.length, }; api.calls = []; const res = await run(api); assert.deepEqual({ rollen: api.guild.roles.length, kanaele: api.channels.length, webhooks: api.webhooks.length, automod: api.automod.length, }, nachErstlauf, 'beim zweiten Lauf wurde etwas doppelt angelegt'); // Kein POST ausser den Lesevorgaengen - nichts Neues, nichts Geaendertes. const schreibend = api.calls.filter((c) => c.startsWith('POST') || (c.startsWith('PATCH') && !c.endsWith('/roles'))); assert.deepEqual(schreibend.filter((c) => c !== `PATCH /guilds/${GUILD}`), [], `unerwartete Schreibzugriffe: ${schreibend.join(', ')}`); assert.equal(counts(res.steps)['erstellt'] ?? 0, 0); assert.equal(res.webhooks, 4, 'die bestehenden Webhook-URLs muessen wiedergefunden werden'); }); test('die Begruessung wird nicht ein zweites Mal gepostet', async () => { const api = new FakeDiscord(); await run(api); await run(api); const willkommen = live(api, 'willkommen'); assert.equal((api.messages.get(willkommen!.id) ?? []).length, 1); }); // ---------------------------------------------------------------- Vorschau test('die Vorschau schreibt nichts', async () => { const api = new FakeDiscord(); const res = await run(api, true); assert.equal(api.guild.roles.length, 2, 'nur @everyone und die Bot-Rolle, die es schon gab'); assert.equal(api.channels.length, 0); assert.equal(api.webhooks.length, 0); assert.equal(api.automod.length, 0); assert.deepEqual(api.calls.filter((c) => !c.startsWith('GET')), []); assert.ok((counts(res.steps)['erstellt'] ?? 0) > 10, 'die Vorschau muss zeigen, was entstehen wuerde'); }); // ---------------------------------------------------------------- Bestand test('von Hand angelegte Kanaele und Rechte bleiben stehen', async () => { const api = new FakeDiscord(); api.channels.push({ id: '900', name: 'memes', type: CH.TEXT, position: 0, parent_id: null, permission_overwrites: [{ id: '777', type: 1, allow: '2048', deny: '0' }], }); api.channels.push({ id: '901', name: 'allgemein', type: CH.TEXT, position: 1, parent_id: null, permission_overwrites: [{ id: '777', type: 1, allow: '0', deny: '2048' }], }); await run(api); assert.ok(api.channels.find((c) => c.name === 'memes'), 'ein fremder Kanal darf nicht verschwinden'); const allgemein = api.channels.find((c) => c.id === '901'); assert.equal(live(api, 'allgemein')?.id, '901', 'der bestehende Kanal muss umbenannt werden'); assert.equal(api.channels.filter((c) => c.name.includes('allgemein')).length, 1, 'nicht doppelt anlegen'); const fremd = (allgemein?.permission_overwrites ?? []).find((o: ApiOverwrite) => o.id === '777'); assert.deepEqual(fremd, { id: '777', type: 1, allow: '0', deny: '2048' }, 'eine von Hand gesetzte Ausnahme darf nicht wegfallen'); }); test('ein von Hand angelegter Webhook wird gemeldet, nicht ueberschrieben', async () => { const api = new FakeDiscord(); await run(api); // legt alles an // Jemand loescht den Go-Live-Webhook und baut ihn in der Oberflaeche nach - // dann fehlt uns der Token, und das muss auffallen statt still zu scheitern. const i = api.webhooks.findIndex((w) => w.name === 'Go-Live'); delete api.webhooks[i]!.token; const res = await run(api); const step = res.steps.find((s) => s.kind === 'Webhook' && s.name.startsWith('Go-Live')); assert.equal(step?.change, 'uebersprungen'); assert.match(step?.note ?? '', /nicht auslesbar/); assert.equal(res.webhooks, 3, 'die drei uebrigen muessen trotzdem durchkommen'); }); test('eine von Discord verwaltete Rolle wird in Ruhe gelassen', async () => { const api = new FakeDiscord(); // Die Twitch-Anbindung legt eigene Rollen an. Die gehoeren Discord. api.guild.roles.push({ id: '800', name: 'Stammgast', permissions: '0', position: 2, color: 0, hoist: false, mentionable: false, managed: true, }); const res = await run(api); const step = res.steps.find((s) => s.kind === 'Rolle' && s.name === 'Stammgast'); assert.equal(step?.change, 'uebersprungen'); assert.equal(api.guild.roles.find((r) => r.id === '800')?.permissions, '0', 'unveraendert geblieben'); }); test('scheitert die Rangfolge, laeuft der Rest trotzdem durch', async () => { const api = new FakeDiscord(); const echt = api.patch.bind(api); api.patch = async (path: string, body: unknown): Promise => { if (path === `/guilds/${GUILD}/roles`) throw new Error('Missing Permissions'); return echt(path, body); }; const res = await run(api); const step = res.steps.find((s) => s.name === 'Rangfolge'); assert.equal(step?.change, 'uebersprungen'); assert.equal(api.channels.length, 3 + CHANNELS.length, 'die Kanaele muessen trotzdem entstehen'); }); // ---------------------------------------------------------------- Rangfolge test('die Rollen landen unter der Bot-Rolle, in der Reihenfolge des Bauplans', async () => { const api = new FakeDiscord({ botRolePosition: 8 }); await run(api); const pos = (name: string) => api.guild.roles.find((r) => r.name === name)?.position; assert.deepEqual(ROLES.map((r) => pos(r.name)), [7, 6, 5, 4]); for (const r of ROLES) { assert.ok(pos(r.name)! < api.botRolePosition, `${r.name} steht ueber der Bot-Rolle`); } }); test('Bot-Rolle ganz unten: Rangfolge wird gemeldet statt zu scheitern', async () => { // Der reale Fall direkt nach dem Einladen. Discord haengt die Bot-Rolle auf // Platz 1, darunter ist kein Platz - frueher gab das ein Missing Permissions. const api = new FakeDiscord({ botRolePosition: 1 }); const res = await run(api); const step = res.steps.find((s) => s.name === 'Rangfolge'); assert.equal(step?.change, 'uebersprungen'); assert.match(step?.note ?? '', /nach oben ziehen/); // Alles andere muss trotzdem stehen. assert.equal(api.channels.length, 3 + CHANNELS.length); assert.equal(res.webhooks, 4); }); // ---------------------------------------------------------------- Umbenennen test('ein Kanal unter altem Namen wird umbenannt, nicht verdoppelt', async () => { const api = new FakeDiscord(); // So sah der Server nach dem ersten Lauf ohne Emoji aus. const alt = new Map(); CHANNELS.forEach((plan, i) => { const id = String(900 + i); // der Fake erwartet Ziffern-IDs alt.set(plan.key, id); api.channels.push({ id, name: plan.aliases![0]!, type: plan.type, position: 0, parent_id: null, permission_overwrites: [], }); }); const res = await run(api); for (const plan of CHANNELS) { assert.equal(live(api, plan.key)?.id, alt.get(plan.key), `${plan.key} wurde neu angelegt statt umbenannt`); } assert.equal(api.channels.filter((c) => c.type !== CH.CATEGORY).length, CHANNELS.length, 'es darf kein Kanal dazugekommen sein'); assert.ok(res.steps.some((s) => s.name.includes('->')), 'die Umbenennung muss im Bericht stehen'); }); test('haengt Discord beim Umbenennen im Rate Limit, laeuft der Rest weiter', async () => { const api = new FakeDiscord(); api.channels.push({ id: '901', name: 'allgemein', type: CH.TEXT, position: 0, parent_id: null, permission_overwrites: [], }); const echt = api.patch.bind(api); api.patch = async (path: string, body: unknown): Promise => { if (path === '/channels/901') throw new Error('Discord verlangt 530 s Pause'); return echt(path, body); }; const res = await run(api); const step = res.steps.find((s) => s.name.includes('allgemein')); assert.equal(step?.change, 'uebersprungen'); assert.match(step?.note ?? '', /530 s/); // Die uebrigen Kanaele und die Webhooks muessen trotzdem entstehen. assert.equal(res.webhooks, 4); assert.equal(api.automod.length, 2); }); test('der zweite Lauf schreibt nichts mehr - auch nicht Einstellungen und Rangfolge', async () => { // Der Bauplan verspricht, dass ein zweiter Lauf nichts aendert. Zwei // Schritte haben das lange gebrochen, weil sie ohne Vergleich geschrieben // haben: die Servereinstellungen und die Rangfolge der Rollen. Folgenlos // war das nicht - jeder Lauf hinterliess zwei Eintraege im Audit-Log und // liess den Bericht so aussehen, als sei etwas passiert. const api = new FakeDiscord(); await run(api); api.calls.length = 0; const res = await run(api); assert.equal(api.calls.some((c) => c === `PATCH /guilds/${GUILD}`), false, 'Servereinstellungen standen schon'); assert.equal(api.calls.some((c) => c === `PATCH /guilds/${GUILD}/roles`), false, 'die Rangfolge stand schon'); assert.equal(res.steps.find((s) => s.kind === 'Server')?.change, 'unveraendert'); assert.equal(res.steps.find((s) => s.name === 'Rangfolge')?.change, 'unveraendert'); assert.equal(res.steps.some((s) => s.change === 'erstellt'), false, 'nichts neu angelegt'); }); test('ein geaenderter Blocktext wird auf eine bestehende Regel nachgezogen', async () => { const api = new FakeDiscord(); await run(api); const regel = api.automod[0]!; (regel.actions as Array<{ type: number; metadata: { custom_message: string } }>)[0]! .metadata.custom_message = 'alter Text'; const res = await run(api); const step = res.steps.find((s) => s.kind === 'AutoMod' && s.name === regel.name); assert.equal(step?.change, 'angepasst'); assert.match(step?.note ?? '', /actions/); assert.equal(api.automod.length, 2, 'die Regel darf nicht neu angelegt werden'); }); test('der Meldekanal einer bestehenden Regel bleibt, wie er gesetzt wurde', async () => { const api = new FakeDiscord(); await run(api); const regel = api.automod[0]!; const actions = regel.actions as Array<{ type: number; metadata: Record }>; actions[0]!.metadata['custom_message'] = 'alter Text'; actions[1]!.metadata['channel_id'] = '424242'; // jemand hat umgestellt await run(api); const danach = (api.automod[0]!.actions as typeof actions).find((a) => a.type === 2); assert.equal(danach?.metadata['channel_id'], '424242', 'die eigene Wahl darf nicht ueberschrieben werden'); }); test('der Willkommenstext bekommt die Knopfleiste', async () => { const api = new FakeDiscord(); await run(api); const post = api.messages.get(live(api, 'willkommen')!.id)![0]!; const reihen = post.components as Array<{ type: number; components: Array<{ custom_id: string }> }>; assert.equal(reihen.length, 1); assert.equal(reihen[0]!.components[0]!.custom_id, 'rolle:live-ping'); }); test('eine Nachricht ohne Knopfleiste bekommt sie nachgereicht', async () => { // So sieht der Willkommenstext aus, der vor den Knoepfen gepostet wurde. const api = new FakeDiscord(); await run(api); const kanal = live(api, 'willkommen')!.id; api.messages.get(kanal)![0]!.components = []; const res = await run(api); const step = res.steps.find((s) => s.name === 'Willkommenstext'); assert.equal(step?.change, 'angepasst'); assert.equal((api.messages.get(kanal)![0]!.components as unknown[]).length, 1); assert.equal(api.messages.get(kanal)!.length, 1, 'nicht neu posten'); });