fix(user): send Steam ID as string and refresh token on settings save

JavaScript rounds 17-digit Steam IDs, so 76561198000000001 was stored as
76561198005679790. Steam IDs are now JSON strings (17 digits, numbers
rejected), and null means "no Steam ID" instead of 0 (V4 migrates old rows).

PUT /api/users/settings returns a new token, because Core reads the Steam ID
from the token. A Steam ID owned by another user returns 409.
This commit is contained in:
lobo committed 2026-09-24 08:51:01 +02:00
1 parent faed2d3411
commit 0e830aca72
29 files changed
+513 -39

No files matched your search

@@ -1,5 +1,6 @@
package auth_service.modules.login.service;
import auth_service.shared.security.TokenGenerator;
import auth_service.modules.login.exception.InvalidCredentialsException;
import auth_service.modules.login.exception.UserNotFoundException;
import auth_service.modules.login.model.User;
@@ -8,16 +8,23 @@ import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.service.RegistrationService;
import auth_service.shared.test.AbstractWebMvcTest;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import jakarta.servlet.ServletException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -54,7 +61,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
.email("test@example.com")
.password("SuperSecret123!")
.inviteCode("test-invite-code")
.steamId(314159L)
.steamId("76561198000000001")
.build();
long expectedUserId = 42L;
@@ -77,7 +84,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
.email("test@example.com")
.password("SuperSecret123!")
.inviteCode("test-invite-code")
.steamId(314159L)
.steamId("76561198000000001")
.build();
when(registrationService.register(any(Registration.class)))
@@ -91,4 +98,74 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
.content(registrationRequestJson.write(request).getJson()))
.andExpect(status().isBadRequest()); // Adjust status based on your @ExceptionHandler
}
@Test
void register_SteamIdIsParsedExactly() throws Exception {
// Arrange
var request = RegistrationRequest.builder()
.email("test@example.com")
.password("SuperSecret123!")
.inviteCode("code")
.steamId("76561198000000001")
.build();
when(registrationService.register(any(Registration.class))).thenReturn(42L);
// Act
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content(registrationRequestJson.write(request).getJson()))
.andExpect(status().isOk());
// Assert
var captor = ArgumentCaptor.forClass(Registration.class);
verify(registrationService).register(captor.capture());
assertEquals(76561198000000001L, captor.getValue().getSteamId());
}
@Test
void register_InvalidSteamId() throws Exception {
// Arrange
var request = RegistrationRequest.builder()
.email("test@example.com")
.password("SuperSecret123!")
.inviteCode("code")
.steamId("12345")
.build();
// Act & Assert
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content(registrationRequestJson.write(request).getJson()))
.andExpect(status().isBadRequest());
verifyNoInteractions(registrationService);
}
@Test
void register_NumericSteamIdIsRejected() throws Exception {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","steamId":76561198005679790}"""))
.andExpect(status().isBadRequest());
verifyNoInteractions(registrationService);
}
@Test
void register_DuplicateEmailRaceIsNotReportedAsSteamIdConflict() throws Exception {
// Arrange
var request = RegistrationRequest.builder()
.email("test@example.com")
.password("SuperSecret123!")
.inviteCode("code")
.build();
when(registrationService.register(any(Registration.class)))
.thenThrow(new DataIntegrityViolationException("users_email_key"));
// Act & Assert: the user module's 409 mapping must not apply to registration
assertThrows(ServletException.class, () -> mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content(registrationRequestJson.write(request).getJson())));
}
}
@@ -42,7 +42,7 @@ class RegistrationServiceTest extends AbstractUnitTest {
.email("test@example.com")
.rawPassword("SuperSecret123!")
.inviteCode("test-invite-code")
.steamId(314159L)
.steamId(76561198000000001L)
.build();
}
@@ -103,4 +103,21 @@ class RegistrationServiceTest extends AbstractUnitTest {
verify(registeredUserRepository, never()).save(any());
}
@Test
void register_WithoutSteamId() {
// Arrange
validRegistration.setSteamId(null);
when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true);
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ");
when(registeredUserRepository.save(any(Registration.class))).thenReturn(42L);
// Act
long result = registrationService.register(validRegistration);
// Assert
assertEquals(42L, result);
verify(registeredUserRepository, never()).existsBySteamId(anyLong());
}
}
@@ -3,7 +3,7 @@ package auth_service.modules.user.api;
import auth_service.configuration.security.JwtAuthenticationFilter;
import auth_service.configuration.security.JwtService;
import auth_service.shared.security.UserPrincipal;
import auth_service.modules.user.api.dto.request.UserSettingsRequest;
import auth_service.modules.user.exception.SteamIdAlreadyInUseException;
import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings;
@@ -13,9 +13,9 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
@@ -26,6 +26,7 @@ import java.util.Collections;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
@@ -49,9 +50,6 @@ class UserControllerTest extends AbstractWebMvcTest {
@MockitoBean
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
private JacksonTester<UserSettingsRequest> userSettingsRequestJson;
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
@@ -76,6 +74,44 @@ class UserControllerTest extends AbstractWebMvcTest {
.andExpect(jsonPath("$.email").value(email));
}
@Test
void getCurrentUserV1_SteamIdIsSentAsExactString() throws Exception {
// Arrange
var email = "test@example.com";
var settings = UserSettings.builder().language("en").theme("dark").build();
var user = User.builder().email(email).steamId(76561198000000001L).settings(settings).build();
var principal = new UserPrincipal("1", email, null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(user);
// Act & Assert
mockMvc.perform(get("/api/users/me")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.steamId").value("76561198000000001"));
}
@Test
void getCurrentUserV1_NoSteamId() throws Exception {
// Arrange
var email = "test@example.com";
var settings = UserSettings.builder().language("en").theme("dark").build();
var user = User.builder().email(email).settings(settings).build();
var principal = new UserPrincipal("1", email, null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(user);
// Act & Assert
mockMvc.perform(get("/api/users/me")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.steamId").doesNotExist());
}
@Test
void getCurrentUserV1_NotFound() throws Exception {
// Arrange
@@ -100,14 +136,129 @@ class UserControllerTest extends AbstractWebMvcTest {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
var request = new UserSettingsRequest(true, "en", 123456789L);
when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198000000001L), any())).thenReturn("new-token");
// Act & Assert
mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON)
.content(userSettingsRequestJson.write(request).getJson()))
.content("""
{"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.token").value("new-token"));
}
@Test
void updateUserSettings_NoSteamId() throws Exception {
// Arrange
var email = "test@example.com";
var principal = new UserPrincipal("1", email, null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
// Act & Assert
mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"useDarkTheme":true,"language":"en","steamId":null}"""))
.andExpect(status().isOk());
verify(userService).updateUserSettings(eq(1L), eq(123456789L), any());
verify(userService).updateUserSettings(eq(1L), eq(email), isNull(), any());
}
@Test
void updateUserSettings_InvalidSteamId() throws Exception {
// Arrange
var principal = new UserPrincipal("1", "test@example.com", null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
// Act & Assert
mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"useDarkTheme":true,"language":"en","steamId":"7656119800567978x"}"""))
.andExpect(status().isBadRequest());
verifyNoInteractions(userService);
}
@Test
void updateUserSettings_UniqueConstraintRaceIsConflict() throws Exception {
// Arrange
var email = "test@example.com";
var principal = new UserPrincipal("1", email, null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198000000001L), any()))
.thenThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"uc_steam_id\""));
// Act & Assert
mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("STEAM_ID_ALREADY_IN_USE"));
}
@Test
void updateUserSettings_NumericSteamIdIsRejected() throws Exception {
// Arrange
var principal = new UserPrincipal("1", "test@example.com", null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
// Act & Assert
mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"useDarkTheme":true,"language":"en","steamId":76561198005679790}"""))
.andExpect(status().isBadRequest());
verifyNoInteractions(userService);
}
@Test
void updateUserSettings_SteamIdOfOtherUser() throws Exception {
// Arrange
var email = "test@example.com";
var principal = new UserPrincipal("1", email, null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198000000001L), any()))
.thenThrow(new SteamIdAlreadyInUseException("taken"));
// Act & Assert
mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"useDarkTheme":true,"language":"en","steamId":"76561198000000001"}"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("STEAM_ID_ALREADY_IN_USE"));
}
@Test
void updateUserSettings_OtherConstraintIsNotSteamIdConflict() throws Exception {
// Arrange
var email = "test@example.com";
var principal = new UserPrincipal("1", email, null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
when(userService.updateUserSettings(eq(1L), eq(email), isNull(), any()))
.thenThrow(new DataIntegrityViolationException("value too long for type character varying(10)"));
// Act & Assert
mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"useDarkTheme":true,"language":"english-long","steamId":null}"""))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"));
}
}
@@ -1,5 +1,6 @@
package auth_service.modules.user.persistence;
import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.entities.UserEntity;
import auth_service.shared.test.AbstractPersistenceTest;
@@ -73,4 +74,75 @@ class UserRepositoryTest extends AbstractPersistenceTest {
assertNotNull(updatedUser.getSettings());
assertEquals("de", updatedUser.getSettings().getLanguage());
}
@Test
void findByEmail_NoSteamId() {
// Arrange
var email = "nosteam@example.com";
entityManager.persist(UserEntity.builder()
.email(email)
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
// Act
var result = userRepository.findByEmail(email);
// Assert
assertTrue(result.isPresent());
assertNull(result.get().getSteamId());
}
@Test
void updateSettings_ClearsSteamId() {
// Arrange
var userEntity = entityManager.persist(UserEntity.builder()
.email("clear@example.com")
.password("password")
.enabled(true)
.steamId(123456L)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
var settings = UserSettings.builder().language("en").theme("light").build();
// Act
userRepository.updateSettings(userEntity.getId(), null, settings);
// Assert
assertNull(entityManager.find(UserEntity.class, userEntity.getId()).getSteamId());
}
@Test
void isSteamIdUsedByOtherUser() {
// Arrange
var owner = entityManager.persist(UserEntity.builder()
.email("owner@example.com")
.password("password")
.enabled(true)
.steamId(76561198000000001L)
.registrationDate(LocalDateTime.now())
.build());
var other = entityManager.persist(UserEntity.builder()
.email("other@example.com")
.password("password")
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
// Act & Assert
assertFalse(userRepository.isSteamIdUsedByOtherUser(owner.getId(), 76561198000000001L));
assertTrue(userRepository.isSteamIdUsedByOtherUser(other.getId(), 76561198000000001L));
assertFalse(userRepository.isSteamIdUsedByOtherUser(other.getId(), 76561198000000000L));
}
@Test
void updateSettings_UnknownUser() {
var settings = UserSettings.builder().language("en").theme("light").build();
assertThrows(UserNotFoundException.class, () -> userRepository.updateSettings(-1L, null, settings));
}
}
@@ -1,9 +1,11 @@
package auth_service.modules.user.service;
import auth_service.modules.user.exception.SteamIdAlreadyInUseException;
import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.UserRepository;
import auth_service.shared.security.TokenGenerator;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
@@ -13,6 +15,8 @@ import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
/**
@@ -23,6 +27,9 @@ class UserServiceTest extends AbstractUnitTest {
@Mock
private UserRepository userRepository;
@Mock
private TokenGenerator tokenGenerator;
@InjectMocks
private UserService userService;
@@ -51,17 +58,51 @@ class UserServiceTest extends AbstractUnitTest {
}
@Test
void updateUserSettings_Success() {
void updateUserSettings_ReturnsTokenWithNewSteamId() {
// Arrange
long userId = 1L;
long steamId = 123456789L;
var email = "test@example.com";
Long steamId = 76561198000000001L;
var userSettings = UserSettings.builder().language("en").build();
when(userRepository.updateSettings(userId, steamId, userSettings)).thenReturn(true);
when(userRepository.isSteamIdUsedByOtherUser(userId, steamId)).thenReturn(false);
when(tokenGenerator.generateToken("1", email, steamId)).thenReturn("new-token");
// Act
userService.updateUserSettings(userId, steamId, userSettings);
var token = userService.updateUserSettings(userId, email, steamId, userSettings);
// Assert
verify(userRepository, times(1)).updateSettings(userId, steamId, userSettings);
assertEquals("new-token", token);
verify(userRepository).updateSettings(userId, steamId, userSettings);
}
@Test
void updateUserSettings_WithoutSteamId() {
// Arrange
long userId = 1L;
var email = "test@example.com";
var userSettings = UserSettings.builder().language("en").build();
when(tokenGenerator.generateToken("1", email, null)).thenReturn("new-token");
// Act
var token = userService.updateUserSettings(userId, email, null, userSettings);
// Assert
assertEquals("new-token", token);
verify(userRepository).updateSettings(userId, null, userSettings);
verify(userRepository, never()).isSteamIdUsedByOtherUser(anyLong(), anyLong());
}
@Test
void updateUserSettings_SteamIdOfOtherUser() {
// Arrange
long userId = 1L;
Long steamId = 76561198000000001L;
var userSettings = UserSettings.builder().language("en").build();
when(userRepository.isSteamIdUsedByOtherUser(userId, steamId)).thenReturn(true);
// Act & Assert
assertThrows(SteamIdAlreadyInUseException.class,
() -> userService.updateUserSettings(userId, "test@example.com", steamId, userSettings));
verify(userRepository, never()).updateSettings(anyLong(), any(), any());
}
}