From 4d400dc48da7700df44c8cc048eae1aec3849830 Mon Sep 17 00:00:00 2001 From: dahoud <41957584+DahoudG@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:48:59 +0000 Subject: [PATCH] =?UTF-8?q?feat(sprint-7=20P1-NEW-15=202026-04-25):=20PI-S?= =?UTF-8?q?PI=20Readiness=20check=20(8=20v=C3=A9rifications)=20+=20endpoin?= =?UTF-8?q?t=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service ops/compliance pour valider l'état d'intégration PI-SPI BCEAO avant activation production. PispiReadinessService — 8 vérifications structurées - OAUTH2_CREDENTIALS (BLOCKING) : client_id + client_secret - API_KEY (BLOCKING) : header X-API-Key obligatoire BCEAO - MTLS_KEYSTORE (BLOCKING) : path + password + fichier .p12 existant - MTLS_TRUSTSTORE (WARNING) : configuré + fichier existant (fallback cacerts si absent) - WEBHOOK_SECRET (BLOCKING) : HMAC-SHA256 webhooks - BASE_URL (WARNING) : sandbox vs production détecté automatiquement - TAUX_EUR_XOF (WARNING) : taux récent ≤ 7 jours (sinon obsolète) - PROVIDER_CONFIGURED (BLOCKING) : PispiAuth.isConfigured() = true (synthèse) Statut global agrégé - READY — tous PASS - DEGRADED — uniquement WARNING en échec (sandbox OK, prod à risque) - BLOCKED — au moins un BLOCKING en échec (sandbox impossible) Endpoint /api/admin/pispi/readiness - @RolesAllowed SUPER_ADMIN, COMPLIANCE_OFFICER - HTTP 200 si READY/DEGRADED, 503 si BLOCKED - Réponse JSON : globalStatus, baseUrl, checks détaillés, blockingIssues, warnings Helpers publics ajoutés (pour readiness sans casser l'encapsulation) - PispiAuth : hasClientId(), hasClientSecret(), hasApiKey(), keystorePath(), keystorePassword(), truststorePath(), truststorePassword() - PispiSignatureVerifier : hasWebhookSecret() Tests (15/15 verts) - BLOCKED tout vide, READY tout configuré, DEGRADED warnings only - Checks individuels OAuth2/ApiKey/Keystore (path absent / fichier inexistant / OK), Truststore warning, Webhook, BaseUrl sandbox/prod, Taux récent/obsolète, Provider --- .../server/payment/pispi/PispiAuth.java | 17 ++ .../payment/pispi/PispiSignatureVerifier.java | 5 + .../readiness/PispiReadinessResource.java | 42 +++ .../readiness/PispiReadinessService.java | 226 +++++++++++++++ .../readiness/PispiReadinessServiceTest.java | 274 ++++++++++++++++++ 5 files changed, 564 insertions(+) create mode 100644 src/main/java/dev/lions/unionflow/server/payment/pispi/readiness/PispiReadinessResource.java create mode 100644 src/main/java/dev/lions/unionflow/server/payment/pispi/readiness/PispiReadinessService.java create mode 100644 src/test/java/dev/lions/unionflow/server/payment/pispi/readiness/PispiReadinessServiceTest.java diff --git a/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiAuth.java b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiAuth.java index 9e300bb..41910f9 100644 --- a/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiAuth.java +++ b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiAuth.java @@ -117,6 +117,23 @@ public class PispiAuth { return apiKey; } + // ── Readiness inspection helpers (P1-NEW-15) ──────────────────────────── + public boolean hasClientId() { return clientId != null && !clientId.isEmpty(); } + public boolean hasClientSecret() { return clientSecret != null && !clientSecret.isEmpty(); } + public boolean hasApiKey() { return apiKey != null && !apiKey.isEmpty(); } + public java.util.Optional keystorePath() { + return keystorePathOpt.filter(s -> !s.isBlank()); + } + public java.util.Optional keystorePassword() { + return keystorePasswordOpt.filter(s -> !s.isBlank()); + } + public java.util.Optional truststorePath() { + return truststorePathOpt.filter(s -> !s.isBlank()); + } + public java.util.Optional truststorePassword() { + return truststorePasswordOpt.filter(s -> !s.isBlank()); + } + /** Base URL configurée (sandbox ou production). */ public String getBaseUrl() { return baseUrl; diff --git a/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiSignatureVerifier.java b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiSignatureVerifier.java index 9346248..785fe02 100644 --- a/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiSignatureVerifier.java +++ b/src/main/java/dev/lions/unionflow/server/payment/pispi/PispiSignatureVerifier.java @@ -30,6 +30,11 @@ public class PispiSignatureVerifier { allowedIps = allowedIpsOpt.orElse(""); } + /** Readiness helper (P1-NEW-15) — TRUE si webhook secret HMAC est configuré. */ + public boolean hasWebhookSecret() { + return webhookSecret != null && !webhookSecret.isEmpty(); + } + public boolean isIpAllowed(String ip) { if (allowedIps == null || allowedIps.isBlank()) { return true; diff --git a/src/main/java/dev/lions/unionflow/server/payment/pispi/readiness/PispiReadinessResource.java b/src/main/java/dev/lions/unionflow/server/payment/pispi/readiness/PispiReadinessResource.java new file mode 100644 index 0000000..7a138dd --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/payment/pispi/readiness/PispiReadinessResource.java @@ -0,0 +1,42 @@ +package dev.lions.unionflow.server.payment.pispi.readiness; + +import dev.lions.unionflow.server.payment.pispi.readiness.PispiReadinessService.ReadinessReport; +import dev.lions.unionflow.server.payment.pispi.readiness.PispiReadinessService.ReadinessStatus; +import io.quarkus.security.Authenticated; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +/** + * Endpoint d'inspection PI-SPI Readiness — usage ops/compliance avant activation production. + * + *

Status HTTP miroir du {@link ReadinessStatus} : + *

    + *
  • 200 — READY (tout configuré, prêt pour prod)
  • + *
  • 200 — DEGRADED (warnings non bloquants — sandbox OK, prod à risque)
  • + *
  • 503 — BLOCKED (au moins un blocage critique — sandbox impossible)
  • + *
+ * + * @since 2026-04-25 (P1-NEW-15) + */ +@Path("/api/admin/pispi/readiness") +@Produces(MediaType.APPLICATION_JSON) +@Authenticated +public class PispiReadinessResource { + + @Inject PispiReadinessService readinessService; + + @GET + @RolesAllowed({"SUPER_ADMIN", "COMPLIANCE_OFFICER"}) + public Response getReadiness() { + ReadinessReport report = readinessService.verifierReadiness(); + int status = report.globalStatus() == ReadinessStatus.BLOCKED + ? Response.Status.SERVICE_UNAVAILABLE.getStatusCode() + : Response.Status.OK.getStatusCode(); + return Response.status(status).entity(report).build(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/payment/pispi/readiness/PispiReadinessService.java b/src/main/java/dev/lions/unionflow/server/payment/pispi/readiness/PispiReadinessService.java new file mode 100644 index 0000000..8d8ee00 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/payment/pispi/readiness/PispiReadinessService.java @@ -0,0 +1,226 @@ +package dev.lions.unionflow.server.payment.pispi.readiness; + +import dev.lions.unionflow.server.entity.Devise; +import dev.lions.unionflow.server.payment.pispi.PispiAuth; +import dev.lions.unionflow.server.payment.pispi.PispiSignatureVerifier; +import dev.lions.unionflow.server.repository.TauxChangeRepository; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + +/** + * Service de vérification des pré-requis PI-SPI BCEAO avant activation production. + * + *

Permet à l'équipe ops/compliance de valider en un coup d'œil que tous les facteurs + * sont en place avant l'activation de l'intégration PI-SPI sandbox/production. + * + *

8 vérifications réalisées : + *

    + *
  • OAuth2 client credentials (client_id + client_secret)
  • + *
  • X-API-Key API Business
  • + *
  • mTLS keystore présent (path + password)
  • + *
  • Truststore présent (optionnel mais recommandé)
  • + *
  • Webhook signature secret (HMAC-SHA256)
  • + *
  • Base URL configurée (sandbox vs production)
  • + *
  • Taux de change EUR/XOF récent (≤ 7 jours)
  • + *
  • Provider PI-SPI globalement configuré ({@link PispiAuth#isConfigured()})
  • + *
+ * + * @since 2026-04-25 (P1-NEW-15) + */ +@ApplicationScoped +public class PispiReadinessService { + + private static final Logger LOG = Logger.getLogger(PispiReadinessService.class); + + @Inject PispiAuth pispiAuth; + @Inject PispiSignatureVerifier signatureVerifier; + @Inject TauxChangeRepository tauxChangeRepository; + + @ConfigProperty(name = "pispi.api.base-url", + defaultValue = "https://sandbox.pispi.bceao.int/business-api/v1") + String baseUrl; + + /** + * Exécute tous les checks et retourne un rapport structuré. + * + * @return rapport synthèse + détail par check + */ + public ReadinessReport verifierReadiness() { + List checks = new ArrayList<>(); + + checks.add(verifierOAuth2()); + checks.add(verifierApiKey()); + checks.add(verifierMtlsKeystore()); + checks.add(verifierTruststore()); + checks.add(verifierWebhookSecret()); + checks.add(verifierBaseUrl()); + checks.add(verifierTauxEurXof()); + checks.add(verifierProviderConfigured()); + + ReadinessStatus globalStatus = computeGlobalStatus(checks); + List blocking = checks.stream() + .filter(c -> c.severity() == Severity.BLOCKING && c.status() == CheckStatus.FAIL) + .map(c -> c.name() + " — " + c.message()) + .toList(); + List warnings = checks.stream() + .filter(c -> c.severity() == Severity.WARNING && c.status() == CheckStatus.FAIL) + .map(c -> c.name() + " — " + c.message()) + .toList(); + + LOG.infof("PI-SPI Readiness : %s — blocking=%d, warnings=%d", + globalStatus, blocking.size(), warnings.size()); + return new ReadinessReport(globalStatus, baseUrl, checks, blocking, warnings); + } + + // ──────────────────────────────────────────────────────────── + // Checks + // ──────────────────────────────────────────────────────────── + + CheckResult verifierOAuth2() { + boolean ok = pispiAuth.hasClientId() && pispiAuth.hasClientSecret(); + return ok + ? CheckResult.pass("OAUTH2_CREDENTIALS", Severity.BLOCKING, + "client_id et client_secret configurés") + : CheckResult.fail("OAUTH2_CREDENTIALS", Severity.BLOCKING, + "Manquant : pispi.api.client-id et/ou pispi.api.client-secret"); + } + + CheckResult verifierApiKey() { + return pispiAuth.hasApiKey() + ? CheckResult.pass("API_KEY", Severity.BLOCKING, + "X-API-Key configurée") + : CheckResult.fail("API_KEY", Severity.BLOCKING, + "Manquant : pispi.api.api-key (header X-API-Key obligatoire BCEAO)"); + } + + CheckResult verifierMtlsKeystore() { + var pathOpt = pispiAuth.keystorePath(); + var pwdOpt = pispiAuth.keystorePassword(); + + if (pathOpt.isEmpty() || pwdOpt.isEmpty()) { + return CheckResult.fail("MTLS_KEYSTORE", Severity.BLOCKING, + "Manquant : pispi.api.tls.keystore-path et/ou keystore-password (PKCS12 client cert)"); + } + + String path = pathOpt.get(); + if (!Files.exists(Path.of(path))) { + return CheckResult.fail("MTLS_KEYSTORE", Severity.BLOCKING, + "Keystore introuvable au chemin : " + path); + } + return CheckResult.pass("MTLS_KEYSTORE", Severity.BLOCKING, + "Keystore PKCS12 présent : " + path); + } + + CheckResult verifierTruststore() { + var pathOpt = pispiAuth.truststorePath(); + if (pathOpt.isEmpty()) { + return CheckResult.fail("MTLS_TRUSTSTORE", Severity.WARNING, + "Truststore non configuré — fallback sur cacerts JVM (acceptable mais non recommandé)"); + } + String path = pathOpt.get(); + if (!Files.exists(Path.of(path))) { + return CheckResult.fail("MTLS_TRUSTSTORE", Severity.WARNING, + "Truststore introuvable au chemin : " + path); + } + return CheckResult.pass("MTLS_TRUSTSTORE", Severity.WARNING, + "Truststore présent : " + path); + } + + CheckResult verifierWebhookSecret() { + return signatureVerifier.hasWebhookSecret() + ? CheckResult.pass("WEBHOOK_SECRET", Severity.BLOCKING, + "Webhook HMAC secret configuré") + : CheckResult.fail("WEBHOOK_SECRET", Severity.BLOCKING, + "Manquant : pispi.webhook.secret (signature HMAC-SHA256 webhooks)"); + } + + CheckResult verifierBaseUrl() { + if (baseUrl == null || baseUrl.isBlank()) { + return CheckResult.fail("BASE_URL", Severity.BLOCKING, + "Base URL PI-SPI non configurée"); + } + boolean isSandbox = baseUrl.contains("sandbox") || baseUrl.contains("dev"); + String env = isSandbox ? "SANDBOX" : "PRODUCTION"; + return CheckResult.pass("BASE_URL", Severity.WARNING, + "Base URL : " + baseUrl + " (" + env + ")"); + } + + CheckResult verifierTauxEurXof() { + LocalDate dateLimite = LocalDate.now().minusDays(7); + var taux = tauxChangeRepository.trouverPlusRecent(Devise.EUR, Devise.XOF, LocalDate.now()); + + if (taux.isEmpty()) { + return CheckResult.fail("TAUX_EUR_XOF", Severity.WARNING, + "Aucun taux EUR/XOF disponible — conversion impossible (parité fixe BCEAO devrait être seedée)"); + } + if (taux.get().getDateValidite().isBefore(dateLimite)) { + return CheckResult.fail("TAUX_EUR_XOF", Severity.WARNING, + "Taux EUR/XOF obsolète (" + taux.get().getDateValidite() + ") — synchroniser"); + } + return CheckResult.pass("TAUX_EUR_XOF", Severity.WARNING, + "Taux EUR/XOF récent : " + taux.get().getTaux() + " (date " + taux.get().getDateValidite() + ")"); + } + + CheckResult verifierProviderConfigured() { + return pispiAuth.isConfigured() + ? CheckResult.pass("PROVIDER_CONFIGURED", Severity.BLOCKING, + "PispiAuth.isConfigured() = true — provider en mode RÉEL") + : CheckResult.fail("PROVIDER_CONFIGURED", Severity.BLOCKING, + "PispiAuth.isConfigured() = false — provider en mode MOCK (un facteur manque)"); + } + + // ──────────────────────────────────────────────────────────── + // Status global + // ──────────────────────────────────────────────────────────── + + private ReadinessStatus computeGlobalStatus(List checks) { + boolean anyBlockingFail = checks.stream() + .anyMatch(c -> c.severity() == Severity.BLOCKING && c.status() == CheckStatus.FAIL); + if (anyBlockingFail) return ReadinessStatus.BLOCKED; + + boolean anyWarning = checks.stream() + .anyMatch(c -> c.severity() == Severity.WARNING && c.status() == CheckStatus.FAIL); + if (anyWarning) return ReadinessStatus.DEGRADED; + + return ReadinessStatus.READY; + } + + // ──────────────────────────────────────────────────────────── + // DTOs + // ──────────────────────────────────────────────────────────── + + public enum ReadinessStatus { READY, DEGRADED, BLOCKED } + + public enum CheckStatus { PASS, FAIL } + + public enum Severity { BLOCKING, WARNING } + + public record ReadinessReport( + ReadinessStatus globalStatus, + String baseUrl, + List checks, + List blockingIssues, + List warnings + ) {} + + public record CheckResult( + String name, + CheckStatus status, + Severity severity, + String message + ) { + public static CheckResult pass(String name, Severity severity, String message) { + return new CheckResult(name, CheckStatus.PASS, severity, message); + } + public static CheckResult fail(String name, Severity severity, String message) { + return new CheckResult(name, CheckStatus.FAIL, severity, message); + } + } +} diff --git a/src/test/java/dev/lions/unionflow/server/payment/pispi/readiness/PispiReadinessServiceTest.java b/src/test/java/dev/lions/unionflow/server/payment/pispi/readiness/PispiReadinessServiceTest.java new file mode 100644 index 0000000..82b6b86 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/server/payment/pispi/readiness/PispiReadinessServiceTest.java @@ -0,0 +1,274 @@ +package dev.lions.unionflow.server.payment.pispi.readiness; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import dev.lions.unionflow.server.entity.Devise; +import dev.lions.unionflow.server.entity.TauxChange; +import dev.lions.unionflow.server.payment.pispi.PispiAuth; +import dev.lions.unionflow.server.payment.pispi.PispiSignatureVerifier; +import dev.lions.unionflow.server.payment.pispi.readiness.PispiReadinessService.CheckStatus; +import dev.lions.unionflow.server.payment.pispi.readiness.PispiReadinessService.ReadinessStatus; +import dev.lions.unionflow.server.payment.pispi.readiness.PispiReadinessService.Severity; +import dev.lions.unionflow.server.repository.TauxChangeRepository; +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Optional; +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 PispiReadinessServiceTest { + + @Mock TauxChangeRepository tauxChangeRepository; + + private PispiReadinessService service; + private PispiAuth auth; + private PispiSignatureVerifier verifier; + + @BeforeEach + void setUp() throws Exception { + auth = new PispiAuth(); + setField(auth, "clientId", ""); + setField(auth, "clientSecret", ""); + setField(auth, "apiKey", ""); + setField(auth, "keystorePathOpt", Optional.empty()); + setField(auth, "keystorePasswordOpt", Optional.empty()); + setField(auth, "truststorePathOpt", Optional.empty()); + setField(auth, "truststorePasswordOpt", Optional.empty()); + + verifier = new PispiSignatureVerifier(); + setField(verifier, "webhookSecret", ""); + + service = new PispiReadinessService(); + setField(service, "pispiAuth", auth); + setField(service, "signatureVerifier", verifier); + setField(service, "tauxChangeRepository", tauxChangeRepository); + setField(service, "baseUrl", "https://sandbox.pispi.bceao.int/business-api/v1"); + } + + private void setField(Object target, String fieldName, Object value) throws Exception { + Field f = findField(target.getClass(), fieldName); + f.setAccessible(true); + f.set(target, value); + } + + private Field findField(Class cls, String name) throws NoSuchFieldException { + Class c = cls; + while (c != null) { + try { + return c.getDeclaredField(name); + } catch (NoSuchFieldException e) { + c = c.getSuperclass(); + } + } + throw new NoSuchFieldException(name); + } + + private TauxChange tauxRecent() { + TauxChange t = new TauxChange(); + t.setDeviseSource(Devise.EUR); + t.setDeviseCible(Devise.XOF); + t.setTaux(new BigDecimal("655.957")); + t.setDateValidite(LocalDate.now()); + return t; + } + + // ── Tout vide ──────────────────────────────────────────────────────────── + + @Test + @DisplayName("Tout manquant → BLOCKED + checks failed") + void toutManquant_blocked() { + lenient().when(tauxChangeRepository.trouverPlusRecent(Devise.EUR, Devise.XOF, LocalDate.now())) + .thenReturn(Optional.empty()); + + var report = service.verifierReadiness(); + + assertThat(report.globalStatus()).isEqualTo(ReadinessStatus.BLOCKED); + assertThat(report.checks()).hasSize(8); + assertThat(report.blockingIssues()).hasSizeGreaterThanOrEqualTo(4); + } + + // ── Tout configuré ────────────────────────────────────────────────────── + + @Test + @DisplayName("Tout configuré + keystore valide + taux récent → READY") + void toutOk_ready(@org.junit.jupiter.api.io.TempDir java.nio.file.Path tmp) throws Exception { + java.nio.file.Path keystore = tmp.resolve("client.p12"); + java.nio.file.Files.writeString(keystore, "fake-keystore"); + java.nio.file.Path truststore = tmp.resolve("trust.p12"); + java.nio.file.Files.writeString(truststore, "fake-trust"); + + setField(auth, "clientId", "client-uuid"); + setField(auth, "clientSecret", "secret"); + setField(auth, "apiKey", "api-key-xyz"); + setField(auth, "keystorePathOpt", Optional.of(keystore.toString())); + setField(auth, "keystorePasswordOpt", Optional.of("kspw")); + setField(auth, "truststorePathOpt", Optional.of(truststore.toString())); + setField(auth, "truststorePasswordOpt", Optional.of("tspw")); + setField(verifier, "webhookSecret", "webhook-hmac-secret"); + + when(tauxChangeRepository.trouverPlusRecent(Devise.EUR, Devise.XOF, LocalDate.now())) + .thenReturn(Optional.of(tauxRecent())); + + var report = service.verifierReadiness(); + + assertThat(report.globalStatus()).isEqualTo(ReadinessStatus.READY); + assertThat(report.blockingIssues()).isEmpty(); + assertThat(report.warnings()).isEmpty(); + assertThat(report.checks()).allMatch(c -> c.status() == CheckStatus.PASS); + } + + // ── DEGRADED ───────────────────────────────────────────────────────────── + + @Test + @DisplayName("Tout BLOCKING ok mais taux EUR/XOF absent → DEGRADED") + void blockingOkWarningKO_degraded(@org.junit.jupiter.api.io.TempDir java.nio.file.Path tmp) + throws Exception { + java.nio.file.Path keystore = tmp.resolve("client.p12"); + java.nio.file.Files.writeString(keystore, "fake"); + java.nio.file.Path truststore = tmp.resolve("trust.p12"); + java.nio.file.Files.writeString(truststore, "fake"); + + setField(auth, "clientId", "c"); + setField(auth, "clientSecret", "s"); + setField(auth, "apiKey", "k"); + setField(auth, "keystorePathOpt", Optional.of(keystore.toString())); + setField(auth, "keystorePasswordOpt", Optional.of("p")); + setField(auth, "truststorePathOpt", Optional.of(truststore.toString())); + setField(auth, "truststorePasswordOpt", Optional.of("p")); + setField(verifier, "webhookSecret", "secret"); + + when(tauxChangeRepository.trouverPlusRecent(Devise.EUR, Devise.XOF, LocalDate.now())) + .thenReturn(Optional.empty()); // ← warning + + var report = service.verifierReadiness(); + + assertThat(report.globalStatus()).isEqualTo(ReadinessStatus.DEGRADED); + assertThat(report.blockingIssues()).isEmpty(); + assertThat(report.warnings()).anyMatch(w -> w.contains("TAUX_EUR_XOF")); + } + + // ── Checks individuels ─────────────────────────────────────────────────── + + @Test + @DisplayName("verifierOAuth2 — fail si client_id vide") + void oauth2_failClientIdVide() throws Exception { + setField(auth, "clientId", ""); + setField(auth, "clientSecret", "secret"); + var r = service.verifierOAuth2(); + assertThat(r.status()).isEqualTo(CheckStatus.FAIL); + assertThat(r.severity()).isEqualTo(Severity.BLOCKING); + } + + @Test + @DisplayName("verifierOAuth2 — pass si client_id et client_secret") + void oauth2_passComplet() throws Exception { + setField(auth, "clientId", "abc"); + setField(auth, "clientSecret", "def"); + var r = service.verifierOAuth2(); + assertThat(r.status()).isEqualTo(CheckStatus.PASS); + } + + @Test + @DisplayName("verifierApiKey — fail si vide, pass sinon") + void apiKey() throws Exception { + setField(auth, "apiKey", ""); + assertThat(service.verifierApiKey().status()).isEqualTo(CheckStatus.FAIL); + setField(auth, "apiKey", "key"); + assertThat(service.verifierApiKey().status()).isEqualTo(CheckStatus.PASS); + } + + @Test + @DisplayName("verifierMtlsKeystore — fail si chemin absent") + void keystore_failPathAbsent() throws Exception { + setField(auth, "keystorePathOpt", Optional.empty()); + setField(auth, "keystorePasswordOpt", Optional.of("p")); + assertThat(service.verifierMtlsKeystore().status()).isEqualTo(CheckStatus.FAIL); + } + + @Test + @DisplayName("verifierMtlsKeystore — fail si fichier inexistant") + void keystore_failFichierInexistant() throws Exception { + setField(auth, "keystorePathOpt", Optional.of("/nonexistent/keystore.p12")); + setField(auth, "keystorePasswordOpt", Optional.of("p")); + var r = service.verifierMtlsKeystore(); + assertThat(r.status()).isEqualTo(CheckStatus.FAIL); + assertThat(r.message()).contains("introuvable"); + } + + @Test + @DisplayName("verifierMtlsKeystore — pass si path + password + fichier existe") + void keystore_passComplet(@org.junit.jupiter.api.io.TempDir java.nio.file.Path tmp) + throws Exception { + java.nio.file.Path k = tmp.resolve("k.p12"); + java.nio.file.Files.writeString(k, "f"); + setField(auth, "keystorePathOpt", Optional.of(k.toString())); + setField(auth, "keystorePasswordOpt", Optional.of("pw")); + assertThat(service.verifierMtlsKeystore().status()).isEqualTo(CheckStatus.PASS); + } + + @Test + @DisplayName("verifierTruststore — warning si non configuré") + void truststore_warningSiAbsent() throws Exception { + setField(auth, "truststorePathOpt", Optional.empty()); + var r = service.verifierTruststore(); + assertThat(r.status()).isEqualTo(CheckStatus.FAIL); + assertThat(r.severity()).isEqualTo(Severity.WARNING); + } + + @Test + @DisplayName("verifierWebhookSecret — fail si vide") + void webhook_failVide() throws Exception { + setField(verifier, "webhookSecret", ""); + assertThat(service.verifierWebhookSecret().status()).isEqualTo(CheckStatus.FAIL); + } + + @Test + @DisplayName("verifierBaseUrl — distingue sandbox / production") + void baseUrl_environnement() throws Exception { + setField(service, "baseUrl", "https://sandbox.pispi.bceao.int/business-api/v1"); + var rSandbox = service.verifierBaseUrl(); + assertThat(rSandbox.status()).isEqualTo(CheckStatus.PASS); + assertThat(rSandbox.message()).contains("SANDBOX"); + + setField(service, "baseUrl", "https://api.pispi.bceao.int/business-api/v1"); + var rProd = service.verifierBaseUrl(); + assertThat(rProd.message()).contains("PRODUCTION"); + } + + @Test + @DisplayName("verifierTauxEurXof — fail si taux > 7 jours") + void tauxEurXof_failObsolete() { + TauxChange vieux = new TauxChange(); + vieux.setDateValidite(LocalDate.now().minusDays(15)); + vieux.setTaux(new BigDecimal("655.957")); + when(tauxChangeRepository.trouverPlusRecent(Devise.EUR, Devise.XOF, LocalDate.now())) + .thenReturn(Optional.of(vieux)); + + var r = service.verifierTauxEurXof(); + assertThat(r.status()).isEqualTo(CheckStatus.FAIL); + assertThat(r.message()).contains("obsolète"); + } + + @Test + @DisplayName("verifierTauxEurXof — pass si taux récent") + void tauxEurXof_passRecent() { + when(tauxChangeRepository.trouverPlusRecent(Devise.EUR, Devise.XOF, LocalDate.now())) + .thenReturn(Optional.of(tauxRecent())); + assertThat(service.verifierTauxEurXof().status()).isEqualTo(CheckStatus.PASS); + } + + @Test + @DisplayName("verifierProviderConfigured — fail si PispiAuth.isConfigured = false") + void providerConfigured_fail() throws Exception { + setField(auth, "clientId", ""); // not configured + assertThat(service.verifierProviderConfigured().status()).isEqualTo(CheckStatus.FAIL); + } +}