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

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

No files matched your search

@@ -0,0 +1,72 @@
package de.stadtbote.votian;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.*;
import static de.stadtbote.votian.Models.*;
@RestController
@RequestMapping("/api")
public class ApiController {
private final LegacyReadService legacy;
private final String legacyUrl;
ApiController(LegacyReadService legacy,@Value("${app.legacy-url:}") String legacyUrl) { this.legacy=legacy;this.legacyUrl=safeLegacyUrl(legacyUrl); }
static String safeLegacyUrl(String raw) {
if(raw==null || raw.isBlank())return null;
URI uri=URI.create(raw.trim());
if(!Set.of("http","https").contains(uri.getScheme()) || uri.getHost()==null || uri.getUserInfo()!=null || uri.getQuery()!=null || uri.getFragment()!=null) throw new IllegalArgumentException("LEGACY_APP_URL muss eine HTTP(S)-Basisadresse ohne Zugangsdaten, Query oder Fragment sein.");
return raw.trim().replaceAll("/+$","");
}
static LegacyUser user(Authentication authentication) {
if(authentication==null || !(authentication.getPrincipal() instanceof LegacyUser u))throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"Bitte anmelden.");
return u;
}
@GetMapping("/health") public Map<String,String> health() { return Map.of("status","UP"); }
@GetMapping("/csrf") public Map<String,String> csrf(CsrfToken csrf) {
return Map.of("token",csrf.getToken(),"headerName",csrf.getHeaderName(),"parameterName",csrf.getParameterName());
}
@GetMapping("/system") public Map<String,Object> system(Authentication authentication) {
var connection=legacy.connectionCheck();
boolean connected=connection.connected();
LegacyUser u=authentication!=null&&authentication.getPrincipal() instanceof LegacyUser lu?lu:null;
Map<String,Object> result=new LinkedHashMap<>();
result.put("connected",connected);result.put("database","legacy-mysql");result.put("readOnly",true);result.put("schemaChanges",false);
result.put("message",connection.message());result.put("reason",connection.reason());
result.put("legacyUrl",legacyUrl);result.put("maxResults",legacy.maxResults());
result.put("capabilities",u==null?new Capabilities(false,false,false,false,false,false,false,false,false,false):u.capabilities());
result.put("unavailableModules",List.of("Rechnungserstellung und Zahlungsstatus","Änderungen an Aufträgen und Stammdaten","Historie aus separater Log-Datenbank","Archivtabellen"));
return result;
}
@GetMapping("/me") public Map<String,Object> me(Authentication authentication) {
return userDetails(authentication);
}
static Map<String,Object> userDetails(Authentication authentication) {
LegacyUser u=user(authentication);return Map.of("username",u.username(),"roles",List.of("VIEWER"),"headquartersId",u.headquartersId(),"userId",u.userId(),"capabilities",u.capabilities());
}
@GetMapping("/orders") public ResponseEntity<List<DeliveryOrder>> orders(Authentication auth,@RequestParam(required=false) String q,@RequestParam(required=false) OrderStatus status,@RequestParam(required=false) Long customerId,@RequestParam(required=false) Long courierId,@RequestParam(required=false) LocalDate from,@RequestParam(required=false) LocalDate to) {
var result=legacy.orders(user(auth),q,status,customerId,courierId,from,to);
return ResponseEntity.ok().header("X-Total-Count",Long.toString(result.total())).header("X-Result-Limit",Integer.toString(result.limit())).body(result.orders());
}
@GetMapping("/orders/{id}") public DeliveryOrder order(Authentication auth,@PathVariable long id) { return legacy.order(user(auth),id); }
@GetMapping("/customers") public List<Customer> customers(Authentication auth,@RequestParam(required=false) String q) { return legacy.customers(user(auth),q); }
@GetMapping("/couriers") public List<Courier> couriers(Authentication auth,@RequestParam(required=false) String q) { return legacy.couriers(user(auth),q); }
@GetMapping("/orders/export.csv") public ResponseEntity<byte[]> csv(Authentication auth,@RequestParam(required=false) String q,@RequestParam(required=false) OrderStatus status,@RequestParam(required=false) Long customerId,@RequestParam(required=false) Long courierId,@RequestParam(required=false) LocalDate from,@RequestParam(required=false) LocalDate to) {
LegacyUser u=user(auth);u.require(5);
var result=legacy.orders(u,q,status,customerId,courierId,from,to);
if(result.total()>result.limit())throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Der Export umfasst mehr als "+result.limit()+" Aufträge. Bitte den Zeitraum oder die Filter eingrenzen; ein unvollständiger Export wird nicht erstellt.");
StringBuilder csv=new StringBuilder("\uFEFFAuftrag;Status;Kunde-ID;Kurier-ID;Abholung;Referenz;Netto;Kurierpreis;MwSt\n");
for(DeliveryOrder o:result.orders())csv.append(csvCell(o.number)).append(';').append(csvCell(o.legacyStatusLabel)).append(';').append(csvCell(o.customerId)).append(';').append(csvCell(o.courierId)).append(';').append(csvCell(o.pickupAt)).append(';').append(csvCell(o.reference)).append(';').append(csvCell(o.netPrice)).append(';').append(csvCell(o.courierPrice)).append(';').append(csvCell(o.vatRate)).append('\n');
return ResponseEntity.ok().contentType(new MediaType("text","csv",StandardCharsets.UTF_8)).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=stadtbote-auftraege.csv").body(csv.toString().getBytes(StandardCharsets.UTF_8));
}
static String csvCell(Object value) { String s=value==null?"":value.toString();String trimmed=s.stripLeading();if(!trimmed.isEmpty()&&"=+-@".indexOf(trimmed.charAt(0))>=0)s="'"+s;return '"'+s.replace("\"","\"\"")+'"'; }
@GetMapping("/invoices") public Object invoices(Authentication auth) { user(auth).require(5);throw unsupported(); }
@RequestMapping(value="/**",method={RequestMethod.POST,RequestMethod.PUT,RequestMethod.PATCH,RequestMethod.DELETE}) public Object mutation() { throw unsupported(); }
static ResponseStatusException unsupported() { return new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED,"Dieser Schreib-/Abrechnungsworkflow ist noch nicht auf die vorhandene VoTiAn-Datenbank portiert. Bitte das bestehende VoTiAn verwenden. Es werden keine Ersatzdatensätze oder Tabellen angelegt."); }
}
@@ -0,0 +1,30 @@
package de.stadtbote.votian;
import org.springframework.dao.DataAccessException;
import de.stadtbote.votian.database.DatabaseService;
import de.stadtbote.votian.database.DatabaseWritesDisabledException;
import org.springframework.http.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.util.Map;
@RestControllerAdvice
public class ApiErrors {
static final String DATABASE_UNAVAILABLE=DatabaseService.DATABASE_UNAVAILABLE;
static Map<String,Object> body(String message) { return Map.of("message",message,"timestamp",Instant.now().toString()); }
@ExceptionHandler(DataAccessException.class)
ResponseEntity<?> database(DataAccessException exception) { return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(body(DATABASE_UNAVAILABLE)); }
@ExceptionHandler(DatabaseWritesDisabledException.class)
ResponseEntity<?> writesDisabled(DatabaseWritesDisabledException exception) { return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(body(exception.getMessage())); }
@ExceptionHandler(AccessDeniedException.class)
ResponseEntity<?> denied(AccessDeniedException exception) { return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body(exception.getMessage())); }
@ExceptionHandler(ResponseStatusException.class)
ResponseEntity<?> status(ResponseStatusException exception) { return ResponseEntity.status(exception.getStatusCode()).body(body(exception.getReason()==null?"Die Anfrage konnte nicht verarbeitet werden.":exception.getReason())); }
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
ResponseEntity<?> argument(MethodArgumentTypeMismatchException exception) { return ResponseEntity.badRequest().body(body("Ungültiger Parameter: "+exception.getName())); }
@ExceptionHandler(Exception.class)
ResponseEntity<?> unknown(Exception exception) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body("Die Anfrage konnte nicht verarbeitet werden. Bitte die Konfiguration des Legacy-Adapters prüfen.")); }
}
@@ -0,0 +1,39 @@
package de.stadtbote.votian;
import org.springframework.dao.DataAccessException;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.security.authentication.*;
import org.springframework.security.core.*;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;
import java.util.*;
@Component
public class LegacyAuthenticationProvider implements AuthenticationProvider {
private final DatabaseService database;
static final String AUTH_SQL = """
SELECT u.usr_id, u.hq_id, u.usr_account, e.emp_id, e.emp_rights
FROM user u JOIN employee e ON e.usr_id = u.usr_id
JOIN headquarters h ON h.hq_id = u.hq_id
WHERE u.usr_account = ? AND u.usr_type = 1 AND u.hq_id > 0
AND EXISTS (SELECT 1 FROM mandatorheadquarters mh WHERE mh.hq_id=u.hq_id AND mh.md_id>0)
AND (u.usr_password = PASSWORD(?) OR u.usr_password_old = OLD_PASSWORD(?))
""";
public LegacyAuthenticationProvider(DatabaseService database) { this.database = database; }
@Override public Authentication authenticate(Authentication authentication) {
String username = authentication.getName();
String password = String.valueOf(authentication.getCredentials());
if (username.isBlank() || password.isBlank() || username.length()>200 || password.length()>1024) throw new BadCredentialsException("Benutzername oder Passwort ist falsch.");
try {
List<Map<String,Object>> rows = database.queryForList(AUTH_SQL,username,password,password);
if (rows.size() != 1) throw new BadCredentialsException("Benutzername oder Passwort ist falsch, oder das Konto hat keinen Zentralenzugang.");
Map<String,Object> row = rows.getFirst();
LegacyUser user = new LegacyUser(LegacyMapping.id(row,"usr_id"),LegacyMapping.id(row,"emp_id"),LegacyMapping.id(row,"hq_id"),LegacyMapping.text(row,"usr_account"),LegacyMapping.text(row,"emp_rights"));
if (user.headquartersId() <= 0 || user.employeeId() <= 0) throw new BadCredentialsException("Dem Konto fehlt eine eindeutige Niederlassung oder Mitarbeiterzuordnung.");
return UsernamePasswordAuthenticationToken.authenticated(user,null,List.of(new SimpleGrantedAuthority("ROLE_VIEWER")));
} catch (DataAccessException ex) {
throw new AuthenticationServiceException(ApiErrors.DATABASE_UNAVAILABLE);
}
}
@Override public boolean supports(Class<?> authentication) { return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); }
}
@@ -0,0 +1,75 @@
package de.stadtbote.votian;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import static de.stadtbote.votian.Models.*;
/** Mappings traced to include/mcglobal.inc.php and the corresponding PHP job/customer/courier modules. */
final class LegacyMapping {
private LegacyMapping() {}
static Object value(Map<String,Object> row,String key) {
Object result = row.get(key);
if (result == null) result = row.get(key.toUpperCase(java.util.Locale.ROOT));
return result;
}
static String text(Map<String,Object> row,String key) { Object v=value(row,key); return v==null ? "" : v.toString(); }
static long id(Map<String,Object> row,String key) {
Object v=value(row,key);
if(v==null || v.toString().isBlank()) return 0;
// JDBC may return Boolean for legacy TINYINT(1) flags, such as cmp_archived.
if(v instanceof Boolean flag) return flag ? 1 : 0;
return v instanceof Number n ? n.longValue() : Long.parseLong(v.toString());
}
static Long nullableId(Map<String,Object> row,String key) { long n=id(row,key); return n>0?n:null; }
static BigDecimal decimal(Map<String,Object> row,String key) { Object v=value(row,key); if(v==null || v.toString().isBlank()) return null; return v instanceof BigDecimal d ? d : new BigDecimal(v.toString()); }
static LocalDateTime dateTime(Map<String,Object> row,String key) {
Object v=value(row,key); if(v==null) return null;
if(v instanceof Timestamp t) return t.toLocalDateTime();
if(v instanceof LocalDateTime t) return t;
String s=v.toString(); if(s.isBlank() || s.startsWith("0000-")) return null;
if(s.matches("\\d{14}")) return LocalDateTime.parse(s,DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
return LocalDateTime.parse(s.replace(' ','T'));
}
static String join(String a,String b) { return (a+" "+b).trim(); }
static Customer customer(Map<String,Object> row) {
Customer c=new Customer();c.id=id(row,"cs_id");c.number=text(row,"cs_eid");c.name=text(row,"cmp_comp");
c.street=join(text(row,"ad_street"),text(row,"cmp_hsno"));c.postalCode=text(row,"ad_zipcode");c.city=text(row,"ad_city");
c.contact=join(text(row,"usr_firstname"),text(row,"usr_name"));c.phone=text(row,"usr_phone");c.email=text(row,"usr_email");
String days=text(row,"cs_payment_term");c.paymentDays=days.matches("\\d+")?Integer.valueOf(days):null;
c.active=id(row,"cmp_archived")==0 && id(row,"cmp_visible")==1 && id(row,"cmp_authenticated")==1;
return c;
}
static Courier courier(Map<String,Object> row) {
Courier c=new Courier();c.id=id(row,"cr_id");c.number=text(row,"cr_eid");c.name=text(row,"cmp_comp");
if(c.name.isBlank()) c.name=join(text(row,"usr_firstname"),text(row,"usr_name"));
c.phone=text(row,"usr_phone");c.vehicle=text(row,"vehicle_label");c.plate=text(row,"crvh_vh_sign");
c.active=id(row,"cmp_archived")==0 && id(row,"cmp_visible")==1 && id(row,"cmp_authenticated")==1;
c.status=!c.active?CourierStatus.OFFLINE:(id(row,"active_jobs")>0?CourierStatus.BUSY:(id(row,"cr_available")==1?CourierStatus.AVAILABLE:CourierStatus.OFFLINE));
return c;
}
static DeliveryOrder order(Map<String,Object> row) {
DeliveryOrder o=new DeliveryOrder();o.id=id(row,"jb_id");o.number=Long.toString(o.id);o.version=id(row,"jb_version");
o.customerId=nullableId(row,"app_customer_id");o.payerCustomerId=nullableId(row,"app_payer_customer_id");o.courierId=nullableId(row,"cr_id");
o.legacyStatus=(int)id(row,"jb_status");int storno=(int)id(row,"jb_storno");o.legacyStorno=storno;o.isCreditNote=storno==3||storno==4;
o.cancelMode=storno==1 || storno==3?CancelMode.CHARGEABLE:storno==2 || storno==4?CancelMode.FREE:null;
o.status=o.cancelMode!=null?OrderStatus.CANCELLED:switch(o.legacyStatus){case 0->OrderStatus.OFFERED;case 1->OrderStatus.IN_PROGRESS;case 2->OrderStatus.COMPLETED;default->OrderStatus.DISPOSITION;};
o.legacyStatusLabel=switch(o.legacyStatus){case 9->"Disposition";case 8->"Autoranking";case 0->"Offen / angeboten";case 1->"Laufend";case 2->"Erledigt";default->"Legacy-Status "+o.legacyStatus;};
if(o.cancelMode!=null) o.legacyStatusLabel=switch(storno){case 1->"Storniert · kostenpflichtig";case 2->"Storniert · kostenfrei";case 3->"Storno-Gutschrift · kostenpflichtiges Storno";case 4->"Storno-Gutschrift · kostenfreies Storno";default->"Storno";};
o.legacyService=id(row,"jb_service");o.service=text(row,"service_label");o.vehicle=text(row,"vehicle_label");
o.pickupAt=dateTime(row,"jb_ordertime");o.reference=text(row,"jb_commission_no");o.costCenter=text(row,"app_cost_center");o.notes=text(row,"jb_dispoinfo");
o.weight=decimal(row,"jb_weight");o.netPrice=decimal(row,"jb_totalprice");o.courierPrice=decimal(row,"jb_cr_price");o.vatRate=decimal(row,"jb_sales_tax_rate");
o.createdAt=dateTime(row,"jb_createtime");o.updatedAt=dateTime(row,"jb_modify");o.deliveredAt=dateTime(row,"jb_finishtime");
return o;
}
static Stop stop(Map<String,Object> row) {
Stop s=new Stop();s.id=id(row,"tr_id");s.sequence=(int)id(row,"tr_sort");
// Existing VoTiAn tour field is preserved through the explicit warehouse direction mapping.
s.type=switch((int)id(row,"tr_ware_from_to")){case 1->StopType.PICKUP;case 2->StopType.DELIVERY;case 3->StopType.BOTH;default->s.sequence==1?StopType.PICKUP:StopType.DELIVERY;};
s.name=join(text(row,"tr_comp"),text(row,"tr_comp2"));s.street=join(text(row,"ad_street"),text(row,"tr_hsno"));
s.postalCode=text(row,"ad_zipcode");s.city=text(row,"ad_city");s.contact=text(row,"tr_person");s.phone=text(row,"tr_phone");s.note=text(row,"tr_remark");
s.completedAt=id(row,"tr_status")==1?dateTime(row,"tr_finishtime"):null;s.recipientName=text(row,"tr_signname");return s;
}
}
@@ -0,0 +1,104 @@
package de.stadtbote.votian;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.*;
import static de.stadtbote.votian.Models.*;
/** SELECT-only adapter. Never writes data or manages the legacy schema. */
@Service
public class LegacyReadService {
private final DatabaseService database;
private final int maxResults;
public LegacyReadService(DatabaseService database,@Value("${app.max-results:500}") int maxResults) { this.database=database;this.maxResults=Math.max(1,Math.min(maxResults,5000)); }
static final String ORDER_FROM="""
FROM job jb LEFT JOIN costcenter cc ON cc.csc_id = jb.csc_id
LEFT JOIN costcenter payer ON payer.csc_id=jb.csc_id_payer
LEFT JOIN customer cs ON cs.cs_id = cc.cs_id
LEFT JOIN company cmp ON cmp.cmp_id = cs.cmp_id
""";
static final String ORDER_SELECT="""
SELECT jb.*, cc.cs_id AS app_customer_id, payer.cs_id AS app_payer_customer_id, cc.csc_name AS app_cost_center,
(SELECT MIN(mt.mt_value) FROM metatype mt WHERE mt.mt_type='vehicletype' AND mt.mt_sort=jb.vht_id) AS vehicle_label,
(SELECT GROUP_CONCAT(DISTINCT mt.mt_value ORDER BY mt.mt_sort SEPARATOR ', ') FROM metatype mt WHERE mt.mt_type='service' AND (jb.jb_service & (1 << mt.mt_sort)) <> 0) AS service_label
""";
public record OrderResult(List<DeliveryOrder> orders,long total,int limit) {}
public OrderResult orders(LegacyUser user,String q,OrderStatus status,Long customerId,Long courierId,LocalDate from,LocalDate to) {
user.require(4);
if(from!=null && to!=null && from.isAfter(to)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Das Startdatum liegt nach dem Enddatum.");
if(q!=null && !q.isBlank() && q.length()>200) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"Suchtext ist zu lang.");
return database.readTransaction(() -> readOrders(user,q,status,customerId,courierId,from,to));
}
private OrderResult readOrders(LegacyUser user,String q,OrderStatus status,Long customerId,Long courierId,LocalDate from,LocalDate to) {
StringBuilder where=new StringBuilder(" WHERE jb.hq_id = ? ");List<Object> parameters=new ArrayList<>(List.of(user.headquartersId()));
if(from!=null){where.append(" AND jb.jb_ordertime >= ?");parameters.add(from.atStartOfDay());}
if(to!=null){where.append(" AND jb.jb_ordertime < ?");parameters.add(to.plusDays(1).atStartOfDay());}
if(customerId!=null){where.append(" AND cc.cs_id = ?");parameters.add(customerId);}
if(courierId!=null){where.append(" AND jb.cr_id = ?");parameters.add(courierId);}
if(status!=null) {
if(status==OrderStatus.CANCELLED) where.append(" AND jb.jb_storno IN (1,2,3,4)");
else {
where.append(" AND COALESCE(jb.jb_storno,0) = 0");
switch(status) {case DISPOSITION->where.append(" AND jb.jb_status IN (8,9,99)");case OFFERED->where.append(" AND jb.jb_status=0");case IN_PROGRESS->where.append(" AND jb.jb_status=1");case COMPLETED->where.append(" AND jb.jb_status=2");default->throw new IllegalStateException();}
}
}
if(q!=null && !q.isBlank()) {
where.append(" AND (CAST(jb.jb_id AS CHAR) LIKE ? OR jb.jb_commission_no LIKE ? OR cmp.cmp_comp LIKE ? OR EXISTS (SELECT 1 FROM tour t JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id=jb.jb_id AND (t.tr_comp LIKE ? OR a.ad_city LIKE ? OR a.ad_street LIKE ?)))");
String term="%"+q.trim()+"%";for(int i=0;i<6;i++)parameters.add(term);
}
Long total=database.queryForObject("SELECT COUNT(*) "+ORDER_FROM+where,Long.class,parameters.toArray());
List<DeliveryOrder> result=database.queryForList(ORDER_SELECT+ORDER_FROM+where+" ORDER BY jb.jb_ordertime DESC,jb.jb_id DESC LIMIT "+maxResults,parameters.toArray()).stream().map(LegacyMapping::order).toList();
addStops(result);
return new OrderResult(result,total==null?0:total,maxResults);
}
public DeliveryOrder order(LegacyUser user,long id) {
user.require(4);
return database.readTransaction(() -> readOrder(user,id));
}
private DeliveryOrder readOrder(LegacyUser user,long id) {
List<DeliveryOrder> rows=database.queryForList(ORDER_SELECT+ORDER_FROM+" WHERE jb.jb_id=? AND jb.hq_id=?",id,user.headquartersId()).stream().map(LegacyMapping::order).toList();
if(rows.isEmpty())throw new ResponseStatusException(HttpStatus.NOT_FOUND,"Auftrag in dieser Niederlassung nicht gefunden.");
addStops(rows);return rows.getFirst();
}
void addStops(List<DeliveryOrder> orders) {
if(orders.isEmpty())return;
Map<Long,DeliveryOrder> byId=new HashMap<>();orders.forEach(o->byId.put(o.id,o));
String placeholders=String.join(",",Collections.nCopies(orders.size(),"?"));
database.queryForList("SELECT t.*,a.ad_street,a.ad_zipcode,a.ad_city FROM tour t LEFT JOIN address a ON a.ad_id=t.ad_id WHERE t.jb_id IN ("+placeholders+") ORDER BY t.jb_id,t.tr_sort,t.tr_id",orders.stream().map(o->o.id).toArray()).forEach(row->{
DeliveryOrder o=byId.get(LegacyMapping.id(row,"jb_id"));if(o!=null){Stop s=LegacyMapping.stop(row);o.stops.add(s);if(s.recipientName!=null&&!s.recipientName.isBlank())o.recipientName=s.recipientName;}
});
}
public List<Customer> customers(LegacyUser user,String q) {
user.require(0);
String sql="""
SELECT cs.*,cmp.cmp_comp,cmp.cmp_hsno,cmp.cmp_visible,cmp.cmp_authenticated,cmp.cmp_archived,
a.ad_street,a.ad_zipcode,a.ad_city,u.usr_firstname,u.usr_name,u.usr_phone,u.usr_email
FROM customer cs JOIN company cmp ON cmp.cmp_id=cs.cmp_id
LEFT JOIN address a ON a.ad_id=cmp.ad_id LEFT JOIN employee e ON e.emp_id=cs.cs_admin
LEFT JOIN user u ON u.usr_id=e.usr_id WHERE cs.hq_id=?
""";
List<Object> args=new ArrayList<>(List.of(user.headquartersId()));
if(q!=null&&!q.isBlank()){sql+=" AND (cmp.cmp_comp LIKE ? OR cs.cs_eid LIKE ? OR a.ad_city LIKE ?)";String term="%"+q.trim()+"%";args.add(term);args.add(term);args.add(term);}
return database.queryForList(sql+" ORDER BY cmp.cmp_comp LIMIT "+maxResults,args.toArray()).stream().map(LegacyMapping::customer).toList();
}
public List<Courier> couriers(LegacyUser user,String q) {
user.require(1);
String sql="""
SELECT cr.*,cmp.cmp_comp,cmp.cmp_visible,cmp.cmp_authenticated,cmp.cmp_archived,
u.usr_firstname,u.usr_name,u.usr_phone,
(SELECT MIN(mt.mt_value) FROM metatype mt WHERE mt.mt_type='vehicletype' AND mt.mt_sort=cr.vht_id) AS vehicle_label,
(SELECT MIN(cv.crvh_vh_sign) FROM couriervehicle cv WHERE cv.cr_id=cr.cr_id AND cv.crvh_sid=cr.cr_sid) AS crvh_vh_sign,
(SELECT COUNT(*) FROM job jb WHERE jb.cr_id=cr.cr_id AND jb.jb_status=1 AND jb.hq_id=u.hq_id) AS active_jobs
FROM courier cr JOIN user u ON u.usr_id=cr.usr_id JOIN company cmp ON cmp.cmp_id=cr.cmp_id WHERE u.hq_id=? AND cr.hq_id=u.hq_id
""";
List<Object> args=new ArrayList<>(List.of(user.headquartersId()));
if(q!=null&&!q.isBlank()){sql+=" AND (cmp.cmp_comp LIKE ? OR cr.cr_eid LIKE ? OR u.usr_name LIKE ?)";String term="%"+q.trim()+"%";args.add(term);args.add(term);args.add(term);}
return database.queryForList(sql+" ORDER BY cmp.cmp_comp LIMIT "+maxResults,args.toArray()).stream().map(LegacyMapping::courier).toList();
}
public DatabaseService.ConnectionCheck connectionCheck() { return database.checkConnection(); }
public int maxResults() { return maxResults; }
}
@@ -0,0 +1,15 @@
package de.stadtbote.votian;
import org.springframework.security.access.AccessDeniedException;
import java.security.Principal;
import static de.stadtbote.votian.Models.*;
/** Scope is the original headquarters. Cross-headquarters and customer access are never widened implicitly. */
public record LegacyUser(long userId, long employeeId, long headquartersId, String username, String rights) implements Principal {
@Override public String getName() { return username; }
public boolean hasRight(int position) { return rights != null && rights.length() > position && rights.charAt(position) == '1'; }
public void require(int position) {
if (!hasRight(position)) throw new AccessDeniedException("Für diesen Bereich fehlt die Berechtigung im vorhandenen VoTiAn-Konto.");
}
public Capabilities capabilities() { return new Capabilities(hasRight(4),hasRight(0),hasRight(1),false,false,false,false,false,false,hasRight(4) && hasRight(5)); }
}
@@ -0,0 +1,60 @@
package de.stadtbote.votian;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.*;
/** API projections only. These classes neither define nor create database tables. */
public final class Models {
private Models() {}
public enum OrderStatus { DISPOSITION, OFFERED, IN_PROGRESS, COMPLETED, CANCELLED }
public enum StopType { PICKUP, DELIVERY, BOTH }
public enum CourierStatus { AVAILABLE, BUSY, OFFLINE }
public enum CancelMode { FREE, CHARGEABLE }
public static class Customer {
public Long id, version;
public String number, name, street, postalCode, city, contact, phone, email;
public Integer paymentDays;
public boolean active;
}
public static class Courier {
public Long id, version;
public String number, name, phone, vehicle, plate;
public CourierStatus status;
public boolean active;
}
public static class DeliveryOrder {
public Long id, version, customerId, payerCustomerId, courierId, invoiceId;
public String number;
public OrderStatus status;
public int legacyStatus, legacyStorno;
public long legacyService;
public boolean isCreditNote;
public String legacyStatusLabel;
public String service, vehicle;
public LocalDateTime pickupAt, deliveryDeadline;
public String reference, costCenter, notes;
public Integer packages;
public BigDecimal weight, distanceKm, netPrice, courierPrice, vatRate;
public String recipientName;
public LocalDateTime deliveredAt, createdAt, updatedAt;
public CancelMode cancelMode;
public List<Stop> stops = new ArrayList<>();
public List<History> history = new ArrayList<>();
}
public static class Stop {
public Long id;
public int sequence;
public StopType type;
public String name, street, postalCode, city, contact, phone, note;
public LocalDateTime completedAt;
public String recipientName;
}
public static class History {
public Long id;
public LocalDateTime at;
public String action, detail, actor;
}
public record Capabilities(boolean readOrders, boolean readCustomers, boolean readCouriers, boolean readInvoices,
boolean writeOrders, boolean writeCustomers, boolean writeCouriers, boolean writeInvoices, boolean completeStops, boolean exportOrders) {}
}
@@ -0,0 +1,101 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CsrfException;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
@Configuration
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public class SecurityConfig {
@Bean
SecurityFilterChain security(HttpSecurity http, LegacyAuthenticationProvider provider, ObjectMapper json) throws Exception {
return http.authenticationProvider(provider)
.csrf(csrf -> csrf.csrfTokenRepository(new HttpSessionCsrfTokenRepository()))
.cors(cors -> {})
// Authorization belongs to any upstream Basic authentication, not to VoTiAn.
.httpBasic(AbstractHttpConfigurer::disable)
.requestCache(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.sessionFixation(fixation -> fixation.changeSessionId()))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/login", "/api/logout").permitAll()
.requestMatchers("/api/login", "/api/logout").denyAll()
.requestMatchers("/api/health", "/api/system", "/api/csrf",
"/", "/index.html", "/assets/**", "/stadtbote-logo.png",
"/votian-logo.png", "/favicon.ico", "/error").permitAll()
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll())
.formLogin(form -> form
.loginPage("/")
.loginProcessingUrl("/api/login")
.successHandler((request, response, authentication) -> {
response.setContentType("application/json;charset=UTF-8");
json.writeValue(response.getOutputStream(), ApiController.userDetails(authentication));
})
.failureHandler((request, response, exception) -> {
boolean unavailable = exception instanceof AuthenticationServiceException;
response.setStatus(unavailable ? 503 : 401);
response.setContentType("application/json;charset=UTF-8");
json.writeValue(response.getOutputStream(), ApiErrors.body(unavailable
? ApiErrors.DATABASE_UNAVAILABLE
: "Bitte mit einem bestehenden VoTiAn-Zentralenkonto anmelden. Benutzername oder Passwort ist ungültig."));
}))
.logout(logout -> logout
.logoutUrl("/api/logout")
.invalidateHttpSession(true)
.clearAuthentication(true)
.deleteCookies("VOTIANSESSION")
.logoutSuccessHandler((request, response, authentication) -> response.setStatus(204)))
.exceptionHandling(errors -> errors
.authenticationEntryPoint((request, response, exception) -> {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
json.writeValue(response.getOutputStream(), ApiErrors.body("Bitte anmelden. Die Sitzung fehlt oder ist abgelaufen."));
})
.accessDeniedHandler((request, response, exception) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
boolean csrf = exception instanceof CsrfException;
var body = new LinkedHashMap<>(ApiErrors.body(csrf
? "Sicherheitsprüfung fehlgeschlagen. Bitte die Seite neu laden."
: "Für diesen Bereich fehlt die Berechtigung."));
if (csrf) body.put("code", "CSRF_INVALID");
json.writeValue(response.getOutputStream(), body);
}))
.build();
}
@Bean
CorsConfigurationSource cors(@Value("${app.cors-origins}") String origins) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Arrays.stream(origins.split(",")).map(String::trim).toList());
config.setAllowedMethods(List.of("GET", "POST", "OPTIONS"));
config.setAllowedHeaders(List.of("Content-Type", "X-CSRF-TOKEN"));
config.setExposedHeaders(List.of("Content-Disposition", "X-Total-Count", "X-Result-Limit"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
@@ -0,0 +1,46 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseDiagnostics;
import java.util.Arrays;
import org.springframework.boot.Banner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
@SpringBootApplication
public class VotianApplication {
public static void main(String[] args) {
if (Arrays.asList(args).contains("--database-check")) {
System.exit(databaseCheck());
}
SpringApplication.run(VotianApplication.class, args);
}
private static int databaseCheck() {
SpringApplication application = new SpringApplication(VotianApplication.class);
application.setWebApplicationType(WebApplicationType.NONE);
application.setBannerMode(Banner.Mode.OFF);
application.setLogStartupInfo(false);
DatabaseDiagnostics.Result result;
// Keep stdout machine-readable and prevent JDBC startup errors exposing connection details.
try (ConfigurableApplicationContext context = application.run(
"--spring.main.web-application-type=none", "--spring.main.banner-mode=off",
"--logging.level.root=OFF", "--logging.level.com.zaxxer.hikari=OFF",
"--logging.level.org.mariadb.jdbc=OFF")) {
result = context.getBean(DatabaseDiagnostics.class).check();
} catch (RuntimeException error) {
result = DatabaseDiagnostics.failure("CONFIGURATION_ERROR",
"Backend-Konfiguration oder Datenbankverbindung prüfen.", null);
}
try {
System.out.println(new ObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(result.details()));
} catch (JsonProcessingException error) {
System.out.println("{\"connected\":false,\"reason\":\"DIAGNOSTIC_ERROR\",\"message\":\"Diagnose konnte nicht ausgegeben werden.\",\"writes\":0}");
return 1;
}
return result.exitCode();
}
}
@@ -0,0 +1,95 @@
package de.stadtbote.votian.database;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Service;
/** Checks only connectivity and schema metadata through the shared database service. */
@Service
public class DatabaseDiagnostics {
private static final Map<String, List<String>> REQUIRED = Map.ofEntries(
Map.entry("job", columns("jb_id hq_id csc_id csc_id_payer cr_id jb_status jb_storno jb_version jb_ordertime jb_totalprice jb_cr_price jb_service jb_commission_no vht_id")),
Map.entry("tour", columns("tr_id jb_id ad_id tr_sort tr_ware_from_to tr_status tr_finishtime tr_signname")),
Map.entry("customer", columns("cs_id cmp_id hq_id cs_eid cs_admin")),
Map.entry("company", columns("cmp_id cmp_comp ad_id cmp_hsno cmp_archived cmp_visible cmp_authenticated")),
Map.entry("costcenter", columns("csc_id cs_id csc_name")),
Map.entry("address", columns("ad_id ad_street ad_zipcode ad_city")),
Map.entry("courier", columns("cr_id hq_id usr_id cmp_id cr_sid cr_eid vht_id cr_available")),
Map.entry("couriervehicle", columns("cr_id crvh_sid crvh_vh_sign")),
Map.entry("user", columns("usr_id hq_id usr_account usr_type usr_password usr_password_old usr_firstname usr_name usr_phone usr_email")),
Map.entry("employee", columns("emp_id usr_id emp_rights")),
Map.entry("headquarters", columns("hq_id")),
Map.entry("mandatorheadquarters", columns("hq_id md_id")),
Map.entry("metatype", columns("mt_type mt_sort mt_value")));
private final DatabaseService database;
public DatabaseDiagnostics(DatabaseService database) {
this.database = database;
}
public record Result(Map<String, Object> details, int exitCode) {}
public Result check() {
try {
DatabaseService.ConnectionCheck connection = database.checkConnection();
if (!connection.connected()) {
Integer errorCode = switch (connection.reason()) {
case "AUTHENTICATION_FAILED" -> 1045;
case "DATABASE_ACCESS_DENIED" -> 1044;
default -> null;
};
return failure(connection.reason(), connection.message(), errorCode);
}
String version = database.queryForObject("SELECT VERSION()", String.class);
List<Map<String, Object>> metadata = database.queryForList(
"SELECT TABLE_NAME AS table_name,COLUMN_NAME AS column_name "
+ "FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE()");
Map<String, Set<String>> actual = new HashMap<>();
for (Map<String, Object> row : metadata) {
String table = row.get("table_name").toString().toLowerCase(Locale.ROOT);
String column = row.get("column_name").toString().toLowerCase(Locale.ROOT);
actual.computeIfAbsent(table, ignored -> new HashSet<>()).add(column);
}
Map<String, List<String>> missing = new LinkedHashMap<>();
REQUIRED.keySet().stream().sorted().forEach(table -> {
List<String> absent = new ArrayList<>(REQUIRED.get(table));
absent.removeAll(actual.getOrDefault(table, Set.of()));
absent.sort(String::compareTo);
if (!absent.isEmpty()) missing.put(table, absent);
});
Map<String, Object> details = new LinkedHashMap<>();
details.put("connected", true);
details.put("serverVersion", version);
details.put("checkedTables", REQUIRED.size());
details.put("missingColumns", missing);
details.put("applicationRowsRead", 0);
details.put("writes", 0);
return new Result(details, missing.isEmpty() ? 0 : 1);
} catch (RuntimeException error) {
// JDBC exception text can contain connection details; never expose it.
return failure("UNAVAILABLE", "Datenbankverbindung oder Zugriff auf Schemametadaten prüfen.", null);
}
}
public static Result failure(String reason, String message, Integer errorCode) {
Map<String, Object> details = new LinkedHashMap<>();
details.put("connected", false);
details.put("reason", reason);
details.put("errorCode", errorCode);
details.put("message", message);
details.put("applicationRowsRead", 0);
details.put("writes", 0);
return new Result(details, 1);
}
private static List<String> columns(String columns) {
return List.of(columns.split(" "));
}
}
@@ -0,0 +1,96 @@
package de.stadtbote.votian.database;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionException;
import org.springframework.transaction.support.TransactionTemplate;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
import java.util.regex.Pattern;
/** The only application component allowed to execute SQL or control database transactions. */
@Service
public class DatabaseService {
public static final String DATABASE_UNAVAILABLE = "Die vorhandene VoTiAn-Datenbank ist nicht erreichbar oder das Schema konnte nicht gelesen werden. Bitte Verbindung/VPN und DB-Konfiguration prüfen.";
private static final Pattern SELECT = Pattern.compile("^SELECT\\b", Pattern.CASE_INSENSITIVE);
private static final Pattern SIDE_EFFECT = Pattern.compile("\\b(?:INTO|FOR\\s+UPDATE|LOCK\\s+IN\\s+SHARE\\s+MODE)\\b", Pattern.CASE_INSENSITIVE);
private final JdbcTemplate jdbc;
private final TransactionTemplate readTransaction;
@Autowired
public DatabaseService(JdbcTemplate jdbc, DataSourceTransactionManager transactions) {
this(jdbc, (PlatformTransactionManager)transactions);
// A JDBC read-only hint alone does not enforce MariaDB's transaction access mode.
transactions.setEnforceReadOnly(true);
}
// Package-private test seam; production injection always uses the enforcing constructor above.
DatabaseService(JdbcTemplate jdbc, PlatformTransactionManager transactions) {
this.jdbc = jdbc;
this.readTransaction = new TransactionTemplate(transactions);
this.readTransaction.setReadOnly(true);
}
/** SQL is application-owned; values must be supplied as bound parameters. No raw JDBC is exposed. */
public List<Map<String,Object>> queryForList(String sql, Object... parameters) {
requireReadStatement(sql);
return readTransaction(() -> jdbc.queryForList(sql, parameters));
}
public <T> T queryForObject(String sql, Class<T> type, Object... parameters) {
requireReadStatement(sql);
return readTransaction(() -> jdbc.queryForObject(sql, type, parameters));
}
/** Groups dependent reads (count, orders and stops) in one database transaction. */
public <T> T readTransaction(Supplier<T> work) {
try {
return readTransaction.execute(status -> work.get());
} catch (TransactionException exception) {
throw new DataAccessResourceFailureException(DATABASE_UNAVAILABLE, exception);
}
}
/** Central entry point for future writes. No incomplete legacy workflow may write today. */
public int update(String sql, Object... parameters) {
throw new DatabaseWritesDisabledException();
}
public record ConnectionCheck(boolean connected, String reason, String message) {}
public ConnectionCheck checkConnection() {
try {
boolean connected = Objects.equals(queryForObject("SELECT 1", Integer.class), 1);
return new ConnectionCheck(connected, connected ? "CONNECTED" : "UNAVAILABLE", connected
? "Mit der vorhandenen VoTiAn-Datenbank verbunden. Diese Ausbaustufe liest Bestandsdaten; Erfassung und Änderungen erfolgen im bestehenden VoTiAn."
: DATABASE_UNAVAILABLE);
} catch (DataAccessException exception) {
for (Throwable cause = exception; cause != null; cause = cause.getCause()) {
if (cause instanceof SQLException sql && (sql.getErrorCode() == 1045 || "28000".equals(sql.getSQLState()))) {
return new ConnectionCheck(false, "AUTHENTICATION_FAILED", "Der MySQL-Server ist erreichbar, weist das Datenbankkonto jedoch ab. Bitte Zugangsdaten und Hostfreigabe prüfen (MySQL 1045).");
}
if (cause instanceof SQLException sql && sql.getErrorCode() == 1044) {
return new ConnectionCheck(false, "DATABASE_ACCESS_DENIED", "Das Datenbankkonto hat keine Zugriffsrechte auf die vorhandene Datenbank (MySQL 1044).");
}
}
return new ConnectionCheck(false, "UNAVAILABLE", DATABASE_UNAVAILABLE);
}
}
private static void requireReadStatement(String sql) {
String statement = sql == null ? "" : sql.strip();
// Guard against accidental use of the read API for writes, not a parser for untrusted SQL.
if (!SELECT.matcher(statement).find() || statement.contains(";") || statement.contains("--")
|| statement.contains("/*") || statement.contains("#") || SIDE_EFFECT.matcher(statement).find()) {
throw new IllegalArgumentException("Für Lesezugriffe sind nur einzelne SELECT-Anweisungen ohne Seiteneffekte zulässig.");
}
}
}
@@ -0,0 +1,8 @@
package de.stadtbote.votian.database;
/** Business write workflows have not yet been ported; the central database boundary enforces this. */
public final class DatabaseWritesDisabledException extends UnsupportedOperationException {
public DatabaseWritesDisabledException() {
super("Schreibzugriffe sind in dieser Ausbaustufe gesperrt. Bitte die bestehende VoTiAn-Anwendung verwenden.");
}
}
@@ -0,0 +1,47 @@
server:
address: ${SERVER_ADDRESS:127.0.0.1}
port: ${PORT:8080}
forward-headers-strategy: ${FORWARD_HEADERS_STRATEGY:native}
servlet:
session:
timeout: ${SESSION_TIMEOUT:30m}
tracking-modes: cookie
cookie:
name: VOTIANSESSION
http-only: true
same-site: lax
error:
include-message: never
spring:
application:
name: votian-ng
datasource:
# Only the existing database; never initialize or replace its schema.
url: ${DB_URL:jdbc:mariadb://127.0.0.1:1/unconfigured?connectTimeout=2000&socketTimeout=5000}
username: ${DB_USER:}
password: ${DB_PASSWORD:}
driver-class-name: org.mariadb.jdbc.Driver
hikari:
initialization-fail-timeout: -1
connection-timeout: 3000
validation-timeout: 1000
maximum-pool-size: 4
minimum-idle: 0
read-only: true
data-source-properties:
# Preserve numeric legacy codes, including values above 1 in TINYINT(1).
tinyInt1isBit: false
sql:
init:
mode: never
jackson:
serialization:
write-dates-as-timestamps: false
app:
cors-origins: ${CORS_ORIGINS:http://localhost:5173,http://127.0.0.1:5173}
legacy-url: ${LEGACY_APP_URL:}
max-results: ${MAX_RESULTS:500}
logging:
level:
com.zaxxer.hikari: WARN
org.mariadb.jdbc: ERROR
@@ -0,0 +1,191 @@
package de.stadtbote.votian;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.stadtbote.votian.database.DatabaseService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest(properties={"spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration"})
@AutoConfigureMockMvc
class ApiSecurityTest {
@Autowired MockMvc mvc;
@Autowired ObjectMapper json;
@MockitoBean DatabaseService database;
record Csrf(MockHttpSession session, String token, String header) {}
@BeforeEach void configure() {
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(true,"CONNECTED","Verbunden"));
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("existing-user"),eq("existing-password"),eq("existing-password")))
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","existing-user","emp_rights","111111111111")));
}
Csrf csrf(MockHttpSession session) throws Exception {
var request = get("/api/csrf");
if (session != null) request.session(session);
MvcResult response = mvc.perform(request).andExpect(status().isOk())
.andExpect(header().string("Cache-Control", org.hamcrest.Matchers.containsString("no-store")))
.andReturn();
var body = json.readTree(response.getResponse().getContentAsString());
return new Csrf((MockHttpSession) response.getRequest().getSession(false), body.get("token").asText(), body.get("headerName").asText());
}
MvcResult login(Csrf csrf, String username, String password, int expectedStatus) throws Exception {
// Represents upstream htaccess authentication: VoTiAn must ignore this header.
return mvc.perform(post("/api/login").session(csrf.session()).header(csrf.header(),csrf.token())
.with(httpBasic("proxy-fixture","proxy-password"))
.contentType("application/x-www-form-urlencoded").param("username",username).param("password",password))
.andExpect(status().is(expectedStatus)).andExpect(header().doesNotExist("WWW-Authenticate")).andReturn();
}
MockHttpSession authenticated(String username, String password) throws Exception {
return (MockHttpSession) login(csrf(null),username,password,200).getRequest().getSession(false);
}
@Test void healthAndSystemArePublicButDataRequiresSession() throws Exception {
mvc.perform(get("/api/health").with(httpBasic("proxy-fixture","proxy-password")))
.andExpect(status().isOk()).andExpect(jsonPath("$.status").value("UP"));
mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.readOnly").value(true))
.andExpect(jsonPath("$.schemaChanges").value(false)).andExpect(jsonPath("$.capabilities.writeOrders").value(false));
MvcResult unauthorized = mvc.perform(get("/api/orders")).andExpect(status().isUnauthorized())
.andExpect(header().doesNotExist("WWW-Authenticate")).andReturn();
assertThat(unauthorized.getRequest().getSession(false)).isNull();
Csrf csrf = csrf(null);
mvc.perform(post("/api/orders").session(csrf.session()).header(csrf.header(),csrf.token())
.contentType("application/json").content("{}"))
.andExpect(status().isUnauthorized());
}
@Test void existingLoginPersistsSessionRotatesIdAndStoresNoPassword() throws Exception {
Csrf anonymous = csrf(null);
String previousId = anonymous.session().getId();
MvcResult login = login(anonymous,"existing-user","existing-password",200);
var body = json.readTree(login.getResponse().getContentAsString());
assertThat(body.get("username").asText()).isEqualTo("existing-user");
assertThat(body.has("password")).isFalse();
MockHttpSession session = (MockHttpSession) login.getRequest().getSession(false);
assertThat(session.getId()).isNotEqualTo(previousId);
SecurityContext context = (SecurityContext) session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
assertThat(context.getAuthentication().getCredentials()).isNull();
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk())
.andExpect(jsonPath("$.headquartersId").value(7)).andExpect(jsonPath("$.roles[0]").value("VIEWER"))
.andExpect(jsonPath("$.password").doesNotExist()).andExpect(jsonPath("$.capabilities.exportOrders").value(true))
.andExpect(jsonPath("$.capabilities.writeOrders").value(false));
mvc.perform(get("/api/me").session(session).with(httpBasic("proxy-fixture","proxy-password")))
.andExpect(status().isOk()).andExpect(jsonPath("$.username").value("existing-user"));
verify(database,times(1)).queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),any(),any(),any());
}
@Test void basicCredentialsAloneCannotAuthenticateTheApplication() throws Exception {
mvc.perform(get("/api/me").with(httpBasic("existing-user","existing-password")))
.andExpect(status().isUnauthorized()).andExpect(header().doesNotExist("WWW-Authenticate"));
verify(database,never()).queryForList(anyString(),any(),any(),any());
}
@Test void loginRequiresCsrfAndDoesNotAcceptCredentialsInGetQuery() throws Exception {
mvc.perform(post("/api/login").param("username","existing-user").param("password","existing-password"))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
mvc.perform(get("/api/login").param("username","existing-user").param("password","existing-password"))
.andExpect(status().isUnauthorized());
verify(database,never()).queryForList(anyString(),any(),any(),any());
}
@Test void loginRotatesCsrfAndLogoutInvalidatesSession() throws Exception {
Csrf anonymous = csrf(null);
MockHttpSession session = (MockHttpSession) login(anonymous,"existing-user","existing-password",200).getRequest().getSession(false);
mvc.perform(post("/api/logout").session(session).header(anonymous.header(),anonymous.token()))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk());
Csrf authenticated = csrf(session);
mvc.perform(post("/api/logout").session(session).header(authenticated.header(),authenticated.token()))
.andExpect(status().isNoContent()).andExpect(cookie().maxAge("VOTIANSESSION",0));
assertThat(session.isInvalid()).isTrue();
mvc.perform(get("/api/me")).andExpect(status().isUnauthorized());
Csrf afterLogout = csrf(null);
mvc.perform(post("/api/login").session(afterLogout.session()).header(authenticated.header(),authenticated.token())
.param("username","existing-user").param("password","existing-password"))
.andExpect(status().isForbidden());
login(afterLogout,"existing-user","existing-password",200);
}
@Test void logoutRequiresCsrfAndGetCannotLogOut() throws Exception {
MockHttpSession session = authenticated("existing-user","existing-password");
mvc.perform(post("/api/logout").session(session)).andExpect(status().isForbidden());
mvc.perform(get("/api/logout").session(session)).andExpect(status().isForbidden());
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk());
}
@Test void csrfCannotBeReusedAcrossSessions() throws Exception {
Csrf first = csrf(null);
Csrf second = csrf(null);
mvc.perform(post("/api/login").session(second.session()).header(first.header(),first.token())
.param("username","existing-user").param("password","existing-password"))
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value("CSRF_INVALID"));
verify(database,never()).queryForList(anyString(),any(),any(),any());
}
@Test void noWriteEndpointCanModifyTheDatabase() throws Exception {
MockHttpSession session = authenticated("existing-user","existing-password");
Csrf csrf = csrf(session);
mvc.perform(post("/api/orders/12/cancel").session(session).header(csrf.header(),csrf.token())
.contentType("application/json").content("{\"version\":1,\"cancelMode\":\"FREE\",\"reason\":\"test\"}"))
.andExpect(status().isNotImplemented());
verify(database,never()).update(anyString(),any(Object[].class));
}
@Test void backendDatabaseFailureIs503NotFakeLoginOrEmptyDataset() throws Exception {
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("offline"),eq("password"),eq("password")))
.thenThrow(new DataAccessResourceFailureException("server secret must not be exposed"));
var result = login(csrf(null),"offline","password",503);
assertThat(json.readTree(result.getResponse().getContentAsString()).get("message").asText()).isEqualTo(ApiErrors.DATABASE_UNAVAILABLE);
assertThat(result.getRequest().getSession(false).getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)).isNull();
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(false,"UNAVAILABLE",ApiErrors.DATABASE_UNAVAILABLE));
mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.connected").value(false));
}
@Test void wrongOrUnscopedAccountDoesNotAuthenticate() throws Exception {
Csrf wrong = csrf(null);
login(wrong,"unknown","incorrect",401);
mvc.perform(get("/api/me").session(wrong.session())).andExpect(status().isUnauthorized());
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("invalid"),eq("password"),eq("password")))
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",0,"usr_account","invalid","emp_rights","111111")));
login(csrf(null),"invalid","password",401);
}
@Test void rightsAreEnforcedBeyondAuthentication() throws Exception {
when(database.queryForList(eq(LegacyAuthenticationProvider.AUTH_SQL),eq("restricted"),eq("password"),eq("password")))
.thenReturn(List.of(Map.of("usr_id",11,"emp_id",12,"hq_id",7,"usr_account","restricted","emp_rights","0000000000")));
MockHttpSession session = authenticated("restricted","password");
mvc.perform(get("/api/me").session(session)).andExpect(status().isOk()).andExpect(jsonPath("$.capabilities.exportOrders").value(false));
mvc.perform(get("/api/orders").session(session)).andExpect(status().isForbidden());
mvc.perform(get("/api/customers").session(session)).andExpect(status().isForbidden());
mvc.perform(get("/api/orders/export.csv").session(session)).andExpect(status().isForbidden());
}
@Test void databaseAccountDenialIsDistinguishedFromNetworkFailure() throws Exception {
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(false,"AUTHENTICATION_FAILED","Bitte Hostfreigabe prüfen."));
mvc.perform(get("/api/system")).andExpect(status().isOk()).andExpect(jsonPath("$.connected").value(false))
.andExpect(jsonPath("$.reason").value("AUTHENTICATION_FAILED"))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("Hostfreigabe")));
}
}
@@ -0,0 +1,243 @@
package de.stadtbote.votian;
import org.junit.jupiter.api.Test;
import org.springframework.asm.AnnotationVisitor;
import org.springframework.asm.ClassReader;
import org.springframework.asm.ClassVisitor;
import org.springframework.asm.ClassWriter;
import org.springframework.asm.ConstantDynamic;
import org.springframework.asm.FieldVisitor;
import org.springframework.asm.Handle;
import org.springframework.asm.MethodVisitor;
import org.springframework.asm.Opcodes;
import org.springframework.asm.RecordComponentVisitor;
import org.springframework.asm.Type;
import net.bytebuddy.jar.asm.signature.SignatureReader;
import net.bytebuddy.jar.asm.signature.SignatureVisitor;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
/** Enforces the database boundary using compiled dependencies, including fully qualified references. */
class DatabaseAccessArchitectureTest {
private static final String BASE = "de/stadtbote/votian/";
private static final String SERVICE = BASE + "database/DatabaseService";
private static final String SERVICE_TEST = BASE + "database/DatabaseServiceTest";
private static final String THIS_TEST = BASE + "DatabaseAccessArchitectureTest";
private static final List<String> FORBIDDEN_PREFIXES = List.of(
"java/sql/", "javax/sql/", "org/springframework/jdbc/",
"org/springframework/transaction/", "org/springframework/orm/",
"org/springframework/data/", "jakarta/persistence/", "javax/persistence/",
"org/hibernate/", "com/zaxxer/hikari/", "org/mariadb/jdbc/", "com/mysql/",
"org/postgresql/", "org/h2/", "org/apache/commons/dbcp", "org/apache/tomcat/jdbc/"
);
// Timestamp is a value already returned by JDBC, not a connection or execution API.
private static final Set<String> ALLOWED_VALUE_TYPES = Set.of("java/sql/Timestamp");
@Test void productionDatabaseAccessGoesThroughOneService() throws Exception {
assertNoBypasses(classesDirectory(VotianApplication.class), false);
}
@Test void testsUseTheSameBoundaryExceptTheServiceOwnUnitTests() throws Exception {
assertNoBypasses(classesDirectory(DatabaseAccessArchitectureTest.class), true);
}
@Test void bytecodeGuardDetectsConstructorFieldsCallsAndReflectiveTypeNames() {
ClassWriter writer = new ClassWriter(0);
writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "ForbiddenAccess", null, "java/lang/Object", null);
writer.visitField(Opcodes.ACC_PRIVATE, "template", "Lorg/springframework/jdbc/core/JdbcTemplate;", null, null).visitEnd();
MethodVisitor method = writer.visitMethod(Opcodes.ACC_PUBLIC, "read", "(Ljavax/sql/DataSource;)V", null, null);
method.visitCode();
method.visitMethodInsn(Opcodes.INVOKESTATIC, "java/sql/DriverManager", "getConnection", "(Ljava/lang/String;)Ljava/sql/Connection;", false);
method.visitLdcInsn("jakarta.persistence.EntityManager");
method.visitInsn(Opcodes.RETURN);
method.visitMaxs(2, 2);
method.visitEnd();
writer.visitEnd();
var findings = dependencies(writer.toByteArray());
assertThat(findings).contains("org/springframework/jdbc/core/JdbcTemplate", "javax/sql/DataSource",
"java/sql/DriverManager", "java/sql/Connection", "jakarta/persistence/EntityManager");
}
@Test void timestampValueMappingRemainsAllowedButGenericJdbcDependenciesDoNot() {
ClassWriter writer = new ClassWriter(0);
writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "ValueMapping", null, "java/lang/Object", null);
writer.visitField(Opcodes.ACC_PRIVATE, "timestamp", "Ljava/sql/Timestamp;", null, null).visitEnd();
writer.visitEnd();
assertThat(dependencies(writer.toByteArray())).isEmpty();
writer = new ClassWriter(0);
writer.visit(Opcodes.V21, Opcodes.ACC_PUBLIC, BASE + "GenericBypass", null, "java/lang/Object", null);
writer.visitField(Opcodes.ACC_PRIVATE, "connections", "Ljava/util/List;", "Ljava/util/List<Ljava/sql/Connection;>;", null).visitEnd();
writer.visitEnd();
assertThat(dependencies(writer.toByteArray())).containsExactly("java/sql/Connection");
}
@Test void pythonLaunchersDoNotOpenTheirOwnDatabaseConnections() throws Exception {
Path scripts = projectRoot().resolve("scripts");
assertThat(scripts).isDirectory();
// Parse source only: do not execute/import launchers or read PHP/local connection files.
String checker = """
import ast, pathlib, sys
drivers = {'pymysql', 'MySQLdb', 'mysql', 'mariadb', 'psycopg', 'psycopg2',
'sqlite3', 'aiosqlite', 'sqlalchemy', 'asyncpg', 'pg8000', 'pyodbc',
'jaydebeapi', 'adodbapi', 'peewee', 'tortoise', 'databases', 'duckdb'}
db_commands = {'mysql', 'mariadb', 'psql', 'sqlite3', 'sqlcmd', 'isql'}
def forbidden(module):
return bool(module) and module.split('.')[0] in drivers
def violations(source):
problems = []
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Import):
problems += [(node.lineno, item.name) for item in node.names if forbidden(item.name)]
elif isinstance(node, ast.ImportFrom) and forbidden(node.module):
problems.append((node.lineno, node.module))
elif isinstance(node, ast.Call) and node.args:
function = node.func.id if isinstance(node.func, ast.Name) else node.func.attr if isinstance(node.func, ast.Attribute) else ''
first = node.args[0]
if function in {'__import__', 'import_module'} and isinstance(first, ast.Constant) and isinstance(first.value, str) and forbidden(first.value):
problems.append((node.lineno, first.value))
elif function in {'run', 'Popen', 'call', 'check_call', 'check_output', 'system', 'execvp', 'execvpe'}:
command = first.elts[0] if isinstance(first, (ast.List, ast.Tuple)) and first.elts else first
if isinstance(command, ast.Constant) and isinstance(command.value, str):
executable = pathlib.Path(command.value.split()[0]).name if command.value.split() else ''
if executable in db_commands:
problems.append((node.lineno, executable))
return problems
assert violations('import pymysql as database')
assert violations('from mysql import connector')
assert violations("importlib.import_module('mariadb')")
assert violations("subprocess.run(['mysql', '--version'])")
assert not violations("import json; subprocess.run(['java', '-jar', 'app.jar'])")
findings = []
for path in sorted(pathlib.Path(sys.argv[1]).rglob('*.py')):
for line, name in violations(path.read_text(encoding='utf-8')):
findings.append(f'{path.name}:{line}: direct database dependency {name}')
print('\\n'.join(findings))
sys.exit(1 if findings else 0)
""";
Process process = new ProcessBuilder("python3", "-c", checker, scripts.toString()).redirectErrorStream(true).start();
boolean finished = process.waitFor(30, TimeUnit.SECONDS);
if (!finished) process.destroyForcibly();
assertThat(finished).as("Python AST architecture check must terminate without starting the application").isTrue();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
assertThat(process.exitValue()).as("Python launchers must delegate database work to the Java DatabaseService:%n%s", output).isZero();
}
private static Path projectRoot() {
Path directory = Path.of("").toAbsolutePath().normalize();
for (int level = 0; directory != null && level < 5; level++, directory = directory.getParent()) {
if (Files.isDirectory(directory.resolve("backend")) && Files.isDirectory(directory.resolve("scripts"))) return directory;
}
throw new AssertionError("Cannot locate the project scripts directory from Maven working directory");
}
private static Path classesDirectory(Class<?> anchor) throws Exception {
Path classes = Path.of(anchor.getProtectionDomain().getCodeSource().getLocation().toURI());
assertThat(classes).as("Architecture check expects compiled class directories").isDirectory();
return classes;
}
private static boolean belongsTo(String name, String owner) { return name.equals(owner) || name.startsWith(owner + "$"); }
private static void assertNoBypasses(Path classes, boolean tests) throws IOException {
List<String> violations = new ArrayList<>();
if (!tests) assertThat(classes.resolve(SERVICE + ".class")).as("The central database service must exist").isRegularFile();
try (var paths = Files.walk(classes)) {
for (Path path : paths.filter(p -> p.toString().endsWith(".class")).sorted().toList()) {
byte[] bytes = Files.readAllBytes(path);
String name = new ClassReader(bytes).getClassName();
boolean exempt = belongsTo(name, SERVICE) || (tests && (belongsTo(name, SERVICE_TEST) || belongsTo(name, THIS_TEST)));
if (!exempt) for (String dependency : dependencies(bytes)) violations.add(name.replace('/', '.') + " -> " + dependency.replace('/', '.'));
}
}
assertThat(violations).as("Every DB-access type must remain inside DatabaseService (only DatabaseServiceTest may mock raw JDBC)").isEmpty();
}
private static Set<String> dependencies(byte[] bytes) {
Set<String> forbidden = new TreeSet<>();
new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) {
void name(String name) {
if (name == null) return;
if (name.startsWith("[")) { descriptor(name); return; }
if (!ALLOWED_VALUE_TYPES.contains(name) && FORBIDDEN_PREFIXES.stream().anyMatch(name::startsWith)) forbidden.add(name);
}
void type(Type type) {
switch (type.getSort()) {
case Type.OBJECT -> name(type.getInternalName());
case Type.ARRAY -> type(type.getElementType());
case Type.METHOD -> { type(type.getReturnType()); for (Type arg : type.getArgumentTypes()) type(arg); }
default -> { }
}
}
void descriptor(String descriptor) { if (descriptor != null) type(Type.getType(descriptor)); }
void signature(String signature, boolean field) {
if (signature == null) return;
var visitor = new SignatureVisitor(Opcodes.ASM9) {
@Override public void visitClassType(String name) { name(name); }
};
if (field) new SignatureReader(signature).acceptType(visitor); else new SignatureReader(signature).accept(visitor);
}
void handle(Handle handle) { name(handle.getOwner()); descriptor(handle.getDesc()); }
void constant(Object value) {
if (value instanceof Type type) type(type);
else if (value instanceof Handle handle) handle(handle);
else if (value instanceof ConstantDynamic dynamic) {
descriptor(dynamic.getDescriptor());handle(dynamic.getBootstrapMethod());
for (int i=0;i<dynamic.getBootstrapMethodArgumentCount();i++) constant(dynamic.getBootstrapMethodArgument(i));
} else if (value instanceof String text) name(text.replace('.', '/'));
}
AnnotationVisitor annotation(String descriptor) {
descriptor(descriptor);
return new AnnotationVisitor(Opcodes.ASM9) {
@Override public void visit(String name, Object value) { constant(value); }
@Override public void visitEnum(String name, String descriptor, String value) { descriptor(descriptor); }
@Override public AnnotationVisitor visitAnnotation(String name, String descriptor) { return annotation(descriptor); }
@Override public AnnotationVisitor visitArray(String name) { return annotation(null); }
};
}
@Override public void visit(int version,int access,String name,String signature,String superName,String[] interfaces) {
name(superName);if(interfaces!=null)for(String iface:interfaces)name(iface);signature(signature,false);
}
@Override public AnnotationVisitor visitAnnotation(String descriptor,boolean visible) { return annotation(descriptor); }
@Override public RecordComponentVisitor visitRecordComponent(String name,String descriptor,String signature) {
descriptor(descriptor);signature(signature,true);
return new RecordComponentVisitor(Opcodes.ASM9) {
@Override public AnnotationVisitor visitAnnotation(String descriptor,boolean visible) { return annotation(descriptor); }
};
}
@Override public FieldVisitor visitField(int access,String name,String descriptor,String signature,Object value) {
descriptor(descriptor);signature(signature,true);constant(value);
return new FieldVisitor(Opcodes.ASM9) {
@Override public AnnotationVisitor visitAnnotation(String descriptor,boolean visible) { return annotation(descriptor); }
};
}
@Override public MethodVisitor visitMethod(int access,String name,String descriptor,String signature,String[] exceptions) {
descriptor(descriptor);signature(signature,false);if(exceptions!=null)for(String exception:exceptions)name(exception);
return new MethodVisitor(Opcodes.ASM9) {
@Override public AnnotationVisitor visitAnnotation(String descriptor,boolean visible) { return annotation(descriptor); }
@Override public AnnotationVisitor visitParameterAnnotation(int parameter,String descriptor,boolean visible) { return annotation(descriptor); }
@Override public void visitTypeInsn(int opcode,String type) { name(type); }
@Override public void visitFieldInsn(int opcode,String owner,String name,String descriptor) { name(owner);descriptor(descriptor); }
@Override public void visitMethodInsn(int opcode,String owner,String name,String descriptor,boolean isInterface) { name(owner);descriptor(descriptor); }
@Override public void visitInvokeDynamicInsn(String name,String descriptor,Handle bootstrap,Object... arguments) {
descriptor(descriptor);handle(bootstrap);for(Object argument:arguments)constant(argument);
}
@Override public void visitLdcInsn(Object value) { constant(value); }
@Override public void visitMultiANewArrayInsn(String descriptor,int dimensions) { descriptor(descriptor); }
@Override public void visitTryCatchBlock(org.springframework.asm.Label start,org.springframework.asm.Label end,org.springframework.asm.Label handler,String type) { name(type); }
};
}
}, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
return forbidden;
}
}
@@ -0,0 +1,43 @@
package de.stadtbote.votian;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/** Opt-in SELECT-only check against the configured existing database. No accounts or data are created. */
@EnabledIfEnvironmentVariable(named="VOTIAN_VERIFY_EXISTING_DB", matches="true")
@SpringBootTest
@AutoConfigureMockMvc(printOnlyOnFailure=false, print=org.springframework.boot.test.autoconfigure.web.servlet.MockMvcPrint.NONE)
class LegacyDatabaseReadIntegrationTest {
@Autowired DatabaseService database;
@Autowired MockMvc mvc;
@Test void readsExistingListsThroughHttpWithNumericLegacyFlags() throws Exception {
// The session default is not the active transaction's access mode. Enforced READ ONLY
// is verified at the transaction boundary in DatabaseServiceTest, without test writes.
assertThat(database.checkConnection().connected()).isTrue();
// Select only a scope ID. Authentication is simulated in this test; no real account is used.
Long hq=database.queryForObject("SELECT hq_id FROM job ORDER BY jb_ordertime DESC LIMIT 1",Long.class);
assertThat(hq).isPositive();
var principal=new LegacyUser(0,0,hq,"read-only-integration-test","111111");
var auth=UsernamePasswordAuthenticationToken.authenticated(principal,null,List.of());
for(String route:List.of("/api/orders","/api/customers","/api/couriers")) {
mvc.perform(get(route).with(authentication(auth)))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$[0].id").isNumber());
}
Object archived=database.queryForObject("SELECT cmp_archived FROM company WHERE cmp_archived IS NOT NULL LIMIT 1",Object.class);
assertThat(archived).isInstanceOf(Number.class);
}
}
@@ -0,0 +1,98 @@
package de.stadtbote.votian;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static de.stadtbote.votian.Models.*;
class LegacyMappingTest {
@Test void mapsBooleanCompanyFlagsForActiveAndArchivedCustomers() {
var row=new HashMap<String,Object>(Map.of("cs_id",1,"cmp_archived",false,"cmp_visible",true,"cmp_authenticated",true));
assertThat(LegacyMapping.customer(row).active).isTrue();
row.put("cmp_archived",true);
assertThat(LegacyMapping.customer(row).active).isFalse();
row.put("cmp_archived",false);row.put("cmp_visible",false);
assertThat(LegacyMapping.customer(row).active).isFalse();
row.put("cmp_visible",true);row.put("cmp_authenticated",false);
assertThat(LegacyMapping.customer(row).active).isFalse();
}
@Test void mapsBooleanCompanyFlagsAndAvailabilityForCouriers() {
var row=new HashMap<String,Object>(Map.of("cr_id",1,"cmp_archived",false,"cmp_visible",true,"cmp_authenticated",true,"cr_available",true));
var courier=LegacyMapping.courier(row);
assertThat(courier.active).isTrue();
assertThat(courier.status).isEqualTo(CourierStatus.AVAILABLE);
row.put("cr_available",false);
assertThat(LegacyMapping.courier(row).status).isEqualTo(CourierStatus.OFFLINE);
row.put("active_jobs",1);
assertThat(LegacyMapping.courier(row).status).isEqualTo(CourierStatus.BUSY);
row.put("cmp_archived",true);
courier=LegacyMapping.courier(row);
assertThat(courier.active).isFalse();
assertThat(courier.status).isEqualTo(CourierStatus.OFFLINE);
row.put("cmp_archived",false);row.put("cmp_visible",false);
assertThat(LegacyMapping.courier(row).active).isFalse();
row.put("cmp_visible",true);row.put("cmp_authenticated",false);
assertThat(LegacyMapping.courier(row).active).isFalse();
}
@Test void preservesNumericStatusCancellationAndWarehouseDirectionValues() {
var row=new HashMap<String,Object>(Map.of("jb_id",1,"jb_status",2,"jb_storno",3));
var order=LegacyMapping.order(row);
assertThat(order.legacyStatus).isEqualTo(2);
assertThat(order.legacyStorno).isEqualTo(3);
assertThat(order.cancelMode).isEqualTo(CancelMode.CHARGEABLE);
assertThat(order.isCreditNote).isTrue();
row.put("jb_storno",4);
order=LegacyMapping.order(row);
assertThat(order.legacyStatus).isEqualTo(2);
assertThat(order.legacyStorno).isEqualTo(4);
assertThat(order.cancelMode).isEqualTo(CancelMode.FREE);
assertThat(order.isCreditNote).isTrue();
row.put("jb_storno",0);row.put("jb_status",9);
assertThat(LegacyMapping.order(row).legacyStatus).isEqualTo(9);
assertThat(LegacyMapping.order(row).status).isEqualTo(OrderStatus.DISPOSITION);
assertThat(LegacyMapping.stop(Map.of("tr_id",1,"tr_sort",2,"tr_ware_from_to",3)).type).isEqualTo(StopType.BOTH);
}
@Test void mapsLegacyStatusesWithoutLosingAutorankingOrCreditNote() {
var row=new HashMap<String,Object>(Map.of("jb_id",12,"jb_status",8,"jb_storno",0,"jb_totalprice",new BigDecimal("119.42"),"jb_subtotalprice",new BigDecimal("110.00"),"app_customer_id",15,"app_payer_customer_id",19,"jb_service",5));
var order=LegacyMapping.order(row);
assertThat(order.status).isEqualTo(OrderStatus.DISPOSITION);
assertThat(order.legacyStatusLabel).isEqualTo("Autoranking");
assertThat(order.netPrice).isEqualByComparingTo("119.42");
assertThat(order.customerId).isEqualTo(15L);
assertThat(order.payerCustomerId).isEqualTo(19L);
assertThat(order.legacyService).isEqualTo(5);
row.put("jb_storno",3);row.put("jb_status",2);
order=LegacyMapping.order(row);
assertThat(order.isCreditNote).isTrue();
assertThat(order.cancelMode).isEqualTo(CancelMode.CHARGEABLE);
assertThat(order.legacyStatusLabel).contains("Storno-Gutschrift");
}
@Test void respectsExplicitWarehouseDirectionBeforeStationPosition() {
var row=new HashMap<String,Object>(Map.of("tr_id",1,"tr_sort",3,"tr_ware_from_to",1));
assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.PICKUP);
row.put("tr_ware_from_to",3);
assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.BOTH);
row.put("tr_ware_from_to",2);row.put("tr_sort",1);
assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.DELIVERY);
row.put("tr_ware_from_to",0);
assertThat(LegacyMapping.stop(row).type).isEqualTo(StopType.PICKUP);
}
@Test void missingValuesAndMysqlZeroDatesRemainUnknown() {
var order=LegacyMapping.order(Map.of("jb_id",7,"jb_ordertime","0000-00-00 00:00:00","jb_modify","20261001123159"));
assertThat(order.pickupAt).isNull();
assertThat(order.netPrice).isNull();
assertThat(order.distanceKm).isNull();
assertThat(order.packages).isNull();
assertThat(order.updatedAt.toString()).isEqualTo("2026-10-01T12:31:59");
}
@Test void csvEscapesFormulaAndQuotes() {
assertThat(ApiController.csvCell("=HYPERLINK(\"test\")")).isEqualTo("\"'=HYPERLINK(\"\"test\"\")\"");
assertThat(ApiController.csvCell(null)).isEqualTo("\"\"");
}
@Test void doesNotPublishCredentialsFromLegacyUrl() {
assertThatThrownBy(()->ApiController.safeLegacyUrl("https://user:secret@example.org/votian")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(()->ApiController.safeLegacyUrl("javascript:alert(1)")).isInstanceOf(IllegalArgumentException.class);
assertThat(ApiController.safeLegacyUrl("https://example.org/votian/")).isEqualTo("https://example.org/votian");
}
}
@@ -0,0 +1,60 @@
package de.stadtbote.votian;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import java.util.function.Supplier;
import org.mockito.ArgumentCaptor;
import de.stadtbote.votian.database.DatabaseService;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class LegacyReadServiceTest {
final DatabaseService database=mock(DatabaseService.class);
final LegacyReadService service=new LegacyReadService(database,500);
final LegacyUser allowed=new LegacyUser(12,13,7,"disponent","111111111111");
@BeforeEach void configureTransactions() {
when(database.readTransaction(any())).thenAnswer(call -> ((Supplier<?>)call.getArgument(0)).get());
}
@Test void refusesUnpermittedDataWithoutTouchingDatabase() {
var denied=new LegacyUser(1,2,7,"restricted","0000000000");
assertThatThrownBy(()->service.orders(denied,null,null,null,null,null,null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->service.customers(denied,null)).isInstanceOf(AccessDeniedException.class);
assertThatThrownBy(()->service.couriers(denied,null)).isInstanceOf(AccessDeniedException.class);
verifyNoInteractions(database);
}
@Test void parameterizesScopeAndSearchAndLimitsOnlyAfterSqlFilters() {
when(database.queryForObject(anyString(),eq(Long.class),any(Object[].class))).thenReturn(0L);
when(database.queryForList(anyString(),any(Object[].class))).thenReturn(List.of());
service.orders(allowed,"' OR 1=1 --",null,23L,42L,LocalDate.of(2026,10,1),LocalDate.of(2026,10,5));
ArgumentCaptor<String> sql=ArgumentCaptor.forClass(String.class);
ArgumentCaptor<Object[]> args=ArgumentCaptor.forClass(Object[].class);
verify(database).queryForList(sql.capture(),args.capture());
assertThat(sql.getValue()).contains("jb.hq_id = ?","cc.cs_id = ?","jb.cr_id = ?","LIMIT 500").doesNotContain("OR 1=1");
assertThat(args.getValue()[0]).isEqualTo(7L);
assertThat(Arrays.asList(args.getValue())).contains("%' OR 1=1 --%");
}
@Test void singleOrderCannotEscapeHeadquarters() {
when(database.queryForList(anyString(),any(Object[].class))).thenReturn(List.of());
assertThatThrownBy(()->service.order(allowed,999)).isInstanceOf(ResponseStatusException.class);
verify(database).queryForList(contains("WHERE jb.jb_id=? AND jb.hq_id=?"),eq(999L),eq(7L));
}
@Test void customerAndCourierQueriesRemainScopedToHomeBranch() {
when(database.queryForList(anyString(),any(Object[].class))).thenReturn(List.of());
service.customers(allowed,null);service.couriers(allowed,null);
verify(database).queryForList(contains("WHERE cs.hq_id=?"),eq(7L));
verify(database).queryForList(contains("WHERE u.hq_id=? AND cr.hq_id=u.hq_id"),eq(7L));
}
@Test void invalidDateRangeNeverHitsDatabase() {
assertThatThrownBy(()->service.orders(allowed,null,null,null,null,LocalDate.of(2026,10,5),LocalDate.of(2026,10,1))).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(database);
}
@Test void invalidSearchIsRejectedBeforeOpeningATransaction() {
assertThatThrownBy(()->service.orders(allowed,"x".repeat(201),null,null,null,null,null)).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(database);
}
}
@@ -0,0 +1,62 @@
package de.stadtbote.votian.database;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class DatabaseDiagnosticsTest {
private final DatabaseService database = mock(DatabaseService.class);
private final DatabaseDiagnostics diagnostics = new DatabaseDiagnostics(database);
@Test void rejectedConnectionDoesNotReadMetadata() {
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(
false, "AUTHENTICATION_FAILED", "Anmeldung abgewiesen."));
DatabaseDiagnostics.Result result = diagnostics.check();
assertThat(result.exitCode()).isEqualTo(1);
assertThat(result.details()).containsEntry("connected", false).containsEntry("errorCode", 1045)
.containsEntry("applicationRowsRead", 0).containsEntry("writes", 0);
verify(database).checkConnection();
verifyNoMoreInteractions(database);
}
@Test void reportsMissingColumnsWithoutQueryingApplicationTables() {
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(true, "CONNECTED", "Verbunden."));
when(database.queryForObject("SELECT VERSION()", String.class)).thenReturn("test-server");
String metadataSql = "SELECT TABLE_NAME AS table_name,COLUMN_NAME AS column_name "
+ "FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE()";
when(database.queryForList(metadataSql)).thenReturn(List.of(
Map.of("table_name", "headquarters", "column_name", "hq_id"),
Map.of("table_name", "job", "column_name", "jb_id")));
DatabaseDiagnostics.Result result = diagnostics.check();
assertThat(result.exitCode()).isEqualTo(1);
assertThat(result.details()).containsEntry("connected", true).containsEntry("serverVersion", "test-server")
.containsEntry("checkedTables", 13).containsEntry("applicationRowsRead", 0).containsEntry("writes", 0);
@SuppressWarnings("unchecked")
Map<String, List<String>> missing = (Map<String, List<String>>) result.details().get("missingColumns");
assertThat(missing).doesNotContainKey("headquarters");
assertThat(missing.get("job")).contains("jb_totalprice", "csc_id_payer").doesNotContain("jb_id");
verify(database).checkConnection();
verify(database).queryForObject("SELECT VERSION()", String.class);
verify(database).queryForList(metadataSql);
verifyNoMoreInteractions(database);
}
@Test void neverReturnsJdbcExceptionDetails() {
when(database.checkConnection()).thenReturn(new DatabaseService.ConnectionCheck(true, "CONNECTED", "Verbunden."));
when(database.queryForObject("SELECT VERSION()", String.class))
.thenThrow(new IllegalStateException("jdbc:vendor://private-host password=secret"));
DatabaseDiagnostics.Result result = diagnostics.check();
assertThat(result.exitCode()).isEqualTo(1);
assertThat(result.details()).containsEntry("connected", false).containsEntry("reason", "UNAVAILABLE")
.containsEntry("writes", 0);
assertThat(result.details().toString()).doesNotContain("private-host", "secret", "jdbc:");
}
}
@@ -0,0 +1,119 @@
package de.stadtbote.votian.database;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.jdbc.CannotGetJdbcConnectionException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.CannotCreateTransactionException;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.SimpleTransactionStatus;
import java.sql.SQLException;
import java.sql.Connection;
import java.sql.Statement;
import javax.sql.DataSource;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class DatabaseServiceTest {
final JdbcTemplate jdbc=mock(JdbcTemplate.class);
final PlatformTransactionManager transactions=mock(PlatformTransactionManager.class);
final SimpleTransactionStatus transaction=new SimpleTransactionStatus();
final DatabaseService database=new DatabaseService(jdbc,transactions);
DatabaseServiceTest() {
when(transactions.getTransaction(any())).thenReturn(transaction);
clearInvocations(transactions);
}
@Test void enforcesReadOnlyOnTheServerBeforeExecutingQueries() throws Exception {
var source=mock(DataSource.class);
var connection=mock(Connection.class);
var statement=mock(Statement.class);
when(source.getConnection()).thenReturn(connection);
when(connection.getAutoCommit()).thenReturn(true);
when(connection.createStatement()).thenReturn(statement);
var manager=new DataSourceTransactionManager(source);
var service=new DatabaseService(jdbc,manager);
when(jdbc.queryForObject("SELECT 1",Integer.class,new Object[0])).thenReturn(1);
assertThat(service.queryForObject("SELECT 1",Integer.class)).isEqualTo(1);
assertThat(manager.isEnforceReadOnly()).isTrue();
var order=inOrder(statement,jdbc,connection);
order.verify(statement).executeUpdate("SET TRANSACTION READ ONLY");
order.verify(jdbc).queryForObject("SELECT 1",Integer.class,new Object[0]);
order.verify(connection).commit();
verify(connection).close();
}
@Test void bindsValuesWithoutInsertingThemIntoSqlAndUsesReadOnlyTransactions() {
String sql="SELECT jb_id FROM job WHERE jb_commission_no=? AND jb_storno=?";
Object[] values={"'; DELETE FROM job; --",null};
var rows=List.of(Map.<String,Object>of("jb_id",17));
when(jdbc.queryForList(sql,values)).thenReturn(rows);
assertThat(database.queryForList(sql,values)).isSameAs(rows);
verify(jdbc).queryForList(sql,values);
var definition=ArgumentCaptor.forClass(TransactionDefinition.class);
verify(transactions).getTransaction(definition.capture());
assertThat(definition.getValue().isReadOnly()).isTrue();
verify(transactions).commit(transaction);
}
@Test void typedQueriesPreserveNullResultsAndBoundParameters() {
when(jdbc.queryForObject("SELECT MAX(jb_id) FROM job WHERE hq_id=?",Long.class,7L)).thenReturn(null);
assertThat(database.queryForObject("SELECT MAX(jb_id) FROM job WHERE hq_id=?",Long.class,7L)).isNull();
verify(jdbc).queryForObject("SELECT MAX(jb_id) FROM job WHERE hq_id=?",Long.class,7L);
}
@ParameterizedTest
@ValueSource(strings={"UPDATE job SET jb_status=2", "INSERT INTO job VALUES (?)", "DELETE FROM job", "CREATE TABLE test(id INT)", "CALL procedure_name()", "SET autocommit=1", "SELECT 1; DELETE FROM job", "SELECT 1 INTO OUTFILE '/tmp/data'", "SELECT jb_id FROM job FOR UPDATE", "/* read */ DELETE FROM job", "SELECT 1 -- comment"})
void rejectsNonReadStatementsBeforeJdbcOrTransactions(String sql) {
assertThatIllegalArgumentException().isThrownBy(()->database.queryForList(sql));
assertThatIllegalArgumentException().isThrownBy(()->database.queryForObject(sql,Integer.class));
verifyNoInteractions(jdbc,transactions);
}
@Test void writeEntryPointIsClosedBeforeOpeningAnyConnection() {
assertThatThrownBy(()->database.update("UPDATE job SET jb_status=? WHERE jb_id=?",2,17))
.isInstanceOf(DatabaseWritesDisabledException.class);
verifyNoInteractions(jdbc,transactions);
}
@Test void failedQueriesRollbackAndDoNotReturnAnEmptyReplacement() {
var failure=new DataAccessResourceFailureException("private database detail");
when(jdbc.queryForList("SELECT jb_id FROM job",new Object[0])).thenThrow(failure);
assertThatThrownBy(()->database.queryForList("SELECT jb_id FROM job")).isSameAs(failure);
verify(transactions).rollback(transaction);
verify(transactions,never()).commit(any());
}
@Test void transactionConnectionFailuresUseTheSameDatabaseErrorContract() {
when(transactions.getTransaction(any())).thenThrow(new CannotCreateTransactionException("private details"));
assertThatThrownBy(()->database.queryForObject("SELECT 1",Integer.class))
.isInstanceOf(DataAccessResourceFailureException.class)
.hasMessage(DatabaseService.DATABASE_UNAVAILABLE);
}
@Test void connectionCheckRecognizesLoginDenialInsideTransactionFailure() {
when(transactions.getTransaction(any())).thenThrow(new CannotCreateTransactionException("private detail",new SQLException("password must not be exposed","28000",1045)));
var result=database.checkConnection();
assertThat(result.connected()).isFalse();
assertThat(result.reason()).isEqualTo("AUTHENTICATION_FAILED");
assertThat(result.message()).doesNotContain("password must not be exposed");
}
@Test void connectionCheckDistinguishesMissingDatabaseRightsAndNetworkFailure() {
when(jdbc.queryForObject("SELECT 1",Integer.class,new Object[0]))
.thenThrow(new CannotGetJdbcConnectionException("private",new SQLException("private","42000",1044)))
.thenThrow(new CannotGetJdbcConnectionException("private"));
assertThat(database.checkConnection().reason()).isEqualTo("DATABASE_ACCESS_DENIED");
assertThat(database.checkConnection().reason()).isEqualTo("UNAVAILABLE");
}
}
@@ -0,0 +1 @@
mock-maker-subclass