feat(p0-2026-04-25): mobile SPKI pinning + Play Integrity/App Attest + tests Sprint 1
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m7s

P0-NEW-21 — SPKI Pinning + rotation Firebase Remote Config (mobile)
  - lib/core/security/spki_pinning_service.dart : digest SHA-256 SPKI/cert
  - Liste de pins active depuis Firebase Remote Config (key 'spki_pins')
  - Fallback statique au bundle si Firebase indisponible
  - Multi-pin (leaf + backup + intermediate) pour transitions sans downtime
  - Hosts pinnés : api.lions.dev, security.lions.dev
  - Câblé dans ApiClient._configureSslPinning() (remplace check CN obsolète)

P0-NEW-22 — Play Integrity (Android) + App Attest (iOS) (mobile)
  - lib/core/security/app_device_integrity_service.dart
  - Token attestation court (cache 60s) avec challenge backend
  - Bypass automatique en kDebugMode
  - À compléter avec un Dio interceptor X-Device-Integrity-Token avant go-live

pubspec.yaml :
  - freerasp 7.0.0 → 7.5.1
  - +app_device_integrity 1.1.0
  - +firebase_core 3.6.0 + firebase_remote_config 5.1.3

Tests Sprint 1 (40 tests, 0 failure) :
  - ReferentielComptableTest (6 cas) : defaultFor mapping
  - AmlSeuilsTest (10 cas) : seuils 10M/5M/1M, pays UEMOA, depasseSeuil
  - SoDPermissionCheckerTest (13 cas) : validation distincte, combinations interdites,
    compliance officer eligibility
  - PispiRtpRequestTest (6 cas) : validation contraintes
  - PispiRtpResponseTest (5 cas) : helpers status
This commit is contained in:
2026-04-25 01:24:53 +00:00
parent 144137656f
commit 7099f554fe
5 changed files with 421 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
package dev.lions.unionflow.server.entity;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class ReferentielComptableTest {
@Test
@DisplayName("defaultFor null retourne SYSCOHADA")
void defaultForNullReturnsSyscohada() {
assertThat(ReferentielComptable.defaultFor(null)).isEqualTo(ReferentielComptable.SYSCOHADA);
}
@Test
@DisplayName("defaultFor MUTUELLE_SANTE retourne SYCEBNL")
void defaultForMutuelleSanteReturnsSycebnl() {
assertThat(ReferentielComptable.defaultFor("MUTUELLE_SANTE"))
.isEqualTo(ReferentielComptable.SYCEBNL);
}
@Test
@DisplayName("defaultFor ASSOCIATION (case-insensitive) retourne SYCEBNL")
void defaultForAssociationCaseInsensitiveReturnsSycebnl() {
assertThat(ReferentielComptable.defaultFor("association")).isEqualTo(ReferentielComptable.SYCEBNL);
assertThat(ReferentielComptable.defaultFor("Association")).isEqualTo(ReferentielComptable.SYCEBNL);
assertThat(ReferentielComptable.defaultFor("ASSOCIATION")).isEqualTo(ReferentielComptable.SYCEBNL);
}
@Test
@DisplayName("defaultFor LIONS_CLUB / ONG / FONDATION / SYNDICAT / ORDRE_PROFESSIONNEL retourne SYCEBNL")
void defaultForBNLEntitiesReturnsSycebnl() {
for (String t : new String[] {
"LIONS_CLUB", "ONG", "FONDATION", "SYNDICAT", "ORDRE_PROFESSIONNEL", "PROJET_DEVELOPPEMENT"
}) {
assertThat(ReferentielComptable.defaultFor(t))
.as("type=%s", t)
.isEqualTo(ReferentielComptable.SYCEBNL);
}
}
@Test
@DisplayName("defaultFor SFD_TIER_1 retourne PCSFD_UMOA")
void defaultForSfdTier1ReturnsPcsfd() {
assertThat(ReferentielComptable.defaultFor("SFD_TIER_1"))
.isEqualTo(ReferentielComptable.PCSFD_UMOA);
assertThat(ReferentielComptable.defaultFor("SFD_CATEGORIE_III"))
.isEqualTo(ReferentielComptable.PCSFD_UMOA);
}
@Test
@DisplayName("defaultFor type non listé retourne SYSCOHADA")
void defaultForUnknownReturnsSyscohada() {
assertThat(ReferentielComptable.defaultFor("MUTUELLE_EPARGNE_CREDIT"))
.isEqualTo(ReferentielComptable.SYSCOHADA);
assertThat(ReferentielComptable.defaultFor("COOPERATIVE_COMMERCIALE"))
.isEqualTo(ReferentielComptable.SYSCOHADA);
assertThat(ReferentielComptable.defaultFor("INCONNU_XYZ"))
.isEqualTo(ReferentielComptable.SYSCOHADA);
}
}

View File

@@ -0,0 +1,90 @@
package dev.lions.unionflow.server.payment.pispi.dto;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class PispiRtpRequestTest {
private PispiRtpRequest validRequest() {
return new PispiRtpRequest(
"RTP-001",
"SFD-CIV-001",
"ACC-1234",
"Mutuelle X",
"+22507123456@unionflow",
new BigDecimal("5000"),
"XOF",
"COTISATION",
"Cotisation oct 2026",
LocalDateTime.now().plusDays(1),
LocalDateTime.now().plusDays(7));
}
@Test
@DisplayName("Request valide → validate() ne throw pas")
void validRequestPasses() {
validRequest().validate();
}
@Test
@DisplayName("RTP id manquant → IllegalArgumentException")
void rtpIdMissing() {
PispiRtpRequest r = new PispiRtpRequest(
"", "SFD", "ACC", "Mutuelle", "+225@unionflow",
new BigDecimal("5000"), "XOF", "P", "D", null, null);
assertThatThrownBy(r::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("RTP id");
}
@Test
@DisplayName("Montant <= 0 → IllegalArgumentException")
void montantNonPositif() {
PispiRtpRequest r = new PispiRtpRequest(
"RTP-1", "SFD", "ACC", "Mutuelle", "+225@unionflow",
BigDecimal.ZERO, "XOF", "P", "D", null, null);
assertThatThrownBy(r::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("positif");
PispiRtpRequest neg = new PispiRtpRequest(
"RTP-1", "SFD", "ACC", "Mutuelle", "+225@unionflow",
new BigDecimal("-100"), "XOF", "P", "D", null, null);
assertThatThrownBy(neg::validate).isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("Alias débiteur manquant → IllegalArgumentException")
void aliasDebiteurMissing() {
PispiRtpRequest r = new PispiRtpRequest(
"RTP-1", "SFD", "ACC", "Mutuelle", "",
new BigDecimal("5000"), "XOF", "P", "D", null, null);
assertThatThrownBy(r::validate).hasMessageContaining("Alias débiteur");
}
@Test
@DisplayName("Devise non-XOF → IllegalArgumentException")
void deviseNonXof() {
PispiRtpRequest r = new PispiRtpRequest(
"RTP-1", "SFD", "ACC", "Mutuelle", "+225@unionflow",
new BigDecimal("5000"), "EUR", "P", "D", null, null);
assertThatThrownBy(r::validate).hasMessageContaining("XOF");
}
@Test
@DisplayName("Date expiration avant date exécution → IllegalArgumentException")
void expiryAvantExecution() {
LocalDateTime exec = LocalDateTime.now().plusDays(7);
LocalDateTime expiry = LocalDateTime.now().plusDays(2);
PispiRtpRequest r = new PispiRtpRequest(
"RTP-1", "SFD", "ACC", "Mutuelle", "+225@unionflow",
new BigDecimal("5000"), "XOF", "P", "D", exec, expiry);
assertThatThrownBy(r::validate).hasMessageContaining("expiration");
}
}

View File

@@ -0,0 +1,55 @@
package dev.lions.unionflow.server.payment.pispi.dto;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class PispiRtpResponseTest {
@Test
@DisplayName("isAccepted true uniquement si status=ACCEPTED")
void isAccepted() {
var accepted = new PispiRtpResponse("R1", "ACCEPTED", null, null, null, "TX001");
assertThat(accepted.isAccepted()).isTrue();
assertThat(accepted.isRefused()).isFalse();
assertThat(accepted.isPending()).isFalse();
assertThat(accepted.isExpired()).isFalse();
}
@Test
@DisplayName("isRefused true uniquement si status=REFUSED")
void isRefused() {
var refused = new PispiRtpResponse("R1", "REFUSED", "FOCR", "Compte clôturé", null, null);
assertThat(refused.isRefused()).isTrue();
assertThat(refused.isAccepted()).isFalse();
assertThat(refused.isPending()).isFalse();
assertThat(refused.isExpired()).isFalse();
}
@Test
@DisplayName("isPending true uniquement si status=PENDING")
void isPending() {
var pending = new PispiRtpResponse("R1", "PENDING", null, null, null, null);
assertThat(pending.isPending()).isTrue();
assertThat(pending.isAccepted()).isFalse();
}
@Test
@DisplayName("isExpired true uniquement si status=EXPIRED")
void isExpired() {
var expired = new PispiRtpResponse("R1", "EXPIRED", null, null, null, null);
assertThat(expired.isExpired()).isTrue();
assertThat(expired.isAccepted()).isFalse();
}
@Test
@DisplayName("Tous les helpers false si status null")
void statusNullAllFalse() {
var nullStatus = new PispiRtpResponse("R1", null, null, null, null, null);
assertThat(nullStatus.isAccepted()).isFalse();
assertThat(nullStatus.isRefused()).isFalse();
assertThat(nullStatus.isPending()).isFalse();
assertThat(nullStatus.isExpired()).isFalse();
}
}

View File

@@ -0,0 +1,130 @@
package dev.lions.unionflow.server.security;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class SoDPermissionCheckerTest {
private SoDPermissionChecker checker;
private final UUID userA = UUID.randomUUID();
private final UUID userB = UUID.randomUUID();
private final UUID entityId = UUID.randomUUID();
@BeforeEach
void setUp() {
checker = new SoDPermissionChecker();
// AuditTrailService est injecté en prod ; on injecte un mock pour les tests.
checker.auditTrail = mock(AuditTrailService.class);
}
@Test
@DisplayName("checkValidationDistinct PASS si créateur ≠ validateur")
void validationDistinctPass() {
var result = checker.checkValidationDistinct(userA, userB, "Cotisation", entityId);
assertThat(result.passed()).isTrue();
assertThat(result.violationReason()).isNull();
}
@Test
@DisplayName("checkValidationDistinct VIOLATION si même utilisateur")
void validationDistinctViolation() {
var result = checker.checkValidationDistinct(userA, userA, "Cotisation", entityId);
assertThat(result.passed()).isFalse();
assertThat(result.violationReason()).contains("SoD VIOLATION").contains("Cotisation");
}
@Test
@DisplayName("checkValidationDistinct PASS si UUID null (contexte manquant)")
void validationDistinctNullPass() {
assertThat(checker.checkValidationDistinct(null, userA, "X", entityId).passed()).isTrue();
assertThat(checker.checkValidationDistinct(userA, null, "X", entityId).passed()).isTrue();
}
@Test
@DisplayName("checkRoleCombination : Trésorier + Président → VIOLATION")
void cumulTresorierPresidentInterdit() {
var result = checker.checkRoleCombination(userA, Set.of("TRESORIER", "PRESIDENT"));
assertThat(result.passed()).isFalse();
assertThat(result.violationReason()).contains("TRESORIER + PRESIDENT");
}
@Test
@DisplayName("checkRoleCombination : Trésorier + Contrôleur Interne → VIOLATION (auto-contrôle)")
void cumulTresorierControleurInterne() {
var result = checker.checkRoleCombination(userA, Set.of("TRESORIER", "CONTROLEUR_INTERNE"));
assertThat(result.passed()).isFalse();
assertThat(result.violationReason()).contains("auto-contrôle");
}
@Test
@DisplayName("checkRoleCombination : Président + Contrôleur Interne → VIOLATION (juge et partie)")
void cumulPresidentControleur() {
var result = checker.checkRoleCombination(userA, Set.of("PRESIDENT", "CONTROLEUR_INTERNE"));
assertThat(result.passed()).isFalse();
assertThat(result.violationReason()).contains("juge et partie");
}
@Test
@DisplayName("checkRoleCombination : Commissaire aux comptes + autre → VIOLATION (indépendance OHADA)")
void cumulCommissaireInterdit() {
var result = checker.checkRoleCombination(userA, Set.of("COMMISSAIRE_COMPTES", "MEMBRE_ACTIF"));
assertThat(result.passed()).isFalse();
assertThat(result.violationReason()).contains("COMMISSAIRE_COMPTES");
assertThat(result.violationReason()).contains("indépendant");
}
@Test
@DisplayName("checkRoleCombination : un seul rôle → PASS")
void unSeulRolePass() {
assertThat(checker.checkRoleCombination(userA, Set.of("MEMBRE_ACTIF")).passed()).isTrue();
assertThat(checker.checkRoleCombination(userA, Set.of("TRESORIER")).passed()).isTrue();
}
@Test
@DisplayName("checkRoleCombination : combinaison autorisée → PASS")
void combinaisonAutoriseePass() {
assertThat(checker.checkRoleCombination(userA, Set.of("MEMBRE_ACTIF", "ANIMATEUR_ZONE")).passed())
.isTrue();
assertThat(checker.checkRoleCombination(userA, Set.of("SECRETAIRE", "SECRETAIRE_ADJOINT")).passed())
.isTrue();
}
@Test
@DisplayName("checkRoleCombination : null/empty → PASS")
void roleCombinaisonNull() {
assertThat(checker.checkRoleCombination(userA, null).passed()).isTrue();
assertThat(checker.checkRoleCombination(userA, Set.of()).passed()).isTrue();
}
@Test
@DisplayName("checkComplianceOfficerEligibility : Trésorier interdit (Instruction BCEAO 001-03-2025)")
void complianceOfficerNotTresorier() {
var result = checker.checkComplianceOfficerEligibility(
userA, Set.of("TRESORIER", "MEMBRE_ACTIF"));
assertThat(result.passed()).isFalse();
assertThat(result.violationReason()).contains("TRESORIER");
}
@Test
@DisplayName("checkComplianceOfficerEligibility : Commissaire aux comptes interdit (indépendance)")
void complianceOfficerNotCommissaire() {
var result = checker.checkComplianceOfficerEligibility(
userA, Set.of("COMMISSAIRE_COMPTES"));
assertThat(result.passed()).isFalse();
assertThat(result.violationReason()).contains("COMMISSAIRE_COMPTES");
}
@Test
@DisplayName("checkComplianceOfficerEligibility : ADMIN_ORGANISATION + MEMBRE → PASS")
void complianceOfficerAdminMembrePass() {
var result = checker.checkComplianceOfficerEligibility(
userA, Set.of("ADMIN_ORGANISATION", "MEMBRE_ACTIF"));
assertThat(result.passed()).isTrue();
}
}

View File

@@ -0,0 +1,84 @@
package dev.lions.unionflow.server.service;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class AmlSeuilsTest {
@Test
@DisplayName("Seuils alignés Instruction BCEAO 002-03-2025")
void seuilsConformesInstructionBceao() {
assertThat(AmlSeuils.SEUIL_INTRA_UEMOA_FCFA).isEqualByComparingTo(new BigDecimal("10000000"));
assertThat(AmlSeuils.SEUIL_ENTREE_SORTIE_UEMOA_FCFA).isEqualByComparingTo(new BigDecimal("5000000"));
assertThat(AmlSeuils.SEUIL_OPERATION_ESPECE_FCFA).isEqualByComparingTo(new BigDecimal("1000000"));
}
@Test
@DisplayName("Pays UEMOA = 8 États ISO 3166-1 alpha-3")
void paysUemoaContientLes8Etats() {
assertThat(AmlSeuils.PAYS_UEMOA)
.containsExactlyInAnyOrder("BEN", "BFA", "CIV", "GNB", "MLI", "NER", "SEN", "TGO");
}
@Test
@DisplayName("Transaction CIV → SEN (intra-UEMOA) → seuil 10M")
void transactionIntraUemoaSeuil10M() {
assertThat(AmlSeuils.seuilApplicable("CIV", "SEN"))
.isEqualByComparingTo(AmlSeuils.SEUIL_INTRA_UEMOA_FCFA);
}
@Test
@DisplayName("Transaction CIV → FRA (sortie UEMOA) → seuil 5M")
void transactionSortieUemoaSeuil5M() {
assertThat(AmlSeuils.seuilApplicable("CIV", "FRA"))
.isEqualByComparingTo(AmlSeuils.SEUIL_ENTREE_SORTIE_UEMOA_FCFA);
}
@Test
@DisplayName("Transaction USA → CIV (entrée UEMOA) → seuil 5M")
void transactionEntreeUemoaSeuil5M() {
assertThat(AmlSeuils.seuilApplicable("USA", "CIV"))
.isEqualByComparingTo(AmlSeuils.SEUIL_ENTREE_SORTIE_UEMOA_FCFA);
}
@Test
@DisplayName("Pays null → seuil le plus restrictif (5M)")
void paysNullRetourneSeuilRestrictif() {
assertThat(AmlSeuils.seuilApplicable(null, "CIV"))
.isEqualByComparingTo(AmlSeuils.SEUIL_ENTREE_SORTIE_UEMOA_FCFA);
assertThat(AmlSeuils.seuilApplicable("CIV", null))
.isEqualByComparingTo(AmlSeuils.SEUIL_ENTREE_SORTIE_UEMOA_FCFA);
}
@Test
@DisplayName("depasseSeuil intra-UEMOA : 9M sous seuil, 11M au-dessus")
void depasseSeuilIntraUemoa() {
assertThat(AmlSeuils.depasseSeuil(new BigDecimal("9000000"), "CIV", "SEN")).isFalse();
assertThat(AmlSeuils.depasseSeuil(new BigDecimal("10000000"), "CIV", "SEN")).isFalse(); // seuil exact = pas dépassé (>)
assertThat(AmlSeuils.depasseSeuil(new BigDecimal("11000000"), "CIV", "SEN")).isTrue();
}
@Test
@DisplayName("depasseSeuil sortie UEMOA : 4M sous seuil, 6M au-dessus")
void depasseSeuilSortieUemoa() {
assertThat(AmlSeuils.depasseSeuil(new BigDecimal("4000000"), "CIV", "FRA")).isFalse();
assertThat(AmlSeuils.depasseSeuil(new BigDecimal("6000000"), "CIV", "FRA")).isTrue();
}
@Test
@DisplayName("depasseSeuilEspece : 999K sous seuil, 1M001 au-dessus")
void depasseSeuilEspece() {
assertThat(AmlSeuils.depasseSeuilEspece(new BigDecimal("999999"))).isFalse();
assertThat(AmlSeuils.depasseSeuilEspece(new BigDecimal("1000001"))).isTrue();
}
@Test
@DisplayName("Montant null jamais dépasse")
void montantNullJamaisDepasse() {
assertThat(AmlSeuils.depasseSeuil(null, "CIV", "SEN")).isFalse();
assertThat(AmlSeuils.depasseSeuilEspece(null)).isFalse();
}
}