test(ops): verify Docker persistence, browser workflows and document deployment

This commit is contained in:
OpenCode committed 2026-10-24 15:43:37 +02:00
1 parent a58516e424
commit 8fa63415f1
35 files changed
+1105

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
node_modules
.next
.git
.env*
uploads
test-results
playwright-report
coverage
docs/screenshots
*.tsbuildinfo
+8
View File
@@ -0,0 +1,8 @@
DATABASE_URL=postgres://hamham:CHANGE_ME@localhost:5432/hamham
POSTGRES_PASSWORD=CHANGE_ME
APP_ORIGIN=http://localhost:3000
HOST_PORT=3000
COOKIE_SECURE=false
UPLOAD_DIR=./uploads
# Only enable behind a trusted proxy that overwrites X-Forwarded-For.
TRUST_PROXY=false
+1
View File
@@ -1,4 +1,5 @@
node_modules node_modules
next-env.d.ts
.next .next
package-lock.json package-lock.json
public/examples public/examples
+28
View File
@@ -0,0 +1,28 @@
FROM node:24-bookworm-slim AS dependencies
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM dependencies AS builder
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM dependencies AS runtime-dependencies
RUN npm prune --omit=dev && npm cache clean --force
FROM node:24-bookworm-slim AS runner
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 UPLOAD_DIR=/app/uploads
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*
COPY --from=builder --chown=node:node /app/.next ./.next
COPY --from=runtime-dependencies --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/package.json /app/next.config.mjs ./
COPY --from=builder --chown=node:node /app/scripts/admin.ts /app/scripts/migrate.ts ./scripts/
COPY --from=builder --chown=node:node /app/migrations ./migrations
RUN mkdir -p /app/uploads && chown node:node /app/uploads
USER node
EXPOSE 3000
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["sh", "-c", "npm run db:migrate && exec node node_modules/next/dist/bin/next start --hostname 0.0.0.0"]
+81
View File
@@ -0,0 +1,81 @@
# HamHam
**Dein persönliches Kochbuch. Selbst gehostet. Für die echte Küche.**
Eine deutschsprachige Rezeptplattform mit öffentlicher Sammlung, geschützter Verwaltung, präziser Mengenberechnung und einem geführten Kochmodus. Alle Daten, Bilder und Schriften bleiben auf deinem Server. Favoriten, Einkaufslisten, Editorentwürfe und Kochfortschritt werden im jeweiligen Browser gespeichert.
## Start mit Docker
```sh
cp .env.example .env
# POSTGRES_PASSWORD durch einen zufälligen URL-sicheren Wert ersetzen.
# APP_ORIGIN auf die tatsächlich verwendete Adresse setzen.
docker compose up -d --build
docker compose exec app npm run admin:create -- deinname
```
Das CLI fragt das Passwort verborgen ab (mindestens 12 Zeichen). Es gibt **keine Standardzugangsdaten**. Anschließend `http://localhost:3000/admin` öffnen. Der erste Start legt eine leere Sammlung an; Rezepte werden nicht automatisch veröffentlicht.
Für HTTPS: `APP_ORIGIN=https://kochen.example.org` und `COOKIE_SECURE=true`. Details zu Proxy, Backup, Restore und Updates: [Deployment](docs/DEPLOYMENT.md).
## Funktionen
- Öffentliche Rezeptbibliothek mit Zutaten-/Titelsuche, Kategorien, Tags, Zeit- und Schwierigkeitsfiltern sowie teilbaren URLs.
- Rezepteditor mit strukturierten Zutaten, Abschnitten, optionalen Mengen, sortierbaren Schritten, Zutatenreferenzen, mehreren Timern, lokaler Entwurfssicherung und Live-Vorschau.
- Veröffentlichung mit serverseitiger Autorisierung und optimistischer Versionskontrolle gegen gleichzeitiges Überschreiben.
- Mengenberechnung mit `decimal.js`: Portionen oder vorhandene Zutatenmenge als Ausgangspunkt, kompatible Einheiten, keine kumulative Rundung.
- Kochmodus mit großen Bedienelementen, Zutaten zum Abhaken, persistenten Zeitstempel-Timern, Wake Lock, optionalem Signalton und Abschlussansicht.
- JSON/XML-Import mit Vorschau, Duplikaterkennung, atomarer Übernahme; Einzel- und Gesamtexport.
- Lokale Bilder inklusive Re-Encoding, Einkaufsliste mit Mengenzusammenführung, Favoriten und Drucklayout.
- Helles/dunkles Design, lokale Schriftdateien, installierbare PWA und ehrliches Offline-Fallback.
- Argon2id, zufällige serverseitige Sessions, HTTP-only-Cookies, Origin-CSRF-Prüfung, persistente Loginlimits und Sicherheitsheader.
## Rezepte mit ChatGPT erstellen
1. [Wiederverwendbaren Prompt](docs/CHATGPT-PROMPT.md) und [JSON Schema](public/examples/hamham.schema.json) an ChatGPT geben.
2. Gewünschtes Rezept beschreiben.
3. Ergebnis als JSON speichern oder kopieren.
4. Unter **Administration → Importieren** Vorschau prüfen und anschließend übernehmen.
Vollständige Beispiele: [Pljeskavica JSON](public/examples/pljeskavica.json), [Pljeskavica XML](public/examples/pljeskavica.xml), [drei Kochbuchrezepte](public/examples/kochbuch.json). Letztere enthalten lokale Bilder und sind ausdrücklich zum optionalen Import gedacht.
Format, Mengenreferenzen und XML-Struktur: [Importanleitung](docs/IMPORT.md).
## Entwicklung und Prüfung
Node.js 24 und PostgreSQL 17:
```sh
npm ci
cp .env.example .env
# DATABASE_URL und APP_ORIGIN eintragen
npm run db:migrate
npm run admin:create -- deinname
npm run dev
```
```sh
npm run typecheck
npm test
npm run build
npx playwright install chromium
createdb hamham_test
TEST_DATABASE_URL=postgres://localhost/hamham_test npm run test:e2e
npm run test:docker
```
Die E2E-Suite verwendet einen Produktionsserver auf Port 3100 und leert ausschließlich die ausdrücklich konfigurierte, auf `_test` endende Testdatenbank. Zugangsdaten werden für jeden Lauf zufällig erzeugt. Screenshots liegen unter `docs/screenshots/`, Traces und HTML-Bericht unter den ignorierten Testverzeichnissen.
Der Docker-Test erstellt ein eigenes zufällig benanntes Compose-Projekt auf Port 3200, prüft Bootstrap, Import, Upload, Neustart-Persistenz und Datenbank-Restore und entfernt anschließend ausschließlich seine eigenen Testcontainer und Testvolumes.
Weitere Informationen: [Architektur und Entwicklung](docs/DEVELOPMENT.md), [Prüfnachweise und Grenzen](docs/VERIFICATION.md).
## Datenschutz und lokale Daten
Kein Tracking, keine Cloudkonten, keine extern geladenen Schriftarten. Bilder sind lokale Dateien. Öffentliche Rezeptbilder sind über ihre URL öffentlich abrufbar. Lokale Favoriten, Entwürfe und Einkaufslisten synchronisieren nicht automatisch zwischen Geräten. Browserdaten-Löschen entfernt diese persönlichen Daten. Rezept- und Bildbestände auf dem Server werden davon nicht beeinflusst.
Die Offline-Funktion ersetzt kein Serverbackup. Hintergrundtimer verwenden absolute Endzeitpunkte; mobile Betriebssysteme können Webseiten anhalten. Der angezeigte Timerstand wird bei Rückkehr korrigiert. Ein akustischer Alarm bei gesperrtem Bildschirm ist browserbedingt nicht garantiert.
## Bildnachweise
Mitgelieferte Food-Fotos stammen von Unsplash und liegen vollständig lokal im Repository. Original-IDs und Hinweise: [ASSETS.md](docs/ASSETS.md).
+52
View File
@@ -0,0 +1,52 @@
services:
db:
image: postgres:17-bookworm
environment:
POSTGRES_DB: hamham
POSTGRES_USER: hamham
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
volumes:
- database:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U hamham -d hamham']
interval: 5s
timeout: 5s
retries: 20
restart: unless-stopped
stop_grace_period: 30s
app:
build: .
environment:
DATABASE_URL: postgres://hamham:${POSTGRES_PASSWORD}@db:5432/hamham
APP_ORIGIN: ${APP_ORIGIN:-http://localhost:3000}
COOKIE_SECURE: ${COOKIE_SECURE:-false}
TRUST_PROXY: ${TRUST_PROXY:-false}
UPLOAD_DIR: /app/uploads
ports:
- '${HOST_PORT:-3000}:3000'
volumes:
- images:/app/uploads
depends_on:
db:
condition: service_healthy
healthcheck:
test:
[
'CMD',
'node',
'-e',
"fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 10s
start_period: 45s
retries: 3
restart: unless-stopped
stop_grace_period: 30s
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
volumes:
database:
images:
+13
View File
@@ -0,0 +1,13 @@
# Lokale Assets
Alle zur Laufzeit benötigten Bild- und Schriftdateien werden vom eigenen Host ausgeliefert. Kein Browseraufruf an Unsplash, Google Fonts oder andere Drittanbieter.
Mitgelieferte Fotos (Unsplash-Lizenz, https://unsplash.com/license):
- `public/images/kitchen.jpg`: https://images.unsplash.com/photo-1473093295043-cdd812d0e601, Pasta mit Pesto.
- `public/images/garden.jpg`: CDN-ID `photo-1512621776951-a57141f2eefd`, Gemüse-Bowl.
- `public/images/soup.jpg`: CDN-ID `photo-1547592166-23ac45744acd`, cremige Gemüsesuppe.
Die Fotos illustrieren das Kochbuch und die optionalen Beispielrezepte; sie wurden nicht beim Kochen dieser konkreten Anleitungen aufgenommen. Eigene Bilder können im Editor hochgeladen werden.
Schriften: DM Sans und Fraunces, SIL Open Font License, über `@fontsource` als lokale Pakete. Icons: Lucide, ISC-Lizenz. HamHam-App-Icon: eigens als SVG erstellt; PNG-Ableitungen in 192/512 px.
+27
View File
@@ -0,0 +1,27 @@
# Wiederverwendbarer ChatGPT-Prompt
Kopiere den folgenden Text zusammen mit `public/examples/hamham.schema.json` in ChatGPT. Hänge idealerweise auch `pljeskavica.json` als Beispiel an.
---
Erstelle ein kulinarisch plausibles, vollständig nachkochbares Rezept für meine selbst gehostete Rezept-App **HamHam**.
**Mein Wunsch:** [z. B. Serbische Pljeskavica für 4 Personen]
Antworte ausschließlich mit gültigem JSON, ohne Markdown und ohne Begleittext. Halte dich exakt an das beigefügte JSON Schema. Die Hülle lautet `{"version":1,"recipes":[…]}`.
Regeln:
1. Deutsch, bevorzugt in Österreich übliche Zutatenbezeichnungen. Konkrete, sichere und nachvollziehbare Zubereitung.
2. `title`, `subtitle`, `description`, `cuisine`, `categories`, `tags`, `difficulty`, `prepMinutes`, `cookMinutes`, `restMinutes`, `servings`, `ingredients`, `steps`, `tips`, `storage` ausfüllen.
3. Mengen ausschließlich als positive JSON-Zahlen mit Dezimalpunkt. Keine Bruchstrings, keine Einheiten im Mengenfeld. „Nach Geschmack“ als `amount: null`, `unit: ""`, `note: "nach Geschmack"`.
4. Einheiten ausschließlich `g`, `kg`, `ml`, `l`, `Stück`, `TL`, `EL`, `Prise`, `Bund`, `""`.
5. Jede Zutat hat eine eindeutige lesbare `id`, einen `name`, `amount`, `unit`, `section`, `note`, `optional`.
6. Jeder Schritt hat eine eindeutige `id`, `title`, `text`, `ingredients` (Zutaten-IDs) und `timers` (ggf. leeres Array).
7. Mengen in Anleitungen ausschließlich mit `{{zutaten_id}}` referenzieren: `"{{fleisch}} in Würfel schneiden."`. Die App ersetzt die Referenz mit Menge, Einheit, Zutatenname und Hinweis. Nicht nochmals den Namen oder die Menge dahinter schreiben. Referenzen müssen existieren.
8. Für Warte-, Gar- und Ruhezeiten passende Timer angeben: `{"id":"eindeutige_id","label":"Nudeln kochen","seconds":600}`. IDs im gesamten Rezept eindeutig. Zeiten und Temperaturen nicht in Zutatenreferenzen umwandeln.
9. `image: ""`, `images: []`, `published: false`. Keine erfundenen Bild-URLs. Rezept-ID und Zeitstempel weglassen; die App erzeugt sie.
10. `nutrition: null`, sofern keine verlässlichen Nährwerte pro Portion verfügbar sind. Keine scheinpräzisen Werte erfinden.
11. Kein HTML, keine Kommentare, keine zusätzlichen Felder. Alle Pflichtfelder und mindestens eine Zutat und ein Schritt müssen vorhanden sein.
Prüfe vor der Ausgabe die Mengenverhältnisse, die Gesamtzeit, die Referenzen und die JSON-Syntax.
+79
View File
@@ -0,0 +1,79 @@
# Betrieb, Updates und Datensicherung
## Voraussetzungen und Erststart
Docker Engine und Compose v2+, empfohlen mindestens 2 CPU-Kerne und 2 GB RAM (Builds benötigen zeitweise mehr). Unterstützt werden die von Node 24 und PostgreSQL 17 angebotenen Linux-Architekturen; der konkrete ARM64-Test ist im Prüfbericht vermerkt.
```sh
cp .env.example .env
openssl rand -hex 32
# Ergebnis als POSTGRES_PASSWORD in .env eintragen.
docker compose up -d --build
docker compose ps
docker compose logs --tail=100 app
docker compose exec app npm run admin:create -- deinname
```
Passwort wird interaktiv verborgen abgefragt. Der Benutzername besteht aus 3–80 Zeichen (`a-z`, `0-9`, `.`, `_`, `-`); das Passwort hat 12–200 Zeichen. Doppelte Benutzernamen werden abgewiesen. Weitere Administratoren werden ebenfalls über dieses CLI angelegt. Über die CLI kann der Serverbetreiber neue Zugänge erstellen, wenn ein Passwort verloren geht.
App läuft als unprivilegierter Benutzer `node`, PID-1-Verwaltung über `tini`. PostgreSQL ist nur intern erreichbar. `database` und `images` sind persistente Named Volumes. Startup-Migrationen laufen in einer Transaktion unter einem PostgreSQL-Advisory-Lock. HTTP-Healthcheck prüft auch die Datenbankverbindung. Logs gehen nach stdout/stderr. `docker compose down` behält Daten; **`down -v` löscht sie**.
`DATABASE_URL` aus `.env` wird für lokale CLI/Entwicklung verwendet; Compose setzt die interne URL aus `POSTGRES_PASSWORD` zusammen. Deshalb ein URL-sicheres Passwort wählen (z. B. Hex). Der Wert `CHANGE_ME` muss ersetzt werden.
## Adresse und Reverse Proxy
- `HOST_PORT=3000` konfiguriert den Host-Port.
- `APP_ORIGIN` ist exakt die Browseradresse inklusive Scheme und ggf. Port, ohne Pfad; z. B. `https://rezepte.example.org`. Diese Einstellung ist für die Origin-basierte CSRF-Prüfung erforderlich.
- `COOKIE_SECURE=true` hinter HTTPS. Für ausschließlich lokales HTTP `false`.
- Proxy muss `Host` und `X-Forwarded-Proto` korrekt setzen; WebSocket-Unterstützung wird in Produktion nicht benötigt.
- `TRUST_PROXY=true` nur, wenn ausschließlich der vertrauenswürdige Proxy auf den App-Port zugreifen kann und `X-Forwarded-For` **überschreibt**, statt einen beliebigen Clientwert zu übernehmen. Ansonsten werden direkte Zugriffe in einem gemeinsamen IP-Limit zusammengefasst. Zusätzlich gilt immer ein benutzerbezogenes Limit.
- Uploadlimit am Proxy mindestens 10 MB, Importanfragen maximal 2 MB nach JSON-Verpackung; sinnvoller Proxy-Gesamtgrenzwert 11 MB. App prüft die gelesenen Bytes selbst.
- Kein öffentliches Caching für `/admin`, `/api`, dynamische HTML- oder RSC-Antworten. Bilder sind content-addressed-artig per zufälligem Dateinamen versioniert und dürfen langfristig gecacht werden.
- HSTS nach erfolgreicher HTTPS-Einrichtung am vorhandenen Proxy setzen. Keine spezielle Proxysoftware erforderlich.
Session-Cookie: HTTP-only, SameSite Strict, 7 Tage absolute Gültigkeit, optional Secure. Datenbank enthält ausschließlich den SHA-256-Hash des zufälligen 256-Bit-Tokens. Passwortwechsel widerruft alle bisherigen Sessions. Origin-Prüfung gilt auch für Login und Uploads.
## Backup
Ein konsistentes Backup umfasst Datenbank **und** Bilder. Für ein kleines Haushaltskochbuch ist eine kurze Schreibpause am einfachsten:
```sh
mkdir -p backups
docker compose stop app
docker compose exec -T db pg_dump -U hamham -d hamham -Fc > backups/hamham.dump
docker compose run --rm --no-deps --entrypoint tar app -C /app/uploads -czf - . > backups/images.tar.gz
docker compose start app
```
Zusätzlich `.env` sicher und separat sichern. Dumps enthalten Passwort-Hashes und aktive Session-Hashes und sind vertraulich. Backups verschlüsselt außerhalb des Hosts aufbewahren, regelmäßig rotieren und Restore testen. JSON-Export ist ein portables Rezeptformat, aber ersetzt kein vollständiges Backup (keine Bildbytes oder Authentifizierungsdaten).
## Restore
Auf einem Host mit kompatiblem Release und korrekter `.env`:
```sh
docker compose up -d db
docker compose stop app
docker compose exec -T db pg_restore -U hamham -d hamham --clean --if-exists < backups/hamham.dump
docker compose run --rm -T --no-deps --entrypoint tar app -C /app/uploads -xzf - < backups/images.tar.gz
docker compose exec -T db psql -U hamham -d hamham -c 'DELETE FROM sessions;'
docker compose up -d app
```
Vor dem Restore auf bereits benutzten Volumes alte Bilddateien bei Bedarf in ein separates Archiv verschieben. Keine nicht zugeordneten fremden Daten löschen. Anschließend Anmeldung, Bilder, Rezeptzahl und `/api/health` kontrollieren.
## Update und Migrationen
1. Releasehinweise lesen, Backup erstellen.
2. `git pull --ff-only` auf dem gewünschten Release-Branch.
3. `docker compose build --pull app`.
4. `docker compose up -d`.
5. `docker compose logs --tail=100 app` und Healthchecks prüfen.
Migrationen sind nummerierte, unveränderliche SQL-Dateien in `migrations/`. Bereits angewendete Namen stehen in `migrations`. Neue Migrationen müssen vor Auslieferung auf leerer Datenbank und einer Kopie vorhandener Daten getestet werden. Schemaänderungen möglichst additiv und abwärtskompatibel. Kein automatisches destruktives Downgrade: für inkompatible Rückkehr vorheriges Image plus dazugehöriges Datenbank-/Bildbackup wiederherstellen.
## Offline und Timer
Die PWA cached lokale statische Ressourcen und online geöffnete, veröffentlichte Rezept-/Kochseiten. Auch nach clientseitiger Navigation wird eine vollständige Dokumentfassung für die Offline-Nutzung gespeichert. Bereits besuchte öffentliche Listenansichten werden ebenfalls gesichert. Nicht besuchte Seiten zeigen eine klare Offline-Ansicht. Admin- und API-Antworten werden vom Service Worker nicht gecacht. Browser können Cachedateien aus Platzgründen entfernen; offline ist deshalb eine Komfortfunktion, kein Verfügbarkeitsversprechen. Für einen zuverlässigen Kochabend die benötigte Kochseite vorab online öffnen.
Timer speichern absolute Endzeiten und korrigieren ihren Stand nach Navigation, Schlafmodus und Reload. Signalton benötigt eine Nutzeraktion („Ton an“); Betriebssysteme können Hintergrundseiten und Audio pausieren. Für zeitkritische Vorgänge zusätzlich einen Geräte-/Küchentimer verwenden. Wake Lock ist eine progressive Browserfunktion und wird im UI mit seinem tatsächlichen Status angezeigt.
+55
View File
@@ -0,0 +1,55 @@
# Architektur und Entwicklung
## Stack
Next.js App Router, React, TypeScript, PostgreSQL, Drizzle, Zod, decimal.js, Motion, Lucide, Sharp und `@node-rs/argon2`. Konkrete kompatible Versionen sind in `package-lock.json` fixiert. Das UI nutzt bewusst eigenständiges CSS mit Designvariablen statt eines generischen Dashboard-Themes. Lokale DM Sans und Fraunces werden über Fontsource gebündelt.
## Verantwortlichkeiten
| Pfad | Verantwortung |
| -------------------------------- | -------------------------------------------------------------- |
| `src/lib/recipe.ts` | Gemeinsames Datenmodell, Validierung, Referenzintegrität |
| `src/lib/calculator.ts` | Reine präzise Skalierung, Einheiten, Anzeige und Einkaufsliste |
| `src/lib/import.ts` | JSON/XML-Dekodierung und verlustfreier Metadatenexport |
| `src/lib/cooking.ts` | Testbare Zeitstempel-Timer |
| `src/lib/server.ts` | Sessions, CSRF, Größenlimits, Rate Limit, Fehlerabbildung |
| `src/lib/repository.ts` | Rezeptpersistenz, Sichtbarkeit, Versionskonflikte |
| `src/db/` | Drizzle-Mappings und PostgreSQL-Pool |
| `src/app/api/[...path]/route.ts` | Dünne HTTP-Operationen mit Validierung/Autorisierung |
| `src/components/` | Bibliothek, Detail, Kochmodus, Editor, Administration |
| `migrations/` | Versionierte SQL-Migrationen |
| `scripts/` | Migration, Admin-Bootstrap, Schema/Beispielgenerierung |
Die Rezeptstruktur liegt als validiertes JSONB-Dokument vor, ergänzt um relationale ID, Titel, normalisierten eindeutigen Titel, Veröffentlichungsstatus und Zeitstempel. Damit werden Zutaten/Schritte atomar geändert und beim Import nicht auseinandergerissen. Sessions, Administratoren, Limits und Medien sind getrennte relationale Tabellen. Öffentliche Leseoperationen filtern serverseitig auf `published=true`.
## API
Alle Fehler: `{ "error": "Verständliche Meldung" }`. Statuscodes: 400 Syntax, 401 Anmeldung, 403 Origin, 404 fehlt, 409 Duplikat/Versionskonflikt, 413 Größe, 415 Dateityp, 422 Datenvalidierung, 428 fehlende Bearbeitungsversion, 429 Loginlimit, 500 unerwarteter Serverfehler. Unerwartete Fehler werden serverseitig geloggt, ohne interne Details an Clients auszugeben.
- `GET /api/recipes`: veröffentlichte Rezepte; `?admin=1` nur mit gültiger Session erweitert.
- `GET /api/recipes/:id`: öffentlich nur veröffentlicht, sonst autorisierter Admin.
- `POST /api/recipes`, `PUT /api/recipes/:id`, `DELETE /api/recipes/:id`: Admin, Origin erforderlich. PUT benötigt den letzten `updatedAt`-Wert.
- `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/me`, `POST /api/auth/password`.
- `POST /api/import`: `{raw, format: "json"|"xml", preview: boolean}`.
- `GET /api/export?format=json|xml&id=UUID`: ID optional, sonst gesamte Sammlung.
- `GET/PUT /api/settings`: Kategorien-/Tag-Auswahllisten und Sammlungsname.
- `GET/POST/DELETE /api/media`: Rohbild-Upload mit Content-Type JPEG/PNG/WebP, Löschung per `{name}`. Bilder werden serverseitig dekodiert und als WebP neu kodiert (max. 40 MP, 10 MB Eingabe, 2400 px Kantenlänge). EXIF wird entfernt.
- `GET /api/health`: Datenbankverbindung + Anwendung.
Alle Admin-Operationen prüfen die Session auf dem Server. Passwörter werden mit Argon2id (64 MiB, 3 Iterationen) gehasht. CSRF-Schutz nutzt den verpflichtenden exakten Origin-Vergleich plus SameSite-Strict-Cookies. Automatisierte API-Clients müssen die konfigurierte Origin explizit setzen. Es gibt keine Token in Local Storage.
## Rechner und Referenzen
Faktoren bleiben als hochpräzise Dezimalstrings erhalten. Neue Zielmengen werden stets durch die unveränderte Originalmenge geteilt; Originalzutaten werden nie mutiert. Runden erfolgt nur zur Anzeige. Übliche Viertel-/Halbbrüche werden typografisch dargestellt, kleine Mengen mit bis zu sechs Nachkommastellen. Nährwerte sind pro Originalportion, Gesamtzeit wird nicht skaliert.
## Editor und lokale Speicherung
Änderungen werden lokal im Browser gesichert, ohne das veröffentlichte Rezept zu verändern. „Rezept speichern“ validiert und überträgt ausdrücklich auf den Server. Der Browser warnt beim Verlassen mit noch nicht auf den Server übertragenen Änderungen. Konflikte mit einer zwischenzeitlich geänderten Fassung werden als 409 zurückgegeben. Lokale Entwürfe bleiben dann erhalten.
Zutaten und Schritte lassen sich per Drag-Handle sowie per Auf/Ab-Buttons sortieren. So ist die Funktion auch ohne Maus bedienbar. Bilder werden sofort in die lokale Medienablage hochgeladen und können dort später entfernt werden, solange kein Rezept sie verwendet.
## Tests
Unit: `npm test`. E2E: `npm run build && npm run test:e2e`. Tests verwenden echte PostgreSQL-Persistenz, echte HTTP-Authentifizierung und Chromium ohne gemockte Produktfunktionen. Die E2E-Datenbank muss auf `_test` enden und wird zurückgesetzt. Playwright erzeugt Screenshots für Desktop (1440 px), Smartphone (390 px) und Tablet (768 px).
Bei Schemaänderungen `npm run schema:generate` ausführen und generierte Dokumente mitcommitten. Entwicklungsserver und Produktionsbuild teilen dieselbe Datenbankkonfiguration. Next-Telemetrie im Container ist deaktiviert; lokal bei Bedarf `NEXT_TELEMETRY_DISABLED=1` setzen.
+83
View File
@@ -0,0 +1,83 @@
# Importformat HamHam v1
## Ablauf
Administration → Importieren → JSON/XML auswählen → Datei hochladen oder Text einfügen → Vorschau prüfen → Import bestätigen. Maximal 2 MB und 100 Rezepte pro Vorgang. Kein Datensatz wird gespeichert, solange die Vorschau läuft. Ein Fehler oder Duplikat blockiert die gesamte Übernahme.
Titel werden für die Duplikatprüfung mit NFKC normalisiert, getrimmt und ohne Groß-/Kleinschreibung verglichen. Vorhandene IDs gelten ebenfalls als Duplikat. Import überschreibt keine bestehenden Rezepte. Zum Bearbeiten den Editor verwenden; zum Anlegen einer Variante eine neue ID bzw. keine ID und einen eigenen Titel verwenden.
JSON-Dateien haben diese Hülle:
```json
{
"version": 1,
"recipes": [{ "title": "Mein Rezept", "servings": 2, "ingredients": [], "steps": [] }]
}
```
Die leeren Arrays im obigen Strukturdiagramm müssen mit mindestens einer gültigen Zutat und einem Schritt befüllt werden. Ein vollständig gültiges Beispiel steht in `public/examples/pljeskavica.json`. Das verbindliche maschinenlesbare Schema liegt in `public/examples/hamham.schema.json` (JSON Schema 2020-12).
## Felder
Pflicht: `title` (2–180 Zeichen), `servings` (positiv), `ingredients` (1–200), `steps` (1–100).
Optional: `id` (UUID), `subtitle`, `description`, `image`, `images`, `categories`, `tags`, `cuisine`, `difficulty` (`Einfach`, `Mittel`, `Anspruchsvoll`), `prepMinutes`, `cookMinutes`, `restMinutes`, `tips`, `storage`, `nutrition`, `published`, `createdAt`, `updatedAt` (UTC ISO-8601). Gesamtzeit wird aus den drei Zeitfeldern berechnet. `published` ist standardmäßig `false`. Nährwerte gelten pro Originalportion; `nutrition` ist `null` oder `{ "kcal": 500, "protein": 25, "carbs": 60, "fat": 18 }`.
Zutat:
```json
{
"id": "huhn",
"name": "Hühnerbrust",
"amount": 400,
"unit": "g",
"section": "Hauptgericht",
"note": "ohne Haut",
"optional": false
}
```
`amount`: positive JSON-Zahl oder `null`. Dezimalpunkt verwenden, keine Zeichenketten wie `"1/2"`, `"400 g"` oder `"1,5"`. Für eine halbe Zitrone `0.5` und `Stück` verwenden. Bei Salz nach Geschmack `amount: null`, `unit: ""`, `note: "nach Geschmack"`.
Einheiten: `g`, `kg`, `ml`, `l`, `Stück`, `TL`, `EL`, `Prise`, `Bund`, `""`. Umrechnung: 1 kg = 1000 g, 1 l = 1000 ml, 1 TL = 5 ml, 1 EL = 15 ml. Masse wird nie ohne Dichteannahme in Volumen umgerechnet. IDs sind innerhalb des Rezepts eindeutig, bestehen aus Buchstaben, Ziffern, `_` oder `-`, maximal 80 Zeichen.
Schritt:
```json
{
"id": "anbraten",
"title": "Goldbraun anbraten",
"text": "{{huhn}} in Würfel schneiden und anbraten.",
"ingredients": ["huhn"],
"timers": [{ "id": "bratzeit", "label": "Hühnerbrust braten", "seconds": 480 }]
}
```
`{{huhn}}` wird zu **Menge + Einheit + Zutatenname + Hinweis** aufgelöst. Diese Referenz bleibt beim Skalieren konsistent. Keine konkreten skalierbaren Mengen als Freitext wiederholen. Temperaturen und Zeiten bleiben normaler Text. `ingredients` enthält die in diesem Schritt benötigten Zutaten-IDs. Timer-IDs müssen im gesamten Rezept eindeutig sein; ein Timer dauert 1–172800 Sekunden. Zeiten werden nicht mit Portionen skaliert.
## Bilder
Nur lokale URLs: `/media/<uuid>.webp` oder mitgelieferte `/images/<name>.jpg|webp|svg`. Bilder zuerst im Adminbereich hochladen, dann den lokalen Pfad verwenden. ChatGPT soll `image: ""` und `images: []` setzen und keine erfundenen URLs ausgeben.
JSON/XML-Exporte enthalten Bildreferenzen, keine Binärdaten. Für einen Umzug das Bildvolume mitnehmen; für ein vollständiges Backup zusätzlich die Datenbank sichern. Metadaten, Zutaten, Schritte, Timer, Zeitstempel und IDs bleiben beim Export erhalten.
## XML
Das vollständige Beispiel ist `public/examples/pljeskavica.xml`. UTF-8, Wurzel `<hamham version="1">`, darunter `<recipes><recipe>…</recipe></recipes>`. Rezeptfelder entsprechen JSON. Array-Container:
| JSON | XML |
| ---------------- | ----------------------------------------------------------- |
| recipes | `<recipes><recipe>…</recipe></recipes>` |
| ingredients | `<ingredients><ingredient>…</ingredient></ingredients>` |
| steps | `<steps><step>…</step></steps>` |
| timers | `<timers><timer>…</timer></timers>` |
| categories | `<categories><category>Vegetarisch</category></categories>` |
| tags | `<tags><tag>Schnell</tag></tags>` |
| images | `<images><image>/media/…webp</image></images>` |
| step.ingredients | `<ingredients><ref>huhn</ref></ingredients>` |
Leere Menge: `<amount/>`. Keine Nährwerte: `<nutrition/>`. Boolean: `true`/`false`. Texte standardkonform escapen (`&amp;`, `&lt;` etc.). DOCTYPE und Entity-Deklarationen sind verboten, es werden keine externen Ressourcen aufgelöst. Nach dem XML-Parsing gilt dieselbe Zod-Validierung wie bei JSON.
## Versionsstrategie
Die Hülle `version: 1` ist verbindlich. Inkompatible Änderungen bekommen eine neue Version mit expliziter Konvertierung. Unbekannte Versionen werden abgewiesen. Optionale Felder erhalten dokumentierte Standardwerte. HTML-Inhalte werden nicht übernommen.
+78
View File
@@ -0,0 +1,78 @@
# Prüfnachweise
Stand: **24. Oktober 2026**. Die folgenden Prüfungen wurden tatsächlich ausgeführt, nicht lediglich vorbereitet.
## Ergebnisse
| Prüfung | Ergebnis |
| ---------------------- | ------------------------------------------------------------------ |
| `npm run typecheck` | Erfolgreich, keine TypeScript-Fehler |
| `npm test` | **33/33 Tests bestanden**, 3 Testdateien |
| `npm run build` | Erfolgreicher Next.js-Produktionsbuild, keine Buildwarnungen |
| `npm run test:e2e` | **17/17 Tests bestanden**, keine Retries oder übersprungenen Tests |
| `npm audit --omit=dev` | **0 gemeldete Schwachstellen** zum Prüfzeitpunkt |
| `npm run test:docker` | Erfolgreich, tatsächlicher Linux/ARM64-Compose-Betrieb |
### Fachtests
- 2 → 8 Portionen; 400 → 800 g als Zutatenanker; Rückskalierung und Kommadezimalwerte.
- g/kg, ml/l, TL/EL, inkompatible Dimensionen, optionale Zutaten und Zutaten ohne Menge.
- Ungültige/negative/nichtendliche Eingaben, Division durch null, extreme Faktoren, 100 wiederholte Umskalierungen ohne kumulative Rundung.
- Bruchdarstellung, reine Anzeigerundung, strukturierte Referenzen in Kochtexten.
- Einkaufsliste: kompatible Mengen zusammenführen; verschiedene Einheiten/Notizen getrennt halten.
- Vollständiger JSON- und XML-Roundtrip inklusive optionaler Daten und Escaping.
- Unbekannte Formatversionen, kaputte Referenzen, doppelte IDs, HTML, Path Traversal, XML-Entities/DOCTYPE und Größenlimits.
- Timerstart, Pause, Fortsetzung, serialisierte Wiederherstellung, Ablauf im Hintergrund und korrekte Anzeige direkt beim Start.
### Browser- und API-Integration
Chromium gegen den **Produktionsbuild**, mit echter PostgreSQL-Testdatenbank:
- Öffentliche Sammlung ohne Anmeldung, keine unveröffentlichten Rezepte.
- Live-Zutatensuche und URL-Synchronisierung, Favoriten, Themewechsel und Theme-Persistenz.
- Portionsänderung, direkter Zutatenanker mit kg/g-Umrechnung, skalierte Kochtexte.
- Einkaufsliste und gespeicherter Abhakstatus, Drucklayout ohne Navigation.
- Kochmodus vor/zurück, Zutatenstatus, Timer über Schritte hinweg, Pause/Reset, Reload-Wiederherstellung, Abschluss und Neustart.
- Admin-Anmeldung, Anlage, Bearbeitung, Veröffentlichung, Bild-Upload, lokal wiederhergestellter Editorentwurf, Live-Vorschau, Exportdownload, Löschung und Logout.
- JSON/XML-Import über die grafische Oberfläche, prüfbare Vorschau und Duplikatsperre.
- 401 für unbefugte Schreib-/Exportversuche, 403 bei fremder Origin, Session-Cookie-Eigenschaften, Versionskonflikt 409, Datenvalidierung 422.
- Atomare Importablehnung, XXE-Abweisung und Ablehnung unzulässiger/gefälschter Bilddateien.
- Passwortwechsel mit Widerruf einer anderen Sitzung, Login-Brute-Force-Limit 429.
- Echte Browser-Offlineschaltung: besuchte Kochseite lädt weiter interaktiv, Timer bleibt erhalten, unbekannte Seite zeigt ehrliches Offline-Fallback.
- Editor in Dark Mode einschließlich Zutatenreihenfolge auf allen drei Viewports.
Viewports: Desktop **1440 × 1000**, Smartphone **390 × 664** (Chromium mit iPhone-13-Geräteprofil), Tablet **768 × 1024** (Chromium mit iPad-Mini-Profil). Geräteprofile sind Emulationen; damit wird keine Prüfung auf physischem iOS/Safari behauptet.
### Tatsächlicher Docker-Betrieb
Docker Engine über Colima, Compose 5.6.1; `node:24-bookworm-slim`, `postgres:17-bookworm`, Linux ARM64. Der automatisierte Test erstellt ein getrenntes zufällig benanntes Projekt und testet:
1. Image-Build und `compose up --wait`: App und Datenbank **healthy**.
2. App-UID **1000**, kein Root-App-Prozess.
3. Initialer Admin per CLI mit zufälligem Passwort über stdin.
4. HTTP-Anmeldung, Import von drei vollständigen Rezepten, echter JPEG-Upload mit WebP-Re-Encoding, Rezeptänderung und Gesamtexport.
5. Neustart von App und Datenbank, erneute Healthchecks.
6. Gleiche Rezeptzahl und exakt gleiche Bildbytes nach Neustart; Session weiterhin gültig.
7. `pg_dump -Fc`, Restore in eine neue Datenbank, Kontrolle der wiederhergestellten Rezeptzahl.
8. Wiederholt gestartete Migrationen bleiben idempotent.
Ein erster Docker-Prüflauf deckte einen wiederverwendeten, durch den Containerneustart geschlossenen HTTP-Testsocket auf. Die Testverbindungen werden nun explizit geschlossen; der vollständige anschließende Prüflauf bestand. Es wurden keine Fehler übersprungen und keine Produktfunktionen gemockt.
## Visuelle Inspektion
16 reale Browser-Screenshots liegen in [`docs/screenshots/`](screenshots/): Bibliothek, Rezeptseite, Kochmodus und Favoriten in Desktop/Mobile/Tablet sowie Editor in hellem und dunklem Design.
Tatsächlich angesehen: Desktop- und Mobile-Bibliothek, Tablet-Bibliothek, mobile Rezeptseite, mobiler Kochmodus, Desktop-Editor, mobiler Dark-Mode-Editor und dunkle Favoritenansicht. Dabei wurden eine falsche Suchfeld-Flexrichtung, ein zu schmaler mobiler Infobereich und eine Timer-Anzeigeabweichung nach Start korrigiert. Die aktualisierten Bilder wurden erneut kontrolliert. Browserprüfungen bestätigen fehlenden horizontalen Überlauf in den geprüften Bibliotheks-, Rezept- und Editoransichten.
## Präzise Grenzen
- Kein Test auf physischem Smartphone, Safari oder Firefox; keine tatsächliche iOS-/Android-Installation über das Betriebssystem-Installationsmenü.
- Wake Lock und akustische Signale sind implementiert, aber nicht auf gesperrten physischen Geräten validiert. Browser/OS können Hintergrundseiten suspendieren; Endzeitpunkte bleiben korrekt, ein akustischer Hintergrundalarm ist nicht garantiert.
- Reverse-Proxy-HTTPS, TLS-Zertifikate und Secure-Cookies müssen am Zielhost mit seiner Domain geprüft werden. Die lokale Integration verwendete HTTP.
- Linux ARM64 ist tatsächlich getestet; ein separater AMD64-Deploy wurde nicht durchgeführt.
- Kein externer Penetrationstest, keine Garantie, dass ein Abhängigkeitsaudit jede Schwachstelle erkennt. Es sind aus den durchgeführten Prüfungen keine kritischen Sicherheitsfehler bekannt.
- Editorentwürfe, Favoriten, Einkaufslisten und Kochfortschritt sind bewusst geräte-/browserlokal; kein geräteübergreifender Sync.
- JSON/XML transportiert Rezeptdaten und lokale Bildreferenzen. Bilddateien werden separat über das persistente Volume gesichert und übertragen.
- Offline umfasst tatsächlich gespeicherte öffentliche Ansichten; neue oder aus dem Browsercache entfernte Inhalte benötigen Netz. Administration bleibt onlinepflichtig.
Diese Grenzen sind keine als fertig ausgegebenen ungeprüften Plattformversprechen. Die oben genannten implementierten Kernabläufe wurden in der dokumentierten Umgebung erfolgreich geprüft.
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 966 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+47
View File
@@ -0,0 +1,47 @@
import { defineConfig, devices } from '@playwright/test';
import { randomBytes } from 'node:crypto';
process.env.TEST_PASSWORD ??= randomBytes(24).toString('base64url');
process.env.TEST_DATABASE_URL ??= `postgres://${process.env.USER ?? 'postgres'}@localhost:5432/hamham_test`;
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
workers: 1,
retries: 0,
timeout: 45000,
globalSetup: './tests/setup.ts',
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'http://localhost:3100',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
webServer: {
command: 'npm run start -- --port 3100',
url: 'http://localhost:3100/api/health',
reuseExistingServer: false,
timeout: 60000,
env: {
DATABASE_URL: process.env.TEST_DATABASE_URL,
APP_ORIGIN: 'http://localhost:3100',
COOKIE_SECURE: 'false',
UPLOAD_DIR: './test-results/uploads',
NEXT_TELEMETRY_DISABLED: '1',
},
},
projects: [
{
name: 'desktop',
use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 1000 } },
},
{
name: 'mobile',
testMatch: /public.spec.ts/,
use: { ...devices['iPhone 13'], defaultBrowserType: 'chromium' },
},
{
name: 'tablet',
testMatch: /public.spec.ts/,
use: { ...devices['iPad Mini'], defaultBrowserType: 'chromium' },
},
],
});
+107
View File
@@ -0,0 +1,107 @@
import { execFileSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import assert from 'node:assert/strict';
// Isolated disposable Compose project. Never targets the user's deployment or volumes.
const project = `hamham-check-${randomBytes(4).toString('hex')}`;
const port = process.env.VERIFY_PORT ?? '3200';
const origin = `http://localhost:${port}`;
const password = randomBytes(30).toString('base64url');
const env = {
...process.env,
POSTGRES_PASSWORD: randomBytes(32).toString('hex'),
HOST_PORT: port,
APP_ORIGIN: origin,
COOKIE_SECURE: 'false',
};
const compose = (args: string[], input?: string | Buffer) =>
execFileSync('docker', ['compose', '-p', project, ...args], {
env,
input,
maxBuffer: 32 * 1024 * 1024,
stdio: input === undefined ? ['ignore', 'pipe', 'inherit'] : ['pipe', 'pipe', 'inherit'],
});
let cookie = '';
async function request(path: string, method = 'GET', body?: unknown) {
const response = await fetch(`${origin}/api/${path}`, {
method,
headers: {
Origin: origin,
Cookie: cookie,
Connection: 'close',
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
assert.ok(
response.ok,
`${method} ${path}: ${response.status} ${response.ok ? '' : await response.text()}`,
);
if (response.headers.get('set-cookie'))
cookie = response.headers.get('set-cookie')!.split(';')[0];
return response;
}
try {
console.log(`Compose-Integrationstest: ${project} (${origin})`);
console.log(compose(['up', '-d', '--build', '--wait', '--wait-timeout', '240']).toString());
assert.equal(compose(['exec', '-T', 'app', 'id', '-u']).toString().trim(), '1000');
console.log(
compose(
['exec', '-T', 'app', 'npm', 'run', 'admin:create', '--', 'dockercheck'],
`${password}\n`,
).toString(),
);
await request('auth/login', 'POST', { username: 'dockercheck', password });
const raw = await readFile('public/examples/kochbuch.json', 'utf8');
const recipes = await (
await request('import', 'POST', { raw, format: 'json', preview: false })
).json();
assert.equal(recipes.length, 3);
const imageResponse = await fetch(`${origin}/api/media`, {
method: 'POST',
headers: { Origin: origin, Cookie: cookie, Connection: 'close', 'Content-Type': 'image/jpeg' },
body: await readFile('public/images/garden.jpg'),
});
assert.equal(imageResponse.status, 201);
const { url: image } = await imageResponse.json();
const before = await fetch(`${origin}${image}`, { headers: { Connection: 'close' } }).then((r) =>
r.arrayBuffer(),
);
await request(`recipes/${recipes[0].id}`, 'PUT', { ...recipes[0], image });
assert.equal((await (await request('export')).json()).recipes.length, 3);
console.log('Admin-CLI, Login, Import, Bild-Upload, Rezeptänderung und Export: OK');
console.log(compose(['restart']).toString());
console.log(compose(['up', '-d', '--wait', '--wait-timeout', '120']).toString());
const after = await fetch(`${origin}${image}`, { headers: { Connection: 'close' } }).then((r) =>
r.arrayBuffer(),
);
assert.deepEqual(Buffer.from(after), Buffer.from(before));
assert.equal((await (await request('recipes')).json()).length, 3);
console.log('Persistenz nach Neustart: Datenbank, Sitzung und Bildbytes OK');
const dump = compose(['exec', '-T', 'db', 'pg_dump', '-U', 'hamham', '-d', 'hamham', '-Fc']);
compose(['exec', '-T', 'db', 'createdb', '-U', 'hamham', 'hamham_restore']);
compose(['exec', '-T', 'db', 'pg_restore', '-U', 'hamham', '-d', 'hamham_restore'], dump);
assert.equal(
compose([
'exec',
'-T',
'db',
'psql',
'-U',
'hamham',
'-d',
'hamham_restore',
'-Atc',
'SELECT count(*) FROM recipes',
])
.toString()
.trim(),
'3',
);
console.log('PostgreSQL-Backup/Restore und wiederholte Migrationen: OK');
console.log(compose(['ps']).toString());
} finally {
// Only the unique verification project created above is removed, including its test volumes.
compose(['down', '-v', '--remove-orphans']);
}
+97
View File
@@ -0,0 +1,97 @@
import { test, expect } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import { exportRecipes } from '../../src/lib/import';
import { pasta } from '../fixtures';
test('admin creates and edits recipes with recoverable drafts and image upload', async ({
page,
}) => {
await page.goto('/admin');
await page.getByRole('textbox', { name: 'Benutzername' }).fill('testadmin');
await page.getByLabel('Passwort', { exact: true }).fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Hallo, testadmin.' })).toBeVisible();
await page.getByRole('link', { name: 'Neues Rezept' }).click();
await page
.getByRole('textbox', { name: 'Rezepttitel', exact: true })
.fill('Browser-Küchenrezept');
await page
.getByRole('textbox', { name: 'Beschreibung', exact: true })
.fill('Ein echtes Rezept aus dem grafischen Editor.');
page.on('dialog', (d) => d.accept());
await page.reload();
await expect(page.getByRole('textbox', { name: 'Rezepttitel', exact: true })).toHaveValue(
'Browser-Küchenrezept',
);
await page.getByLabel('Titelbild hochladen').setInputFiles('public/images/garden.jpg');
await expect(page.getByRole('img', { name: 'Titelbild', exact: true })).toBeVisible();
await page.getByRole('checkbox', { name: /Rezept veröffentlichen/ }).check();
await page.getByRole('button', { name: 'Zutaten', exact: true }).click();
await page.getByRole('textbox', { name: 'Zutat 1', exact: true }).fill('Kartoffeln');
await page.getByRole('spinbutton', { name: 'Menge 1', exact: true }).fill('400');
await page.getByRole('button', { name: 'Zutat hinzufügen' }).click();
await page.getByRole('textbox', { name: 'Zutat 2', exact: true }).fill('Salz');
await page.getByRole('combobox', { name: 'Einheit 2', exact: true }).selectOption('');
await page.getByRole('button', { name: 'Zubereitung', exact: true }).click();
await page.getByRole('textbox', { name: 'Schritttitel 1' }).fill('Kartoffeln kochen');
await page.getByRole('textbox', { name: 'Anleitung 1' }).fill('In Salzwasser weich kochen.');
await page.getByRole('button', { name: 'Kartoffeln', exact: true }).click();
await page.getByRole('button', { name: 'Timer hinzufügen' }).click();
await page.getByRole('button', { name: 'Live-Vorschau', exact: true }).click();
await expect(page.locator('.editor-preview')).toContainText('400 g Kartoffeln');
await page.screenshot({ path: 'docs/screenshots/editor-desktop.png', fullPage: true });
await page.getByRole('button', { name: 'Rezept speichern' }).click();
await expect(page).toHaveURL(/\/admin\/rezepte\/[a-f0-9-]{36}$/);
await page.getByRole('button', { name: 'Das Rezept', exact: true }).click();
await page
.getByRole('textbox', { name: 'Rezepttitel', exact: true })
.fill('Browser-Küchenrezept verbessert');
await page.getByRole('button', { name: 'Rezept speichern' }).click();
await expect(page.getByRole('status')).toContainText('erfolgreich gespeichert');
await page.goto('/admin');
await expect(
page.getByRole('heading', { name: 'Browser-Küchenrezept verbessert' }),
).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await page
.getByRole('link', { name: 'Browser-Küchenrezept verbessert als JSON exportieren' })
.click();
const download = await downloadPromise;
const data = JSON.parse(await readFile((await download.path())!, 'utf8'));
expect(data.recipes[0].title).toBe('Browser-Küchenrezept verbessert');
await page.getByRole('button', { name: 'Browser-Küchenrezept verbessert löschen' }).click();
await page.getByRole('button', { name: 'Endgültig löschen' }).click();
await expect(page.getByRole('heading', { name: 'Browser-Küchenrezept verbessert' })).toHaveCount(
0,
);
await page.getByRole('button', { name: 'Abmelden' }).click();
await expect(page.getByRole('heading', { name: 'Willkommen zurück.' })).toBeVisible();
});
test('JSON and XML import with a real preview, duplicate prevention and export', async ({
page,
}) => {
await page.goto('/admin');
await page.getByLabel('Benutzername').fill('testadmin');
await page.getByLabel('Passwort', { exact: true }).fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
for (const format of ['json', 'xml'] as const) {
await page.getByRole('button', { name: 'Importieren', exact: true }).click();
await page.getByLabel('Importformat').selectOption(format);
const recipe = {
...pasta,
id: undefined,
title: `Importierte ${format.toUpperCase()} Pasta`,
published: false,
};
await page.getByLabel('Rezeptdaten').fill(exportRecipes([recipe], format));
await page.getByRole('button', { name: 'Importvorschau prüfen' }).click();
await expect(page.locator('.import-preview')).toContainText(recipe.title);
await expect(page.locator('.import-preview')).toContainText('200 g Pasta');
await page.getByRole('button', { name: '1 Rezept(e) jetzt importieren' }).click();
await expect(page.getByRole('heading', { name: recipe.title })).toBeVisible();
}
await page.getByRole('button', { name: 'Importieren', exact: true }).click();
await page.getByLabel('Rezeptdaten').fill(exportRecipes([pasta], 'json'));
await page.getByRole('button', { name: 'Importvorschau prüfen' }).click();
await expect(page.getByText('Duplikat – Import gesperrt')).toBeVisible();
await expect(page.getByRole('button', { name: '1 Rezept(e) jetzt importieren' })).toBeDisabled();
});
+146
View File
@@ -0,0 +1,146 @@
import { test, expect } from '@playwright/test';
import { pasta } from '../fixtures';
import { exportRecipes } from '../../src/lib/import';
const headers = { origin: 'http://localhost:3100' };
test('authorization, CSRF, optimistic concurrency, validation and transactional imports', async ({
request,
}) => {
expect((await request.post('/api/recipes', { headers, data: pasta })).status()).toBe(401);
expect(
(
await request.post('/api/auth/login', {
headers: { origin: 'https://evil.invalid' },
data: { username: 'testadmin', password: process.env.TEST_PASSWORD },
})
).status(),
).toBe(403);
const login = await request.post('/api/auth/login', {
headers,
data: { username: 'testadmin', password: process.env.TEST_PASSWORD },
});
expect(login.ok()).toBe(true);
expect(login.headers()['set-cookie']).toMatch(/HttpOnly/i);
expect(login.headers()['set-cookie']).toMatch(/SameSite=strict/i);
const created = await request.post('/api/recipes', {
headers,
data: { ...pasta, id: undefined, title: 'API Rezept', published: false },
});
expect(created.status()).toBe(201);
const r = await created.json();
expect(
(
await request.put(`/api/recipes/${r.id}`, {
headers,
data: { ...r, title: 'API Rezept geändert' },
})
).status(),
).toBe(200);
expect(
(
await request.put(`/api/recipes/${r.id}`, {
headers,
data: { ...r, title: 'Veraltete Änderung' },
})
).status(),
).toBe(409);
expect(
(await request.put(`/api/recipes/${r.id}`, { headers, data: { ...r, servings: 0 } })).status(),
).toBe(422);
expect(
(
await request.post('/api/import', {
headers,
data: {
format: 'json',
preview: false,
raw: exportRecipes(
[{ ...pasta, id: undefined, title: 'Transaktion darf nicht bleiben' }, pasta],
'json',
),
},
})
).status(),
).toBe(409);
expect(
(await (await request.get('/api/recipes?admin=1')).json()).some(
(r: { title: string }) => r.title === 'Transaktion darf nicht bleiben',
),
).toBe(false);
expect(
(
await request.post('/api/import', {
headers,
data: {
format: 'xml',
preview: true,
raw: '<!DOCTYPE x [<!ENTITY e SYSTEM "file:///etc/passwd">]><x/>',
},
})
).status(),
).toBe(422);
expect(
(
await request.post('/api/media', {
headers: { ...headers, 'content-type': 'image/svg+xml' },
data: '<svg />',
})
).status(),
).toBe(415);
expect(
(
await request.post('/api/media', {
headers: { ...headers, 'content-type': 'image/png' },
data: 'not a png',
})
).status(),
).toBe(422);
await request.post('/api/auth/logout', { headers });
expect((await request.get(`/api/recipes/${r.id}`)).status()).toBe(404);
expect((await request.get('/api/export')).status()).toBe(401);
expect((await request.delete(`/api/recipes/${r.id}`, { headers })).status()).toBe(401);
});
test('password change revokes other sessions and brute force is rate limited', async ({
request,
playwright,
}) => {
const other = await playwright.request.newContext({ baseURL: 'http://localhost:3100' });
const credentials = { username: 'testadmin', password: process.env.TEST_PASSWORD };
await request.post('/api/auth/login', { headers, data: credentials });
await other.post('/api/auth/login', { headers, data: credentials });
const replacement = `${process.env.TEST_PASSWORD}-changed`;
expect(
(
await request.post('/api/auth/password', {
headers,
data: { current: process.env.TEST_PASSWORD, password: replacement },
})
).status(),
).toBe(200);
expect((await other.get('/api/export')).status()).toBe(401);
expect(
(
await request.post('/api/auth/password', {
headers,
data: { current: replacement, password: process.env.TEST_PASSWORD },
})
).status(),
).toBe(200);
for (let n = 0; n < 8; n++)
expect(
(
await other.post('/api/auth/login', {
headers,
data: { username: 'nonexistent', password: 'wrong' },
})
).status(),
).toBe(401);
expect(
(
await other.post('/api/auth/login', {
headers,
data: { username: 'nonexistent', password: 'wrong' },
})
).status(),
).toBe(429);
await other.dispose();
});
+41
View File
@@ -0,0 +1,41 @@
import { test, expect } from '@playwright/test';
const id = '10000000-0000-4000-8000-000000000001';
test('visited cooking page remains interactive offline and uncached routes show an honest fallback', async ({
page,
context,
}) => {
await page.goto(`/rezepte/${id}/kochen?faktor=2`);
await page.evaluate(() => navigator.serviceWorker.ready);
await expect
.poll(() =>
page.evaluate(async () => {
const cache = await caches.open('hamham-public-v1');
return !!(await cache.match(location.href));
}),
)
.toBe(true);
await page.getByRole('button', { name: 'Nächster Schritt' }).click();
await page.getByRole('button', { name: 'Pinienkerne rösten starten' }).click();
// Wait until the cached HTML's script assets have been stored as well.
await expect
.poll(() =>
page.evaluate(async () => {
const cache = await caches.open('hamham-public-v1');
const scripts = Array.from(document.scripts)
.map((s) => s.src)
.filter(Boolean);
return (await Promise.all(scripts.map((s) => cache.match(s)))).every(Boolean);
}),
)
.toBe(true);
await page.reload();
await context.setOffline(true);
await page.reload();
await expect(page.getByRole('heading', { name: 'Das Pesto zum Leuchten bringen' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Pinienkerne rösten pausieren' })).toBeVisible();
await page.getByRole('button', { name: 'Nächster Schritt' }).click();
await expect(page.getByRole('heading', { name: 'Pasta trifft Pesto' })).toBeVisible();
await page.goto('/rezepte/10000000-0000-4000-8000-999999999999');
await expect(page.getByRole('heading', { name: 'Ein Moment ohne Netz.' })).toBeVisible();
await context.setOffline(false);
});
+129
View File
@@ -0,0 +1,129 @@
import { test, expect } from '@playwright/test';
import { mkdir } from 'node:fs/promises';
const id = '10000000-0000-4000-8000-000000000001';
test('editor remains usable on the current viewport in dark mode', async ({ page }, info) => {
await page.goto('/admin');
await page.getByLabel('Benutzername').fill('testadmin');
await page.getByLabel('Passwort', { exact: true }).fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
await page.getByRole('link', { name: 'Neues Rezept' }).click();
await page.getByRole('button', { name: 'Theme wechseln', exact: true }).click();
await page.getByRole('button', { name: 'Zutaten', exact: true }).click();
await page.getByRole('textbox', { name: 'Zutat 1', exact: true }).fill('Kartoffeln');
await page.getByRole('button', { name: 'Zutat hinzufügen' }).click();
await page.getByRole('textbox', { name: 'Zutat 2', exact: true }).fill('Karotten');
await page.getByRole('button', { name: 'Zutat 2 nach oben' }).click();
await expect(page.getByRole('textbox', { name: 'Zutat 1', exact: true })).toHaveValue('Karotten');
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.screenshot({
path: `docs/screenshots/editor-dark-${info.project.name}.png`,
fullPage: true,
animations: 'disabled',
});
});
test('public library, shareable search, favorites, themes and responsive layouts', async ({
page,
}, info) => {
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Zuhause schmeckt’s am besten.' })).toBeVisible();
await expect(page.locator('.recipe-card')).toHaveCount(3);
await expect(page.getByText('Geheimer Entwurf')).toHaveCount(0);
await page.evaluate(() => document.fonts.ready);
for (const card of await page.locator('.recipe-card').all())
await expect(card).toHaveCSS('opacity', '1');
await mkdir('docs/screenshots', { recursive: true });
await page.screenshot({
path: `docs/screenshots/library-${info.project.name}.png`,
fullPage: true,
animations: 'disabled',
});
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.getByRole('textbox', { name: 'Rezepte suchen' }).fill('Basilikum');
await expect(page.locator('.recipe-card')).toHaveCount(1);
await expect(page).toHaveURL(/q=Basilikum/);
await page.getByRole('button', { name: 'Suche löschen' }).click();
await page.getByRole('button', { name: 'Grüne Pesto-Pasta als Favorit merken' }).click();
const nav =
page.viewportSize()!.width <= 700
? page.getByRole('navigation', { name: 'Mobile Navigation' })
: page.getByRole('navigation', { name: 'Hauptnavigation' });
await nav.getByRole('link', { name: /Favoriten|Lieblingsrezepte/ }).click();
await expect(page.locator('.recipe-card')).toHaveCount(1);
await page.getByRole('button', { name: 'Theme wechseln', exact: true }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect(page.locator('.recipe-card')).toHaveCSS('opacity', '1');
await page.screenshot({
path: `docs/screenshots/favorites-dark-${info.project.name}.png`,
fullPage: true,
animations: 'disabled',
});
await page.reload();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
expect(errors).toEqual([]);
});
test('portion calculator, ingredient anchor, shopping list and print content', async ({
page,
}, info) => {
await page.goto(`/rezepte/${id}`);
await page.getByRole('spinbutton', { name: 'Portionen', exact: true }).fill('8');
await page.getByRole('spinbutton', { name: 'Portionen', exact: true }).press('Tab');
await expect(page.getByRole('button', { name: 'Menge Pasta ändern' })).toHaveText('800 g');
await expect(page.locator('.steps-list')).toContainText('800 g Pasta');
await page.getByRole('button', { name: 'Nach Zutat', exact: true }).click();
await page.getByRole('button', { name: 'Menge Pasta ändern' }).click();
await page.getByRole('textbox', { name: 'Neue Menge Pasta' }).fill('0,4');
await page.getByRole('combobox', { name: 'Einheit Pasta' }).selectOption('kg');
await page.getByRole('button', { name: 'Menge übernehmen' }).click();
await expect(page.getByRole('button', { name: 'Menge Pasta ändern' })).toHaveText('400 g');
await expect(page.locator('.panel-title')).toContainText('FÜR 4 PORTIONEN');
await expect(page.getByRole('button', { name: 'Menge Olivenöl ändern' })).toHaveText('120 ml');
await page.screenshot({
path: `docs/screenshots/recipe-${info.project.name}.png`,
fullPage: true,
});
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
await page.getByRole('button', { name: 'Zur Einkaufsliste hinzufügen' }).click();
await page.goto('/einkaufsliste');
await expect(page.locator('.shopping-list')).toContainText('400 g');
await page.locator('.shopping-item').first().click();
await page.reload();
await expect(page.locator('.shopping-list li.is-checked')).toHaveCount(1);
await page.goto(`/rezepte/${id}`);
await page.emulateMedia({ media: 'print' });
await expect(page.locator('.site-header')).toBeHidden();
await expect(page.locator('.ingredients-panel')).toBeVisible();
});
test('guided cooking restores scaled progress and timestamp timers through navigation', async ({
page,
}, info) => {
await page.goto(`/rezepte/${id}/kochen?faktor=2`);
await expect(page.locator('.cooking-instruction')).toContainText('80 g Basilikum');
await page.locator('.step-ingredients button').first().click();
await page.getByRole('button', { name: 'Nächster Schritt' }).click();
await expect(page.getByRole('heading', { name: 'Das Pesto zum Leuchten bringen' })).toBeVisible();
await page.getByRole('button', { name: 'Pinienkerne rösten starten' }).click();
await expect(page.getByRole('button', { name: 'Pinienkerne rösten pausieren' })).toBeVisible();
await page.screenshot({
path: `docs/screenshots/cooking-${info.project.name}.png`,
fullPage: true,
});
await page.getByRole('button', { name: 'Nächster Schritt' }).click();
await expect(page.getByRole('button', { name: 'Pinienkerne rösten pausieren' })).toBeVisible();
await page.getByRole('button', { name: 'Zurück', exact: true }).click();
await page.reload();
await expect(page.getByRole('heading', { name: 'Das Pesto zum Leuchten bringen' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Pinienkerne rösten pausieren' })).toBeVisible();
await page.getByRole('button', { name: 'Pinienkerne rösten pausieren' }).click();
await page.getByRole('button', { name: 'Pinienkerne rösten zurücksetzen' }).click();
await expect(page.getByRole('timer')).toHaveText('03:00');
await page.getByRole('button', { name: 'Nächster Schritt' }).click();
await page.getByRole('button', { name: 'Nächster Schritt' }).click();
await page.getByRole('button', { name: 'Fertig gekocht' }).click();
await expect(page.getByRole('heading', { name: 'Guten Appetit!' })).toBeVisible();
await page.reload();
await expect(page.getByRole('heading', { name: 'Guten Appetit!' })).toBeVisible();
await page.getByRole('button', { name: 'Erneut kochen' }).click();
await expect(page.getByRole('heading', { name: 'Ein bisschen Vorbereitung' })).toBeVisible();
});
+23
View File
@@ -0,0 +1,23 @@
import { execFileSync } from 'node:child_process';
import postgres from 'postgres';
import { hash } from '@node-rs/argon2';
import { examples } from './fixtures';
export default async function setup() {
const url = process.env.TEST_DATABASE_URL!;
if (!new URL(url).pathname.endsWith('_test'))
throw new Error('E2E-Datenbank muss auf _test enden.');
execFileSync(process.execPath, ['node_modules/tsx/dist/cli.mjs', 'scripts/migrate.ts'], {
env: { ...process.env, DATABASE_URL: url },
stdio: 'inherit',
});
const sql = postgres(url, { max: 1 });
try {
await sql`TRUNCATE sessions, admins, recipes, media, login_limits CASCADE`;
await sql`INSERT INTO admins (username, password) VALUES ('testadmin', ${await hash(process.env.TEST_PASSWORD!, { algorithm: 2, memoryCost: 65536, timeCost: 3 })})`;
for (const r of examples)
await sql`INSERT INTO recipes (id, title, title_key, published, data) VALUES (${r.id!}, ${r.title}, ${r.title.toLowerCase()}, true, ${sql.json(r)})`;
await sql`INSERT INTO recipes (title, title_key, published, data) VALUES ('Geheimer Entwurf', 'geheimer entwurf', false, ${sql.json({ ...examples[0], id: undefined, title: 'Geheimer Entwurf', published: false })})`;
} finally {
await sql.end();
}
}