From dea904814b625423ff8d59d7165970a503d25026 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Wed, 26 Aug 2026 19:02:51 +0200 Subject: [PATCH] fix(backend): Neustart gegen die eigene Datenbank, /info als Lebendprobe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Dienst lief einmal und stuerzte danach in einer Schleife: MODE=PostgreSQL laesst H2 unquotierte Bezeichner klein anlegen, Liquibase sucht seine Verwaltungstabellen gross, findet nichts, legt sie an — "Table databasechangelog already exists". Der erste Start ging, jeder weitere nicht. Gemessen mit dem echten Jar: ohne den Modus laufen beide. Liquibase auf Kleinschreibung zu konfigurieren half nicht, es korrigiert den Namen selbst zurueck — deshalb weicht der Modus ganz. Die Testsuite konnte das nicht finden (jeder Test bekommt eine frische In-Memory-DB), und der Regressionstest dafuer hat zweimal gelogen: erst reichte er die URL als Default-Property herein, die die application.yaml ueberstimmt; dann als Argument, aber damit pruefte er eine URL, die er sich selbst ausgedacht hatte. Jetzt hat die URL einen Regler (werkbaum.data-dir), der Test ueberschreibt nur den, und die Gegenprobe faellt. Dieselbe Sorte Fehler eine Ebene hoeher: Die Testkonfiguration hiess application.yaml und verdeckte damit die Hauptkonfiguration vollstaendig. Sie ist jetzt eine Profil-Ueberlagerung. Dazu GET /api/v1/info mit Name, Version und Bauzeitpunkt. Die Lebendprobe erwartete bisher eine 404 von einem Dokument, das es nicht gibt — ein erwarteter Fehler ist eine schlechte Zusicherung, dieselbe 404 liefert auch ein falsch konfigurierter Proxy. 138 Backend-Tests. Gegenprobe: MODE=PostgreSQL zurueck -> genau der Neustart-Test faellt. Co-Authored-By: Claude Opus 5 --- README.md | 12 +- backend/README.md | 44 +++++- backend/build.gradle.kts | 6 + .../de/werkbaum/api/DocumentsController.kt | 24 +++ backend/src/main/resources/application.yaml | 10 +- backend/src/main/resources/openapi/api.yaml | 32 ++++ .../test/kotlin/de/werkbaum/RestartTest.kt | 53 +++++++ .../werkbaum/api/MasterPasswordDefaultTest.kt | 6 +- .../bdd/CucumberSpringConfiguration.kt | 2 + .../werkbaum/bdd/DocumentStepDefinitions.kt | 16 ++ .../src/test/resources/application-test.yaml | 20 +++ backend/src/test/resources/application.yaml | 27 ---- .../test/resources/features/dokumente.feature | 9 ++ docs/DECISIONS.md | 62 ++++++++ docs/examples/werkbaum.werkbaum | 12 +- scripts/deploy-backend.sh | 16 +- scripts/reset-password.sh | 137 ++++++++++++++++++ 17 files changed, 436 insertions(+), 52 deletions(-) create mode 100644 backend/src/test/kotlin/de/werkbaum/RestartTest.kt create mode 100644 backend/src/test/resources/application-test.yaml delete mode 100644 backend/src/test/resources/application.yaml create mode 100755 scripts/reset-password.sh diff --git a/README.md b/README.md index 2de1f45..1bd2e4d 100644 --- a/README.md +++ b/README.md @@ -270,9 +270,15 @@ The backend is a separate deploy, because it is a service rather than files: ```bash scripts/install-jdk.sh # once: a JDK 21 into the server's home scripts/deploy-backend.sh # build, upload, systemd user unit, restart +scripts/reset-password.sh # asks for the master password, hashes it there scripts/deploy-prod.sh # the editor — also writes the /api/ proxy rule ``` +`GET /api/v1/info` answers with name, version and build time — that is the +liveness check, for the deploy and for monitoring. Expecting a **404** from a +document that does not exist would be a poor assurance: a misconfigured proxy +returns one too. + Configuration lives in the git-ignored `.env` (template `.env.example`): `BACKEND_SSH`, and optionally `BACKEND_DIR`, `BACKEND_JDK_DIR`, `BACKEND_PORT`, `BACKEND_XMX`. @@ -292,8 +298,10 @@ Configuration lives in the git-ignored `.env` (template `.env.example`): - **The master password never leaves the server.** It goes into `/env` (mode 600), which the deploy creates empty on the first run. Until a hash is in it, the document list stays locked — deliberately. - Generate it **interactively** (`htpasswd -nBC 12 ''`, no `-b`): a password on - a command line lands in the shell history, and the shell mangles it on the + `scripts/reset-password.sh` asks for it, sends it to the server over + **stdin** (never as an argument — those show up in the process list) and + verifies afterwards that hash and password match. Never put a password on a + command line: it lands in the shell history, and the shell mangles it on the way — `ge$heim` becomes `ge`. What gets hashed is then not what you type later, and the server answers 401 while everything looks right. - **Memory is the scarce resource on that host**, not CPU. The JVM flags are diff --git a/backend/README.md b/backend/README.md index 0f9940c..8c784cf 100644 --- a/backend/README.md +++ b/backend/README.md @@ -218,21 +218,47 @@ alles richtig aussieht. ## Persistenz -- **H2 im File-Modus** (`./data/editor.mv.db`) mit `MODE=PostgreSQL` – - läuft im Server-Prozess, keine Datenbank-Installation nötig. Dokumente und - Historie überleben einen Neustart. +- **H2 im File-Modus** (`${werkbaum.data-dir:./data}/editor.mv.db`) – läuft im + Server-Prozess, keine Datenbank-Installation nötig. Dokumente und Historie + überleben einen Neustart. +- **Bewusst ohne `MODE=PostgreSQL`**, obwohl es naheliegt: In dem Modus legt H2 + unquotierte Bezeichner klein an, Liquibase sucht seine Verwaltungstabellen + aber groß — findet nichts, legt sie an, und H2 antwortet „Table + databasechangelog already exists". Der erste Start ging, **jeder weitere + stürzte ab**. Gemessen und in D77 begründet. +- Die JDBC-URL hat **einen** Regler: `werkbaum.data-dir`. Der Regressionstest + überschreibt nur den, damit alles Übrige an der ausgelieferten URL unter + Test steht. - **Schema per Liquibase im formatierten SQL-Format** (`src/main/resources/db/changelog/db.changelog-master.sql`, kein XML). Neue Änderungen werden als weitere `--changeset`-Blöcke angehängt; Hibernate validiert nur (`ddl-auto: validate`). - **Umstieg auf echtes PostgreSQL:** im Wesentlichen JDBC-URL/Credentials in der `application.yaml` tauschen und den Postgres-Treiber als Dependency - ergänzen – Schema-Migrationen und Code bleiben unverändert. -- Tests laufen gegen H2 in-memory (`src/test/resources/application.yaml`), - mit demselben Liquibase-Schema. + ergänzen – Schema-Migrationen und Code bleiben unverändert. (Der + H2-PostgreSQL-Modus war ein Nachbau davon und ist es nicht wert, siehe oben.) +- Tests laufen gegen H2 in-memory. Die Testkonfiguration heißt + `application-test.yaml` und ist eine **Profil-Überlagerung**: Gleichnamig + (`application.yaml`) verdeckte sie die Hauptkonfiguration vollständig, und + die Tests prüften eine Konfiguration, die in Produktion nie läuft. - Service-Methoden sind `@Transactional`: Dokument-Änderung und Historieneintrag werden atomar geschrieben. +## Läuft er? `GET /api/v1/info` + +```json +{"name":"editor-backend","version":"0.1.0-SNAPSHOT","builtAt":"2026-08-26T16:59:40.341Z"} +``` + +Offen, ohne Nebenwirkung, und sagt zugleich, **welcher Stand** läuft. Vorher +war die Lebendprobe eine Anfrage nach einem nicht existierenden Dokument mit +der Erwartung **404** — ein erwarteter *Fehler* ist eine schlechte +Zusicherung, weil ihn auch ein falsch konfigurierter Proxy liefert. + +Die Angaben kommen aus `META-INF/build-info.properties` +(`springBoot { buildInfo() }`, Teil des Boot-Plugins). Fehlt die Datei — etwa +beim Start aus der IDE —, steht dort `unbekannt` statt eines Fehlers. + ## Betrieb auf der stabilen Instanz `scripts/deploy-backend.sh` (im Repo-Wurzelordner) baut das Fat-Jar, legt es @@ -244,8 +270,10 @@ nur auf `127.0.0.1` — von außen kommt man über die Proxy-Regel in - **Speicher:** `-Xmx192m -Xms48m` plus Freiraum-Verhältnisse; gemessen rund 174 MB RSS gegen 291 MB ohne Angaben. Nach einem GC leben ~45 MB. Zu wenig Luft? `BACKEND_XMX` in `.env`. -- **Master-Passwort:** `/env` auf dem Server, Modus 600. Ohne - Hash bleibt `GET /documents` gesperrt. +- **Master-Passwort:** `scripts/reset-password.sh` fragt es verdeckt ab, hasht + es auf dem Server und prüft selbst nach, ob Hash und Passwort zueinander + passen. Gespeichert wird nur der Hash (`/env`, Modus 600); ohne + ihn bleibt `GET /documents` gesperrt. - **Datenbank:** H2 im Dateimodus unter `/data/`. Ein Deploy fasst das Verzeichnis nicht an (kein `--delete`). Umstieg auf das dort laufende PostgreSQL: JDBC-URL in der `application.yaml` tauschen und den diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 612a5ef..83be3a1 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -11,6 +11,12 @@ plugins { group = "de.werkbaum" version = "0.1.0-SNAPSHOT" +// Erzeugt META-INF/build-info.properties (Teil des Boot-Plugins, keine neue +// Abhaengigkeit) - daraus speist sich GET /api/v1/info. +springBoot { + buildInfo() +} + java { toolchain { languageVersion = JavaLanguageVersion.of(21) diff --git a/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt b/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt index b526f20..ce924ff 100644 --- a/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt +++ b/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt @@ -10,6 +10,7 @@ import de.werkbaum.generated.model.DocumentCreateRequest import de.werkbaum.generated.model.DocumentHistoryEntry as ApiHistoryEntry import de.werkbaum.generated.model.DocumentUpdateRequest import de.werkbaum.generated.model.RestoreRequest +import de.werkbaum.generated.model.ServiceInfo import de.werkbaum.domain.ChangeAuthor import de.werkbaum.domain.ChangeEvent import de.werkbaum.domain.ChangeFeed @@ -18,6 +19,7 @@ import de.werkbaum.domain.Document import de.werkbaum.domain.DocumentHistoryEntry import de.werkbaum.service.DocumentService import de.werkbaum.service.LiveEditingService +import org.springframework.boot.info.BuildProperties import org.springframework.http.CacheControl import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity @@ -36,8 +38,30 @@ import java.util.UUID class DocumentsController( private val service: DocumentService, private val liveEditing: LiveEditingService, + /** + * Aus `META-INF/build-info.properties` (Gradle: `springBoot { buildInfo() }`). + * Bewusst optional: Wer die Anwendung aus der IDE startet, hat die Datei + * nicht — dann fehlt die Zusatzangabe, statt dass der Start scheitert. + */ + private val buildProperties: BuildProperties? = null, ) : DocumentsApi { + /** + * Lebendprobe: läuft der Dienst, und welcher Stand ist es? + * + * Offen und ohne Nebenwirkung. Vorher musste die Prüfung eine Anfrage nach + * einem nicht existierenden Dokument stellen und auf **404** hoffen — ein + * erwarteter Fehler ist eine schlechte Zusicherung, weil ihn auch ein + * falsch konfigurierter Proxy liefert. + */ + override fun getInfo(): ResponseEntity = ResponseEntity.ok( + ServiceInfo( + name = buildProperties?.name ?: "werkbaum-backend", + version = buildProperties?.version ?: "unbekannt", + builtAt = buildProperties?.time?.atOffset(java.time.ZoneOffset.UTC), + ) + ) + override fun listDocuments(): ResponseEntity> = ResponseEntity.ok(service.findAll().map { it.toApi() }) diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index f7ea50e..7a84dec 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -3,9 +3,13 @@ spring: name: editor-backend datasource: - # H2 im File-Modus mit PostgreSQL-Kompatibilitaet. - # Spaeterer Umstieg auf echtes PostgreSQL = im Wesentlichen nur diese URL aendern. - url: jdbc:h2:file:./data/editor;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH + # H2 im File-Modus. Bewusst OHNE MODE=PostgreSQL, obwohl das naheliegt: + # In dem Modus legt H2 unquotierte Bezeichner klein an, Liquibase sucht + # seine Verwaltungstabellen aber gross - findet nichts, legt sie an, und H2 + # antwortet "Table databasechangelog already exists". Folge: Der erste + # Start geht, JEDER WEITERE stuerzt ab. Gemessen und in D77 begruendet; + # der Umstieg auf echtes PostgreSQL bleibt eine Frage von URL und Treiber. + url: jdbc:h2:file:${werkbaum.data-dir:./data}/editor;DEFAULT_NULL_ORDERING=HIGH username: sa password: "" driver-class-name: org.h2.Driver diff --git a/backend/src/main/resources/openapi/api.yaml b/backend/src/main/resources/openapi/api.yaml index cd47dac..7cd4789 100644 --- a/backend/src/main/resources/openapi/api.yaml +++ b/backend/src/main/resources/openapi/api.yaml @@ -313,6 +313,25 @@ paths: "404": $ref: "#/components/responses/NotFound" + /info: + get: + tags: [Documents] + operationId: getInfo + summary: Name und Version des Dienstes + description: > + Offen und ohne Nebenwirkung - gedacht als Lebendprobe fuer Deploy und + Ueberwachung. Ohne diesen Endpunkt bliebe dafuer nur eine Anfrage nach + einem Dokument, das es nicht gibt, und man muesste auf eine 404 hoffen: + Ein erwarteter Fehler ist eine schlechte Zusicherung, weil ihn auch ein + falsch konfigurierter Proxy liefert. + responses: + "200": + description: Der Dienst laeuft + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceInfo" + components: responses: NotFound: @@ -565,6 +584,19 @@ components: items: $ref: "#/components/schemas/ChangeEvent" + ServiceInfo: + type: object + required: [name, version] + properties: + name: + type: string + version: + type: string + builtAt: + type: string + format: date-time + description: Fehlt, wenn ohne Build-Informationen gestartet (z. B. aus der IDE). + ProblemDetail: type: object description: Fehlerformat nach RFC 9457 (Problem Details) diff --git a/backend/src/test/kotlin/de/werkbaum/RestartTest.kt b/backend/src/test/kotlin/de/werkbaum/RestartTest.kt new file mode 100644 index 0000000..70b4db8 --- /dev/null +++ b/backend/src/test/kotlin/de/werkbaum/RestartTest.kt @@ -0,0 +1,53 @@ +package de.werkbaum + +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.springframework.boot.builder.SpringApplicationBuilder +import java.nio.file.Path + +/** + * Die Anwendung muss **gegen ihre eigene Datenbank neu starten** können. + * + * Klingt selbstverständlich und war es nicht: `DATABASE_TO_LOWER=TRUE` lässt + * H2 unquotierte Bezeichner klein anlegen (wie PostgreSQL, dafür steht es in + * der URL), Liquibase sucht seine Verwaltungstabellen aber unter + * `DATABASECHANGELOG`, findet nichts und legt sie an — woraufhin H2 + * „Table databasechangelog already exists" sagt. Der **erste** Start ging, + * jeder weitere stürzte ab. + * + * Gefunden hat das erst der Server, nicht die Testsuite: Jeder Test bekommt + * eine frische In-Memory-Datenbank, und auch von Hand gestartet wurde immer + * gegen ein leeres Verzeichnis. Der Fall „starte noch einmal" kam schlicht nie + * vor — bis der erste Deploy ihn zum Regelfall machte (D77-Nachtrag). + * + * Deshalb hier eine **Datei**-Datenbank und zwei Starts nacheinander. Der Test + * kostet zwei Kontext-Starts; das ist er wert. + */ +class RestartTest { + + @Test + fun `startet auch gegen eine bestehende Datenbank`(@TempDir dir: Path) { + repeat(2) { lauf -> + val context = SpringApplicationBuilder(EditorBackendApplication::class.java) + .run( + // Überschrieben wird **nur das Verzeichnis**, nicht die + // ganze JDBC-URL: Sonst prüfte der Test eine URL, die er + // sich selbst ausgedacht hat, und die ausgelieferte bliebe + // ungeprüft — genau daran ist der erste Anlauf gescheitert. + // + // Und als **Argument**, nicht als `properties(...)`: + // Letztere sind Default-Properties mit der NIEDRIGSTEN + // Priorität, die `application.yaml` überstimmt sie. + "--werkbaum.data-dir=$dir", + "--server.port=0", + "--werkbaum.master-password.hash={noop}egal", + ) + try { + context.isRunning shouldBe true + } finally { + context.close() + } + } + } +} diff --git a/backend/src/test/kotlin/de/werkbaum/api/MasterPasswordDefaultTest.kt b/backend/src/test/kotlin/de/werkbaum/api/MasterPasswordDefaultTest.kt index d1007e0..61e76bb 100644 --- a/backend/src/test/kotlin/de/werkbaum/api/MasterPasswordDefaultTest.kt +++ b/backend/src/test/kotlin/de/werkbaum/api/MasterPasswordDefaultTest.kt @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.TestPropertySource import org.springframework.test.web.servlet.client.RestTestClient import java.util.Base64 @@ -18,6 +19,7 @@ import java.util.Base64 * UUID offen da, und das Zugriffsmodell (D76) wäre hinfällig — ohne dass es * jemandem auffiele. */ +@ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureRestTestClient @TestPropertySource( @@ -26,8 +28,8 @@ import java.util.Base64 // Eigene Datenbank: Dieser Test braucht einen zweiten Spring-Kontext, // und zwei Kontexte auf derselben In-Memory-H2 stolpern uebereinander // (Liquibase legt DATABASECHANGELOG ein zweites Mal an). - "spring.datasource.url=jdbc:h2:mem:editor-master-pw;MODE=PostgreSQL;" + - "DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1", + "spring.datasource.url=jdbc:h2:mem:editor-master-pw;" + + "DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1", ] ) class MasterPasswordDefaultTest { diff --git a/backend/src/test/kotlin/de/werkbaum/bdd/CucumberSpringConfiguration.kt b/backend/src/test/kotlin/de/werkbaum/bdd/CucumberSpringConfiguration.kt index fe79059..2f3c538 100644 --- a/backend/src/test/kotlin/de/werkbaum/bdd/CucumberSpringConfiguration.kt +++ b/backend/src/test/kotlin/de/werkbaum/bdd/CucumberSpringConfiguration.kt @@ -8,7 +8,9 @@ import io.cucumber.spring.CucumberContextConfiguration import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +@ActiveProfiles("test") @CucumberContextConfiguration @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // Seit Boot 4 stellt @SpringBootTest die Test-Client-Bean nicht mehr von selbst bereit diff --git a/backend/src/test/kotlin/de/werkbaum/bdd/DocumentStepDefinitions.kt b/backend/src/test/kotlin/de/werkbaum/bdd/DocumentStepDefinitions.kt index 073def4..38a308f 100644 --- a/backend/src/test/kotlin/de/werkbaum/bdd/DocumentStepDefinitions.kt +++ b/backend/src/test/kotlin/de/werkbaum/bdd/DocumentStepDefinitions.kt @@ -71,6 +71,22 @@ class DocumentStepDefinitions { lastResponse = listDocuments("test-geheim") } + @Wenn("ich die Info des Dienstes abrufe") + fun `ich rufe die Info ab`() { + lastResponse = client.get() + .uri("/api/v1/info") + .exchange() + .returnResult(String::class.java) + } + + @Und("die Antwort nennt einen Namen und eine Version") + fun `die Antwort nennt Name und Version`() { + withClue("Antwort: ${body()}") { + body() shouldContain "\"name\"" + body() shouldContain "\"version\"" + } + } + @Wenn("ich alle Dokumente ohne Master-Passwort abrufe") fun `ich rufe alle Dokumente ohne Passwort ab`() { lastResponse = client.get() diff --git a/backend/src/test/resources/application-test.yaml b/backend/src/test/resources/application-test.yaml new file mode 100644 index 0000000..f7e4ff6 --- /dev/null +++ b/backend/src/test/resources/application-test.yaml @@ -0,0 +1,20 @@ +# Nur die Abweichungen fuer Tests. Bewusst `application-test.yaml` und nicht +# `application.yaml`: Gleichnamig VERDECKT die Testdatei die Hauptkonfiguration +# vollstaendig, und dann pruefen die Tests eine Konfiguration, die in +# Produktion nie laeuft. Als Profil-Ueberlagerung laedt Spring erst die +# Hauptdatei und legt diese darueber - Fehler wie die Liquibase-Schreibweise +# (D77-Nachtrag) fallen so im Test auf statt beim zweiten Start am Server. +spring: + datasource: + url: jdbc:h2:mem:editor-test;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1 + +werkbaum: + live-editing: + # Kurz, damit die Behavior-Tests nicht auf die Produktionswerte warten. + max-wait: 5s + + master-password: + # {noop} = Klartext. Nur im Test; in Produktion steht hier {bcrypt}$2a$... + hash: "{noop}test-geheim" + max-attempts: 3 + lockout: 2s diff --git a/backend/src/test/resources/application.yaml b/backend/src/test/resources/application.yaml deleted file mode 100644 index a06821a..0000000 --- a/backend/src/test/resources/application.yaml +++ /dev/null @@ -1,27 +0,0 @@ -spring: - datasource: - url: jdbc:h2:mem:editor-test;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1 - username: sa - password: "" - driver-class-name: org.h2.Driver - jpa: - hibernate: - ddl-auto: validate - open-in-view: false - liquibase: - change-log: classpath:db/changelog/db.changelog-master.sql - - threads: - virtual: - enabled: true - -werkbaum: - live-editing: - # Kurz, damit die Behavior-Tests nicht auf die Produktionswerte warten. - max-wait: 5s - - master-password: - # {noop} = Klartext. Nur im Test; in Produktion steht hier {bcrypt}$2a$... - hash: "{noop}test-geheim" - max-attempts: 3 - lockout: 2s diff --git a/backend/src/test/resources/features/dokumente.feature b/backend/src/test/resources/features/dokumente.feature index 8d7eb24..c5048eb 100644 --- a/backend/src/test/resources/features/dokumente.feature +++ b/backend/src/test/resources/features/dokumente.feature @@ -4,6 +4,15 @@ Funktionalität: Dokumente verwalten möchte ich Dokumente anlegen, abrufen, ändern und löschen können, damit das Backend die Grundlage für den Editor bildet. + Szenario: Der Dienst sagt, wer er ist + Wenn ich die Info des Dienstes abrufe + Dann erhalte ich den Status 200 + Und die Antwort nennt einen Namen und eine Version + + Szenario: Die Info ist ohne Master-Passwort zu haben + Wenn ich die Info des Dienstes abrufe + Dann erhalte ich den Status 200 + Szenario: Ein neues Dokument anlegen Wenn ich ein Dokument mit dem Titel "Notizen" und dem Inhalt "Hallo Welt" anlege Dann erhalte ich den Status 201 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a4c9e79..5ab55fc 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6386,6 +6386,68 @@ nichts. Der Test hat bewiesen, dass die richtigen Pfade *übergeben* werden, nicht dass die Gegenseite sie versteht. Dieselbe Grenze wie in D25 und D72, nur eine Ebene tiefer: Ein Stub prüft die eigene Seite der Naht. +**Nachtrag beim ersten Betrieb: `MODE=PostgreSQL` verhindert den Neustart.** +Der Dienst lief einmal und stürzte danach in einer Schleife: Liquibase legt +seine Verwaltungstabelle an, H2 antwortet „Table databasechangelog already +exists". In dem Modus schreibt H2 unquotierte Bezeichner **klein** (wie +PostgreSQL, dafür stand er in der URL); Liquibase sucht sie **groß**, findet +nichts und legt sie neu an. Der erste Start ging, jeder weitere nicht. + +Gemessen, mit dem echten Jar und je frischem Verzeichnis: + +| URL | zweiter Start | +|---|---| +| `MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE` (bisher) | stürzt ab | +| dito, Liquibase-Tabellen kleingeschrieben konfiguriert | stürzt ab | +| **ohne `MODE=PostgreSQL`** | **läuft** | + +Die zweite Zeile ist der Grund, warum der Modus ganz weicht statt Liquibase +umkonfiguriert zu werden: Liquibase korrigiert den Namen selbst wieder auf +Großschreibung. Verloren geht wenig — der Modus ließ H2 wie PostgreSQL +*aussehen*, das Schema kommt aber ohnehin aus einem Liquibase-Changelog, und +der Umstieg auf echtes PostgreSQL bleibt eine Frage von URL und Treiber. + +**Warum die Testsuite das nicht gefunden hat, und was daraus folgt.** Jeder +Test bekam eine frische In-Memory-Datenbank; „starte noch einmal" kam nie vor. +Der Regressionstest dafür hat mich dann **zweimal hintereinander belogen**, und +beide Male auf dieselbe Art — er prüfte etwas anderes, als er behauptete: + +1. Er reichte die JDBC-URL über `SpringApplicationBuilder.properties(…)` + herein. Das sind **Default**-Properties mit der *niedrigsten* Priorität; die + `application.yaml` überstimmt sie. Der Test lief die ganze Zeit gegen eine + andere Datenbank und meldete für jede Variante dasselbe Ergebnis. +2. Als Kommandozeilen-Argument gereicht wirkte die URL — aber jetzt prüfte der + Test eine URL, die er sich **selbst ausgedacht** hatte, während die + ausgelieferte ungeprüft blieb. Die Gegenprobe (Modus zurückbauen, muss + fallen) blieb prompt stumm. + +Beides fiel nur auf, weil die Gegenprobe zum Verfahren gehört. Jetzt hat die +URL **einen** Regler — `werkbaum.data-dir` —, der Test überschreibt nur den, +und alles Übrige an der ausgelieferten URL steht unter Test. Mit +`MODE=PostgreSQL` zurück fällt genau die eine danach benannte Zusicherung. + +Dieselbe Lehre eine Ebene höher: Die Testkonfiguration hieß +`application.yaml` und **verdeckte** damit die Hauptkonfiguration vollständig — +die Tests prüften eine Konfiguration, die in Produktion nie läuft. Sie heißt +jetzt `application-test.yaml` und ist eine Profil-Überlagerung. + +**Eine Lebendprobe braucht einen eigenen Endpunkt.** Bis hierher fragte das +Deploy nach einem Dokument, das es nicht gibt, und hoffte auf **404**. Ein +erwarteter *Fehler* ist eine schlechte Zusicherung: Dieselbe 404 liefert auch +ein falsch konfigurierter Proxy. `GET /api/v1/info` antwortet stattdessen mit +Name, Version und Bauzeitpunkt — offen, ohne Nebenwirkung, und es sagt +zugleich, **welcher Stand** läuft. Die Daten kommen aus +`META-INF/build-info.properties` (Gradle: `springBoot { buildInfo() }`, Teil +des Boot-Plugins — keine neue Abhängigkeit) und sind optional: Wer aus der IDE +startet, hat die Datei nicht und bekommt „unbekannt" statt eines Fehlers. + +**Das Passwort setzt jetzt ein eigenes Skript** (`scripts/reset-password.sh`). +Es fragt verdeckt nach, schickt das Passwort über **stdin** zum Server (nicht +als Argument — Argumente stehen in der Prozessliste, die auf einem geteilten +Host jeder lesen kann), hasht dort mit `htpasswd -i` und prüft anschließend +selbst mit `htpasswd -vi`, ob Hash und Passwort zueinander passen. Genau diese +Gegenprobe fehlte, als das erste 401 wie ein Konfigurationsfehler aussah. + **Nicht getestet, weil es nicht zu testen war:** Alles bis zur SSH-Grenze ist gemessen — die erzeugte Unit ist mit `systemd-analyze verify` gültig, das Jar startet mit **genau** den Flags der Unit in einer Sekunde, antwortet auf die diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index 7c9cc4c..1517a1d 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -116,7 +116,8 @@ - [ ] #be: Backend (XL) - [/] #be.scaffold: Kotlin and Spring Boot scaffold (M) - [^] #be.scaffold.gradle: Gradle Kotlin DSL, JDK 21 (S) - - [ ] #be.scaffold.ci: Health endpoint and CI (S) + - [x] #be.scaffold.info: An endpoint that says name and version (XS) + - [ ] #be.scaffold.ci: Continuous integration for the backend (S) - [^] #be.docs: Documents on the server (L) :#be.scaffold - [^] #be.docs.model: Text plus metadata, no structure format (M) - [^] #be.docs.rest: REST endpoints (S) @@ -768,9 +769,14 @@ The build itself: Kotlin DSL, one module, the current long-term-support Java. +#be.scaffold.info + Something to ask "are you there, and which build is it?" — open and without + side effects. Expecting a 404 from a document that does not exist would be a + poor assurance: a misconfigured proxy returns one too. + #be.scaffold.ci - Enough to see that it lives: a health endpoint, and a pipeline that builds - and tests it on every push. + A pipeline that builds and tests the backend on every push, like the editor + has. The "is it alive" half moved out into its own node and is done. #be.docs Documents on a server instead of in one browser. Text plus metadata — diff --git a/scripts/deploy-backend.sh b/scripts/deploy-backend.sh index b2213fa..d7aa1a7 100755 --- a/scripts/deploy-backend.sh +++ b/scripts/deploy-backend.sh @@ -190,14 +190,16 @@ REMOTE # ---- 4) Nachsehen, ob er wirklich antwortet ---- if [ "$RESTART" -eq 1 ]; then echo "==> Warten, bis der Dienst antwortet" - # Es gibt (noch) keinen Health-Endpunkt — der steht als eigener Knoten im - # Plan (`#be.scaffold.ci`). Bis dahin ist die ehrlichste Lebendprobe eine - # Anfrage nach einem Dokument, das es nicht gibt: 404 heißt, die Anwendung - # ist oben und beantwortet Anfragen. - PROBE="http://127.0.0.1:${PORT}/api/v1/documents/00000000-0000-0000-0000-000000000000" + # `GET /api/v1/info` — offen, ohne Nebenwirkung, und sagt zugleich, welcher + # Stand läuft. Vorher stand hier eine Anfrage nach einem nicht existierenden + # Dokument mit der Erwartung 404; ein erwarteter **Fehler** ist eine schlechte + # Zusicherung, weil ihn auch ein falsch konfigurierter Proxy liefert. + PROBE="http://127.0.0.1:${PORT}/api/v1/info" if ssh "$SSH_TARGET" "for i in \$(seq 1 45); do - code=\$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 '$PROBE' || true) - if [ \"\$code\" = '404' ]; then echo \" oben nach \${i}s (HTTP 404 wie erwartet)\"; exit 0; fi + body=\$(curl -s --max-time 3 '$PROBE' || true) + case \"\$body\" in + *'\"version\"'*) echo \" oben nach \${i}s: \$body\"; exit 0 ;; + esac sleep 1 done echo ' ! antwortet nicht.'; exit 1"; then diff --git a/scripts/reset-password.sh b/scripts/reset-password.sh new file mode 100755 index 0000000..a11588b --- /dev/null +++ b/scripts/reset-password.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# +# Werkbaum — Master-Passwort der Dokumentenliste setzen (D77). +# +# Fragt das Passwort **verdeckt ab** und hasht es auf dem Server. Bewusst so +# und nicht `htpasswd -b … PASSWORT`: Ein Passwort auf der Kommandozeile landet +# in der Shell-History, und schlimmer — die Shell fasst es vorher an. `ge$heim` +# wird zu `ge`, `ge heim` zu `geheim`. Gehasht wird dann etwas anderes als das, +# was man später eintippt, und der Server antwortet mit 401, obwohl alles +# richtig aussieht (genau so passiert, D76-Nachtrag 6). +# +# Verwendung: +# scripts/reset-password.sh [ssh-ziel] [--no-restart] +# +# ssh-ziel ohne Angabe BACKEND_SSH aus .env (Vorlage: .env.example) +# --no-restart Hash schreiben, Dienst nicht neu starten +# +# Das Passwort verlässt diesen Rechner nur über die SSH-Verbindung und wird +# **nur als Hash** gespeichert (`/env`, Modus 600). Danach prüft +# das Skript selbst, ob Hash und Passwort zueinander passen — der Fehler oben +# sieht sonst aus wie ein Konfigurationsfehler. +# +# Siehe docs/DECISIONS.md D77 und backend/README.md. + +set -euo pipefail + +SSH_TARGET="" +RESTART=1 +for arg in "$@"; do + case "$arg" in + --no-restart) RESTART=0 ;; + -h|--help) + awk 'NR>2 { if ($0 ~ /^#/) { sub(/^# ?/, ""); print } else exit }' "$0" + exit 0 ;; + -*) echo "Unbekannte Option: $arg" >&2; exit 2 ;; + *) + if [ -n "$SSH_TARGET" ]; then echo "Zu viele Argumente." >&2; exit 2; fi + SSH_TARGET="$arg" ;; + esac +done + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib-env.sh" + +[ -n "$SSH_TARGET" ] || SSH_TARGET="$(env_value BACKEND_SSH)" +if [ -z "$SSH_TARGET" ]; then + echo "Usage: $0 (oder BACKEND_SSH in .env)" >&2 + exit 2 +fi +DIR="$(env_value BACKEND_DIR)"; DIR="${DIR:-opt/werkbaum}" +case "$DIR" in /*) DIR_SH="$DIR" ;; *) DIR_SH="\$HOME/$DIR" ;; esac + +echo "==> Master-Passwort für ${SSH_TARGET}" +echo " (die Dokumentenliste; alles andere ist über die Dokument-UUID erreichbar)" + +# Verdeckt einlesen, zweimal. `read -s` gibt es in bash überall; auf ein +# Terminal angewiesen ist es nicht, deshalb der Hinweis bei Rohr-Eingabe. +if [ ! -t 0 ]; then + echo "Kein Terminal — dieses Skript fragt nach und braucht eines." >&2 + exit 2 +fi +printf ' Neues Passwort: ' +read -rs PASSWORT +printf '\n Wiederholen: ' +read -rs PASSWORT2 +printf '\n' + +if [ "$PASSWORT" != "$PASSWORT2" ]; then + echo "Die beiden Eingaben sind verschieden — nichts geändert." >&2 + exit 1 +fi +if [ -z "$PASSWORT" ]; then + echo "Leeres Passwort — nichts geändert." >&2 + exit 1 +fi +if [ "${#PASSWORT}" -lt 8 ]; then + # Keine Regeln über Sonderzeichen; die Länge ist das, was zählt, und dieses + # eine Passwort schützt eine Liste, die alle Dokument-Adressen preisgäbe. + echo "Kürzer als 8 Zeichen — bitte länger wählen." >&2 + exit 1 +fi + +# Das Passwort geht über **stdin** an den Server, nie als Argument: Argumente +# stehen in der Prozessliste, die auf einem geteilten Host jeder lesen kann. +echo "==> hashen und speichern" +printf '%s' "$PASSWORT" | ssh "$SSH_TARGET" DIR="$DIR_SH" RESTART="$RESTART" 'bash -s' <<'REMOTE' +set -euo pipefail +DIR="$(eval echo "$DIR")" +export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" + +PASSWORT="$(cat)" +[ -n "$PASSWORT" ] || { echo "Kein Passwort angekommen." >&2; exit 1; } + +command -v htpasswd >/dev/null || { + echo "htpasswd fehlt auf dem Server (Paket apache2-utils)." >&2; exit 1; } + +# htpasswd -i liest das Passwort von stdin - es steht also weder in der +# Prozessliste noch in einer History. +HASH="$(printf '%s' "$PASSWORT" | htpasswd -niBC 12 '' | tr -d ':\n')" +case "$HASH" in + \$2*) : ;; + *) echo "htpasswd hat keinen bcrypt-Hash geliefert." >&2; exit 1 ;; +esac + +mkdir -p "$DIR" +umask 077 +printf 'WERKBAUM_MASTER_PASSWORD_HASH={bcrypt}%s\n' "$HASH" > "$DIR/env.neu" +mv "$DIR/env.neu" "$DIR/env" +chmod 600 "$DIR/env" +echo " gespeichert in $DIR/env (Modus 600)" + +# Gegenprobe, bevor irgendjemand sich wundert: Passen Hash und Passwort? +CHK="$(mktemp)" +trap 'rm -f "$CHK"' EXIT +printf 'werkbaum:%s\n' "$HASH" > "$CHK" +if printf '%s' "$PASSWORT" | htpasswd -vi "$CHK" werkbaum >/dev/null 2>&1; then + echo " Gegenprobe: Hash und Passwort passen zueinander." +else + echo " ! Gegenprobe FEHLGESCHLAGEN - der Hash passt nicht zum Passwort." >&2 + exit 1 +fi + +if [ "$RESTART" = "1" ]; then + if systemctl --user is-enabled werkbaum-backend.service >/dev/null 2>&1; then + systemctl --user restart werkbaum-backend.service + echo " Dienst neu gestartet." + else + echo " ! Dienst noch nicht eingerichtet - scripts/deploy-backend.sh ausführen." + fi +fi +REMOTE + +unset PASSWORT PASSWORT2 + +echo "==> Fertig. Probe:" +echo " curl -su werkbaum: https://werkbaum.javagil.de/api/v1/documents"