Aufgefallen beim Nachweis auf der echten Adresse, nicht beim Bauen: Der Kasten haengt an `bereich === "agentur"` und an der Rolle. DogFather ist aber in BEIDEN Haeusern `admin`. Oeffnet er auf crew.dogfather-universe.com von Hand `bereich.html?b=agentur`, dann stimmt der Bereich, die Rolle stimmt -- und `hausFuerNeuenEintrag` gaebe der Kampagne das Haus "crew". Eine Agentur-Kampagne staende im Rudel, und die Trennung waere an genau der Stelle unterlaufen, an der niemand nachsieht. Bauplan, Teil 11: "Nichts im Haus Team Dogi." ZWEI FRAGEN, ZWEI SCHWELLEN -- und das ist Absicht: /api/ich (ZEIGEN) haus !== "crew" Route (SCHREIBEN) haus === "agentur" Der Unterschied betrifft genau einen Fall: `null`. Den gibt es nur auf einer PRUEFADRESSE -- `sitzungLesen` vergibt dort seit dem 24.09.2026 bewusst kein Haus, und die Begruendung dort nennt den Schaden: Als 127.0.0.1 einmal ein Haus bekam, fiel pruef-chat-kanaele mit 13 Fehlschlaegen um, und die naechsten Pruefungen waeren still gruen geblieben, ohne noch etwas zu messen. Eine echte Anfrage hat immer eine der beiden Waende; eine Sitzung zu einer dritten lehnt `sitzungPasstZurAdresse` ohnehin ab. Beim SCHREIBEN wird trotzdem streng gefragt: Ein Eintrag mit `haus = null` waere in BEIDEN Haeusern sichtbar -- genau das, was Teil 11 ausschliesst. WAS ICH DABEI ZWEIMAL FALSCH VERSUCHT HABE Zuerst sollte das Bildwerkzeug ueber die Agenturwand kommen, damit es den Kasten weiter messen kann. Den Host-Kopf beim Durchreichen zu ueberschreiben wirkt nicht -- gemessen: /api/ich meldete weiter `darf: false`, der Wirt blieb "127.0.0.1:4338". Chromium laesst `Host` nicht ueberschreiben. Dann den Namen wirklich aufloesen (--host-resolver-rules). Das funktioniert, bringt aber die Anmeldemaske der echten Wand mit einer Tuer davor, die ein Bildwerkzeug nicht aufmachen soll. Gemessen werden soll die KACHEL, nicht der Zugang. Deshalb bleibt bild-kampagne auf der Pruefadresse -- und genau deshalb fragt /api/ich `!== "crew"` statt `=== "agentur"`. Beide Zeilen tragen jetzt die Begruendung bei sich, damit sie niemand "vereinheitlicht": Das macht entweder die Pruefungen blind oder laesst eine Kampagne ins Rudel. Zwei Gegenproben gefahren, beide schlagen an: Route nimmt jede Wand an -> 2 rot. /api/ich fragt nicht nach dem Haus -> 1 rot. GEPRUEFT: pruef-kampagne 98 -> 99 gruen · bild-kampagne 20 Messungen gruen · pruef-struktur, pruef-haus-trennung 107, pruef-agentur 62, pruef-eventkarte 94, pruef-alle-wege, pruef-schranke, pruef-zeichen 8 Co-Authored-By: Claude Opus 5 <[email protected]>
901 lines
42 KiB
JavaScript
901 lines
42 KiB
JavaScript
/* =====================================================================
|
||
EINE KAMPAGNEN-KACHEL AUS EINEM TIKTOK-LINK (07.10.2026)
|
||
|
||
Prüft Etappe 2 und 3 des Bauplans: den Weg nach draußen
|
||
(`helfer-tiktok.mjs`) und die Route (`workspace-kampagne.js`).
|
||
Das Auslesen selbst prüft `pruef-kampagne-lesen.mjs` — ohne Server,
|
||
ohne Netz, gegen gespeicherte Seiten.
|
||
|
||
---------------------------------------------------------------------
|
||
DER NACHGEBAUTE DIENST LIEGT AUF 127.0.0.1
|
||
|
||
Umgestellt über `TIKTOK_KAMPAGNE_BASIS` — genau wie
|
||
`TIKTOK_OEMBED_BASIS` beim Video. Eine Prüfung gegen das echte
|
||
TikTok wäre langsam, hinge am Netz und würde irgendwann rot, ohne
|
||
dass etwas kaputt ist. Sie wäre die Sorte Prüfung, die man abschaltet.
|
||
|
||
Ausgeliefert werden die ECHTEN Seiten aus `server/pruefdaten/`.
|
||
Verändert wird daran genau eine Sache: die Banneradresse zeigt auf
|
||
diesen Dienst statt auf TikToks CDN. Ohne das würde diese Prüfung
|
||
bei jedem Lauf ein Bild aus dem Internet holen — und wäre damit
|
||
genau das, was sie vermeiden soll.
|
||
|
||
---------------------------------------------------------------------
|
||
KEINE TESTDATEN IM LIVE-SYSTEM
|
||
|
||
Alles läuft auf einer frisch angelegten Datenbank in einem
|
||
Wegwerfordner. Es entsteht nie eine Kachel auf
|
||
workspace.dogfather-universe.com.
|
||
|
||
KEINE WANDUHR ALS ANNAHME bei den Daten, die geprüft werden: Die
|
||
Kampagnen in den gespeicherten Seiten haben feste Zeitstempel, und
|
||
genau die werden nachgerechnet (1791064800000 → 2026-10-04 in
|
||
Europe/Berlin). Das ist eine EINGABE, keine Annahme über „jetzt“ —
|
||
der Kalender kann es nicht einholen.
|
||
|
||
Aufrufen mit: node server/pruef-kampagne.mjs
|
||
===================================================================== */
|
||
import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
|
||
import { brotliDecompressSync } from "node:zlib";
|
||
import { tmpdir } from "node:os";
|
||
import { dirname, join } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import { heuteLokal } from "./helfer-tag.mjs";
|
||
|
||
const HIER = dirname(fileURLToPath(import.meta.url));
|
||
const DATEN = join(HIER, "pruefdaten");
|
||
|
||
const ordner = mkdtempSync(join(tmpdir(), "ws-kmp-"));
|
||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||
|
||
import { notbremse } from "./helfer-notbremse.mjs";
|
||
const { eigenerPort } = await import("./helfer-port.mjs");
|
||
const PORT = await eigenerPort(import.meta, "die Kampagnen-Pruefung");
|
||
const TIKPORT = await eigenerPort(import.meta, "der nachgebaute TikTok-Dienst", 1);
|
||
|
||
/* DER SCHALTER. Er macht zweierlei: `istTikTok` lässt 127.0.0.1 durch,
|
||
und `bildAdresseErlaubt` ebenfalls. Beides nur hier — im Betrieb
|
||
steht die Variable nicht, und dann gilt die echte Regel. */
|
||
process.env.TIKTOK_KAMPAGNE_BASIS = "http://127.0.0.1:" + TIKPORT;
|
||
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";
|
||
|
||
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 });
|
||
};
|
||
await import("./index.js");
|
||
notbremse(300_000, "pruef-kampagne");
|
||
await new Promise((r) => setTimeout(r, 1000));
|
||
const BASIS = "http://127.0.0.1:" + PORT;
|
||
|
||
let fehler = 0;
|
||
let geprueft = 0;
|
||
const melde = (z) => console.log(z);
|
||
const ok = (b, t) => { geprueft++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||
|
||
function nichtNachsehbar(grund, rat) {
|
||
melde("");
|
||
melde("KONNTE NICHT NACHSEHEN");
|
||
melde(" Grund: " + grund);
|
||
melde(" Was tun: " + rat);
|
||
process.exit(2);
|
||
}
|
||
|
||
/* =====================================================================
|
||
DIE GESPEICHERTEN SEITEN
|
||
===================================================================== */
|
||
function seiteLesen(name) {
|
||
const pfad = join(DATEN, name);
|
||
if (!existsSync(pfad)) {
|
||
nichtNachsehbar(`die Prüfdatei ${name} fehlt in ${DATEN}`,
|
||
"Sie ist in Gitea versioniert: git checkout -- server/pruefdaten/");
|
||
}
|
||
try {
|
||
return name.endsWith(".br")
|
||
? brotliDecompressSync(readFileSync(pfad)).toString("utf8")
|
||
: readFileSync(pfad, "utf8");
|
||
} catch (f) {
|
||
return nichtNachsehbar(`${name} lässt sich nicht lesen (${f.message})`,
|
||
"Aus Gitea neu holen.");
|
||
}
|
||
}
|
||
|
||
/** Macht das Datenpaket einer gespeicherten Seite auf, lässt daran
|
||
* ändern, schreibt es zurück. Steht hier und nicht im Leser: Der
|
||
* Leser liest, er baut nicht. */
|
||
function umbauen(html, aendern) {
|
||
const marke = html.indexOf('id="__MODERN_ROUTER_DATA__"');
|
||
const a = html.indexOf(">", marke) + 1;
|
||
const e = html.indexOf("</script>", a);
|
||
const daten = JSON.parse(html.slice(a, e));
|
||
aendern(daten.loaderData.home_page.COSMIC_D_K.value, daten);
|
||
return html.slice(0, a) + JSON.stringify(daten) + html.slice(e);
|
||
}
|
||
|
||
const BANNER_HIER = `http://127.0.0.1:${TIKPORT}/banner.png`;
|
||
const TEXT_STATT_BILD = `http://127.0.0.1:${TIKPORT}/banner.txt`;
|
||
|
||
/** Die Banneradresse auf unseren Dienst biegen — der EINZIGE Eingriff
|
||
* an den echten Seiten. */
|
||
function bannerHierher(html, ziel = BANNER_HIER) {
|
||
return umbauen(html, (w) => {
|
||
const s = JSON.parse(w.globalData.activityInfo.ac_schema_with_interaction_rules
|
||
?? JSON.stringify(w.schema));
|
||
let getroffen = 0;
|
||
(function lauf(k, t = 0) {
|
||
if (!k || typeof k !== "object" || t > 20) return;
|
||
if (typeof k.type === "string" && k.type.includes("live_top_image")
|
||
&& Array.isArray(k.props?.imageUrl)) {
|
||
for (const b of k.props.imageUrl) { b.url = ziel; b.urlSG = ziel; getroffen++; }
|
||
}
|
||
for (const v of Object.values(k)) {
|
||
if (Array.isArray(v)) { for (const x of v) lauf(x, t + 1); }
|
||
else if (v && typeof v === "object") lauf(v, t + 1);
|
||
}
|
||
})(s);
|
||
if (!getroffen) throw new Error("kein Bildbaustein gefunden");
|
||
if (w.globalData.activityInfo.ac_schema_with_interaction_rules) {
|
||
w.globalData.activityInfo.ac_schema_with_interaction_rules = JSON.stringify(s);
|
||
} else {
|
||
w.schema = typeof w.schema === "string" ? JSON.stringify(s) : s;
|
||
}
|
||
});
|
||
}
|
||
|
||
const ID_GIPFEL = "7691493175936699143";
|
||
const ID_BEENDET = "7657456323453405959";
|
||
const ID_OHNEPREISE = "7670899782609996552";
|
||
const ID_TEXTBANNER = "7600000000000000001";
|
||
|
||
const SEITEN = {
|
||
[ID_GIPFEL]: bannerHierher(seiteLesen("kampagne-gipfelstuermer.html.br")),
|
||
[ID_BEENDET]: bannerHierher(seiteLesen("kampagne-beendet.html.br")),
|
||
[ID_OHNEPREISE]: bannerHierher(seiteLesen("kampagne-ohne-preise.html.br")),
|
||
};
|
||
const ROH_GIPFEL = seiteLesen("kampagne-gipfelstuermer.html.br");
|
||
const SEITE_KAPUTT = seiteLesen("kampagne-kaputt.html");
|
||
|
||
/* Eine Kampagne, deren Banner eine TEXTDATEI ist. Für die Gegenprobe
|
||
zu `bildArtVon`: Was von draußen kommt, ist von draußen -- und der
|
||
fremde Server behauptet den Typ. */
|
||
SEITEN[ID_TEXTBANNER] = umbauen(bannerHierher(ROH_GIPFEL, TEXT_STATT_BILD), (w) => {
|
||
w.globalData.activityInfo.activity_id = ID_TEXTBANNER;
|
||
w.globalData.activityId = ID_TEXTBANNER;
|
||
w.globalData.pageInfo.title = "Kampagne mit kaputtem Banner";
|
||
});
|
||
|
||
/* Eine Kampagne ohne Enddatum: Pflichtfeld fehlt, es darf KEINE Kachel
|
||
entstehen. */
|
||
const SEITE_OHNE_ENDE = umbauen(ROH_GIPFEL, (w) => {
|
||
delete w.globalData.activityInfo.end_time;
|
||
});
|
||
|
||
/* =====================================================================
|
||
DER NACHGEBAUTE DIENST
|
||
===================================================================== */
|
||
const { createServer } = await import("node:http");
|
||
const { pngBauen } = await import("./helfer-png-bauen.mjs");
|
||
const BANNERBILD = pngBauen(750, 500, (x, y, b, h) => {
|
||
const g = Math.max(0, 1 - Math.hypot(x / b - .5, y / h - .5) * 1.6);
|
||
return [Math.round(30 + g * 120), Math.round(40 + g * 90), Math.round(20 + g * 60)];
|
||
});
|
||
|
||
let abrufe = 0;
|
||
const gesehen = [];
|
||
const tikDienst = createServer((anfrage, antwort) => {
|
||
abrufe++;
|
||
const u = new URL(anfrage.url, "http://127.0.0.1");
|
||
gesehen.push({ pfad: u.pathname, kennung: anfrage.headers["user-agent"] || "" });
|
||
const leite = (nach) => { antwort.writeHead(302, { location: nach }); antwort.end(); };
|
||
|
||
if (u.pathname === "/t/gipfel") return leite(`/falcon/home?activityId=${ID_GIPFEL}&campaign_traffic_id=1`);
|
||
if (u.pathname === "/t/gipfel-zweiter-versand") return leite(`/falcon/home?activityId=${ID_GIPFEL}&campaign_traffic_id=2`);
|
||
if (u.pathname === "/t/beendet") return leite(`/falcon/home?activityId=${ID_BEENDET}`);
|
||
if (u.pathname === "/t/ohnepreise") return leite(`/falcon/home?activityId=${ID_OHNEPREISE}`);
|
||
if (u.pathname === "/t/textbanner") return leite(`/falcon/home?activityId=${ID_TEXTBANNER}`);
|
||
if (u.pathname === "/t/fremd") return leite("https://beispiel.de/ganz-woanders");
|
||
if (u.pathname === "/t/kreis") return leite("/t/kreis");
|
||
if (u.pathname === "/t/kaputt") return leite("/falcon/kaputt");
|
||
if (u.pathname === "/t/ohneende") return leite("/falcon/ohneende");
|
||
if (u.pathname === "/t/stumm") return leite("/falcon/stumm");
|
||
if (u.pathname === "/t/riesig") return leite("/falcon/riesig");
|
||
|
||
if (u.pathname === "/banner.png") {
|
||
antwort.writeHead(200, { "content-type": "image/png",
|
||
"content-length": BANNERBILD.length });
|
||
return antwort.end(BANNERBILD);
|
||
}
|
||
if (u.pathname === "/banner.txt") {
|
||
const b = Buffer.from("Das ist kein Bild, sondern Text.");
|
||
/* Der Typ wird BEHAUPTET -- genau darum geht es: Die Byteprüfung
|
||
glaubt dem fremden Server nicht. */
|
||
antwort.writeHead(200, { "content-type": "image/png", "content-length": b.length });
|
||
return antwort.end(b);
|
||
}
|
||
if (u.pathname === "/falcon/stumm") {
|
||
/* Nie antworten. Die Verbindung bleibt offen, bis die Frist greift. */
|
||
return;
|
||
}
|
||
if (u.pathname === "/falcon/riesig") {
|
||
antwort.writeHead(200, { "content-type": "text/html" });
|
||
/* OHNE content-length (gestückelt): Die Grenze muss beim LESEN
|
||
greifen, nicht über einen Kopf, den man auch weglassen kann. */
|
||
const brocken = Buffer.alloc(256 * 1024, 0x41);
|
||
let n = 0;
|
||
const weiter = () => {
|
||
if (antwort.writableEnded || antwort.destroyed) return;
|
||
if (n++ >= 20) { antwort.end(); return; } /* 5 MB */
|
||
if (antwort.write(brocken)) setImmediate(weiter);
|
||
else antwort.once("drain", weiter);
|
||
};
|
||
return weiter();
|
||
}
|
||
if (u.pathname === "/falcon/kaputt") {
|
||
antwort.writeHead(200, { "content-type": "text/html" });
|
||
return antwort.end(SEITE_KAPUTT);
|
||
}
|
||
if (u.pathname === "/falcon/ohneende") {
|
||
antwort.writeHead(200, { "content-type": "text/html" });
|
||
return antwort.end(SEITE_OHNE_ENDE);
|
||
}
|
||
if (u.pathname === "/falcon/home") {
|
||
const id = u.searchParams.get("activityId");
|
||
if (SEITEN[id]) {
|
||
antwort.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||
return antwort.end(SEITEN[id]);
|
||
}
|
||
}
|
||
antwort.writeHead(404, { "content-type": "text/plain" });
|
||
antwort.end("nicht da");
|
||
});
|
||
await new Promise((f, s) => {
|
||
tikDienst.once("error", s);
|
||
tikDienst.listen(TIKPORT, "127.0.0.1", f);
|
||
});
|
||
const TIK = `http://127.0.0.1:${TIKPORT}`;
|
||
|
||
/* =====================================================================
|
||
PERSONEN UND ZUGANG
|
||
===================================================================== */
|
||
const { DatabaseSync } = await import("node:sqlite");
|
||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||
method: "POST", headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
||
for (let i = 0; i < 60; i++) {
|
||
try {
|
||
const p = new DatabaseSync(process.env.WORKSPACE_DB);
|
||
try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); }
|
||
} catch { await new Promise((r) => setTimeout(r, 250)); }
|
||
}
|
||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||
const jetzt = new Date().toISOString();
|
||
function anlegen(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, jetzt);
|
||
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||
}
|
||
const idDogi = anlegen("Dogfather", "admin", "CODE-DOGI-0001");
|
||
anlegen("Luna", "creator", "CODE-CREA-0001");
|
||
anlegen("Mara", "manager", "CODE-MANA-0001");
|
||
anlegen("Bremser", "manager", "CODE-BREM-0001");
|
||
|
||
/* EIN EINTRAG IM ANDEREN HAUS -- damit „Crew unverändert“ nicht eine
|
||
Behauptung über eine leere Menge ist. `every()` auf einem leeren Feld
|
||
meldet `true`; eine Zählung über null Zeilen ebenso. */
|
||
d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum,"
|
||
+ " dringlichkeit, erstellt, erstellt_von, haus)"
|
||
+ " VALUES (?,?,'treff','frage','offen',?, 'mittel', ?, ?, 'crew')")
|
||
/* DER TAG IN ORTSZEIT (berichtigt 07.10.2026, gefunden von
|
||
pruef-struktur). Hier stand `jetzt.slice(0, 10)` -- das ist der
|
||
UTC-Tag. Zwischen 00:00 und 02:00 deutscher Zeit legt das den
|
||
Eintrag auf gestern; harmlos fuer DIESE Pruefung, aber genau die
|
||
Gewohnheit, die anderswo zwei Stunden am Tag rot macht. */
|
||
.run("Etwas vom Rudel", "Gehört Team Dogi.", heuteLokal(), jetzt, idDogi);
|
||
d.close();
|
||
|
||
const { request } = await import("node:http");
|
||
const HOST = "workspace.dogfather-universe.com";
|
||
const CREW_HOST = "crew.dogfather-universe.com";
|
||
function roh(pfad, o = {}) {
|
||
return new Promise((f, s) => {
|
||
const daten = o.rumpf === undefined ? null : Buffer.from(JSON.stringify(o.rumpf));
|
||
const wirt = o.wirt || HOST;
|
||
const a = request({
|
||
host: "127.0.0.1", port: PORT, path: pfad,
|
||
method: o.methode || (daten ? "POST" : "GET"),
|
||
headers: {
|
||
Host: wirt, Origin: "https://" + wirt,
|
||
...(o.keks ? { Cookie: o.keks } : {}),
|
||
...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
|
||
...(o.kopf || {}),
|
||
},
|
||
}, (r) => {
|
||
const t = [];
|
||
r.on("data", (x) => t.push(x));
|
||
r.on("end", () => f({ code: r.statusCode, kopf: r.headers,
|
||
text: Buffer.concat(t).toString("utf8") }));
|
||
});
|
||
a.on("error", s);
|
||
if (daten) a.write(daten);
|
||
a.end();
|
||
});
|
||
}
|
||
async function anmelden(rolle, code, wirt = HOST) {
|
||
const a = await roh("/workspace/api/anmelden", { rumpf: { rolle, code }, wirt });
|
||
if (a.code !== 200) {
|
||
nichtNachsehbar(`Anmeldung ${rolle} auf ${wirt} ergab ${a.code}`,
|
||
"Ohne Anmeldung lässt sich nichts von dem hier messen.");
|
||
}
|
||
return [].concat(a.kopf["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
|
||
}
|
||
const keksDogi = await anmelden("admin", "CODE-DOGI-0001");
|
||
const keksLuna = await anmelden("creator", "CODE-CREA-0001");
|
||
const keksMara = await anmelden("manager", "CODE-MANA-0001");
|
||
const keksBrem = await anmelden("manager", "CODE-BREM-0001");
|
||
const keksDogiCrew = await anmelden("admin", "CODE-DOGI-0001", CREW_HOST);
|
||
|
||
/** Eine Einladung einlesen -- so, wie es die Oberfläche tut. */
|
||
const einlesen = (text, keks = keksDogi, wirt = HOST) =>
|
||
roh("/workspace/api/kampagne/einlesen", { rumpf: { text }, keks, wirt })
|
||
.then((a) => ({ ...a, j: JSON.parse(a.text || "{}") }));
|
||
|
||
const dbLesen = (sql, ...w) => {
|
||
const x = new DatabaseSync(process.env.WORKSPACE_DB);
|
||
try { return x.prepare(sql).all(...w); } finally { x.close(); }
|
||
};
|
||
|
||
/* =====================================================================
|
||
1. DEN LINK AUS DEM GANZEN TEXT HOLEN
|
||
===================================================================== */
|
||
melde("=== 1. Der Link steckt mitten im Fließtext ===");
|
||
|
||
const { linkAusText, istTikTok, hostVon, bildAdresseErlaubt,
|
||
FRIST_SEITE_MS, SEITE_MAX, UMLEITUNGEN_MAX, KENNUNG, holeSeite, folgeUmleitung }
|
||
= await import("./helfer-tiktok.mjs");
|
||
|
||
{
|
||
const einladung = "Dein Creator Network lädt dich ein, an einer Kampagne "
|
||
+ "teilzunehmen.\nKlicke hier, um weitere Informationen zu erhalten: "
|
||
+ "https://www.tiktok.com/t/ZSbqPsFa8/";
|
||
ok(linkAusText(einladung) === "https://www.tiktok.com/t/ZSbqPsFa8/",
|
||
`aus 150 Zeichen Einladung: ${linkAusText(einladung)}`);
|
||
|
||
ok(linkAusText("Schau mal: https://www.tiktok.com/t/ZSbqPsFa8/.") === "https://www.tiktok.com/t/ZSbqPsFa8/",
|
||
"ein Satzpunkt dahinter gehört nicht zur Adresse");
|
||
ok(linkAusText("Hier (https://www.tiktok.com/t/ABC/) steht er") === "https://www.tiktok.com/t/ABC/",
|
||
"eine Klammer dahinter auch nicht");
|
||
ok(linkAusText("Ohne jede Adresse.") === null,
|
||
"ein Text ohne Link ergibt nichts");
|
||
ok(linkAusText("https://example.com/x https://www.tiktok.com/t/B/") === "https://www.tiktok.com/t/B/",
|
||
"eine fremde Adresse davor wird übersprungen");
|
||
|
||
/* DIE GEGENPROBE, DIE ZÄHLT. "tiktok.com.beispiel.de" enthält
|
||
"tiktok.com" -- wer mit einer Zeichenkettensuche arbeitet, ruft
|
||
einen fremden Server auf. */
|
||
ok(linkAusText("https://tiktok.com.beispiel.de/t/X/") === null,
|
||
"tiktok.com.beispiel.de wird NICHT genommen");
|
||
ok(istTikTok("https://tiktok.com.beispiel.de/") === false,
|
||
" und istTikTok lehnt es einzeln ebenso ab");
|
||
ok(istTikTok("https://www.tiktok.com/t/X/") === true,
|
||
" die echte Adresse dagegen nicht");
|
||
ok(linkAusText("http://www.tiktok.com/t/X/") === null,
|
||
"und ohne Verschlüsselung gar nicht");
|
||
ok(hostVon("https://www.tiktok.com/t/X/") === "www.tiktok.com",
|
||
"der Rechnername für die Meldung an den Menschen stimmt");
|
||
}
|
||
|
||
/* =====================================================================
|
||
2. DIE UMLEITUNG, STATION FÜR STATION
|
||
===================================================================== */
|
||
melde("");
|
||
melde("=== 2. Jede Station wird einzeln geprüft ===");
|
||
|
||
{
|
||
const gut = await folgeUmleitung(`${TIK}/t/gipfel`);
|
||
ok(gut.ok && gut.url.includes(`activityId=${ID_GIPFEL}`),
|
||
`der Kurzlink führt zur Kampagne (${gut.weg.length} Stationen)`);
|
||
|
||
const weg = await folgeUmleitung(`${TIK}/t/fremd`);
|
||
ok(weg.ok === false && weg.grund === "fremd" && hostVon(weg.url) === "beispiel.de",
|
||
`eine Umleitung von TikTok weg bricht ab und nennt das Ziel (${hostVon(weg.url)})`);
|
||
|
||
const kreis = await folgeUmleitung(`${TIK}/t/kreis`);
|
||
/* weg.length ist UMLEITUNGEN_MAX + 1: Der Weg beginnt mit der
|
||
eingegebenen Adresse, danach kommen fuenf Spruenge. Hier stand
|
||
zuerst "=== UMLEITUNGEN_MAX" -- geraten, nicht gezaehlt. */
|
||
ok(kreis.ok === false && kreis.grund === "zu_viele"
|
||
&& kreis.weg.length === UMLEITUNGEN_MAX + 1,
|
||
`eine Schleife endet nach ${UMLEITUNGEN_MAX} Spruengen (${kreis.weg.length} Stationen)`);
|
||
|
||
ok(UMLEITUNGEN_MAX === 5 && FRIST_SEITE_MS === 12_000 && SEITE_MAX === 3 * 1024 * 1024,
|
||
`die drei Grenzen stehen: ${UMLEITUNGEN_MAX} Sprünge, `
|
||
+ `${FRIST_SEITE_MS / 1000} s, ${SEITE_MAX / 1048576} MB`);
|
||
|
||
/* EIN EHRLICHER NAME. Kein Abruf gibt sich als Browser aus. */
|
||
ok(gesehen.length > 0 && gesehen.every((g) => g.kennung.startsWith("DogfatherUniverse-Workspace/")),
|
||
`alle ${gesehen.length} Abrufe nennen, wer fragt`);
|
||
ok(/workspace\.dogfather-universe\.com/.test(KENNUNG) && !/Mozilla/i.test(KENNUNG),
|
||
"und die Kennung ist keine Browser-Verkleidung");
|
||
}
|
||
|
||
/* =====================================================================
|
||
3. FRIST UND GRÖSSE
|
||
===================================================================== */
|
||
melde("");
|
||
melde("=== 3. Ein Server, der nicht antwortet, und einer, der zu viel schickt ===");
|
||
|
||
{
|
||
/* MIT KURZER FRIST GEMESSEN, nicht mit zwölf Sekunden: Geprüft wird
|
||
der MECHANISMUS. Dass im Betrieb 12 s gelten, steht oben als
|
||
eigene Zeile -- und die Route selbst wird unten noch einmal gegen
|
||
den stummen Server geschickt, dort mit der echten Frist. */
|
||
const t0 = Date.now();
|
||
const stumm = await holeSeite(`${TIK}/falcon/stumm`, { frist: 700 });
|
||
const dauer = Date.now() - t0;
|
||
ok(stumm.ok === false && stumm.grund === "frist" && dauer < 3000,
|
||
`ein stummer Server wird nach ${dauer} ms abgebrochen (${stumm.grund})`);
|
||
|
||
const t1 = Date.now();
|
||
const riesig = await holeSeite(`${TIK}/falcon/riesig`, { maxBytes: 512 * 1024 });
|
||
const dauer1 = Date.now() - t1;
|
||
ok(riesig.ok === false && riesig.grund === "zu_gross" && riesig.angesagt === false,
|
||
`5 MB ohne angesagte Größe werden beim LESEN abgebrochen (nach ${dauer1} ms)`);
|
||
|
||
/* DIE GEGENPROBE: Derselbe Abruf unter der Grenze geht durch. Ohne
|
||
sie bewiese die Zeile darüber nur, dass irgendetwas scheitert. */
|
||
const passt = await holeSeite(`${TIK}/falcon/home?activityId=${ID_GIPFEL}`);
|
||
ok(passt.ok === true && passt.bytes > 1_000_000 && passt.text.includes("Gipfelstürmer"),
|
||
`eine echte Seite (${Math.round(passt.bytes / 1024)} KB) kommt ganz an`);
|
||
}
|
||
|
||
/* =====================================================================
|
||
4. DIE KACHEL ENTSTEHT
|
||
===================================================================== */
|
||
melde("");
|
||
melde("=== 4. Aus der Einladung wird eine Agentur-Event-Kachel ===");
|
||
|
||
let kachelId = null;
|
||
{
|
||
const t0 = Date.now();
|
||
const a = await einlesen(
|
||
"Dein Creator Network lädt dich ein, an einer Kampagne teilzunehmen.\n"
|
||
+ `Klicke hier, um weitere Informationen zu erhalten: ${TIK}/t/gipfel`);
|
||
const dauer = Date.now() - t0;
|
||
ok(a.code === 201 && Number.isFinite(a.j.id),
|
||
`HTTP ${a.code}, Kachel #${a.j.id}, in ${dauer} ms`);
|
||
ok(dauer < 15_000, `und das in unter 15 Sekunden (${(dauer / 1000).toFixed(1)} s)`);
|
||
kachelId = a.j.id;
|
||
ok(a.j.titel === "Gipfelstürmer" && a.j.von === "2026-10-04" && a.j.bis === "2026-10-07",
|
||
`die Antwort nennt „${a.j.titel}“, ${a.j.von} bis ${a.j.bis}`);
|
||
ok(Array.isArray(a.j.hinweise) && a.j.hinweise.length === 0,
|
||
`nichts fehlte (${(a.j.hinweise || []).length} Hinweise)`);
|
||
|
||
const z = dbLesen("SELECT * FROM eintraege WHERE id = ?", kachelId)[0];
|
||
ok(!!z, "der Eintrag steht in der Datenbank");
|
||
ok(z.bereich === "agentur" && z.art === "kampagne",
|
||
`Bereich ${z.bereich}, Art ${z.art}`);
|
||
/* DER TAG IN DER ZONE DER KAMPAGNE, nicht in UTC und nicht nach der
|
||
Serverzeit. `toISOString()` ergäbe hier den 03.10. */
|
||
ok(z.datum === "2026-10-04",
|
||
`Beginn ${z.datum} -- nicht 2026-10-03, wie UTC es ergäbe`);
|
||
ok(z.event_ende === "2026-10-07", `Ende ${z.event_ende}`);
|
||
ok(z.kampagne_zone === "Europe/Berlin", `Zone ${z.kampagne_zone}`);
|
||
ok(z.kampagne_id === ID_GIPFEL, `Nummer ${z.kampagne_id}`);
|
||
ok(z.kampagne_quelle === `${TIK}/t/gipfel`,
|
||
"der eingefügte Link steht unverändert dabei");
|
||
ok(!!z.kampagne_geholt_am && Math.abs(Date.parse(z.kampagne_geholt_am) - Date.now()) < 120_000,
|
||
"mit Zeitstempel");
|
||
ok(z.nur_leitung === 0, "eine Kampagne ist für alle, nicht nur für die Leitung");
|
||
ok(z.titel === "Gipfelstürmer" && (z.text || "").includes("Alpen-Vibes"),
|
||
"Titel und Einleitung stehen drin");
|
||
|
||
/* KEIN FELD LÄNGER ALS DAS FORMULAR ERLAUBT. Sonst entstünde eine
|
||
Kachel, die DogFather öffnen, aber nicht speichern kann. */
|
||
for (const feld of ["text", "event_aufgaben", "event_regeln"]) {
|
||
ok((z[feld] || "").length <= 6000,
|
||
` ${feld}: ${(z[feld] || "").length} Zeichen (höchstens 6000)`);
|
||
}
|
||
ok((z.event_regeln || "").includes("Der Regeltext war länger"),
|
||
"und wo gekürzt wurde, sagt die Kachel es selbst");
|
||
|
||
/* DER AUSZUG BEWAHRT, WAS NICHT INS FELD PASST. */
|
||
const auszug = JSON.parse(z.kampagne_daten || "{}");
|
||
ok(auszug.preise?.length === 6 && auszug.geschenke?.length === 5
|
||
&& auszug.klapptexte?.length === 2,
|
||
`die Rohdaten haben alles: ${auszug.preise?.length} Preisgruppen, `
|
||
+ `${auszug.geschenke?.length} Geschenke, ${auszug.klapptexte?.length} Klapptexte`);
|
||
}
|
||
|
||
/* =====================================================================
|
||
5. FÜNF HAKEN ZUM ABHAKEN
|
||
===================================================================== */
|
||
melde("");
|
||
melde("=== 5. Die Aufgaben werden zu Haken ===");
|
||
|
||
{
|
||
const b = JSON.parse((await roh("/workspace/api/bereich/agentur", { keks: keksDogi })).text);
|
||
const k = (b.eintraege || []).find((e) => e.id === kachelId);
|
||
ok(!!k, "die Kachel steht auf dem Agentur-Brett");
|
||
ok(Array.isArray(k.event_punkte) && k.event_punkte.length === 5,
|
||
`${(k.event_punkte || []).length} Haken`);
|
||
ok((k.event_punkte || [])[0]?.text === "1 Diamant = 1 Punkt",
|
||
`der erste heißt „${(k.event_punkte || [])[0]?.text}“`);
|
||
ok((k.event_punkte || []).every((p) => !/^[-–—•·*▪◦‣>+]/.test(p.text)),
|
||
"keiner trägt ein Aufzählungszeichen, das niemand getippt hat");
|
||
|
||
/* EIN HAKEN BLEIBT STEHEN. Abnahmepunkt 2 des Bauplans. */
|
||
const s = k.event_punkte[0].schluessel;
|
||
const setzen = await roh(`/workspace/api/eintrag/${kachelId}/punkt/${s}`,
|
||
{ methode: "PUT", rumpf: { an: true }, keks: keksLuna });
|
||
ok(setzen.code === 200, `ein Creator setzt einen Haken (HTTP ${setzen.code})`);
|
||
const b2 = JSON.parse((await roh("/workspace/api/bereich/agentur", { keks: keksLuna })).text);
|
||
const k2 = (b2.eintraege || []).find((e) => e.id === kachelId);
|
||
/* DAS FELD HEISST `meiner`, nicht `an` (berichtigt 07.10.2026).
|
||
Abgeschrieben aus workspace-bereiche.js statt geraten -- hier
|
||
stand zuerst `an`, und die Pruefung meldete einen Fehler an einer
|
||
Stelle, an der keiner war. */
|
||
ok((k2?.event_punkte || []).find((p) => p.schluessel === s)?.meiner === true,
|
||
"und er steht beim nächsten Laden noch da");
|
||
}
|
||
|
||
/* =====================================================================
|
||
6. DER WICHTIGSTE NACHWEIS: DAS SCHEMA, NICHT DAS WÖRTERBUCH
|
||
===================================================================== */
|
||
melde("");
|
||
melde("=== 6. „1 schenkende Person“ steht NICHT in der Kachel ===");
|
||
|
||
{
|
||
const z = dbLesen("SELECT * FROM eintraege WHERE id = ?", kachelId)[0];
|
||
const alles = [z.titel, z.text, z.event_aufgaben, z.event_regeln, z.kampagne_daten]
|
||
.join("\n");
|
||
/* GEGENPROBE ZUERST: Der Satz steht sehr wohl in der Seite, die wir
|
||
gelesen haben. Ohne diese Zeile wäre die nächste auch dann grün,
|
||
wenn gar nichts gelesen worden wäre. */
|
||
ok(ROH_GIPFEL.includes("schenkende Person"),
|
||
"Gegenprobe: in der gelesenen Seite kommt er vor");
|
||
ok(alles.length > 3000 && !alles.includes("schenkende"),
|
||
`in ${alles.length} Zeichen Kachelinhalt kommt er nicht vor`);
|
||
}
|
||
|
||
/* =====================================================================
|
||
7. DAS BANNER LIEGT BEI UNS
|
||
===================================================================== */
|
||
melde("");
|
||
melde("=== 7. Das Banner wird geholt, nicht verlinkt ===");
|
||
|
||
{
|
||
const dat = dbLesen("SELECT * FROM dateien WHERE eintrag_id = ?", kachelId);
|
||
ok(dat.length === 1, `${dat.length} Datei am Eintrag`);
|
||
ok(dat[0]?.typ === "image/png" && dat[0]?.groesse > 1000,
|
||
`als ${dat[0]?.typ}, ${dat[0]?.groesse} Bytes`);
|
||
ok(existsSync(join(ordner, "dateien", dat[0]?.name_datei || "x")),
|
||
"und sie liegt wirklich auf der Platte");
|
||
ok(!(dat[0]?.name_datei || "").startsWith("http"),
|
||
"gespeichert ist ein Dateiname, keine fremde Adresse");
|
||
|
||
/* SICHTBAR AUCH FÜR EINEN CREATOR. Am 03.10.2026 war genau das
|
||
schon einmal ein 404. */
|
||
/* DER LESWEG IST /api/dateien/:id/bild (berichtigt 07.10.2026).
|
||
Hier stand /api/bereich/agentur/:id/bild -- das ist der Weg zum
|
||
HOCHLADEN (POST), und als GET gibt es ihn nicht. Abgeschrieben aus
|
||
workspace-bereiche.js statt geraten. */
|
||
const bild = await roh(`/workspace/api/dateien/${dat[0]?.id}/bild`, { keks: keksLuna });
|
||
ok(bild.code === 200, `ein Creator bekommt das Banner (HTTP ${bild.code})`);
|
||
/* GEGENPROBE: ohne Anmeldung nicht. Sonst bewiese die Zeile darueber
|
||
nur, dass irgendeine Datei ausgeliefert wird. */
|
||
const ohne = await roh(`/workspace/api/dateien/${dat[0]?.id}/bild`);
|
||
ok(ohne.code === 401 || ohne.code === 403,
|
||
`Gegenprobe: ohne Anmeldung nicht (HTTP ${ohne.code})`);
|
||
|
||
/* GEGENPROBE: Eine Textdatei mit `content-type: image/png` wird
|
||
abgelehnt -- die Byteprüfung glaubt dem fremden Server nicht. */
|
||
const t = await einlesen(`Einladung: ${TIK}/t/textbanner`);
|
||
ok(t.code === 201, `eine Kampagne mit Textdatei als Banner entsteht trotzdem (HTTP ${t.code})`);
|
||
const datT = dbLesen("SELECT * FROM dateien WHERE eintrag_id = ?", t.j.id);
|
||
ok(datT.length === 0, `aber ohne Datei (${datT.length})`);
|
||
ok((t.j.hinweise || []).some((h) => h.includes("Banner")),
|
||
"und die Kachel sagt, dass das Banner fehlt");
|
||
|
||
/* DIE SCHRANKE GEGEN FREMDE ZIELE. */
|
||
ok(bildAdresseErlaubt("https://p16.tiktokcdn.com/x.png") === true
|
||
&& bildAdresseErlaubt("https://169.254.169.254/latest/") === false
|
||
&& bildAdresseErlaubt("http://p16.tiktokcdn.com/x.png") === false,
|
||
"eine Banneradresse ins eigene Netz würde abgelehnt");
|
||
}
|
||
|
||
/* =====================================================================
|
||
8. DIESELBE KAMPAGNE NUR EINMAL
|
||
===================================================================== */
|
||
melde("");
|
||
melde("=== 8. Zwei Einladungen zur selben Kampagne ergeben EINE Kachel ===");
|
||
|
||
{
|
||
const vorher = dbLesen("SELECT COUNT(*) AS n FROM eintraege WHERE kampagne_id = ?", ID_GIPFEL)[0].n;
|
||
const zweit = await einlesen(`Noch eine Einladung: ${TIK}/t/gipfel-zweiter-versand`);
|
||
ok(zweit.code === 200 && zweit.j.schon === true && zweit.j.id === kachelId,
|
||
`ein ZWEITER Kurzlink auf dieselbe Kampagne: HTTP ${zweit.code}, `
|
||
+ `dieselbe Kachel #${zweit.j.id}`);
|
||
const nachher = dbLesen("SELECT COUNT(*) AS n FROM eintraege WHERE kampagne_id = ?", ID_GIPFEL)[0].n;
|
||
ok(vorher === 1 && nachher === 1, `und es blieb bei ${nachher} Eintrag`);
|
||
|
||
/* WELCHE DER ZWEI SICHERUNGEN HAT GEGRIFFEN? (nachgetragen 07.10.2026)
|
||
Es gibt zwei: die Pruefung im Code (faengt den Normalfall) und den
|
||
eindeutigen Index (faengt den Wettlauf). Beide antworten gleich --
|
||
und genau daran ist eine Gegenprobe gescheitert: Nimmt man die
|
||
Pruefung im Code heraus, faengt der Index es auf, die Antwort ist
|
||
dieselbe, und die Pruefung blieb gruen.
|
||
Das war kein Schaden, aber ein blinder Fleck. Unterschieden wird
|
||
deshalb am Protokolltext: Der Index vermerkt "Wettlauf, Index".
|
||
Steht das im Normalfall da, fehlt die Pruefung im Code. */
|
||
const wie = dbLesen("SELECT detail FROM protokoll WHERE aktion = 'kampagne_schon_da'"
|
||
+ " ORDER BY id DESC LIMIT 1")[0];
|
||
ok(/-> Eintrag [0-9]+$/.test((wie?.detail || "").trim()),
|
||
`gefangen hat es die Pruefung im Code: „${wie?.detail}“`);
|
||
ok(!/Wettlauf/.test(wie?.detail || ""),
|
||
" nicht erst der Index -- sonst waere die Pruefung im Code wirkungslos");
|
||
|
||
/* GEGENPROBE: Eine ANDERE Kampagne entsteht sehr wohl. */
|
||
const andere = await einlesen(`Einladung: ${TIK}/t/beendet`);
|
||
ok(andere.code === 201 && andere.j.id !== kachelId,
|
||
`Gegenprobe: eine andere Kampagne wird angelegt (#${andere.j.id}, `
|
||
+ `„${andere.j.titel}“)`);
|
||
/* Und sie kommt aus der ZWEITEN Schemaquelle -- eine beendete
|
||
Kampagne liefert das Interaktionsschema nicht mehr. */
|
||
const az = dbLesen("SELECT kampagne_daten FROM eintraege WHERE id = ?", andere.j.id)[0];
|
||
ok(JSON.parse(az.kampagne_daten).gelesen_aus === "value.schema",
|
||
" gelesen aus value.schema, weil sie beendet ist");
|
||
|
||
/* DER EINDEUTIGE INDEX fängt den Wettlauf, den der Code nicht sieht. */
|
||
const idx = dbLesen("SELECT sql FROM sqlite_master WHERE name = 'idx_eintraege_kampagne'");
|
||
ok(idx.length === 1 && /UNIQUE/i.test(idx[0].sql) && /kampagne_id IS NOT NULL/i.test(idx[0].sql),
|
||
"und ein eindeutiger Index steht dahinter, nicht nur die Prüfung im Code");
|
||
}
|
||
|
||
/* =====================================================================
|
||
9. DREI VERSCHIEDENE, VERSTÄNDLICHE MELDUNGEN
|
||
===================================================================== */
|
||
melde("");
|
||
melde("=== 9. Was schiefgehen kann, wird verständlich gesagt ===");
|
||
|
||
{
|
||
const vorher = dbLesen("SELECT COUNT(*) AS n FROM eintraege")[0].n;
|
||
|
||
const ohneLink = await einlesen("Hier steht gar keine Adresse drin.");
|
||
ok(ohneLink.code === 400 && /keine TikTok-Adresse/i.test(ohneLink.j.fehler || ""),
|
||
`kein Link: HTTP ${ohneLink.code} — „${ohneLink.j.fehler}“`);
|
||
|
||
const fremd = await einlesen(`Klick: ${TIK}/t/fremd`);
|
||
ok(fremd.code === 400 && /beispiel\.de/.test(fremd.j.fehler || ""),
|
||
`fremdes Ziel: HTTP ${fremd.code} — „${fremd.j.fehler}“`);
|
||
|
||
const kaputt = await einlesen(`Klick: ${TIK}/t/kaputt`);
|
||
ok(kaputt.code === 502 && /__MODERN_ROUTER_DATA__/.test(kaputt.j.fehler || ""),
|
||
`umgebaute Seite: HTTP ${kaputt.code} — „${(kaputt.j.fehler || "").slice(0, 90)}…“`);
|
||
|
||
const ohneEnde = await einlesen(`Klick: ${TIK}/t/ohneende`);
|
||
ok(ohneEnde.code === 502 && /Ende/.test(ohneEnde.j.fehler || ""),
|
||
`ohne Enddatum: HTTP ${ohneEnde.code} — „${(ohneEnde.j.fehler || "").slice(0, 90)}…“`);
|
||
|
||
/* DIE DREI MELDUNGEN SIND WIRKLICH VERSCHIEDEN. Eine Prüfung, die
|
||
nur „es kam ein Fehler“ sagt, wäre auch grün, wenn überall
|
||
dasselbe stünde. */
|
||
const saetze = new Set([ohneLink.j.fehler, fremd.j.fehler,
|
||
kaputt.j.fehler, ohneEnde.j.fehler]);
|
||
ok(saetze.size === 4, `${saetze.size} verschiedene Sätze für vier Fälle`);
|
||
ok([...saetze].every((s) => s && !/\b50\d\b/.test(s) && s.length > 25),
|
||
"keiner davon sagt „Fehler 502“, alle sind ganze Sätze");
|
||
|
||
const nachher = dbLesen("SELECT COUNT(*) AS n FROM eintraege")[0].n;
|
||
ok(vorher === nachher,
|
||
`und keiner der vier hat eine halbe Kachel hinterlassen (${vorher} = ${nachher})`);
|
||
|
||
/* OHNE PREISE: 201 MIT HINWEIS, nicht 502. Unvollständig ist etwas
|
||
anderes als falsch. */
|
||
const op = await einlesen(`Klick: ${TIK}/t/ohnepreise`);
|
||
ok(op.code === 201 && (op.j.hinweise || []).some((h) => h.includes("Preise")),
|
||
`ohne Preise: HTTP ${op.code} mit Hinweis „${(op.j.hinweise || [])[0]}“`);
|
||
const opZ = dbLesen("SELECT event_regeln FROM eintraege WHERE id = ?", op.j.id)[0];
|
||
ok(/Bitte nachtragen/.test(opZ.event_regeln || ""),
|
||
"und die Kachel selbst sagt, dass etwas nachzutragen ist");
|
||
}
|
||
|
||
/* =====================================================================
|
||
10. EIN STUMMER SERVER HÄNGT DIE ROUTE NICHT AUF
|
||
===================================================================== */
|
||
melde("");
|
||
melde("=== 10. Die echte Frist am echten Weg ===");
|
||
|
||
{
|
||
const t0 = Date.now();
|
||
const a = await einlesen(`Klick: ${TIK}/t/stumm`);
|
||
const dauer = Date.now() - t0;
|
||
ok(a.code === 502 && /12 s/.test(a.j.fehler || ""),
|
||
`HTTP ${a.code} nach ${(dauer / 1000).toFixed(1)} s — „${a.j.fehler}“`);
|
||
ok(dauer >= 11_000 && dauer < 20_000,
|
||
`die Frist greift bei 12 s, nicht früher und nicht nie (${(dauer / 1000).toFixed(1)} s)`);
|
||
}
|
||
|
||
/* =====================================================================
|
||
11. WER DARF DAS
|
||
===================================================================== */
|
||
melde("");
|
||
melde("=== 11. Kampagnen anlegen dürfen DogFather und die Manager ===");
|
||
|
||
{
|
||
const creator = await einlesen(`Klick: ${TIK}/t/gipfel`, keksLuna);
|
||
ok(creator.code === 403, `ein Creator darf nicht (HTTP ${creator.code})`);
|
||
ok(/DogFather und die Manager/.test(creator.j.fehler || ""),
|
||
`und erfährt warum: „${creator.j.fehler}“`);
|
||
|
||
const ohne = await roh("/workspace/api/kampagne/einlesen", { rumpf: { text: "x" } });
|
||
ok(ohne.code === 401, `ohne Anmeldung gar nicht (HTTP ${ohne.code})`);
|
||
|
||
/* GEGENPROBE: Eine Managerin darf -- sonst bewiese die Zeile oben
|
||
nur, dass die Route überhaupt ablehnt. */
|
||
const mara = await einlesen(`Klick: ${TIK}/t/gipfel`, keksMara);
|
||
ok(mara.code === 200 && mara.j.schon === true,
|
||
`eine Managerin darf (HTTP ${mara.code}, Kampagne war schon da)`);
|
||
|
||
/* UND DIE OBERFLÄCHE FRAGT DIESELBE REGEL. Keine Rollenliste im
|
||
Browser -- am 10.09.2026 widersprachen sich zwei solche Listen
|
||
drei Zeilen auseinander. */
|
||
const ichDogi = JSON.parse((await roh("/workspace/api/ich", { keks: keksDogi })).text);
|
||
const ichLuna = JSON.parse((await roh("/workspace/api/ich", { keks: keksLuna })).text);
|
||
const ichMara = JSON.parse((await roh("/workspace/api/ich", { keks: keksMara })).text);
|
||
ok(ichDogi.darf_kampagne_einlesen === true && ichMara.darf_kampagne_einlesen === true
|
||
&& ichLuna.darf_kampagne_einlesen === false,
|
||
"/api/ich sagt der Oberfläche dasselbe wie die Schranke der Route");
|
||
|
||
/* UND AUF DER CREW-ADRESSE SAGT SIE NEIN. Das ist der Fall, den
|
||
es in der Wirklichkeit gibt: DogFather ist dort ebenfalls
|
||
`admin`, und ohne diese Antwort stuende der Kasten im Rudel. */
|
||
const ichCrew = JSON.parse((await roh("/workspace/api/ich",
|
||
{ keks: keksDogiCrew, wirt: CREW_HOST })).text);
|
||
ok(ichCrew.rolle === "admin" && ichCrew.darf_kampagne_einlesen === false,
|
||
`auf der Crew-Adresse sagt sie nein (Rolle ${ichCrew.rolle},`
|
||
+ ` darf ${ichCrew.darf_kampagne_einlesen})`);
|
||
|
||
/* FREMDE HERKUNFT. Die Wache steht in index.js, VOR diesem Router. */
|
||
const fremd = await new Promise((f, s) => {
|
||
const daten = Buffer.from(JSON.stringify({ text: "x" }));
|
||
const a = request({
|
||
host: "127.0.0.1", port: PORT, path: "/workspace/api/kampagne/einlesen",
|
||
method: "POST",
|
||
headers: { Host: HOST, Origin: "https://boeswillig.example", Cookie: keksDogi,
|
||
"Content-Type": "application/json", "Content-Length": daten.length },
|
||
}, (r) => { r.resume(); r.on("end", () => f({ code: r.statusCode })); });
|
||
a.on("error", s); a.write(daten); a.end();
|
||
});
|
||
ok(fremd.code === 403, `eine Anfrage von fremder Seite wird abgewiesen (HTTP ${fremd.code})`);
|
||
}
|
||
|
||
/* =====================================================================
|
||
12. DAS ANDERE HAUS
|
||
===================================================================== */
|
||
melde("");
|
||
melde("=== 12. Team Dogi bleibt unberührt ===");
|
||
|
||
{
|
||
const crewVorher = dbLesen("SELECT COUNT(*) AS n FROM eintraege WHERE haus = 'crew'")[0].n;
|
||
ok(crewVorher === 1,
|
||
`es gibt ${crewVorher} Crew-Eintrag -- gezählt wird über etwas, nicht über nichts`);
|
||
|
||
const alle = dbLesen("SELECT haus, COUNT(*) AS n FROM eintraege"
|
||
+ " WHERE kampagne_id IS NOT NULL GROUP BY haus");
|
||
ok(alle.length === 1 && alle[0].haus === "agentur",
|
||
`alle ${alle[0]?.n} Kampagnen liegen im Haus „${alle[0]?.haus}“`);
|
||
|
||
/* VON DER CREW-ADRESSE AUS: nicht sichtbar. */
|
||
const crewBrett = await roh("/workspace/api/bereich/agentur",
|
||
{ keks: keksDogiCrew, wirt: CREW_HOST });
|
||
const sichtbar = JSON.parse(crewBrett.text || "{}").eintraege || [];
|
||
ok(!sichtbar.some((e) => e.id === kachelId),
|
||
`die Kampagne erscheint auf der Crew-Adresse NICHT (${sichtbar.length} Einträge dort)`);
|
||
|
||
/* GEGENPROBE: Auf der Agentur-Adresse sehr wohl. */
|
||
const wsBrett = JSON.parse((await roh("/workspace/api/bereich/agentur", { keks: keksDogi })).text);
|
||
ok((wsBrett.eintraege || []).some((e) => e.id === kachelId),
|
||
"Gegenprobe: auf der Agentur-Adresse schon");
|
||
|
||
/* UND VON DER CREW-ADRESSE AUS LAESST SIE SICH GAR NICHT ANLEGEN.
|
||
|
||
DogFather ist in beiden Haeusern `admin` -- die Rollenfrage
|
||
allein laesst ihn also durch. Ohne diese Schranke bekaeme die
|
||
Kampagne das Haus "crew", und die Trennung waere an genau der
|
||
Stelle unterlaufen, an der niemand nachsieht. */
|
||
const vonCrew = await einlesen(`Klick: ${TIK}/t/gipfel`, keksDogiCrew, CREW_HOST);
|
||
ok(vonCrew.code === 403,
|
||
`von der Crew-Adresse: HTTP ${vonCrew.code} — „${vonCrew.j.fehler}“`);
|
||
ok(/[Aa]gentur/.test(vonCrew.j.fehler || ""),
|
||
" und die Meldung sagt, wo es hingehoert");
|
||
|
||
const crewNachher = dbLesen("SELECT COUNT(*) AS n FROM eintraege WHERE haus = 'crew'")[0].n;
|
||
ok(crewVorher === crewNachher,
|
||
`und die Zahl der Crew-Einträge ist unverändert (${crewVorher} = ${crewNachher})`);
|
||
}
|
||
|
||
/* =====================================================================
|
||
13. DAS PROTOKOLL
|
||
===================================================================== */
|
||
melde("");
|
||
melde("=== 13. Jeder Versuch steht im Protokoll, nicht nur der Erfolg ===");
|
||
|
||
{
|
||
const p = dbLesen("SELECT aktion, COUNT(*) AS n FROM protokoll"
|
||
+ " WHERE aktion LIKE 'kampagne%' GROUP BY aktion ORDER BY aktion");
|
||
const nach = Object.fromEntries(p.map((z) => [z.aktion, z.n]));
|
||
ok((nach.kampagne_eingelesen || 0) >= 3,
|
||
`${nach.kampagne_eingelesen || 0} erfolgreiche Einlesevorgänge`);
|
||
ok((nach.kampagne_gescheitert || 0) >= 5,
|
||
`${nach.kampagne_gescheitert || 0} gescheiterte -- genau die, die sonst spurlos wären`);
|
||
ok((nach.kampagne_schon_da || 0) >= 2,
|
||
`${nach.kampagne_schon_da || 0}× „war schon da“`);
|
||
|
||
const einer = dbLesen("SELECT detail FROM protokoll WHERE aktion = 'kampagne_gescheitert'"
|
||
+ " ORDER BY id DESC LIMIT 1")[0];
|
||
ok((einer?.detail || "").length > 5,
|
||
`und der Grund steht dabei: „${(einer?.detail || "").slice(0, 70)}“`);
|
||
}
|
||
|
||
/* =====================================================================
|
||
14. DIE BREMSE
|
||
===================================================================== */
|
||
melde("");
|
||
melde("=== 14. Zwanzig in einer Stunde, dann ist Schluss ===");
|
||
|
||
{
|
||
/* EINE EIGENE PERSON dafür: Die Bremse zählt je Person, und die
|
||
Versuche der Abschnitte darüber gehören DogFather. */
|
||
const { KAMPAGNEN_JE_STUNDE } = await import("./workspace-kampagne.js");
|
||
ok(KAMPAGNEN_JE_STUNDE === 20, `die Grenze steht bei ${KAMPAGNEN_JE_STUNDE}`);
|
||
|
||
let letzterGute = null;
|
||
for (let i = 1; i <= KAMPAGNEN_JE_STUNDE; i++) {
|
||
letzterGute = await einlesen(`Klick: ${TIK}/t/gipfel`, keksBrem);
|
||
if (letzterGute.code === 429) break;
|
||
}
|
||
ok(letzterGute && letzterGute.code !== 429,
|
||
`der ${KAMPAGNEN_JE_STUNDE}. Versuch geht noch durch (HTTP ${letzterGute?.code})`);
|
||
|
||
const zuviel = await einlesen(`Klick: ${TIK}/t/gipfel`, keksBrem);
|
||
ok(zuviel.code === 429, `der ${KAMPAGNEN_JE_STUNDE + 1}. wird gebremst (HTTP ${zuviel.code})`);
|
||
ok(/Minute/.test(zuviel.j.fehler || ""),
|
||
`und sagt, wann es weitergeht: „${zuviel.j.fehler}“`);
|
||
|
||
/* GEGENPROBE: Eine andere Person ist NICHT gebremst. Die Bremse ist
|
||
persönlich, keine Sperre für alle. */
|
||
const andere = await einlesen(`Klick: ${TIK}/t/gipfel`, keksMara);
|
||
ok(andere.code !== 429, `eine andere Person darf weiter (HTTP ${andere.code})`);
|
||
|
||
/* UND DIE BREMSE VERLÄNGERT SICH NICHT SELBST: Ihre eigene Meldung
|
||
zählt nicht mit, sonst käme man nie wieder heraus. */
|
||
const bremsZaehler = dbLesen("SELECT COUNT(*) AS n FROM protokoll"
|
||
+ " WHERE aktion = 'kampagne_bremse'")[0].n;
|
||
ok(bremsZaehler >= 1, `${bremsZaehler} Bremsvorgänge stehen im Protokoll`);
|
||
const gezaehlt = dbLesen("SELECT COUNT(*) AS n FROM protokoll"
|
||
+ " WHERE aktion IN ('kampagne_eingelesen','kampagne_gescheitert','kampagne_schon_da')"
|
||
+ " AND person_id = (SELECT id FROM personen WHERE name = 'Bremser')")[0].n;
|
||
ok(gezaehlt === KAMPAGNEN_JE_STUNDE,
|
||
`gezählt werden genau ${gezaehlt} von ${KAMPAGNEN_JE_STUNDE} Versuchen`
|
||
+ " -- die Bremsmeldungen selbst nicht");
|
||
}
|
||
|
||
/* =====================================================================
|
||
SCHLUSS
|
||
===================================================================== */
|
||
melde("");
|
||
melde(`${abrufe} Abrufe beim nachgebauten Dienst, keiner im echten Netz.`);
|
||
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
|
||
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
|
||
|
||
tikDienst.close();
|
||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Rest */ }
|
||
process.exit(fehler ? 1 : 0);
|