# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: dependabot[bot] <[email protected]>
Signed-off-by: Balázs Szücs <[email protected]>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: ConnorYoh <[email protected]>
Co-authored-by: Connor Yoh <[email protected]>
Co-authored-by: OUNZAR Aymane <[email protected]>
Co-authored-by: YAOU Reda <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: Balázs Szücs <[email protected]>
Co-authored-by: Ludy <[email protected]>
Co-authored-by: tkymmm <[email protected]>
Co-authored-by: Peter Dave Hello <[email protected]>
Co-authored-by: albanobattistella <[email protected]>
Co-authored-by: PingLin8888 <[email protected]>
Co-authored-by: FdaSilvaYY <[email protected]>
Co-authored-by: Copilot <[email protected]>
Co-authored-by: OteJlo <[email protected]>
Co-authored-by: Angel <[email protected]>
Co-authored-by: Ricardo Catarino <[email protected]>
Co-authored-by: Luis Antonio Argüelles González <[email protected]>
Co-authored-by: Dawid Urbański <[email protected]>
Co-authored-by: Stephan Paternotte <[email protected]>
Co-authored-by: Leonardo Santos Paulucio <[email protected]>
Co-authored-by: hamza khalem <[email protected]>
Co-authored-by: IT Creativity + Art Team <[email protected]>
Co-authored-by: Reece Browne <[email protected]>
Co-authored-by: James Brunton <[email protected]>
Co-authored-by: Victor Villarreal <[email protected]>
This commit is contained in:
Anthony Stirling
2025-12-21 10:40:32 +00:00
committed by GitHub
co-authored by ConnorYoh Connor Yoh OUNZAR Aymane YAOU Reda dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> Balázs Szücs Ludy tkymmm Peter Dave Hello albanobattistella PingLin8888 FdaSilvaYY Copilot OteJlo Angel Ricardo Catarino Luis Antonio Argüelles González Dawid Urbański Stephan Paternotte Leonardo Santos Paulucio hamza khalem IT Creativity + Art Team Reece Browne James Brunton Victor Villarreal
parent a5dcdd5bd9
commit 68ed54e398
343 changed files with 25208 additions and 6588 deletions
@@ -0,0 +1,74 @@
package stirling.software.proprietary.model;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.security.model.User;
@ExtendWith(MockitoExtension.class)
class TeamTest {
@Test
void users_isInitializedAndEmpty() {
Team team = new Team();
assertNotNull(team.getUsers(), "users Set should be initialized");
assertTrue(team.getUsers().isEmpty(), "users Set should start empty");
}
@Test
void addUser_addsToSet_and_setsBackReference() {
Team team = new Team();
User user = mock(User.class);
team.addUser(user);
assertTrue(team.getUsers().contains(user), "Team should contain added user");
verify(user, times(1)).setTeam(team);
verifyNoMoreInteractions(user);
}
@Test
void addUser_twice_isIdempotent_dueToSetSemantics() {
Team team = new Team();
User user = mock(User.class);
team.addUser(user);
team.addUser(user);
assertEquals(1, team.getUsers().size(), "Adding same user twice should not duplicate");
// In our code, setTeam is called twice (we only test Set idempotency)
verify(user, times(2)).setTeam(team);
}
@Test
void removeUser_removesFromSet_and_clearsBackReference() {
Team team = new Team();
User user = mock(User.class);
team.addUser(user);
assertTrue(team.getUsers().contains(user));
team.removeUser(user);
assertFalse(team.getUsers().contains(user), "User should be removed from Team");
verify(user, times(1)).setTeam(null);
}
@Test
void removeUser_onUserNotInSet_still_clearsBackReference() {
Team team = new Team();
User stranger = mock(User.class);
// not added
team.removeUser(stranger);
// Set remains empty
assertTrue(team.getUsers().isEmpty());
// Back-reference is still set to null
verify(stranger, times(1)).setTeam(null);
}
}
@@ -0,0 +1,58 @@
package stirling.software.proprietary.model.dto;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class TeamWithUserCountDTOTest {
@Test
void allArgsConstructor_setsFields() {
TeamWithUserCountDTO dto = new TeamWithUserCountDTO(1L, "Engineering", 42L);
assertEquals(1L, dto.getId());
assertEquals("Engineering", dto.getName());
assertEquals(42L, dto.getUserCount());
}
@Test
void noArgsConstructor_and_setters_work() {
TeamWithUserCountDTO dto = new TeamWithUserCountDTO();
assertNull(dto.getId());
assertNull(dto.getName());
assertNull(dto.getUserCount());
dto.setId(7L);
dto.setName("Ops");
dto.setUserCount(5L);
assertEquals(7L, dto.getId());
assertEquals("Ops", dto.getName());
assertEquals(5L, dto.getUserCount());
}
@Test
void equals_and_hashCode_based_on_fields() {
TeamWithUserCountDTO a = new TeamWithUserCountDTO(10L, "Team", 3L);
TeamWithUserCountDTO b = new TeamWithUserCountDTO(10L, "Team", 3L);
TeamWithUserCountDTO c = new TeamWithUserCountDTO(10L, "Team", 4L); // differs in userCount
assertEquals(a, b);
assertEquals(a.hashCode(), b.hashCode());
assertNotEquals(a, c);
// Not strictly required but often true when a field differs:
assertNotEquals(a.hashCode(), c.hashCode());
}
@Test
void toString_contains_field_values() {
TeamWithUserCountDTO dto = new TeamWithUserCountDTO(2L, "QA", 8L);
String ts = dto.toString();
assertTrue(ts.contains("2"));
assertTrue(ts.contains("QA"));
assertTrue(ts.contains("8"));
}
}
@@ -16,7 +16,6 @@ import org.springframework.security.oauth2.client.authentication.OAuth2Authentic
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.service.JwtServiceInterface;
@@ -25,8 +24,6 @@ class CustomLogoutSuccessHandlerTest {
@Mock private ApplicationProperties.Security securityProperties;
@Mock private AppConfig appConfig;
@Mock private JwtServiceInterface jwtService;
@InjectMocks private CustomLogoutSuccessHandler customLogoutSuccessHandler;
@@ -83,4 +83,12 @@ class DatabaseConfigTest {
assertThrows(UnsupportedProviderException.class, () -> databaseConfig.dataSource());
}
@Test
void getDriverClassName_returnsH2Driver() throws Exception {
var m = DatabaseConfig.class.getDeclaredMethod("getDriverClassName", String.class);
m.setAccessible(true);
String driver = (String) m.invoke(databaseConfig, "h2");
assertEquals(org.springframework.boot.jdbc.DatabaseDriver.H2.getDriverClassName(), driver);
}
}
@@ -61,7 +61,7 @@ class LicenseKeyCheckerTest {
ApplicationProperties props = new ApplicationProperties();
props.getPremium().setEnabled(true);
props.getPremium().setKey("file:" + file.toString());
props.getPremium().setKey("file:" + file);
when(verifier.verifyLicense("filekey")).thenReturn(License.ENTERPRISE);
LicenseKeyChecker checker =
@@ -77,7 +77,7 @@ class LicenseKeyCheckerTest {
Path file = temp.resolve("missing.txt");
ApplicationProperties props = new ApplicationProperties();
props.getPremium().setEnabled(true);
props.getPremium().setKey("file:" + file.toString());
props.getPremium().setKey("file:" + file);
LicenseKeyChecker checker =
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
@@ -0,0 +1,87 @@
package stirling.software.proprietary.security.database;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.core.type.AnnotatedTypeMetadata;
import org.springframework.mock.env.MockEnvironment;
class H2SQLConditionTest {
private final H2SQLCondition condition = new H2SQLCondition();
private boolean eval(MockEnvironment env) {
ConditionContext ctx = mock(ConditionContext.class);
when(ctx.getEnvironment()).thenReturn(env);
AnnotatedTypeMetadata md = mock(AnnotatedTypeMetadata.class);
return condition.matches(ctx, md);
}
@Test
void returnsTrue_whenDisabledOrMissing_and_typeIsH2_caseInsensitive() {
// Flag fehlt, Typ=h2 -> true
MockEnvironment envMissingFlag =
new MockEnvironment().withProperty("system.datasource.type", "h2");
assertTrue(eval(envMissingFlag));
// Flag=false, Typ=H2 -> true
MockEnvironment envFalseFlag =
new MockEnvironment()
.withProperty("system.datasource.enableCustomDatabase", "false")
.withProperty("system.datasource.type", "H2");
assertTrue(eval(envFalseFlag));
}
@Test
void returnsFalse_whenEnableCustomDatabase_true_regardlessOfType() {
// Flag=true, Typ=h2 -> false
MockEnvironment envTrueH2 =
new MockEnvironment()
.withProperty("system.datasource.enableCustomDatabase", "true")
.withProperty("system.datasource.type", "h2");
assertFalse(eval(envTrueH2));
// Flag=true, Typ=postgres -> false
MockEnvironment envTrueOther =
new MockEnvironment()
.withProperty("system.datasource.enableCustomDatabase", "true")
.withProperty("system.datasource.type", "postgresql");
assertFalse(eval(envTrueOther));
// Flag=true, Typ fehlt -> false
MockEnvironment envTrueMissingType =
new MockEnvironment()
.withProperty("system.datasource.enableCustomDatabase", "true");
assertFalse(eval(envTrueMissingType));
}
@Test
void returnsFalse_whenTypeNotH2_orMissing_andFlagNotEnabled() {
// Flag fehlt, Typ=postgres -> false
MockEnvironment envNotH2 =
new MockEnvironment().withProperty("system.datasource.type", "postgresql");
assertFalse(eval(envNotH2));
// Flag=false, Typ fehlt -> false (Default: "")
MockEnvironment envMissingType =
new MockEnvironment()
.withProperty("system.datasource.enableCustomDatabase", "false");
assertFalse(eval(envMissingType));
}
@Test
void returnsFalse_whenEnabled_but_type_not_h2_or_missing() {
MockEnvironment envNotH2 =
new MockEnvironment()
.withProperty("system.datasource.enableCustomDatabase", "true")
.withProperty("system.datasource.type", "postgresql");
assertFalse(eval(envNotH2));
MockEnvironment envMissingType =
new MockEnvironment()
.withProperty("system.datasource.enableCustomDatabase", "true");
assertFalse(eval(envMissingType));
}
}
@@ -0,0 +1,71 @@
package stirling.software.proprietary.security.database;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.lang.reflect.Method;
import java.sql.SQLException;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.annotation.Conditional;
import org.springframework.scheduling.annotation.Scheduled;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
@ExtendWith(MockitoExtension.class)
class ScheduledTasksTest {
@Mock private DatabaseServiceInterface databaseService;
@Test
void performBackup_calls_exportDatabase() throws Exception {
ScheduledTasks tasks = new ScheduledTasks(databaseService);
tasks.performBackup();
verify(databaseService, times(1)).exportDatabase();
verifyNoMoreInteractions(databaseService);
}
@Test
void performBackup_propagates_SQLException() throws Exception {
ScheduledTasks tasks = new ScheduledTasks(databaseService);
doThrow(new SQLException("boom")).when(databaseService).exportDatabase();
assertThrows(SQLException.class, tasks::performBackup);
}
@Test
void performBackup_propagates_UnsupportedProviderException() throws Exception {
ScheduledTasks tasks = new ScheduledTasks(databaseService);
doThrow(new UnsupportedProviderException("nope")).when(databaseService).exportDatabase();
assertThrows(UnsupportedProviderException.class, tasks::performBackup);
}
@Test
void hasScheduledAnnotation_withSpELCron() throws Exception {
Method m = ScheduledTasks.class.getDeclaredMethod("performBackup");
Scheduled scheduled = m.getAnnotation(Scheduled.class);
assertNotNull(scheduled, "@Scheduled annotation missing on performBackup()");
assertEquals(
"#{applicationProperties.system.databaseBackup.cron}",
scheduled.cron(),
"Unexpected cron SpEL expression");
}
@Test
void classHasConditional_onH2SQLCondition() {
Conditional conditional = ScheduledTasks.class.getAnnotation(Conditional.class);
assertNotNull(conditional, "@Conditional missing on ScheduledTasks class");
boolean containsH2 =
Arrays.stream(conditional.value()).anyMatch(c -> c == H2SQLCondition.class);
assertTrue(containsH2, "@Conditional should include H2SQLCondition");
}
}
@@ -0,0 +1,141 @@
package stirling.software.proprietary.security.database.repository;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import java.util.Date;
import java.util.Optional;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken;
import stirling.software.proprietary.security.model.PersistentLogin;
class JPATokenRepositoryImplTest {
private final PersistentLoginRepository persistentLoginRepository =
mock(PersistentLoginRepository.class);
private final JPATokenRepositoryImpl tokenRepository =
new JPATokenRepositoryImpl(persistentLoginRepository);
@Nested
@DisplayName("createNewToken")
class CreateNewTokenTests {
@Test
@DisplayName("should save new PersistentLogin with correct values")
void shouldSaveNewToken() {
Date date = new Date();
PersistentRememberMeToken token =
new PersistentRememberMeToken("user1", "series123", "tokenABC", date);
tokenRepository.createNewToken(token);
ArgumentCaptor<PersistentLogin> captor = ArgumentCaptor.forClass(PersistentLogin.class);
verify(persistentLoginRepository).save(captor.capture());
PersistentLogin saved = captor.getValue();
assertEquals("series123", saved.getSeries());
assertEquals("user1", saved.getUsername());
assertEquals("tokenABC", saved.getToken());
assertEquals(date.toInstant(), saved.getLastUsed());
}
}
@Nested
@DisplayName("updateToken")
class UpdateTokenTests {
@Test
@DisplayName("should update existing token if found")
void shouldUpdateExistingToken() {
PersistentLogin existing = new PersistentLogin();
existing.setSeries("series123");
existing.setUsername("user1");
existing.setToken("oldToken");
existing.setLastUsed(new Date().toInstant());
when(persistentLoginRepository.findById("series123")).thenReturn(Optional.of(existing));
Date newDate = new Date();
tokenRepository.updateToken("series123", "newToken", newDate);
assertEquals("newToken", existing.getToken());
assertEquals(newDate.toInstant(), existing.getLastUsed());
verify(persistentLoginRepository).save(existing);
}
@Test
@DisplayName("should do nothing if token not found")
void shouldDoNothingIfNotFound() {
when(persistentLoginRepository.findById("unknownSeries")).thenReturn(Optional.empty());
tokenRepository.updateToken("unknownSeries", "newToken", new Date());
verify(persistentLoginRepository, never()).save(any());
}
}
@Nested
@DisplayName("getTokenForSeries")
class GetTokenForSeriesTests {
@Test
@DisplayName("should return PersistentRememberMeToken if found")
void shouldReturnTokenIfFound() {
Date date = new Date();
PersistentLogin login = new PersistentLogin();
login.setSeries("series123");
login.setUsername("user1");
login.setToken("tokenXYZ");
login.setLastUsed(date.toInstant());
when(persistentLoginRepository.findById("series123")).thenReturn(Optional.of(login));
PersistentRememberMeToken result = tokenRepository.getTokenForSeries("series123");
assertNotNull(result);
assertEquals("user1", result.getUsername());
assertEquals("series123", result.getSeries());
assertEquals("tokenXYZ", result.getTokenValue());
assertEquals(date, result.getDate());
}
@Test
@DisplayName("should return null if token not found")
void shouldReturnNullIfNotFound() {
when(persistentLoginRepository.findById("series123")).thenReturn(Optional.empty());
PersistentRememberMeToken result = tokenRepository.getTokenForSeries("series123");
assertNull(result);
}
}
@Nested
@DisplayName("removeUserTokens")
class RemoveUserTokensTests {
@Test
@DisplayName("should call deleteByUsername normally")
void shouldCallDeleteByUsername() {
tokenRepository.removeUserTokens("user1");
verify(persistentLoginRepository).deleteByUsername("user1");
}
@Test
@DisplayName("should swallow exception if deleteByUsername fails")
void shouldSwallowException() {
doThrow(new RuntimeException("DB error"))
.when(persistentLoginRepository)
.deleteByUsername("user1");
assertDoesNotThrow(() -> tokenRepository.removeUserTokens("user1"));
verify(persistentLoginRepository).deleteByUsername("user1");
}
}
}
@@ -0,0 +1,84 @@
package stirling.software.proprietary.security.model;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
class ApiKeyAuthenticationTokenTest {
@Test
void ctor_apiKeyOnly_isUnauthenticated_andStoresApiKey() {
String apiKey = "abc-123";
ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(apiKey);
assertFalse(token.isAuthenticated(), "should be unauthenticated");
assertNull(token.getPrincipal(), "principal should be null for unauthenticated ctor");
assertEquals(apiKey, token.getCredentials(), "credentials should store api key");
// Authorities: do not check version-dependent behavior (can be null or empty depending on
// Spring Security)
}
@Test
void ctor_withPrincipalAndAuthorities_isAuthenticated_andStoresAll() {
String apiKey = "xyz-999";
Object principal = new Object();
var authorities = List.of(new SimpleGrantedAuthority("ROLE_API"));
ApiKeyAuthenticationToken token =
new ApiKeyAuthenticationToken(principal, apiKey, authorities);
assertTrue(token.isAuthenticated(), "should be authenticated");
assertSame(principal, token.getPrincipal(), "principal should be set");
assertEquals(apiKey, token.getCredentials(), "credentials should store api key");
assertNotNull(token.getAuthorities());
assertEquals(1, token.getAuthorities().size());
assertEquals("ROLE_API", token.getAuthorities().iterator().next().getAuthority());
}
@Test
void setAuthenticated_true_throwsIllegalArgumentException() {
ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken("k");
IllegalArgumentException ex =
assertThrows(IllegalArgumentException.class, () -> token.setAuthenticated(true));
assertTrue(
ex.getMessage().toLowerCase().contains("trusted"),
"message should explain to use the constructor with authorities");
}
@Test
void setAuthenticated_false_isAllowed_andUnsetsFlag() {
Object principal = new Object();
ApiKeyAuthenticationToken token =
new ApiKeyAuthenticationToken(
principal, "k", List.of(new SimpleGrantedAuthority("ROLE_API")));
assertTrue(token.isAuthenticated());
// allowed to set to false (via the override method)
token.setAuthenticated(false);
assertFalse(token.isAuthenticated());
assertSame(principal, token.getPrincipal(), "principal remains");
assertEquals("k", token.getCredentials(), "credentials remain until erased");
}
@Test
void eraseCredentials_setsCredentialsNull_butKeepsPrincipal() {
Object principal = new Object();
ApiKeyAuthenticationToken token =
new ApiKeyAuthenticationToken(
principal, "top-secret", List.of(new SimpleGrantedAuthority("ROLE_API")));
assertEquals("top-secret", token.getCredentials());
assertSame(principal, token.getPrincipal());
token.eraseCredentials();
assertNull(token.getCredentials(), "credentials should be nulled after erase");
assertSame(principal, token.getPrincipal(), "principal should remain");
}
}
@@ -0,0 +1,285 @@
package stirling.software.proprietary.security.model;
import static org.junit.jupiter.api.Assertions.*;
import java.lang.reflect.Field;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Comprehensive tests for AttemptCounter. Notes: - We avoid timing flakiness by using generous
* windows or setting lastAttemptTime to 'now'. - Where assumptions are made about edge-case
* behavior, they are documented in comments.
*/
class AttemptCounterTest {
// --- Helper functions for reflection access to private fields ---
private static void setPrivateLong(Object target, String fieldName, long value) {
try {
Field f = target.getClass().getDeclaredField(fieldName);
f.setAccessible(true);
f.setLong(target, value);
} catch (Exception e) {
fail("Could not set field '" + fieldName + "': " + e.getMessage());
}
}
private static void setPrivateInt(Object target, String fieldName, int value) {
try {
Field f = target.getClass().getDeclaredField(fieldName);
f.setAccessible(true);
f.setInt(target, value);
} catch (Exception e) {
fail("Could not set field '" + fieldName + "': " + e.getMessage());
}
}
private static long getPrivateLong(Object target, String fieldName) {
try {
Field f = target.getClass().getDeclaredField(fieldName);
f.setAccessible(true);
return f.getLong(target);
} catch (Exception e) {
fail("Could not read field '" + fieldName + "': " + e.getMessage());
return -1L; // unreachable
}
}
// --- Tests ---
@Test
@DisplayName("Constructor: attemptCount=0 and lastAttemptTime within creation period")
void constructor_shouldInitializeFields() {
long before = System.currentTimeMillis();
AttemptCounter counter = new AttemptCounter();
long after = System.currentTimeMillis();
// Purpose: Ensure that count is 0 and the timestamp lies in the [before, after] window
assertAll(
() -> assertEquals(0, counter.getAttemptCount(), "attemptCount should be 0"),
() -> {
long ts = counter.getLastAttemptTime();
assertTrue(
ts >= before && ts <= after,
"lastAttemptTime should be between constructor start and end");
});
}
@Test
@DisplayName(
"increment(): increases attemptCount and updates lastAttemptTime (not less than"
+ " before)")
void increment_shouldIncreaseCountAndUpdateTime() {
AttemptCounter counter = new AttemptCounter();
long prevTime = counter.getLastAttemptTime();
counter.increment();
// Purpose: After increment, count is +1 and timestamp is not older than before
assertAll(
() -> assertEquals(1, counter.getAttemptCount(), "attemptCount should be 1"),
() ->
assertTrue(
counter.getLastAttemptTime() >= prevTime,
"lastAttemptTime should not be less after increment"));
}
@Test
@DisplayName("reset(): sets attemptCount to 0 and updates lastAttemptTime")
void reset_shouldZeroCountAndRefreshTime() {
AttemptCounter counter = new AttemptCounter();
counter.increment();
counter.increment();
long beforeReset = counter.getLastAttemptTime();
counter.reset();
// Purpose: Ensure the counter is reset and time is updated
assertAll(
() ->
assertEquals(
0,
counter.getAttemptCount(),
"attemptCount should be 0 after reset"),
() ->
assertTrue(
counter.getLastAttemptTime() >= beforeReset,
"lastAttemptTime should be updated after reset (>= previous)"));
}
@Nested
@DisplayName("shouldReset(attemptIncrementTime)")
class ShouldResetTests {
@Test
@DisplayName("returns FALSE when time difference is smaller than window")
void shouldReturnFalseWhenWithinWindow() {
AttemptCounter counter = new AttemptCounter();
long window = 5_000L; // 5 seconds - generous buffer to avoid timing flakiness
long now = System.currentTimeMillis();
// Changed: Avoid flaky 1ms margin. We set lastAttemptTime to 'now' and choose a large
// window so elapsed < window is reliably true despite scheduling/clock granularity.
// Changed: Reason for change -> eliminate timing flakiness that caused sporadic
// failures.
setPrivateLong(counter, "lastAttemptTime", now);
// Purpose: Inside the window -> no reset
assertFalse(counter.shouldReset(window), "Within the window, no reset should occur");
}
@Test
@DisplayName("returns TRUE when time difference is exactly equal to window")
void shouldReturnTrueWhenExactlyWindow() {
AttemptCounter counter = new AttemptCounter();
long window = 200L;
long now = System.currentTimeMillis();
// Simulate: last action was exactly 'window' ms ago
setPrivateLong(counter, "lastAttemptTime", now - window);
// Purpose: Equality -> reset should occur because the window has fully elapsed
assertTrue(
counter.shouldReset(window),
"With exactly equal difference, the reset window has elapsed");
}
@Test
@DisplayName("returns TRUE when time difference is greater than window")
void shouldReturnTrueWhenGreaterThanWindow() {
AttemptCounter counter = new AttemptCounter();
long window = 100L;
long now = System.currentTimeMillis();
// Simulate: last action was (window + 1) ms ago
setPrivateLong(counter, "lastAttemptTime", now - (window + 1));
// Purpose: Outside the window -> reset
assertTrue(counter.shouldReset(window), "Outside the window, reset should occur");
}
}
@Nested
@DisplayName("shouldReset(attemptIncrementTime) additional edge cases")
class AdditionalEdgeCases {
@Test
@DisplayName("returns TRUE when window is zero (elapsed >= 0 is always true)")
void shouldReset_shouldReturnTrueWhenWindowIsZero() {
AttemptCounter counter = new AttemptCounter();
// Set lastAttemptTime == now to avoid timing flakiness
long now = System.currentTimeMillis();
setPrivateLong(counter, "lastAttemptTime", now);
// Assumption/Documentation: current implementation uses 'elapsed >=
// attemptIncrementTime'
// With attemptIncrementTime == 0, condition is always true.
assertTrue(counter.shouldReset(0L), "Window=0 means the window has already elapsed");
}
@Test
@DisplayName("returns TRUE when window is negative (elapsed >= negative is always true)")
void shouldReset_shouldReturnTrueWhenWindowIsNegative() {
AttemptCounter counter = new AttemptCounter();
long now = System.currentTimeMillis();
setPrivateLong(counter, "lastAttemptTime", now);
// Assumption/Documentation: Negative window is treated as already elapsed.
assertTrue(
counter.shouldReset(-1L),
"Negative window is nonsensical and should result in reset=true (elapsed >="
+ " negative)");
}
}
@Test
@DisplayName("Getters: return current values")
void getters_shouldReturnCurrentValues() {
AttemptCounter counter = new AttemptCounter();
assertAll(
// Purpose: Basic getter functionality
() ->
assertEquals(
0, counter.getAttemptCount(), "Initial attemptCount should be 0"),
() ->
assertTrue(
counter.getLastAttemptTime() <= System.currentTimeMillis(),
"lastAttemptTime should not be in the future"));
counter.increment();
int afterInc = counter.getAttemptCount();
long last = counter.getLastAttemptTime();
assertAll(
// Purpose: After increment, getters reflect the new state
() -> assertEquals(1, afterInc, "attemptCount should be 1 after increment"),
() ->
assertEquals(
last,
counter.getLastAttemptTime(),
"lastAttemptTime should be consistent"));
}
@Test
@DisplayName(
"Multiple increments(): Count increases monotonically and timestamp remains"
+ " monotonically non-decreasing")
void multipleIncrements_shouldIncreaseMonotonically() {
AttemptCounter counter = new AttemptCounter();
long t1 = counter.getLastAttemptTime();
counter.increment();
long t2 = counter.getLastAttemptTime();
counter.increment();
long t3 = counter.getLastAttemptTime();
// Purpose: Document monotonic behavior
assertAll(
() ->
assertEquals(
2,
counter.getAttemptCount(),
"After two increments, count should be 2"),
() ->
assertTrue(
t2 >= t1 && t3 >= t2,
"Timestamps should be monotonically non-decreasing"));
}
@Test
@DisplayName("Documenting edge case: attemptCount can technically overflow (int)")
void noteOnIntegerOverflowBehavior() {
// Note: This test only documents the current behavior of int overflow in Java.
// It does not enforce that overflow is desired, only makes visible what happens.
AttemptCounter counter = new AttemptCounter();
// Set counter close to Integer.MAX_VALUE and increment()
setPrivateInt(counter, "attemptCount", Integer.MAX_VALUE - 1);
counter.increment(); // -> MAX_VALUE
assertEquals(
Integer.MAX_VALUE,
counter.getAttemptCount(),
"Count should reach Integer.MAX_VALUE");
counter.increment(); // -> overflow to Integer.MIN_VALUE
assertEquals(
Integer.MIN_VALUE,
counter.getAttemptCount(),
"After increment past MAX_VALUE, int overflows to MIN_VALUE (Java standard"
+ " behavior)");
}
@Test
@DisplayName("Reflection: getPrivateLong reads the actual lastAttemptTime")
void reflectionGetter_shouldReturnInternalValue() {
AttemptCounter counter = new AttemptCounter();
long expected = counter.getLastAttemptTime();
long reflected = getPrivateLong(counter, "lastAttemptTime");
assertEquals(expected, reflected, "Reflection getter should match the field value");
}
}
@@ -0,0 +1,96 @@
package stirling.software.proprietary.security.model;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import stirling.software.proprietary.model.Team;
class AuthorityTest {
@Test
void noArgsConstructor_allowsSettersAndGetters() {
Authority a = new Authority();
assertNull(a.getId());
assertNull(a.getAuthority());
assertNull(a.getUser());
a.setId(42L);
a.setAuthority("ROLE_USER");
User u = new User();
a.setUser(u);
assertEquals(42L, a.getId());
assertEquals("ROLE_USER", a.getAuthority());
assertSame(u, a.getUser());
}
@Test
void ctorWithUser_setsFields_and_registersInUserAuthorities() {
User u = new User();
// sanity: authorities set initialized?
assertNotNull(u.getAuthorities());
assertTrue(u.getAuthorities().isEmpty());
Authority a = new Authority("ROLE_ADMIN", u);
assertEquals("ROLE_ADMIN", a.getAuthority());
assertSame(u, a.getUser());
assertTrue(u.getAuthorities().contains(a), "Authority should be registered in user's set");
assertEquals(1, u.getAuthorities().size());
}
@Test
void multipleAuthorities_registerEachInUser() {
User u = new User();
Authority a1 = new Authority("ROLE_A", u);
Authority a2 = new Authority("ROLE_B", u);
assertTrue(u.getAuthorities().contains(a1));
assertTrue(u.getAuthorities().contains(a2));
assertEquals(2, u.getAuthorities().size());
}
@Test
void ctorWithNullUser_throwsNpe_dueToRegistrationInUserSet() {
assertThrows(
NullPointerException.class,
() -> new Authority("ROLE_X", null),
"Constructor calls user.getAuthorities() and should throw NPE when null");
}
@Test
void setUser_doesNotAutoRegisterInUserAuthorities_currentBehavior() {
User u = new User();
Authority a = new Authority();
a.setAuthority("ROLE_VIEWER");
// only using the setter → no automatic entry in the user's set
a.setUser(u);
assertSame(u, a.getUser());
assertTrue(
u.getAuthorities().isEmpty(),
"Current behavior: setUser() does not automatically register in user's set");
}
@Test
void toString_equalsHashCode_fromLombok_defaultObjectSemantics() {
// no @EqualsAndHashCode annotation -> default Object semantics
Authority a1 = new Authority();
Authority a2 = new Authority();
assertNotEquals(a1, a2);
assertNotEquals(a1.hashCode(), a2.hashCode());
assertNotNull(a1);
}
// Optional: shows that User has other fields that don't interfere
@Test
void worksWithUserHavingTeamField() {
User u = new User();
u.setTeam(new Team()); // just to show that it has no effect
Authority a = new Authority("ROLE_TEST", u);
assertTrue(u.getAuthorities().contains(a));
}
}
@@ -0,0 +1,152 @@
package stirling.software.proprietary.security.model;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.times;
import java.util.LinkedHashSet;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
class UserTest {
@Test
void defaults_collections_initialized() {
User u = new User();
assertNotNull(u.getAuthorities(), "authorities should be initialized");
assertTrue(u.getAuthorities().isEmpty());
assertNotNull(u.getSettings(), "settings should be initialized");
assertTrue(u.getSettings().isEmpty());
assertNull(u.getTeam());
}
@Test
void addAuthority_adds_to_set_but_doesNot_set_backref_on_authority() {
User u = new User();
Authority a = new Authority();
a.setAuthority("ROLE_A");
u.addAuthority(a);
assertTrue(u.getAuthorities().contains(a));
// current behavior: addAuthority() does NOT call a.setUser(u)
assertNull(
a.getUser(), "Current behavior: Authority.user is NOT set by User.addAuthority()");
}
@Test
void addAuthorities_adds_all() {
User u = new User();
Authority a1 = new Authority();
a1.setAuthority("ROLE_A");
Authority a2 = new Authority();
a2.setAuthority("ROLE_B");
Set<Authority> batch = new LinkedHashSet<>();
batch.add(a1);
batch.add(a2);
u.addAuthorities(batch);
assertEquals(2, u.getAuthorities().size());
assertTrue(u.getAuthorities().contains(a1));
assertTrue(u.getAuthorities().contains(a2));
}
@Test
void getRolesAsString_returns_roles_joined_order_agnostic() {
User u = new User();
// We use the Authority constructor that automatically adds itself to u.getAuthorities()
new Authority("ROLE_USER", u);
new Authority("ROLE_ADMIN", u);
String roles = u.getRolesAsString();
// Order is not guaranteed due to HashSet -> split/trim and compare as a Set
Set<String> parts =
java.util.Arrays.stream(roles.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(java.util.stream.Collectors.toSet());
assertEquals(Set.of("ROLE_USER", "ROLE_ADMIN"), parts);
}
@Test
void hasPassword_null_empty_and_present() {
User u = new User();
u.setPassword(null);
assertFalse(u.hasPassword());
u.setPassword("");
assertFalse(u.hasPassword());
u.setPassword("secret");
assertTrue(u.hasPassword());
}
@Test
void isFirstLogin_handles_null_false_true() {
User u = new User();
// Default is Boolean false (according to field initialization)
assertFalse(u.isFirstLogin());
u.setFirstLogin(true);
assertTrue(u.isFirstLogin());
// explicitly null -> method returns false
u.setIsFirstLogin(null);
assertFalse(u.isFirstLogin());
}
@Test
void setAuthenticationType_lowercases_enum_name() {
User u = new User();
// Use an existing value from your AuthenticationType enum (e.g. OAUTH2/SAML2/DATABASE)
// If the name differs, simply adjust below.
AuthenticationType at = AuthenticationType.SSO;
u.setAuthenticationType(at);
assertEquals("sso", u.getAuthenticationType());
}
@Test
void team_setter_getter() {
User u = new User();
Team t = new Team();
u.setTeam(t);
assertSame(t, u.getTeam());
}
@Test
void getRoleName_delegatesToRole_withRolesAsString() {
User u = new User();
// Add authorities (order in HashSet doesn't matter)
new Authority("ROLE_USER", u);
new Authority("ROLE_ADMIN", u);
// Expected argument created exactly as getRoleName() does internally
String expectedArg = u.getRolesAsString();
try (MockedStatic<Role> roleMock = mockStatic(Role.class)) {
roleMock.when(() -> Role.getRoleNameByRoleId(expectedArg)).thenReturn("Friendly Name");
String result = u.getRoleName();
assertEquals("Friendly Name", result);
// Verify it was delegated exactly with the expected string
roleMock.verify(() -> Role.getRoleNameByRoleId(expectedArg), times(1));
}
}
}
@@ -0,0 +1,34 @@
package stirling.software.proprietary.security.model.exception;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class BackupNotFoundExceptionTest {
@Test
void constructor_setsMessage() {
BackupNotFoundException ex = new BackupNotFoundException("not found");
assertEquals("not found", ex.getMessage());
assertNull(ex.getCause(), "No cause expected for single-arg constructor");
}
@Test
void extendsRuntimeExceptionDirectly() {
assertEquals(
RuntimeException.class,
BackupNotFoundException.class.getSuperclass(),
"BackupNotFoundException should extend RuntimeException directly");
}
@Test
void canBeThrownAndCaught() {
BackupNotFoundException ex =
assertThrows(
BackupNotFoundException.class,
() -> {
throw new BackupNotFoundException("missing backup");
});
assertEquals("missing backup", ex.getMessage());
}
}
@@ -0,0 +1,33 @@
package stirling.software.proprietary.security.model.exception;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class NoProviderFoundExceptionTest {
@Test
void constructor_setsMessage_withoutCause() {
NoProviderFoundException ex = new NoProviderFoundException("no provider");
assertEquals("no provider", ex.getMessage());
assertNull(ex.getCause(), "Cause should be null for single-arg constructor");
}
@Test
void constructor_setsMessage_andCause() {
Throwable cause = new IllegalStateException("root");
NoProviderFoundException ex = new NoProviderFoundException("missing", cause);
assertEquals("missing", ex.getMessage());
assertSame(cause, ex.getCause());
}
@Test
void canBeThrownAndCaught_checkedException() {
try {
throw new NoProviderFoundException("boom");
} catch (NoProviderFoundException ex) {
assertEquals("boom", ex.getMessage());
}
}
}
@@ -1,9 +1,6 @@
package stirling.software.proprietary.security.saml2;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
@@ -62,7 +59,6 @@ class JwtSaml2AuthenticationRequestRepositoryTest {
String id = "testId";
String relayState = "testRelayState";
String authnRequestUri = "example.com/authnRequest";
Map<String, Object> claims = Map.of();
String samlRequest = "testSamlRequest";
String relyingPartyRegistrationId = "stirling-pdf";
@@ -1,7 +1,6 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -63,7 +62,7 @@ public class EmailServiceTest {
}
@Test
void testSendEmailWithAttachmentThrowsExceptionForMissingFilename() throws MessagingException {
void testSendEmailWithAttachmentThrowsExceptionForMissingFilename() {
Email email = new Email();
email.setTo("[email protected]");
email.setSubject("Test Email");
@@ -82,8 +81,7 @@ public class EmailServiceTest {
}
@Test
void testSendEmailWithAttachmentThrowsExceptionForMissingFilenameNull()
throws MessagingException {
void testSendEmailWithAttachmentThrowsExceptionForMissingFilenameNull() {
Email email = new Email();
email.setTo("[email protected]");
email.setSubject("Test Email");
@@ -102,7 +100,7 @@ public class EmailServiceTest {
}
@Test
void testSendEmailWithAttachmentThrowsExceptionForMissingFile() throws MessagingException {
void testSendEmailWithAttachmentThrowsExceptionForMissingFile() {
Email email = new Email();
email.setTo("[email protected]");
email.setSubject("Test Email");
@@ -120,7 +118,7 @@ public class EmailServiceTest {
}
@Test
void testSendEmailWithAttachmentThrowsExceptionForMissingFileNull() throws MessagingException {
void testSendEmailWithAttachmentThrowsExceptionForMissingFileNull() {
Email email = new Email();
email.setTo("[email protected]");
email.setSubject("Test Email");
@@ -136,8 +134,7 @@ public class EmailServiceTest {
}
@Test
void testSendEmailWithAttachmentThrowsExceptionForInvalidAddressNull()
throws MessagingException {
void testSendEmailWithAttachmentThrowsExceptionForInvalidAddressNull() {
Email email = new Email();
email.setTo(null); // Invalid address
email.setSubject("Test Email");
@@ -153,8 +150,7 @@ public class EmailServiceTest {
}
@Test
void testSendEmailWithAttachmentThrowsExceptionForInvalidAddressEmpty()
throws MessagingException {
void testSendEmailWithAttachmentThrowsExceptionForInvalidAddressEmpty() {
Email email = new Email();
email.setTo(""); // Invalid address
email.setSubject("Test Email");
@@ -0,0 +1,239 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.*;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.Locale;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import stirling.software.proprietary.security.model.AttemptCounter;
/**
* Tests for LoginAttemptService#getRemainingAttempts(...) focusing on edge cases and documented
* behavior. We instantiate the service reflectively to avoid depending on a specific constructor
* signature. Private fields are set via reflection to keep existing production code unchanged.
*
* <p>Assumptions: - 'MAX_ATTEMPT' is a private int (possibly static final); we read it via
* reflection (static-aware). - 'attemptsCache' is a ConcurrentHashMap<String, AttemptCounter>. -
* 'isBlockedEnabled' is a boolean flag. - Behavior without clamping is intentional for now (can
* return negative values).
*/
class LoginAttemptServiceTest {
// --- Reflection helpers ---
private static Object constructLoginAttemptService() {
try {
Class<?> clazz =
Class.forName(
"stirling.software.proprietary.security.service.LoginAttemptService");
// Prefer a no-arg constructor if present; otherwise use the first and mock parameters.
Constructor<?>[] ctors = clazz.getDeclaredConstructors();
Arrays.stream(ctors).forEach(c -> c.setAccessible(true));
Constructor<?> target =
Arrays.stream(ctors)
.filter(c -> c.getParameterCount() == 0)
.findFirst()
.orElse(ctors[0]);
Object[] args = new Object[target.getParameterCount()];
Class<?>[] paramTypes = target.getParameterTypes();
for (int i = 0; i < paramTypes.length; i++) {
Class<?> p = paramTypes[i];
if (p.isPrimitive()) {
// Provide basic defaults for primitives
args[i] = defaultValueForPrimitive(p);
} else {
args[i] = Mockito.mock(p);
}
}
return target.newInstance(args);
} catch (Exception e) {
fail("Could not construct LoginAttemptService reflectively: " + e.getMessage());
return null; // unreachable
}
}
private static Object defaultValueForPrimitive(Class<?> p) {
if (p == boolean.class) return false;
if (p == byte.class) return (byte) 0;
if (p == short.class) return (short) 0;
if (p == char.class) return (char) 0;
if (p == int.class) return 0;
if (p == long.class) return 0L;
if (p == float.class) return 0f;
if (p == double.class) return 0d;
throw new IllegalArgumentException("Unsupported primitive: " + p);
}
private static void setPrivate(Object target, String fieldName, Object value) {
try {
Field f = target.getClass().getDeclaredField(fieldName);
f.setAccessible(true);
if (Modifier.isStatic(f.getModifiers())) {
f.set(null, value);
} else {
f.set(target, value);
}
} catch (Exception e) {
fail("Could not set field '" + fieldName + "': " + e.getMessage());
}
}
private static void setPrivateBoolean(Object target, String fieldName, boolean value) {
try {
Field f = target.getClass().getDeclaredField(fieldName);
f.setAccessible(true);
if (Modifier.isStatic(f.getModifiers())) {
f.setBoolean(null, value);
} else {
f.setBoolean(target, value);
}
} catch (Exception e) {
fail("Could not set boolean field '" + fieldName + "': " + e.getMessage());
}
}
private static int getPrivateInt(Object targetOrClassInstance, String fieldName) {
try {
Class<?> clazz =
targetOrClassInstance instanceof Class
? (Class<?>) targetOrClassInstance
: targetOrClassInstance.getClass();
Field f = clazz.getDeclaredField(fieldName);
f.setAccessible(true);
if (Modifier.isStatic(f.getModifiers())) {
return f.getInt(null);
} else {
return f.getInt(targetOrClassInstance);
}
} catch (Exception e) {
fail("Could not read int field '" + fieldName + "': " + e.getMessage());
return -1; // unreachable
}
}
// --- Tests ---
@Test
@DisplayName("getRemainingAttempts(): returns Integer.MAX_VALUE when disabled or key blank")
void getRemainingAttempts_shouldReturnMaxValueWhenDisabledOrBlankKey() throws Exception {
Object svc = constructLoginAttemptService();
// Ensure blocking disabled
setPrivateBoolean(svc, "isBlockedEnabled", false);
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
setPrivate(svc, "attemptsCache", attemptsCache);
var method = svc.getClass().getMethod("getRemainingAttempts", String.class);
// Case 1: disabled -> always MAX_VALUE regardless of key
int disabledVal = (Integer) method.invoke(svc, "someUser");
assertEquals(
Integer.MAX_VALUE,
disabledVal,
"Disabled tracking should return Integer.MAX_VALUE");
// Enable and verify blank/whitespace/null handling
setPrivateBoolean(svc, "isBlockedEnabled", true);
int nullKeyVal = (Integer) method.invoke(svc, (Object) null);
int blankKeyVal = (Integer) method.invoke(svc, " ");
assertEquals(
Integer.MAX_VALUE,
nullKeyVal,
"Null key should return Integer.MAX_VALUE per current contract");
assertEquals(
Integer.MAX_VALUE,
blankKeyVal,
"Blank key should return Integer.MAX_VALUE per current contract");
}
@Test
@DisplayName("getRemainingAttempts(): returns MAX_ATTEMPT when no counter exists for key")
void getRemainingAttempts_shouldReturnMaxAttemptWhenNoEntry() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", true);
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
setPrivate(svc, "attemptsCache", attemptsCache);
int maxAttempt = getPrivateInt(svc, "MAX_ATTEMPT"); // Reads current policy value
var method = svc.getClass().getMethod("getRemainingAttempts", String.class);
int v1 = (Integer) method.invoke(svc, "UserA");
int v2 =
(Integer)
method.invoke(svc, "uSeRa"); // case-insensitive by service (normalization)
assertEquals(maxAttempt, v1, "Unknown user should start with MAX_ATTEMPT remaining");
assertEquals(
maxAttempt,
v2,
"Case-insensitivity should not create separate entries if none exists yet");
}
@Test
@DisplayName("getRemainingAttempts(): decreases with attemptCount in cache")
void getRemainingAttempts_shouldDecreaseAfterAttemptCount() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", true);
int maxAttempt = getPrivateInt(svc, "MAX_ATTEMPT");
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
setPrivate(svc, "attemptsCache", attemptsCache);
// Prepare a counter with attemptCount = 1
AttemptCounter c1 = new AttemptCounter();
Field ac = AttemptCounter.class.getDeclaredField("attemptCount");
ac.setAccessible(true);
ac.setInt(c1, 1);
attemptsCache.put("userx".toLowerCase(Locale.ROOT), c1);
var method = svc.getClass().getMethod("getRemainingAttempts", String.class);
int actual = (Integer) method.invoke(svc, "USERX");
assertEquals(
maxAttempt - 1,
actual,
"Remaining attempts should reflect current attemptCount (case-insensitive lookup)");
}
@Test
@DisplayName(
"getRemainingAttempts(): can become negative when attemptCount > MAX_ATTEMPT (document"
+ " current behavior)")
void getRemainingAttempts_shouldBecomeNegativeWhenOverLimit_CurrentBehavior() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", true);
int maxAttempt = getPrivateInt(svc, "MAX_ATTEMPT");
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
setPrivate(svc, "attemptsCache", attemptsCache);
// Create counter with attemptCount = MAX_ATTEMPT + 5
AttemptCounter c = new AttemptCounter();
Field ac = AttemptCounter.class.getDeclaredField("attemptCount");
ac.setAccessible(true);
ac.setInt(c, maxAttempt + 5);
attemptsCache.put("over".toLowerCase(Locale.ROOT), c);
var method = svc.getClass().getMethod("getRemainingAttempts", String.class);
int actual = (Integer) method.invoke(svc, "OVER");
int expected = maxAttempt - (maxAttempt + 5); // -5
// Documentation test: current implementation returns a negative number.
// If you later clamp to 0, update this assertion accordingly and add a new test.
assertEquals(expected, actual, "Current behavior returns negative values without clamping");
}
}
@@ -1,8 +1,6 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -1,8 +1,6 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
import java.sql.SQLException;
@@ -135,10 +133,9 @@ class UserServiceTest {
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(invalidUsername, authType));
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(invalidUsername, authType));
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
@@ -210,10 +207,9 @@ class UserServiceTest {
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(reservedUsername, authType));
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(reservedUsername, authType));
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
@@ -226,10 +222,9 @@ class UserServiceTest {
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(anonymousUsername, authType));
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(anonymousUsername, authType));
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
@@ -0,0 +1,176 @@
package stirling.software.proprietary.util;
import static org.junit.jupiter.api.Assertions.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link SecretMasker}.
*
* <p>Assumptions: - Key matching is case-insensitive via the pattern in SENSITIVE. - If the key
* matches a sensitive pattern, the value is replaced with "***REDACTED***". - Nested maps and lists
* are searched recursively. - Null maps and null values are ignored or returned as null. -
* Non-sensitive keys/values remain unchanged.
*/
class SecretMaskerTest {
@Nested
@DisplayName("mask(Map<String,Object>) method")
class MaskMethod {
@Test
@DisplayName("should return null when input map is null")
void shouldReturnNullWhenInputIsNull() {
assertNull(SecretMasker.mask(null));
}
@Test
@DisplayName("should mask simple sensitive keys at root level")
void shouldMaskSimpleSensitiveKeys() {
Map<String, Object> input =
Map.of(
"password", "mySecret",
"username", "john");
Map<String, Object> result = SecretMasker.mask(input);
assertEquals("***REDACTED***", result.get("password"));
assertEquals("john", result.get("username"));
}
@Test
@DisplayName("should mask keys case-insensitively and with special characters")
void shouldMaskKeysCaseInsensitive() {
Map<String, Object> input =
Map.of(
"Api-Key", "12345",
"TOKEN", "abcde",
"normal", "keepme");
Map<String, Object> result = SecretMasker.mask(input);
assertEquals("***REDACTED***", result.get("Api-Key"));
assertEquals("***REDACTED***", result.get("TOKEN"));
assertEquals("keepme", result.get("normal"));
}
@Test
@DisplayName("should mask nested map sensitive keys")
void shouldMaskNestedMapSensitiveKeys() {
Map<String, Object> input =
Map.of(
"outer",
Map.of(
"jwt",
"tokenValue",
"inner",
Map.of(
"secret", "deepValue",
"other", "ok")));
Map<String, Object> result = SecretMasker.mask(input);
Map<String, Object> outer = (Map<String, Object>) result.get("outer");
assertEquals("***REDACTED***", outer.get("jwt"));
Map<String, Object> inner = (Map<String, Object>) outer.get("inner");
assertEquals("***REDACTED***", inner.get("secret"));
assertEquals("ok", inner.get("other"));
}
@Test
@DisplayName("should mask sensitive keys inside lists")
void shouldMaskSensitiveKeysInsideLists() {
Map<String, Object> input =
Map.of(
"list",
List.of(
Map.of("token", "abc123"),
Map.of("username", "john"),
"stringValue"));
Map<String, Object> result = SecretMasker.mask(input);
List<?> list = (List<?>) result.get("list");
Map<String, Object> first = (Map<String, Object>) list.get(0);
assertEquals("***REDACTED***", first.get("token"));
Map<String, Object> second = (Map<String, Object>) list.get(1);
assertEquals("john", second.get("username"));
assertEquals("stringValue", list.get(2));
}
@Test
@DisplayName("should ignore null values")
void shouldIgnoreNullValues() {
// IMPORTANT: Map.of(...) does not allow nulls -> use a mutable Map instead
Map<String, Object> input = new HashMap<>();
input.put("password", null);
input.put("normal", null);
Map<String, Object> result = SecretMasker.mask(input);
// Null values are completely filtered out
assertFalse(result.containsKey("password"));
assertFalse(result.containsKey("normal"));
assertTrue(result.isEmpty(), "Result map should be empty if all entries were null");
}
@Test
@DisplayName("should not mask when key does not match pattern")
void shouldNotMaskWhenKeyNotSensitive() {
Map<String, Object> input = Map.of("email", "[email protected]");
Map<String, Object> result = SecretMasker.mask(input);
assertEquals("[email protected]", result.get("email"));
}
}
@Nested
@DisplayName("Deep masking edge branches")
class DeepMaskBranches {
@Test
@DisplayName("should filter out null values inside nested map")
void shouldFilterOutNullValuesInsideNestedMap() {
// outer -> { inner -> { "token": null, "username": "john" } }
Map<String, Object> inner = new HashMap<>();
inner.put("token", null); // <- should be filtered out in the result (branch false)
inner.put("username", "john"); // <- should remain
Map<String, Object> input = Map.of("outer", Map.of("inner", inner));
Map<String, Object> result = SecretMasker.mask(input);
Map<String, Object> outer = (Map<String, Object>) result.get("outer");
Map<String, Object> maskedInner = (Map<String, Object>) outer.get("inner");
// "token" was null -> should be completely absent (filter branch in deepMask(Map))
assertFalse(maskedInner.containsKey("token"));
// "username" remains unchanged
assertEquals("john", maskedInner.get("username"));
}
@Test
@DisplayName("should not mask when key is null (falls back to deepMask(value))")
void shouldNotMaskWhenKeyIsNull() {
// Map with null key: { null: "plainText", "password": "toHide" }
Map<String, Object> sensitive = new HashMap<>();
sensitive.put(null, "plainText"); // <- key == null -> no masking, value stays
sensitive.put("password", "toHide"); // <- sensitive key -> will be masked
Map<String, Object> input = Map.of("outer", sensitive);
Map<String, Object> result = SecretMasker.mask(input);
Map<String, Object> outer = (Map<String, Object>) result.get("outer");
assertTrue(outer.containsKey(null), "Null key should be preserved");
assertEquals("plainText", outer.get(null), "Value for null key must not be masked");
assertEquals("***REDACTED***", outer.get("password"), "Sensitive keys must be masked");
}
}
}
@@ -0,0 +1,317 @@
package stirling.software.proprietary.web;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.util.*;
import org.junit.jupiter.api.*;
import org.slf4j.MDC;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
/**
* Tests for {@link AuditWebFilter}.
*
* <p>Note: The filter clears the MDC in its finally block. Therefore we capture the MDC values
* inside a special FilterChain before the clear happens (snapshot).
*/
class AuditWebFilterTest {
private AuditWebFilter filter;
private MockHttpServletRequest request;
private MockHttpServletResponse response;
/** Small helper chain that captures MDC values during the chain invocation. */
static class CapturingFilterChain implements FilterChain {
final Map<String, String> captured = new HashMap<>();
boolean called = false;
@Override
public void doFilter(ServletRequest req, ServletResponse res)
throws IOException, ServletException {
called = true;
// Snapshot of the MDC keys set by the filter (before the finally-clear)
captured.put("userAgent", MDC.get("userAgent"));
captured.put("referer", MDC.get("referer"));
captured.put("acceptLanguage", MDC.get("acceptLanguage"));
captured.put("contentType", MDC.get("contentType"));
captured.put("userRoles", MDC.get("userRoles"));
captured.put("queryParams", MDC.get("queryParams"));
}
}
/** Variant that intentionally throws an exception after capturing. */
static class ThrowingAfterCaptureChain extends CapturingFilterChain {
@Override
public void doFilter(ServletRequest req, ServletResponse res)
throws IOException, ServletException {
super.doFilter(req, res);
throw new IOException("Test Exception");
}
}
@BeforeEach
void setUp() {
filter = new AuditWebFilter();
request = new MockHttpServletRequest();
response = new MockHttpServletResponse();
MDC.clear();
SecurityContextHolder.clearContext();
}
@AfterEach
void tearDown() {
MDC.clear();
SecurityContextHolder.clearContext();
}
@Nested
@DisplayName("Header and query parameter handling")
class HeaderAndQueryTests {
@Test
@DisplayName("Should store all provided headers and query parameters in MDC")
void shouldStoreHeadersAndQueryParamsInMdc() throws ServletException, IOException {
request.addHeader("User-Agent", "JUnit-Test-Agent");
request.addHeader("Referer", "http://example.com");
request.addHeader("Accept-Language", "de-DE");
request.addHeader("Content-Type", "application/json");
request.setParameter("param1", "value1");
request.setParameter("param2", "value2");
CapturingFilterChain chain = new CapturingFilterChain();
filter.doFilterInternal(request, response, chain);
assertTrue(chain.called, "FilterChain should have been called");
assertEquals("JUnit-Test-Agent", chain.captured.get("userAgent"));
assertEquals("http://example.com", chain.captured.get("referer"));
assertEquals("de-DE", chain.captured.get("acceptLanguage"));
assertEquals("application/json", chain.captured.get("contentType"));
String params = chain.captured.get("queryParams");
assertNotNull(params);
assertTrue(params.contains("param1"));
assertTrue(params.contains("param2"));
assertNull(MDC.get("userAgent"));
assertNull(MDC.get("queryParams"));
}
@Test
@DisplayName("Should only store present headers and set nothing for empty inputs")
void shouldNotStoreNullHeaders() throws ServletException, IOException {
request.setParameter("onlyParam", "123");
CapturingFilterChain chain = new CapturingFilterChain();
filter.doFilterInternal(request, response, chain);
assertNull(chain.captured.get("userAgent"));
assertNull(chain.captured.get("referer"));
assertNull(chain.captured.get("acceptLanguage"));
assertNull(chain.captured.get("contentType"));
assertEquals("onlyParam", chain.captured.get("queryParams"));
}
// New: empty parameter map case (branch: parameterMap != null && !isEmpty() -> false)
@Test
@DisplayName("Should not set queryParams when parameter map is empty")
void shouldNotStoreQueryParamsWhenEmpty() throws ServletException, IOException {
// no request.setParameter(...)
CapturingFilterChain chain = new CapturingFilterChain();
filter.doFilterInternal(request, response, chain);
assertNull(
chain.captured.get("queryParams"),
"With an empty map, queryParams must not be set");
}
// New: parameterMap == null (branch: parameterMap != null -> false)
@Test
@DisplayName("Should handle getParameterMap() returning null safely")
void shouldHandleNullParameterMapSafely() throws ServletException, IOException {
MockHttpServletRequest reqWithNullParamMap =
new MockHttpServletRequest() {
@Override
public Map<String, String[]> getParameterMap() {
// Assumption: defensive branch in the filter; simulate a broken/unusual
// implementation
return null;
}
};
CapturingFilterChain chain = new CapturingFilterChain();
filter.doFilterInternal(reqWithNullParamMap, response, chain);
assertNull(
chain.captured.get("queryParams"),
"With a null parameter map, queryParams must not be set");
}
}
@Nested
@DisplayName("Authenticated users")
class AuthenticatedUserTests {
@Test
@DisplayName("Should store roles of the authenticated user")
void shouldStoreUserRolesInMdc() throws ServletException, IOException {
SecurityContextHolder.getContext()
.setAuthentication(
new UsernamePasswordAuthenticationToken(
"user",
"pass",
Collections.singletonList(
new SimpleGrantedAuthority("ROLE_ADMIN"))));
CapturingFilterChain chain = new CapturingFilterChain();
filter.doFilterInternal(request, response, chain);
assertEquals("ROLE_ADMIN", chain.captured.get("userRoles"));
assertNull(MDC.get("userRoles"));
}
@Test
@DisplayName("Should store multiple roles comma-separated")
void shouldStoreMultipleRolesCommaSeparated() throws ServletException, IOException {
SecurityContextHolder.getContext()
.setAuthentication(
new UsernamePasswordAuthenticationToken(
"user",
"pass",
List.of(
new SimpleGrantedAuthority("ROLE_USER"),
new SimpleGrantedAuthority("ROLE_ADMIN"))));
CapturingFilterChain chain = new CapturingFilterChain();
filter.doFilterInternal(request, response, chain);
String roles = chain.captured.get("userRoles");
assertNotNull(roles, "Roles should be set");
assertTrue(roles.contains("ROLE_USER"));
assertTrue(roles.contains("ROLE_ADMIN"));
assertTrue(roles.contains(","), "Roles should be separated by a comma");
}
// New: auth == null (branch: auth != null -> false)
@Test
@DisplayName("Should not set userRoles when no Authentication object is present")
void shouldNotStoreUserRolesWhenAuthIsNull() throws ServletException, IOException {
// SecurityContext remains empty
CapturingFilterChain chain = new CapturingFilterChain();
filter.doFilterInternal(request, response, chain);
assertNull(chain.captured.get("userRoles"));
}
// New: authorities == null (branch: auth != null && authorities != null -> false)
@Test
@DisplayName("Should not set userRoles when authorities are null")
void shouldNotStoreUserRolesWhenAuthoritiesIsNull() throws ServletException, IOException {
Authentication authWithNullAuthorities =
new Authentication() {
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null; // important
}
@Override
public Object getCredentials() {
return "cred";
}
@Override
public Object getDetails() {
return null;
}
@Override
public Object getPrincipal() {
return "user";
}
@Override
public boolean isAuthenticated() {
return true;
}
@Override
public void setAuthenticated(boolean isAuthenticated)
throws IllegalArgumentException {}
@Override
public String getName() {
return "user";
}
};
SecurityContextHolder.getContext().setAuthentication(authWithNullAuthorities);
CapturingFilterChain chain = new CapturingFilterChain();
filter.doFilterInternal(request, response, chain);
assertNull(
chain.captured.get("userRoles"),
"With null authorities, userRoles must not be set");
}
// New: empty authorities list -> reduce(...).orElse("") → empty string is set
@Test
@DisplayName("Should set empty string when authorities list is empty")
void shouldStoreEmptyStringWhenAuthoritiesEmpty() throws ServletException, IOException {
SecurityContextHolder.getContext()
.setAuthentication(
new UsernamePasswordAuthenticationToken(
"user", "pass", Collections.emptyList()));
CapturingFilterChain chain = new CapturingFilterChain();
filter.doFilterInternal(request, response, chain);
assertEquals(
"",
chain.captured.get("userRoles"),
"With an empty roles list, an empty string should be set");
}
}
@Nested
@DisplayName("MDC cleanup logic")
class MdcCleanupTests {
@Test
@DisplayName("Should clear MDC after processing")
void shouldClearMdcAfterProcessing() throws ServletException, IOException {
request.addHeader("User-Agent", "JUnit-Test-Agent");
CapturingFilterChain chain = new CapturingFilterChain();
filter.doFilterInternal(request, response, chain);
assertEquals("JUnit-Test-Agent", chain.captured.get("userAgent"));
assertNull(MDC.get("userAgent"), "MDC should be cleared after processing");
}
@Test
@DisplayName("Should clear MDC even when the FilterChain throws")
void shouldClearMdcOnException() throws ServletException, IOException {
request.addHeader("User-Agent", "JUnit-Test-Agent");
ThrowingAfterCaptureChain chain = new ThrowingAfterCaptureChain();
IOException thrown =
assertThrows(
IOException.class,
() -> filter.doFilterInternal(request, response, chain));
assertEquals("Test Exception", thrown.getMessage());
assertEquals("JUnit-Test-Agent", chain.captured.get("userAgent"));
assertNull(MDC.get("userAgent"), "MDC should also be cleared after exceptions");
}
}
}
@@ -0,0 +1,188 @@
package stirling.software.proprietary.web;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.*;
import org.slf4j.MDC;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
/**
* Tests for {@link CorrelationIdFilter}.
*
* <p>Important notes: - The filter sets MDC in the try block and clears it in the finally block.
* Therefore, we capture the MDC values inside a special FilterChain before the clear happens
* (snapshot). - The response header is sanitized via Newlines.stripAll(id). The current code does
* NOT sanitize the value stored in the MDC or the request attribute. These tests reflect the
* current behavior.
*/
class CorrelationIdFilterTest {
private CorrelationIdFilter filter;
private MockHttpServletRequest request;
private MockHttpServletResponse response;
/** Chain that snapshots the MDC and header/attribute values during doFilter(). */
static class CapturingFilterChain implements FilterChain {
final Map<String, String> capturedMdc = new HashMap<>();
String responseHeader;
Object requestAttr;
boolean called = false;
@Override
public void doFilter(ServletRequest req, ServletResponse res)
throws IOException, ServletException {
called = true;
// Snapshot: MDC and request attributes during chain execution
capturedMdc.put(CorrelationIdFilter.MDC_KEY, MDC.get(CorrelationIdFilter.MDC_KEY));
requestAttr = ((MockHttpServletRequest) req).getAttribute(CorrelationIdFilter.MDC_KEY);
responseHeader = ((MockHttpServletResponse) res).getHeader(CorrelationIdFilter.HEADER);
}
}
/** Variant that intentionally throws an exception after capturing (to test cleanup). */
static class ThrowingAfterCaptureChain extends CapturingFilterChain {
@Override
public void doFilter(ServletRequest req, ServletResponse res)
throws IOException, ServletException {
super.doFilter(req, res);
throw new IOException("boom");
}
}
@BeforeEach
void setUp() {
filter = new CorrelationIdFilter();
request = new MockHttpServletRequest();
response = new MockHttpServletResponse();
MDC.clear();
}
@AfterEach
void tearDown() {
MDC.clear();
}
@Nested
@DisplayName("Existing X-Request-Id header")
class ExistingHeader {
@Test
@DisplayName(
"Should propagate existing ID unchanged to MDC & request attribute, and set it in"
+ " the response header")
void shouldPropagateExistingId() throws ServletException, IOException {
String givenId = "abc-123";
request.addHeader(CorrelationIdFilter.HEADER, givenId);
CapturingFilterChain chain = new CapturingFilterChain();
filter.doFilterInternal(request, response, chain);
assertTrue(chain.called);
// Set during the chain
assertEquals(givenId, chain.capturedMdc.get(CorrelationIdFilter.MDC_KEY));
assertEquals(givenId, chain.requestAttr);
assertEquals(givenId, chain.responseHeader);
// Cleared afterwards
assertNull(MDC.get(CorrelationIdFilter.MDC_KEY));
}
@Test
@DisplayName(
"Should strip newlines only in the response header, leaving MDC/attribute"
+ " unsanitized (per current code)")
void shouldStripNewlinesOnlyInResponseHeader() throws ServletException, IOException {
String raw = "id-with\r\nnewlines";
String expectedSanitized = "id-withnewlines"; // Newlines removed
request.addHeader(CorrelationIdFilter.HEADER, raw);
CapturingFilterChain chain = new CapturingFilterChain();
filter.doFilterInternal(request, response, chain);
// MDC & request attribute get the raw value (per implementation)
assertEquals(raw, chain.capturedMdc.get(CorrelationIdFilter.MDC_KEY));
assertEquals(raw, chain.requestAttr);
// Response header is sanitized
assertEquals(expectedSanitized, chain.responseHeader);
assertNull(MDC.get(CorrelationIdFilter.MDC_KEY));
}
}
@Nested
@DisplayName("Missing or blank header")
class MissingOrBlankHeader {
@Test
@DisplayName("Should generate UUID when header is missing")
void shouldGenerateUuidWhenHeaderMissing() throws ServletException, IOException {
CapturingFilterChain chain = new CapturingFilterChain();
filter.doFilterInternal(request, response, chain);
assertTrue(chain.called);
// Consistency: same value in MDC, request attribute, and response header (no newline
// removal needed)
String mdcId = chain.capturedMdc.get(CorrelationIdFilter.MDC_KEY);
assertNotNull(mdcId);
assertEquals(mdcId, chain.requestAttr);
assertEquals(mdcId, chain.responseHeader);
// UUID format check
assertDoesNotThrow(() -> UUID.fromString(mdcId));
assertNull(MDC.get(CorrelationIdFilter.MDC_KEY));
}
@Test
@DisplayName("Should generate UUID when header is blank/whitespace")
void shouldGenerateUuidWhenHeaderBlank() throws ServletException, IOException {
request.addHeader(CorrelationIdFilter.HEADER, " \t ");
CapturingFilterChain chain = new CapturingFilterChain();
filter.doFilterInternal(request, response, chain);
String mdcId = chain.capturedMdc.get(CorrelationIdFilter.MDC_KEY);
assertNotNull(mdcId);
assertEquals(mdcId, chain.requestAttr);
assertEquals(mdcId, chain.responseHeader);
assertDoesNotThrow(() -> UUID.fromString(mdcId));
assertNull(MDC.get(CorrelationIdFilter.MDC_KEY));
}
}
@Nested
@DisplayName("Cleanup logic (finally)")
class CleanupBehavior {
@Test
@DisplayName("Should clear MDC even when FilterChain throws")
void shouldClearMdcOnException() throws ServletException, IOException {
request.addHeader(CorrelationIdFilter.HEADER, "req-1");
ThrowingAfterCaptureChain chain = new ThrowingAfterCaptureChain();
IOException ex =
assertThrows(
IOException.class,
() -> filter.doFilterInternal(request, response, chain));
assertEquals("boom", ex.getMessage());
// Was set during the chain…
assertEquals("req-1", chain.capturedMdc.get(CorrelationIdFilter.MDC_KEY));
// …and cleared afterwards.
assertNull(MDC.get(CorrelationIdFilter.MDC_KEY));
}
}
}