decrypt(UserSigningCredentials credentials) {
- if (!properties.getSigning().hasMasterKey()) {
- log.warn("Master-Key fehlt – nutzerseitige Credentials für {} können nicht entschlüsselt werden.",
- credentials.getUserId());
- return Optional.empty();
- }
- try {
- AesGcmCipher cipher = newCipher();
- byte[] keystoreBytes = cipher.decrypt(credentials.getEncryptedKeystore());
- byte[] passwordBytes = cipher
- .decrypt(java.util.Base64.getDecoder().decode(credentials.getEncryptedPassword()));
- char[] password = new String(passwordBytes, StandardCharsets.UTF_8).toCharArray();
-
- KeyStore keystore = KeyStore.getInstance("PKCS12");
- try (ByteArrayInputStream in = new ByteArrayInputStream(keystoreBytes)) {
- keystore.load(in, password);
- }
- return Optional.of(new LoadedCredentials(keystore, password, credentials.getKeyAlias(), credentials));
- } catch (Exception ex) {
- log.warn("Nutzer-Keystore für {} konnte nicht entschlüsselt werden: {}", credentials.getUserId(),
- ex.getMessage());
- return Optional.empty();
- }
- }
-
- private ValidationResult validate(byte[] p12Bytes, String password, String alias) {
- try {
- KeyStore keystore = KeyStore.getInstance("PKCS12");
- try (ByteArrayInputStream in = new ByteArrayInputStream(p12Bytes)) {
- keystore.load(in, password.toCharArray());
- }
- if (!keystore.containsAlias(alias)) {
- throw new IllegalArgumentException("Alias '" + alias + "' nicht im Keystore enthalten.");
- }
- PrivateKey privateKey = (PrivateKey) keystore.getKey(alias, password.toCharArray());
- if (privateKey == null) {
- throw new IllegalArgumentException(
- "Im Alias '" + alias + "' wurde kein privater Schlüssel gefunden.");
- }
- Certificate[] chain = keystore.getCertificateChain(alias);
- if (chain == null || chain.length == 0 || !(chain[0] instanceof X509Certificate)) {
- throw new IllegalArgumentException("Kein verwendbares X.509-Zertifikat im Alias '" + alias + "'.");
- }
- X509Certificate cert = (X509Certificate) chain[0];
- ValidationResult result = new ValidationResult();
- result.subjectDn = cert.getSubjectX500Principal().getName();
- result.issuerDn = cert.getIssuerX500Principal().getName();
- result.serialNumber = cert.getSerialNumber().toString(16);
- result.validFrom = toLocalDateTime(cert.getNotBefore());
- result.validUntil = toLocalDateTime(cert.getNotAfter());
- return result;
- } catch (IllegalArgumentException ex) {
- throw ex;
- } catch (java.io.IOException ex) {
- throw new IllegalArgumentException("Keystore konnte nicht gelesen werden – falsches Passwort?", ex);
- } catch (Exception ex) {
- throw new IllegalArgumentException("Keystore-Validierung fehlgeschlagen: " + ex.getMessage(), ex);
- }
- }
-
- private AesGcmCipher newCipher() {
- return new AesGcmCipher(properties.getSigning().getMasterKey());
- }
-
- private void ensureMasterKey() {
- if (!properties.getSigning().hasMasterKey()) {
- throw new IllegalStateException(
- "Master-Key (votianlt.einvoice.signing.master-key) ist nicht konfiguriert. "
- + "Setzen Sie einen mindestens 16 Zeichen langen Master-Key, bevor Sie Nutzer-Keystores hinterlegen.");
- }
- }
-
- private LocalDateTime toLocalDateTime(Date date) {
- return date == null ? null : date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
- }
-
- private static class ValidationResult {
- String subjectDn;
- String issuerDn;
- String serialNumber;
- LocalDateTime validFrom;
- LocalDateTime validUntil;
- }
-
- /**
- * Bündel aus einem im Speicher entschlüsselten Keystore plus zugehörigem Klartext-Passwort.
- */
- public static final class LoadedCredentials {
-
- private final KeyStore keystore;
- private final char[] password;
- private final String alias;
- private final UserSigningCredentials metadata;
-
- LoadedCredentials(KeyStore keystore, char[] password, String alias, UserSigningCredentials metadata) {
- this.keystore = keystore;
- this.password = password;
- this.alias = alias;
- this.metadata = metadata;
- }
-
- public KeyStore getKeystore() {
- return keystore;
- }
-
- public char[] getPassword() {
- return password;
- }
-
- public String getAlias() {
- return alias;
- }
-
- public UserSigningCredentials getMetadata() {
- return metadata;
- }
- }
-}
diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties
index 8f1a33c..ad53bf9 100644
--- a/backend/src/main/resources/application.properties
+++ b/backend/src/main/resources/application.properties
@@ -95,34 +95,4 @@ spring.ai.openai.read-timeout=120s
spring.ai.mcp.server.enabled=true
spring.ai.mcp.server.name=votianlt-mcp-server
spring.ai.mcp.server.version=1.0.0
-spring.ai.mcp.server.sse-message-endpoint=/mcp/message
-
-# ===========================================
-# E-Rechnung (ZUGFeRD/Factur-X) und PAdES-Signatur
-# ===========================================
-# Aktivieren Sie die ZUGFeRD-Anreicherung systemweit. Pro Nutzer entscheidet
-# zusätzlich das Profilfeld eInvoiceEnabled, ob es tatsächlich angewendet wird.
-votianlt.einvoice.enabled=false
-votianlt.einvoice.profile=EN16931
-
-# PAdES-Signatur: nur aktiv, wenn unten ein gültiger Keystore konfiguriert ist.
-votianlt.einvoice.signing.enabled=false
-votianlt.einvoice.signing.keystore-path=
-votianlt.einvoice.signing.keystore-password=
-votianlt.einvoice.signing.key-alias=
-votianlt.einvoice.signing.reason=Rechnung
-votianlt.einvoice.signing.location=
-votianlt.einvoice.signing.contact=
-# Master-Key (>= 16 Zeichen) zum Verschlüsseln nutzerseitig hinterlegter PKCS#12-Keystores.
-# Verlust dieses Keys macht alle gespeicherten Nutzer-Keystores unbrauchbar.
-#
-# SICHERHEITSEMPFEHLUNG (Stufe 2/3):
-# - Den Key NIEMALS inline hier hinterlegen — nutzen Sie ENV oder eine Secret-Datei.
-# - ENV-Variante: VOTIANLT_EINVOICE_SIGNING_MASTER_KEY=...
-# (oder Lower-Case-Equivalent via Spring Relaxed Binding)
-# - Secret-Datei-Variante: master-key-file zeigt auf eine Datei (Docker-/K8s-Secret),
-# chmod 600 auf Bare-Metal/VM-Deployments.
-# - Die Spring-Placeholder-Syntax ${VAR:default} liest die ENV automatisch.
-# - application.properties selbst sollte nicht weltlesbar sein (chmod 600).
-votianlt.einvoice.signing.master-key=${VOTIANLT_EINVOICE_SIGNING_MASTER_KEY:}
-votianlt.einvoice.signing.master-key-file=${VOTIANLT_EINVOICE_SIGNING_MASTER_KEY_FILE:}
\ No newline at end of file
+spring.ai.mcp.server.sse-message-endpoint=/mcp/message
\ No newline at end of file
diff --git a/backend/src/main/resources/messages_de.properties b/backend/src/main/resources/messages_de.properties
index 304f22a..aa4e5b2 100644
--- a/backend/src/main/resources/messages_de.properties
+++ b/backend/src/main/resources/messages_de.properties
@@ -9,17 +9,6 @@ nav.customers=Adressbuch
nav.appusers=App-Nutzer
nav.statistics=Statistiken
nav.invoices=Rechnungen
-nav.datev.export=DATEV-Export
-nav.approvals=Freigaben
-datev.export.title=DATEV-Export
-datev.export.description=Lädt einen DATEV-kompatiblen Buchungsstapel mit allen festgeschriebenen Rechnungen des gewählten Zeitraums herunter. Die Datei kann in DATEV Unternehmen Online sowie in DATEV-importfähigen Drittprogrammen eingelesen werden.
-datev.export.from=Von
-datev.export.to=Bis
-datev.export.button=Rechnungen exportieren
-datev.export.success=Export erstellt: {0}
-datev.export.error.dates=Bitte Von- und Bis-Datum auswählen.
-datev.export.error.range=Das Bis-Datum darf nicht vor dem Von-Datum liegen.
-datev.export.error.user=Aktueller Nutzer konnte nicht ermittelt werden.
nav.messages=Nachrichten
nav.profile=Mein Profil
nav.myinvoices=Rechnungen
@@ -58,36 +47,6 @@ profile.settings.digitalprocess.info=Aufträge werden digital über die App abge
profile.settings.locateappuser=App-Nutzer orten
profile.settings.locateappuser.info=Standort der App-Nutzer wird regelmäßig übertragen
profile.settings.vatrate=Umsatzsteuer
-profile.settings.einvoice=ZUGFeRD-E-Rechnung erstellen
-profile.settings.einvoice.helper=Erzeugt PDF/A-3 mit eingebettetem XRechnung/ZUGFeRD-XML (sofern systemweit aktiviert).
-profile.settings.signinvoices=Rechnungen digital signieren
-profile.settings.signinvoices.helper=Erzeugt eine PAdES-Signatur mit dem hinterlegten Zertifikat. Ohne aktives Zertifikat schlägt das Speichern fehl.
-profile.signing.title=Signatur-Zertifikat
-profile.signing.hint=Hinterlegen Sie Ihr eigenes PKCS#12-Zertifikat (.p12/.pfx), damit Ihre Rechnungen mit Ihrer Signatur erstellt werden. Der private Schlüssel wird verschlüsselt in der Datenbank gespeichert.
-profile.signing.masterkey.missing=Hinweis: Der Server-Master-Key ist nicht gesetzt. Bitten Sie Ihren Administrator, votianlt.einvoice.signing.master-key zu konfigurieren, bevor Sie ein Zertifikat hinterlegen.
-profile.signing.none=Es ist noch kein eigenes Signatur-Zertifikat hinterlegt. Beim Signieren wird der systemweit konfigurierte Schlüssel verwendet.
-profile.signing.metadata.alias=Alias
-profile.signing.metadata.subject=Inhaber
-profile.signing.metadata.issuer=Aussteller
-profile.signing.metadata.serial=Seriennummer
-profile.signing.metadata.validity=Gültig
-profile.signing.expired=Zertifikat abgelaufen
-profile.signing.expiring=Läuft in den nächsten 30 Tagen ab
-profile.signing.enabled=Eigenes Zertifikat zum Signieren verwenden
-profile.signing.toggle.saved=Einstellung gespeichert.
-profile.signing.delete=Zertifikat entfernen
-profile.signing.deleted=Signatur-Zertifikat entfernt.
-profile.signing.upload.title=Zertifikat hochladen
-profile.signing.upload.drop=PKCS#12-Datei hier ablegen oder klicken
-profile.signing.upload.received=Datei empfangen — bitte Alias und Passwort angeben.
-profile.signing.upload.required=Bitte zuerst eine Zertifikatsdatei hochladen.
-profile.signing.upload.save=Speichern
-profile.signing.alias=Schlüssel-Alias
-profile.signing.alias.required=Bitte den Alias des Schlüssels angeben.
-profile.signing.password=Keystore-Passwort
-profile.signing.password.required=Bitte das Keystore-Passwort angeben.
-profile.signing.saved=Signatur-Zertifikat gespeichert.
-profile.signing.error=Speichern fehlgeschlagen
profile.account=Konto
profile.security=Sicherheit
profile.security.twofactor=Zwei-Faktor-Authentifizierung
@@ -798,8 +757,6 @@ invoices.audit.action.replaced=Ersetzt durch neue Rechnung
invoices.audit.action.deleted_draft=Entwurf gelöscht
invoices.audit.action.payment_recorded=Zahlung erfasst
invoices.audit.resulting=Erzeugter Folgebeleg: {0}
-invoices.column.payment=Zahlung
-invoices.column.outstanding=Offen
invoices.payment.unpaid=Offen
invoices.payment.partially_paid=Teilzahlung
invoices.payment.paid=Bezahlt
@@ -815,28 +772,6 @@ invoices.payment.reference=Zahlungsreferenz (z.B. Kontoauszug, Buchungs-Nr.)
invoices.payment.reason=Anmerkung
invoices.payment.confirm=Zahlung erfassen
invoices.notification.payment=Zahlung erfasst.
-invoices.einvoice.tooltip=PDF/A-3 mit eingebettetem ZUGFeRD/XRechnung-XML
-invoices.einvoice.signed=Signiert
-invoices.action.cancel.request=Storno beantragen
-invoices.action.correct.request=Berichtigung beantragen
-invoices.notification.requested=Freigabe-Anfrage erstellt. Bitte auf Freigabe warten.
-approvals.title=Freigaben
-approvals.no.permission=Sie haben keine Berechtigung, Freigaben zu bearbeiten.
-approvals.column.requested=Beantragt am
-approvals.column.requester=Beantragt von
-approvals.column.invoice=Rechnung
-approvals.column.action=Aktion
-approvals.column.reason=Grund
-approvals.action.approve=Freigeben
-approvals.action.reject=Ablehnen
-approvals.confirm.approve.title=Anfrage zu Rechnung {0} freigeben
-approvals.confirm.reject.title=Anfrage zu Rechnung {0} ablehnen
-approvals.review.fields=Berichtigte Angaben
-approvals.review.reason=Grund
-approvals.review.comment=Kommentar (optional)
-approvals.notification.approved=Anfrage freigegeben — Folgebeleg wurde erstellt.
-approvals.notification.rejected=Anfrage abgelehnt.
-page.title.approvals=Freigaben
# My Invoices
myinvoices.title=Rechnungen
diff --git a/backend/src/main/resources/messages_en.properties b/backend/src/main/resources/messages_en.properties
index d046770..46f56f1 100644
--- a/backend/src/main/resources/messages_en.properties
+++ b/backend/src/main/resources/messages_en.properties
@@ -9,17 +9,6 @@ nav.customers=Address Book
nav.appusers=App Users
nav.statistics=Statistics
nav.invoices=Invoices
-nav.datev.export=DATEV Export
-nav.approvals=Approvals
-datev.export.title=DATEV Export
-datev.export.description=Downloads a DATEV-compatible booking batch containing all finalized invoices for the selected period. The file can be imported into DATEV Unternehmen Online as well as DATEV-compatible third-party tools.
-datev.export.from=From
-datev.export.to=To
-datev.export.button=Export invoices
-datev.export.success=Export created: {0}
-datev.export.error.dates=Please pick both From and To dates.
-datev.export.error.range=To date must not be before From date.
-datev.export.error.user=Could not determine the current user.
nav.messages=Messages
nav.profile=My Profile
nav.myinvoices=Invoices
@@ -58,36 +47,6 @@ profile.settings.digitalprocess.info=Jobs are processed digitally via the app
profile.settings.locateappuser=Locate App Users
profile.settings.locateappuser.info=App user location is transmitted regularly
profile.settings.vatrate=VAT rate
-profile.settings.einvoice=Create ZUGFeRD e-invoice
-profile.settings.einvoice.helper=Generates PDF/A-3 with embedded XRechnung/ZUGFeRD XML (when enabled system-wide).
-profile.settings.signinvoices=Digitally sign invoices
-profile.settings.signinvoices.helper=Adds a PAdES signature using the configured certificate. Saving fails if no active certificate is available.
-profile.signing.title=Signing certificate
-profile.signing.hint=Upload your own PKCS#12 certificate (.p12/.pfx) so invoices are signed with your signature. The private key is stored encrypted in the database.
-profile.signing.masterkey.missing=Note: The server master key is not configured. Ask your administrator to set votianlt.einvoice.signing.master-key before uploading a certificate.
-profile.signing.none=No personal signing certificate stored yet. The system-wide key will be used when signing.
-profile.signing.metadata.alias=Alias
-profile.signing.metadata.subject=Subject
-profile.signing.metadata.issuer=Issuer
-profile.signing.metadata.serial=Serial number
-profile.signing.metadata.validity=Valid
-profile.signing.expired=Certificate expired
-profile.signing.expiring=Expires within the next 30 days
-profile.signing.enabled=Use my certificate for signing
-profile.signing.toggle.saved=Setting saved.
-profile.signing.delete=Remove certificate
-profile.signing.deleted=Signing certificate removed.
-profile.signing.upload.title=Upload certificate
-profile.signing.upload.drop=Drop your PKCS#12 file here or click to upload
-profile.signing.upload.received=File received — please provide alias and password.
-profile.signing.upload.required=Please upload a certificate file first.
-profile.signing.upload.save=Save
-profile.signing.alias=Key alias
-profile.signing.alias.required=Please provide the key alias.
-profile.signing.password=Keystore password
-profile.signing.password.required=Please provide the keystore password.
-profile.signing.saved=Signing certificate saved.
-profile.signing.error=Save failed
profile.account=Account
profile.security=Security
profile.security.twofactor=Two-Factor Authentication
@@ -798,8 +757,6 @@ invoices.audit.action.replaced=Replaced by new invoice
invoices.audit.action.deleted_draft=Draft deleted
invoices.audit.action.payment_recorded=Payment recorded
invoices.audit.resulting=Resulting document: {0}
-invoices.column.payment=Payment
-invoices.column.outstanding=Outstanding
invoices.payment.unpaid=Open
invoices.payment.partially_paid=Partially paid
invoices.payment.paid=Paid
@@ -815,28 +772,6 @@ invoices.payment.reference=Payment reference (e.g. statement, booking ID)
invoices.payment.reason=Note
invoices.payment.confirm=Record payment
invoices.notification.payment=Payment recorded.
-invoices.einvoice.tooltip=PDF/A-3 with embedded ZUGFeRD/XRechnung XML
-invoices.einvoice.signed=Signed
-invoices.action.cancel.request=Request cancellation
-invoices.action.correct.request=Request correction
-invoices.notification.requested=Approval request created. Please wait for approval.
-approvals.title=Approvals
-approvals.no.permission=You do not have permission to handle approvals.
-approvals.column.requested=Requested at
-approvals.column.requester=Requested by
-approvals.column.invoice=Invoice
-approvals.column.action=Action
-approvals.column.reason=Reason
-approvals.action.approve=Approve
-approvals.action.reject=Reject
-approvals.confirm.approve.title=Approve request for invoice {0}
-approvals.confirm.reject.title=Reject request for invoice {0}
-approvals.review.fields=Corrected information
-approvals.review.reason=Reason
-approvals.review.comment=Comment (optional)
-approvals.notification.approved=Request approved — follow-up document created.
-approvals.notification.rejected=Request rejected.
-page.title.approvals=Approvals
# My Invoices
myinvoices.title=Invoices
diff --git a/backend/src/test/java/de/assecutor/votianlt/service/DatevExportServiceTest.java b/backend/src/test/java/de/assecutor/votianlt/service/DatevExportServiceTest.java
deleted file mode 100644
index c30e8d1..0000000
--- a/backend/src/test/java/de/assecutor/votianlt/service/DatevExportServiceTest.java
+++ /dev/null
@@ -1,173 +0,0 @@
-package de.assecutor.votianlt.service;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.mockito.Mockito.when;
-
-import de.assecutor.votianlt.model.invoices.CustomerInvoice;
-import de.assecutor.votianlt.model.invoices.InvoiceStatus;
-import de.assecutor.votianlt.model.invoices.InvoiceType;
-import de.assecutor.votianlt.repository.CustomerInvoiceRepository;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-import java.math.BigDecimal;
-import java.nio.charset.Charset;
-import java.time.LocalDate;
-import java.util.List;
-
-@ExtendWith(MockitoExtension.class)
-class DatevExportServiceTest {
-
- private static final Charset DATEV_CHARSET = Charset.forName("Windows-1252");
- private static final String USER_ID = "user-1";
-
- @Mock
- private CustomerInvoiceRepository invoiceRepository;
-
- private DatevExportService service;
-
- @BeforeEach
- void setUp() {
- service = new DatevExportService(invoiceRepository);
- }
-
- @Test
- void exportContainsHeaderColumnsAndOneRowPerInvoice() {
- when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
- invoice("R-2026-001", LocalDate.of(2026, 4, 5), new BigDecimal("119.00"),
- new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Kunde A"),
- invoice("R-2026-002", LocalDate.of(2026, 4, 12), new BigDecimal("214.00"),
- new BigDecimal("0.07"), InvoiceType.INVOICE, InvoiceStatus.SENT, "Kunde B")));
-
- byte[] csv = service.export(USER_ID, LocalDate.of(2026, 4, 1), LocalDate.of(2026, 4, 30));
- String content = new String(csv, DATEV_CHARSET);
- String[] lines = content.split("\\r\\n");
-
- assertThat(lines[0]).startsWith("\"EXTF\";700;21;\"Buchungsstapel\"");
- assertThat(lines[0]).contains("20260401;20260430");
- assertThat(lines[1]).startsWith("Umsatz (ohne Soll/Haben-Kz);Soll/Haben-Kennzeichen;WKZ Umsatz;Konto;");
- assertThat(lines).hasSize(4); // header + col-header + 2 invoices
- assertThat(lines[2]).startsWith("119,00;S;\"EUR\";10000;8400;;0504;\"R-2026-001\";");
- assertThat(lines[3]).startsWith("214,00;S;\"EUR\";10000;8300;;1204;\"R-2026-002\";");
- }
-
- @Test
- void cancellationFlipsSollHabenAndUsesAbsoluteAmount() {
- when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
- invoice("S-2026-007", LocalDate.of(2026, 4, 20), new BigDecimal("-119.00"),
- new BigDecimal("0.19"), InvoiceType.CANCELLATION, InvoiceStatus.ISSUED, "Kunde A")));
-
- String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1),
- LocalDate.of(2026, 4, 30)), DATEV_CHARSET);
- String[] lines = content.split("\\r\\n");
-
- assertThat(lines[2]).startsWith("119,00;H;\"EUR\";10000;8400;;");
- assertThat(lines[2]).contains("\"Storno: Kunde A\"");
- }
-
- @Test
- void zeroVatMapsToReverseChargeAccount() {
- when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
- invoice("R-2026-009", LocalDate.of(2026, 4, 7), new BigDecimal("500.00"),
- BigDecimal.ZERO, InvoiceType.INVOICE, InvoiceStatus.ISSUED, "EU-Kunde")));
-
- String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1),
- LocalDate.of(2026, 4, 30)), DATEV_CHARSET);
- String[] lines = content.split("\\r\\n");
-
- assertThat(lines[2]).contains(";8125;");
- }
-
- @Test
- void draftsAreExcluded() {
- when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
- invoice("R-DRAFT", LocalDate.of(2026, 4, 5), new BigDecimal("100.00"),
- new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.DRAFT, "X"),
- invoice("R-ISSUED", LocalDate.of(2026, 4, 6), new BigDecimal("100.00"),
- new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Y")));
-
- String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1),
- LocalDate.of(2026, 4, 30)), DATEV_CHARSET);
- String[] lines = content.split("\\r\\n");
-
- assertThat(lines).hasSize(3);
- assertThat(lines[2]).contains("\"R-ISSUED\"");
- }
-
- @Test
- void invoicesOutsidePeriodAreIgnored() {
- when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
- invoice("R-2026-MAR", LocalDate.of(2026, 3, 31), new BigDecimal("100.00"),
- new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Y"),
- invoice("R-2026-IN", LocalDate.of(2026, 4, 1), new BigDecimal("100.00"),
- new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Y"),
- invoice("R-2026-OUT", LocalDate.of(2026, 5, 1), new BigDecimal("100.00"),
- new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED, "Y")));
-
- String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1),
- LocalDate.of(2026, 4, 30)), DATEV_CHARSET);
- String[] lines = content.split("\\r\\n");
-
- assertThat(lines).hasSize(3);
- assertThat(lines[2]).contains("\"R-2026-IN\"");
- }
-
- @Test
- void emptyResultStillProducesValidHeader() {
- when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of());
-
- String content = new String(service.export(USER_ID, LocalDate.of(2026, 1, 1),
- LocalDate.of(2026, 1, 31)), DATEV_CHARSET);
- String[] lines = content.split("\\r\\n");
-
- assertThat(lines[0]).startsWith("\"EXTF\";700;21;\"Buchungsstapel\"");
- assertThat(lines).hasSize(2);
- }
-
- @Test
- void quotesInsideRecipientAreEscaped() {
- when(invoiceRepository.findByUserId(USER_ID)).thenReturn(List.of(
- invoice("R-2026-QT", LocalDate.of(2026, 4, 15), new BigDecimal("119.00"),
- new BigDecimal("0.19"), InvoiceType.INVOICE, InvoiceStatus.ISSUED,
- "Acme \"Premium\" GmbH")));
-
- String content = new String(service.export(USER_ID, LocalDate.of(2026, 4, 1),
- LocalDate.of(2026, 4, 30)), DATEV_CHARSET);
- // DATEV escaped Anführungszeichen durch Verdoppeln.
- assertThat(content).contains("\"Rechnung an Acme \"\"Premium\"\" GmbH\"");
- }
-
- @Test
- void rejectsInvalidInputs() {
- assertThatThrownBy(() -> service.export(null, LocalDate.now(), LocalDate.now()))
- .isInstanceOf(IllegalArgumentException.class);
- assertThatThrownBy(() -> service.export(USER_ID, null, LocalDate.now()))
- .isInstanceOf(IllegalArgumentException.class);
- assertThatThrownBy(() -> service.export(USER_ID, LocalDate.of(2026, 5, 1), LocalDate.of(2026, 4, 1)))
- .isInstanceOf(IllegalArgumentException.class);
- }
-
- @Test
- void filenameContainsDateRange() {
- String filename = service.suggestFilename(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 31));
- assertThat(filename).isEqualTo("EXTF_Buchungsstapel_20260101_20260331.csv");
- }
-
- private CustomerInvoice invoice(String number, LocalDate date, BigDecimal total, BigDecimal vatRate,
- InvoiceType type, InvoiceStatus status, String recipient) {
- CustomerInvoice invoice = new CustomerInvoice();
- invoice.setInvoiceNumber(number);
- invoice.setInvoiceDate(date);
- invoice.setTotalAmount(total);
- invoice.setVatRate(vatRate);
- invoice.setType(type);
- invoice.setStatus(status);
- invoice.setRecipientName(recipient);
- invoice.setUserId(USER_ID);
- return invoice;
- }
-}
diff --git a/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceDssValidationTest.java b/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceDssValidationTest.java
deleted file mode 100644
index a312a4d..0000000
--- a/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceDssValidationTest.java
+++ /dev/null
@@ -1,271 +0,0 @@
-package de.assecutor.votianlt.service;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.when;
-
-import de.assecutor.votianlt.config.EInvoiceProperties;
-import de.assecutor.votianlt.model.invoices.CustomerInvoice;
-import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
-import de.assecutor.votianlt.model.invoices.UserSigningCredentials;
-import de.assecutor.votianlt.repository.UserSigningCredentialsRepository;
-import eu.europa.esig.dss.diagnostic.DiagnosticData;
-import eu.europa.esig.dss.diagnostic.SignatureWrapper;
-import eu.europa.esig.dss.enumerations.SignatureLevel;
-import eu.europa.esig.dss.model.DSSDocument;
-import eu.europa.esig.dss.model.InMemoryDocument;
-import eu.europa.esig.dss.model.x509.CertificateToken;
-import eu.europa.esig.dss.spi.validation.CommonCertificateVerifier;
-import eu.europa.esig.dss.spi.x509.CommonTrustedCertificateSource;
-import eu.europa.esig.dss.validation.SignedDocumentValidator;
-import eu.europa.esig.dss.validation.reports.Reports;
-import org.bouncycastle.asn1.x500.X500Name;
-import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
-import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
-import org.bouncycastle.jce.provider.BouncyCastleProvider;
-import org.bouncycastle.operator.ContentSigner;
-import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.junit.jupiter.api.io.TempDir;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-import java.io.ByteArrayOutputStream;
-import java.math.BigDecimal;
-import java.math.BigInteger;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.security.KeyPair;
-import java.security.KeyPairGenerator;
-import java.security.KeyStore;
-import java.security.Security;
-import java.security.cert.Certificate;
-import java.security.cert.X509Certificate;
-import java.time.Instant;
-import java.time.LocalDate;
-import java.time.temporal.ChronoUnit;
-import java.util.Date;
-import java.util.List;
-import java.util.Optional;
-
-import org.apache.pdfbox.pdmodel.PDDocument;
-import org.apache.pdfbox.pdmodel.PDPage;
-
-/**
- * Validiert die vom Service erzeugten Signaturen gegen die EU DSS
- * (Digital Signature Service) Bibliothek. Während {@link EInvoiceServiceTest}
- * nur die strukturelle Korrektheit des Signature-Dictionarys prüft, parst
- * DSS hier die Signatur kryptographisch nach und liefert ein Diagnostic-Data-
- * Modell.
- *
- * Geprüft wird pro Signaturpfad (System- und Nutzer-Keystore):
- *
- * - genau eine Signatur ist im Dokument enthalten,
- * - die Signatur ist kryptographisch intakt (Hash & Signaturwert),
- * - das Format ist PAdES-Baseline (mehr ist mit den aktuellen
- * Service-Settings nicht zu erwarten — kein TSA, kein DSS-Dictionary),
- * - der Signer-DN entspricht dem im Test erzeugten Zertifikat.
- *
- *
- * Die Self-Signed-Zertifikate werden als Trust-Anchor in den
- * {@link CommonCertificateVerifier} eingehängt — sonst würde DSS die Kette
- * korrekt als nicht vertrauenswürdig melden, was über die rein technische
- * Signaturprüfung hinausgeht und für unseren Test irrelevant ist.
- */
-@ExtendWith(MockitoExtension.class)
-class EInvoiceServiceDssValidationTest {
-
- private static final String USER_ID = "user-1";
- private static final String SYSTEM_ALIAS = "system-signer";
- private static final String USER_ALIAS = "user-signer";
- private static final String SYSTEM_PASSWORD = "system-pass";
- private static final String USER_PASSWORD = "user-pass";
- private static final String MASTER_KEY = "0123456789abcdef0123456789abcdef";
-
- static {
- if (Security.getProvider("BC") == null) {
- Security.addProvider(new BouncyCastleProvider());
- }
- }
-
- @Mock
- private UserSigningCredentialsRepository repository;
-
- @TempDir
- Path tempDir;
-
- private EInvoiceProperties properties;
- private SigningCredentialsService signingCredentialsService;
- private EInvoiceService eInvoiceService;
-
- @BeforeEach
- void setUp() {
- properties = new EInvoiceProperties();
- properties.setEnabled(true);
- properties.setProfile("EN16931");
- properties.getSigning().setEnabled(false);
- properties.getSigning().setMasterKey(MASTER_KEY);
-
- signingCredentialsService = new SigningCredentialsService(repository, properties);
- eInvoiceService = new EInvoiceService(properties, signingCredentialsService);
- }
-
- @Test
- void systemKeystoreSignaturePassesDssValidation() throws Exception {
- KeystoreFixture fixture = generateKeystore(SYSTEM_ALIAS, SYSTEM_PASSWORD);
- Path keystoreFile = tempDir.resolve("system.p12");
- Files.write(keystoreFile, fixture.keystoreBytes());
- configureSystemKeystore(keystoreFile, SYSTEM_PASSWORD, SYSTEM_ALIAS);
- when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty());
-
- CustomerInvoice invoice = sampleInvoice();
- byte[] signedPdf = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true);
-
- assertDssAcceptsSignature(signedPdf, fixture.certificate(), "Votianlt Test " + SYSTEM_ALIAS);
- }
-
- @Test
- void userKeystoreSignaturePassesDssValidation() throws Exception {
- KeystoreFixture fixture = generateKeystore(USER_ALIAS, USER_PASSWORD);
- when(repository.save(any(UserSigningCredentials.class))).thenAnswer(inv -> inv.getArgument(0));
- UserSigningCredentials stored = signingCredentialsService.store(
- USER_ID, fixture.keystoreBytes(), USER_PASSWORD, USER_ALIAS);
- when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored));
-
- CustomerInvoice invoice = sampleInvoice();
- byte[] signedPdf = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true);
-
- assertDssAcceptsSignature(signedPdf, fixture.certificate(), "Votianlt Test " + USER_ALIAS);
- }
-
- private void assertDssAcceptsSignature(byte[] signedPdf, X509Certificate trustAnchor,
- String expectedSubjectFragment) {
- DSSDocument document = new InMemoryDocument(signedPdf);
- SignedDocumentValidator validator = SignedDocumentValidator.fromDocument(document);
-
- CommonTrustedCertificateSource trustedSource = new CommonTrustedCertificateSource();
- trustedSource.addCertificate(new CertificateToken(trustAnchor));
-
- CommonCertificateVerifier verifier = new CommonCertificateVerifier();
- verifier.setTrustedCertSources(trustedSource);
- verifier.setAIASource(null);
- verifier.setCrlSource(null);
- verifier.setOcspSource(null);
-
- validator.setCertificateVerifier(verifier);
-
- Reports reports = validator.validateDocument();
- DiagnosticData diagnosticData = reports.getDiagnosticData();
-
- List signatureIds = diagnosticData.getSignatureIdList();
- assertThat(signatureIds)
- .as("DSS muss genau eine PAdES-Signatur im PDF erkennen")
- .hasSize(1);
-
- String signatureId = signatureIds.get(0);
- SignatureWrapper signature = diagnosticData.getSignatureById(signatureId);
-
- assertThat(signature.isSignatureIntact())
- .as("Signatur-Hash über den signierten Bytes muss aufgehen")
- .isTrue();
- assertThat(signature.isSignatureValid())
- .as("Signaturwert muss mit dem Public Key des Signer-Zertifikats verifizierbar sein")
- .isTrue();
-
- // Aktueller Stand: der Service erzeugt eine generische PKCS#7-Signatur (subFilter
- // adbe.pkcs7.detached) ohne ESS-signing-certificate-v2-Attribut. DSS klassifiziert
- // das deshalb als PKCS7-B, nicht als PAdES-BASELINE-B. Hochstufung auf
- // PAdES-BASELINE-B verlangt signierte Attribute (ESS Signing Certificate v2,
- // signing-time im subFilter etrsi.RFC3161 oder ETSI.CAdES.detached, etc.) und
- // wäre eine inhaltliche Erweiterung des Services.
- SignatureLevel level = diagnosticData.getSignatureFormat(signatureId);
- assertThat(level)
- .as("Service erzeugt aktuell PKCS#7-B; ein Upgrade auf PAdES-BASELINE-B "
- + "wäre über zusätzliche CMS-signed-attributes möglich")
- .isEqualTo(SignatureLevel.PKCS7_B);
-
- String signerDn = signature.getSigningCertificate().getCertificateDN();
- assertThat(signerDn)
- .as("Signer-DN muss zum Test-Zertifikat passen")
- .contains(expectedSubjectFragment);
- }
-
- private void configureSystemKeystore(Path keystoreFile, String password, String alias) {
- EInvoiceProperties.Signing signing = properties.getSigning();
- signing.setEnabled(true);
- signing.setKeystorePath(keystoreFile.toString());
- signing.setKeystorePassword(password);
- signing.setKeyAlias(alias);
- }
-
- private byte[] buildBlankPdf() throws Exception {
- try (PDDocument document = new PDDocument(); ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
- document.addPage(new PDPage());
- document.save(baos);
- return baos.toByteArray();
- }
- }
-
- private KeystoreFixture generateKeystore(String alias, String password) {
- try {
- KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
- kpg.initialize(2048);
- KeyPair keyPair = kpg.generateKeyPair();
-
- X500Name subject = new X500Name("CN=Votianlt Test " + alias + ",O=Votianlt,C=DE");
- BigInteger serial = BigInteger.valueOf(System.nanoTime());
- Date notBefore = Date.from(Instant.now().minus(1, ChronoUnit.DAYS));
- Date notAfter = Date.from(Instant.now().plus(365, ChronoUnit.DAYS));
-
- JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(subject, serial, notBefore,
- notAfter, subject, keyPair.getPublic());
- ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").setProvider("BC")
- .build(keyPair.getPrivate());
- X509Certificate certificate = new JcaX509CertificateConverter().setProvider("BC")
- .getCertificate(certBuilder.build(signer));
-
- KeyStore keystore = KeyStore.getInstance("PKCS12");
- keystore.load(null, null);
- keystore.setKeyEntry(alias, keyPair.getPrivate(), password.toCharArray(),
- new Certificate[] { certificate });
-
- ByteArrayOutputStream baos = new ByteArrayOutputStream();
- keystore.store(baos, password.toCharArray());
- return new KeystoreFixture(baos.toByteArray(), certificate);
- } catch (Exception ex) {
- throw new IllegalStateException("Test-Keystore konnte nicht erzeugt werden: " + ex.getMessage(), ex);
- }
- }
-
- private CustomerInvoice sampleInvoice() {
- CustomerInvoice invoice = new CustomerInvoice();
- invoice.setUserId(USER_ID);
- invoice.setInvoiceNumber("R-2026-0001");
- invoice.setInvoiceDate(LocalDate.now());
- invoice.setDeliveryDate(LocalDate.now());
- invoice.setSenderName("Votianlt Test GmbH");
- invoice.setSenderAddress("Teststraße 1");
- invoice.setSenderPostcode("12345");
- invoice.setSenderCity("Berlin");
- invoice.setSenderCountry("DE");
- invoice.setRecipientName("Empfänger AG");
- invoice.setRecipientAddress("Kundenweg 2");
- invoice.setRecipientPostcode("54321");
- invoice.setRecipientCity("Hamburg");
- invoice.setRecipientCountry("DE");
- invoice.setDescription("Beratungsleistung");
- invoice.setVatRate(new BigDecimal("0.19"));
- invoice.setNetAmount(new BigDecimal("100.00"));
- invoice.setVatAmount(new BigDecimal("19.00"));
- invoice.setTotalAmount(new BigDecimal("119.00"));
- CustomerInvoiceItem item = new CustomerInvoiceItem(BigDecimal.ONE, "h", "Beratung", new BigDecimal("100.00"),
- new BigDecimal("0.19"));
- invoice.setItems(List.of(item));
- return invoice;
- }
-
- private record KeystoreFixture(byte[] keystoreBytes, X509Certificate certificate) {
- }
-}
diff --git a/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceTest.java b/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceTest.java
deleted file mode 100644
index 10767aa..0000000
--- a/backend/src/test/java/de/assecutor/votianlt/service/EInvoiceServiceTest.java
+++ /dev/null
@@ -1,331 +0,0 @@
-package de.assecutor.votianlt.service;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.when;
-
-import de.assecutor.votianlt.config.EInvoiceProperties;
-import de.assecutor.votianlt.model.invoices.CustomerInvoice;
-import de.assecutor.votianlt.model.invoices.CustomerInvoiceItem;
-import de.assecutor.votianlt.model.invoices.EInvoiceFormat;
-import de.assecutor.votianlt.model.invoices.UserSigningCredentials;
-import de.assecutor.votianlt.repository.UserSigningCredentialsRepository;
-import org.apache.pdfbox.Loader;
-import org.apache.pdfbox.pdmodel.PDDocument;
-import org.apache.pdfbox.pdmodel.PDPage;
-import org.apache.pdfbox.pdmodel.PDPageContentStream;
-import org.apache.pdfbox.pdmodel.font.PDType1Font;
-import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
-import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
-import org.bouncycastle.asn1.x500.X500Name;
-import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
-import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
-import org.bouncycastle.jce.provider.BouncyCastleProvider;
-import org.bouncycastle.operator.ContentSigner;
-import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.junit.jupiter.api.io.TempDir;
-import org.mockito.Mock;
-import org.mockito.junit.jupiter.MockitoExtension;
-
-import java.io.ByteArrayOutputStream;
-import java.math.BigDecimal;
-import java.math.BigInteger;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.security.KeyPair;
-import java.security.KeyPairGenerator;
-import java.security.KeyStore;
-import java.security.Security;
-import java.security.cert.Certificate;
-import java.security.cert.X509Certificate;
-import java.time.Instant;
-import java.time.LocalDate;
-import java.time.temporal.ChronoUnit;
-import java.util.Date;
-import java.util.List;
-import java.util.Optional;
-
-/**
- * Integrationstest des Signaturpfads — deckt System-Keystore, Nutzer-Keystore,
- * Vorrang-Logik und alle wichtigen Fehlerpfade ab. Verwendet bewusst keinen
- * vollen Spring-Boot-Kontext: das vermeidet die Mongo-Abhängigkeit der echten
- * Repository-Klasse, lässt aber die echten Service-Klassen (Verschlüsselung,
- * PDFBox/BouncyCastle-Signatur) durchlaufen.
- *
- * Das Zertifikat wird zur Testzeit per BouncyCastle erzeugt — der Test ist
- * damit reproduzierbar und braucht keine Datei-Fixtures.
- */
-@ExtendWith(MockitoExtension.class)
-class EInvoiceServiceTest {
-
- private static final String USER_ID = "user-1";
- private static final String SYSTEM_ALIAS = "system-signer";
- private static final String USER_ALIAS = "user-signer";
- private static final String SYSTEM_PASSWORD = "system-pass";
- private static final String USER_PASSWORD = "user-pass";
- private static final String MASTER_KEY = "0123456789abcdef0123456789abcdef";
-
- static {
- if (Security.getProvider("BC") == null) {
- Security.addProvider(new BouncyCastleProvider());
- }
- }
-
- @Mock
- private UserSigningCredentialsRepository repository;
-
- @TempDir
- Path tempDir;
-
- private EInvoiceProperties properties;
- private SigningCredentialsService signingCredentialsService;
- private EInvoiceService eInvoiceService;
-
- @BeforeEach
- void setUp() {
- properties = new EInvoiceProperties();
- properties.setEnabled(true);
- properties.setProfile("EN16931");
- properties.getSigning().setEnabled(false);
- properties.getSigning().setMasterKey(MASTER_KEY);
-
- signingCredentialsService = new SigningCredentialsService(repository, properties);
- eInvoiceService = new EInvoiceService(properties, signingCredentialsService);
- }
-
- @Test
- void signsPdfWithSystemKeystore() throws Exception {
- byte[] keystoreBytes = generateKeystore(SYSTEM_ALIAS, SYSTEM_PASSWORD);
- Path keystoreFile = tempDir.resolve("system.p12");
- Files.write(keystoreFile, keystoreBytes);
- configureSystemKeystore(keystoreFile, SYSTEM_PASSWORD, SYSTEM_ALIAS);
- when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty());
-
- CustomerInvoice invoice = sampleInvoice();
- byte[] result = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true);
-
- assertSignedPdf(result);
- assertThat(invoice.isSigned()).isTrue();
- assertThat(invoice.getSignedAt()).isNotNull();
- assertThat(invoice.getSignedBy()).isEqualTo(SYSTEM_ALIAS);
- }
-
- @Test
- void signsPdfWithUserKeystore() throws Exception {
- UserSigningCredentials stored = storeUserKeystore();
- when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored));
-
- CustomerInvoice invoice = sampleInvoice();
- byte[] result = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true);
-
- assertSignedPdf(result);
- assertThat(invoice.isSigned()).isTrue();
- assertThat(invoice.getSignedBy()).contains("Votianlt Test");
- }
-
- @Test
- void userKeystoreTakesPrecedenceOverSystemKeystore() throws Exception {
- byte[] systemKeystoreBytes = generateKeystore(SYSTEM_ALIAS, SYSTEM_PASSWORD);
- Path keystoreFile = tempDir.resolve("system.p12");
- Files.write(keystoreFile, systemKeystoreBytes);
- configureSystemKeystore(keystoreFile, SYSTEM_PASSWORD, SYSTEM_ALIAS);
-
- UserSigningCredentials stored = storeUserKeystore();
- when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored));
-
- CustomerInvoice invoice = sampleInvoice();
- byte[] result = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true);
-
- assertSignedPdf(result);
- assertThat(invoice.getSignedBy())
- .as("Bei vorhandenen Nutzer-Credentials darf nicht der System-Alias signieren")
- .isNotEqualTo(SYSTEM_ALIAS)
- .contains("Votianlt Test");
- }
-
- @Test
- void failsWhenNoKeystoreAvailable() {
- when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty());
-
- CustomerInvoice invoice = sampleInvoice();
- assertThatThrownBy(() -> eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true))
- .isInstanceOf(InvoiceLifecycleException.class)
- .hasMessageContaining("kein Signatur-Zertifikat verfügbar");
- assertThat(invoice.isSigned()).isFalse();
- }
-
- @Test
- void failsWhenUserCredentialsDisabled() {
- UserSigningCredentials stored = storeUserKeystore();
- stored.setEnabled(false);
- when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored));
-
- CustomerInvoice invoice = sampleInvoice();
- assertThatThrownBy(() -> eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true))
- .isInstanceOf(InvoiceLifecycleException.class)
- .hasMessageContaining("deaktiviert");
- assertThat(invoice.isSigned()).isFalse();
- }
-
- @Test
- void failsWhenMasterKeyChangedAfterStorage() {
- UserSigningCredentials stored = storeUserKeystore();
- when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(stored));
-
- // Master-Key wird nach dem Persistieren ausgetauscht — der Keystore lässt sich
- // nicht mehr entschlüsseln, der Service muss dem Anwender klar signalisieren,
- // dass er das Zertifikat erneut hochladen muss.
- properties.getSigning().setMasterKey("ffffffffffffffffffffffffffffffff");
-
- CustomerInvoice invoice = sampleInvoice();
- assertThatThrownBy(() -> eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, false, true))
- .isInstanceOf(InvoiceLifecycleException.class)
- .hasMessageContaining("nicht entschlüsselt");
- assertThat(invoice.isSigned()).isFalse();
- }
-
- /**
- * Ein nicht-PDF/A-konformes Eingabe-PDF (z.B. ohne eingebettete Fonts) lässt
- * die Mustang-Anreicherung scheitern. Der Service muss laut Klassen-JavaDoc
- * mit dem Roh-PDF fortfahren und die Signatur trotzdem strikt durchführen —
- * Format wird auf NONE gesetzt, Signatur darf nicht ausfallen.
- */
- @Test
- void gracefullyDegradesWhenZugferdEmbeddingFails() throws Exception {
- byte[] keystoreBytes = generateKeystore(SYSTEM_ALIAS, SYSTEM_PASSWORD);
- Path keystoreFile = tempDir.resolve("system.p12");
- Files.write(keystoreFile, keystoreBytes);
- configureSystemKeystore(keystoreFile, SYSTEM_PASSWORD, SYSTEM_ALIAS);
- when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty());
-
- CustomerInvoice invoice = sampleInvoice();
- byte[] result = eInvoiceService.enhanceAndSign(buildBlankPdf(), invoice, true, true);
-
- assertSignedPdf(result);
- assertThat(invoice.getEInvoiceFormat()).isEqualTo(EInvoiceFormat.NONE);
- assertThat(invoice.isSigned()).isTrue();
- }
-
- @Test
- void embedsZugferdXmlIntoValidPdf() throws Exception {
- CustomerInvoice invoice = sampleInvoice();
- byte[] result = eInvoiceService.embedZugferdXml(buildPdfWithFont(), invoice);
-
- assertThat(result).isNotEmpty();
- // Das ZUGFeRD-XML wird als „factur-x.xml" eingebettet — schneller Smoke-Test
- // über die UTF-8-Repräsentation des Containers.
- String resultAsString = new String(result, java.nio.charset.StandardCharsets.ISO_8859_1);
- assertThat(resultAsString).contains("factur-x.xml");
- }
-
- private void configureSystemKeystore(Path keystoreFile, String password, String alias) {
- EInvoiceProperties.Signing signing = properties.getSigning();
- signing.setEnabled(true);
- signing.setKeystorePath(keystoreFile.toString());
- signing.setKeystorePassword(password);
- signing.setKeyAlias(alias);
- }
-
- private UserSigningCredentials storeUserKeystore() {
- byte[] userKeystore = generateKeystore(USER_ALIAS, USER_PASSWORD);
- when(repository.save(any(UserSigningCredentials.class))).thenAnswer(invocation -> invocation.getArgument(0));
- return signingCredentialsService.store(USER_ID, userKeystore, USER_PASSWORD, USER_ALIAS);
- }
-
- private void assertSignedPdf(byte[] pdfBytes) throws Exception {
- try (PDDocument document = Loader.loadPDF(pdfBytes)) {
- List signatures = document.getSignatureDictionaries();
- assertThat(signatures).as("Signed PDF must contain a signature dictionary").isNotEmpty();
- PDSignature signature = signatures.get(0);
- assertThat(signature.getFilter()).isEqualTo("Adobe.PPKLite");
- assertThat(signature.getSubFilter()).isEqualTo("adbe.pkcs7.detached");
- assertThat(signature.getContents(pdfBytes)).isNotEmpty();
- }
- }
-
- private byte[] buildBlankPdf() throws Exception {
- try (PDDocument document = new PDDocument(); ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
- document.addPage(new PDPage());
- document.save(baos);
- return baos.toByteArray();
- }
- }
-
- private byte[] buildPdfWithFont() throws Exception {
- try (PDDocument document = new PDDocument(); ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
- PDPage page = new PDPage();
- document.addPage(page);
- try (PDPageContentStream cs = new PDPageContentStream(document, page)) {
- cs.beginText();
- cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f);
- cs.newLineAtOffset(50, 750);
- cs.showText("Test-Rechnung");
- cs.endText();
- }
- document.save(baos);
- return baos.toByteArray();
- }
- }
-
- private byte[] generateKeystore(String alias, String password) {
- try {
- KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
- kpg.initialize(2048);
- KeyPair keyPair = kpg.generateKeyPair();
-
- X500Name subject = new X500Name("CN=Votianlt Test " + alias + ",O=Votianlt,C=DE");
- BigInteger serial = BigInteger.valueOf(System.nanoTime());
- Date notBefore = Date.from(Instant.now().minus(1, ChronoUnit.DAYS));
- Date notAfter = Date.from(Instant.now().plus(365, ChronoUnit.DAYS));
-
- JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(subject, serial, notBefore,
- notAfter, subject, keyPair.getPublic());
- ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").setProvider("BC")
- .build(keyPair.getPrivate());
- X509Certificate certificate = new JcaX509CertificateConverter().setProvider("BC")
- .getCertificate(certBuilder.build(signer));
-
- KeyStore keystore = KeyStore.getInstance("PKCS12");
- keystore.load(null, null);
- keystore.setKeyEntry(alias, keyPair.getPrivate(), password.toCharArray(),
- new Certificate[] { certificate });
-
- ByteArrayOutputStream baos = new ByteArrayOutputStream();
- keystore.store(baos, password.toCharArray());
- return baos.toByteArray();
- } catch (Exception ex) {
- throw new IllegalStateException("Test-Keystore konnte nicht erzeugt werden: " + ex.getMessage(), ex);
- }
- }
-
- private CustomerInvoice sampleInvoice() {
- CustomerInvoice invoice = new CustomerInvoice();
- invoice.setUserId(USER_ID);
- invoice.setInvoiceNumber("R-2026-0001");
- invoice.setInvoiceDate(LocalDate.now());
- invoice.setDeliveryDate(LocalDate.now());
- invoice.setSenderName("Votianlt Test GmbH");
- invoice.setSenderAddress("Teststraße 1");
- invoice.setSenderPostcode("12345");
- invoice.setSenderCity("Berlin");
- invoice.setSenderCountry("DE");
- invoice.setRecipientName("Empfänger AG");
- invoice.setRecipientAddress("Kundenweg 2");
- invoice.setRecipientPostcode("54321");
- invoice.setRecipientCity("Hamburg");
- invoice.setRecipientCountry("DE");
- invoice.setDescription("Beratungsleistung");
- invoice.setVatRate(new BigDecimal("0.19"));
- invoice.setNetAmount(new BigDecimal("100.00"));
- invoice.setVatAmount(new BigDecimal("19.00"));
- invoice.setTotalAmount(new BigDecimal("119.00"));
- CustomerInvoiceItem item = new CustomerInvoiceItem(BigDecimal.ONE, "h", "Beratung", new BigDecimal("100.00"),
- new BigDecimal("0.19"));
- invoice.setItems(List.of(item));
- return invoice;
- }
-}