/* ===================================================================== Dateifassungen (15.09.2026) Stufe 9: „Dateiversionen statt Überschreiben". --------------------------------------------------------------------- GEMESSEN, BEVOR ETWAS GEBAUT WURDE Der Plan sagt „statt Überschreiben" — überschrieben wurde aber nie: `name_datei` ist eindeutig und zufällig, jedes Hochladen legt eine neue Zeile an. Der wirkliche Mangel ist ein anderer, und er ist gefährlicher: Zwei Dateien gleichen Namens standen BEZIEHUNGSLOS nebeneinander. Die alte kann „freigegeben" sein, die neue „entwurf" — und wer die Liste ansieht, lädt die freigegebene herunter. Also die falsche. Kein Fehler, keine Meldung, nur die alte Fassung in der Hand. --------------------------------------------------------------------- DIE DREI FRAGEN 1. Wird die richtige Vorgängerin erkannt? 2. Wird bei Zweifel NICHT geraten? ← die wichtigere Hälfte 3. Sieht man der alten Fassung an, dass sie alt ist? Die zweite ist der Grund, warum die Bedingung eng ist: Eine Automatik, die falsch verknüpft, erfindet einen Zusammenhang zwischen zwei Dateien, die nichts miteinander zu tun haben. Aufrufen mit: node server/pruef-fassungen.mjs ===================================================================== */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { request as httpAnfrage } from "node:http"; import { eigenerPort } from "./helfer-port.mjs"; const PORT = await eigenerPort(import.meta, "pruef-fassungen"); const ordner = mkdtempSync(join(tmpdir(), "ws-fass-")); 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"; 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-fassungen"); await new Promise((r) => setTimeout(r, 700)); process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); let fehler = 0, geprueft = 0; const melde = (t) => console.log(t); const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; const HAUS = "workspace.dogfather-universe.com"; const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); let idDogi, idLuna, idMax; { const d = oeffne(); const schluessel = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", schluessel, jetzt); const 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, code_kennung, aktiv, erstellt)" + " VALUES (?,?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); return d.prepare("SELECT last_insert_rowid() AS id").get().id; }; idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); idMax = anlegen("Max", "creator", "CODE-CREA-0002"); d.close(); } function ruf(pfad, methode, kopf = {}, koerper = null) { return new Promise((fertig, schief) => { const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: methode, headers: { Host: HAUS, ...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(koerper); a.end(); }); } const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } }; const anm = await ruf("/workspace/api/anmelden", "POST", { "Content-Type": "application/json" }, JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" })); const kDogi = [].concat(anm.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "); ok(!!kDogi, "DogFather ist angemeldet"); /** Laedt eine Datei hoch und gibt die Antwort zurueck. */ async function hochladen(name, inhalt, creatorId) { return ruf("/workspace/api/dateien", "POST", { cookie: kDogi, "content-type": "text/plain", "x-dateiname": encodeURIComponent(name), ...(creatorId ? { "x-creator": String(creatorId) } : {}), "content-length": Buffer.byteLength(inhalt), }, inhalt); } const liste = async () => json(await ruf("/workspace/api/dateien", "GET", { cookie: kDogi })).dateien || []; /* Erst nachsehen, wie der Weg die Angaben erwartet -- eine Pruefung, die am Hochladen scheitert, prueft nichts. */ { const probe = await hochladen("probe.txt", "eins", idLuna); ok(probe.code === 201, `eine Datei laesst sich hochladen (${probe.code}) ${probe.code === 201 ? "" : probe.text.slice(0, 120)}`); } /* ======================================================================= 1. DIE ZWEITE FASSUNG WIRD ERKANNT ======================================================================= */ melde(""); melde("=== 1. Die zweite Fassung ==="); { const eins = await hochladen("vertrag.pdf", "Fassung eins", idLuna); ok(eins.code === 201, `erste Fassung hochgeladen (${eins.code})`); ok(json(eins).ersetzt_id === null, "sie ersetzt nichts — es gab noch nichts"); const zwei = await hochladen("vertrag.pdf", "Fassung zwei", idLuna); ok(zwei.code === 201, `zweite Fassung hochgeladen (${zwei.code})`); ok(json(zwei).ersetzt_id === json(eins).id, `und sie zeigt auf die erste (${json(zwei).ersetzt_id} → ${json(eins).id})`); const drei = await hochladen("vertrag.pdf", "Fassung drei", idLuna); ok(json(drei).ersetzt_id === json(zwei).id, "die dritte auf die zweite — eine Kette, kein Stern"); const alle = await liste(); const d3 = alle.find((x) => x.id === json(drei).id); const d2 = alle.find((x) => x.id === json(zwei).id); const d1 = alle.find((x) => x.id === json(eins).id); ok(d3?.fassung === 3, `die neueste ist Fassung ${d3?.fassung}`); ok(d2?.fassung === 2 && d1?.fassung === 1, `die beiden davor sind ${d2?.fassung} und ${d1?.fassung}`); ok(d1?.ersetzt_durch === d2?.id && d2?.ersetzt_durch === d3?.id, "jede weiss, wer sie ersetzt hat"); /* DAS IST DER GRUND FUER DAS GANZE: Der alten sieht man an, dass sie alt ist. */ ok(d3?.ersetzt_durch === null, "und die neueste wird von niemandem ersetzt"); } /* ======================================================================= 2. BEI ZWEIFEL WIRD NICHT GERATEN Die wichtigere Haelfte. Eine Automatik, die falsch verknuepft, erfindet einen Zusammenhang zwischen zwei Dateien, die nichts miteinander zu tun haben. ======================================================================= */ melde(""); melde("=== 2. Nicht geraten ==="); { /* Anderer Creator, gleicher Name -- das ist NICHT dieselbe Datei. */ const fremd = await hochladen("vertrag.pdf", "Vertrag von Max", idMax); ok(fremd.code === 201, `dieselbe Benennung bei einem anderen Creator (${fremd.code})`); ok(json(fremd).ersetzt_id === null, `wird nicht verknuepft (${json(fremd).ersetzt_id}) — es ist Max' Vertrag, nicht Lunas`); /* Anderer Name, gleicher Creator -- auch nicht. */ const anders = await hochladen("konzept.pdf", "Etwas anderes", idLuna); ok(json(anders).ersetzt_id === null, "und ein anderer Name erst recht nicht"); /* MEHRDEUTIG: Gibt es zwei unersetzte Dateien gleichen Namens, wird GAR NICHT verknuepft. Hergestellt wird der Fall ueber den Weg, den es dafuer gibt -- die Verknuepfung loesen. */ const a = await hochladen("doppelt.txt", "A", idLuna); const b = await hochladen("doppelt.txt", "B", idLuna); ok(json(b).ersetzt_id === json(a).id, "zwei gleiche Namen ergeben zunaechst eine Kette"); const geloest = await ruf(`/workspace/api/dateien/${json(b).id}/fassung`, "DELETE", { cookie: kDogi }); ok(geloest.code === 200, `die Verknuepfung laesst sich loesen (${geloest.code})`); const c = await hochladen("doppelt.txt", "C", idLuna); ok(json(c).ersetzt_id === null, `jetzt gibt es zwei Unersetzte gleichen Namens — und es wird NICHT geraten (${json(c).ersetzt_id})`); } /* ======================================================================= 3. DIE VERKNUEPFUNG LOESEN -- UND NUR SIE ======================================================================= */ melde(""); melde("=== 3. Loesen ==="); { const eins = await hochladen("loesen.txt", "eins", idLuna); const zwei = await hochladen("loesen.txt", "zwei", idLuna); ok(json(zwei).ersetzt_id === json(eins).id, "verknuepft"); const vorher = (await liste()).length; const a = await ruf(`/workspace/api/dateien/${json(zwei).id}/fassung`, "DELETE", { cookie: kDogi }); ok(a.code === 200, `geloest (${a.code})`); const nachher = await liste(); ok(nachher.length === vorher, `KEINE Datei ist dabei verschwunden (${vorher} → ${nachher.length})`); const d2 = nachher.find((x) => x.id === json(zwei).id); const d1 = nachher.find((x) => x.id === json(eins).id); ok(d2?.fassung === 1 && d2?.ersetzt_id === null, "die zweite steht wieder fuer sich"); ok(d1?.ersetzt_durch === null, "und die erste gilt nicht mehr als ueberholt"); /* Ein zweites Loesen ist kein Fehler des Nutzers, aber auch kein Erfolg -- es gibt nichts mehr zu loesen. */ const nochmal = await ruf(`/workspace/api/dateien/${json(zwei).id}/fassung`, "DELETE", { cookie: kDogi }); ok(nochmal.code === 409, `ein zweites Loesen sagt "da war nichts" (${nochmal.code})`); const erfunden = await ruf("/workspace/api/dateien/999999/fassung", "DELETE", { cookie: kDogi }); ok(erfunden.code === 404, `eine erfundene Nummer ergibt 404 (${erfunden.code})`); const ohne = await ruf(`/workspace/api/dateien/${json(eins).id}/fassung`, "DELETE"); ok(ohne.code === 401 || ohne.code === 404, `ohne Anmeldung geht es nicht (${ohne.code})`); } /* ======================================================================= 4. WIRD DIE ALTE FASSUNG GELOESCHT, BLEIBT DIE NEUE ======================================================================= */ melde(""); melde("=== 4. Wenn die alte verschwindet ==="); { const eins = await hochladen("weg.txt", "eins", idLuna); const zwei = await hochladen("weg.txt", "zwei", idLuna); ok(json(zwei).ersetzt_id === json(eins).id, "verknuepft"); const d = oeffne(); d.prepare("PRAGMA foreign_keys = ON").run(); d.prepare("DELETE FROM dateien WHERE id = ?").run(json(eins).id); const uebrig = d.prepare("SELECT id, ersetzt_id FROM dateien WHERE id = ?").get(json(zwei).id); d.close(); ok(!!uebrig, "die neue Fassung gibt es noch, obwohl die alte weg ist"); ok(uebrig.ersetzt_id === null, `und der Verweis steht auf null statt ins Leere (${uebrig.ersetzt_id})`); const nach = (await liste()).find((x) => x.id === json(zwei).id); ok(nach?.fassung === 1, `sie zaehlt jetzt wieder als erste Fassung (${nach?.fassung}) — die Nummer ist gerechnet, nicht gespeichert`); } /* ======================================================================= 5. EIN RING WUERDE DIE SEITE NICHT EINFRIEREN Kann durch die Wege oben nicht entstehen -- aber ein Fehler anderswo koennte ihn erzeugen, und ein Stillstand ist schlimmer als ein Fehler. Deshalb eine Schranke in der Zaehlung, und deshalb hier der Beweis, dass sie greift. ======================================================================= */ melde(""); melde("=== 5. Ein Ring haelt die Seite nicht an ==="); { const a = await hochladen("ring.txt", "a", idLuna); const b = await hochladen("ring.txt", "b", idLuna); const d = oeffne(); d.prepare("UPDATE dateien SET ersetzt_id = ? WHERE id = ?").run(json(b).id, json(a).id); d.close(); const beginn = Date.now(); const alle = await liste(); const dauer = Date.now() - beginn; ok(alle.length > 0, `die Liste kommt trotzdem (${alle.length} Dateien)`); ok(dauer < 5000, `und zwar in ${dauer} ms statt nie`); /* Aufraeumen, damit die naechste Zeile nicht an diesem Ring haengt. */ const dd = oeffne(); dd.prepare("UPDATE dateien SET ersetzt_id = NULL WHERE id = ?").run(json(a).id); dd.close(); } /* ======================================================================= 6. IM BROWSER ======================================================================= */ melde(""); melde("=== 6. Im Browser ==="); { const { chromium } = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await chromium.launch(); const kontext = await browser.newContext({ viewport: { width: 1280, height: 900 } }); const seite = await kontext.newPage(); const konsole = []; seite.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); }); seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); await seite.goto(`http://127.0.0.1:${PORT}/workspace/`, { waitUntil: "domcontentloaded" }); await seite.click('.rolle[data-rolle="admin"]'); await seite.fill("#code", "CODE-DOGI-0001"); await seite.click("button[type=submit]"); await seite.waitForURL("**/start.html", { timeout: 20000 }); await seite.goto(`http://127.0.0.1:${PORT}/workspace/dateien.html`, { waitUntil: "networkidle" }); await seite.waitForTimeout(900); const stand = await seite.evaluate(() => ({ nummern: document.querySelectorAll(".fassung__nummer").length, alt: document.querySelectorAll(".fassung__alt").length, loesen: document.querySelectorAll(".fassung__loesen").length, text: [...document.querySelectorAll(".fassung")].map((f) => f.innerText.replace(/\s+/g, " ").trim()).slice(0, 4), })); ok(stand.nummern >= 2, `${stand.nummern} Dateien tragen eine Fassungsnummer`); ok(stand.alt >= 1, `${stand.alt} sind als „nicht mehr aktuell“ gekennzeichnet`); ok(stand.loesen >= 1, `${stand.loesen} lassen sich wieder lösen`); ok(stand.text.some((t) => /Fassung \d/.test(t)), `die Beschriftung steht da: ${stand.text.join(" | ").slice(0, 110)}`); /* GEGENPROBE: Der Loese-Knopf steht NUR an aktuellen Fassungen. Wer eine ueberholte loest, macht daraus eine zweite aktuelle. */ const falsch = await seite.evaluate(() => [...document.querySelectorAll(".fassung")] .filter((f) => f.querySelector(".fassung__alt") && f.querySelector(".fassung__loesen")).length); ok(falsch === 0, `kein Löse-Knopf an einer überholten Fassung (${falsch})`); ok(konsole.length === 0, konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 90) : "keine Fehler im Browser"); await browser.close(); } /* ------------------------------------------------------------------- */ melde(""); melde(`${geprueft} Pruefungen, ${fehler} Fehler`); melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } process.exit(fehler ? 1 : 0);