Initialisiere VoTiAn NG mit Docker-Build, .env-Konfiguration und Cookie-Sitzungen

This commit is contained in:
Sven committed 2026-10-05 17:45:38 +02:00
commit 1a30233269
63 files changed
+12138

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
/target/
/data/
*.log
+3
View File
@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip
+114
View File
@@ -0,0 +1,114 @@
# Spring-Boot-Adapter für die vorhandene VoTiAn-Datenbank
Java 21, Spring Boot 3.5.16 und MariaDB JDBC. Dieser Adapter verwendet ausschließlich bestehende Tabellen. Er enthält kein JPA-Modell, keine Schema-Migration, keine eingebettete Datenbank und keine Seed-Daten. Alle implementierten Datenbankabfragen sind `SELECT`; der Verbindungspool ist zusätzlich schreibgeschützt.
## Zentrale Datenbankserviceschicht
`database.DatabaseService` ist die einzige Komponente mit Zugriff auf `JdbcTemplate` und den Transaktionsmanager. `LegacyAuthenticationProvider`, `LegacyReadService` und `database.DatabaseDiagnostics` verwenden ausschließlich diesen Service; auch Integrationstests lesen den Bestand über ihn. Der Service aktiviert `setEnforceReadOnly(true)`, sodass jede Lesetransaktion mit `SET TRANSACTION READ ONLY` serverseitig abgesichert wird. Der JDBC-Hinweis allein reicht hierfür beim verwendeten MariaDB-Treiber nicht aus.
- `queryForList(sql, parameters...)` und `queryForObject(sql, type, parameters...)` führen einzelne parametrisierte SELECT-Abfragen in Lesetransaktionen aus.
- `readTransaction(work)` fasst abhängige Abfragen zusammen; Auftragsliste einschließlich Anzahl und Stationen sowie Auftragsdetails verwenden diese Grenze.
- `checkConnection()` liefert einen bereinigten Verbindungsstatus und unterscheidet Netzwerkfehler, abgewiesene Anmeldung und fehlende Datenbankrechte.
- `update(sql, parameters...)` ist der zentrale Einstieg für zukünftige Schreibprozesse. Er wirft derzeit vor jedem Datenbankzugriff `DatabaseWritesDisabledException`. Es wurde kein fachlicher Schreibvorgang freigeschaltet.
SQL bleibt anwendungseigener Code. Benutzereingaben dürfen nur als gebundene Werte übergeben werden. Der Service weist Nicht-SELECTs, mehrere Statements und bekannte SELECT-Schreib-/Sperrformen über die Lese-API zurück; diese Prüfung ist kein SQL-Parser für beliebige fremde Abfragen. Es werden weder JDBC-Verbindungen noch JDBC-Callbacks nach außen gegeben.
`python3 scripts/check-database.py` im Projektstamm startet das gebaute JAR mit `--database-check`. Dieser Modus nutzt denselben Service und dieselbe Poolkonfiguration ohne Webserver, gibt JSON aus und schließt den Spring-Kontext. Python stellt keine eigene Datenbankverbindung mehr her.
## Start und Konfiguration
Die lokale Datenbankkonfiguration steht in der `.env` im Projektstamm: `DB_URL`, `DB_USER` und `DB_PASSWORD`. `./run.sh`, `./dev.sh` und `python3 scripts/check-database.py` laden diese Datei automatisch. Bereits gesetzte Prozessumgebungsvariablen haben Vorrang vor den Werten aus `.env`.
Direkte Maven-Aufrufe laden `.env` nicht automatisch. Für den eigenständigen Backend-Start mit der lokalen Konfiguration im Verzeichnis `backend/` ausführen:
```sh
python3 ../scripts/env_config.py ./mvnw spring-boot:run
```
Verfügbare Konfiguration:
| Variable | Bedeutung |
| --- | --- |
| `DB_URL` | JDBC-Adresse der bestehenden DB, z. B. `jdbc:mariadb://DB-HOST:3306/DB-NAME?connectTimeout=2000&socketTimeout=5000` |
| `DB_USER`, `DB_PASSWORD` | Vorhandenes Datenbankkonto; keine Zugangsdaten im Quelltext |
| `LEGACY_APP_URL` | Optionale HTTP(S)-Basisadresse der vorhandenen PHP-Anwendung |
| `SERVER_ADDRESS` | Standard `127.0.0.1` |
| `PORT` | Standard `8080` |
| `CORS_ORIGINS` | Standard localhost/127.0.0.1 auf Port 5173 |
| `FORWARD_HEADERS_STRATEGY` | Standard `native`; Proxy-Header werden durch Tomcat ausgewertet |
| `SERVER_SERVLET_SESSION_COOKIE_SECURE` | Bei HTTPS in Produktion explizit `true`; ohne Vorgabe vom erkannten Request-Protokoll abhängig |
| `SESSION_TIMEOUT` | Ablauf bei Inaktivität, Standard `30m` |
| `MAX_RESULTS` | Abfragegrenze, Standard 500, maximal 5000 |
Die Anwendung startet auch ohne erreichbare Datenbank. `/api/health` prüft die Anwendung; `/api/system` meldet die tatsächliche Datenbankerreichbarkeit. Eine fehlgeschlagene Datenabfrage oder Anmeldung bei fehlender DB-Verbindung liefert 503 und niemals erfundene oder leere Ersatzdaten. Die SQL-Kompatibilität muss zusätzlich am tatsächlichen Bestandsschema geprüft werden.
## Anmeldung und Umfang
Die Anmeldung prüft ausschließlich vorhandene VoTiAn-Konten über `user.usr_account`, `PASSWORD()` und `OLD_PASSWORD()` und speichert die authentifizierte Identität in einer serverseitigen HTTP-Sitzung. Es erfolgt keine Passwortmigration und kein Update. Unterstützt sind vorhandene Zentralenbenutzer (`usr_type=1`) mit Mitarbeiter-, Niederlassungs- und Mandantenzuordnung. Keine Demo-Anmeldung und kein automatisch angelegtes Konto.
HTTP Basic ist im Backend deaktiviert. Ein vom Browser für den vorgeschalteten Nginx-Passwortschutz gesendeter `Authorization`-Header wird nicht zur VoTiAn-Anmeldung verwendet. VoTiAn erhält Benutzername und Passwort ausschließlich über `POST /api/login`; das Frontend speichert diese Werte nicht in `sessionStorage` oder `localStorage`.
Die Sitzung wird ausschließlich per Cookie `VOTIANSESSION` übertragen (`HttpOnly`, `SameSite=Lax`, Pfad `/`), nicht per URL. Ihre Kennung wechselt bei erfolgreicher Anmeldung. Nach standardmäßig 30 Minuten Inaktivität oder nach `POST /api/logout` ist die Sitzung ungültig. Sitzungen liegen im Backend-Arbeitsspeicher, benötigen keine Datenbanktabellen und gehen beim Neustart verloren. Mehrere Instanzen erfordern Sitzungsaffinität oder eine gemeinsame Sitzungsablage. Niederlassung und Rechte sind ein Snapshot bei Anmeldung; Änderungen im Bestand greifen für eine vorhandene Sitzung erst nach erneuter Anmeldung.
### Ablauf für API-Clients
1. `GET /api/csrf` aufrufen und das dabei gesetzte Sitzungscookie behalten. Die JSON-Antwort enthält `token`, `headerName` und `parameterName`.
2. `POST /api/login` mit `Content-Type: application/x-www-form-urlencoded`, den Formularfeldern `username` und `password`, dem Sitzungscookie und dem CSRF-Token im angegebenen Header senden. Erfolgreich: `200` mit denselben Benutzerinformationen wie `/api/me`; die Antwort erneuert die Sitzungskennung. Falsche Zugangsdaten liefern `401`, eine nicht verfügbare Datenbank `503`.
3. Für folgende API-Aufrufe das erneuerte Sitzungscookie verwenden. `GET /api/me` prüft die bestehende Anmeldung; ohne gültige Anmeldung liefert es `401` und keine HTTP-Basic-Aufforderung.
4. Nach erfolgreicher Anmeldung und nach Abmeldung mit `GET /api/csrf` einen neuen Token beziehen, bevor erneut ein schreibender HTTP-Aufruf erfolgt. Die bisherigen Token werden dabei ungültig. Der Token gehört zur Sitzung und muss bei `POST`, `PUT`, `PATCH` und `DELETE` im angegebenen Header mitgeschickt werden.
5. Zum Abmelden `POST /api/logout` mit gültigem CSRF-Token und Sitzungscookie senden. Die Antwort ist `204`; Sitzung und Cookie werden gelöscht. `GET /api/logout` meldet nicht ab.
Ein fehlender oder falscher CSRF-Token führt zu `403`, auch bei Anmeldung und Abmeldung. Die React-Oberfläche übernimmt diesen Ablauf einschließlich erneuter Anmeldung bei abgelaufener Sitzung. Die CSRF-Token bleiben getrennt vom `HttpOnly`-Sitzungscookie. Hinter einem zusätzlichen Proxy-Passwortschutz bleiben dessen Zugangsdaten für alle Aufrufe notwendig; die VoTiAn-Abmeldung beendet den Proxy-Passwortschutz nicht. Siehe [Spring Security: CSRF für JavaScript-Clients](https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html#csrf-integration-javascript).
### Betrieb hinter Nginx / Nginx Proxy Manager
Frontend und API unter derselben HTTPS-Origin bereitstellen und Cookies unverändert weiterleiten. Der Proxy muss `X-Forwarded-Proto: https` setzen und vom Client gelieferte Forwarded-Header ersetzen. Das Backend sollte nur vom vertrauenswürdigen Proxy erreichbar sein. `FORWARD_HEADERS_STRATEGY=native` verwendet Tomcats Proxy-Auswertung einschließlich seiner Vertrauensprüfung für interne Proxyadressen. Falls nötig, diese gezielt mit `SERVER_TOMCAT_REMOTEIP_INTERNAL_PROXIES` anpassen; keine leere Einstellung verwenden, die alle Proxies als vertrauenswürdig behandelt.
Im HTTPS-Produktionscontainer `SERVER_SERVLET_SESSION_COOKIE_SECURE=true` setzen. Dann funktionieren Sitzungsanmeldungen nur über HTTPS, auch wenn der interne Weg vom Proxy zum Backend HTTP verwendet. Für den lokalen HTTP-Start die Variable weglassen. Die Vite-Entwicklungsoberfläche verwendet ihren bestehenden `/api`-Proxy, sodass auch dort Oberfläche und API aus Browsersicht dieselbe Origin haben. Ein vorhandener Nginx-Basic-Passwortschutz kann aktiv bleiben; die Systemanmeldung verwendet dafür keinen `Authorization`-Header mehr.
Die ursprüngliche Niederlassung `user.hq_id` begrenzt alle Abfragen. Bei Aufträgen gilt die **Erfassungsniederlassung** `job.hq_id`; zusätzliche Dispositions-/Ausführungs-/Vertriebsniederlassungen werden nicht pauschal freigegeben. Kunden werden über `customer.hq_id`, Kuriere über `courier.hq_id` und `user.hq_id` beschränkt. Zusätzliche Rechteprüfungen verwenden die vorhandenen `employee.emp_rights`-Positionen: Kunden 0, Kuriere 1, Auftragslisten 4, Rechnung/CSV 5. Kunden- und Kurierlogins sowie Niederlassungswechsel sind noch nicht portiert.
## API
| Route | Verhalten |
| --- | --- |
| `GET /api/health` | Öffentlich, Anwendungsbereitschaft |
| `GET /api/system` | Öffentlich, Verbindung, Funktionsumfang, Legacy-Link, Ergebnisgrenze |
| `GET /api/csrf` | Öffentlich, CSRF-Token und Header-/Parametername für die aktuelle Sitzung |
| `POST /api/login` | CSRF-geschützte Anmeldung mit Formularfeldern `username` und `password`; `200` mit Benutzerinformationen |
| `POST /api/logout` | CSRF-geschützte Abmeldung, Sitzung ungültig machen und Cookie löschen; `204` |
| `GET /api/me` | Bestehendes Konto, Niederlassung, verfügbare Lesebereiche |
| `GET /api/orders` | Aufträge, Filter `q`, `status`, `customerId`, `courierId`, `from`, `to` |
| `GET /api/orders/{id}` | Auftrag derselben Erfassungsniederlassung mit Tourstationen |
| `GET /api/orders/export.csv` | Gefilterter UTF-8-Export, zusätzlich Recht 5; verweigert einen unvollständigen Export oberhalb der Ergebnisgrenze |
| `GET /api/customers`, `GET /api/couriers` | Stammdaten, optionaler Suchparameter `q` |
| `GET /api/invoices` | 501, Bestands-Abrechnungsprozess noch nicht portiert |
| Übrige Schreibmethoden unter `/api/**` | 501 nach erfolgreicher Anmeldung und gültiger CSRF-Prüfung; keine Datenänderung |
Listen liefern Arrays. Auftragslisten senden zusätzlich `X-Total-Count` und `X-Result-Limit`. `from`/`to` sind einschließlich, Format `YYYY-MM-DD`, bezogen auf `jb_ordertime`. Nicht belegte API-Felder bleiben `null`, insbesondere Stammdatenversionen, Rechnungs-ID, Lieferfrist, Packstückzahl und Distanz. Die UI soll diese als unbekannt anzeigen.
## Fachliche Zuordnung und offene Arbeit
- Auftraggeber aus `job.csc_id → costcenter.cs_id`; Rechnungskunde separat als `payerCustomerId` aus `csc_id_payer`.
- Kundenpreis aus `jb_totalprice` (Netto einschließlich Zuschlägen), Kurierpreis aus `jb_cr_price`; keine neu erfundene Tarifierung.
- Status 0/1/2/8/9 bleiben zusätzlich als `legacyStatus`/`legacyStatusLabel` erhalten. Die UI fasst 8/9 unter Disposition zusammen.
- `jb_storno` bleibt als `legacyStorno` erhalten. 3/4 sind Gegenbuchungen und werden ausdrücklich als Storno-Gutschriften markiert (`isCreditNote`).
- Tourrichtung 1=Abholung, 2=Zustellung, 3=beides (`BOTH`), 0=positionsabhängige Legacy-Regel.
- Leistungen aus der Bitmaske `jb_service`, wobei `metatype.mt_sort` die Bitposition ist. Fahrzeugtypen werden über `metatype` gelesen.
- Historie liegt im Bestand in einer separaten Log-Datenbank. Der Adapter erfindet keine Audit-Ereignisse; das Feld bleibt aktuell leer.
- Abrechnung im Bestand ist keine nachgewiesene einfache Invoice-CRUD-Tabelle. Belege, Gegenbuchungen, Zahlungsstatus, Tarifierung, Auftragsanlage/Änderung und Fahrerkommunikation bleiben bis zur vollständigen Portierung im vorhandenen PHP-System. Optional verlinkt die React-Oberfläche diese Module.
- Archivtabellen und Mehrniederlassungsrechte sind noch nicht eingebunden. Die bestehende Anwendung bleibt für diese Funktionen erforderlich.
SQL-/Mapping-Quellen im vorhandenen Code: `include/mcglobal.inc.php`, `include/dbglobal.inc.php`, `include/caglobal.inc.php`, `admin/login.php`, `admin/menu.php`, `jobs/job_options.php`, `admin/jb_list.php` und `invoice/inv.inc.php`.
## Prüfung
`./mvnw test` führt Tests ohne Bestandszugriff aus: bestehende Anmeldung und Rechte, Sitzungs- und CSRF-Verhalten, HQ-Abgrenzung, SQL-Parameterbindung, Nichtverfügbarkeit, Verbot fachlicher Schreibpfade, serverseitige Vorbereitung der Lesetransaktion, Transaktions-/Fehlerverhalten, Schemadiagnose, Legacy-Status/Preis-/Tour-Mapping einschließlich JDBC-Boolean-Flags und CSV-Schutz. Normale Maven-Builds und Unit-Tests laden `.env` nicht. Ein Architekturtest prüft die kompilierten Java-Abhängigkeiten und Python-Launcher auf direkte Datenbankzugriffe außerhalb des Services. Nur dessen eigener Unit-Test darf JDBC mocken. Es wird keine Testdatenbank angelegt und keine Bestandsdatenbank verändert.
Mit `VOTIAN_VERIFY_EXISTING_DB=true` und den konfigurierten `DB_URL`, `DB_USER`, `DB_PASSWORD` wird zusätzlich `LegacyDatabaseReadIntegrationTest` ausgeführt. Er prüft die drei Listen-APIs mit echten SELECT-Abfragen über MockMvc und die numerische JDBC-Abbildung von `TINYINT(1)`. Die Authentifizierung wird nur in diesem Test simuliert; die echte Anmeldung wird damit nicht getestet. Die gewählte Niederlassung muss vorhandene Aufträge, Kunden und Kuriere enthalten. Ohne ausdrückliche Aktivierung bleibt dieser Test deaktiviert.
Für diesen bewusst aktivierten Integrationstest mit der lokalen `.env` im Verzeichnis `backend/` ausführen:
```sh
VOTIAN_VERIFY_EXISTING_DB=true python3 ../scripts/env_config.py ./mvnw test
```
Vendored Executable
+295
View File
@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
+189
View File
@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.16</version><relativePath/></parent>
<groupId>de.stadtbote</groupId><artifactId>votian-backend</artifactId><version>0.1.1</version>
<name>Votian NG Stadtbote</name><properties><java.version>21</java.version></properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
<dependency><groupId>org.mariadb.jdbc</groupId><artifactId>mariadb-java-client</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><resources><resource><directory>src/main/resources</directory></resource><resource><directory>../frontend/dist</directory><targetPath>static</targetPath></resource></resources><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -0,0 +1,72 @@
package de.stadtbote.votian;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.*;
import static de.stadtbote.votian.Models.*;
@RestController
@RequestMapping("/api")
public class ApiController {
private final LegacyReadService legacy;
private final String legacyUrl;
ApiController(LegacyReadService legacy,@Value("${app.legacy-url:}") String legacyUrl) { this.legacy=legacy;this.legacyUrl=safeLegacyUrl(legacyUrl); }
static String safeLegacyUrl(String raw) {
if(raw==null || raw.isBlank())return null;
URI uri=URI.create(raw.trim());
if(!Set.of("http","https").contains(uri.getScheme()) || uri.getHost()==null || uri.getUserInfo()!=null || uri.getQuery()!=null || uri.getFragment()!=null) throw new IllegalArgumentException("LEGACY_APP_URL muss eine HTTP(S)-Basisadresse ohne Zugangsdaten, Query oder Fragment sein.");
return raw.trim().replaceAll("/+$","");
}
static LegacyUser user(Authentication authentication) {
if(authentication==null || !(authentication.getPrincipal() instanceof LegacyUser u))throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"Bitte anmelden.");
return u;
}
@GetMapping("/health") public Map<String,String> health() { return Map.of("status","UP"); }
@GetMapping("/csrf") public Map<String,String> csrf(CsrfToken csrf) {
return Map.of("token",csrf.getToken(),"headerName",csrf.getHeaderName(),"parameterName",csrf.getParameterName());
}
@GetMapping("/system") public Map<String,Object> system(Authentication authentication) {
var connection=legacy.connectionCheck();
boolean connected=connection.connected();
LegacyUser u=authentication!=null&&authentication.getPrincipal() instanceof LegacyUser lu?lu:null;
Map<String,Object> result=new LinkedHashMap<>();
result.put("connected",connected);result.put("database","legacy-mysql");result.put("readOnly",true);result.put("schemaChanges",false);
result.put("message",connection.message());result.put("reason",connection.reason());
result.put("legacyUrl",legacyUrl);result.put("maxResults",legacy.maxResults());
result.put("capabilities",u==null?new Capabilities(false,false,false,false,false,false,false,false,false,false):u.capabilities());
result.put("unavailableModules",List.of("Rechnungserstellung und Zahlungsstatus","Änderungen an Aufträgen und Stammdaten","Historie aus separater Log-Datenbank","Archivtabellen"));
return result;
}
@GetMapping("/me") public Map<String,Object> me(Authentication authentication) {
return userDetails(authentication);
}
static Map<String,Object> userDetails(Authentication authentication) {
LegacyUser u=user(authentication);return Map.of("username",u.username(),"roles",List.of("VIEWER"),"headquartersId",u.headquartersId(),"userId",u.userId(),"capabilities",u.capabilities());
}
@GetMapping("/orders") public ResponseEntity<List<DeliveryOrder>> orders(Authentication auth,@RequestParam(required=false) String q,@RequestParam(required=false) OrderStatus status,@RequestParam(required=false) Long customerId,@RequestParam(required=false) Long courierId,@RequestParam(required=false) LocalDate from,@RequestParam(required=false) LocalDate to) {
var result=legacy.orders(user(auth),q,status,customerId,courierId,from,to);
return ResponseEntity.ok().header("X-Total-Count",Long.toString(result.total())).header("X-Result-Limit",Integer.toString(result.limit())).body(result.orders());
}
@GetMapping("/orders/{id}") public DeliveryOrder order(Authentication auth,@PathVariable long id) { return legacy.order(user(auth),id); }
@GetMapping("/customers") public List<Customer> customers(Authentication auth,@RequestParam(required=false) String q) { return legacy.customers(user(auth),q); }
@GetMapping("/couriers") public List<Courier> couriers(Authentication auth,@RequestParam(required=false) String q) { return legacy.couriers(user(auth),q); }
@GetMapping("/orders/export.csv") public ResponseEntity<byte[]> csv(Authentication auth,@RequestParam(required=false) String q,@RequestParam(required=false) OrderStatus status,@RequestParam(required=false) Long customerId,@RequestParam(required=false) Long courierId,@RequestParam(required=false) LocalDate from,@RequestParam(required=false) LocalDate to) {
LegacyUser u=user(auth);u.require(5);
var result=legacy.orders(u,q,status,customerId,courierId,from,to);
if(result.total()>result.limit())throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Der Export umfasst mehr als "+result.limit()+" Aufträge. Bitte den Zeitraum oder die Filter eingrenzen; ein unvollständiger Export wird nicht erstellt.");
StringBuilder csv=new StringBuilder("\uFEFFAuftrag;Status;Kunde-ID;Kurier-ID;Abholung;Referenz;Netto;Kurierpreis;MwSt\n");
for(DeliveryOrder o:result.orders())csv.append(csvCell(o.number)).append(';').append(csvCell(o.legacyStatusLabel)).append(';').append(csvCell(o.customerId)).append(';').append(csvCell(o.courierId)).append(';').append(csvCell(o.pickupAt)).append(';').append(csvCell(o.reference)).append(';').append(csvCell(o.netPrice)).append(';').append(csvCell(o.courierPrice)).append(';').append(csvCell(o.vatRate)).append('\n');
return ResponseEntity.ok().contentType(new MediaType("text","csv",StandardCharsets.UTF_8)).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=stadtbote-auftraege.csv").body(csv.toString().getBytes(StandardCharsets.UTF_8));
}
static String csvCell(Object value) { String s=value==null?"":value.toString();String trimmed=s.stripLeading();if(!trimmed.isEmpty()&&"=+-@".indexOf(trimmed.charAt(0))>=0)s="'"+s;return '"'+s.replace("\"","\"\"")+'"'; }
@GetMapping("/invoices") public Object invoices(Authentication auth) { user(auth).require(5);throw unsupported(); }
@RequestMapping(value="/**",method={RequestMethod.POST,RequestMethod.PUT,RequestMethod.PATCH,RequestMethod.DELETE}) public Object mutation() { throw unsupported(); }
static ResponseStatusException unsupported() { return new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED,"Dieser Schreib-/Abrechnungsworkflow ist noch nicht auf die vorhandene VoTiAn-Datenbank portiert. Bitte das bestehende VoTiAn verwenden. Es werden keine Ersatzdatensätze oder Tabellen angelegt."); }
}
@@ -0,0 +1,30 @@
package de.stadtbote.votian;
import org.springframework.dao.DataAccessException;
import de.stadtbote.votian.database.DatabaseService;
import de.stadtbote.votian.database.DatabaseWritesDisabledException;
import org.springframework.http.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.util.Map;
@RestControllerAdvice
public class ApiErrors {
static final String DATABASE_UNAVAILABLE=DatabaseService.DATABASE_UNAVAILABLE;
static Map<String,Object> body(String message) { return Map.of("message",message,"timestamp",Instant.now().toString()); }
@ExceptionHandler(DataAccessException.class)
ResponseEntity<?> database(DataAccessException exception) { return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(body(DATABASE_UNAVAILABLE)); }
@ExceptionHandler(DatabaseWritesDisabledException.class)
ResponseEntity<?> writesDisabled(DatabaseWritesDisabledException exception) { return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(body(exception.getMessage())); }
@ExceptionHandler(AccessDeniedException.class)
ResponseEntity<?> denied(AccessDeniedException exception) { return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body(exception.getMessage())); }
@ExceptionHandler(ResponseStatusException.class)
ResponseEntity<?> status(ResponseStatusException exception) { return ResponseEntity.status(exception.getStatusCode()).body(body(exception.getReason()==null?"Die Anfrage konnte nicht verarbeitet werden.":exception.getReason())); }
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
ResponseEntity<?> argument(MethodArgumentTypeMismatchException exception) { return ResponseEntity.badRequest().body(body("Ungültiger Parameter: "+exception.getName())); }
@ExceptionHandler(Exception.class)
ResponseEntity<?> unknown(Exception exception) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body("Die Anfrage konnte nicht verarbeitet werden. Bitte die Konfiguration des Legacy-Adapters prüfen.")); }
}
@@ -0,0 +1,39 @@
package de.stadtbote.votian;
import org.springframework.dao.DataAccessException;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.security.authentication.*;
import org.springframework.security.core.*;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import java.util.*;
@Component
public class LegacyAuthenticationProvider implements AuthenticationProvider {
private final DatabaseService database;
static final String AUTH_SQL = """
SELECT u.usr_id, u.hq_id, u.usr_account, e.emp_id, e.emp_rights
FROM user u JOIN employee e ON e.usr_id = u.usr_id
JOIN headquarters h ON h.hq_id = u.hq_id
WHERE u.usr_account = ? AND u.usr_type = 1 AND u.hq_id > 0
AND EXISTS (SELECT 1 FROM mandatorheadquarters mh WHERE mh.hq_id=u.hq_id AND mh.md_id>0)
AND (u.usr_password = PASSWORD(?) OR u.usr_password_old = OLD_PASSWORD(?))
""";
public LegacyAuthenticationProvider(DatabaseService database) { this.database = database; }
@Override public Authentication authenticate(Authentication authentication) {
String username = authentication.getName();
String password = String.valueOf(authentication.getCredentials());
if (username.isBlank() || password.isBlank() || username.length()>200 || password.length()>1024) throw new BadCredentialsException("Benutzername oder Passwort ist falsch.");
try {
List<Map<String,Object>> rows = database.queryForList(AUTH_SQL,username,password,password);
if (rows.size() != 1) throw new BadCredentialsException("Benutzername oder Passwort ist falsch, oder das Konto hat keinen Zentralenzugang.");
Map<String,Object> row = rows.getFirst();
LegacyUser user = new LegacyUser(LegacyMapping.id(row,"usr_id"),LegacyMapping.id(row,"emp_id"),LegacyMapping.id(row,"hq_id"),LegacyMapping.text(row,"usr_account"),LegacyMapping.text(row,"emp_rights"));
if (user.headquartersId() <= 0 || user.employeeId() <= 0) throw new BadCredentialsException("Dem Konto fehlt eine eindeutige Niederlassung oder Mitarbeiterzuordnung.");
return UsernamePasswordAuthenticationToken.authenticated(user,null,List.of(new SimpleGrantedAuthority("ROLE_VIEWER")));
} catch (DataAccessException ex) {
throw new AuthenticationServiceException(ApiErrors.DATABASE_UNAVAILABLE);
}
}
@Override public boolean supports(Class<?> authentication) { return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); }
}
@@ -0,0 +1,75 @@
package de.stadtbote.votian;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import static de.stadtbote.votian.Models.*;
/** Mappings traced to include/mcglobal.inc.php and the corresponding PHP job/customer/courier modules. */
final class LegacyMapping {
private LegacyMapping() {}
static Object value(Map<String,Object> row,String key) {
Object result = row.get(key);
if (result == null) result = row.get(key.toUpperCase(java.util.Locale.ROOT));
return result;
}
static String text(Map<String,Object> row,String key) { Object v=value(row,key); return v==null ? "" : v.toString(); }
static long id(Map<String,Object> row,String key) {
Object v=value(row,key);
if(v==null || v.toString().isBlank()) return 0;
// JDBC may return Boolean for legacy TINYINT(1) flags, such as cmp_archived.
if(v instanceof Boolean flag) return flag ? 1 : 0;
return v instanceof Number n ? n.longValue() : Long.parseLong(v.toString());
}
static Long nullableId(Map<String,Object> row,String key) { long n=id(row,key); return n>0?n:null; }
static BigDecimal decimal(Map<String,Object> row,String key) { Object v=value(row,key); if(v==null || v.toString().isBlank()) return null; return v instanceof BigDecimal d ? d : new BigDecimal(v.toString()); }
static LocalDateTime dateTime(Map<String,Object> row,String key) {
Object v=value(row,key); if(v==null) return null;
if(v instanceof Timestamp t) return t.toLocalDateTime();
if(v instanceof LocalDateTime t) return t;
String s=v.toString(); if(s.isBlank() || s.startsWith("0000-")) return null;
if(s.matches("\\d{14}")) return LocalDateTime.parse(s,DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
return LocalDateTime.parse(s.replace(' ','T'));
}
static String join(String a,String b) { return (a+" "+b).trim(); }
static Customer customer(Map<String,Object> row) {
Customer c=new Customer();c.id=id(row,"cs_id");c.number=text(row,"cs_eid");c.name=text(row,"cmp_comp");
c.street=join(text(row,"ad_street"),text(row,"cmp_hsno"));c.postalCode=text(row,"ad_zipcode");c.city=text(row,"ad_city");
c.contact=join(text(row,"usr_firstname"),text(row,"usr_name"));c.phone=text(row,"usr_phone");c.email=text(row,"usr_email");
String days=text(row,"cs_payment_term");c.paymentDays=days.matches("\\d+")?Integer.valueOf(days):null;
c.active=id(row,"cmp_archived")==0 && id(row,"cmp_visible")==1 && id(row,"cmp_authenticated")==1;
return c;
}
static Courier courier(Map<String,Object> row) {
Courier c=new Courier();c.id=id(row,"cr_id");c.number=text(row,"cr_eid");c.name=text(row,"cmp_comp");
if(c.name.isBlank()) c.name=join(text(row,"usr_firstname"),text(row,"usr_name"));
c.phone=text(row,"usr_phone");c.vehicle=text(row,"vehicle_label");c.plate=text(row,"crvh_vh_sign");
c.active=id(row,"cmp_archived")==0 && id(row,"cmp_visible")==1 && id(row,"cmp_authenticated")==1;
c.status=!c.active?CourierStatus.OFFLINE:(id(row,"active_jobs")>0?CourierStatus.BUSY:(id(row,"cr_available")==1?CourierStatus.AVAILABLE:CourierStatus.OFFLINE));
return c;
}
static DeliveryOrder order(Map<String,Object> row) {
DeliveryOrder o=new DeliveryOrder();o.id=id(row,"jb_id");o.number=Long.toString(o.id);o.version=id(row,"jb_version");
o.customerId=nullableId(row,"app_customer_id");o.payerCustomerId=nullableId(row,"app_payer_customer_id");o.courierId=nullableId(row,"cr_id");
o.legacyStatus=(int)id(row,"jb_status");int storno=(int)id(row,"jb_storno");o.legacyStorno=storno;o.isCreditNote=storno==3||storno==4;
o.cancelMode=storno==1 || storno==3?CancelMode.CHARGEABLE:storno==2 || storno==4?CancelMode.FREE:null;
o.status=o.cancelMode!=null?OrderStatus.CANCELLED:switch(o.legacyStatus){case 0->OrderStatus.OFFERED;case 1->OrderStatus.IN_PROGRESS;case 2->OrderStatus.COMPLETED;default->OrderStatus.DISPOSITION;};
o.legacyStatusLabel=switch(o.legacyStatus){case 9->"Disposition";case 8->"Autoranking";case 0->"Offen / angeboten";case 1->"Laufend";case 2->"Erledigt";default->"Legacy-Status "+o.legacyStatus;};
if(o.cancelMode!=null) o.legacyStatusLabel=switch(storno){case 1->"Storniert · kostenpflichtig";case 2->"Storniert · kostenfrei";case 3->"Storno-Gutschrift · kostenpflichtiges Storno";case 4->"Storno-Gutschrift · kostenfreies Storno";default->"Storno";};
o.legacyService=id(row,"jb_service");o.service=text(row,"service_label");o.vehicle=text(row,"vehicle_label");
o.pickupAt=dateTime(row,"jb_ordertime");o.reference=text(row,"jb_commission_no");o.costCenter=text(row,"app_cost_center");o.notes=text(row,"jb_dispoinfo");
o.weight=decimal(row,"jb_weight");o.netPrice=decimal(row,"jb_totalprice");o.courierPrice=decimal(row,"jb_cr_price");o.vatRate=decimal(row,"jb_sales_tax_rate");
o.createdAt=dateTime(row,"jb_createtime");o.updatedAt=dateTime(row,"jb_modify");o.deliveredAt=dateTime(row,"jb_finishtime");
return o;
}
static Stop stop(Map<String,Object> row) {
Stop s=new Stop();s.id=id(row,"tr_id");s.sequence=(int)id(row,"tr_sort");
// Existing VoTiAn tour field is preserved through the explicit warehouse direction mapping.
s.type=switch((int)id(row,"tr_ware_from_to")){case 1->StopType.PICKUP;case 2->StopType.DELIVERY;case 3->StopType.BOTH;default->s.sequence==1?StopType.PICKUP:StopType.DELIVERY;};
s.name=join(text(row,"tr_comp"),text(row,"tr_comp2"));s.street=join(text(row,"ad_street"),text(row,"tr_hsno"));
s.postalCode=text(row,"ad_zipcode");s.city=text(row,"ad_city");s.contact=text(row,"tr_person");s.phone=text(row,"tr_phone");s.note=text(row,"tr_remark");
s.completedAt=id(row,"tr_status")==1?dateTime(row,"tr_finishtime"):null;s.recipientName=text(row,"tr_signname");return s;
}
}
@@ -0,0 +1,104 @@
package de.stadtbote.votian;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.*;
import static de.stadtbote.votian.Models.*;
/** SELECT-only adapter. Never writes data or manages the legacy schema. */
@Service
public class LegacyReadService {
private final DatabaseService database;
private final int maxResults;
public LegacyReadService(DatabaseService database,@Value("${app.max-results:500}") int maxResults) { this.database=database;this.maxResults=Math.max(1,Math.min(maxResults,5000)); }
static final String ORDER_FROM="""
FROM job jb LEFT JOIN costcenter cc ON cc.csc_id = jb.csc_id
LEFT JOIN costcenter payer ON payer.csc_id=jb.csc_id_payer
LEFT JOIN customer cs ON cs.cs_id = cc.cs_id
LEFT JOIN company cmp ON cmp.cmp_id = cs.cmp_id
""";
static final String ORDER_SELECT="""
SELECT jb.*, cc.cs_id AS app_customer_id, payer.cs_id AS app_payer_customer_id, cc.csc_name AS app_cost_center,
(SELECT MIN(mt.mt_value) FROM metatype mt WHERE mt.mt_type='vehicletype' AND mt.mt_sort=jb.vht_id) AS vehicle_label,
(SELECT GROUP_CONCAT(DISTINCT mt.mt_value ORDER BY mt.mt_sort SEPARATOR ', ') FROM metatype mt WHERE mt.mt_type='service' AND (jb.jb_service & (1 << mt.mt_sort)) <> 0) AS service_label
""";
public record OrderResult(List<DeliveryOrder> orders,long total,int limit) {}
public OrderResult orders(LegacyUser user,String q,OrderStatus status,Long customerId,Long courierId,LocalDate from,LocalDate to) {
user.require(4);
if(from!=null && to!=null && from.isAfter(to)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Das Startdatum liegt nach dem Enddatum.");
if(q!=null && !q.isBlank() && q.length()>200) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Suchtext ist zu lang.");
return database.readTransaction(() -> readOrders(user,q,status,customerId,courierId,from,to));
}
private OrderResult readOrders(LegacyUser user,String q,OrderStatus status,Long customerId,Long courierId,LocalDate from,LocalDate to) {
StringBuilder where=new StringBuilder(" WHERE jb.hq_id = ? ");List<Object> parameters=new ArrayList<>(List.of(user.headquartersId()));
if(from!=null){where.append(" AND jb.jb_ordertime >= ?");parameters.add(from.atStartOfDay());}
if(to!=null){where.append(" AND jb.jb_ordertime < ?");parameters.add(to.plusDays(1).atStartOfDay());}
if(customerId!=null){where.append(" AND cc.cs_id = ?");parameters.add(customerId);}
if(courierId!=null){where.append(" AND jb.cr_id = ?");parameters.add(courierId);}
if(status!=null) {
if(status==OrderStatus.CANCELLED) where.append(" AND jb.jb_storno IN (1,2,3,4)");
else {
where.append(" AND COALESCE(jb.jb_storno,0) = 0");
switch(status) {case DISPOSITION->where.append(" AND jb.jb_status IN (8,9,99)");case OFFERED->where.append(" AND jb.jb_status=0");case IN_PROGRESS->where.append(" AND jb.jb_status=1");case COMPLETED->where.append(" AND jb.jb_status=2");default->throw new IllegalStateException();}
}
}
if(q!=null && !q.isBlank()) {
where.append(" AND (CAST(jb.jb_id AS CHAR) LIKE ? OR jb.jb_commission_no LIKE ? OR cmp.cmp_comp LIKE ? OR EXISTS (SELECT 1 FROM tour t JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=jb.jb_id AND (t.tr_comp LIKE ? OR a.ad_city LIKE ? OR a.ad_street LIKE ?)))");
String term="%"+q.trim()+"%";for(int i=0;i<6;i++)parameters.add(term);
}
Long total=database.queryForObject("SELECT COUNT(*) "+ORDER_FROM+where,Long.class,parameters.toArray());
List<DeliveryOrder> result=database.queryForList(ORDER_SELECT+ORDER_FROM+where+" ORDER BY jb.jb_ordertime DESC,jb.jb_id DESC LIMIT "+maxResults,parameters.toArray()).stream().map(LegacyMapping::order).toList();
addStops(result);
return new OrderResult(result,total==null?0:total,maxResults);
}
public DeliveryOrder order(LegacyUser user,long id) {
user.require(4);
return database.readTransaction(() -> readOrder(user,id));
}
private DeliveryOrder readOrder(LegacyUser user,long id) {
List<DeliveryOrder> rows=database.queryForList(ORDER_SELECT+ORDER_FROM+" WHERE jb.jb_id=? AND jb.hq_id=?",id,user.headquartersId()).stream().map(LegacyMapping::order).toList();
if(rows.isEmpty())throw new ResponseStatusException(HttpStatus.NOT_FOUND,"Auftrag in dieser Niederlassung nicht gefunden.");
addStops(rows);return rows.getFirst();
}
void addStops(List<DeliveryOrder> orders) {
if(orders.isEmpty())return;
Map<Long,DeliveryOrder> byId=new HashMap<>();orders.forEach(o->byId.put(o.id,o));
String placeholders=String.join(",",Collections.nCopies(orders.size(),"?"));
database.queryForList("SELECT t.*,a.ad_street,a.ad_zipcode,a.ad_city FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id IN ("+placeholders+") ORDER BY t.jb_id,t.tr_sort,t.tr_id",orders.stream().map(o->o.id).toArray()).forEach(row->{
DeliveryOrder o=byId.get(LegacyMapping.id(row,"jb_id"));if(o!=null){Stop s=LegacyMapping.stop(row);o.stops.add(s);if(s.recipientName!=null&&!s.recipientName.isBlank())o.recipientName=s.recipientName;}
});
}
public List<Customer> customers(LegacyUser user,String q) {
user.require(0);
String sql="""
SELECT cs.*,cmp.cmp_comp,cmp.cmp_hsno,cmp.cmp_visible,cmp.cmp_authenticated,cmp.cmp_archived,
a.ad_street,a.ad_zipcode,a.ad_city,u.usr_firstname,u.usr_name,u.usr_phone,u.usr_email
FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id
LEFT JOIN address a ON a.ad_id=cmp.ad_id LEFT JOIN employee e ON e.emp_id=cs.cs_admin
LEFT JOIN user u ON u.usr_id=e.usr_id WHERE cs.hq_id=?
""";
List<Object> args=new ArrayList<>(List.of(user.headquartersId()));
if(q!=null&&!q.isBlank()){sql+=" AND (cmp.cmp_comp LIKE ? OR cs.cs_eid LIKE ? OR a.ad_city LIKE ?)";String term="%"+q.trim()+"%";args.add(term);args.add(term);args.add(term);}
return database.queryForList(sql+" ORDER BY cmp.cmp_comp LIMIT "+maxResults,args.toArray()).stream().map(LegacyMapping::customer).toList();
}
public List<Courier> couriers(LegacyUser user,String q) {
user.require(1);
String sql="""
SELECT cr.*,cmp.cmp_comp,cmp.cmp_visible,cmp.cmp_authenticated,cmp.cmp_archived,
u.usr_firstname,u.usr_name,u.usr_phone,
(SELECT MIN(mt.mt_value) FROM metatype mt WHERE mt.mt_type='vehicletype' AND mt.mt_sort=cr.vht_id) AS vehicle_label,
(SELECT MIN(cv.crvh_vh_sign) FROM couriervehicle cv WHERE cv.cr_id=cr.cr_id AND cv.crvh_sid=cr.cr_sid) AS crvh_vh_sign,
(SELECT COUNT(*) FROM job jb WHERE jb.cr_id=cr.cr_id AND jb.jb_status=1 AND jb.hq_id=u.hq_id) AS active_jobs
FROM courier cr JOIN user u ON u.usr_id=cr.usr_id JOIN company cmp ON cmp.cmp_id=cr.cmp_id WHERE u.hq_id=? AND cr.hq_id=u.hq_id
""";
List<Object> args=new ArrayList<>(List.of(user.headquartersId()));
if(q!=null&&!q.isBlank()){sql+=" AND (cmp.cmp_comp LIKE ? OR cr.cr_eid LIKE ? OR u.usr_name LIKE ?)";String term="%"+q.trim()+"%";args.add(term);args.add(term);args.add(term);}
return database.queryForList(sql+" ORDER BY cmp.cmp_comp LIMIT "+maxResults,args.toArray()).stream().map(LegacyMapping::courier).toList();
}
public DatabaseService.ConnectionCheck connectionCheck() { return database.checkConnection(); }
public int maxResults() { return maxResults; }
}
@@ -0,0 +1,15 @@
package de.stadtbote.votian;
import org.springframework.security.access.AccessDeniedException;
import java.security.Principal;
import static de.stadtbote.votian.Models.*;
/** Scope is the original headquarters. Cross-headquarters and customer access are never widened implicitly. */
public record LegacyUser(long userId, long employeeId, long headquartersId, String username, String rights) implements Principal {
@Override public String getName() { return username; }
public boolean hasRight(int position) { return rights != null && rights.length() > position && rights.charAt(position) == '1'; }
public void require(int position) {
if (!hasRight(position)) throw new AccessDeniedException("Für diesen Bereich fehlt die Berechtigung im vorhandenen VoTiAn-Konto.");
}
public Capabilities capabilities() { return new Capabilities(hasRight(4),hasRight(0),hasRight(1),false,false,false,false,false,false,hasRight(4) && hasRight(5)); }
}
@@ -0,0 +1,60 @@
package de.stadtbote.votian;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
/** API projections only. These classes neither define nor create database tables. */
public final class Models {
private Models() {}
public enum OrderStatus { DISPOSITION, OFFERED, IN_PROGRESS, COMPLETED, CANCELLED }
public enum StopType { PICKUP, DELIVERY, BOTH }
public enum CourierStatus { AVAILABLE, BUSY, OFFLINE }
public enum CancelMode { FREE, CHARGEABLE }
public static class Customer {
public Long id, version;
public String number, name, street, postalCode, city, contact, phone, email;
public Integer paymentDays;
public boolean active;
}
public static class Courier {
public Long id, version;
public String number, name, phone, vehicle, plate;
public CourierStatus status;
public boolean active;
}
public static class DeliveryOrder {
public Long id, version, customerId, payerCustomerId, courierId, invoiceId;
public String number;
public OrderStatus status;
public int legacyStatus, legacyStorno;
public long legacyService;
public boolean isCreditNote;
public String legacyStatusLabel;
public String service, vehicle;
public LocalDateTime pickupAt, deliveryDeadline;
public String reference, costCenter, notes;
public Integer packages;
public BigDecimal weight, distanceKm, netPrice, courierPrice, vatRate;
public String recipientName;
public LocalDateTime deliveredAt, createdAt, updatedAt;
public CancelMode cancelMode;
public List<Stop> stops = new ArrayList<>();
public List<History> history = new ArrayList<>();
}
public static class Stop {
public Long id;
public int sequence;
public StopType type;
public String name, street, postalCode, city, contact, phone, note;
public LocalDateTime completedAt;
public String recipientName;
}
public static class History {
public Long id;
public LocalDateTime at;
public String action, detail, actor;
}
public record Capabilities(boolean readOrders, boolean readCustomers, boolean readCouriers, boolean readInvoices,
boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders) {}
}
@@ -0,0 +1,101 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CsrfException;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
@Configuration
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public class SecurityConfig {
@Bean
SecurityFilterChain security(HttpSecurity http, LegacyAuthenticationProvider provider, ObjectMapper json) throws Exception {
return http.authenticationProvider(provider)
.csrf(csrf -> csrf.csrfTokenRepository(new HttpSessionCsrfTokenRepository()))
.cors(cors -> {})
// Authorization belongs to any upstream Basic authentication, not to VoTiAn.
.httpBasic(AbstractHttpConfigurer::disable)
.requestCache(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation(fixation -> fixation.changeSessionId()))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/login", "/api/logout").permitAll()
.requestMatchers("/api/login", "/api/logout").denyAll()
.requestMatchers("/api/health", "/api/system", "/api/csrf",
"/", "/index.html", "/assets/**", "/stadtbote-logo.png",
"/votian-logo.png", "/favicon.ico", "/error").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll())
.formLogin(form -> form
.loginPage("/")
.loginProcessingUrl("/api/login")
.successHandler((request, response, authentication) -> {
response.setContentType("application/json;charset=UTF-8");
json.writeValue(response.getOutputStream(), ApiController.userDetails(authentication));
})
.failureHandler((request, response, exception) -> {
boolean unavailable = exception instanceof AuthenticationServiceException;
response.setStatus(unavailable ? 503 : 401);
response.setContentType("application/json;charset=UTF-8");
json.writeValue(response.getOutputStream(), ApiErrors.body(unavailable
? ApiErrors.DATABASE_UNAVAILABLE
: "Bitte mit einem bestehenden VoTiAn-Zentralenkonto anmelden. Benutzername oder Passwort ist ungültig."));
}))
.logout(logout -> logout
.logoutUrl("/api/logout")
.invalidateHttpSession(true)
.clearAuthentication(true)
.deleteCookies("VOTIANSESSION")
.logoutSuccessHandler((request, response, authentication) -> response.setStatus(204)))
.exceptionHandling(errors -> errors
.authenticationEntryPoint((request, response, exception) -> {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
json.writeValue(response.getOutputStream(), ApiErrors.body("Bitte anmelden. Die Sitzung fehlt oder ist abgelaufen."));
})
.accessDeniedHandler((request, response, exception) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
boolean csrf = exception instanceof CsrfException;
var body = new LinkedHashMap<>(ApiErrors.body(csrf
? "Sicherheitsprüfung fehlgeschlagen. Bitte die Seite neu laden."
: "Für diesen Bereich fehlt die Berechtigung."));
if (csrf) body.put("code", "CSRF_INVALID");
json.writeValue(response.getOutputStream(), body);
}))
.build();
}
@Bean
CorsConfigurationSource cors(@Value("${app.cors-origins}") String origins) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Arrays.stream(origins.split(",")).map(String::trim).toList());
config.setAllowedMethods(List.of("GET", "POST", "OPTIONS"));
config.setAllowedHeaders(List.of("Content-Type", "X-CSRF-TOKEN"));
config.setExposedHeaders(List.of("Content-Disposition", "X-Total-Count", "X-Result-Limit"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
@@ -0,0 +1,46 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseDiagnostics;
import java.util.Arrays;
import org.springframework.boot.Banner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
@SpringBootApplication
public class VotianApplication {
public static void main(String[] args) {
if (Arrays.asList(args).contains("--database-check")) {
System.exit(databaseCheck());
}
SpringApplication.run(VotianApplication.class, args);
}
private static int databaseCheck() {
SpringApplication application = new SpringApplication(VotianApplication.class);
application.setWebApplicationType(WebApplicationType.NONE);
application.setBannerMode(Banner.Mode.OFF);
application.setLogStartupInfo(false);
DatabaseDiagnostics.Result result;
// Keep stdout machine-readable and prevent JDBC startup errors exposing connection details.
try (ConfigurableApplicationContext context = application.run(
"--spring.main.web-application-type=none", "--spring.main.banner-mode=off",
"--logging.level.root=OFF", "--logging.level.com.zaxxer.hikari=OFF",
"--logging.level.org.mariadb.jdbc=OFF")) {
result = context.getBean(DatabaseDiagnostics.class).check();
} catch (RuntimeException error) {
result = DatabaseDiagnostics.failure("CONFIGURATION_ERROR",
"Backend-Konfiguration oder Datenbankverbindung prüfen.", null);
}
try {
System.out.println(new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(result.details()));
} catch (JsonProcessingException error) {
System.out.println("{\"connected\":false,\"reason\":\"DIAGNOSTIC_ERROR\",\"message\":\"Diagnose konnte nicht ausgegeben werden.\",\"writes\":0}");
return 1;
}
return result.exitCode();
}
}
@@ -0,0 +1,95 @@
package de.stadtbote.votian.database;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Service;
/** Checks only connectivity and schema metadata through the shared database service. */
@Service
public class DatabaseDiagnostics {
private static final Map<String, List<String>> REQUIRED = Map.ofEntries(
Map.entry("job", columns("jb_id hq_id csc_id csc_id_payer cr_id jb_status jb_storno jb_version jb_ordertime jb_totalprice jb_cr_price jb_service jb_commission_no vht_id")),
Map.entry("tour", columns("tr_id jb_id ad_id tr_sort tr_ware_from_to tr_status tr_finishtime tr_signname")),
Map.entry("customer", columns("cs_id cmp_id hq_id cs_eid cs_admin")),
Map.entry("company", columns("cmp_id cmp_comp ad_id cmp_hsno cmp_archived cmp_visible cmp_authenticated")),
Map.entry("costcenter", columns("csc_id cs_id csc_name")),
Map.entry("address", columns("ad_id ad_street ad_zipcode ad_city")),
Map.entry("courier", columns("cr_id hq_id usr_id cmp_id cr_sid cr_eid vht_id cr_available")),
Map.entry("couriervehicle", columns("cr_id crvh_sid crvh_vh_sign")),
Map.entry("user", columns("usr_id hq_id usr_account usr_type usr_password usr_password_old usr_firstname usr_name usr_phone usr_email")),
Map.entry("employee", columns("emp_id usr_id emp_rights")),
Map.entry("headquarters", columns("hq_id")),
Map.entry("mandatorheadquarters", columns("hq_id md_id")),
Map.entry("metatype", columns("mt_type mt_sort mt_value")));
private final DatabaseService database;
public DatabaseDiagnostics(DatabaseService database) {
this.database = database;
}
public record Result(Map<String, Object> details, int exitCode) {}
public Result check() {
try {
DatabaseService.ConnectionCheck connection = database.checkConnection();
if (!connection.connected()) {
Integer errorCode = switch (connection.reason()) {
case "AUTHENTICATION_FAILED" -> 1045;
case "DATABASE_ACCESS_DENIED" -> 1044;
default -> null;
};
return failure(connection.reason(), connection.message(), errorCode);
}
String version = database.queryForObject("SELECT VERSION()", String.class);
List<Map<String, Object>> metadata = database.queryForList(
"SELECT TABLE_NAME AS table_name,COLUMN_NAME AS column_name "
+ "FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE()");
Map<String, Set<String>> actual = new HashMap<>();
for (Map<String, Object> row : metadata) {
String table = row.get("table_name").toString().toLowerCase(Locale.ROOT);
String column = row.get("column_name").toString().toLowerCase(Locale.ROOT);
actual.computeIfAbsent(table, ignored -> new HashSet<>()).add(column);
}
Map<String, List<String>> missing = new LinkedHashMap<>();
REQUIRED.keySet().stream().sorted().forEach(table -> {
List<String> absent = new ArrayList<>(REQUIRED.get(table));
absent.removeAll(actual.getOrDefault(table, Set.of()));
absent.sort(String::compareTo);
if (!absent.isEmpty()) missing.put(table, absent);
});
Map<String, Object> details = new LinkedHashMap<>();
details.put("connected", true);
details.put("serverVersion", version);
details.put("checkedTables", REQUIRED.size());
details.put("missingColumns", missing);
details.put("applicationRowsRead", 0);
details.put("writes", 0);
return new Result(details, missing.isEmpty() ? 0 : 1);
} catch (RuntimeException error) {
// JDBC exception text can contain connection details; never expose it.
return failure("UNAVAILABLE", "Datenbankverbindung oder Zugriff auf Schemametadaten prüfen.", null);
}
}
public static Result failure(String reason, String message, Integer errorCode) {
Map<String, Object> details = new LinkedHashMap<>();
details.put("connected", false);
details.put("reason", reason);
details.put("errorCode", errorCode);
details.put("message", message);
details.put("applicationRowsRead", 0);
details.put("writes", 0);
return new Result(details, 1);
}
private static List<String> columns(String columns) {
return List.of(columns.split(" "));
}
}
@@ -0,0 +1,96 @@
package de.stadtbote.votian.database;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionException;
import org.springframework.transaction.support.TransactionTemplate;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
import java.util.regex.Pattern;
/** The only application component allowed to execute SQL or control database transactions. */
@Service
public class DatabaseService {
public static final String DATABASE_UNAVAILABLE = "Die vorhandene VoTiAn-Datenbank ist nicht erreichbar oder das Schema konnte nicht gelesen werden. Bitte Verbindung/VPN und DB-Konfiguration prüfen.";
private static final Pattern SELECT = Pattern.compile("^SELECT\\b", Pattern.CASE_INSENSITIVE);
private static final Pattern SIDE_EFFECT = Pattern.compile("\\b(?:INTO|FOR\\s+UPDATE|LOCK\\s+IN\\s+SHARE\\s+MODE)\\b", Pattern.CASE_INSENSITIVE);
private final JdbcTemplate jdbc;
private final TransactionTemplate readTransaction;
@Autowired
public DatabaseService(JdbcTemplate jdbc, DataSourceTransactionManager transactions) {
this(jdbc, (PlatformTransactionManager)transactions);
// A JDBC read-only hint alone does not enforce MariaDB's transaction access mode.
transactions.setEnforceReadOnly(true);
}
// Package-private test seam; production injection always uses the enforcing constructor above.
DatabaseService(JdbcTemplate jdbc, PlatformTransactionManager transactions) {
this.jdbc = jdbc;
this.readTransaction = new TransactionTemplate(transactions);
this.readTransaction.setReadOnly(true);
}
/** SQL is application-owned; values must be supplied as bound parameters. No raw JDBC is exposed. */
public List<Map<String,Object>> queryForList(String sql, Object... parameters) {
requireReadStatement(sql);
return readTransaction(() -> jdbc.queryForList(sql, parameters));
}
public <T> T queryForObject(String sql, Class<T> type, Object... parameters) {
requireReadStatement(sql);
return readTransaction(() -> jdbc.queryForObject(sql, type, parameters));
}
/** Groups dependent reads (count, orders and stops) in one database transaction. */
public <T> T readTransaction(Supplier<T> work) {
try {
return readTransaction.execute(status -> work.get());
} catch (TransactionException exception) {
throw new DataAccessResourceFailureException(DATABASE_UNAVAILABLE, exception);
}
}
/** Central entry point for future writes. No incomplete legacy workflow may write today. */
public int update(String sql, Object... parameters) {
throw new DatabaseWritesDisabledException();
}
public record ConnectionCheck(boolean connected, String reason, String message) {}
public ConnectionCheck checkConnection() {
try {
boolean connected = Objects.equals(queryForObject("SELECT 1", Integer.class), 1);
return new ConnectionCheck(connected, connected ? "CONNECTED" : "UNAVAILABLE", connected
? "Mit der vorhandenen VoTiAn-Datenbank verbunden. Diese Ausbaustufe liest Bestandsdaten; Erfassung und Änderungen erfolgen im bestehenden VoTiAn."
: DATABASE_UNAVAILABLE);
} catch (DataAccessException exception) {
for (Throwable cause = exception; cause != null; cause = cause.getCause()) {
if (cause instanceof SQLException sql && (sql.getErrorCode() == 1045 || "28000".equals(sql.getSQLState()))) {
return new ConnectionCheck(false, "AUTHENTICATION_FAILED", "Der MySQL-Server ist erreichbar, weist das Datenbankkonto jedoch ab. Bitte Zugangsdaten und Hostfreigabe prüfen (MySQL 1045).");
}
if (cause instanceof SQLException sql && sql.getErrorCode() == 1044) {
return new ConnectionCheck(false, "DATABASE_ACCESS_DENIED", "Das Datenbankkonto hat keine Zugriffsrechte auf die vorhandene Datenbank (MySQL 1044).");
}
}
return new ConnectionCheck(false, "UNAVAILABLE", DATABASE_UNAVAILABLE);
}
}
private static void requireReadStatement(String sql) {
String statement = sql == null ? "" : sql.strip();
// Guard against accidental use of the read API for writes, not a parser for untrusted SQL.
if (!SELECT.matcher(statement).find() || statement.contains(";") || statement.contains("--")
|| statement.contains("/*") || statement.contains("#") || SIDE_EFFECT.matcher(statement).find()) {
throw new IllegalArgumentException("Für Lesezugriffe sind nur einzelne SELECT-Anweisungen ohne Seiteneffekte zulässig.");
}
}
}
@@ -0,0 +1,8 @@
package de.stadtbote.votian.database;
/** Business write workflows have not yet been ported; the central database boundary enforces this. */
public final class DatabaseWritesDisabledException extends UnsupportedOperationException {
public DatabaseWritesDisabledException() {
super("Schreibzugriffe sind in dieser Ausbaustufe gesperrt. Bitte die bestehende VoTiAn-Anwendung verwenden.");
}
}
@@ -0,0 +1,47 @@
server:
address: ${SERVER_ADDRESS:127.0.0.1}
port: ${PORT:8080}
forward-headers-strategy: ${FORWARD_HEADERS_STRATEGY:native}
servlet:
session:
timeout: ${SESSION_TIMEOUT:30m}
tracking-modes: cookie
cookie:
name: VOTIANSESSION
http-only: true
same-site: lax
error:
include-message: never
spring:
application:
name: votian-ng
datasource:
# Only the existing database; never initialize or replace its schema.
url: ${DB_URL:jdbc:mariadb://127.0.0.1:1/unconfigured?connectTimeout=2000&socketTimeout=5000}
username: ${DB_USER:}
password: ${DB_PASSWORD:}
driver-class-name: org.mariadb.jdbc.Driver
hikari:
initialization-fail-timeout: -1
connection-timeout: 3000
validation-timeout: 1000
maximum-pool-size: 4
minimum-idle: 0
read-only: true
data-source-properties:
# Preserve numeric legacy codes, including values above 1 in TINYINT(1).
tinyInt1isBit: false
sql:
init:
mode: never
jackson:
serialization:
write-dates-as-timestamps: false
app:
cors-origins: ${CORS_ORIGINS:http://localhost:5173,http://127.0.0.1:5173}
legacy-url: ${LEGACY_APP_URL:}
max-results: ${MAX_RESULTS:500}
logging:
level:
com.zaxxer.hikari: WARN
org.mariadb.jdbc: ERROR
@@ -0,0 +1,191 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest(properties={"spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"})
@AutoConfigureMockMvc
class ApiSecurityTest {
@Autowired MockMvc mvc;
@Autowired ObjectMapper json;
@MockitoBean DatabaseService database;
record Csrf(MockHttpSession session, String token, String header) {}
@BeforeEach void configure() {
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(true,"CONNECTED","Verbunden"));
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("existing-user"),eq("existing-password"),eq("existing-password")))
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","existing-user","emp_rights","111111111111")));
}
Csrf csrf(MockHttpSession session) throws Exception {
var request = get("/api/csrf");
if (session != null) request.session(session);
MvcResult response = mvc.perform(request).andExpect(status().isOk())
.andExpect(header().string("Cache-Control", org.hamcrest.Matchers.containsString("no-store")))
.andReturn();
var body = json.readTree(response.getResponse().getContentAsString());
return new Csrf((MockHttpSession) response.getRequest().getSession(false), body.get("token").asText(), body.get("headerName").asText());
}
MvcResult login(Csrf csrf, String username, String password, int expectedStatus) throws Exception {
// Represents upstream htaccess authentication: VoTiAn must ignore this header.
return mvc.perform(post("/api/login").session(csrf.session()).header(csrf.header(),csrf.token())
.with(httpBasic("proxy-fixture","proxy-password"))
.contentType("application/x-www-form-urlencoded").param("username",username).param("password",password))
.andExpect(status().is(expectedStatus)).andExpect(header().doesNotExist("WWW-Authenticate")).andReturn();
}
MockHttpSession authenticated(String username, String password) throws Exception {
return (MockHttpSession) login(csrf(null),username,password,200).getRequest().getSession(false);
}
@Test void healthAndSystemArePublicButDataRequiresSession() throws Exception {
mvc.perform(get("/api/health").with(httpBasic("proxy-fixture","proxy-password")))
.andExpect(status().isOk()).andExpect(jsonPath("$.status").value("UP"));
mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.readOnly").value(true))
.andExpect(jsonPath("$.schemaChanges").value(false)).andExpect(jsonPath("$.capabilities.writeOrders").value(false));
MvcResult unauthorized = mvc.perform(get("/api/orders")).andExpect(status().isUnauthorized())
.andExpect(header().doesNotExist("WWW-Authenticate")).andReturn();
assertThat(unauthorized.getRequest().getSession(false)).isNull();
Csrf csrf = csrf(null);
mvc.perform(post("/api/orders").session(csrf.session()).header(csrf.header(),csrf.token())
.contentType("application/json").content("{}"))
.andExpect(status().isUnauthorized());
}
@Test void existingLoginPersistsSessionRotatesIdAndStoresNoPassword() throws Exception {
Csrf anonymous = csrf(null);
String previousId = anonymous.session().getId();
MvcResult login = login(anonymous,"existing-user","existing-password",200);
var body = json.readTree(login.getResponse().getContentAsString());
assertThat(body.get("username").asText()).isEqualTo("existing-user");
assertThat(body.has("password")).isFalse();
MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false);
assertThat(session.getId()).isNotEqualTo(previousId);
SecurityContext context = (SecurityContext) session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
assertThat(context.getAuthentication().getCredentials()).isNull();
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk())
.andExpect(jsonPath("$.headquartersId").value(7)).andExpect(jsonPath("$.roles[0]").value("VIEWER"))
.andExpect(jsonPath("$.password").doesNotExist()).andExpect(jsonPath("$.capabilities.exportOrders").value(true))
.andExpect(jsonPath("$.capabilities.writeOrders").value(false));
mvc.perform(get("/api/me").session(session).with(httpBasic("proxy-fixture","proxy-password")))
.andExpect(status().isOk()).andExpect(jsonPath("$.username").value("existing-user"));
verify(database,times(1)).queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),any(),any(),any());
}
@Test void basicCredentialsAloneCannotAuthenticateTheApplication() throws Exception {
mvc.perform(get("/api/me").with(httpBasic("existing-user","existing-password")))
.andExpect(status().isUnauthorized()).andExpect(header().doesNotExist("WWW-Authenticate"));
verify(database,never()).queryForList(anyString(),any(),any(),any());
}
@Test void loginRequiresCsrfAndDoesNotAcceptCredentialsInGetQuery() throws Exception {
mvc.perform(post("/api/login").param("username","existing-user").param("password","existing-password"))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
mvc.perform(get("/api/login").param("username","existing-user").param("password","existing-password"))
.andExpect(status().isUnauthorized());
verify(database,never()).queryForList(anyString(),any(),any(),any());
}
@Test void loginRotatesCsrfAndLogoutInvalidatesSession() throws Exception {
Csrf anonymous = csrf(null);
MockHttpSession session = (MockHttpSession) login(anonymous,"existing-user","existing-password",200).getRequest().getSession(false);
mvc.perform(post("/api/logout").session(session).header(anonymous.header(),anonymous.token()))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk());
Csrf authenticated = csrf(session);
mvc.perform(post("/api/logout").session(session).header(authenticated.header(),authenticated.token()))
.andExpect(status().isNoContent()).andExpect(cookie().maxAge("VOTIANSESSION",0));
assertThat(session.isInvalid()).isTrue();
mvc.perform(get("/api/me")).andExpect(status().isUnauthorized());
Csrf afterLogout = csrf(null);
mvc.perform(post("/api/login").session(afterLogout.session()).header(authenticated.header(),authenticated.token())
.param("username","existing-user").param("password","existing-password"))
.andExpect(status().isForbidden());
login(afterLogout,"existing-user","existing-password",200);
}
@Test void logoutRequiresCsrfAndGetCannotLogOut() throws Exception {
MockHttpSession session = authenticated("existing-user","existing-password");
mvc.perform(post("/api/logout").session(session)).andExpect(status().isForbidden());
mvc.perform(get("/api/logout").session(session)).andExpect(status().isForbidden());
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk());
}
@Test void csrfCannotBeReusedAcrossSessions() throws Exception {
Csrf first = csrf(null);
Csrf second = csrf(null);
mvc.perform(post("/api/login").session(second.session()).header(first.header(),first.token())
.param("username","existing-user").param("password","existing-password"))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
verify(database,never()).queryForList(anyString(),any(),any(),any());
}
@Test void noWriteEndpointCanModifyTheDatabase() throws Exception {
MockHttpSession session = authenticated("existing-user","existing-password");
Csrf csrf = csrf(session);
mvc.perform(post("/api/orders/12/cancel").session(session).header(csrf.header(),csrf.token())
.contentType("application/json").content("{\"version\":1,\"cancelMode\":\"FREE\",\"reason\":\"test\"}"))
.andExpect(status().isNotImplemented());
verify(database,never()).update(anyString(),any(Object[].class));
}
@Test void backendDatabaseFailureIs503NotFakeLoginOrEmptyDataset() throws Exception {
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("offline"),eq("password"),eq("password")))
.thenThrow(new DataAccessResourceFailureException("server secret must not be exposed"));
var result = login(csrf(null),"offline","password",503);
assertThat(json.readTree(result.getResponse().getContentAsString()).get("message").asText()).isEqualTo(ApiErrors.DATABASE_UNAVAILABLE);
assertThat(result.getRequest().getSession(false).getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)).isNull();
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(false,"UNAVAILABLE",ApiErrors.DATABASE_UNAVAILABLE));
mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.connected").value(false));
}
@Test void wrongOrUnscopedAccountDoesNotAuthenticate() throws Exception {
Csrf wrong = csrf(null);
login(wrong,"unknown","incorrect",401);
mvc.perform(get("/api/me").session(wrong.session())).andExpect(status().isUnauthorized());
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("invalid"),eq("password"),eq("password")))
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",0,"usr_account","invalid","emp_rights","111111")));
login(csrf(null),"invalid","password",401);
}
@Test void rightsAreEnforcedBeyondAuthentication() throws Exception {
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("restricted"),eq("password"),eq("password")))
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","restricted","emp_rights","0000000000")));
MockHttpSession session = authenticated("restricted","password");
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk()).andExpect(jsonPath("$.capabilities.exportOrders").value(false));
mvc.perform(get("/api/orders").session(session)).andExpect(status().isForbidden());
mvc.perform(get("/api/customers").session(session)).andExpect(status().isForbidden());
mvc.perform(get("/api/orders/export.csv").session(session)).andExpect(status().isForbidden());
}
@Test void databaseAccountDenialIsDistinguishedFromNetworkFailure() throws Exception {
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(false,"AUTHENTICATION_FAILED","Bitte Hostfreigabe prüfen."));
mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.connected").value(false))
.andExpect(jsonPath("$.reason").value("AUTHENTICATION_FAILED"))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("Hostfreigabe")));
}
}
@@ -0,0 +1,243 @@
package de.stadtbote.votian;
import org.junit.jupiter.api.Test;
import org.springframework.asm.AnnotationVisitor;
import org.springframework.asm.ClassReader;
import org.springframework.asm.ClassVisitor;
import org.springframework.asm.ClassWriter;
import org.springframework.asm.ConstantDynamic;
import org.springframework.asm.FieldVisitor;
import org.springframework.asm.Handle;
import org.springframework.asm.MethodVisitor;
import org.springframework.asm.Opcodes;
import org.springframework.asm.RecordComponentVisitor;
import org.springframework.asm.Type;
import net.bytebuddy.jar.asm.signature.SignatureReader;
import net.bytebuddy.jar.asm.signature.SignatureVisitor;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
/** Enforces the database boundary using compiled dependencies, including fully qualified references. */
class DatabaseAccessArchitectureTest {
private static final String BASE = "de/stadtbote/votian/";
private static final String SERVICE = BASE + "database/DatabaseService";
private static final String SERVICE_TEST = BASE + "database/DatabaseServiceTest";
private static final String THIS_TEST = BASE + "DatabaseAccessArchitectureTest";
private static final List<String> FORBIDDEN_PREFIXES = List.of(
"java/sql/", "javax/sql/", "org/springframework/jdbc/",
"org/springframework/transaction/", "org/springframework/orm/",
"org/springframework/data/", "jakarta/persistence/", "javax/persistence/",
"org/hibernate/", "com/zaxxer/hikari/", "org/mariadb/jdbc/", "com/mysql/",
"org/postgresql/", "org/h2/", "org/apache/commons/dbcp", "org/apache/tomcat/jdbc/"
);
// Timestamp is a value already returned by JDBC, not a connection or execution API.
private static final Set<String> ALLOWED_VALUE_TYPES = Set.of("java/sql/Timestamp");
@Test void productionDatabaseAccessGoesThroughOneService() throws Exception {
assertNoBypasses(classesDirectory(VotianApplication.class), false);
}
@Test void testsUseTheSameBoundaryExceptTheServiceOwnUnitTests() throws Exception {
assertNoBypasses(classesDirectory(DatabaseAccessArchitectureTest.class), true);
}
@Test void bytecodeGuardDetectsConstructorFieldsCallsAndReflectiveTypeNames() {
ClassWriter writer = new ClassWriter(0);
writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "ForbiddenAccess", null, "java/lang/Object", null);
writer.visitField(Opcodes.ACC_PRIVATE, "template", "Lorg/springframework/jdbc/core/JdbcTemplate;", null, null).visitEnd();
MethodVisitor method = writer.visitMethod(Opcodes.ACC_PUBLIC, "read", "(Ljavax/sql/DataSource;)V", null, null);
method.visitCode();
method.visitMethodInsn(Opcodes.INVOKESTATIC, "java/sql/DriverManager", "getConnection", "(Ljava/lang/String;)Ljava/sql/Connection;", false);
method.visitLdcInsn("jakarta.persistence.EntityManager");
method.visitInsn(Opcodes.RETURN);
method.visitMaxs(2, 2);
method.visitEnd();
writer.visitEnd();
var findings = dependencies(writer.toByteArray());
assertThat(findings).contains("org/springframework/jdbc/core/JdbcTemplate", "javax/sql/DataSource",
"java/sql/DriverManager", "java/sql/Connection", "jakarta/persistence/EntityManager");
}
@Test void timestampValueMappingRemainsAllowedButGenericJdbcDependenciesDoNot() {
ClassWriter writer = new ClassWriter(0);
writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "ValueMapping", null, "java/lang/Object", null);
writer.visitField(Opcodes.ACC_PRIVATE, "timestamp", "Ljava/sql/Timestamp;", null, null).visitEnd();
writer.visitEnd();
assertThat(dependencies(writer.toByteArray())).isEmpty();
writer = new ClassWriter(0);
writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "GenericBypass", null, "java/lang/Object", null);
writer.visitField(Opcodes.ACC_PRIVATE, "connections", "Ljava/util/List;", "Ljava/util/List<Ljava/sql/Connection;>;", null).visitEnd();
writer.visitEnd();
assertThat(dependencies(writer.toByteArray())).containsExactly("java/sql/Connection");
}
@Test void pythonLaunchersDoNotOpenTheirOwnDatabaseConnections() throws Exception {
Path scripts = projectRoot().resolve("scripts");
assertThat(scripts).isDirectory();
// Parse source only: do not execute/import launchers or read PHP/local connection files.
String checker = """
import ast, pathlib, sys
drivers = {'pymysql', 'MySQLdb', 'mysql', 'mariadb', 'psycopg', 'psycopg2',
'sqlite3', 'aiosqlite', 'sqlalchemy', 'asyncpg', 'pg8000', 'pyodbc',
'jaydebeapi', 'adodbapi', 'peewee', 'tortoise', 'databases', 'duckdb'}
db_commands = {'mysql', 'mariadb', 'psql', 'sqlite3', 'sqlcmd', 'isql'}
def forbidden(module):
return bool(module) and module.split('.')[0] in drivers
def violations(source):
problems = []
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Import):
problems += [(node.lineno, item.name) for item in node.names if forbidden(item.name)]
elif isinstance(node, ast.ImportFrom) and forbidden(node.module):
problems.append((node.lineno, node.module))
elif isinstance(node, ast.Call) and node.args:
function = node.func.id if isinstance(node.func, ast.Name) else node.func.attr if isinstance(node.func, ast.Attribute) else ''
first = node.args[0]
if function in {'__import__', 'import_module'} and isinstance(first, ast.Constant) and isinstance(first.value, str) and forbidden(first.value):
problems.append((node.lineno, first.value))
elif function in {'run', 'Popen', 'call', 'check_call', 'check_output', 'system', 'execvp', 'execvpe'}:
command = first.elts[0] if isinstance(first, (ast.List, ast.Tuple)) and first.elts else first
if isinstance(command, ast.Constant) and isinstance(command.value, str):
executable = pathlib.Path(command.value.split()[0]).name if command.value.split() else ''
if executable in db_commands:
problems.append((node.lineno, executable))
return problems
assert violations('import pymysql as database')
assert violations('from mysql import connector')
assert violations("importlib.import_module('mariadb')")
assert violations("subprocess.run(['mysql', '--version'])")
assert not violations("import json; subprocess.run(['java', '-jar', 'app.jar'])")
findings = []
for path in sorted(pathlib.Path(sys.argv[1]).rglob('*.py')):
for line, name in violations(path.read_text(encoding='utf-8')):
findings.append(f'{path.name}:{line}: direct database dependency {name}')
print('\\n'.join(findings))
sys.exit(1 if findings else 0)
""";
Process process = new ProcessBuilder("python3", "-c", checker, scripts.toString()).redirectErrorStream(true).start();
boolean finished = process.waitFor(30, TimeUnit.SECONDS);
if (!finished) process.destroyForcibly();
assertThat(finished).as("Python AST architecture check must terminate without starting the application").isTrue();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
assertThat(process.exitValue()).as("Python launchers must delegate database work to the Java DatabaseService:%n%s", output).isZero();
}
private static Path projectRoot() {
Path directory = Path.of("").toAbsolutePath().normalize();
for (int level = 0; directory != null && level < 5; level++, directory = directory.getParent()) {
if (Files.isDirectory(directory.resolve("backend")) && Files.isDirectory(directory.resolve("scripts"))) return directory;
}
throw new AssertionError("Cannot locate the project scripts directory from Maven working directory");
}
private static Path classesDirectory(Class<?> anchor) throws Exception {
Path classes = Path.of(anchor.getProtectionDomain().getCodeSource().getLocation().toURI());
assertThat(classes).as("Architecture check expects compiled class directories").isDirectory();
return classes;
}
private static boolean belongsTo(String name, String owner) { return name.equals(owner) || name.startsWith(owner + "$"); }
private static void assertNoBypasses(Path classes, boolean tests) throws IOException {
List<String> violations = new ArrayList<>();
if (!tests) assertThat(classes.resolve(SERVICE + ".class")).as("The central database service must exist").isRegularFile();
try (var paths = Files.walk(classes)) {
for (Path path : paths.filter(p -> p.toString().endsWith(".class")).sorted().toList()) {
byte[] bytes = Files.readAllBytes(path);
String name = new ClassReader(bytes).getClassName();
boolean exempt = belongsTo(name, SERVICE) || (tests && (belongsTo(name, SERVICE_TEST) || belongsTo(name, THIS_TEST)));
if (!exempt) for (String dependency : dependencies(bytes)) violations.add(name.replace('/', '.') + " -> " + dependency.replace('/', '.'));
}
}
assertThat(violations).as("Every DB-access type must remain inside DatabaseService (only DatabaseServiceTest may mock raw JDBC)").isEmpty();
}
private static Set<String> dependencies(byte[] bytes) {
Set<String> forbidden = new TreeSet<>();
new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) {
void name(String name) {
if (name == null) return;
if (name.startsWith("[")) { descriptor(name); return; }
if (!ALLOWED_VALUE_TYPES.contains(name) && FORBIDDEN_PREFIXES.stream().anyMatch(name::startsWith)) forbidden.add(name);
}
void type(Type type) {
switch (type.getSort()) {
case Type.OBJECT -> name(type.getInternalName());
case Type.ARRAY -> type(type.getElementType());
case Type.METHOD -> { type(type.getReturnType()); for (Type arg : type.getArgumentTypes()) type(arg); }
default -> { }
}
}
void descriptor(String descriptor) { if (descriptor != null) type(Type.getType(descriptor)); }
void signature(String signature, boolean field) {
if (signature == null) return;
var visitor = new SignatureVisitor(Opcodes.ASM9) {
@Override public void visitClassType(String name) { name(name); }
};
if (field) new SignatureReader(signature).acceptType(visitor); else new SignatureReader(signature).accept(visitor);
}
void handle(Handle handle) { name(handle.getOwner()); descriptor(handle.getDesc()); }
void constant(Object value) {
if (value instanceof Type type) type(type);
else if (value instanceof Handle handle) handle(handle);
else if (value instanceof ConstantDynamic dynamic) {
descriptor(dynamic.getDescriptor());handle(dynamic.getBootstrapMethod());
for (int i=0;i<dynamic.getBootstrapMethodArgumentCount();i++) constant(dynamic.getBootstrapMethodArgument(i));
} else if (value instanceof String text) name(text.replace('.', '/'));
}
AnnotationVisitor annotation(String descriptor) {
descriptor(descriptor);
return new AnnotationVisitor(Opcodes.ASM9) {
@Override public void visit(String name, Object value) { constant(value); }
@Override public void visitEnum(String name, String descriptor, String value) { descriptor(descriptor); }
@Override public AnnotationVisitor visitAnnotation(String name, String descriptor) { return annotation(descriptor); }
@Override public AnnotationVisitor visitArray(String name) { return annotation(null); }
};
}
@Override public void visit(int version,int access,String name,String signature,String superName,String[] interfaces) {
name(superName);if(interfaces!=null)for(String iface:interfaces)name(iface);signature(signature,false);
}
@Override public AnnotationVisitor visitAnnotation(String descriptor,boolean visible) { return annotation(descriptor); }
@Override public RecordComponentVisitor visitRecordComponent(String name,String descriptor,String signature) {
descriptor(descriptor);signature(signature,true);
return new RecordComponentVisitor(Opcodes.ASM9) {
@Override public AnnotationVisitor visitAnnotation(String descriptor,boolean visible) { return annotation(descriptor); }
};
}
@Override public FieldVisitor visitField(int access,String name,String descriptor,String signature,Object value) {
descriptor(descriptor);signature(signature,true);constant(value);
return new FieldVisitor(Opcodes.ASM9) {
@Override public AnnotationVisitor visitAnnotation(String descriptor,boolean visible) { return annotation(descriptor); }
};
}
@Override public MethodVisitor visitMethod(int access,String name,String descriptor,String signature,String[] exceptions) {
descriptor(descriptor);signature(signature,false);if(exceptions!=null)for(String exception:exceptions)name(exception);
return new MethodVisitor(Opcodes.ASM9) {
@Override public AnnotationVisitor visitAnnotation(String descriptor,boolean visible) { return annotation(descriptor); }
@Override public AnnotationVisitor visitParameterAnnotation(int parameter,String descriptor,boolean visible) { return annotation(descriptor); }
@Override public void visitTypeInsn(int opcode,String type) { name(type); }
@Override public void visitFieldInsn(int opcode,String owner,String name,String descriptor) { name(owner);descriptor(descriptor); }
@Override public void visitMethodInsn(int opcode,String owner,String name,String descriptor,boolean isInterface) { name(owner);descriptor(descriptor); }
@Override public void visitInvokeDynamicInsn(String name,String descriptor,Handle bootstrap,Object... arguments) {
descriptor(descriptor);handle(bootstrap);for(Object argument:arguments)constant(argument);
}
@Override public void visitLdcInsn(Object value) { constant(value); }
@Override public void visitMultiANewArrayInsn(String descriptor,int dimensions) { descriptor(descriptor); }
@Override public void visitTryCatchBlock(org.springframework.asm.Label start,org.springframework.asm.Label end,org.springframework.asm.Label handler,String type) { name(type); }
};
}
}, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
return forbidden;
}
}
@@ -0,0 +1,43 @@
package de.stadtbote.votian;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/** Opt-in SELECT-only check against the configured existing database. No accounts or data are created. */
@EnabledIfEnvironmentVariable(named="VOTIAN_VERIFY_EXISTING_DB", matches="true")
@SpringBootTest
@AutoConfigureMockMvc(printOnlyOnFailure=false, print=org.springframework.boot.test.autoconfigure.web.servlet.MockMvcPrint.NONE)
class LegacyDatabaseReadIntegrationTest {
@Autowired DatabaseService database;
@Autowired MockMvc mvc;
@Test void readsExistingListsThroughHttpWithNumericLegacyFlags() throws Exception {
// The session default is not the active transaction's access mode. Enforced READ ONLY
// is verified at the transaction boundary in DatabaseServiceTest, without test writes.
assertThat(database.checkConnection().connected()).isTrue();
// Select only a scope ID. Authentication is simulated in this test; no real account is used.
Long hq=database.queryForObject("SELECT hq_id FROM job ORDER BY jb_ordertime DESC LIMIT 1",Long.class);
assertThat(hq).isPositive();
var principal=new LegacyUser(0,0,hq,"read-only-integration-test","111111");
var auth=UsernamePasswordAuthenticationToken.authenticated(principal,null,List.of());
for(String route:List.of("/api/orders","/api/customers","/api/couriers")) {
mvc.perform(get(route).with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].id").isNumber());
}
Object archived=database.queryForObject("SELECT cmp_archived FROM company WHERE cmp_archived IS NOT NULL LIMIT 1",Object.class);
assertThat(archived).isInstanceOf(Number.class);
}
}
@@ -0,0 +1,98 @@
package de.stadtbote.votian;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static de.stadtbote.votian.Models.*;
class LegacyMappingTest {
@Test void mapsBooleanCompanyFlagsForActiveAndArchivedCustomers() {
var row=new HashMap<String,Object>(Map.of("cs_id",1,"cmp_archived",false,"cmp_visible",true,"cmp_authenticated",true));
assertThat(LegacyMapping.customer(row).active).isTrue();
row.put("cmp_archived",true);
assertThat(LegacyMapping.customer(row).active).isFalse();
row.put("cmp_archived",false);row.put("cmp_visible",false);
assertThat(LegacyMapping.customer(row).active).isFalse();
row.put("cmp_visible",true);row.put("cmp_authenticated",false);
assertThat(LegacyMapping.customer(row).active).isFalse();
}
@Test void mapsBooleanCompanyFlagsAndAvailabilityForCouriers() {
var row=new HashMap<String,Object>(Map.of("cr_id",1,"cmp_archived",false,"cmp_visible",true,"cmp_authenticated",true,"cr_available",true));
var courier=LegacyMapping.courier(row);
assertThat(courier.active).isTrue();
assertThat(courier.status).isEqualTo(CourierStatus.AVAILABLE);
row.put("cr_available",false);
assertThat(LegacyMapping.courier(row).status).isEqualTo(CourierStatus.OFFLINE);
row.put("active_jobs",1);
assertThat(LegacyMapping.courier(row).status).isEqualTo(CourierStatus.BUSY);
row.put("cmp_archived",true);
courier=LegacyMapping.courier(row);
assertThat(courier.active).isFalse();
assertThat(courier.status).isEqualTo(CourierStatus.OFFLINE);
row.put("cmp_archived",false);row.put("cmp_visible",false);
assertThat(LegacyMapping.courier(row).active).isFalse();
row.put("cmp_visible",true);row.put("cmp_authenticated",false);
assertThat(LegacyMapping.courier(row).active).isFalse();
}
@Test void preservesNumericStatusCancellationAndWarehouseDirectionValues() {
var row=new HashMap<String,Object>(Map.of("jb_id",1,"jb_status",2,"jb_storno",3));
var order=LegacyMapping.order(row);
assertThat(order.legacyStatus).isEqualTo(2);
assertThat(order.legacyStorno).isEqualTo(3);
assertThat(order.cancelMode).isEqualTo(CancelMode.CHARGEABLE);
assertThat(order.isCreditNote).isTrue();
row.put("jb_storno",4);
order=LegacyMapping.order(row);
assertThat(order.legacyStatus).isEqualTo(2);
assertThat(order.legacyStorno).isEqualTo(4);
assertThat(order.cancelMode).isEqualTo(CancelMode.FREE);
assertThat(order.isCreditNote).isTrue();
row.put("jb_storno",0);row.put("jb_status",9);
assertThat(LegacyMapping.order(row).legacyStatus).isEqualTo(9);
assertThat(LegacyMapping.order(row).status).isEqualTo(OrderStatus.DISPOSITION);
assertThat(LegacyMapping.stop(Map.of("tr_id",1,"tr_sort",2,"tr_ware_from_to",3)).type).isEqualTo(StopType.BOTH);
}
@Test void mapsLegacyStatusesWithoutLosingAutorankingOrCreditNote() {
var row=new HashMap<String,Object>(Map.of("jb_id",12,"jb_status",8,"jb_storno",0,"jb_totalprice",new BigDecimal("119.42"),"jb_subtotalprice",new BigDecimal("110.00"),"app_customer_id",15,"app_payer_customer_id",19,"jb_service",5));
var order=LegacyMapping.order(row);
assertThat(order.status).isEqualTo(OrderStatus.DISPOSITION);
assertThat(order.legacyStatusLabel).isEqualTo("Autoranking");
assertThat(order.netPrice).isEqualByComparingTo("119.42");
assertThat(order.customerId).isEqualTo(15L);
assertThat(order.payerCustomerId).isEqualTo(19L);
assertThat(order.legacyService).isEqualTo(5);
row.put("jb_storno",3);row.put("jb_status",2);
order=LegacyMapping.order(row);
assertThat(order.isCreditNote).isTrue();
assertThat(order.cancelMode).isEqualTo(CancelMode.CHARGEABLE);
assertThat(order.legacyStatusLabel).contains("Storno-Gutschrift");
}
@Test void respectsExplicitWarehouseDirectionBeforeStationPosition() {
var row=new HashMap<String,Object>(Map.of("tr_id",1,"tr_sort",3,"tr_ware_from_to",1));
assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.PICKUP);
row.put("tr_ware_from_to",3);
assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.BOTH);
row.put("tr_ware_from_to",2);row.put("tr_sort",1);
assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.DELIVERY);
row.put("tr_ware_from_to",0);
assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.PICKUP);
}
@Test void missingValuesAndMysqlZeroDatesRemainUnknown() {
var order=LegacyMapping.order(Map.of("jb_id",7,"jb_ordertime","0000-00-00 00:00:00","jb_modify","20261001123159"));
assertThat(order.pickupAt).isNull();
assertThat(order.netPrice).isNull();
assertThat(order.distanceKm).isNull();
assertThat(order.packages).isNull();
assertThat(order.updatedAt.toString()).isEqualTo("2026-10-01T12:31:59");
}
@Test void csvEscapesFormulaAndQuotes() {
assertThat(ApiController.csvCell("=HYPERLINK(\"test\")")).isEqualTo("\"'=HYPERLINK(\"\"test\"\")\"");
assertThat(ApiController.csvCell(null)).isEqualTo("\"\"");
}
@Test void doesNotPublishCredentialsFromLegacyUrl() {
assertThatThrownBy(()->ApiController.safeLegacyUrl("https://user:secret@example.org/votian")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(()->ApiController.safeLegacyUrl("javascript:alert(1)")).isInstanceOf(IllegalArgumentException.class);
assertThat(ApiController.safeLegacyUrl("https://example.org/votian/")).isEqualTo("https://example.org/votian");
}
}
@@ -0,0 +1,60 @@
package de.stadtbote.votian;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import java.util.function.Supplier;
import org.mockito.ArgumentCaptor;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class LegacyReadServiceTest {
final DatabaseService database=mock(DatabaseService.class);
final LegacyReadService service=new LegacyReadService(database,500);
final LegacyUser allowed=new LegacyUser(12,13,7,"disponent","111111111111");
@BeforeEach void configureTransactions() {
when(database.readTransaction(any())).thenAnswer(call -> ((Supplier<?>)call.getArgument(0)).get());
}
@Test void refusesUnpermittedDataWithoutTouchingDatabase() {
var denied=new LegacyUser(1,2,7,"restricted","0000000000");
assertThatThrownBy(()->service.orders(denied,null,null,null,null,null,null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->service.customers(denied,null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->service.couriers(denied,null)).isInstanceOf(AccessDeniedException.class);
verifyNoInteractions(database);
}
@Test void parameterizesScopeAndSearchAndLimitsOnlyAfterSqlFilters() {
when(database.queryForObject(anyString(),eq(Long.class),any(Object[].class))).thenReturn(0L);
when(database.queryForList(anyString(),any(Object[].class))).thenReturn(List.of());
service.orders(allowed,"' OR 1=1 --",null,23L,42L,LocalDate.of(2026,10,1),LocalDate.of(2026,10,5));
ArgumentCaptor<String> sql=ArgumentCaptor.forClass(String.class);
ArgumentCaptor<Object[]> args=ArgumentCaptor.forClass(Object[].class);
verify(database).queryForList(sql.capture(),args.capture());
assertThat(sql.getValue()).contains("jb.hq_id = ?","cc.cs_id = ?","jb.cr_id = ?","LIMIT 500").doesNotContain("OR 1=1");
assertThat(args.getValue()[0]).isEqualTo(7L);
assertThat(Arrays.asList(args.getValue())).contains("%' OR 1=1 --%");
}
@Test void singleOrderCannotEscapeHeadquarters() {
when(database.queryForList(anyString(),any(Object[].class))).thenReturn(List.of());
assertThatThrownBy(()->service.order(allowed,999)).isInstanceOf(ResponseStatusException.class);
verify(database).queryForList(contains("WHERE jb.jb_id=? AND jb.hq_id=?"),eq(999L),eq(7L));
}
@Test void customerAndCourierQueriesRemainScopedToHomeBranch() {
when(database.queryForList(anyString(),any(Object[].class))).thenReturn(List.of());
service.customers(allowed,null);service.couriers(allowed,null);
verify(database).queryForList(contains("WHERE cs.hq_id=?"),eq(7L));
verify(database).queryForList(contains("WHERE u.hq_id=? AND cr.hq_id=u.hq_id"),eq(7L));
}
@Test void invalidDateRangeNeverHitsDatabase() {
assertThatThrownBy(()->service.orders(allowed,null,null,null,null,LocalDate.of(2026,10,5),LocalDate.of(2026,10,1))).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(database);
}
@Test void invalidSearchIsRejectedBeforeOpeningATransaction() {
assertThatThrownBy(()->service.orders(allowed,"x".repeat(201),null,null,null,null,null)).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(database);
}
}
@@ -0,0 +1,62 @@
package de.stadtbote.votian.database;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class DatabaseDiagnosticsTest {
private final DatabaseService database = mock(DatabaseService.class);
private final DatabaseDiagnostics diagnostics = new DatabaseDiagnostics(database);
@Test void rejectedConnectionDoesNotReadMetadata() {
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(
false, "AUTHENTICATION_FAILED", "Anmeldung abgewiesen."));
DatabaseDiagnostics.Result result = diagnostics.check();
assertThat(result.exitCode()).isEqualTo(1);
assertThat(result.details()).containsEntry("connected", false).containsEntry("errorCode", 1045)
.containsEntry("applicationRowsRead", 0).containsEntry("writes", 0);
verify(database).checkConnection();
verifyNoMoreInteractions(database);
}
@Test void reportsMissingColumnsWithoutQueryingApplicationTables() {
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(true, "CONNECTED", "Verbunden."));
when(database.queryForObject("SELECT VERSION()", String.class)).thenReturn("test-server");
String metadataSql = "SELECT TABLE_NAME AS table_name,COLUMN_NAME AS column_name "
+ "FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE()";
when(database.queryForList(metadataSql)).thenReturn(List.of(
Map.of("table_name", "headquarters", "column_name", "hq_id"),
Map.of("table_name", "job", "column_name", "jb_id")));
DatabaseDiagnostics.Result result = diagnostics.check();
assertThat(result.exitCode()).isEqualTo(1);
assertThat(result.details()).containsEntry("connected", true).containsEntry("serverVersion", "test-server")
.containsEntry("checkedTables", 13).containsEntry("applicationRowsRead", 0).containsEntry("writes", 0);
@SuppressWarnings("unchecked")
Map<String, List<String>> missing = (Map<String, List<String>>) result.details().get("missingColumns");
assertThat(missing).doesNotContainKey("headquarters");
assertThat(missing.get("job")).contains("jb_totalprice", "csc_id_payer").doesNotContain("jb_id");
verify(database).checkConnection();
verify(database).queryForObject("SELECT VERSION()", String.class);
verify(database).queryForList(metadataSql);
verifyNoMoreInteractions(database);
}
@Test void neverReturnsJdbcExceptionDetails() {
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(true, "CONNECTED", "Verbunden."));
when(database.queryForObject("SELECT VERSION()", String.class))
.thenThrow(new IllegalStateException("jdbc:vendor://private-host password=secret"));
DatabaseDiagnostics.Result result = diagnostics.check();
assertThat(result.exitCode()).isEqualTo(1);
assertThat(result.details()).containsEntry("connected", false).containsEntry("reason", "UNAVAILABLE")
.containsEntry("writes", 0);
assertThat(result.details().toString()).doesNotContain("private-host", "secret", "jdbc:");
}
}
@@ -0,0 +1,119 @@
package de.stadtbote.votian.database;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.jdbc.CannotGetJdbcConnectionException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.CannotCreateTransactionException;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.SimpleTransactionStatus;
import java.sql.SQLException;
import java.sql.Connection;
import java.sql.Statement;
import javax.sql.DataSource;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class DatabaseServiceTest {
final JdbcTemplate jdbc=mock(JdbcTemplate.class);
final PlatformTransactionManager transactions=mock(PlatformTransactionManager.class);
final SimpleTransactionStatus transaction=new SimpleTransactionStatus();
final DatabaseService database=new DatabaseService(jdbc,transactions);
DatabaseServiceTest() {
when(transactions.getTransaction(any())).thenReturn(transaction);
clearInvocations(transactions);
}
@Test void enforcesReadOnlyOnTheServerBeforeExecutingQueries() throws Exception {
var source=mock(DataSource.class);
var connection=mock(Connection.class);
var statement=mock(Statement.class);
when(source.getConnection()).thenReturn(connection);
when(connection.getAutoCommit()).thenReturn(true);
when(connection.createStatement()).thenReturn(statement);
var manager=new DataSourceTransactionManager(source);
var service=new DatabaseService(jdbc,manager);
when(jdbc.queryForObject("SELECT 1",Integer.class,new Object[0])).thenReturn(1);
assertThat(service.queryForObject("SELECT 1",Integer.class)).isEqualTo(1);
assertThat(manager.isEnforceReadOnly()).isTrue();
var order=inOrder(statement,jdbc,connection);
order.verify(statement).executeUpdate("SET TRANSACTION READ ONLY");
order.verify(jdbc).queryForObject("SELECT 1",Integer.class,new Object[0]);
order.verify(connection).commit();
verify(connection).close();
}
@Test void bindsValuesWithoutInsertingThemIntoSqlAndUsesReadOnlyTransactions() {
String sql="SELECT jb_id FROM job WHERE jb_commission_no=? AND jb_storno=?";
Object[] values={"'; DELETE FROM job; --",null};
var rows=List.of(Map.<String,Object>of("jb_id",17));
when(jdbc.queryForList(sql,values)).thenReturn(rows);
assertThat(database.queryForList(sql,values)).isSameAs(rows);
verify(jdbc).queryForList(sql,values);
var definition=ArgumentCaptor.forClass(TransactionDefinition.class);
verify(transactions).getTransaction(definition.capture());
assertThat(definition.getValue().isReadOnly()).isTrue();
verify(transactions).commit(transaction);
}
@Test void typedQueriesPreserveNullResultsAndBoundParameters() {
when(jdbc.queryForObject("SELECT MAX(jb_id) FROM job WHERE hq_id=?",Long.class,7L)).thenReturn(null);
assertThat(database.queryForObject("SELECT MAX(jb_id) FROM job WHERE hq_id=?",Long.class,7L)).isNull();
verify(jdbc).queryForObject("SELECT MAX(jb_id) FROM job WHERE hq_id=?",Long.class,7L);
}
@ParameterizedTest
@ValueSource(strings={"UPDATE job SET jb_status=2", "INSERT INTO job VALUES (?)", "DELETE FROM job", "CREATE TABLE test(id INT)", "CALL procedure_name()", "SET autocommit=1", "SELECT 1; DELETE FROM job", "SELECT 1 INTO OUTFILE '/tmp/data'", "SELECT jb_id FROM job FOR UPDATE", "/* read */ DELETE FROM job", "SELECT 1 -- comment"})
void rejectsNonReadStatementsBeforeJdbcOrTransactions(String sql) {
assertThatIllegalArgumentException().isThrownBy(()->database.queryForList(sql));
assertThatIllegalArgumentException().isThrownBy(()->database.queryForObject(sql,Integer.class));
verifyNoInteractions(jdbc,transactions);
}
@Test void writeEntryPointIsClosedBeforeOpeningAnyConnection() {
assertThatThrownBy(()->database.update("UPDATE job SET jb_status=? WHERE jb_id=?",2,17))
.isInstanceOf(DatabaseWritesDisabledException.class);
verifyNoInteractions(jdbc,transactions);
}
@Test void failedQueriesRollbackAndDoNotReturnAnEmptyReplacement() {
var failure=new DataAccessResourceFailureException("private database detail");
when(jdbc.queryForList("SELECT jb_id FROM job",new Object[0])).thenThrow(failure);
assertThatThrownBy(()->database.queryForList("SELECT jb_id FROM job")).isSameAs(failure);
verify(transactions).rollback(transaction);
verify(transactions,never()).commit(any());
}
@Test void transactionConnectionFailuresUseTheSameDatabaseErrorContract() {
when(transactions.getTransaction(any())).thenThrow(new CannotCreateTransactionException("private details"));
assertThatThrownBy(()->database.queryForObject("SELECT 1",Integer.class))
.isInstanceOf(DataAccessResourceFailureException.class)
.hasMessage(DatabaseService.DATABASE_UNAVAILABLE);
}
@Test void connectionCheckRecognizesLoginDenialInsideTransactionFailure() {
when(transactions.getTransaction(any())).thenThrow(new CannotCreateTransactionException("private detail",new SQLException("password must not be exposed","28000",1045)));
var result=database.checkConnection();
assertThat(result.connected()).isFalse();
assertThat(result.reason()).isEqualTo("AUTHENTICATION_FAILED");
assertThat(result.message()).doesNotContain("password must not be exposed");
}
@Test void connectionCheckDistinguishesMissingDatabaseRightsAndNetworkFailure() {
when(jdbc.queryForObject("SELECT 1",Integer.class,new Object[0]))
.thenThrow(new CannotGetJdbcConnectionException("private",new SQLException("private","42000",1044)))
.thenThrow(new CannotGetJdbcConnectionException("private"));
assertThat(database.checkConnection().reason()).isEqualTo("DATABASE_ACCESS_DENIED");
assertThat(database.checkConnection().reason()).isEqualTo("UNAVAILABLE");
}
}
@@ -0,0 +1 @@
mock-maker-subclass