feat(security): add TOTP-based multi-factor authentication with backend and UI support (#5417)

# Description of Changes

This pull request introduces several improvements and new features
across the authentication and admin data APIs, with a particular focus
on multi-factor authentication (MFA) support and better handling of user
settings. The changes include integrating MFA status into account data
responses, masking sensitive user settings in admin views, and
refactoring code to use more robust user creation methods. Additionally,
there are minor code cleanups and consistency improvements.

### Multi-factor Authentication (MFA) Integration

* Added `mfaEnabled` and `mfaRequired` fields to the `AccountData`
response, populated using the new `MfaService`, to provide clients with
MFA status information for users.
[[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R430-R431)
[[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R72)
[[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L83-R86)
[[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R98)
[[5]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R574-R575)

### User Settings Handling and Security

* Admin settings API now returns user settings for each user, with the
`mfaSecret` field masked to protect sensitive information. This is
achieved by fetching settings via `findByIdWithSettings` and
copying/masking the relevant field before returning.
[[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R252-R259)
[[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L302-R322)
[[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R378)
[[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R563)

### Refactoring and Code Consistency

* Refactored user creation in `InitialSecuritySetup` to use the
`SaveUserRequest` builder and `saveUserCore` for better maintainability
and clarity, replacing direct calls to `saveUser`.
[[1]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850R22)
[[2]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L116-R124)
[[3]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L130-R144)
[[4]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L140-R160)
* Standardized checks for internal team membership by comparing with
`TeamService.INTERNAL_TEAM_NAME` on the left side for consistency.
[[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L267-R273)
[[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L332-R351)
[[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L421-R443)
[[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L449-R471)
[[5]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L462-R485)

### API and DTO Changes

* Changed the login API to accept `UsernameAndPassMfa` instead of
`UsernameAndPass`, paving the way for MFA code support in authentication
requests.
[[1]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L30-R41)
[[2]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L61-R71)
* Updated import statements and controller dependencies to include new
DTOs and services related to MFA.
[[1]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L14-R19)
[[2]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8R56-R57)

These updates improve security, prepare the system for MFA rollout, and
make admin and authentication APIs more robust and informative.

---

## 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)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### 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.

---------

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Ludy
2026-01-23 21:34:57 +00:00
committed by GitHub
co-authored by Copilot
parent 0436460c03
commit 0b86dd79d3
52 changed files with 3739 additions and 558 deletions
@@ -0,0 +1,137 @@
package stirling.software.proprietary.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
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.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.AccountData;
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.DatabaseData;
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.LoginData;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.database.repository.SessionRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.DatabaseService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@ExtendWith(MockitoExtension.class)
class ProprietaryUIDataControllerTest {
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
@Mock private UserRepository userRepository;
@Mock private TeamRepository teamRepository;
@Mock private SessionRepository sessionRepository;
@Mock private DatabaseService databaseService;
@Mock private UserLicenseSettingsService licenseSettingsService;
@Mock private PersistentAuditEventRepository auditRepository;
@Mock private MfaService mfaService;
private ApplicationProperties applicationProperties;
private AuditConfigurationProperties auditConfig;
private ObjectMapper objectMapper;
private ProprietaryUIDataController controller;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getUi().setLanguages(List.of("en", "de"));
applicationProperties.getSystem().setDefaultLocale("en");
applicationProperties.getSecurity().setEnableLogin(true);
applicationProperties.getSecurity().getOauth2().setEnabled(false);
applicationProperties.getSecurity().getSaml2().setEnabled(false);
auditConfig = new AuditConfigurationProperties(applicationProperties);
objectMapper = new ObjectMapper();
controller =
new ProprietaryUIDataController(
applicationProperties,
auditConfig,
sessionPersistentRegistry,
userRepository,
teamRepository,
sessionRepository,
databaseService,
objectMapper,
false,
licenseSettingsService,
auditRepository,
mfaService);
}
@Test
void loginDataFlagsFirstTimeSetupWhenNoUsers() {
when(userRepository.findAll()).thenReturn(Collections.emptyList());
ResponseEntity<LoginData> response = controller.getLoginData();
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
LoginData body = response.getBody();
assertThat(body.isFirstTimeSetup()).isTrue();
assertThat(body.isShowDefaultCredentials()).isTrue();
assertThat(body.getLanguages()).containsExactly("en", "de");
assertThat(body.getDefaultLocale()).isEqualTo("en");
}
@Test
void accountDataReturnsUserSettingsAndMfaFlags() {
User user = new User();
user.setUsername("[email protected]");
Authority authority = new Authority();
authority.setAuthority(Role.USER.getRoleId());
user.addAuthority(authority);
user.setSettings(Map.of("theme", "dark"));
when(userRepository.findByUsernameIgnoreCaseWithSettings("[email protected]"))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(true);
when(mfaService.isMfaRequired(user)).thenReturn(false);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
ResponseEntity<AccountData> response = controller.getAccountData(authentication);
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
AccountData data = response.getBody();
assertThat(data.getUsername()).isEqualTo("[email protected]");
assertThat(data.isMfaEnabled()).isTrue();
assertThat(data.isMfaRequired()).isFalse();
assertThat(data.getSettings()).contains("\"theme\":\"dark\"");
}
@Test
void databaseDataMarksUnknownVersion() {
when(databaseService.getBackupList()).thenReturn(List.of());
when(databaseService.getH2Version()).thenReturn("Unknown");
ResponseEntity<DatabaseData> response = controller.getDatabaseData();
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
DatabaseData data = response.getBody();
assertThat(data.getBackupFiles()).isEmpty();
assertThat(data.isVersionUnknown()).isTrue();
}
}
@@ -0,0 +1,115 @@
package stirling.software.proprietary.security;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.sql.SQLException;
import java.util.Collections;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
import stirling.software.proprietary.security.service.SaveUserRequest;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@ExtendWith(MockitoExtension.class)
class InitialSecuritySetupTest {
@Mock private UserService userService;
@Mock private TeamService teamService;
@Mock private DatabaseServiceInterface databaseService;
@Mock private UserLicenseSettingsService licenseSettingsService;
private ApplicationProperties applicationProperties;
private InitialSecuritySetup initialSecuritySetup;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getSecurity().getInitialLogin().setUsername("admin");
applicationProperties.getSecurity().getInitialLogin().setPassword("password");
Team internalTeam = new Team();
internalTeam.setName(TeamService.INTERNAL_TEAM_NAME);
User internalUser = new User();
internalUser.setUsername(Role.INTERNAL_API_USER.getRoleId());
internalUser.setTeam(internalTeam);
when(userService.findByUsernameIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
.thenReturn(Optional.of(internalUser));
when(teamService.getOrCreateInternalTeam()).thenReturn(internalTeam);
initialSecuritySetup =
new InitialSecuritySetup(
userService,
teamService,
applicationProperties,
databaseService,
licenseSettingsService);
}
@Test
void initImportsBackupWhenPresent() throws SQLException, UnsupportedProviderException {
when(userService.hasUsers()).thenReturn(false);
when(databaseService.hasBackup()).thenReturn(true);
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
when(userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
.thenReturn(true);
initialSecuritySetup.init();
verify(databaseService).importDatabase();
verify(userService, never()).saveUserCore(any());
verify(licenseSettingsService).initializeGrandfatheredCount();
verify(licenseSettingsService).updateLicenseMaxUsers();
}
@Test
void initCreatesConfiguredAdminWhenNoBackup()
throws SQLException, UnsupportedProviderException {
when(userService.hasUsers()).thenReturn(false);
when(databaseService.hasBackup()).thenReturn(false);
when(userService.findByUsernameIgnoreCase("admin")).thenReturn(Optional.empty());
Team defaultTeam = new Team();
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
when(teamService.getOrCreateDefaultTeam()).thenReturn(defaultTeam);
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
when(userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
.thenReturn(true);
initialSecuritySetup.init();
ArgumentCaptor<SaveUserRequest> captor = ArgumentCaptor.forClass(SaveUserRequest.class);
verify(userService).saveUserCore(captor.capture());
SaveUserRequest saved = captor.getValue();
assertThat(saved.getUsername()).isEqualTo("admin");
assertThat(saved.getPassword()).isEqualTo("password");
}
@Test
void configureJwtSettingsDisablesKeyCleanupWhenJwtDisabled() {
ReflectionTestUtils.setField(initialSecuritySetup, "v2Enabled", true);
applicationProperties.getSecurity().getJwt().setEnableKeystore(false);
when(userService.hasUsers()).thenReturn(true);
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
when(userService.usernameExistsIgnoreCase(any())).thenReturn(true);
initialSecuritySetup.init();
assertThat(applicationProperties.getSecurity().getJwt().isEnableKeyCleanup()).isFalse();
}
}
@@ -0,0 +1,234 @@
package stirling.software.proprietary.security.controller.api;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
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.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.service.TotpService;
import stirling.software.proprietary.security.service.UserService;
@ExtendWith(MockitoExtension.class)
class AuthControllerLoginTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private MockMvc mockMvc;
private ApplicationProperties.Security securityProperties;
@Mock private UserService userService;
@Mock private JwtServiceInterface jwtService;
@Mock private CustomUserDetailsService userDetailsService;
@Mock private LoginAttemptService loginAttemptService;
@Mock private MfaService mfaService;
@Mock private TotpService totpService;
@BeforeEach
void setUp() {
securityProperties = new ApplicationProperties.Security();
securityProperties.setLoginMethod("all");
AuthController controller =
new AuthController(
userService,
jwtService,
userDetailsService,
loginAttemptService,
mfaService,
totpService,
securityProperties);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@Test
void loginRejectsWhenUserPassDisabled() throws Exception {
securityProperties.setLoginMethod(
ApplicationProperties.Security.LoginMethods.OAUTH2.toString());
UsernameAndPassMfa payload = buildPayload(null);
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isForbidden())
.andExpect(
jsonPath("$.error")
.value(
"Username/password authentication is not enabled. Please use the configured authentication method."));
verify(userDetailsService, never()).loadUserByUsername(any());
}
@Test
void loginBlockedAccountReturnsUnauthorized() throws Exception {
UsernameAndPassMfa payload = buildPayload(null);
when(loginAttemptService.isBlocked("[email protected]")).thenReturn(true);
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isUnauthorized())
.andExpect(
jsonPath("$.error")
.value("Account is locked due to too many failed attempts"));
verify(loginAttemptService, never()).loginSucceeded(any());
}
@Test
void loginRequiresMfaCodeWhenEnabled() throws Exception {
UsernameAndPassMfa payload = buildPayload(null);
User user = buildUser();
when(userDetailsService.loadUserByUsername("[email protected]")).thenReturn(user);
when(userService.isPasswordCorrect(user, "pw")).thenReturn(true);
when(mfaService.isMfaEnabled(user)).thenReturn(true);
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("mfa_required"));
verify(loginAttemptService, never()).loginSucceeded(any());
}
@Test
void loginFailsWhenPasswordIncorrect() throws Exception {
UsernameAndPassMfa payload = buildPayload(null);
User user = buildUser();
when(userDetailsService.loadUserByUsername("[email protected]")).thenReturn(user);
when(userService.isPasswordCorrect(user, "pw")).thenReturn(false);
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("Invalid credentials"));
verify(loginAttemptService).loginFailed("[email protected]");
}
@Test
void loginSucceedsAndGeneratesToken() throws Exception {
UsernameAndPassMfa payload = buildPayload(null);
User user = buildUser();
when(userDetailsService.loadUserByUsername("[email protected]")).thenReturn(user);
when(userService.isPasswordCorrect(user, "pw")).thenReturn(true);
when(mfaService.isMfaEnabled(user)).thenReturn(false);
when(jwtService.generateToken(eq("[email protected]"), any(Map.class)))
.thenReturn("token-123");
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.session.access_token").value("token-123"))
.andExpect(jsonPath("$.user.username").value("[email protected]"));
verify(loginAttemptService).loginSucceeded("[email protected]");
}
@Test
void refreshReturnsUnauthorizedWhenTokenMissing() throws Exception {
when(jwtService.extractToken(any())).thenReturn(null);
mockMvc.perform(post("/api/v1/auth/refresh"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("No token found"));
}
@Test
void refreshReturnsNewTokenWhenValid() throws Exception {
User user = buildUser();
when(jwtService.extractToken(any())).thenReturn("old");
when(jwtService.extractUsername("old")).thenReturn("[email protected]");
when(userDetailsService.loadUserByUsername("[email protected]")).thenReturn(user);
when(jwtService.generateToken(eq("[email protected]"), any(Map.class)))
.thenReturn("new-token");
mockMvc.perform(post("/api/v1/auth/refresh"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.access_token").value("new-token"))
.andExpect(jsonPath("$.expires_in").value(3600));
}
@Test
void getCurrentUserReturnsUnauthorizedWhenAnonymous() throws Exception {
SecurityContextHolder.clearContext();
mockMvc.perform(get("/api/v1/auth/me"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("Not authenticated"));
}
@Test
void getCurrentUserReturnsUserDetails() throws Exception {
User user = buildUser();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
mockMvc.perform(get("/api/v1/auth/me"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.user.username").value("[email protected]"))
.andExpect(
jsonPath("$.user.authenticationType")
.value(AuthenticationType.WEB.name().toLowerCase()));
SecurityContextHolder.clearContext();
}
private User buildUser() {
User user = new User();
user.setUsername("[email protected]");
user.setEnabled(true);
user.setAuthenticationType(AuthenticationType.WEB);
Authority authority = new Authority();
authority.setAuthority(Role.USER.getRoleId());
user.addAuthorities(Set.of(authority));
return user;
}
private UsernameAndPassMfa buildPayload(String mfaCode) {
UsernameAndPassMfa payload = new UsernameAndPassMfa();
payload.setUsername("[email protected]");
payload.setPassword("pw");
payload.setMfaCode(mfaCode);
return payload;
}
}
@@ -0,0 +1,218 @@
package stirling.software.proprietary.security.controller.api;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.service.TotpService;
import stirling.software.proprietary.security.service.UserService;
@ExtendWith(MockitoExtension.class)
class AuthControllerMfaTest {
private static final String USERNAME = "[email protected]";
private final ObjectMapper objectMapper = new ObjectMapper();
private MockMvc mockMvc;
private Authentication authentication;
private User user;
@Mock private UserService userService;
@Mock private JwtServiceInterface jwtService;
@Mock private CustomUserDetailsService userDetailsService;
@Mock private LoginAttemptService loginAttemptService;
@Mock private MfaService mfaService;
@Mock private TotpService totpService;
@InjectMocks private AuthController authController;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(authController).build();
authentication = new UsernamePasswordAuthenticationToken(USERNAME, "password", List.of());
user = new User();
user.setUsername(USERNAME);
user.setAuthenticationType(AuthenticationType.WEB);
}
@Test
void setupMfaRequiresAuthentication() throws Exception {
mockMvc.perform(get("/api/v1/auth/mfa/setup"))
.andExpect(status().isUnauthorized())
.andExpect(content().json("{\"error\":\"Not authenticated\"}"));
}
@Test
void setupMfaReturnsSecretAndUri() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(false);
when(totpService.generateSecret()).thenReturn("SECRET");
when(totpService.buildOtpAuthUri(USERNAME, "SECRET")).thenReturn("otpauth://test");
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
.andExpect(status().isOk())
.andExpect(jsonPath("$.secret").value("SECRET"))
.andExpect(jsonPath("$.otpauthUri").value("otpauth://test"));
verify(mfaService).setSecret(user, "SECRET");
}
@Test
void setupMfaReturnsConflictWhenAlreadyEnabled() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(true);
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
.andExpect(status().isConflict())
.andExpect(content().json("{\"error\":\"MFA already enabled\"}"));
verify(totpService, never()).generateSecret();
}
@Test
void setupMfaRejectsNonWebAuthenticationType() throws Exception {
user.setAuthenticationType(AuthenticationType.OAUTH2);
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
.andExpect(status().isForbidden())
.andExpect(
content()
.json(
"{\"error\":\"MFA settings are only available for web accounts\"}"));
}
@Test
void enableMfaRejectsMissingCode() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.getSecret(user)).thenReturn("SECRET");
mockMvc.perform(
post("/api/v1/auth/mfa/enable")
.principal(authentication)
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest())
.andExpect(content().json("{\"error\":\"MFA code is required\"}"));
}
@Test
void enableMfaCompletesWorkflow() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.getSecret(user)).thenReturn("SECRET");
when(totpService.getValidTimeStep("SECRET", "123456")).thenReturn(42L);
when(mfaService.isTotpStepUsable(user, 42L)).thenReturn(true);
mockMvc.perform(
post("/api/v1/auth/mfa/enable")
.principal(authentication)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of("code", "123456"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.enabled").value(true));
verify(mfaService).enableMfa(user);
verify(mfaService).markTotpStepUsed(user, 42L);
verify(mfaService).setMfaRequired(user, false);
}
@Test
void disableMfaCompletesWorkflow() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(true);
when(mfaService.getSecret(user)).thenReturn("SECRET");
when(totpService.getValidTimeStep("SECRET", "654321")).thenReturn(7L);
when(mfaService.isTotpStepUsable(user, 7L)).thenReturn(true);
mockMvc.perform(
post("/api/v1/auth/mfa/disable")
.principal(authentication)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of("code", "654321"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.enabled").value(false));
verify(mfaService).disableMfa(user);
verify(mfaService).markTotpStepUsed(user, 7L);
}
@Test
void disableMfaReturnsDisabledWhenNotEnabled() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(false);
mockMvc.perform(
post("/api/v1/auth/mfa/disable")
.principal(authentication)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of("code", "654321"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.enabled").value(false));
verify(mfaService, never()).getSecret(user);
verifyNoInteractions(totpService);
}
@Test
void cancelMfaSetupClearsPendingSecret() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
mockMvc.perform(post("/api/v1/auth/mfa/setup/cancel").principal(authentication))
.andExpect(status().isOk())
.andExpect(jsonPath("$.cleared").value(true));
verify(mfaService).clearPendingSecret(user);
}
@Test
void cancelMfaSetupReturnsConflictWhenEnabled() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(true);
mockMvc.perform(post("/api/v1/auth/mfa/setup/cancel").principal(authentication))
.andExpect(status().isConflict())
.andExpect(content().json("{\"error\":\"MFA already enabled\"}"));
verify(mfaService, never()).clearPendingSecret(user);
}
}
@@ -0,0 +1,162 @@
package stirling.software.proprietary.security.controller.api;
import static org.hamcrest.Matchers.startsWith;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
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.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.InviteToken;
import stirling.software.proprietary.security.repository.InviteTokenRepository;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
@ExtendWith(MockitoExtension.class)
class InviteLinkControllerTest {
@Mock private InviteTokenRepository inviteTokenRepository;
@Mock private TeamRepository teamRepository;
@Mock private UserService userService;
@Mock private EmailService emailService;
private ApplicationProperties applicationProperties;
private MockMvc mockMvc;
private Principal adminPrincipal;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getMail().setEnableInvites(true);
applicationProperties.getMail().setInviteLinkExpiryHours(24);
applicationProperties.getSystem().setFrontendUrl("https://frontend.example.com");
adminPrincipal = () -> "admin";
InviteLinkController controller =
new InviteLinkController(
inviteTokenRepository,
teamRepository,
userService,
applicationProperties,
Optional.of(emailService));
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@Test
void generateInviteLinkRejectsWhenInvitesDisabled() throws Exception {
applicationProperties.getMail().setEnableInvites(false);
mockMvc.perform(post("/api/v1/invite/generate").principal(adminPrincipal))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("Email invites are not enabled"));
verify(inviteTokenRepository, never()).save(any());
}
@Test
void generateInviteLinkRejectsInvalidEmail() throws Exception {
applicationProperties.getMail().setEnableInvites(true);
mockMvc.perform(
post("/api/v1/invite/generate")
.principal(adminPrincipal)
.param("email", "not-an-email"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("Invalid email address"));
}
@Test
void generateInviteLinkBlocksOnLicenseLimit() throws Exception {
applicationProperties.getPremium().setEnabled(true);
applicationProperties.getPremium().setMaxUsers(1);
when(userService.getTotalUsersCount()).thenReturn(1L);
when(inviteTokenRepository.countActiveInvites(any(LocalDateTime.class))).thenReturn(0L);
mockMvc.perform(
post("/api/v1/invite/generate")
.principal(adminPrincipal)
.param("email", "[email protected]"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value(startsWith("License limit reached")));
}
@Test
void generateInviteLinkBuildsFrontendUrl() throws Exception {
Team defaultTeam = new Team();
defaultTeam.setId(5L);
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME))
.thenReturn(Optional.of(defaultTeam));
when(userService.usernameExistsIgnoreCase("[email protected]")).thenReturn(false);
when(inviteTokenRepository.findByEmail("[email protected]")).thenReturn(Optional.empty());
mockMvc.perform(
post("/api/v1/invite/generate")
.principal(adminPrincipal)
.param("email", "[email protected]"))
.andExpect(status().isOk())
.andExpect(
jsonPath("$.inviteUrl")
.value(startsWith("https://frontend.example.com/invite?token=")))
.andExpect(jsonPath("$.email").value("[email protected]"));
verify(inviteTokenRepository).save(any());
}
@Test
void validateInviteTokenReturnsGoneWhenExpired() throws Exception {
InviteToken expired = new InviteToken();
expired.setToken("abc");
expired.setExpiresAt(LocalDateTime.now().minusHours(1));
expired.setRole(Role.USER.getRoleId());
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(expired));
mockMvc.perform(get("/api/v1/invite/validate/abc"))
.andExpect(status().isGone())
.andExpect(jsonPath("$.error").value("This invite link has expired"));
}
@Test
void acceptInviteCreatesUserWhenEmailProvided() throws Exception {
InviteToken invite = new InviteToken();
invite.setToken("abc");
invite.setExpiresAt(LocalDateTime.now().plusHours(2));
invite.setRole(Role.USER.getRoleId());
invite.setUsed(false);
invite.setEmail(null); // email required from request
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(invite));
when(userService.usernameExistsIgnoreCase("[email protected]")).thenReturn(false);
mockMvc.perform(
post("/api/v1/invite/accept/abc")
.param("email", "[email protected]")
.param("password", "password123"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Account created successfully"))
.andExpect(jsonPath("$.username").value("[email protected]"));
verify(userService).saveUserCore(any());
verify(inviteTokenRepository).save(invite);
}
}
@@ -0,0 +1,140 @@
package stirling.software.proprietary.security.controller.api;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
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.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@ExtendWith(MockitoExtension.class)
class UserControllerTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Mock private UserService userService;
@Mock private SessionPersistentRegistry sessionRegistry;
@Mock private TeamRepository teamRepository;
@Mock private UserRepository userRepository;
@Mock private EmailService emailService;
@Mock private UserLicenseSettingsService licenseSettingsService;
private ApplicationProperties applicationProperties;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getPremium().setMaxUsers(10);
applicationProperties.getMail().setEnabled(true);
UserController controller =
new UserController(
userService,
sessionRegistry,
applicationProperties,
teamRepository,
userRepository,
Optional.of(emailService),
licenseSettingsService);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@Test
void registerRejectsExistingUser() throws Exception {
UsernameAndPass payload = new UsernameAndPass();
payload.setUsername("[email protected]");
payload.setPassword("pw");
when(userService.usernameExistsIgnoreCase("[email protected]")).thenReturn(true);
mockMvc.perform(
post("/api/v1/user/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("User already exists"));
verify(userService, never()).saveUserCore(any());
}
@Test
void registerCreatesUserWhenValid() throws Exception {
UsernameAndPass payload = new UsernameAndPass();
payload.setUsername("[email protected]");
payload.setPassword("pw");
Team defaultTeam = new Team();
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
when(userService.usernameExistsIgnoreCase("[email protected]")).thenReturn(false);
when(userService.isUsernameValid("[email protected]")).thenReturn(true);
when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false);
when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME))
.thenReturn(Optional.of(defaultTeam));
User savedUser = new User();
savedUser.setUsername("[email protected]");
savedUser.setEnabled(false);
when(userService.saveUserCore(any())).thenReturn(savedUser);
mockMvc.perform(
post("/api/v1/user/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.user.username").value("[email protected]"));
}
@Test
void changeUserEnabledPreventsSelfDisable() throws Exception {
User user = new User();
user.setUsername("admin");
when(userService.usernameExistsIgnoreCase("admin")).thenReturn(true);
when(userService.findByUsernameIgnoreCase("admin")).thenReturn(Optional.of(user));
Authentication authentication = new UsernamePasswordAuthenticationToken("admin", "pw");
mockMvc.perform(
post("/api/v1/user/admin/changeUserEnabled/admin")
.param("enabled", "false")
.principal(authentication))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("Cannot disable your own account."));
}
@Test
void changePasswordRejectsMissingUser() throws Exception {
Authentication authentication = new UsernamePasswordAuthenticationToken("ghost", "pw");
when(userService.usernameExistsIgnoreCase("ghost")).thenReturn(false);
mockMvc.perform(post("/api/v1/user/admin/deleteUser/ghost").principal(authentication))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").value("User not found."));
}
}
@@ -0,0 +1,131 @@
package stirling.software.proprietary.security.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import javax.sql.DataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.FileInfo;
import stirling.software.proprietary.security.database.DatabaseNotificationServiceInterface;
class DatabaseServiceTest {
@TempDir Path tempDir;
@Mock private DatabaseNotificationServiceInterface notificationService;
private DatabaseService databaseService;
private ApplicationProperties.Datasource datasourceProps;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
datasourceProps = new ApplicationProperties.Datasource();
datasourceProps.setType(ApplicationProperties.Driver.H2.name());
datasourceProps.setCustomDatabaseUrl("jdbc:h2:mem:test");
datasourceProps.setEnableCustomDatabase(false);
DataSource dataSource =
new DriverManagerDataSource(
"jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "");
databaseService = new DatabaseService(datasourceProps, dataSource, notificationService);
ReflectionTestUtils.setField(databaseService, "BACKUP_DIR", tempDir);
}
@Test
void hasBackupReturnsFalseWhenEmpty() {
assertThat(databaseService.hasBackup()).isFalse();
assertThat(Files.exists(tempDir)).isTrue();
}
@Test
void getBackupListReturnsEntries() throws IOException {
Path backup =
tempDir.resolve(
"backup_"
+ LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"))
+ ".sql");
Files.writeString(backup, "CREATE TABLE TEST(ID INT);");
List<stirling.software.common.model.FileInfo> backups = databaseService.getBackupList();
assertThat(backups).hasSize(1);
assertThat(backups.get(0).getFileName()).isEqualTo(backup.getFileName().toString());
}
@Test
void importDatabaseFromUICopiesBackupAndDeletesTemp() throws IOException {
Path script = Files.createTempFile("script", ".sql");
Files.writeString(script, "CREATE TABLE SAMPLE(ID INT PRIMARY KEY);\n");
boolean result = databaseService.importDatabaseFromUI(script);
assertThat(result).isTrue();
assertThat(Files.exists(script)).isFalse();
try (var stream = Files.list(tempDir)) {
assertThat(stream.toList()).isNotEmpty();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Test
void getBackupFilePathPreventsTraversal() {
assertThatThrownBy(() -> databaseService.getBackupFilePath("../evil.sql"))
.isInstanceOf(SecurityException.class);
}
@Test
void deleteBackupFileRejectsInvalidName() throws Exception {
boolean deleted = databaseService.deleteBackupFile("..bad.sql");
assertThat(deleted).isFalse();
}
@Test
void deleteAllBackupsRemovesFiles() throws Exception {
Path first = tempDir.resolve("backup_first.sql");
Path second = tempDir.resolve("backup_second.sql");
Files.writeString(first, "SELECT 1;");
Files.writeString(second, "SELECT 1;");
List<org.apache.commons.lang3.tuple.Pair<FileInfo, Boolean>> results =
databaseService.deleteAllBackups();
assertThat(results).hasSize(2);
assertThat(results.stream().allMatch(org.apache.commons.lang3.tuple.Pair::getRight))
.isTrue();
assertThat(Files.exists(first)).isFalse();
assertThat(Files.exists(second)).isFalse();
}
@Test
void exportDatabaseCreatesScript() {
databaseService.exportDatabase();
assertThat(tempDir.toFile().list()).isNotEmpty();
verify(notificationService)
.notifyBackupsSuccess(
org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.anyString());
}
}
@@ -0,0 +1,146 @@
package stirling.software.proprietary.security.service;
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.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
@ExtendWith(MockitoExtension.class)
class MfaServiceTest {
@Mock private UserRepository userRepository;
@Mock private DatabaseServiceInterface databaseService;
@InjectMocks private MfaService mfaService;
@Test
void setSecretStoresSecretAndDisablesMfa() throws Exception {
User user = new User();
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "10");
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.setSecret(user, "NEWSECRET");
assertEquals("NEWSECRET", user.getSettings().get(MfaService.MFA_SECRET_KEY));
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(databaseService).exportDatabase();
}
@Test
void enableMfaSetsEnabledFlag() throws Exception {
User user = new User();
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.enableMfa(user);
assertEquals("true", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
verify(databaseService).exportDatabase();
}
@Test
void disableMfaClearsSecretAndUsage() throws Exception {
User user = new User();
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "20");
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.disableMfa(user);
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
assertNull(user.getSettings().get(MfaService.MFA_SECRET_KEY));
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(databaseService).exportDatabase();
}
@Test
void markTotpStepUsedTracksNewestStep() throws Exception {
User user = new User();
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
assertTrue(mfaService.markTotpStepUsed(user, 100L));
assertEquals("100", user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(databaseService).exportDatabase();
}
@Test
void markTotpStepUsedRejectsReplays() throws Exception {
User user = new User();
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "200");
assertFalse(mfaService.markTotpStepUsed(user, 199L));
assertFalse(mfaService.markTotpStepUsed(user, 200L));
verify(databaseService, never()).exportDatabase();
}
@Test
void markTotpStepUsedIgnoresMalformedStoredValue() throws Exception {
User user = new User();
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "not-a-number");
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
assertTrue(mfaService.markTotpStepUsed(user, 5L));
assertEquals("5", user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(databaseService, times(1)).exportDatabase();
}
@Test
void isTotpStepUsableRespectsLastStep() {
User user = new User();
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "50");
assertFalse(mfaService.isTotpStepUsable(user, 50));
assertFalse(mfaService.isTotpStepUsable(user, 40));
assertTrue(mfaService.isTotpStepUsable(user, 51));
}
@Test
void isMfaRequiredDefaultsToFalse() {
User user = new User();
assertFalse(mfaService.isMfaRequired(user));
}
@Test
void setMfaRequiredStoresFlag() throws Exception {
User user = new User();
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.setMfaRequired(user, true);
assertEquals("true", user.getSettings().get(MfaService.MFA_REQUIRED_KEY));
verify(databaseService).exportDatabase();
}
@Test
void clearPendingSecretResetsValues() throws Exception {
User user = new User();
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "12");
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.clearPendingSecret(user);
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
assertNull(user.getSettings().get(MfaService.MFA_SECRET_KEY));
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(databaseService).exportDatabase();
}
@Test
void isMfaEnabledAndGetSecretReadSettings() {
User user = new User();
user.getSettings().put(MfaService.MFA_ENABLED_KEY, "true");
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
assertTrue(mfaService.isMfaEnabled(user));
assertEquals("SECRET", mfaService.getSecret(user));
}
}
@@ -0,0 +1,96 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.util.Base32Codec;
class TotpServiceTest {
private TotpService buildService(String appName) {
ApplicationProperties properties = new ApplicationProperties();
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
ui.setAppNameNavbar(appName);
properties.setUi(ui);
return new TotpService(properties);
}
@Test
void generateSecretReturnsBase32String() {
TotpService service = buildService("Test App");
String secret = service.generateSecret();
assertNotNull(secret);
assertEquals(32, secret.length());
assertTrue(secret.matches("[A-Z2-7]+"));
}
@Test
void buildOtpAuthUriIncludesIssuerAndUsername() {
TotpService service = buildService("Stirling Test");
String uri = service.buildOtpAuthUri("[email protected]", "SECRET");
assertTrue(uri.contains("issuer=Stirling%20Test"));
assertTrue(uri.contains("Stirling%20Test%3Auser%40example.com"));
}
@Test
void isValidCodeAcceptsCurrentAndAdjacentTimeSteps() throws Exception {
TotpService service = buildService("Test App");
byte[] secretBytes = "super-secret".getBytes(StandardCharsets.UTF_8);
String secret = Base32Codec.encode(secretBytes);
long timeStep = Instant.now().getEpochSecond() / 30;
String currentCode = generateCode(service, secretBytes, timeStep);
String nextCode = generateCode(service, secretBytes, timeStep + 1);
assertTrue(service.isValidCode(secret, currentCode));
assertEquals(timeStep, service.getValidTimeStep(secret, currentCode));
assertTrue(service.isValidCode(secret, nextCode));
}
@Test
void isValidCodeRejectsInvalidFormats() {
TotpService service = buildService("Test App");
assertFalse(service.isValidCode("SECRET", "ABCDEF"));
assertFalse(service.isValidCode("SECRET", "12345"));
assertFalse(service.isValidCode(null, "123456"));
}
@Test
void isValidCodeRejectsInvalidSecrets() {
TotpService service = buildService("Test App");
assertFalse(service.isValidCode("INVALID*", "123456"));
}
@Test
void buildOtpAuthUriUsesDefaultIssuerWhenMissing() {
ApplicationProperties properties = new ApplicationProperties();
TotpService service = new TotpService(properties);
String uri = service.buildOtpAuthUri("[email protected]", "SECRET");
assertTrue(uri.contains("issuer=Stirling%20PDF"));
}
private String generateCode(TotpService service, byte[] secretBytes, long timeStep)
throws Exception {
Method generateCode =
TotpService.class.getDeclaredMethod("generateCode", byte[].class, long.class);
generateCode.setAccessible(true);
return (String) generateCode.invoke(service, secretBytes, timeStep);
}
}
@@ -1,22 +1,26 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
import java.sql.SQLException;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.MessageSource;
import org.springframework.security.crypto.password.PasswordEncoder;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
@@ -29,272 +33,156 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
class UserServiceTest {
@Mock private UserRepository userRepository;
@Mock private TeamRepository teamRepository;
@Mock private AuthorityRepository authorityRepository;
@Mock private PasswordEncoder passwordEncoder;
@Mock private MessageSource messageSource;
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
@Mock private SessionPersistentRegistry sessionRegistry;
@Mock private DatabaseServiceInterface databaseService;
@Mock private ApplicationProperties.Security.OAUTH2 oAuth2;
@Mock private ApplicationProperties.Security.OAUTH2 oauth2Properties;
@InjectMocks private UserService userService;
private Team mockTeam;
private User mockUser;
@BeforeEach
void setUp() {
mockTeam = new Team();
mockTeam.setId(1L);
mockTeam.setName("Test Team");
mockUser = new User();
mockUser.setId(1L);
mockUser.setUsername("testuser");
mockUser.setEnabled(true);
}
@Spy @InjectMocks private UserService userService;
@Test
void testSaveUser_WithUsernameAndAuthenticationType_Success() throws Exception {
// Given
String username = "testuser";
AuthenticationType authType = AuthenticationType.WEB;
void saveUserCore_populatesFieldsAndPersists()
throws SQLException, UnsupportedProviderException {
Long teamId = 42L;
Team team = new Team();
team.setId(teamId);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(team));
when(passwordEncoder.encode("plain")).thenReturn("encoded");
when(userRepository.save(any(User.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
SaveUserRequest request =
SaveUserRequest.builder()
.username("validUser")
.password("plain")
.ssoProviderId("sso-id")
.ssoProvider("provider-x")
.authenticationType(AuthenticationType.OAUTH2)
.teamId(teamId)
.role(Role.ADMIN.getRoleId())
.firstLogin(true)
.enabled(false)
.requireMfa(true)
.mfaEnabled(true)
.mfaSecret("top-secret")
.mfaLastUsedStep(5L)
.build();
// When
userService.saveUser(username, authType);
User saved = userService.saveUserCore(request);
// Then
verify(userRepository).save(any(User.class));
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(userCaptor.capture());
verify(databaseService).exportDatabase();
User persisted = userCaptor.getValue();
assertEquals("validUser", persisted.getUsername());
assertEquals("encoded", persisted.getPassword());
assertEquals("provider-x", persisted.getSsoProvider());
assertEquals("sso-id", persisted.getSsoProviderId());
assertEquals(team, persisted.getTeam());
assertEquals("oauth2", persisted.getAuthenticationType());
assertFalse(persisted.isEnabled());
assertTrue(persisted.isFirstLogin());
assertTrue(
persisted.getAuthorities().stream()
.anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority())));
assertEquals(
"true",
persisted.getSettings().get(MfaService.MFA_REQUIRED_KEY),
"MFA requirement should be stored");
assertEquals(
"true",
persisted.getSettings().get(MfaService.MFA_ENABLED_KEY),
"MFA enabled flag should be stored");
assertEquals(
"top-secret",
persisted.getSettings().get(MfaService.MFA_SECRET_KEY),
"MFA secret should be stored");
assertEquals(
"5",
persisted.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY),
"MFA last used step should be stored");
assertSame(saved, persisted, "Returned user should be the persisted instance");
}
@Test
void testSaveUser_WithUsernamePasswordAndTeamId_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
String encodedPassword = "encodedPassword123";
void saveUserCore_withoutTeam_usesDefaultTeam()
throws SQLException, UnsupportedProviderException {
Team defaultTeam = new Team();
defaultTeam.setName("Default");
when(teamRepository.findByName("Default")).thenReturn(Optional.of(defaultTeam));
when(userRepository.save(any(User.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
SaveUserRequest request = SaveUserRequest.builder().username("anotherUser").build();
// When
User result = userService.saveUser(username, password, teamId);
User saved = userService.saveUserCore(request);
// Then
assertNotNull(result);
verify(passwordEncoder).encode(password);
verify(teamRepository).findById(teamId);
verify(userRepository).save(any(User.class));
verify(teamRepository).findByName("Default");
verify(teamRepository, never()).findById(anyLong());
verify(databaseService).exportDatabase();
assertEquals(defaultTeam, saved.getTeam(), "Default team should be applied");
}
@Test
void testSaveUser_WithTeamAndRole_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
String role = Role.ADMIN.getRoleId();
boolean firstLogin = true;
String encodedPassword = "encodedPassword123";
void processSSOPostLogin_autoCreatesUserWhenMissing()
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
String username = "autoUser";
doReturn(true).when(userService).isUsernameValid(username);
when(userRepository.findBySsoProviderAndSsoProviderId("prov", "id"))
.thenReturn(Optional.empty());
when(userRepository.findByUsernameIgnoreCase(username)).thenReturn(Optional.empty());
User created = new User();
doReturn(created).when(userService).saveUserCore(any(SaveUserRequest.class));
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
userService.processSSOPostLogin(username, "id", "prov", true, AuthenticationType.SAML2);
// When
User result = userService.saveUser(username, password, mockTeam, role, firstLogin);
ArgumentCaptor<SaveUserRequest> reqCaptor = ArgumentCaptor.forClass(SaveUserRequest.class);
verify(userService).saveUserCore(reqCaptor.capture());
SaveUserRequest captured = reqCaptor.getValue();
// Then
assertNotNull(result);
verify(passwordEncoder).encode(password);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
assertEquals(username, captured.getUsername());
assertEquals("id", captured.getSsoProviderId());
assertEquals("prov", captured.getSsoProvider());
assertEquals(AuthenticationType.SAML2, captured.getAuthenticationType());
}
@Test
void testSaveUser_WithInvalidUsername_ThrowsException() throws Exception {
// Given
String invalidUsername = "ab"; // Too short (less than 3 characters)
AuthenticationType authType = AuthenticationType.WEB;
void addApiKeyToUserGeneratesAndPersists() {
User user = new User();
user.setUsername("user");
when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user));
when(userRepository.findByApiKey(any())).thenReturn(Optional.empty());
when(userRepository.save(any(User.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// When & Then
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(invalidUsername, authType));
User updated = userService.addApiKeyToUser("user");
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
assertNotNull(updated.getApiKey());
verify(userRepository).save(user);
}
@Test
void testSaveUser_WithNullPassword_Success() throws Exception {
// Given
String username = "testuser";
Long teamId = 1L;
void getApiKeyForUserCreatesWhenMissing() {
User user = new User();
user.setUsername("user");
when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user));
when(userRepository.findByApiKey(any())).thenReturn(Optional.empty());
when(userRepository.save(any(User.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
String apiKey = userService.getApiKeyForUser("user");
// When
User result = userService.saveUser(username, null, teamId);
// Then
assertNotNull(result);
verify(passwordEncoder, never()).encode(anyString());
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
assertNotNull(apiKey);
verify(userRepository).save(user);
}
@Test
void testSaveUser_WithEmptyPassword_Success() throws Exception {
// Given
String username = "testuser";
String emptyPassword = "";
Long teamId = 1L;
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
User result = userService.saveUser(username, emptyPassword, teamId);
// Then
assertNotNull(result);
verify(passwordEncoder, never()).encode(anyString());
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithValidEmail_Success() throws Exception {
// Given
String emailUsername = "[email protected]";
AuthenticationType authType = AuthenticationType.OAUTH2;
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
userService.saveUser(emailUsername, authType);
// Then
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithReservedUsername_ThrowsException() throws Exception {
// Given
String reservedUsername = "all_users";
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(reservedUsername, authType));
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
}
@Test
void testSaveUser_WithAnonymousUser_ThrowsException() throws Exception {
// Given
String anonymousUsername = "anonymoususer";
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(anonymousUsername, authType));
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
}
@Test
void testSaveUser_DatabaseExportThrowsException_StillSavesUser() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doThrow(new SQLException("Database export failed")).when(databaseService).exportDatabase();
// When & Then
assertThrows(SQLException.class, () -> userService.saveUser(username, password, teamId));
// Verify user was still saved before the exception
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithFirstLoginFlag_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
boolean firstLogin = true;
boolean enabled = false;
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
userService.saveUser(username, password, teamId, firstLogin, enabled);
// Then
verify(passwordEncoder).encode(password);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithCustomRole_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
String customRole = Role.LIMITED_API_USER.getRoleId();
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
userService.saveUser(username, password, teamId, customRole);
// Then
verify(passwordEncoder).encode(password);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
void isUsernameValidRejectsReservedAndAcceptsEmail() {
assertFalse(userService.isUsernameValid("ALL_USERS"));
assertTrue(userService.isUsernameValid("[email protected]"));
}
}
@@ -0,0 +1,45 @@
package stirling.software.proprietary.security.util;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class Base32CodecTest {
@Test
void encodeReturnsEmptyStringForEmptyInput() {
assertEquals("", Base32Codec.encode(new byte[0]));
assertEquals("", Base32Codec.encode(null));
}
@Test
void decodeReturnsEmptyArrayForBlankInput() {
assertArrayEquals(new byte[0], Base32Codec.decode(null));
assertArrayEquals(new byte[0], Base32Codec.decode(""));
assertArrayEquals(new byte[0], Base32Codec.decode(" "));
}
@Test
void encodeDecodeRoundTrip() {
byte[] input = "hello world".getBytes(StandardCharsets.UTF_8);
String encoded = Base32Codec.encode(input);
byte[] decoded = Base32Codec.decode(encoded);
assertArrayEquals(input, decoded);
}
@Test
void decodeAcceptsPaddingSpacesAndLowercase() {
byte[] decoded = Base32Codec.decode("mzxw6y tboi====");
assertEquals("foobar", new String(decoded, StandardCharsets.UTF_8));
}
@Test
void decodeRejectsInvalidCharacters() {
assertThrows(IllegalArgumentException.class, () -> Base32Codec.decode("MZXW6$"));
}
}