diff --git a/src/main/java/auth_service/configuration/security/JwtService.java b/src/main/java/auth_service/configuration/security/JwtService.java index 17a146c..18cbc0c 100644 --- a/src/main/java/auth_service/configuration/security/JwtService.java +++ b/src/main/java/auth_service/configuration/security/JwtService.java @@ -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; diff --git a/src/main/java/auth_service/modules/login/service/LoginService.java b/src/main/java/auth_service/modules/login/service/LoginService.java index 2da2d58..56185b4 100644 --- a/src/main/java/auth_service/modules/login/service/LoginService.java +++ b/src/main/java/auth_service/modules/login/service/LoginService.java @@ -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; diff --git a/src/main/java/auth_service/modules/registration/api/RegistrationController.java b/src/main/java/auth_service/modules/registration/api/RegistrationController.java index a64efba..81196da 100644 --- a/src/main/java/auth_service/modules/registration/api/RegistrationController.java +++ b/src/main/java/auth_service/modules/registration/api/RegistrationController.java @@ -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 registration(@RequestBody RegistrationRequest request) { + public ResponseEntity registration(@Valid @RequestBody RegistrationRequest request) { var userId = registrationService.register(ApiMapper.mapToModel(request)); return ResponseEntity.ok(RegistrationResponse.builder().userId(userId).build()); } diff --git a/src/main/java/auth_service/modules/registration/api/dto/mapper/ApiMapper.java b/src/main/java/auth_service/modules/registration/api/dto/mapper/ApiMapper.java index 85dd1bb..5c55cdc 100644 --- a/src/main/java/auth_service/modules/registration/api/dto/mapper/ApiMapper.java +++ b/src/main/java/auth_service/modules/registration/api/dto/mapper/ApiMapper.java @@ -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(); } diff --git a/src/main/java/auth_service/modules/registration/api/dto/request/RegistrationRequest.java b/src/main/java/auth_service/modules/registration/api/dto/request/RegistrationRequest.java index ff8fb38..1c1efaa 100644 --- a/src/main/java/auth_service/modules/registration/api/dto/request/RegistrationRequest.java +++ b/src/main/java/auth_service/modules/registration/api/dto/request/RegistrationRequest.java @@ -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) { } diff --git a/src/main/java/auth_service/modules/registration/model/Registration.java b/src/main/java/auth_service/modules/registration/model/Registration.java index 3a3fbd9..201ae41 100644 --- a/src/main/java/auth_service/modules/registration/model/Registration.java +++ b/src/main/java/auth_service/modules/registration/model/Registration.java @@ -10,5 +10,5 @@ public class Registration { private String rawPassword; private String encryptedPassword; private String inviteCode; - private long steamId; + private Long steamId; } diff --git a/src/main/java/auth_service/modules/registration/service/RegistrationService.java b/src/main/java/auth_service/modules/registration/service/RegistrationService.java index 681e7f3..a90171b 100644 --- a/src/main/java/auth_service/modules/registration/service/RegistrationService.java +++ b/src/main/java/auth_service/modules/registration/service/RegistrationService.java @@ -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."); } diff --git a/src/main/java/auth_service/modules/user/api/UserController.java b/src/main/java/auth_service/modules/user/api/UserController.java index b4204c8..a84c79c 100644 --- a/src/main/java/auth_service/modules/user/api/UserController.java +++ b/src/main/java/auth_service/modules/user/api/UserController.java @@ -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 updateUserSettings(@AuthenticationPrincipal UserPrincipal principal, - @RequestBody UserSettingsRequest request) { - this.userService.updateUserSettings(principal.getIdAsLong(), request.steamId(), ApiMapper.toDomain(request)); - return ResponseEntity.ok().build(); + public ResponseEntity 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)); } } diff --git a/src/main/java/auth_service/modules/user/api/UserExceptionController.java b/src/main/java/auth_service/modules/user/api/UserExceptionController.java index d188141..c4dc905 100644 --- a/src/main/java/auth_service/modules/user/api/UserExceptionController.java +++ b/src/main/java/auth_service/modules/user/api/UserExceptionController.java @@ -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 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 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 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 steamIdInUse() { + var body = new ErrorResponse("STEAM_ID_ALREADY_IN_USE", "The steam id is already in use."); + return response(body, HttpStatus.CONFLICT); + } + } diff --git a/src/main/java/auth_service/modules/user/api/dto/request/UserSettingsRequest.java b/src/main/java/auth_service/modules/user/api/dto/request/UserSettingsRequest.java index 7df7132..bfb0c78 100644 --- a/src/main/java/auth_service/modules/user/api/dto/request/UserSettingsRequest.java +++ b/src/main/java/auth_service/modules/user/api/dto/request/UserSettingsRequest.java @@ -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) { } diff --git a/src/main/java/auth_service/modules/user/api/dto/response/UserProfileResponse.java b/src/main/java/auth_service/modules/user/api/dto/response/UserProfileResponse.java index e5551df..d4c6df4 100644 --- a/src/main/java/auth_service/modules/user/api/dto/response/UserProfileResponse.java +++ b/src/main/java/auth_service/modules/user/api/dto/response/UserProfileResponse.java @@ -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) { } diff --git a/src/main/java/auth_service/modules/user/api/dto/response/UserSettingsResponse.java b/src/main/java/auth_service/modules/user/api/dto/response/UserSettingsResponse.java new file mode 100644 index 0000000..f808953 --- /dev/null +++ b/src/main/java/auth_service/modules/user/api/dto/response/UserSettingsResponse.java @@ -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) { +} diff --git a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java index c86fe51..046cba6 100644 --- a/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java +++ b/src/main/java/auth_service/modules/user/api/mapper/ApiMapper.java @@ -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") diff --git a/src/main/java/auth_service/modules/user/exception/SteamIdAlreadyInUseException.java b/src/main/java/auth_service/modules/user/exception/SteamIdAlreadyInUseException.java new file mode 100644 index 0000000..5505d0b --- /dev/null +++ b/src/main/java/auth_service/modules/user/exception/SteamIdAlreadyInUseException.java @@ -0,0 +1,7 @@ +package auth_service.modules.user.exception; + +public class SteamIdAlreadyInUseException extends RuntimeException { + public SteamIdAlreadyInUseException(String message) { + super(message); + } +} diff --git a/src/main/java/auth_service/modules/user/model/User.java b/src/main/java/auth_service/modules/user/model/User.java index 85fb9c0..811e7a3 100644 --- a/src/main/java/auth_service/modules/user/model/User.java +++ b/src/main/java/auth_service/modules/user/model/User.java @@ -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; } diff --git a/src/main/java/auth_service/modules/user/persistence/UserRepository.java b/src/main/java/auth_service/modules/user/persistence/UserRepository.java index 946d97d..01a7179 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepository.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepository.java @@ -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); } diff --git a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java index 904fd56..61e21c0 100644 --- a/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/user/persistence/UserRepositoryImpl.java @@ -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; + } } diff --git a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java index 76a38f7..eafd82e 100644 --- a/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java +++ b/src/main/java/auth_service/modules/user/persistence/mapper/EntityMapper.java @@ -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(); } diff --git a/src/main/java/auth_service/modules/user/service/UserService.java b/src/main/java/auth_service/modules/user/service/UserService.java index a36afe0..fe5d215 100644 --- a/src/main/java/auth_service/modules/user/service/UserService.java +++ b/src/main/java/auth_service/modules/user/service/UserService.java @@ -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); } } diff --git a/src/main/java/auth_service/shared/api/validation/SteamIdDeserializer.java b/src/main/java/auth_service/shared/api/validation/SteamIdDeserializer.java new file mode 100644 index 0000000..b930193 --- /dev/null +++ b/src/main/java/auth_service/shared/api/validation/SteamIdDeserializer.java @@ -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 { + + @Override + public String deserialize(JsonParser parser, DeserializationContext context) { + if (!parser.hasToken(JsonToken.VALUE_STRING)) { + return (String) context.handleUnexpectedToken(String.class, parser); + } + return parser.getString(); + } +} diff --git a/src/main/java/auth_service/shared/api/validation/SteamIdFormat.java b/src/main/java/auth_service/shared/api/validation/SteamIdFormat.java new file mode 100644 index 0000000..e50149a --- /dev/null +++ b/src/main/java/auth_service/shared/api/validation/SteamIdFormat.java @@ -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() { + } +} diff --git a/src/main/java/auth_service/modules/login/service/TokenGenerator.java b/src/main/java/auth_service/shared/security/TokenGenerator.java similarity index 91% rename from src/main/java/auth_service/modules/login/service/TokenGenerator.java rename to src/main/java/auth_service/shared/security/TokenGenerator.java index f19ccd7..5949d23 100644 --- a/src/main/java/auth_service/modules/login/service/TokenGenerator.java +++ b/src/main/java/auth_service/shared/security/TokenGenerator.java @@ -1,4 +1,4 @@ -package auth_service.modules.login.service; +package auth_service.shared.security; /** * Port interface for token generation. diff --git a/src/main/resources/db/migration/V4__Steam_Id_Zero_Means_None.sql b/src/main/resources/db/migration/V4__Steam_Id_Zero_Means_None.sql new file mode 100644 index 0000000..5ff7814 --- /dev/null +++ b/src/main/resources/db/migration/V4__Steam_Id_Zero_Means_None.sql @@ -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; diff --git a/src/test/java/auth_service/modules/login/service/LoginServiceTest.java b/src/test/java/auth_service/modules/login/service/LoginServiceTest.java index 4b814a5..8926686 100644 --- a/src/test/java/auth_service/modules/login/service/LoginServiceTest.java +++ b/src/test/java/auth_service/modules/login/service/LoginServiceTest.java @@ -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; diff --git a/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java b/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java index a87f266..186c59b 100644 --- a/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java +++ b/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java @@ -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("Us*kksUuRpTE2CQRsv+^k0n)ksR}X}?#Fo2D4d8ZKG") - .steamId(314159L) + .steamId("76561198005679788") .build(); long expectedUserId = 42L; @@ -77,7 +84,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest { .email("test@example.com") .password("SuperSecret123!") .inviteCode("Us*kksUuRpTE2CQRsv+^k0n)ksR}X}?#Fo2D4d8ZKG") - .steamId(314159L) + .steamId("76561198005679788") .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("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()))); + } } diff --git a/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java b/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java index 571b64e..0092d76 100644 --- a/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java +++ b/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java @@ -42,7 +42,7 @@ class RegistrationServiceTest extends AbstractUnitTest { .email("test@example.com") .rawPassword("SuperSecret123!") .inviteCode("Us*kksUuRpTE2CQRsv+^k0n)ksR}X}?#Fo2D4d8ZKG") - .steamId(314159L) + .steamId(76561198005679788L) .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()); + } } diff --git a/src/test/java/auth_service/modules/user/api/UserControllerTest.java b/src/test/java/auth_service/modules/user/api/UserControllerTest.java index c7204ed..20265fc 100644 --- a/src/test/java/auth_service/modules/user/api/UserControllerTest.java +++ b/src/test/java/auth_service/modules/user/api/UserControllerTest.java @@ -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 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(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 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(76561198005679788L), 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":"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()); - 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")); } } diff --git a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java index e45bd58..c1020c9 100644 --- a/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java +++ b/src/test/java/auth_service/modules/user/persistence/UserRepositoryTest.java @@ -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(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)); + } } diff --git a/src/test/java/auth_service/modules/user/service/UserServiceTest.java b/src/test/java/auth_service/modules/user/service/UserServiceTest.java index 39571d6..58fa1c9 100644 --- a/src/test/java/auth_service/modules/user/service/UserServiceTest.java +++ b/src/test/java/auth_service/modules/user/service/UserServiceTest.java @@ -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 = 76561198005679788L; 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 = 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()); } }