Public Access
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.
This commit is contained in:
1 parent
221b5594c5
commit
a2ea1e45d3
16 files changed
+330
-23
No files matched your search
@@ -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<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 |
|
||||
@@ -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
|
||||
|
||||
@@ -64,7 +64,7 @@ public class InternalAccountController {
|
||||
|
||||
private static List<AccountState> 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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) {
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
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<String> 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<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() {
|
||||
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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
* 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 {
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user