Zwei Wuensche vom 05.09.2026, dazu drei Fehler, die dabei ans Licht kamen.
ABBRECHEN (Wunsch: "in jedem status die aufgaben auch abbrechen koennen,
nur ich die manager und scouts")
Neuer Status mit Pflicht-Grund, aus jedem der vier Status heraus.
Festgehalten wird auch, WO die Aufgabe stand -- "im Review abgebrochen"
ist eine andere Aussage als "nie angefangen", und das Wiederaufnehmen
geht dorthin zurueck statt nach "offen".
Eigener Weg statt "abgebrochen" in der Statusliste: Dort entscheidet
darfAendern(), und das laesst auch den zustaendigen Creator aendern.
Der gewoehnliche PATCH kann diesen Zustand deshalb gar nicht erreichen
-- auch nicht fuer DogFather, sonst waere die Grund-Pflicht umgehbar.
Abgebrochenes steht in einem zugeklappten Bereich unter dem Brett, nicht
als fuenfte Spalte: Am Handy waeren dann alle fuenf unlesbar schmal.
Verschwinden darf es nicht, sonst waere der Abbruch ein Loeschen mit
Zwischenschritt.
Die Tabelle musste dafuer getauscht werden (SQLite kann CHECK nicht
aendern). Vorher auf einer Kopie durchgespielt: 40 von 40 Aufgaben,
Inhalte, Verweise und Indizes geprueft, Gegenprobe zeigt, dass der CHECK
noch lebt.
TEILNEHMERWAHL (Wunsch: "das soll viel besser aussehen und fuer jeden
verfuegbar sein")
Auf dem Bildschirm klebten die Namen aneinander: "DogfatherDogFather".
Ursache war, dass kalender.html das Stylesheet mit diesen Klassen nie
eingebunden hat -- sie standen in dateien.css. Vierzig gruene Pruefungen
zur Teilnehmerwahl hatten das nicht gemerkt, weil keine je gefragt hat,
ob es AUSSIEHT wie gedacht.
Jetzt eigene Klassen im eigenen Stylesheet, nach Rollen gruppiert: Die
Rolle steht einmal als Ueberschrift statt neunmal am Namen. Damit ist
das Kleben an der Wurzel weg, nicht zugepflastert.
"Fuer jeden" war mehr als ein hidden zu entfernen: darfEintragen() haette
einem Creator nur sich selbst erlaubt. Er haette seinen Scout gesehen,
angeklickt, und der Server haette ihn still weggelassen -- ein Knopf, der
nichts tut. einladbareIds() schaut jetzt in beide Richtungen, bewusst
getrennt von /api/personen: Wer die erweitert, gibt einem Creator
nebenbei die Moeglichkeit, seinem Scout Aufgaben zuzuweisen.
BENACHRICHTIGUNGEN (Wunsch: "sowas, und dass es perfekt funktioniert
fuer jeden")
Web Push nach RFC 8291/8292, ohne fremde Abhaengigkeit. Der Knopf sagt
in jeder Lage die Wahrheit, auch die unbequemen: abgelehnt (mit dem
Hinweis, wo man es zuruecknimmt), iPhone im Reiter (mit Anleitung),
Browser ohne Push. Ein Knopf, der bei abgelehnter Berechtigung nur
nichts tut, ist der sichere Weg zu "das funktioniert nicht".
DREI FEHLER, DIE DABEI AUFFIELEN
1. Ein defekter Zugangsdatensatz sperrte ALLE einer Rolle aus. Wirft
hashe() bei einer Person, flog die ganze Anmeldung in den catch: 503
"nicht verfuegbar" fuer jeden mit dieser Rolle. Aufgefallen durch
einen eigenen Testfehler. Jetzt wird die defekte Person uebersprungen
und laut protokolliert; die Gegenprobe zeigt, dass ein falscher Code
weiterhin abgelehnt wird.
2. Die Glocke sprengte die Kopfleiste -- zweimal. Bei 320 px lag die
Lupe des Suchknopfes auf dem Sicht-Umschalter (ein Knopf, der auf 12
Seiten ins Leere tippt), bei 768 px wurde der Abmelden-Knopf bis zu
15 px aus dem Bild geschoben, weil die Textgrenze auf 760 stand und
ein Tablet 768 hat. Nachgewiesen durch Messen mit und ohne Glocke,
nicht durch Vermuten.
3. .block__frage war viermal gestaltet und stand auf einer Seite, die
keine dieser Dateien laedt -- derselbe Fehler wie bei der
Teilnehmerwahl. Gefunden von der neuen Klassenpruefung beim ersten
Lauf.
NEUE PRUEFUNGEN
pruef-css-klassen jede gestaltete Klasse muss auf ihrer Seite ankommen
(unterscheidet Struktur-Anker von echtem Verlust)
pruef-dabei-optik die Wahl im Browser, an den echten Pixeln
pruef-abbrechen Umstellung auf einer Kopie, Rechte, Rueckweg
pruef-abbrechen-optik Knopf, Dialog, Bereich, Handy
pruef-glocke Zustaende, An/Abmelden, jede Rolle
pruef-push(-weg) Rechnung gegen die RFC-Vektoren, Zustellung
pruef-struktur prueft jetzt zusaetzlich, ob sich jedes Server-Modul als
ESM laden laesst. node --check auf einer .js-Datei prueft als CommonJS
und meldete "ok", waehrend der Import scheiterte.
Gesamtlauf: 55 von 55 Dateien, 1973 von 1973 Punkten.
Was NICHT geprueft werden konnte und deshalb dasteht: Der Schritt
"Browser holt eine Adresse beim Push-Dienst" braucht eine Verbindung zu
Googles FCM, die ein Pruef-Browser nicht hat. Die Pruefung misst das
zuerst und meldet es als dritten Ausgang, statt gruen zu sein.
Verschluesselung und Zustellung sind getrennt geprueft; diese eine
Strecke beweist sich erst auf dem Server.
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 { portMussFreiSein } = await import("./helfer-port.mjs");
|
|
await portMussFreiSein(4295, "die Abbruchpruefung");
|
|
process.env.PORT = "4295";
|
|
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:4295";
|
|
|
|
{
|
|
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);
|