feat(sprint-7 P1-NEW-15 2026-04-25): PI-SPI Readiness check (8 vérifications) + endpoint admin
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m46s
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m46s
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
This commit is contained in:
@@ -117,6 +117,23 @@ public class PispiAuth {
|
|||||||
return apiKey;
|
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<String> keystorePath() {
|
||||||
|
return keystorePathOpt.filter(s -> !s.isBlank());
|
||||||
|
}
|
||||||
|
public java.util.Optional<String> keystorePassword() {
|
||||||
|
return keystorePasswordOpt.filter(s -> !s.isBlank());
|
||||||
|
}
|
||||||
|
public java.util.Optional<String> truststorePath() {
|
||||||
|
return truststorePathOpt.filter(s -> !s.isBlank());
|
||||||
|
}
|
||||||
|
public java.util.Optional<String> truststorePassword() {
|
||||||
|
return truststorePasswordOpt.filter(s -> !s.isBlank());
|
||||||
|
}
|
||||||
|
|
||||||
/** Base URL configurée (sandbox ou production). */
|
/** Base URL configurée (sandbox ou production). */
|
||||||
public String getBaseUrl() {
|
public String getBaseUrl() {
|
||||||
return baseUrl;
|
return baseUrl;
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ public class PispiSignatureVerifier {
|
|||||||
allowedIps = allowedIpsOpt.orElse("");
|
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) {
|
public boolean isIpAllowed(String ip) {
|
||||||
if (allowedIps == null || allowedIps.isBlank()) {
|
if (allowedIps == null || allowedIps.isBlank()) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Status HTTP miroir du {@link ReadinessStatus} :
|
||||||
|
* <ul>
|
||||||
|
* <li>200 — READY (tout configuré, prêt pour prod)</li>
|
||||||
|
* <li>200 — DEGRADED (warnings non bloquants — sandbox OK, prod à risque)</li>
|
||||||
|
* <li>503 — BLOCKED (au moins un blocage critique — sandbox impossible)</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>8 vérifications réalisées :
|
||||||
|
* <ul>
|
||||||
|
* <li>OAuth2 client credentials (client_id + client_secret)</li>
|
||||||
|
* <li>X-API-Key API Business</li>
|
||||||
|
* <li>mTLS keystore présent (path + password)</li>
|
||||||
|
* <li>Truststore présent (optionnel mais recommandé)</li>
|
||||||
|
* <li>Webhook signature secret (HMAC-SHA256)</li>
|
||||||
|
* <li>Base URL configurée (sandbox vs production)</li>
|
||||||
|
* <li>Taux de change EUR/XOF récent (≤ 7 jours)</li>
|
||||||
|
* <li>Provider PI-SPI globalement configuré ({@link PispiAuth#isConfigured()})</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @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<CheckResult> 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<String> blocking = checks.stream()
|
||||||
|
.filter(c -> c.severity() == Severity.BLOCKING && c.status() == CheckStatus.FAIL)
|
||||||
|
.map(c -> c.name() + " — " + c.message())
|
||||||
|
.toList();
|
||||||
|
List<String> 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<CheckResult> 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<CheckResult> checks,
|
||||||
|
List<String> blockingIssues,
|
||||||
|
List<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.<String>empty());
|
||||||
|
setField(auth, "keystorePasswordOpt", Optional.<String>empty());
|
||||||
|
setField(auth, "truststorePathOpt", Optional.<String>empty());
|
||||||
|
setField(auth, "truststorePasswordOpt", Optional.<String>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.<String>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.<String>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user