grandfather users (#4984)

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

### 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: ConnorYoh <[email protected]>
Co-authored-by: Dario Ghunney Ware <[email protected]>
This commit is contained in:
Anthony Stirling
2025-11-25 21:23:32 +00:00
committed by GitHub
co-authored by ConnorYoh Dario Ghunney Ware
parent daf749e6be
commit e6db57e031
22 changed files with 564 additions and 71 deletions
@@ -103,6 +103,9 @@ class CustomLogoutSuccessHandlerTest {
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
when(request.getParameter("maxUsersReached")).thenReturn(null);
when(request.getParameter(error)).thenReturn("true");
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
@@ -208,6 +211,9 @@ class CustomLogoutSuccessHandlerTest {
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
when(request.getParameter("maxUsersReached")).thenReturn(null);
when(request.getParameter("userIsDisabled")).thenReturn(null);
when(request.getParameter("error")).thenReturn("!@$!@£" + error + "£$%^*$");
when(request.getScheme()).thenReturn("http");
@@ -237,6 +243,9 @@ class CustomLogoutSuccessHandlerTest {
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
when(request.getParameter("oAuth2AdminBlockedUser")).thenReturn(null);
when(request.getParameter("oAuth2RequiresLicense")).thenReturn(null);
when(request.getParameter("saml2RequiresLicense")).thenReturn(null);
when(request.getParameter("maxUsersReached")).thenReturn(null);
when(request.getParameter("userIsDisabled")).thenReturn(null);
when(request.getParameter("error")).thenReturn(null);
when(request.getParameter(error)).thenReturn("true");
@@ -44,13 +44,13 @@ class LicenseKeyCheckerTest {
ApplicationProperties props = new ApplicationProperties();
props.getPremium().setEnabled(true);
props.getPremium().setKey("abc");
when(verifier.verifyLicense("abc")).thenReturn(License.PRO);
when(verifier.verifyLicense("abc")).thenReturn(License.SERVER);
LicenseKeyChecker checker =
new LicenseKeyChecker(verifier, props, userLicenseSettingsService);
checker.init();
assertEquals(License.PRO, checker.getPremiumLicenseEnabledResult());
assertEquals(License.SERVER, checker.getPremiumLicenseEnabledResult());
verify(verifier).verifyLicense("abc");
}
@@ -0,0 +1,201 @@
package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
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.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.beans.factory.ObjectProvider;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.UserLicenseSettings;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository;
import stirling.software.proprietary.security.service.UserService;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class UserLicenseSettingsServiceTest {
@Mock private UserLicenseSettingsRepository settingsRepository;
@Mock private UserService userService;
@Mock private ApplicationProperties applicationProperties;
@Mock private ApplicationProperties.Premium premium;
@Mock private LicenseKeyChecker licenseKeyChecker;
@Mock private ObjectProvider<LicenseKeyChecker> licenseKeyCheckerProvider;
private UserLicenseSettingsService service;
private UserLicenseSettings mockSettings;
@BeforeEach
void setUp() {
mockSettings = new UserLicenseSettings();
mockSettings.setId(1L);
mockSettings.setGrandfatheredUserCount(80);
mockSettings.setGrandfatheringLocked(true);
mockSettings.setIntegritySalt("test-salt");
mockSettings.setGrandfatheredUserSignature("80:test-signature");
when(applicationProperties.getPremium()).thenReturn(premium);
when(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings));
when(userService.getTotalUsersCount()).thenReturn(80L);
when(settingsRepository.save(any(UserLicenseSettings.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(licenseKeyChecker);
// Create service with overridden validateSettingsIntegrity to bypass signature validation
service =
new UserLicenseSettingsService(
settingsRepository,
userService,
applicationProperties,
licenseKeyCheckerProvider) {
@Override
public void validateSettingsIntegrity() {
// Override to do nothing in tests - avoid HMAC signature validation
// complexity
}
};
}
@Test
void noLicense_returnsGrandfatheredLimit() {
// No license active
when(premium.isEnabled()).thenReturn(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
int result = service.calculateMaxAllowedUsers();
assertEquals(80, result, "Should return grandfathered user count when no license");
}
@Test
void serverLicense_returnsUnlimited() {
// SERVER license with users=0
when(premium.isEnabled()).thenReturn(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
mockSettings.setLicenseMaxUsers(0);
int result = service.calculateMaxAllowedUsers();
assertEquals(Integer.MAX_VALUE, result, "SERVER license should return unlimited users");
}
@Test
void enterpriseLicense_returnsLicenseSeatsOnly() {
// ENTERPRISE license with 5 seats
when(premium.isEnabled()).thenReturn(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
mockSettings.setLicenseMaxUsers(5);
int result = service.calculateMaxAllowedUsers();
assertEquals(
5,
result,
"ENTERPRISE license should return license seats only (NOT grandfathered + seats)");
}
@Test
void enterpriseLicense_ignoresGrandfathering() {
// ENTERPRISE with 20 seats, grandfathered was 80
when(premium.isEnabled()).thenReturn(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
mockSettings.setLicenseMaxUsers(20);
mockSettings.setGrandfatheredUserCount(80); // This should be ignored
int result = service.calculateMaxAllowedUsers();
assertEquals(
20,
result,
"ENTERPRISE license should ignore grandfathering and use license seats only");
}
@Test
void freshInstall_noLicense_returnsFive() {
// Fresh install with default 5 users grandfathered
mockSettings.setGrandfatheredUserCount(5);
when(premium.isEnabled()).thenReturn(false);
int result = service.calculateMaxAllowedUsers();
assertEquals(5, result, "Fresh install with no license should return 5 users");
}
@Test
void freshInstall_serverLicense_returnsUnlimited() {
// Fresh install with SERVER license
mockSettings.setGrandfatheredUserCount(5);
when(premium.isEnabled()).thenReturn(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
mockSettings.setLicenseMaxUsers(0);
int result = service.calculateMaxAllowedUsers();
assertEquals(
Integer.MAX_VALUE,
result,
"Fresh install with SERVER license should return unlimited");
}
@Test
void freshInstall_enterpriseLicense_returnsLicenseSeats() {
// Fresh install with ENTERPRISE 10 seats
mockSettings.setGrandfatheredUserCount(5);
when(premium.isEnabled()).thenReturn(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
mockSettings.setLicenseMaxUsers(10);
int result = service.calculateMaxAllowedUsers();
assertEquals(
10, result, "Fresh install with ENTERPRISE license should return license seats");
}
@Test
void v1MigrationWith80Users_noLicense_returns80() {
// V1→V2 migration with 80 users, no paid license
mockSettings.setGrandfatheredUserCount(80);
when(premium.isEnabled()).thenReturn(false);
int result = service.calculateMaxAllowedUsers();
assertEquals(80, result, "V1→V2 migration should preserve 80 grandfathered users");
}
@Test
void v1MigrationWith80Users_thenEnterpriseWith5Seats_returns5() {
// V1→V2 with 80 users, then buy ENTERPRISE 5 seats
mockSettings.setGrandfatheredUserCount(80);
when(premium.isEnabled()).thenReturn(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
mockSettings.setLicenseMaxUsers(5);
int result = service.calculateMaxAllowedUsers();
assertEquals(
5, result, "ENTERPRISE 5 seats should override grandfathered 80 users (not 85)");
}
@Test
void zeroGrandfathered_fallsBackToDefault() {
// Edge case: grandfathered is 0 (should not happen)
mockSettings.setGrandfatheredUserCount(0);
when(premium.isEnabled()).thenReturn(false);
int result = service.calculateMaxAllowedUsers();
assertEquals(5, result, "Should fall back to default 5 users if grandfathered is 0");
}
}