From 1008034e829fa90b43ef5eefc0fa5bb279c20f52 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 1 Oct 2026 09:18:12 +0200 Subject: [PATCH] feat(admin): accept or deny registrations --- readme.md | 13 +++-- .../admin/api/AdminUserController.java | 20 +++++++- .../api/dto/request/DecisionRequest.java | 6 +++ .../api/dto/response/AdminUserResponse.java | 3 +- .../dto/response/PendingCountResponse.java | 4 ++ .../AdminActionRefusedException.java | 2 +- .../modules/admin/model/AdminUser.java | 4 +- .../modules/admin/model/TargetState.java | 2 +- .../admin/repo/AdminUserRepository.java | 44 ++++++++++++---- .../admin/service/AdminUserService.java | 43 +++++++++++++--- .../login/api/LoginExceptionController.java | 6 +++ .../exception/AccountPendingException.java | 7 +++ .../modules/login/model/User.java | 3 +- .../MinimalUserRepositoryImpl.java | 4 +- .../modules/login/service/LoginService.java | 11 ++-- .../modules/mail/model/MailTemplate.java | 18 +++++++ .../modules/mail/service/MailService.java | 12 +++++ .../shared/account/ActiveUser.java | 4 +- .../shared/mail/AccountMailer.java | 4 ++ .../db/migration/V22__Registration_Status.sql | 4 ++ .../account/api/DeletedAccountFlowTest.java | 17 +++++++ .../modules/admin/api/AdminFlowTest.java | 41 +++++++++++++++ .../admin/repo/AdminUserRepositoryTest.java | 47 ++++++++++++++--- .../admin/service/AdminUserServiceTest.java | 51 +++++++++++++++++-- .../login/api/LoginControllerTest.java | 12 +++++ .../modules/login/model/UserTest.java | 8 +++ .../MinimalUserRepositoryTest.java | 17 +++++++ .../login/service/LoginServiceTest.java | 23 +++++++++ .../modules/mail/service/MailServiceTest.java | 19 +++++++ 29 files changed, 405 insertions(+), 44 deletions(-) create mode 100644 src/main/java/auth_service/modules/admin/api/dto/request/DecisionRequest.java create mode 100644 src/main/java/auth_service/modules/admin/api/dto/response/PendingCountResponse.java create mode 100644 src/main/java/auth_service/modules/login/exception/AccountPendingException.java create mode 100644 src/main/resources/db/migration/V22__Registration_Status.sql diff --git a/readme.md b/readme.md index 7c2a795..9ac5e44 100644 --- a/readme.md +++ b/readme.md @@ -65,6 +65,11 @@ To Be Played is split into three repositories: (`users.enabled`; blocking ends every login and, within 30 s, the access token at Auth; Core accepts the token until it expires, max 15 min; the login then answers `403 ACCOUNT_BLOCKED`, but only after the right password), delete (the same flag and nightly cleanup as "Delete my account"). No action on the own account or on another admin. Every action and every "view as" is a row in `admin_audit`. + Accept or deny an account that waits (`users.status` PENDING; the login answers `403 ACCOUNT_PENDING` after the + right password): accept makes it ACTIVE and mails "your account is active"; deny makes it DENIED, mails the user + and sets `deleted_at`, so the login answers like a deleted account and the nightly cleanup removes it after 30 + days. A waiting account cannot be blocked or deleted. Pending and denied users are nowhere public + (`shared/account/ActiveUser`). - **Dashboard hints**: a PLATFORM_ADMIN writes a hint for all users (plain text in English, German optional, start and end time, type `DISMISS` or `ACCEPT`). The dashboard shows the running hints one at a time, newest first. "Do not show again" on a `DISMISS` hint or "OK" on an `ACCEPT` hint is a row in `hint_done`, so the hint never @@ -93,9 +98,11 @@ To Be Played is split into three repositories: | `POST` | `/api/users/steam-link` | access token, returns Steam's login URL and sets the binding cookie | | `DELETE` | `/api/users/steam-link` | access token, removes the Steam ID and sets a new access cookie | | `POST` | `/api/auth/view-as` (`{role}`, null = own role; new cookies; `403 ROLE_NOT_ALLOWED` above the own role) | refresh cookie | -| `GET` | `/api/admin/users?query=&page=&size=` (`{content, page, size, totalElements, totalPages}`) | PLATFORM_ADMIN | -| `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`) | PLATFORM_ADMIN | -| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`) | PLATFORM_ADMIN | +| `GET` | `/api/admin/users?query=&status=&page=&size=` (`{content, page, size, totalElements, totalPages}`; `status` PENDING, ACTIVE or DENIED, empty for all; each user has `status`) | PLATFORM_ADMIN | +| `GET` | `/api/admin/users/pending-count` (`{count}`: registrations that wait) | PLATFORM_ADMIN | +| `PUT` | `/api/admin/users/{id}/decision` (`{accepted}`; `204`; `409 NOT_PENDING`) | PLATFORM_ADMIN | +| `PUT` | `/api/admin/users/{id}/blocked` (`{blocked}`; `409 SELF_ACTION`, `TARGET_IS_ADMIN`, `ALREADY_DELETED`, `TARGET_IS_PENDING`) | PLATFORM_ADMIN | +| `DELETE` | `/api/admin/users/{id}` (`409 SELF_ACTION`, `TARGET_IS_ADMIN`, `TARGET_IS_PENDING`) | PLATFORM_ADMIN | | `GET` | `/api/hints` (`{results}`: running hints not closed for good, newest first) | access token | | `PUT` | `/api/hints/{id}/done` (`204`; `404 HINT_NOT_FOUND`) | access token | | `GET` | `/api/admin/hints` (`{results}` with `status` PLANNED/ACTIVE/ENDED by the server clock and `doneCount`, newest first) | PLATFORM_ADMIN | diff --git a/src/main/java/auth_service/modules/admin/api/AdminUserController.java b/src/main/java/auth_service/modules/admin/api/AdminUserController.java index 22da7f9..ddab170 100644 --- a/src/main/java/auth_service/modules/admin/api/AdminUserController.java +++ b/src/main/java/auth_service/modules/admin/api/AdminUserController.java @@ -1,8 +1,10 @@ package auth_service.modules.admin.api; import auth_service.modules.admin.api.dto.request.BlockRequest; +import auth_service.modules.admin.api.dto.request.DecisionRequest; import auth_service.modules.admin.api.dto.response.AdminUserPageResponse; import auth_service.modules.admin.api.dto.response.AdminUserResponse; +import auth_service.modules.admin.api.dto.response.PendingCountResponse; import auth_service.modules.admin.model.AdminUser; import auth_service.modules.admin.model.AdminUserPage; import auth_service.modules.admin.service.AdminUserService; @@ -29,13 +31,26 @@ public class AdminUserController { @GetMapping public ResponseEntity findUsers(@RequestParam(defaultValue = "") String query, + @RequestParam(defaultValue = "") String status, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { - AdminUserPage found = adminUserService.findUsers(query, page, size); + AdminUserPage found = adminUserService.findUsers(query, status, page, size); return ResponseEntity.ok(new AdminUserPageResponse(found.content().stream().map(AdminUserController::toApi).toList(), found.page(), found.size(), found.totalElements(), found.totalPages())); } + @GetMapping("/pending-count") + public ResponseEntity countPending() { + return ResponseEntity.ok(new PendingCountResponse(adminUserService.countPending())); + } + + @PutMapping(value = "/{userId}/decision") + public ResponseEntity decide(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId, + @Valid @RequestBody DecisionRequest request) { + adminUserService.decide(principal.getIdAsLong(), userId, request.accepted()); + return ResponseEntity.noContent().build(); + } + @PutMapping(value = "/{userId}/blocked") public ResponseEntity setBlocked(@AuthenticationPrincipal UserPrincipal principal, @PathVariable long userId, @Valid @RequestBody BlockRequest request) { @@ -51,6 +66,7 @@ public class AdminUserController { private static AdminUserResponse toApi(AdminUser user) { return new AdminUserResponse(user.id(), user.email(), user.displayName(), user.role().name(), - user.registrationDate(), user.lastLoginDate(), user.steamLinked(), user.blocked(), user.deletedAt()); + user.registrationDate(), user.lastLoginDate(), user.steamLinked(), user.blocked(), user.deletedAt(), + user.status()); } } diff --git a/src/main/java/auth_service/modules/admin/api/dto/request/DecisionRequest.java b/src/main/java/auth_service/modules/admin/api/dto/request/DecisionRequest.java new file mode 100644 index 0000000..598a901 --- /dev/null +++ b/src/main/java/auth_service/modules/admin/api/dto/request/DecisionRequest.java @@ -0,0 +1,6 @@ +package auth_service.modules.admin.api.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record DecisionRequest(@NotNull Boolean accepted) { +} diff --git a/src/main/java/auth_service/modules/admin/api/dto/response/AdminUserResponse.java b/src/main/java/auth_service/modules/admin/api/dto/response/AdminUserResponse.java index ece77f0..a207719 100644 --- a/src/main/java/auth_service/modules/admin/api/dto/response/AdminUserResponse.java +++ b/src/main/java/auth_service/modules/admin/api/dto/response/AdminUserResponse.java @@ -4,5 +4,6 @@ import java.time.Instant; import java.time.LocalDateTime; public record AdminUserResponse(long id, String email, String displayName, String role, LocalDateTime registrationDate, - LocalDateTime lastLoginDate, boolean steamLinked, boolean blocked, Instant deletedAt) { + LocalDateTime lastLoginDate, boolean steamLinked, boolean blocked, Instant deletedAt, + String status) { } diff --git a/src/main/java/auth_service/modules/admin/api/dto/response/PendingCountResponse.java b/src/main/java/auth_service/modules/admin/api/dto/response/PendingCountResponse.java new file mode 100644 index 0000000..680094a --- /dev/null +++ b/src/main/java/auth_service/modules/admin/api/dto/response/PendingCountResponse.java @@ -0,0 +1,4 @@ +package auth_service.modules.admin.api.dto.response; + +public record PendingCountResponse(long count) { +} diff --git a/src/main/java/auth_service/modules/admin/exception/AdminActionRefusedException.java b/src/main/java/auth_service/modules/admin/exception/AdminActionRefusedException.java index 6edb44a..9275139 100644 --- a/src/main/java/auth_service/modules/admin/exception/AdminActionRefusedException.java +++ b/src/main/java/auth_service/modules/admin/exception/AdminActionRefusedException.java @@ -5,7 +5,7 @@ import lombok.Getter; @Getter public class AdminActionRefusedException extends RuntimeException { /** - * SELF_ACTION, TARGET_IS_ADMIN or ALREADY_DELETED + * SELF_ACTION, TARGET_IS_ADMIN, ALREADY_DELETED, NOT_PENDING or TARGET_IS_PENDING */ private final String code; diff --git a/src/main/java/auth_service/modules/admin/model/AdminUser.java b/src/main/java/auth_service/modules/admin/model/AdminUser.java index 843a113..7d44f74 100644 --- a/src/main/java/auth_service/modules/admin/model/AdminUser.java +++ b/src/main/java/auth_service/modules/admin/model/AdminUser.java @@ -6,8 +6,10 @@ import java.time.Instant; import java.time.LocalDateTime; /** + * @param status PENDING (registration waits for an admin), ACTIVE or DENIED * @param deletedAt set while the account waits for the nightly cleanup */ public record AdminUser(long id, String email, String displayName, Role role, LocalDateTime registrationDate, - LocalDateTime lastLoginDate, boolean steamLinked, boolean blocked, Instant deletedAt) { + LocalDateTime lastLoginDate, boolean steamLinked, boolean blocked, Instant deletedAt, + String status) { } diff --git a/src/main/java/auth_service/modules/admin/model/TargetState.java b/src/main/java/auth_service/modules/admin/model/TargetState.java index c23fcfd..bc8860a 100644 --- a/src/main/java/auth_service/modules/admin/model/TargetState.java +++ b/src/main/java/auth_service/modules/admin/model/TargetState.java @@ -2,5 +2,5 @@ package auth_service.modules.admin.model; import auth_service.shared.security.Role; -public record TargetState(Role role, boolean deleted) { +public record TargetState(Role role, boolean deleted, boolean pending) { } diff --git a/src/main/java/auth_service/modules/admin/repo/AdminUserRepository.java b/src/main/java/auth_service/modules/admin/repo/AdminUserRepository.java index 538a434..25902d2 100644 --- a/src/main/java/auth_service/modules/admin/repo/AdminUserRepository.java +++ b/src/main/java/auth_service/modules/admin/repo/AdminUserRepository.java @@ -22,6 +22,7 @@ public class AdminUserRepository { // An empty query matches every user; % and _ in the query are searched as text. private static final String MATCHES = "(? = '' OR email ILIKE ? ESCAPE '\\' OR display_name ILIKE ? ESCAPE '\\')"; + private static final String HAS_STATUS = "(? = '' OR status = ?)"; private static final RowMapper ROW = (rs, row) -> new AdminUser( rs.getLong("id"), @@ -32,7 +33,8 @@ public class AdminUserRepository { localDateTime(rs.getTimestamp("last_login_date")), rs.getObject("steam_id") != null, !rs.getBoolean("enabled"), - rs.getTimestamp("deleted_at") != null ? rs.getTimestamp("deleted_at").toInstant() : null); + rs.getTimestamp("deleted_at") != null ? rs.getTimestamp("deleted_at").toInstant() : null, + rs.getString("status")); private final JdbcTemplate jdbc; @@ -50,27 +52,49 @@ public class AdminUserRepository { } /** - * @param query part of the email or display name, any case; empty for all users + * @param query part of the email or display name, any case; empty for all users + * @param status PENDING, ACTIVE or DENIED; empty for all */ - public List findPage(String query, int page, int size) { + public List findPage(String query, String status, int page, int size) { Object[] match = matchArgs(query); return jdbc.query(""" - SELECT id, email, display_name, role, registration_date, last_login_date, steam_id, enabled, deleted_at - FROM users WHERE %s ORDER BY id LIMIT ? OFFSET ?""".formatted(MATCHES), ROW, - match[0], match[1], match[2], size, (long) page * size); + SELECT id, email, display_name, role, registration_date, last_login_date, steam_id, enabled, deleted_at, + status + FROM users WHERE %s AND %s ORDER BY id LIMIT ? OFFSET ?""".formatted(MATCHES, HAS_STATUS), ROW, + match[0], match[1], match[2], status, status, size, (long) page * size); } - public long count(String query) { - Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE " + MATCHES, Long.class, matchArgs(query)); + public long count(String query, String status) { + Object[] match = matchArgs(query); + Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE " + MATCHES + " AND " + HAS_STATUS, Long.class, + match[0], match[1], match[2], status, status); return count != null ? count : 0L; } public Optional findState(long userId) { - return jdbc.query("SELECT role, deleted_at FROM users WHERE id = ?", - (rs, row) -> new TargetState(Role.parse(rs.getString("role")), rs.getTimestamp("deleted_at") != null), + return jdbc.query("SELECT role, deleted_at, status FROM users WHERE id = ?", + (rs, row) -> new TargetState(Role.parse(rs.getString("role")), rs.getTimestamp("deleted_at") != null, + "PENDING".equals(rs.getString("status"))), userId).stream().findFirst(); } + /** + * @return false when the user is not pending (any more) + */ + public boolean accept(long userId) { + return jdbc.update("UPDATE users SET status = 'ACTIVE' WHERE id = ? AND status = 'PENDING'", userId) > 0; + } + + /** + * deleted_at hands the request to the nightly cleanup, like a deleted account. + * + * @return false when the user is not pending (any more) + */ + public boolean deny(long userId, Instant now) { + return jdbc.update("UPDATE users SET status = 'DENIED', deleted_at = ? WHERE id = ? AND status = 'PENDING'", + Timestamp.from(now), userId) > 0; + } + /** * Blocking also ends every login of the user. * diff --git a/src/main/java/auth_service/modules/admin/service/AdminUserService.java b/src/main/java/auth_service/modules/admin/service/AdminUserService.java index 4da382b..11cf51f 100644 --- a/src/main/java/auth_service/modules/admin/service/AdminUserService.java +++ b/src/main/java/auth_service/modules/admin/service/AdminUserService.java @@ -34,20 +34,44 @@ public class AdminUserService { private final AccountStatePublisher accountStatePublisher; private final AccountMailer accountMailer; - public AdminUserPage findUsers(String query, int page, int size) { + public AdminUserPage findUsers(String query, String status, int page, int size) { String search = query == null ? "" : query.trim(); + String withStatus = status == null ? "" : status; int pageSize = Math.clamp(size, 1, MAX_PAGE_SIZE); int pageNumber = Math.max(page, 0); - long total = adminUserRepository.count(search); - return new AdminUserPage(adminUserRepository.findPage(search, pageNumber, pageSize), pageNumber, pageSize, total, - (int) Math.ceil((double) total / pageSize)); + long total = adminUserRepository.count(search, withStatus); + return new AdminUserPage(adminUserRepository.findPage(search, withStatus, pageNumber, pageSize), pageNumber, + pageSize, total, (int) Math.ceil((double) total / pageSize)); + } + + public long countPending() { + return adminUserRepository.count("", "PENDING"); + } + + @Transactional + @Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#targetUserId"), + @CacheEvict(value = "publicStats", allEntries = true)}) + public void decide(long actorUserId, long targetUserId, boolean accepted) { + checkedTarget(actorUserId, targetUserId); + boolean changed = accepted ? adminUserRepository.accept(targetUserId) + : adminUserRepository.deny(targetUserId, Instant.now()); + if (!changed) { + throw new AdminActionRefusedException("NOT_PENDING", "The registration is already decided"); + } + adminAuditLog.record(actorUserId, accepted ? "ACCEPT" : "DENY", targetUserId, null); + if (accepted) { + accountMailer.accountAccepted(targetUserId); + } else { + accountMailer.registrationDenied(targetUserId); + } + log.info("Admin {} {} the registration of user {}", actorUserId, accepted ? "accepted" : "denied", targetUserId); } @Transactional @Caching(evict = {@CacheEvict(value = {"users", "activeUsers"}, key = "#targetUserId"), @CacheEvict(value = "publicStats", allEntries = true)}) public void setBlocked(long actorUserId, long targetUserId, boolean blocked) { - TargetState target = checkedTarget(actorUserId, targetUserId); + TargetState target = notPending(checkedTarget(actorUserId, targetUserId)); if (target.deleted()) { throw new AdminActionRefusedException("ALREADY_DELETED", "The account is already deleted"); } @@ -66,7 +90,7 @@ public class AdminUserService { * transaction: the flag commits before the port calls Core, so Core never hides a user Auth did not flag. */ public void delete(long actorUserId, long targetUserId) { - checkedTarget(actorUserId, targetUserId); + notPending(checkedTarget(actorUserId, targetUserId)); if (accountDeleter.deleteByAdmin(targetUserId)) { adminAuditLog.record(actorUserId, "DELETE", targetUserId, null); log.info("Admin {} deleted user {}", actorUserId, targetUserId); @@ -84,4 +108,11 @@ public class AdminUserService { } return target; } + + private static TargetState notPending(TargetState target) { + if (target.pending()) { + throw new AdminActionRefusedException("TARGET_IS_PENDING", "Accept or deny the registration first"); + } + return target; + } } diff --git a/src/main/java/auth_service/modules/login/api/LoginExceptionController.java b/src/main/java/auth_service/modules/login/api/LoginExceptionController.java index 2d5106e..285c3ac 100644 --- a/src/main/java/auth_service/modules/login/api/LoginExceptionController.java +++ b/src/main/java/auth_service/modules/login/api/LoginExceptionController.java @@ -2,6 +2,7 @@ package auth_service.modules.login.api; import auth_service.shared.api.dto.response.ErrorResponse; import auth_service.modules.login.exception.AccountBlockedException; +import auth_service.modules.login.exception.AccountPendingException; import auth_service.modules.login.exception.InvalidCredentialsException; import auth_service.modules.login.exception.RoleNotAllowedException; import auth_service.modules.login.exception.TooManyLoginAttemptsException; @@ -45,6 +46,11 @@ public class LoginExceptionController { return response(new ErrorResponse("ACCOUNT_BLOCKED", ex.getMessage()), HttpStatus.FORBIDDEN); } + @ExceptionHandler(AccountPendingException.class) + public ResponseEntity clientError(AccountPendingException ex) { + return response(new ErrorResponse("ACCOUNT_PENDING", ex.getMessage()), HttpStatus.FORBIDDEN); + } + @ExceptionHandler(RoleNotAllowedException.class) public ResponseEntity clientError(RoleNotAllowedException ex) { return response(new ErrorResponse("ROLE_NOT_ALLOWED", ex.getMessage()), HttpStatus.FORBIDDEN); diff --git a/src/main/java/auth_service/modules/login/exception/AccountPendingException.java b/src/main/java/auth_service/modules/login/exception/AccountPendingException.java new file mode 100644 index 0000000..11385f8 --- /dev/null +++ b/src/main/java/auth_service/modules/login/exception/AccountPendingException.java @@ -0,0 +1,7 @@ +package auth_service.modules.login.exception; + +public class AccountPendingException extends RuntimeException { + public AccountPendingException() { + super("An admin has not accepted this account yet"); + } +} diff --git a/src/main/java/auth_service/modules/login/model/User.java b/src/main/java/auth_service/modules/login/model/User.java index 7db7579..fe9824b 100644 --- a/src/main/java/auth_service/modules/login/model/User.java +++ b/src/main/java/auth_service/modules/login/model/User.java @@ -15,10 +15,11 @@ public class User { private Long steamId; private boolean enabled; private boolean deleted; + private boolean pending; @Builder.Default private Role role = Role.USER; public boolean canLogin() { - return enabled && !deleted; + return enabled && !deleted && !pending; } } diff --git a/src/main/java/auth_service/modules/login/persistence/MinimalUserRepositoryImpl.java b/src/main/java/auth_service/modules/login/persistence/MinimalUserRepositoryImpl.java index 5a2fb77..53130ce 100644 --- a/src/main/java/auth_service/modules/login/persistence/MinimalUserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/login/persistence/MinimalUserRepositoryImpl.java @@ -17,7 +17,8 @@ import java.util.Optional; public class MinimalUserRepositoryImpl implements MinimalUserRepository { private static final String SELECT_USER = - "SELECT id, email, password, steam_id, enabled, deleted_at IS NOT NULL AS deleted, role FROM users "; + "SELECT id, email, password, steam_id, enabled, deleted_at IS NOT NULL OR status = 'DENIED' AS deleted, status = 'PENDING' AS pending, " + + "role FROM users "; private static final RowMapper USER_ROW = (rs, row) -> User.builder() .id(rs.getLong("id")) @@ -26,6 +27,7 @@ public class MinimalUserRepositoryImpl implements MinimalUserRepository { .steamId(rs.getObject("steam_id", Long.class)) .enabled(rs.getBoolean("enabled")) .deleted(rs.getBoolean("deleted")) + .pending(rs.getBoolean("pending")) .role(Role.parse(rs.getString("role"))) .build(); diff --git a/src/main/java/auth_service/modules/login/service/LoginService.java b/src/main/java/auth_service/modules/login/service/LoginService.java index 8e1cda0..7ef443e 100644 --- a/src/main/java/auth_service/modules/login/service/LoginService.java +++ b/src/main/java/auth_service/modules/login/service/LoginService.java @@ -3,6 +3,7 @@ package auth_service.modules.login.service; import auth_service.shared.security.Public; import auth_service.shared.security.TokenGenerator; import auth_service.modules.login.exception.AccountBlockedException; +import auth_service.modules.login.exception.AccountPendingException; import auth_service.modules.login.exception.InvalidCredentialsException; import auth_service.modules.login.model.Session; import auth_service.modules.login.model.User; @@ -44,7 +45,7 @@ public class LoginService { /** * Authenticates a user and starts a session. Unknown email, wrong password and deleted account all fail with the - * same InvalidCredentialsException; a blocked account says so, but only after the right password. + * same InvalidCredentialsException; a pending or blocked account says so, but only after the right password. * * @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. @@ -60,9 +61,13 @@ public class LoginService { if (foundUser.isEmpty() || !passwordMatches || foundUser.get().isDeleted()) { throw new InvalidCredentialsException("Invalid email or password"); } - // Only after the right password, so a wrong one never tells that the account exists and is blocked. + // Only after the right password, so a wrong one never tells that the account exists and is pending or blocked. + // The right password is no failed attempt; else the user gets 429 and stays locked after the accept or unblock. + if (foundUser.get().isPending()) { + loginAttemptService.recordSuccess(email, clientIp); + throw new AccountPendingException(); + } if (!foundUser.get().isEnabled()) { - // The right password is no failed attempt; else the user gets 429 and stays locked after an unblock. loginAttemptService.recordSuccess(email, clientIp); throw new AccountBlockedException(); } diff --git a/src/main/java/auth_service/modules/mail/model/MailTemplate.java b/src/main/java/auth_service/modules/mail/model/MailTemplate.java index 2ece7ce..cc7a9da 100644 --- a/src/main/java/auth_service/modules/mail/model/MailTemplate.java +++ b/src/main/java/auth_service/modules/mail/model/MailTemplate.java @@ -41,6 +41,24 @@ public enum MailTemplate { ToBePlayed """), + REGISTRATION_DENIED( + "Your ToBePlayed registration was denied", """ + Hello %s, + + an administrator has denied your registration, so no account was made. + Your data will be deleted in 30 days. + + ToBePlayed + """, + "Deine ToBePlayed-Registrierung wurde abgelehnt", """ + Hallo %s, + + ein Administrator hat deine Registrierung abgelehnt. Es gibt kein Konto für dich. + Deine Daten werden in 30 Tagen gelöscht. + + ToBePlayed + """), + ACCOUNT_BLOCKED( "Your ToBePlayed account is blocked", """ Hello %s, diff --git a/src/main/java/auth_service/modules/mail/service/MailService.java b/src/main/java/auth_service/modules/mail/service/MailService.java index 61934c4..4bf6b7b 100644 --- a/src/main/java/auth_service/modules/mail/service/MailService.java +++ b/src/main/java/auth_service/modules/mail/service/MailService.java @@ -42,6 +42,18 @@ public class MailService implements AccountMailer { }); } + @Override + public void accountAccepted(long userId) { + repository.findRecipient(userId).ifPresent(user -> + insertForUser(MailTemplate.ACCOUNT_ACTIVE, userId, user, user.greeting(), frontendUrl)); + } + + @Override + public void registrationDenied(long userId) { + repository.findRecipient(userId).ifPresent(user -> + insertForUser(MailTemplate.REGISTRATION_DENIED, userId, user, user.greeting())); + } + @Override public void accountBlocked(long userId) { repository.findRecipient(userId).ifPresent(user -> diff --git a/src/main/java/auth_service/shared/account/ActiveUser.java b/src/main/java/auth_service/shared/account/ActiveUser.java index 0b835a4..783ff9b 100644 --- a/src/main/java/auth_service/shared/account/ActiveUser.java +++ b/src/main/java/auth_service/shared/account/ActiveUser.java @@ -1,7 +1,7 @@ package auth_service.shared.account; /** - * The SQL rule for a user who may log in and is shown to others: not blocked and not deleted. + * The SQL rule for a user who may log in and is shown to others: not blocked, not deleted, accepted by an admin. */ public final class ActiveUser { @@ -9,6 +9,6 @@ public final class ActiveUser { } public static String of(String alias) { - return alias + ".enabled AND " + alias + ".deleted_at IS NULL"; + return alias + ".enabled AND " + alias + ".deleted_at IS NULL AND " + alias + ".status = 'ACTIVE'"; } } diff --git a/src/main/java/auth_service/shared/mail/AccountMailer.java b/src/main/java/auth_service/shared/mail/AccountMailer.java index 0eb571d..a574716 100644 --- a/src/main/java/auth_service/shared/mail/AccountMailer.java +++ b/src/main/java/auth_service/shared/mail/AccountMailer.java @@ -13,6 +13,10 @@ public interface AccountMailer { */ void userRegistered(long userId); + void accountAccepted(long userId); + + void registrationDenied(long userId); + void accountBlocked(long userId); /** diff --git a/src/main/resources/db/migration/V22__Registration_Status.sql b/src/main/resources/db/migration/V22__Registration_Status.sql new file mode 100644 index 0000000..95233b1 --- /dev/null +++ b/src/main/resources/db/migration/V22__Registration_Status.sql @@ -0,0 +1,4 @@ +-- PENDING until an admin accepts the registration. A denied one also gets deleted_at, so the nightly cleanup +-- removes it like a deleted account. Existing users are ACTIVE. +ALTER TABLE users ADD COLUMN status VARCHAR(8) NOT NULL DEFAULT 'ACTIVE' + CHECK (status IN ('PENDING', 'ACTIVE', 'DENIED')); diff --git a/src/test/java/auth_service/modules/account/api/DeletedAccountFlowTest.java b/src/test/java/auth_service/modules/account/api/DeletedAccountFlowTest.java index 69afbdd..5eef124 100644 --- a/src/test/java/auth_service/modules/account/api/DeletedAccountFlowTest.java +++ b/src/test/java/auth_service/modules/account/api/DeletedAccountFlowTest.java @@ -10,6 +10,7 @@ import jakarta.servlet.http.Cookie; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.CacheManager; import org.springframework.http.MediaType; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.mock.web.MockHttpServletResponse; @@ -47,6 +48,9 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest { @Autowired private UserService userService; + @Autowired + private CacheManager cacheManager; + @Test void steamIdOfAFlaggedUser_CanBeLinkedByANewAccount() throws Exception { long steamId = 76561198099990001L; @@ -174,6 +178,19 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest { assertThat(totalUsers()).isEqualTo(usersBefore - 1); } + @Test + void pendingUser_IsNotCountedAndHasNoPublicProfile() throws Exception { + mockMvc.perform(get("/api/public/profiles/" + NAME)).andExpect(status().isOk()); + cacheManager.getCache("publicStats").clear(); + long before = totalUsers(); + + jdbc.update("UPDATE users SET status = 'PENDING' WHERE email = ?", EMAIL); + cacheManager.getCache("publicStats").clear(); + + assertThat(totalUsers()).isEqualTo(before - 1); + mockMvc.perform(get("/api/public/profiles/" + NAME)).andExpect(status().isNotFound()); + } + @Test void registration_CountsInThePublicStatsAtOnce() throws Exception { String newEmail = "delete-flow-registered@example.com"; diff --git a/src/test/java/auth_service/modules/admin/api/AdminFlowTest.java b/src/test/java/auth_service/modules/admin/api/AdminFlowTest.java index 9173aa5..5e3a3a1 100644 --- a/src/test/java/auth_service/modules/admin/api/AdminFlowTest.java +++ b/src/test/java/auth_service/modules/admin/api/AdminFlowTest.java @@ -106,6 +106,47 @@ class AdminFlowTest extends AbstractIntegrationTest { Long.class, action, target); } + private ResultActions decide(Session session, long userId, boolean accepted) throws Exception { + return mockMvc.perform(put("/api/admin/users/" + userId + "/decision").header(ClientHeaderFilter.CLIENT_HEADER, "web") + .cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access())) + .contentType(MediaType.APPLICATION_JSON).content("{\"accepted\":" + accepted + "}")); + } + + @Test + void pendingRequest_WaitsForTheAcceptAndThenLogsIn() throws Exception { + jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", playerId); + Session admin = Session.of(login(ADMIN)); + + assertThat(login(PLAYER).getStatus()).isEqualTo(403); + assertThat(login(PLAYER).getContentAsString()).contains("ACCOUNT_PENDING"); + mockMvc.perform(get("/api/admin/users").param("status", "PENDING") + .cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access()))) + .andExpect(jsonPath("$.content[*].email").value(org.hamcrest.Matchers.hasItem(PLAYER))) + .andExpect(jsonPath("$.content[?(@.email == '" + PLAYER + "')].status").value("PENDING")); + mockMvc.perform(get("/api/admin/users/pending-count").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access()))) + .andExpect(status().isOk()).andExpect(jsonPath("$.count").isNumber()); + block(admin, playerId, true).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("TARGET_IS_PENDING")); + + decide(admin, playerId, true).andExpect(status().isNoContent()); + + assertThat(login(PLAYER).getStatus()).isEqualTo(200); + assertThat(audits("ACCEPT", playerId)).isEqualTo(1); + decide(admin, playerId, true).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NOT_PENDING")); + } + + @Test + void deniedRequest_CannotLogInAndWaitsForTheCleanup() throws Exception { + jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", playerId); + + decide(Session.of(login(ADMIN)), playerId, false).andExpect(status().isNoContent()); + + assertThat(login(PLAYER).getStatus()).isEqualTo(401); + assertThat(jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, playerId)).isEqualTo("DENIED"); + assertThat(jdbc.queryForObject("SELECT deleted_at IS NOT NULL FROM users WHERE id = ?", Boolean.class, playerId)) + .isTrue(); + assertThat(audits("DENY", playerId)).isEqualTo(1); + } + @Test void admin_ListsUsersWithSearchAndPaging() throws Exception { Session admin = Session.of(login(ADMIN)); diff --git a/src/test/java/auth_service/modules/admin/repo/AdminUserRepositoryTest.java b/src/test/java/auth_service/modules/admin/repo/AdminUserRepositoryTest.java index 5648331..3b402db 100644 --- a/src/test/java/auth_service/modules/admin/repo/AdminUserRepositoryTest.java +++ b/src/test/java/auth_service/modules/admin/repo/AdminUserRepositoryTest.java @@ -11,6 +11,7 @@ import org.springframework.jdbc.core.JdbcTemplate; import java.time.Instant; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; class AdminUserRepositoryTest extends AbstractPersistenceTest { @@ -37,29 +38,29 @@ class AdminUserRepositoryTest extends AbstractPersistenceTest { @Test void findPage_SearchesEmailAndNameInAnyCase() { - assertThat(repository.findPage("EXAMPLE", 0, 10)).extracting(AdminUser::email) + assertThat(repository.findPage("EXAMPLE", "", 0, 10)).extracting(AdminUser::email) .containsExactly("alice@example.com", "bob@example.com"); - assertThat(repository.findPage("bob", 0, 10)).extracting(AdminUser::email).containsExactly("bob@example.com"); - assertThat(repository.count("")).isEqualTo(3); + assertThat(repository.findPage("bob", "", 0, 10)).extracting(AdminUser::email).containsExactly("bob@example.com"); + assertThat(repository.count("", "")).isEqualTo(3); } @Test void findPage_TreatsLikeCharactersAsText() { - assertThat(repository.findPage("%", 0, 10)).extracting(AdminUser::email).containsExactly("carol@sample.org"); - assertThat(repository.findPage("e_1", 0, 10)).extracting(AdminUser::email).containsExactly("alice@example.com"); - assertThat(repository.count("_")).isEqualTo(1); + assertThat(repository.findPage("%", "", 0, 10)).extracting(AdminUser::email).containsExactly("carol@sample.org"); + assertThat(repository.findPage("e_1", "", 0, 10)).extracting(AdminUser::email).containsExactly("alice@example.com"); + assertThat(repository.count("_", "")).isEqualTo(1); } @Test void findPage_Pages() { - assertThat(repository.findPage("", 1, 2)).extracting(AdminUser::email).containsExactly("carol@sample.org"); + assertThat(repository.findPage("", "", 1, 2)).extracting(AdminUser::email).containsExactly("carol@sample.org"); } @Test void findPage_ShowsRoleBlockAndDelete() { jdbc.update("UPDATE users SET role = 'AUDITOR', enabled = false, deleted_at = NOW(), steam_id = 1 WHERE id = ?", alice); - AdminUser found = repository.findPage("alice", 0, 10).getFirst(); + AdminUser found = repository.findPage("alice", "", 0, 10).getFirst(); assertThat(found.role()).isEqualTo(Role.AUDITOR); assertThat(found.blocked()).isTrue(); @@ -88,4 +89,34 @@ class AdminUserRepositoryTest extends AbstractPersistenceTest { assertThat(repository.setBlocked(alice, true, Instant.now())).isFalse(); assertThat(repository.setBlocked(alice, false, Instant.now())).isTrue(); } + + @Test + void findPage_FiltersByStatusAndShowsIt() { + jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", alice); + + assertThat(repository.findPage("", "PENDING", 0, 10)).extracting(AdminUser::email, AdminUser::status) + .containsExactly(tuple("alice@example.com", "PENDING")); + assertThat(repository.count("", "PENDING")).isEqualTo(1); + assertThat(repository.count("", "ACTIVE")).isEqualTo(2); + } + + @Test + void accept_ChangesOnlyAPendingRequest() { + jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", alice); + + assertThat(repository.findState(alice).orElseThrow().pending()).isTrue(); + assertThat(repository.accept(alice)).isTrue(); + assertThat(repository.accept(alice)).isFalse(); + assertThat(jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, alice)).isEqualTo("ACTIVE"); + } + + @Test + void deny_FlagsTheRequestForTheNightlyCleanup() { + jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", alice); + + assertThat(repository.deny(alice, Instant.now())).isTrue(); + assertThat(repository.deny(alice, Instant.now())).isFalse(); + assertThat(jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, alice)).isEqualTo("DENIED"); + assertThat(repository.findState(alice).orElseThrow().deleted()).isTrue(); + } } diff --git a/src/test/java/auth_service/modules/admin/service/AdminUserServiceTest.java b/src/test/java/auth_service/modules/admin/service/AdminUserServiceTest.java index f82adf0..ccdfbc7 100644 --- a/src/test/java/auth_service/modules/admin/service/AdminUserServiceTest.java +++ b/src/test/java/auth_service/modules/admin/service/AdminUserServiceTest.java @@ -56,7 +56,11 @@ class AdminUserServiceTest extends AbstractUnitTest { } private void target(Role role, boolean deleted) { - when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(role, deleted))); + when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(role, deleted, false))); + } + + private void pendingTarget() { + when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(Role.USER, false, true))); } private String refusal(Runnable action) { @@ -146,9 +150,48 @@ class AdminUserServiceTest extends AbstractUnitTest { @Test void findUsers_ClampsTheSizeAndCountsThePages() { - when(adminUserRepository.count("bob")).thenReturn(250L); + when(adminUserRepository.count("bob", "PENDING")).thenReturn(250L); - assertThat(service.findUsers(" bob ", -1, 500).totalPages()).isEqualTo(3); - verify(adminUserRepository).findPage("bob", 0, 100); + assertThat(service.findUsers(" bob ", "PENDING", -1, 500).totalPages()).isEqualTo(3); + verify(adminUserRepository).findPage("bob", "PENDING", 0, 100); + } + + @Test + void decide_AcceptActivatesAuditsAndMails() { + pendingTarget(); + when(adminUserRepository.accept(TARGET)).thenReturn(true); + + service.decide(ADMIN, TARGET, true); + + verify(adminAuditLog).record(ADMIN, "ACCEPT", TARGET, null); + verify(accountMailer).accountAccepted(TARGET); + } + + @Test + void decide_DenyFlagsAuditsAndMails() { + pendingTarget(); + when(adminUserRepository.deny(eq(TARGET), any())).thenReturn(true); + + service.decide(ADMIN, TARGET, false); + + verify(adminAuditLog).record(ADMIN, "DENY", TARGET, null); + verify(accountMailer).registrationDenied(TARGET); + } + + @Test + void decide_OnlyForAPendingRequest() { + target(Role.USER, false); + + assertThat(refusal(() -> service.decide(ADMIN, TARGET, true))).isEqualTo("NOT_PENDING"); + verifyNoInteractions(adminAuditLog, accountMailer); + } + + @Test + void blockAndDelete_RefuseAPendingRequest() { + pendingTarget(); + + assertThat(refusal(() -> service.setBlocked(ADMIN, TARGET, true))).isEqualTo("TARGET_IS_PENDING"); + assertThat(refusal(() -> service.delete(ADMIN, TARGET))).isEqualTo("TARGET_IS_PENDING"); + verifyNoInteractions(accountDeleter); } } diff --git a/src/test/java/auth_service/modules/login/api/LoginControllerTest.java b/src/test/java/auth_service/modules/login/api/LoginControllerTest.java index b9ab2d8..87d5e63 100644 --- a/src/test/java/auth_service/modules/login/api/LoginControllerTest.java +++ b/src/test/java/auth_service/modules/login/api/LoginControllerTest.java @@ -2,6 +2,7 @@ package auth_service.modules.login.api; import auth_service.configuration.security.JwtAuthenticationFilter; import auth_service.modules.login.api.dto.request.LoginRequest; +import auth_service.modules.login.exception.AccountPendingException; import auth_service.modules.login.exception.InvalidCredentialsException; import auth_service.modules.login.exception.TooManyLoginAttemptsException; import auth_service.modules.login.model.Session; @@ -119,6 +120,17 @@ class LoginControllerTest extends AbstractWebMvcTest { .andExpect(status().isOk()); } + @Test + void login_PendingRequest_Is403AccountPending() throws Exception { + when(loginService.login(any(User.class), any(), any())).thenThrow(new AccountPendingException()); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(loginRequestJson.write(new LoginRequest("test@example.com", "password")).getJson())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("ACCOUNT_PENDING")); + } + @Test void login_TooManyAttempts() throws Exception { // Arrange diff --git a/src/test/java/auth_service/modules/login/model/UserTest.java b/src/test/java/auth_service/modules/login/model/UserTest.java index 2f4a872..02bf883 100644 --- a/src/test/java/auth_service/modules/login/model/UserTest.java +++ b/src/test/java/auth_service/modules/login/model/UserTest.java @@ -8,6 +8,14 @@ import static org.assertj.core.api.Assertions.assertThat; class UserTest extends AbstractUnitTest { + @Test + void canLogin_OnlyWhenEnabledNotDeletedAndNotPending() { + assertThat(User.builder().enabled(true).build().canLogin()).isTrue(); + assertThat(User.builder().enabled(true).pending(true).build().canLogin()).isFalse(); + assertThat(User.builder().enabled(true).deleted(true).build().canLogin()).isFalse(); + assertThat(User.builder().enabled(false).build().canLogin()).isFalse(); + } + @Test void toString_ShowsNoPassword() { assertThat(User.builder().email("a@example.com").password("hash-secret").build().toString()) diff --git a/src/test/java/auth_service/modules/login/persistence/MinimalUserRepositoryTest.java b/src/test/java/auth_service/modules/login/persistence/MinimalUserRepositoryTest.java index 510218b..8e6dc4d 100644 --- a/src/test/java/auth_service/modules/login/persistence/MinimalUserRepositoryTest.java +++ b/src/test/java/auth_service/modules/login/persistence/MinimalUserRepositoryTest.java @@ -40,9 +40,26 @@ class MinimalUserRepositoryTest extends AbstractPersistenceTest { assertEquals(76561198000000001L, user.getSteamId()); assertTrue(user.isEnabled()); assertTrue(user.isDeleted()); + assertFalse(user.isPending()); assertEquals(Role.USER, user.getRole()); } + @Test + void findById_MapsAPendingRequest() { + long id = insertUser("minimal-pending@example.com"); + jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", id); + + assertTrue(minimalUserRepository.findById(id).orElseThrow().isPending()); + } + + @Test + void deniedUser_CannotLoginEvenWithoutDeletedAt() { + long id = insertUser("minimal-denied@example.com"); + jdbc.update("UPDATE users SET status = 'DENIED', deleted_at = NULL WHERE id = ?", id); + + assertFalse(minimalUserRepository.findById(id).orElseThrow().canLogin()); + } + @Test void findById_UnknownUser_IsEmpty() { assertFalse(minimalUserRepository.findById(-1).isPresent()); diff --git a/src/test/java/auth_service/modules/login/service/LoginServiceTest.java b/src/test/java/auth_service/modules/login/service/LoginServiceTest.java index c75756a..2270324 100644 --- a/src/test/java/auth_service/modules/login/service/LoginServiceTest.java +++ b/src/test/java/auth_service/modules/login/service/LoginServiceTest.java @@ -3,6 +3,7 @@ package auth_service.modules.login.service; import auth_service.shared.security.Role; import auth_service.shared.security.TokenGenerator; import auth_service.modules.login.exception.AccountBlockedException; +import auth_service.modules.login.exception.AccountPendingException; import auth_service.modules.login.exception.InvalidCredentialsException; import auth_service.modules.login.exception.TooManyLoginAttemptsException; import auth_service.modules.login.model.Session; @@ -179,6 +180,28 @@ class LoginServiceTest extends AbstractUnitTest { verify(loginAttemptService).recordSuccess("test@example.com", IP); } + @Test + void login_PendingUserWithTheRightPassword_IsToldItWaits() { + User dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true) + .pending(true).build(); + when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser)); + when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true); + + assertThrows(AccountPendingException.class, () -> loginService.login(user, IP, null)); + verify(tokenGenerator, never()).generateToken(any(), any(), any()); + verify(loginAttemptService).recordSuccess("test@example.com", IP); + } + + @Test + void login_PendingUserWithAWrongPassword_LooksLikeAnyWrongPassword() { + User dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true) + .pending(true).build(); + when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser)); + when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(false); + + assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null)); + } + @Test void login_BlockedUserWithAWrongPassword_LooksLikeAnyWrongPassword() { var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(false).build(); diff --git a/src/test/java/auth_service/modules/mail/service/MailServiceTest.java b/src/test/java/auth_service/modules/mail/service/MailServiceTest.java index 699ce8f..fb9fde9 100644 --- a/src/test/java/auth_service/modules/mail/service/MailServiceTest.java +++ b/src/test/java/auth_service/modules/mail/service/MailServiceTest.java @@ -93,6 +93,25 @@ class MailServiceTest extends AbstractUnitTest { assertThat(inserted(MailTemplate.ACCOUNT_DELETED, "alice@example.com").body()).contains("ab dem 05.11.2026"); } + @Test + void accountAccepted_TellsTheUserTheAccountIsActive() { + user("Alice", "en"); + + service(ADMIN).accountAccepted(USER); + + assertThat(inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com").body()) + .startsWith("Hello Alice,").contains("https://tbp.example"); + } + + @Test + void registrationDenied_TellsTheUserInTheirLanguage() { + user("Alice", "de"); + + service(ADMIN).registrationDenied(USER); + + assertThat(inserted(MailTemplate.REGISTRATION_DENIED, "alice@example.com").subject()).contains("abgelehnt"); + } + @Test void anUnknownUserGetsNoMail() { when(repository.findRecipient(USER)).thenReturn(Optional.empty());