Gemessen: 165 Pruefdateien, 129 verschiedene Nummern -- NEUNZEHN
doppelt, vier davon dreifach. Niemand hatte das gewollt; jede neue
Pruefung wurde von einer vorhandenen abgeschrieben, und die Nummer kam
mit. Meine eigene Notiz sagte "sieben" -- auch eine Bestandsliste
altert.
Der Waechter faengt den Schaden ab, aber er kann nur melden, was schon
passiert ist: Zwei Pruefungen mit derselben Nummer koennen nie
gleichzeitig laufen, und ein liegengebliebener Prozess der einen laesst
die andere abbrechen mit einer Meldung, die wie ein Befund aussieht.
Genau das ist mir am 20.09. zweimal passiert.
Jetzt leitet jede Datei ihre Nummer aus ihrer STELLE IM ALPHABET ab
(eigenerPort in helfer-port.mjs), zwei je Datei. Nicht ueber eine
Pruefsumme: Bei 165 Namen in 4900 Nummern waeren nach dem
Geburtstagsproblem rund DREI Zusammenstoesse zu erwarten -- ein Hash
tauscht eine sichtbare Doppelung gegen eine unsichtbare. Die Stelle im
Alphabet ist eindeutig von der Bauart her.
--- ZWEI FEHLER AUF DEM WEG, BEIDE LEHRREICH -------------------------
1. DER ERSTE VERSUCH WAR GRUEN UND KAPUTT. Ersetzt wurde mit einem
Muster: "([^"]*4231[^"]*)". Das hielt
{ host: "127.0.0.1", port: 4231, path: "/404.html" }
fuer eine Zeichenkette -- ein Muster kann eine oeffnende nicht von
einer schliessenden Anfuehrung unterscheiden. Heraus kam
{ host: "127.0.0.1`, port: ${PORT}, path: `/404.html" }
also GUELTIGER Code ohne port-Feld. `node --check` sagte gruen fuer
alle 164 Dateien. Aufgefallen ist es erst, weil ich vier Vertreter
gegen eine vorher gemessene Grundlinie laufen liess: pruef-schranke
39/0 vorher, 38/1 nachher.
Alles zurueckgenommen und mit einem Zerleger neu gemacht, der weiss,
ob eine Stelle Code, Zeichenkette, Vorlage, Kommentar oder
regulaerer Ausdruck ist. In pruef-ics stand die Nummer in einem
regulaeren Ausdruck -- der wird jetzt gebaut statt hingeschrieben.
2. EIN MODUL, DAS BEIM IMPORTIEREN ARBEITET, IST EINE FALLE. Der
zweite Durchgang importierte den ersten, um seine Mechanik zu
benutzen -- und fuehrte dessen Hauptlauf gleich mit aus. Die
zweiten Nummern wurden dadurch als erste behandelt, zwei Aufrufe
bekamen dieselbe Nummer, und in pruef-content stand `const PORT`
zweimal.
--- WAS DAS DAUERHAFT HAELT -----------------------------------------
pruef-portnummern.mjs (neu, 8 Pruefungen) fragt nicht "welche Nummern
sind doppelt", sondern "wer traegt ueberhaupt noch eine von Hand ein"
und "wer startet einen Server, ohne seine Nummer abzuleiten". Die
zweite Frage hat sofort etwas gefunden, das in KEINER Doppelungsliste
stand: pruef-push-weg belegte 4341 und 4342, rief den Waechter aber
gar nicht auf -- dieselbe Nummer wie pruef-agentur. Eine Liste zeigt
nur, was auf ihr steht.
Mit Gegenprobe: Eine unbekannte Datei bekommt keine geratene Nummer,
sondern einen Abbruch, und eine dritte Nummer je Datei gibt es nicht.
--- NACHGEMESSEN ----------------------------------------------------
Sechzehn Pruefungen gegen ihre vorher gemessene Grundlinie, je eine
Vertreterin jeder umgestellten Bauweise (eine Nummer, zwei Nummern,
Nummer in einer Zeichenkette, in einer Vorlage, in einem regulaeren
Ausdruck, dynamische Einfuhr, Nachtlauf mit zwei Laeufen):
crew-adresse 132/0 · schranke 39/0 · content 45/0 · entwicklung 46/0
anruf 127/0 · ics 37/0 · push-weg 20/0 · arten 28/0 · video 67/0
kanalzeile 14/0 · agentur 62/0 · spicy 83/0 · creator-anlegen 50/0
push-ziel 10/0 · portnummern 8/0
Alle exakt wie vorher. Zwei waren schon vorher rot und sind es
unveraendert geblieben (crew-wand-bild 41/4, teilen 14/3) -- per
`git stash` belegt, nicht angenommen.
Co-Authored-By: Claude Opus 5 <[email protected]>
406 lines
20 KiB
JavaScript
406 lines
20 KiB
JavaScript
/* AUFGABEN ABBRECHEN — Umstellung, Rechte und Rückweg.
|
|
|
|
Wunsch Filipe, 05.09.2026: *"ich will dass man in jedem status die
|
|
aufgaben auch abbrechen kann. nur ich die manager und scouts sollen
|
|
auch die aufgaben abbrechen können."*
|
|
|
|
ZWEI TEILE, und der erste ist der gefährlichere:
|
|
|
|
1. DIE UMSTELLUNG DER TABELLE. Der CHECK-Constraint einer Tabelle
|
|
lässt sich in SQLite nicht ändern -- die Tabelle muss neu gebaut
|
|
werden: anlegen, kopieren, alte löschen, umbenennen. Zwischen
|
|
"kopieren" und "löschen" steht der ganze Bestand an Aufgaben.
|
|
Hausregel dazu: *"Was sich nicht zurücknehmen lässt, wird vorher
|
|
auf einer Kopie durchgespielt."* Genau das passiert hier -- mit
|
|
einer Datenbank im ALTEN Zustand, gefüllt, und danach wird
|
|
gezählt.
|
|
|
|
2. DIE RECHTE. Ein Creator darf nicht abbrechen, auch nicht seine
|
|
eigene Aufgabe. Das ist der Punkt, an dem so etwas gern
|
|
durchrutscht: Der normale PATCH lässt ihn ändern, und stünde
|
|
"abgebrochen" einfach in der Statusliste, könnte er es darüber.
|
|
|
|
GEGENPROBEN durchgehend: Jede Sperre wird auch von der erlaubten
|
|
Seite versucht -- eine Prüfung, die immer "verboten" sagt, beweist
|
|
nichts. */
|
|
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
const ordner = mkdtempSync(join(tmpdir(), "ws-abbruch-"));
|
|
|
|
let fehler = 0;
|
|
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
|
|
|
/* =======================================================================
|
|
TEIL 1 — Die Umstellung, auf einer Kopie im alten Zustand
|
|
======================================================================= */
|
|
console.log("\n=== Umstellung der Aufgabentabelle (auf einer Kopie) ===");
|
|
|
|
const { DatabaseSync } = await import("node:sqlite");
|
|
const altPfad = join(ordner, "alt.db");
|
|
{
|
|
/* Eine Datenbank genau so, wie sie VOR der Umstellung aussah --
|
|
mit dem alten CHECK ohne 'abgebrochen'. */
|
|
const a = new DatabaseSync(altPfad);
|
|
a.exec(`
|
|
PRAGMA foreign_keys = ON;
|
|
CREATE TABLE personen (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL,
|
|
rolle TEXT NOT NULL CHECK (rolle IN ('admin','manager','scout','creator')),
|
|
code_hash TEXT NOT NULL, code_salt TEXT NOT NULL, code_n INTEGER NOT NULL,
|
|
aktiv INTEGER NOT NULL DEFAULT 1, erstellt TEXT NOT NULL, letzter_login TEXT);
|
|
CREATE TABLE aufgaben (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
titel TEXT NOT NULL, beschreibung TEXT,
|
|
status TEXT NOT NULL DEFAULT 'offen'
|
|
CHECK (status IN ('offen','arbeit','review','erledigt')),
|
|
prioritaet TEXT NOT NULL DEFAULT 'mittel'
|
|
CHECK (prioritaet IN ('hoch','mittel','niedrig')),
|
|
creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
verantwortlich_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
frist TEXT, erstellt TEXT NOT NULL,
|
|
erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
|
|
geaendert TEXT, erledigt_am TEXT);
|
|
`);
|
|
const jetzt = new Date().toISOString();
|
|
/* Diese Person ist nur das Ziel der Fremdschlüssel. Ihre
|
|
scrypt-Parameter müssen trotzdem gültig sein: Mit code_n = 1 wirft
|
|
scrypt "Invalid scrypt params" -- und genau das hat beim ersten
|
|
Lauf dieser Prüfung JEDE Anmeldung als admin unmöglich gemacht.
|
|
Der Fehler steckte in dieser Zeile, aber er hat einen echten
|
|
Mangel im Server aufgedeckt (siehe unten, Abschnitt
|
|
"Ein defekter Datensatz"). */
|
|
a.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, erstellt) VALUES (?,?,?,?,?,?)")
|
|
.run("Platzhalter", "creator", "x", "y", 16384, jetzt);
|
|
/* Genug Zeilen, dass ein Verlust auffiele -- und in jedem Status,
|
|
damit auch der Übergang jedes einzelnen geprüft ist. */
|
|
for (let i = 0; i < 40; i++) {
|
|
a.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, creator_id,
|
|
verantwortlich_id, frist, erstellt, erstellt_von, geaendert, erledigt_am)
|
|
VALUES (?,?,?,?,1,1,?,?,1,?,?)`)
|
|
.run(`Aufgabe ${i}`, `Text ${i}`, ["offen", "arbeit", "review", "erledigt"][i % 4],
|
|
["hoch", "mittel", "niedrig"][i % 3], "2026-09-30", jetzt, jetzt,
|
|
i % 4 === 3 ? jetzt : null);
|
|
}
|
|
a.close();
|
|
}
|
|
|
|
/* Der Server wird jetzt AUF DIESER Datei gestartet -- die Umstellung
|
|
läuft beim Import. */
|
|
process.env.WORKSPACE_DB = altPfad;
|
|
const { eigenerPort } = await import("./helfer-port.mjs");
|
|
const PORT = await eigenerPort(import.meta, "die Abbruchpruefung");
|
|
process.env.PORT = `${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");
|
|
await new Promise((r) => setTimeout(r, 1400));
|
|
const BASIS = `http://127.0.0.1:${PORT}`;
|
|
|
|
{
|
|
const p = new DatabaseSync(altPfad);
|
|
const plan = p.prepare(
|
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='aufgaben'").get()?.sql || "";
|
|
ok(plan.includes("'abgebrochen'"), "der CHECK erlaubt jetzt 'abgebrochen'");
|
|
|
|
/* DIE ANZAHL IST DER EIGENTLICHE BEWEIS. Ein Tabellentausch, der
|
|
Zeilen verliert, sieht ohne diese Zahl aus wie ein Erfolg. */
|
|
const n = p.prepare("SELECT COUNT(*) AS n FROM aufgaben").get().n;
|
|
ok(n === 40, `alle ${n} von 40 Aufgaben sind noch da`);
|
|
|
|
/* Und ihr INHALT, nicht nur ihre Zahl. Eine vergessene Spalte in der
|
|
INSERT...SELECT-Liste füllt sie still mit NULL. */
|
|
const eine = p.prepare("SELECT * FROM aufgaben WHERE titel = 'Aufgabe 7'").get();
|
|
ok(eine?.beschreibung === "Text 7" && eine?.status === "erledigt"
|
|
&& eine?.prioritaet === "mittel" && eine?.frist === "2026-09-30"
|
|
&& eine?.erledigt_am, `Inhalte erhalten (Text, Status, Priorität, Frist, erledigt_am)`);
|
|
|
|
const spalten = p.prepare("PRAGMA table_info(aufgaben)").all().map((s) => s.name);
|
|
const noetig = ["abbruch_grund", "abgebrochen_am", "abbruch_von", "status_vorher"];
|
|
const fehlend = noetig.filter((s) => !spalten.includes(s));
|
|
ok(fehlend.length === 0, fehlend.length
|
|
? `es fehlen: ${fehlend.join(", ")}` : `alle vier Abbruchspalten sind da`);
|
|
|
|
/* Verweise: Der Tausch lief mit ausgeschalteten Fremdschlüsseln. */
|
|
const kaputt = p.prepare("PRAGMA foreign_key_check").all();
|
|
ok(kaputt.length === 0, `${kaputt.length} verwaiste Verweise nach dem Tausch`);
|
|
|
|
/* Und die Indizes -- sie hängen an der Tabelle und wären beim DROP
|
|
mitgegangen. Ohne sie liest jede Brettabfrage die ganze Tabelle. */
|
|
const idx = p.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='aufgaben'").all()
|
|
.map((z) => z.name);
|
|
ok(idx.includes("idx_aufgaben_status") && idx.includes("idx_aufgaben_creator"),
|
|
`Indizes wieder da: ${idx.filter((x) => x.startsWith("idx_")).join(", ")}`);
|
|
|
|
/* GEGENPROBE: Der alte CHECK hätte 'abgebrochen' abgelehnt. Lässt
|
|
die Tabelle jetzt ALLES zu, wäre der CHECK verlorengegangen -- das
|
|
wäre schlimmer als der alte Zustand. */
|
|
let unsinnGing = false;
|
|
try {
|
|
p.prepare("UPDATE aufgaben SET status = 'quatsch' WHERE id = 1").run();
|
|
unsinnGing = true;
|
|
} catch { unsinnGing = false; }
|
|
ok(!unsinnGing, "ein erfundener Status wird weiterhin abgelehnt — der CHECK lebt");
|
|
p.close();
|
|
}
|
|
|
|
/* =======================================================================
|
|
TEIL 2 — Rechte, am laufenden Server
|
|
======================================================================= */
|
|
const { scryptSync, randomBytes } = await import("node:crypto");
|
|
const d = new DatabaseSync(altPfad);
|
|
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("Dogi", "admin", "CODE-DOGI-0001");
|
|
const idMana = anlegen("Cigdem", "manager", "CODE-MANA-0001");
|
|
const idScout = anlegen("Patrick", "scout", "CODE-SCOU-0001");
|
|
const idCrea = anlegen("Luna", "creator", "CODE-CREA-0001");
|
|
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idCrea, idScout, jetzt);
|
|
d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idScout, idMana, jetzt);
|
|
|
|
/** Legt eine Aufgabe an und gibt ihre Nummer zurück. */
|
|
function aufgabe(status = "offen") {
|
|
d.prepare(`INSERT INTO aufgaben (titel, status, prioritaet, creator_id, verantwortlich_id,
|
|
erstellt, erstellt_von) VALUES (?,?,'hoch',?,?,?,?)`)
|
|
.run(`Probe ${status} ${Math.abs(Number(process.hrtime.bigint() % 100000n))}`,
|
|
status, idCrea, idCrea, jetzt, idDogi);
|
|
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
|
}
|
|
|
|
/** Meldet sich an und gibt die Kekse zurück. */
|
|
async function anmelden(rolle, code) {
|
|
const a = await fetch(BASIS + "/workspace/api/anmelden", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ rolle, code }) });
|
|
const keks = (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; ");
|
|
return { ok: a.ok, keks };
|
|
}
|
|
const rufe = (keks, weg, koerper, methode = "POST") =>
|
|
fetch(BASIS + weg, {
|
|
method: methode,
|
|
headers: { "Content-Type": "application/json", Cookie: keks },
|
|
body: koerper === undefined ? undefined : JSON.stringify(koerper),
|
|
});
|
|
|
|
const dogi = await anmelden("admin", "CODE-DOGI-0001");
|
|
const mana = await anmelden("manager", "CODE-MANA-0001");
|
|
const scout = await anmelden("scout", "CODE-SCOU-0001");
|
|
const crea = await anmelden("creator", "CODE-CREA-0001");
|
|
/* Ohne diese Prüfung liefe alles Folgende gegen nicht angemeldete
|
|
Aufrufe -- und "403" käme dann aus dem falschen Grund. */
|
|
ok([dogi, mana, scout, crea].every((x) => x.ok && x.keks),
|
|
"alle vier Rollen sind angemeldet");
|
|
|
|
console.log("\n=== Wer darf abbrechen? ===");
|
|
{
|
|
for (const [name, wer, sollGehen] of [
|
|
["DogFather", dogi, true], ["Manager", mana, true],
|
|
["Scout", scout, true], ["Creator", crea, false],
|
|
]) {
|
|
const id = aufgabe("offen");
|
|
const a = await rufe(wer.keks, `/workspace/api/aufgaben/${id}/abbrechen`,
|
|
{ grund: "Kampagne verschoben." });
|
|
const stand = d.prepare("SELECT status FROM aufgaben WHERE id = ?").get(id)?.status;
|
|
ok(a.ok === sollGehen && (stand === "abgebrochen") === sollGehen,
|
|
`${name}: ${a.status} → Status "${stand}" (${sollGehen ? "darf" : "darf NICHT"})`);
|
|
}
|
|
}
|
|
|
|
console.log("\n=== Aus JEDEM Status heraus ===");
|
|
{
|
|
for (const status of ["offen", "arbeit", "review", "erledigt"]) {
|
|
const id = aufgabe(status);
|
|
const a = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/abbrechen`,
|
|
{ grund: `Aus ${status} heraus abgebrochen.` });
|
|
const z = d.prepare(
|
|
"SELECT status, status_vorher, abbruch_grund, abbruch_von, erledigt_am FROM aufgaben WHERE id = ?").get(id);
|
|
ok(a.ok && z.status === "abgebrochen" && z.status_vorher === status
|
|
&& z.abbruch_grund && z.abbruch_von === idDogi,
|
|
`aus "${status}": abgebrochen, status_vorher="${z.status_vorher}", Grund und Person festgehalten`);
|
|
/* Ein abgebrochenes "erledigt" darf kein Erledigt-Datum behalten --
|
|
sonst zählt es in Auswertungen weiter als geschafft. */
|
|
if (status === "erledigt") {
|
|
ok(z.erledigt_am === null, "beim Abbrechen eines erledigten wird erledigt_am geleert");
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log("\n=== Der Grund ist Pflicht ===");
|
|
{
|
|
const id = aufgabe("offen");
|
|
for (const [was, koerper] of [
|
|
["ganz ohne Grund", {}],
|
|
["leerer Grund", { grund: "" }],
|
|
["nur Leerzeichen", { grund: " " }],
|
|
["zu kurz", { grund: "x" }],
|
|
]) {
|
|
const a = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/abbrechen`, koerper);
|
|
const stand = d.prepare("SELECT status FROM aufgaben WHERE id = ?").get(id)?.status;
|
|
ok(a.status === 400 && stand === "offen", `${was}: abgelehnt (${a.status}), Aufgabe unverändert`);
|
|
}
|
|
/* GEGENPROBE: Mit Grund geht es -- sonst prüfte das oben nichts. */
|
|
const a = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/abbrechen`,
|
|
{ grund: "Jetzt mit Grund." });
|
|
ok(a.ok, "mit Grund geht es — die Prüfung unterscheidet also");
|
|
|
|
/* Zu lang wird ebenfalls abgelehnt, nicht abgeschnitten. */
|
|
const id2 = aufgabe("offen");
|
|
const b = await rufe(dogi.keks, `/workspace/api/aufgaben/${id2}/abbrechen`,
|
|
{ grund: "z".repeat(501) });
|
|
ok(b.status === 400, `501 Zeichen: abgelehnt (${b.status})`);
|
|
}
|
|
|
|
console.log("\n=== Der Weg zurück ===");
|
|
{
|
|
const id = aufgabe("review");
|
|
await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/abbrechen`, { grund: "Doch nicht nötig." });
|
|
const a = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/wiederaufnehmen`);
|
|
const z = d.prepare(
|
|
"SELECT status, status_vorher, abbruch_grund, abgebrochen_am, abbruch_von FROM aufgaben WHERE id = ?").get(id);
|
|
/* ZURÜCK IN DEN ALTEN STATUS, nicht nach "offen". Wer im Review
|
|
abbricht und wieder aufnimmt, will dort weitermachen. */
|
|
ok(a.ok && z.status === "review", `zurück in "${z.status}" (nicht nach "offen")`);
|
|
ok(!z.status_vorher && !z.abbruch_grund && !z.abgebrochen_am && !z.abbruch_von,
|
|
"die Abbruchfelder sind wieder leer — keine Reste");
|
|
|
|
/* Ein Creator darf auch das nicht. */
|
|
const id2 = aufgabe("arbeit");
|
|
await rufe(dogi.keks, `/workspace/api/aufgaben/${id2}/abbrechen`, { grund: "Test." });
|
|
const b = await rufe(crea.keks, `/workspace/api/aufgaben/${id2}/wiederaufnehmen`);
|
|
ok(b.status === 403, `Creator darf nicht wiederaufnehmen (${b.status})`);
|
|
|
|
/* Zweimal abbrechen ergibt keinen Sinn und wird erkannt. */
|
|
const c = await rufe(dogi.keks, `/workspace/api/aufgaben/${id2}/abbrechen`, { grund: "Nochmal." });
|
|
ok(c.status === 409, `zweimal abbrechen: abgelehnt (${c.status})`);
|
|
|
|
/* Und etwas Laufendes wiederaufnehmen ebenso wenig. */
|
|
const id3 = aufgabe("offen");
|
|
const e = await rufe(dogi.keks, `/workspace/api/aufgaben/${id3}/wiederaufnehmen`);
|
|
ok(e.status === 409, `etwas Laufendes wiederaufnehmen: abgelehnt (${e.status})`);
|
|
}
|
|
|
|
console.log("\n=== Der Umweg über den normalen PATCH ist zu ===");
|
|
{
|
|
/* DER PUNKT, AN DEM ES DURCHRUTSCHEN WÜRDE: Stünde "abgebrochen"
|
|
einfach in der STATUS-Liste, könnte ein Creator seine eigene
|
|
Aufgabe über den gewöhnlichen PATCH abbrechen -- ohne Grund, ohne
|
|
Protokoll, und an der Rollenregel vorbei. */
|
|
const id = aufgabe("offen");
|
|
const a = await rufe(crea.keks, `/workspace/api/aufgaben/${id}`,
|
|
{ status: "abgebrochen" }, "PATCH");
|
|
const stand = d.prepare("SELECT status FROM aufgaben WHERE id = ?").get(id)?.status;
|
|
ok(a.status === 400 && stand === "offen",
|
|
`Creator per PATCH: abgelehnt (${a.status}), Status bleibt "${stand}"`);
|
|
|
|
/* Auch DogFather kommt hier nicht durch -- der Grund wäre sonst
|
|
umgehbar, und ohne Grund ist ein Abbruch ein Löschen. */
|
|
const b = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}`,
|
|
{ status: "abgebrochen" }, "PATCH");
|
|
ok(b.status === 400, `auch DogFather per PATCH: abgelehnt (${b.status}) — der Grund ist nicht umgehbar`);
|
|
|
|
/* GEGENPROBE: Ein echter Statuswechsel geht weiterhin. */
|
|
const c = await rufe(dogi.keks, `/workspace/api/aufgaben/${id}`,
|
|
{ status: "arbeit" }, "PATCH");
|
|
ok(c.ok, "ein normaler Statuswechsel geht weiterhin — der PATCH ist nicht kaputt");
|
|
}
|
|
|
|
console.log("\n=== Sichtbarkeit bleibt gewahrt ===");
|
|
{
|
|
/* Ein Scout darf keine Aufgabe abbrechen, die er gar nicht sehen
|
|
darf -- sonst wäre die Nummer ein Weg, fremde Aufgaben anzufassen.
|
|
Dafür eine Aufgabe eines Creators, der niemandem zugeteilt ist. */
|
|
const idFremd = anlegen("Nora", "creator", "CODE-CREA-0002");
|
|
d.prepare(`INSERT INTO aufgaben (titel, status, prioritaet, creator_id, verantwortlich_id,
|
|
erstellt, erstellt_von) VALUES ('Fremde Aufgabe','offen','hoch',?,?,?,?)`)
|
|
.run(idFremd, idFremd, jetzt, idDogi);
|
|
const id = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
|
|
|
const a = await rufe(scout.keks, `/workspace/api/aufgaben/${id}/abbrechen`,
|
|
{ grund: "Sollte nicht gehen." });
|
|
const stand = d.prepare("SELECT status FROM aufgaben WHERE id = ?").get(id)?.status;
|
|
/* 404 und nicht 403: Was man nicht sehen darf, existiert nicht --
|
|
sonst liesse sich durch Ausprobieren herausfinden, welche Nummern
|
|
es gibt. */
|
|
ok(a.status === 404 && stand === "offen",
|
|
`Scout an einer fremden Aufgabe: ${a.status}, Status bleibt "${stand}"`);
|
|
|
|
/* GEGENPROBE: An einer EIGENEN geht es. */
|
|
const idEigen = aufgabe("offen");
|
|
const b = await rufe(scout.keks, `/workspace/api/aufgaben/${idEigen}/abbrechen`,
|
|
{ grund: "Bei seinem Creator." });
|
|
ok(b.ok, "an der Aufgabe seines eigenen Creators geht es — die Sperre trifft das Richtige");
|
|
}
|
|
|
|
console.log("\n=== Ein defekter Datensatz sperrt nicht alle aus ===");
|
|
{
|
|
/* GEFUNDEN DURCH EINEN EIGENEN FEHLER, 05.09.2026.
|
|
|
|
Im ersten Entwurf dieser Prüfung legte ich eine Person mit
|
|
code_n = 1 an. scrypt lehnt das ab ("Invalid scrypt params"), die
|
|
Anmeldeschleife hatte keinen Schutz, und die Ausnahme flog bis in
|
|
den catch am Ende der Route: 503 für JEDEN mit dieser Rolle.
|
|
|
|
Das ist keine Kleinigkeit. Ein einziger krummer Datensatz -- aus
|
|
einem Import, einer Migration, einer Reparatur von Hand -- hätte
|
|
ein ganzes Team ausgesperrt, mit der Meldung "nicht verfügbar".
|
|
Da sucht man lange.
|
|
|
|
Deshalb wird es hier absichtlich herbeigeführt und geprüft. */
|
|
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
|
|
.run("Kaputt", "manager", "aa", "bb", 1, jetzt);
|
|
|
|
const trotzdem = await anmelden("manager", "CODE-MANA-0001");
|
|
ok(trotzdem.ok && trotzdem.keks,
|
|
trotzdem.ok ? "trotz eines defekten Datensatzes kommt die gesunde Person herein"
|
|
: `AUSGESPERRT — ein krummer Datensatz blockiert die ganze Rolle`);
|
|
|
|
/* GEGENPROBE: Ein falscher Code wird trotzdem noch abgelehnt. Ein
|
|
try/catch, das zu viel verschluckt, wäre schlimmer als das
|
|
Problem -- es könnte jeden hereinlassen. */
|
|
const falsch = await anmelden("manager", "CODE-FALSCH-9999");
|
|
ok(!falsch.ok, `ein falscher Code wird weiterhin abgelehnt (${falsch.ok ? "NEIN!" : "ja"})`);
|
|
|
|
d.prepare("DELETE FROM personen WHERE name = 'Kaputt'").run();
|
|
}
|
|
|
|
console.log("\n=== Was die Oberfläche zu sehen bekommt ===");
|
|
{
|
|
const id = aufgabe("arbeit");
|
|
await rufe(dogi.keks, `/workspace/api/aufgaben/${id}/abbrechen`, { grund: "Sichtbarer Grund." });
|
|
const a = await fetch(BASIS + "/workspace/api/aufgaben", { headers: { Cookie: dogi.keks } });
|
|
const antwort = await a.json();
|
|
/* Die Liste kann unter verschiedenen Namen kommen -- steht sie gar
|
|
nicht da, ist das ein Fehler und kein Grund zum Abstürzen. */
|
|
const liste = antwort.aufgaben || antwort.eintraege || antwort.liste;
|
|
ok(Array.isArray(liste), Array.isArray(liste)
|
|
? `die Liste kam an (${liste.length} Aufgaben)`
|
|
: `unerwartete Antwort: ${JSON.stringify(antwort).slice(0, 90)}`);
|
|
const eine = (liste || []).find((x) => x.id === id);
|
|
ok(eine?.abbruch_grund === "Sichtbarer Grund." && eine?.status_vorher === "arbeit"
|
|
&& eine?.abbruch_von_name === "Dogi" && eine?.abgebrochen_am,
|
|
`Grund, vorheriger Status, Name und Zeitpunkt kommen in der Liste mit`);
|
|
|
|
/* Und sie steht nicht mehr in einer der vier Brettspalten. */
|
|
ok(eine?.status === "abgebrochen",
|
|
`Status "${eine?.status}" — fällt damit aus allen vier Spalten heraus`);
|
|
}
|
|
|
|
d.close();
|
|
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
|
|
|
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
|
|
process.exit(fehler ? 1 : 0);
|