From 0c46d9bad69fcea458657b474613a6a4dc7df5b5 Mon Sep 17 00:00:00 2001
From: dahoud <41957584+DahoudG@users.noreply.github.com>
Date: Sat, 25 Apr 2026 16:28:51 +0000
Subject: [PATCH] =?UTF-8?q?feat(sprint-16=20backend=202026-04-25):=20trans?=
=?UTF-8?q?parency=20operations=20=E2=80=94=20M=C3=A9triques=20Prometheus?=
=?UTF-8?q?=20+=20Export=20multi-format=20+=20Notifications=20auto?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Trois piliers transparency opérationnelle UnionFlow livrés en série :
S16.A — Métriques Prometheus métier (transparency observabilité)
- AuditTrailService.log() incrémente :
- unionflow.audit.operations{action, entity}
- unionflow.audit.sod_violations{entity}
- application.properties : quarkus.micrometer.export.prometheus.enabled=true,
http-server + jvm binders activés
- Endpoint /q/metrics exposé pour scraping Prometheus K8s
S16.B — Export massif audit-trail (transparency réglementaire BCEAO/ARTCI/CENTIF)
- AuditTrailExportService :
- exportCsv : Apache Commons CSV avec BOM UTF-8 (compat Excel)
- exportXlsx : POI XSSFWorkbook avec header coloré et auto-size
- exportPdf : OpenPDF A4 paysage avec table 7 colonnes
- Endpoint GET /api/audit-trail/export?format=csv|xlsx|pdf&scope=...&limit=500
- @RolesAllowed COMPLIANCE_OFFICER, CONTROLEUR_INTERNE, SUPER_ADMIN
- Auto-log de l'export lui-même dans audit (méta-traçabilité)
S16.C — Notifications transparency (pattern CDI Event Observer)
- AuditOperationLoggedEvent record + helper estSensible() (DELETE / PAYMENT_* / BUDGET_APPROVED / AID_REQUEST_APPROVED / EXPORT / VALIDATE / SoD-violation)
- AuditTrailService.log() fire le CDI event après persist
- AuditNotificationService observe @Observes(AFTER_SUCCESS) — notifie via NotificationService existant (DRY, pas de nouveau client mail)
- Sujets/corps localisés français + priorité automatique (HAUTE pour DELETE/PAYMENT_FAILED/SoD, NORMALE pour confirmations, BASSE pour exports)
- Fail-soft : exception notification ne bloque jamais l'audit principal
Tests (10 nouveaux S16.C, 21/21 cumulé audit)
- onAuditOperation × 5 (sensible/non-sensible/SoD/null/userId-null)
- mapping helpers × 4 (sujet × 8 actions, sodPriority, priorité × 6 cas, corps × 2)
---
.../resource/AuditTrailOperationResource.java | 42 ++++
.../security/AuditOperationLoggedEvent.java | 36 ++++
.../server/security/AuditTrailService.java | 17 ++
.../audit/AuditNotificationService.java | 105 +++++++++
.../audit/AuditTrailExportService.java | 199 ++++++++++++++++++
src/main/resources/application.properties | 9 +
.../audit/AuditNotificationServiceTest.java | 143 +++++++++++++
7 files changed, 551 insertions(+)
create mode 100644 src/main/java/dev/lions/unionflow/server/security/AuditOperationLoggedEvent.java
create mode 100644 src/main/java/dev/lions/unionflow/server/service/audit/AuditNotificationService.java
create mode 100644 src/main/java/dev/lions/unionflow/server/service/audit/AuditTrailExportService.java
create mode 100644 src/test/java/dev/lions/unionflow/server/service/audit/AuditNotificationServiceTest.java
diff --git a/src/main/java/dev/lions/unionflow/server/resource/AuditTrailOperationResource.java b/src/main/java/dev/lions/unionflow/server/resource/AuditTrailOperationResource.java
index 62484d5..7935da1 100644
--- a/src/main/java/dev/lions/unionflow/server/resource/AuditTrailOperationResource.java
+++ b/src/main/java/dev/lions/unionflow/server/resource/AuditTrailOperationResource.java
@@ -30,6 +30,7 @@ import java.util.UUID;
public class AuditTrailOperationResource {
@Inject AuditTrailQueryService queryService;
+ @Inject dev.lions.unionflow.server.service.audit.AuditTrailExportService exportService;
@GET
@Path("/by-user/{userId}")
@@ -102,6 +103,47 @@ public class AuditTrailOperationResource {
return queryService.listerRecentes(scope, orgId, userId, limit);
}
+ /**
+ * Export massif audit (Sprint 16.B) — formats CSV / XLSX / PDF pour BCEAO/ARTCI/CENTIF.
+ */
+ @GET
+ @Path("/export")
+ @jakarta.ws.rs.Produces("application/octet-stream")
+ @RolesAllowed({"COMPLIANCE_OFFICER", "CONTROLEUR_INTERNE", "SUPER_ADMIN"})
+ public jakarta.ws.rs.core.Response export(
+ @QueryParam("format") @jakarta.ws.rs.DefaultValue("csv") String format,
+ @QueryParam("scope") @jakarta.ws.rs.DefaultValue("ALL") String scope,
+ @QueryParam("orgId") UUID orgId,
+ @QueryParam("userId") UUID userId,
+ @QueryParam("limit") @jakarta.ws.rs.DefaultValue("500") int limit) {
+ String fmt = format == null ? "csv" : format.toLowerCase();
+ byte[] payload;
+ String mediaType;
+ String extension;
+ switch (fmt) {
+ case "xlsx" -> {
+ payload = exportService.exportXlsx(scope, orgId, userId, limit);
+ mediaType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+ extension = "xlsx";
+ }
+ case "pdf" -> {
+ payload = exportService.exportPdf(scope, orgId, userId, limit);
+ mediaType = "application/pdf";
+ extension = "pdf";
+ }
+ default -> {
+ payload = exportService.exportCsv(scope, orgId, userId, limit);
+ mediaType = "text/csv";
+ extension = "csv";
+ }
+ }
+ String filename = String.format("audit-trail-%s-%s.%s",
+ scope.toLowerCase(), java.time.LocalDate.now(), extension);
+ return jakarta.ws.rs.core.Response.ok(payload, mediaType)
+ .header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
+ .build();
+ }
+
private LocalDateTime parseDateTime(String input, LocalDateTime fallback) {
if (input == null || input.isBlank()) return fallback;
try {
diff --git a/src/main/java/dev/lions/unionflow/server/security/AuditOperationLoggedEvent.java b/src/main/java/dev/lions/unionflow/server/security/AuditOperationLoggedEvent.java
new file mode 100644
index 0000000..d148f0d
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/security/AuditOperationLoggedEvent.java
@@ -0,0 +1,36 @@
+package dev.lions.unionflow.server.security;
+
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+/**
+ * Événement CDI émis après chaque écriture audit trail (Sprint 16.C).
+ *
+ *
Permet aux observers ({@code AuditNotificationService}) de réagir sans coupler le
+ * service d'écriture aux services de notification. Pattern Observer via {@code @Observes}.
+ *
+ * @since 2026-04-25 (Sprint 16.C — transparency)
+ */
+public record AuditOperationLoggedEvent(
+ UUID operationId,
+ UUID userId,
+ String userEmail,
+ UUID organisationActiveId,
+ String actionType,
+ String entityType,
+ UUID entityId,
+ String description,
+ Boolean sodCheckPassed,
+ LocalDateTime operationAt
+) {
+ /** Renvoie true si cette opération mérite une notification (sensible). */
+ public boolean estSensible() {
+ if (Boolean.FALSE.equals(sodCheckPassed)) return true;
+ if (actionType == null) return false;
+ return switch (actionType) {
+ case "DELETE", "PAYMENT_INITIATED", "PAYMENT_CONFIRMED", "PAYMENT_FAILED",
+ "BUDGET_APPROVED", "AID_REQUEST_APPROVED", "EXPORT", "VALIDATE" -> true;
+ default -> false;
+ };
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/security/AuditTrailService.java b/src/main/java/dev/lions/unionflow/server/security/AuditTrailService.java
index 55e3f7b..1b7f77f 100644
--- a/src/main/java/dev/lions/unionflow/server/security/AuditTrailService.java
+++ b/src/main/java/dev/lions/unionflow/server/security/AuditTrailService.java
@@ -38,6 +38,8 @@ public class AuditTrailService {
@Inject AuditTrailOperationRepository repository;
@Inject OrganisationContextHolder context;
+ @Inject io.micrometer.core.instrument.MeterRegistry registry;
+ @Inject jakarta.enterprise.event.Event auditEvent;
private final ObjectMapper objectMapper = new ObjectMapper();
@@ -81,6 +83,21 @@ public class AuditTrailService {
.operationAt(LocalDateTime.now())
.build();
repository.persist(entry);
+
+ // Sprint 16.A — Métriques Prometheus métier (transparency)
+ registry.counter("unionflow.audit.operations",
+ "action", actionType == null ? "UNKNOWN" : actionType,
+ "entity", entityType == null ? "UNKNOWN" : entityType).increment();
+ if (Boolean.FALSE.equals(sodCheckPassed)) {
+ registry.counter("unionflow.audit.sod_violations",
+ "entity", entityType == null ? "UNKNOWN" : entityType).increment();
+ }
+
+ // Sprint 16.C — Fire CDI event pour observers (notifications, etc.)
+ auditEvent.fire(new AuditOperationLoggedEvent(
+ entry.getId(), entry.getUserId(), entry.getUserEmail(),
+ entry.getOrganisationActiveId(), actionType, entityType, entityId,
+ description, sodCheckPassed, entry.getOperationAt()));
} catch (Exception e) {
// Fail-soft : l'audit trail ne doit jamais bloquer une opération métier.
// Les violations sont loguées et peuvent être détectées via les logs applicatifs.
diff --git a/src/main/java/dev/lions/unionflow/server/service/audit/AuditNotificationService.java b/src/main/java/dev/lions/unionflow/server/service/audit/AuditNotificationService.java
new file mode 100644
index 0000000..3fe8b3a
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/service/audit/AuditNotificationService.java
@@ -0,0 +1,105 @@
+package dev.lions.unionflow.server.service.audit;
+
+import dev.lions.unionflow.server.api.dto.notification.request.CreateNotificationRequest;
+import dev.lions.unionflow.server.security.AuditOperationLoggedEvent;
+import dev.lions.unionflow.server.service.NotificationService;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.event.Observes;
+import jakarta.enterprise.event.TransactionPhase;
+import jakarta.inject.Inject;
+import org.jboss.logging.Logger;
+
+/**
+ * Notifications transparency (Sprint 16.C) — observe les events audit et notifie l'utilisateur
+ * concerné par les opérations sensibles.
+ *
+ * Filtre selon {@link AuditOperationLoggedEvent#estSensible()} : opérations financières,
+ * suppressions, validations, exports, ou violations SoD.
+ *
+ *
Observe en {@code AFTER_SUCCESS} pour ne notifier que si la transaction de l'audit
+ * a réussi. Fail-soft : un échec de notification ne propage jamais d'erreur.
+ *
+ * @since 2026-04-25 (Sprint 16.C — transparency)
+ */
+@ApplicationScoped
+public class AuditNotificationService {
+
+ private static final Logger LOG = Logger.getLogger(AuditNotificationService.class);
+
+ @Inject NotificationService notificationService;
+
+ /**
+ * Observe l'événement après commit transactionnel — la notification ne part que si
+ * l'écriture audit a effectivement été persistée.
+ */
+ public void onAuditOperation(@Observes(during = TransactionPhase.AFTER_SUCCESS)
+ AuditOperationLoggedEvent event) {
+ if (event == null || !event.estSensible() || event.userId() == null) return;
+
+ try {
+ String sujet = sujetPourEvent(event);
+ String corps = corpsPourEvent(event);
+
+ CreateNotificationRequest req = CreateNotificationRequest.builder()
+ .typeNotification("APP")
+ .priorite(prioritePourEvent(event))
+ .sujet(sujet)
+ .corps(corps)
+ .membreId(event.userId())
+ .organisationId(event.organisationActiveId())
+ .build();
+
+ notificationService.creerNotification(req);
+ LOG.debugf("Notification audit transparency émise pour user=%s action=%s",
+ event.userId(), event.actionType());
+ } catch (Exception e) {
+ // Fail-soft : ne jamais bloquer l'audit principal
+ LOG.warnf("Notification audit échouée pour event %s : %s",
+ event.operationId(), e.getMessage());
+ }
+ }
+
+ String sujetPourEvent(AuditOperationLoggedEvent e) {
+ if (Boolean.FALSE.equals(e.sodCheckPassed())) {
+ return "⚠ Violation SoD détectée — " + e.entityType();
+ }
+ return switch (e.actionType()) {
+ case "DELETE" -> "Suppression — " + e.entityType();
+ case "PAYMENT_INITIATED" -> "Paiement initié";
+ case "PAYMENT_CONFIRMED" -> "Paiement confirmé";
+ case "PAYMENT_FAILED" -> "⚠ Paiement échoué";
+ case "BUDGET_APPROVED" -> "Budget approuvé";
+ case "AID_REQUEST_APPROVED" -> "Demande d'aide approuvée";
+ case "EXPORT" -> "Export effectué";
+ case "VALIDATE" -> "Validation — " + e.entityType();
+ default -> "Opération sensible — " + e.entityType();
+ };
+ }
+
+ String corpsPourEvent(AuditOperationLoggedEvent e) {
+ StringBuilder sb = new StringBuilder();
+ if (e.description() != null && !e.description().isBlank()) {
+ sb.append(e.description()).append("\n\n");
+ }
+ sb.append("Acteur : ").append(e.userEmail() == null ? "—" : e.userEmail()).append('\n');
+ sb.append("Action : ").append(e.actionType()).append('\n');
+ sb.append("Entité : ").append(e.entityType()).append('\n');
+ if (e.entityId() != null) {
+ sb.append("ID : ").append(e.entityId()).append('\n');
+ }
+ sb.append("Quand : ").append(e.operationAt());
+ if (Boolean.FALSE.equals(e.sodCheckPassed())) {
+ sb.append("\n\n⚠ Cette opération a déclenché une alerte de séparation des pouvoirs.");
+ }
+ return sb.toString();
+ }
+
+ String prioritePourEvent(AuditOperationLoggedEvent e) {
+ if (Boolean.FALSE.equals(e.sodCheckPassed())) return "HAUTE";
+ return switch (e.actionType()) {
+ case "PAYMENT_FAILED", "DELETE" -> "HAUTE";
+ case "PAYMENT_CONFIRMED", "BUDGET_APPROVED", "AID_REQUEST_APPROVED" -> "NORMALE";
+ default -> "BASSE";
+ };
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/service/audit/AuditTrailExportService.java b/src/main/java/dev/lions/unionflow/server/service/audit/AuditTrailExportService.java
new file mode 100644
index 0000000..c2feb7a
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/service/audit/AuditTrailExportService.java
@@ -0,0 +1,199 @@
+package dev.lions.unionflow.server.service.audit;
+
+import com.lowagie.text.Document;
+import com.lowagie.text.Element;
+import com.lowagie.text.Font;
+import com.lowagie.text.FontFactory;
+import com.lowagie.text.PageSize;
+import com.lowagie.text.Paragraph;
+import com.lowagie.text.Phrase;
+import com.lowagie.text.pdf.PdfPCell;
+import com.lowagie.text.pdf.PdfPTable;
+import com.lowagie.text.pdf.PdfWriter;
+import dev.lions.unionflow.server.api.dto.audit.response.AuditTrailOperationResponse;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import java.awt.Color;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.UUID;
+import org.apache.commons.csv.CSVFormat;
+import org.apache.commons.csv.CSVPrinter;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellStyle;
+import org.apache.poi.ss.usermodel.IndexedColors;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.jboss.logging.Logger;
+
+/**
+ * Export massif audit trail (Sprint 16.B) — formats CSV / XLSX / PDF.
+ *
+ *
Pour transmission BCEAO / ARTCI / CENTIF en cas d'inspection.
+ * Les exports respectent les filtres scope passés à {@link AuditTrailQueryService}.
+ *
+ * @since 2026-04-25 (Sprint 16.B — transparency operations)
+ */
+@ApplicationScoped
+public class AuditTrailExportService {
+
+ private static final Logger LOG = Logger.getLogger(AuditTrailExportService.class);
+ private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+ private static final String[] HEADERS = {
+ "Timestamp", "Acteur (email)", "Rôle", "Org active", "Action", "Entité",
+ "Entity ID", "Description", "SoD OK", "Violations SoD", "IP", "Request ID"
+ };
+
+ @Inject AuditTrailQueryService queryService;
+ @Inject dev.lions.unionflow.server.security.AuditTrailService auditWrite;
+
+ /** CSV (UTF-8 BOM, RFC 4180). */
+ public byte[] exportCsv(String scope, UUID orgId, UUID userId, int limit) {
+ List ops = queryService.listerRecentes(scope, orgId, userId, limit);
+ try (ByteArrayOutputStream out = new ByteArrayOutputStream();
+ OutputStreamWriter writer = new OutputStreamWriter(out, StandardCharsets.UTF_8)) {
+ // BOM UTF-8 pour Excel
+ out.write(0xEF); out.write(0xBB); out.write(0xBF);
+ try (CSVPrinter printer = new CSVPrinter(writer,
+ CSVFormat.DEFAULT.builder().setHeader(HEADERS).build())) {
+ for (AuditTrailOperationResponse o : ops) {
+ printer.printRecord(
+ fmt(o.operationAt()), nz(o.userEmail()), nz(o.roleActif()),
+ o.organisationActiveId(), nz(o.actionType()), nz(o.entityType()),
+ o.entityId(), nz(o.description()),
+ o.sodCheckPassed() == null ? "" : o.sodCheckPassed().toString(),
+ nz(o.sodViolations()), nz(o.ipAddress()), o.requestId());
+ }
+ }
+ writer.flush();
+ auditWrite.logSimple("AuditTrailExport", null, "EXPORT",
+ "Export CSV audit-trail (" + ops.size() + " ops, scope=" + scope + ")");
+ return out.toByteArray();
+ } catch (IOException e) {
+ throw new IllegalStateException("Export CSV échoué", e);
+ }
+ }
+
+ /** XLSX via Apache POI. */
+ public byte[] exportXlsx(String scope, UUID orgId, UUID userId, int limit) {
+ List ops = queryService.listerRecentes(scope, orgId, userId, limit);
+ try (XSSFWorkbook wb = new XSSFWorkbook();
+ ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+ org.apache.poi.xssf.usermodel.XSSFSheet sheet = wb.createSheet("Audit Trail");
+
+ CellStyle header = wb.createCellStyle();
+ org.apache.poi.xssf.usermodel.XSSFFont fontH = wb.createFont();
+ fontH.setBold(true);
+ fontH.setColor(IndexedColors.WHITE.getIndex());
+ header.setFont(fontH);
+ header.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex());
+ header.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
+
+ Row headerRow = sheet.createRow(0);
+ for (int i = 0; i < HEADERS.length; i++) {
+ Cell cell = headerRow.createCell(i);
+ cell.setCellValue(HEADERS[i]);
+ cell.setCellStyle(header);
+ }
+
+ int r = 1;
+ for (AuditTrailOperationResponse o : ops) {
+ Row row = sheet.createRow(r++);
+ row.createCell(0).setCellValue(fmt(o.operationAt()));
+ row.createCell(1).setCellValue(nz(o.userEmail()));
+ row.createCell(2).setCellValue(nz(o.roleActif()));
+ row.createCell(3).setCellValue(o.organisationActiveId() == null ? "" : o.organisationActiveId().toString());
+ row.createCell(4).setCellValue(nz(o.actionType()));
+ row.createCell(5).setCellValue(nz(o.entityType()));
+ row.createCell(6).setCellValue(o.entityId() == null ? "" : o.entityId().toString());
+ row.createCell(7).setCellValue(nz(o.description()));
+ row.createCell(8).setCellValue(o.sodCheckPassed() == null ? "" : o.sodCheckPassed().toString());
+ row.createCell(9).setCellValue(nz(o.sodViolations()));
+ row.createCell(10).setCellValue(nz(o.ipAddress()));
+ row.createCell(11).setCellValue(o.requestId() == null ? "" : o.requestId().toString());
+ }
+
+ for (int i = 0; i < HEADERS.length; i++) sheet.autoSizeColumn(i);
+ wb.write(out);
+ auditWrite.logSimple("AuditTrailExport", null, "EXPORT",
+ "Export XLSX audit-trail (" + ops.size() + " ops, scope=" + scope + ")");
+ return out.toByteArray();
+ } catch (IOException e) {
+ throw new IllegalStateException("Export XLSX échoué", e);
+ }
+ }
+
+ /** PDF via OpenPDF (page A4 paysage pour les colonnes larges). */
+ public byte[] exportPdf(String scope, UUID orgId, UUID userId, int limit) {
+ List ops = queryService.listerRecentes(scope, orgId, userId, limit);
+ try (Document doc = new Document(PageSize.A4.rotate());
+ ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+ PdfWriter.getInstance(doc, out);
+ doc.open();
+
+ Font titleFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 14, Color.BLACK);
+ Font headerFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 9, Color.WHITE);
+ Font rowFont = FontFactory.getFont(FontFactory.HELVETICA, 8, Color.BLACK);
+
+ Paragraph title = new Paragraph(
+ "Audit Trail UnionFlow — Export " + LocalDateTime.now().format(DT_FMT)
+ + " (scope=" + scope + ", " + ops.size() + " opérations)", titleFont);
+ title.setAlignment(Element.ALIGN_CENTER);
+ title.setSpacingAfter(15);
+ doc.add(title);
+
+ PdfPTable table = new PdfPTable(7);
+ table.setWidthPercentage(100);
+ table.setWidths(new float[] {15, 18, 10, 12, 13, 12, 20});
+
+ String[] cols = {"Quand", "Acteur", "Rôle", "Action", "Entité", "SoD", "Description"};
+ Color headerBg = new Color(20, 80, 150);
+ for (String c : cols) {
+ PdfPCell cell = new PdfPCell(new Phrase(c, headerFont));
+ cell.setBackgroundColor(headerBg);
+ cell.setPadding(5);
+ table.addCell(cell);
+ }
+
+ for (AuditTrailOperationResponse o : ops) {
+ addCell(table, fmt(o.operationAt()), rowFont);
+ addCell(table, nz(o.userEmail()), rowFont);
+ addCell(table, nz(o.roleActif()), rowFont);
+ addCell(table, nz(o.actionType()), rowFont);
+ addCell(table, nz(o.entityType()), rowFont);
+ addCell(table, o.sodCheckPassed() == null ? "—"
+ : (o.sodCheckPassed() ? "OK" : "VIOLATION"), rowFont);
+ addCell(table, truncate(nz(o.description()), 120), rowFont);
+ }
+
+ doc.add(table);
+ doc.close();
+
+ auditWrite.logSimple("AuditTrailExport", null, "EXPORT",
+ "Export PDF audit-trail (" + ops.size() + " ops, scope=" + scope + ")");
+ return out.toByteArray();
+ } catch (IOException e) {
+ throw new IllegalStateException("Export PDF échoué", e);
+ }
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────
+
+ private void addCell(PdfPTable t, String content, Font f) {
+ PdfPCell cell = new PdfPCell(new Phrase(content, f));
+ cell.setPadding(3);
+ t.addCell(cell);
+ }
+
+ static String fmt(LocalDateTime dt) { return dt == null ? "" : dt.format(DT_FMT); }
+ static String nz(String s) { return s == null ? "" : s; }
+ static String truncate(String s, int max) {
+ if (s == null) return "";
+ return s.length() <= max ? s : s.substring(0, max - 1) + "…";
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index be32539..3c318bb 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -54,6 +54,15 @@ quarkus.hibernate-orm.log.sql=false
quarkus.hibernate-orm.jdbc.timezone=UTC
quarkus.hibernate-orm.metrics.enabled=false
+# Sprint 16.A — Métriques Prometheus métier (transparency)
+# Endpoint /q/metrics exposé en Prometheus format
+quarkus.micrometer.export.prometheus.enabled=true
+quarkus.micrometer.binder.http-server.enabled=true
+quarkus.micrometer.binder.jvm=true
+# /q/metrics nécessite auth (rôles SUPER_ADMIN ou COMPLIANCE_OFFICER en prod)
+# Pour permettre Prometheus scraping interne K8s, autoriser via annotation pod ou
+# whitelist IP. Pour l'instant : exposé en plain pour scraping intra-cluster.
+
# Configuration Flyway — base commune
quarkus.flyway.migrate-at-start=true
quarkus.flyway.baseline-on-migrate=true
diff --git a/src/test/java/dev/lions/unionflow/server/service/audit/AuditNotificationServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/audit/AuditNotificationServiceTest.java
new file mode 100644
index 0000000..12d72cc
--- /dev/null
+++ b/src/test/java/dev/lions/unionflow/server/service/audit/AuditNotificationServiceTest.java
@@ -0,0 +1,143 @@
+package dev.lions.unionflow.server.service.audit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import dev.lions.unionflow.server.api.dto.notification.request.CreateNotificationRequest;
+import dev.lions.unionflow.server.security.AuditOperationLoggedEvent;
+import dev.lions.unionflow.server.service.NotificationService;
+import java.lang.reflect.Field;
+import java.time.LocalDateTime;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
+class AuditNotificationServiceTest {
+
+ @Mock NotificationService notificationService;
+ private AuditNotificationService service;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ service = new AuditNotificationService();
+ Field f = AuditNotificationService.class.getDeclaredField("notificationService");
+ f.setAccessible(true);
+ f.set(service, notificationService);
+ }
+
+ private AuditOperationLoggedEvent event(String action, Boolean sod) {
+ return new AuditOperationLoggedEvent(
+ UUID.randomUUID(), UUID.randomUUID(), "test@unionflow.dev",
+ UUID.randomUUID(), action, "Cotisation", UUID.randomUUID(),
+ "Test description", sod, LocalDateTime.now());
+ }
+
+ @Test
+ @DisplayName("Event sensible (PAYMENT_CONFIRMED) → notification créée")
+ void sensiblePaymentConfirmed() {
+ when(notificationService.creerNotification(any())).thenReturn(null);
+ service.onAuditOperation(event("PAYMENT_CONFIRMED", true));
+ verify(notificationService).creerNotification(any(CreateNotificationRequest.class));
+ }
+
+ @Test
+ @DisplayName("Event non-sensible (CREATE Membre) → pas de notification")
+ void nonSensibleSkipped() {
+ service.onAuditOperation(event("CREATE", true));
+ verify(notificationService, never()).creerNotification(any());
+ }
+
+ @Test
+ @DisplayName("Violation SoD → notification HAUTE priorité même sur action non-listée")
+ void sodViolationAlwaysNotifies() {
+ when(notificationService.creerNotification(any())).thenReturn(null);
+ service.onAuditOperation(event("CREATE", false));
+ verify(notificationService).creerNotification(any(CreateNotificationRequest.class));
+ }
+
+ @Test
+ @DisplayName("Event null → ignoré silencieusement")
+ void eventNullIgnored() {
+ service.onAuditOperation(null);
+ verify(notificationService, never()).creerNotification(any());
+ }
+
+ @Test
+ @DisplayName("UserId null → pas de notification (pas de cible)")
+ void userIdNullSkipped() {
+ AuditOperationLoggedEvent e = new AuditOperationLoggedEvent(
+ UUID.randomUUID(), null, "anon", UUID.randomUUID(),
+ "DELETE", "X", UUID.randomUUID(), "test", true, LocalDateTime.now());
+ service.onAuditOperation(e);
+ verify(notificationService, never()).creerNotification(any());
+ }
+
+ // ── Helpers de mapping ────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("sujetPourEvent — actions classiques")
+ void sujetMapping() {
+ assertThat(service.sujetPourEvent(event("PAYMENT_INITIATED", true)))
+ .isEqualTo("Paiement initié");
+ assertThat(service.sujetPourEvent(event("PAYMENT_CONFIRMED", true)))
+ .isEqualTo("Paiement confirmé");
+ assertThat(service.sujetPourEvent(event("PAYMENT_FAILED", true)))
+ .startsWith("⚠");
+ assertThat(service.sujetPourEvent(event("BUDGET_APPROVED", true)))
+ .isEqualTo("Budget approuvé");
+ assertThat(service.sujetPourEvent(event("AID_REQUEST_APPROVED", true)))
+ .isEqualTo("Demande d'aide approuvée");
+ assertThat(service.sujetPourEvent(event("EXPORT", true)))
+ .isEqualTo("Export effectué");
+ assertThat(service.sujetPourEvent(event("DELETE", true)))
+ .startsWith("Suppression");
+ assertThat(service.sujetPourEvent(event("VALIDATE", true)))
+ .startsWith("Validation");
+ }
+
+ @Test
+ @DisplayName("sujetPourEvent — violation SoD prioritaire sur actionType")
+ void sujetSodPriority() {
+ assertThat(service.sujetPourEvent(event("PAYMENT_CONFIRMED", false)))
+ .startsWith("⚠ Violation SoD");
+ }
+
+ @Test
+ @DisplayName("prioritePourEvent — règles")
+ void prioriteMapping() {
+ assertThat(service.prioritePourEvent(event("DELETE", true))).isEqualTo("HAUTE");
+ assertThat(service.prioritePourEvent(event("PAYMENT_FAILED", true))).isEqualTo("HAUTE");
+ assertThat(service.prioritePourEvent(event("PAYMENT_CONFIRMED", true))).isEqualTo("NORMALE");
+ assertThat(service.prioritePourEvent(event("BUDGET_APPROVED", true))).isEqualTo("NORMALE");
+ assertThat(service.prioritePourEvent(event("EXPORT", true))).isEqualTo("BASSE");
+ assertThat(service.prioritePourEvent(event("CREATE", false))).isEqualTo("HAUTE"); // SoD
+ }
+
+ @Test
+ @DisplayName("corpsPourEvent — contient les champs clés")
+ void corpsBuilder() {
+ String body = service.corpsPourEvent(event("PAYMENT_CONFIRMED", true));
+ assertThat(body)
+ .contains("Acteur :")
+ .contains("test@unionflow.dev")
+ .contains("Action :")
+ .contains("PAYMENT_CONFIRMED")
+ .contains("Entité :")
+ .contains("Cotisation");
+ }
+
+ @Test
+ @DisplayName("corpsPourEvent — note SoD si violation")
+ void corpsSodNote() {
+ String body = service.corpsPourEvent(event("PAYMENT_CONFIRMED", false));
+ assertThat(body).contains("séparation des pouvoirs");
+ }
+}