refactor(api): one error body, token audiences, user lookup by id
Build, Test & Push Auth Service / quality-check (push) Successful in 54s
Build, Test & Push Auth Service / docker (push) Successful in 1m21s

This commit is contained in:
lobo committed 2026-09-29 14:48:28 +02:00
1 parent 68919eaab4
commit afcfacb68b
30 files changed
+661 -198

No files matched your search

+10 -3
View File
@@ -41,8 +41,15 @@ To Be Played is split into three repositories:
tiles in order, each with a size S, M or L), apart from the app appearance; the public answer sends both as
`style`. The ticket's claims come from the layout: `sections` names the shown parts (`STATS` when any `STATS_`
tile is shown), `profileStats` the stat groups of the `STATS_` tiles. The user's dashboard layout
(`dashboard_tiles`, NULL = never changed) is stored the same way. The ticket is not a login:
both services reject every token with a `typ` claim on normal endpoints.
(`dashboard_tiles`, NULL = never changed) is stored the same way. The ticket is not a login: every token names
its kind in `aud` (`tbp-login`, `tbp-public-profile`, `tbp-internal`, `iss=tbp-auth`), and both services check it.
A login's `sub` is the user id; the email is in no token. Until item 29 the tokens also carry the claims of before
item 26 (`userId`, `typ`), so an older Core still reads them. The claims are pinned in
`src/test/resources/contract/auth-core-contract.json`, the same file as in Core
(`ToBePlayed-Deployment/scripts/check-contract.sh` compares both).
- Errors: every error answer of a controller has the body `{code, message}` (a 401 or 403 of the security filters has none); a refused request is `400 VALIDATION_FAILED` with
`fields`, a duplicate email or display name `409 EMAIL_ALREADY_IN_USE` / `DISPLAY_NAME_ALREADY_IN_USE`, anything
unexpected `500 INTERNAL_ERROR` without details (`shared/api/GlobalExceptionController`).
- **Roles**: one per user, a ladder `USER < AUDITOR < PLATFORM_ADMIN` (`users.role`, the first admin is set by
SQL). The access token carries the active role in the `role` claim. **View as**: an admin switches the own login
down to USER or AUDITOR and back (`POST /api/auth/view-as`); the choice is stored per login in
@@ -68,7 +75,7 @@ To Be Played is split into three repositories:
link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an undo needs a
new Steam link. A nightly job (04:15, Europe/Berlin) removes
users flagged 30 days ago, in Core first and then in Auth. It calls Core's `/internal/accounts/*` with a
**service token** (EdDSA JWT, 60 s, `typ=service`). `app.account-cleanup.dry-run` (default `true`) only logs.
**service token** (EdDSA JWT, 60 s, `aud=tbp-internal`). `app.account-cleanup.dry-run` (default `true`) only logs.
## API
@@ -22,7 +22,7 @@ public class CacheConfig {
@Bean
public CacheManager cacheManager() {
log.info("########### Productive cache active ###########");
CaffeineCacheManager cacheManager = new CaffeineCacheManager("usersByEmail", "publicStats");
CaffeineCacheManager cacheManager = new CaffeineCacheManager("users", "publicStats");
cacheManager.setCaffeine(caffeineCacheBuilder());
return cacheManager;
}
@@ -22,7 +22,7 @@ public class DevCacheConfig {
@Bean
public CacheManager cacheManager() {
log.error("########### DEVELOPMENT CACHE ACTIVE ###########");
CaffeineCacheManager cacheManager = new CaffeineCacheManager("usersByEmail", "publicStats");
CaffeineCacheManager cacheManager = new CaffeineCacheManager("users", "publicStats");
cacheManager.setCaffeine(caffeineCacheBuilder());
return cacheManager;
}
@@ -23,6 +23,7 @@ import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
@@ -32,8 +33,12 @@ import java.util.Optional;
@Service
public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceTokenIssuer, PublicKeyPublisher {
static final String ISSUER = "tbp-auth";
static final String LOGIN_AUDIENCE = "tbp-login";
static final String PROFILE_TICKET_AUDIENCE = "tbp-public-profile";
static final String SERVICE_AUDIENCE = "tbp-internal";
private static final long PROFILE_TICKET_EXPIRATION_MS = 10 * 60 * 1000;
// Only non-login tokens carry this claim; isTokenValid rejects them.
// The kind of a token before item 26; item 29 removes it.
private static final String TYPE_CLAIM = "typ";
private static final String PROFILE_TICKET_TYPE = "public-profile";
private static final long SERVICE_TOKEN_EXPIRATION_MS = 60 * 1000;
@@ -64,10 +69,13 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
}
@Override
public String generateToken(String userId, String email, Long steamId, Role role) {
public String generateToken(String userId, Long steamId, Role role) {
var builder = Jwts.builder()
.header().keyId(jwtConfig.getKid()).and()
.subject(email)
.issuer(ISSUER)
.audience().add(LOGIN_AUDIENCE).and()
.subject(userId)
// item 29 removes: a Core from before item 26 reads the user id only from this claim.
.claim("userId", userId)
.claim(ROLE_CLAIM, role.name())
.issuedAt(new Date(System.currentTimeMillis()))
@@ -86,6 +94,8 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
public String generateProfileTicket(long userId, List<String> profileStats, List<String> sections) {
return Jwts.builder()
.header().keyId(jwtConfig.getKid()).and()
.issuer(ISSUER)
.audience().add(PROFILE_TICKET_AUDIENCE).and()
.subject(PROFILE_TICKET_TYPE)
.claim(TYPE_CLAIM, PROFILE_TICKET_TYPE)
.claim("profileUserId", String.valueOf(userId))
@@ -101,6 +111,8 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
public String generateServiceToken() {
return Jwts.builder()
.header().keyId(jwtConfig.getKid()).and()
.issuer(ISSUER)
.audience().add(SERVICE_AUDIENCE).and()
.subject("tbp-auth")
.claim(TYPE_CLAIM, SERVICE_TOKEN_TYPE)
.issuedAt(new Date(System.currentTimeMillis()))
@@ -126,17 +138,36 @@ public class JwtService implements TokenGenerator, ProfileTicketIssuer, ServiceT
public Optional<UserPrincipal> parseLoginToken(String token) {
try {
Claims claims = parser.parseSignedClaims(token).getPayload();
String userId = claims.get("userId", String.class);
if (claims.get(TYPE_CLAIM) != null || claims.getSubject() == null || userId == null) {
if (!isKind(claims, LOGIN_AUDIENCE, null)) {
return Optional.empty();
}
return Optional.of(new UserPrincipal(userId, claims.getSubject(), claims.get("steamId", Long.class),
// item 29 removes: a token without aud comes from before item 26, with the user id in "userId".
String userId = hasAudience(claims) ? claims.getSubject()
: claims.getSubject() == null ? null : claims.get("userId", String.class);
if (userId == null) {
return Optional.empty();
}
return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class),
Role.parse(claims.get(ROLE_CLAIM, String.class))));
} catch (Exception _) {
return Optional.empty();
}
}
private static boolean hasAudience(Claims claims) {
return claims.getAudience() != null && !claims.getAudience().isEmpty();
}
/**
* @param legacyType the {@code typ} of this kind before item 26, null for a login
*/
private static boolean isKind(Claims claims, String audience, String legacyType) {
if (hasAudience(claims)) {
return claims.getAudience().contains(audience) && ISSUER.equals(claims.getIssuer());
}
return Objects.equals(legacyType, claims.get(TYPE_CLAIM, String.class));
}
private static PrivateKey decodePrivateKey(String base64Pkcs8Der) {
try {
byte[] keyBytes = Base64.getDecoder().decode(base64Pkcs8Der);
@@ -41,7 +41,7 @@ public class AccountService implements AccountDeleter {
* Flags the user as deleted and ends all logins; the nightly cleanup removes the user 30 days later. Core
* hides the user at once; when Core is down, the nightly cleanup tells it.
*/
@CacheEvict(value = {"usersByEmail", "publicStats"}, allEntries = true)
@CacheEvict(value = {"users", "publicStats"}, allEntries = true)
public void deleteAccount(long userId, String password) {
String key = String.valueOf(userId);
if (!attemptLimiter.tryAcquire(key, MAX_WRONG_PASSWORDS)) {
@@ -62,7 +62,7 @@ public class AccountService implements AccountDeleter {
@Override
@RequiresRole(Role.PLATFORM_ADMIN)
@CacheEvict(value = {"usersByEmail", "publicStats"}, allEntries = true)
@CacheEvict(value = {"users", "publicStats"}, allEntries = true)
public boolean deleteByAdmin(long userId) {
return flagAndTellCore(userId);
}
@@ -39,7 +39,7 @@ public class AdminUserService {
}
@Transactional
@CacheEvict(value = {"usersByEmail", "publicStats"}, allEntries = true)
@CacheEvict(value = {"users", "publicStats"}, allEntries = true)
public void setBlocked(long actorUserId, long targetUserId, boolean blocked) {
TargetState target = checkedTarget(actorUserId, targetUserId);
if (target.deleted()) {
@@ -9,7 +9,8 @@ import auth_service.modules.login.model.User;
import auth_service.modules.login.persistence.MinimalUserRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import java.util.Optional;
@@ -24,18 +25,20 @@ public class LoginService {
private final TokenGenerator tokenGenerator;
private final LoginAttemptService loginAttemptService;
private final RefreshTokenService refreshTokenService;
private final CacheManager cacheManager;
// Checked against for unknown emails, so they cost the same bcrypt time as a wrong password.
private final String dummyHash;
public LoginService(MinimalUserRepository minimalUserRepository, PasswordEncoder passwordEncoder,
TokenGenerator tokenGenerator, LoginAttemptService loginAttemptService,
RefreshTokenService refreshTokenService) {
RefreshTokenService refreshTokenService, CacheManager cacheManager) {
this.minimalUserRepository = minimalUserRepository;
this.passwordEncoder = passwordEncoder;
this.tokenGenerator = tokenGenerator;
this.loginAttemptService = loginAttemptService;
this.refreshTokenService = refreshTokenService;
this.cacheManager = cacheManager;
this.dummyHash = passwordEncoder.encode(UUID.randomUUID().toString());
}
@@ -46,8 +49,6 @@ public class LoginService {
* @param previousRefreshToken the refresh cookie this browser still sends, or null. The new cookie replaces it,
* so its login is revoked; else it would stay valid unseen for 30 days.
*/
// /api/users/me caches the user by email; a role set by SQL must show after the next login.
@CacheEvict(value = "usersByEmail", key = "#userToLogin.email")
public Session login(User userToLogin, String clientIp, String previousRefreshToken) {
String email = userToLogin.getEmail();
loginAttemptService.acquire(email, clientIp);
@@ -69,9 +70,13 @@ public class LoginService {
User user = foundUser.get();
loginAttemptService.recordSuccess(email, clientIp);
minimalUserRepository.updateLoginDate(user.getId());
// /api/users/me caches the user; a role set by SQL must show after the next login.
Cache users = cacheManager.getCache("users");
if (users != null) {
users.evict(user.getId());
}
refreshTokenService.revoke(previousRefreshToken);
String accessToken = tokenGenerator.generateToken(String.valueOf(user.getId()), user.getEmail(), user.getSteamId(),
user.getRole());
String accessToken = tokenGenerator.generateToken(String.valueOf(user.getId()), user.getSteamId(), user.getRole());
return new Session(accessToken, refreshTokenService.startFamily(user.getId()));
}
}
@@ -110,8 +110,7 @@ public class RefreshTokenService {
if (switching && stored != Role.USER) {
adminAuditLog.record(current.getId(), "VIEW_AS", null, requested == null ? "OWN_ROLE" : requested.name());
}
String accessToken = tokenGenerator.generateToken(
String.valueOf(current.getId()), current.getEmail(), current.getSteamId(), active);
String accessToken = tokenGenerator.generateToken(String.valueOf(current.getId()), current.getSteamId(), active);
return Optional.of(new Session(accessToken, next));
}
@@ -37,7 +37,7 @@ public class RegistrationExceptionController {
@ExceptionHandler(EmailAlreadyInUseException.class)
public ResponseEntity<ErrorResponse> clientError(EmailAlreadyInUseException ex) {
var body = new ErrorResponse("EMAIL_ALREADY_IN_USE", ex.getMessage());
return response(body, HttpStatus.BAD_REQUEST);
return response(body, HttpStatus.CONFLICT);
}
@ExceptionHandler(InvalidInvitationCodeException.class)
@@ -49,7 +49,7 @@ public class RegistrationExceptionController {
@ExceptionHandler(DisplayNameAlreadyRegisteredException.class)
public ResponseEntity<ErrorResponse> clientError(DisplayNameAlreadyRegisteredException ex) {
var body = new ErrorResponse("DISPLAY_NAME_ALREADY_IN_USE", ex.getMessage());
return response(body, HttpStatus.BAD_REQUEST);
return response(body, HttpStatus.CONFLICT);
}
@@ -44,7 +44,7 @@ public class SteamLinkController {
*/
@DeleteMapping(value = "/api/users/steam-link", version = "1.0")
public ResponseEntity<Void> unlink(@AuthenticationPrincipal UserPrincipal principal) {
String token = userService.unlinkSteamId(principal.getIdAsLong(), principal.email(), principal.role());
String token = userService.unlinkSteamId(principal.getIdAsLong(), principal.role());
return ResponseEntity.noContent()
.header(HttpHeaders.SET_COOKIE, authCookies.access(token).toString())
.build();
@@ -35,9 +35,7 @@ public class UserController {
@Timed(value = "user.me.v1.requests.time", description = "Time taken to process v1 user profile requests")
public ResponseEntity<UserProfileResponse> getCurrentUserV1(@AuthenticationPrincipal UserPrincipal principal) {
// this is the email from the login, if a user is not authenticated, it will not even come to this point.
var authenticatedEmail = principal.email();
var profileResponse = this.userService.getUserByEmail(authenticatedEmail);
var profileResponse = this.userService.getUser(principal.getIdAsLong());
return ResponseEntity.ok(ApiMapper.toApi(profileResponse, principal.role()));
}
@@ -4,9 +4,7 @@ import auth_service.shared.api.dto.response.ErrorResponse;
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
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;
@@ -14,9 +12,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(assignableTypes = UserController.class)
@Order(value = 1)
@Slf4j
public class UserExceptionController {
private static final String DISPLAY_NAME_CONSTRAINT = "uq_users_display_name_lower";
private static @NonNull ResponseEntity<ErrorResponse> response(ErrorResponse body, HttpStatus status) {
return ResponseEntity.status(status.value())
@@ -37,23 +33,8 @@ public class UserExceptionController {
return response(body, HttpStatus.NOT_FOUND);
}
// A parallel request can save the same display name 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(DISPLAY_NAME_CONSTRAINT)) {
return displayNameInUse();
}
log.error("Data integrity violation in user request", ex);
return response(new ErrorResponse("INTERNAL_ERROR", "Unexpected database error."), HttpStatus.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler(DisplayNameAlreadyInUseException.class)
public ResponseEntity<ErrorResponse> clientError(DisplayNameAlreadyInUseException ex) {
return displayNameInUse();
}
private static ResponseEntity<ErrorResponse> displayNameInUse() {
var body = new ErrorResponse("DISPLAY_NAME_ALREADY_IN_USE", "The display name is already in use.");
return response(body, HttpStatus.CONFLICT);
}
@@ -18,12 +18,9 @@ import java.util.Optional;
*/
public interface UserRepository {
/**
* Finds a user by their email address.
* * @param email The email to search for
*
* @return An Optional containing the user if found
* The user with the settings.
*/
Optional<User> findByEmail(String email);
Optional<User> findById(long userId);
@NotNull
UserSettings findSettingsByUserId(String userId);
@@ -1,6 +1,5 @@
package auth_service.modules.user.persistence;
import auth_service.shared.account.EmailAddress;
import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.ProfileStyle;
@@ -28,7 +27,7 @@ public class UserRepositoryImpl implements UserRepository {
private EntityManager entityManager;
@Override
public Optional<User> findByEmail(String email) {
public Optional<User> findById(long userId) {
var criteriaBuilder = entityManager.getCriteriaBuilder();
var userEntityCriteriaQuery = criteriaBuilder.createQuery(UserEntity.class);
var userEntity = userEntityCriteriaQuery.from(UserEntity.class);
@@ -37,8 +36,7 @@ public class UserRepositoryImpl implements UserRepository {
// A LEFT JOIN ensures the user is returned even if no settings exist yet.
userEntity.fetch("settings", jakarta.persistence.criteria.JoinType.LEFT);
userEntityCriteriaQuery.select(userEntity).where(criteriaBuilder.equal(
userEntity.get("email"), EmailAddress.normalize(email)));
userEntityCriteriaQuery.select(userEntity).where(criteriaBuilder.equal(userEntity.get("id"), userId));
TypedQuery<UserEntity> typedQuery = entityManager.createQuery(userEntityCriteriaQuery);
@@ -31,16 +31,10 @@ 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.
*
* @param email The authenticated user's email
* @return Optional containing the User model if found
*/
@RequiresRole(Role.USER)
@Cacheable(value = "usersByEmail", key = "#email")
public User getUserByEmail(String email) {
return userRepository.findByEmail(email).orElseThrow(() -> new UserNotFoundException("Could not find user with email " + email));
@Cacheable(value = "users", key = "#userId")
public User getUser(long userId) {
return userRepository.findById(userId).orElseThrow(() -> new UserNotFoundException("Could not find user " + userId));
}
/**
@@ -48,7 +42,7 @@ public class UserService {
*/
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "usersByEmail", allEntries = true)
@CacheEvict(value = "users", key = "#userId")
public void updateUserSettings(long userId, String displayName, UserSettings userSettings) {
if (displayName != null && userRepository.isDisplayNameUsedByOtherUser(userId, displayName)) {
throw new DisplayNameAlreadyInUseException("The display name is already in use.");
@@ -63,7 +57,8 @@ public class UserService {
* the ArchitectureTest.
*/
@Transactional
@CacheEvict(value = "usersByEmail", allEntries = true)
// All: the user who had typed the same Steam ID in loses it.
@CacheEvict(value = "users", allEntries = true)
public SteamLinkOutcome linkVerifiedSteamId(long userId, long steamId) {
Optional<SteamIdHolder> holder = userRepository.findSteamIdHolder(steamId);
if (holder.isPresent() && holder.get().userId() != userId) {
@@ -84,44 +79,44 @@ public class UserService {
*/
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "usersByEmail", allEntries = true)
public String unlinkSteamId(long userId, String email, Role role) {
@CacheEvict(value = "users", key = "#userId")
public String unlinkSteamId(long userId, Role role) {
userRepository.clearSteamId(userId);
log.info("Steam ID unlinked for user {}", userId);
return tokenGenerator.generateToken(String.valueOf(userId), email, null, role);
return tokenGenerator.generateToken(String.valueOf(userId), null, role);
}
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "usersByEmail", allEntries = true)
@CacheEvict(value = "users", key = "#userId")
public void updateAppearance(long userId, Appearance appearance) {
userRepository.updateAppearance(userId, appearance);
}
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "usersByEmail", allEntries = true)
@CacheEvict(value = "users", key = "#userId")
public void updateDashboardTiles(long userId, List<Tile> tiles) {
userRepository.updateDashboardTiles(userId, tiles == null ? null : Tile.distinct(tiles));
}
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "usersByEmail", allEntries = true)
@CacheEvict(value = "users", key = "#userId")
public void updateProfileTiles(long userId, List<Tile> tiles) {
userRepository.updateProfileTiles(userId, Tile.distinct(tiles));
}
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "usersByEmail", allEntries = true)
@CacheEvict(value = "users", key = "#userId")
public void updateFavoritePlatform(long userId, String platform) {
userRepository.updateFavoritePlatform(userId, platform == null ? null : platform.strip());
}
@RequiresRole(Role.USER)
@Transactional
@CacheEvict(value = "usersByEmail", allEntries = true)
@CacheEvict(value = "users", key = "#userId")
public void updateProfileStyle(long userId, ProfileStyle style) {
userRepository.updateProfileStyle(userId, style);
}
@@ -0,0 +1,171 @@
package auth_service.shared.api;
import auth_service.shared.api.dto.response.ErrorResponse;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.core.MethodParameter;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.method.ParameterValidationResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.sql.SQLException;
import java.util.List;
/**
* Gives every error the {@link ErrorResponse} body. Spring's own exceptions keep their status; a 400 is
* {@code VALIDATION_FAILED}. A module's own exceptions belong in its own handler.
*/
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
@Slf4j
public class GlobalExceptionController extends ResponseEntityExceptionHandler {
static final String VALIDATION_FAILED = "VALIDATION_FAILED";
private static final String INTERNAL_ERROR = "INTERNAL_ERROR";
// Postgres unique_violation. The state decides: the message text follows the server's language.
private static final String UNIQUE_VIOLATION = "23505";
private static final String EMAIL_CONSTRAINT = "users_email_key";
private static final String EMAIL_LOWER_CONSTRAINT = "users_email_lower";
private static final String DISPLAY_NAME_CONSTRAINT = "uq_users_display_name_lower";
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status,
WebRequest request) {
return validationFailed(ex.getBindingResult().getFieldErrors().stream()
.map(error -> new ErrorResponse.FieldError(error.getField(), error.getDefaultMessage()))
.toList());
}
@Override
protected ResponseEntity<Object> handleHandlerMethodValidationException(HandlerMethodValidationException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
return validationFailed(ex.getParameterValidationResults().stream()
.flatMap(result -> result.getResolvableErrors().stream()
.map(error -> new ErrorResponse.FieldError(
error instanceof FieldError field ? field.getField() : parameterName(result),
error.getDefaultMessage())))
.toList());
}
/**
* The base keeps its checks (a committed response, the error attribute of a 500); only the body is ours.
*/
@Override
protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body, HttpHeaders headers,
HttpStatusCode statusCode, WebRequest request) {
ResponseEntity<Object> answer = super.handleExceptionInternal(ex, body, headers, statusCode, request);
if (answer == null) {
return null;
}
ErrorResponse error;
if (statusCode.is5xxServerError()) {
log.error("Unexpected error", ex);
error = new ErrorResponse(INTERNAL_ERROR, "Unexpected error");
} else if (statusCode.value() == HttpStatus.BAD_REQUEST.value()) {
String field = ex instanceof MethodArgumentTypeMismatchException mismatch ? mismatch.getName()
: ex instanceof MissingServletRequestParameterException missing ? missing.getParameterName() : null;
error = new ErrorResponse(VALIDATION_FAILED, "The request is not valid",
field == null ? null : List.of(new ErrorResponse.FieldError(field, "Missing or not readable")));
} else {
HttpStatus status = HttpStatus.resolve(statusCode.value());
error = new ErrorResponse(status != null ? status.name() : "ERROR",
status != null ? status.getReasonPhrase() : "Error");
}
return ResponseEntity.status(answer.getStatusCode()).headers(answer.getHeaders()).body(error);
}
/**
* A parallel request can store the same email or display name after the service checked it. Then the unique
* index refuses the write. Any other refused write (a value too long, a missing row) is a bug and stays a 500.
*/
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> dataIntegrity(DataIntegrityViolationException ex) throws Exception {
if (!isUniqueViolation(ex)) {
return unexpected(ex);
}
ErrorResponse body;
if (violates(ex, EMAIL_CONSTRAINT) || violates(ex, EMAIL_LOWER_CONSTRAINT)) {
body = new ErrorResponse("EMAIL_ALREADY_IN_USE", "The email is already in use.");
} else if (violates(ex, DISPLAY_NAME_CONSTRAINT)) {
body = new ErrorResponse("DISPLAY_NAME_ALREADY_IN_USE", "The display name is already in use.");
} else {
log.warn("Database refused a write: {}", ex.getMostSpecificCause().getMessage());
body = new ErrorResponse("CONFLICT", "The data changed at the same time; try again");
}
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}
/**
* Rethrows Spring Security's exceptions: its filters answer them with 401 and 403.
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> unexpected(Exception ex) throws Exception {
if (ex instanceof AccessDeniedException || ex instanceof AuthenticationException) {
throw ex;
}
log.error("Unexpected error", ex);
return ResponseEntity.internalServerError().body(new ErrorResponse(INTERNAL_ERROR, "Unexpected error"));
}
/**
* Hibernate reads the name only from an English Postgres message. The name itself is in the message in every
* language, so the message is searched when Hibernate found none.
*/
private static boolean violates(DataIntegrityViolationException ex, String constraint) {
for (Throwable cause = ex; cause != null; cause = cause.getCause()) {
if (cause instanceof ConstraintViolationException violation && constraint.equals(violation.getConstraintName())) {
return true;
}
}
return String.valueOf(ex.getMostSpecificCause().getMessage()).contains(constraint);
}
private static boolean isUniqueViolation(Throwable ex) {
for (Throwable cause = ex; cause != null; cause = cause.getCause()) {
if (cause instanceof SQLException sql && UNIQUE_VIOLATION.equals(sql.getSQLState())) {
return true;
}
}
return false;
}
private static ResponseEntity<Object> validationFailed(List<ErrorResponse.FieldError> fields) {
return ResponseEntity.badRequest().body(new ErrorResponse(VALIDATION_FAILED, "The request is not valid", fields));
}
// The name in the request, e.g. "q" of @RequestParam("q") String query.
private static String parameterName(ParameterValidationResult result) {
MethodParameter parameter = result.getMethodParameter();
RequestParam requestParam = parameter.getParameterAnnotation(RequestParam.class);
if (requestParam != null && !requestParam.name().isEmpty()) {
return requestParam.name();
}
PathVariable pathVariable = parameter.getParameterAnnotation(PathVariable.class);
if (pathVariable != null && !pathVariable.name().isEmpty()) {
return pathVariable.name();
}
return parameter.getParameterName();
}
}
@@ -1,13 +1,24 @@
package auth_service.shared.api.dto.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.jspecify.annotations.NonNull;
import java.util.List;
/**
* Shared error response record for all modules.
*
* @param code A machine-readable error code.
* @param message A human-readable error message.
* @param fields the refused request fields of a {@code VALIDATION_FAILED}, else left out
*/
public record ErrorResponse(@NonNull String code, @NonNull String message) {
}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ErrorResponse(@NonNull String code, @NonNull String message, List<FieldError> fields) {
public ErrorResponse(@NonNull String code, @NonNull String message) {
this(code, message, null);
}
public record FieldError(String field, String message) {
}
}
@@ -9,11 +9,10 @@ public interface TokenGenerator {
* Generates a token for the given user.
*
* @param userId The unique identifier of the user.
* @param email The email address of the user.
* @param steamId The Steam ID of the user.
* @param role The active role of the login.
* @return The generated token string.
*/
String generateToken(String userId, String email, Long steamId, Role role);
String generateToken(String userId, Long steamId, Role role);
}
@@ -7,13 +7,12 @@ import java.security.Principal;
*/
public record UserPrincipal(
String id,
String email,
Long steamId,
Role role
) implements Principal {
public UserPrincipal(String id, String email, Long steamId) {
this(id, email, steamId, Role.USER);
public UserPrincipal(String id, Long steamId) {
this(id, steamId, Role.USER);
}
@Override
@@ -4,11 +4,14 @@ import auth_service.shared.security.Role;
import auth_service.shared.security.SessionProperties;
import auth_service.shared.security.UserPrincipal;
import auth_service.shared.test.AbstractUnitTest;
import auth_service.shared.test.ContractFixture;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.node.ObjectNode;
import java.security.KeyFactory;
import java.security.KeyPair;
@@ -66,7 +69,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void generateToken_Success() {
var token = jwtService.generateToken("0815", "test@example.com", null, Role.USER);
var token = jwtService.generateToken("0815", null, Role.USER);
assertNotNull(token);
assertFalse(token.isEmpty());
@@ -74,7 +77,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void generateToken_setsKidHeader() {
var token = jwtService.generateToken("0815", "test@example.com", null, Role.USER);
var token = jwtService.generateToken("0815", null, Role.USER);
var header = Jwts.parser()
.verifyWith(publicKey)
@@ -88,9 +91,9 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void parseLoginToken_ReturnsTheUser() {
var token = jwtService.generateToken("0815", "test@example.com", 123456789L, Role.USER);
var token = jwtService.generateToken("0815", 123456789L, Role.USER);
assertEquals(Optional.of(new UserPrincipal("0815", "test@example.com", 123456789L)),
assertEquals(Optional.of(new UserPrincipal("0815", 123456789L)),
jwtService.parseLoginToken(token));
}
@@ -101,7 +104,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void generateToken_ExpiresAfterTheAccessTokenTtl() {
var token = jwtService.generateToken("0815", "test@example.com", null, Role.USER);
var token = jwtService.generateToken("0815", null, Role.USER);
var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token).getPayload();
long secondsLeft = (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000;
@@ -162,7 +165,7 @@ class JwtServiceTest extends AbstractUnitTest {
System.arraycopy(prefix, 0, der, 0, prefix.length);
System.arraycopy(raw, 0, der, prefix.length, raw.length);
PublicKey fromJwk = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(der));
String token = jwtService.generateToken("7", "a@b.de", null, Role.USER);
String token = jwtService.generateToken("7", null, Role.USER);
assertEquals("7", Jwts.parser().verifyWith(fromJwk).build().parseSignedClaims(token).getPayload().get("userId"));
}
@@ -181,7 +184,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void parseLoginToken_ReadsTheRole() {
String token = jwtService.generateToken("7", "a@b.de", null, Role.AUDITOR);
String token = jwtService.generateToken("7", null, Role.AUDITOR);
assertEquals(Role.AUDITOR, jwtService.parseLoginToken(token).orElseThrow().role());
}
@@ -194,4 +197,52 @@ class JwtServiceTest extends AbstractUnitTest {
assertEquals(Role.USER, jwtService.parseLoginToken(old).orElseThrow().role());
}
// The claims as the other side reads them: iat and exp change per token.
private static JsonNode payload(String token) {
ObjectNode payload = (ObjectNode) ContractFixture.MAPPER.readTree(Base64.getUrlDecoder().decode(token.split("\\.")[1]));
payload.remove(List.of("iat", "exp"));
return payload;
}
@SuppressWarnings("unchecked")
private String sign(JsonNode claims) {
return Jwts.builder().header().keyId(KID).and()
.claims(ContractFixture.MAPPER.treeToValue(claims, Map.class))
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(privateKey, Jwts.SIG.EdDSA).compact();
}
@Test
void tokens_MatchTheContractWithCore() {
assertEquals(ContractFixture.token("login"),
payload(jwtService.generateToken("42", 76561198000000001L, Role.USER)));
assertEquals(ContractFixture.token("publicProfile"),
payload(jwtService.generateProfileTicket(42L, List.of("OVERVIEW"), List.of("SUMMARY"))));
assertEquals(ContractFixture.token("service"), payload(jwtService.generateServiceToken()));
}
@Test
void parseLoginToken_ALoginOfAnAuthFromBeforeItem26_IsAccepted() {
assertEquals("42", jwtService.parseLoginToken(sign(ContractFixture.legacyToken("login"))).orElseThrow().id());
}
@Test
void parseLoginToken_ALoginWithoutTheOldUserIdClaim_IsAccepted() {
ObjectNode login = (ObjectNode) ContractFixture.token("login").deepCopy();
login.remove("userId");
assertEquals("42", jwtService.parseLoginToken(sign(login)).orElseThrow().id());
}
@Test
void parseLoginToken_ATokenForAnotherAudience_IsRejected() {
ObjectNode ticketAudience = (ObjectNode) ContractFixture.token("login").deepCopy();
ticketAudience.putArray("aud").add(JwtService.PROFILE_TICKET_AUDIENCE);
ObjectNode otherIssuer = (ObjectNode) ContractFixture.token("login").deepCopy();
otherIssuer.put("iss", "someone-else");
assertEquals(Optional.empty(), jwtService.parseLoginToken(sign(ticketAudience)));
assertEquals(Optional.empty(), jwtService.parseLoginToken(sign(otherIssuer)));
}
}
@@ -93,7 +93,7 @@ class RoleCheckFlowTest extends AbstractIntegrationTest {
private TokenGenerator tokenGenerator;
private String token(Role role) {
return tokenGenerator.generateToken("4711", "probe@example.com", null, role);
return tokenGenerator.generateToken("4711", null, role);
}
private ResultActions call(String path, Role role) throws Exception {
@@ -3,6 +3,7 @@ package auth_service.modules.account.service;
import auth_service.modules.account.model.CoreCleanupResult;
import auth_service.shared.security.ServiceTokenIssuer;
import auth_service.shared.test.AbstractUnitTest;
import auth_service.shared.test.ContractFixture;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
@@ -10,6 +11,7 @@ import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import tools.jackson.databind.JsonNode;
import java.util.List;
import java.util.Map;
@@ -39,15 +41,23 @@ class CoreAccountServiceTest extends AbstractUnitTest {
service = new CoreAccountService(builder.build(), TOKENS);
}
@Test
void markAndReplace_SendTheUserIdsWithTheServiceToken() {
core.expect(requestTo(CORE + "/internal/accounts/deleted")).andExpect(method(HttpMethod.POST))
private void expect(String name) {
JsonNode call = ContractFixture.internalCall(name);
var expectation = core.expect(requestTo(CORE + call.path("path").asString()))
.andExpect(method(HttpMethod.valueOf(call.path("method").asString())))
.andExpect(header("Authorization", "Bearer service-token"))
.andExpect(content().json("{\"userIds\":[42]}"))
.andRespond(withNoContent());
core.expect(requestTo(CORE + "/internal/accounts/deleted")).andExpect(method(HttpMethod.PUT))
.andExpect(content().json("{\"userIds\":[]}"))
.andRespond(withNoContent());
.andExpect(content().json(call.path("request").toString()));
if (call.has("response")) {
expectation.andRespond(withSuccess(call.path("response").toString(), MediaType.APPLICATION_JSON));
} else {
expectation.andRespond(withNoContent());
}
}
@Test
void markAndReplace_SendTheContractBodiesWithTheServiceToken() {
expect("markDeleted");
expect("replaceDeleted");
service.markDeleted(List.of(42L));
service.replaceDeleted(List.of());
@@ -56,12 +66,9 @@ class CoreAccountServiceTest extends AbstractUnitTest {
}
@Test
void purgeAndPrune_ReadTheResult() {
String result = "{\"dryRun\":true,\"users\":[42],\"rows\":{\"user_game\":3},\"refused\":false}";
core.expect(requestTo(CORE + "/internal/accounts/purge")).andExpect(content().json("{\"userIds\":[42],\"dryRun\":false}"))
.andRespond(withSuccess(result, MediaType.APPLICATION_JSON));
core.expect(requestTo(CORE + "/internal/accounts/prune")).andExpect(content().json("{\"knownUserIds\":[1,42],\"dryRun\":true}"))
.andRespond(withSuccess(result, MediaType.APPLICATION_JSON));
void purgeAndPrune_ReadTheContractResult() {
expect("purge");
expect("prune");
CoreCleanupResult expected = new CoreCleanupResult(true, List.of(42L), Map.of("user_game", 3), false);
assertThat(service.purge(List.of(42L), false)).isEqualTo(expected);
@@ -10,6 +10,8 @@ import auth_service.modules.login.model.User;
import auth_service.modules.login.persistence.MinimalUserRepository;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.cache.CacheManager;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.security.crypto.password.PasswordEncoder;
@@ -17,6 +19,8 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
@@ -55,11 +59,13 @@ class LoginServiceTest extends AbstractUnitTest {
private User user;
private final CacheManager cacheManager = new ConcurrentMapCacheManager("users");
@BeforeEach
void setUp() {
lenient().when(passwordEncoder.encode(anyString())).thenReturn("dummy-hash");
loginService = new LoginService(minimalUserRepository, passwordEncoder, tokenGenerator, loginAttemptService,
refreshTokenService);
refreshTokenService, cacheManager);
user = User.builder()
.email("test@example.com")
.password("password")
@@ -77,7 +83,7 @@ class LoginServiceTest extends AbstractUnitTest {
.build();
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
when(tokenGenerator.generateToken("815", "test@example.com", null, Role.USER)).thenReturn("mock-token");
when(tokenGenerator.generateToken("815", null, Role.USER)).thenReturn("mock-token");
when(refreshTokenService.startFamily(815L)).thenReturn("mock-refresh");
// Act
@@ -89,6 +95,20 @@ class LoginServiceTest extends AbstractUnitTest {
verify(loginAttemptService).recordSuccess("test@example.com", IP);
}
@Test
void login_Success_DropsOnlyThisUsersCachedProfile() {
var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true).build();
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
cacheManager.getCache("users").put(815L, "old profile");
cacheManager.getCache("users").put(999L, "other user");
loginService.login(user, IP, null);
assertNull(cacheManager.getCache("users").get(815L));
assertNotNull(cacheManager.getCache("users").get(999L));
}
@Test
void login_Success_RevokesTheRefreshTokenThisBrowserStillSends() {
var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true).build();
@@ -155,7 +175,7 @@ class LoginServiceTest extends AbstractUnitTest {
// Act & Assert
assertThrows(AccountBlockedException.class, () -> loginService.login(user, IP, null));
verify(tokenGenerator, never()).generateToken(any(), any(), any(), any());
verify(tokenGenerator, never()).generateToken(any(), any(), any());
verify(loginAttemptService).recordSuccess("test@example.com", IP);
}
@@ -177,7 +197,7 @@ class LoginServiceTest extends AbstractUnitTest {
loginService.login(user, IP, null);
verify(tokenGenerator).generateToken("815", "test@example.com", null, Role.PLATFORM_ADMIN);
verify(tokenGenerator).generateToken("815", null, Role.PLATFORM_ADMIN);
}
@Test
@@ -1,6 +1,12 @@
package auth_service.modules.registration.api;
import auth_service.configuration.security.JwtAuthenticationFilter;
import java.sql.SQLException;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.hibernate.exception.ConstraintViolationException;
import auth_service.configuration.security.JwtService;
import auth_service.modules.registration.api.dto.request.RegistrationRequest;
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
@@ -23,14 +29,18 @@ import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
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.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -59,6 +69,14 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
@Autowired
private JacksonTester<RegistrationRequest> registrationRequestJson;
// A German server: Hibernate reads no constraint name from this text, the handler must still find it.
private static DataIntegrityViolationException duplicate(String constraint) {
String text = "doppelter Schlüsselwert verletzt Unique-Constraint »" + constraint + "«";
return new DataIntegrityViolationException(text,
new ConstraintViolationException(text, new SQLException(text, "23505"), null));
}
@Test
void register_Success() throws Exception {
// Arrange
@@ -93,13 +111,11 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
when(registrationService.register(any(Registration.class), any()))
.thenThrow(new EmailAlreadyInUseException("The provided email address is already in use."));
// Act & Assert
// Depending on your GlobalExceptionHandler, this could also be 400 or 409.
// Assuming standard behavior without advice or mapping to 400 for now.
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content(registrationRequestJson.write(request).getJson()))
.andExpect(status().isBadRequest()); // Adjust status based on your @ExceptionHandler
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("EMAIL_ALREADY_IN_USE"));
}
@Test
@@ -114,20 +130,53 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
}
@Test
void register_DuplicateEmailRaceIsNotReportedAsConflict() throws Exception {
// Arrange
void register_DuplicateEmailRace_Is409EmailAlreadyInUse() throws Exception {
var request = RegistrationRequest.builder()
.email("test@example.com")
.password("SuperSecret123!")
.inviteCode("code")
.build();
when(registrationService.register(any(Registration.class), any()))
.thenThrow(new DataIntegrityViolationException("users_email_key"));
.thenThrow(duplicate("users_email_lower"));
// Act & Assert: the user module's 409 mapping must not apply to registration
assertThrows(ServletException.class, () -> mockMvc.perform(post("/api/registration/new")
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content(registrationRequestJson.write(request).getJson())));
.content(registrationRequestJson.write(request).getJson()))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("EMAIL_ALREADY_IN_USE"));
}
@Test
void register_DisplayNameRace_Is409DisplayNameAlreadyInUse() throws Exception {
when(registrationService.register(any(Registration.class), any()))
.thenThrow(duplicate("uq_users_display_name_lower"));
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","displayName":"Lobo"}"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DISPLAY_NAME_ALREADY_IN_USE"));
}
@Test
void register_UnreadableBody_Is400ValidationFailed() throws Exception {
mockMvc.perform(post("/api/registration/new").contentType(MediaType.APPLICATION_JSON).content("{not json"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
}
@Test
void register_UnexpectedError_Is500WithoutDetails() throws Exception {
when(registrationService.register(any(Registration.class), any())).thenThrow(new IllegalStateException("secret detail"));
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}"""))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"))
.andExpect(content().string(not(containsString("secret detail"))));
}
@Test
@@ -136,7 +185,9 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","displayName":"x"}"""))
.andExpect(status().isBadRequest());
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.fields[0].field").value("displayName"));
verifyNoInteractions(registrationService);
}
@@ -238,4 +289,20 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
verify(registrationService).register(registration.capture(), any());
assertEquals("bob@example.com", registration.getValue().getEmail());
}
@Test
@ExtendWith(OutputCaptureExtension.class)
void aSpringServerError_IsLoggedAsInternalError(CapturedOutput output) throws Exception {
when(registrationService.register(any(Registration.class), any()))
.thenThrow(new HttpMessageNotWritableException("body not writable"));
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}"""))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"));
assertTrue(output.getAll().contains("body not writable"), "the 500 left no trace in the log");
}
}
@@ -63,10 +63,13 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
entityManager.flush();
assertTrue(registeredUserRepository.existsByEmail("Dup@Example.com"));
assertThrows(org.hibernate.exception.ConstraintViolationException.class, () -> {
var refused = assertThrows(org.hibernate.exception.ConstraintViolationException.class, () -> {
entityManager.persist(RegisteredUserEntity.builder().email("Dup@example.com").password("password")
.enabled(true).registrationDate(java.time.LocalDateTime.now()).build());
entityManager.flush();
});
// GlobalExceptionController decides on the state; on an English server Hibernate also gives the name.
assertEquals("23505", refused.getSQLState());
assertEquals("users_email_lower", refused.getConstraintName());
}
}
@@ -1,6 +1,8 @@
package auth_service.modules.user.api;
import auth_service.modules.user.model.ProfileStyle;
import java.sql.SQLException;
import org.hibernate.exception.ConstraintViolationException;
import auth_service.modules.user.model.Tile;
import auth_service.configuration.security.JwtAuthenticationFilter;
import auth_service.configuration.security.JwtService;
@@ -69,17 +71,25 @@ class UserControllerTest extends AbstractWebMvcTest {
SecurityContextHolder.clearContext();
}
// A German server: Hibernate reads no constraint name from this text, the handler must still find it.
private static DataIntegrityViolationException duplicate(String constraint) {
String text = "doppelter Schlüsselwert verletzt Unique-Constraint »" + constraint + "«";
return new DataIntegrityViolationException(text,
new ConstraintViolationException(text, new SQLException(text, "23505"), null));
}
@Test
void getCurrentUserV1_Success() 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);
var principal = new UserPrincipal("1", null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(user);
when(userService.getUser(1L)).thenReturn(user);
// Act & Assert
mockMvc.perform(get("/api/users/me")
@@ -94,11 +104,11 @@ class UserControllerTest extends AbstractWebMvcTest {
var email = "test@example.com";
var settings = UserSettings.builder().language("en").theme("dark").build();
var user = User.builder().email(email).steamId(76561198000000001L).steamIdVerified(true).settings(settings).build();
var principal = new UserPrincipal("1", email, null);
var principal = new UserPrincipal("1", null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(user);
when(userService.getUser(1L)).thenReturn(user);
// Act & Assert
mockMvc.perform(get("/api/users/me")
@@ -114,11 +124,11 @@ class UserControllerTest extends AbstractWebMvcTest {
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);
var principal = new UserPrincipal("1", null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(user);
when(userService.getUser(1L)).thenReturn(user);
// Act & Assert
mockMvc.perform(get("/api/users/me")
@@ -131,11 +141,11 @@ class UserControllerTest extends AbstractWebMvcTest {
void getCurrentUserV1_NotFound() throws Exception {
// Arrange
var email = "notfound@example.com";
var principal = new UserPrincipal("1", email, null);
var principal = new UserPrincipal("1", null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenThrow(new UserNotFoundException("User not found"));
when(userService.getUser(1L)).thenThrow(new UserNotFoundException("User not found"));
// Act & Assert
mockMvc.perform(get("/api/users/me")
@@ -145,7 +155,7 @@ class UserControllerTest extends AbstractWebMvcTest {
@Test
void updateUserSettings_OldClientSendingASteamId_SavesTheRestAndIssuesNoCookie() throws Exception {
var principal = new UserPrincipal("1", "test@example.com", null);
var principal = new UserPrincipal("1", null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
@@ -164,11 +174,11 @@ class UserControllerTest extends AbstractWebMvcTest {
void updateUserSettings_OtherConstraintIsNotAConflict() throws Exception {
// Arrange
var email = "test@example.com";
var principal = new UserPrincipal("1", email, null);
var principal = new UserPrincipal("1", null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
doThrow(new DataIntegrityViolationException("value too long for type character varying(10)")).when(userService).updateUserSettings(eq(1L), any(), any());
doThrow(new DataIntegrityViolationException("Wert zu lang", new SQLException("Wert zu lang", "22001"))).when(userService).updateUserSettings(eq(1L), any(), any());
// Act & Assert
mockMvc.perform(put("/api/users/settings")
@@ -185,11 +195,11 @@ class UserControllerTest extends AbstractWebMvcTest {
var email = "test@example.com";
var settings = UserSettings.builder().language("en").theme("dark").build();
var user = User.builder().email(email).displayName("Lobo").settings(settings).build();
var principal = new UserPrincipal("1", email, null);
var principal = new UserPrincipal("1", null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(user);
when(userService.getUser(1L)).thenReturn(user);
// Act & Assert
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
@@ -201,7 +211,7 @@ class UserControllerTest extends AbstractWebMvcTest {
void updateUserSettings_PassesDisplayName() throws Exception {
// Arrange
var email = "test@example.com";
var principal = new UserPrincipal("1", email, null);
var principal = new UserPrincipal("1", null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
@@ -218,7 +228,7 @@ class UserControllerTest extends AbstractWebMvcTest {
@ValueSource(strings = {"ab", " Lobo", "Lobo ", "<script>", "abcdefghijklmnopqrstuvwxy"})
void updateUserSettings_InvalidDisplayName(String displayName) throws Exception {
// Arrange
var principal = new UserPrincipal("1", "test@example.com", null);
var principal = new UserPrincipal("1", null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
@@ -236,7 +246,7 @@ class UserControllerTest extends AbstractWebMvcTest {
void updateUserSettings_DisplayNameTaken() throws Exception {
// Arrange
var email = "test@example.com";
var principal = new UserPrincipal("1", email, null);
var principal = new UserPrincipal("1", null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
@@ -255,11 +265,11 @@ class UserControllerTest extends AbstractWebMvcTest {
void updateUserSettings_DisplayNameRaceIsConflict() throws Exception {
// Arrange
var email = "test@example.com";
var principal = new UserPrincipal("1", email, null);
var principal = new UserPrincipal("1", null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
doThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint \"uq_users_display_name_lower\"")).when(userService).updateUserSettings(eq(1L), eq("Lobo"), any());
doThrow(duplicate("uq_users_display_name_lower")).when(userService).updateUserSettings(eq(1L), eq("Lobo"), any());
// Act & Assert
mockMvc.perform(put("/api/users/settings")
@@ -273,7 +283,7 @@ class UserControllerTest extends AbstractWebMvcTest {
@ParameterizedTest
@CsvSource({"',\"publicProfile\":true', true", "'', false", "',\"publicProfile\":null', false"})
void updateUserSettings_PassesPublicProfile_MissingIsFalse(String field, boolean expected) throws Exception {
var principal = new UserPrincipal("1", "test@example.com", null);
var principal = new UserPrincipal("1", null);
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(principal, null, Collections.emptyList())
);
@@ -290,7 +300,7 @@ class UserControllerTest extends AbstractWebMvcTest {
@Test
void updateUserSettings_PublicProfileWithoutDisplayNameIsRejected() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
new UserPrincipal("1", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings")
.contentType(MediaType.APPLICATION_JSON)
@@ -303,9 +313,9 @@ class UserControllerTest extends AbstractWebMvcTest {
var email = "test@example.com";
var settings = UserSettings.builder().language("en").theme("dark").publicProfile(true).build();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", null), null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(User.builder().email(email).settings(settings).build());
when(userService.getUser(1L)).thenReturn(User.builder().email(email).settings(settings).build());
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
@@ -318,9 +328,9 @@ class UserControllerTest extends AbstractWebMvcTest {
var settings = UserSettings.builder().language("en").theme("dark")
.appearance(new Appearance("blue", "round", true, 620L, 1086940L, false)).build();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", null), null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(User.builder().email(email).settings(settings).build());
when(userService.getUser(1L)).thenReturn(User.builder().email(email).settings(settings).build());
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
@@ -337,9 +347,9 @@ class UserControllerTest extends AbstractWebMvcTest {
var email = "test@example.com";
var settings = UserSettings.builder().language("en").theme("dark").build();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", null), null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(User.builder().email(email).settings(settings).build());
when(userService.getUser(1L)).thenReturn(User.builder().email(email).settings(settings).build());
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
@@ -358,9 +368,9 @@ class UserControllerTest extends AbstractWebMvcTest {
.dashboardTiles(List.of(new Tile("STATS_TREND", "L")))
.profileTiles(List.of(new Tile("PLAYING", "S"))).favoritePlatform("PS5").build();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", null), null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(User.builder().email(email).settings(settings).build());
when(userService.getUser(1L)).thenReturn(User.builder().email(email).settings(settings).build());
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
@@ -375,9 +385,9 @@ class UserControllerTest extends AbstractWebMvcTest {
void getCurrentUserV1_NeverChangedDashboard_SendsNullTiles() throws Exception {
String email = "test@example.com";
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", null), null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(User.builder().email(email)
when(userService.getUser(1L)).thenReturn(User.builder().email(email)
.settings(UserSettings.builder().language("en").theme("dark").build()).build());
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
@@ -389,7 +399,7 @@ class UserControllerTest extends AbstractWebMvcTest {
@Test
void updateDashboardTiles_StoresTheTiles() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
new UserPrincipal("1", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/dashboard-tiles")
.contentType(MediaType.APPLICATION_JSON)
@@ -402,7 +412,7 @@ class UserControllerTest extends AbstractWebMvcTest {
@Test
void updateDashboardTiles_NullTilesResets() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
new UserPrincipal("1", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/dashboard-tiles")
.contentType(MediaType.APPLICATION_JSON)
@@ -415,7 +425,7 @@ class UserControllerTest extends AbstractWebMvcTest {
@Test
void updateFavoritePlatform_StoresOrClearsIt() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
new UserPrincipal("1", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/favorite-platform")
.contentType(MediaType.APPLICATION_JSON)
@@ -433,7 +443,7 @@ class UserControllerTest extends AbstractWebMvcTest {
@Test
void updateFavoritePlatform_BlankOrTooLong_Is400() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
new UserPrincipal("1", null), null, Collections.emptyList()));
for (String platform : List.of(" ", "x".repeat(33))) {
mockMvc.perform(put("/api/users/settings/favorite-platform")
@@ -447,7 +457,7 @@ class UserControllerTest extends AbstractWebMvcTest {
@Test
void updateProfileTiles_StoresTheTiles() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
new UserPrincipal("1", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/profile-tiles")
.contentType(MediaType.APPLICATION_JSON)
@@ -471,7 +481,7 @@ class UserControllerTest extends AbstractWebMvcTest {
})
void updateTiles_UnknownIdBadSizeOrMissing_IsRejected(String pathAndBody) throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
new UserPrincipal("1", null), null, Collections.emptyList()));
String[] parts = pathAndBody.split("\\|", 2);
mockMvc.perform(put("/api/users/settings/" + parts[0])
@@ -485,7 +495,7 @@ class UserControllerTest extends AbstractWebMvcTest {
@Test
void updateTiles_MoreThanTwentyTiles_IsRejected() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
new UserPrincipal("1", null), null, Collections.emptyList()));
String tiles = String.join(",", Collections.nCopies(21, "{\"id\":\"PLAYING\",\"size\":\"M\"}"));
mockMvc.perform(put("/api/users/settings/dashboard-tiles")
@@ -500,9 +510,9 @@ class UserControllerTest extends AbstractWebMvcTest {
UserSettings settings = UserSettings.builder().language("en").theme("dark")
.profileStyle(new ProfileStyle("red", "sharp", 620L, null)).build();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", email, null), null, Collections.emptyList())
new UsernamePasswordAuthenticationToken(new UserPrincipal("1", null), null, Collections.emptyList())
);
when(userService.getUserByEmail(email)).thenReturn(User.builder().email(email).settings(settings).build());
when(userService.getUser(1L)).thenReturn(User.builder().email(email).settings(settings).build());
mockMvc.perform(get("/api/users/me").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
@@ -515,7 +525,7 @@ class UserControllerTest extends AbstractWebMvcTest {
@Test
void updateProfileStyle_StoresTheValues() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
new UserPrincipal("1", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/profile-style")
.contentType(MediaType.APPLICATION_JSON)
@@ -536,7 +546,7 @@ class UserControllerTest extends AbstractWebMvcTest {
})
void updateProfileStyle_UnknownOrMissingValue_IsRejected(String body) throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
new UserPrincipal("1", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/profile-style")
.contentType(MediaType.APPLICATION_JSON)
@@ -549,7 +559,7 @@ class UserControllerTest extends AbstractWebMvcTest {
@Test
void updateAppearance_StoresTheValues() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
new UserPrincipal("1", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/appearance")
.contentType(MediaType.APPLICATION_JSON)
@@ -563,7 +573,7 @@ class UserControllerTest extends AbstractWebMvcTest {
@Test
void updateAppearance_MissingOptionalFields_AreTheDefaults() throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
new UserPrincipal("1", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/appearance")
.contentType(MediaType.APPLICATION_JSON)
@@ -587,7 +597,7 @@ class UserControllerTest extends AbstractWebMvcTest {
})
void updateAppearance_UnknownOrMissingValue_IsRejected(String body) throws Exception {
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new UserPrincipal("1", "test@example.com", null), null, Collections.emptyList()));
new UserPrincipal("1", null), null, Collections.emptyList()));
mockMvc.perform(put("/api/users/settings/appearance")
.contentType(MediaType.APPLICATION_JSON)
@@ -2,6 +2,7 @@ package auth_service.modules.user.persistence;
import auth_service.modules.user.model.ProfileStyle;
import auth_service.modules.user.model.Tile;
import auth_service.modules.user.model.User;
import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance;
import auth_service.modules.user.model.SteamIdHolder;
@@ -32,6 +33,16 @@ class UserRepositoryTest extends AbstractPersistenceTest {
@Autowired
private JdbcTemplate jdbc;
// The repository finds users by id; the tests know them by email.
private Optional<User> findByEmail(String email) {
return entityManager.getEntityManager()
.createQuery("select u.id from UserEntity u where u.email = :email", Long.class)
.setParameter("email", email)
.getResultStream()
.findFirst()
.flatMap(userRepository::findById);
}
@Test
void updateSettings_KeepsADeleteThatCommittedAfterTheLoad() {
var user = entityManager.persistFlushFind(UserEntity.builder().email("race@example.com").password("password")
@@ -60,7 +71,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
entityManager.flush();
// Act
var result = userRepository.findByEmail(email);
var result = findByEmail(email);
// Assert
assertTrue(result.isPresent());
@@ -110,7 +121,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
entityManager.flush();
// Act
var result = userRepository.findByEmail(email);
var result = findByEmail(email);
// Assert
assertTrue(result.isPresent());
@@ -179,7 +190,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
@Test
void migrations_LeaveNoSeededTestUser() {
// V1 seeded test@example.com with a known password; it must not exist after all migrations.
assertTrue(userRepository.findByEmail("test@example.com").isEmpty());
assertTrue(findByEmail("test@example.com").isEmpty());
}
@Test
@@ -205,7 +216,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
entityManager.flush();
// Assert
assertEquals("Lobo", userRepository.findByEmail("owner2@example.com").orElseThrow().getDisplayName());
assertEquals("Lobo", findByEmail("owner2@example.com").orElseThrow().getDisplayName());
assertFalse(userRepository.isDisplayNameUsedByOtherUser(owner.getId(), "lobo"));
assertTrue(userRepository.isDisplayNameUsedByOtherUser(other.getId(), "LOBO"));
assertFalse(userRepository.isDisplayNameUsedByOtherUser(other.getId(), "Wolf"));
@@ -226,7 +237,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
entityManager.flush();
entityManager.clear();
assertTrue(userRepository.findByEmail("public@example.com").orElseThrow().getSettings().isPublicProfile());
assertTrue(findByEmail("public@example.com").orElseThrow().getSettings().isPublicProfile());
}
@Test
@@ -243,7 +254,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
entityManager.flush();
entityManager.clear();
var settings = userRepository.findByEmail("appearance@example.com").orElseThrow().getSettings();
var settings = findByEmail("appearance@example.com").orElseThrow().getSettings();
assertEquals(new Appearance("orange", "round", true, 620L, 1086940L, false), settings.getAppearance());
}
@@ -264,7 +275,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
entityManager.flush();
entityManager.clear();
var settings = userRepository.findByEmail("keep@example.com").orElseThrow().getSettings();
var settings = findByEmail("keep@example.com").orElseThrow().getSettings();
assertEquals(new Appearance("pink", "sharp", false, 220L, null, true), settings.getAppearance());
}
@@ -278,7 +289,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.build());
entityManager.flush();
assertEquals(Appearance.DEFAULT, userRepository.findByEmail("defaults@example.com").orElseThrow()
assertEquals(Appearance.DEFAULT, findByEmail("defaults@example.com").orElseThrow()
.getSettings().getAppearance());
}
@@ -291,7 +302,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
assertEquals(ProfileStyle.DEFAULT, userRepository.findByEmail("style@example.com").orElseThrow()
assertEquals(ProfileStyle.DEFAULT, findByEmail("style@example.com").orElseThrow()
.getSettings().getProfileStyle());
ProfileStyle style = new ProfileStyle("pink", "sharp", 620L, 1086940L);
@@ -299,7 +310,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
entityManager.flush();
entityManager.clear();
assertEquals(style, userRepository.findByEmail("style@example.com").orElseThrow().getSettings().getProfileStyle());
assertEquals(style, findByEmail("style@example.com").orElseThrow().getSettings().getProfileStyle());
}
@Test
@@ -311,7 +322,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.registrationDate(LocalDateTime.now())
.build());
entityManager.flush();
UserSettings fresh = userRepository.findByEmail("tiles@example.com").orElseThrow().getSettings();
UserSettings fresh = findByEmail("tiles@example.com").orElseThrow().getSettings();
assertNull(fresh.getDashboardTiles());
assertEquals(UserSettings.DEFAULT_PROFILE_TILES, fresh.getProfileTiles());
@@ -325,7 +336,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
entityManager.flush();
entityManager.clear();
UserSettings saved = userRepository.findByEmail("tiles@example.com").orElseThrow().getSettings();
UserSettings saved = findByEmail("tiles@example.com").orElseThrow().getSettings();
assertEquals(dashboard, saved.getDashboardTiles());
assertEquals(profile, saved.getProfileTiles());
}
@@ -338,18 +349,18 @@ class UserRepositoryTest extends AbstractPersistenceTest {
.enabled(true)
.registrationDate(LocalDateTime.now())
.build());
assertNull(userRepository.findByEmail("favorite@example.com").orElseThrow().getSettings().getFavoritePlatform());
assertNull(findByEmail("favorite@example.com").orElseThrow().getSettings().getFavoritePlatform());
userRepository.updateFavoritePlatform(userEntity.getId(), "Switch");
entityManager.flush();
entityManager.clear();
assertEquals("Switch", userRepository.findByEmail("favorite@example.com").orElseThrow().getSettings()
assertEquals("Switch", findByEmail("favorite@example.com").orElseThrow().getSettings()
.getFavoritePlatform());
userRepository.updateFavoritePlatform(userEntity.getId(), null);
entityManager.flush();
entityManager.clear();
assertNull(userRepository.findByEmail("favorite@example.com").orElseThrow().getSettings().getFavoritePlatform());
assertNull(findByEmail("favorite@example.com").orElseThrow().getSettings().getFavoritePlatform());
}
@Test
@@ -367,7 +378,7 @@ class UserRepositoryTest extends AbstractPersistenceTest {
entityManager.flush();
entityManager.clear();
assertNull(userRepository.findByEmail("reset@example.com").orElseThrow().getSettings().getDashboardTiles());
assertNull(findByEmail("reset@example.com").orElseThrow().getSettings().getDashboardTiles());
}
@Test
@@ -41,27 +41,18 @@ class UserServiceTest extends AbstractUnitTest {
private UserService userService;
@Test
void getUserByEmail_Success() {
// Arrange
var email = "test@example.com";
var user = User.builder().email(email).build();
when(userRepository.findByEmail(email)).thenReturn(Optional.of(user));
void getUser_Success() {
var user = User.builder().id(7L).email("test@example.com").build();
when(userRepository.findById(7L)).thenReturn(Optional.of(user));
// Act
var result = userService.getUserByEmail(email);
// Assert
assertEquals(user, result);
assertEquals(user, userService.getUser(7L));
}
@Test
void getUserByEmail_NotFound() {
// Arrange
var email = "notfound@example.com";
when(userRepository.findByEmail(email)).thenReturn(Optional.empty());
void getUser_NotFound() {
when(userRepository.findById(7L)).thenReturn(Optional.empty());
// Act & Assert
assertThrows(UserNotFoundException.class, () -> userService.getUserByEmail(email));
assertThrows(UserNotFoundException.class, () -> userService.getUser(7L));
}
@Test
@@ -161,9 +152,9 @@ class UserServiceTest extends AbstractUnitTest {
@Test
void unlinkSteamId_ClearsItAndReturnsATokenWithoutIt() {
when(tokenGenerator.generateToken("1", "test@example.com", null, Role.USER)).thenReturn("token-without-steam");
when(tokenGenerator.generateToken("1", null, Role.USER)).thenReturn("token-without-steam");
assertEquals("token-without-steam", userService.unlinkSteamId(1L, "test@example.com", Role.USER));
assertEquals("token-without-steam", userService.unlinkSteamId(1L, Role.USER));
verify(userRepository).clearSteamId(1L);
}
@@ -0,0 +1,41 @@
package auth_service.shared.test;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
/**
* {@code contract/auth-core-contract.json}: what Core expects of Auth's tokens and calls. Core builds its test tokens
* from the same file.
*/
public final class ContractFixture {
public static final JsonMapper MAPPER = JsonMapper.builder().build();
private static final JsonNode CONTRACT = load();
private ContractFixture() {
}
private static JsonNode load() {
try (InputStream in = ContractFixture.class.getResourceAsStream("/contract/auth-core-contract.json")) {
return MAPPER.readTree(in);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
public static JsonNode token(String kind) {
return CONTRACT.path("tokens").required(kind);
}
public static JsonNode legacyToken(String kind) {
return CONTRACT.path("legacyTokens").required(kind);
}
public static JsonNode internalCall(String name) {
return CONTRACT.path("internal").required(name);
}
}
@@ -0,0 +1,71 @@
{
"_about": "The token and internal-call contract between Auth and Core. The same file is in both repos; ToBePlayed-Deployment/scripts/check-contract.sh compares them. iat and exp are left out. legacyTokens are the shapes of an Auth from before item 26: Core and Auth accept them until item 29.",
"tokens": {
"login": {
"iss": "tbp-auth",
"aud": ["tbp-login"],
"sub": "42",
"userId": "42",
"role": "USER",
"steamId": 76561198000000001
},
"publicProfile": {
"iss": "tbp-auth",
"aud": ["tbp-public-profile"],
"sub": "public-profile",
"typ": "public-profile",
"profileUserId": "42",
"profileStats": ["OVERVIEW"],
"sections": ["SUMMARY"]
},
"service": {
"iss": "tbp-auth",
"aud": ["tbp-internal"],
"sub": "tbp-auth",
"typ": "service"
}
},
"legacyTokens": {
"login": {
"sub": "user@example.com",
"userId": "42",
"role": "USER",
"steamId": 76561198000000001
},
"publicProfile": {
"sub": "public-profile",
"typ": "public-profile",
"profileUserId": "42",
"profileStats": ["OVERVIEW"],
"sections": ["SUMMARY"]
},
"service": {
"sub": "tbp-auth",
"typ": "service"
}
},
"internal": {
"markDeleted": {
"method": "POST",
"path": "/internal/accounts/deleted",
"request": {"userIds": [42]}
},
"replaceDeleted": {
"method": "PUT",
"path": "/internal/accounts/deleted",
"request": {"userIds": []}
},
"purge": {
"method": "POST",
"path": "/internal/accounts/purge",
"request": {"userIds": [42], "dryRun": false},
"response": {"dryRun": true, "users": [42], "rows": {"user_game": 3}, "refused": false}
},
"prune": {
"method": "POST",
"path": "/internal/accounts/prune",
"request": {"knownUserIds": [1, 42], "dryRun": true},
"response": {"dryRun": true, "users": [42], "rows": {"user_game": 3}, "refused": false}
}
}
}