diff --git a/jans-fido2/server/src/main/java/io/jans/fido2/service/metric/Fido2AnalyticsService.java b/jans-fido2/server/src/main/java/io/jans/fido2/service/metric/Fido2AnalyticsService.java index ef22bea4463..b68842c37a1 100644 --- a/jans-fido2/server/src/main/java/io/jans/fido2/service/metric/Fido2AnalyticsService.java +++ b/jans-fido2/server/src/main/java/io/jans/fido2/service/metric/Fido2AnalyticsService.java @@ -132,11 +132,11 @@ public Map generateExecutiveSummary(LocalDateTime startTime, Loc // Top insights List insights = new ArrayList<>(); - // User adoption insights - Long totalUsers = (Long) userAdoption.get(Fido2MetricsConstants.TOTAL_UNIQUE_USERS); - Long newUsers = (Long) userAdoption.get(Fido2MetricsConstants.NEW_USERS); - if (totalUsers != null && newUsers != null && totalUsers > 0) { - double adoptionRate = (double) newUsers / totalUsers; + // User adoption insights - reuse getUserAdoptionMetrics's adoptionRate rather than + // recomputing one from totalUniqueUsers/newUsers, which measures window activity rather + // than a user population and falls toward zero exactly as adoption succeeds. + Double adoptionRate = (Double) userAdoption.get(Fido2MetricsConstants.ADOPTION_RATE); + if (adoptionRate != null) { if (adoptionRate > STRONG_ADOPTION_RATE_THRESHOLD) { insights.add("Strong user adoption with " + String.format("%.1f%%", adoptionRate * 100) + " new users"); } else if (adoptionRate < LOW_ADOPTION_RATE_THRESHOLD) { diff --git a/jans-fido2/server/src/test/java/io/jans/fido2/service/metric/Fido2AnalyticsServiceTest.java b/jans-fido2/server/src/test/java/io/jans/fido2/service/metric/Fido2AnalyticsServiceTest.java new file mode 100644 index 00000000000..2576820628f --- /dev/null +++ b/jans-fido2/server/src/test/java/io/jans/fido2/service/metric/Fido2AnalyticsServiceTest.java @@ -0,0 +1,120 @@ +/* + * Janssen Project software is available under the MIT License (2008). See http://opensource.org/licenses/MIT for full text. + * + * Copyright (c) 2026, Janssen Project + */ + +package io.jans.fido2.service.metric; + +import io.jans.fido2.model.metric.Fido2MetricsConstants; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.slf4j.Logger; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * Test class for {@link Fido2AnalyticsService#generateExecutiveSummary}. + * + *

{@code getUserAdoptionMetrics} stores {@code totalUniqueUsers} and {@code newUsers} as + * {@code Set.size()} — an {@code int}, autoboxed to {@code Integer} — so casting either to + * {@code Long} here threw {@code ClassCastException} on every window with at least one active user. + * The recomputed rate also measured window activity rather than a user population, which is exactly + * the defect fixed in {@code Fido2MetricsService.getUserAdoptionMetrics} itself; reusing its + * {@code adoptionRate} fixes both at once. + * + * @author Janssen Project + * @version 1.0 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class Fido2AnalyticsServiceTest { + + @Mock + private Logger log; + + @Mock + private Fido2MetricsService metricsService; + + @InjectMocks + private Fido2AnalyticsService fido2AnalyticsService; + + @Test + void generateExecutiveSummary_ifWindowHasActiveUsers_doesNotThrow() { + stubAdoption(Map.of( + Fido2MetricsConstants.TOTAL_UNIQUE_USERS, 5, + Fido2MetricsConstants.NEW_USERS, 2, + Fido2MetricsConstants.ADOPTION_RATE, 0.6)); + + Map summary = summary(); + + assertEquals(0.6, (Double) summary.get(Fido2MetricsConstants.ADOPTION_RATE), 0.0001); + assertTrue(insights(summary).stream().anyMatch(i -> i.contains("Strong user adoption"))); + } + + /** + * A recompute from totalUniqueUsers/newUsers would have reported this as low adoption + * (1 new / 5 active = 0.2), even though the population-correct adoptionRate is high. Reusing the + * already-fixed figure keeps the two call sites in agreement instead of disagreeing about what + * "adoption" means. + */ + @Test + void generateExecutiveSummary_usesAdoptionRateFromMetricsService_notWindowActivityRatio() { + stubAdoption(Map.of( + Fido2MetricsConstants.TOTAL_UNIQUE_USERS, 5, + Fido2MetricsConstants.NEW_USERS, 1, + Fido2MetricsConstants.ADOPTION_RATE, 0.75)); + + List insights = insights(summary()); + + assertTrue(insights.stream().anyMatch(i -> i.contains("Strong user adoption")), + "must reflect the fixed adoptionRate (0.75), not the window ratio newUsers/totalUniqueUsers (0.2)"); + } + + /** + * With no population to measure adoption against, getUserAdoptionMetrics reports adoptionRate as + * null rather than a misleading 0.0; the summary must not add an insight from an unknown rate, and + * must not throw unboxing it. + */ + @Test + void generateExecutiveSummary_ifAdoptionRateIsUnknown_addsNoAdoptionInsight() { + Map adoption = new HashMap<>(); + adoption.put(Fido2MetricsConstants.TOTAL_UNIQUE_USERS, 0); + adoption.put(Fido2MetricsConstants.NEW_USERS, 0); + adoption.put(Fido2MetricsConstants.ADOPTION_RATE, null); + stubAdoption(adoption); + + List insights = insights(summary()); + + assertFalse(insights.stream().anyMatch(i -> i.contains("adoption"))); + } + + @SuppressWarnings("unchecked") + private List insights(Map summary) { + return (List) summary.get("keyInsights"); + } + + private void stubAdoption(Map adoption) { + when(metricsService.getUserAdoptionMetrics(any(), any())).thenReturn(adoption); + when(metricsService.getPerformanceMetrics(any(), any())).thenReturn(Map.of()); + when(metricsService.getErrorAnalysis(any(), any())).thenReturn(Map.of()); + } + + private Map summary() { + return fido2AnalyticsService.generateExecutiveSummary(LocalDateTime.now().minusDays(1), LocalDateTime.now()); + } +}