Public Access
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
315e22c220 | ||
|
|
dd4fcd1546 | ||
|
|
f86f3c2e63 |
No files matched your search
@@ -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
|
||||
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,
|
||||
10 minutes, `typ=public-profile`) that Core accepts for the public library data only. Its `profileStats` claim
|
||||
names the stat groups the user chose to show there (none by default). The ticket is not a login:
|
||||
10 minutes, `typ=public-profile`) that Core accepts for the public library data only. The profile has its own
|
||||
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.
|
||||
- **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
|
||||
@@ -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 |
|
||||
| `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-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/stats/public` | 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
|
||||
|
||||
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
|
||||
./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
|
||||
public String generateProfileTicket(long userId, List<String> profileStats) {
|
||||
public String generateProfileTicket(long userId, List<String> profileStats, List<String> sections) {
|
||||
return Jwts.builder()
|
||||
.header().keyId(jwtConfig.getKid()).and()
|
||||
.subject(PROFILE_TICKET_TYPE)
|
||||
.claim(TYPE_CLAIM, PROFILE_TICKET_TYPE)
|
||||
.claim("profileUserId", String.valueOf(userId))
|
||||
.claim("profileStats", profileStats)
|
||||
.claim("sections", sections)
|
||||
.issuedAt(new Date(System.currentTimeMillis()))
|
||||
.expiration(new Date(System.currentTimeMillis() + PROFILE_TICKET_EXPIRATION_MS))
|
||||
.signWith(privateKey, Jwts.SIG.EdDSA)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth_service.modules.profile.api;
|
||||
|
||||
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.shared.api.dto.response.ErrorResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -26,8 +27,14 @@ public class PublicProfileController {
|
||||
public ResponseEntity<?> getPublicProfile(@PathVariable("displayName") String displayName) {
|
||||
return publicProfileService.findPublicProfile(displayName)
|
||||
.<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)
|
||||
.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;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @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.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.stereotype.Repository;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -16,19 +17,30 @@ public class PublicProfileRepository {
|
||||
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.
|
||||
*/
|
||||
public Optional<PublicProfile> findPublicByDisplayName(String displayName) {
|
||||
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
|
||||
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.getTimestamp("registration_date") != 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();
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,11 @@ import auth_service.shared.security.Public;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@Public
|
||||
@@ -16,6 +20,8 @@ public class PublicProfileService {
|
||||
|
||||
public record TicketedProfile(PublicProfile profile, String ticket) {}
|
||||
|
||||
private static final String STATS_PREFIX = "STATS_";
|
||||
|
||||
private final PublicProfileRepository publicProfileRepository;
|
||||
private final ProfileTicketIssuer profileTicketIssuer;
|
||||
|
||||
@@ -24,6 +30,18 @@ public class PublicProfileService {
|
||||
*/
|
||||
public Optional<TicketedProfile> findPublicProfile(String 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.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.response.UserProfileResponse;
|
||||
import auth_service.modules.user.api.mapper.ApiMapper;
|
||||
@@ -46,10 +48,24 @@ public class UserController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PutMapping(value = "/settings/profile-stats", version = "1.0")
|
||||
public ResponseEntity<Void> updateProfileStats(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody ProfileStatsRequest request) {
|
||||
this.userService.updateProfileStats(principal.getIdAsLong(), request.groups());
|
||||
@PutMapping(value = "/settings/dashboard-tiles", version = "1.0")
|
||||
public ResponseEntity<Void> updateDashboardTiles(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody DashboardTilesRequest request) {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,13 @@ import jakarta.validation.constraints.Positive;
|
||||
* The frontend has a CSS preset for each allowed name.
|
||||
*/
|
||||
public record AppearanceRequest(
|
||||
@NotNull @Pattern(regexp = "violet|blue|green|orange|pink|red") String accent,
|
||||
@NotNull @Pattern(regexp = "sharp|default|round") String corners,
|
||||
@NotNull @Pattern(regexp = AppearanceRequest.ACCENTS) String accent,
|
||||
@NotNull @Pattern(regexp = AppearanceRequest.CORNERS) String corners,
|
||||
Boolean wideLayout,
|
||||
@Positive Long backgroundAppid,
|
||||
@Positive Long bannerAppid,
|
||||
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
|
||||
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.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.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.UserProfileResponse;
|
||||
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.UserSettings;
|
||||
import auth_service.shared.security.Role;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public abstract class ApiMapper {
|
||||
private ApiMapper() {
|
||||
}
|
||||
@@ -35,7 +43,10 @@ public abstract class ApiMapper {
|
||||
.language(user.getSettings().getLanguage())
|
||||
.publicProfile(user.getSettings().isPublicProfile())
|
||||
.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();
|
||||
}
|
||||
@@ -45,6 +56,25 @@ public abstract class ApiMapper {
|
||||
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) {
|
||||
// A missing showBanner keeps the banner on, the default.
|
||||
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;
|
||||
@Builder.Default
|
||||
private Appearance appearance = Appearance.DEFAULT;
|
||||
/** The stat groups of Core the public profile shows. */
|
||||
@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.ProfileStyle;
|
||||
import auth_service.modules.user.model.Tile;
|
||||
import auth_service.modules.user.model.SteamIdHolder;
|
||||
import auth_service.modules.user.model.User;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
@@ -32,7 +34,11 @@ public interface UserRepository {
|
||||
@Transactional
|
||||
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);
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package auth_service.modules.user.persistence;
|
||||
|
||||
import auth_service.modules.user.exception.UserNotFoundException;
|
||||
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.User;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
@@ -15,7 +17,6 @@ import jakarta.transaction.Transactional;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -99,8 +100,24 @@ public class UserRepositoryImpl implements UserRepository {
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
public void updateProfileStats(long userId, List<String> groups) {
|
||||
settingsOf(userId).setProfileStats(new ArrayList<>(groups));
|
||||
public void updateDashboardTiles(long userId, List<Tile> tiles) {
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+22
-3
@@ -1,6 +1,9 @@
|
||||
package auth_service.modules.user.persistence.entities;
|
||||
|
||||
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.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
@@ -10,7 +13,6 @@ import lombok.Setter;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@@ -52,7 +54,24 @@ public class UserSettingsEntity {
|
||||
@Column(name = "show_banner", nullable = false)
|
||||
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)
|
||||
@Column(name = "profile_stats", nullable = false)
|
||||
private List<String> profileStats = new ArrayList<>();
|
||||
@Column(name = "dashboard_tiles")
|
||||
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;
|
||||
|
||||
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.UserSettings;
|
||||
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(),
|
||||
entity.getSettings().isWideLayout(), entity.getSettings().getBackgroundAppid(),
|
||||
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();
|
||||
} else {
|
||||
// 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.UserNotFoundException;
|
||||
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.SteamLinkOutcome;
|
||||
import auth_service.modules.user.model.User;
|
||||
@@ -96,13 +98,24 @@ public class UserService {
|
||||
userRepository.updateAppearance(userId, appearance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores each group once; the request may repeat one.
|
||||
*/
|
||||
@RequiresRole(Role.USER)
|
||||
@Transactional
|
||||
@CacheEvict(value = "usersByEmail", allEntries = true)
|
||||
public void updateProfileStats(long userId, List<String> groups) {
|
||||
userRepository.updateProfileStats(userId, groups.stream().distinct().toList());
|
||||
public void updateDashboardTiles(long userId, List<Tile> tiles) {
|
||||
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 {
|
||||
/**
|
||||
* @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
|
||||
@@ -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
|
||||
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();
|
||||
assertEquals("public-profile", claims.get("typ", String.class));
|
||||
@@ -122,15 +122,16 @@ class JwtServiceTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
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();
|
||||
assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class));
|
||||
assertEquals(List.of("PLAYING", "STATS"), claims.get("sections", List.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
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
|
||||
|
||||
@@ -73,8 +73,9 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicProfile_TicketCarriesTheSharedStatGroups() throws Exception {
|
||||
jdbc.update("update user_settings set profile_stats = '{PILE,TREND}' where user_id = ?", publicUserId);
|
||||
void publicProfile_TicketCarriesTheStatTilesAsSharedGroups() throws Exception {
|
||||
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();
|
||||
|
||||
@@ -82,6 +83,55 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
||||
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("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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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.JwtService;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
@@ -349,10 +351,11 @@ class UserControllerTest extends AbstractWebMvcTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCurrentUserV1_ShowsTheSharedStatGroups() throws Exception {
|
||||
void getCurrentUserV1_ShowsTheTileLayouts() throws Exception {
|
||||
String email = "test@example.com";
|
||||
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(
|
||||
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))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.settings.profileStats[0]").value("PILE"))
|
||||
.andExpect(jsonPath("$.settings.profileStats[1]").value("TREND"));
|
||||
.andExpect(jsonPath("$.settings.dashboardTiles[0].id").value("STATS_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
|
||||
void updateProfileStats_StoresTheGroups() throws Exception {
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
|
||||
void getCurrentUserV1_NeverChangedDashboard_SendsNullTiles() throws Exception {
|
||||
String email = "test@example.com";
|
||||
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")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"groups\":[\"PILE\",\"TREND\"]}"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(userService).updateProfileStats(1L, List.of("PILE", "TREND"));
|
||||
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.settings.dashboardTiles").isEmpty())
|
||||
.andExpect(jsonPath("$.settings.profileTiles.length()").value(4));
|
||||
}
|
||||
|
||||
// OVERVIEW is public anyway, so it is no choice.
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"{}", "{\"groups\":[\"OVERVIEW\"]}", "{\"groups\":[\"MONEY\"]}", "{\"groups\":[null]}"})
|
||||
void updateProfileStats_UnknownOrMissingGroups_Is400(String body) throws Exception {
|
||||
@Test
|
||||
void updateDashboardTiles_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-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)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verifyNoInteractions(userService);
|
||||
}
|
||||
|
||||
@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;
|
||||
|
||||
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.model.Appearance;
|
||||
import auth_service.modules.user.model.SteamIdHolder;
|
||||
@@ -281,26 +283,69 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProfileStats_IsReadBackAndKeptByUpdateSettings() {
|
||||
void updateProfileStyle_IsReadBackAndStartsWithTheDefaults() {
|
||||
UserEntity userEntity = entityManager.persist(UserEntity.builder()
|
||||
.email("stats@example.com")
|
||||
.email("style@example.com")
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build());
|
||||
entityManager.flush();
|
||||
assertEquals(List.of(), userRepository.findByEmail("stats@example.com").orElseThrow()
|
||||
.getSettings().getProfileStats());
|
||||
assertEquals(ProfileStyle.DEFAULT, userRepository.findByEmail("style@example.com").orElseThrow()
|
||||
.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();
|
||||
userRepository.updateSettings(userEntity.getId(), null,
|
||||
UserSettings.builder().language("de").theme("dark").build());
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
assertEquals(List.of("PILE", "TREND"), userRepository.findByEmail("stats@example.com").orElseThrow()
|
||||
.getSettings().getProfileStats());
|
||||
UserSettings saved = userRepository.findByEmail("tiles@example.com").orElseThrow().getSettings();
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package auth_service.modules.user.service;
|
||||
|
||||
import auth_service.modules.user.model.Tile;
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
|
||||
import auth_service.modules.user.exception.UserNotFoundException;
|
||||
@@ -64,10 +65,19 @@ class UserServiceTest extends AbstractUnitTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProfileStats_StoresEachGroupOnce() {
|
||||
userService.updateProfileStats(1L, List.of("PILE", "TREND", "PILE"));
|
||||
void updateTiles_StoreEachTileOnce() {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user