Files
dogfather-universe/server/pruef-anruf.mjs
T
DogFatherGitandClaude Opus 5 4ea7b37333 Anruf: "sie kriegt nur eine Benachrichtigung aber keinen Anruf"
Filipe, gerade gemeldet. Es waren ZWEI Fehler, und beide erklaeren
genau das, was er gesehen hat.

--- 1. Die Benachrichtigung sagte nicht, dass es ein Anruf ist ------

Gemessen kam bei ihr an:

    Nachricht von [object Object]
    (kein Text)

`nachricht.von` ist beim Klingeln ein OBJEKT (`{id, name}`) und keine
Zeichenkette; einen `text` gibt es bei einem Anruf gar nicht. Moeglich
wurde beides, weil die ART des Ereignisses die Benachrichtigung nie
erreichte: `chatEreignis` nimmt sie als vierten Parameter entgegen,
reichte sie aber nur in den Ereignisstrom weiter. Fuer den Push galt
jedes Ereignis als Chatnachricht -- auch das Klingeln.

Jetzt steht dort "Filipe ruft an" / "Tippen zum Rangehen", und beim
Tippen landet man im richtigen Gespraech.

--- 2. Und dort klingelte es dann trotzdem nicht --------------------

Das Klingeln lief ausschliesslich ueber den offenen Ereignisstrom. Wer
zusieht, hoert es. Wer die Seite NICHT offen hat, bekommt die
Benachrichtigung, tippt darauf, die Seite laedt -- und bleibt still.
Das Ereignis war vorbei, bevor sie da war.

Der Anruf funktionierte damit ausgerechnet fuer die nicht, fuer die
die Benachrichtigung ueberhaupt gebaut wurde.

Neu: `GET /workspace/api/anruf/offen` -- beim Laden fragt die Seite
einmal nach, ob in einem ihrer Raeume jemand wartet. Nur was noch
klingelt (45 s), nur Raeume, in denen die Person drin ist, und nicht
beim Anrufer selbst.

--- Gepruefte Wege -------------------------------------------------

pruef-anruf 80 -> 95. Zwei neue Abschnitte, beide mit Gegenprobe:
Route stillgelegt -> 3 rot; der Text ist jetzt einzeln pruefbar
(`pushTextFuer`), weil er vorher tief in einer Funktion entstand, die
nur der Push-Weg aufruft -- von aussen nicht messbar.

Nebenbei gefunden: `istDrinFuerAnruf` braucht die PERSON, nicht ihre
Nummer, und sagt das mit einem eigenen TypeError. Meine erste Fassung
uebergab die Nummer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-18 23:01:13 +02:00

837 lines
38 KiB
JavaScript

/* =====================================================================
TELEFONIEREN IM CHAT -- die Riegel (18.09.2026)
Filipe: „kann man machen dass die modis, rechte hand und ich auch
telefonieren können im chat?"
DER WICHTIGSTE ABSCHNITT IST DER LETZTE. Ein Anruf ist der
vertraulichste Vorgang im ganzen Haus: Was gesagt wird, steht
nirgends, und wer mithört, hinterlässt keine Spur. Die Frage „kommt
jemand hinein, der nicht in den Raum gehört?" muss deshalb an JEDER
Route beantwortet sein und nicht an der Liste davor.
Besonders die Signalisierung: Sie reicht beliebiges JSON an eine
Personennummer weiter. Ohne Prüfung wäre das ein Weg, jedem
Angemeldeten etwas zu schicken -- an jeder Chatberechtigung vorbei.
Aufrufen mit: node server/pruef-anruf.mjs
===================================================================== */
import { mkdtempSync, rmSync, writeFileSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { portMussFreiSein } from "./helfer-port.mjs";
const PORT = await portMussFreiSein(4196, "pruef-anruf");
const ordner = mkdtempSync(join(tmpdir(), "ws-anruf-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
/* Der Vermittlungsserver holt sein Geheimnis aus einer Datei. Hier muss
der Pfad UMGEBOGEN werden, bevor index.js geladen wird -- sonst
zeigte er auf die echte Datei unter /etc, und die Pruefung wuerde
entweder am echten Betrieb messen oder gar nichts sehen koennen. */
process.env.TURN_GEHEIMNIS_DATEI = join(ordner, "turn.geheimnis");
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(180_000, "pruef-anruf");
await new Promise((r) => setTimeout(r, 800));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
let fehler = 0, geprueft = 0, offen = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const unklar = (t) => { offen++; console.log(" -- " + t); };
const CREW = "crew.dogfather-universe.com";
const HAUS = "workspace.dogfather-universe.com";
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
const anrufModul = await import("./workspace-anruf.js");
/* ---- Leute anlegen ---- */
const d = new DatabaseSync(process.env.WORKSPACE_DB);
function person(name, rolle, code) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(code, salt, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salt, 32768, new Date().toISOString());
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
const idDogi = person("Filipe", "admin", "CODE-DOGI-0001");
const idHand = person("Rieke", "hand", "CODE-HAND-0001");
const idModi = person("Kessi", "modi", "CODE-MODI-0001");
const idFremd = person("Tili", "creator", "CODE-TILI-0001");
d.close();
function roh(pfad, host, kopf = {}, koerper = null, methode = null) {
return new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad,
method: methode || (koerper ? "POST" : "GET"),
headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf },
}, (antwort) => {
let text = "";
antwort.on("data", (s) => { text += s; });
antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
}
const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } };
async function anmelden(rolle, code, host) {
const r = await roh("/workspace/api/anmelden", host, {}, { rolle, code });
return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
}
const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS);
const kHand = await anmelden("hand", "CODE-HAND-0001", CREW);
const kModi = await anmelden("modi", "CODE-MODI-0001", CREW);
const kFremd = await anmelden("creator", "CODE-TILI-0001", HAUS);
ok(!!kDogi && !!kHand && !!kModi && !!kFremd, "vier Leute sind angemeldet");
/* =======================================================================
1. DER KATALOG
======================================================================= */
melde("");
melde("=== 1. Was fest steht ===");
ok(anrufModul.GRUPPE_MAX >= 2 && anrufModul.GRUPPE_MAX <= 8,
`die Gruppengrenze steht als Zahl im Code (${anrufModul.GRUPPE_MAX})`);
/* =======================================================================
2. EIN GESPRAECH UND EIN ANRUF
======================================================================= */
melde("");
melde("=== 2. Anrufen ===");
/* Ein Zweier-Gespraech zwischen DogFather und der rechten Hand. */
let raumId = null;
{
/* Die Route heisst `direkt`, nicht `mit` -- nachgesehen statt
geraten. */
const r = await roh("/workspace/api/chat/direkt", HAUS, { cookie: kDogi },
{ mit: idHand });
raumId = json(r).raum?.id || json(r).raum_id || json(r).id;
ok(!!raumId, `ein Gespraech entsteht (${r.code}, Raum ${raumId})`);
}
{
const r = await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi });
ok(r.code === 200 && json(r).laeuft === false,
`am Anfang laeuft kein Anruf (${json(r).laeuft})`);
}
{
const r = await roh(`/workspace/api/anruf/${raumId}/start`, HAUS,
{ cookie: kDogi }, { video: false });
ok(r.code === 200, `anrufen geht (${r.code})`);
const l = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
ok(l.laeuft === true, "und danach laeuft einer");
ok(l.wer?.length === 1 && l.wer[0].id === idDogi,
`mit genau einem Teilnehmer (${l.wer?.map((w) => w.name).join(", ")})`);
}
{
const r = await roh(`/workspace/api/anruf/${raumId}/dabei`, CREW, { cookie: kHand }, {}, "POST");
ok(r.code === 200, `die rechte Hand geht ran (${r.code})`);
const l = json(await roh(`/workspace/api/anruf/${raumId}`, CREW, { cookie: kHand }));
ok(l.wer?.length === 2, `jetzt sind es zwei (${l.wer?.map((w) => w.name).join(", ")})`);
ok(l.ich_dabei === true, "und sie weiss, dass sie dabei ist");
}
/* =======================================================================
3. DIE SIGNALISIERUNG
Sie reicht beliebiges JSON an eine Personennummer weiter. Ohne
Pruefung waere das ein Weg, jedem Angemeldeten etwas zu schicken --
an jeder Chatberechtigung vorbei.
======================================================================= */
melde("");
melde("=== 3. Die Signalisierung ===");
{
const r = await roh(`/workspace/api/anruf/${raumId}/signal`, HAUS, { cookie: kDogi },
{ an: idHand, inhalt: { angebot: { type: "offer", sdp: "v=0" } } });
ok(r.code === 200, `ein Angebot geht durch (${r.code})`);
}
{
const r = await roh(`/workspace/api/anruf/${raumId}/signal`, HAUS, { cookie: kDogi },
{ an: idFremd, inhalt: { angebot: {} } });
ok(r.code === 404,
`an jemanden AUSSERHALB des Raums geht nichts (${r.code}) -- sonst waere das `
+ "ein Weg, jedem Angemeldeten JSON zu schicken");
}
{
const r = await roh(`/workspace/api/anruf/${raumId}/signal`, HAUS, { cookie: kDogi },
{ an: idHand });
ok(r.code === 400, `ohne Inhalt wird abgewiesen (${r.code})`);
}
/* =======================================================================
4. WER NICHT HINEINGEHOERT
Der wichtigste Abschnitt. Ein Anruf hinterlaesst keine Spur -- wer
mithoert, faellt nicht auf. Die Frage muss an JEDER Route
beantwortet sein.
======================================================================= */
melde("");
melde("=== 4. Wer nicht hineingehoert ===");
for (const [pfad, methode, koerper] of [
["", "GET", null],
["/start", "POST", { video: false }],
["/dabei", "POST", {}],
["/ende", "POST", {}],
["/signal", "POST", { an: idDogi, inhalt: { weg: {} } }],
]) {
const r = await roh(`/workspace/api/anruf/${raumId}${pfad}`, HAUS,
{ cookie: kFremd }, koerper, methode);
ok(r.code === 404,
`${(pfad || "/lage").padEnd(8)} ist fuer Fremde zu -- und zwar 404, `
+ `nicht „kein Zugriff" (${r.code})`);
}
{
const r = await roh(`/workspace/api/anruf/${raumId}`, HAUS, {});
ok(r.code === 401, `ohne Anmeldung gar nichts (${r.code})`);
}
{
const r = await roh("/workspace/api/anruf/999999/start", HAUS,
{ cookie: kDogi }, { video: false });
ok(r.code === 404, `ein erfundener Raum wird abgewiesen (${r.code})`);
}
/* =======================================================================
5. AUFLEGEN
======================================================================= */
melde("");
melde("=== 5. Auflegen ===");
{
const r = await roh(`/workspace/api/anruf/${raumId}/ende`, CREW, { cookie: kHand }, {}, "POST");
ok(r.code === 200, `die rechte Hand legt auf (${r.code})`);
const l = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
ok(l.laeuft === true && l.wer?.length === 1,
`der Anruf laeuft weiter, solange noch jemand drin ist (${l.wer?.length})`);
await roh(`/workspace/api/anruf/${raumId}/ende`, HAUS, { cookie: kDogi }, {}, "POST");
const l2 = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
ok(l2.laeuft === false, "wenn der Letzte geht, ist er vorbei");
}
/* =======================================================================
6. DIE ADRESSEN
Sie enthalten Zugangsdaten (TURN braucht Benutzer und Passwort).
Deshalb darf nur DogFather sie setzen.
======================================================================= */
/* =======================================================================
5c. WER ERST DURCH DIE BENACHRICHTIGUNG KOMMT
Filipe, 18.09.2026: „wenn ich vanvan anrufe kriegt sie nur eine
benarichtigung aber keinen anruf was soll das."
Das war kein Missverstaendnis, sondern ein Loch. Das Klingeln lief
ausschliesslich ueber den offenen Ereignisstrom: Wer zusieht, hoert
es. Wer die Seite NICHT offen hat, bekommt die Push-Nachricht,
tippt darauf, die Seite laedt -- und bleibt still. Das Ereignis war
vorbei, bevor sie da war.
Der Anruf funktionierte damit ausgerechnet fuer die nicht, fuer die
die Benachrichtigung gebaut wurde.
DIESE PRUEFUNG BILDET GENAU DAS AB: Erst anrufen, DANN die Seite
oeffnen. Ohne sie beweist der Dreier-Test unten nur, dass es
funktioniert, wenn alle schon zusehen.
======================================================================= */
/* =======================================================================
5d. WAS IN DER BENACHRICHTIGUNG STEHT
Filipe: „wenn ich vanvan anrufe kriegt sie nur eine benarichtigung
aber keinen anruf was soll das."
Gemessen kam bei ihr an: „Nachricht von [object Object]", ohne Text.
Zwei Fehler in einer Zeile. `von` ist beim Klingeln ein OBJEKT und
keine Zeichenkette; und einen `text` gibt es bei einem Anruf gar
nicht. Moeglich wurde beides, weil die ART des Ereignisses die
Benachrichtigung nie erreichte -- jedes Ereignis galt dort als
Chatnachricht.
DIESE PRUEFUNG WAR VORHER NICHT MOEGLICH. Der Text entstand tief in
einer Funktion, die nur der Push-Weg aufruft. Er steht deshalb
jetzt in `pushTextFuer()` -- eine Zeile weniger Verschachtelung und
eine Frage, die sich beantworten laesst.
======================================================================= */
melde("");
melde("=== 5d. Was in der Benachrichtigung steht ===");
{
const { pushTextFuer } = await import("./workspace-chat.js");
const a = pushTextFuer("anruf", { von: { id: 7, name: "Filipe" }, video: false }, 3);
ok(a.titel === "Filipe ruft an", `beim Anruf steht der Name und „ruft an" (${a.titel})`);
ok(!/\[object/i.test(a.titel + a.text),
"und nirgends [object Object] -- genau das kam vorher an");
ok(a.text.includes("Rangehen"), `der Text sagt, was zu tun ist (${a.text})`);
ok(a.ziel === "/workspace/chat.html?raum=3",
`und es fuehrt in das richtige Gespraech (${a.ziel})`);
const v = pushTextFuer("anruf", { von: { id: 7, name: "Filipe" }, video: true }, 3);
ok(v.text.toLowerCase().includes("video"),
`ein Videoanruf sagt das auch (${v.text})`);
/* UND EINE NORMALE NACHRICHT BLEIBT EINE NORMALE NACHRICHT. */
const n = pushTextFuer("neu", { von: "Rieke", text: "Hallo!" }, 3);
ok(n.titel === "Nachricht von Rieke" && n.text === "Hallo!",
`eine Nachricht meldet sich weiter als Nachricht (${n.titel})`);
/* Und wenn dort doch einmal ein Objekt steht -- dieselbe Falle,
nur an der anderen Stelle -- kommt trotzdem ein Name heraus. */
const o = pushTextFuer("neu", { von: { name: "Rieke" }, text: "Hallo!" }, 3);
ok(!/\[object/i.test(o.titel), `auch mit einem Objekt als Absender (${o.titel})`);
}
melde("");
melde("=== 5c. Wer erst durch die Benachrichtigung kommt ===");
{
const raum = json(await roh("/workspace/api/chat/raeume", HAUS,
{ cookie: kDogi })).raeume?.[0];
if (!raum) {
unklar("kein Gespraech vorhanden -- nicht nachgesehen");
} else {
/* Erst pruefen, dass nichts offen ist -- sonst misst der Rest
einen Rest von vorher. */
const vorher = json(await roh("/workspace/api/anruf/offen", CREW, { cookie: kHand }));
ok(Array.isArray(vorher.anrufe) && vorher.anrufe.length === 0,
`vorher klingelt nichts (${vorher.anrufe?.length})`);
const start = await roh(`/workspace/api/anruf/${raum.id}/start`, HAUS,
{ cookie: kDogi }, { video: false });
ok(start.code === 200 || start.code === 201, `DogFather ruft an (${start.code})`);
/* JETZT fragt die rechte Hand -- so, wie ihre Seite es beim Laden
tut, nachdem sie auf die Benachrichtigung getippt hat. */
const offen = json(await roh("/workspace/api/anruf/offen", CREW, { cookie: kHand }));
ok(offen.anrufe?.length === 1,
`sie erfaehrt beim Laden, dass es klingelt (${offen.anrufe?.length})`);
ok(offen.anrufe?.[0]?.von?.name && offen.anrufe[0].von.id !== undefined,
`und von wem (${offen.anrufe?.[0]?.von?.name})`);
ok(offen.anrufe?.[0]?.raum_id === raum.id,
"und in welchem Gespraech");
/* DER ANRUFER SELBST DARF NICHT KLINGELN. Er hat den Anruf
gestartet; ein zweites Fenster von ihm wuerde sonst mitlaeuten. */
const beimAnrufer = json(await roh("/workspace/api/anruf/offen", HAUS, { cookie: kDogi }));
ok(beimAnrufer.anrufe?.length === 0,
`beim Anrufer selbst klingelt nichts (${beimAnrufer.anrufe?.length})`);
/* UND WER NICHT IN DEN RAUM GEHOERT, ERFAEHRT NICHTS. Dieselbe
Frage wie ueberall sonst -- eine neue Route ist eine neue Tuer. */
const fremd = json(await roh("/workspace/api/anruf/offen", HAUS, { cookie: kFremd }));
ok(fremd.anrufe?.length === 0,
`ein Fremder erfaehrt nichts davon (${fremd.anrufe?.length})`);
await roh(`/workspace/api/anruf/${raum.id}/ende`, HAUS, { cookie: kDogi }, {});
const danach = json(await roh("/workspace/api/anruf/offen", CREW, { cookie: kHand }));
ok(danach.anrufe?.length === 0,
`nach dem Auflegen klingelt es nicht mehr (${danach.anrufe?.length})`);
}
}
melde("");
melde("=== 6. Die Verbindungsadressen ===");
{
const r = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
ok(Array.isArray(r.adressen), `die Liste kommt (${r.adressen?.length} Eintraege)`);
ok(r.nur_gleiches_netz === true,
"und sie sagt ehrlich, dass es ohne Eintrag nur im selben Netz geht");
}
{
const r = await roh("/workspace/api/anruf/adressen", CREW, { cookie: kHand },
{ adressen: [{ urls: "stun:beispiel.de:3478" }] }, "PUT");
ok(r.code === 404, `die rechte Hand darf sie nicht setzen (${r.code})`);
}
{
const r = await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }, {
adressen: [
{ urls: "stun:turn.dogfather-universe.com:3478" },
{ urls: "turn:turn.dogfather-universe.com:3478", username: "a", credential: "b" },
/* DAS HIER MUSS RAUSFALLEN: kein STUN/TURN, sondern irgendetwas. */
{ urls: "https://beispiel.de/boese" },
],
}, "PUT");
ok(r.code === 200 && json(r).anzahl === 2,
`DogFather setzt sie, und nur echte Adressen bleiben (${json(r).anzahl} von 3)`);
const l = json(await roh("/workspace/api/anruf/adressen", CREW, { cookie: kModi }));
ok(l.adressen?.length === 2, `alle bekommen sie zu sehen (${l.adressen?.length})`);
ok(l.nur_gleiches_netz === false, "und der Hinweis verschwindet");
/* UND WIEDER WEG. Die Adressen oben sind erfunden -- es gibt unter
`turn.dogfather-universe.com` keinen STUN-Dienst. Ein Browser
versucht sie trotzdem zu erreichen und wartet auf das Zeitlimit;
der Dreier-Test unten haette danach gegen diese Wartezeit
gekaempft statt gegen den Code.
Eine Pruefung, die der naechsten den Boden verstellt, macht deren
Ergebnis unlesbar -- genau das ist heute schon einmal passiert
(die Anfragen, Abschnitt 8b). */
await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi },
{ adressen: [] }, "PUT");
const zurueck = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
ok(zurueck.adressen?.length === 0,
"danach ist die Liste wieder leer -- der naechste Abschnitt faengt sauber an");
}
/* =======================================================================
6b. DIE ZUGANGSDATEN ZUM VERMITTLUNGSSERVER
Hier steht die Frage, an der sich entscheidet, ob coturn ein Dienst
fuer uns ist oder eine offene Weiterleitung fuer jeden: Das
Geheimnis, aus dem die Zugangsdaten gerechnet werden, darf den
Browser NIE erreichen. Wer es haette, koennte sich selbst beliebig
viele Zugaenge ausstellen -- dauerhaft, ohne Konto, ueber unsere
IP-Adresse.
Deshalb wird hier nicht nur geprueft, dass Zugangsdaten ANKOMMEN,
sondern vor allem, dass das Geheimnis es NICHT tut -- und zwar im
rohen Antworttext, nicht im ausgewerteten Objekt. Ein Feld, an das
niemand denkt, faellt beim Auswerten nicht auf.
======================================================================= */
melde("");
melde("=== 6b. Zugangsdaten fuer die Vermittlung ===");
{
const turn = await import("./workspace-turn.js");
const datei = process.env.TURN_GEHEIMNIS_DATEI;
/* Erfunden, nur fuer diesen Lauf, und am Ende wieder weg. Lang genug,
dass die Laengenpruefung nicht zufaellig anschlaegt. */
const GEHEIM = "pruef-geheimnis-nur-fuer-diesen-lauf-0123456789";
/* --- Die drei Ausgaenge beim Lesen ------------------------------- */
{
const g = turn.turnGeheimnisLesen(datei);
ok(g.wert === "" && g.grund === "fehlt",
`ohne Datei sagt es "fehlt" statt stillschweigend nichts (${g.grund})`);
}
{
writeFileSync(datei, "zu-kurz");
const g = turn.turnGeheimnisLesen(datei);
ok(g.wert === "" && g.grund === "zu_kurz",
`ein zu kurzes Geheimnis wird abgelehnt, nicht benutzt (${g.grund})`);
}
{
writeFileSync(datei, " \n");
const g = turn.turnGeheimnisLesen(datei);
ok(g.wert === "" && g.grund === "leer", `eine leere Datei meldet "leer" (${g.grund})`);
}
{
writeFileSync(datei, GEHEIM + "\n");
const g = turn.turnGeheimnisLesen(datei);
ok(g.wert === GEHEIM && g.grund === "",
"mit Datei kommt das Geheimnis an (Zeilenumbruch abgeschnitten)");
}
/* --- Ein WECHSEL muss auffallen ---------------------------------- */
{
writeFileSync(datei, GEHEIM + "-zweite-fassung-mit-anderer-laenge");
const g = turn.turnGeheimnisLesen(datei);
ok(g.wert.endsWith("-zweite-fassung-mit-anderer-laenge"),
"wird die Datei getauscht, wird der neue Wert gelesen (kein Festhaengen)");
writeFileSync(datei, GEHEIM + "\n");
}
/* --- Die Rechnung ------------------------------------------------ */
{
/* Eine FESTE Zeit als Eingabe, nicht die Wanduhr: Sonst waere die
Pruefung eine Zeitbombe, die irgendwann etwas anderes misst. */
const JETZT = 1_700_000_000_000;
const z = turn.turnZugang("w7", GEHEIM, JETZT);
const [ablaufStr, kennung] = z.username.split(":");
ok(kennung === "w7", `im Benutzernamen steht die Kennung (${kennung})`);
ok(Number(ablaufStr) === Math.floor(JETZT / 1000) + turn.TURN_GUELTIG_STUNDEN * 3600,
`und davor der Ablauf, genau ${turn.TURN_GUELTIG_STUNDEN} Stunden spaeter`);
/* FESTER VERGLEICHSWERT, ausgerechnet und nicht behauptet. Er
beweist NICHT, dass coturn ihn akzeptiert -- das beweist nur ein
Versuch am laufenden coturn (turnutils_uclient). Er beweist,
dass sich die Rechenvorschrift nicht unbemerkt aendert; genau
das wuerde jeden Anruf ueber die Vermittlung scheitern lassen,
ohne dass irgendwo ein Fehler steht. */
ok(z.credential === "9AbMBJeTDL6uebcR6KRpYVTFwt8=",
`das Passwort ist die bekannte HMAC-SHA1-Rechnung (${z.credential})`);
const nochmal = turn.turnZugang("w7", GEHEIM, JETZT);
ok(nochmal.credential === z.credential, "dieselbe Eingabe ergibt dasselbe Passwort");
const anderer = turn.turnZugang("w8", GEHEIM, JETZT);
ok(anderer.credential !== z.credential,
"eine andere Person bekommt ein anderes Passwort");
const spaeter = turn.turnZugang("w7", GEHEIM, JETZT + 60_000);
ok(spaeter.credential !== z.credential,
"und eine Minute spaeter ein anderes -- die Daten verfallen wirklich");
}
/* --- Wer bekommt Zugangsdaten und wer nicht ---------------------- */
{
const liste = [
{ urls: "stun:turn.dogfather-universe.com:3478" },
{ urls: "turn:turn.dogfather-universe.com:3478" },
{ urls: "turn:fremder-dienst.example:3478", username: "fremd", credential: "eigenes" },
];
const e = turn.mitZugangsdaten(liste, "w7", 1_700_000_000_000, datei);
ok(!e.adressen[0].credential, "ein STUN-Eintrag bekommt keine Zugangsdaten");
ok(!!e.adressen[1].credential && e.adressen[1].username.includes(":w7"),
"unser TURN-Eintrag bekommt gerechnete");
ok(e.adressen[2].credential === "eigenes" && e.adressen[2].username === "fremd",
"ein fremder Dienst behaelt seine eigenen -- wir ueberschreiben nichts");
ok(e.turn_gesamt === 2 && e.turn_bereit === 2,
`und die Zahlen stimmen (${e.turn_bereit} von ${e.turn_gesamt} bereit)`);
}
{
/* OHNE Geheimnis: Der Eintrag bleibt stehen, aber es wird gesagt. */
unlinkSync(datei);
const e = turn.mitZugangsdaten(
[{ urls: "turn:turn.dogfather-universe.com:3478" }], "w7", Date.now(), datei);
ok(e.turn_gesamt === 1 && e.turn_bereit === 0 && e.grund === "fehlt",
`ohne Geheimnis ist nichts bereit, und der Grund steht dabei (${e.grund})`);
writeFileSync(datei, GEHEIM + "\n");
}
/* --- Und jetzt ueber die Schnittstelle, wie der Browser sie sieht - */
{
await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }, {
adressen: [{ urls: "turn:turn.dogfather-universe.com:3478" }],
}, "PUT");
const a = await roh("/workspace/api/anruf/adressen", CREW, { cookie: kModi });
const r = json(a);
ok(r.turn_gesamt === 1 && r.turn_bereit === 1,
`der Modi bekommt einen einsatzbereiten Eintrag (${r.turn_bereit}/${r.turn_gesamt})`);
ok(!!r.adressen?.[0]?.credential && !!r.adressen?.[0]?.username,
"mit Benutzername und Passwort");
/* DIE WICHTIGSTE PRUEFUNG DES ABSCHNITTS. Im ROHEN Text gesucht,
nicht im ausgewerteten Objekt. */
ok(!a.text.includes(GEHEIM),
"und das Geheimnis selbst steht NIRGENDS in der Antwort");
/* Jeder bekommt EIGENE Zugangsdaten -- im coturn-Protokoll steht
damit die Nummer, nicht ein Sammelkonto. */
const rDogi = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
ok(rDogi.adressen?.[0]?.username !== r.adressen?.[0]?.username,
"DogFather und der Modi bekommen verschiedene Zugangsdaten");
/* Der Ablauf muss fuer den Browser LESBAR sein -- darauf beruht das
Auffrischen vor jedem Anruf in anruf.js. */
const ablauf = Number(String(r.adressen?.[0]?.username || "").split(":")[0]);
ok(ablauf > Math.floor(Date.now() / 1000) + 3600,
`der Ablauf steht vorn im Benutzernamen und liegt in der Zukunft (${ablauf})`);
}
/* --- Aufraeumen: der naechste Abschnitt faengt sauber an ---------- */
{
await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi },
{ adressen: [] }, "PUT");
try { unlinkSync(datei); } catch { /* schon weg */ }
const zurueck = json(await roh("/workspace/api/anruf/adressen", HAUS, { cookie: kDogi }));
ok(zurueck.adressen?.length === 0 && !zurueck.turn_grund,
"danach ist wieder alles leer -- keine erfundene Adresse bleibt stehen");
}
}
/* =======================================================================
7. IM BROWSER
======================================================================= */
melde("");
melde("=== 7. Im Browser ===");
{
let chromium;
try {
({ chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"));
} catch { chromium = null; }
if (!chromium) {
unklar("kein Playwright gefunden -- die Oberflaeche wurde nicht angesehen");
} else {
const browser = await chromium.launch({
/* Mikrofon ohne Rueckfrage und mit einem Testton statt echter
Hardware -- sonst haengt getUserMedia im Prueflauf. */
args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"],
});
try {
const sicht = await browser.newContext({
viewport: { width: 1280, height: 900 },
permissions: ["microphone", "camera"],
});
await sicht.addCookies(kDogi.split("; ").map((c) => {
const [name, ...rest] = c.split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await sicht.newPage();
const konsole = [];
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
await seite.goto(`http://127.0.0.1:${PORT}/workspace/chat.html`,
{ waitUntil: "domcontentloaded" });
await seite.waitForTimeout(1800);
ok(await seite.evaluate(() => typeof window.anrufStarten === "function"),
"der Anruf ist geladen");
/* Ein Gespraech oeffnen -- erst dann gibt es Knoepfe. */
const raeume = await seite.locator("#raeume li").count();
ok(raeume > 0, `${raeume} Gespraech(e) in der Liste`);
await seite.locator("#raeume li").first().click();
await seite.waitForTimeout(1200);
const knoepfe = await seite.evaluate(() => ({
ton: !!document.getElementById("anruf-ton-start"),
video: !!document.getElementById("anruf-video-start"),
sichtbar: document.getElementById("anruf-ton-start")?.offsetHeight > 0,
}));
ok(knoepfe.ton && knoepfe.video, "beide Anrufknoepfe stehen im Kopf");
ok(knoepfe.sichtbar, "und man sieht sie");
/* ANRUFEN -- und zwar wirklich. Ein Test, der nur prueft, dass
ein Knopf da ist, prueft, dass jemand einen Knopf gebaut hat. */
await seite.click("#anruf-ton-start");
await seite.waitForTimeout(2500);
const kasten = await seite.evaluate(() => {
const k = document.getElementById("anruf");
return {
da: !!k && !k.hidden,
lage: document.getElementById("anruf-lage")?.textContent || "",
zeit: document.getElementById("anruf-zeit")?.textContent || "",
};
});
ok(kasten.da, `der Anrufkasten erscheint ("${kasten.lage}")`);
ok(/klingelt|Verbind/i.test(kasten.lage),
`und sagt, was gerade passiert ("${kasten.lage}")`);
ok(/^\d\d:\d\d$/.test(kasten.zeit), `die Uhr laeuft (${kasten.zeit})`);
/* Und der Server weiss davon. */
const l = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
ok(l.laeuft === true, "der Server kennt den Anruf");
await seite.screenshot({ path: "server/pruef-anruf.png", fullPage: false });
melde(" -- Bildschirmfoto: server/pruef-anruf.png");
await seite.click("#anruf-auflegen");
await seite.waitForTimeout(900);
ok(await seite.evaluate(() => document.getElementById("anruf")?.hidden === true),
"nach dem Auflegen ist der Kasten weg");
const l2 = json(await roh(`/workspace/api/anruf/${raumId}`, HAUS, { cookie: kDogi }));
ok(l2.laeuft === false, "und der Server auch");
ok(konsole.length === 0,
konsole.length ? `Konsolenfehler: ${konsole[0]}` : "kein Konsolenfehler");
} finally {
await browser.close();
}
}
}
/* =======================================================================
8. ZU DRITT -- UND ZWAR WIRKLICH (18.09.2026)
GEBAUT IST NICHT BEWIESEN. Der Anruf ist fuer bis zu vier Leute
geschrieben, geprueft war er zu zweit. Genau diese Luecke zwischen
Behauptung und Beleg ist die, an der im Haus schon mehrfach etwas
haengengeblieben ist -- zuletzt bei den Kachelfarben, wo ein
Werkzeug 21 Farben kannte und 37 gemeint waren.
DER ENTSCHEIDENDE PUNKT IST DIE ZAHL DER VERBINDUNGEN. Bei drei
Teilnehmern muss JEDER zwei haben -- eine zu jedem anderen. Haette
`verbindungen` eine einzelne Variable statt einer Karte, ginge zu
zweit alles gut und der Dritte ueberschriebe stillschweigend den
Ersten: Man sieht sich zu dritt und hoert nur einen.
UND DIE ANGEBOTSREGEL. Bei WebRTC muss genau EINER das Angebot
machen. Machen beide eins, heben sie sich auf ("glare") und es kommt
keine Verbindung zustande -- ohne Fehlermeldung, man wartet nur.
Die Regel „die kleinere Personennummer beginnt" ist auf beiden
Seiten ohne Absprache dieselbe Entscheidung. Ob sie traegt, sieht
man erst ab drei Leuten.
======================================================================= */
melde("");
melde("=== 8. Zu dritt ===");
{
let chromium;
try {
({ chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"));
} catch { chromium = null; }
if (!chromium) {
unklar("kein Playwright -- der Gruppenanruf wurde nicht ausprobiert");
} else {
/* Ein Gruppenraum mit allen dreien. */
const g = await roh("/workspace/api/chat/gruppe", HAUS, { cookie: kDogi },
{ name: "Testrunde", mit: [idHand, idModi] });
const gruppeId = json(g).raum?.id || json(g).raum_id || json(g).id;
ok(!!gruppeId, `ein Gruppengespraech entsteht (${g.code}, Raum ${gruppeId})`);
const browser = await chromium.launch({
args: ["--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"],
});
try {
const machSeite = async (keks) => {
const k = await browser.newContext({
viewport: { width: 1100, height: 800 },
permissions: ["microphone"],
});
await k.addCookies(keks.split("; ").map((c) => {
const [name, ...rest] = c.split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
}));
const se = await k.newPage();
await se.goto(`http://127.0.0.1:${PORT}/workspace/chat.html`,
{ waitUntil: "domcontentloaded" });
await se.waitForTimeout(1600);
return { k, se };
};
const a = await machSeite(kDogi);
const b = await machSeite(kHand);
const c = await machSeite(kModi);
/* Alle drei oeffnen denselben Raum. */
for (const x of [a, b, c]) {
await x.se.evaluate((id) => {
const li = [...document.querySelectorAll("#raeume li")]
.find((e) => Number(e.dataset.raum || e.dataset.id) === id);
(li || document.querySelector("#raeume li"))?.click();
}, gruppeId);
await x.se.waitForTimeout(700);
}
/* DogFather ruft an. */
await a.se.evaluate((id) => window.anrufStarten(id, false), gruppeId);
await a.se.waitForTimeout(1500);
/* Die anderen beiden gehen ran -- ueber den Klingelknopf, nicht
ueber eine Abkuerzung: Geprueft werden soll der Weg, den ein
Mensch geht. */
for (const x of [b, c]) {
const da = await x.se.locator("#anruf-klingelt .anruf-klingelt__ja").count();
ok(da === 1, `es klingelt bei ${x === b ? "der rechten Hand" : "dem Modi"} (${da})`);
if (da) await x.se.click("#anruf-klingelt .anruf-klingelt__ja");
}
/* WebRTC braucht ein paar Sekunden, bis die Verbindungen stehen. */
await a.se.waitForTimeout(6000);
/* Der Server: drei drin. */
const lage = json(await roh(`/workspace/api/anruf/${gruppeId}`, HAUS, { cookie: kDogi }));
ok(lage.wer?.length === 3,
`der Server zaehlt drei Teilnehmer (${lage.wer?.map((w) => w.name).join(", ")})`);
/* DER EIGENTLICHE PUNKT: Jeder hat ZWEI Verbindungen. */
const zaehlen = (x) => x.se.evaluate(() => ({
/* Ueber die Oberflaeche nicht erreichbar -- deshalb ueber die
Zahl der Video-/Audio-Elemente im Kasten, die jede
Gegenstelle erzeugt. */
stroeme: document.querySelectorAll("#anruf-leute audio, #anruf-leute video").length,
lage: document.getElementById("anruf-lage")?.textContent || "",
}));
for (const [wer, x] of [["DogFather", a], ["die rechte Hand", b], ["der Modi", c]]) {
const z = await zaehlen(x);
ok(z.stroeme >= 2,
`${wer} hoert BEIDE anderen (${z.stroeme} Stroeme, „${z.lage}")`);
}
/* ---- DER LAUTSPRECHER (18.09.2026) -------------------------
Filipe: „die funktion lautsprecher fehlt."
Gegenstueck zum Mikrofon: „Mikro aus" heisst, die anderen
hoeren mich nicht; „Lautsprecher aus" heisst, ich hoere sie
nicht.
DIE PRUEFUNG, AUF DIE ES ANKOMMT, ist die dritte: Kommt
jemand dazu oder geht jemand, werden die Toenelemente NEU
GEBAUT -- und neue Elemente wissen nichts von einem Schalter,
der vorher umgelegt wurde. Genau daran scheitern solche
Knoepfe sonst: Sie wirken, bis sich die Runde aendert, und
dann hoert man ploetzlich wieder mit. */
const stumm = (x) => x.se.evaluate(() =>
[...document.querySelectorAll("#anruf-leute audio, #anruf-leute video")]
.filter((m) => !m.classList.contains("anruf__bild--ich"))
.map((m) => m.muted));
{
const vorher = await stumm(a);
/* Die ANZAHL gehoert in die Bedingung: `every` ist auf einer
leeren Liste wahr, und dann waere diese Pruefung gruen,
ohne ein einziges Element gesehen zu haben. */
ok(vorher.length >= 2 && vorher.every((m) => m === false),
`vorher hoert DogFather beide (${vorher.length} Stroeme, keiner stumm)`);
const knopf = await a.se.evaluate(() => {
const k = document.getElementById("anruf-hoeren");
return k ? { text: k.textContent.trim(), an: k.getAttribute("aria-pressed") } : null;
});
ok(knopf?.text === "Lautsprecher an" && knopf?.an === "true",
`der Lautsprecher-Knopf ist da und steht auf an („${knopf?.text}")`);
await a.se.click("#anruf-hoeren");
await a.se.waitForTimeout(300);
const nachher = await stumm(a);
ok(nachher.length >= 2 && nachher.every((m) => m === true),
`nach dem Druecken ist ALLES stumm (${nachher.length} Stroeme)`);
const knopf2 = await a.se.evaluate(() => {
const k = document.getElementById("anruf-hoeren");
return { text: k.textContent.trim(), an: k.getAttribute("aria-pressed") };
});
ok(knopf2.text === "Lautsprecher aus" && knopf2.an === "false",
`und der Knopf sagt es auch („${knopf2.text}")`);
/* NUR BEI IHM. Ein Schalter, der bei den anderen mitwirkt,
waere kein Lautsprecher, sondern eine Fernbedienung. */
const beiB = await stumm(b);
ok(beiB.length >= 2 && beiB.every((m) => m === false),
`bei der rechten Hand aendert sich nichts (${beiB.length} Stroeme, keiner stumm)`);
}
/* Einer legt auf -- fuer die anderen laeuft es weiter. */
await c.se.click("#anruf-auflegen");
await c.se.waitForTimeout(2000);
const lage2 = json(await roh(`/workspace/api/anruf/${gruppeId}`, HAUS, { cookie: kDogi }));
ok(lage2.laeuft === true && lage2.wer?.length === 2,
`einer geht, zwei telefonieren weiter (${lage2.wer?.length})`);
const zA = await zaehlen(a);
ok(zA.stroeme === 1,
`und bei DogFather ist genau ein Strom weniger (${zA.stroeme})`);
/* DIE FALLE: Die Runde hat sich geaendert, die Elemente wurden
neu gebaut. Bliebe der Schalter dabei auf der Strecke, wuerde
DogFather ploetzlich wieder mithoeren -- ohne dass irgendwo
etwas anders aussieht. */
const nachWechsel = await stumm(a);
ok(nachWechsel.length === 1 && nachWechsel[0] === true,
`und der Lautsprecher bleibt aus, obwohl neu gezeichnet wurde (${nachWechsel.length} Strom)`);
const knopfNoch = await a.se.evaluate(() =>
document.getElementById("anruf-hoeren")?.getAttribute("aria-pressed"));
ok(knopfNoch === "false", "auch der Knopf steht noch richtig");
await a.se.click("#anruf-auflegen");
await b.se.click("#anruf-auflegen");
await a.se.waitForTimeout(1200);
const lage3 = json(await roh(`/workspace/api/anruf/${gruppeId}`, HAUS, { cookie: kDogi }));
ok(lage3.laeuft === false, "wenn alle auflegen, ist der Anruf vorbei");
await a.k.close(); await b.k.close(); await c.k.close();
} finally {
await browser.close();
}
}
}
/* ------------------------------------------------------------------- */
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`
+ (offen ? `, ${offen} nicht nachsehbar` : ""));
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
process.exit(fehler ? 1 : 0);