feat(profile): let users share stat groups
Build, Test & Push Auth Service / quality-check (push) Successful in 30s
Build, Test & Push Auth Service / docker (push) Successful in 1m24s

Store the groups in user_settings.profile_stats and send them in the
profile ticket, so Core shows only these on the public profile.
This commit is contained in:
lobo committed 2026-09-27 07:16:10 +02:00
1 parent 4b2acef381
commit 4ec47f1b2c
22 files changed
+206 -25

No files matched your search

@@ -3,6 +3,7 @@ package auth_service.configuration.security;
import auth_service.shared.security.SessionProperties;
import auth_service.shared.security.UserPrincipal;
import auth_service.shared.test.AbstractUnitTest;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -104,7 +105,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void generateProfileTicket_NamesTheUserAndExpiresSoon() {
var ticket = jwtService.generateProfileTicket(42L);
var ticket = jwtService.generateProfileTicket(42L, List.of());
var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
assertEquals("public-profile", claims.get("typ", String.class));
@@ -114,9 +115,17 @@ class JwtServiceTest extends AbstractUnitTest {
assertTrue(secondsLeft > 0 && secondsLeft <= 600, "expires in " + secondsLeft + "s");
}
@Test
void generateProfileTicket_CarriesTheSharedStatGroups() {
String ticket = jwtService.generateProfileTicket(42L, List.of("PILE", "TREND"));
Claims claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(ticket).getPayload();
assertEquals(List.of("PILE", "TREND"), claims.get("profileStats", List.class));
}
@Test
void parseLoginToken_ProfileTicketIsNoLoginToken() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L)));
assertEquals(Optional.empty(), jwtService.parseLoginToken(jwtService.generateProfileTicket(42L, List.of())));
}
@Test
@@ -1,6 +1,7 @@
package auth_service.modules.profile.api;
import auth_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -13,8 +14,10 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.web.servlet.MockMvc;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -66,6 +69,19 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
var key = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicKey)));
var claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(ticket).getPayload();
assertEquals(String.valueOf(publicUserId), claims.get("profileUserId", String.class));
assertEquals(List.of(), claims.get("profileStats", List.class));
}
@Test
void publicProfile_TicketCarriesTheSharedStatGroups() throws Exception {
jdbc.update("update user_settings set profile_stats = '{PILE,TREND}' 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("PILE", "TREND"), claims.get("profileStats", List.class));
}
@Test
@@ -30,6 +30,7 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Collections;
import java.util.List;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.doThrow;
@@ -347,6 +348,48 @@ class UserControllerTest extends AbstractWebMvcTest {
.andExpect(jsonPath("$.settings.appearance.showBanner").value(true));
}
@Test
void getCurrentUserV1_ShowsTheSharedStatGroups() throws Exception {
String email = "test@example.com";
UserSettings settings = UserSettings.builder().language("en").theme("dark")
.profileStats(List.of("PILE", "TREND")).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.profileStats[0]").value("PILE"))
.andExpect(jsonPath("$.settings.profileStats[1]").value("TREND"));
}
@Test
void updateProfileStats_StoresTheGroups() 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")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"groups\":[\"PILE\",\"TREND\"]}"))
.andExpect(status().isNoContent());
verify(userService).updateProfileStats(1L, List.of("PILE", "TREND"));
}
// 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 {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/profile-stats")
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isBadRequest());
}
@Test
void updateAppearance_StoresTheValues() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
@@ -12,6 +12,7 @@ import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.context.annotation.Import;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
@@ -253,6 +254,29 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.getSettings().getAppearance());
}
@Test
void updateProfileStats_IsReadBackAndKeptByUpdateSettings() {
UserEntity userEntity = entityManager.persist(UserEntity.builder()
.email("stats@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
assertEquals(List.of(), userRepository.findByEmail("stats@example.com").orElseThrow()
.getSettings().getProfileStats());
userRepository.updateProfileStats(userEntity.getId(), List.of("PILE", "TREND"));
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());
}
@Test
void updateAppearance_UnknownUser() {
assertThrows(UserNotFoundException.class, () -> userRepository.updateAppearance(-1L, Appearance.DEFAULT));
@@ -15,6 +15,7 @@ import org.mockito.InOrder;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -61,6 +62,13 @@ class UserServiceTest extends AbstractUnitTest {
assertThrows(UserNotFoundException.class, () -> userService.getUserByEmail(email));
}
@Test
void updateProfileStats_StoresEachGroupOnce() {
userService.updateProfileStats(1L, List.of("PILE", "TREND", "PILE"));
verify(userRepository).updateProfileStats(1L, List.of("PILE", "TREND"));
}
@Test
void updateUserSettings_NeverTouchesTheSteamIdAndIssuesNoToken() {
var userSettings = UserSettings.builder().language("en").build();