Phase 7: Benachrichtigungen und Termine

Terminkalender mit Serienterminen, Selbstanmeldung, Warteliste und
Anwesenheitsliste. Vier Zustellwege hinter Schnittstellen: echtes SMTP,
ntfy (selbst gehostet), Telegram und Web-Push. Kalenderabonnement als
ICS-Feed, digitaler Dienstausweis mit signiertem QR und Pruefseite.

Niemand wird ungefragt benachrichtigt: ein neuer Zustellweg hat eine leere
Ereignisliste, und der Seed legt bewusst keine an. Was das Netz der Wehr
verlaesst, bekommt nur die Kurzfassung ohne Namen und Details.

Kein CalDAV (Lesen genuegt, ICS kann jeder Kalender) und kein WhatsApp
(Verarbeitung ueber Meta, Kosten je Konversation, Telefonbuchabgleich) --
beides im README begruendet.

56 neue Unit-Tests, 17 neue E2E-Tests, Version 0.8.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-04 08:39:38 +02:00
co-authored by Claude Opus 5
parent c779266450
commit 0b8874da26
134 changed files with 9340 additions and 123 deletions
+124
View File
@@ -0,0 +1,124 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
AUSWEIS_GUELTIG_SEKUNDEN,
AUSWEIS_FEHLER_TEXT,
ausweisErzeugen,
ausweisPruefen,
} from "@/lib/dienstausweis";
import { markeErzeugen } from "@/lib/kiosk/anmeldemarke";
/**
* Der Dienstausweis weist eine Person aus. Er verdient dieselbe Sorgfalt wie
* ein Anmeldeweg — auch wenn er nichts öffnet, denn wer ihn fälschen kann,
* kann an einer Absperrung behaupten, jemand anderes zu sein.
*
* Der interessanteste Test ist der letzte: Anmeldemarke und Ausweis sind mit
* demselben Geheimnis signiert. Ohne den Zweck im signierten Teil ginge das
* eine als das andere durch — dann wäre der Ausweis ein Anmeldeweg.
*/
const PERSON = "8f7d3b1e-2c4a-4e6f-9a1b-0c2d4e6f8a1b";
const ORGANISATION = "1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d";
afterEach(() => {
vi.useRealTimers();
});
describe("Dienstausweis", () => {
it("erkennt den eigenen Ausweis wieder", () => {
const ergebnis = ausweisPruefen(ausweisErzeugen(PERSON, ORGANISATION));
expect(ergebnis).toMatchObject({ personId: PERSON, organisationId: ORGANISATION });
expect("fehler" in ergebnis).toBe(false);
});
it("nennt den Zeitpunkt der Ausstellung", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-04T10:00:00Z"));
const ergebnis = ausweisPruefen(ausweisErzeugen(PERSON, ORGANISATION));
expect("erzeugtAm" in ergebnis && ergebnis.erzeugtAm).toEqual(
new Date("2026-09-04T10:00:00Z"),
);
});
it("weist einen Code ab, an dem jemand gedreht hat", () => {
const code = ausweisErzeugen(PERSON, ORGANISATION);
const gefaelscht = code.replace(PERSON, "00000000-0000-4000-8000-000000000000");
expect(ausweisPruefen(gefaelscht)).toEqual({ fehler: "verfaelscht" });
});
it("weist eine abgeschnittene Signatur ab", () => {
const code = ausweisErzeugen(PERSON, ORGANISATION);
expect(ausweisPruefen(code.slice(0, -5))).toEqual({ fehler: "verfaelscht" });
});
it("weist Unsinn ab", () => {
expect(ausweisPruefen("")).toEqual({ fehler: "ungueltig" });
expect(ausweisPruefen("hallo")).toEqual({ fehler: "ungueltig" });
expect(ausweisPruefen("a.b.c.d")).toEqual({ fehler: "ungueltig" });
});
it("gilt kurz vor Ablauf noch", () => {
vi.useFakeTimers();
const code = ausweisErzeugen(PERSON, ORGANISATION);
vi.advanceTimersByTime((AUSWEIS_GUELTIG_SEKUNDEN - 1) * 1000);
expect("personId" in ausweisPruefen(code)).toBe(true);
});
it("gilt eine Sekunde nach Ablauf nicht mehr", () => {
vi.useFakeTimers();
const code = ausweisErzeugen(PERSON, ORGANISATION);
vi.advanceTimersByTime((AUSWEIS_GUELTIG_SEKUNDEN + 1) * 1000);
expect(ausweisPruefen(code)).toEqual({ fehler: "abgelaufen" });
});
/**
* Ein Code aus der Zukunft ist kein gültiger Code. Ohne diese Prüfung
* könnte jemand mit einem gefälschten Zeitstempel einen Ausweis bauen, der
* beliebig lange gilt — sofern er an das Geheimnis käme.
*/
it("nimmt keinen Code aus der Zukunft an", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-04T12:00:00Z"));
const code = ausweisErzeugen(PERSON, ORGANISATION);
vi.setSystemTime(new Date("2026-09-04T11:00:00Z"));
expect(ausweisPruefen(code)).toEqual({ fehler: "abgelaufen" });
});
it("erzeugt bei jedem Aufruf einen anderen Code", () => {
vi.useFakeTimers();
const erster = ausweisErzeugen(PERSON, ORGANISATION);
vi.advanceTimersByTime(1000);
const zweiter = ausweisErzeugen(PERSON, ORGANISATION);
expect(erster).not.toBe(zweiter);
});
/**
* Der Ausweis darf nicht als Anmeldemarke durchgehen und umgekehrt. Beide
* sind mit AUTH_SECRET signiert — was sie trennt, ist der Zweck im
* signierten Teil.
*/
it("nimmt keine Anmeldemarke als Ausweis an", () => {
const marke = markeErzeugen(PERSON, ORGANISATION);
expect(ausweisPruefen(marke)).toEqual({ fehler: "ungueltig" });
});
it("hat für jeden Fehler einen Satz, den ein Mensch versteht", () => {
for (const text of Object.values(AUSWEIS_FEHLER_TEXT)) {
expect(text.length).toBeGreaterThan(10);
expect(text.endsWith(".")).toBe(true);
}
});
});