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/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `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)
```
@@ -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) {
}
@@ -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<CoreAccountState> 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<CoreAccountState> 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<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.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;
}
@@ -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<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 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());
}
}
}
}
@@ -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;
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());
}
@@ -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;
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<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;
@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<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.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<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")
public ResponseEntity<Void> markWelcomeSeen(@AuthenticationPrincipal UserPrincipal principal) {
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
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
boolean showInOwnerLists,
AppearanceResponse appearance, ProfileStyleResponse profileStyle,
List<TileResponse> dashboardTiles, List<TileResponse> profileTiles,
Map<String, String> statCharts, String favoritePlatform, Instant welcomeSeenAt) {
@@ -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
@@ -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
@@ -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;
@@ -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());
@@ -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")
@@ -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);
@@ -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() {
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);
@@ -31,7 +31,7 @@ import static org.mockito.Mockito.when;
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);
@@ -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
@@ -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();
}
@@ -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<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
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);
}
}
@@ -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"));
}
@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(
@@ -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()
@@ -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<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;
@Test
@@ -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",