mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +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
@@ -49,7 +49,7 @@ dependencies {
|
||||
api 'org.springframework.boot:spring-boot-starter-mail'
|
||||
api 'org.springframework.boot:spring-boot-starter-cache'
|
||||
api 'com.github.ben-manes.caffeine:caffeine'
|
||||
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.38'
|
||||
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.41'
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.15.0'
|
||||
|
||||
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
|
||||
@@ -57,7 +57,7 @@ dependencies {
|
||||
|
||||
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE'
|
||||
api 'io.micrometer:micrometer-registry-prometheus'
|
||||
implementation 'com.unboundid.product.scim2:scim2-sdk-client:4.0.0'
|
||||
implementation 'com.unboundid.product.scim2:scim2-sdk-client:4.1.0'
|
||||
|
||||
api "io.jsonwebtoken:jjwt-api:$jwtVersion"
|
||||
runtimeOnly "io.jsonwebtoken:jjwt-impl:$jwtVersion"
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.IntStream;
|
||||
@@ -307,8 +308,8 @@ public class AuditUtils {
|
||||
|
||||
// For HTTP methods, infer based on controller and path
|
||||
if (httpMethod != null && path != null) {
|
||||
String cls = controller.getSimpleName().toLowerCase();
|
||||
String pkg = controller.getPackage().getName().toLowerCase();
|
||||
String cls = controller.getSimpleName().toLowerCase(Locale.ROOT);
|
||||
String pkg = controller.getPackage().getName().toLowerCase(Locale.ROOT);
|
||||
|
||||
if ("GET".equals(httpMethod)) return AuditEventType.HTTP_REQUEST;
|
||||
|
||||
@@ -374,8 +375,8 @@ public class AuditUtils {
|
||||
}
|
||||
|
||||
// Otherwise infer from controller and path
|
||||
String cls = controller.getSimpleName().toLowerCase();
|
||||
String pkg = controller.getPackage().getName().toLowerCase();
|
||||
String cls = controller.getSimpleName().toLowerCase(Locale.ROOT);
|
||||
String pkg = controller.getPackage().getName().toLowerCase(Locale.ROOT);
|
||||
|
||||
if ("GET".equals(httpMethod)) return AuditEventType.HTTP_REQUEST;
|
||||
|
||||
|
||||
+1
-1
@@ -134,7 +134,7 @@ public class ProprietaryUIDataController {
|
||||
Security securityProps = applicationProperties.getSecurity();
|
||||
|
||||
// Add enableLogin flag so frontend doesn't need to call /app-config
|
||||
data.setEnableLogin(securityProps.getEnableLogin());
|
||||
data.setEnableLogin(securityProps.isEnableLogin());
|
||||
|
||||
// Check if this is first-time setup with default credentials
|
||||
// The isFirstLogin flag captures: default username/password usage and unchanged state
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import java.security.cert.X509Certificate;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
@@ -150,7 +151,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
registrationId = oAuthToken.getAuthorizedClientRegistrationId();
|
||||
|
||||
// Redirect based on OAuth2 provider
|
||||
switch (registrationId.toLowerCase()) {
|
||||
switch (registrationId.toLowerCase(Locale.ROOT)) {
|
||||
case "keycloak" -> {
|
||||
KeycloakProvider keycloak = oauth.getClient().getKeycloak();
|
||||
|
||||
|
||||
+1
-1
@@ -151,7 +151,7 @@ public class InitialSecuritySetup {
|
||||
if (internalApiUserOpt.isPresent()) {
|
||||
User internalApiUser = internalApiUserOpt.get();
|
||||
// move to team internal API user
|
||||
if (!internalApiUser.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
if (!TeamService.INTERNAL_TEAM_NAME.equals(internalApiUser.getTeam().getName())) {
|
||||
log.info(
|
||||
"Moving internal API user to team: {}", TeamService.INTERNAL_TEAM_NAME);
|
||||
Team internalTeam = teamService.getOrCreateInternalTeam();
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
package stirling.software.proprietary.security.configuration;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
@@ -133,7 +135,7 @@ public class DatabaseConfig {
|
||||
private String getDriverClassName(String driverName) throws UnsupportedProviderException {
|
||||
try {
|
||||
ApplicationProperties.Driver driver =
|
||||
ApplicationProperties.Driver.valueOf(driverName.toUpperCase());
|
||||
ApplicationProperties.Driver.valueOf(driverName.toUpperCase(Locale.ROOT));
|
||||
|
||||
return switch (driver) {
|
||||
case H2 -> {
|
||||
|
||||
+6
-3
@@ -5,6 +5,7 @@ import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters;
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer;
|
||||
@@ -185,7 +186,7 @@ public class KeygenLicenseVerifier {
|
||||
byte[] signatureBytes = Base64.getDecoder().decode(encodedSignature);
|
||||
|
||||
// Create the signing data format - prefix with "license/"
|
||||
String signingData = String.format("license/%s", encryptedData);
|
||||
String signingData = String.format(Locale.ROOT, "license/%s", encryptedData);
|
||||
byte[] signingDataBytes = signingData.getBytes();
|
||||
|
||||
log.info("Signing data length: {} bytes", signingDataBytes.length);
|
||||
@@ -348,7 +349,7 @@ public class KeygenLicenseVerifier {
|
||||
.decode(encodedSignature.replace('-', '+').replace('_', '/'));
|
||||
|
||||
// For ED25519_SIGN format, the signing data is "key/" + encodedPayload
|
||||
String signingData = String.format("key/%s", encodedPayload);
|
||||
String signingData = String.format(Locale.ROOT, "key/%s", encodedPayload);
|
||||
byte[] dataBytes = signingData.getBytes();
|
||||
|
||||
byte[] publicKeyBytes = Hex.decode(PUBLIC_KEY);
|
||||
@@ -526,8 +527,10 @@ public class KeygenLicenseVerifier {
|
||||
String licenseKey, String machineFingerprint, LicenseContext context) throws Exception {
|
||||
String requestBody =
|
||||
String.format(
|
||||
Locale.ROOT,
|
||||
"{\"meta\":{\"key\":\"%s\",\"scope\":{\"fingerprint\":\"%s\"}}}",
|
||||
licenseKey, machineFingerprint);
|
||||
licenseKey,
|
||||
machineFingerprint);
|
||||
HttpRequest request =
|
||||
HttpRequest.newBuilder()
|
||||
.uri(
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package stirling.software.proprietary.security.database;
|
||||
|
||||
public interface DatabaseNotificationServiceInterface {
|
||||
void notifyBackupsSuccess(String subject, String message);
|
||||
|
||||
void notifyBackupsFailure(String subject, String message);
|
||||
|
||||
void notifyImportsSuccess(String subject, String message);
|
||||
|
||||
void notifyImportsFailure(String subject, String message);
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package stirling.software.proprietary.security.database.service;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.mail.MessagingException;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Premium.EnterpriseFeatures.DatabaseNotifications;
|
||||
import stirling.software.proprietary.security.database.DatabaseNotificationServiceInterface;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class DatabaseNotificationService implements DatabaseNotificationServiceInterface {
|
||||
|
||||
private final Optional<EmailService> emailService;
|
||||
private final ApplicationProperties props;
|
||||
private final boolean runningEE;
|
||||
private DatabaseNotifications notifications;
|
||||
|
||||
DatabaseNotificationService(
|
||||
Optional<EmailService> emailService,
|
||||
ApplicationProperties props,
|
||||
@Qualifier("runningEE") boolean runningEE) {
|
||||
this.emailService = emailService;
|
||||
this.props = props;
|
||||
this.runningEE = runningEE;
|
||||
notifications = props.getPremium().getEnterpriseFeatures().getDatabaseNotifications();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyBackupsSuccess(String subject, String message) {
|
||||
if (notifications.getBackups().isSuccessful() && runningEE) {
|
||||
sendMail(subject, message);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyBackupsFailure(String subject, String message) {
|
||||
if (notifications.getBackups().isFailed() && runningEE) {
|
||||
sendMail(subject, message);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyImportsSuccess(String subject, String message) {
|
||||
if (notifications.getImports().isSuccessful() && runningEE) {
|
||||
sendMail(subject, message);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyImportsFailure(String subject, String message) {
|
||||
if (notifications.getImports().isFailed() && runningEE) {
|
||||
sendMail(subject, message);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendMail(String subject, String message) {
|
||||
emailService.ifPresent(
|
||||
service -> {
|
||||
try {
|
||||
String to = props.getMail().getFrom();
|
||||
service.sendSimpleMail(to, subject, message);
|
||||
} catch (MessagingException e) {
|
||||
log.error("Error sending notification email: {}", e.getMessage(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -18,7 +18,8 @@ public class AttemptCounter {
|
||||
}
|
||||
|
||||
public boolean shouldReset(long attemptIncrementTime) {
|
||||
return System.currentTimeMillis() - lastAttemptTime > attemptIncrementTime;
|
||||
long elapsed = System.currentTimeMillis() - lastAttemptTime;
|
||||
return elapsed >= attemptIncrementTime;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -129,7 +130,7 @@ public class User implements UserDetails, Serializable {
|
||||
}
|
||||
|
||||
public void setAuthenticationType(AuthenticationType authenticationType) {
|
||||
this.authenticationType = authenticationType.toString().toLowerCase();
|
||||
this.authenticationType = authenticationType.toString().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
public void addAuthorities(Set<Authority> authorities) {
|
||||
|
||||
+4
-2
@@ -7,6 +7,7 @@ import static stirling.software.common.util.ValidationUtils.isStringEmpty;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -197,7 +198,7 @@ public class OAuth2Configuration {
|
||||
|
||||
String name = oauth.getProvider();
|
||||
String firstChar = String.valueOf(name.charAt(0));
|
||||
String clientName = name.replaceFirst(firstChar, firstChar.toUpperCase());
|
||||
String clientName = name.replaceFirst(firstChar, firstChar.toUpperCase(Locale.ROOT));
|
||||
|
||||
Provider oidcProvider =
|
||||
new Provider(
|
||||
@@ -207,7 +208,8 @@ public class OAuth2Configuration {
|
||||
oauth.getClientId(),
|
||||
oauth.getClientSecret(),
|
||||
oauth.getScopes(),
|
||||
UsernameAttribute.valueOf(oauth.getUseAsUsername().toUpperCase()),
|
||||
UsernameAttribute.valueOf(
|
||||
oauth.getUseAsUsername().toUpperCase(Locale.ROOT)),
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ class AppUpdateAuthService implements ShowAdminInterface {
|
||||
if (!showUpdate) {
|
||||
return showUpdate;
|
||||
}
|
||||
boolean showUpdateOnlyAdmin = applicationProperties.getSystem().getShowUpdateOnlyAdmin();
|
||||
boolean showUpdateOnlyAdmin = applicationProperties.getSystem().isShowUpdateOnlyAdmin();
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return !showUpdateOnlyAdmin;
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.security.authentication.LockedException;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
@@ -60,7 +62,7 @@ public class CustomUserDetailsService implements UserDetailsService {
|
||||
}
|
||||
|
||||
AuthenticationType userAuthenticationType =
|
||||
AuthenticationType.valueOf(authTypeStr.toUpperCase());
|
||||
AuthenticationType.valueOf(authTypeStr.toUpperCase(Locale.ROOT));
|
||||
if (!user.hasPassword() && userAuthenticationType == AuthenticationType.WEB) {
|
||||
throw new IllegalArgumentException("Password must not be null");
|
||||
}
|
||||
|
||||
+63
-1
@@ -7,7 +7,10 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
@@ -32,6 +35,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.FileInfo;
|
||||
import stirling.software.proprietary.security.database.DatabaseNotificationServiceInterface;
|
||||
import stirling.software.proprietary.security.model.exception.BackupNotFoundException;
|
||||
|
||||
@Slf4j
|
||||
@@ -44,12 +48,16 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
|
||||
private final ApplicationProperties.Datasource datasourceProps;
|
||||
private final DataSource dataSource;
|
||||
private final DatabaseNotificationServiceInterface backupNotificationService;
|
||||
|
||||
public DatabaseService(
|
||||
ApplicationProperties.Datasource datasourceProps, DataSource dataSource) {
|
||||
ApplicationProperties.Datasource datasourceProps,
|
||||
DataSource dataSource,
|
||||
DatabaseNotificationServiceInterface backupNotificationService) {
|
||||
this.BACKUP_DIR = Paths.get(InstallationPathConfig.getBackupPath()).normalize();
|
||||
this.datasourceProps = datasourceProps;
|
||||
this.dataSource = dataSource;
|
||||
this.backupNotificationService = backupNotificationService;
|
||||
moveBackupFiles();
|
||||
}
|
||||
|
||||
@@ -172,6 +180,8 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
public boolean importDatabaseFromUI(String fileName) {
|
||||
try {
|
||||
importDatabaseFromUI(getBackupFilePath(fileName));
|
||||
backupNotificationService.notifyImportsSuccess(
|
||||
"Database import completed", "Import file: " + fileName);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
@@ -179,6 +189,9 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
fileName,
|
||||
e.getMessage(),
|
||||
e.getCause());
|
||||
backupNotificationService.notifyImportsFailure(
|
||||
"Database import failed",
|
||||
"Import file: " + fileName + " Message: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -219,16 +232,59 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
PreparedStatement stmt = conn.prepareStatement(query)) {
|
||||
stmt.setString(1, insertOutputFilePath.toString());
|
||||
stmt.execute();
|
||||
backupNotificationService.notifyBackupsSuccess(
|
||||
"Database backup export completed",
|
||||
"Backup file: " + insertOutputFilePath.getFileName());
|
||||
} catch (SQLException e) {
|
||||
log.error("Error during database export: {}", e.getMessage(), e);
|
||||
backupNotificationService.notifyBackupsFailure(
|
||||
"Database backup export failed",
|
||||
"Backup file: "
|
||||
+ insertOutputFilePath.getFileName()
|
||||
+ " Message: "
|
||||
+ e.getMessage());
|
||||
} catch (CannotReadScriptException e) {
|
||||
log.error("Error during database export: File {} not found", insertOutputFilePath);
|
||||
backupNotificationService.notifyBackupsFailure(
|
||||
"Database backup export failed",
|
||||
"Error during database export: File "
|
||||
+ insertOutputFilePath.getFileName()
|
||||
+ " not found. Message: "
|
||||
+ e.getMessage());
|
||||
}
|
||||
|
||||
log.info("Database export completed: {}", insertOutputFilePath);
|
||||
verifyBackup(insertOutputFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean verifyBackup(Path backupPath) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] content = Files.readAllBytes(backupPath);
|
||||
String checksum = bytesToHex(digest.digest(content));
|
||||
log.info("Checksum for {}: {}", backupPath.getFileName(), checksum);
|
||||
|
||||
try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:backupVerify");
|
||||
PreparedStatement stmt = conn.prepareStatement("RUNSCRIPT FROM ?")) {
|
||||
stmt.setString(1, backupPath.toString());
|
||||
stmt.execute();
|
||||
}
|
||||
return true;
|
||||
} catch (IOException | NoSuchAlgorithmException | SQLException e) {
|
||||
log.error("Backup verification failed for {}: {}", backupPath, e.getMessage(), e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private String bytesToHex(byte[] hash) {
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hash) {
|
||||
hexString.append(String.format("%02x", b));
|
||||
}
|
||||
return hexString.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Pair<FileInfo, Boolean>> deleteAllBackups() {
|
||||
List<FileInfo> backupList = this.getBackupList();
|
||||
@@ -384,6 +440,12 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
*/
|
||||
private void executeDatabaseScript(Path scriptPath) {
|
||||
if (isH2Database()) {
|
||||
|
||||
if (!verifyBackup(scriptPath)) {
|
||||
log.error("Backup verification failed for: {}", scriptPath);
|
||||
throw new IllegalArgumentException("Backup verification failed for: " + scriptPath);
|
||||
}
|
||||
|
||||
String query = "RUNSCRIPT from ?;";
|
||||
|
||||
try (Connection conn = dataSource.getConnection();
|
||||
|
||||
+36
@@ -11,6 +11,7 @@ import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.api.Email;
|
||||
@@ -20,6 +21,7 @@ import stirling.software.proprietary.security.model.api.Email;
|
||||
* JavaMailSender to send the email and is designed to handle both the message content and file
|
||||
* attachments.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(value = "mail.enabled", havingValue = "true", matchIfMissing = false)
|
||||
@@ -72,6 +74,40 @@ public class EmailService {
|
||||
|
||||
// Sends the email via the configured mail sender
|
||||
mailSender.send(message);
|
||||
log.debug(
|
||||
"Email sent successfully to {} with subject: {} body: {}",
|
||||
email.getTo(),
|
||||
email.getSubject(),
|
||||
email.getBody());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a simple email without attachments asynchronously.
|
||||
*
|
||||
* @param to the recipient address
|
||||
* @param subject subject line
|
||||
* @param body message body
|
||||
* @throws MessagingException if sending fails or address is invalid
|
||||
*/
|
||||
@Async
|
||||
public void sendSimpleMail(String to, String subject, String body) throws MessagingException {
|
||||
if (to == null || to.trim().isEmpty()) {
|
||||
throw new MessagingException("Invalid Addresses");
|
||||
}
|
||||
|
||||
ApplicationProperties.Mail mailProperties = applicationProperties.getMail();
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, false);
|
||||
helper.addTo(to);
|
||||
helper.setSubject(subject);
|
||||
helper.setText(body, false);
|
||||
helper.setFrom(mailProperties.getFrom());
|
||||
mailSender.send(message);
|
||||
log.debug(
|
||||
"Simple email sent successfully to {} with subject: {} body: {}",
|
||||
to,
|
||||
subject,
|
||||
body);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+10
-5
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@@ -45,17 +46,19 @@ public class LoginAttemptService {
|
||||
if (!isBlockedEnabled || key == null || key.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
attemptsCache.remove(key.toLowerCase());
|
||||
String normalizedKey = key.toLowerCase(Locale.ROOT);
|
||||
attemptsCache.remove(normalizedKey);
|
||||
}
|
||||
|
||||
public void loginFailed(String key) {
|
||||
if (!isBlockedEnabled || key == null || key.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
AttemptCounter attemptCounter = attemptsCache.get(key.toLowerCase());
|
||||
String normalizedKey = key.toLowerCase(Locale.ROOT);
|
||||
AttemptCounter attemptCounter = attemptsCache.get(normalizedKey);
|
||||
if (attemptCounter == null) {
|
||||
attemptCounter = new AttemptCounter();
|
||||
attemptsCache.put(key.toLowerCase(), attemptCounter);
|
||||
attemptsCache.put(normalizedKey, attemptCounter);
|
||||
} else {
|
||||
if (attemptCounter.shouldReset(ATTEMPT_INCREMENT_TIME)) {
|
||||
attemptCounter.reset();
|
||||
@@ -68,7 +71,8 @@ public class LoginAttemptService {
|
||||
if (!isBlockedEnabled || key == null || key.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
AttemptCounter attemptCounter = attemptsCache.get(key.toLowerCase());
|
||||
String normalizedKey = key.toLowerCase(Locale.ROOT);
|
||||
AttemptCounter attemptCounter = attemptsCache.get(normalizedKey);
|
||||
if (attemptCounter == null) {
|
||||
return false;
|
||||
}
|
||||
@@ -80,7 +84,8 @@ public class LoginAttemptService {
|
||||
// Arbitrarily high number if tracking is disabled
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
AttemptCounter attemptCounter = attemptsCache.get(key.toLowerCase());
|
||||
String normalizedKey = key.toLowerCase(Locale.ROOT);
|
||||
AttemptCounter attemptCounter = attemptsCache.get(normalizedKey);
|
||||
if (attemptCounter == null) {
|
||||
return MAX_ATTEMPT;
|
||||
}
|
||||
|
||||
+7
-2
@@ -5,6 +5,7 @@ import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -145,6 +146,7 @@ public class UserService implements UserServiceInterface {
|
||||
return addApiKeyToUser(username);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getApiKeyForUser(String username) {
|
||||
User user =
|
||||
findByUsernameIgnoreCase(username)
|
||||
@@ -581,9 +583,10 @@ public class UserService implements UserServiceInterface {
|
||||
.matches();
|
||||
|
||||
List<String> notAllowedUserList = new ArrayList<>();
|
||||
notAllowedUserList.add("ALL_USERS".toLowerCase());
|
||||
notAllowedUserList.add("ALL_USERS".toLowerCase(Locale.ROOT));
|
||||
notAllowedUserList.add("anonymoususer");
|
||||
boolean notAllowedUser = notAllowedUserList.contains(username.toLowerCase());
|
||||
String normalizedUsername = username.toLowerCase(Locale.ROOT);
|
||||
boolean notAllowedUser = notAllowedUserList.contains(normalizedUsername);
|
||||
return (isValidSimpleUsername || isValidEmail) && !notAllowedUser;
|
||||
}
|
||||
|
||||
@@ -631,6 +634,7 @@ public class UserService implements UserServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCurrentUsername() {
|
||||
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
|
||||
|
||||
@@ -717,6 +721,7 @@ public class UserService implements UserServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getTotalUsersCount() {
|
||||
// Count all users in the database
|
||||
long userCount = userRepository.count();
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
package stirling.software.proprietary.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class FormCopyUtils {
|
||||
|
||||
public boolean hasAnyRotatedPage(PDDocument document) {
|
||||
try {
|
||||
for (PDPage page : document.getPages()) {
|
||||
int rot = page.getRotation();
|
||||
int norm = ((rot % 360) + 360) % 360;
|
||||
if (norm != 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to inspect page rotations: {}", e.getMessage(), e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void copyAndTransformFormFields(
|
||||
PDDocument sourceDocument,
|
||||
PDDocument newDocument,
|
||||
int totalPages,
|
||||
int pagesPerSheet,
|
||||
int cols,
|
||||
int rows,
|
||||
float cellWidth,
|
||||
float cellHeight)
|
||||
throws IOException {
|
||||
|
||||
PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog();
|
||||
PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm();
|
||||
|
||||
if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog();
|
||||
PDAcroForm newAcroForm = new PDAcroForm(newDocument);
|
||||
newCatalog.setAcroForm(newAcroForm);
|
||||
|
||||
PDResources dr = new PDResources();
|
||||
PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS);
|
||||
dr.put(COSName.getPDFName("Helv"), helvetica);
|
||||
dr.put(COSName.getPDFName("ZaDb"), zapfDingbats);
|
||||
newAcroForm.setDefaultResources(dr);
|
||||
newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
|
||||
// Temporarily set NeedAppearances to true during field creation
|
||||
newAcroForm.setNeedAppearances(true);
|
||||
|
||||
Map<String, Integer> fieldNameCounters = new HashMap<>();
|
||||
|
||||
// Build widget -> field map once for efficient lookups
|
||||
Map<PDAnnotationWidget, PDField> widgetFieldMap = buildWidgetFieldMap(sourceAcroForm);
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(pageIndex);
|
||||
List<PDAnnotation> annotations = sourcePage.getAnnotations();
|
||||
|
||||
if (annotations.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int destinationPageIndex = pageIndex / pagesPerSheet;
|
||||
int adjustedPageIndex = pageIndex % pagesPerSheet;
|
||||
int rowIndex = adjustedPageIndex / cols;
|
||||
int colIndex = adjustedPageIndex % cols;
|
||||
|
||||
if (rowIndex >= rows) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (destinationPageIndex >= newDocument.getNumberOfPages()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
PDPage destinationPage = newDocument.getPage(destinationPageIndex);
|
||||
PDRectangle sourceRect = sourcePage.getMediaBox();
|
||||
|
||||
float scaleWidth = cellWidth / sourceRect.getWidth();
|
||||
float scaleHeight = cellHeight / sourceRect.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight);
|
||||
|
||||
float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2;
|
||||
float y =
|
||||
destinationPage.getMediaBox().getHeight()
|
||||
- ((rowIndex + 1) * cellHeight
|
||||
- (cellHeight - sourceRect.getHeight() * scale) / 2);
|
||||
|
||||
copyBasicFormFields(
|
||||
sourceAcroForm,
|
||||
newAcroForm,
|
||||
sourcePage,
|
||||
destinationPage,
|
||||
x,
|
||||
y,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters,
|
||||
widgetFieldMap);
|
||||
}
|
||||
|
||||
// Generate appearance streams and embed them authoritatively
|
||||
boolean appearancesGenerated = false;
|
||||
try {
|
||||
newAcroForm.refreshAppearances();
|
||||
appearancesGenerated = true;
|
||||
} catch (NoSuchMethodError nsme) {
|
||||
log.warn(
|
||||
"AcroForm.refreshAppearances() not available in this PDFBox version; "
|
||||
+ "leaving NeedAppearances=true for viewer-side rendering.");
|
||||
} catch (Exception t) {
|
||||
log.warn(
|
||||
"Failed to refresh field appearances via AcroForm: {}. "
|
||||
+ "Leaving NeedAppearances=true as fallback.",
|
||||
t.getMessage(),
|
||||
t);
|
||||
}
|
||||
|
||||
// After successful appearance generation, set NeedAppearances to false
|
||||
// to signal that appearance streams are now embedded authoritatively
|
||||
if (appearancesGenerated) {
|
||||
try {
|
||||
newAcroForm.setNeedAppearances(false);
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to set NeedAppearances to false: {}. "
|
||||
+ "Appearances were generated but flag could not be updated.",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void copyBasicFormFields(
|
||||
PDAcroForm sourceAcroForm,
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage sourcePage,
|
||||
PDPage destinationPage,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters,
|
||||
Map<PDAnnotationWidget, PDField> widgetFieldMap) {
|
||||
|
||||
try {
|
||||
List<PDAnnotation> sourceAnnotations = sourcePage.getAnnotations();
|
||||
List<PDAnnotation> destinationAnnotations = destinationPage.getAnnotations();
|
||||
|
||||
for (PDAnnotation annotation : sourceAnnotations) {
|
||||
if (annotation instanceof PDAnnotationWidget widgetAnnotation) {
|
||||
if (widgetAnnotation.getRectangle() == null) {
|
||||
continue;
|
||||
}
|
||||
PDField sourceField =
|
||||
widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null;
|
||||
if (sourceField == null) {
|
||||
continue; // skip widgets without a matching field
|
||||
}
|
||||
if (!(sourceField instanceof PDTerminalField terminalField)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
FormFieldTypeSupport handler = FormFieldTypeSupport.forField(terminalField);
|
||||
if (handler == null) {
|
||||
log.debug(
|
||||
"Skipping unsupported field type '{}' for widget '{}'",
|
||||
sourceField.getClass().getSimpleName(),
|
||||
Optional.ofNullable(sourceField.getFullyQualifiedName())
|
||||
.orElseGet(sourceField::getPartialName));
|
||||
continue;
|
||||
}
|
||||
|
||||
copyFieldUsingHandler(
|
||||
handler,
|
||||
terminalField,
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
widgetAnnotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to copy basic form fields for page {}: {}",
|
||||
pageIndex,
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private void copyFieldUsingHandler(
|
||||
FormFieldTypeSupport handler,
|
||||
PDTerminalField sourceField,
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
try {
|
||||
PDTerminalField newField = handler.createField(newAcroForm);
|
||||
boolean initialized =
|
||||
initializeFieldWithWidget(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
newField,
|
||||
sourceField.getPartialName(),
|
||||
handler.fallbackWidgetName(),
|
||||
sourceWidget,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
handler.copyFromOriginal(sourceField, newField);
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to copy {} field '{}': {}",
|
||||
handler.typeName(),
|
||||
Optional.ofNullable(sourceField.getFullyQualifiedName())
|
||||
.orElseGet(sourceField::getPartialName),
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private <T extends PDTerminalField> boolean initializeFieldWithWidget(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
T newField,
|
||||
String originalName,
|
||||
String fallbackName,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
String baseName = (originalName != null) ? originalName : fallbackName;
|
||||
String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters);
|
||||
newField.setPartialName(newFieldName);
|
||||
|
||||
PDAnnotationWidget newWidget = new PDAnnotationWidget();
|
||||
PDRectangle sourceRect = sourceWidget.getRectangle();
|
||||
if (sourceRect == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
float newX = (sourceRect.getLowerLeftX() * scale) + offsetX;
|
||||
float newY = (sourceRect.getLowerLeftY() * scale) + offsetY;
|
||||
float newWidth = sourceRect.getWidth() * scale;
|
||||
float newHeight = sourceRect.getHeight() * scale;
|
||||
newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight));
|
||||
newWidget.setPage(destinationPage);
|
||||
|
||||
newField.getWidgets().add(newWidget);
|
||||
newWidget.setParent(newField);
|
||||
newAcroForm.getFields().add(newField);
|
||||
destinationAnnotations.add(newWidget);
|
||||
return true;
|
||||
}
|
||||
|
||||
private String generateUniqueFieldName(
|
||||
String originalName, int pageIndex, Map<String, Integer> fieldNameCounters) {
|
||||
String baseName = "page" + pageIndex + "_" + originalName;
|
||||
|
||||
Integer counter = fieldNameCounters.get(baseName);
|
||||
if (counter == null) {
|
||||
counter = 0;
|
||||
} else {
|
||||
counter++;
|
||||
}
|
||||
fieldNameCounters.put(baseName, counter);
|
||||
|
||||
return counter == 0 ? baseName : baseName + "_" + counter;
|
||||
}
|
||||
|
||||
private Map<PDAnnotationWidget, PDField> buildWidgetFieldMap(PDAcroForm acroForm) {
|
||||
Map<PDAnnotationWidget, PDField> map = new HashMap<>();
|
||||
if (acroForm == null) {
|
||||
return map;
|
||||
}
|
||||
try {
|
||||
for (PDField field : acroForm.getFieldTree()) {
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
if (widgets == null) {
|
||||
continue;
|
||||
}
|
||||
for (PDAnnotationWidget widget : widgets) {
|
||||
if (widget != null) {
|
||||
map.put(widget, field);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to build widget->field map: {}", e.getMessage(), e);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -1690,9 +1690,9 @@ public class FormUtils {
|
||||
acroForm.getFields().add(field);
|
||||
}
|
||||
|
||||
// Delegation methods to FormCopyUtils for form field transformation
|
||||
// Delegation methods to GeneralFormCopyUtils for form field transformation
|
||||
public boolean hasAnyRotatedPage(PDDocument document) {
|
||||
return FormCopyUtils.hasAnyRotatedPage(document);
|
||||
return stirling.software.common.util.GeneralFormCopyUtils.hasAnyRotatedPage(document);
|
||||
}
|
||||
|
||||
public void copyAndTransformFormFields(
|
||||
@@ -1705,7 +1705,7 @@ public class FormUtils {
|
||||
float cellWidth,
|
||||
float cellHeight)
|
||||
throws IOException {
|
||||
FormCopyUtils.copyAndTransformFormFields(
|
||||
stirling.software.common.util.GeneralFormCopyUtils.copyAndTransformFormFields(
|
||||
sourceDocument,
|
||||
newDocument,
|
||||
totalPages,
|
||||
|
||||
@@ -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