diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1f5e741 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.next +.git +.env* +uploads +test-results +playwright-report +coverage +docs/screenshots +*.tsbuildinfo diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f5458fb --- /dev/null +++ b/.env.example @@ -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 diff --git a/.prettierignore b/.prettierignore index 5e1beee..34516d1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ node_modules +next-env.d.ts .next package-lock.json public/examples diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1744384 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..68df1df --- /dev/null +++ b/README.md @@ -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). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..66abb06 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/ASSETS.md b/docs/ASSETS.md new file mode 100644 index 0000000..bda0e1f --- /dev/null +++ b/docs/ASSETS.md @@ -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. diff --git a/docs/CHATGPT-PROMPT.md b/docs/CHATGPT-PROMPT.md new file mode 100644 index 0000000..a77c554 --- /dev/null +++ b/docs/CHATGPT-PROMPT.md @@ -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. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..8c9dd42 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -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. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..6cc9579 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -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. diff --git a/docs/IMPORT.md b/docs/IMPORT.md new file mode 100644 index 0000000..38bc8a6 --- /dev/null +++ b/docs/IMPORT.md @@ -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/.webp` oder mitgelieferte `/images/.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 ``, darunter `…`. Rezeptfelder entsprechen JSON. Array-Container: + +| JSON | XML | +| ---------------- | ----------------------------------------------------------- | +| recipes | `…` | +| ingredients | `…` | +| steps | `…` | +| timers | `…` | +| categories | `Vegetarisch` | +| tags | `Schnell` | +| images | `/media/…webp` | +| step.ingredients | `huhn` | + +Leere Menge: ``. Keine Nährwerte: ``. Boolean: `true`/`false`. Texte standardkonform escapen (`&`, `<` 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. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md new file mode 100644 index 0000000..7e59fd3 --- /dev/null +++ b/docs/VERIFICATION.md @@ -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. diff --git a/docs/screenshots/cooking-desktop.png b/docs/screenshots/cooking-desktop.png new file mode 100644 index 0000000..96c6cc0 Binary files /dev/null and b/docs/screenshots/cooking-desktop.png differ diff --git a/docs/screenshots/cooking-mobile.png b/docs/screenshots/cooking-mobile.png new file mode 100644 index 0000000..1f377fd Binary files /dev/null and b/docs/screenshots/cooking-mobile.png differ diff --git a/docs/screenshots/cooking-tablet.png b/docs/screenshots/cooking-tablet.png new file mode 100644 index 0000000..0a01a41 Binary files /dev/null and b/docs/screenshots/cooking-tablet.png differ diff --git a/docs/screenshots/editor-dark-desktop.png b/docs/screenshots/editor-dark-desktop.png new file mode 100644 index 0000000..95d814a Binary files /dev/null and b/docs/screenshots/editor-dark-desktop.png differ diff --git a/docs/screenshots/editor-dark-mobile.png b/docs/screenshots/editor-dark-mobile.png new file mode 100644 index 0000000..1635290 Binary files /dev/null and b/docs/screenshots/editor-dark-mobile.png differ diff --git a/docs/screenshots/editor-dark-tablet.png b/docs/screenshots/editor-dark-tablet.png new file mode 100644 index 0000000..1d91b9c Binary files /dev/null and b/docs/screenshots/editor-dark-tablet.png differ diff --git a/docs/screenshots/editor-desktop.png b/docs/screenshots/editor-desktop.png new file mode 100644 index 0000000..f77bc34 Binary files /dev/null and b/docs/screenshots/editor-desktop.png differ diff --git a/docs/screenshots/favorites-dark-desktop.png b/docs/screenshots/favorites-dark-desktop.png new file mode 100644 index 0000000..4dba6ea Binary files /dev/null and b/docs/screenshots/favorites-dark-desktop.png differ diff --git a/docs/screenshots/favorites-dark-mobile.png b/docs/screenshots/favorites-dark-mobile.png new file mode 100644 index 0000000..7be9ab6 Binary files /dev/null and b/docs/screenshots/favorites-dark-mobile.png differ diff --git a/docs/screenshots/favorites-dark-tablet.png b/docs/screenshots/favorites-dark-tablet.png new file mode 100644 index 0000000..8be7d7b Binary files /dev/null and b/docs/screenshots/favorites-dark-tablet.png differ diff --git a/docs/screenshots/library-desktop.png b/docs/screenshots/library-desktop.png new file mode 100644 index 0000000..639110a Binary files /dev/null and b/docs/screenshots/library-desktop.png differ diff --git a/docs/screenshots/library-mobile.png b/docs/screenshots/library-mobile.png new file mode 100644 index 0000000..166d117 Binary files /dev/null and b/docs/screenshots/library-mobile.png differ diff --git a/docs/screenshots/library-tablet.png b/docs/screenshots/library-tablet.png new file mode 100644 index 0000000..1554143 Binary files /dev/null and b/docs/screenshots/library-tablet.png differ diff --git a/docs/screenshots/recipe-desktop.png b/docs/screenshots/recipe-desktop.png new file mode 100644 index 0000000..285f49c Binary files /dev/null and b/docs/screenshots/recipe-desktop.png differ diff --git a/docs/screenshots/recipe-mobile.png b/docs/screenshots/recipe-mobile.png new file mode 100644 index 0000000..fae5a04 Binary files /dev/null and b/docs/screenshots/recipe-mobile.png differ diff --git a/docs/screenshots/recipe-tablet.png b/docs/screenshots/recipe-tablet.png new file mode 100644 index 0000000..c3cf99f Binary files /dev/null and b/docs/screenshots/recipe-tablet.png differ diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..37fb5c3 --- /dev/null +++ b/playwright.config.ts @@ -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' }, + }, + ], +}); diff --git a/scripts/verify-docker.ts b/scripts/verify-docker.ts new file mode 100644 index 0000000..77cc602 --- /dev/null +++ b/scripts/verify-docker.ts @@ -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']); +} diff --git a/tests/e2e/admin.spec.ts b/tests/e2e/admin.spec.ts new file mode 100644 index 0000000..ff60d17 --- /dev/null +++ b/tests/e2e/admin.spec.ts @@ -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(); +}); diff --git a/tests/e2e/api.spec.ts b/tests/e2e/api.spec.ts new file mode 100644 index 0000000..58428d6 --- /dev/null +++ b/tests/e2e/api.spec.ts @@ -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: ']>', + }, + }) + ).status(), + ).toBe(422); + expect( + ( + await request.post('/api/media', { + headers: { ...headers, 'content-type': 'image/svg+xml' }, + data: '', + }) + ).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(); +}); diff --git a/tests/e2e/offline.spec.ts b/tests/e2e/offline.spec.ts new file mode 100644 index 0000000..464d533 --- /dev/null +++ b/tests/e2e/offline.spec.ts @@ -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); +}); diff --git a/tests/e2e/public.spec.ts b/tests/e2e/public.spec.ts new file mode 100644 index 0000000..9a01c91 --- /dev/null +++ b/tests/e2e/public.spec.ts @@ -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(); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..bb6fbee --- /dev/null +++ b/tests/setup.ts @@ -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(); + } +}