From 89e2fa3df24bf70da1d845ca2f1655c491abc269 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 30 Sep 2026 15:00:47 +0200 Subject: [PATCH] refactor(user): check only the shape of a tile id Core and the Frontend skip ids they do not know; Core never shares the overview or the activity. --- readme.md | 4 ++-- .../dto/request/DashboardTilesRequest.java | 9 -------- .../api/dto/request/ProfileTilesRequest.java | 13 +---------- .../user/api/dto/request/TileRequest.java | 15 ++++-------- .../modules/user/api/UserControllerTest.java | 23 +++++++++++++++---- .../user/persistence/UserRepositoryTest.java | 17 ++++++++++++++ 6 files changed, 42 insertions(+), 39 deletions(-) diff --git a/readme.md b/readme.md index 0ed80f1..690c724 100644 --- a/readme.md +++ b/readme.md @@ -102,9 +102,9 @@ To Be Played is split into three repositories: | `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings | | `PUT` | `/api/users/settings/appearance` | access token | | `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid}`; `204`) | access token | -| `PUT` | `/api/users/settings/dashboard-tiles` (`{tiles: [{id, size}]}` in order, at most 20; `{tiles: null}` resets; `204`) | access token | +| `PUT` | `/api/users/settings/dashboard-tiles` (`{tiles: [{id, size}]}` in order, at most 20; `{tiles: null}` resets; `204`). Auth checks only the id shape `[A-Z_]{1,30}`: Core and the Frontend skip ids they do not know, and Core never shares the overview or the activity | access token | | `PUT` | `/api/users/settings/favorite-platform` (`{platform}`, free text up to 32, e.g. `PS5`; `null` clears it; `204`) | access token | -| `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`: `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_`; `204`) | access token | +| `PUT` | `/api/users/settings/profile-tiles` (`{tiles: [{id, size}]}`, e.g. `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_`; `204`) | access token | | `GET` | `/api/public/profiles/{displayName}` | public | | `GET` | `/api/stats/public` | public | | `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public | diff --git a/src/main/java/auth_service/modules/user/api/dto/request/DashboardTilesRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/DashboardTilesRequest.java index 31e5f8f..e0d46c7 100644 --- a/src/main/java/auth_service/modules/user/api/dto/request/DashboardTilesRequest.java +++ b/src/main/java/auth_service/modules/user/api/dto/request/DashboardTilesRequest.java @@ -1,7 +1,6 @@ package auth_service.modules.user.api.dto.request; import jakarta.validation.Valid; -import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -11,12 +10,4 @@ import java.util.List; * @param tiles the shown tiles in their order; null resets to the default dashboard */ public record DashboardTilesRequest(@Size(max = 20) List<@NotNull @Valid TileRequest> tiles) { - - public static final String IDS = "GAME_COUNT|MOST_PLAYED|PLAYING|PILE_OF_SHAME|ACTIVITY|ACHIEVEMENTS|STATS_OVERVIEW|" - + ProfileTilesRequest.STAT_IDS; - - @AssertTrue(message = "unknown tile id") - boolean isKnownIds() { - return TileRequest.knownIds(tiles, IDS); - } } diff --git a/src/main/java/auth_service/modules/user/api/dto/request/ProfileTilesRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/ProfileTilesRequest.java index eed9bbd..d2e43f2 100644 --- a/src/main/java/auth_service/modules/user/api/dto/request/ProfileTilesRequest.java +++ b/src/main/java/auth_service/modules/user/api/dto/request/ProfileTilesRequest.java @@ -1,24 +1,13 @@ package auth_service.modules.user.api.dto.request; import jakarta.validation.Valid; -import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import java.util.List; /** - * @param tiles the shown parts of the public profile in their order; the STATS_ tiles are the shared stat groups + * @param tiles the shown parts of the public profile in their order; the STATS_ tiles are Core's stat groups */ public record ProfileTilesRequest(@NotNull @Size(max = 20) List<@NotNull @Valid TileRequest> tiles) { - - /** The stat groups of Core's stats page, as tiles. */ - public static final String STAT_IDS = "STATS_PILE|STATS_TREND|STATS_TOP_GAMES|STATS_TASTE|STATS_HABITS|STATS_FINISHED"; - - public static final String IDS = "SUMMARY|STATUS|PLAYING|FINISHED|" + STAT_IDS; - - @AssertTrue(message = "unknown tile id") - boolean isKnownIds() { - return TileRequest.knownIds(tiles, IDS); - } } diff --git a/src/main/java/auth_service/modules/user/api/dto/request/TileRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/TileRequest.java index 436fd44..df5da31 100644 --- a/src/main/java/auth_service/modules/user/api/dto/request/TileRequest.java +++ b/src/main/java/auth_service/modules/user/api/dto/request/TileRequest.java @@ -3,17 +3,10 @@ package auth_service.modules.user.api.dto.request; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; -import java.util.List; - /** - * The id is checked by the layout request that holds the tile, with {@link #knownIds}. + * @param id any tile id of the Frontend; Core and the Frontend skip ids they do not know. At most 30 characters: + * the column stores "ID:SIZE" in 32. */ -public record TileRequest(@NotNull String id, @NotNull @Pattern(regexp = "S|M|L") String size) { - - /** - * @return true when every id matches; a missing list, tile or id is left to its own constraint - */ - static boolean knownIds(List tiles, String ids) { - return tiles == null || tiles.stream().allMatch(tile -> tile == null || tile.id() == null || tile.id().matches(ids)); - } +public record TileRequest(@NotNull @Pattern(regexp = "[A-Z_]{1,30}") String id, + @NotNull @Pattern(regexp = "S|M|L") String size) { } diff --git a/src/test/java/auth_service/modules/user/api/UserControllerTest.java b/src/test/java/auth_service/modules/user/api/UserControllerTest.java index 318815a..108abe7 100644 --- a/src/test/java/auth_service/modules/user/api/UserControllerTest.java +++ b/src/test/java/auth_service/modules/user/api/UserControllerTest.java @@ -467,19 +467,32 @@ class UserControllerTest extends AbstractWebMvcTest { verify(userService).updateProfileTiles(1L, List.of(new Tile("STATS_PILE", "M"))); } - // A dashboard tile is no profile tile and the other way round; activity and the game count stay private. + // Core and the Frontend know the tiles; Auth checks only the shape (Core keeps activity and the overview private). + @Test + void updateTiles_ANewTileIdIsPassedThrough() throws Exception { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + new UserPrincipal("1", null), null, Collections.emptyList())); + + mockMvc.perform(put("/api/users/settings/profile-tiles") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"tiles\":[{\"id\":\"STATS_NEW_GROUP\",\"size\":\"M\"}]}")) + .andExpect(status().isNoContent()); + + verify(userService).updateProfileTiles(1L, List.of(new Tile("STATS_NEW_GROUP", "M"))); + } + @ParameterizedTest @ValueSource(strings = { - "dashboard-tiles|{\"tiles\":[{\"id\":\"SUMMARY\",\"size\":\"M\"}]}", + "dashboard-tiles|{\"tiles\":[{\"id\":\"stats_pile\",\"size\":\"M\"}]}", + "dashboard-tiles|{\"tiles\":[{\"id\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\",\"size\":\"M\"}]}", "dashboard-tiles|{\"tiles\":[{\"id\":\"PLAYING\",\"size\":\"XL\"}]}", "dashboard-tiles|{\"tiles\":[{\"id\":\"PLAYING\"}]}", "dashboard-tiles|{\"tiles\":[null]}", - "profile-tiles|{\"tiles\":[{\"id\":\"ACTIVITY\",\"size\":\"M\"}]}", - "profile-tiles|{\"tiles\":[{\"id\":\"STATS_OVERVIEW\",\"size\":\"M\"}]}", + "profile-tiles|{\"tiles\":[{\"id\":\"\",\"size\":\"M\"}]}", "profile-tiles|{\"tiles\":null}", "profile-tiles|{}" }) - void updateTiles_UnknownIdBadSizeOrMissing_IsRejected(String pathAndBody) throws Exception { + void updateTiles_BadIdBadSizeOrMissing_IsRejected(String pathAndBody) throws Exception { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( new UserPrincipal("1", null), null, Collections.emptyList())); String[] parts = pathAndBody.split("\\|", 2); diff --git a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java index 12882ed..802a921 100644 --- a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java +++ b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java @@ -357,6 +357,23 @@ class UserRepositoryTest extends AbstractPersistenceTest { assertNull(findByEmail("reset@example.com").orElseThrow().getSettings().getDashboardTiles()); } + @Test + void updateTiles_TheLongestIdAuthAcceptsIsStored() { + UserEntity userEntity = persisted(UserEntity.builder() + .email("longtile@example.com") + .password("password") + .enabled(true) + .registrationDate(LocalDateTime.now()) + .build()); + List profile = List.of(new Tile("STATS_" + "A".repeat(24), "M")); + + userRepository.update(userEntity.getId(), user -> user.getSettings().setProfileTiles(profile)); + entityManager.flush(); + entityManager.clear(); + + assertEquals(profile, findByEmail("longtile@example.com").orElseThrow().getSettings().getProfileTiles()); + } + private void updateSettings(long userId, String displayName, UserSettings settings) { userRepository.update(userId, user -> { user.setDisplayName(displayName);