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,8 @@
|
|||||||
|
# Nur das gebaute JAR wird für das Laufzeit-Image benötigt.
|
||||||
|
**
|
||||||
|
!Dockerfile
|
||||||
|
!backend/
|
||||||
|
backend/**
|
||||||
|
!backend/target/
|
||||||
|
backend/target/**
|
||||||
|
!backend/target/*.jar
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Als .env im Projektstamm speichern und die vorhandenen Datenbankwerte eintragen.
|
||||||
|
# Prozessumgebungsvariablen haben Vorrang vor dieser Datei.
|
||||||
|
# Für docker run --env-file .env: Werte ohne umschließende Anführungszeichen.
|
||||||
|
# $, #, Backslashes und = in Werten bleiben wörtlich erhalten. Nicht per Shell source laden.
|
||||||
|
DB_URL=jdbc:mariadb://DB-HOST:3306/DB-NAME?connectTimeout=3000&socketTimeout=5000&allowMultiQueries=false
|
||||||
|
DB_USER=CHANGE_ME
|
||||||
|
DB_PASSWORD=CHANGE_ME
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
frontend/node_modules/
|
||||||
|
# Eigenständiges Repository der PHP-Altanwendung.
|
||||||
|
/votian/
|
||||||
|
frontend/dist/
|
||||||
|
backend/target/
|
||||||
|
backend/data/
|
||||||
|
data/
|
||||||
|
work/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
connection.local.json
|
||||||
|
*.tsbuildinfo
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"java.compile.nullAnalysis.mode": "automatic",
|
||||||
|
"java.configuration.updateBuildConfiguration": "automatic"
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
FROM eclipse-temurin:21-jre
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ARG JAR_FILE=backend/target/*.jar
|
||||||
|
COPY --chown=10001:10001 ${JAR_FILE} app.jar
|
||||||
|
|
||||||
|
ENV SERVER_ADDRESS=0.0.0.0
|
||||||
|
USER 10001:10001
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# VoTiAn NG · Stadtbote
|
||||||
|
|
||||||
|
React-/TypeScript-Oberfläche und Spring-Boot-Anbindung für die vorhandene PHP-Anwendung in `votian/`. Die neue Anwendung verwendet **dieselbe vorhandene MySQL-Datenbank und deren Tabellen**. Sie enthält keine eigene Datenbank, keine automatischen Schemaänderungen und keine Demodatensätze.
|
||||||
|
|
||||||
|
## Stand
|
||||||
|
|
||||||
|
Die Altanwendung wurde fachlich und technisch analysiert. Die neue Oberfläche bildet Auftragslisten, Disposition, Touren, Kunden, Kuriere und Auswertung ab. Das Spring-Boot-Backend greift ausschließlich über den zentralen `DatabaseService` auf das vorhandene Schema zu. Die Anmeldung verwendet vorhandene VoTiAn-Zentralenbenutzer mit ihren Niederlassungs- und Mitarbeiterrechten.
|
||||||
|
|
||||||
|
**Die vollständige Ablösung der PHP-Anwendung ist noch nicht abgeschlossen.** Schreibende Vorgänge wie Auftragserfassung, Tarifberechnung, Storno und Rechnungserzeugung haben im Altcode zahlreiche weitere Auswirkungen (unter anderem Fahrerbenachrichtigungen und Gegenbuchungen). Solange diese nicht vollständig portiert und am aktiven Bestand geprüft sind, öffnet die Oberfläche dafür die passenden Module der vorhandenen PHP-Anwendung, wenn `LEGACY_APP_URL` konfiguriert ist. Es gibt keine vereinfachten Schreibzugriffe, die diese Regeln umgehen.
|
||||||
|
|
||||||
|
Die vollständige Analyse und Abdeckungsmatrix stehen in [docs/ALTSYSTEM-ANALYSE.md](docs/ALTSYSTEM-ANALYSE.md).
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
Benötigt: Java 21, Node.js 22.12 oder neuer, Python 3 sowie der Netzwerk-/VPN-Zugang zur bestehenden Datenbank. Maven wird über den mitgelieferten Wrapper verwendet.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/svencarstensen/Documents/Entwicklung/Source/Kunden/Assecutor/votianNG
|
||||||
|
./dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Oberfläche ist anschließend unter `http://localhost:5173` erreichbar. `Strg+C` beendet die gestarteten Prozesse. Das Backend-Protokoll liegt in `work/backend.log`.
|
||||||
|
|
||||||
|
`dev.sh`, `run.sh` und die Datenbankdiagnose laden `DB_URL`, `DB_USER` und `DB_PASSWORD` aus der lokalen `.env` im Projektstamm. Bereits gesetzte Umgebungsvariablen haben Vorrang. Die Zugangsdaten werden ausschließlich als Prozessumgebung an Java übergeben, ohne sie auszugeben oder in Prozessargumente zu schreiben.
|
||||||
|
|
||||||
|
Die vorhandenen lokalen Datenbankwerte wurden nach `.env` übernommen. Bei einer neuen Einrichtung `.env.example` nach `.env` kopieren und die drei Werte eintragen. `DB_URL` enthält den vom jeweiligen Rechner erreichbaren Host bzw. die VPN-Adresse, Port und Datenbanknamen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DB_URL=jdbc:mariadb://DB-HOST:3306/DB-NAME?connectTimeout=3000&socketTimeout=5000&allowMultiQueries=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Zeile gehört in `.env`. Werte werden wörtlich gelesen; `$`, `#`, `=` und Backslashes im Passwort bleiben erhalten. Die Datei wird nicht als Shellskript ausgeführt und sollte nicht mit `source` geladen werden. Für die zusätzliche Verwendung mit Docker die Werte ohne umschließende Anführungszeichen schreiben. `.env` und lokale Varianten sind per `.gitignore` ausgeschlossen; `.env.example` enthält ausschließlich Platzhalter.
|
||||||
|
|
||||||
|
Nur die Konfiguration ohne Datenbankverbindung prüfen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/run-backend.py --check-config
|
||||||
|
```
|
||||||
|
|
||||||
|
Die neue Anwendung liest `connection.local.json` und die PHP-Konfiguration nicht mehr als Zugangsdatenquelle. Der PHP-Altbestand bleibt eigenständig; seine Konfiguration wird durch diese Umstellung nicht verändert.
|
||||||
|
|
||||||
|
## Gebündelte Anwendung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build.sh
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`build.sh` baut React, integriert die gebauten Dateien in das Spring-Boot-JAR und führt die Backend-Tests aus. Die gebündelte Oberfläche ist über `http://localhost:8080` erreichbar. `run.sh` übernimmt dieselbe `.env` wie der Entwicklungsstart. Build und Unit-Tests laden keine lokalen Datenbankzugangsdaten.
|
||||||
|
|
||||||
|
## Docker-Image veröffentlichen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login registry.assecutor.org
|
||||||
|
./docker_push.sh
|
||||||
|
# Alternativer Image-Tag:
|
||||||
|
./docker_push.sh 0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript führt `build.sh` einschließlich Tests aus und pusht das Java-21-Image mit gebündeltem React-Frontend für `linux/amd64` nach `registry.assecutor.org/votianng`. Ohne Argument verwendet es die Version aus `backend/pom.xml`; ein expliziter Tag ändert nur den Image-Tag. Das Ziel lässt sich über `REGISTRY_IMAGE` überschreiben.
|
||||||
|
|
||||||
|
Im Container lauscht die Anwendung auf Port 8080 an `0.0.0.0`. Die `.env` wird beim Containerstart übergeben:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm --env-file .env -p 127.0.0.1:8080:8080 registry.assecutor.org/votianng:0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
Die vorhandene PHP-Anwendung und `.env` sind nicht im Image enthalten. Bei Bedarf `LEGACY_APP_URL` zusätzlich als Umgebungsvariable übergeben. Beim direkten Start über Maven oder `java -jar` wird `.env` über den gemeinsamen Wrapper geladen, beispielsweise aus `backend/`: `python3 ../scripts/env_config.py ./mvnw spring-boot:run`.
|
||||||
|
|
||||||
|
## Anmeldung und Reverse Proxy
|
||||||
|
|
||||||
|
VoTiAn verwendet serverseitige Sitzungen mit dem Cookie `VOTIANSESSION`. Die Systemzugangsdaten werden einmal beim Anmelden übertragen; die Oberfläche speichert sie nicht im Browser und setzt keinen `Authorization`-Header. Ein zusätzlicher HTTP-Basic-Passwortschutz im Nginx Proxy Manager kann deshalb bestehen bleiben: Dessen Zugangsdaten verwaltet der Browser unabhängig von der VoTiAn-Sitzung. Die Abmeldung in VoTiAn beendet nur die Sitzung der Anwendung.
|
||||||
|
|
||||||
|
Für `https://votian.app` muss der Proxy die Oberfläche und `/api/**` an denselben VoTiAn-Dienst weiterleiten, Cookies durchlassen und das ursprüngliche HTTPS-Protokoll als `X-Forwarded-Proto: https` übermitteln. Die Anwendung wertet diese Header mit der nativen Tomcat-Unterstützung aus. Nur der vertrauenswürdige Proxy sollte den Backend-Port erreichen; er muss vom Client übermittelte Forwarded-Header ersetzen. Tomcat vertraut standardmäßig internen Proxyadressen. Bei anderer Netzwerkstruktur die erlaubten Proxyadressen gezielt über `SERVER_TOMCAT_REMOTEIP_INTERNAL_PROXIES` konfigurieren; die Prüfung nicht durch einen leeren Wert abschalten. Siehe [Spring Boot: Reverse Proxy](https://docs.spring.io/spring-boot/3.5/how-to/webserver.html#howto.webserver.use-behind-a-proxy-server).
|
||||||
|
|
||||||
|
Im HTTPS-Produktionsbetrieb zusätzlich `SERVER_SERVLET_SESSION_COOKIE_SECURE=true` als Container-Umgebungsvariable setzen, zum Beispiel mit `docker run --env-file .env -e SERVER_SERVLET_SESSION_COOKIE_SECURE=true ...`. Damit wird das Sitzungscookie ausschließlich über HTTPS übertragen. Der direkte HTTP-Aufruf per Server-IP ist dann kein Anmeldeweg mehr. Für die lokale Entwicklung über HTTP diese Einstellung weglassen; ohne explizite Vorgabe richtet sich `Secure` nach dem vom Backend erkannten Request-Protokoll.
|
||||||
|
|
||||||
|
Das Sitzungscookie ist `HttpOnly` und `SameSite=Lax`. Standardmäßig ist nach 30 Minuten ohne Anfrage an die Sitzung eine erneute Anmeldung erforderlich. Sitzungen liegen im Arbeitsspeicher des Backends und gehen bei einem Neustart verloren; mehrere Instanzen benötigen Sitzungsaffinität oder eine später einzurichtende gemeinsame Sitzungsablage. Benutzerrechte werden bei der Anmeldung geladen und gelten bis zur Abmeldung bzw. zum Sitzungsablauf. Änderungen an Konten oder Rechten beenden vorhandene Sitzungen nicht sofort.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
| Variable | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `DB_URL`, `DB_USER`, `DB_PASSWORD` | Datenbankzugang aus `.env`; Prozessumgebung hat Vorrang |
|
||||||
|
| `LEGACY_APP_URL` | Basis-URL der vorhandenen PHP-Anwendung, damit Aktionen die bestehenden Fachabläufe öffnen |
|
||||||
|
| `PORT` | Spring-Boot-Port, Standard 8080 |
|
||||||
|
| `SERVER_ADDRESS` | Bind-Adresse, Standard `127.0.0.1` |
|
||||||
|
| `FORWARD_HEADERS_STRATEGY` | Auswertung der Proxy-Header, Standard `native` |
|
||||||
|
| `SERVER_SERVLET_SESSION_COOKIE_SECURE` | Im HTTPS-Produktionsbetrieb `true` setzen; lokal über HTTP weglassen |
|
||||||
|
| `SESSION_TIMEOUT` | Ablauf bei Inaktivität, Standard `30m` |
|
||||||
|
| `MAX_RESULTS` | Obergrenze des geladenen Listenausschnitts, Standard 500 |
|
||||||
|
|
||||||
|
Die neue Anwendung ist standardmäßig nur lokal erreichbar. Für einen Serverbetrieb sind TLS und die korrekte Weiterleitung über einen Reverse Proxy zu konfigurieren. Es werden keine Standardkonten angelegt. Kunden- und Kurierlogins sind noch keine portierten Zugangswege; vorhandene Zentralenrechte werden nicht pauschal erweitert.
|
||||||
|
|
||||||
|
## Prüfungen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover -s tests -p 'test_*.py' -v
|
||||||
|
cd backend && ./mvnw test
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Tests verwenden Mocks und getrennte Testfixtures im Arbeitsspeicher der Tests. Sie erzeugen keine Testdatenbank und führen keine Schreibzugriffe gegen den Bestand aus. Ein erfolgreicher Build ersetzt nicht die noch notwendige Prüfung mit aktivem VPN, dem korrekten Datenbankendpunkt und fachlichen Vergleichsfällen aus dem Altbetrieb.
|
||||||
|
|
||||||
|
Die optionale Diagnose `python3 scripts/check-database.py` startet das gebaute Backend-JAR im Modus `--database-check`, ohne Webserver. Verbindung und benötigte Tabellenspalten werden ausschließlich über denselben `DatabaseService` mit `SELECT` auf Serverversion und `information_schema` geprüft. Die Diagnose liest keine Auftrags-/Benutzerdatensätze, gibt keine Zugangsdaten aus und beendet danach den Prozess. Ein eigener Python-Datenbanktreiber ist nicht mehr erforderlich.
|
||||||
|
|
||||||
|
## Datenbank-Serviceschicht
|
||||||
|
|
||||||
|
Alle Zugriffe der neuen Anwendung laufen über `backend/src/main/java/de/stadtbote/votian/database/DatabaseService.java`: Anmeldung, Aufträge und Stationen, Kunden, Kuriere, CSV-Daten sowie Verbindungs- und Schemadiagnose. Nur dieser Service verwendet JDBC und steuert Datenbanktransaktionen. Auftragsanzahl, Liste und Stationen werden in einer gemeinsamen Lesetransaktion gelesen. SQL-Werte werden als Parameter gebunden.
|
||||||
|
|
||||||
|
Der zentrale Schreibzugang `update(...)` ist in der aktuellen Ausbaustufe gesperrt und öffnet keine Verbindung. Die Fachprozesse für schreibende Vorgänge sind weiterhin erst vollständig zu portieren. Architekturtests prüfen Java-Bytecode und Python-Launcher auf Direktzugriffe außerhalb dieser Schicht. Die vorhandene PHP-Anwendung ist von dieser Umstellung nicht betroffen.
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```text
|
||||||
|
votian/ Bestehende PHP-Anwendung, unverändert weiter nutzbar
|
||||||
|
frontend/ React, TypeScript, Vite und Stadtbote-Oberfläche
|
||||||
|
backend/ Spring Boot, JDBC und vorhandene Benutzer-/Rechtestruktur
|
||||||
|
docs/ Analyse, Fachregeln, Abdeckung und weitere Abnahme
|
||||||
|
scripts/ Lokaler Start mit vorhandener Konfiguration
|
||||||
|
tests/ Zusätzliche Konfigurations- und Browserprüfungen
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$ROOT/frontend"
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
cd "$ROOT/backend"
|
||||||
|
./mvnw clean verify
|
||||||
|
echo 'Build und Tests abgeschlossen. Start mit Datenbankkonfiguration aus .env: ./run.sh'
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
command -v java >/dev/null || { echo 'Java 21 wird benötigt.'; exit 1; }
|
||||||
|
command -v npm >/dev/null || { echo 'Node.js 22.12+ und npm werden benötigt.'; exit 1; }
|
||||||
|
mkdir -p work
|
||||||
|
if [[ ! -d frontend/node_modules ]]; then
|
||||||
|
(cd frontend && npm ci)
|
||||||
|
fi
|
||||||
|
for port in 8080 5173; do
|
||||||
|
if command -v lsof >/dev/null && lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||||
|
echo "Port $port ist bereits belegt. Bitte die dort laufende Anwendung zuerst beenden."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
BACKEND_PID=''
|
||||||
|
FRONTEND_PID=''
|
||||||
|
cleanup() {
|
||||||
|
[[ -z "$FRONTEND_PID" ]] || kill "$FRONTEND_PID" 2>/dev/null || true
|
||||||
|
[[ -z "$BACKEND_PID" ]] || kill "$BACKEND_PID" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
echo 'Backend wird gebaut …'
|
||||||
|
(cd backend && ./mvnw -q -DskipTests package)
|
||||||
|
python3 scripts/run-backend.py > work/backend.log 2>&1 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
echo 'Spring Boot startet. Protokoll: work/backend.log'
|
||||||
|
READY=false
|
||||||
|
for attempt in {1..120}; do
|
||||||
|
if curl --fail --silent http://127.0.0.1:8080/api/health >/dev/null 2>&1; then READY=true; break; fi
|
||||||
|
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then cat work/backend.log; exit 1; fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if [[ "$READY" != true ]]; then echo 'Backend-Start dauert zu lange. Siehe work/backend.log'; exit 1; fi
|
||||||
|
(cd frontend && exec node node_modules/vite/bin/vite.js --host 127.0.0.1 --port 5173 --strictPort) &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
echo 'VoTiAn NG: http://localhost:5173 — Beenden mit Strg+C'
|
||||||
|
echo 'Anmeldung mit einem vorhandenen VoTiAn-Zentralenbenutzer.'
|
||||||
|
while kill -0 "$BACKEND_PID" 2>/dev/null && kill -0 "$FRONTEND_PID" 2>/dev/null; do sleep 2; done
|
||||||
Executable
+93
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
readonly REGISTRY_IMAGE="${REGISTRY_IMAGE:-registry.assecutor.org/votianng}"
|
||||||
|
readonly BACKEND_DIR="${SCRIPT_DIR}/backend"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Verwendung:
|
||||||
|
./docker_push.sh [tag]
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
./docker_push.sh 0.1
|
||||||
|
./docker_push.sh
|
||||||
|
|
||||||
|
Voraussetzungen:
|
||||||
|
- Java 21 sowie Node.js 22.12 oder neuer mit npm
|
||||||
|
- Docker läuft und Docker Buildx ist installiert
|
||||||
|
- Login zur Registry wurde bereits ausgeführt:
|
||||||
|
docker login registry.assecutor.org
|
||||||
|
|
||||||
|
Ohne Tag wird die Version aus backend/pom.xml verwendet (auch SNAPSHOT-Versionen).
|
||||||
|
Ein expliziter Tag ändert nur den Image-Tag, nicht die Maven-Projektversion.
|
||||||
|
REGISTRY_IMAGE überschreibt das Standardziel registry.assecutor.org/votianng.
|
||||||
|
Gebaut werden React und das Backend einschließlich Tests für linux/amd64.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "Fehler: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || fail "'$1' wurde nicht gefunden."
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_pom_value() {
|
||||||
|
local value
|
||||||
|
value="$(
|
||||||
|
cd "${BACKEND_DIR}" && ./mvnw -q -Dstyle.color=never -DforceStdout help:evaluate -Dexpression="$1" \
|
||||||
|
| sed $'s/\033\\[[0-9;]*m//g' \
|
||||||
|
| awk 'NF { last = $0 } END { print last }'
|
||||||
|
)" || fail "Maven-Wert '$1' konnte nicht ermittelt werden."
|
||||||
|
|
||||||
|
[[ -n "${value}" && "${value}" != *"null object or invalid expression"* ]] \
|
||||||
|
|| fail "Maven-Wert '$1' ist leer oder ungültig."
|
||||||
|
printf '%s\n' "${value}"
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ $# -le 1 ]] || fail "Erwartet wird höchstens ein Image-Tag. Siehe --help."
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_command java
|
||||||
|
require_command npm
|
||||||
|
require_command docker
|
||||||
|
[[ -x "${BACKEND_DIR}/mvnw" ]] || fail "'${BACKEND_DIR}/mvnw' wurde nicht gefunden oder ist nicht ausführbar."
|
||||||
|
[[ -x "${SCRIPT_DIR}/build.sh" ]] || fail "'${SCRIPT_DIR}/build.sh' wurde nicht gefunden oder ist nicht ausführbar."
|
||||||
|
[[ -f "${SCRIPT_DIR}/Dockerfile" ]] || fail "'${SCRIPT_DIR}/Dockerfile' wurde nicht gefunden."
|
||||||
|
|
||||||
|
VERSION="${1-$(resolve_pom_value project.version)}"
|
||||||
|
if [[ ! "${VERSION}" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$ ]]; then
|
||||||
|
fail "Ungültiger Image-Tag: erlaubt sind maximal 128 Zeichen aus Buchstaben, Ziffern, _, . und -; am Anfang weder . noch -."
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker buildx version >/dev/null 2>&1 || fail "Docker Buildx ist nicht verfügbar."
|
||||||
|
docker info >/dev/null 2>&1 || fail "Docker-Daemon ist nicht erreichbar. Bitte Docker starten."
|
||||||
|
|
||||||
|
JAR_FILE_REL="backend/target/$(resolve_pom_value project.build.finalName).jar"
|
||||||
|
JAR_FILE_ABS="${SCRIPT_DIR}/${JAR_FILE_REL}"
|
||||||
|
|
||||||
|
echo "Verwende Image-Tag ${VERSION}."
|
||||||
|
echo "Baue React-Frontend und Spring-Boot-Backend einschließlich Tests ..."
|
||||||
|
"${SCRIPT_DIR}/build.sh"
|
||||||
|
|
||||||
|
[[ -f "${JAR_FILE_ABS}" ]] || fail "Backend-JAR wurde nicht gefunden: ${JAR_FILE_ABS}"
|
||||||
|
|
||||||
|
echo "Pushe Image ${REGISTRY_IMAGE}:${VERSION} ..."
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
-f "${SCRIPT_DIR}/Dockerfile" \
|
||||||
|
--build-arg "JAR_FILE=${JAR_FILE_REL}" \
|
||||||
|
-t "${REGISTRY_IMAGE}:${VERSION}" \
|
||||||
|
--push \
|
||||||
|
"${SCRIPT_DIR}"
|
||||||
|
|
||||||
|
echo "Fertig: ${REGISTRY_IMAGE}:${VERSION}"
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
# Analyse des PHP-Altsystems votian
|
||||||
|
|
||||||
|
Stand: 01.10.2026. Auftraggeberkontext: Stadtbote GmbH. Grundlage ist eine statische, lesende Analyse des Quellbestands im Verzeichnis `votian/`. Es wurden keine produktiven Datenbanken, Fahrergeräte oder externen Dienste angesprochen. Dieser Bericht enthält keine produktiven Kundendatensätze oder Zugangsdaten.
|
||||||
|
|
||||||
|
**Verbindliche Datenbankvorgabe:** Die Neuentwicklung verwendet die bereits vorhandene MySQL-Datenbank und deren bestehendes Schema. Es wird keine eigene Datenbank angelegt und keine Schemaänderung vorgenommen. DDL, automatische Schemaerzeugung, Schemaaktualisierung und ungefragte Initialbefüllung sind ausgeschlossen.
|
||||||
|
|
||||||
|
Die Neuentwicklung soll die tägliche Auftragserfassung und Disposition in React und Spring Boot abbilden. Der Bestand umfasst jedoch zusätzlich eine umfangreiche Tarif-, Berechtigungs-, Abrechnungs- und Integrationslogik. Die nachfolgend als **Ziel erster Umsetzung** bezeichneten Funktionen sind ein geplanter Umfang und kein Nachweis ihres Implementierungsstands. Vollständige fachliche Parität oder Produktionsreife wird mit diesem Bericht nicht behauptet.
|
||||||
|
|
||||||
|
**Aktuelle Umsetzungsentscheidung:** Die erste Anbindung soll über lesende JDBC-Adapter auf das unveränderte Bestandsschema, vorhandene Benutzerkonten und Verweise auf bestehende PHP-Aktionen erfolgen. Das betrifft insbesondere Erfassung, Bearbeitung und weitere schreibende Geschäftsprozesse, deren Regeln nicht ungeprüft durch direkte SQL-Schreibzugriffe ersetzt werden. Ein PHP-Verweis ist keine React-/Spring-Portierung dieses Ablaufs. Wegen des derzeit nicht nachgewiesenen Datenbankzugriffs bleiben Live-Funktion, Spaltenverträglichkeit, Authentisierung und vollständige Portierung offen; dieser Bericht weist sie nicht als getestet aus.
|
||||||
|
|
||||||
|
## 1. Quellen und Aussagekraft
|
||||||
|
|
||||||
|
Quellenangaben haben die Form `votian/pfad/datei.php:Zeile` und beziehen sich auf den untersuchten Bestand. Zeilennummern markieren den Einstieg in die betreffende Funktion oder den relevanten Codeblock. Historische Kopien und nummerierte Dateivarianten kommen häufig vor; als Grundlage wurden überwiegend die unnummerierten, von den Hauptseiten referenzierten Dateien verwendet. Aus dem bloßen Vorhandensein einer Datei folgt nicht, dass die Funktion heute produktiv genutzt wird.
|
||||||
|
|
||||||
|
Die tatsächliche Ausprägung hängt von Datenbankparametern, Niederlassung, Kunde, Fahrzeugtyp, Zeitpunkt und Mitarbeiterrechten ab. Ohne diese Konfiguration und fachlich freigegebene Beispieldaten lassen sich die aktiven Regeln nicht vollständig bestimmen. Einzelne Codekommentare dienen als Hinweis; entscheidende Aussagen wurden soweit möglich mit ausführbarem Code abgeglichen.
|
||||||
|
|
||||||
|
Zentrale Quellen:
|
||||||
|
|
||||||
|
| Bereich | Quellen |
|
||||||
|
|---|---|
|
||||||
|
| Auftragsmaske und Benutzerkontext | `votian/jobs/job_edit.php:20`, `:38`, `:81` |
|
||||||
|
| Auftrags- und Stationsspeicherung | `votian/jobs/job_options.php:208`, `:421`, `:2290` |
|
||||||
|
| Disposition und Listen | `votian/admin/jb_list.php:505`, `:708`, `:957`, `:1376` |
|
||||||
|
| Fachliche Feldlisten | `votian/include/mcglobal.inc.php:145`, `:169`, `:177`, `:198`, `:217` |
|
||||||
|
| Tarifberechnung | `votian/include/services_func.inc.php:24`, `:89`, `:168`, `:371`, `:538` |
|
||||||
|
| Storno | `votian/include/inc_job.inc.php:1242` |
|
||||||
|
| Berechtigungen | `votian/include/auth.inc.php:208`, `:264`, `:285`, `:315` |
|
||||||
|
| Abrechnung | `votian/invoice/inv.inc.php:28`, `votian/invoice/inv_aux.inc.php:264` |
|
||||||
|
|
||||||
|
## 2. Fachliches Datenmodell
|
||||||
|
|
||||||
|
### Kunden, Kostenstellen und Niederlassungen
|
||||||
|
|
||||||
|
Die Struktur ist `company → customer → costcenter`. Eine Firma enthält den Unternehmensstamm; der Kunde ergänzt die kaufmännischen Einstellungen. Kostenstellen bilden eine Hierarchie über `csc_pre_id` und `csc_path`. Sie haben eigene Sichtbarkeit, Sperren und Rechnungsparameter. Kostenstellenadressen unterscheiden unter anderem Abhol-, Rechnungs- und Serviceadressen.
|
||||||
|
|
||||||
|
Wichtige Felder:
|
||||||
|
|
||||||
|
- Kunde: `cs_id`, `cmp_id`, `hq_id`, externe/interne Kundennummer, Hauptkostenstelle, Standardzahler, übergeordneter/zugehöriger Kunde, Rabatt, Festpreisrabatt, Provision, Rechnungsmodus, Kommissionsnummer, Zuschlag, Zahlungsziel, Auftrags-/Tracking-Einstellungen.
|
||||||
|
- Kostenstelle: `csc_id`, `cs_id`, Name, Elternreferenz, Pfad, extern/intern, gesperrt, sichtbar, Rechnungsintervall und Rechnungsart.
|
||||||
|
- Adresse: Straße, Hausnummer, PLZ, Ort, Land, Firmenzusätze, Ansprechpartner, Telefon und Bemerkung.
|
||||||
|
|
||||||
|
Quellen: `votian/include/mcglobal.inc.php:130`, `:145`, `:147`, `:200`, `:217`.
|
||||||
|
|
||||||
|
**Konsequenz:** Ein einzelner Kundenname am Auftrag ersetzt die Kostenstellenstruktur nicht. Auftraggeber und Rechnungsempfänger müssen unterscheidbar bleiben. Niederlassungszugehörigkeit ist außerdem eine fachliche Zugriffsgrenze.
|
||||||
|
|
||||||
|
### Kuriere und Fahrzeuge
|
||||||
|
|
||||||
|
Kurier und Fahrzeug sind getrennte Entitäten. In der Disposition wird häufig die Fahrzeug-SID verwendet. Ein angebotener Kurier (`cr_id_order`) ist vom tatsächlich annehmenden Kurier (`cr_id`) zu unterscheiden.
|
||||||
|
|
||||||
|
- Kurier: Unternehmen und Benutzer, SID/Nummer, Fahrzeugtyp, Merkmale/Filter, Maximalgewicht, mögliche Auslage, Verfügbarkeit, Verfügbarkeitszeit und Standort-PLZ.
|
||||||
|
- Fahrzeug: Kurierbezug, Fahrzeug-SID, Kennzeichen, Fahrzeugtyp, Nutzlast, Gesamtgewicht, Länge/Breite/Höhe, Stellplätze, Eigenschaften, Provision und Aktivierung.
|
||||||
|
|
||||||
|
Quellen: `votian/include/mcglobal.inc.php:198`, `:206`; Zuweisung über SID in `votian/admin/jb_list.php:515`.
|
||||||
|
|
||||||
|
### Auftrag, Stationen und Leistungen
|
||||||
|
|
||||||
|
| Entität | Fachlich relevante Felder |
|
||||||
|
|---|---|
|
||||||
|
| Auftrag `job` | Nummer, Version, Auftraggeber-Kostenstelle, Zahler-Kostenstelle, Niederlassungen für Disposition/Ausführung/Vertrieb, Besteller, Kommission, gewünschter/tatsächlicher Fahrzeugtyp, Gewicht/Maße, Auftrags-/Annahme-/Abschluss-/Warnzeit, Reservierung, Dauerauftrag, Tourname, Status, Storno, Angebot, unvollständig, Fernfahrt, Freitext, Dispositionshinweis, übergeordneter Auftrag, Preise, Buchungs-/Exportdaten |
|
||||||
|
| Station `tour` | Auftragsbezug, Reihenfolge, Kostenstelle, Firma/Firmenzusatz, Adresse, Person, Telefon, Bemerkung, Kommission, Zusatzkilometer/-leistung, Abhol-/Lieferart, Status, Signatur, Empfängername, Abschlusszeit |
|
||||||
|
| Stationsleistung `tourservice` | Station, Leistung/Leistungsart, Leistungsbezeichnung, Preis, Rabatt, zahlende Kostenstelle |
|
||||||
|
| Artikelposition `tourarticle` | Artikel, Menge, Sortierung, Preis, Packstücke, Gewicht, Seriennummer, Zustand, Bemerkung |
|
||||||
|
| Artikelbearbeitung `tourarticleprocess` | Positionsbezug, Vorgangsart, Status, Bemerkung, Zeitpunkt |
|
||||||
|
| Preiskomponente `jobprice` | Auftragsbezug, Art der Komponente und Betrag; zusätzliche Informationen zu Kunden-/Kurierberechnung |
|
||||||
|
|
||||||
|
Quellen: `votian/include/mcglobal.inc.php:169`, `:177`, `:179`, `:181`, `:183`; weitere Maße und Bearbeitungsfelder in `votian/jobs/job_options.php:208`; Preiskomponenten in `votian/include/caglobal.inc.php:1663`.
|
||||||
|
|
||||||
|
## 3. Statusmodell und Arbeitsabläufe
|
||||||
|
|
||||||
|
### Auftragsstatus
|
||||||
|
|
||||||
|
| Legacywert | Bedeutung | Übertragung |
|
||||||
|
|---|---|---|
|
||||||
|
| `9` | Manuelle Disposition, noch zuzuteilen | Eigener fachlicher Status |
|
||||||
|
| `8` | Autoranking | Eigener Zustand, dessen automatische Bearbeitung einen separaten Dienst benötigt |
|
||||||
|
| `0` | Offen / anzunehmen | Kurierangebot und Annahme unterscheiden |
|
||||||
|
| `1` | Angenommen / laufend | Laufende Ausführung |
|
||||||
|
| `2` | Erledigt | Regulärer Abschluss oder Abschluss durch Storno; Stornoflag zusätzlich prüfen |
|
||||||
|
| `99` | Interner Zustand im Zusammenhang mit Börsen-/Globalauftrag | Kein normaler Benutzerstatus ohne weitere Prüfung des Börsenablaufs |
|
||||||
|
| `10` bzw. `ALL` | Alle Aufträge | Ausschließlich Listenfilter |
|
||||||
|
|
||||||
|
Quellen: `votian/jobs/job_options.php:421`, `:439`, `:451`; `votian/admin/jb_list.php:79`, `:516`.
|
||||||
|
|
||||||
|
**Storno ist eine eigene Dimension:** `jb_storno=1` bedeutet kostenpflichtig, `2` kostenfrei. Stornierung setzt `jb_status=2`, den Abschlusszeitpunkt und den stornierenden Benutzer. Ein vereinfachtes neues Enum darf diese Information nicht verlieren. Quelle: `votian/include/inc_job.inc.php:1242`.
|
||||||
|
|
||||||
|
Die dazugehörigen Gegenbuchungsaufträge verwenden `jb_storno=3` für die Gutschrift zu einem kostenpflichtigen Storno bzw. `4` für die Gutschrift zu einem kostenfreien Storno. Der Code kopiert und negiert dabei Preise/Leistungen und verknüpft den Originalauftrag über `jb_id_copy`. Solche Datensätze sind nicht einfach weitere reguläre Touren. Quelle: `votian/jobs/job_options.php:662`.
|
||||||
|
|
||||||
|
### Stationsstatus und Stationsart
|
||||||
|
|
||||||
|
Stationen sind einzeln offen (`tr_status=0`) oder erledigt (`1`). Bei vollständig erledigten Stationen kann der Gesamtauftrag abgeschlossen werden. Das Entfernen einer noch offenen Station kann ebenfalls einen Abschluss auslösen. Bei nachträglichen Stationsänderungen sind vorhandene Abschlussdaten zu erhalten.
|
||||||
|
|
||||||
|
`tr_ware_from_to` unterscheidet `1=Abholung`, `2=Anlieferung`, `3=Anlieferung und Abholung`. `0` leitet die Standardart aus der Stationsposition ab. Die neue Oberfläche sollte eindeutige, explizite Arten verwenden.
|
||||||
|
|
||||||
|
Quellen: `votian/jobs/job_options.php:2290`, `:2335`, `:2356`; `votian/include/caglobal.inc.php:1355`.
|
||||||
|
|
||||||
|
### Kernablauf
|
||||||
|
|
||||||
|
1. **Erfassen:** Die Zentrale wählt eine Kostenstelle. Beim Kundenbenutzer wird die auftraggebende Kostenstelle aus dem Mitarbeiterkonto abgeleitet. Die Auftragsmaske erlaubt Kurieren und Systemadministratoren in diesem Kontext keine normale Erfassung. Quelle: `votian/jobs/job_edit.php:38`.
|
||||||
|
2. **Route anlegen:** Mehrere Stationen mit Abhol-/Lieferkennzeichen, Adressen, Ansprechpartnern, Hinweisen und optionalen Artikeln/Leistungen erfassen. Quellen: `votian/include/mcglobal.inc.php:177`, `votian/jobs/job_options.php:236`.
|
||||||
|
3. **Zeit und Leistung festlegen:** Sofortfahrt, Reservierung oder Dauerauftrag; Fahrzeuganforderung, Gewicht, Maße und besondere Leistungen festlegen.
|
||||||
|
4. **Preis bestimmen:** Tarifberechnung oder manueller Festpreis; Kundenpreis und Kuriervergütung separat behandeln. Quellen: `votian/jobs/job_options.php:942`, `:1811`.
|
||||||
|
5. **Disponieren:** Ohne Kurier abhängig vom Niederlassungsmodus Status `9` oder `8`; mit Kurier üblicherweise `0`. Offline-Disposition kann direkt `1` setzen. Quellen: `votian/jobs/job_options.php:421`, `votian/admin/jb_list.php:516`.
|
||||||
|
6. **Ausführen:** Annahme, Stationsabschlüsse und Zustellnachweis; anschließend Gesamtabschluss. Quellen: `votian/include/mcglobal.inc.php:169`, `:177`, `votian/jobs/job_options.php:2335`.
|
||||||
|
7. **Nachbearbeiten:** Kopieren/Nacherfassen, Versionsänderung, Änderungsprotokoll und gegebenenfalls Information an das Fahrergerät. Quellen: `votian/jobs/job_options.php:185`, `:2366`.
|
||||||
|
8. **Stornieren und abrechnen:** Kostenpflicht prüfen, bestehende Buchungen/Exporte beachten und erforderlichenfalls Gegenbuchungen erzeugen. Quelle: `votian/include/inc_job.inc.php:1242`.
|
||||||
|
|
||||||
|
### Reservierungen, Daueraufträge und Vorlagen
|
||||||
|
|
||||||
|
Die Wiederholungslogik unterstützt Werktage, Werktage mit Samstag, täglich, wöchentlich, vierzehntäglich, monatlich, Monatsende, 15./Monatsende, 1./16., vierwöchentlich und Feiertage. Eine siebenstellige Maske enthält die ausgewählten Wochentage Montag bis Sonntag. Feiertagsverschiebungen unterscheiden sich je nach Wiederholungsart. Ein Enddatum begrenzt Serien. Quelle: `votian/include/caglobal.inc.php:2304`.
|
||||||
|
|
||||||
|
Ein gespeicherter Tourname dient auch dem Wiederverwenden einer Tour; die Preisermittlung kann beim Wiederverwenden auf aktuelle Tarife zugreifen. Quellen: `votian/jobs/job_options.php:231`, `:1799`.
|
||||||
|
|
||||||
|
## 4. Preislogik und Abrechnung
|
||||||
|
|
||||||
|
Der Bestand besitzt keine einzelne universelle Formel. Wesentliche Einflussgrößen sind Niederlassung, Kunde, Fahrzeugtyp, Auftragszeit, Leistung, PLZ-Paar, PLZ-Gebiet, Zusatzleistungen und Zahlungsart.
|
||||||
|
|
||||||
|
| Regelbereich | Beleg | Für Parität benötigter Nachweis |
|
||||||
|
|---|---|---|
|
||||||
|
| Zeitlich gültige Leistungs-/Kundentarife | `votian/include/services_func.inc.php:24`, `:371` | Aktive Tarifstände und erwartete Auswahl bei Überschneidungen |
|
||||||
|
| PLZ-Paar-/Gebietstarife | `votian/include/services_func.inc.php:89`, `:168` | Gebietsdefinitionen, Richtung, Prioritäten, Sonderadressen |
|
||||||
|
| Treibstoffzuschlag Kunde/Kurier | `votian/include/services_func.inc.php:430`, `:494` | Gültigkeitsstände und zuschlagsfähige Komponenten |
|
||||||
|
| Leistungspreise, Rabatt, Kostenaufteilung | `votian/include/services_func.inc.php:538` | Reihenfolge, Zahlerzuordnung, Rundung und Ausnahmen |
|
||||||
|
| Manuelle Fest-/Servicepreise | `votian/jobs/job_options.php:1050`, `:1067`, `:1828` | Einbeziehung von Rabatt und Zuschlag; Eingabepreis netto/brutto |
|
||||||
|
| Separate Kuriervergütung | `votian/include/services_func.inc.php:766`, `:794`, `:813` | Provision, Fernfahrt-, Fahrzeug- und Unternehmerregeln |
|
||||||
|
| Summen-/Rundungsfolge | `votian/jobs/job_options.php:1811` | Referenzfälle mit identischen Einzel- und Endsummen |
|
||||||
|
| Umsatzsteuer/Maut | `votian/jobs/job_options.php:2140` | Satz, Leistungsdatum und steuerliche Zuordnung je Fall |
|
||||||
|
| Storno und bereits exportierte Buchungen | `votian/include/inc_job.inc.php:1286` | Gegenbuchungs- und Exportregeln, keine Doppelabrechnung |
|
||||||
|
|
||||||
|
Bei Festpreisen wirken im untersuchten Zweig ein Zuschlag, ein separat gerundeter Selbstbedienungsrabatt und anschließend ein Kundenrabatt. Servicepreise erhalten nicht automatisch denselben Zuschlag; ihre Rabattfähigkeit ist konfigurierbar. Die Kuriervergütung wird gesondert ermittelt. Deshalb reicht ein pauschales Feld „Gesamtpreis“ nicht zum Nachweis gleicher Abrechnung.
|
||||||
|
|
||||||
|
Für eine spätere native Schreibumsetzung ist manuelle Preisangabe mit nachvollziehbaren Werten ein sinnvoller begrenzter Umfang. Im aktuellen lesenden Ansatz sollen Preise aus vorhandenen Aufträgen angezeigt und Preisänderungen über die geprüfte PHP-Funktion vorgenommen werden. Es müssen jedoch Währung, Netto-/Bruttobedeutung, Steuersatz und Rundung klar sein. Beispielpreise sind als Beispieldaten zu behandeln. Die vollständige Altpreisberechnung ist damit nicht ersetzt.
|
||||||
|
|
||||||
|
Für Spring Boot sollten Geldbeträge als `BigDecimal` und passend zu den vorhandenen Datenbanktypen gelesen und geschrieben werden; Schemaänderungen sind ausgeschlossen. Der Auftrag benötigt einen unveränderlich nachvollziehbaren Preisstand mit Herkunft und Komponenten. Eine spätere Tarifänderung darf historische Beträge nicht stillschweigend verändern. Dies sind Empfehlungen für die Neuentwicklung, keine Behauptungen über deren aktuellen Code.
|
||||||
|
|
||||||
|
Die Rechnungsansicht unterscheidet Kunden- und Kurierabrechnung und berücksichtigt Kostenstellen, Barzahlungen und bereits exportierte Aufträge. Barzahlungen werden im betreffenden Zweig nicht als gewöhnliche Kundenrechnung behandelt. Quellen: `votian/invoice/inv.inc.php:28`, `:200`; `votian/invoice/inv_aux.inc.php:264`, `:319`, `:357`.
|
||||||
|
|
||||||
|
Eine neue Rechnungsübersicht oder ein CSV-Export ist daher zunächst eine Übersicht/Ausgabe. Gleichwertige Fakturierung, Korrekturbelege, Buchhaltungsexporte oder rechtliche Rechnungsanforderungen sind damit noch nicht nachgewiesen.
|
||||||
|
|
||||||
|
## 5. Benutzerrechte und fachliche Grenzen
|
||||||
|
|
||||||
|
Die Benutzertypen sind `1=Zentrale`, `2=Kunde`, `3=Kurier`, `4=Lager`. Darüber hinaus existiert gesonderte Systemadministratorbehandlung in verschiedenen Seiten. Quelle: `votian/include/dbglobal.inc.php:786`; Beispiel für Typ `0` in `votian/invoice/inv.inc.php:52`.
|
||||||
|
|
||||||
|
Mitarbeiterrechte werden zusätzlich über eine positionsbasierte Rechtemaske ausgewertet. Die Zugriffsprüfung unterscheidet Zentrale, Kunde und Kurier; Kundenrechte sind an Kostenstellen gebunden. Im Rechnungsmodul wird zusätzlich das Rechnungsrecht geprüft und die ausgewählte Kostenstelle auf den erlaubten Teilbaum beschränkt.
|
||||||
|
|
||||||
|
Quellen: `votian/include/auth.inc.php:208`, `:214`, `:264`, `:285`, `:315`; `votian/invoice/inv.inc.php:36`.
|
||||||
|
|
||||||
|
Erforderliche Nachweise vor produktiver Ablösung:
|
||||||
|
|
||||||
|
- Rollen- und Aktionsmatrix für Lesen, Erfassen, Ändern, Disponieren, Stornieren, Preise, Rechnungen und Export.
|
||||||
|
- Mandanten-/Niederlassungsgrenzen und erlaubte Kostenstellen-Teilbäume.
|
||||||
|
- Nachweis serverseitiger Prüfung bei direkten API-Aufrufen, nicht nur ausgeblendeter Navigation.
|
||||||
|
- Trennung zwischen Stammdaten eines Kuriers, angemeldetem Fahrer und zugewiesenem Fahrzeug.
|
||||||
|
- Historie mit Akteur, Zeitpunkt und relevanter Änderung; Schutz vor konkurrierenden Status-/Preisänderungen.
|
||||||
|
|
||||||
|
Eine Anmeldung mit wenigen Rollen kann die erste Umsetzung absichern, ersetzt aber die vorhandene Rechte- und Teilbaummatrix noch nicht.
|
||||||
|
|
||||||
|
## 6. Schnittstellen und Hintergrundverarbeitung
|
||||||
|
|
||||||
|
| Bereich | Im Quellbestand erkennbar | Quelle | Grenze der ersten Umsetzung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Fahrer-/Mobile-Kommunikation | XML-Verarbeitung mit Modulen für Aufträge, Kuriere, Artikel, Fahrzeuge und weitere Funktionen | `votian/mobile/serviceXML.php:15`, `:1668` | Eine neue REST-API ist nicht automatisch kompatibel zu vorhandenen Fahrergeräten |
|
||||||
|
| Änderungen laufender Aufträge | Erzeugung zeitgesteuerter Gerätekommandos | `votian/jobs/job_options.php:2366` | Bestätigung, Wiederholung, Offlinebetrieb und Konfliktbehandlung benötigen eigene Tests |
|
||||||
|
| Routing/Geokodierung | Adresssuche und Tour-/Zeitfensterplanung; PTV-Bezug im XML | `votian/import/xServer.inc.php:35`, `:101`, `:195` | Lizenz, Endpunkt, Koordinatensystem, Distanz-/Zeitgrundlage und Fehlerfälle sind offen |
|
||||||
|
| Kilometer-/Routenpreis | Geokodierung, gespeicherte Routen, Optimierungsinformationen | `votian/include/ajaxReqKmPrice.php:5`, `:161`, `:259`, `:376` | Keine belastbare automatische Distanz- oder Preisberechnung ohne Dienst und Tarifabnahme |
|
||||||
|
| Dateiimporte | Allgemeine und kundenspezifische Importstrecken | `votian/import/import_STANDARD_01.php:12`, `votian/import/import_TIMS.php:18` | Feldmapping, Dublettenschutz und Fehlerrückmeldung je aktivem Format prüfen |
|
||||||
|
| Dateitransfer | FTP-bezogene Funktionen für Import-/Exportdateien | `votian/import/data_transfer.php:94`, `:767`, `:772` | Transporteinstellungen werden nicht aus dem Altsystem übernommen |
|
||||||
|
| Datenexport | Eigenständiges Exportmodul mit Rechteprüfung über Menüeinträge | `votian/admin/menu.php:265`, `:331` | CSV-Ausgabe ist kein Ersatz aller bestehenden Vertragsschnittstellen |
|
||||||
|
| Wiederholungsaufträge | Datums-/Feiertagslogik für nächste Ausführung | `votian/include/caglobal.inc.php:2304` | Ausführung, Eindeutigkeit und Neustartverhalten eines neuen Schedulers belegen |
|
||||||
|
|
||||||
|
Für jede tatsächlich genutzte Integration sind Verantwortlicher, Format-/Protokollversion, Testzugang, Beispieldaten, Authentisierung, Fehlerrückmeldung, Wiederholungsstrategie und Abnahmekriterium zu erfassen. Alte Zugangsdaten werden nicht migriert oder in die neue Anwendung kopiert. Der Bericht dokumentiert keine geheimen Konfigurationswerte.
|
||||||
|
|
||||||
|
## 7. Optik und Bedienung
|
||||||
|
|
||||||
|
Das Altsystem nutzt überwiegend kompakte, tabellarische Masken. Die Auftragserfassung ist in Kopf, Stationsmaske, Optionen und Stationsliste gegliedert; im Bestand erfolgt dies über Frames. Quelle: `votian/jobs/job_edit.php:81`.
|
||||||
|
|
||||||
|
Die erkennbaren Standardfarben sind Hellblau `#CDD9FD`, dunkles Blau `#1b12b9` und Akzentblau `#4e45ec`. Die Standardschrift ist Verdana mit Arial/Helvetica als Ersatz, häufig in 8 pt. Quellen: `votian/css/navigation.css.php:28`, `:33`, `:35`, `:52`; `votian/css/phoenix.css.php:22`.
|
||||||
|
|
||||||
|
Ziel der React-Oberfläche ist eine wiedererkennbare blaue Gestaltung mit klarer Navigation, dichten Auftragstabellen, sichtbaren Statusangaben und kurzer Erfassung. Lesbarkeit, Tastaturbedienung und responsive Darstellung können modernisiert werden. Die Übernahme der Frames, der kleinen historischen Schriftgröße oder jedes pixelgenauen Abstands ist kein fachliches Erfordernis. Visuelle Gleichheit ist ohne Screenshots der aktiven Installation nicht nachgewiesen.
|
||||||
|
|
||||||
|
## 8. Modul-Abdeckungsmatrix
|
||||||
|
|
||||||
|
Diese Matrix trennt vorhandene Fachlichkeit von dem vorgesehenen Einstieg. **Sie ersetzt keine Prüfung des tatsächlich implementierten Codes und der Tests.**
|
||||||
|
|
||||||
|
| Modul/Funktion | Bestand | Ziel erster Umsetzung | Noch zu belegende Parität / Priorität |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Aufträge | Umfangreiche Felder, Listen, Kopien, Nacherfassung | Lesen/Filtern über JDBC; Erstellen/Bearbeiten als PHP-Verweis | Pflichtfelder, abgeschlossene/exportierte Aufträge und Historie; P0 |
|
||||||
|
| Mehrstopp-Tour | Beliebige Stationsfolge mit Einzelstatus und Nachweisen | Vorhandene Stationsfolge lesen; Bearbeitung im PHP-Ablauf | Gemischte Stationen, Stationsabschluss, Empfänger/Signatur; P0 |
|
||||||
|
| Disposition | Manuell, Autoranking, Börse, Offline-Zuweisung | Dispositionsdaten lesen; Zuweisung/Status als PHP-Aktion | Angebot gegenüber Annahme, Rücknahme, Konkurrenz; P0 |
|
||||||
|
| Storno | Kostenpflichtig/kostenfrei, Buchungsfolgen | Storno anzeigen; Aktion im bestehenden PHP-Prozess | Altflags, Beträge, Export-/Gegenbuchungsfälle; P0 |
|
||||||
|
| Kunden | Firmen, Kostenstellenhierarchie, Zahler, Parameter | Kundenstammdaten lesen; Pflege über PHP-Verweis | Kostenstellen und abweichender Rechnungsempfänger; P0/P1 |
|
||||||
|
| Kuriere/Fahrzeuge | Getrennte Modelle, SID, Verfügbarkeit und Merkmale | Kurierstammdaten lesen; Pflege über PHP-Verweis | Fahrzeugzuordnung, Eignung, Verfügbarkeit; P1 |
|
||||||
|
| Preise | Historisierte Tarife, PLZ-/Gebiete, Leistungen, Rabatte, Zuschläge | Vorhandene Preiswerte lesen; Kalkulation/Änderung im PHP-Prozess | Tarifmatrix und getrennte Kurierabrechnung; vor automatischer Preisnutzung P0 |
|
||||||
|
| Rechnungen | Kunden-/Kurieransicht, Kostenstellen, Bar-/Exportregeln | Vorhandene Abrechnungswerte lesen bzw. PHP-Rechnungsansicht öffnen | Fakturierung, Korrekturen, Nummernkreise, Steuern und Exportstatus; vor Abrechnung P0 |
|
||||||
|
| Export | Rechtegebundene und kundenspezifische Ausgaben | Lesende Ausgabe bzw. vorhandener PHP-Export; Umsetzung separat belegen | Schnittstellenverträge, Kodierung, Dubletten und Wiederholung; P1 |
|
||||||
|
| Reservierungen | Zukünftige Auftragszeit und Dispositionsregeln | Vorhandenen Termin anzeigen; Änderung im PHP-Prozess | Zeitzone, Warn-/Vorbereitungszeit; P1 |
|
||||||
|
| Daueraufträge/Vorlagen | Wiederholungsmuster, Feiertage, Enddatum, gespeicherte Tour | Nicht als abgedeckt voraussetzen | Scheduler und Serien-/Vorlagenabnahme; P1 |
|
||||||
|
| Zugriffsrechte | Rolle, Einzelaktion, Niederlassung und Kostenstelle | Bestehende Benutzerkonten, HQ-Mitarbeiterbezug und Leserechte; noch live nachzuweisen | Vollständige serverseitige Rechte-/Sichtbarkeitsmatrix; P0 |
|
||||||
|
| Kunden-/Fahrerportal | Unterschiedliche Ansichten und mobile Kommunikation | Nicht als abgedeckt voraussetzen | Eigene Datenbegrenzung, Geräteprotokoll und Offlinefälle; P1 |
|
||||||
|
| Artikel/Lager | Artikelpositionen, Packstücke, Bearbeitungsstatus | Nicht als abgedeckt voraussetzen | Lager-/Scanprozesse und Bestandsintegration; P2 nach Nutzungsnachweis |
|
||||||
|
| Routing/GPS/Autoranking | Externe Routen- und Ortungsinformationen | Nicht als abgedeckt voraussetzen | Dienstintegration, Rankingregeln, Ausfallbetrieb; P2/P1 je Betrieb |
|
||||||
|
| Bestandsanbindung | Bestehendes MySQL-Schema mit verknüpften Bestands-/Archivdaten | Direkte, kompatible Anbindung ohne eigene Datenbank | Feld-, Status-, Transaktions- und Berechtigungsabgleich; keine DDL/Schemaänderung; P0 |
|
||||||
|
|
||||||
|
Prioritäten: **P0** verhindert die fachlich sichere Ablösung des betroffenen Kernablaufs; **P1** ist als nächster operativer Ausbau einzuplanen; **P2** hängt von der nachgewiesenen produktiven Nutzung ab. Nicht jede Funktion muss vor einer begrenzten Pilotnutzung verfügbar sein; der Pilotumfang muss die vorhandenen Einschränkungen ausdrücklich berücksichtigen.
|
||||||
|
|
||||||
|
Die besonders dringenden Ergänzungen über ein einfaches CRUD hinaus sind: getrennte Kostenstellen/Zahler, einzelne Stationsabschlüsse, echte Stornobedeutung, Trennung Angebot/Annahme, serverseitige Zugriffsgrenzen und nachvollziehbare Änderungen. Diese Aspekte beeinflussen den täglichen Kernprozess unmittelbar.
|
||||||
|
|
||||||
|
## 9. Bestehende Datenbank anbinden, ohne Schemaänderung
|
||||||
|
|
||||||
|
Die ausdrückliche Vorgabe lautet, die vorhandene Datenbank zu verwenden. Es gibt deshalb keinen Plan zur Anlage einer neuen Datenbank, zur Erstellung neuer Tabellen oder zum Import der Bestandsdaten in ein neues Schema. „Mapping“ bezeichnet ausschließlich die Abbildung bestehender Spalten auf Java-/API-Modelle. Eine rein lesende Schemaanalyse ist von Datensatzänderungen zu unterscheiden.
|
||||||
|
|
||||||
|
1. **Verbindung und Bestand feststellen.** Autorisiert bereitgestellte Verbindungsparameter außerhalb des Quellcodes konfigurieren. Datenbankversion, Tabellen, Spaltentypen, Schlüssel, Archivtabellen, Kodierung und aktive Parameter lesend ermitteln. Zugangsdaten nicht in Ausgaben oder Repository schreiben.
|
||||||
|
2. **Automatische Strukturänderungen abschalten.** Keine H2-Ersatzdatenbank, keine neue MySQL-Datenbank, keine DDL, keine Flyway-/Liquibase-Migrationen und keine automatische Seed-Befüllung. Bei JPA darf die DDL-Einstellung keine Erzeugung oder Änderung auslösen. Explizites JDBC mit kontrolliertem SQL ist ebenfalls möglich.
|
||||||
|
3. **API-/Feldmapping erstellen.** Für jede verwendete API-Eigenschaft die vorhandene Tabelle, Spalte, Join-Bedingung, Bedeutung, Nullbehandlung und Schreibregel dokumentieren. Auftraggeber/Zahler, Kunde/Kostenstelle, Kurier/Fahrzeug und Auftrag/Station bleiben unterscheidbar.
|
||||||
|
4. **Bestand lesend prüfen.** Fachliche Stichproben, Objektanzahlen, Fremdschlüsselbeziehungen, Statuswerte und Summen abgleichen. Verwaiste Referenzen, doppelte Nummern und widersprüchliche Daten melden, ohne sie automatisch zu korrigieren. Die alte Oberfläche deklariert ISO-8859-1 (`votian/jobs/job_edit.php:67`); der tatsächliche Datenbankzeichensatz und JDBC-Transport sind separat zu prüfen.
|
||||||
|
5. **Kompatibles Schreiben entwickeln.** Bestehende Schlüsselerzeugung, erforderliche Spalten, Status-/Stornofelder, Zeitstempel, Historie und Nebentabellen berücksichtigen. Ein fachlicher Speichervorgang benötigt eine Transaktion. Unbekannte Altspalten nicht durch vollständiges Ersetzen eines Datensatzes verlieren. Die Existenz aller SQL-Spalten vor Schreibfreigabe nachweisen.
|
||||||
|
6. **Schreiben gezielt verifizieren.** Autorisierte Testdatensätze oder eine ausdrücklich bereitgestellte Testinstanz des unveränderten Bestandsschemas verwenden. Keine eigenmächtige Testdatenbankanlage. Produktive Datensätze nicht als Testmaterial ändern. Auftragserfassung ist mehr als ein `INSERT INTO job`: Stations-, Preis-, Zuweisungs- und eventuell Historieneinträge müssen fachlich zusammenpassen.
|
||||||
|
7. **Parallelbetrieb prüfen.** PHP und Spring Boot können dieselben Datensätze sehen. Konkurrierende Bearbeitung, Nummernvergabe, Statuswechsel und Hintergrundprozesse dürfen sich nicht überschreiben. Bereits vorhandene Versions-/Sperrkonzepte sind auf Kompatibilität zu untersuchen. Serienaufträge, Benachrichtigungen und Exporte dürfen nicht doppelt ausgelöst werden.
|
||||||
|
8. **Abgleich und Freigabe.** Lesen über PHP und neue API anhand identischer IDs vergleichen. Nach erlaubten Testschreibvorgängen Sichtbarkeit und Bedeutung im Altsystem prüfen. Disposition und Abrechnung bestätigen die relevanten Fälle; verbleibende Grenzen bleiben dokumentiert.
|
||||||
|
|
||||||
|
Falls ein bestehender Datentyp oder eine fehlende Spalte die gewünschte Funktion nicht abbildet, darf daraus keine automatische Schemaänderung folgen. Zunächst vorhandene fachliche Speicherorte untersuchen, dann die Einschränkung konkret benennen. Ein neuer API-Begriff ist kein Auftrag, eine gleichnamige Spalte anzulegen.
|
||||||
|
|
||||||
|
Authentisierung muss zum vorhandenen Benutzermodell passen. Alte Passwortwerte dürfen weder in API-Antworten noch in Logs gelangen. Eine Änderung von Passwortformaten, Kontoaktivierungen oder Benutzerrechten ist eine eigene fachliche Änderung und nicht Teil der bloßen Anbindung. Signaturen, Fotos und Dokumente bleiben an ihren vorhandenen Daten-/Dateibeziehungen; sie werden nicht ungefragt kopiert oder verschoben.
|
||||||
|
|
||||||
|
## 10. Fachliche Abnahme und technische Nachweise
|
||||||
|
|
||||||
|
| Abnahmeszenario | Erwartung |
|
||||||
|
|---|---|
|
||||||
|
| Sofortauftrag mit zwei Stationen | Pflichtfelder, Preisbedeutung und initialer Status stimmen; Auftrag nach Neustart vorhanden |
|
||||||
|
| Mehrstopp mit Abholung, Lieferung und gemischter Station | Reihenfolge bleibt erhalten; Teilabschluss beendet nicht vorzeitig den Gesamtauftrag |
|
||||||
|
| Zuweisen, annehmen, zurücknehmen | Kurierangebot und Annahme sind nachvollziehbar; ungültige Übergänge werden serverseitig abgewiesen |
|
||||||
|
| Zwei Disponenten bearbeiten denselben Auftrag | Keine unbemerkte Überschreibung; Versionskonflikt ist verständlich lösbar |
|
||||||
|
| Reservierung und Warnzeit | Lokale Zeitdarstellung und Speicherung stimmen, auch bei Zeitumstellung |
|
||||||
|
| Kostenpflichtiges/kostenfreies Storno | Zustand, Preisfolgen und Historie bleiben eindeutig; abgeschlossene/exportierte Fälle geprüft |
|
||||||
|
| Abweichender Zahler und Unterkostenstelle | Rechnungszuordnung korrekt; unberechtigter Benutzer kann fremde Daten auch per API nicht erreichen |
|
||||||
|
| Festpreis mit Rabatt, Zuschlag, Service und Steuer | Beträge und Rundungsfolge anhand freigegebener Referenzfälle exakt geprüft |
|
||||||
|
| Historischer Auftrag nach Tarifänderung | Historischer Preis bleibt nachvollziehbar und unverändert, sofern keine ausdrückliche Neukalkulation erfolgt |
|
||||||
|
| Abrechnung und Wiederholung des Exports | Keine Doppelabrechnung; Exportinhalt und Status sind reproduzierbar |
|
||||||
|
| Fehlende externe Dienste | Verständliche Fehler, keine stillen Fantasiepreise oder falschen Statusbestätigungen |
|
||||||
|
| Bestandsanbindung und Parallelbetrieb | Identische fachliche Daten in PHP und neuer API; keine DDL, keine verlorenen Felder und keine doppelten Hintergrundaktionen |
|
||||||
|
|
||||||
|
Die erste Lieferung ist anhand ihrer tatsächlich vorhandenen Endpunkte, Bedienwege und Tests gegen diese Matrix zu prüfen. Bestehende Backend-Tests, ein erfolgreicher Frontend-Build und ein erfolgreicher Start sind technische Nachweise; sie ersetzen weder Tarifvergleich noch Benutzerrechteprüfung, Schnittstellenabnahme und Datenabgleich.
|
||||||
|
|
||||||
|
Ein zunächst auf `job.hq_id = user.hq_id` begrenzter Lesezugriff zeigt nur Aufträge der eigenen **Erfassungsniederlassung**. Die Legacy-Liste berücksichtigt zusätzlich Ausführungs-, Dispositions- und Vertriebsniederlassung über eine Vereinigung erlaubter HQ-Mengen (`votian/admin/jb_list.php:1101`, `:1363`). Die engere neue Sicht ist als Einschränkung auszuweisen: Ein in einer anderen Niederlassung erfasster, der eigenen Disposition zugehöriger Auftrag kann fehlen. Zugriffsbereiche dürfen ohne passende Rechteabbildung nicht pauschal erweitert werden.
|
||||||
|
|
||||||
|
Eine vollständige Ablösung kann erst bestätigt werden, wenn Stadtbote die aktiv genutzten Module bestimmt hat, die zugehörigen Regeln in der neuen Anwendung kompatibel umgesetzt oder ausdrücklich stillgelegt sind und die relevanten Referenzfälle bestanden wurden.
|
||||||
|
|
||||||
|
## Anhang A. Konkrete Zuordnung bestehender Tabellen zu API-Modellen
|
||||||
|
|
||||||
|
Die API-Feldnamen in der linken Spalte sind Vorschläge für die Java-/JSON-Abbildung. Die rechten Spalten sind im PHP-Code belegte bestehende Datenbanknamen. Die tatsächlichen Typen, Nullmöglichkeiten und Indizes müssen über die vorhandene Datenbank lesend geprüft werden. Die folgenden SQL-Formen dienen nur der Implementierungsanalyse und wurden nicht gegen eine Datenbank ausgeführt.
|
||||||
|
|
||||||
|
### Kunde und Firmenstamm
|
||||||
|
|
||||||
|
| API-Feld | Bestehende Spalte / Ausdruck | Beziehung |
|
||||||
|
|---|---|---|
|
||||||
|
| `customer.id` | `customer.cs_id` | Fachlicher Kundenschlüssel, nicht `cmp_id` |
|
||||||
|
| `customer.number` | `customer.cs_eid` | Im Bearbeitungsablauf auf Eindeutigkeit geprüft |
|
||||||
|
| `customer.internalNumber` | `customer.cs_sid` | Weitere interne Nummer |
|
||||||
|
| `customer.companyId` | `customer.cmp_id` | `customer.cmp_id = company.cmp_id` |
|
||||||
|
| `customer.name` | `company.cmp_comp` | Firmenzusätze `cmp_comp2` bis `cmp_comp4` separat |
|
||||||
|
| `customer.headquarterId` | `customer.hq_id` | Sichtbarkeit nach erlaubtem Niederlassungs-/Mandantenbereich |
|
||||||
|
| `customer.defaultCostCenterId` | `customer.csc_id` | Hauptkostenstelle |
|
||||||
|
| `customer.payerCostCenterId` | `customer.csc_id_payer` | Abweichender Standardzahler |
|
||||||
|
| `customer.contact.firstName/lastName` | `user.usr_firstname`, `user.usr_name` | `customer.cs_admin = employee.emp_id`, dann `employee.usr_id = user.usr_id` |
|
||||||
|
| `customer.phone/email` | `user.usr_phone`, `user.usr_email` | Wie Kontakt; Telefon nicht aus `company` erfinden |
|
||||||
|
| `customer.address.street` | `address.ad_street` | `company.ad_id = address.ad_id` |
|
||||||
|
| `customer.address.houseNumber` | `company.cmp_hsno` | Hausnummer liegt hier außerhalb der Adresstabelle |
|
||||||
|
| `customer.address.postalCode/city/country` | `address.ad_zipcode`, `ad_city`, `ad_country` | Wie Straße |
|
||||||
|
| `customer.visible/archived/authorized` | `company.cmp_visible`, `cmp_archived`, `cmp_authenticated` | Drei unterschiedliche Zustände |
|
||||||
|
| `customer.discount/fixedPriceDiscount` | `customer.cs_discount`, `cs_fixprice_discount` | Prozentwerte; historische Tarif-/Änderungslogik zusätzlich prüfen |
|
||||||
|
| `customer.paymentTerm` | `customer.cs_payment_term` | Vorhandene Bedeutung/Einheit am Bestand verifizieren |
|
||||||
|
|
||||||
|
Lese-Joins: `votian/include/inc_customer.inc.php:31`, `:70`; vollständige Feldliste `votian/include/mcglobal.inc.php:217`. Eindeutigkeitsprüfung `votian/admin/customer_special.php:797`.
|
||||||
|
|
||||||
|
Bestehende Anlagefolge in `votian/include/inc_customer.inc.php:678`: `costcenter` anlegen, `user` vom Typ `2`, `employee` mit Kostenstellen-/Rechtebezug, `company`, `customer`, anschließend `costcenter.cs_id` zurückverknüpfen, Adresse verknüpfen und mehrere `costcenteraddress`-Arten anlegen (`:746`, `:757`, `:762`). Eine alleinige Anlage in `customer` wäre zu dieser Fachlichkeit nicht kompatibel. Die Feldlisten für Änderungen stehen in `votian/admin/customer_special.php:823`, `:840`, `:844`; sie aktualisieren Firma, Kunde und Kontaktbenutzer separat. Das ist eine Analyse der Altlogik, keine Aufforderung zu Teständerungen am Bestand.
|
||||||
|
|
||||||
|
### Kostenstelle und Kostenstellenadresse
|
||||||
|
|
||||||
|
| API-Feld | Bestehende Spalte |
|
||||||
|
|---|---|
|
||||||
|
| `costCenter.id/customerId/name` | `costcenter.csc_id`, `cs_id`, `csc_name` |
|
||||||
|
| `costCenter.parentId/path` | `costcenter.csc_pre_id`, `csc_path` |
|
||||||
|
| `costCenter.visible/blocked/external` | `costcenter.csc_visible`, `csc_blocked`, `csc_is_extern` |
|
||||||
|
| `costCenter.invoiceInterval/invoiceType` | `costcenter.csc_inv_interval`, `csc_inv_type` |
|
||||||
|
| `costCenterAddress.typeId/addressId` | `costcenteraddress.adt_id`, `ad_id` |
|
||||||
|
| `costCenterAddress.company/houseNumber/contact/phone/email` | `cscad_comp`, `cscad_hsno`, `cscad_person`, `cscad_phone`, `cscad_email` |
|
||||||
|
| `costCenterAddress.street/postalCode/city` | `address.ad_street`, `ad_zipcode`, `ad_city` über `costcenteraddress.ad_id = address.ad_id` |
|
||||||
|
|
||||||
|
Leseabfragen: `votian/include/inc_customer.inc.php:973`, `:998`. Anlage in `votian/customer/costcenter.php:77` prüft kundenspezifisch eindeutige Namen, setzt `cs_id`, `csc_pre_id`, `csc_name`, `csc_path`, `csc_visible` und übernimmt Adressen/Rechnungsintervall der Hauptkostenstelle. Umbenennen/Anzeige/Rechnungsdaten werden separat aktualisiert (`:172`, `:193`, `:375`). `csc_path` enthält im untersuchten Aufbau Namen und Trenner; nicht ohne Nachweis als reine ID-Liste interpretieren.
|
||||||
|
|
||||||
|
### Kurier, Kontakt und Fahrzeug
|
||||||
|
|
||||||
|
| API-Feld | Bestehende Spalte / Beziehung |
|
||||||
|
|---|---|
|
||||||
|
| `courier.id/number/dispatchSid` | `courier.cr_id`, `cr_eid`, `cr_sid` |
|
||||||
|
| `courier.companyId/userId/headquarterId` | `courier.cmp_id`, `usr_id`, `hq_id` |
|
||||||
|
| `courier.companyName` | `company.cmp_comp` über `courier.cmp_id = company.cmp_id` |
|
||||||
|
| `courier.firstName/lastName/phone/email` | `user.usr_firstname`, `usr_name`, `usr_phone`, `usr_email` über `courier.usr_id = user.usr_id` |
|
||||||
|
| `courier.vehicleTypeId` | `courier.vht_id`; nicht mit `vht_id_inv` für Abrechnung verwechseln |
|
||||||
|
| `courier.available/availableAt/locationPostalCode` | `courier.cr_available`, `cr_availabletime`, `cr_locationzipcode` |
|
||||||
|
| `courier.maxWeight/outlayLimit` | `courier.cr_maxweight`, `cr_outlay` |
|
||||||
|
| `courier.disposition/appBlocked` | `courier.cr_disposition`, `cr_app_blocked` |
|
||||||
|
| `vehicle.id/courierId/dispatchSid/typeId` | `couriervehicle.crvh_id`, `cr_id`, `crvh_sid`, `vht_id` |
|
||||||
|
| `vehicle.licensePlate/payload` | `couriervehicle.crvh_vh_sign`, `crvh_payload` |
|
||||||
|
|
||||||
|
Lese-Joins: `votian/include/inc_courier.inc.php:32`, `:68`; Fahrzeugfelder: `votian/include/mcglobal.inc.php:206`.
|
||||||
|
|
||||||
|
Anlage erzeugt zuerst `user` vom Typ `3`, dann `company`, `courier` und Adresse (`votian/include/inc_courier.inc.php:645`, `:653`, `:682`, `:694`). Die Änderungsmaske schreibt Firma, Kurier und Benutzer getrennt (`votian/admin/courier_special.php:486`, `:525`, `:549`). Der aktuelle Kommentar vor dem Kurier-Update weist ausdrücklich darauf hin, dass `cr_sid` aus diesem allgemeinen Update entfernt wurde (`:524`); SID deshalb nicht ungeprüft als frei editierbares Stammdatenfeld behandeln. Änderungen an der Gerätenummer können weitere Zuordnungen beeinflussen; für einen einfachen Kontakt-Edit ist `usr_phone` maßgeblich, nicht automatisch `cr_mobile_pda`.
|
||||||
|
|
||||||
|
### Authentisierung und Autorisierung
|
||||||
|
|
||||||
|
| Zweck | Bestehende Felder / Beziehungen |
|
||||||
|
|---|---|
|
||||||
|
| Kontosuche | `user.usr_account`; Ergebnis `usr_id`, `hq_id`, `usr_type` |
|
||||||
|
| Passwortprüfung | `user.usr_password`, gegebenenfalls `usr_password_old`; nur intern verwenden, nie API-Ausgabe |
|
||||||
|
| Mitarbeiterbezug | `employee.usr_id = user.usr_id`; `emp_id`, `csc_id`, `emp_rights`, `emp_heritable_rights` |
|
||||||
|
| Rollen | `usr_type`: `1` Zentrale, `2` Kunde, `3` Kurier, `4` Lager; Typ `0` separat prüfen |
|
||||||
|
| Niederlassungszugriff | `user.hq_id`, Mitarbeiterkontext und besondere Freigaben; nicht nur Rolle |
|
||||||
|
| Erweiterte Niederlassungsrechte | `employeerights.emp_id`, `hq_id`, `rgt_id` |
|
||||||
|
| Kundenzulassung | `employee.csc_id → costcenter.cs_id → customer.cmp_id → company.cmp_authenticated` |
|
||||||
|
| Kurierzuordnung | `courier.usr_id = user.usr_id` |
|
||||||
|
|
||||||
|
Der untersuchte Login prüft gegen die Datenbankfunktionen `PASSWORD()` und `OLD_PASSWORD()` (`votian/admin/login.php:484`, `:494`), und `cryptDbPassword` ruft ebenfalls `PASSWORD()` auf (`votian/include/dbglobal.inc.php:458`). Ein pauschaler BCrypt-Vergleich wäre deshalb nicht mit diesem Legacyzweig kompatibel. Passwortwerte oder Formate wurden hier nicht aus der Datenbank gelesen. Die konkret eingesetzte Datenbankversion und die vorhandenen Hashformate müssen vor Implementierung der Prüfung sicher geklärt werden. Die im Altlogin enthaltene automatische Passwortänderung wird bei einer rein lesenden Anbindungsprüfung nicht übernommen.
|
||||||
|
|
||||||
|
Die Benutzer-/Mitarbeiterbeziehung wird beim Login aufgelöst (`votian/admin/login.php:531`). Kunden müssen zusätzlich freigeschaltet sein (`:545`). Aktionsrechte werden über `employee.emp_rights` geprüft, können beim Niederlassungswechsel eingeschränkt werden und werden durch eine separate Niederlassungsrechtematrix ergänzt (`votian/include/dbglobal.inc.php:815`, `:841`; `votian/include/auth.inc.php:315`). Ein erfolgreiches Passwort allein berechtigt daher nicht zum Zugriff auf sämtliche Aufträge.
|
||||||
|
|
||||||
|
|
||||||
|
### Präzisierungen für einen lesenden HQ-Zugang
|
||||||
|
|
||||||
|
Für einen Zugang als Zentralenmitarbeiter (`usr_type=1`) benötigt der Legacy-Prüfpfad eine existierende, positive `emp_id` mit `employee.usr_id = user.usr_id`. Der Niederlassungsschlüssel stammt aus `user.hq_id`, nicht aus einem unbelegten Feld `employee.hq_id`. Zunächst ist der eigene HQ-Bereich zu verwenden; Mehrfachzugriff setzt besondere Freigaben und Rechtebeschränkungen voraus (`votian/include/auth.inc.php:214`; `votian/include/dbglobal.inc.php:815`).
|
||||||
|
|
||||||
|
Der Login-Join fordert eine existierende `headquarters`-Zeile (`votian/admin/login.php:494`). Die allgemeine Authentisierungsinitialisierung verlangt außerdem einen gültigen Mandantenbezug über `mandatorheadquarters.hq_id → md_id` (`votian/include/auth.inc.php:115`). In den untersuchten Dateien `admin/login.php`, `include/auth.inc.php`, `admin/employee.php`, `admin/user_list.php` und `include/dbglobal.inc.php` wurde keine Loginprüfung anhand von `usr_active`, `usr_disabled`, `usr_deleted`, `emp_active` oder `emp_disabled` gefunden; diese Felder dürfen nicht in SQL erfunden werden. Das belegt nicht ihre Abwesenheit in jedem Datenbankstand. `hq_disabled` existiert als Auswahlfilter (`votian/jobs/job_header.php:79`), wird aber im untersuchten Passwort-Login nicht geprüft. Ob die neue Anmeldung eine deaktivierte HQ ausdrücklich ablehnt, ist als eigene Regel festzulegen und gegen den Bestand zu prüfen.
|
||||||
|
|
||||||
|
Die folgende HQ-Rechtematrix verwendet **nullbasierte** Indizes in `employee.emp_rights`. Sie gilt nicht automatisch für Kundenbenutzer:
|
||||||
|
|
||||||
|
| Zugriff | Bitindex | Quelle |
|
||||||
|
|---|---|---|
|
||||||
|
| Kunden | `0` | `votian/admin/menu.php:214` |
|
||||||
|
| Kuriere/Transporteure | `1` | `votian/admin/menu.php:215` |
|
||||||
|
| Auftragslisten | `4` | `votian/admin/menu.php:206` |
|
||||||
|
| Rechnungsübersicht, PDF-/CSV-Ausgabe | `5` | `votian/admin/menu.php:254`, `:258`, `:259` |
|
||||||
|
| Allgemeiner Datenexport | `6` | `votian/admin/menu.php:265` |
|
||||||
|
| Auftragserfassung | `7` | `votian/admin/menu.php:246` |
|
||||||
|
| Statistik | `8` | `votian/admin/menu.php:211` |
|
||||||
|
|
||||||
|
Diese Zugriffe müssen auch bei einer lesenden API serverseitig geprüft werden. Dashboard-Zahlen und Preiszusammenfassungen unterliegen ebenfalls den passenden Lese-/Abrechnungsrechten. Vorhandene PHP-Links besitzen ihre eigene Sitzung und Authentisierung; aus einem erfolgreichen Login der neuen Oberfläche folgt noch keine automatisch angemeldete PHP-Sitzung.
|
||||||
|
|
||||||
|
## Anhang B. Geprüfte PHP-Modulziele
|
||||||
|
|
||||||
|
Die folgenden Pfade sind relativ zur konfigurierten Basis-URL der vorhandenen `votian`-Installation. Sie öffnen Module und übermitteln keine Speicher-, Lösch-, Storno- oder Importausführungsaktion. Die Funktionsprüfung erfolgte am Quelltext; eine aktive PHP-Sitzung, Berechtigungen und eine laufende Installation bleiben Voraussetzung.
|
||||||
|
|
||||||
|
| Ziel | Relativer Pfad | Quellbeleg |
|
||||||
|
|---|---|---|
|
||||||
|
| Neuer Auftrag | `jobs/job_edit.php` | `votian/admin/menu.php:248` |
|
||||||
|
| Auftrag auswählen/bearbeiten | `admin/jb_list.php` | `votian/admin/jb_list.php:33` |
|
||||||
|
| Kundenverwaltung | `admin/customer_list.php` | `votian/admin/menu.php:214` |
|
||||||
|
| Kurierverwaltung | `admin/courier_list.php` | `votian/admin/menu.php:215` |
|
||||||
|
| Rechnungsübersicht | `invoice/invoice.php` | `votian/admin/menu.php:254` |
|
||||||
|
| Servicepreise | `admin/services.php?mode=price` | `votian/admin/menu.php:268` |
|
||||||
|
| PLZ-Preise | `admin/services_plz.php?mode=price` | `votian/admin/menu.php:269` |
|
||||||
|
| Datenimport/-transfer | `import/data_transfer.php` | `votian/admin/menu.php:266` |
|
||||||
|
| Datenexport | `export/export.php` | `votian/admin/menu.php:265` |
|
||||||
|
| Statistik | `statistic/statistic.php` | `votian/admin/menu.php:211` |
|
||||||
|
|
||||||
|
Objektbezogene Links werden im Bestand normalerweise als `jobs/job_edit.php?jb_id=ec(jb_id)`, `admin/customer_special.php?companyId=ec(cmp_id)` und `admin/courier_special.php?companyId=ec(cmp_id)` erzeugt. `companyId` ist der Firmenschlüssel `cmp_id`, nicht die Kunden-ID `cs_id` und nicht die Kurier-ID `cr_id`. Quellen: `votian/include/jb_list_defineoutput.inc.php:665`, `votian/admin/customer_list.php:854`, `votian/admin/courier_list.php:712`.
|
||||||
|
|
||||||
|
`ec()` hängt von `HTTP_VARS_SEC_STATE`, einer Markierungssequenz und dem PHP-Sitzungskontext ab. `getSecHttpVars("1", …)` ruft `dc()` auf. Die untersuchte Implementierung von `dc()` lässt unmarkierte Werte unverändert; Klartext-IDs sind daher auf Quelltextebene nicht grundsätzlich ungültig. Eine eigene Nachbildung der Codierung oder ein beliebig erzeugter codierter Wert ist trotzdem kein nachgewiesen funktionsfähiger Link. Quellen: `votian/include/global.inc.php:57`, `:846`, `:868`.
|
||||||
|
|
||||||
|
Für die erste Anbindung sind Module ohne Objektparameter die belastbarere Option. Direkte Objektlinks benötigen eine Prüfung mit der tatsächlichen PHP-Konfiguration und einem erlaubten Testkonto. Es werden keine schreibenden `f_act`-Parameter konstruiert.
|
||||||
|
|
||||||
|
## Anhang C. Bei der Adapterprüfung besonders zu beachtende Bedeutungen
|
||||||
|
|
||||||
|
- `job.jb_totalprice` enthält den Kundenpreis einschließlich der dort angewendeten Zuschläge, während `jb_subtotalprice` im untersuchten Festpreiszweig den Zuschlag nicht enthält. Die Steuer wird gesondert berechnet. Eine Anzeige des vollständigen Nettopreises darf deshalb nicht ungeprüft auf `jb_subtotalprice` zugreifen (`votian/jobs/job_options.php:1828`, `:1886`, `:2140`).
|
||||||
|
- `job.jb_service` wird als Bitmaske verwendet. Im Filter wird `2^mt_sort` getestet; ein einfacher Gleichheitsjoin `metatype.mt_sort = job.jb_service` ist für Kombinationen falsch (`votian/admin/jb_list.php:1158`). Fahrzeugtypen verwenden hingegen tatsächlich `metatype.mt_sort = job.vht_id` (`:1177`).
|
||||||
|
- Kilometer werden im untersuchten Speicherpfad als `jobprice.jbprc_price` mit `mt_sort=11` gespeichert. Ein Feld `job.jb_km` darf daraus nicht abgeleitet werden (`votian/jobs/job_options.php:224`, `:2097`).
|
||||||
|
- Packstücke werden über `SUM(tourarticle.trat_packingpieces)` ermittelt, Artikelgewichte über `SUM(tourarticle.trat_weight)`; `job.jb_weight` ist daneben ein vorhandenes Auftragsfeld (`votian/include/inc_job.inc.php:91`, `:112`).
|
||||||
|
- Die Historie liegt im untersuchten Zugriff unter `phoenix_log.log`. Eine nicht verfügbare Historienanbindung darf nicht als nachgewiesen leere Historie erscheinen; sie benötigt eine ausdrückliche Kennzeichnung (`votian/include/caglobal.inc.php:2254`).
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Prüfstatus der ersten Bestandsanbindung
|
||||||
|
|
||||||
|
Stand: 1. Oktober 2026.
|
||||||
|
|
||||||
|
## Aktuelle Umstellung auf den zentralen Datenbankservice
|
||||||
|
|
||||||
|
Die neue Anwendung verwendet für jeden Datenbankzugriff `database.DatabaseService`, einschließlich Anmeldung, Listen, CSV-Leseabfragen, Verbindungstest und Schemadiagnose. Python ist bei der Diagnose nur noch der Launcher des Java-JARs. Der zentrale Schreibzugang bleibt gesperrt. Architekturtests erzwingen die Grenze auch für die Integrationstests; die PHP-Altanwendung bleibt unverändert.
|
||||||
|
|
||||||
|
Nach wiederhergestellter Datenbankverbindung wurden am 1. Oktober 2026 um 17:00 Uhr **49 Backend-Tests einschließlich des Integrationstests gegen die Bestandsdatenbank** erfolgreich ausgeführt: keine Fehler, keine übersprungenen Tests. Außerdem bestanden alle **10 Python-Tests**. Der Integrationstest lädt Aufträge, Kunden und Kuriere über die API und den zentralen Service mit simuliertem Anwendungskonto und echten SELECT-Abfragen.
|
||||||
|
|
||||||
|
Die erneute Java-CLI-Diagnose war ebenfalls erfolgreich: MariaDB `11.7.2-MariaDB-ubu2404-log`, 13 geprüfte Tabellen, keine fehlenden erforderlichen Spalten. Es wurden keine Daten oder Tabellen geändert. Die Anwendung bleibt gestoppt; nach den Tests waren die Ports 8080 und 5173 frei.
|
||||||
|
|
||||||
|
Im ersten erreichbaren Testlauf fiel eine ungeeignete Prüfung von `@@session.tx_read_only` auf: Dieser Wert beschreibt den Sessionstandard, nicht den Modus der einzelnen Transaktion. Die Prüfung wurde ersetzt und der Service aktiviert nun ausdrücklich `DataSourceTransactionManager.setEnforceReadOnly(true)`. Ein zusätzlicher Test mit echtem Transaktionsmanager und gemockter Verbindung prüft die Reihenfolge `SET TRANSACTION READ ONLY → SELECT → COMMIT`; es gibt keine Schreibversuche gegen den Bestand. Grundlagen: [MariaDB SET TRANSACTION](https://mariadb.com/docs/server/reference/sql-statements/administrative-sql-statements/set-commands/set-transaction), [Spring DataSourceTransactionManager](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/jdbc/datasource/DataSourceTransactionManager.html#setEnforceReadOnly(boolean)).
|
||||||
|
|
||||||
|
Der zuvor bei unterbrochener Verbindung geprüfte Fehlerfall bleibt gültig: Der CLI-Modus liefert bereinigtes JSON mit `reason: UNAVAILABLE`, Exitcode 1 und startet keinen Webserver.
|
||||||
|
|
||||||
|
## Durchgeführt
|
||||||
|
|
||||||
|
| Prüfung | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| React-/TypeScript-Produktionsbuild | Erfolgreich |
|
||||||
|
| Spring-Boot `clean verify`, inklusive gebündeltem React | Erfolgreich |
|
||||||
|
| 20 Backend-Tests ohne Bestandszugriff | Erfolgreich: Legacy-Mapping einschließlich Boolean-Flags und mehrwertiger Statuscodes, Rechte, Niederlassung, CSV, gesperrte Schreibmethoden |
|
||||||
|
| 1 optionaler Integrationstest mit vorhandener Datenbank | Erfolgreich: Auftrags-, Kunden- und Kurier-API über MockMvc mit echten JDBC-Abfragen; Test-Authentifizierung, keine Datenänderungen |
|
||||||
|
| 7 Python-Konfigurationstests | Erfolgreich: Kommentare, Escaping, Umgebungspriorität, keine PHP-Ausführung |
|
||||||
|
| 1 HTTP-Integrationstest gegen das gebaute JAR | Erfolgreich: Health, Offline-Systemzustand, geschützte API, öffentliche Logo-Datei |
|
||||||
|
| Manuelle Browserprüfung im integrierten Browser | Anmeldung mit Testantworten, Auftragsliste, Mehrstoppdetails, Disposition, Touren, Kunden, Kuriere, Abrechnungshinweis, Auswertung, serverseitige Suche, Logout und eingeschränktes Folgekonto geprüft |
|
||||||
|
| Browserprüfung mit fehlenden Preisen und Terminen | Keine Browserfehler; unbekannte Werte bleiben „–“, Summen als unvollständig gekennzeichnet |
|
||||||
|
| Abhängigkeiten des Frontends | `npm install` meldet keine bekannten Sicherheitslücken |
|
||||||
|
| Verbindung und Schemametadaten der Bestandsdatenbank | Erfolgreich mit `scripts/check-database.py`: 13 Tabellen geprüft, keine erforderlichen Spalten fehlen; keine Anwendungsdatensätze gelesen und keine Schreibzugriffe |
|
||||||
|
| Backend nach Neustart mit dem konfigurierten Datenbankkonto | Erfolgreich: `GET /api/health` liefert `status: UP`; `GET /api/system` liefert `connected: true`, `reason: CONNECTED`, `readOnly: true`, `schemaChanges: false` |
|
||||||
|
|
||||||
|
Für die Browserprüfung diente ausschließlich ein separater lokaler HTTP-Testserver mit ausdrücklich markierten Testantworten. Er ist kein Bestandteil der Anwendung und hat keine Datenbank. Es wurden keine Testdatensätze in MySQL angelegt. Die vollständige automatisierte Playwright-Browsersuite ist vorbereitet, konnte hier wegen eines abgewiesenen Chrome-Prozessstarts nicht ausgeführt werden; sie wird nicht als bestanden gewertet. Die entsprechende Bedienung wurde im integrierten Browser geprüft.
|
||||||
|
|
||||||
|
## Verbindung zur Bestandsdatenbank
|
||||||
|
|
||||||
|
Die lokale Verbindungskonfiguration steht in der `.env` im Projektstamm: `DB_URL` enthält Host, Port und Datenbanknamen; `DB_USER` und `DB_PASSWORD` enthalten das Datenbankkonto. Die Prüfung am 1. Oktober 2026 mit `scripts/check-database.py` war erfolgreich:
|
||||||
|
|
||||||
|
- Verbindung: `connected: true`
|
||||||
|
- Serverversion: `11.7.2-MariaDB-ubu2404-log`
|
||||||
|
- Geprüfte Tabellen: `checkedTables: 13`
|
||||||
|
- Fehlende erforderliche Spalten: `missingColumns: {}`
|
||||||
|
- Gelesene Anwendungsdatensätze: `applicationRowsRead: 0`
|
||||||
|
- Schreibzugriffe: `writes: 0`
|
||||||
|
|
||||||
|
Die mit dem zuvor verwendeten Datenbankkonto aufgetretene Anmeldungssperre (MySQL-Fehler **1045**, auch beim Versuch mit TLS) blockiert den Zugriff mit dem anschließend konfigurierten Datenbankkonto nicht mehr. Die Prüfung hat ausschließlich Server- und Schemametadaten gelesen; sie bestätigt die Existenz der vom Prüfskript benötigten Tabellen und Spalten.
|
||||||
|
|
||||||
|
## Noch nicht erfolgreich geprüft
|
||||||
|
|
||||||
|
Die SQL-Zuordnungen beruhen auf dem vorhandenen PHP-Quellcode. Die drei Listen wurden mit jeweils 500 Bestandsdatensätzen erfolgreich gelesen. Der Fehler beim Kunden-/Kurierlesen wurde zuvor als `NumberFormatException` bei `company.cmp_archived` reproduziert: JDBC lieferte einen Boolean, der Mapper erwartete eine Zahl. Der Mapper akzeptiert nun Boolean-Flags; `tinyInt1isBit=false` erhält außerdem mehrwertige numerische Legacy-Codes. Alle 21 Backend-Tests einschließlich der optionalen Bestandsprüfung bestanden nach der Korrektur.
|
||||||
|
|
||||||
|
Die Bestandsprüfung verwendet ausschließlich für den Test eine simulierte Authentifizierung innerhalb einer vorhandenen Niederlassung. Sie bestätigt weder die reale Benutzeranmeldung noch sämtliche Filter und Berechtigungsgrenzen. Diese sowie die fachlichen Ergebnisse sind weiterhin gegen die PHP-Anwendung zu vergleichen.
|
||||||
|
|
||||||
|
### Tagesansicht der offenen Aufträge
|
||||||
|
|
||||||
|
Am 1. Oktober 2026 wurde die Auswahl für das vorhandene Zentralenkonto `admin_b` zusätzlich mit dessen Niederlassung und Rechten über den echten Leseadapter geprüft: Status `DISPOSITION` und Abholdatum 1. Oktober 2026 liefern genau einen Auftrag, `21891299`. Die zuvor fehlende Datumseinschränkung hatte auch historische und weit in der Zukunft geplante Aufträge geladen. Die Oberfläche verwendet wie die Standardliste im PHP-Bestand den heutigen lokalen Kalendertag als vorbelegten Zeitraum; andere Zeiträume bleiben über die Filter auswählbar. Es gibt keine fest programmierte Konto- oder Auftragsausnahme und keine Statusänderung in der Datenbank.
|
||||||
|
|
||||||
|
Frontend- und Backend-Build nach dieser Änderung erfolgreich (20 reguläre Backend-Tests bestanden; die optionale Bestands-Testsuite in diesem Build deaktiviert). Im Browser mit isolierten Testantworten geprüft: Schon die erste Listenanfrage enthält beide Tagesgrenzen; ein bewusst erweiterter Zeitraum zeigt zusätzlich einen Zukunftsauftrag; „Heute“ reduziert die Liste wieder auf den Tagesbestand. Das laufende Backend wurde neu gestartet und liefert den aktualisierten Frontend-Build.
|
||||||
|
|
||||||
|
## Bewusst noch offene Fachlichkeit
|
||||||
|
|
||||||
|
Die neue Oberfläche und der JDBC-Leseadapter sind eine erste Ausbaustufe, keine vollständige fachliche Ablösung. Noch offen sind insbesondere:
|
||||||
|
|
||||||
|
- Native Auftragserfassung/-bearbeitung, Disposition mit Fahrerkommunikation, Stationsabschluss und Storno einschließlich Gegenbuchungen.
|
||||||
|
- Originale Tarife, Preisgültigkeiten, Rabatte, Zuschläge, Kurierabrechnung, Rechnungserstellung und Buchhaltungsexporte.
|
||||||
|
- Kostenstellenhierarchie, separate Fahrzeuge, Daueraufträge, Tourvorlagen, Lager/Artikel und kundenspezifische Schnittstellen.
|
||||||
|
- Kunden-/Kurierportale, zusätzliche Niederlassungsrechte, Archiv- und Log-Datenbanken.
|
||||||
|
- Konfiguration der tatsächlich vorhandenen PHP-Basisadresse `LEGACY_APP_URL`, um noch nicht portierte Fachabläufe gezielt aufzurufen.
|
||||||
|
|
||||||
|
Die Detailanalyse steht in `ALTSYSTEM-ANALYSE.md`. Es gibt keine neue Datenbank, keine automatische Schemaänderung und keinen Seed-Mechanismus. Die bisherige PHP-Anwendung wurde durch diese Umsetzung nicht verändert.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#27206b" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Votian NG – Auftragsmanagement und Disposition für Stadtbote"
|
||||||
|
/>
|
||||||
|
<title>Votian NG · Stadtbote</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+944
@@ -0,0 +1,944 @@
|
|||||||
|
{
|
||||||
|
"name": "votian-ng-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "votian-ng-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "19.3.0",
|
||||||
|
"react-dom": "19.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.1.0",
|
||||||
|
"@types/react-dom": "^19.1.0",
|
||||||
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "8.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@oxc-project/types": {
|
||||||
|
"version": "0.152.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz",
|
||||||
|
"integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/oxc-project"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/pluginutils": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||||
|
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/react-dom": {
|
||||||
|
"version": "19.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
|
||||||
|
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^19.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitejs/plugin-react": {
|
||||||
|
"version": "6.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
|
||||||
|
"integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rolldown/pluginutils": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"oxc-transform-react": "^0.145.0",
|
||||||
|
"vite": "^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@rolldown/plugin-babel": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"babel-plugin-react-compiler": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"oxc-transform-react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fdir": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"picomatch": "^3 || ^4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"picomatch": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"lightningcss-android-arm64": "1.33.0",
|
||||||
|
"lightningcss-darwin-arm64": "1.33.0",
|
||||||
|
"lightningcss-darwin-x64": "1.33.0",
|
||||||
|
"lightningcss-freebsd-x64": "1.33.0",
|
||||||
|
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||||
|
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||||
|
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||||
|
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||||
|
"lightningcss-linux-x64-musl": "1.33.0",
|
||||||
|
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||||
|
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-android-arm64": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
|
"version": "1.33.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||||
|
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "0.468.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz",
|
||||||
|
"integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nanoid": {
|
||||||
|
"version": "3.3.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
||||||
|
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/picocolors": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/picomatch": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postcss": {
|
||||||
|
"version": "8.5.28",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||||
|
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "^3.3.18",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react": {
|
||||||
|
"version": "19.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
|
||||||
|
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-dom": {
|
||||||
|
"version": "19.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
|
||||||
|
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"scheduler": "^0.28.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rolldown": {
|
||||||
|
"version": "1.2.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
|
||||||
|
"integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@oxc-project/types": "=0.152.0",
|
||||||
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rolldown": "bin/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@rolldown/binding-android-arm-eabi": "1.2.12",
|
||||||
|
"@rolldown/binding-android-arm64": "1.2.12",
|
||||||
|
"@rolldown/binding-darwin-arm64": "1.2.12",
|
||||||
|
"@rolldown/binding-darwin-x64": "1.2.12",
|
||||||
|
"@rolldown/binding-freebsd-x64": "1.2.12",
|
||||||
|
"@rolldown/binding-linux-arm-gnueabihf": "1.2.12",
|
||||||
|
"@rolldown/binding-linux-arm64-gnu": "1.2.12",
|
||||||
|
"@rolldown/binding-linux-arm64-musl": "1.2.12",
|
||||||
|
"@rolldown/binding-linux-ppc64-gnu": "1.2.12",
|
||||||
|
"@rolldown/binding-linux-s390x-gnu": "1.2.12",
|
||||||
|
"@rolldown/binding-linux-x64-gnu": "1.2.12",
|
||||||
|
"@rolldown/binding-linux-x64-musl": "1.2.12",
|
||||||
|
"@rolldown/binding-openharmony-arm64": "1.2.12",
|
||||||
|
"@rolldown/binding-win32-arm64-msvc": "1.2.12",
|
||||||
|
"@rolldown/binding-win32-x64-msvc": "1.2.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/scheduler": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/source-map-js": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyglobby": {
|
||||||
|
"version": "0.2.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
|
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fdir": "^6.5.0",
|
||||||
|
"picomatch": "^4.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite": {
|
||||||
|
"version": "8.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz",
|
||||||
|
"integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lightningcss": "^1.33.0",
|
||||||
|
"picomatch": "^4.0.7",
|
||||||
|
"postcss": "^8.5.28",
|
||||||
|
"rolldown": "~1.2.9",
|
||||||
|
"tinyglobby": "^0.2.17"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vite": "bin/vite.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
|
"@vitejs/devtools": "^0.7.1",
|
||||||
|
"esbuild": "^0.27.0 || ^0.28.0",
|
||||||
|
"jiti": ">=1.21.0",
|
||||||
|
"less": "^4.0.0",
|
||||||
|
"sass": "^1.70.0",
|
||||||
|
"sass-embedded": "^1.70.0",
|
||||||
|
"stylus": ">=0.54.8",
|
||||||
|
"sugarss": "^5.0.0",
|
||||||
|
"terser": "^5.16.0",
|
||||||
|
"tsx": "^4.8.1",
|
||||||
|
"yaml": "^2.4.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitejs/devtools": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"esbuild": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jiti": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"less": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sass": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sass-embedded": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"stylus": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sugarss": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"terser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"tsx": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"yaml": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "votian-ng-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 127.0.0.1",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview --host 127.0.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "19.3.0",
|
||||||
|
"react-dom": "19.3.0",
|
||||||
|
"lucide-react": "^0.468.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.1.0",
|
||||||
|
"@types/react-dom": "^19.1.0",
|
||||||
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "8.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,195 @@
|
|||||||
|
import type { User } from "./types";
|
||||||
|
|
||||||
|
// Remove credentials saved by older clients; the browser now owns authentication.
|
||||||
|
for (const storage of ["sessionStorage", "localStorage"] as const) {
|
||||||
|
try {
|
||||||
|
window[storage].removeItem("votian.session");
|
||||||
|
} catch {
|
||||||
|
// Storage may be disabled. Cookie sessions do not depend on it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
message: string,
|
||||||
|
public readonly code?: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CsrfToken = { token: string; headerName: string; parameterName: string };
|
||||||
|
let csrfToken: Promise<CsrfToken> | undefined;
|
||||||
|
let sessionVersion = 0;
|
||||||
|
let sessionActive = false;
|
||||||
|
const expiredListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function onSessionExpired(listener: () => void) {
|
||||||
|
expiredListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
expiredListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCsrfToken() {
|
||||||
|
csrfToken = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCsrfToken(): Promise<CsrfToken> {
|
||||||
|
if (!csrfToken) {
|
||||||
|
const pending = request<CsrfToken>("/csrf");
|
||||||
|
csrfToken = pending;
|
||||||
|
void pending.catch(() => {
|
||||||
|
if (csrfToken === pending) resetCsrfToken();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateSession(active: boolean) {
|
||||||
|
sessionVersion++;
|
||||||
|
sessionActive = active;
|
||||||
|
resetCsrfToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCurrentSession(version: number) {
|
||||||
|
if (version !== sessionVersion)
|
||||||
|
throw new ApiError(0, "Diese Anfrage gehört zu einer beendeten Sitzung.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseError(response: Response, path: string) {
|
||||||
|
let message =
|
||||||
|
response.status === 401
|
||||||
|
? path === "/login"
|
||||||
|
? "Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen."
|
||||||
|
: "Die Sitzung ist abgelaufen. Bitte erneut anmelden."
|
||||||
|
: response.status === 403
|
||||||
|
? "Für diese Aktion fehlt die Berechtigung."
|
||||||
|
: `Anfrage fehlgeschlagen (${response.status}).`;
|
||||||
|
let code: string | undefined;
|
||||||
|
try {
|
||||||
|
const error = await response.json();
|
||||||
|
message = error.message || message;
|
||||||
|
code = error.code;
|
||||||
|
if (error.errors)
|
||||||
|
message +=
|
||||||
|
" " +
|
||||||
|
(Array.isArray(error.errors)
|
||||||
|
? error.errors.join(" · ")
|
||||||
|
: Object.values(error.errors).join(" · "));
|
||||||
|
} catch {
|
||||||
|
/* non-JSON error */
|
||||||
|
}
|
||||||
|
return new ApiError(response.status, message, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchResponse(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit,
|
||||||
|
version: number,
|
||||||
|
retryCsrf = true,
|
||||||
|
): Promise<Response> {
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
// Authorization belongs exclusively to any upstream HTTP Basic protection.
|
||||||
|
headers.delete("Authorization");
|
||||||
|
if (options.body && !headers.has("Content-Type"))
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
const unsafe = !["GET", "HEAD", "OPTIONS"].includes(
|
||||||
|
(options.method || "GET").toUpperCase(),
|
||||||
|
);
|
||||||
|
if (unsafe) {
|
||||||
|
const csrf = await getCsrfToken();
|
||||||
|
headers.set(csrf.headerName, csrf.token);
|
||||||
|
}
|
||||||
|
ensureCurrentSession(version);
|
||||||
|
const response = await fetch("/api" + path, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
credentials: "same-origin",
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
ensureCurrentSession(version);
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await responseError(response, path);
|
||||||
|
ensureCurrentSession(version);
|
||||||
|
if (
|
||||||
|
unsafe && retryCsrf &&
|
||||||
|
error.status === 403 && error.code === "CSRF_INVALID"
|
||||||
|
) {
|
||||||
|
resetCsrfToken();
|
||||||
|
return fetchResponse(path, options, version, false);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
error.status === 401 && sessionActive &&
|
||||||
|
path !== "/login" && path !== "/csrf"
|
||||||
|
) {
|
||||||
|
rotateSession(false);
|
||||||
|
expiredListeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
onResponse?: (response: Response) => void,
|
||||||
|
): Promise<T> {
|
||||||
|
const version = sessionVersion;
|
||||||
|
const response = await fetchResponse(path, options, version);
|
||||||
|
const result = response.status === 204 ? undefined : await response.json();
|
||||||
|
ensureCurrentSession(version);
|
||||||
|
onResponse?.(response);
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(): Promise<User> {
|
||||||
|
const user = await request<User>("/me");
|
||||||
|
sessionActive = true;
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(username: string, password: string): Promise<User> {
|
||||||
|
const user = await request<User>("/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({ username, password }),
|
||||||
|
});
|
||||||
|
rotateSession(true);
|
||||||
|
// Authentication rotates the CSRF token. A failed refresh is retried by the
|
||||||
|
// next write and must not turn a completed login/logout into a reported error.
|
||||||
|
void getCsrfToken().catch(() => {});
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await request<void>("/logout", { method: "POST" });
|
||||||
|
rotateSession(false);
|
||||||
|
void getCsrfToken().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const post = <T>(path: string, body?: unknown) =>
|
||||||
|
request<T>(path, {
|
||||||
|
method: "POST",
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
export const put = <T>(path: string, body: unknown) =>
|
||||||
|
request<T>(path, { method: "PUT", body: JSON.stringify(body) });
|
||||||
|
export const patch = <T>(path: string, body: unknown) =>
|
||||||
|
request<T>(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||||
|
|
||||||
|
export async function exportCsv(params: URLSearchParams) {
|
||||||
|
const version = sessionVersion;
|
||||||
|
const response = await fetchResponse("/orders/export.csv?" + params, {}, version);
|
||||||
|
const blob = await response.blob();
|
||||||
|
ensureCurrentSession(version);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "stadtbote-auftraege.csv";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,120 @@
|
|||||||
|
export type OrderStatus =
|
||||||
|
"DISPOSITION" | "OFFERED" | "IN_PROGRESS" | "COMPLETED" | "CANCELLED";
|
||||||
|
export interface Stop {
|
||||||
|
id?: number;
|
||||||
|
sequence: number;
|
||||||
|
type: "PICKUP" | "DELIVERY" | "BOTH";
|
||||||
|
name: string;
|
||||||
|
street: string;
|
||||||
|
postalCode: string;
|
||||||
|
city: string;
|
||||||
|
contact: string;
|
||||||
|
phone: string;
|
||||||
|
note: string;
|
||||||
|
completedAt?: string | null;
|
||||||
|
recipientName?: string;
|
||||||
|
}
|
||||||
|
export interface History {
|
||||||
|
id: number;
|
||||||
|
at: string;
|
||||||
|
action: string;
|
||||||
|
detail: string;
|
||||||
|
actor: string;
|
||||||
|
}
|
||||||
|
export interface Order {
|
||||||
|
id: number;
|
||||||
|
number: string;
|
||||||
|
version: number;
|
||||||
|
status: OrderStatus;
|
||||||
|
legacyStatus: number;
|
||||||
|
legacyStatusLabel?: string;
|
||||||
|
legacyStorno?: number;
|
||||||
|
isCreditNote?: boolean;
|
||||||
|
payerCustomerId?: number | null;
|
||||||
|
customerId: number;
|
||||||
|
courierId: number | null;
|
||||||
|
service: string;
|
||||||
|
vehicle: string;
|
||||||
|
pickupAt: string | null;
|
||||||
|
deliveryDeadline: string | null;
|
||||||
|
reference: string;
|
||||||
|
costCenter: string;
|
||||||
|
notes: string;
|
||||||
|
packages: number | null;
|
||||||
|
weight: number | null;
|
||||||
|
distanceKm: number | null;
|
||||||
|
netPrice: number | null;
|
||||||
|
courierPrice: number | null;
|
||||||
|
vatRate: number | null;
|
||||||
|
recipientName: string;
|
||||||
|
deliveredAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
cancelMode: "FREE" | "CHARGEABLE" | null;
|
||||||
|
invoiceId: number | null;
|
||||||
|
stops: Stop[];
|
||||||
|
history: History[];
|
||||||
|
}
|
||||||
|
export interface Customer {
|
||||||
|
id: number;
|
||||||
|
version: number;
|
||||||
|
number: string;
|
||||||
|
name: string;
|
||||||
|
street: string;
|
||||||
|
postalCode: string;
|
||||||
|
city: string;
|
||||||
|
contact: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
paymentDays: number | null;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
export interface Courier {
|
||||||
|
id: number;
|
||||||
|
version: number;
|
||||||
|
number: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
vehicle: string;
|
||||||
|
plate: string;
|
||||||
|
status: "AVAILABLE" | "BUSY" | "OFFLINE";
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
export interface Invoice {
|
||||||
|
id: number;
|
||||||
|
version: number;
|
||||||
|
number: string;
|
||||||
|
customerId: number;
|
||||||
|
orderIds: number[];
|
||||||
|
issuedAt: string;
|
||||||
|
dueAt: string;
|
||||||
|
netAmount: number;
|
||||||
|
taxAmount: number;
|
||||||
|
grossAmount: number;
|
||||||
|
status: "ISSUED" | "PAID";
|
||||||
|
}
|
||||||
|
export interface User {
|
||||||
|
username: string;
|
||||||
|
roles: string[];
|
||||||
|
}
|
||||||
|
export interface Capabilities {
|
||||||
|
readOrders: boolean;
|
||||||
|
readCustomers: boolean;
|
||||||
|
readCouriers: boolean;
|
||||||
|
readInvoices: boolean;
|
||||||
|
writeOrders: boolean;
|
||||||
|
writeCustomers: boolean;
|
||||||
|
writeCouriers: boolean;
|
||||||
|
writeInvoices: boolean;
|
||||||
|
completeStops: boolean;
|
||||||
|
exportOrders?: boolean;
|
||||||
|
}
|
||||||
|
export interface SystemStatus {
|
||||||
|
connected: boolean;
|
||||||
|
database: string;
|
||||||
|
message: string;
|
||||||
|
legacyUrl: string | null;
|
||||||
|
capabilities: Capabilities;
|
||||||
|
schemaChanges: boolean;
|
||||||
|
maxResults?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: { "/api": "http://127.0.0.1:8080" },
|
||||||
|
},
|
||||||
|
build: { outDir: "dist" },
|
||||||
|
});
|
||||||
Generated
+60
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"name": "votian-ng",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "votian-ng",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.63.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "votian-ng",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "./dev.sh",
|
||||||
|
"build": "./build.sh",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "1.63.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests",
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 0,
|
||||||
|
timeout: 30_000,
|
||||||
|
reporter: [
|
||||||
|
["list"],
|
||||||
|
["html", { outputFolder: "work/playwright-report", open: "never" }],
|
||||||
|
],
|
||||||
|
outputDir: "work/test-results",
|
||||||
|
use: {
|
||||||
|
baseURL: "http://127.0.0.1:18080",
|
||||||
|
channel: process.platform === "darwin" ? "chrome" : undefined,
|
||||||
|
headless: true,
|
||||||
|
viewport: { width: 1440, height: 1000 },
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command:
|
||||||
|
`python3 -c 'import os; from pathlib import Path; jars = sorted(Path("backend/target").glob("votian-backend-*.jar")); assert len(jars) == 1, "Run ./build.sh first: expected exactly one backend JAR"; os.execvp("java", ["java", "-jar", str(jars[0]), "--server.port=18080"])'`,
|
||||||
|
// Start the built JAR directly: do not load the developer's .env file.
|
||||||
|
// Explicitly unreachable endpoint: no test can connect to the real database.
|
||||||
|
env: {
|
||||||
|
DB_URL:
|
||||||
|
"jdbc:mariadb://127.0.0.1:1/unreachable?connectTimeout=500&socketTimeout=500",
|
||||||
|
DB_USER: "test",
|
||||||
|
DB_PASSWORD: "test",
|
||||||
|
LEGACY_APP_URL: "",
|
||||||
|
},
|
||||||
|
url: "http://127.0.0.1:18080/api/health",
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
exec python3 "$ROOT/scripts/run-backend.py" "$@"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run read-only metadata diagnostics through the backend's shared DatabaseService."""
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
spec = importlib.util.spec_from_file_location("legacy_config", ROOT / "scripts/run-backend.py")
|
||||||
|
config = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(config)
|
||||||
|
|
||||||
|
|
||||||
|
def failure(message):
|
||||||
|
print(json.dumps({"connected": False, "reason": "CONFIGURATION_ERROR", "errorCode": None,
|
||||||
|
"message": message, "applicationRowsRead": 0, "writes": 0},
|
||||||
|
ensure_ascii=False, indent=2))
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
jar = config.backend_jar(ROOT)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return failure("Kein eindeutiges Backend-JAR. Zuerst ./build.sh ausführen.")
|
||||||
|
try:
|
||||||
|
env = config.database_environment(ROOT, dict(os.environ))
|
||||||
|
os.chdir(ROOT / "backend")
|
||||||
|
# Credentials are passed only in the environment, never as process arguments.
|
||||||
|
os.execvpe("java", ["java", "-jar", str(jar), "--database-check"], env)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return failure("Backend-Start oder DB_URL, DB_USER und DB_PASSWORD in .env/Umgebung prüfen.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Read local environment configuration without evaluating its contents."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_ASSIGNMENT = re.compile(r"(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)")
|
||||||
|
|
||||||
|
|
||||||
|
def load_environment(path: Path, current: dict[str, str]) -> dict[str, str]:
|
||||||
|
"""Merge a local .env file with the current environment, which takes priority.
|
||||||
|
|
||||||
|
Values are literal: shell commands, variable references, backslashes and
|
||||||
|
inline ``#`` characters are never interpreted. Optional surrounding quotes
|
||||||
|
are removed, without interpreting escapes. Keep the project's .env values
|
||||||
|
unquoted (and omit ``export``) for compatibility with Docker ``--env-file``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
contents = path.read_bytes()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return dict(current)
|
||||||
|
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
for line_number, raw_line in enumerate(contents.split(b"\n"), start=1):
|
||||||
|
# Accept both LF and CRLF, but not embedded line breaks in a value.
|
||||||
|
raw_line = raw_line.removesuffix(b"\r")
|
||||||
|
try:
|
||||||
|
line = raw_line.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raise ValueError(f"Invalid .env syntax at line {line_number}") from None
|
||||||
|
|
||||||
|
if "\x00" in line or "\r" in line:
|
||||||
|
raise ValueError(f"Invalid .env syntax at line {line_number}")
|
||||||
|
line = line.lstrip(" \t")
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
match = _ASSIGNMENT.fullmatch(line)
|
||||||
|
if match is None:
|
||||||
|
raise ValueError(f"Invalid .env syntax at line {line_number}")
|
||||||
|
|
||||||
|
key, value = match.groups()
|
||||||
|
if value.startswith(("'", '"')):
|
||||||
|
quoted_value = value.rstrip(" \t")
|
||||||
|
if len(quoted_value) < 2 or quoted_value[-1] != quoted_value[0]:
|
||||||
|
raise ValueError(f"Invalid .env syntax at line {line_number}")
|
||||||
|
value = quoted_value[1:-1]
|
||||||
|
values[key] = value
|
||||||
|
|
||||||
|
values.update(current)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Run a command with the project's .env, retaining the caller's directory."""
|
||||||
|
command = sys.argv[1:] if argv is None else argv
|
||||||
|
if not command or command[0] in ("-h", "--help"):
|
||||||
|
print(
|
||||||
|
"Usage: python3 scripts/env_config.py COMMAND [ARGS...]\n"
|
||||||
|
"Run COMMAND with the project .env; existing environment variables take priority.\n"
|
||||||
|
"The working directory is preserved. From backend, for example:\n"
|
||||||
|
" python3 ../scripts/env_config.py ./mvnw test",
|
||||||
|
file=sys.stderr if not command else sys.stdout,
|
||||||
|
)
|
||||||
|
return 2 if not command else 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
environment = load_environment(Path(__file__).resolve().parents[1] / ".env", dict(os.environ))
|
||||||
|
except ValueError as error:
|
||||||
|
message = str(error)
|
||||||
|
if re.fullmatch(r"Invalid \.env syntax at line [0-9]+", message) is None:
|
||||||
|
message = "Invalid .env syntax."
|
||||||
|
print(message, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
except OSError:
|
||||||
|
print("Unable to read .env.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.execvpe(command[0], command, environment)
|
||||||
|
except OSError:
|
||||||
|
print("Unable to start command.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# No Python database driver is required. check-database.py delegates to the built Java backend.
|
||||||
Executable
+59
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Start Spring Boot with database credentials from .env or the process environment."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from env_config import load_environment
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def database_environment(root: Path, current: dict[str, str]) -> dict[str, str]:
|
||||||
|
env = load_environment(root / ".env", current)
|
||||||
|
required = ("DB_URL", "DB_USER", "DB_PASSWORD")
|
||||||
|
if any(key not in env for key in required) or not env["DB_URL"] or not env["DB_USER"]:
|
||||||
|
raise ValueError("DB_URL, DB_USER und DB_PASSWORD müssen konfiguriert sein.")
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def backend_jar(root: Path) -> Path:
|
||||||
|
jars = sorted((root / "backend/target").glob("votian-backend-*.jar"))
|
||||||
|
if len(jars) != 1 or not jars[0].is_file():
|
||||||
|
raise FileNotFoundError("Kein eindeutiges Backend-JAR. Zuerst ./build.sh ausführen.")
|
||||||
|
return jars[0]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--check-config", action="store_true", help="Nur Konfiguration prüfen; keine DB-Verbindung")
|
||||||
|
parser.add_argument("--port", type=int, help="Port aus .env/Umgebung überschreiben (Standard 8080)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
env = database_environment(ROOT, dict(os.environ))
|
||||||
|
port = args.port if args.port is not None else int(env.get("PORT", "8080"))
|
||||||
|
if not 1 <= port <= 65535:
|
||||||
|
raise ValueError("Ungültiger Port.")
|
||||||
|
env["PORT"] = str(port)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
print("Konfiguration nicht lesbar/ungültig. DB_URL, DB_USER und DB_PASSWORD in .env oder der Umgebung sowie PORT prüfen.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if args.check_config:
|
||||||
|
print("Datenbankkonfiguration aus .env/Umgebung geprüft. Keine Verbindung hergestellt; keine Daten verändert.")
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
jar = backend_jar(ROOT)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("Kein eindeutiges Backend-JAR. Zuerst ./build.sh ausführen.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
os.chdir(ROOT / "backend")
|
||||||
|
os.execvpe("java", ["java", "-jar", str(jar)], env)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
// These records exist only in Playwright network mocks. They are never seeded
|
||||||
|
// into an application database, filesystem store, or production response.
|
||||||
|
const capabilities = {
|
||||||
|
readOrders: true,
|
||||||
|
readCustomers: true,
|
||||||
|
readCouriers: true,
|
||||||
|
readInvoices: false,
|
||||||
|
writeOrders: false,
|
||||||
|
writeCustomers: false,
|
||||||
|
writeCouriers: false,
|
||||||
|
writeInvoices: false,
|
||||||
|
completeStops: false,
|
||||||
|
exportOrders: true,
|
||||||
|
};
|
||||||
|
const user = {
|
||||||
|
username: "browser-test",
|
||||||
|
roles: ["VIEWER"],
|
||||||
|
headquartersId: 1,
|
||||||
|
capabilities,
|
||||||
|
};
|
||||||
|
const system = {
|
||||||
|
connected: true,
|
||||||
|
database: "legacy-mysql",
|
||||||
|
readOnly: true,
|
||||||
|
schemaChanges: false,
|
||||||
|
message: "Testfixture: bestehende Datenbank verbunden",
|
||||||
|
legacyUrl: "https://legacy.example.test/votian",
|
||||||
|
maxResults: 500,
|
||||||
|
capabilities,
|
||||||
|
unavailableModules: [
|
||||||
|
"Rechnungserstellung und Zahlungsstatus",
|
||||||
|
"Historie aus separater Log-Datenbank",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const customers = [
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
number: "C-TEST",
|
||||||
|
name: "Testkunde Nordlicht",
|
||||||
|
street: "Teststraße 1",
|
||||||
|
postalCode: "20095",
|
||||||
|
city: "Hamburg",
|
||||||
|
contact: "Testkontakt",
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
paymentDays: 14,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const couriers = [
|
||||||
|
{
|
||||||
|
id: 22,
|
||||||
|
number: "K-TEST",
|
||||||
|
name: "Testkurier Hafen",
|
||||||
|
phone: "",
|
||||||
|
vehicle: "Transporter",
|
||||||
|
plate: "HH-TEST",
|
||||||
|
status: "AVAILABLE",
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const orders = [
|
||||||
|
{
|
||||||
|
id: 880001,
|
||||||
|
number: "880001",
|
||||||
|
version: 2,
|
||||||
|
status: "DISPOSITION",
|
||||||
|
legacyStatus: 9,
|
||||||
|
legacyStatusLabel: "Disposition",
|
||||||
|
customerId: 11,
|
||||||
|
courierId: null,
|
||||||
|
service: "Direktfahrt",
|
||||||
|
vehicle: "Transporter",
|
||||||
|
pickupAt: `${today}T09:00:00`,
|
||||||
|
deliveryDeadline: null,
|
||||||
|
reference: "TEST-REFERENZ",
|
||||||
|
costCenter: "Testkostenstelle",
|
||||||
|
notes: "Nur Browser-Testfixture",
|
||||||
|
packages: null,
|
||||||
|
weight: 12.5,
|
||||||
|
distanceKm: null,
|
||||||
|
netPrice: 42.5,
|
||||||
|
courierPrice: 25,
|
||||||
|
vatRate: 19,
|
||||||
|
recipientName: "",
|
||||||
|
deliveredAt: null,
|
||||||
|
createdAt: `${today}T08:00:00`,
|
||||||
|
updatedAt: `${today}T08:10:00`,
|
||||||
|
cancelMode: null,
|
||||||
|
invoiceId: null,
|
||||||
|
stops: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
sequence: 1,
|
||||||
|
type: "PICKUP",
|
||||||
|
direction: 1,
|
||||||
|
name: "Abholung Testlager",
|
||||||
|
street: "Teststraße 1",
|
||||||
|
postalCode: "20095",
|
||||||
|
city: "Hamburg",
|
||||||
|
contact: "",
|
||||||
|
phone: "",
|
||||||
|
note: "",
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
sequence: 2,
|
||||||
|
type: "DELIVERY",
|
||||||
|
direction: 2,
|
||||||
|
name: "Zustellung Testbüro",
|
||||||
|
street: "Hafenweg 2",
|
||||||
|
postalCode: "20457",
|
||||||
|
city: "Hamburg",
|
||||||
|
contact: "",
|
||||||
|
phone: "",
|
||||||
|
note: "",
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
history: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const loginPassword = "browser-only fixture +%&=$";
|
||||||
|
|
||||||
|
async function mockApi(
|
||||||
|
page: Page,
|
||||||
|
overrides: Record<string, unknown> = {},
|
||||||
|
options: { authenticated?: boolean } = {},
|
||||||
|
) {
|
||||||
|
// Model the session on the server, independently of browser storage. A page
|
||||||
|
// reload preserves it; logout and expiry invalidate it for every endpoint.
|
||||||
|
let authenticated = options.authenticated ?? true;
|
||||||
|
let csrfVersion = 0;
|
||||||
|
const csrfToken = () => `browser-csrf-${csrfVersion}`;
|
||||||
|
const currentUser = overrides["/me"] ?? user;
|
||||||
|
await page.route("**/api/**", async (route) => {
|
||||||
|
const request = route.request();
|
||||||
|
const url = new URL(request.url());
|
||||||
|
const path = url.pathname.replace("/api", "");
|
||||||
|
const respond = (status: number, body: unknown) =>
|
||||||
|
route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
// The application must leave the Authorization header available to the
|
||||||
|
// reverse proxy. These tests run without proxy credentials.
|
||||||
|
expect(request.headers().authorization).toBeUndefined();
|
||||||
|
if (path === "/csrf" && request.method() === "GET")
|
||||||
|
return respond(200, {
|
||||||
|
token: csrfToken(),
|
||||||
|
headerName: "X-CSRF-TOKEN",
|
||||||
|
parameterName: "_csrf",
|
||||||
|
});
|
||||||
|
if (request.method() !== "GET") {
|
||||||
|
if (request.headers()["x-csrf-token"] !== csrfToken())
|
||||||
|
return respond(403, { message: "Invalid CSRF token", code: "CSRF_INVALID" });
|
||||||
|
if (path === "/login" && request.method() === "POST") {
|
||||||
|
expect(request.headers()["content-type"]).toContain(
|
||||||
|
"application/x-www-form-urlencoded",
|
||||||
|
);
|
||||||
|
const credentials = new URLSearchParams(request.postData() ?? "");
|
||||||
|
if (
|
||||||
|
credentials.get("username") !== user.username ||
|
||||||
|
credentials.get("password") !== loginPassword
|
||||||
|
)
|
||||||
|
return respond(401, {
|
||||||
|
message: "Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen.",
|
||||||
|
});
|
||||||
|
authenticated = true;
|
||||||
|
csrfVersion += 1;
|
||||||
|
return respond(200, currentUser);
|
||||||
|
}
|
||||||
|
if (path === "/logout" && request.method() === "POST") {
|
||||||
|
authenticated = false;
|
||||||
|
csrfVersion += 1;
|
||||||
|
return route.fulfill({ status: 204 });
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected mutation: ${request.method()} ${path}`);
|
||||||
|
}
|
||||||
|
if (!authenticated && path !== "/system")
|
||||||
|
return respond(401, { message: "Anmeldung erforderlich." });
|
||||||
|
let body: unknown = {
|
||||||
|
"/me": currentUser,
|
||||||
|
"/system": system,
|
||||||
|
"/customers": customers,
|
||||||
|
"/couriers": couriers,
|
||||||
|
"/orders": orders,
|
||||||
|
"/orders/880001": orders[0],
|
||||||
|
...overrides,
|
||||||
|
}[path];
|
||||||
|
if (path === "/orders" && url.searchParams.get("q")) {
|
||||||
|
const q = url.searchParams.get("q")!.toLowerCase();
|
||||||
|
body = orders.filter((o) => JSON.stringify(o).toLowerCase().includes(q));
|
||||||
|
}
|
||||||
|
if (body === undefined)
|
||||||
|
return route.fulfill({
|
||||||
|
status: 404,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ message: "Unknown test endpoint: " + path }),
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "X-Total-Count": "1", "X-Result-Limit": "500" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
rotateCsrf: () => {
|
||||||
|
csrfVersion += 1;
|
||||||
|
},
|
||||||
|
expire: () => {
|
||||||
|
authenticated = false;
|
||||||
|
csrfVersion += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectNoStoredCredentials(page: Page) {
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => ({
|
||||||
|
session: Object.entries(sessionStorage),
|
||||||
|
local: Object.entries(localStorage),
|
||||||
|
})),
|
||||||
|
).toEqual({ session: [], local: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
test("bundled application is available; real API fails closed without a database", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
expect((await request.get("/api/health")).status()).toBe(200);
|
||||||
|
const systemResponse = await request.get("/api/system");
|
||||||
|
expect(systemResponse.status()).toBe(200);
|
||||||
|
const state = await systemResponse.json();
|
||||||
|
expect(state.connected).toBe(false);
|
||||||
|
expect(state.schemaChanges).toBe(false);
|
||||||
|
expect(state.readOnly).toBe(true);
|
||||||
|
expect((await request.get("/api/orders")).status()).toBe(401);
|
||||||
|
expect((await request.post("/api/orders", { data: {} })).status()).toBe(403);
|
||||||
|
const csrfResponse = await request.get("/api/csrf");
|
||||||
|
expect(csrfResponse.status()).toBe(200);
|
||||||
|
const csrf = await csrfResponse.json();
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await request.post("/api/orders", {
|
||||||
|
data: {},
|
||||||
|
headers: { [csrf.headerName]: csrf.token },
|
||||||
|
})
|
||||||
|
).status(),
|
||||||
|
).toBe(401);
|
||||||
|
expect((await request.get("/stadtbote-logo.png")).status()).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("real session cookie is HttpOnly and honors HTTPS forwarded by the proxy", async ({
|
||||||
|
playwright,
|
||||||
|
baseURL,
|
||||||
|
}) => {
|
||||||
|
for (const secure of [false, true]) {
|
||||||
|
const context = await playwright.request.newContext({
|
||||||
|
baseURL,
|
||||||
|
extraHTTPHeaders: secure ? { "X-Forwarded-Proto": "https" } : {},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const response = await context.get("/api/csrf");
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(await response.json()).toMatchObject({
|
||||||
|
token: expect.any(String),
|
||||||
|
headerName: "X-CSRF-TOKEN",
|
||||||
|
});
|
||||||
|
const sessionCookie = (await context.storageState()).cookies.find(
|
||||||
|
(cookie) => cookie.name === "VOTIANSESSION",
|
||||||
|
);
|
||||||
|
expect(sessionCookie).toMatchObject({
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "Lax",
|
||||||
|
secure,
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await context.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("real backend ignores proxy Basic authentication and returns its own JSON 401", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Basic ${Buffer.from("proxy-fixture:unused").toString("base64")}`,
|
||||||
|
};
|
||||||
|
expect((await request.get("/api/health", { headers })).status()).toBe(200);
|
||||||
|
const response = await request.get("/api/me", { headers });
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
expect(response.headers()["content-type"]).toContain("application/json");
|
||||||
|
expect(response.headers()["www-authenticate"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offline login reports connection issue and exposes no fake work data", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByText(
|
||||||
|
/nicht erreichbar|keine.*verbindung|verbindung.*fehlgeschlagen/i,
|
||||||
|
)
|
||||||
|
.first(),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(page.getByText("880001")).toHaveCount(0);
|
||||||
|
expect((await page.request.get("/api/csrf")).status()).toBe(200);
|
||||||
|
expect(
|
||||||
|
(await page.context().cookies()).find((cookie) => cookie.name === "VOTIANSESSION"),
|
||||||
|
).toMatchObject({ httpOnly: true });
|
||||||
|
expect(await page.evaluate(() => document.cookie)).not.toContain("VOTIANSESSION");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login sends form credentials with CSRF and keeps them out of browser storage", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockApi(page, {}, { authenticated: false });
|
||||||
|
const initialSessionCheck = page.waitForResponse("**/api/me");
|
||||||
|
await page.goto("/");
|
||||||
|
expect((await initialSessionCheck).status()).toBe(401);
|
||||||
|
await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible();
|
||||||
|
await page.getByLabel("VoTiAn Benutzerkonto").fill(user.username);
|
||||||
|
await page.getByLabel("Passwort", { exact: true }).fill(loginPassword);
|
||||||
|
const loginRequest = page.waitForRequest("**/api/login");
|
||||||
|
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||||
|
const submitted = await loginRequest;
|
||||||
|
expect(submitted.method()).toBe("POST");
|
||||||
|
expect(new URLSearchParams(submitted.postData() ?? "").get("username")).toBe(
|
||||||
|
user.username,
|
||||||
|
);
|
||||||
|
expect(new URLSearchParams(submitted.postData() ?? "").get("password")).toBe(
|
||||||
|
loginPassword,
|
||||||
|
);
|
||||||
|
expect(submitted.headers()["x-csrf-token"]).toBe("browser-csrf-0");
|
||||||
|
expect(submitted.headers().authorization).toBeUndefined();
|
||||||
|
expect(new URL(submitted.url()).search).toBe("");
|
||||||
|
await expect(page.getByText("880001").first()).toBeVisible();
|
||||||
|
await expectNoStoredCredentials(page);
|
||||||
|
// Login rotates the server CSRF token. Logout must use the new token.
|
||||||
|
const logoutRequest = page.waitForRequest("**/api/logout");
|
||||||
|
await page.getByRole("button", { name: "Abmelden", exact: true }).click();
|
||||||
|
expect((await logoutRequest).headers()["x-csrf-token"]).toBe("browser-csrf-1");
|
||||||
|
await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible();
|
||||||
|
await expectNoStoredCredentials(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("incorrect login remains anonymous without retaining credentials", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockApi(page, {}, { authenticated: false });
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByLabel("VoTiAn Benutzerkonto").fill(user.username);
|
||||||
|
await page.getByLabel("Passwort", { exact: true }).fill("incorrect fixture");
|
||||||
|
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||||
|
await expect(page.getByRole("alert")).toContainText("Anmeldung fehlgeschlagen");
|
||||||
|
await expect(page.getByText("880001")).toHaveCount(0);
|
||||||
|
await expectNoStoredCredentials(page);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a CSRF token changed by another session is refreshed before retrying logout", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const session = await mockApi(page, {}, { authenticated: false });
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByLabel("VoTiAn Benutzerkonto").fill(user.username);
|
||||||
|
await page.getByLabel("Passwort", { exact: true }).fill(loginPassword);
|
||||||
|
const refreshedToken = page.waitForResponse(async (response) =>
|
||||||
|
new URL(response.url()).pathname === "/api/csrf" &&
|
||||||
|
(await response.json()).token === "browser-csrf-1",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||||
|
await refreshedToken;
|
||||||
|
await expect(page.getByText("880001").first()).toBeVisible();
|
||||||
|
session.rotateCsrf();
|
||||||
|
const logoutTokens: string[] = [];
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (new URL(request.url()).pathname === "/api/logout")
|
||||||
|
logoutTokens.push(request.headers()["x-csrf-token"]);
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Abmelden", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible();
|
||||||
|
expect(logoutTokens).toEqual(["browser-csrf-1", "browser-csrf-2"]);
|
||||||
|
await expect(page.getByText("880001")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a non-CSRF logout failure is shown once and leaves the session active", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockApi(page);
|
||||||
|
let logoutAttempts = 0;
|
||||||
|
await page.route("**/api/logout", async (route) => {
|
||||||
|
logoutAttempts += 1;
|
||||||
|
return route.fulfill({
|
||||||
|
status: 403,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ message: "Abmeldung nicht erlaubt." }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByText("880001").first()).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Abmelden", exact: true }).click();
|
||||||
|
await expect(page.getByRole("alert")).toContainText("Abmeldung fehlgeschlagen");
|
||||||
|
expect(logoutAttempts).toBe(1);
|
||||||
|
await expect(page.getByText("880001").first()).toBeVisible();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByText("880001").first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("existing server session is restored on load and reload without browser storage", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockApi(page);
|
||||||
|
const initialSessionCheck = page.waitForResponse("**/api/me");
|
||||||
|
await page.goto("/");
|
||||||
|
expect((await initialSessionCheck).status()).toBe(200);
|
||||||
|
await expect(page.getByText("880001").first()).toBeVisible();
|
||||||
|
await expectNoStoredCredentials(page);
|
||||||
|
const restoredSessionCheck = page.waitForResponse("**/api/me");
|
||||||
|
await page.reload();
|
||||||
|
expect((await restoredSessionCheck).status()).toBe(200);
|
||||||
|
await expect(page.getByText("880001").first()).toBeVisible();
|
||||||
|
await expectNoStoredCredentials(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("existing account opens order list, details and module navigation with read-only capabilities", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await mockApi(page);
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByText("880001").first()).toBeVisible();
|
||||||
|
await expect(page.getByText("Testkunde Nordlicht").first()).toBeVisible();
|
||||||
|
await page.getByText("880001").first().click();
|
||||||
|
await expect(page.getByRole("dialog")).toBeVisible();
|
||||||
|
await expect(page.getByText("Abholung Testlager").first()).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Details schließen", exact: true }).click();
|
||||||
|
for (const name of [
|
||||||
|
"Disposition",
|
||||||
|
"Touren",
|
||||||
|
"Kunden",
|
||||||
|
"Kuriere",
|
||||||
|
"Abrechnung",
|
||||||
|
"Auswertung",
|
||||||
|
]) {
|
||||||
|
await page.getByRole("button", { name, exact: true }).click();
|
||||||
|
await expect(page.locator("main")).toBeVisible();
|
||||||
|
}
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
await page.screenshot({
|
||||||
|
path: "work/test-results/auswertung-testfixture.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing module rights do not trigger forbidden API calls", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const limited = {
|
||||||
|
...capabilities,
|
||||||
|
readCustomers: false,
|
||||||
|
readCouriers: false,
|
||||||
|
};
|
||||||
|
const requested: string[] = [];
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (request.url().includes("/api/"))
|
||||||
|
requested.push(new URL(request.url()).pathname);
|
||||||
|
});
|
||||||
|
await mockApi(page, {
|
||||||
|
"/me": { ...user, capabilities: limited },
|
||||||
|
"/system": { ...system, capabilities: limited },
|
||||||
|
});
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByText("880001").first()).toBeVisible();
|
||||||
|
expect(requested).not.toContain("/api/customers");
|
||||||
|
expect(requested).not.toContain("/api/couriers");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("search sends a server query instead of searching only a limited local list", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockApi(page);
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByText("880001").first()).toBeVisible();
|
||||||
|
const request = page.waitForRequest(
|
||||||
|
(request) =>
|
||||||
|
new URL(request.url()).pathname === "/api/orders" &&
|
||||||
|
new URL(request.url()).searchParams.get("q") === "TEST-REFERENZ",
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByRole("textbox", { name: "Aufträge suchen" })
|
||||||
|
.fill("TEST-REFERENZ");
|
||||||
|
await request;
|
||||||
|
await expect(page.getByText("880001").first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nullable legacy fields do not break order lists or reports", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await mockApi(page, {
|
||||||
|
"/orders": [
|
||||||
|
{
|
||||||
|
...orders[0],
|
||||||
|
pickupAt: null,
|
||||||
|
netPrice: null,
|
||||||
|
courierPrice: null,
|
||||||
|
weight: null,
|
||||||
|
vatRate: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByText("880001").first()).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Auswertung", exact: true }).click();
|
||||||
|
await expect(page.locator("main")).toBeVisible();
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logout invalidates the server session and removes previous account data", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await mockApi(page);
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByText("880001").first()).toBeVisible();
|
||||||
|
const logoutRequest = page.waitForRequest("**/api/logout");
|
||||||
|
await page.getByRole("button", { name: "Abmelden", exact: true }).click();
|
||||||
|
const submitted = await logoutRequest;
|
||||||
|
expect(submitted.method()).toBe("POST");
|
||||||
|
expect(submitted.headers()["x-csrf-token"]).toBe("browser-csrf-0");
|
||||||
|
await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible();
|
||||||
|
await expect(page.getByText("880001")).toHaveCount(0);
|
||||||
|
await expect(page.getByText("Testkunde Nordlicht")).toHaveCount(0);
|
||||||
|
await expectNoStoredCredentials(page);
|
||||||
|
const sessionCheck = page.waitForResponse("**/api/me");
|
||||||
|
await page.reload();
|
||||||
|
expect((await sessionCheck).status()).toBe(401);
|
||||||
|
await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible();
|
||||||
|
await expect(page.getByText("880001")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expired session clears work data and requests a fresh login", async ({ page }) => {
|
||||||
|
const session = await mockApi(page);
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.getByText("880001").first()).toBeVisible();
|
||||||
|
await expect(page.getByText("Testkunde Nordlicht").first()).toBeVisible();
|
||||||
|
session.expire();
|
||||||
|
await page.getByRole("button", { name: "Aktualisieren", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: /anmelden/i })).toBeVisible();
|
||||||
|
await expect(page.getByText(/Sitzung ist abgelaufen/)).toBeVisible();
|
||||||
|
await expect(page.getByText("880001")).toHaveCount(0);
|
||||||
|
await expect(page.getByText("Testkunde Nordlicht")).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||||
|
await expectNoStoredCredentials(page);
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import contextlib
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts/run-backend.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("backend_config", MODULE_PATH)
|
||||||
|
CONFIG = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
class BackendConfigurationTest(unittest.TestCase):
|
||||||
|
def test_env_file_works_without_php_or_connection_json(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / ".env").write_text("DB_URL=jdbc:mariadb://test.invalid/db\nDB_USER=fixture\nDB_PASSWORD=literal$#=secret\\end\n")
|
||||||
|
result = CONFIG.database_environment(root, {})
|
||||||
|
self.assertEqual(result["DB_PASSWORD"], "literal$#=secret\\end")
|
||||||
|
self.assertEqual(result["DB_USER"], "fixture")
|
||||||
|
|
||||||
|
def test_process_environment_overrides_env_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / ".env").write_text("DB_URL=jdbc:mariadb://test.invalid/db\nDB_USER=fixture\nDB_PASSWORD=local\n")
|
||||||
|
result = CONFIG.database_environment(root, {"DB_USER": "override", "DB_PASSWORD": ""})
|
||||||
|
self.assertEqual(result["DB_USER"], "override")
|
||||||
|
self.assertEqual(result["DB_PASSWORD"], "")
|
||||||
|
|
||||||
|
def test_explicit_environment_needs_no_file(self):
|
||||||
|
expected = {"DB_URL": "jdbc:mariadb://test.invalid/db", "DB_USER": "fixture", "DB_PASSWORD": "secret"}
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
self.assertEqual(CONFIG.database_environment(Path(directory), expected), expected)
|
||||||
|
|
||||||
|
def test_missing_credentials_do_not_fall_back_to_legacy_files(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
CONFIG.database_environment(Path(directory), {"DB_URL": "jdbc:mariadb://test.invalid/db"})
|
||||||
|
|
||||||
|
def test_jar_version_is_not_hardcoded_and_original_is_ignored(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
target = root / "backend/target"
|
||||||
|
target.mkdir(parents=True)
|
||||||
|
jar = target / "votian-backend-0.1.jar"
|
||||||
|
jar.touch()
|
||||||
|
(target / "votian-backend-0.1.jar.original").touch()
|
||||||
|
self.assertEqual(CONFIG.backend_jar(root), jar)
|
||||||
|
(target / "votian-backend-1.0.0-SNAPSHOT.jar").touch()
|
||||||
|
with self.assertRaises(FileNotFoundError):
|
||||||
|
CONFIG.backend_jar(root)
|
||||||
|
|
||||||
|
def test_check_config_does_not_find_jar_or_launch_java(self):
|
||||||
|
environment = {"DB_URL": "jdbc:mariadb://test.invalid/db", "DB_USER": "fixture", "DB_PASSWORD": "secret"}
|
||||||
|
with patch.object(CONFIG.sys, "argv", ["run-backend.py", "--check-config"]), \
|
||||||
|
patch.object(CONFIG, "database_environment", return_value=environment), \
|
||||||
|
patch.object(CONFIG, "backend_jar") as jar, \
|
||||||
|
patch.object(CONFIG.os, "execvpe") as execute, contextlib.redirect_stdout(io.StringIO()):
|
||||||
|
self.assertEqual(CONFIG.main(), 0)
|
||||||
|
jar.assert_not_called()
|
||||||
|
execute.assert_not_called()
|
||||||
|
|
||||||
|
def test_launch_uses_env_port_and_keeps_secrets_out_of_arguments(self):
|
||||||
|
for arguments, expected_port in (([], "18081"), (["--port", "18082"], "18082")):
|
||||||
|
environment = {"DB_URL": "jdbc:mariadb://test.invalid/db", "DB_USER": "fixture", "DB_PASSWORD": "secret", "PORT": "18081"}
|
||||||
|
with self.subTest(arguments=arguments), \
|
||||||
|
patch.object(CONFIG.sys, "argv", ["run-backend.py", *arguments]), \
|
||||||
|
patch.object(CONFIG, "database_environment", return_value=environment), \
|
||||||
|
patch.object(CONFIG, "backend_jar", return_value=Path("/fixture/backend.jar")), \
|
||||||
|
patch.object(CONFIG.os, "chdir"), patch.object(CONFIG.os, "execvpe") as execute:
|
||||||
|
self.assertEqual(CONFIG.main(), 0)
|
||||||
|
command, actual_arguments, actual_environment = execute.call_args.args
|
||||||
|
self.assertEqual(command, "java")
|
||||||
|
self.assertEqual(actual_arguments, ["java", "-jar", "/fixture/backend.jar"])
|
||||||
|
self.assertEqual(actual_environment["PORT"], expected_port)
|
||||||
|
self.assertEqual(actual_environment["DB_PASSWORD"], "secret")
|
||||||
|
|
||||||
|
def test_configuration_error_never_prints_secret(self):
|
||||||
|
output = io.StringIO()
|
||||||
|
with patch.object(CONFIG.sys, "argv", ["run-backend.py", "--check-config"]), \
|
||||||
|
patch.object(CONFIG, "database_environment", side_effect=ValueError("secret=do-not-expose")), \
|
||||||
|
patch.object(CONFIG.os, "execvpe") as execute, contextlib.redirect_stderr(output):
|
||||||
|
self.assertEqual(CONFIG.main(), 2)
|
||||||
|
execute.assert_not_called()
|
||||||
|
self.assertNotIn("do-not-expose", output.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import contextlib
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts/check-database.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("database_check", MODULE_PATH)
|
||||||
|
CHECK = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(CHECK)
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseCheckLauncherTest(unittest.TestCase):
|
||||||
|
def test_delegates_to_java_with_credentials_only_in_environment(self):
|
||||||
|
environment = {"DB_URL": "jdbc:mariadb://test.invalid/db", "DB_USER": "fixture", "DB_PASSWORD": "test-secret"}
|
||||||
|
with patch.object(CHECK.config, "backend_jar", return_value=Path("/fixture/backend.jar")), \
|
||||||
|
patch.object(CHECK.config, "database_environment", return_value=environment), \
|
||||||
|
patch.object(CHECK.os, "chdir"), patch.object(CHECK.os, "execvpe") as execute:
|
||||||
|
self.assertEqual(CHECK.main(), 0)
|
||||||
|
|
||||||
|
command, arguments, actual_environment = execute.call_args.args
|
||||||
|
self.assertEqual(command, "java")
|
||||||
|
self.assertEqual(arguments[-1], "--database-check")
|
||||||
|
self.assertNotIn("test-secret", " ".join(arguments))
|
||||||
|
self.assertNotIn("test.invalid", " ".join(arguments))
|
||||||
|
self.assertEqual(actual_environment, environment)
|
||||||
|
|
||||||
|
def test_missing_jar_never_loads_credentials_or_starts_java(self):
|
||||||
|
output = io.StringIO()
|
||||||
|
with patch.object(CHECK.config, "backend_jar", side_effect=FileNotFoundError), \
|
||||||
|
patch.object(CHECK.config, "database_environment") as load, \
|
||||||
|
patch.object(CHECK.os, "execvpe") as execute, contextlib.redirect_stdout(output):
|
||||||
|
self.assertEqual(CHECK.main(), 2)
|
||||||
|
load.assert_not_called()
|
||||||
|
execute.assert_not_called()
|
||||||
|
self.assertFalse(json.loads(output.getvalue())["connected"])
|
||||||
|
|
||||||
|
def test_configuration_error_is_sanitized_json(self):
|
||||||
|
output = io.StringIO()
|
||||||
|
with patch.object(CHECK.config, "backend_jar", return_value=Path("/fixture/backend.jar")), \
|
||||||
|
patch.object(CHECK.config, "database_environment", side_effect=ValueError("secret=do-not-expose")), \
|
||||||
|
patch.object(CHECK.os, "execvpe") as execute, contextlib.redirect_stdout(output):
|
||||||
|
self.assertEqual(CHECK.main(), 2)
|
||||||
|
execute.assert_not_called()
|
||||||
|
self.assertNotIn("do-not-expose", output.getvalue())
|
||||||
|
self.assertEqual(json.loads(output.getvalue())["writes"], 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_PATH = Path(__file__).resolve().parents[1] / "scripts" / "env_config.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("env_config", MODULE_PATH)
|
||||||
|
env_config = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(env_config)
|
||||||
|
|
||||||
|
|
||||||
|
class EnvironmentConfigTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.directory.cleanup)
|
||||||
|
self.path = Path(self.directory.name) / ".env"
|
||||||
|
|
||||||
|
def load(self, contents, current=None):
|
||||||
|
self.path.write_text(contents, encoding="utf-8")
|
||||||
|
return env_config.load_environment(self.path, current or {})
|
||||||
|
|
||||||
|
def test_current_environment_wins_including_empty_values(self):
|
||||||
|
current = {"VALUE": "from-environment", "EMPTY": "", "EXTRA": "kept"}
|
||||||
|
result = self.load("VALUE=from-file\nEMPTY=from-file\nFILE_ONLY=yes", current)
|
||||||
|
self.assertEqual(
|
||||||
|
result,
|
||||||
|
{"VALUE": "from-environment", "EMPTY": "", "EXTRA": "kept", "FILE_ONLY": "yes"},
|
||||||
|
)
|
||||||
|
self.assertEqual(current, {"VALUE": "from-environment", "EMPTY": "", "EXTRA": "kept"})
|
||||||
|
self.assertIsNot(result, current)
|
||||||
|
|
||||||
|
def test_missing_file_returns_a_copy_of_current_environment(self):
|
||||||
|
current = {"VALUE": "present", "EMPTY": ""}
|
||||||
|
result = env_config.load_environment(self.path, current)
|
||||||
|
self.assertEqual(result, current)
|
||||||
|
self.assertIsNot(result, current)
|
||||||
|
|
||||||
|
def test_blank_lines_comments_export_and_quotes(self):
|
||||||
|
result = self.load(
|
||||||
|
"\n \t\n # comment\n FIRST=one\n\texport SECOND='two words'\n"
|
||||||
|
'THIRD="three words" \nEMPTY=\nEMPTY_QUOTED=\'\'\n'
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
result,
|
||||||
|
{"FIRST": "one", "SECOND": "two words", "THIRD": "three words", "EMPTY": "", "EMPTY_QUOTED": ""},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_special_characters_remain_literal(self):
|
||||||
|
value = r"$NAME:${NAME}:#part=a=b:C:\folder\file\n"
|
||||||
|
self.assertEqual(self.load(f"VALUE={value}\n"), {"VALUE": value})
|
||||||
|
self.assertEqual(self.load(f"VALUE='{value}'\n"), {"VALUE": value})
|
||||||
|
self.assertEqual(self.load(f'VALUE="{value}"\n'), {"VALUE": value})
|
||||||
|
|
||||||
|
def test_unquoted_whitespace_and_inline_hash_are_preserved(self):
|
||||||
|
value = " value # still part of the value \t "
|
||||||
|
self.assertEqual(self.load(f"VALUE={value}\n"), {"VALUE": value})
|
||||||
|
|
||||||
|
def test_commands_are_not_executed(self):
|
||||||
|
marker = Path(self.directory.name) / "should-not-exist"
|
||||||
|
value = f"$(touch {marker}) `touch {marker}` $HOME ${{HOME}}"
|
||||||
|
self.assertEqual(self.load(f"VALUE={value}\n"), {"VALUE": value})
|
||||||
|
self.assertFalse(marker.exists())
|
||||||
|
|
||||||
|
def test_last_file_assignment_wins(self):
|
||||||
|
self.assertEqual(self.load("VALUE=first\nVALUE=last\n"), {"VALUE": "last"})
|
||||||
|
|
||||||
|
def test_crlf_line_endings(self):
|
||||||
|
self.path.write_bytes(b"FIRST=one\r\nSECOND=two\r\n")
|
||||||
|
self.assertEqual(env_config.load_environment(self.path, {}), {"FIRST": "one", "SECOND": "two"})
|
||||||
|
|
||||||
|
def test_invalid_syntax_only_reports_line_number(self):
|
||||||
|
for invalid in (
|
||||||
|
"secret-without-an-assignment",
|
||||||
|
"1INVALID=private-value",
|
||||||
|
"INVALID-KEY=private-value",
|
||||||
|
"INVALID KEY=private-value",
|
||||||
|
"=private-value",
|
||||||
|
"export VALUE",
|
||||||
|
"VALUE='private-value",
|
||||||
|
'VALUE="private-value',
|
||||||
|
"VALUE='",
|
||||||
|
'VALUE="',
|
||||||
|
"VALUE='private-value' trailing-text",
|
||||||
|
"VALUE=private\x00value",
|
||||||
|
"# private\x00value",
|
||||||
|
"VALUE=private\rvalue",
|
||||||
|
):
|
||||||
|
with self.subTest(invalid=invalid):
|
||||||
|
with self.assertRaises(ValueError) as caught:
|
||||||
|
self.load(f"VALID=ok\n{invalid}\n")
|
||||||
|
self.assertEqual(str(caught.exception), "Invalid .env syntax at line 2")
|
||||||
|
|
||||||
|
def test_invalid_encoding_only_reports_line_number(self):
|
||||||
|
self.path.write_bytes(b"VALID=ok\nVALUE=private\xffvalue\n")
|
||||||
|
with self.assertRaises(ValueError) as caught:
|
||||||
|
env_config.load_environment(self.path, {})
|
||||||
|
self.assertEqual(str(caught.exception), "Invalid .env syntax at line 2")
|
||||||
|
self.assertTrue(caught.exception.__suppress_context__)
|
||||||
|
|
||||||
|
def test_invalid_file_is_validated_even_when_current_has_the_key(self):
|
||||||
|
with self.assertRaisesRegex(ValueError, r"^Invalid \.env syntax at line 1$"):
|
||||||
|
self.load("VALUE='unterminated", {"VALUE": "current"})
|
||||||
|
|
||||||
|
|
||||||
|
class EnvironmentCommandTests(unittest.TestCase):
|
||||||
|
def test_command_executes_with_environment_and_literal_arguments(self):
|
||||||
|
current = {"EXISTING": "present", "EMPTY": ""}
|
||||||
|
environment = {**current, "FROM_FILE": "loaded"}
|
||||||
|
command = ["./mvnw", "test", "literal $HOME ; $(command) `command`"]
|
||||||
|
original_directory = Path.cwd()
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, current, clear=True),
|
||||||
|
patch.object(env_config, "load_environment", return_value=environment) as load,
|
||||||
|
patch.object(env_config.os, "execvpe") as execute,
|
||||||
|
):
|
||||||
|
self.assertEqual(env_config.main(command), 0)
|
||||||
|
load.assert_called_once_with(MODULE_PATH.parents[1] / ".env", current)
|
||||||
|
execute.assert_called_once_with(command[0], command, environment)
|
||||||
|
self.assertEqual(Path.cwd(), original_directory)
|
||||||
|
|
||||||
|
def test_help_and_missing_command_do_not_load_or_execute(self):
|
||||||
|
for arguments, status, stream in (([], 2, "stderr"), (["--help"], 0, "stdout"), (["-h"], 0, "stdout")):
|
||||||
|
with (
|
||||||
|
self.subTest(arguments=arguments),
|
||||||
|
patch.object(env_config, "load_environment") as load,
|
||||||
|
patch.object(env_config.os, "execvpe") as execute,
|
||||||
|
patch.object(env_config.sys, stream, new_callable=io.StringIO) as output,
|
||||||
|
):
|
||||||
|
self.assertEqual(env_config.main(arguments), status)
|
||||||
|
self.assertIn("COMMAND [ARGS...]", output.getvalue())
|
||||||
|
load.assert_not_called()
|
||||||
|
execute.assert_not_called()
|
||||||
|
|
||||||
|
def test_invalid_file_reports_only_safe_line_number(self):
|
||||||
|
with (
|
||||||
|
patch.object(env_config, "load_environment", side_effect=ValueError("Invalid .env syntax at line 7")),
|
||||||
|
patch.object(env_config.os, "execvpe") as execute,
|
||||||
|
patch.object(env_config.sys, "stderr", new_callable=io.StringIO) as output,
|
||||||
|
):
|
||||||
|
self.assertEqual(env_config.main(["command"]), 1)
|
||||||
|
self.assertEqual(output.getvalue(), "Invalid .env syntax at line 7\n")
|
||||||
|
execute.assert_not_called()
|
||||||
|
|
||||||
|
def test_unexpected_value_error_is_sanitized(self):
|
||||||
|
with (
|
||||||
|
patch.object(env_config, "load_environment", side_effect=ValueError("private-value")),
|
||||||
|
patch.object(env_config.sys, "stderr", new_callable=io.StringIO) as output,
|
||||||
|
):
|
||||||
|
self.assertEqual(env_config.main(["command"]), 1)
|
||||||
|
self.assertEqual(output.getvalue(), "Invalid .env syntax.\n")
|
||||||
|
|
||||||
|
def test_read_error_is_sanitized(self):
|
||||||
|
with (
|
||||||
|
patch.object(env_config, "load_environment", side_effect=OSError("private-value")),
|
||||||
|
patch.object(env_config.os, "execvpe") as execute,
|
||||||
|
patch.object(env_config.sys, "stderr", new_callable=io.StringIO) as output,
|
||||||
|
):
|
||||||
|
self.assertEqual(env_config.main(["command"]), 1)
|
||||||
|
self.assertEqual(output.getvalue(), "Unable to read .env.\n")
|
||||||
|
execute.assert_not_called()
|
||||||
|
|
||||||
|
def test_execution_error_is_sanitized(self):
|
||||||
|
with (
|
||||||
|
patch.object(env_config, "load_environment", return_value={}),
|
||||||
|
patch.object(env_config.os, "execvpe", side_effect=OSError("private-value")),
|
||||||
|
patch.object(env_config.sys, "stderr", new_callable=io.StringIO) as output,
|
||||||
|
):
|
||||||
|
self.assertEqual(env_config.main(["private-command", "private-value"]), 1)
|
||||||
|
self.assertEqual(output.getvalue(), "Unable to start command.\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user