From a2ea1e45d3e2c3d989b20905fee8d45b9bf58b86 Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 2 Oct 2026 09:10:56 +0200 Subject: [PATCH] feat(audit): add audit log endpoint and display names from Auth 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. --- readme.md | 13 +-- .../api/InternalAccountController.java | 2 +- .../account/api/dto/AccountStatesRequest.java | 7 +- .../modules/account/domain/AccountState.java | 3 +- .../account/repo/AccountStateRepository.java | 9 +- .../library/api/LibraryController.java | 17 ++++ .../library/api/dto/AuditEntryDto.java | 10 +++ .../modules/library/domain/AuditEntry.java | 12 +++ .../library/repo/AdminAuditRepository.java | 35 ++++++++ .../library/service/AuditLogService.java | 35 ++++++++ .../api/InternalAccountControllerTest.java | 16 ++++ .../repo/AccountStateRepositoryTest.java | 30 +++++-- .../library/api/AuditLogControllerTest.java | 82 +++++++++++++++++++ .../repo/AdminAuditRepositoryTest.java | 73 +++++++++++++++++ .../shared/test/AbstractPersistenceTest.java | 5 +- .../contract/auth-core-contract.json | 4 +- 16 files changed, 330 insertions(+), 23 deletions(-) create mode 100644 src/main/java/core_service/modules/library/api/dto/AuditEntryDto.java create mode 100644 src/main/java/core_service/modules/library/domain/AuditEntry.java create mode 100644 src/main/java/core_service/modules/library/service/AuditLogService.java create mode 100644 src/test/java/core_service/modules/library/api/AuditLogControllerTest.java create mode 100644 src/test/java/core_service/modules/library/repo/AdminAuditRepositoryTest.java diff --git a/readme.md b/readme.md index 946f732..a2d8f59 100644 --- a/readme.md +++ b/readme.md @@ -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 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` - (`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. - **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 @@ -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 | | `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 | +| `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 | | `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 | @@ -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/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/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 | | `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")] Stats -->|"listed_user_game, counted_user_game, game_mood, user_game_status_change, deleted_user, blocked_user"| Db Auth -->|"service token
/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 | @@ -216,7 +218,7 @@ flowchart LR | `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data | | `stats` | Public numbers for the landing page | | `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 | | `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 | @@ -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 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. +The purge also removes the user's `account_name` row and link requests (`game_link_request`). ### 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 -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 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 diff --git a/src/main/java/core_service/modules/account/api/InternalAccountController.java b/src/main/java/core_service/modules/account/api/InternalAccountController.java index 24b6a6c..b45fa50 100644 --- a/src/main/java/core_service/modules/account/api/InternalAccountController.java +++ b/src/main/java/core_service/modules/account/api/InternalAccountController.java @@ -64,7 +64,7 @@ public class InternalAccountController { private static List toStates(AccountStatesRequest request) { 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(); } diff --git a/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java b/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java index 6b55b1e..b682812 100644 --- a/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java +++ b/src/main/java/core_service/modules/account/api/dto/AccountStatesRequest.java @@ -2,11 +2,16 @@ package core_service.modules.account.api.dto; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.util.List; 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) { } } diff --git a/src/main/java/core_service/modules/account/domain/AccountState.java b/src/main/java/core_service/modules/account/domain/AccountState.java index 839a3c2..4dc4c65 100644 --- a/src/main/java/core_service/modules/account/domain/AccountState.java +++ b/src/main/java/core_service/modules/account/domain/AccountState.java @@ -4,6 +4,7 @@ package core_service.modules.account.domain; * 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 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) { } diff --git a/src/main/java/core_service/modules/account/repo/AccountStateRepository.java b/src/main/java/core_service/modules/account/repo/AccountStateRepository.java index ac03481..7375dc8 100644 --- a/src/main/java/core_service/modules/account/repo/AccountStateRepository.java +++ b/src/main/java/core_service/modules/account/repo/AccountStateRepository.java @@ -10,8 +10,8 @@ import java.util.Collection; import java.util.List; /** - * Blocks in blocked_user, and what a Steam ID change means for user_steam_sync. Plain SQL, because the library - * module owns the entities. + * Blocks in blocked_user, the display name in account_name, and what a Steam ID change means for user_steam_sync. + * Plain SQL, because the library module owns the entities. */ @Repository @RequiredArgsConstructor @@ -22,6 +22,11 @@ public class AccountStateRepository { public void apply(AccountState state) { MapSqlParameterSource params = new MapSqlParameterSource("userId", state.userId()) .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()) { jdbc.update("insert into blocked_user (user_id) values (:userId) on conflict do nothing", params); } else { diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index 61e9a0d..542d919 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -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.ActivityDto; 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.CatalogGameDto; 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.UserStatsDto; 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.GameStatus; import core_service.modules.library.domain.LinkPair; import core_service.modules.library.domain.Mood; 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.GameCorrectionService; import core_service.modules.library.service.GameDetailService; @@ -90,6 +93,7 @@ public class LibraryController { private final GameCorrectionService gameCorrectionService; private final GameMergeService gameMergeService; private final GameLinkService gameLinkService; + private final AuditLogService auditLogService; @GetMapping("/games-count") public ResponseEntity getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { @@ -267,6 +271,14 @@ public class LibraryController { }; } + @GetMapping("/audit-log") + public ResponseEntity> 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) { 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()); } + 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() { return ResponseEntity.badRequest().body(new ErrorResponse("SAME_GAME", "A game cannot be linked with itself")); } diff --git a/src/main/java/core_service/modules/library/api/dto/AuditEntryDto.java b/src/main/java/core_service/modules/library/api/dto/AuditEntryDto.java new file mode 100644 index 0000000..644363f --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/AuditEntryDto.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/domain/AuditEntry.java b/src/main/java/core_service/modules/library/domain/AuditEntry.java new file mode 100644 index 0000000..4ad9bab --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/AuditEntry.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/repo/AdminAuditRepository.java b/src/main/java/core_service/modules/library/repo/AdminAuditRepository.java index c058c92..e3704eb 100644 --- a/src/main/java/core_service/modules/library/repo/AdminAuditRepository.java +++ b/src/main/java/core_service/modules/library/repo/AdminAuditRepository.java @@ -1,10 +1,13 @@ package core_service.modules.library.repo; +import core_service.modules.library.domain.AuditEntry; import lombok.RequiredArgsConstructor; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Repository; +import java.util.List; + /** * The audit log of what auditors changed for every user (V21). */ @@ -12,6 +15,8 @@ import org.springframework.stereotype.Repository; @RequiredArgsConstructor public class AdminAuditRepository { + public static final List ACTIONS = List.of("GAME_MERGE", "GAME_LINK", "GAME_UNLINK", "GAME_LINK_DISMISS"); + private final NamedParameterJdbcTemplate jdbc; 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) .addValue("appid", appid).addValue("target", targetAppid).addValue("detail", detail)); } + + /** + * @param action null for every action + */ + public List 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; + } } diff --git a/src/main/java/core_service/modules/library/service/AuditLogService.java b/src/main/java/core_service/modules/library/service/AuditLogService.java new file mode 100644 index 0000000..b89e3b7 --- /dev/null +++ b/src/main/java/core_service/modules/library/service/AuditLogService.java @@ -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 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)); + } +} diff --git a/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java b/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java index c1b1278..5d721de 100644 --- a/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java +++ b/src/test/java/core_service/modules/account/api/InternalAccountControllerTest.java @@ -44,6 +44,7 @@ class InternalAccountControllerTest extends AbstractIntegrationTest { void cleanUp() { 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 account_name where user_id in (?, 42)", USER_ID); } private static String serviceToken() { @@ -115,6 +116,21 @@ class InternalAccountControllerTest extends AbstractIntegrationTest { 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. @Test void contractCalls_AreAnswered() throws Exception { diff --git a/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java b/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java index 5a0fdc7..2c1c14c 100644 --- a/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java +++ b/src/test/java/core_service/modules/account/repo/AccountStateRepositoryTest.java @@ -28,6 +28,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { @BeforeEach void seed() { jdbc.update("delete from blocked_user"); + jdbc.update("delete from account_name"); for (long userId : List.of(1L, 2L)) { 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)); @@ -43,15 +44,26 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { 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 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(sync(1L).get("user_seen_at")).isNull(); 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(); } @@ -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. @Test 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("last_synced_at")).isNull(); @@ -68,14 +80,14 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { @Test 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(); } @Test 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("steam_id")).isEqualTo(String.valueOf(OLD_STEAM_ID)); @@ -83,7 +95,7 @@ class AccountStateRepositoryTest extends AbstractPersistenceTest { @Test 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(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 void replaceAll_LiftsTheBlocksAuthNoLongerSends() { - repository.apply(new AccountState(1L, OLD_STEAM_ID, true)); - repository.apply(new AccountState(2L, OLD_STEAM_ID, true)); + repository.apply(new AccountState(1L, OLD_STEAM_ID, null, 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); repository.replaceAll(List.of()); diff --git a/src/test/java/core_service/modules/library/api/AuditLogControllerTest.java b/src/test/java/core_service/modules/library/api/AuditLogControllerTest.java new file mode 100644 index 0000000..b6e6d68 --- /dev/null +++ b/src/test/java/core_service/modules/library/api/AuditLogControllerTest.java @@ -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()); + } +} diff --git a/src/test/java/core_service/modules/library/repo/AdminAuditRepositoryTest.java b/src/test/java/core_service/modules/library/repo/AdminAuditRepositoryTest.java new file mode 100644 index 0000000..19f541c --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/AdminAuditRepositoryTest.java @@ -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 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(); + } +} diff --git a/src/test/java/core_service/shared/test/AbstractPersistenceTest.java b/src/test/java/core_service/shared/test/AbstractPersistenceTest.java index 94ca882..7d62599 100644 --- a/src/test/java/core_service/shared/test/AbstractPersistenceTest.java +++ b/src/test/java/core_service/shared/test/AbstractPersistenceTest.java @@ -8,10 +8,11 @@ import org.testcontainers.containers.PostgreSQLContainer; /** * 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") -@DataJpaTest +@DataJpaTest(properties = "spring.datasource.hikari.maximum-pool-size=4") @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) public abstract class AbstractPersistenceTest { diff --git a/src/test/resources/contract/auth-core-contract.json b/src/test/resources/contract/auth-core-contract.json index a296e79..87bbb4f 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, "blocked": true}]} + "request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true}]} }, "replaceStates": { "method": "PUT", "path": "/internal/accounts/states", - "request": {"accounts": [{"userId": 42, "steamId": null, "blocked": false}]} + "request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false}]} }, "purge": { "method": "POST",