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

JavaScript rounds 17-digit Steam IDs, so 76561198005679788 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 39f08c9563
commit a3aeb666a8
29 files changed
+513 -39

No files matched your search

@@ -1,6 +1,6 @@
package auth_service.configuration.security; package auth_service.configuration.security;
import auth_service.modules.login.service.TokenGenerator; import auth_service.shared.security.TokenGenerator;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -1,5 +1,6 @@
package auth_service.modules.login.service; 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.InvalidCredentialsException;
import auth_service.modules.login.exception.UserNotFoundException; import auth_service.modules.login.exception.UserNotFoundException;
import auth_service.modules.login.model.User; import auth_service.modules.login.model.User;
@@ -5,6 +5,7 @@ import auth_service.modules.registration.api.dto.request.RegistrationRequest;
import auth_service.modules.registration.api.dto.response.RegistrationResponse; import auth_service.modules.registration.api.dto.response.RegistrationResponse;
import auth_service.modules.registration.service.RegistrationService; import auth_service.modules.registration.service.RegistrationService;
import io.micrometer.core.annotation.Timed; import io.micrometer.core.annotation.Timed;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -21,7 +22,7 @@ public class RegistrationController {
@PostMapping(value = "/new", version = "1.0", produces = "application/json", consumes = "application/json") @PostMapping(value = "/new", version = "1.0", produces = "application/json", consumes = "application/json")
@Timed(value = "auth.registration.new.requests.time", description = "Time taken to process registration requests") @Timed(value = "auth.registration.new.requests.time", description = "Time taken to process registration requests")
public ResponseEntity<RegistrationResponse> registration(@RequestBody RegistrationRequest request) { public ResponseEntity<RegistrationResponse> registration(@Valid @RequestBody RegistrationRequest request) {
var userId = registrationService.register(ApiMapper.mapToModel(request)); var userId = registrationService.register(ApiMapper.mapToModel(request));
return ResponseEntity.ok(RegistrationResponse.builder().userId(userId).build()); return ResponseEntity.ok(RegistrationResponse.builder().userId(userId).build());
} }
@@ -12,7 +12,7 @@ public abstract class ApiMapper {
email(registrationRequest.email()). email(registrationRequest.email()).
rawPassword(registrationRequest.password()). rawPassword(registrationRequest.password()).
inviteCode(registrationRequest.inviteCode()). inviteCode(registrationRequest.inviteCode()).
steamId(registrationRequest.steamId()). steamId(registrationRequest.steamId() == null ? null : Long.valueOf(registrationRequest.steamId())).
build(); build();
} }
@@ -1,7 +1,13 @@
package auth_service.modules.registration.api.dto.request; package auth_service.modules.registration.api.dto.request;
import auth_service.shared.api.validation.SteamIdDeserializer;
import auth_service.shared.api.validation.SteamIdFormat;
import jakarta.validation.constraints.Pattern;
import lombok.Builder; import lombok.Builder;
import tools.jackson.databind.annotation.JsonDeserialize;
@Builder @Builder
public record RegistrationRequest(String email, String password, String inviteCode, long steamId) { public record RegistrationRequest(String email, String password, String inviteCode,
@Pattern(regexp = SteamIdFormat.PATTERN)
@JsonDeserialize(using = SteamIdDeserializer.class) String steamId) {
} }
@@ -10,5 +10,5 @@ public class Registration {
private String rawPassword; private String rawPassword;
private String encryptedPassword; private String encryptedPassword;
private String inviteCode; private String inviteCode;
private long steamId; private Long steamId;
} }
@@ -26,7 +26,7 @@ public class RegistrationService {
throw new EmailAlreadyInUseException("The provided email address is already in use."); throw new EmailAlreadyInUseException("The provided email address is already in use.");
} }
if (registeredUserRepository.existsBySteamId(registration.getSteamId())) { if (registration.getSteamId() != null && registeredUserRepository.existsBySteamId(registration.getSteamId())) {
throw new SteamIdAlreadyRegisteredException("The steam id is already in use."); throw new SteamIdAlreadyRegisteredException("The steam id is already in use.");
} }
@@ -3,9 +3,11 @@ package auth_service.modules.user.api;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
import auth_service.modules.user.api.dto.request.UserSettingsRequest; import auth_service.modules.user.api.dto.request.UserSettingsRequest;
import auth_service.modules.user.api.dto.response.UserProfileResponse; import auth_service.modules.user.api.dto.response.UserProfileResponse;
import auth_service.modules.user.api.dto.response.UserSettingsResponse;
import auth_service.modules.user.api.mapper.ApiMapper; import auth_service.modules.user.api.mapper.ApiMapper;
import auth_service.modules.user.service.UserService; import auth_service.modules.user.service.UserService;
import io.micrometer.core.annotation.Timed; import io.micrometer.core.annotation.Timed;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -37,9 +39,10 @@ public class UserController {
@PutMapping(value = "/settings", version = "1.0", produces = "application/json") @PutMapping(value = "/settings", version = "1.0", produces = "application/json")
@Timed(value = "user.me.v1.requests.time", description = "Time taken to process v1 user profile requests") @Timed(value = "user.me.v1.requests.time", description = "Time taken to process v1 user profile requests")
public ResponseEntity<Void> updateUserSettings(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<UserSettingsResponse> updateUserSettings(@AuthenticationPrincipal UserPrincipal principal,
@RequestBody UserSettingsRequest request) { @Valid @RequestBody UserSettingsRequest request) {
this.userService.updateUserSettings(principal.getIdAsLong(), request.steamId(), ApiMapper.toDomain(request)); var token = this.userService.updateUserSettings(principal.getIdAsLong(), principal.email(),
return ResponseEntity.ok().build(); ApiMapper.toSteamId(request), ApiMapper.toDomain(request));
return ResponseEntity.ok(new UserSettingsResponse(token));
} }
} }
@@ -1,19 +1,23 @@
package auth_service.modules.user.api; package auth_service.modules.user.api;
import auth_service.shared.api.dto.response.ErrorResponse; import auth_service.shared.api.dto.response.ErrorResponse;
import auth_service.modules.user.exception.SteamIdAlreadyInUseException;
import auth_service.modules.user.exception.UserNotFoundException; import auth_service.modules.user.exception.UserNotFoundException;
import lombok.NonNull; import lombok.NonNull;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice @RestControllerAdvice(assignableTypes = UserController.class)
@Order(value = 1) @Order(value = 1)
@Slf4j @Slf4j
public class UserExceptionController { public class UserExceptionController {
private static final String STEAM_ID_CONSTRAINT = "uc_steam_id";
private static @NonNull ResponseEntity<ErrorResponse> response(ErrorResponse body, HttpStatus status) { private static @NonNull ResponseEntity<ErrorResponse> response(ErrorResponse body, HttpStatus status) {
return ResponseEntity.status(status.value()) return ResponseEntity.status(status.value())
.contentType(org.springframework.http.MediaType.APPLICATION_JSON) .contentType(org.springframework.http.MediaType.APPLICATION_JSON)
@@ -33,4 +37,25 @@ public class UserExceptionController {
return response(body, HttpStatus.NOT_FOUND); return response(body, HttpStatus.NOT_FOUND);
} }
@ExceptionHandler(SteamIdAlreadyInUseException.class)
public ResponseEntity<ErrorResponse> clientError(SteamIdAlreadyInUseException ex) {
return steamIdInUse();
}
// A parallel request can save the same Steam ID after the service check. Then the DB constraint fails.
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> dataIntegrityError(DataIntegrityViolationException ex) {
var cause = ex.getMostSpecificCause().getMessage();
if (cause != null && cause.contains(STEAM_ID_CONSTRAINT)) {
return steamIdInUse();
}
log.error("Data integrity violation in user request", ex);
return response(new ErrorResponse("INTERNAL_ERROR", "Unexpected database error."), HttpStatus.INTERNAL_SERVER_ERROR);
}
private static ResponseEntity<ErrorResponse> steamIdInUse() {
var body = new ErrorResponse("STEAM_ID_ALREADY_IN_USE", "The steam id is already in use.");
return response(body, HttpStatus.CONFLICT);
}
} }
@@ -1,4 +1,14 @@
package auth_service.modules.user.api.dto.request; package auth_service.modules.user.api.dto.request;
public record UserSettingsRequest(boolean useDarkTheme, String language, long steamId) { import auth_service.shared.api.validation.SteamIdDeserializer;
import auth_service.shared.api.validation.SteamIdFormat;
import jakarta.validation.constraints.Pattern;
import tools.jackson.databind.annotation.JsonDeserialize;
/**
* @param steamId null removes the Steam ID
*/
public record UserSettingsRequest(boolean useDarkTheme, String language,
@Pattern(regexp = SteamIdFormat.PATTERN)
@JsonDeserialize(using = SteamIdDeserializer.class) String steamId) {
} }
@@ -5,5 +5,5 @@ import lombok.Builder;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Builder @Builder
public record UserProfileResponse(long id, String email, String role, LocalDateTime lastLogin, LocalDateTime createdDate, long steamId, SettingsResponse settings) { public record UserProfileResponse(long id, String email, String role, LocalDateTime lastLogin, LocalDateTime createdDate, String steamId, SettingsResponse settings) {
} }
@@ -0,0 +1,7 @@
package auth_service.modules.user.api.dto.response;
/**
* @param token replaces the client's token, so the steamId claim matches the saved settings
*/
public record UserSettingsResponse(String token) {
}
@@ -17,7 +17,7 @@ public abstract class ApiMapper {
.email(user.getEmail()) .email(user.getEmail())
.createdDate(user.getCreatedDate()) .createdDate(user.getCreatedDate())
.lastLogin(user.getLastLogin()) .lastLogin(user.getLastLogin())
.steamId(user.getSteamId()) .steamId(user.getSteamId() == null ? null : String.valueOf(user.getSteamId()))
.role("USER") .role("USER")
.settings(SettingsResponse.builder() .settings(SettingsResponse.builder()
.id(user.getId()) .id(user.getId())
@@ -27,6 +27,10 @@ public abstract class ApiMapper {
.build(); .build();
} }
public static Long toSteamId(UserSettingsRequest request) {
return request.steamId() == null ? null : Long.valueOf(request.steamId());
}
public static UserSettings toDomain(UserSettingsRequest request) { public static UserSettings toDomain(UserSettingsRequest request) {
return UserSettings.builder() return UserSettings.builder()
.theme(request.useDarkTheme() ? "dark" : "light") .theme(request.useDarkTheme() ? "dark" : "light")
@@ -0,0 +1,7 @@
package auth_service.modules.user.exception;
public class SteamIdAlreadyInUseException extends RuntimeException {
public SteamIdAlreadyInUseException(String message) {
super(message);
}
}
@@ -13,7 +13,7 @@ public class User {
private String encryptedPassword; private String encryptedPassword;
private LocalDateTime lastLogin; private LocalDateTime lastLogin;
private LocalDateTime createdDate; private LocalDateTime createdDate;
private long steamId; private Long steamId;
private UserSettings settings; private UserSettings settings;
} }
@@ -24,5 +24,7 @@ public interface UserRepository {
UserSettings findSettingsByUserId(String userId); UserSettings findSettingsByUserId(String userId);
@Transactional @Transactional
boolean updateSettings(long userId, long steamId, UserSettings settings); boolean updateSettings(long userId, Long steamId, UserSettings settings);
boolean isSteamIdUsedByOtherUser(long userId, long steamId);
} }
@@ -1,5 +1,6 @@
package auth_service.modules.user.persistence; package auth_service.modules.user.persistence;
import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.entities.UserEntity; import auth_service.modules.user.persistence.entities.UserEntity;
@@ -61,10 +62,10 @@ public class UserRepositoryImpl implements UserRepository {
@Transactional @Transactional
@Override @Override
public boolean updateSettings(long userId, long steamId, UserSettings settings) { public boolean updateSettings(long userId, Long steamId, UserSettings settings) {
var userEntity = entityManager.find(UserEntity.class, userId); var userEntity = entityManager.find(UserEntity.class, userId);
if (userEntity == null) { if (userEntity == null) {
return false; throw new UserNotFoundException("Could not find user with id " + userId);
} }
var settingsEntity = userEntity.getSettings(); var settingsEntity = userEntity.getSettings();
@@ -82,4 +83,14 @@ public class UserRepositoryImpl implements UserRepository {
return steamIdChanged; return steamIdChanged;
} }
@Override
public boolean isSteamIdUsedByOtherUser(long userId, long steamId) {
var count = entityManager.createQuery(
"SELECT COUNT(u) FROM UserEntity u WHERE u.steamId = :steamId AND u.id <> :userId", Long.class)
.setParameter("steamId", steamId)
.setParameter("userId", userId)
.getSingleResult();
return count > 0;
}
} }
@@ -32,15 +32,13 @@ public abstract class EntityMapper {
// 2. Map the main user fields and attach the settings // 2. Map the main user fields and attach the settings
var steamId = entity.getSteamId() == null ? 0L : entity.getSteamId();
return User.builder() return User.builder()
.id(entity.getId()) .id(entity.getId())
.email(entity.getEmail()) .email(entity.getEmail())
.encryptedPassword(entity.getPassword()) .encryptedPassword(entity.getPassword())
.lastLogin(entity.getLastLoginDate()) .lastLogin(entity.getLastLoginDate())
.createdDate(entity.getRegistrationDate()) .createdDate(entity.getRegistrationDate())
.steamId(steamId) .steamId(entity.getSteamId())
.settings(settingsModel) .settings(settingsModel)
.build(); .build();
} }
@@ -1,9 +1,11 @@
package auth_service.modules.user.service; 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.exception.UserNotFoundException;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.UserRepository; import auth_service.modules.user.persistence.UserRepository;
import auth_service.shared.security.TokenGenerator;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.CacheEvict;
@@ -17,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional;
public class UserService { public class UserService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final TokenGenerator tokenGenerator;
/** /**
* Fetches the user by email. Results are cached to ensure blazing-fast UI rendering. * Fetches the user by email. Results are cached to ensure blazing-fast UI rendering.
@@ -31,11 +34,15 @@ public class UserService {
@Transactional @Transactional
@CacheEvict(value = "usersByEmail", allEntries = true) @CacheEvict(value = "usersByEmail", allEntries = true)
public void updateUserSettings(long userId, long steamId, UserSettings userSettings) { public String updateUserSettings(long userId, String email, Long steamId, UserSettings userSettings) {
if (steamId != null && userRepository.isSteamIdUsedByOtherUser(userId, steamId)) {
throw new SteamIdAlreadyInUseException("The steam id is already in use.");
}
var updated = userRepository.updateSettings(userId, steamId, userSettings); var updated = userRepository.updateSettings(userId, steamId, userSettings);
if (updated) { if (updated) {
log.info("Steam id has changed for user {}", userId); log.info("Steam id has changed for user {}", userId);
} }
log.info("User settings updated for user {}", userId); log.info("User settings updated for user {}", userId);
return tokenGenerator.generateToken(String.valueOf(userId), email, steamId);
} }
} }
@@ -0,0 +1,20 @@
package auth_service.shared.api.validation;
import tools.jackson.core.JsonParser;
import tools.jackson.core.JsonToken;
import tools.jackson.databind.DeserializationContext;
import tools.jackson.databind.ValueDeserializer;
/**
* Rejects a JSON number: an old client sends the Steam ID already rounded by JavaScript.
*/
public class SteamIdDeserializer extends ValueDeserializer<String> {
@Override
public String deserialize(JsonParser parser, DeserializationContext context) {
if (!parser.hasToken(JsonToken.VALUE_STRING)) {
return (String) context.handleUnexpectedToken(String.class, parser);
}
return parser.getString();
}
}
@@ -0,0 +1,11 @@
package auth_service.shared.api.validation;
/**
* A SteamID64 has 17 digits. It is sent as a JSON string because JavaScript numbers lose precision above 2^53.
*/
public final class SteamIdFormat {
public static final String PATTERN = "\\d{17}";
private SteamIdFormat() {
}
}
@@ -1,4 +1,4 @@
package auth_service.modules.login.service; package auth_service.shared.security;
/** /**
* Port interface for token generation. * Port interface for token generation.
@@ -0,0 +1,2 @@
-- Before V4 a missing Steam ID was stored as 0. NULL is the only "no Steam ID" value from now on.
UPDATE users SET steam_id = NULL WHERE steam_id = 0;
@@ -1,5 +1,6 @@
package auth_service.modules.login.service; 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.InvalidCredentialsException;
import auth_service.modules.login.exception.UserNotFoundException; import auth_service.modules.login.exception.UserNotFoundException;
import auth_service.modules.login.model.User; 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.modules.registration.service.RegistrationService;
import auth_service.shared.test.AbstractWebMvcTest; import auth_service.shared.test.AbstractWebMvcTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters; import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
import org.springframework.boot.test.json.JacksonTester; import org.springframework.boot.test.json.JacksonTester;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; 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.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any; 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.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 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.jsonPath;
@@ -54,7 +61,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
.email("test@example.com") .email("test@example.com")
.password("SuperSecret123!") .password("SuperSecret123!")
.inviteCode("Us*kksUuRpTE2CQRsv+^k0n)ksR}X}?#Fo2D4d8ZKG") .inviteCode("Us*kksUuRpTE2CQRsv+^k0n)ksR}X}?#Fo2D4d8ZKG")
.steamId(314159L) .steamId("76561198005679788")
.build(); .build();
long expectedUserId = 42L; long expectedUserId = 42L;
@@ -77,7 +84,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
.email("test@example.com") .email("test@example.com")
.password("SuperSecret123!") .password("SuperSecret123!")
.inviteCode("Us*kksUuRpTE2CQRsv+^k0n)ksR}X}?#Fo2D4d8ZKG") .inviteCode("Us*kksUuRpTE2CQRsv+^k0n)ksR}X}?#Fo2D4d8ZKG")
.steamId(314159L) .steamId("76561198005679788")
.build(); .build();
when(registrationService.register(any(Registration.class))) when(registrationService.register(any(Registration.class)))
@@ -91,4 +98,74 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
.content(registrationRequestJson.write(request).getJson())) .content(registrationRequestJson.write(request).getJson()))
.andExpect(status().isBadRequest()); // Adjust status based on your @ExceptionHandler .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("76561198005679788")
.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(76561198005679788L, 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") .email("test@example.com")
.rawPassword("SuperSecret123!") .rawPassword("SuperSecret123!")
.inviteCode("Us*kksUuRpTE2CQRsv+^k0n)ksR}X}?#Fo2D4d8ZKG") .inviteCode("Us*kksUuRpTE2CQRsv+^k0n)ksR}X}?#Fo2D4d8ZKG")
.steamId(314159L) .steamId(76561198005679788L)
.build(); .build();
} }
@@ -103,4 +103,21 @@ class RegistrationServiceTest extends AbstractUnitTest {
verify(registeredUserRepository, never()).save(any()); 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.JwtAuthenticationFilter;
import auth_service.configuration.security.JwtService; import auth_service.configuration.security.JwtService;
import auth_service.shared.security.UserPrincipal; import auth_service.shared.security.UserPrincipal;
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.exception.UserNotFoundException;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
@@ -13,9 +13,9 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters; 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.AutoConfigureMockMvc;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
@@ -26,6 +26,7 @@ import java.util.Collections;
import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; 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.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
@@ -49,9 +50,6 @@ class UserControllerTest extends AbstractWebMvcTest {
@MockitoBean @MockitoBean
private JwtAuthenticationFilter jwtAuthenticationFilter; private JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
private JacksonTester<UserSettingsRequest> userSettingsRequestJson;
@AfterEach @AfterEach
void tearDown() { void tearDown() {
SecurityContextHolder.clearContext(); SecurityContextHolder.clearContext();
@@ -76,6 +74,44 @@ class UserControllerTest extends AbstractWebMvcTest {
.andExpect(jsonPath("$.email").value(email)); .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(76561198005679788L).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("76561198005679788"));
}
@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 @Test
void getCurrentUserV1_NotFound() throws Exception { void getCurrentUserV1_NotFound() throws Exception {
// Arrange // Arrange
@@ -100,14 +136,129 @@ class UserControllerTest extends AbstractWebMvcTest {
SecurityContextHolder.getContext().setAuthentication( SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList()) new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
); );
var request = new UserSettingsRequest(true, "en", 123456789L); when(userService.updateUserSettings(eq(1L), eq(email), eq(76561198005679788L), any())).thenReturn("new-token");
// Act & Assert // Act & Assert
mockMvc.perform(put("/api/users/settings") mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(userSettingsRequestJson.write(request).getJson())) .content("""
{"useDarkTheme":true,"language":"en","steamId":"76561198005679788"}"""))
.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()); .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(76561198005679788L), 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":"76561198005679788"}"""))
.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(76561198005679788L), any()))
.thenThrow(new SteamIdAlreadyInUseException("taken"));
// Act & Assert
mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"useDarkTheme":true,"language":"en","steamId":"76561198005679788"}"""))
.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; 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.model.UserSettings;
import auth_service.modules.user.persistence.entities.UserEntity; import auth_service.modules.user.persistence.entities.UserEntity;
import auth_service.shared.test.AbstractPersistenceTest; import auth_service.shared.test.AbstractPersistenceTest;
@@ -73,4 +74,75 @@ class UserRepositoryTest extends AbstractPersistenceTest {
assertNotNull(updatedUser.getSettings()); assertNotNull(updatedUser.getSettings());
assertEquals("de", updatedUser.getSettings().getLanguage()); 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(76561198005679788L)
.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(), 76561198005679788L));
assertTrue(userRepository.isSteamIdUsedByOtherUser(other.getId(), 76561198005679788L));
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; 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.exception.UserNotFoundException;
import auth_service.modules.user.model.User; import auth_service.modules.user.model.User;
import auth_service.modules.user.model.UserSettings; import auth_service.modules.user.model.UserSettings;
import auth_service.modules.user.persistence.UserRepository; import auth_service.modules.user.persistence.UserRepository;
import auth_service.shared.security.TokenGenerator;
import auth_service.shared.test.AbstractUnitTest; import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks; 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; 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.*; import static org.mockito.Mockito.*;
/** /**
@@ -23,6 +27,9 @@ class UserServiceTest extends AbstractUnitTest {
@Mock @Mock
private UserRepository userRepository; private UserRepository userRepository;
@Mock
private TokenGenerator tokenGenerator;
@InjectMocks @InjectMocks
private UserService userService; private UserService userService;
@@ -51,17 +58,51 @@ class UserServiceTest extends AbstractUnitTest {
} }
@Test @Test
void updateUserSettings_Success() { void updateUserSettings_ReturnsTokenWithNewSteamId() {
// Arrange // Arrange
long userId = 1L; long userId = 1L;
long steamId = 123456789L; var email = "test@example.com";
Long steamId = 76561198005679788L;
var userSettings = UserSettings.builder().language("en").build(); 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 // Act
userService.updateUserSettings(userId, steamId, userSettings); var token = userService.updateUserSettings(userId, email, steamId, userSettings);
// Assert // 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 = 76561198005679788L;
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());
} }
} }