feat(audit): add audit log endpoint and display names from Auth
Build, Test & Push Core Service / quality-check (push) Successful in 1m14s
Build, Test & Push Core Service / docker (push) Successful in 1m20s

GET /api/library/audit-log for auditors and admins, paged, filter by
action. The account state push stores displayName in account_name so
the log shows names. Contract test updated.
This commit is contained in:
lobo committed 2026-10-02 09:10:56 +02:00
1 parent 221b5594c5
commit a2ea1e45d3
16 files changed
+330 -23

No files matched your search

+8 -5
View File
@@ -72,7 +72,8 @@ To Be Played is split into three repositories:
- **Editions and links**: editions, remasters and ports of one game stay separate library rows, but an AUDITOR can - **Editions and links**: editions, remasters and ports of one game stay separate library rows, but an AUDITOR can
link their keys as one game for every user (see "Editions of one game"). IGDB suggests pairs, users can ask for link their keys as one game for every user (see "Editions of one game"). IGDB suggests pairs, users can ask for
one, and one queue holds both. Every link, unlink and dismissal goes to `admin_audit` one, and one queue holds both. Every link, unlink and dismissal goes to `admin_audit`
(`GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`). (`GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`); auditors read that log through `GET /api/library/audit-log`, with
the actor's display name from Auth's account state push.
- **Wishlist**: synced from Steam with store price and discount. - **Wishlist**: synced from Steam with store price and discount.
- **Broken image repair**: the frontend reports a Steam cover or achievement icon that does not load. At 01:30 - **Broken image repair**: the frontend reports a Steam cover or achievement icon that does not load. At 01:30
(Europe/Berlin) a job checks up to 200 reports. A cover that loads again is left alone; else the store's hashed (Europe/Berlin) a job checks up to 200 reports. A cover that loads again is left alone; else the store's hashed
@@ -138,6 +139,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR | | `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
| `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR | | `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
| `POST`, `DELETE` | `/api/library/games/{appid}/link` (POST `{otherAppid}`, `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED`; DELETE takes the game out of its set, `204`, `404 NOT_LINKED`) | JWT, AUDITOR | | `POST`, `DELETE` | `/api/library/games/{appid}/link` (POST `{otherAppid}`, `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED`; DELETE takes the game out of its set, `204`, `404 NOT_LINKED`) | JWT, AUDITOR |
| `GET` | `/api/library/audit-log?action=&page=&size=` (the newest `admin_audit` rows, `PageDto` of `{id, actorUserId, actorName, action, appid, gameName, targetAppid, targetGameName, detail, createdAt}`; `action` is `GAME_MERGE`, `GAME_LINK`, `GAME_UNLINK` or `GAME_LINK_DISMISS`; size 20, 40 or 80) | JWT, AUDITOR |
| `GET` | `/api/library/link-queue?page=&size=` (`PageDto` of `{a, b, igdb, requests, createdAt}`: both games, most requested first; size 20, 40 or 80) | JWT, AUDITOR | | `GET` | `/api/library/link-queue?page=&size=` (`PageDto` of `{a, b, igdb, requests, createdAt}`: both games, most requested first; size 20, 40 or 80) | JWT, AUDITOR |
| `DELETE` | `/api/library/link-queue/{appid}/{otherAppid}` (dismiss; `204`, `404 PAIR_NOT_OPEN`) | JWT, AUDITOR | | `DELETE` | `/api/library/link-queue/{appid}/{otherAppid}` (dismiss; `204`, `404 PAIR_NOT_OPEN`) | JWT, AUDITOR |
| `POST` | `/api/library/games/{appid}/link-requests` (`{otherAppid}`, both in the caller's library; `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED` / `PAIR_DISMISSED`, `429 TOO_MANY_REQUESTS`) | JWT | | `POST` | `/api/library/games/{appid}/link-requests` (`{otherAppid}`, both in the caller's library; `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED` / `PAIR_DISMISSED`, `429 TOO_MANY_REQUESTS`) | JWT |
@@ -159,7 +161,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket | | `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket |
| `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public | | `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public |
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token | | `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, blocked}]}`; POST updates, PUT replaces the whole set; `204`) | service token | | `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked}]}`, `displayName` optional, at most 100 characters, kept in `account_name` for the audit log; POST updates, PUT replaces the whole set; `204`) | service token |
| `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token | | `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token |
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public | | `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
@@ -208,7 +210,7 @@ flowchart LR
LibRepo --> Db[("PostgreSQL tbp_core")] LibRepo --> Db[("PostgreSQL tbp_core")]
Stats -->|"listed_user_game, counted_user_game, game_mood, user_game_status_change, deleted_user, blocked_user"| Db Stats -->|"listed_user_game, counted_user_game, game_mood, user_game_status_change, deleted_user, blocked_user"| Db
Auth -->|"service token<br/>/internal/accounts"| Account Auth -->|"service token<br/>/internal/accounts"| Account
Account -->|"per-user rows, deleted_user, blocked_user, user_seen_at"| Db Account -->|"per-user rows, deleted_user, blocked_user, account_name, user_seen_at"| Db
``` ```
| Module | Content | | Module | Content |
@@ -216,7 +218,7 @@ flowchart LR
| `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data | | `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data |
| `stats` | Public numbers for the landing page | | `stats` | Public numbers for the landing page |
| `version` | `GET /api/version`: commit and build time of the running image | | `version` | `GET /api/version`: commit and build time of the running image |
| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, Steam ID changes stop the nightly sync | | `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, display names in `account_name`, Steam ID changes stop the nightly sync |
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures | | `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search | | `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs | | `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
@@ -365,11 +367,12 @@ gets no more syncs and leaves the landing stats and the pile snapshots at once.
flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned ones), then `prune` with all its flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned ones), then `prune` with all its
user ids, which removes the rows of users Auth does not know (refused for more than user ids, which removes the rows of users Auth does not know (refused for more than
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows. `app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
The purge also removes the user's `account_name` row and link requests (`game_link_request`).
### Blocked users and Steam ID changes ### Blocked users and Steam ID changes
Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account
took by a verified link, a block and an unblock; its nightly job sends every user (`PUT`). A blocked user is in took by a verified link, a display name change, a block and an unblock; its nightly job sends every user (`PUT`). A blocked user is in
`blocked_user`: no nightly sync, no new sync run, not in the landing stats. The access token still works in Core until `blocked_user`: no nightly sync, no new sync run, not in the landing stats. The access token still works in Core until
it expires (max 15 min). A new Steam ID or no Steam ID takes the user out of the nightly sync; the next visit syncs it expires (max 15 min). A new Steam ID or no Steam ID takes the user out of the nightly sync; the next visit syncs
the ID of the token (Core's copy changes only then, so that sync sees the change and drops the old account's games). A visit with an access token that still has the old ID (max 15 min) can sync it once the ID of the token (Core's copy changes only then, so that sync sees the change and drops the old account's games). A visit with an access token that still has the old ID (max 15 min) can sync it once
@@ -64,7 +64,7 @@ public class InternalAccountController {
private static List<AccountState> toStates(AccountStatesRequest request) { private static List<AccountState> toStates(AccountStatesRequest request) {
return request.accounts().stream() return request.accounts().stream()
.map(account -> new AccountState(account.userId(), account.steamId(), account.blocked())) .map(account -> new AccountState(account.userId(), account.steamId(), account.displayName(), account.blocked()))
.toList(); .toList();
} }
@@ -2,11 +2,16 @@ package core_service.modules.account.api.dto;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List; import java.util.List;
public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> accounts) { public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> accounts) {
public record AccountStateDto(@NotNull Long userId, Long steamId, boolean blocked) { /**
* @param displayName null from an Auth that does not send it yet
*/
public record AccountStateDto(@NotNull Long userId, Long steamId, @Size(max = 100) String displayName,
boolean blocked) {
} }
} }
@@ -4,6 +4,7 @@ package core_service.modules.account.domain;
* What Core copies from Auth for one user. * What Core copies from Auth for one user.
* *
* @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link) * @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link)
* @param displayName null when Auth did not send one; Core then keeps the stored name
*/ */
public record AccountState(long userId, Long steamId, boolean blocked) { public record AccountState(long userId, Long steamId, String displayName, boolean blocked) {
} }
@@ -10,8 +10,8 @@ import java.util.Collection;
import java.util.List; import java.util.List;
/** /**
* Blocks in blocked_user, and what a Steam ID change means for user_steam_sync. Plain SQL, because the library * Blocks in blocked_user, the display name in account_name, and what a Steam ID change means for user_steam_sync.
* module owns the entities. * Plain SQL, because the library module owns the entities.
*/ */
@Repository @Repository
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -22,6 +22,11 @@ public class AccountStateRepository {
public void apply(AccountState state) { public void apply(AccountState state) {
MapSqlParameterSource params = new MapSqlParameterSource("userId", state.userId()) MapSqlParameterSource params = new MapSqlParameterSource("userId", state.userId())
.addValue("steamId", state.steamId() == null ? null : String.valueOf(state.steamId())); .addValue("steamId", state.steamId() == null ? null : String.valueOf(state.steamId()));
if (state.displayName() != null) {
jdbc.update("insert into account_name (user_id, display_name) values (:userId, :displayName) "
+ "on conflict (user_id) do update set display_name = excluded.display_name",
params.addValue("displayName", state.displayName()));
}
if (state.blocked()) { if (state.blocked()) {
jdbc.update("insert into blocked_user (user_id) values (:userId) on conflict do nothing", params); jdbc.update("insert into blocked_user (user_id) values (:userId) on conflict do nothing", params);
} else { } else {
@@ -4,6 +4,7 @@ import core_service.shared.api.ErrorResponse;
import core_service.modules.library.api.dto.AchievementSummaryDto; import core_service.modules.library.api.dto.AchievementSummaryDto;
import core_service.modules.library.api.dto.ActivityDto; import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.BacklogPatch; import core_service.modules.library.api.dto.BacklogPatch;
import core_service.modules.library.api.dto.AuditEntryDto;
import core_service.modules.library.api.dto.BrokenImageReport; import core_service.modules.library.api.dto.BrokenImageReport;
import core_service.modules.library.api.dto.CatalogGameDto; import core_service.modules.library.api.dto.CatalogGameDto;
import core_service.modules.library.api.dto.CorrectionDto; import core_service.modules.library.api.dto.CorrectionDto;
@@ -22,11 +23,13 @@ import core_service.modules.library.api.dto.SteamProfileDto;
import core_service.modules.library.api.dto.SyncStatusDto; import core_service.modules.library.api.dto.SyncStatusDto;
import core_service.modules.library.api.dto.UserStatsDto; import core_service.modules.library.api.dto.UserStatsDto;
import core_service.modules.library.api.dto.WishlistDto; import core_service.modules.library.api.dto.WishlistDto;
import core_service.modules.library.domain.AuditEntry;
import core_service.modules.library.domain.CatalogGame; import core_service.modules.library.domain.CatalogGame;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.LinkPair; import core_service.modules.library.domain.LinkPair;
import core_service.modules.library.domain.Mood; import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.StatGroup; import core_service.modules.library.domain.StatGroup;
import core_service.modules.library.service.AuditLogService;
import core_service.modules.library.service.BacklogService; import core_service.modules.library.service.BacklogService;
import core_service.modules.library.service.GameCorrectionService; import core_service.modules.library.service.GameCorrectionService;
import core_service.modules.library.service.GameDetailService; import core_service.modules.library.service.GameDetailService;
@@ -90,6 +93,7 @@ public class LibraryController {
private final GameCorrectionService gameCorrectionService; private final GameCorrectionService gameCorrectionService;
private final GameMergeService gameMergeService; private final GameMergeService gameMergeService;
private final GameLinkService gameLinkService; private final GameLinkService gameLinkService;
private final AuditLogService auditLogService;
@GetMapping("/games-count") @GetMapping("/games-count")
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
@@ -267,6 +271,14 @@ public class LibraryController {
}; };
} }
@GetMapping("/audit-log")
public ResponseEntity<PageDto<AuditEntryDto>> getAuditLog(@RequestParam(required = false) String action,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
return ResponseEntity.ok(PageDto.of(auditLogService.newest(action, PageRequest.of(Math.max(0, page), safeSize)).map(LibraryController::toDto)));
}
private static LinkPairDto toDto(LinkPair pair) { private static LinkPairDto toDto(LinkPair pair) {
return new LinkPairDto(toDto(pair.a()), toDto(pair.b()), pair.igdb(), pair.requests(), pair.createdAt()); return new LinkPairDto(toDto(pair.a()), toDto(pair.b()), pair.igdb(), pair.requests(), pair.createdAt());
} }
@@ -275,6 +287,11 @@ public class LibraryController {
return new CatalogGameDto(game.appid(), game.name(), game.imageUrl()); return new CatalogGameDto(game.appid(), game.name(), game.imageUrl());
} }
private static AuditEntryDto toDto(AuditEntry entry) {
return new AuditEntryDto(entry.id(), entry.actorUserId(), entry.actorName(), entry.action(), entry.appid(),
entry.gameName(), entry.targetAppid(), entry.targetGameName(), entry.detail(), entry.createdAt());
}
private static ResponseEntity<?> sameGame() { private static ResponseEntity<?> sameGame() {
return ResponseEntity.badRequest().body(new ErrorResponse("SAME_GAME", "A game cannot be linked with itself")); return ResponseEntity.badRequest().body(new ErrorResponse("SAME_GAME", "A game cannot be linked with itself"));
} }
@@ -0,0 +1,10 @@
package core_service.modules.library.api.dto;
import java.time.Instant;
/**
* @param actorName null when Auth never sent the actor's display name
* @param gameName null when the key has no catalog row any more (a merged key)
*/
public record AuditEntryDto(long id, long actorUserId, String actorName, String action, Long appid, String gameName,
Long targetAppid, String targetGameName, String detail, Instant createdAt) {}
@@ -0,0 +1,12 @@
package core_service.modules.library.domain;
import java.time.Instant;
/**
* One row of Core's audit log, with the names found today.
*
* @param actorName null when Auth never sent the actor's display name
* @param gameName null when the key has no catalog row any more (a merged key)
*/
public record AuditEntry(long id, long actorUserId, String actorName, String action, Long appid, String gameName,
Long targetAppid, String targetGameName, String detail, Instant createdAt) {}
@@ -1,10 +1,13 @@
package core_service.modules.library.repo; package core_service.modules.library.repo;
import core_service.modules.library.domain.AuditEntry;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
/** /**
* The audit log of what auditors changed for every user (V21). * The audit log of what auditors changed for every user (V21).
*/ */
@@ -12,6 +15,8 @@ import org.springframework.stereotype.Repository;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AdminAuditRepository { public class AdminAuditRepository {
public static final List<String> ACTIONS = List.of("GAME_MERGE", "GAME_LINK", "GAME_UNLINK", "GAME_LINK_DISMISS");
private final NamedParameterJdbcTemplate jdbc; private final NamedParameterJdbcTemplate jdbc;
public void write(long actorUserId, String action, Long appid, Long targetAppid, String detail) { public void write(long actorUserId, String action, Long appid, Long targetAppid, String detail) {
@@ -20,4 +25,34 @@ public class AdminAuditRepository {
new MapSqlParameterSource("actor", actorUserId).addValue("action", action) new MapSqlParameterSource("actor", actorUserId).addValue("action", action)
.addValue("appid", appid).addValue("target", targetAppid).addValue("detail", detail)); .addValue("appid", appid).addValue("target", targetAppid).addValue("detail", detail));
} }
/**
* @param action null for every action
*/
public List<AuditEntry> findNewest(String action, int offset, int limit) {
return jdbc.query("""
select a.id, a.actor_user_id, n.display_name as actor_name, a.action, a.appid, g.name as game_name,
a.target_appid, t.name as target_name, a.detail, a.created_at
from admin_audit a
left join account_name n on n.user_id = a.actor_user_id
left join steam_game g on g.appid = a.appid
left join steam_game t on t.appid = a.target_appid
where (cast(:action as varchar) is null or a.action = :action)
order by a.id desc
limit :limit offset :offset
""", new MapSqlParameterSource("action", action).addValue("limit", limit).addValue("offset", offset),
(rs, row) -> new AuditEntry(rs.getLong("id"), rs.getLong("actor_user_id"), rs.getString("actor_name"),
rs.getString("action"), (Long) rs.getObject("appid"), rs.getString("game_name"),
(Long) rs.getObject("target_appid"), rs.getString("target_name"), rs.getString("detail"),
rs.getTimestamp("created_at").toInstant()));
}
/**
* @param action null for every action
*/
public long count(String action) {
Long count = jdbc.queryForObject("select count(*) from admin_audit where (cast(:action as varchar) is null "
+ "or action = :action)", new MapSqlParameterSource("action", action), Long.class);
return count == null ? 0 : count;
}
} }
@@ -0,0 +1,35 @@
package core_service.modules.library.service;
import core_service.modules.library.domain.AuditEntry;
import core_service.modules.library.repo.AdminAuditRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* What auditors changed, newest first.
*/
@Service
@RequiresRole(Role.AUDITOR)
@RequiredArgsConstructor
public class AuditLogService {
private final AdminAuditRepository adminAuditRepository;
/**
* @param action null for every action; an unknown one gives an empty page
*/
public Page<AuditEntry> newest(String action, Pageable pageable) {
if (action != null && !AdminAuditRepository.ACTIONS.contains(action)) {
return new PageImpl<>(List.of(), pageable, 0);
}
return new PageImpl<>(adminAuditRepository.findNewest(action, (int) pageable.getOffset(), pageable.getPageSize()),
pageable, adminAuditRepository.count(action));
}
}
@@ -44,6 +44,7 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
void cleanUp() { void cleanUp() {
jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID); jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID);
jdbc.update("delete from blocked_user where user_id in (?, 42)", USER_ID); jdbc.update("delete from blocked_user where user_id in (?, 42)", USER_ID);
jdbc.update("delete from account_name where user_id in (?, 42)", USER_ID);
} }
private static String serviceToken() { private static String serviceToken() {
@@ -115,6 +116,21 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
return request(HttpMethod.valueOf(call.path("method").asString()), call.path("path").asString()); return request(HttpMethod.valueOf(call.path("method").asString()), call.path("path").asString());
} }
@Test
void stateCallWithADisplayName_StoresItAndAStateWithoutOneKeepsIt() throws Exception {
String token = ContractTokens.serviceToken();
JsonNode call = ContractTokens.internalCall("applyStates");
send(contractRequest(call), call.path("request").toString(), token).andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("select display_name from account_name where user_id = 42", String.class)).isEqualTo("lobo");
send(post("/internal/accounts/states"), "{\"accounts\":[{\"userId\":42,\"blocked\":false}]}", token)
.andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("select display_name from account_name where user_id = 42", String.class)).isEqualTo("lobo");
send(post("/internal/accounts/states"), "{\"accounts\":[{\"userId\":42,\"displayName\":\"" + "x".repeat(101) + "\"}]}", token)
.andExpect(status().isBadRequest());
}
// The calls as Auth's CoreAccountService sends them. // The calls as Auth's CoreAccountService sends them.
@Test @Test
void contractCalls_AreAnswered() throws Exception { void contractCalls_AreAnswered() throws Exception {
@@ -28,6 +28,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@BeforeEach @BeforeEach
void seed() { void seed() {
jdbc.update("delete from blocked_user"); jdbc.update("delete from blocked_user");
jdbc.update("delete from account_name");
for (long userId : List.of(1L, 2L)) { for (long userId : List.of(1L, 2L)) {
jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, user_seen_at) " jdbc.update("insert into user_steam_sync (user_id, steam_id, last_synced_at, user_seen_at) "
+ "values (?, ?, now(), now())", userId, String.valueOf(OLD_STEAM_ID)); + "values (?, ?, now(), now())", userId, String.valueOf(OLD_STEAM_ID));
@@ -43,15 +44,26 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
return jdbc.queryForList("select user_id from blocked_user order by user_id", Long.class); return jdbc.queryForList("select user_id from blocked_user order by user_id", Long.class);
} }
@Test
void displayName_IsStoredAndReplacedAndAStateWithoutOneKeepsIt() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, "lobo", false));
repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false));
assertThat(jdbc.queryForObject("select display_name from account_name where user_id = 1", String.class)).isEqualTo("lobo");
repository.apply(new AccountState(1L, OLD_STEAM_ID, "wolf", false));
assertThat(jdbc.queryForList("select display_name from account_name", String.class)).containsExactly("wolf");
}
@Test @Test
void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() { void block_StopsTheNightlySyncAndUnblockLiftsTheMarker() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, true)); repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true));
assertThat(blocked()).containsExactly(1L); assertThat(blocked()).containsExactly(1L);
assertThat(sync(1L).get("user_seen_at")).isNull(); assertThat(sync(1L).get("user_seen_at")).isNull();
assertThat(sync(2L).get("user_seen_at")).isNotNull(); assertThat(sync(2L).get("user_seen_at")).isNotNull();
repository.apply(new AccountState(1L, OLD_STEAM_ID, false)); repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false));
assertThat(blocked()).isEmpty(); assertThat(blocked()).isEmpty();
} }
@@ -59,7 +71,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
// steam_id stays: the next sync with the new ID compares with it and drops the old account's games. // steam_id stays: the next sync with the new ID compares with it and drops the old account's games.
@Test @Test
void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() { void newSteamId_StopsTheNightlySyncOfTheOldIdAndSyncsOnTheNextVisit() {
repository.apply(new AccountState(1L, NEW_STEAM_ID, false)); repository.apply(new AccountState(1L, NEW_STEAM_ID, null, false));
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID)); assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
assertThat(sync(1L).get("last_synced_at")).isNull(); assertThat(sync(1L).get("last_synced_at")).isNull();
@@ -68,14 +80,14 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void sameSteamId_KeepsTheLastSync() { void sameSteamId_KeepsTheLastSync() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, false)); repository.apply(new AccountState(1L, OLD_STEAM_ID, null, false));
assertThat(sync(1L).get("last_synced_at")).isNotNull(); assertThat(sync(1L).get("last_synced_at")).isNotNull();
} }
@Test @Test
void noSteamId_StopsTheNightlySync() { void noSteamId_StopsTheNightlySync() {
repository.apply(new AccountState(1L, null, false)); repository.apply(new AccountState(1L, null, null, false));
assertThat(sync(1L).get("user_seen_at")).isNull(); assertThat(sync(1L).get("user_seen_at")).isNull();
assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID)); assertThat(sync(1L).get("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID));
@@ -83,7 +95,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void userWithoutASyncRow_GetsOnlyTheBlock() { void userWithoutASyncRow_GetsOnlyTheBlock() {
repository.apply(new AccountState(3L, NEW_STEAM_ID, true)); repository.apply(new AccountState(3L, NEW_STEAM_ID, null, true));
assertThat(blocked()).containsExactly(3L); assertThat(blocked()).containsExactly(3L);
assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty(); assertThat(jdbc.queryForList("select user_id from user_steam_sync where user_id = 3", Long.class)).isEmpty();
@@ -91,10 +103,10 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest {
@Test @Test
void replaceAll_LiftsTheBlocksAuthNoLongerSends() { void replaceAll_LiftsTheBlocksAuthNoLongerSends() {
repository.apply(new AccountState(1L, OLD_STEAM_ID, true)); repository.apply(new AccountState(1L, OLD_STEAM_ID, null, true));
repository.apply(new AccountState(2L, OLD_STEAM_ID, true)); repository.apply(new AccountState(2L, OLD_STEAM_ID, null, true));
repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, true))); repository.replaceAll(List.of(new AccountState(2L, OLD_STEAM_ID, null, true)));
assertThat(blocked()).containsExactly(2L); assertThat(blocked()).containsExactly(2L);
repository.replaceAll(List.of()); repository.replaceAll(List.of());
@@ -0,0 +1,82 @@
package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.library.repo.AdminAuditRepository;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import static org.hamcrest.Matchers.hasSize;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The audit log through the real (non-dev) filter chain, so the role in the token decides.
*/
@AutoConfigureMockMvc
@ActiveProfiles(value = "prod", inheritProfiles = false)
class AuditLogControllerTest extends AbstractIntegrationTest {
private static final long AUDITOR_ID = 6001L;
private static final long GAME = -1_930_007L;
private static final long GAME_2 = -1_930_008L;
@Autowired
private MockMvc mockMvc;
@Autowired
private AdminAuditRepository adminAuditRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from admin_audit");
jdbc.update("delete from account_name where user_id = ?", AUDITOR_ID);
jdbc.update("insert into account_name (user_id, display_name) values (?, 'lobo')", AUDITOR_ID);
jdbc.update("delete from steam_game where appid in (?, ?)", GAME, GAME_2);
jdbc.update("insert into steam_game (appid, name) values (?, 'Hades (Switch)'), (?, 'Hades (PS5)')", GAME, GAME_2);
adminAuditRepository.write(AUDITOR_ID, "GAME_LINK", GAME, GAME_2, "\"Hades (Switch)\" with \"Hades (PS5)\"");
adminAuditRepository.write(AUDITOR_ID, "GAME_UNLINK", GAME, null, "Was linked with \"Hades (PS5)\"");
}
private ResultActions log(Role role, String query) throws Exception {
return mockMvc.perform(get("/api/library/audit-log" + query)
.header("Authorization", "Bearer " + ContractTokens.loginToken(AUDITOR_ID, role))
.header(ClientHeaderFilter.CLIENT_HEADER, "test"));
}
@Test
void auditor_GetsTheNewestRowsWithNames() throws Exception {
log(Role.AUDITOR, "").andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(2))
.andExpect(jsonPath("$.content[0].action").value("GAME_UNLINK"))
.andExpect(jsonPath("$.content[0].actorName").value("lobo"))
.andExpect(jsonPath("$.content[0].gameName").value("Hades (Switch)"))
.andExpect(jsonPath("$.content[0].createdAt").exists())
.andExpect(jsonPath("$.content[1].targetGameName").value("Hades (PS5)"));
}
@Test
void actionFilter_KeepsOneActionAndAnUnknownOneIsEmpty() throws Exception {
log(Role.PLATFORM_ADMIN, "?action=GAME_LINK").andExpect(status().isOk())
.andExpect(jsonPath("$.content", hasSize(1)))
.andExpect(jsonPath("$.content[0].action").value("GAME_LINK"));
log(Role.AUDITOR, "?action=NOPE").andExpect(status().isOk()).andExpect(jsonPath("$.content", hasSize(0)));
}
@Test
void user_IsForbidden() throws Exception {
log(Role.USER, "").andExpect(status().isForbidden());
}
}
@@ -0,0 +1,73 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.AuditEntry;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Import(AdminAuditRepository.class)
class AdminAuditRepositoryTest extends AbstractPersistenceTest {
@Autowired
private AdminAuditRepository repository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
jdbc.update("delete from admin_audit");
jdbc.update("delete from account_name");
jdbc.update("insert into steam_game (appid, name) values (220, 'Half-Life 2'), (620, 'Portal 2') on conflict do nothing");
jdbc.update("insert into account_name (user_id, display_name) values (1, 'lobo')");
}
@Test
void findNewest_NewestFirstWithTheNamesFound() {
repository.write(1, "GAME_LINK", 220L, 620L, "\"Half-Life 2\" with \"Portal 2\"");
repository.write(2, "GAME_MERGE", -5L, 220L, "\"Old\" into \"Half-Life 2\": 1 moved, 0 combined");
List<AuditEntry> entries = repository.findNewest(null, 0, 10);
assertThat(entries).extracting(AuditEntry::action).containsExactly("GAME_MERGE", "GAME_LINK");
AuditEntry merge = entries.get(0);
assertThat(merge.actorName()).isNull();
assertThat(merge.actorUserId()).isEqualTo(2);
assertThat(merge.gameName()).isNull();
assertThat(merge.targetGameName()).isEqualTo("Half-Life 2");
AuditEntry link = entries.get(1);
assertThat(link.actorName()).isEqualTo("lobo");
assertThat(link.gameName()).isEqualTo("Half-Life 2");
assertThat(link.targetGameName()).isEqualTo("Portal 2");
assertThat(link.createdAt()).isNotNull();
}
@Test
void findNewest_FiltersByActionAndPages() {
repository.write(1, "GAME_LINK", 220L, 620L, null);
repository.write(1, "GAME_UNLINK", 220L, null, "Was linked with \"Portal 2\"");
repository.write(1, "GAME_LINK", 620L, 220L, null);
assertThat(repository.findNewest("GAME_LINK", 0, 10)).extracting(AuditEntry::appid).containsExactly(620L, 220L);
assertThat(repository.findNewest(null, 1, 1)).extracting(AuditEntry::action).containsExactly("GAME_UNLINK");
assertThat(repository.findNewest("GAME_UNLINK", 0, 10).getFirst().targetAppid()).isNull();
}
@Test
void count_PerAction() {
repository.write(1, "GAME_LINK", 220L, 620L, null);
repository.write(1, "GAME_UNLINK", 220L, null, null);
repository.write(1, "GAME_LINK", 620L, 220L, null);
assertThat(repository.count(null)).isEqualTo(3);
assertThat(repository.count("GAME_LINK")).isEqualTo(2);
assertThat(repository.count("GAME_MERGE")).isZero();
}
}
@@ -8,10 +8,11 @@ import org.testcontainers.containers.PostgreSQLContainer;
/** /**
* Base class for persistence tests. * Base class for persistence tests.
* Slices only the persistence layer with Testcontainers. * Slices only the persistence layer with Testcontainers. Every distinct @Import set caches its own context and pool;
* with the default pool of 10 the 100 connections of the container run out at ten contexts.
*/ */
@Tag("PersistenceTest") @Tag("PersistenceTest")
@DataJpaTest @DataJpaTest(properties = "spring.datasource.hikari.maximum-pool-size=4")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public abstract class AbstractPersistenceTest { public abstract class AbstractPersistenceTest {
@@ -37,12 +37,12 @@
"applyStates": { "applyStates": {
"method": "POST", "method": "POST",
"path": "/internal/accounts/states", "path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "blocked": true}]} "request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true}]}
}, },
"replaceStates": { "replaceStates": {
"method": "PUT", "method": "PUT",
"path": "/internal/accounts/states", "path": "/internal/accounts/states",
"request": {"accounts": [{"userId": 42, "steamId": null, "blocked": false}]} "request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false}]}
}, },
"purge": { "purge": {
"method": "POST", "method": "POST",