Public Access
feat(profile): store the public profile look
This commit is contained in:
1 parent
f86f3c2e63
commit
dd4fcd1546
27 files changed
+308
-15
No files matched your search
@@ -36,7 +36,9 @@ To Be Played is split into three repositories:
|
||||
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:
|
||||
names the stat groups the user chose to show there (none by default), its `sections` claim the parts of the
|
||||
profile the user shows. The profile has its own look (accent, corners, banner and background from own game art,
|
||||
sections in order), apart from the app appearance, and the public answer sends it as `style`. 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
|
||||
@@ -90,6 +92,7 @@ To Be Played is split into three repositories:
|
||||
| `GET` | `/api/steam-link/callback` | public, Steam's redirect back; binding cookie; redirects to the frontend settings |
|
||||
| `PUT` | `/api/users/settings/appearance` | access token |
|
||||
| `PUT` | `/api/users/settings/profile-stats` (`{groups}`: `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`; `204`) | access token |
|
||||
| `PUT` | `/api/users/settings/profile-style` (`{accent, corners, bannerAppid, backgroundAppid, sections}`, sections from `SUMMARY`, `STATUS`, `PLAYING`, `FINISHED`, `STATS` in their order; `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 |
|
||||
|
||||
@@ -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,13 @@ 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().style()))))
|
||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(new ErrorResponse("PROFILE_NOT_FOUND", "No public profile with this name")));
|
||||
}
|
||||
|
||||
private static PublicProfileResponse.Style style(PublicProfile.Style style) {
|
||||
return new PublicProfileResponse.Style(style.accent(), style.corners(), style.bannerAppid(),
|
||||
style.backgroundAppid(), style.sections());
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
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 sections the parts of the profile in their order; a part not listed is hidden
|
||||
*/
|
||||
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List<String> sections) {}
|
||||
}
|
||||
@@ -3,4 +3,8 @@ 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, List<String> profileStats,
|
||||
Style style) {
|
||||
|
||||
public record Style(String accent, String corners, Long bannerAppid, Long backgroundAppid, List<String> sections) {}
|
||||
}
|
||||
@@ -21,14 +21,19 @@ public class PublicProfileRepository {
|
||||
*/
|
||||
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_stats, s.profile_accent,
|
||||
s.profile_corners, s.profile_banner_appid, s.profile_background_appid, s.profile_sections
|
||||
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())),
|
||||
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),
|
||||
List.of((String[]) rs.getArray("profile_sections").getArray()))),
|
||||
displayName).stream().findFirst();
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ 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, profileTicketIssuer.generateProfileTicket(
|
||||
profile.userId(), profile.profileStats(), profile.style().sections())));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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.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;
|
||||
@@ -53,6 +54,13 @@ public class UserController {
|
||||
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();
|
||||
}
|
||||
|
||||
@PutMapping(value = "/settings/appearance", version = "1.0")
|
||||
public ResponseEntity<Void> updateAppearance(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody AppearanceRequest request) {
|
||||
|
||||
@@ -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,18 @@
|
||||
package auth_service.modules.user.api.dto.request;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @param sections the parts of the public profile in their order; a part not listed is hidden
|
||||
*/
|
||||
public record ProfileStyleRequest(
|
||||
@NotNull @Pattern(regexp = AppearanceRequest.ACCENTS) String accent,
|
||||
@NotNull @Pattern(regexp = AppearanceRequest.CORNERS) String corners,
|
||||
@Positive Long bannerAppid,
|
||||
@Positive Long backgroundAppid,
|
||||
@NotNull List<@NotNull @Pattern(regexp = "SUMMARY|STATUS|PLAYING|FINISHED|STATS") String> sections) {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package auth_service.modules.user.api.dto.response;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ProfileStyleResponse(String accent, String corners, Long bannerAppid, Long backgroundAppid,
|
||||
List<String> sections) {
|
||||
}
|
||||
@@ -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, List<String> profileStats,
|
||||
ProfileStyleResponse profileStyle) {
|
||||
}
|
||||
@@ -2,11 +2,14 @@ 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.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.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.User;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
import auth_service.shared.security.Role;
|
||||
@@ -36,6 +39,7 @@ public abstract class ApiMapper {
|
||||
.publicProfile(user.getSettings().isPublicProfile())
|
||||
.appearance(toApi(user.getSettings().getAppearance()))
|
||||
.profileStats(user.getSettings().getProfileStats())
|
||||
.profileStyle(toApi(user.getSettings().getProfileStyle()))
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
@@ -45,6 +49,16 @@ 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(),
|
||||
style.sections());
|
||||
}
|
||||
|
||||
public static ProfileStyle toDomain(ProfileStyleRequest request) {
|
||||
return new ProfileStyle(request.accent(), request.corners(), request.bannerAppid(), request.backgroundAppid(),
|
||||
request.sections());
|
||||
}
|
||||
|
||||
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,26 @@
|
||||
package auth_service.modules.user.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The look of the public profile, apart from the {@link Appearance} of the app.
|
||||
*
|
||||
* @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
|
||||
* @param sections the parts of the profile in their order; a part not listed is hidden
|
||||
*/
|
||||
public record ProfileStyle(String accent, String corners, Long bannerAppid, Long backgroundAppid,
|
||||
List<String> sections) {
|
||||
|
||||
public static final List<String> ALL_SECTIONS = List.of("SUMMARY", "STATUS", "PLAYING", "FINISHED", "STATS");
|
||||
|
||||
public static final ProfileStyle DEFAULT = new ProfileStyle(Appearance.DEFAULT.accent(), Appearance.DEFAULT.corners(),
|
||||
null, null, ALL_SECTIONS);
|
||||
|
||||
/**
|
||||
* Keeps each section once, at its first place.
|
||||
*/
|
||||
public ProfileStyle {
|
||||
sections = sections.stream().distinct().toList();
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ public class UserSettings {
|
||||
private boolean publicProfile;
|
||||
@Builder.Default
|
||||
private Appearance appearance = Appearance.DEFAULT;
|
||||
@Builder.Default
|
||||
private ProfileStyle profileStyle = ProfileStyle.DEFAULT;
|
||||
/** The stat groups of Core the public profile shows. */
|
||||
@Builder.Default
|
||||
private List<String> profileStats = List.of();
|
||||
|
||||
@@ -2,6 +2,7 @@ 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.SteamIdHolder;
|
||||
import auth_service.modules.user.model.User;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
@@ -34,6 +35,8 @@ public interface UserRepository {
|
||||
|
||||
void updateProfileStats(long userId, List<String> groups);
|
||||
|
||||
void updateProfileStyle(long userId, ProfileStyle style);
|
||||
|
||||
Optional<SteamIdHolder> findSteamIdHolder(long steamId);
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ 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.SteamIdHolder;
|
||||
import auth_service.modules.user.model.User;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
@@ -103,6 +104,17 @@ public class UserRepositoryImpl implements UserRepository {
|
||||
settingsOf(userId).setProfileStats(new ArrayList<>(groups));
|
||||
}
|
||||
|
||||
@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());
|
||||
settingsEntity.setProfileSections(new ArrayList<>(style.sections()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings row of the user, created with the defaults of the user_settings columns when there is none.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth_service.modules.user.persistence.entities;
|
||||
|
||||
import auth_service.modules.user.model.Appearance;
|
||||
import auth_service.modules.user.model.ProfileStyle;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
@@ -55,4 +56,20 @@ public class UserSettingsEntity {
|
||||
@JdbcTypeCode(SqlTypes.ARRAY)
|
||||
@Column(name = "profile_stats", nullable = false)
|
||||
private List<String> profileStats = new ArrayList<>();
|
||||
|
||||
@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;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.ARRAY)
|
||||
@Column(name = "profile_sections", nullable = false)
|
||||
private List<String> profileSections = new ArrayList<>(ProfileStyle.ALL_SECTIONS);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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.User;
|
||||
import auth_service.modules.user.model.UserSettings;
|
||||
import auth_service.modules.user.persistence.entities.UserEntity;
|
||||
@@ -30,6 +31,10 @@ public abstract class EntityMapper {
|
||||
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(),
|
||||
List.copyOf(entity.getSettings().getProfileSections())))
|
||||
.build();
|
||||
} else {
|
||||
// Fallback default settings for users without an entry in the database
|
||||
|
||||
@@ -3,6 +3,7 @@ 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.SteamIdHolder;
|
||||
import auth_service.modules.user.model.SteamLinkOutcome;
|
||||
import auth_service.modules.user.model.User;
|
||||
@@ -105,4 +106,11 @@ public class UserService {
|
||||
public void updateProfileStats(long userId, List<String> groups) {
|
||||
userRepository.updateProfileStats(userId, groups.stream().distinct().toList());
|
||||
}
|
||||
|
||||
@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,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;
|
||||
@@ -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
|
||||
|
||||
@@ -84,6 +84,40 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
|
||||
assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", 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_sections = '{PLAYING,SUMMARY}' 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.sections[0]").value("PLAYING"))
|
||||
.andExpect(jsonPath("$.style.sections[1]").value("SUMMARY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicProfile_NewRowShowsEverySection() throws Exception {
|
||||
mockMvc.perform(get("/api/public/profiles/Lobo"))
|
||||
.andExpect(jsonPath("$.style.accent").value("violet"))
|
||||
.andExpect(jsonPath("$.style.sections.length()").value(5));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicProfile_TicketCarriesTheShownSections() throws Exception {
|
||||
jdbc.update("update user_settings set profile_sections = '{PLAYING}' 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));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicProfile_NameIgnoresCase() throws Exception {
|
||||
mockMvc.perform(get("/api/public/profiles/lOBO"))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package auth_service.modules.user.api;
|
||||
|
||||
import auth_service.modules.user.model.ProfileStyle;
|
||||
import auth_service.configuration.security.JwtAuthenticationFilter;
|
||||
import auth_service.configuration.security.JwtService;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
@@ -390,6 +391,62 @@ class UserControllerTest extends AbstractWebMvcTest {
|
||||
.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, List.of("PLAYING"))).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())
|
||||
.andExpect(jsonPath("$.settings.profileStyle.sections[0]").value("PLAYING"));
|
||||
}
|
||||
|
||||
@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,"
|
||||
+ "\"sections\":[\"STATS\",\"SUMMARY\"]}"))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(userService).updateProfileStyle(1L, new ProfileStyle("green", "round", 620L, null, List.of("STATS", "SUMMARY")));
|
||||
}
|
||||
|
||||
// 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\",\"sections\":[]}",
|
||||
"{\"accent\":\"violet\",\"corners\":\"pointy\",\"sections\":[]}",
|
||||
"{\"accent\":\"violet\",\"corners\":\"default\",\"bannerAppid\":0,\"sections\":[]}",
|
||||
"{\"accent\":\"violet\",\"corners\":\"default\",\"backgroundAppid\":-620,\"sections\":[]}",
|
||||
"{\"accent\":\"violet\",\"corners\":\"default\",\"sections\":[\"NOTES\"]}",
|
||||
"{\"accent\":\"violet\",\"corners\":\"default\",\"sections\":[null]}",
|
||||
"{\"accent\":\"violet\",\"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
|
||||
void updateAppearance_StoresTheValues() throws Exception {
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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 ProfileStyleTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void keepsEachSectionOnceAtItsFirstPlace() {
|
||||
assertEquals(List.of("STATS", "PLAYING"),
|
||||
new ProfileStyle("red", "round", 620L, null, List.of("STATS", "PLAYING", "STATS")).sections());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package auth_service.modules.user.persistence;
|
||||
|
||||
import auth_service.modules.user.model.ProfileStyle;
|
||||
import auth_service.modules.user.exception.UserNotFoundException;
|
||||
import auth_service.modules.user.model.Appearance;
|
||||
import auth_service.modules.user.model.SteamIdHolder;
|
||||
@@ -280,6 +281,26 @@ class UserRepositoryTest extends AbstractPersistenceTest {
|
||||
.getSettings().getAppearance());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProfileStyle_IsReadBackAndStartsWithTheDefaults() {
|
||||
UserEntity userEntity = entityManager.persist(UserEntity.builder()
|
||||
.email("style@example.com")
|
||||
.password("password")
|
||||
.enabled(true)
|
||||
.registrationDate(LocalDateTime.now())
|
||||
.build());
|
||||
entityManager.flush();
|
||||
assertEquals(ProfileStyle.DEFAULT, userRepository.findByEmail("style@example.com").orElseThrow()
|
||||
.getSettings().getProfileStyle());
|
||||
|
||||
ProfileStyle style = new ProfileStyle("pink", "sharp", 620L, 1086940L, List.of("FINISHED", "SUMMARY"));
|
||||
userRepository.updateProfileStyle(userEntity.getId(), style);
|
||||
entityManager.flush();
|
||||
entityManager.clear();
|
||||
|
||||
assertEquals(style, userRepository.findByEmail("style@example.com").orElseThrow().getSettings().getProfileStyle());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProfileStats_IsReadBackAndKeptByUpdateSettings() {
|
||||
UserEntity userEntity = entityManager.persist(UserEntity.builder()
|
||||
|
||||
Reference in new issue
Block a user