From d924c09953cd1d753c2d29ced1239b9338f69753 Mon Sep 17 00:00:00 2001 From: lobo Date: Tue, 6 Oct 2026 13:19:58 +0200 Subject: [PATCH] feat(user): add owner-lists opt-in setting and user listing endpoint --- readme.md | 12 +- .../account/model/CoreAccountState.java | 4 +- .../account/repo/AccountRepository.java | 13 ++- .../account/service/AccountService.java | 9 +- .../service/CoreAccountStatePublisher.java | 42 +++++-- .../modules/stats/api/UserListController.java | 29 +++++ .../modules/stats/api/dto/PeopleDto.java | 14 +++ .../modules/stats/api/mapper/ApiMapper.java | 7 ++ .../modules/stats/model/PeopleCounts.java | 8 ++ .../modules/stats/model/PeopleList.java | 10 ++ .../modules/stats/model/UserCountKey.java | 9 ++ .../modules/stats/repo/StatsRepository.java | 41 ++++++- .../stats/service/UserListService.java | 35 ++++++ .../modules/user/api/UserController.java | 8 ++ .../api/dto/request/OwnerListsRequest.java | 6 + .../api/dto/response/SettingsResponse.java | 1 + .../modules/user/api/mapper/ApiMapper.java | 1 + .../modules/user/model/UserSettings.java | 2 + .../entities/UserSettingsEntity.java | 3 + .../user/persistence/mapper/EntityMapper.java | 2 + .../modules/user/service/UserService.java | 18 ++- .../shared/account/AccountStatePublisher.java | 4 +- .../shared/account/ListedUser.java | 16 +++ .../db/migration/V34__Owner_Lists.sql | 3 + .../api/DeletedAccountListingTest.java | 77 +++++++++++++ .../account/repo/AccountRepositoryTest.java | 38 ++++++- .../service/AccountCleanupServiceTest.java | 2 +- .../account/service/AccountServiceTest.java | 7 +- .../service/CoreAccountServiceTest.java | 4 +- .../CoreAccountStatePublisherTest.java | 97 +++++++++++++++- .../stats/api/UserListControllerTest.java | 105 ++++++++++++++++++ .../modules/user/api/UserControllerTest.java | 27 +++++ .../user/persistence/UserRepositoryTest.java | 19 ++++ .../modules/user/service/UserServiceTest.java | 56 +++++++++- .../contract/auth-core-contract.json | 4 +- 35 files changed, 693 insertions(+), 40 deletions(-) create mode 100644 src/main/java/auth_service/modules/stats/api/UserListController.java create mode 100644 src/main/java/auth_service/modules/stats/api/dto/PeopleDto.java create mode 100644 src/main/java/auth_service/modules/stats/model/PeopleCounts.java create mode 100644 src/main/java/auth_service/modules/stats/model/PeopleList.java create mode 100644 src/main/java/auth_service/modules/stats/model/UserCountKey.java create mode 100644 src/main/java/auth_service/modules/stats/service/UserListService.java create mode 100644 src/main/java/auth_service/modules/user/api/dto/request/OwnerListsRequest.java create mode 100644 src/main/java/auth_service/shared/account/ListedUser.java create mode 100644 src/main/resources/db/migration/V34__Owner_Lists.sql create mode 100644 src/test/java/auth_service/modules/account/api/DeletedAccountListingTest.java create mode 100644 src/test/java/auth_service/modules/stats/api/UserListControllerTest.java diff --git a/readme.md b/readme.md index 0df2103..9faf5f9 100644 --- a/readme.md +++ b/readme.md @@ -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/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_`; `204`) | access token | | `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/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 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 -nor a database connection waits for Core; a failed call only warns. +(`AccountStatePublisher`), also when the display name, the public profile or "show me in owner lists" changes; the nightly +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 sequenceDiagram participant A as Auth participant C as Core A->>A: link / unlink / block / unblock / name change (commit) - A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked, roleChangedAt} - C->>C: blocked_user, account_role_change, Steam ID in user_steam_sync, display name in account_name + A->>C: POST /internal/accounts/states {userId, steamId, displayName, blocked, roleChangedAt, listed} + 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 A->>C: PUT /internal/accounts/states (every user) ``` diff --git a/src/main/java/auth_service/modules/account/model/CoreAccountState.java b/src/main/java/auth_service/modules/account/model/CoreAccountState.java index 03316a5..af0aa44 100644 --- a/src/main/java/auth_service/modules/account/model/CoreAccountState.java +++ b/src/main/java/auth_service/modules/account/model/CoreAccountState.java @@ -6,6 +6,8 @@ import java.time.Instant; * One user as Core copies it. * * @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) { } diff --git a/src/main/java/auth_service/modules/account/repo/AccountRepository.java b/src/main/java/auth_service/modules/account/repo/AccountRepository.java index 88b517b..908a3e9 100644 --- a/src/main/java/auth_service/modules/account/repo/AccountRepository.java +++ b/src/main/java/auth_service/modules/account/repo/AccountRepository.java @@ -4,6 +4,7 @@ import auth_service.modules.account.model.CoreAccountState; import auth_service.shared.account.ActiveAdmins; import auth_service.shared.account.ActiveUser; import auth_service.shared.account.EmailAddress; +import auth_service.shared.account.ListedUser; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; @@ -113,18 +114,22 @@ public class AccountRepository { private static final RowMapper STATE_ROW = (rs, row) -> new CoreAccountState(rs.getLong("id"), rs.getObject("steam_id", Long.class), rs.getString("display_name"), !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 findState(long userId) { - return jdbc.query("SELECT id, steam_id, display_name, enabled, role_changed_at FROM users WHERE id = ?", - STATE_ROW, userId).stream().findFirst(); + return jdbc.query(STATE_SELECT + " WHERE u.id = ?", STATE_ROW, userId).stream().findFirst(); } /** * Every user, also the flagged ones. */ public List 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); } /** diff --git a/src/main/java/auth_service/modules/account/service/AccountService.java b/src/main/java/auth_service/modules/account/service/AccountService.java index 01faea0..62d95d5 100644 --- a/src/main/java/auth_service/modules/account/service/AccountService.java +++ b/src/main/java/auth_service/modules/account/service/AccountService.java @@ -5,6 +5,7 @@ import auth_service.modules.account.exception.LastAdminException; import auth_service.modules.account.exception.TooManyAttemptsException; import auth_service.modules.account.repo.AccountRepository; import auth_service.shared.account.AccountDeleter; +import auth_service.shared.account.AccountStatePublisher; import auth_service.shared.mail.AccountMailer; import auth_service.shared.security.AttemptLimiter; import auth_service.shared.security.OpaqueToken; @@ -38,18 +39,20 @@ public class AccountService implements AccountDeleter { private final PasswordEncoder passwordEncoder; private final CoreAccountService coreAccountService; private final AccountMailer accountMailer; + private final AccountStatePublisher accountStatePublisher; private final TransactionOperations transactions; private final Duration retention; private final AttemptLimiter attemptLimiter = new AttemptLimiter(LOCK); public AccountService(AccountRepository accountRepository, PasswordEncoder passwordEncoder, CoreAccountService coreAccountService, AccountMailer accountMailer, - TransactionOperations transactions, + AccountStatePublisher accountStatePublisher, TransactionOperations transactions, @Value("${app.account-cleanup.retention}") Duration retention) { this.accountRepository = accountRepository; this.passwordEncoder = passwordEncoder; this.coreAccountService = coreAccountService; this.accountMailer = accountMailer; + this.accountStatePublisher = accountStatePublisher; this.transactions = transactions; this.retention = retention; } @@ -133,8 +136,10 @@ public class AccountService implements AccountDeleter { try { coreAccountService.markDeleted(List.of(userId)); } 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()); + // The push carries listed=false and is retried; the nightly cleanup stays the last fallback. + accountStatePublisher.publish(userId); } return true; } diff --git a/src/main/java/auth_service/modules/account/service/CoreAccountStatePublisher.java b/src/main/java/auth_service/modules/account/service/CoreAccountStatePublisher.java index f085f70..c68778c 100644 --- a/src/main/java/auth_service/modules/account/service/CoreAccountStatePublisher.java +++ b/src/main/java/auth_service/modules/account/service/CoreAccountStatePublisher.java @@ -4,30 +4,46 @@ import auth_service.modules.account.repo.AccountRepository; import auth_service.shared.account.AccountStatePublisher; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.scheduling.TaskScheduler; import org.springframework.stereotype.Service; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; +import java.time.Duration; +import java.time.Instant; import java.util.List; import java.util.concurrent.Executor; /** * 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 @Slf4j public class CoreAccountStatePublisher implements AccountStatePublisher { + static final List 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 CoreAccountService coreAccountService; private final Executor executor; + private final TaskScheduler scheduler; public CoreAccountStatePublisher(AccountRepository accountRepository, CoreAccountService coreAccountService, - @Qualifier("applicationTaskExecutor") Executor executor) { + @Qualifier("applicationTaskExecutor") Executor executor, + @Qualifier("taskScheduler") TaskScheduler scheduler) { this.accountRepository = accountRepository; this.coreAccountService = coreAccountService; this.executor = executor; + this.scheduler = scheduler; + for (int i = 0; i < LOCK_STRIPES; i++) { + sendLocks[i] = new Object(); + } } @Override @@ -36,20 +52,30 @@ public class CoreAccountStatePublisher implements AccountStatePublisher { TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { - executor.execute(() -> send(userId)); + executor.execute(() -> send(userId, 0)); } }); } else { - executor.execute(() -> send(userId)); + executor.execute(() -> send(userId, 0)); } } - private void send(long userId) { + private void send(long userId, int attempt) { 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) { - log.warn("Core did not take the state of user {}, the nightly cleanup sends it again: {}", userId, - e.getMessage()); + if (attempt < RETRY_DELAYS.size()) { + 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()); + } } } } diff --git a/src/main/java/auth_service/modules/stats/api/UserListController.java b/src/main/java/auth_service/modules/stats/api/UserListController.java new file mode 100644 index 0000000..561aaf7 --- /dev/null +++ b/src/main/java/auth_service/modules/stats/api/UserListController.java @@ -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 getUsers(@AuthenticationPrincipal UserPrincipal principal, + @RequestParam UserCountKey key, + @RequestParam(defaultValue = "0") int page) { + return ResponseEntity.ok(ApiMapper.toApi(userListService.users(principal.getIdAsLong(), key, page))); + } +} diff --git a/src/main/java/auth_service/modules/stats/api/dto/PeopleDto.java b/src/main/java/auth_service/modules/stats/api/dto/PeopleDto.java new file mode 100644 index 0000000..c0877c6 --- /dev/null +++ b/src/main/java/auth_service/modules/stats/api/dto/PeopleDto.java @@ -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 listed, long listedTotal, long privateCount, boolean includesYou, int page, + int totalPages) { + + public record Person(String displayName) { + } +} diff --git a/src/main/java/auth_service/modules/stats/api/mapper/ApiMapper.java b/src/main/java/auth_service/modules/stats/api/mapper/ApiMapper.java index 62429f3..6e05acc 100644 --- a/src/main/java/auth_service/modules/stats/api/mapper/ApiMapper.java +++ b/src/main/java/auth_service/modules/stats/api/mapper/ApiMapper.java @@ -1,12 +1,19 @@ 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.model.PeopleList; import auth_service.modules.stats.model.PublicStats; public abstract class 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) { return new PublicStatsDto(stats.totalUsers(), stats.usersWithSteamLinked()); } diff --git a/src/main/java/auth_service/modules/stats/model/PeopleCounts.java b/src/main/java/auth_service/modules/stats/model/PeopleCounts.java new file mode 100644 index 0000000..8ccfe13 --- /dev/null +++ b/src/main/java/auth_service/modules/stats/model/PeopleCounts.java @@ -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) { +} diff --git a/src/main/java/auth_service/modules/stats/model/PeopleList.java b/src/main/java/auth_service/modules/stats/model/PeopleList.java new file mode 100644 index 0000000..c0fa7f5 --- /dev/null +++ b/src/main/java/auth_service/modules/stats/model/PeopleList.java @@ -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 listed, long listedTotal, long privateCount, boolean includesYou, int page, + int totalPages) { +} diff --git a/src/main/java/auth_service/modules/stats/model/UserCountKey.java b/src/main/java/auth_service/modules/stats/model/UserCountKey.java new file mode 100644 index 0000000..3e4d111 --- /dev/null +++ b/src/main/java/auth_service/modules/stats/model/UserCountKey.java @@ -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 +} diff --git a/src/main/java/auth_service/modules/stats/repo/StatsRepository.java b/src/main/java/auth_service/modules/stats/repo/StatsRepository.java index 2d6d00f..8972fb8 100644 --- a/src/main/java/auth_service/modules/stats/repo/StatsRepository.java +++ b/src/main/java/auth_service/modules/stats/repo/StatsRepository.java @@ -1,13 +1,27 @@ 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.ListedUser; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Map; + @Repository public class StatsRepository { + // One WHERE per number: the count and its list read the same set. + private static final Map 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; @Autowired @@ -16,13 +30,32 @@ public class StatsRepository { } public long countTotalUsers() { - Long v = jdbc.queryForObject("SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u"), Long.class); - return v != null ? v : 0L; + return count(UserCountKey.TOTAL_USERS); } public long countUsersWithSteamLinked() { - Long v = jdbc.queryForObject( - "SELECT COUNT(*) FROM users u WHERE " + ActiveUser.of("u") + " AND u.steam_id IS NOT NULL", Long.class); + return count(UserCountKey.STEAM_LINKED); + } + + private long count(UserCountKey key) { + Long v = jdbc.queryForObject("SELECT COUNT(*)" + FROM + SETS.get(key), Long.class); 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 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); + } } diff --git a/src/main/java/auth_service/modules/stats/service/UserListService.java b/src/main/java/auth_service/modules/stats/service/UserListService.java new file mode 100644 index 0000000..f3b3f9f --- /dev/null +++ b/src/main/java/auth_service/modules/stats/service/UserListService.java @@ -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 names = statsRepository.listedUserNames(key, viewerId, clamped * PAGE_SIZE, PAGE_SIZE); + return new PeopleList(names, counts.listed(), counts.others() - counts.listed(), counts.includesYou(), clamped, + totalPages); + } +} diff --git a/src/main/java/auth_service/modules/user/api/UserController.java b/src/main/java/auth_service/modules/user/api/UserController.java index e77e46d..a1baa57 100644 --- a/src/main/java/auth_service/modules/user/api/UserController.java +++ b/src/main/java/auth_service/modules/user/api/UserController.java @@ -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.DashboardTilesRequest; 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.ProfileStyleRequest; import auth_service.modules.user.api.dto.request.StatChartRequest; @@ -72,6 +73,13 @@ public class UserController { return ResponseEntity.noContent().build(); } + @PutMapping(value = "/settings/owner-lists") + public ResponseEntity updateOwnerLists(@AuthenticationPrincipal UserPrincipal principal, + @Valid @RequestBody OwnerListsRequest request) { + this.userService.updateOwnerLists(principal.getIdAsLong(), request.enabled()); + return ResponseEntity.noContent().build(); + } + @PutMapping(value = "/settings/welcome-seen") public ResponseEntity markWelcomeSeen(@AuthenticationPrincipal UserPrincipal principal) { this.userService.markWelcomeSeen(principal.getIdAsLong()); diff --git a/src/main/java/auth_service/modules/user/api/dto/request/OwnerListsRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/OwnerListsRequest.java new file mode 100644 index 0000000..d971f26 --- /dev/null +++ b/src/main/java/auth_service/modules/user/api/dto/request/OwnerListsRequest.java @@ -0,0 +1,6 @@ +package auth_service.modules.user.api.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record OwnerListsRequest(@NotNull Boolean enabled) { +} diff --git a/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java b/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java index b4b8e66..caa2d11 100644 --- a/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java +++ b/src/main/java/auth_service/modules/user/api/dto/response/SettingsResponse.java @@ -8,6 +8,7 @@ import java.util.Map; @Builder public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile, + boolean showInOwnerLists, AppearanceResponse appearance, ProfileStyleResponse profileStyle, List dashboardTiles, List profileTiles, Map statCharts, String favoritePlatform, Instant welcomeSeenAt) { diff --git a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java index 3949bcf..09f62ba 100644 --- a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java +++ b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java @@ -42,6 +42,7 @@ public abstract class ApiMapper { .useDarkTheme(user.getSettings().getTheme().equals("dark")) .language(user.getSettings().getLanguage()) .publicProfile(user.getSettings().isPublicProfile()) + .showInOwnerLists(user.getSettings().isShowInOwnerLists()) .appearance(toApi(user.getSettings().getAppearance())) .profileStyle(toApi(user.getSettings().getProfileStyle())) .dashboardTiles(user.getSettings().getDashboardTiles() == null ? null diff --git a/src/main/java/auth_service/modules/user/model/UserSettings.java b/src/main/java/auth_service/modules/user/model/UserSettings.java index 9312878..803d216 100644 --- a/src/main/java/auth_service/modules/user/model/UserSettings.java +++ b/src/main/java/auth_service/modules/user/model/UserSettings.java @@ -13,6 +13,8 @@ public class UserSettings { private String language; private String theme; private boolean publicProfile; + /** Listed by name only together with publicProfile, see ListedUser. */ + private boolean showInOwnerLists; @Builder.Default private Appearance appearance = Appearance.DEFAULT; @Builder.Default diff --git a/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java b/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java index 65cebb6..6c21181 100644 --- a/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java +++ b/src/main/java/auth_service/modules/user/persistence/entities/UserSettingsEntity.java @@ -32,6 +32,9 @@ public class UserSettingsEntity { @Column(name = "public_profile", nullable = false) private boolean publicProfile; + @Column(name = "show_in_owner_lists", nullable = false) + private boolean showInOwnerLists; + @Column(nullable = false) private String accent; diff --git a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java index 3c6acef..6226644 100644 --- a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java +++ b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java @@ -28,6 +28,7 @@ public abstract class EntityMapper { .language(settings.getLanguage()) .theme(settings.getTheme()) .publicProfile(settings.isPublicProfile()) + .showInOwnerLists(settings.isShowInOwnerLists()) .appearance(new Appearance(settings.getAccent(), settings.getCorners(), settings.isWideLayout(), settings.getBackgroundAppid(), settings.getBannerAppid(), settings.isShowBanner(), settings.isReduceMotion())) @@ -61,6 +62,7 @@ public abstract class EntityMapper { target.setLanguage(settings.getLanguage()); target.setTheme(settings.getTheme()); target.setPublicProfile(settings.isPublicProfile()); + target.setShowInOwnerLists(settings.isShowInOwnerLists()); Appearance appearance = settings.getAppearance(); target.setAccent(appearance.accent()); target.setCorners(appearance.corners()); diff --git a/src/main/java/auth_service/modules/user/service/UserService.java b/src/main/java/auth_service/modules/user/service/UserService.java index 964ceb5..c4a098e 100644 --- a/src/main/java/auth_service/modules/user/service/UserService.java +++ b/src/main/java/auth_service/modules/user/service/UserService.java @@ -26,6 +26,7 @@ import java.time.Instant; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; @Slf4j @@ -53,15 +54,18 @@ public class UserService { if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) { throw new DisplayNameAlreadyInUseException("The display name is already in use."); } - boolean renamed = displayName != null && userRepository.findById(userId) - .map(user -> !displayName.equals(user.getDisplayName())).orElse(true); + // The listing in Core depends on the name and the public profile. + 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 -> { user.setDisplayName(displayName); user.getSettings().setLanguage(userSettings.getLanguage()); user.getSettings().setTheme(userSettings.getTheme()); user.getSettings().setPublicProfile(userSettings.isPublicProfile()); }); - if (renamed) { + if (listingChanged) { accountStatePublisher.publish(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())); } + @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) @Transactional @CacheEvict(value = "users", key = "#userId") diff --git a/src/main/java/auth_service/shared/account/AccountStatePublisher.java b/src/main/java/auth_service/shared/account/AccountStatePublisher.java index b74c5dd..fab210f 100644 --- a/src/main/java/auth_service/shared/account/AccountStatePublisher.java +++ b/src/main/java/auth_service/shared/account/AccountStatePublisher.java @@ -1,8 +1,8 @@ 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, - * because the nightly cleanup sends every user again. + * Port for a change Core copies: the Steam ID or the block of a user. Sends after the commit; a failure is retried 3 + * times, then the nightly cleanup sends every user again. */ public interface AccountStatePublisher { void publish(long userId); diff --git a/src/main/java/auth_service/shared/account/ListedUser.java b/src/main/java/auth_service/shared/account/ListedUser.java new file mode 100644 index 0000000..f991020 --- /dev/null +++ b/src/main/java/auth_service/shared/account/ListedUser.java @@ -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"; + } +} diff --git a/src/main/resources/db/migration/V34__Owner_Lists.sql b/src/main/resources/db/migration/V34__Owner_Lists.sql new file mode 100644 index 0000000..a37036e --- /dev/null +++ b/src/main/resources/db/migration/V34__Owner_Lists.sql @@ -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; diff --git a/src/test/java/auth_service/modules/account/api/DeletedAccountListingTest.java b/src/test/java/auth_service/modules/account/api/DeletedAccountListingTest.java new file mode 100644 index 0000000..a3cc943 --- /dev/null +++ b/src/test/java/auth_service/modules/account/api/DeletedAccountListingTest.java @@ -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); + } +} diff --git a/src/test/java/auth_service/modules/account/repo/AccountRepositoryTest.java b/src/test/java/auth_service/modules/account/repo/AccountRepositoryTest.java index 48e6544..9132283 100644 --- a/src/test/java/auth_service/modules/account/repo/AccountRepositoryTest.java +++ b/src/test/java/auth_service/modules/account/repo/AccountRepositoryTest.java @@ -56,12 +56,44 @@ class AccountRepositoryTest extends AbstractPersistenceTest { void findStates_CarryTheSteamIdTheDisplayNameAndTheBlock() { 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.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null), - new CoreAccountState(bob, null, null, false, 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, false), + new CoreAccountState(bob, null, null, false, null, false)); 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 void findStates_CarryTheRoleChangeTime() { jdbc.update("update users set role_changed_at = ? where id = ?", Timestamp.from(NOW), alice); diff --git a/src/test/java/auth_service/modules/account/service/AccountCleanupServiceTest.java b/src/test/java/auth_service/modules/account/service/AccountCleanupServiceTest.java index 2722a9a..6f46665 100644 --- a/src/test/java/auth_service/modules/account/service/AccountCleanupServiceTest.java +++ b/src/test/java/auth_service/modules/account/service/AccountCleanupServiceTest.java @@ -31,7 +31,7 @@ import static org.mockito.Mockito.when; class AccountCleanupServiceTest extends AbstractUnitTest { - private static final List STATES = List.of(new CoreAccountState(1L, 76561198000000001L, "lobo", true, null)); + private static final List STATES = List.of(new CoreAccountState(1L, 76561198000000001L, "lobo", true, null, false)); private static final Duration RETENTION = Duration.ofDays(30); diff --git a/src/test/java/auth_service/modules/account/service/AccountServiceTest.java b/src/test/java/auth_service/modules/account/service/AccountServiceTest.java index 0d60109..12d4da0 100644 --- a/src/test/java/auth_service/modules/account/service/AccountServiceTest.java +++ b/src/test/java/auth_service/modules/account/service/AccountServiceTest.java @@ -4,6 +4,7 @@ import auth_service.modules.account.exception.InvalidPasswordException; import auth_service.modules.account.exception.LastAdminException; import auth_service.modules.account.exception.TooManyAttemptsException; import auth_service.modules.account.repo.AccountRepository; +import auth_service.shared.account.AccountStatePublisher; import auth_service.shared.mail.AccountMailer; import auth_service.shared.security.OpaqueToken; import auth_service.shared.test.AbstractUnitTest; @@ -51,12 +52,15 @@ class AccountServiceTest extends AbstractUnitTest { @Mock private AccountMailer accountMailer; + @Mock + private AccountStatePublisher accountStatePublisher; + private AccountService service; @BeforeEach void setUp() { 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(passwordEncoder.matches("right", "hash")).thenReturn(true); 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")); verify(accountRepository).flagDeleted(eq(USER_ID), any()); verify(accountMailer).accountDeleted(eq(USER_ID), any()); + verify(accountStatePublisher).publish(USER_ID); } @Test diff --git a/src/test/java/auth_service/modules/account/service/CoreAccountServiceTest.java b/src/test/java/auth_service/modules/account/service/CoreAccountServiceTest.java index 55ba3d8..f769213 100644 --- a/src/test/java/auth_service/modules/account/service/CoreAccountServiceTest.java +++ b/src/test/java/auth_service/modules/account/service/CoreAccountServiceTest.java @@ -73,8 +73,8 @@ class CoreAccountServiceTest extends AbstractUnitTest { expect("replaceStates"); service.applyStates(List.of(new CoreAccountState(42L, 76561198000000001L, "lobo", true, - Instant.parse("2026-10-04T12:00:00Z")))); - service.replaceStates(List.of(new CoreAccountState(42L, null, "lobo", false, null))); + Instant.parse("2026-10-04T12:00:00Z"), true))); + service.replaceStates(List.of(new CoreAccountState(42L, null, "lobo", false, null, false))); core.verify(); } diff --git a/src/test/java/auth_service/modules/account/service/CoreAccountStatePublisherTest.java b/src/test/java/auth_service/modules/account/service/CoreAccountStatePublisherTest.java index 65e7c76..412ec82 100644 --- a/src/test/java/auth_service/modules/account/service/CoreAccountStatePublisherTest.java +++ b/src/test/java/auth_service/modules/account/service/CoreAccountStatePublisherTest.java @@ -5,24 +5,37 @@ import auth_service.modules.account.repo.AccountRepository; import auth_service.shared.test.AbstractUnitTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import org.springframework.scheduling.TaskScheduler; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; 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.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.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; 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.when; 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 private AccountRepository accountRepository; @@ -32,10 +45,13 @@ class CoreAccountStatePublisherTest extends AbstractUnitTest { private CoreAccountStatePublisher publisher; + @Mock + private TaskScheduler scheduler; + // Runs each task at once, so the test sees the call. @BeforeEach void setUp() { - publisher = new CoreAccountStatePublisher(accountRepository, coreAccountService, Runnable::run); + publisher = new CoreAccountStatePublisher(accountRepository, coreAccountService, Runnable::run, scheduler); } @Test @@ -71,6 +87,58 @@ class CoreAccountStatePublisherTest extends AbstractUnitTest { 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 retry = ArgumentCaptor.forClass(Runnable.class); + ArgumentCaptor 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 tasks = new ArrayList<>(); + List delays = new ArrayList<>(); + doAnswer(call -> { + tasks.add(call.getArgument(0)); + delays.add(Duration.between(Instant.now(), call.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 void unknownUser_SendsNothing() { when(accountRepository.findState(7L)).thenReturn(Optional.empty()); @@ -79,4 +147,29 @@ class CoreAccountStatePublisherTest extends AbstractUnitTest { 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); + } } diff --git a/src/test/java/auth_service/modules/stats/api/UserListControllerTest.java b/src/test/java/auth_service/modules/stats/api/UserListControllerTest.java new file mode 100644 index 0000000..e082037 --- /dev/null +++ b/src/test/java/auth_service/modules/stats/api/UserListControllerTest.java @@ -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 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); + } + } +} diff --git a/src/test/java/auth_service/modules/user/api/UserControllerTest.java b/src/test/java/auth_service/modules/user/api/UserControllerTest.java index d8acd2d..fad843e 100644 --- a/src/test/java/auth_service/modules/user/api/UserControllerTest.java +++ b/src/test/java/auth_service/modules/user/api/UserControllerTest.java @@ -387,6 +387,33 @@ class UserControllerTest extends AbstractWebMvcTest { .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 void getCurrentUser_SendsWelcomeSeenAtNullForANewUser() throws Exception { SecurityContextHolder.getContext().setAuthentication( diff --git a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java index 26a8e28..334d4d3 100644 --- a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java +++ b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java @@ -253,6 +253,25 @@ class UserRepositoryTest extends AbstractPersistenceTest { 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 void updateSettings_KeepsTheAppearance() { var userEntity = persisted(UserEntity.builder() diff --git a/src/test/java/auth_service/modules/user/service/UserServiceTest.java b/src/test/java/auth_service/modules/user/service/UserServiceTest.java index f00ae25..931e3fa 100644 --- a/src/test/java/auth_service/modules/user/service/UserServiceTest.java +++ b/src/test/java/auth_service/modules/user/service/UserServiceTest.java @@ -153,7 +153,7 @@ class UserServiceTest extends AbstractUnitTest { @Test void updateUserSettings_UnchangedDisplayName_PublishesNothing() { 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); @@ -161,6 +161,60 @@ class UserServiceTest extends AbstractUnitTest { 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> 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; @Test diff --git a/src/test/resources/contract/auth-core-contract.json b/src/test/resources/contract/auth-core-contract.json index c24e2a8..5fd06af 100644 --- a/src/test/resources/contract/auth-core-contract.json +++ b/src/test/resources/contract/auth-core-contract.json @@ -37,12 +37,12 @@ "applyStates": { "method": "POST", "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": { "method": "PUT", "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": { "method": "POST",