fix(backend): Neustart gegen die eigene Datenbank, /info als Lebendprobe

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 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-26 19:02:51 +02:00
co-authored by Claude Opus 5
parent 78901793f0
commit dea904814b
17 changed files with 436 additions and 52 deletions
+36 -8
View File
@@ -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:** `<BACKEND_DIR>/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 (`<BACKEND_DIR>/env`, Modus 600); ohne
ihn bleibt `GET /documents` gesperrt.
- **Datenbank:** H2 im Dateimodus unter `<BACKEND_DIR>/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
+6
View File
@@ -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)
@@ -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<ServiceInfo> = ResponseEntity.ok(
ServiceInfo(
name = buildProperties?.name ?: "werkbaum-backend",
version = buildProperties?.version ?: "unbekannt",
builtAt = buildProperties?.time?.atOffset(java.time.ZoneOffset.UTC),
)
)
override fun listDocuments(): ResponseEntity<List<ApiDocument>> =
ResponseEntity.ok(service.findAll().map { it.toApi() })
+7 -3
View File
@@ -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
@@ -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)
@@ -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()
}
}
}
}
@@ -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 {
@@ -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
@@ -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()
@@ -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
@@ -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
@@ -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