Compare commits

...
3 Commits
Author SHA1 Message Date
lobo 315e22c220 feat(tiles): store dashboard and profile tile layouts
Build, Test & Push Auth Service / docker (push) Successful in 1m26s
Build, Test & Push Auth Service / quality-check (push) Successful in 48s
2026-09-28 15:03:16 +02:00
lobo dd4fcd1546 feat(profile): store the public profile look 2026-09-28 14:20:14 +02:00
lobo f86f3c2e63 feat(dev): add the local profile with generated keys and a dev user
Profile "local" starts without keys: LocalKeysEnvironmentPostProcessor
makes a new signing key per start and refuses to run next to prod. A
Flyway callback in db/local adds dev@localhost as PLATFORM_ADMIN with a
linked Steam ID.
2026-09-28 13:07:40 +02:00
42 changed files with 958 additions and 82 deletions

No files matched your search

+33 -4
View File
@@ -35,8 +35,12 @@ To Be Played is split into three repositories:
- Appearance settings synced across devices: accent color, corner style, wide layout, background and dashboard - Appearance settings synced across devices: accent color, corner style, wide layout, background and dashboard
banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs. banner from the user's own game art. Only whitelisted presets and Steam app ids are stored, never raw CSS or URLs.
- Opt-in public profile at `/u/<display name>`. Auth answers with a short-lived **profile ticket** (EdDSA JWT, - Opt-in public profile at `/u/<display name>`. Auth answers with a short-lived **profile ticket** (EdDSA JWT,
10 minutes, `typ=public-profile`) that Core accepts for the public library data only. Its `profileStats` claim 10 minutes, `typ=public-profile`) that Core accepts for the public library data only. The profile has its own
names the stat groups the user chose to show there (none by default). The ticket is not a login: look (accent, corners, banner and background from own game art) and a **tile layout** (`profile_tiles`: the shown
tiles in order, each with a size S, M or L), apart from the app appearance; the public answer sends both as
`style`. The ticket's claims come from the layout: `sections` names the shown parts (`STATS` when any `STATS_`
tile is shown), `profileStats` the stat groups of the `STATS_` tiles. The user's dashboard layout
(`dashboard_tiles`, NULL = never changed) is stored the same way. The ticket is not a login:
both services reject every token with a `typ` claim on normal endpoints. both services reject every token with a `typ` claim on normal endpoints.
- **Roles**: one per user, a ladder `USER < AUDITOR < PLATFORM_ADMIN` (`users.role`, the first admin is set by - **Roles**: one per user, a ladder `USER < AUDITOR < PLATFORM_ADMIN` (`users.role`, the first admin is set by
SQL). The access token carries the active role in the `role` claim. **View as**: an admin switches the own login SQL). The access token carries the active role in the `role` claim. **View as**: an admin switches the own login
@@ -89,7 +93,9 @@ To Be Played is split into three repositories:
| `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `429`) | access token | | `DELETE` | `/api/users/me` (`{password}`; `204` and both cookies cleared, `403 INVALID_PASSWORD`, `429`) | access token |
| `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings | | `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/appearance` | access token |
| `PUT` | `/api/users/settings/profile-stats` (`{groups}`: `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`; `204`) | 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/profile-tiles` (`{tiles: [{id, size}]}`: `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS_<GROUP>`; `204`) | access token |
| `GET` | `/api/public/profiles/{displayName}` | public | | `GET` | `/api/public/profiles/{displayName}` | public |
| `GET` | `/api/stats/public` | public | | `GET` | `/api/stats/public` | public |
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public | | `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
@@ -204,7 +210,30 @@ days is `UPDATE users SET deleted_at = NULL` (see the Deployment README).
## Run locally ## Run locally
Needs Java 25 and Docker. ### Local development without keys (profile `local`)
The whole stack runs on your machine with no API keys and no calls to Steam, Twitch/IGDB or speedrun.com. Needs
Java 25, Node 24 and Docker. Clone `ToBePlayed-Auth`, `ToBePlayed-Core` and `ToBePlayed-Frontend` next to each
other, then start them in this order (Core also starts without Auth and finds it later):
1. **Auth**: in `ToBePlayed-Auth`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`
(IDE: active profile `local`). Port 8080.
2. **Core**: in `ToBePlayed-Core`, `./mvnw spring-boot:run -Dspring-boot.run.profiles=local`. Port 8090.
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 8
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
In Auth, `local` makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this
profile, and never together with `prod`), so no key is in the repo; after an Auth restart (also a devtools
restart), log in again. Never use `local` on a server: the dev user's password is public. The
Flyway callback in `db/local` adds the dev user (`PLATFORM_ADMIN`, linked Steam ID). The invite code for more
users is `local`. What Core mocks and how to change the recorded answers: Core readme, "Run locally".
### With your own keys (profile `dev`)
Needs Java 25, Docker and your own keys.
```bash ```bash
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev ./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
@@ -0,0 +1,55 @@
package auth_service.configuration;
import org.springframework.boot.EnvironmentPostProcessor;
import org.springframework.boot.SpringApplication;
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Map;
import java.util.UUID;
/**
* Only in the local profile: a new Ed25519 key pair and kid at each start, so no signing key is in the repo. A new
* kid lets Core's JWKS reload find the new key by itself.
*/
public class LocalKeysEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {
static final String LOCAL_PROFILE = "local";
private static final String PROD_PROFILE = "prod";
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
if (!environment.matchesProfiles(LOCAL_PROFILE)) {
return;
}
// local also seeds an admin with a public password and opens CORS to localhost.
if (environment.matchesProfiles(PROD_PROFILE)) {
throw new IllegalStateException("The local profile must never run together with prod");
}
KeyPair keys = generateKeys();
Base64.Encoder base64 = Base64.getEncoder();
environment.getPropertySources().addFirst(new MapPropertySource("localKeys", Map.of(
"security.jwt.private-key", base64.encodeToString(keys.getPrivate().getEncoded()),
"security.jwt.public-key", base64.encodeToString(keys.getPublic().getEncoded()),
"security.jwt.kid", LOCAL_PROFILE + "-" + UUID.randomUUID())));
}
// After the config files, which decide the active profiles.
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
private static KeyPair generateKeys() {
try {
return KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -83,13 +83,14 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
} }
@Override @Override
public String generateProfileTicket(long userId, List<String> profileStats) { public String generateProfileTicket(long userId, List<String> profileStats, List<String> sections) {
return Jwts.builder() return Jwts.builder()
.header().keyId(jwtConfig.getKid()).and() .header().keyId(jwtConfig.getKid()).and()
.subject(PROFILE_TICKET_TYPE) .subject(PROFILE_TICKET_TYPE)
.claim(TYPE_CLAIM, PROFILE_TICKET_TYPE) .claim(TYPE_CLAIM, PROFILE_TICKET_TYPE)
.claim("profileUserId", String.valueOf(userId)) .claim("profileUserId", String.valueOf(userId))
.claim("profileStats", profileStats) .claim("profileStats", profileStats)
.claim("sections", sections)
.issuedAt(new Date(System.currentTimeMillis())) .issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + PROFILE_TICKET_EXPIRATION_MS)) .expiration(new Date(System.currentTimeMillis() + PROFILE_TICKET_EXPIRATION_MS))
.signWith(privateKey, Jwts.SIG.EdDSA) .signWith(privateKey, Jwts.SIG.EdDSA)
@@ -1,6 +1,7 @@
package auth_service.modules.profile.api; package auth_service.modules.profile.api;
import auth_service.modules.profile.api.dto.PublicProfileResponse; import auth_service.modules.profile.api.dto.PublicProfileResponse;
import auth_service.modules.profile.model.PublicProfile;
import auth_service.modules.profile.service.PublicProfileService; import auth_service.modules.profile.service.PublicProfileService;
import auth_service.shared.api.dto.response.ErrorResponse; import auth_service.shared.api.dto.response.ErrorResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -26,8 +27,14 @@ public class PublicProfileController {
public ResponseEntity<?> getPublicProfile(@PathVariable("displayName") String displayName) { public ResponseEntity<?> getPublicProfile(@PathVariable("displayName") String displayName) {
return publicProfileService.findPublicProfile(displayName) return publicProfileService.findPublicProfile(displayName)
.<ResponseEntity<?>>map(p -> ResponseEntity.ok(new PublicProfileResponse( .<ResponseEntity<?>>map(p -> ResponseEntity.ok(new PublicProfileResponse(
p.profile().displayName(), p.profile().memberSince(), p.ticket()))) p.profile().displayName(), p.profile().memberSince(), p.ticket(), style(p.profile()))))
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("PROFILE_NOT_FOUND", "No public profile with this name"))); .body(new ErrorResponse("PROFILE_NOT_FOUND", "No public profile with this name")));
} }
private static PublicProfileResponse.Style style(PublicProfile profile) {
PublicProfile.Style style = profile.style();
return new PublicProfileResponse.Style(style.accent(), style.corners(), style.bannerAppid(), style.backgroundAppid(),
profile.tiles().stream().map(tile -> new PublicProfileResponse.Tile(tile.id(), tile.size())).toList());
}
} }
@@ -1,8 +1,18 @@
package auth_service.modules.profile.api.dto; package auth_service.modules.profile.api.dto;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List;
/** /**
* @param ticket for Core's public profile endpoint; valid for a few minutes * @param ticket for Core's public profile endpoint; valid for a few minutes
* @param style the look the owner picked for the profile
*/ */
public record PublicProfileResponse(String displayName, LocalDate memberSince, String ticket) {} public record PublicProfileResponse(String displayName, LocalDate memberSince, String ticket, Style style) {
/**
* @param tiles the shown parts of the profile in their order
*/
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List<Tile> tiles) {}
public record Tile(String id, String size) {}
}
@@ -3,4 +3,12 @@ package auth_service.modules.profile.model;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
public record PublicProfile(long userId, String displayName, LocalDate memberSince, List<String> profileStats) {} public record PublicProfile(long userId, String displayName, LocalDate memberSince, Style style, List<Tile> tiles) {
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid) {}
/**
* @param id a part of the profile, or STATS_ and a stat group of Core
*/
public record Tile(String id, String size) {}
}
@@ -4,6 +4,7 @@ import auth_service.modules.profile.model.PublicProfile;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -16,19 +17,30 @@ public class PublicProfileRepository {
this.jdbc = jdbc; this.jdbc = jdbc;
} }
// Stored as "ID:SIZE", like user.model.Tile.parse, which this module may not use; a bare id (hand-written SQL) is M.
private static PublicProfile.Tile tile(String stored) {
int colon = stored.lastIndexOf(':');
return colon < 0 ? new PublicProfile.Tile(stored, "M")
: new PublicProfile.Tile(stored.substring(0, colon), stored.substring(colon + 1));
}
/** /**
* Only enabled, not deleted users who switched the public profile on. The name ignores case, like its unique index. * Only enabled, not deleted users who switched the public profile on. The name ignores case, like its unique index.
*/ */
public Optional<PublicProfile> findPublicByDisplayName(String displayName) { public Optional<PublicProfile> findPublicByDisplayName(String displayName) {
return jdbc.query(""" return jdbc.query("""
SELECT u.id, u.display_name, u.registration_date, s.profile_stats SELECT u.id, u.display_name, u.registration_date, s.profile_accent, s.profile_corners,
s.profile_banner_appid, s.profile_background_appid, s.profile_tiles
FROM users u JOIN user_settings s ON s.user_id = u.id FROM users u JOIN user_settings s ON s.user_id = u.id
WHERE LOWER(u.display_name) = LOWER(?) AND u.enabled AND u.deleted_at IS NULL AND s.public_profile WHERE LOWER(u.display_name) = LOWER(?) AND u.enabled AND u.deleted_at IS NULL AND s.public_profile
""", """,
(rs, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"), (rs, row) -> new PublicProfile(rs.getLong("id"), rs.getString("display_name"),
rs.getTimestamp("registration_date") != null rs.getTimestamp("registration_date") != null
? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null, ? rs.getTimestamp("registration_date").toLocalDateTime().toLocalDate() : null,
List.of((String[]) rs.getArray("profile_stats").getArray())), new PublicProfile.Style(rs.getString("profile_accent"), rs.getString("profile_corners"),
rs.getObject("profile_banner_appid", Long.class),
rs.getObject("profile_background_appid", Long.class)),
Arrays.stream((String[]) rs.getArray("profile_tiles").getArray()).map(PublicProfileRepository::tile).toList()),
displayName).stream().findFirst(); displayName).stream().findFirst();
} }
} }
@@ -7,7 +7,11 @@ import auth_service.shared.security.Public;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
@Service @Service
@Public @Public
@@ -16,6 +20,8 @@ public class PublicProfileService {
public record TicketedProfile(PublicProfile profile, String ticket) {} public record TicketedProfile(PublicProfile profile, String ticket) {}
private static final String STATS_PREFIX = "STATS_";
private final PublicProfileRepository publicProfileRepository; private final PublicProfileRepository publicProfileRepository;
private final ProfileTicketIssuer profileTicketIssuer; private final ProfileTicketIssuer profileTicketIssuer;
@@ -24,6 +30,18 @@ public class PublicProfileService {
*/ */
public Optional<TicketedProfile> findPublicProfile(String displayName) { public Optional<TicketedProfile> findPublicProfile(String displayName) {
return publicProfileRepository.findPublicByDisplayName(displayName) return publicProfileRepository.findPublicByDisplayName(displayName)
.map(profile -> new TicketedProfile(profile, profileTicketIssuer.generateProfileTicket(profile.userId(), profile.profileStats()))); .map(profile -> new TicketedProfile(profile, ticket(profile)));
}
// Core knows sections and stat groups, not tiles: every STATS_ tile is one shared group of the STATS section.
private String ticket(PublicProfile profile) {
Map<Boolean, List<String>> byStats = profile.tiles().stream().map(PublicProfile.Tile::id)
.collect(Collectors.partitioningBy(id -> id.startsWith(STATS_PREFIX)));
List<String> statGroups = byStats.get(true).stream().map(id -> id.substring(STATS_PREFIX.length())).toList();
List<String> sections = new ArrayList<>(byStats.get(false));
if (!statGroups.isEmpty()) {
sections.add("STATS");
}
return profileTicketIssuer.generateProfileTicket(profile.userId(), statGroups, sections);
} }
} }
@@ -2,7 +2,9 @@ package auth_service.modules.user.api;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
import auth_service.modules.user.api.dto.request.AppearanceRequest; import auth_service.modules.user.api.dto.request.AppearanceRequest;
import auth_service.modules.user.api.dto.request.ProfileStatsRequest; import auth_service.modules.user.api.dto.request.DashboardTilesRequest;
import auth_service.modules.user.api.dto.request.ProfileTilesRequest;
import auth_service.modules.user.api.dto.request.ProfileStyleRequest;
import auth_service.modules.user.api.dto.request.UserSettingsRequest; import auth_service.modules.user.api.dto.request.UserSettingsRequest;
import auth_service.modules.user.api.dto.response.UserProfileResponse; import auth_service.modules.user.api.dto.response.UserProfileResponse;
import auth_service.modules.user.api.mapper.ApiMapper; import auth_service.modules.user.api.mapper.ApiMapper;
@@ -46,10 +48,24 @@ public class UserController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@PutMapping(value = "/settings/profile-stats", version = "1.0") @PutMapping(value = "/settings/dashboard-tiles", version = "1.0")
public ResponseEntity<Void> updateProfileStats(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<Void> updateDashboardTiles(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody ProfileStatsRequest request) { @Valid @RequestBody DashboardTilesRequest request) {
this.userService.updateProfileStats(principal.getIdAsLong(), request.groups()); this.userService.updateDashboardTiles(principal.getIdAsLong(), ApiMapper.toDomain(request.tiles()));
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/settings/profile-tiles", version = "1.0")
public ResponseEntity<Void> updateProfileTiles(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody ProfileTilesRequest request) {
this.userService.updateProfileTiles(principal.getIdAsLong(), ApiMapper.toDomain(request.tiles()));
return ResponseEntity.noContent().build();
}
@PutMapping(value = "/settings/profile-style", version = "1.0")
public ResponseEntity<Void> updateProfileStyle(@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody ProfileStyleRequest request) {
this.userService.updateProfileStyle(principal.getIdAsLong(), ApiMapper.toDomain(request));
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@@ -8,10 +8,13 @@ import jakarta.validation.constraints.Positive;
* The frontend has a CSS preset for each allowed name. * The frontend has a CSS preset for each allowed name.
*/ */
public record AppearanceRequest( public record AppearanceRequest(
@NotNull @Pattern(regexp = "violet|blue|green|orange|pink|red") String accent, @NotNull @Pattern(regexp = AppearanceRequest.ACCENTS) String accent,
@NotNull @Pattern(regexp = "sharp|default|round") String corners, @NotNull @Pattern(regexp = AppearanceRequest.CORNERS) String corners,
Boolean wideLayout, Boolean wideLayout,
@Positive Long backgroundAppid, @Positive Long backgroundAppid,
@Positive Long bannerAppid, @Positive Long bannerAppid,
Boolean showBanner) { Boolean showBanner) {
public static final String ACCENTS = "violet|blue|green|orange|pink|red";
public static final String CORNERS = "sharp|default|round";
} }
@@ -0,0 +1,22 @@
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 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);
}
}
@@ -1,13 +0,0 @@
package auth_service.modules.user.api.dto.request;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import java.util.List;
/**
* @param groups the stat groups of Core the public profile shows; empty shows none
*/
public record ProfileStatsRequest(
@NotNull List<@NotNull @Pattern(regexp = "PILE|TOP_GAMES|TASTE|HABITS|FINISHED|TREND") String> groups) {
}
@@ -0,0 +1,12 @@
package auth_service.modules.user.api.dto.request;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
public record ProfileStyleRequest(
@NotNull @Pattern(regexp = AppearanceRequest.ACCENTS) String accent,
@NotNull @Pattern(regexp = AppearanceRequest.CORNERS) String corners,
@Positive Long bannerAppid,
@Positive Long backgroundAppid) {
}
@@ -0,0 +1,24 @@
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
*/
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);
}
}
@@ -0,0 +1,19 @@
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}.
*/
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<TileRequest> tiles, String ids) {
return tiles == null || tiles.stream().allMatch(tile -> tile == null || tile.id() == null || tile.id().matches(ids));
}
}
@@ -0,0 +1,4 @@
package auth_service.modules.user.api.dto.response;
public record ProfileStyleResponse(String accent, String corners, Long bannerAppid, Long backgroundAppid) {
}
@@ -6,5 +6,6 @@ import java.util.List;
@Builder @Builder
public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile, public record SettingsResponse(long id, boolean useDarkTheme, String language, boolean publicProfile,
AppearanceResponse appearance, List<String> profileStats) { AppearanceResponse appearance, ProfileStyleResponse profileStyle,
List<TileResponse> dashboardTiles, List<TileResponse> profileTiles) {
} }
@@ -0,0 +1,4 @@
package auth_service.modules.user.api.dto.response;
public record TileResponse(String id, String size) {
}
@@ -2,15 +2,23 @@ package auth_service.modules.user.api.mapper;
import auth_service.modules.user.api.dto.request.AppearanceRequest; import auth_service.modules.user.api.dto.request.AppearanceRequest;
import auth_service.modules.user.api.dto.request.ProfileStyleRequest;
import auth_service.modules.user.api.dto.request.TileRequest;
import auth_service.modules.user.api.dto.request.UserSettingsRequest; import auth_service.modules.user.api.dto.request.UserSettingsRequest;
import auth_service.modules.user.api.dto.response.AppearanceResponse; import auth_service.modules.user.api.dto.response.AppearanceResponse;
import auth_service.modules.user.api.dto.response.ProfileStyleResponse;
import auth_service.modules.user.api.dto.response.TileResponse;
import auth_service.modules.user.api.dto.response.SettingsResponse; import auth_service.modules.user.api.dto.response.SettingsResponse;
import auth_service.modules.user.api.dto.response.UserProfileResponse; import auth_service.modules.user.api.dto.response.UserProfileResponse;
import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import auth_service.shared.security.Role; import auth_service.shared.security.Role;
import java.util.List;
public abstract class ApiMapper { public abstract class ApiMapper {
private ApiMapper() { private ApiMapper() {
} }
@@ -35,7 +43,10 @@ public abstract class ApiMapper {
.language(user.getSettings().getLanguage()) .language(user.getSettings().getLanguage())
.publicProfile(user.getSettings().isPublicProfile()) .publicProfile(user.getSettings().isPublicProfile())
.appearance(toApi(user.getSettings().getAppearance())) .appearance(toApi(user.getSettings().getAppearance()))
.profileStats(user.getSettings().getProfileStats()) .profileStyle(toApi(user.getSettings().getProfileStyle()))
.dashboardTiles(user.getSettings().getDashboardTiles() == null ? null
: toApi(user.getSettings().getDashboardTiles()))
.profileTiles(toApi(user.getSettings().getProfileTiles()))
.build()) .build())
.build(); .build();
} }
@@ -45,6 +56,25 @@ public abstract class ApiMapper {
appearance.backgroundAppid(), appearance.bannerAppid(), appearance.showBanner()); appearance.backgroundAppid(), appearance.bannerAppid(), appearance.showBanner());
} }
private static ProfileStyleResponse toApi(ProfileStyle style) {
return new ProfileStyleResponse(style.accent(), style.corners(), style.bannerAppid(), style.backgroundAppid());
}
public static ProfileStyle toDomain(ProfileStyleRequest request) {
return new ProfileStyle(request.accent(), request.corners(), request.bannerAppid(), request.backgroundAppid());
}
private static List<TileResponse> toApi(List<Tile> tiles) {
return tiles.stream().map(tile -> new TileResponse(tile.id(), tile.size())).toList();
}
/**
* @return null for null, the reset of a layout
*/
public static List<Tile> toDomain(List<TileRequest> tiles) {
return tiles == null ? null : tiles.stream().map(tile -> new Tile(tile.id(), tile.size())).toList();
}
public static Appearance toDomain(AppearanceRequest request) { public static Appearance toDomain(AppearanceRequest request) {
// A missing showBanner keeps the banner on, the default. // A missing showBanner keeps the banner on, the default.
return new Appearance(request.accent(), request.corners(), Boolean.TRUE.equals(request.wideLayout()), return new Appearance(request.accent(), request.corners(), Boolean.TRUE.equals(request.wideLayout()),
@@ -0,0 +1,14 @@
package auth_service.modules.user.model;
/**
* The look of the public profile, apart from the {@link Appearance} of the app. Its layout is
* {@link UserSettings#getProfileTiles()}.
*
* @param bannerAppid the Steam app whose art is the banner of the profile; null for none
* @param backgroundAppid the Steam app whose art fills the background of the profile; null for none
*/
public record ProfileStyle(String accent, String corners, Long bannerAppid, Long backgroundAppid) {
public static final ProfileStyle DEFAULT = new ProfileStyle(Appearance.DEFAULT.accent(), Appearance.DEFAULT.corners(),
null, null);
}
@@ -0,0 +1,41 @@
package auth_service.modules.user.model;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* One tile of a layout (dashboard or public profile). The frontend knows the tiles and their sizes; a layout lists
* the shown tiles in their order.
*
* @param size S, M or L
*/
public record Tile(String id, String size) {
/**
* @param stored "ID:SIZE", as the tile columns hold it
*/
public static Tile parse(String stored) {
int colon = stored.lastIndexOf(':');
return colon < 0 ? new Tile(stored, "M") : new Tile(stored.substring(0, colon), stored.substring(colon + 1));
}
public String stored() {
return id + ":" + size;
}
public static ArrayList<String> storedAll(List<Tile> tiles) {
return tiles.stream().map(Tile::stored).collect(Collectors.toCollection(ArrayList::new));
}
/**
* Keeps each tile once, at its first place.
*/
public static List<Tile> distinct(List<Tile> tiles) {
Map<String, Tile> byId = new LinkedHashMap<>();
tiles.forEach(tile -> byId.putIfAbsent(tile.id(), tile));
return new ArrayList<>(byId.values());
}
}
@@ -13,7 +13,14 @@ public class UserSettings {
private boolean publicProfile; private boolean publicProfile;
@Builder.Default @Builder.Default
private Appearance appearance = Appearance.DEFAULT; private Appearance appearance = Appearance.DEFAULT;
/** The stat groups of Core the public profile shows. */
@Builder.Default @Builder.Default
private List<String> profileStats = List.of(); private ProfileStyle profileStyle = ProfileStyle.DEFAULT;
/** Null: never changed, the frontend shows its default dashboard. */
private List<Tile> dashboardTiles;
@Builder.Default
private List<Tile> profileTiles = DEFAULT_PROFILE_TILES;
/** Also the column default of user_settings.profile_tiles. */
public static final List<Tile> DEFAULT_PROFILE_TILES = List.of(new Tile("SUMMARY", "M"), new Tile("STATUS", "M"),
new Tile("PLAYING", "M"), new Tile("FINISHED", "M"));
} }
@@ -2,6 +2,8 @@ package auth_service.modules.user.persistence;
import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.SteamIdHolder; import auth_service.modules.user.model.SteamIdHolder;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
@@ -32,7 +34,11 @@ public interface UserRepository {
@Transactional @Transactional
void updateAppearance(long userId, Appearance appearance); void updateAppearance(long userId, Appearance appearance);
void updateProfileStats(long userId, List<String> groups); void updateDashboardTiles(long userId, List<Tile> tiles);
void updateProfileTiles(long userId, List<Tile> tiles);
void updateProfileStyle(long userId, ProfileStyle style);
Optional<SteamIdHolder> findSteamIdHolder(long steamId); Optional<SteamIdHolder> findSteamIdHolder(long steamId);
@@ -2,6 +2,8 @@ package auth_service.modules.user.persistence;
import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.SteamIdHolder; import auth_service.modules.user.model.SteamIdHolder;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
@@ -15,7 +17,6 @@ import jakarta.transaction.Transactional;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@@ -99,8 +100,24 @@ public class UserRepositoryImpl implements UserRepository {
@Transactional @Transactional
@Override @Override
public void updateProfileStats(long userId, List<String> groups) { public void updateDashboardTiles(long userId, List<Tile> tiles) {
settingsOf(userId).setProfileStats(new ArrayList<>(groups)); settingsOf(userId).setDashboardTiles(tiles == null ? null : Tile.storedAll(tiles));
}
@Transactional
@Override
public void updateProfileTiles(long userId, List<Tile> tiles) {
settingsOf(userId).setProfileTiles(Tile.storedAll(tiles));
}
@Transactional
@Override
public void updateProfileStyle(long userId, ProfileStyle style) {
UserSettingsEntity settingsEntity = settingsOf(userId);
settingsEntity.setProfileAccent(style.accent());
settingsEntity.setProfileCorners(style.corners());
settingsEntity.setProfileBannerAppid(style.bannerAppid());
settingsEntity.setProfileBackgroundAppid(style.backgroundAppid());
} }
/** /**
@@ -1,6 +1,9 @@
package auth_service.modules.user.persistence.entities; package auth_service.modules.user.persistence.entities;
import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.UserSettings;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.Id; import jakarta.persistence.Id;
@@ -10,7 +13,6 @@ import lombok.Setter;
import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes; import org.hibernate.type.SqlTypes;
import java.util.ArrayList;
import java.util.List; import java.util.List;
@Entity @Entity
@@ -52,7 +54,24 @@ public class UserSettingsEntity {
@Column(name = "show_banner", nullable = false) @Column(name = "show_banner", nullable = false)
private boolean showBanner = Appearance.DEFAULT.showBanner(); private boolean showBanner = Appearance.DEFAULT.showBanner();
@Column(name = "profile_accent", nullable = false)
private String profileAccent = ProfileStyle.DEFAULT.accent();
@Column(name = "profile_corners", nullable = false)
private String profileCorners = ProfileStyle.DEFAULT.corners();
@Column(name = "profile_banner_appid")
private Long profileBannerAppid;
@Column(name = "profile_background_appid")
private Long profileBackgroundAppid;
/** "ID:SIZE" entries, see {@link auth_service.modules.user.model.Tile}. */
@JdbcTypeCode(SqlTypes.ARRAY) @JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "profile_stats", nullable = false) @Column(name = "dashboard_tiles")
private List<String> profileStats = new ArrayList<>(); private List<String> dashboardTiles;
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "profile_tiles", nullable = false)
private List<String> profileTiles = Tile.storedAll(UserSettings.DEFAULT_PROFILE_TILES);
} }
@@ -1,6 +1,8 @@
package auth_service.modules.user.persistence.mapper; package auth_service.modules.user.persistence.mapper;
import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.entities.UserEntity; import auth_service.modules.user.persistence.entities.UserEntity;
@@ -29,7 +31,12 @@ public abstract class EntityMapper {
.appearance(new Appearance(entity.getSettings().getAccent(), entity.getSettings().getCorners(), .appearance(new Appearance(entity.getSettings().getAccent(), entity.getSettings().getCorners(),
entity.getSettings().isWideLayout(), entity.getSettings().getBackgroundAppid(), entity.getSettings().isWideLayout(), entity.getSettings().getBackgroundAppid(),
entity.getSettings().getBannerAppid(), entity.getSettings().isShowBanner())) entity.getSettings().getBannerAppid(), entity.getSettings().isShowBanner()))
.profileStats(List.copyOf(entity.getSettings().getProfileStats())) .profileStyle(new ProfileStyle(entity.getSettings().getProfileAccent(),
entity.getSettings().getProfileCorners(), entity.getSettings().getProfileBannerAppid(),
entity.getSettings().getProfileBackgroundAppid()))
.dashboardTiles(entity.getSettings().getDashboardTiles() == null ? null
: entity.getSettings().getDashboardTiles().stream().map(Tile::parse).toList())
.profileTiles(entity.getSettings().getProfileTiles().stream().map(Tile::parse).toList())
.build(); .build();
} else { } else {
// Fallback default settings for users without an entry in the database // Fallback default settings for users without an entry in the database
@@ -3,6 +3,8 @@ package auth_service.modules.user.service;
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.SteamIdHolder; import auth_service.modules.user.model.SteamIdHolder;
import auth_service.modules.user.model.SteamLinkOutcome; import auth_service.modules.user.model.SteamLinkOutcome;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
@@ -96,13 +98,24 @@ public class UserService {
userRepository.updateAppearance(userId, appearance); userRepository.updateAppearance(userId, appearance);
} }
/**
* Stores each group once; the request may repeat one.
*/
@RequiresRole(Role.USER) @RequiresRole(Role.USER)
@Transactional @Transactional
@CacheEvict(value = "usersByEmail", allEntries = true) @CacheEvict(value = "usersByEmail", allEntries = true)
public void updateProfileStats(long userId, List<String> groups) { public void updateDashboardTiles(long userId, List<Tile> tiles) {
userRepository.updateProfileStats(userId, groups.stream().distinct().toList()); userRepository.updateDashboardTiles(userId, tiles == null ? null : Tile.distinct(tiles));
}
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "usersByEmail", allEntries = true)
public void updateProfileTiles(long userId, List<Tile> tiles) {
userRepository.updateProfileTiles(userId, Tile.distinct(tiles));
}
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "usersByEmail", allEntries = true)
public void updateProfileStyle(long userId, ProfileStyle style) {
userRepository.updateProfileStyle(userId, style);
} }
} }
@@ -9,6 +9,7 @@ import java.util.List;
public interface ProfileTicketIssuer { public interface ProfileTicketIssuer {
/** /**
* @param profileStats the stat groups of Core the profile shows * @param profileStats the stat groups of Core the profile shows
* @param sections the parts of the profile Core sends data for
*/ */
String generateProfileTicket(long userId, List<String> profileStats); String generateProfileTicket(long userId, List<String> profileStats, List<String> sections);
} }
@@ -0,0 +1 @@
org.springframework.boot.EnvironmentPostProcessor=auth_service.configuration.LocalKeysEnvironmentPostProcessor
+18
View File
@@ -0,0 +1,18 @@
# Local development without keys or third parties: start with the profile "local" (alone). Postgres comes from
# compose.yaml, the signing keys from LocalKeysEnvironmentPostProcessor. See the readme, "Run locally".
spring:
flyway:
locations: classpath:db/migration,classpath:db/local
app:
cors:
allowed-origins: "http://localhost:5173"
auth:
# Plain http; Safari drops Secure cookies on localhost.
cookie-secure: false
core:
internal-url: "http://localhost:8090"
auth:
registration:
invitation-code: local
@@ -0,0 +1,15 @@
-- Local profile only. A Flyway callback, not a migration: it runs after every migrate and leaves no history row,
-- so the same database still starts with "dev". dev@localhost / password123, PLATFORM_ADMIN, with the Steam ID of
-- Core's recorded answers. Display name and Steam ID are unique: when another user has one, the dev user comes
-- without it.
INSERT INTO users (email, password, enabled, display_name, steam_id, steam_id_verified, role)
SELECT 'dev@localhost', '$2a$10$3FApyf15qVEvFLEvl/d7aecSMZlslQdXiYlDgbOa4DfGWu4l0VCY.', true,
CASE WHEN NOT EXISTS (SELECT 1 FROM users WHERE lower(display_name) = 'dev') THEN 'Dev' END,
CASE WHEN NOT EXISTS (SELECT 1 FROM users WHERE steam_id = 76561198000000001) THEN 76561198000000001 END,
NOT EXISTS (SELECT 1 FROM users WHERE steam_id = 76561198000000001),
'PLATFORM_ADMIN'
ON CONFLICT DO NOTHING;
INSERT INTO user_settings (user_id)
SELECT id FROM users WHERE email = 'dev@localhost'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,10 @@
-- The look of the public profile, apart from the app appearance. A profile starts with the accent and corners the
-- user has in the app. The sections are the parts of the profile page in their order; a part not listed is hidden.
ALTER TABLE user_settings
ADD COLUMN profile_accent VARCHAR(16) NOT NULL DEFAULT 'violet',
ADD COLUMN profile_corners VARCHAR(16) NOT NULL DEFAULT 'default',
ADD COLUMN profile_banner_appid BIGINT,
ADD COLUMN profile_background_appid BIGINT,
ADD COLUMN profile_sections VARCHAR(16)[] NOT NULL DEFAULT '{SUMMARY,STATUS,PLAYING,FINISHED,STATS}';
UPDATE user_settings SET profile_accent = accent, profile_corners = corners;
@@ -0,0 +1,22 @@
-- Tile layouts of the dashboard and the public profile: tile ids in their order as 'ID:SIZE' (size S, M or L); a
-- tile not listed is hidden. dashboard_tiles NULL = never changed, the frontend shows its default dashboard.
ALTER TABLE user_settings
ADD COLUMN dashboard_tiles VARCHAR(32)[],
ADD COLUMN profile_tiles VARCHAR(32)[] NOT NULL DEFAULT '{SUMMARY:M,STATUS:M,PLAYING:M,FINISHED:M}';
-- The profile layout replaces the section list (V16) and the shared stat groups (V12): the sections keep their
-- order, and STATS becomes one STATS_<GROUP> tile per shared group at its place.
UPDATE user_settings s
SET profile_tiles = COALESCE((SELECT array_agg(tile ORDER BY section_no, group_no)
FROM (SELECT sec.no AS section_no, 0 AS group_no, sec.name || ':M' AS tile
FROM unnest(s.profile_sections) WITH ORDINALITY AS sec(name, no)
WHERE sec.name <> 'STATS'
UNION ALL
SELECT sec.no, grp.no, 'STATS_' || grp.name || ':M'
FROM unnest(s.profile_sections) WITH ORDINALITY AS sec(name, no)
CROSS JOIN unnest(s.profile_stats) WITH ORDINALITY AS grp(name, no)
WHERE sec.name = 'STATS') AS tiles), '{}');
ALTER TABLE user_settings
DROP COLUMN profile_sections,
DROP COLUMN profile_stats;
@@ -0,0 +1,51 @@
package auth_service.configuration;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.springframework.boot.SpringApplication;
import org.springframework.mock.env.MockEnvironment;
import java.security.KeyFactory;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LocalKeysEnvironmentPostProcessorTest extends AbstractUnitTest {
private final LocalKeysEnvironmentPostProcessor processor = new LocalKeysEnvironmentPostProcessor();
@Test
void localProfile_GetsAFreshKeyPairAndKid() throws Exception {
MockEnvironment local = new MockEnvironment();
local.setActiveProfiles("local");
processor.postProcessEnvironment(local, new SpringApplication());
assertThat(local.getProperty("security.jwt.kid")).startsWith("local-");
byte[] privateKey = Base64.getDecoder().decode(local.getProperty("security.jwt.private-key"));
assertThat(KeyFactory.getInstance("Ed25519").generatePrivate(new PKCS8EncodedKeySpec(privateKey))).isNotNull();
assertThat(local.getProperty("security.jwt.public-key")).isNotBlank();
}
@Test
void otherProfiles_KeepTheirConfiguredKeys() {
MockEnvironment prod = new MockEnvironment().withProperty("security.jwt.kid", "tbp-auth-1");
prod.setActiveProfiles("prod");
processor.postProcessEnvironment(prod, new SpringApplication());
assertThat(prod.getProperty("security.jwt.kid")).isEqualTo("tbp-auth-1");
assertThat(prod.getProperty("security.jwt.private-key")).isNull();
}
@Test
void localTogetherWithProd_RefusesToStart() {
MockEnvironment both = new MockEnvironment();
both.setActiveProfiles("prod", "local");
assertThatThrownBy(() -> processor.postProcessEnvironment(both, new SpringApplication()))
.isInstanceOf(IllegalStateException.class);
}
}
@@ -0,0 +1,60 @@
package auth_service.configuration;
import auth_service.configuration.security.ClientHeaderFilter;
import auth_service.shared.api.AuthCookies;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.PostgreSQLContainer;
import static org.hamcrest.Matchers.startsWith;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* The committed local profile: it starts without keys, and the seeded dev user logs in.
*/
@Tag("IntegrationTest")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "app.account-cleanup.cron=-")
@AutoConfigureMockMvc
@ActiveProfiles("local")
class LocalProfileTest {
// Its own database, not the one of AbstractIntegrationTest: the seeded admin would stay there for every other
// test class.
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine");
static {
postgres.start();
}
@Autowired
private MockMvc mockMvc;
@Test
void devUser_LogsInAsAdminWithItsSteamId() throws Exception {
Cookie session = mockMvc.perform(post("/api/auth/login").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"dev@localhost\",\"password\":\"password123\"}"))
.andExpect(status().isOk())
.andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE);
mockMvc.perform(get("/api/users/me").cookie(session))
.andExpect(jsonPath("$.role").value("PLATFORM_ADMIN"))
.andExpect(jsonPath("$.steamId").value("76561198000000001"))
.andExpect(jsonPath("$.steamIdVerified").value(true));
mockMvc.perform(get("/.well-known/jwks.json"))
.andExpect(jsonPath("$.keys[0].kid", startsWith("local-")));
}
}
@@ -0,0 +1,61 @@
package auth_service.configuration;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
/**
* The dev and local profiles share the Postgres of compose.yaml (readme, "Run locally").
*/
@Tag("IntegrationTest")
class LocalSeedFlywayTest {
private static final String DEFAULT_LOCATIONS = "classpath:db/migration";
private static final String LOCAL_LOCATIONS = "classpath:db/migration,classpath:db/local";
@Test
void devAfterLocal_StartsOnTheSameDatabase() {
try (PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine")) {
postgres.start();
flyway(postgres, LOCAL_LOCATIONS).migrate();
assertThatCode(() -> flyway(postgres, DEFAULT_LOCATIONS).migrate()).doesNotThrowAnyException();
}
}
@Test
void localOnADatabaseWithAUserNamedDev_StillAddsTheDevUser() throws Exception {
try (PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine")) {
postgres.start();
flyway(postgres, DEFAULT_LOCATIONS).migrate();
try (Connection connection = DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
Statement statement = connection.createStatement()) {
statement.execute("INSERT INTO users (email, password, display_name) VALUES ('me@example.com', 'x', 'dev')");
flyway(postgres, LOCAL_LOCATIONS).migrate();
ResultSet rows = statement.executeQuery("SELECT count(*) FROM users WHERE email = 'dev@localhost'");
rows.next();
assertThat(rows.getInt(1)).isEqualTo(1);
}
}
}
// Spring Boot's defaults for this repo (application.yml): baseline-on-migrate, validate on migrate.
private static Flyway flyway(PostgreSQLContainer<?> postgres, String locations) {
return Flyway.configure()
.dataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())
.locations(locations.split(","))
.baselineOnMigrate(true)
.load();
}
}
@@ -110,7 +110,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test @Test
void generateProfileTicket_NamesTheUserAndExpiresSoon() { void generateProfileTicket_NamesTheUserAndExpiresSoon() {
var ticket = jwtService.generateProfileTicket(42L, List.of()); var ticket = jwtService.generateProfileTicket(42L, List.of(), List.of());
var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload(); var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
assertEquals("public-profile", claims.get("typ", String.class)); assertEquals("public-profile", claims.get("typ", String.class));
@@ -122,15 +122,16 @@ class JwtServiceTest extends AbstractUnitTest {
@Test @Test
void generateProfileTicket_CarriesTheSharedStatGroups() { void generateProfileTicket_CarriesTheSharedStatGroups() {
String ticket = jwtService.generateProfileTicket(42L, List.of("PILE", "TREND")); String ticket = jwtService.generateProfileTicket(42L, List.of("PILE", "TREND"), List.of("PLAYING", "STATS"));
Claims claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload(); Claims claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class)); assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class));
assertEquals(List.of("PLAYING", "STATS"), claims.get("sections", List.class));
} }
@Test @Test
void parseLoginToken_ProfileTicketIsNoLoginToken() { void parseLoginToken_ProfileTicketIsNoLoginToken() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L, List.of()))); assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L, List.of(), List.of())));
} }
@Test @Test
@@ -73,8 +73,9 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
} }
@Test @Test
void publicProfile_TicketCarriesTheSharedStatGroups() throws Exception { void publicProfile_TicketCarriesTheStatTilesAsSharedGroups() throws Exception {
jdbc.update("update user_settings set profile_stats = '{PILE,TREND}' where user_id = ?", publicUserId); jdbc.update("update user_settings set profile_tiles = '{STATS_PILE:M,PLAYING:S,STATS_TREND:L}' where user_id = ?",
publicUserId);
String body = mockMvc.perform(get("/api/public/profiles/Lobo")).andReturn().getResponse().getContentAsString(); String body = mockMvc.perform(get("/api/public/profiles/Lobo")).andReturn().getResponse().getContentAsString();
@@ -82,6 +83,55 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
PublicKey key = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicKey))); PublicKey key = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicKey)));
Claims claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload(); Claims claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload();
assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class)); assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class));
assertEquals(List.of("PLAYING", "STATS"), claims.get("sections", List.class));
}
@Test
void publicProfile_AnswersWithTheOwnersStyle() throws Exception {
jdbc.update("update user_settings set profile_accent = 'red', profile_corners = 'round', profile_banner_appid = 620, "
+ "profile_tiles = '{PLAYING:S,STATS_PILE:L}' where user_id = ?", publicUserId);
mockMvc.perform(get("/api/public/profiles/Lobo"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.style.accent").value("red"))
.andExpect(jsonPath("$.style.corners").value("round"))
.andExpect(jsonPath("$.style.bannerAppid").value(620))
.andExpect(jsonPath("$.style.backgroundAppid").isEmpty())
.andExpect(jsonPath("$.style.tiles[0].id").value("PLAYING"))
.andExpect(jsonPath("$.style.tiles[0].size").value("S"))
.andExpect(jsonPath("$.style.tiles[1].id").value("STATS_PILE"))
.andExpect(jsonPath("$.style.tiles[1].size").value("L"));
}
@Test
void publicProfile_NewRowShowsTheDefaultTiles() throws Exception {
mockMvc.perform(get("/api/public/profiles/Lobo"))
.andExpect(jsonPath("$.style.accent").value("violet"))
.andExpect(jsonPath("$.style.tiles.length()").value(4))
.andExpect(jsonPath("$.style.tiles[3].id").value("FINISHED"));
}
@Test
void publicProfile_NoStatTiles_TicketSharesNoStats() throws Exception {
jdbc.update("update user_settings set profile_tiles = '{PLAYING:M}' where user_id = ?", publicUserId);
String body = mockMvc.perform(get("/api/public/profiles/Lobo")).andReturn().getResponse().getContentAsString();
String ticket = body.replaceAll(".*\"ticket\"\\s*:\\s*\"([^\"]+)\".*", "$1");
PublicKey key = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicKey)));
Claims claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload();
assertEquals(List.of("PLAYING"), claims.get("sections", List.class));
assertEquals(List.of(), claims.get("profileStats", List.class));
}
@Test
void publicProfile_TileWithoutSize_IsMedium() throws Exception {
jdbc.update("update user_settings set profile_tiles = '{PLAYING}' where user_id = ?", publicUserId);
mockMvc.perform(get("/api/public/profiles/Lobo"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.style.tiles[0].id").value("PLAYING"))
.andExpect(jsonPath("$.style.tiles[0].size").value("M"));
} }
@Test @Test
@@ -1,5 +1,7 @@
package auth_service.modules.user.api; package auth_service.modules.user.api;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.configuration.security.JwtAuthenticationFilter; import auth_service.configuration.security.JwtAuthenticationFilter;
import auth_service.configuration.security.JwtService; import auth_service.configuration.security.JwtService;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
@@ -349,10 +351,11 @@ class UserControllerTest extends AbstractWebMvcTest {
} }
@Test @Test
void getCurrentUserV1_ShowsTheSharedStatGroups() throws Exception { void getCurrentUserV1_ShowsTheTileLayouts() throws Exception {
String email = "test@example.com"; String email = "test@example.com";
UserSettings settings = UserSettings.builder().language("en").theme("dark") UserSettings settings = UserSettings.builder().language("en").theme("dark")
.profileStats(List.of("PILE", "TREND")).build(); .dashboardTiles(List.of(new Tile("STATS_TREND", "L")))
.profileTiles(List.of(new Tile("PLAYING", "S"))).build();
SecurityContextHolder.getContext().setAuthentication( SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList()) new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
); );
@@ -360,34 +363,153 @@ class UserControllerTest extends AbstractWebMvcTest {
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON)) mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andExpect(jsonPath("$.settings.profileStats[0]").value("PILE")) .andExpect(jsonPath("$.settings.dashboardTiles[0].id").value("STATS_TREND"))
.andExpect(jsonPath("$.settings.profileStats[1]").value("TREND")); .andExpect(jsonPath("$.settings.dashboardTiles[0].size").value("L"))
.andExpect(jsonPath("$.settings.profileTiles[0].id").value("PLAYING"))
.andExpect(jsonPath("$.settings.profileTiles[0].size").value("S"));
} }
@Test @Test
void updateProfileStats_StoresTheGroups() throws Exception { void getCurrentUserV1_NeverChangedDashboard_SendsNullTiles() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( String email = "test@example.com";
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList())); SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(User.builder().email(email)
.settings(UserSettings.builder().language("en").theme("dark").build()).build());
mockMvc.perform(put("/api/users/settings/profile-stats") mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.contentType(MediaType.APPLICATION_JSON) .andExpect(status().isOk())
.content("{\"groups\":[\"PILE\",\"TREND\"]}")) .andExpect(jsonPath("$.settings.dashboardTiles").isEmpty())
.andExpect(status().isNoContent()); .andExpect(jsonPath("$.settings.profileTiles.length()").value(4));
verify(userService).updateProfileStats(1L, List.of("PILE", "TREND"));
} }
// OVERVIEW is public anyway, so it is no choice. @Test
@ParameterizedTest void updateDashboardTiles_StoresTheTiles() throws Exception {
@ValueSource(strings = {"{}", "{\"groups\":[\"OVERVIEW\"]}", "{\"groups\":[\"MONEY\"]}", "{\"groups\":[null]}"})
void updateProfileStats_UnknownOrMissingGroups_Is400(String body) throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList())); new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/profile-stats") mockMvc.perform(put("/api/users/settings/dashboard-tiles")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"tiles\":[{\"id\":\"STATS_TREND\",\"size\":\"L\"},{\"id\":\"PLAYING\",\"size\":\"S\"}]}"))
.andExpect(status().isNoContent());
verify(userService).updateDashboardTiles(1L, List.of(new Tile("STATS_TREND", "L"), new Tile("PLAYING", "S")));
}
@Test
void updateDashboardTiles_NullTilesResets() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/dashboard-tiles")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"tiles\":null}"))
.andExpect(status().isNoContent());
verify(userService).updateDashboardTiles(1L, null);
}
@Test
void updateProfileTiles_StoresTheTiles() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/profile-tiles")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"tiles\":[{\"id\":\"STATS_PILE\",\"size\":\"M\"}]}"))
.andExpect(status().isNoContent());
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.
@ParameterizedTest
@ValueSource(strings = {
"dashboard-tiles|{\"tiles\":[{\"id\":\"SUMMARY\",\"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\":null}",
"profile-tiles|{}"
})
void updateTiles_UnknownIdBadSizeOrMissing_IsRejected(String pathAndBody) throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
String[] parts = pathAndBody.split("\\|", 2);
mockMvc.perform(put("/api/users/settings/" + parts[0])
.contentType(MediaType.APPLICATION_JSON)
.content(parts[1]))
.andExpect(status().isBadRequest());
verifyNoInteractions(userService);
}
@Test
void updateTiles_MoreThanTwentyTiles_IsRejected() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
String tiles = String.join(",", Collections.nCopies(21, "{\"id\":\"PLAYING\",\"size\":\"M\"}"));
mockMvc.perform(put("/api/users/settings/dashboard-tiles")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"tiles\":[" + tiles + "]}"))
.andExpect(status().isBadRequest());
}
@Test
void getCurrentUserV1_ShowsTheProfileStyle() throws Exception {
String email = "test@example.com";
UserSettings settings = UserSettings.builder().language("en").theme("dark")
.profileStyle(new ProfileStyle("red", "sharp", 620L, null)).build();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(User.builder().email(email).settings(settings).build());
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.settings.profileStyle.accent").value("red"))
.andExpect(jsonPath("$.settings.profileStyle.corners").value("sharp"))
.andExpect(jsonPath("$.settings.profileStyle.bannerAppid").value(620))
.andExpect(jsonPath("$.settings.profileStyle.backgroundAppid").isEmpty());
}
@Test
void updateProfileStyle_StoresTheValues() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/profile-style")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"accent\":\"green\",\"corners\":\"round\",\"bannerAppid\":620}"))
.andExpect(status().isNoContent());
verify(userService).updateProfileStyle(1L, new ProfileStyle("green", "round", 620L, null));
}
// Each body differs from a valid one in one field only, so the 400 comes from that field's constraint.
@ParameterizedTest
@ValueSource(strings = {
"{\"accent\":\"hotpink\",\"corners\":\"default\"}",
"{\"accent\":\"violet\",\"corners\":\"pointy\"}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"bannerAppid\":0}",
"{\"accent\":\"violet\",\"corners\":\"default\",\"backgroundAppid\":-620}",
"{\"corners\":\"default\"}"
})
void updateProfileStyle_UnknownOrMissingValue_IsRejected(String body) throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/profile-style")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(body)) .content(body))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
verifyNoInteractions(userService);
} }
@Test @Test
@@ -0,0 +1,23 @@
package auth_service.modules.user.model;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class TileTest extends AbstractUnitTest {
@Test
void parse_ReadsTheStoredForm() {
assertEquals(new Tile("STATS_TOP_GAMES", "L"), Tile.parse("STATS_TOP_GAMES:L"));
assertEquals("PLAYING:S", new Tile("PLAYING", "S").stored());
}
@Test
void distinct_KeepsEachTileOnceAtItsFirstPlace() {
assertEquals(List.of(new Tile("PLAYING", "S"), new Tile("SUMMARY", "M")),
Tile.distinct(List.of(new Tile("PLAYING", "S"), new Tile("SUMMARY", "M"), new Tile("PLAYING", "L"))));
}
}
@@ -1,5 +1,7 @@
package auth_service.modules.user.persistence; package auth_service.modules.user.persistence;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance; import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.SteamIdHolder; import auth_service.modules.user.model.SteamIdHolder;
@@ -281,26 +283,69 @@ class UserRepositoryTest extends AbstractPersistenceTest {
} }
@Test @Test
void updateProfileStats_IsReadBackAndKeptByUpdateSettings() { void updateProfileStyle_IsReadBackAndStartsWithTheDefaults() {
UserEntity userEntity = entityManager.persist(UserEntity.builder() UserEntity userEntity = entityManager.persist(UserEntity.builder()
.email("stats@example.com") .email("style@example.com")
.password("password") .password("password")
.enabled(true) .enabled(true)
.registrationDate(LocalDateTime.now()) .registrationDate(LocalDateTime.now())
.build()); .build());
entityManager.flush(); entityManager.flush();
assertEquals(List.of(), userRepository.findByEmail("stats@example.com").orElseThrow() assertEquals(ProfileStyle.DEFAULT, userRepository.findByEmail("style@example.com").orElseThrow()
.getSettings().getProfileStats()); .getSettings().getProfileStyle());
userRepository.updateProfileStats(userEntity.getId(), List.of("PILE", "TREND")); ProfileStyle style = new ProfileStyle("pink", "sharp", 620L, 1086940L);
userRepository.updateProfileStyle(userEntity.getId(), style);
entityManager.flush();
entityManager.clear();
assertEquals(style, userRepository.findByEmail("style@example.com").orElseThrow().getSettings().getProfileStyle());
}
@Test
void updateTiles_AreReadBackAndKeptByUpdateSettings() {
UserEntity userEntity = entityManager.persist(UserEntity.builder()
.email("tiles@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
UserSettings fresh = userRepository.findByEmail("tiles@example.com").orElseThrow().getSettings();
assertNull(fresh.getDashboardTiles());
assertEquals(UserSettings.DEFAULT_PROFILE_TILES, fresh.getProfileTiles());
List<Tile> dashboard = List.of(new Tile("STATS_TREND", "L"), new Tile("PLAYING", "S"));
List<Tile> profile = List.of(new Tile("STATS_PILE", "M"), new Tile("SUMMARY", "S"));
userRepository.updateDashboardTiles(userEntity.getId(), dashboard);
userRepository.updateProfileTiles(userEntity.getId(), profile);
entityManager.flush(); entityManager.flush();
userRepository.updateSettings(userEntity.getId(), null, userRepository.updateSettings(userEntity.getId(), null,
UserSettings.builder().language("de").theme("dark").build()); UserSettings.builder().language("de").theme("dark").build());
entityManager.flush(); entityManager.flush();
entityManager.clear(); entityManager.clear();
assertEquals(List.of("PILE", "TREND"), userRepository.findByEmail("stats@example.com").orElseThrow() UserSettings saved = userRepository.findByEmail("tiles@example.com").orElseThrow().getSettings();
.getSettings().getProfileStats()); assertEquals(dashboard, saved.getDashboardTiles());
assertEquals(profile, saved.getProfileTiles());
}
@Test
void updateDashboardTiles_NullResetsToTheDefault() {
UserEntity userEntity = entityManager.persist(UserEntity.builder()
.email("reset@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
userRepository.updateDashboardTiles(userEntity.getId(), List.of(new Tile("PLAYING", "S")));
entityManager.flush();
userRepository.updateDashboardTiles(userEntity.getId(), null);
entityManager.flush();
entityManager.clear();
assertNull(userRepository.findByEmail("reset@example.com").orElseThrow().getSettings().getDashboardTiles());
} }
@Test @Test
@@ -1,5 +1,6 @@
package auth_service.modules.user.service; package auth_service.modules.user.service;
import auth_service.modules.user.model.Tile;
import auth_service.shared.security.Role; import auth_service.shared.security.Role;
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException; import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.exception.UserNotFoundException;
@@ -64,10 +65,19 @@ class UserServiceTest extends AbstractUnitTest {
} }
@Test @Test
void updateProfileStats_StoresEachGroupOnce() { void updateTiles_StoreEachTileOnce() {
userService.updateProfileStats(1L, List.of("PILE", "TREND", "PILE")); userService.updateDashboardTiles(1L, List.of(new Tile("PLAYING", "S"), new Tile("PLAYING", "L")));
userService.updateProfileTiles(1L, List.of(new Tile("SUMMARY", "M"), new Tile("SUMMARY", "S")));
verify(userRepository).updateProfileStats(1L, List.of("PILE", "TREND")); verify(userRepository).updateDashboardTiles(1L, List.of(new Tile("PLAYING", "S")));
verify(userRepository).updateProfileTiles(1L, List.of(new Tile("SUMMARY", "M")));
}
@Test
void updateDashboardTiles_NullStaysNull() {
userService.updateDashboardTiles(1L, null);
verify(userRepository).updateDashboardTiles(1L, null);
} }
@Test @Test