perf(portal): collapse admin-roster N+1 + session indexes (#7008)

# Description of Changes

Collapses the portal admin-roster endpoint (`getAdminSettingsData`,
`/api/v1/proprietary/ui-data/admin-settings`) from a per-user N+1 into a
constant set of queries, and adds the missing
session/user/team-membership indexes.

**Verified on H2 and real Postgres 16, 2,000-user roster:** 10,601 → 7
SQL statements, 600 → 0 writes-during-a-GET, O(N) → O(1). Portal-access
resolution is proven equivalent to the per-user check (parity test), and
a scaling guard fails the build if the endpoint ever regresses.

Also in scope (same controller / session subsystem): `getLoginData`
counts instead of loading the whole user table; `getTeamDetailsData`
fetch-joins authorities; `SessionScheduled` uses one bulk expire + a
bounded purge.

Behaviour note: the roster "active" flag now reflects *any* live session
(a strict superset of the old "newest session only") — no user who was
active is ever shown inactive.

---

## Checklist

### General

- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Testing

- [x] I have run backend `task check` (spotless + full backend test
suite) — all green
- [x] I have tested my changes locally (before/after benchmark on H2 +
Postgres)
This commit is contained in:
Anthony Stirling
2026-07-16 12:00:02 +00:00
committed by GitHub
parent 2118de0bb7
commit 939afef14f
18 changed files with 941 additions and 128 deletions
@@ -72,6 +72,8 @@ spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=false
spring.jpa.hibernate.ddl-auto=update
# Batch associations into IN() loads so list endpoints don't N+1 as tables grow.
spring.jpa.properties.hibernate.default_batch_fetch_size=100
# Defer datasource initialization to ensure that the database is fully set up
# before Hibernate attempts to access it. This is particularly useful when
# using database initialization scripts or tools.
+1
View File
@@ -103,6 +103,7 @@ dependencies {
testImplementation "org.testcontainers:testcontainers:${testcontainersMinioVersion}"
testImplementation "org.testcontainers:minio:${testcontainersMinioVersion}"
testImplementation "org.testcontainers:localstack:${testcontainersMinioVersion}"
testImplementation "org.testcontainers:postgresql:${testcontainersMinioVersion}"
testImplementation "org.testcontainers:junit-jupiter:${testcontainersMinioVersion}"
}
@@ -1,5 +1,6 @@
package stirling.software.proprietary.access.service;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@@ -42,6 +43,47 @@ public class ResourceAccessService {
return canUseResource(ResourceType.PORTAL, "", null, portalDefaultPolicy, user);
}
/** Portal access for a roster (admin, grant, or default policy). */
public Set<Long> usersWithPortalAccess(Collection<User> users, Set<Long> teamLeaderUserIds) {
Set<PrincipalRef> grantedPrincipals = new HashSet<>();
for (ResourceGrant g :
grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, "")) {
if (permissionSatisfies(g.getPermission(), AccessPermission.USE)) {
grantedPrincipals.add(new PrincipalRef(g.getPrincipalType(), g.getPrincipalId()));
}
}
Set<Long> leaderIds = teamLeaderUserIds == null ? Set.of() : teamLeaderUserIds;
Set<Long> allowed = new HashSet<>();
for (User user : users) {
if (user != null
&& user.getId() != null
&& hasPortalAccess(user, grantedPrincipals, leaderIds)) {
allowed.add(user.getId());
}
}
return allowed;
}
private boolean hasPortalAccess(
User user, Set<PrincipalRef> grantedPrincipals, Set<Long> leaderIds) {
if (isAdmin(user)) {
return true;
}
for (PrincipalRef principal : principalResolver.principalsOf(user)) {
if (grantedPrincipals.contains(principal)) {
return true;
}
}
if (portalDefaultPolicy == null) {
return false;
}
return switch (portalDefaultPolicy) {
case ORG_ALL -> principalResolver.allowsDeploymentWideAccess();
case ADMINS_AND_TEAM_LEADS -> leaderIds.contains(user.getId());
case EXPLICIT_ONLY -> false;
};
}
/** Whether the user may use a resource, falling back to its default policy. */
public boolean canUseResource(
ResourceType type,
@@ -3,7 +3,6 @@ package stirling.software.proprietary.controller.api;
import static stirling.software.common.util.ProviderUtils.validateProvider;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
@@ -45,7 +44,6 @@ import stirling.software.proprietary.security.config.EnterpriseEndpoint;
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.SessionEntity;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.dto.AdminUserSummary;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
@@ -169,16 +167,8 @@ public class ProprietaryUIDataController {
boolean isFirstTimeSetup = false;
boolean showDefaultCredentials = false;
List<User> allUsers = userRepository.findAll();
List<User> realUsers =
allUsers.stream()
.filter(
user ->
!Role.INTERNAL_API_USER
.getRoleId()
.equals(user.getUsername()))
.toList();
long userCount = realUsers.size();
// Count real users, excluding the internal API user.
long userCount = userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId());
if (userCount == 0) {
isFirstTimeSetup = true;
@@ -265,92 +255,67 @@ public class ProprietaryUIDataController {
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Get admin settings data")
public ResponseEntity<AdminSettingsData> getAdminSettingsData(Authentication authentication) {
List<User> allUsers = userRepository.findAllWithTeam();
Iterator<User> iterator = allUsers.iterator();
List<User> allUsers = userRepository.findAllWithTeamAndAuthorities();
Map<String, String> roleDetails = Role.getAllRoleDetails();
// Drop the internal API user and internal-team members; the roster never shows them.
boolean hasInternalApiUser = false;
List<User> visibleUsers = new ArrayList<>(allUsers.size());
for (User user : allUsers) {
if (user == null) {
continue;
}
if (isInternalApiUser(user)) {
hasInternalApiUser = true;
continue;
}
if (user.getTeam() != null
&& TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
continue;
}
visibleUsers.add(user);
}
if (hasInternalApiUser) {
roleDetails.remove(Role.INTERNAL_API_USER.getRoleId());
}
// All users' settings in one query (mfaSecret masked).
Map<Long, Map<String, String>> settingsByUserId =
loadSettingsByUserId(visibleUsers.stream().map(User::getId).toList());
// Active = any non-expired session within the inactivity window; expiry is left to
// SessionScheduled.
int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval();
Instant activeCutoff = Instant.now().minusSeconds(maxInactiveInterval);
Map<String, Instant> lastRequestByPrincipal = new HashMap<>();
for (Object[] row : sessionRepository.findLatestRequestPerPrincipal()) {
if (row[0] != null) {
lastRequestByPrincipal.put((String) row[0], (Instant) row[1]);
}
}
Set<String> activePrincipals =
new HashSet<>(sessionRepository.findActivePrincipalsSince(activeCutoff));
Map<String, Boolean> userSessions = new HashMap<>();
Map<String, Date> userLastRequest = new HashMap<>();
Map<String, Map<String, String>> userSettings = new HashMap<>();
int activeUsers = 0;
int disabledUsers = 0;
while (iterator.hasNext()) {
User user = iterator.next();
if (user != null) {
String username = user.getUsername();
boolean shouldRemove = false;
// Check if user is an INTERNAL_API_USER
for (Authority authority : user.getAuthorities()) {
if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) {
shouldRemove = true;
roleDetails.remove(Role.INTERNAL_API_USER.getRoleId());
break;
}
}
// Check if user is part of the Internal team
if (user.getTeam() != null
&& TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
shouldRemove = true;
}
if (shouldRemove) {
iterator.remove();
continue;
}
// Session status and last request time
int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval();
boolean hasActiveSession = false;
Date lastRequest = null;
Optional<SessionEntity> latestSession =
sessionPersistentRegistry.findLatestSession(username);
if (latestSession.isPresent()) {
SessionEntity sessionEntity = latestSession.get();
Instant lastAccessedTime =
Optional.ofNullable(sessionEntity.getLastRequest())
.orElse(Instant.EPOCH);
Instant now = Instant.now();
Instant expirationTime =
lastAccessedTime.plus(maxInactiveInterval, ChronoUnit.SECONDS);
if (now.isAfter(expirationTime)) {
sessionPersistentRegistry.expireSession(sessionEntity.getSessionId());
} else {
hasActiveSession = !sessionEntity.isExpired();
}
lastRequest = Date.from(lastAccessedTime);
} else {
lastRequest = new Date(0);
}
User userWithSettings =
userRepository.findByIdWithSettings(user.getId()).orElse(user);
// Mask mfaSecret if present in settings
Map<String, String> originalSettings = userWithSettings.getSettings();
Map<String, String> settingsCopy =
originalSettings != null
? new HashMap<>(originalSettings)
: new HashMap<>();
if (settingsCopy.containsKey("mfaSecret")) {
settingsCopy.put("mfaSecret", "********");
}
userSettings.put(username, settingsCopy);
userSessions.put(username, hasActiveSession);
userLastRequest.put(username, lastRequest);
if (hasActiveSession) activeUsers++;
if (!user.isEnabled()) disabledUsers++;
}
for (User user : visibleUsers) {
String username = user.getUsername();
boolean hasActiveSession = activePrincipals.contains(username);
Instant lastRequest = lastRequestByPrincipal.get(username);
userSessions.put(username, hasActiveSession);
userLastRequest.put(
username, lastRequest != null ? Date.from(lastRequest) : new Date(0));
userSettings.put(username, maskSecrets(settingsByUserId.get(user.getId())));
if (hasActiveSession) activeUsers++;
if (!user.isEnabled()) disabledUsers++;
}
// Sort users by active status and last request date
List<User> sortedUsers =
allUsers.stream()
visibleUsers.stream()
.sorted(
(u1, u2) -> {
boolean u1Active = userSessions.get(u1.getUsername());
@@ -380,11 +345,13 @@ public class ProprietaryUIDataController {
int licenseMaxUsers = licenseSettingsService.getSettings().getLicenseMaxUsers();
boolean premiumEnabled = applicationProperties.getPremium().isEnabled();
// Convert User entities to AdminUserSummary DTOs to exclude sensitive fields
// Resolve portal access for the whole roster.
Set<Long> leaderUserIds = leaderUserIds();
Set<Long> portalAccessUserIds =
resourceAccessService.usersWithPortalAccess(sortedUsers, leaderUserIds);
List<AdminUserSummary> userSummaries =
sortedUsers.stream()
.map(user -> convertUserToSummary(user, leaderUserIds))
.map(user -> convertUserToSummary(user, leaderUserIds, portalAccessUserIds))
.toList();
AdminSettingsData data = new AdminSettingsData();
@@ -393,7 +360,7 @@ public class ProprietaryUIDataController {
data.setRoleDetails(roleDetails);
data.setUserSessions(userSessions);
data.setUserLastRequest(userLastRequest);
data.setTotalUsers(allUsers.size());
data.setTotalUsers(visibleUsers.size());
data.setActiveUsers(activeUsers);
data.setDisabledUsers(disabledUsers);
data.setTeams(allTeams);
@@ -516,7 +483,8 @@ public class ProprietaryUIDataController {
}
List<User> teamUsers = userRepository.findAllByTeamId(id);
List<User> allUsers = userRepository.findAllWithTeam();
// Fetch authorities + team for the available-users list.
List<User> allUsers = userRepository.findAllWithTeamAndAuthorities();
List<User> availableUsers =
allUsers.stream()
.filter(
@@ -575,17 +543,48 @@ public class ProprietaryUIDataController {
.collect(Collectors.toSet());
}
/** Whether the user holds the internal-API authority (never shown in the roster). */
private boolean isInternalApiUser(User user) {
for (Authority authority : user.getAuthorities()) {
if (Role.INTERNAL_API_USER.getRoleId().equals(authority.getAuthority())) {
return true;
}
}
return false;
}
/** Assemble per-user settings maps from the flat (id, key, value) rows of one bulk query. */
private Map<Long, Map<String, String>> loadSettingsByUserId(List<Long> userIds) {
Map<Long, Map<String, String>> byUser = new HashMap<>();
if (userIds.isEmpty()) {
return byUser;
}
for (Object[] row : userRepository.findSettingsByUserIds(userIds)) {
byUser.computeIfAbsent((Long) row[0], id -> new HashMap<>())
.put((String) row[1], (String) row[2]);
}
return byUser;
}
/** Copy a settings map with mfaSecret masked; null-safe. */
private Map<String, String> maskSecrets(Map<String, String> settings) {
Map<String, String> copy = settings != null ? new HashMap<>(settings) : new HashMap<>();
if (copy.containsKey("mfaSecret")) {
copy.put("mfaSecret", "********");
}
return copy;
}
/**
* Convert User entity to AdminUserSummary DTO, excluding sensitive fields like password and
* apiKey.
* Convert a User to AdminUserSummary (excludes sensitive fields); portal access is passed in.
*/
private AdminUserSummary convertUserToSummary(User user, Set<Long> leaderUserIds) {
private AdminUserSummary convertUserToSummary(
User user, Set<Long> leaderUserIds, Set<Long> portalAccessUserIds) {
AdminUserSummary summary = new AdminUserSummary();
summary.setId(user.getId());
summary.setTeamLead(leaderUserIds.contains(user.getId()));
// Authoritative portal access, same call /me uses, so the roster honors the configured
// policy instead of the frontend guessing from role/team-leadership.
summary.setPortalAccess(resourceAccessService.canAccessPortal(user));
// Portal access (same policy /me uses).
summary.setPortalAccess(portalAccessUserIds.contains(user.getId()));
summary.setUsername(user.getUsername());
summary.setEmail(user.getUsername()); // Use username as email for consistency
summary.setRoleName(user.getRoleName());
@@ -24,7 +24,17 @@ import stirling.software.proprietary.security.model.User;
@Entity
@Table(
name = "team_memberships",
uniqueConstraints = {@UniqueConstraint(columnNames = {"team_id", "user_id"})})
uniqueConstraints = {@UniqueConstraint(columnNames = {"team_id", "user_id"})},
// Match the saas migration names so ddl-auto skips them on saas (index already there) and
// only creates them on self-hosted, which has no migrations.
indexes = {
@Index(
name = "idx_team_memberships_user_role",
columnList = "user_id, role"), // leader-set lookups
@Index(
name = "idx_team_memberships_team_role",
columnList = "team_id, role") // per-team member lists
})
@NoArgsConstructor
@Getter
@Setter
@@ -45,4 +45,29 @@ public interface SessionRepository extends JpaRepository<SessionEntity, String>
+ "WHERE u.team.id = :teamId "
+ "GROUP BY u.username")
List<Object[]> findLatestSessionByTeamId(@Param("teamId") Long teamId);
/** Latest request instant per principal. */
@Query(
"SELECT s.principalName, MAX(s.lastRequest) FROM SessionEntity s GROUP BY s.principalName")
List<Object[]> findLatestRequestPerPrincipal();
/** Principals with a live (non-expired, within-window) session. */
@Query(
"SELECT DISTINCT s.principalName FROM SessionEntity s "
+ "WHERE s.expired = false AND s.lastRequest > :cutoff")
List<String> findActivePrincipalsSince(@Param("cutoff") Instant cutoff);
/** Flag timed-out sessions as expired. */
@Modifying
@Transactional
@Query(
"UPDATE SessionEntity s SET s.expired = true "
+ "WHERE s.expired = false AND s.lastRequest < :cutoff")
int expireOlderThan(@Param("cutoff") Instant cutoff);
/** Purge long-expired sessions to bound table growth. */
@Modifying
@Transactional
@Query("DELETE FROM SessionEntity s WHERE s.expired = true AND s.lastRequest < :cutoff")
int deleteExpiredOlderThan(@Param("cutoff") Instant cutoff);
}
@@ -1,11 +1,13 @@
package stirling.software.proprietary.security.database.repository;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Stream;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
@@ -43,6 +45,15 @@ public interface UserRepository extends JpaRepository<User, Long> {
@Query(value = "SELECT u FROM User u LEFT JOIN FETCH u.team")
List<User> findAllWithTeam();
/** All users with team + authorities fetched (DISTINCT dedupes the collection join). */
@EntityGraph(attributePaths = {"team", "authorities"})
@Query("SELECT DISTINCT u FROM User u")
List<User> findAllWithTeamAndAuthorities();
/** (userId, key, value) settings rows for the given users. */
@Query("SELECT u.id, KEY(s), VALUE(s) FROM User u JOIN u.settings s WHERE u.id IN :ids")
List<Object[]> findSettingsByUserIds(@Param("ids") Collection<Long> ids);
@Query(
"SELECT u FROM User u JOIN FETCH u.authorities JOIN FETCH u.team WHERE u.team.id = :teamId")
List<User> findAllByTeamId(@Param("teamId") Long teamId);
@@ -11,6 +11,7 @@ import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@@ -19,7 +20,10 @@ import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "authorities")
@Table(
name = "authorities",
// index the FK: authorities load by user_id
indexes = @Index(name = "idx_authorities_user_id", columnList = "user_id"))
@Getter
@Setter
public class Authority implements GrantedAuthority, Serializable {
@@ -5,13 +5,23 @@ import java.time.Instant;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import lombok.Data;
@Entity
@Data
@Table(name = "sessions")
@Table(
name = "sessions",
indexes = {
// per-principal session/activity lookups
@Index(
name = "idx_sessions_principal_last",
columnList = "principal_name, last_request"),
// scheduled expiry/purge scan
@Index(name = "idx_sessions_expired", columnList = "expired")
})
public class SessionEntity implements Serializable {
@Id private String sessionId;
@@ -28,7 +28,10 @@ import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
@Entity
@Table(name = "users")
@Table(
name = "users",
// team_id backs Team.users joins, the admin roster fetch, and per-team user counts.
indexes = @Index(name = "idx_users_team_id", columnList = "team_id"))
@NoArgsConstructor
@Getter
@Setter
@@ -146,6 +146,16 @@ public class SessionPersistentRegistry implements SessionRegistry {
return sessionRepository.findAll();
}
// Flag every session idle past the timeout.
public int expireStaleSessions() {
return sessionRepository.expireOlderThan(Instant.now().minus(defaultMaxInactiveInterval));
}
// Purge sessions expired longer than the retention window.
public int purgeExpiredSessions(Duration retention) {
return sessionRepository.deleteExpiredOlderThan(Instant.now().minus(retention));
}
// Mark a session as expired
public void expireSession(String sessionId) {
Optional<SessionEntity> sessionEntityOpt = sessionRepository.findById(sessionId);
@@ -1,12 +1,8 @@
package stirling.software.proprietary.security.session;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
import java.time.Duration;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.security.core.session.SessionInformation;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -15,23 +11,15 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class SessionScheduled {
// Retention before an expired session is purged.
private static final Duration EXPIRED_SESSION_RETENTION = Duration.ofDays(30);
private final SessionPersistentRegistry sessionPersistentRegistry;
@Scheduled(cron = "0 0/5 * * * ?")
public void expireSessions() {
Instant now = Instant.now();
for (Object principal : sessionPersistentRegistry.getAllPrincipals()) {
List<SessionInformation> sessionInformations =
sessionPersistentRegistry.getAllSessions(principal, false);
for (SessionInformation sessionInformation : sessionInformations) {
Date lastRequest = sessionInformation.getLastRequest();
int maxInactiveInterval = sessionPersistentRegistry.getMaxInactiveInterval();
Instant expirationTime =
lastRequest.toInstant().plus(maxInactiveInterval, ChronoUnit.SECONDS);
if (now.isAfter(expirationTime)) {
sessionPersistentRegistry.expireSession(sessionInformation.getSessionId());
}
}
}
// Flag timed-out sessions, then purge long-dead ones.
sessionPersistentRegistry.expireStaleSessions();
sessionPersistentRegistry.purgeExpiredSessions(EXPIRED_SESSION_RETENTION);
}
}
@@ -0,0 +1,151 @@
package stirling.software.proprietary.access.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.lenient;
import java.util.List;
import java.util.Set;
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.util.ReflectionTestUtils;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.access.model.AccessPermission;
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
import stirling.software.proprietary.access.model.PrincipalRef;
import stirling.software.proprietary.access.model.PrincipalType;
import stirling.software.proprietary.access.model.ResourceGrant;
import stirling.software.proprietary.access.model.ResourceType;
import stirling.software.proprietary.access.repository.ResourceGrantRepository;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
/** Bulk portal-access must match per-user canAccessPortal for every policy. */
@ExtendWith(MockitoExtension.class)
class ResourceAccessPortalBulkParityTest {
@Mock private ResourceGrantRepository grantRepository;
@Mock private TeamLeadLookup teamLeadLookup;
private ResourceAccessService service;
private User admin;
private User leader;
private User userGrantHolder;
private User teamGrantMember;
private User plainMember;
private List<User> everyone;
private Set<Long> leaderUserIds;
void setUp(DefaultAccessPolicy policy) {
service =
new ResourceAccessService(
grantRepository, teamLeadLookup, new DefaultPrincipalResolver());
ReflectionTestUtils.setField(service, "portalDefaultPolicy", policy);
admin = user(1L, null, Role.ADMIN.getRoleId());
leader = user(2L, 10L, Role.USER.getRoleId());
userGrantHolder = user(3L, null, Role.USER.getRoleId());
teamGrantMember = user(4L, 20L, Role.USER.getRoleId());
plainMember = user(5L, 10L, Role.USER.getRoleId());
everyone = List.of(admin, leader, userGrantHolder, teamGrantMember, plainMember);
// Grants: a USER grant to #3 and a TEAM grant to team 20 (which #4 belongs to).
lenient()
.when(grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, ""))
.thenReturn(
List.of(
grant(PrincipalType.USER, 3L, AccessPermission.USE),
grant(PrincipalType.TEAM, 20L, AccessPermission.USE)));
// Only #2 leads a team; leaderUserIds is what the controller passes to the bulk method.
lenient().when(teamLeadLookup.isAnyTeamLeader(leader)).thenReturn(true);
leaderUserIds = Set.of(2L);
}
@Test
void bulkMatchesPerUserForAdminsAndTeamLeadsPolicy() {
assertParity(DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS);
}
@Test
void bulkMatchesPerUserForOrgAllPolicy() {
assertParity(DefaultAccessPolicy.ORG_ALL);
}
@Test
void bulkMatchesPerUserForExplicitOnlyPolicy() {
assertParity(DefaultAccessPolicy.EXPLICIT_ONLY);
}
/** SaaS no-leak: ORG_ALL grants nobody deployment-wide when the resolver forbids it. */
@Test
void orgAllDoesNotLeakDeploymentWideWhenResolverForbidsIt() {
DefaultPrincipalResolver base = new DefaultPrincipalResolver();
PrincipalResolver saasLikeResolver =
new PrincipalResolver() {
@Override
public Set<PrincipalRef> principalsOf(User user) {
return base.principalsOf(user);
}
// allowsDeploymentWideAccess() inherits the interface default (false) = SaaS.
};
service = new ResourceAccessService(grantRepository, teamLeadLookup, saasLikeResolver);
ReflectionTestUtils.setField(service, "portalDefaultPolicy", DefaultAccessPolicy.ORG_ALL);
lenient()
.when(grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, ""))
.thenReturn(List.of());
User adminUser = user(1L, null, Role.ADMIN.getRoleId());
User plainMember = user(5L, 10L, Role.USER.getRoleId());
Set<Long> bulk = service.usersWithPortalAccess(List.of(adminUser, plainMember), Set.of());
assertThat(bulk).contains(1L).doesNotContain(5L);
assertThat(service.canAccessPortal(plainMember))
.as("ORG_ALL must not grant a plain member deployment-wide on a SaaS-like resolver")
.isFalse();
assertThat(service.canAccessPortal(adminUser)).isTrue();
}
private void assertParity(DefaultAccessPolicy policy) {
setUp(policy);
Set<Long> bulk = service.usersWithPortalAccess(everyone, leaderUserIds);
for (User user : everyone) {
boolean authoritative = service.canAccessPortal(user);
assertThat(bulk.contains(user.getId()))
.as(
"policy=%s user=%d bulk should equal canAccessPortal(%s)",
policy, user.getId(), authoritative)
.isEqualTo(authoritative);
}
}
private User user(Long id, Long teamId, String authority) {
User user = new User();
user.setId(id);
user.setUsername("user-" + id);
new Authority(authority, user);
if (teamId != null) {
Team team = new Team();
team.setId(teamId);
team.setName("team-" + teamId);
user.setTeam(team);
}
return user;
}
private ResourceGrant grant(PrincipalType type, Long principalId, AccessPermission permission) {
ResourceGrant grant = new ResourceGrant();
grant.setResourceType(ResourceType.PORTAL);
grant.setResourceId("");
grant.setPrincipalType(type);
grant.setPrincipalId(principalId);
grant.setPermission(permission);
return grant;
}
}
@@ -0,0 +1,255 @@
package stirling.software.proprietary.controller.api;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.springframework.security.core.Authentication;
import org.springframework.test.util.ReflectionTestUtils;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
import stirling.software.proprietary.access.repository.ResourceGrantRepository;
import stirling.software.proprietary.access.service.DefaultPrincipalResolver;
import stirling.software.proprietary.access.service.MembershipTeamLeadLookup;
import stirling.software.proprietary.access.service.ResourceAccessService;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.model.TeamMembership;
import stirling.software.proprietary.model.UserLicenseSettings;
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.SessionEntity;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService;
import tools.jackson.databind.ObjectMapper;
/** Shared seeding, wiring, and statement-count measurement for the admin-roster query tests. */
class AdminSettingsPerfHarness {
static final Duration SESSION_TIMEOUT = Duration.ofMinutes(30);
private static final Instant STALE = Instant.now().minus(Duration.ofHours(2));
private static final Instant FRESH = Instant.now().minus(Duration.ofMinutes(2));
record Measure(int users, long statements, long updates, long inserts, long millis) {}
private final UserRepository userRepository;
private final SessionRepository sessionRepository;
private final TeamRepository teamRepository;
private final TeamMembershipRepository teamMembershipRepository;
private final ResourceGrantRepository resourceGrantRepository;
private final EntityManager em;
private final EntityManagerFactory emf;
AdminSettingsPerfHarness(
UserRepository userRepository,
SessionRepository sessionRepository,
TeamRepository teamRepository,
TeamMembershipRepository teamMembershipRepository,
ResourceGrantRepository resourceGrantRepository,
EntityManager em,
EntityManagerFactory emf) {
this.userRepository = userRepository;
this.sessionRepository = sessionRepository;
this.teamRepository = teamRepository;
this.teamMembershipRepository = teamMembershipRepository;
this.resourceGrantRepository = resourceGrantRepository;
this.em = em;
this.emf = emf;
}
Measure seedAndMeasure(
ProprietaryUIDataController controller, Authentication auth, int userCount) {
wipe();
seed(userCount);
em.flush();
em.clear();
Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
stats.setStatisticsEnabled(true);
stats.clear();
long t0 = System.nanoTime();
var response = controller.getAdminSettingsData(auth);
int users = response.getBody().getUsers().size();
em.flush(); // materialise any writes the GET issued so they are counted
long millis = (System.nanoTime() - t0) / 1_000_000;
return new Measure(
users,
stats.getPrepareStatementCount(),
stats.getEntityUpdateCount(),
stats.getEntityInsertCount(),
millis);
}
void wipe() {
// Detach anything a prior measure left managed, then delete children before parents.
em.clear();
teamMembershipRepository.deleteAllInBatch();
sessionRepository.deleteAllInBatch();
resourceGrantRepository.deleteAllInBatch();
// Not deleteAllInBatch: a bulk DELETE bypasses the User->authorities/user_settings cascade.
userRepository.deleteAll();
em.flush();
teamRepository.deleteAllInBatch();
em.flush();
em.clear();
}
void seed(int userCount) {
int teamCount = Math.max(1, userCount / 40);
List<Team> teams = new ArrayList<>(teamCount);
for (int i = 0; i < teamCount; i++) {
Team team = new Team();
team.setName("team-" + i);
teams.add(team);
}
List<Team> savedTeams = teamRepository.saveAll(teams);
em.flush();
List<User> users = new ArrayList<>(userCount);
List<SessionEntity> sessions = new ArrayList<>(userCount);
for (int i = 0; i < userCount; i++) {
User user = new User();
String username = "user-" + i;
user.setUsername(username);
user.setEnabled(true);
user.setTeam(savedTeams.get(i % teamCount));
new Authority(i == 0 ? Role.ADMIN.getRoleId() : Role.USER.getRoleId(), user);
Map<String, String> settings = new HashMap<>();
settings.put("language", "en-GB");
if (i % 5 == 0) {
settings.put("mfaSecret", "SECRET-" + i);
}
user.setSettings(settings);
users.add(user);
SessionEntity session = new SessionEntity();
session.setSessionId(UUID.randomUUID().toString());
session.setPrincipalName(username);
// ~30% of sessions are past the timeout.
session.setLastRequest(i % 10 < 3 ? STALE : FRESH);
session.setExpired(false);
sessions.add(session);
}
List<User> savedUsers = userRepository.saveAll(users);
sessionRepository.saveAll(sessions);
em.flush();
List<TeamMembership> memberships = new ArrayList<>();
for (int i = 0; i < userCount; i++) {
if (i % 10 == 0) {
TeamMembership membership = new TeamMembership();
membership.setTeam(savedUsers.get(i).getTeam());
membership.setUser(savedUsers.get(i));
membership.setRole(TeamRole.LEADER);
membership.setInvitedAt(LocalDateTime.now());
memberships.add(membership);
}
}
teamMembershipRepository.saveAll(memberships);
em.flush();
}
ProprietaryUIDataController buildController() {
ApplicationProperties applicationProperties =
mock(ApplicationProperties.class, RETURNS_DEEP_STUBS);
SessionPersistentRegistry sessionRegistry =
new SessionPersistentRegistry(sessionRepository);
ReflectionTestUtils.setField(
sessionRegistry, "defaultMaxInactiveInterval", SESSION_TIMEOUT);
ResourceAccessService resourceAccessService =
new ResourceAccessService(
resourceGrantRepository,
new MembershipTeamLeadLookup(teamMembershipRepository),
new DefaultPrincipalResolver());
ReflectionTestUtils.setField(
resourceAccessService,
"portalDefaultPolicy",
DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS);
UserLicenseSettingsService licenseSettingsService = mock(UserLicenseSettingsService.class);
UserLicenseSettings licenseSettings = mock(UserLicenseSettings.class);
lenient().when(licenseSettings.getLicenseMaxUsers()).thenReturn(0);
lenient().when(licenseSettingsService.getSettings()).thenReturn(licenseSettings);
lenient().when(licenseSettingsService.calculateMaxAllowedUsers()).thenReturn(100_000);
lenient().when(licenseSettingsService.getAvailableUserSlots()).thenReturn(100_000L);
lenient().when(licenseSettingsService.getDisplayGrandfatheredCount()).thenReturn(0);
LoginAttemptService loginAttemptService = mock(LoginAttemptService.class);
lenient().when(loginAttemptService.getAllBlockedUsers()).thenReturn(new ArrayList<>());
return new ProprietaryUIDataController(
applicationProperties,
mock(AuditConfigurationProperties.class),
sessionRegistry,
userRepository,
teamRepository,
teamMembershipRepository,
sessionRepository,
mock(DatabaseServiceInterface.class),
mock(ObjectMapper.class),
false,
licenseSettingsService,
mock(PersistentAuditEventRepository.class),
mock(MfaService.class),
loginAttemptService,
resourceAccessService);
}
Authentication adminAuth() {
Authentication auth = mock(Authentication.class);
lenient().when(auth.getName()).thenReturn("user-0");
return auth;
}
User mkUser(String username, Team team, String authority, Map<String, String> settings) {
User user = new User();
user.setUsername(username);
user.setEnabled(true);
user.setTeam(team);
new Authority(authority, user);
user.setSettings(new HashMap<>(settings));
return user;
}
TeamRepository teams() {
return teamRepository;
}
UserRepository users() {
return userRepository;
}
EntityManager em() {
return em;
}
}
@@ -0,0 +1,194 @@
package stirling.software.proprietary.controller.api;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.security.core.Authentication;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.PersistenceContext;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.access.repository.ResourceGrantRepository;
import stirling.software.proprietary.controller.api.AdminSettingsPerfHarness.Measure;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.SessionRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.dto.AdminUserSummary;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.TeamService;
/** Admin roster issues a constant query count regardless of size (H2). */
@DataJpaTest
class AdminSettingsQueryPerfTest {
@Autowired private UserRepository userRepository;
@Autowired private SessionRepository sessionRepository;
@Autowired private TeamRepository teamRepository;
@Autowired private TeamMembershipRepository teamMembershipRepository;
@Autowired private ResourceGrantRepository resourceGrantRepository;
@Autowired private EntityManagerFactory emf;
@PersistenceContext private EntityManager em;
private AdminSettingsPerfHarness harness() {
return new AdminSettingsPerfHarness(
userRepository,
sessionRepository,
teamRepository,
teamMembershipRepository,
resourceGrantRepository,
em,
emf);
}
@Test
void queryCountDoesNotScaleWithUsers() {
AdminSettingsPerfHarness harness = harness();
ProprietaryUIDataController controller = harness.buildController();
Authentication admin = harness.adminAuth();
Measure small = harness.seedAndMeasure(controller, admin, 150);
Measure large = harness.seedAndMeasure(controller, admin, 750);
System.out.printf(
"%n[admin-settings scaling] N=%d -> %d statements, %d updates, %d ms%n",
small.users(), small.statements(), small.updates(), small.millis());
System.out.printf(
"[admin-settings scaling] N=%d -> %d statements, %d updates, %d ms%n",
large.users(), large.statements(), large.updates(), large.millis());
long delta = large.statements() - small.statements();
System.out.printf(
"[admin-settings scaling] +%d users cost +%d statements%n",
large.users() - small.users(), delta);
assertTrue(
delta <= 40,
"admin-settings issues per-user queries: adding "
+ (large.users() - small.users())
+ " users added "
+ delta
+ " SQL statements (expected <= 40). The roster endpoint still scales O(N).");
assertEquals(
0,
large.updates(),
"admin-settings performed " + large.updates() + " row UPDATEs during a read (GET)");
}
@Test
void headlineBenchmark() {
int n = Integer.getInteger("adminBenchUsers", 2000);
AdminSettingsPerfHarness harness = harness();
ProprietaryUIDataController controller = harness.buildController();
Measure m = harness.seedAndMeasure(controller, harness.adminAuth(), n);
System.out.printf(
"%n==== admin-settings headline (N=%d users) ====%n"
+ " SQL statements : %d%n"
+ " row UPDATEs : %d%n"
+ " row INSERTs : %d%n"
+ " wall-clock : %d ms%n"
+ " statements/user: %.2f%n"
+ "==============================================%n",
m.users(),
m.statements(),
m.updates(),
m.inserts(),
m.millis(),
(double) m.statements() / n);
assertEquals(n, m.users(), "roster should return every seeded (non-internal) user");
}
@Test
void rosterExcludesInternalAccountsAndMasksSecrets() {
AdminSettingsPerfHarness harness = harness();
ProprietaryUIDataController controller = harness.buildController();
harness.wipe();
Team acme = new Team();
acme.setName("acme");
Team internal = new Team();
internal.setName(TeamService.INTERNAL_TEAM_NAME);
List<Team> savedTeams = harness.teams().saveAll(List.of(acme, internal));
harness.em().flush();
User adminUser =
harness.mkUser("admin", savedTeams.get(0), Role.ADMIN.getRoleId(), Map.of());
User mfaUser =
harness.mkUser(
"mfa-user",
savedTeams.get(0),
Role.USER.getRoleId(),
Map.of("mfaSecret", "TOPSECRET", "language", "fr"));
User apiUser =
harness.mkUser(
"internal-api",
savedTeams.get(0),
Role.INTERNAL_API_USER.getRoleId(),
Map.of());
User internalTeamUser =
harness.mkUser("internal-team", savedTeams.get(1), Role.USER.getRoleId(), Map.of());
harness.users().saveAll(List.of(adminUser, mfaUser, apiUser, internalTeamUser));
harness.em().flush();
harness.em().clear();
Authentication auth = mock(Authentication.class);
lenient().when(auth.getName()).thenReturn("admin");
ProprietaryUIDataController.AdminSettingsData data =
controller.getAdminSettingsData(auth).getBody();
Set<String> usernames =
data.getUsers().stream()
.map(AdminUserSummary::getUsername)
.collect(Collectors.toSet());
assertTrue(usernames.contains("admin"));
assertTrue(usernames.contains("mfa-user"));
assertFalse(usernames.contains("internal-api"), "internal-api user must be excluded");
assertFalse(usernames.contains("internal-team"), "internal-team user must be excluded");
assertEquals(2, data.getTotalUsers());
Map<String, String> mfaSettings = data.getUserSettings().get("mfa-user");
assertEquals("********", mfaSettings.get("mfaSecret"), "mfaSecret must be masked");
assertEquals("fr", mfaSettings.get("language"), "non-secret settings preserved");
AdminUserSummary adminSummary =
data.getUsers().stream()
.filter(u -> "admin".equals(u.getUsername()))
.findFirst()
.orElseThrow();
assertTrue(adminSummary.isPortalAccess(), "admin should have portal access");
}
@SpringBootConfiguration
@EntityScan(
basePackages = {
"stirling.software.proprietary.security.model",
"stirling.software.proprietary.model",
"stirling.software.proprietary.access.model"
})
@EnableJpaRepositories(
basePackages = {
"stirling.software.proprietary.security.database.repository",
"stirling.software.proprietary.security.repository",
"stirling.software.proprietary.access.repository"
})
static class TestApp {}
}
@@ -0,0 +1,109 @@
package stirling.software.proprietary.controller.api;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.security.core.Authentication;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.PersistenceContext;
import stirling.software.proprietary.access.repository.ResourceGrantRepository;
import stirling.software.proprietary.controller.api.AdminSettingsPerfHarness.Measure;
import stirling.software.proprietary.security.database.repository.SessionRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
import stirling.software.proprietary.security.repository.TeamRepository;
/** Admin-roster queries + index DDL on real Postgres (skipped without Docker). */
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers(disabledWithoutDocker = true)
class AdminSettingsQueryPostgresTest {
@Container
static PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
registry.add("spring.jpa.properties.hibernate.default_batch_fetch_size", () -> "100");
}
@Autowired private UserRepository userRepository;
@Autowired private SessionRepository sessionRepository;
@Autowired private TeamRepository teamRepository;
@Autowired private TeamMembershipRepository teamMembershipRepository;
@Autowired private ResourceGrantRepository resourceGrantRepository;
@Autowired private EntityManagerFactory emf;
@PersistenceContext private EntityManager em;
private AdminSettingsPerfHarness harness() {
return new AdminSettingsPerfHarness(
userRepository,
sessionRepository,
teamRepository,
teamMembershipRepository,
resourceGrantRepository,
em,
emf);
}
@Test
void newRosterQueriesRunOnPostgresWithConstantScaling() {
AdminSettingsPerfHarness harness = harness();
ProprietaryUIDataController controller = harness.buildController();
Authentication admin = harness.adminAuth();
Measure small = harness.seedAndMeasure(controller, admin, 100);
Measure large = harness.seedAndMeasure(controller, admin, 400);
System.out.printf(
"%n[admin-settings postgres] N=%d -> %d statements, %d updates, %d ms%n",
small.users(), small.statements(), small.updates(), small.millis());
System.out.printf(
"[admin-settings postgres] N=%d -> %d statements, %d updates, %d ms%n",
large.users(), large.statements(), large.updates(), large.millis());
assertEquals(400, large.users(), "roster returns every seeded user on Postgres");
assertTrue(
large.statements() - small.statements() <= 40,
"roster must not scale per-user on Postgres (delta="
+ (large.statements() - small.statements())
+ ")");
assertEquals(0, large.updates(), "no writes during the GET on Postgres");
}
@SpringBootConfiguration
@EntityScan(
basePackages = {
"stirling.software.proprietary.security.model",
"stirling.software.proprietary.model",
"stirling.software.proprietary.access.model"
})
@EnableJpaRepositories(
basePackages = {
"stirling.software.proprietary.security.database.repository",
"stirling.software.proprietary.security.repository",
"stirling.software.proprietary.access.repository"
})
static class TestApp {}
}
@@ -141,7 +141,8 @@ class ProprietaryUIDataControllerMoreTest {
void singleAdminFirstLogin() {
User admin = normalUser(1L, "admin");
admin.setFirstLogin(true);
when(userRepository.findAll()).thenReturn(List.of(admin));
when(userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId()))
.thenReturn(1L);
when(userRepository.findByUsernameIgnoreCase("admin")).thenReturn(Optional.of(admin));
ResponseEntity<LoginData> response = controller.getLoginData();
@@ -154,7 +155,8 @@ class ProprietaryUIDataControllerMoreTest {
@Test
@DisplayName("does not flag setup when a normal user exists")
void normalUserNoSetup() {
when(userRepository.findAll()).thenReturn(List.of(normalUser(1L, "bob")));
when(userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId()))
.thenReturn(1L);
ResponseEntity<LoginData> response = controller.getLoginData();
@@ -252,11 +254,9 @@ class ProprietaryUIDataControllerMoreTest {
@DisplayName("aggregates users, teams and license limits")
void aggregates() {
User user = normalUser(1L, "bob");
when(userRepository.findAllWithTeam())
when(userRepository.findAllWithTeamAndAuthorities())
.thenReturn(new java.util.ArrayList<>(List.of(user)));
when(sessionPersistentRegistry.getMaxInactiveInterval()).thenReturn(3600);
when(sessionPersistentRegistry.findLatestSession("bob")).thenReturn(Optional.empty());
when(userRepository.findByIdWithSettings(1L)).thenReturn(Optional.of(user));
when(teamRepository.findAll()).thenReturn(List.of());
when(licenseSettingsService.calculateMaxAllowedUsers()).thenReturn(10);
@@ -310,7 +310,7 @@ class ProprietaryUIDataControllerMoreTest {
team.setName("Engineering");
when(teamRepository.findById(5L)).thenReturn(Optional.of(team));
when(userRepository.findAllByTeamId(5L)).thenReturn(List.of());
when(userRepository.findAllWithTeam()).thenReturn(List.of());
when(userRepository.findAllWithTeamAndAuthorities()).thenReturn(List.of());
when(sessionRepository.findLatestSessionByTeamId(5L))
.thenReturn(Collections.emptyList());
@@ -3,7 +3,6 @@ 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;
@@ -93,7 +92,7 @@ class ProprietaryUIDataControllerTest {
@Test
void loginDataFlagsFirstTimeSetupWhenNoUsers() {
when(userRepository.findAll()).thenReturn(Collections.emptyList());
when(userRepository.countByUsernameNot(Role.INTERNAL_API_USER.getRoleId())).thenReturn(0L);
ResponseEntity<LoginData> response = controller.getLoginData();