Initialisiere VoTiAn NG mit Docker-Build, .env-Konfiguration und Cookie-Sitzungen
This commit is contained in:
commit
1a30233269
63 files changed
+12138
No files matched your search
@@ -0,0 +1,3 @@
|
||||
/target/
|
||||
/data/
|
||||
*.log
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
+295
@@ -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 "$@"
|
||||
Vendored
+189
@@ -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"
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -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
|
||||
Reference in new issue
Block a user