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

@@ -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",