Filipe: "die funktion lautsprecher fehlt." Er ist das Gegenstueck zum Mikrofon: "Mikro aus" heisst, die anderen hoeren MICH nicht -- "Lautsprecher aus" heisst, ICH hoere sie nicht. Beides braucht man, aus verschiedenen Gruenden: das Mikro, wenn es bei einem selbst laut ist; den Lautsprecher, wenn nebenher etwas laeuft oder jemand ins Zimmer kommt. WAS BEWUSST NICHT GEBAUT WURDE: der Umschalter zwischen Hoermuschel und Freisprechen, den ein Telefon hat. Recherchiert statt geraten -- den gibt es im Browser nicht: Auf dem iPhone entscheidet Safari selbst, wohin der Ton geht, und Android laesst einzelne Toene gar nicht auf ein anderes Geraet legen (setSinkId ist dort nicht verfuegbar, weil die Plattform es nicht hergibt). Ein Knopf, der dort nichts tut, waere schlimmer als keiner -- man drueckt ihn und sucht den Fehler dann bei sich. Wo es GEHT (Rechner mit Chrome, Edge, Safari), steht dafuer eine echte Geraeteauswahl daneben. Sie erscheint nur, wenn es mehr als ein Geraet gibt UND die Namen bekannt sind; eine Auswahl mit einem Eintrag ist keine Auswahl, sondern eine Behauptung. DIE PRUEFUNG, AUF DIE ES ANKOMMT: 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. Gegenprobe (Anwenden nach dem Neuzeichnen ausgebaut) macht genau diese eine Pruefung rot, waehrend alle anderen gruen bleiben. Geprueft: pruef-anruf 73 -> 80, im echten Dreiergespraech im Browser. Co-Authored-By: Claude Opus 5 <[email protected]>
724 lines
32 KiB
JavaScript
724 lines
32 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.
|
|
======================================================================= */
|
|
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);
|