fix(sprint-17 backend): JwtPropagationFilterTest pur Mockito + revert DevServices global trop agressif

- Refactor JwtPropagationFilterTest : @QuarkusTest → pur Mockito (instanciation directe + champ securityIdentity injecté par réflexion). 9 tests en 2s vs boot Quarkus complet.
- Fusion de JwtPropagationFilterNullIdentityTest dans le test principal (branche securityIdentity == null couverte via setAccessible).
- Revert quarkus.devservices.enabled=false (global trop agressif, violait la règle 'ne pas appauvrir pour fixer').
- Conserve quarkus.keycloak.devservices.enabled=false (légitime : OIDC tenant-enabled=false ⇒ KC inutile).
- Documente la dette H2 → Testcontainers (JSONB/RLS/fonctions PG masqués) inline + memory dédiée.
This commit is contained in:
dahoud
2026-04-25 18:25:40 +00:00
parent 07302f2743
commit 5cc38068d0
3 changed files with 89 additions and 109 deletions

View File

@@ -23,13 +23,21 @@ quarkus.flyway.baseline-on-migrate=false
# Configuration Keycloak pour tests (désactivé) # Configuration Keycloak pour tests (désactivé)
quarkus.oidc.tenant-enabled=false quarkus.oidc.tenant-enabled=false
quarkus.keycloak.policy-enforcer.enabled=false quarkus.keycloak.policy-enforcer.enabled=false
# Désactivation globale de TOUS les DevServices en mode test : # Keycloak DevService désactivé : OIDC tenant-enabled=false ⇒ aucun besoin de KC en test.
# - DataSource : H2 in-memory configuré en dur (pas besoin de Postgres DevService) # (Évite le démarrage d'un container KC parasite de 50s+ ignorant l'instance locale 8180.)
# - Keycloak : OIDC désactivé (pas besoin de container KC, qui prenait 50s+ à boot) # NB : on NE désactive PAS quarkus.devservices.enabled globalement — laisse le mécanisme
# Dev mode (mvn quarkus:dev) : DevServices restent actifs (utiles). # disponible si un futur test active un Postgres Testcontainer (cf. dette H2→Testcontainers).
quarkus.devservices.enabled=false
quarkus.keycloak.devservices.enabled=false quarkus.keycloak.devservices.enabled=false
# DETTE TECHNIQUE — H2 vs Postgres réel
# Les tests utilisent H2 en mode PostgreSQL pour rapidité, mais le projet utilise massivement :
# - JSONB (audit_trail, payload_jsonb, etc.) → mappé en VARCHAR sur H2 (faux positif possible)
# - RLS (Row Level Security PostgreSQL) → non supporté par H2
# - Fonctions PG natives (gen_random_uuid, citext, etc.) → comportement divergent
# TODO sprint dédié : migrer vers Testcontainers Postgres avec reuse
# (testcontainers.reuse.enable=true) pour fidélité prod + démarrage rapide après warm-up.
# Cf. memory : project_test_infrastructure_debt.md
# Configuration HTTP pour tests # Configuration HTTP pour tests
quarkus.http.port=0 quarkus.http.port=0
quarkus.http.test-port=0 quarkus.http.test-port=0

View File

@@ -1,52 +0,0 @@
package dev.lions.unionflow.server.client;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap;
import java.io.IOException;
import java.lang.reflect.Field;
import java.net.URI;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Test SANS @QuarkusTest pour couvrir la branche {@code securityIdentity == null}
* dans {@link JwtPropagationFilter#filter} (L29).
*
* <p>En contexte CDI, {@code securityIdentity} est toujours un proxy non-null.
* Cette branche n'est atteignable qu'en instanciant {@link JwtPropagationFilter} directement
* et en laissant le champ à {@code null} (valeur par défaut Java).
*/
class JwtPropagationFilterNullIdentityTest {
private ClientRequestContext buildMockContext() {
MultivaluedMap<String, Object> headers = new MultivaluedHashMap<>();
ClientRequestContext ctx = mock(ClientRequestContext.class);
when(ctx.getHeaders()).thenReturn(headers);
when(ctx.getUri()).thenReturn(URI.create("http://localhost/api/test"));
return ctx;
}
@Test
@DisplayName("filter : securityIdentity null → warn 'Pas de SecurityIdentity', pas de header Authorization (branche null L29)")
void filter_securityIdentityNull_doesNotPropagate() throws Exception {
// Instanciation directe — securityIdentity reste null (champ non injecté)
JwtPropagationFilter filter = new JwtPropagationFilter();
// securityIdentity est null par défaut (pas d'injection CDI)
// Vérifie que le champ est bien null
Field siField = JwtPropagationFilter.class.getDeclaredField("securityIdentity");
siField.setAccessible(true);
assertThat(siField.get(filter)).isNull();
ClientRequestContext ctx = buildMockContext();
filter.filter(ctx); // ne doit pas lever d'exception
// Pas de header Authorization ajouté
assertThat(ctx.getHeaders().getFirst("Authorization")).isNull();
}
}

View File

@@ -1,44 +1,54 @@
package dev.lions.unionflow.server.client; package dev.lions.unionflow.server.client;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import io.quarkus.oidc.runtime.OidcJwtCallerPrincipal; import io.quarkus.oidc.runtime.OidcJwtCallerPrincipal;
import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import jakarta.ws.rs.client.ClientRequestContext; import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.core.MultivaluedHashMap; import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.MultivaluedMap;
import java.io.IOException;
import java.lang.reflect.Field;
import java.net.URI;
import java.security.Principal;
import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.jwt.JsonWebToken;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.URI;
import java.security.Principal;
/** /**
* Tests pour {@link JwtPropagationFilter}. * Tests pour {@link JwtPropagationFilter} — pur Mockito (pas de @QuarkusTest).
*
* <p>Le filtre est instancié directement et {@code securityIdentity} est injecté
* par réflexion. Cela évite le démarrage Quarkus complet (Postgres/OIDC) pour
* un simple test unitaire de filtre REST Client.
* *
* <p>Couvre toutes les branches de {@code filter()} : * <p>Couvre toutes les branches de {@code filter()} :
* <ul> * <ul>
* <li>securityIdentity anonyme → pas de propagation</li> * <li>{@code securityIdentity == null} → log warn, pas de header (branche L41)</li>
* <li>identité anonyme → pas de propagation</li>
* <li>OidcJwtCallerPrincipal avec token valide → header Authorization propagé</li> * <li>OidcJwtCallerPrincipal avec token valide → header Authorization propagé</li>
* <li>OidcJwtCallerPrincipal avec token vide/blank → pas de propagation</li> * <li>OidcJwtCallerPrincipal avec token null/blank → pas de propagation</li>
* <li>JsonWebToken (non OidcJwtCallerPrincipal) avec token valide → header propagé</li> * <li>JsonWebToken (non OidcJwtCallerPrincipal) avec token valide → header propagé</li>
* <li>JsonWebToken avec token null/blank → pas de propagation</li>
* <li>Principal générique (ni OidcJwtCallerPrincipal ni JsonWebToken) → log warn, pas de header</li> * <li>Principal générique (ni OidcJwtCallerPrincipal ni JsonWebToken) → log warn, pas de header</li>
* </ul> * </ul>
*/ */
@QuarkusTest
class JwtPropagationFilterTest { class JwtPropagationFilterTest {
@Inject private JwtPropagationFilter filter;
JwtPropagationFilter filter; private SecurityIdentity securityIdentity;
@InjectMock @BeforeEach
SecurityIdentity securityIdentity; void setUp() throws Exception {
filter = new JwtPropagationFilter();
securityIdentity = mock(SecurityIdentity.class);
Field f = JwtPropagationFilter.class.getDeclaredField("securityIdentity");
f.setAccessible(true);
f.set(filter, securityIdentity);
}
private ClientRequestContext buildMockContext() { private ClientRequestContext buildMockContext() {
MultivaluedMap<String, Object> headers = new MultivaluedHashMap<>(); MultivaluedMap<String, Object> headers = new MultivaluedHashMap<>();
@@ -48,6 +58,22 @@ class JwtPropagationFilterTest {
return ctx; return ctx;
} }
// ─── Branch: securityIdentity == null ────────────────────────────────────
@Test
@DisplayName("filter : securityIdentity null → warn, pas de header (branche L41)")
void filter_securityIdentityNull_doesNotPropagate() throws Exception {
// Remettre le champ à null pour cette branche spécifique
Field f = JwtPropagationFilter.class.getDeclaredField("securityIdentity");
f.setAccessible(true);
f.set(filter, null);
ClientRequestContext ctx = buildMockContext();
filter.filter(ctx);
assertThat(ctx.getHeaders().getFirst("Authorization")).isNull();
}
// ─── Branch: securityIdentity.isAnonymous() = true → skip ──────────────── // ─── Branch: securityIdentity.isAnonymous() = true → skip ────────────────
@Test @Test
@@ -97,12 +123,28 @@ class JwtPropagationFilterTest {
assertThat(ctx.getHeaders().getFirst("Authorization")).isNull(); assertThat(ctx.getHeaders().getFirst("Authorization")).isNull();
} }
// ─── Branch: OidcJwtCallerPrincipal avec token null ──────────────────────
@Test
@DisplayName("filter : OidcJwtCallerPrincipal avec token null → pas de propagation")
void filter_oidcPrincipalWithNullToken_doesNotPropagate() throws IOException {
OidcJwtCallerPrincipal principal = mock(OidcJwtCallerPrincipal.class);
when(principal.getRawToken()).thenReturn(null);
when(securityIdentity.isAnonymous()).thenReturn(false);
when(securityIdentity.getPrincipal()).thenReturn(principal);
ClientRequestContext ctx = buildMockContext();
filter.filter(ctx);
assertThat(ctx.getHeaders().getFirst("Authorization")).isNull();
}
// ─── Branch: JsonWebToken (NOT OidcJwtCallerPrincipal) ─────────────────── // ─── Branch: JsonWebToken (NOT OidcJwtCallerPrincipal) ───────────────────
@Test @Test
@DisplayName("filter : JsonWebToken principal (non-OIDC) avec token valide → Authorization propagé") @DisplayName("filter : JsonWebToken principal (non-OIDC) avec token valide → Authorization propagé")
void filter_jsonWebTokenPrincipalWithValidToken_propagatesToken() throws IOException { void filter_jsonWebTokenPrincipalWithValidToken_propagatesToken() throws IOException {
// JsonWebToken mock n'est PAS OidcJwtCallerPrincipal → branche else-if
JsonWebToken jwt = mock(JsonWebToken.class); JsonWebToken jwt = mock(JsonWebToken.class);
when(jwt.getRawToken()).thenReturn("valid-jwt-from-JsonWebToken"); when(jwt.getRawToken()).thenReturn("valid-jwt-from-JsonWebToken");
@@ -117,41 +159,6 @@ class JwtPropagationFilterTest {
assertThat(authHeader.toString()).isEqualTo("Bearer valid-jwt-from-JsonWebToken"); assertThat(authHeader.toString()).isEqualTo("Bearer valid-jwt-from-JsonWebToken");
} }
// ─── Branch: principal ni OidcJwtCallerPrincipal ni JsonWebToken ─────────
@Test
@DisplayName("filter : principal inconnu (ni OIDC ni JWT) → log warn, pas de header Authorization")
void filter_unknownPrincipalType_doesNotPropagate() throws IOException {
// Principal générique — ni OidcJwtCallerPrincipal ni JsonWebToken → branche else
Principal genericPrincipal = mock(Principal.class);
when(genericPrincipal.getName()).thenReturn("some-user");
when(securityIdentity.isAnonymous()).thenReturn(false);
when(securityIdentity.getPrincipal()).thenReturn(genericPrincipal);
ClientRequestContext ctx = buildMockContext();
filter.filter(ctx);
assertThat(ctx.getHeaders().getFirst("Authorization")).isNull();
}
// ─── Branch: OidcJwtCallerPrincipal avec token null → pas de propagation ─
@Test
@DisplayName("filter : OidcJwtCallerPrincipal avec token null → pas de propagation (branche token==null L35)")
void filter_oidcPrincipalWithNullToken_doesNotPropagate() throws IOException {
OidcJwtCallerPrincipal principal = mock(OidcJwtCallerPrincipal.class);
when(principal.getRawToken()).thenReturn(null); // null → condition false
when(securityIdentity.isAnonymous()).thenReturn(false);
when(securityIdentity.getPrincipal()).thenReturn(principal);
ClientRequestContext ctx = buildMockContext();
filter.filter(ctx);
assertThat(ctx.getHeaders().getFirst("Authorization")).isNull();
}
@Test @Test
@DisplayName("filter : JsonWebToken principal avec token null → pas de propagation") @DisplayName("filter : JsonWebToken principal avec token null → pas de propagation")
void filter_jsonWebTokenWithNullToken_doesNotPropagate() throws IOException { void filter_jsonWebTokenWithNullToken_doesNotPropagate() throws IOException {
@@ -181,4 +188,21 @@ class JwtPropagationFilterTest {
assertThat(ctx.getHeaders().getFirst("Authorization")).isNull(); assertThat(ctx.getHeaders().getFirst("Authorization")).isNull();
} }
// ─── Branch: principal ni OidcJwtCallerPrincipal ni JsonWebToken ─────────
@Test
@DisplayName("filter : principal inconnu (ni OIDC ni JWT) → log warn, pas de header Authorization")
void filter_unknownPrincipalType_doesNotPropagate() throws IOException {
Principal genericPrincipal = mock(Principal.class);
when(genericPrincipal.getName()).thenReturn("some-user");
when(securityIdentity.isAnonymous()).thenReturn(false);
when(securityIdentity.getPrincipal()).thenReturn(genericPrincipal);
ClientRequestContext ctx = buildMockContext();
filter.filter(ctx);
assertThat(ctx.getHeaders().getFirst("Authorization")).isNull();
}
} }