Public Access
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:
1 parent
faed2d3411
commit
0e830aca72
29 files changed
+513
-39
No files matched your search
@@ -1,6 +1,6 @@
|
||||
package auth_service.configuration.security;
|
||||
|
||||
import auth_service.modules.login.service.TokenGenerator;
|
||||
import auth_service.shared.security.TokenGenerator;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.service.RegistrationService;
|
||||
import io.micrometer.core.annotation.Timed;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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")
|
||||
@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));
|
||||
return ResponseEntity.ok(RegistrationResponse.builder().userId(userId).build());
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ public abstract class ApiMapper {
|
||||
email(registrationRequest.email()).
|
||||
rawPassword(registrationRequest.password()).
|
||||
inviteCode(registrationRequest.inviteCode()).
|
||||
steamId(registrationRequest.steamId()).
|
||||
steamId(registrationRequest.steamId() == null ? null : Long.valueOf(registrationRequest.steamId())).
|
||||
build();
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -1,7 +1,13 @@
|
||||
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 tools.jackson.databind.annotation.JsonDeserialize;
|
||||
|
||||
@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 encryptedPassword;
|
||||
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.");
|
||||
}
|
||||
|
||||
if (registeredUserRepository.existsBySteamId(registration.getSteamId())) {
|
||||
if (registration.getSteamId() != null && registeredUserRepository.existsBySteamId(registration.getSteamId())) {
|
||||
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.modules.user.api.dto.request.UserSettingsRequest;
|
||||
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.service.UserService;
|
||||
import io.micrometer.core.annotation.Timed;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
@@ -37,9 +39,10 @@ public class UserController {
|
||||
|
||||
@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")
|
||||
public ResponseEntity<Void> updateUserSettings(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestBody UserSettingsRequest request) {
|
||||
this.userService.updateUserSettings(principal.getIdAsLong(), request.steamId(), ApiMapper.toDomain(request));
|
||||
return ResponseEntity.ok().build();
|
||||
public ResponseEntity<UserSettingsResponse> updateUserSettings(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody UserSettingsRequest request) {
|
||||
var token = this.userService.updateUserSettings(principal.getIdAsLong(), principal.email(),
|
||||
ApiMapper.toSteamId(request), ApiMapper.toDomain(request));
|
||||
return ResponseEntity.ok(new UserSettingsResponse(token));
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
package auth_service.modules.user.api;
|
||||
|
||||
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||
import auth_service.modules.user.exception.SteamIdAlreadyInUseException;
|
||||
import auth_service.modules.user.exception.UserNotFoundException;
|
||||
import lombok.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice
|
||||
@RestControllerAdvice(assignableTypes = UserController.class)
|
||||
@Order(value = 1)
|
||||
@Slf4j
|
||||
public class UserExceptionController {
|
||||
private static final String STEAM_ID_CONSTRAINT = "uc_steam_id";
|
||||
|
||||
private static @NonNull ResponseEntity<ErrorResponse> response(ErrorResponse body, HttpStatus status) {
|
||||
return ResponseEntity.status(status.value())
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
@@ -33,4 +37,25 @@ public class UserExceptionController {
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
@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())
|
||||
.createdDate(user.getCreatedDate())
|
||||
.lastLogin(user.getLastLogin())
|
||||
.steamId(user.getSteamId())
|
||||
.steamId(user.getSteamId() == null ? null : String.valueOf(user.getSteamId()))
|
||||
.role("USER")
|
||||
.settings(SettingsResponse.builder()
|
||||
.id(user.getId())
|
||||
@@ -27,6 +27,10 @@ public abstract class ApiMapper {
|
||||
.build();
|
||||
}
|
||||
|
||||
public static Long toSteamId(UserSettingsRequest request) {
|
||||
return request.steamId() == null ? null : Long.valueOf(request.steamId());
|
||||
}
|
||||
|
||||
public static UserSettings toDomain(UserSettingsRequest request) {
|
||||
return UserSettings.builder()
|
||||
.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 LocalDateTime lastLogin;
|
||||
private LocalDateTime createdDate;
|
||||
private long steamId;
|
||||
private Long steamId;
|
||||
|
||||
private UserSettings settings;
|
||||
}
|
||||
@@ -24,5 +24,7 @@ public interface UserRepository {
|
||||
UserSettings findSettingsByUserId(String userId);
|
||||
|
||||
@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;
|
||||
|
||||
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.entities.UserEntity;
|
||||
@@ -61,10 +62,10 @@ public class UserRepositoryImpl implements UserRepository {
|
||||
|
||||
@Transactional
|
||||
@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);
|
||||
if (userEntity == null) {
|
||||
return false;
|
||||
throw new UserNotFoundException("Could not find user with id " + userId);
|
||||
}
|
||||
|
||||
var settingsEntity = userEntity.getSettings();
|
||||
@@ -82,4 +83,14 @@ public class UserRepositoryImpl implements UserRepository {
|
||||
|
||||
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
|
||||
|
||||
var steamId = entity.getSteamId() == null ? 0L : entity.getSteamId();
|
||||
|
||||
return User.builder()
|
||||
.id(entity.getId())
|
||||
.email(entity.getEmail())
|
||||
.encryptedPassword(entity.getPassword())
|
||||
.lastLogin(entity.getLastLoginDate())
|
||||
.createdDate(entity.getRegistrationDate())
|
||||
.steamId(steamId)
|
||||
.steamId(entity.getSteamId())
|
||||
.settings(settingsModel)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -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 lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
@@ -17,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
public class UserService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final TokenGenerator tokenGenerator;
|
||||
|
||||
/**
|
||||
* Fetches the user by email. Results are cached to ensure blazing-fast UI rendering.
|
||||
@@ -31,11 +34,15 @@ public class UserService {
|
||||
|
||||
@Transactional
|
||||
@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);
|
||||
if (updated) {
|
||||
log.info("Steam id has changed 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
-1
@@ -1,4 +1,4 @@
|
||||
package auth_service.modules.login.service;
|
||||
package auth_service.shared.security;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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;
|
||||
|
||||
+79
-2
@@ -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())));
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user