diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java index 24326f31e3..8d9ec5a015 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java @@ -62,6 +62,7 @@ public class InitialSecuritySetup { private void initializeUserLicenseSettings() { licenseSettingsService.initializeGrandfatheredCount(); licenseSettingsService.updateLicenseMaxUsers(); + licenseSettingsService.grandfatherExistingOAuthUsers(); } private void configureJWTSettings() { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/PremiumEndpointAspect.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/PremiumEndpointAspect.java index 1ee25574bc..9b26e5b55c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/PremiumEndpointAspect.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/PremiumEndpointAspect.java @@ -23,7 +23,7 @@ public class PremiumEndpointAspect { public Object checkPremiumAccess(ProceedingJoinPoint joinPoint) throws Throwable { if (!runningProOrHigher) { throw new ResponseStatusException( - HttpStatus.FORBIDDEN, "This endpoint requires a Pro or higher license"); + HttpStatus.FORBIDDEN, "This endpoint requires a Server or Enterprise license"); } return joinPoint.proceed(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index bd6acc1b96..4646f26c18 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -84,6 +84,8 @@ public class SecurityConfiguration { private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper; private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations; private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver; + private final stirling.software.proprietary.service.UserLicenseSettingsService + licenseSettingsService; public SecurityConfiguration( PersistentLoginRepository persistentLoginRepository, @@ -103,7 +105,9 @@ public class SecurityConfiguration { @Autowired(required = false) RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations, @Autowired(required = false) - OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver) { + OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver, + stirling.software.proprietary.service.UserLicenseSettingsService + licenseSettingsService) { this.userDetailsService = userDetailsService; this.userService = userService; this.loginEnabledValue = loginEnabledValue; @@ -120,6 +124,7 @@ public class SecurityConfiguration { this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper; this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations; this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver; + this.licenseSettingsService = licenseSettingsService; } @Bean @@ -354,7 +359,8 @@ public class SecurityConfiguration { loginAttemptService, securityProperties.getOauth2(), userService, - jwtService)) + jwtService, + licenseSettingsService)) .failureHandler(new CustomOAuth2AuthenticationFailureHandler()) // Add existing Authorities from the database .userInfoEndpoint( @@ -395,7 +401,8 @@ public class SecurityConfiguration { loginAttemptService, securityProperties.getSaml2(), userService, - jwtService)) + jwtService, + licenseSettingsService)) .failureHandler( new CustomSaml2AuthenticationFailureHandler()) .authenticationRequestResolver( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java index 2e71b670d9..85a0a91db5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java @@ -32,7 +32,8 @@ public class EEAppConfig { @Bean(name = "runningProOrHigher") @Primary public boolean runningProOrHigher() { - return licenseKeyChecker.getPremiumLicenseEnabledResult() != License.NORMAL; + License license = licenseKeyChecker.getPremiumLicenseEnabledResult(); + return license == License.SERVER || license == License.ENTERPRISE; } @Profile("security") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java index d5902bcfa2..a10f395b4b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifier.java @@ -30,7 +30,7 @@ public class KeygenLicenseVerifier { public enum License { NORMAL, - PRO, + SERVER, ENTERPRISE } @@ -76,7 +76,7 @@ public class KeygenLicenseVerifier { log.info("Detected certificate-based license. Processing..."); boolean isValid = verifyCertificateLicense(licenseKeyOrCert, context); if (isValid) { - license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO; + license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER; } else { license = License.NORMAL; } @@ -84,7 +84,7 @@ public class KeygenLicenseVerifier { log.info("Detected JWT-style license key. Processing..."); boolean isValid = verifyJWTLicense(licenseKeyOrCert, context); if (isValid) { - license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO; + license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER; } else { license = License.NORMAL; } @@ -92,7 +92,7 @@ public class KeygenLicenseVerifier { log.info("Detected standard license key. Processing..."); boolean isValid = verifyStandardLicense(licenseKeyOrCert, context); if (isValid) { - license = context.isEnterpriseLicense ? License.ENTERPRISE : License.PRO; + license = context.isEnterpriseLicense ? License.ENTERPRISE : License.SERVER; } else { license = License.NORMAL; } @@ -261,10 +261,23 @@ public class KeygenLicenseVerifier { // Extract metadata JSONObject metadataObj = attributesObj.optJSONObject("metadata"); if (metadataObj != null) { - int users = metadataObj.optInt("users", 1); - applicationProperties.getPremium().setMaxUsers(users); - log.info("License allows for {} users", users); + // Check if this is an old license (no planType) with isEnterprise flag context.isEnterpriseLicense = metadataObj.optBoolean("isEnterprise", false); + + // Extract user count - default based on license type + // Old licenses: Only had isEnterprise flag + // New licenses: Have planType field with "server" or "enterprise" + int users = metadataObj.optInt("users", context.isEnterpriseLicense ? 1 : 0); + + // SERVER license (isEnterprise=false, users=0) = unlimited + // ENTERPRISE license (isEnterprise=true, users>0) = limited seats + if (!context.isEnterpriseLicense && users == 0) { + log.info("SERVER license detected with unlimited users"); + } else if (context.isEnterpriseLicense && users > 0) { + log.info("ENTERPRISE license allows for {} users", users); + } + + applicationProperties.getPremium().setMaxUsers(users); } // Check license status if available @@ -431,28 +444,31 @@ public class KeygenLicenseVerifier { } // Extract max users and isEnterprise from policy or metadata - int users = policyObj.optInt("users", 1); context.isEnterpriseLicense = policyObj.optBoolean("isEnterprise", false); + int users = policyObj.optInt("users", -1); - if (users > 0) { - applicationProperties.getPremium().setMaxUsers(users); - log.info("License allows for {} users", users); - } else { - // Try to get users from metadata if present + if (users == -1) { + // Try to get users from metadata if not at policy level Object metadataObj = policyObj.opt("metadata"); if (metadataObj instanceof JSONObject metadata) { - users = metadata.optInt("users", 1); - applicationProperties.getPremium().setMaxUsers(users); - log.info("License allows for {} users (from metadata)", users); - - // Check for isEnterprise flag in metadata - context.isEnterpriseLicense = metadata.optBoolean("isEnterprise", false); + context.isEnterpriseLicense = + metadata.optBoolean("isEnterprise", context.isEnterpriseLicense); + users = metadata.optInt("users", context.isEnterpriseLicense ? 1 : 0); } else { - // Default value - applicationProperties.getPremium().setMaxUsers(1); - log.info("Using default of 1 user for license"); + // Default based on license type + users = context.isEnterpriseLicense ? 1 : 0; } } + + // SERVER license (isEnterprise=false, users=0) = unlimited + // ENTERPRISE license (isEnterprise=true, users>0) = limited seats + if (!context.isEnterpriseLicense && users == 0) { + log.info("SERVER license detected with unlimited users"); + } else if (context.isEnterpriseLicense && users > 0) { + log.info("ENTERPRISE license allows for {} users", users); + } + + applicationProperties.getPremium().setMaxUsers(users); } return true; @@ -584,17 +600,7 @@ public class KeygenLicenseVerifier { context.maxMachines); } - // Extract user count, default to 1 if not specified - int users = - jsonResponse - .path("data") - .path("attributes") - .path("metadata") - .path("users") - .asInt(1); - applicationProperties.getPremium().setMaxUsers(users); - - // Extract isEnterprise flag + // Extract isEnterprise flag first context.isEnterpriseLicense = jsonResponse .path("data") @@ -603,6 +609,24 @@ public class KeygenLicenseVerifier { .path("isEnterprise") .asBoolean(false); + // Extract user count - default based on license type + int users = + jsonResponse + .path("data") + .path("attributes") + .path("metadata") + .path("users") + .asInt(context.isEnterpriseLicense ? 1 : 0); + + // SERVER license (isEnterprise=false, users=0) = unlimited + // ENTERPRISE license (isEnterprise=true, users>0) = limited seats + if (!context.isEnterpriseLicense && users == 0) { + log.info("SERVER license detected with unlimited users"); + } else if (context.isEnterpriseLicense && users > 0) { + log.info("ENTERPRISE license allows for {} users", users); + } + + applicationProperties.getPremium().setMaxUsers(users); log.debug(applicationProperties.toString()); } else { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java index 050a565c24..3e9a8a4131 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java @@ -68,8 +68,8 @@ public class LicenseKeyChecker { premiumEnabledResult = licenseService.verifyLicense(licenseKey); if (License.ENTERPRISE == premiumEnabledResult) { log.info("License key is Enterprise."); - } else if (License.PRO == premiumEnabledResult) { - log.info("License key is Pro."); + } else if (License.SERVER == premiumEnabledResult) { + log.info("License key is Server."); } else { log.info("License key is invalid, defaulting to non pro license."); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java index 36fa23a6aa..e96d3f03f2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/UserRepository.java @@ -39,4 +39,11 @@ public interface UserRepository extends JpaRepository { long countByTeam(Team team); List findAllByTeam(Team team); + + // OAuth grandfathering queries + long countBySsoProviderIsNotNull(); + + long countByOauthGrandfatheredTrue(); + + List findAllBySsoProviderIsNotNull(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index 8f64d3187f..8207eae28b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -71,6 +71,9 @@ public class User implements UserDetails, Serializable { @Column(name = "sso_provider") private String ssoProvider; + @Column(name = "oauth_grandfathered") + private Boolean oauthGrandfathered = false; + @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user") private Set authorities = new HashSet<>(); @@ -135,4 +138,12 @@ public class User implements UserDetails, Serializable { public boolean hasPassword() { return this.password != null && !this.password.isEmpty(); } + + public boolean isOauthGrandfathered() { + return oauthGrandfathered != null && oauthGrandfathered; + } + + public void setOauthGrandfathered(boolean oauthGrandfathered) { + this.oauthGrandfathered = oauthGrandfathered; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index d2e03a04e2..4576063375 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -50,6 +50,8 @@ public class CustomOAuth2AuthenticationSuccessHandler private final ApplicationProperties.Security.OAUTH2 oauth2Properties; private final UserService userService; private final JwtServiceInterface jwtService; + private final stirling.software.proprietary.service.UserLicenseSettingsService + licenseSettingsService; @Override @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) @@ -66,6 +68,19 @@ public class CustomOAuth2AuthenticationSuccessHandler username = detailsUser.getUsername(); } + // Check if user is eligible for OAuth (grandfathered or system has paid license) + if (userService.usernameExistsIgnoreCase(username)) { + stirling.software.proprietary.security.model.User user = + userService.findByUsernameIgnoreCase(username).orElse(null); + + if (user != null && !licenseSettingsService.isOAuthEligible(user)) { + // User is not grandfathered and no paid license - block OAuth login + response.sendRedirect( + request.getContextPath() + "/logout?oAuth2RequiresLicense=true"); + return; + } + } + // Get the saved request HttpSession session = request.getSession(false); String contextPath = request.getContextPath(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index af6d284cf1..4786258682 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -49,6 +49,8 @@ public class CustomSaml2AuthenticationSuccessHandler private ApplicationProperties.Security.SAML2 saml2Properties; private UserService userService; private final JwtServiceInterface jwtService; + private final stirling.software.proprietary.service.UserLicenseSettingsService + licenseSettingsService; @Override @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) @@ -63,6 +65,19 @@ public class CustomSaml2AuthenticationSuccessHandler String username = saml2Principal.name(); log.debug("Authenticated principal found for user: {}", username); + // Check if user is eligible for SAML (grandfathered or system has paid license) + if (userService.usernameExistsIgnoreCase(username)) { + stirling.software.proprietary.security.model.User user = + userService.findByUsernameIgnoreCase(username).orElse(null); + + if (user != null && !licenseSettingsService.isOAuthEligible(user)) { + // User is not grandfathered and no paid license - block SAML login + response.sendRedirect( + request.getContextPath() + "/logout?saml2RequiresLicense=true"); + return; + } + } + HttpSession session = request.getSession(false); String contextPath = request.getContextPath(); SavedRequest savedRequest = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 4772368f88..3a641d7645 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -734,4 +734,47 @@ public class UserService implements UserServiceInterface { public void saveAll(List users) { userRepository.saveAll(users); } + + /** + * Counts the number of OAuth/SAML users (users with SSO provider set). + * + * @return Count of OAuth users + */ + public long countOAuthUsers() { + return userRepository.countBySsoProviderIsNotNull(); + } + + /** + * Counts the number of OAuth users who are grandfathered. + * + * @return Count of grandfathered OAuth users + */ + public long countGrandfatheredOAuthUsers() { + return userRepository.countByOauthGrandfatheredTrue(); + } + + /** + * Grandfathers all existing OAuth/SAML users. This marks all users with an SSO provider as + * grandfathered, allowing them to keep OAuth access even without a paid license. + * + * @return Number of users updated + */ + @Transactional + public int grandfatherAllOAuthUsers() { + List oauthUsers = userRepository.findAllBySsoProviderIsNotNull(); + int updated = 0; + + for (User user : oauthUsers) { + if (!user.isOauthGrandfathered()) { + user.setOauthGrandfathered(true); + updated++; + } + } + + if (updated > 0) { + userRepository.saveAll(oauthUsers); + } + + return updated; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java index aca98eb75f..01db79a5d8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/ServerCertificateService.java @@ -69,7 +69,7 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa private boolean hasProOrEnterpriseAccess() { License license = licenseKeyChecker.getPremiumLicenseEnabledResult(); - return license == License.PRO || license == License.ENTERPRISE; + return license == License.SERVER || license == License.ENTERPRISE; } public boolean isEnabled() { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java index 44efce960f..6301f198c9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java @@ -162,6 +162,40 @@ public class UserLicenseSettingsService { } } + /** + * Grandfathers existing OAuth users on first run. This is a one-time migration that marks all + * existing OAuth/SAML users as grandfathered, allowing them to keep OAuth access even without a + * paid license. + * + *

New users created after this migration will NOT be grandfathered and will require a paid + * license to use OAuth. + */ + @Transactional + public void grandfatherExistingOAuthUsers() { + UserLicenseSettings settings = getOrCreateSettings(); + + // Check if we've already run this migration + if (settings.getId() != null && settings.isGrandfatheringLocked()) { + // Migration should happen at the same time as grandfathering is locked + long oauthUsersCount = userService.countOAuthUsers(); + long grandfatheredCount = userService.countGrandfatheredOAuthUsers(); + + if (oauthUsersCount > 0 && grandfatheredCount == 0) { + // We have OAuth users but none are grandfathered - this is first run after upgrade + int updated = userService.grandfatherAllOAuthUsers(); + log.warn( + "OAuth GRANDFATHERING: Marked {} existing OAuth/SAML users as grandfathered. " + + "They will retain OAuth access even without a paid license. " + + "New users will require a paid license for OAuth.", + updated); + } else if (grandfatheredCount > 0) { + log.debug( + "OAuth grandfathering already completed: {} users grandfathered", + grandfatheredCount); + } + } + } + /** * Validates and enforces the integrity of license settings. This ensures that even if someone * manually modifies the database, the grandfathering rules are still enforced. @@ -241,12 +275,15 @@ public class UserLicenseSettingsService { *

Logic: * *

    - *
  • Grandfathered limit = max(5, existing user count at initialization) - *
  • If premium enabled: total limit = grandfathered limit + license maxUsers - *
  • If premium disabled: total limit = grandfathered limit + *
  • Grandfathered limit = max(5, existing user count at V1→V2 migration) + *
  • No license: Uses grandfathered limit only + *
  • SERVER license (maxUsers=0): Unlimited users (Integer.MAX_VALUE) + *
  • ENTERPRISE license (maxUsers>0): License seats only (NO grandfathering added) *
* - * @return Maximum number of users allowed + *

IMPORTANT: Paid licenses REPLACE the limit, they don't add to grandfathering. + * + * @return Maximum number of users allowed (Integer.MAX_VALUE for unlimited) */ public int calculateMaxAllowedUsers() { validateSettingsIntegrity(); @@ -259,20 +296,52 @@ public class UserLicenseSettingsService { grandfatheredLimit = DEFAULT_USER_LIMIT; } - int totalLimit = grandfatheredLimit; - - if (applicationProperties.getPremium().isEnabled()) { - totalLimit = grandfatheredLimit + settings.getLicenseMaxUsers(); + // No license: use grandfathered limit + if (!applicationProperties.getPremium().isEnabled()) { + log.debug("No license: using grandfathered limit of {}", grandfatheredLimit); + return grandfatheredLimit; } - log.debug( - "Calculated max allowed users: {} (grandfathered: {}, license: {}, premium enabled: {})", - totalLimit, - grandfatheredLimit, - settings.getLicenseMaxUsers(), - applicationProperties.getPremium().isEnabled()); + int licenseMaxUsers = settings.getLicenseMaxUsers(); - return totalLimit; + // SERVER license (maxUsers=0): unlimited users + if (licenseMaxUsers == 0) { + log.debug("SERVER license: unlimited users allowed"); + return Integer.MAX_VALUE; + } + + // ENTERPRISE license (maxUsers>0): license seats only (replaces grandfathering) + log.debug( + "ENTERPRISE license: {} seats (grandfathered {} not added)", + licenseMaxUsers, + grandfatheredLimit); + return licenseMaxUsers; + } + + /** + * Checks if a user is eligible to use OAuth/SAML authentication. + * + *

A user is eligible if: + * + *

    + *
  • They are grandfathered for OAuth (existing user before policy change), OR + *
  • The system has a paid license (SERVER or ENTERPRISE) + *
+ * + * @param user The user to check + * @return true if the user can use OAuth/SAML + */ + public boolean isOAuthEligible(stirling.software.proprietary.security.model.User user) { + // Grandfathered users always have OAuth access + if (user != null && user.isOauthGrandfathered()) { + log.debug("User {} is grandfathered for OAuth", user.getUsername()); + return true; + } + + // Users can use OAuth if system has a paid license (SERVER or ENTERPRISE) + boolean hasPaidLicense = applicationProperties.getPremium().isEnabled(); + log.debug("OAuth eligibility check: hasPaidLicense={}", hasPaidLicense); + return hasPaidLicense; } /** diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyCheckerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyCheckerTest.java index 136bec9e50..70dd809e43 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyCheckerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyCheckerTest.java @@ -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"); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java new file mode 100644 index 0000000000..b38a4ebb05 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java @@ -0,0 +1,184 @@ +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 stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.model.UserLicenseSettings; +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; + + 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)); + + // Create service with overridden validateSettingsIntegrity to bypass signature validation + service = + new UserLicenseSettingsService( + settingsRepository, userService, applicationProperties) { + @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); + + 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); + 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); + 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); + 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); + 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); + 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); + 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"); + } +}