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