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"); + } +}