mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
V1 merge (#5193)
# 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:
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
@@ -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);
|
||||
}
|
||||
}
|
||||
+58
@@ -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"));
|
||||
}
|
||||
}
|
||||
-3
@@ -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;
|
||||
|
||||
+8
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
+87
@@ -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));
|
||||
}
|
||||
}
|
||||
+71
@@ -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");
|
||||
}
|
||||
}
|
||||
+141
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+84
@@ -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");
|
||||
}
|
||||
}
|
||||
+285
@@ -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");
|
||||
}
|
||||
}
|
||||
+96
@@ -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));
|
||||
}
|
||||
}
|
||||
+152
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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());
|
||||
}
|
||||
}
|
||||
+33
@@ -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
-5
@@ -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";
|
||||
|
||||
|
||||
+7
-11
@@ -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");
|
||||
|
||||
+239
@@ -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
-3
@@ -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;
|
||||
|
||||
|
||||
+9
-14
@@ -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();
|
||||
|
||||
+176
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+317
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user