feat(sprint-16 backend 2026-04-25): transparency operations — Métriques Prometheus + Export multi-format + Notifications auto
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m35s

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)
This commit is contained in:
dahoud
2026-04-25 16:28:51 +00:00
parent e4d7c8e4b7
commit 0c46d9bad6
7 changed files with 551 additions and 0 deletions

View File

@@ -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 {

View File

@@ -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).
*
* <p>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;
};
}
}

View File

@@ -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<AuditOperationLoggedEvent> 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.

View File

@@ -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.
*
* <p>Filtre selon {@link AuditOperationLoggedEvent#estSensible()} : opérations financières,
* suppressions, validations, exports, ou violations SoD.
*
* <p>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";
};
}
}

View File

@@ -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.
*
* <p>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<AuditTrailOperationResponse> 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<AuditTrailOperationResponse> 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<AuditTrailOperationResponse> 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) + "";
}
}

View File

@@ -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

View File

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