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

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