feat(user): add owner-lists opt-in setting and user listing endpoint
Build, Test & Push Auth Service / quality-check (push) Successful in 1m5s
Build, Test & Push Auth Service / docker (push) Successful in 1m31s

This commit is contained in:
lobo committed 2026-10-06 13:19:58 +02:00
1 parent d9b816904a
commit d924c09953
35 files changed
+693 -40

No files matched your search

+8 -4
View File
@@ -174,6 +174,8 @@ To Be Played is split into three repositories:
| `PUT` | `/api/users/settings/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token | | `PUT` | `/api/users/settings/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token |
| `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token | | `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token |
| `GET` | `/api/public/profiles/{displayName}` | public | | `GET` | `/api/public/profiles/{displayName}` | public |
| `PUT` | `/api/users/settings/owner-lists` (`{enabled}`; `204`; opt-in to be shown by name in owner and user lists, only together with the public profile) | access token |
| `GET` | `/api/stats/users?key=TOTAL_USERS\|STEAM_LINKED&page=` (`{listed: [{displayName}], listedTotal, privateCount, includesYou, page, totalPages}`; never an id; not cached) | access token |
| `GET` | `/api/stats/public` | public | | `GET` | `/api/stats/public` | public |
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public | | `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
@@ -394,16 +396,18 @@ sequenceDiagram
Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that
loses a typed-in ID), an unlink, a block and an unblock, Auth sends the user's state after the commit loses a typed-in ID), an unlink, a block and an unblock, Auth sends the user's state after the commit
(`AccountStatePublisher`); the nightly cleanup sends every user. The call runs on the task executor, so neither the answer (`AccountStatePublisher`), also when the display name, the public profile or "show me in owner lists" changes; the nightly
nor a database connection waits for Core; a failed call only warns. cleanup sends every user. The state carries `listed` (active, public profile, opt-in and a display name: `ListedUser`),
which Core uses for its owner lists. The call runs on the task executor, so neither the answer nor a database
connection waits for Core; a failed call is tried again after 10 s, 1 min and 5 min (in memory), then it only warns.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant A as Auth participant A as Auth
participant C as Core participant C as Core
A->>A: link / unlink / block / unblock / name change (commit) A->>A: link / unlink / block / unblock / name change (commit)
A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked, roleChangedAt} A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked, roleChangedAt, listed}
C->>C: blocked_user, account_role_change, Steam ID in user_steam_sync, display name in account_name C->>C: blocked_user, listed_user, account_role_change, Steam ID in user_steam_sync, display name in account_name
Note over A,C: nightly 04:15 Note over A,C: nightly 04:15
A->>C: PUT /internal/accounts/states (every user) A->>C: PUT /internal/accounts/states (every user)
``` ```
@@ -6,6 +6,8 @@ import java.time.Instant;
* One user as Core copies it. * One user as Core copies it.
* *
* @param roleChangedAt null when the role never changed on the admin page * @param roleChangedAt null when the role never changed on the admin page
* @param listed shown by name in Core's owner lists, see {@link auth_service.shared.account.ListedUser}
*/ */
public record CoreAccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt) { public record CoreAccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt,
boolean listed) {
} }
@@ -4,6 +4,7 @@ import auth_service.modules.account.model.CoreAccountState;
import auth_service.shared.account.ActiveAdmins; import auth_service.shared.account.ActiveAdmins;
import auth_service.shared.account.ActiveUser; import auth_service.shared.account.ActiveUser;
import auth_service.shared.account.EmailAddress; import auth_service.shared.account.EmailAddress;
import auth_service.shared.account.ListedUser;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
@@ -113,18 +114,22 @@ public class AccountRepository {
private static final RowMapper<CoreAccountState> STATE_ROW = (rs, row) -> private static final RowMapper<CoreAccountState> STATE_ROW = (rs, row) ->
new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), rs.getString("display_name"), new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), rs.getString("display_name"),
!rs.getBoolean("enabled"), !rs.getBoolean("enabled"),
rs.getTimestamp("role_changed_at") == null ? null : rs.getTimestamp("role_changed_at").toInstant()); rs.getTimestamp("role_changed_at") == null ? null : rs.getTimestamp("role_changed_at").toInstant(),
rs.getBoolean("listed"));
private static final String STATE_SELECT = "SELECT u.id, u.steam_id, u.display_name, u.enabled, u.role_changed_at, "
+ "COALESCE(" + ListedUser.of("u", "s") + ", FALSE) AS listed "
+ "FROM users u LEFT JOIN user_settings s ON s.user_id = u.id";
public Optional<CoreAccountState> findState(long userId) { public Optional<CoreAccountState> findState(long userId) {
return jdbc.query("SELECT id, steam_id, display_name, enabled, role_changed_at FROM users WHERE id = ?", return jdbc.query(STATE_SELECT + " WHERE u.id = ?", STATE_ROW, userId).stream().findFirst();
STATE_ROW, userId).stream().findFirst();
} }
/** /**
* Every user, also the flagged ones. * Every user, also the flagged ones.
*/ */
public List<CoreAccountState> findAllStates() { public List<CoreAccountState> findAllStates() {
return jdbc.query("SELECT id, steam_id, display_name, enabled, role_changed_at FROM users ORDER BY id", STATE_ROW); return jdbc.query(STATE_SELECT + " ORDER BY u.id", STATE_ROW);
} }
/** /**
@@ -5,6 +5,7 @@ import auth_service.modules.account.exception.LastAdminException;
import auth_service.modules.account.exception.TooManyAttemptsException; import auth_service.modules.account.exception.TooManyAttemptsException;
import auth_service.modules.account.repo.AccountRepository; import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.account.AccountDeleter; import auth_service.shared.account.AccountDeleter;
import auth_service.shared.account.AccountStatePublisher;
import auth_service.shared.mail.AccountMailer; import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.AttemptLimiter; import auth_service.shared.security.AttemptLimiter;
import auth_service.shared.security.OpaqueToken; import auth_service.shared.security.OpaqueToken;
@@ -38,18 +39,20 @@ public class AccountService implements AccountDeleter {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final CoreAccountService coreAccountService; private final CoreAccountService coreAccountService;
private final AccountMailer accountMailer; private final AccountMailer accountMailer;
private final AccountStatePublisher accountStatePublisher;
private final TransactionOperations transactions; private final TransactionOperations transactions;
private final Duration retention; private final Duration retention;
private final AttemptLimiter attemptLimiter = new AttemptLimiter(LOCK); private final AttemptLimiter attemptLimiter = new AttemptLimiter(LOCK);
public AccountService(AccountRepository accountRepository, PasswordEncoder passwordEncoder, public AccountService(AccountRepository accountRepository, PasswordEncoder passwordEncoder,
CoreAccountService coreAccountService, AccountMailer accountMailer, CoreAccountService coreAccountService, AccountMailer accountMailer,
TransactionOperations transactions, AccountStatePublisher accountStatePublisher, TransactionOperations transactions,
@Value("${app.account-cleanup.retention}") Duration retention) { @Value("${app.account-cleanup.retention}") Duration retention) {
this.accountRepository = accountRepository; this.accountRepository = accountRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.coreAccountService = coreAccountService; this.coreAccountService = coreAccountService;
this.accountMailer = accountMailer; this.accountMailer = accountMailer;
this.accountStatePublisher = accountStatePublisher;
this.transactions = transactions; this.transactions = transactions;
this.retention = retention; this.retention = retention;
} }
@@ -133,8 +136,10 @@ public class AccountService implements AccountDeleter {
try { try {
coreAccountService.markDeleted(List.of(userId)); coreAccountService.markDeleted(List.of(userId));
} catch (RuntimeException e) { } catch (RuntimeException e) {
log.warn("Core did not take the delete of user {}, the nightly cleanup sends it again: {}", userId, log.warn("Core did not take the delete of user {}, the state push tries again: {}", userId,
e.getMessage()); e.getMessage());
// The push carries listed=false and is retried; the nightly cleanup stays the last fallback.
accountStatePublisher.publish(userId);
} }
return true; return true;
} }
@@ -4,30 +4,46 @@ import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.account.AccountStatePublisher; import auth_service.shared.account.AccountStatePublisher;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.Duration;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
/** /**
* Sends on the task executor: in afterCommit the transaction still holds its database connection, and a slow Core * Sends on the task executor: in afterCommit the transaction still holds its database connection, and a slow Core
* (up to 13 s) would keep it from the pool. * (up to 13 s) would keep it from the pool. A failed send is tried again later; the scheduler thread only hands the
* send back to the executor. Retries live in memory, so the nightly cleanup stays the fallback.
*/ */
@Service @Service
@Slf4j @Slf4j
public class CoreAccountStatePublisher implements AccountStatePublisher { public class CoreAccountStatePublisher implements AccountStatePublisher {
static final List<Duration> RETRY_DELAYS = List.of(Duration.ofSeconds(10), Duration.ofMinutes(1),
Duration.ofMinutes(5));
private static final int LOCK_STRIPES = 64;
private final Object[] sendLocks = new Object[LOCK_STRIPES];
private final AccountRepository accountRepository; private final AccountRepository accountRepository;
private final CoreAccountService coreAccountService; private final CoreAccountService coreAccountService;
private final Executor executor; private final Executor executor;
private final TaskScheduler scheduler;
public CoreAccountStatePublisher(AccountRepository accountRepository, CoreAccountService coreAccountService, public CoreAccountStatePublisher(AccountRepository accountRepository, CoreAccountService coreAccountService,
@Qualifier("applicationTaskExecutor") Executor executor) { @Qualifier("applicationTaskExecutor") Executor executor,
@Qualifier("taskScheduler") TaskScheduler scheduler) {
this.accountRepository = accountRepository; this.accountRepository = accountRepository;
this.coreAccountService = coreAccountService; this.coreAccountService = coreAccountService;
this.executor = executor; this.executor = executor;
this.scheduler = scheduler;
for (int i = 0; i < LOCK_STRIPES; i++) {
sendLocks[i] = new Object();
}
} }
@Override @Override
@@ -36,20 +52,30 @@ public class CoreAccountStatePublisher implements AccountStatePublisher {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override @Override
public void afterCommit() { public void afterCommit() {
executor.execute(() -> send(userId)); executor.execute(() -> send(userId, 0));
} }
}); });
} else { } else {
executor.execute(() -> send(userId)); executor.execute(() -> send(userId, 0));
} }
} }
private void send(long userId) { private void send(long userId, int attempt) {
try { try {
accountRepository.findState(userId).ifPresent(state -> coreAccountService.applyStates(List.of(state))); // Read and send under one lock per user: the send that runs last carries the newest state.
synchronized (sendLocks[(int) (userId % LOCK_STRIPES)]) {
accountRepository.findState(userId).ifPresent(state -> coreAccountService.applyStates(List.of(state)));
}
} catch (RuntimeException e) { } catch (RuntimeException e) {
log.warn("Core did not take the state of user {}, the nightly cleanup sends it again: {}", userId, if (attempt < RETRY_DELAYS.size()) {
e.getMessage()); log.warn("Core did not take the state of user {} (try {}), trying again later: {}", userId,
attempt + 1, e.getMessage());
scheduler.schedule(() -> executor.execute(() -> send(userId, attempt + 1)),
Instant.now().plus(RETRY_DELAYS.get(attempt)));
} else {
log.warn("Core did not take the state of user {}, the nightly cleanup sends it again: {}", userId,
e.getMessage());
}
} }
} }
} }
@@ -0,0 +1,29 @@
package auth_service.modules.stats.api;
import auth_service.modules.stats.api.dto.PeopleDto;
import auth_service.modules.stats.api.mapper.ApiMapper;
import auth_service.modules.stats.model.UserCountKey;
import auth_service.modules.stats.service.UserListService;
import auth_service.shared.security.UserPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/stats")
@RequiredArgsConstructor
public class UserListController {
private final UserListService userListService;
@GetMapping("/users")
public ResponseEntity<PeopleDto> getUsers(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam UserCountKey key,
@RequestParam(defaultValue = "0") int page) {
return ResponseEntity.ok(ApiMapper.toApi(userListService.users(principal.getIdAsLong(), key, page)));
}
}
@@ -0,0 +1,14 @@
package auth_service.modules.stats.api.dto;
import java.util.List;
/**
* Who stands behind a number. Never an id, avatar or Steam ID (#26): a private user is only in privateCount, the
* viewer only in includesYou.
*/
public record PeopleDto(List<Person> listed, long listedTotal, long privateCount, boolean includesYou, int page,
int totalPages) {
public record Person(String displayName) {
}
}
@@ -1,12 +1,19 @@
package auth_service.modules.stats.api.mapper; package auth_service.modules.stats.api.mapper;
import auth_service.modules.stats.api.dto.PeopleDto;
import auth_service.modules.stats.api.dto.PublicStatsDto; import auth_service.modules.stats.api.dto.PublicStatsDto;
import auth_service.modules.stats.model.PeopleList;
import auth_service.modules.stats.model.PublicStats; import auth_service.modules.stats.model.PublicStats;
public abstract class ApiMapper { public abstract class ApiMapper {
private ApiMapper() { private ApiMapper() {
} }
public static PeopleDto toApi(PeopleList people) {
return new PeopleDto(people.listed().stream().map(PeopleDto.Person::new).toList(), people.listedTotal(),
people.privateCount(), people.includesYou(), people.page(), people.totalPages());
}
public static PublicStatsDto toApi(PublicStats stats) { public static PublicStatsDto toApi(PublicStats stats) {
return new PublicStatsDto(stats.totalUsers(), stats.usersWithSteamLinked()); return new PublicStatsDto(stats.totalUsers(), stats.usersWithSteamLinked());
} }
@@ -0,0 +1,8 @@
package auth_service.modules.stats.model;
/**
* @param others the users of a set without the viewer
* @param listed those of them shown by name
*/
public record PeopleCounts(long others, long listed, boolean includesYou) {
}
@@ -0,0 +1,10 @@
package auth_service.modules.stats.model;
import java.util.List;
/**
* One page of a user list: names of the listed users only, the rest as a count.
*/
public record PeopleList(List<String> listed, long listedTotal, long privateCount, boolean includesYou, int page,
int totalPages) {
}
@@ -0,0 +1,9 @@
package auth_service.modules.stats.model;
/**
* The user numbers of the landing page; each has a list behind it.
*/
public enum UserCountKey {
TOTAL_USERS,
STEAM_LINKED
}
@@ -1,13 +1,27 @@
package auth_service.modules.stats.repo; package auth_service.modules.stats.repo;
import auth_service.modules.stats.model.PeopleCounts;
import auth_service.modules.stats.model.UserCountKey;
import auth_service.shared.account.ActiveUser; import auth_service.shared.account.ActiveUser;
import auth_service.shared.account.ListedUser;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
@Repository @Repository
public class StatsRepository { public class StatsRepository {
// One WHERE per number: the count and its list read the same set.
private static final Map<UserCountKey, String> SETS = Map.of(
UserCountKey.TOTAL_USERS, ActiveUser.of("u"),
UserCountKey.STEAM_LINKED, ActiveUser.of("u") + " AND u.steam_id IS NOT NULL");
// LEFT JOIN: a user without a settings row is in the number, and private.
private static final String FROM = " FROM users u LEFT JOIN user_settings s ON s.user_id = u.id WHERE ";
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
@Autowired @Autowired
@@ -16,13 +30,32 @@ public class StatsRepository {
} }
public long countTotalUsers() { public long countTotalUsers() {
Long v = jdbc.queryForObject("SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u"), Long.class); return count(UserCountKey.TOTAL_USERS);
return v != null ? v : 0L;
} }
public long countUsersWithSteamLinked() { public long countUsersWithSteamLinked() {
Long v = jdbc.queryForObject( return count(UserCountKey.STEAM_LINKED);
"SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u") + " AND u.steam_id IS NOT NULL", Long.class); }
private long count(UserCountKey key) {
Long v = jdbc.queryForObject("SELECT COUNT(*)" + FROM + SETS.get(key), Long.class);
return v != null ? v : 0L; return v != null ? v : 0L;
} }
public PeopleCounts countUsers(UserCountKey key, long viewerId) {
return jdbc.queryForObject("SELECT COUNT(*) FILTER (WHERE u.id <> ?) AS others, "
+ "COUNT(*) FILTER (WHERE u.id <> ? AND " + ListedUser.of("u", "s") + ") AS listed, "
+ "COALESCE(BOOL_OR(u.id = ?), FALSE) AS you" + FROM + SETS.get(key),
(rs, row) -> new PeopleCounts(rs.getLong("others"), rs.getLong("listed"), rs.getBoolean("you")),
viewerId, viewerId, viewerId);
}
/**
* Selects the display name and nothing else, so no id can reach a response.
*/
public List<String> listedUserNames(UserCountKey key, long viewerId, int offset, int limit) {
return jdbc.queryForList("SELECT u.display_name" + FROM + SETS.get(key) + " AND u.id <> ? AND "
+ ListedUser.of("u", "s") + " ORDER BY LOWER(u.display_name), u.id LIMIT ? OFFSET ?",
String.class, viewerId, limit, offset);
}
} }
@@ -0,0 +1,35 @@
package auth_service.modules.stats.service;
import auth_service.modules.stats.model.PeopleCounts;
import auth_service.modules.stats.model.PeopleList;
import auth_service.modules.stats.model.UserCountKey;
import auth_service.modules.stats.repo.StatsRepository;
import auth_service.shared.security.RequiresRole;
import auth_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Not cached: an opt-out shows on the next load.
*/
@Service
@RequiredArgsConstructor
public class UserListService {
static final int PAGE_SIZE = 40;
static final int MAX_PAGE = 250;
private final StatsRepository statsRepository;
@RequiresRole(Role.USER)
public PeopleList users(long viewerId, UserCountKey key, int page) {
int clamped = Math.clamp(page, 0, MAX_PAGE);
PeopleCounts counts = statsRepository.countUsers(key, viewerId);
int totalPages = (int) ((counts.listed() + PAGE_SIZE - 1) / PAGE_SIZE);
List<String> names = statsRepository.listedUserNames(key, viewerId, clamped * PAGE_SIZE, PAGE_SIZE);
return new PeopleList(names, counts.listed(), counts.others() - counts.listed(), counts.includesYou(), clamped,
totalPages);
}
}
@@ -4,6 +4,7 @@ import auth_service.shared.security.UserPrincipal;
import auth_service.modules.user.api.dto.request.AppearanceRequest; import auth_service.modules.user.api.dto.request.AppearanceRequest;
import auth_service.modules.user.api.dto.request.DashboardTilesRequest; import auth_service.modules.user.api.dto.request.DashboardTilesRequest;
import auth_service.modules.user.api.dto.request.FavoritePlatformRequest; import auth_service.modules.user.api.dto.request.FavoritePlatformRequest;
import auth_service.modules.user.api.dto.request.OwnerListsRequest;
import auth_service.modules.user.api.dto.request.ProfileTilesRequest; import auth_service.modules.user.api.dto.request.ProfileTilesRequest;
import auth_service.modules.user.api.dto.request.ProfileStyleRequest; import auth_service.modules.user.api.dto.request.ProfileStyleRequest;
import auth_service.modules.user.api.dto.request.StatChartRequest; import auth_service.modules.user.api.dto.request.StatChartRequest;
@@ -72,6 +73,13 @@ public class UserController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@PutMapping(value = "/settings/owner-lists")
public ResponseEntity<Void> updateOwnerLists(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody OwnerListsRequest request) {
this.userService.updateOwnerLists(principal.getIdAsLong(), request.enabled());
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/settings/welcome-seen") @PutMapping(value = "/settings/welcome-seen")
public ResponseEntity<Void> markWelcomeSeen(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<Void> markWelcomeSeen(@AuthenticationPrincipal UserPrincipal principal) {
this.userService.markWelcomeSeen(principal.getIdAsLong()); this.userService.markWelcomeSeen(principal.getIdAsLong());
@@ -0,0 +1,6 @@
package auth_service.modules.user.api.dto.request;
import jakarta.validation.constraints.NotNull;
public record OwnerListsRequest(@NotNull Boolean enabled) {
}
@@ -8,6 +8,7 @@ import java.util.Map;
@Builder @Builder
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile, public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
boolean showInOwnerLists,
AppearanceResponse appearance, ProfileStyleResponse profileStyle, AppearanceResponse appearance, ProfileStyleResponse profileStyle,
List<TileResponse> dashboardTiles, List<TileResponse> profileTiles, List<TileResponse> dashboardTiles, List<TileResponse> profileTiles,
Map<String, String> statCharts, String favoritePlatform, Instant welcomeSeenAt) { Map<String, String> statCharts, String favoritePlatform, Instant welcomeSeenAt) {
@@ -42,6 +42,7 @@ public abstract class ApiMapper {
.useDarkTheme(user.getSettings().getTheme().equals("dark")) .useDarkTheme(user.getSettings().getTheme().equals("dark"))
.language(user.getSettings().getLanguage()) .language(user.getSettings().getLanguage())
.publicProfile(user.getSettings().isPublicProfile()) .publicProfile(user.getSettings().isPublicProfile())
.showInOwnerLists(user.getSettings().isShowInOwnerLists())
.appearance(toApi(user.getSettings().getAppearance())) .appearance(toApi(user.getSettings().getAppearance()))
.profileStyle(toApi(user.getSettings().getProfileStyle())) .profileStyle(toApi(user.getSettings().getProfileStyle()))
.dashboardTiles(user.getSettings().getDashboardTiles() == null ? null .dashboardTiles(user.getSettings().getDashboardTiles() == null ? null
@@ -13,6 +13,8 @@ public class UserSettings {
private String language; private String language;
private String theme; private String theme;
private boolean publicProfile; private boolean publicProfile;
/** Listed by name only together with publicProfile, see ListedUser. */
private boolean showInOwnerLists;
@Builder.Default @Builder.Default
private Appearance appearance = Appearance.DEFAULT; private Appearance appearance = Appearance.DEFAULT;
@Builder.Default @Builder.Default
@@ -32,6 +32,9 @@ public class UserSettingsEntity {
@Column(name = "public_profile", nullable = false) @Column(name = "public_profile", nullable = false)
private boolean publicProfile; private boolean publicProfile;
@Column(name = "show_in_owner_lists", nullable = false)
private boolean showInOwnerLists;
@Column(nullable = false) @Column(nullable = false)
private String accent; private String accent;
@@ -28,6 +28,7 @@ public abstract class EntityMapper {
.language(settings.getLanguage()) .language(settings.getLanguage())
.theme(settings.getTheme()) .theme(settings.getTheme())
.publicProfile(settings.isPublicProfile()) .publicProfile(settings.isPublicProfile())
.showInOwnerLists(settings.isShowInOwnerLists())
.appearance(new Appearance(settings.getAccent(), settings.getCorners(), settings.isWideLayout(), .appearance(new Appearance(settings.getAccent(), settings.getCorners(), settings.isWideLayout(),
settings.getBackgroundAppid(), settings.getBannerAppid(), settings.isShowBanner(), settings.getBackgroundAppid(), settings.getBannerAppid(), settings.isShowBanner(),
settings.isReduceMotion())) settings.isReduceMotion()))
@@ -61,6 +62,7 @@ public abstract class EntityMapper {
target.setLanguage(settings.getLanguage()); target.setLanguage(settings.getLanguage());
target.setTheme(settings.getTheme()); target.setTheme(settings.getTheme());
target.setPublicProfile(settings.isPublicProfile()); target.setPublicProfile(settings.isPublicProfile());
target.setShowInOwnerLists(settings.isShowInOwnerLists());
Appearance appearance = settings.getAppearance(); Appearance appearance = settings.getAppearance();
target.setAccent(appearance.accent()); target.setAccent(appearance.accent());
target.setCorners(appearance.corners()); target.setCorners(appearance.corners());
@@ -26,6 +26,7 @@ import java.time.Instant;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
@Slf4j @Slf4j
@@ -53,15 +54,18 @@ public class UserService {
if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) { if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) {
throw new DisplayNameAlreadyInUseException("The display name is already in use."); throw new DisplayNameAlreadyInUseException("The display name is already in use.");
} }
boolean renamed = displayName != null && userRepository.findById(userId) // The listing in Core depends on the name and the public profile.
.map(user -> !displayName.equals(user.getDisplayName())).orElse(true); boolean listingChanged = userRepository.findById(userId)
.map(user -> !Objects.equals(displayName, user.getDisplayName())
|| user.getSettings().isPublicProfile() != userSettings.isPublicProfile())
.orElse(displayName != null || userSettings.isPublicProfile());
userRepository.update(userId, user -> { userRepository.update(userId, user -> {
user.setDisplayName(displayName); user.setDisplayName(displayName);
user.getSettings().setLanguage(userSettings.getLanguage()); user.getSettings().setLanguage(userSettings.getLanguage());
user.getSettings().setTheme(userSettings.getTheme()); user.getSettings().setTheme(userSettings.getTheme());
user.getSettings().setPublicProfile(userSettings.isPublicProfile()); user.getSettings().setPublicProfile(userSettings.isPublicProfile());
}); });
if (renamed) { if (listingChanged) {
accountStatePublisher.publish(userId); accountStatePublisher.publish(userId);
} }
log.info("User settings updated for user {}", userId); log.info("User settings updated for user {}", userId);
@@ -154,6 +158,14 @@ public class UserService {
user -> user.getSettings().setFavoritePlatform(platform == null ? null : platform.strip())); user -> user.getSettings().setFavoritePlatform(platform == null ? null : platform.strip()));
} }
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "users", key = "#userId")
public void updateOwnerLists(long userId, boolean enabled) {
userRepository.update(userId, user -> user.getSettings().setShowInOwnerLists(enabled));
accountStatePublisher.publish(userId);
}
@RequiresRole(Role.USER) @RequiresRole(Role.USER)
@Transactional @Transactional
@CacheEvict(value = "users", key = "#userId") @CacheEvict(value = "users", key = "#userId")
@@ -1,8 +1,8 @@
package auth_service.shared.account; package auth_service.shared.account;
/** /**
* Port for a change Core copies: the Steam ID or the block of a user. Sends after the commit; a failure only warns, * Port for a change Core copies: the Steam ID or the block of a user. Sends after the commit; a failure is retried 3
* because the nightly cleanup sends every user again. * times, then the nightly cleanup sends every user again.
*/ */
public interface AccountStatePublisher { public interface AccountStatePublisher {
void publish(long userId); void publish(long userId);
@@ -0,0 +1,16 @@
package auth_service.shared.account;
/**
* The only rule that decides who is shown by name in owner and user lists (#26). Everyone else is private. The
* settings alias may come from a LEFT JOIN: no settings row is not listed.
*/
public final class ListedUser {
private ListedUser() {
}
public static String of(String user, String settings) {
return ActiveUser.of(user) + " AND " + settings + ".public_profile AND " + settings + ".show_in_owner_lists AND "
+ user + ".display_name IS NOT NULL";
}
}
@@ -0,0 +1,3 @@
-- Opt-in to be shown by name in owner and user lists (#26). Off for every existing user; a user is listed only
-- together with the public profile.
ALTER TABLE user_settings ADD COLUMN show_in_owner_lists BOOLEAN NOT NULL DEFAULT FALSE;
@@ -0,0 +1,77 @@
package auth_service.modules.account.api;
import auth_service.configuration.security.ClientHeaderFilter;
import auth_service.shared.account.AccountStatePublisher;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* A listed user (#26) who deletes the account. Core is not reachable in tests, like Core down in production.
*/
class DeletedAccountListingTest extends AbstractIntegrationTest {
private static final String EMAIL = "delete-listed@example.com";
private static final String PASSWORD = "correct horse battery";
@MockitoBean
private AccountStatePublisher accountStatePublisher;
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private PasswordEncoder passwordEncoder;
private long userId;
@BeforeEach
void seedListedUser() {
jdbc.update("DELETE FROM users WHERE email = ?", EMAIL);
userId = jdbc.queryForObject("INSERT INTO users (email, password, enabled, display_name) VALUES (?, ?, true, ?) "
+ "RETURNING id", Long.class, EMAIL, passwordEncoder.encode(PASSWORD), "DeleteListed");
jdbc.update("INSERT INTO user_settings (user_id, language, theme, public_profile, show_in_owner_lists) "
+ "VALUES (?, 'de', 'dark', true, true)", userId);
}
@AfterEach
void cleanUp() {
jdbc.update("DELETE FROM users WHERE email = ?", EMAIL);
}
@Test
void deleteWhileCoreIsDown_PushesTheUnlistedState() throws Exception {
Cookie access = mockMvc.perform(post("/api/auth/login")
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD + "\"}"))
.andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE);
mockMvc.perform(delete("/api/users/me")
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(access)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"" + PASSWORD + "\"}"))
.andExpect(status().isNoContent());
verify(accountStatePublisher, timeout(2000)).publish(userId);
}
}
@@ -56,12 +56,44 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
void findStates_CarryTheSteamIdTheDisplayNameAndTheBlock() { void findStates_CarryTheSteamIdTheDisplayNameAndTheBlock() {
jdbc.update("update users set enabled = false, steam_id = 76561198000000001, display_name = 'Alice' where id = ?", alice); jdbc.update("update users set enabled = false, steam_id = 76561198000000001, display_name = 'Alice' where id = ?", alice);
assertThat(repository.findState(alice)).contains(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null)); assertThat(repository.findState(alice)).contains(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null, false));
assertThat(repository.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null), assertThat(repository.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null, false),
new CoreAccountState(bob, null, null, false, null)); new CoreAccountState(bob, null, null, false, null, false));
assertThat(repository.findState(-1L)).isEmpty(); assertThat(repository.findState(-1L)).isEmpty();
} }
@Test
void findStates_ListedOnlyForAnActivePublicOptedInUserWithAName() {
long listed = insertUser("listed@example.com");
jdbc.update("update users set display_name = 'Listed' where id = ?", listed);
jdbc.update("update user_settings set public_profile = true, show_in_owner_lists = true where user_id = ?", listed);
assertThat(repository.findState(listed)).get().extracting(CoreAccountState::listed).isEqualTo(true);
String[] breakers = {"update users set enabled = false where id = ?",
"update users set deleted_at = now() where id = ?",
"update users set status = 'PENDING' where id = ?",
"update users set display_name = null where id = ?",
"update user_settings set public_profile = false where user_id = ?",
"update user_settings set show_in_owner_lists = false where user_id = ?"};
for (String breaker : breakers) {
jdbc.update("update users set enabled = true, deleted_at = null, status = 'ACTIVE', display_name = 'Listed' where id = ?", listed);
jdbc.update("update user_settings set public_profile = true, show_in_owner_lists = true where user_id = ?", listed);
jdbc.update(breaker, listed);
assertThat(repository.findState(listed)).as(breaker).get().extracting(CoreAccountState::listed).isEqualTo(false);
assertThat(repository.findAllStates()).as(breaker).filteredOn(state -> state.userId() == listed)
.extracting(CoreAccountState::listed).containsExactly(false);
}
}
@Test
void findAllStates_CarryTheListedFlag() {
jdbc.update("update users set display_name = 'Alice' where id = ?", alice);
jdbc.update("update user_settings set public_profile = true, show_in_owner_lists = true where user_id = ?", alice);
assertThat(repository.findAllStates()).extracting(CoreAccountState::listed).containsExactly(true, false);
}
@Test @Test
void findStates_CarryTheRoleChangeTime() { void findStates_CarryTheRoleChangeTime() {
jdbc.update("update users set role_changed_at = ? where id = ?", Timestamp.from(NOW), alice); jdbc.update("update users set role_changed_at = ? where id = ?", Timestamp.from(NOW), alice);
@@ -31,7 +31,7 @@ import static org.mockito.Mockito.when;
class AccountCleanupServiceTest extends AbstractUnitTest { class AccountCleanupServiceTest extends AbstractUnitTest {
private static final List<CoreAccountState> STATES = List.of(new CoreAccountState(1L, 76561198000000001L, "lobo", true, null)); private static final List<CoreAccountState> STATES = List.of(new CoreAccountState(1L, 76561198000000001L, "lobo", true, null, false));
private static final Duration RETENTION = Duration.ofDays(30); private static final Duration RETENTION = Duration.ofDays(30);
@@ -4,6 +4,7 @@ import auth_service.modules.account.exception.InvalidPasswordException;
import auth_service.modules.account.exception.LastAdminException; import auth_service.modules.account.exception.LastAdminException;
import auth_service.modules.account.exception.TooManyAttemptsException; import auth_service.modules.account.exception.TooManyAttemptsException;
import auth_service.modules.account.repo.AccountRepository; import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.account.AccountStatePublisher;
import auth_service.shared.mail.AccountMailer; import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.OpaqueToken; import auth_service.shared.security.OpaqueToken;
import auth_service.shared.test.AbstractUnitTest; import auth_service.shared.test.AbstractUnitTest;
@@ -51,12 +52,15 @@ class AccountServiceTest extends AbstractUnitTest {
@Mock @Mock
private AccountMailer accountMailer; private AccountMailer accountMailer;
@Mock
private AccountStatePublisher accountStatePublisher;
private AccountService service; private AccountService service;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new AccountService(accountRepository, passwordEncoder, coreAccountService, accountMailer, service = new AccountService(accountRepository, passwordEncoder, coreAccountService, accountMailer,
TransactionOperations.withoutTransaction(), Duration.ofDays(30)); accountStatePublisher, TransactionOperations.withoutTransaction(), Duration.ofDays(30));
lenient().when(accountRepository.findPasswordHashOfActive(USER_ID)).thenReturn(Optional.of("hash")); lenient().when(accountRepository.findPasswordHashOfActive(USER_ID)).thenReturn(Optional.of("hash"));
lenient().when(passwordEncoder.matches("right", "hash")).thenReturn(true); lenient().when(passwordEncoder.matches("right", "hash")).thenReturn(true);
lenient().when(accountRepository.flagDeleted(eq(USER_ID), any())).thenReturn(1); lenient().when(accountRepository.flagDeleted(eq(USER_ID), any())).thenReturn(1);
@@ -141,6 +145,7 @@ class AccountServiceTest extends AbstractUnitTest {
assertThatNoException().isThrownBy(() -> service.deleteAccount(USER_ID, "right")); assertThatNoException().isThrownBy(() -> service.deleteAccount(USER_ID, "right"));
verify(accountRepository).flagDeleted(eq(USER_ID), any()); verify(accountRepository).flagDeleted(eq(USER_ID), any());
verify(accountMailer).accountDeleted(eq(USER_ID), any()); verify(accountMailer).accountDeleted(eq(USER_ID), any());
verify(accountStatePublisher).publish(USER_ID);
} }
@Test @Test
@@ -73,8 +73,8 @@ class CoreAccountServiceTest extends AbstractUnitTest {
expect("replaceStates"); expect("replaceStates");
service.applyStates(List.of(new CoreAccountState(42L, 76561198000000001L, "lobo", true, service.applyStates(List.of(new CoreAccountState(42L, 76561198000000001L, "lobo", true,
Instant.parse("2026-10-04T12:00:00Z")))); Instant.parse("2026-10-04T12:00:00Z"), true)));
service.replaceStates(List.of(new CoreAccountState(42L, null, "lobo", false, null))); service.replaceStates(List.of(new CoreAccountState(42L, null, "lobo", false, null, false)));
core.verify(); core.verify();
} }
@@ -5,24 +5,37 @@ import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.test.AbstractUnitTest; import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestClientException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class CoreAccountStatePublisherTest extends AbstractUnitTest { class CoreAccountStatePublisherTest extends AbstractUnitTest {
private static final CoreAccountState STATE = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null); private static final CoreAccountState STATE = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null, false);
@Mock @Mock
private AccountRepository accountRepository; private AccountRepository accountRepository;
@@ -32,10 +45,13 @@ class CoreAccountStatePublisherTest extends AbstractUnitTest {
private CoreAccountStatePublisher publisher; private CoreAccountStatePublisher publisher;
@Mock
private TaskScheduler scheduler;
// Runs each task at once, so the test sees the call. // Runs each task at once, so the test sees the call.
@BeforeEach @BeforeEach
void setUp() { void setUp() {
publisher = new CoreAccountStatePublisher(accountRepository, coreAccountService, Runnable::run); publisher = new CoreAccountStatePublisher(accountRepository, coreAccountService, Runnable::run, scheduler);
} }
@Test @Test
@@ -71,6 +87,58 @@ class CoreAccountStatePublisherTest extends AbstractUnitTest {
assertDoesNotThrow(() -> publisher.publish(7L)); assertDoesNotThrow(() -> publisher.publish(7L));
} }
@Test
void coreDownOnce_RetriesAfter10sAndSendsTheCurrentState() {
CoreAccountState newer = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null, true);
when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE), Optional.of(newer));
doThrow(new RestClientException("Core down")).doNothing().when(coreAccountService).applyStates(any());
ArgumentCaptor<Runnable> retry = ArgumentCaptor.forClass(Runnable.class);
ArgumentCaptor<Instant> at = ArgumentCaptor.forClass(Instant.class);
Instant before = Instant.now();
publisher.publish(7L);
verify(scheduler).schedule(retry.capture(), at.capture());
assertThat(Duration.between(before, at.getValue())).isBetween(Duration.ofSeconds(9), Duration.ofSeconds(11));
retry.getValue().run();
verify(coreAccountService).applyStates(List.of(STATE));
verify(coreAccountService).applyStates(List.of(newer));
verify(accountRepository, times(2)).findState(7L);
}
@Test
void coreDownFourTimes_StopsAfterThreeRetries() {
when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE));
doThrow(new RestClientException("Core down")).when(coreAccountService).applyStates(any());
List<Runnable> tasks = new ArrayList<>();
List<Duration> delays = new ArrayList<>();
doAnswer(call -> {
tasks.add(call.getArgument(0));
delays.add(Duration.between(Instant.now(), call.<Instant>getArgument(1)));
return null;
}).when(scheduler).schedule(any(Runnable.class), any(Instant.class));
publisher.publish(7L);
for (int next = 0; next < 3; next++) {
tasks.get(next).run();
}
assertThat(tasks).hasSize(3);
verify(coreAccountService, times(4)).applyStates(any());
assertThat(delays.get(0)).isBetween(Duration.ofSeconds(9), Duration.ofSeconds(11));
assertThat(delays.get(1)).isBetween(Duration.ofSeconds(55), Duration.ofSeconds(61));
assertThat(delays.get(2)).isBetween(Duration.ofSeconds(295), Duration.ofSeconds(301));
}
@Test
void success_SchedulesNoRetry() {
when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE));
publisher.publish(7L);
verifyNoInteractions(scheduler);
}
@Test @Test
void unknownUser_SendsNothing() { void unknownUser_SendsNothing() {
when(accountRepository.findState(7L)).thenReturn(Optional.empty()); when(accountRepository.findState(7L)).thenReturn(Optional.empty());
@@ -79,4 +147,29 @@ class CoreAccountStatePublisherTest extends AbstractUnitTest {
verify(coreAccountService, never()).applyStates(any()); verify(coreAccountService, never()).applyStates(any());
} }
@Test
void parallelSendsOfOneUser_NeverRunAtTheSameTime() throws Exception {
when(accountRepository.findState(7L)).thenReturn(Optional.of(STATE));
AtomicInteger running = new AtomicInteger();
AtomicInteger mostRunning = new AtomicInteger();
doAnswer(call -> {
mostRunning.accumulateAndGet(running.incrementAndGet(), Math::max);
Thread.sleep(50);
running.decrementAndGet();
return null;
}).when(coreAccountService).applyStates(any());
ExecutorService threads = Executors.newFixedThreadPool(4);
CoreAccountStatePublisher parallel = new CoreAccountStatePublisher(accountRepository, coreAccountService,
threads, scheduler);
for (int i = 0; i < 4; i++) {
parallel.publish(7L);
}
threads.shutdown();
assertThat(threads.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
verify(coreAccountService, times(4)).applyStates(any());
assertThat(mostRunning.get()).isEqualTo(1);
}
} }
@@ -0,0 +1,105 @@
package auth_service.modules.stats.api;
import auth_service.configuration.security.ClientHeaderFilter;
import auth_service.modules.stats.model.UserCountKey;
import auth_service.modules.stats.repo.StatsRepository;
import auth_service.shared.api.AuthCookies;
import auth_service.shared.test.AbstractIntegrationTest;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.web.servlet.MockMvc;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class UserListControllerTest extends AbstractIntegrationTest {
private static final String PASSWORD = "correct horse battery";
private static final String VIEWER = "viewer@owner.test";
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private StatsRepository statsRepository;
private final JsonMapper json = JsonMapper.builder().build();
private void insertUser(String email, String name, String status, boolean enabled, boolean deleted,
boolean publicProfile, boolean optIn, Long steamId) {
Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, status, display_name, steam_id, deleted_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, CASE WHEN ? THEN now() ELSE NULL END) RETURNING id", Long.class,
email, passwordEncoder.encode(PASSWORD), enabled, status, name, steamId, deleted);
jdbc.update("INSERT INTO user_settings (user_id, language, theme, public_profile, show_in_owner_lists) "
+ "VALUES (?, 'en', 'dark', ?, ?)", id, publicProfile, optIn);
}
@BeforeEach
void seed() {
jdbc.update("DELETE FROM users WHERE email LIKE '%@owner.test'");
insertUser(VIEWER, "TheViewer", "ACTIVE", true, false, true, true, 76561198000000001L);
insertUser("listed@owner.test", "VisibleLobo", "ACTIVE", true, false, true, true, 76561198000000002L);
insertUser("a@owner.test", "HiddenOptOut", "ACTIVE", true, false, true, false, 76561198000000003L);
insertUser("b@owner.test", "HiddenNotPublic", "ACTIVE", true, false, false, true, 76561198000000004L);
insertUser("c@owner.test", "HiddenBlocked", "ACTIVE", false, false, true, true, 76561198000000005L);
insertUser("d@owner.test", "HiddenDeleted", "ACTIVE", true, true, true, true, 76561198000000006L);
insertUser("e@owner.test", "HiddenPending", "PENDING", true, false, true, true, 76561198000000007L);
insertUser("f@owner.test", null, "ACTIVE", true, false, true, true, 76561198000000008L);
}
private Cookie login() throws Exception {
Cookie access = mockMvc.perform(post("/api/auth/login").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + VIEWER + "\",\"password\":\"" + PASSWORD + "\"}"))
.andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE);
return new Cookie(AuthCookies.ACCESS_COOKIE, access.getValue());
}
private String list(UserCountKey key) throws Exception {
return mockMvc.perform(get("/api/stats/users").param("key", key.name()).cookie(login()))
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
}
@Test
void withoutLogin_Answers401() throws Exception {
mockMvc.perform(get("/api/stats/users").param("key", "TOTAL_USERS")).andExpect(status().isUnauthorized());
}
@Test
void privateUsersNeverAppearByNameOrIdAndEveryoneIsCounted() throws Exception {
for (UserCountKey key : UserCountKey.values()) {
String body = list(key);
JsonNode root = json.readTree(body);
assertThat(body).doesNotContain("Hidden", "TheViewer", "@owner.test");
List<String> names = new ArrayList<>();
root.get("listed").forEach(person -> {
assertThat(person.propertyNames()).containsExactly("displayName");
names.add(person.get("displayName").asString());
});
assertThat(names).containsExactly("VisibleLobo");
assertThat(root.get("includesYou").asBoolean()).isTrue();
long expected = key == UserCountKey.TOTAL_USERS ? statsRepository.countTotalUsers()
: statsRepository.countUsersWithSteamLinked();
assertThat(root.get("listedTotal").asLong() + root.get("privateCount").asLong() + 1).isEqualTo(expected);
}
}
}
@@ -387,6 +387,33 @@ class UserControllerTest extends AbstractWebMvcTest {
.andExpect(jsonPath("$.settings.statCharts.PLATFORMS").value("PIE")); .andExpect(jsonPath("$.settings.statCharts.PLATFORMS").value("PIE"));
} }
@Test
void getCurrentUser_SendsShowInOwnerLists() throws Exception {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", null), null, Collections.emptyList())
);
when(userService.getUser(1L)).thenReturn(User.builder().email("test@example.com")
.settings(UserSettings.builder().language("en").theme("dark").showInOwnerLists(true).build()).build());
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.settings.showInOwnerLists").value(true));
}
@Test
void updateOwnerLists_Answers204AndPassesTheFlag_MissingEnabledIs400() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/owner-lists").contentType(MediaType.APPLICATION_JSON)
.content("{\"enabled\":true}"))
.andExpect(status().isNoContent());
mockMvc.perform(put("/api/users/settings/owner-lists").contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
verify(userService).updateOwnerLists(1L, true);
}
@Test @Test
void getCurrentUser_SendsWelcomeSeenAtNullForANewUser() throws Exception { void getCurrentUser_SendsWelcomeSeenAtNullForANewUser() throws Exception {
SecurityContextHolder.getContext().setAuthentication( SecurityContextHolder.getContext().setAuthentication(
@@ -253,6 +253,25 @@ class UserRepositoryTest extends AbstractPersistenceTest {
assertTrue(findByEmail("public@example.com").orElseThrow().getSettings().isPublicProfile()); assertTrue(findByEmail("public@example.com").orElseThrow().getSettings().isPublicProfile());
} }
@Test
void showInOwnerLists_IsOffForANewUserAndRoundTrips() {
var userEntity = persisted(UserEntity.builder()
.email("owners@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
entityManager.clear();
assertFalse(findByEmail("owners@example.com").orElseThrow().getSettings().isShowInOwnerLists());
userRepository.update(userEntity.getId(), user -> user.getSettings().setShowInOwnerLists(true));
entityManager.flush();
entityManager.clear();
assertTrue(findByEmail("owners@example.com").orElseThrow().getSettings().isShowInOwnerLists());
}
@Test @Test
void updateSettings_KeepsTheAppearance() { void updateSettings_KeepsTheAppearance() {
var userEntity = persisted(UserEntity.builder() var userEntity = persisted(UserEntity.builder()
@@ -153,7 +153,7 @@ class UserServiceTest extends AbstractUnitTest {
@Test @Test
void updateUserSettings_UnchangedDisplayName_PublishesNothing() { void updateUserSettings_UnchangedDisplayName_PublishesNothing() {
var userSettings = UserSettings.builder().language("en").build(); var userSettings = UserSettings.builder().language("en").build();
when(userRepository.findById(1L)).thenReturn(Optional.of(User.builder().id(1L).displayName("Lobo").build())); when(userRepository.findById(1L)).thenReturn(Optional.of(stored("Lobo", false)));
userService.updateUserSettings(1L, "Lobo", userSettings); userService.updateUserSettings(1L, "Lobo", userSettings);
@@ -161,6 +161,60 @@ class UserServiceTest extends AbstractUnitTest {
verifyNoInteractions(accountStatePublisher); verifyNoInteractions(accountStatePublisher);
} }
private static User stored(String displayName, boolean publicProfile) {
return User.builder().id(1L).displayName(displayName)
.settings(UserSettings.builder().publicProfile(publicProfile).build()).build();
}
@Test
void updateUserSettings_PublicProfileChange_Publishes() {
when(userRepository.findById(1L)).thenReturn(Optional.of(stored("Lobo", true)));
userService.updateUserSettings(1L, "Lobo", UserSettings.builder().language("en").publicProfile(false).build());
verify(accountStatePublisher).publish(1L);
}
@Test
void updateUserSettings_NameRemoval_Publishes() {
when(userRepository.findById(1L)).thenReturn(Optional.of(stored("Lobo", false)));
userService.updateUserSettings(1L, null, UserSettings.builder().language("en").build());
verify(accountStatePublisher).publish(1L);
}
@Test
void updateUserSettings_ThemeOnly_PublishesNothing() {
when(userRepository.findById(1L)).thenReturn(Optional.of(stored("Lobo", true)));
userService.updateUserSettings(1L, "Lobo", UserSettings.builder().theme("light").publicProfile(true).build());
verifyNoInteractions(accountStatePublisher);
}
@Test
@SuppressWarnings("unchecked")
void updateUserSettings_KeepsShowInOwnerLists() {
when(userRepository.findById(1L)).thenReturn(Optional.of(stored("Lobo", true)));
userService.updateUserSettings(1L, "Lobo", UserSettings.builder().publicProfile(true).build());
ArgumentCaptor<Consumer<User>> change = ArgumentCaptor.forClass(Consumer.class);
verify(userRepository).update(eq(1L), change.capture());
User user = User.builder().id(1L).settings(UserSettings.builder().showInOwnerLists(true).build()).build();
change.getValue().accept(user);
assertTrue(user.getSettings().isShowInOwnerLists());
}
@Test
void updateOwnerLists_StoresAndPublishes() {
userService.updateOwnerLists(1L, true);
assertTrue(appliedChanges(1).getFirst().getSettings().isShowInOwnerLists());
verify(accountStatePublisher).publish(1L);
}
private static final long STEAM_ID = 76561198000000001L; private static final long STEAM_ID = 76561198000000001L;
@Test @Test
@@ -37,12 +37,12 @@
"applyStates": { "applyStates": {
"method": "POST", "method": "POST",
"path": "/internal/accounts/states", "path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true, "roleChangedAt": "2026-10-04T12:00:00Z"}]} "request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true, "roleChangedAt": "2026-10-04T12:00:00Z", "listed": true}]}
}, },
"replaceStates": { "replaceStates": {
"method": "PUT", "method": "PUT",
"path": "/internal/accounts/states", "path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null}]} "request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null, "listed": false}]}
}, },
"purge": { "purge": {
"method": "POST", "method": "POST",