Files
abdulive/packages/discord/src/apply.test.ts
T
ZeasyAlexandClaude Opus 5 e18336c57f discord:setup: der zweite Lauf schreibt wirklich nichts mehr
Der Bauplan verspricht, dass ein zweiter Lauf nichts aendert. Zwei Schritte
haben das gebrochen, weil sie ohne Vergleich geschrieben haben: die
Servereinstellungen und die Rangfolge der Rollen.

Aufgefallen beim Einrichten des Technik-Webhooks - der Lauf meldete "GESETZT
abdulive_" und "GESETZT Rangfolge", obwohl die Vorschau zuvor "steht" gesagt
hatte. Vorschau und echter Lauf sagten also Verschiedenes.

Folgenlos war es nicht: jeder Lauf kostete zwei Aufrufe und hinterliess zwei
Eintraege im Audit-Log des Servers, einen davon eine Rollen-Umsortierung.

Jetzt wird beides vorher verglichen. ApiGuild traegt dafuer die vier Felder
aus GUILD_SETTINGS. Ein Test haelt es fest: nach einem Lauf darf der zweite
weder PATCH /guilds/{id} noch PATCH /guilds/{id}/roles schicken.

Am echten Server nachgeprueft - der dritte Lauf meldet keine Aenderung mehr.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 00:12:58 +02:00

507 lines
21 KiB
TypeScript

// 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<string, Array<{ id: string; content: string; author: { id: string }; components?: unknown[] }>>();
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<T>(path: string): Promise<T> {
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<T>(path: string, body: unknown): Promise<T> {
this.calls.push(`POST ${path}`);
const b = body as Record<string, never>;
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<string, unknown>;
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<string, unknown>)['content'] ?? ''),
author: { id: this.botUserId },
components: ((b as Record<string, unknown>)['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<T>(path: string, body: unknown): Promise<T> {
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<string, unknown>);
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<string, unknown>;
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<string, number> => {
const out: Record<string, number> = {};
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 <T,>(path: string, body: unknown): Promise<T> => {
if (path === `/guilds/${GUILD}/roles`) throw new Error('Missing Permissions');
return echt<T>(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<string, string>();
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 <T,>(path: string, body: unknown): Promise<T> => {
if (path === '/channels/901') throw new Error('Discord verlangt 530 s Pause');
return echt<T>(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<string, string> }>;
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');
});