Public Access
refactor(api): one error body, token audiences, user lookup by id
This commit is contained in:
1 parent
68919eaab4
commit
afcfacb68b
30 files changed
+662
-199
No files matched your search
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+79
-12
@@ -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")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(registrationRequestJson.write(request).getJson())));
|
||||
mockMvc.perform(post("/api/registration/new")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.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");
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user