# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

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