From 39a4d5ae49fd23c2780368cc10ea39762450d92d Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 1 Oct 2026 09:18:12 +0200 Subject: [PATCH] feat(registration): registration waits for the admin, no invite code --- readme.md | 46 ++++++++++++---- .../modules/mail/model/MailTemplate.java | 34 ++++++++++-- .../modules/mail/service/MailService.java | 9 +-- .../api/RegistrationExceptionController.java | 9 ++- .../api/dto/mapper/ApiMapper.java | 1 - .../api/dto/request/RegistrationRequest.java | 1 - .../InvalidInvitationCodeException.java | 7 --- .../PendingLimitReachedException.java | 7 +++ .../registration/model/Registration.java | 1 - .../persistence/RegisteredUserRepository.java | 5 ++ .../RegisteredUserRepositoryImpl.java | 10 +++- .../service/CodeValidationService.java | 19 ------- .../service/RegistrationAttemptService.java | 15 ++++- .../service/RegistrationService.java | 23 +++++--- .../shared/mail/AccountMailer.java | 4 +- src/main/resources/application-local.yml | 4 -- src/main/resources/application.yml | 7 +-- .../account/api/DeletedAccountFlowTest.java | 14 +++-- .../modules/mail/model/MailTemplateTest.java | 2 +- .../modules/mail/service/MailFlowTest.java | 33 ++++++++--- .../modules/mail/service/MailServiceTest.java | 28 +++++----- .../api/RegistrationControllerTest.java | 55 +++++++++++-------- .../RegisteredUserRepositoryTest.java | 10 ++++ .../RegistrationAttemptServiceTest.java | 11 ++++ .../service/RegistrationServiceTest.java | 41 +++++++++----- .../shared/test/AbstractIntegrationTest.java | 12 +++- 26 files changed, 261 insertions(+), 147 deletions(-) delete mode 100644 src/main/java/auth_service/modules/registration/exception/InvalidInvitationCodeException.java create mode 100644 src/main/java/auth_service/modules/registration/exception/PendingLimitReachedException.java delete mode 100644 src/main/java/auth_service/modules/registration/service/CodeValidationService.java diff --git a/readme.md b/readme.md index 9ac5e44..75bdcff 100644 --- a/readme.md +++ b/readme.md @@ -13,10 +13,10 @@ To Be Played is split into three repositories: ## Features -- Registration with an invitation code, login with email and password (BCrypt). Emails are stored trimmed and in +- Registration by admin approval, login with email and password (BCrypt). Emails are stored trimmed and in lower case (V19, unique on `lower(email)`); a password has 8 characters and at most 72 bytes (BCrypt's limit). -- Brute-force protection in memory: 5 failed logins per email or 20 per IP, and 10 failed registrations per IP, - block for 15 minutes with `429 TOO_MANY_ATTEMPTS` and `Retry-After`. Unknown emails cost the same BCrypt time as a +- Brute-force protection in memory: 5 failed logins per email or 20 per IP, and 10 failed or 3 taken registrations + per IP, block for 15 minutes with `429 TOO_MANY_ATTEMPTS` and `Retry-After`. Unknown emails cost the same BCrypt time as a wrong password, and every failed login gets the same error. - JWT signed with **EdDSA (Ed25519)**. Auth holds the private key, Core only the public key, so Core can verify tokens but never create them. Tokens carry a `kid` header, and `GET /.well-known/jwks.json` publishes the @@ -74,9 +74,9 @@ To Be Played is split into three repositories: 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 comes back for this user on any device. Creating and ending a hint are rows in `admin_audit`. -- **Mails** from the platform mailbox: "new user registered" to the admin (`APP_MAIL_ADMIN_TO`), and to the user - "your account is active" at the registration, "your account is blocked" and "your account will be deleted from - ". The registration sends the browser language (`de` for a German browser, else `en`), which also becomes +- **Mails** from the platform mailbox: "a new registration waits" to the admin (`APP_MAIL_ADMIN_TO`, with the link + to the admin page), and to the user "we got your registration", then "your account is active" or "your + registration was denied", later "your account is blocked" and "your account will be deleted from ". The registration sends the browser language (`de` for a German browser, else `en`), which also becomes the user's app language. Plain text only. See "Mail flow". - **Delete my account** with the password (5 wrong tries per 15 minutes, then `429`). It only sets `users.deleted_at` and revokes every refresh token: from then on no login, no refresh, no access token, no public @@ -260,7 +260,7 @@ sequenceDiagram participant B as Browser (Frontend) participant A as Auth participant M as SMTP (Hostinger) - B->>A: register / admin blocks / delete account + B->>A: register / admin decides / admin blocks / delete account A->>A: event + mail_outbox row (PENDING), one transaction loop every minute (MailOutboxService) A->>M: up to 20 due rows @@ -269,6 +269,31 @@ sequenceDiagram Note over A: after 10 tries FAILED and a warning in the log; SENT rows are removed after 30 days ``` +### Registration flow + +A registration is a request: the account is PENDING until a PLATFORM_ADMIN decides. While 20 wait, the +registration answers `409 PENDING_LIMIT_REACHED` and stores nothing. Two requests at the same moment can make it 21. + +```mermaid +sequenceDiagram + participant U as New user + participant A as Auth + participant D as Admin + U->>A: POST /api/registration/new + A->>A: users.status = PENDING, two mail rows + A-->>D: mail "a new registration waits" + A-->>U: mail "we got your registration" + U->>A: login: 403 ACCOUNT_PENDING + D->>A: PUT /api/admin/users/{id}/decision {accepted} + alt accepted + A->>A: status = ACTIVE + A-->>U: mail "your account is active" + else denied + A->>A: status = DENIED, deleted_at = now (nightly cleanup after 30 days) + A-->>U: mail "your registration was denied" + end +``` + ### Steam ID and block sync to Core Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that @@ -307,8 +332,8 @@ other, then start them in this order (Core also starts without Auth and finds it In Auth, `local` makes a new signing key at each start (`LocalKeysEnvironmentPostProcessor`, only in this profile, and never together with `prod`), so no key is in the repo; after an Auth restart (also a devtools restart), log in again. Never use `local` on a server: the dev user's password is public. The -Flyway callback in `db/local` adds the dev user (`PLATFORM_ADMIN`, linked Steam ID). The invite code for more -users is `local`. What Core mocks and how to change the recorded answers: Core readme, "Run locally". +Flyway callback in `db/local` adds the dev user (`PLATFORM_ADMIN`, linked Steam ID). A new local user waits until +the dev user accepts it on the admin page. What Core mocks and how to change the recorded answers: Core readme, "Run locally". ### With your own keys (profile `dev`) @@ -332,14 +357,13 @@ Configuration comes from environment variables: |---|---| | `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY` | Ed25519 keypair, Base64 of the DER bytes (PKCS#8 / X.509) | | `JWT_KID` | Key id written into the token header | -| `AUTH_INVITATION_CODE` | Code required for registration | | `DB_PASSWORD` | Local PostgreSQL password | | `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) | | `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) | | `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) | | `SPRING_MAIL_HOST`, `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD` | SMTP server and its login (port 465, SSL). No host = mail off | | `APP_MAIL_FROM` | Sender address, the mailbox of the SMTP login. Needed with a host | -| `APP_MAIL_ADMIN_TO` | Gets "new user registered". Needed with a host | +| `APP_MAIL_ADMIN_TO` | Gets "a new registration waits". Needed with a host | Generate a keypair: 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 cc7a9da..d088d92 100644 --- a/src/main/java/auth_service/modules/mail/model/MailTemplate.java +++ b/src/main/java/auth_service/modules/mail/model/MailTemplate.java @@ -9,18 +9,42 @@ import java.util.Locale; */ public enum MailTemplate { - NEW_USER_REGISTERED( - "New registration on ToBePlayed", """ - A new user has registered. + REGISTRATION_REQUESTED( + "New registration on ToBePlayed waits", """ + A new registration waits for your decision. Name: %s Email: %s + + Accept or deny it here: + %s """, - "Neue Registrierung bei ToBePlayed", """ - Ein neuer Nutzer hat sich registriert. + "Neue Registrierung bei ToBePlayed wartet", """ + Eine neue Registrierung wartet auf deine Entscheidung. Name: %s E-Mail: %s + + Hier nimmst du sie an oder lehnst sie ab: + %s + """), + + REGISTRATION_RECEIVED( + "Your ToBePlayed registration has arrived", """ + Hello %s, + + thank you for your registration. An administrator checks each new account. + You get a mail as soon as it is decided. + + ToBePlayed + """, + "Deine ToBePlayed-Registrierung ist angekommen", """ + Hallo %s, + + danke für deine Registrierung. Ein Administrator prüft jedes neue Konto. + Du bekommst eine Mail, sobald entschieden ist. + + ToBePlayed """), ACCOUNT_ACTIVE( 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 4bf6b7b..3950af8 100644 --- a/src/main/java/auth_service/modules/mail/service/MailService.java +++ b/src/main/java/auth_service/modules/mail/service/MailService.java @@ -31,14 +31,15 @@ public class MailService implements AccountMailer { } @Override - public void userRegistered(long userId) { + public void registrationRequested(long userId) { repository.findRecipient(userId).ifPresent(user -> { // Blank only while mail is off: with a host, MailOutboxService stops the start without it. if (!adminTo.isBlank()) { - repository.insert(MailTemplate.NEW_USER_REGISTERED, userId, adminTo, MailTemplate.NEW_USER_REGISTERED - .render(ADMIN_LANGUAGE, user.displayName() == null ? "-" : user.displayName(), user.email())); + repository.insert(MailTemplate.REGISTRATION_REQUESTED, userId, adminTo, MailTemplate.REGISTRATION_REQUESTED + .render(ADMIN_LANGUAGE, user.displayName() == null ? "-" : user.displayName(), user.email(), + frontendUrl + "/admin/users")); } - insertForUser(MailTemplate.ACCOUNT_ACTIVE, userId, user, user.greeting(), frontendUrl); + insertForUser(MailTemplate.REGISTRATION_RECEIVED, userId, user, user.greeting()); }); } diff --git a/src/main/java/auth_service/modules/registration/api/RegistrationExceptionController.java b/src/main/java/auth_service/modules/registration/api/RegistrationExceptionController.java index 43b33d6..e158fcc 100644 --- a/src/main/java/auth_service/modules/registration/api/RegistrationExceptionController.java +++ b/src/main/java/auth_service/modules/registration/api/RegistrationExceptionController.java @@ -2,7 +2,7 @@ package auth_service.modules.registration.api; import auth_service.shared.api.dto.response.ErrorResponse; import auth_service.modules.registration.exception.EmailAlreadyInUseException; -import auth_service.modules.registration.exception.InvalidInvitationCodeException; +import auth_service.modules.registration.exception.PendingLimitReachedException; import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException; import lombok.NonNull; @@ -40,10 +40,9 @@ public class RegistrationExceptionController { return response(body, HttpStatus.CONFLICT); } - @ExceptionHandler(InvalidInvitationCodeException.class) - public ResponseEntity clientError(InvalidInvitationCodeException ex) { - var body = new ErrorResponse("INVALID_INVITATION_CODE", ex.getMessage()); - return response(body, HttpStatus.BAD_REQUEST); + @ExceptionHandler(PendingLimitReachedException.class) + public ResponseEntity clientError(PendingLimitReachedException ex) { + return response(new ErrorResponse("PENDING_LIMIT_REACHED", ex.getMessage()), HttpStatus.CONFLICT); } @ExceptionHandler(DisplayNameAlreadyRegisteredException.class) diff --git a/src/main/java/auth_service/modules/registration/api/dto/mapper/ApiMapper.java b/src/main/java/auth_service/modules/registration/api/dto/mapper/ApiMapper.java index 17052d1..bdc5704 100644 --- a/src/main/java/auth_service/modules/registration/api/dto/mapper/ApiMapper.java +++ b/src/main/java/auth_service/modules/registration/api/dto/mapper/ApiMapper.java @@ -11,7 +11,6 @@ public abstract class ApiMapper { return Registration.builder(). email(registrationRequest.email()). rawPassword(registrationRequest.password()). - inviteCode(registrationRequest.inviteCode()). displayName(registrationRequest.displayName()). language(registrationRequest.language()). build(); diff --git a/src/main/java/auth_service/modules/registration/api/dto/request/RegistrationRequest.java b/src/main/java/auth_service/modules/registration/api/dto/request/RegistrationRequest.java index 5b94226..5b8a3c2 100644 --- a/src/main/java/auth_service/modules/registration/api/dto/request/RegistrationRequest.java +++ b/src/main/java/auth_service/modules/registration/api/dto/request/RegistrationRequest.java @@ -18,7 +18,6 @@ import java.nio.charset.StandardCharsets; @Builder public record RegistrationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_LENGTH) String email, @NotBlank @Size(min = 8) String password, - @NotBlank String inviteCode, @Pattern(regexp = DisplayNameFormat.PATTERN) String displayName, // For the mails; null = never chosen. @Pattern(regexp = "de|en") String language) { diff --git a/src/main/java/auth_service/modules/registration/exception/InvalidInvitationCodeException.java b/src/main/java/auth_service/modules/registration/exception/InvalidInvitationCodeException.java deleted file mode 100644 index e5a492d..0000000 --- a/src/main/java/auth_service/modules/registration/exception/InvalidInvitationCodeException.java +++ /dev/null @@ -1,7 +0,0 @@ -package auth_service.modules.registration.exception; - -public class InvalidInvitationCodeException extends RuntimeException { - public InvalidInvitationCodeException(String message) { - super(message); - } -} diff --git a/src/main/java/auth_service/modules/registration/exception/PendingLimitReachedException.java b/src/main/java/auth_service/modules/registration/exception/PendingLimitReachedException.java new file mode 100644 index 0000000..8bf1d2c --- /dev/null +++ b/src/main/java/auth_service/modules/registration/exception/PendingLimitReachedException.java @@ -0,0 +1,7 @@ +package auth_service.modules.registration.exception; + +public class PendingLimitReachedException extends RuntimeException { + public PendingLimitReachedException() { + super("Too many registrations wait for the admin. Please try again later."); + } +} diff --git a/src/main/java/auth_service/modules/registration/model/Registration.java b/src/main/java/auth_service/modules/registration/model/Registration.java index 9d4a5e0..8fc26c5 100644 --- a/src/main/java/auth_service/modules/registration/model/Registration.java +++ b/src/main/java/auth_service/modules/registration/model/Registration.java @@ -12,7 +12,6 @@ public class Registration { private String rawPassword; @ToString.Exclude private String encryptedPassword; - private String inviteCode; private String displayName; private String language; } diff --git a/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepository.java b/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepository.java index 8df9e5a..29faa7c 100644 --- a/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepository.java +++ b/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepository.java @@ -7,5 +7,10 @@ public interface RegisteredUserRepository { boolean existsByDisplayName(String displayName); + /** + * Stores the user as PENDING: an admin accepts or denies the registration. + */ long save(Registration registration); + + long countPending(); } diff --git a/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java b/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java index 7f0365d..4fe6149 100644 --- a/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java @@ -32,8 +32,8 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository { @Transactional public long save(Registration registration) { Long id = jdbc.queryForObject(""" - INSERT INTO users (email, password, enabled, display_name, registration_date) - VALUES (?, ?, TRUE, ?, ?) RETURNING id""", Long.class, + INSERT INTO users (email, password, enabled, display_name, registration_date, status) + VALUES (?, ?, TRUE, ?, ?, 'PENDING') RETURNING id""", Long.class, registration.getEmail(), registration.getEncryptedPassword(), registration.getDisplayName(), LocalDateTime.now(Clock.systemUTC())); if (id == null) { @@ -44,4 +44,10 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository { registration.getLanguage()); return id; } + + @Override + public long countPending() { + Long count = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE status = 'PENDING'", Long.class); + return count != null ? count : 0L; + } } diff --git a/src/main/java/auth_service/modules/registration/service/CodeValidationService.java b/src/main/java/auth_service/modules/registration/service/CodeValidationService.java deleted file mode 100644 index 9b84cec..0000000 --- a/src/main/java/auth_service/modules/registration/service/CodeValidationService.java +++ /dev/null @@ -1,19 +0,0 @@ -package auth_service.modules.registration.service; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -@Service -public class CodeValidationService { - - @Value("${auth.registration.invitation-code}") - private String invitationCode; - - public boolean isCodeValid(String code) { - if (code == null || code.isEmpty()) { - return false; - } - - return code.equals(invitationCode); - } -} diff --git a/src/main/java/auth_service/modules/registration/service/RegistrationAttemptService.java b/src/main/java/auth_service/modules/registration/service/RegistrationAttemptService.java index b825dd5..806fe1c 100644 --- a/src/main/java/auth_service/modules/registration/service/RegistrationAttemptService.java +++ b/src/main/java/auth_service/modules/registration/service/RegistrationAttemptService.java @@ -7,16 +7,18 @@ import org.springframework.stereotype.Service; import java.time.Duration; /** - * Blocks a client IP after too many failed registrations. Every rejection counts, so guessing the - * invitation code and probing which emails have an account are both limited. + * Limits each client IP: 10 failed registrations (probing which emails have an account) and 3 taken requests (so one + * IP cannot fill the queue for the admin) within 15 minutes. */ @Service public class RegistrationAttemptService { static final int MAX_FAILURES_PER_IP = 10; + static final int MAX_REQUESTS_PER_IP = 3; static final Duration LOCK_DURATION = Duration.ofMinutes(15); private final AttemptLimiter limiter = new AttemptLimiter(LOCK_DURATION); + private final AttemptLimiter requests = new AttemptLimiter(LOCK_DURATION); public void acquire(String clientIp) { if (!limiter.tryAcquire(clientIp, MAX_FAILURES_PER_IP)) { @@ -27,4 +29,13 @@ public class RegistrationAttemptService { public void release(String clientIp) { limiter.release(clientIp); } + + /** + * A request that passed every check; it is never given back. + */ + public void countRequest(String clientIp) { + if (!requests.tryAcquire(clientIp, MAX_REQUESTS_PER_IP)) { + throw new TooManyRegistrationAttemptsException(LOCK_DURATION.toSeconds()); + } + } } diff --git a/src/main/java/auth_service/modules/registration/service/RegistrationService.java b/src/main/java/auth_service/modules/registration/service/RegistrationService.java index 0cd4fe4..ede4fc8 100644 --- a/src/main/java/auth_service/modules/registration/service/RegistrationService.java +++ b/src/main/java/auth_service/modules/registration/service/RegistrationService.java @@ -1,14 +1,13 @@ package auth_service.modules.registration.service; import auth_service.modules.registration.exception.EmailAlreadyInUseException; -import auth_service.modules.registration.exception.InvalidInvitationCodeException; import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; +import auth_service.modules.registration.exception.PendingLimitReachedException; import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.persistence.RegisteredUserRepository; import auth_service.shared.mail.AccountMailer; import auth_service.shared.security.Public; import lombok.RequiredArgsConstructor; -import org.springframework.cache.annotation.CacheEvict; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,33 +17,39 @@ import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor public class RegistrationService { - private final CodeValidationService codeValidationService; private final RegisteredUserRepository registeredUserRepository; private final PasswordEncoder passwordEncoder; private final RegistrationAttemptService registrationAttemptService; private final AccountMailer accountMailer; + static final int MAX_PENDING = 20; + + /** + * Stores a request that waits for the admin. When MAX_PENDING wait already, it is refused and counts as a failed + * attempt of the IP. + */ // The user and the mail rows commit together. @Transactional - @CacheEvict(value = "publicStats", allEntries = true) public long register(Registration registration, String clientIp) { registrationAttemptService.acquire(clientIp); + // ponytail: two requests at the same time can pass at 19 and make 21; a pg_advisory_xact_lock would make it + // exact, but the limit is a brake, not a quota. + if (registeredUserRepository.countPending() >= MAX_PENDING) { + throw new PendingLimitReachedException(); + } validate(registration); registrationAttemptService.release(clientIp); + registrationAttemptService.countRequest(clientIp); var rawPassword = registration.getRawPassword(); registration.setEncryptedPassword(passwordEncoder.encode(rawPassword)); long userId = registeredUserRepository.save(registration); - accountMailer.userRegistered(userId); + accountMailer.registrationRequested(userId); return userId; } private void validate(Registration registration) { - if (!codeValidationService.isCodeValid(registration.getInviteCode())) { - throw new InvalidInvitationCodeException("The provided invite code is invalid."); - } - if (registeredUserRepository.existsByEmail(registration.getEmail())) { throw new EmailAlreadyInUseException("The provided email address is already in use."); } diff --git a/src/main/java/auth_service/shared/mail/AccountMailer.java b/src/main/java/auth_service/shared/mail/AccountMailer.java index a574716..ec99143 100644 --- a/src/main/java/auth_service/shared/mail/AccountMailer.java +++ b/src/main/java/auth_service/shared/mail/AccountMailer.java @@ -9,9 +9,9 @@ import java.time.LocalDate; public interface AccountMailer { /** - * "New user registered" to the admin and "your account is active" to the user. + * "A new registration waits" to the admin and "we got your registration" to the user. */ - void userRegistered(long userId); + void registrationRequested(long userId); void accountAccepted(long userId); diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 70de0df..caa5c42 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -10,7 +10,3 @@ app: cookie-secure: false core: internal-url: "http://localhost:8090" - -auth: - registration: - invitation-code: local diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6d3bd9f..3813e96 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -80,11 +80,6 @@ app: mail: # The mailbox the SMTP login belongs to; Hostinger refuses any other sender. from: ${APP_MAIL_FROM:} - # Gets "new user registered". Only in the env file, never in git. + # Gets "a new registration waits". Only in the env file, never in git. admin-to: ${APP_MAIL_ADMIN_TO:} cron: "0 * * * * *" - -# Registration settings -auth: - registration: - invitation-code: ${AUTH_INVITATION_CODE} \ No newline at end of file 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 5eef124..7300e98 100644 --- a/src/test/java/auth_service/modules/account/api/DeletedAccountFlowTest.java +++ b/src/test/java/auth_service/modules/account/api/DeletedAccountFlowTest.java @@ -192,20 +192,22 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest { } @Test - void registration_CountsInThePublicStatsAtOnce() throws Exception { + void registration_WaitsAndDoesNotCountUntilTheAccept() throws Exception { String newEmail = "delete-flow-registered@example.com"; jdbc.update("DELETE FROM users WHERE email = ?", newEmail); long before = totalUsers(); try { mockMvc.perform(post("/api/registration/new") - .header(ClientHeaderFilter.CLIENT_HEADER, "web") + .header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp()) .contentType(MediaType.APPLICATION_JSON) .content("{\"email\":\"" + newEmail + "\",\"password\":\"" + PASSWORD - + "\",\"inviteCode\":\"test-invite-code\"}")) + + "\"}")) .andExpect(status().is2xxSuccessful()); - assertThat(totalUsers()).isEqualTo(before + 1); + assertThat(jdbc.queryForObject("SELECT status FROM users WHERE email = ?", String.class, newEmail)) + .isEqualTo("PENDING"); + assertThat(totalUsers()).isEqualTo(before); } finally { jdbc.update("DELETE FROM users WHERE email = ?", newEmail); } @@ -216,10 +218,10 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest { jdbc.update("UPDATE users SET deleted_at = NOW() WHERE email = ?", EMAIL); mockMvc.perform(post("/api/registration/new") - .header(ClientHeaderFilter.CLIENT_HEADER, "web") + .header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp()) .contentType(MediaType.APPLICATION_JSON) .content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD - + "\",\"inviteCode\":\"test-invite-code\"}")) + + "\"}")) .andExpect(jsonPath("$.code").value("EMAIL_ALREADY_IN_USE")); } } diff --git a/src/test/java/auth_service/modules/mail/model/MailTemplateTest.java b/src/test/java/auth_service/modules/mail/model/MailTemplateTest.java index 7f5c01d..34a69c0 100644 --- a/src/test/java/auth_service/modules/mail/model/MailTemplateTest.java +++ b/src/test/java/auth_service/modules/mail/model/MailTemplateTest.java @@ -17,7 +17,7 @@ class MailTemplateTest extends AbstractUnitTest { @EnumSource(MailTemplate.class) void render_FillsEveryPlaceholderInBothLanguages(MailTemplate template) { for (String language : new String[]{"de", "en"}) { - Mail mail = template.render(language, "Alice", "alice@example.com"); + Mail mail = template.render(language, "Alice", "alice@example.com", "https://tbp.example"); assertThat(mail.subject()).isNotBlank().doesNotContain("%"); assertThat(mail.body()).contains("Alice").doesNotContain("%s"); diff --git a/src/test/java/auth_service/modules/mail/service/MailFlowTest.java b/src/test/java/auth_service/modules/mail/service/MailFlowTest.java index 855e065..5aad1bc 100644 --- a/src/test/java/auth_service/modules/mail/service/MailFlowTest.java +++ b/src/test/java/auth_service/modules/mail/service/MailFlowTest.java @@ -71,10 +71,10 @@ class MailFlowTest extends AbstractIntegrationTest { } private void register() throws Exception { - mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web") + mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp()) .contentType(MediaType.APPLICATION_JSON) .content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD - + "\",\"inviteCode\":\"test-invite-code\",\"displayName\":\"MailFlow\",\"language\":\"de\"}")) + + "\",\"displayName\":\"MailFlow\",\"language\":\"de\"}")) .andExpect(status().isOk()); } @@ -87,9 +87,9 @@ class MailFlowTest extends AbstractIntegrationTest { register(); assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address") + " " + row.get("status")) - .containsExactly("NEW_USER_REGISTERED admin@test.local PENDING", - "ACCOUNT_ACTIVE " + NEW_USER + " PENDING"); - assertThat(rows().get(1).get("subject")).isEqualTo("Dein ToBePlayed-Konto ist aktiv"); + .containsExactly("REGISTRATION_REQUESTED admin@test.local PENDING", + "REGISTRATION_RECEIVED " + NEW_USER + " PENDING"); + assertThat(rows().get(1).get("subject")).isEqualTo("Deine ToBePlayed-Registrierung ist angekommen"); mailOutboxService.sendDue(); @@ -99,6 +99,25 @@ class MailFlowTest extends AbstractIntegrationTest { assertThat(rows()).extracting(row -> row.get("status")).containsOnly("SENT"); } + @Test + void acceptAndDeny_MailTheUser() throws Exception { + insertUser(ADMIN, "PLATFORM_ADMIN"); + long accepted = insertUser(PLAYER, "USER"); + long denied = insertUser(NEW_USER, "USER"); + jdbc.update("UPDATE users SET status = 'PENDING' WHERE id IN (?, ?)", accepted, denied); + String admin = accessToken(ADMIN); + + for (long userId : new long[]{accepted, denied}) { + mockMvc.perform(put("/api/admin/users/" + userId + "/decision").header(ClientHeaderFilter.CLIENT_HEADER, "web") + .cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin)) + .contentType(MediaType.APPLICATION_JSON).content("{\"accepted\":" + (userId == accepted) + "}")) + .andExpect(status().isNoContent()); + } + + assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address")) + .containsExactly("ACCOUNT_ACTIVE " + PLAYER, "REGISTRATION_DENIED " + NEW_USER); + } + @Test void aFailedSend_IsTriedAgainAndThenGivenUp() throws Exception { register(); @@ -121,10 +140,10 @@ class MailFlowTest extends AbstractIntegrationTest { void aFailedRegistration_WritesNoMail() throws Exception { insertUser(NEW_USER, "USER"); - mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web") + mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp()) .contentType(MediaType.APPLICATION_JSON) .content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD - + "\",\"inviteCode\":\"test-invite-code\"}")) + + "\"}")) .andExpect(status().isConflict()); assertThat(rows()).isEmpty(); 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 fb9fde9..0fdfd02 100644 --- a/src/test/java/auth_service/modules/mail/service/MailServiceTest.java +++ b/src/test/java/auth_service/modules/mail/service/MailServiceTest.java @@ -44,35 +44,35 @@ class MailServiceTest extends AbstractUnitTest { } @Test - void userRegistered_TellsTheAdminInGermanAndTheUserInTheirLanguage() { + void registrationRequested_TellsTheAdminInGermanWithTheLinkAndTheUserInTheirLanguage() { user("Alice", "en"); - service(ADMIN).userRegistered(USER); + service(ADMIN).registrationRequested(USER); - assertThat(inserted(MailTemplate.NEW_USER_REGISTERED, ADMIN).body()) - .startsWith("Ein neuer Nutzer").contains("Alice", "alice@example.com"); - assertThat(inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com").body()) - .startsWith("Hello Alice,").contains("https://tbp.example"); + assertThat(inserted(MailTemplate.REGISTRATION_REQUESTED, ADMIN).body()) + .startsWith("Eine neue Registrierung").contains("Alice", "alice@example.com", "https://tbp.example/admin/users"); + assertThat(inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com").body()).startsWith("Hello Alice,"); } @Test - void userRegistered_GreetsByEmailWithoutADisplayName() { + void registrationRequested_GreetsByEmailWithoutADisplayName() { user(null, "de"); - service(ADMIN).userRegistered(USER); + service(ADMIN).registrationRequested(USER); - assertThat(inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com").body()).startsWith("Hallo alice@example.com,"); - assertThat(inserted(MailTemplate.NEW_USER_REGISTERED, ADMIN).body()).contains("Name: -"); + assertThat(inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com").body()) + .startsWith("Hallo alice@example.com,"); + assertThat(inserted(MailTemplate.REGISTRATION_REQUESTED, ADMIN).body()).contains("Name: -"); } @Test - void userRegistered_WritesNoAdminRowWithoutAnAdminAddress() { + void registrationRequested_WritesNoAdminRowWithoutAnAdminAddress() { user("Alice", "en"); - service("").userRegistered(USER); + service("").registrationRequested(USER); - verify(repository, never()).insert(eq(MailTemplate.NEW_USER_REGISTERED), anyLong(), anyString(), any()); - inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com"); + verify(repository, never()).insert(eq(MailTemplate.REGISTRATION_REQUESTED), anyLong(), anyString(), any()); + inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com"); } @Test diff --git a/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java b/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java index 778e16e..f31d782 100644 --- a/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java +++ b/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java @@ -10,6 +10,7 @@ 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; +import auth_service.modules.registration.exception.PendingLimitReachedException; import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException; import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.service.RegistrationService; @@ -83,7 +84,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest { var request = RegistrationRequest.builder() .email("test@example.com") .password("SuperSecret123!") - .inviteCode("test-invite-code") .build(); long expectedUserId = 42L; @@ -105,7 +105,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest { var request = RegistrationRequest.builder() .email("test@example.com") .password("SuperSecret123!") - .inviteCode("test-invite-code") .build(); when(registrationService.register(any(Registration.class), any())) @@ -119,7 +118,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest { } @Test - void register_OldClientSendingASteamId_IsAcceptedAndTheIdIgnored() throws Exception { + void register_OldClientSendingASteamIdAndAnInviteCode_IsAcceptedAndBothIgnored() throws Exception { when(registrationService.register(any(Registration.class), any())).thenReturn(42L); mockMvc.perform(post("/api/registration/new") @@ -134,7 +133,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest { var request = RegistrationRequest.builder() .email("test@example.com") .password("SuperSecret123!") - .inviteCode("code") .build(); when(registrationService.register(any(Registration.class), any())) .thenThrow(duplicate("users_email_lower")); @@ -154,7 +152,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest { mockMvc.perform(post("/api/registration/new") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","displayName":"Lobo"}""")) + {"email":"test@example.com","password":"SuperSecret123!","displayName":"Lobo"}""")) .andExpect(status().isConflict()) .andExpect(jsonPath("$.code").value("DISPLAY_NAME_ALREADY_IN_USE")); } @@ -166,7 +164,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest { mockMvc.perform(post("/api/registration/new") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","language":"de"}""")) + {"email":"test@example.com","password":"SuperSecret123!","language":"de"}""")) .andExpect(status().isOk()); ArgumentCaptor registration = ArgumentCaptor.forClass(Registration.class); @@ -179,7 +177,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest { mockMvc.perform(post("/api/registration/new") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","language":"fr"}""")) + {"email":"test@example.com","password":"SuperSecret123!","language":"fr"}""")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); } @@ -198,7 +196,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest { mockMvc.perform(post("/api/registration/new") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}""")) + {"email":"test@example.com","password":"SuperSecret123!"}""")) .andExpect(status().isInternalServerError()) .andExpect(jsonPath("$.code").value("INTERNAL_ERROR")) .andExpect(content().string(not(containsString("secret detail")))); @@ -209,7 +207,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest { mockMvc.perform(post("/api/registration/new") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","displayName":"x"}""")) + {"email":"test@example.com","password":"SuperSecret123!","displayName":"x"}""")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")) .andExpect(jsonPath("$.fields[0].field").value("displayName")); @@ -226,7 +224,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest { mockMvc.perform(post("/api/registration/new") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","displayName":"Lobo"}""")) + {"email":"test@example.com","password":"SuperSecret123!","displayName":"Lobo"}""")) .andExpect(status().isOk()); // Assert @@ -245,7 +243,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest { .header("X-Forwarded-For", "10.0.0.1, 203.0.113.7") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}""")) + {"email":"test@example.com","password":"SuperSecret123!"}""")) .andExpect(status().isOk()) .andExpect(jsonPath("$.userId").value(42L)); } @@ -260,22 +258,33 @@ class RegistrationControllerTest extends AbstractWebMvcTest { mockMvc.perform(post("/api/registration/new") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}""")) + {"email":"test@example.com","password":"SuperSecret123!"}""")) .andExpect(status().isTooManyRequests()) .andExpect(header().string("Retry-After", "900")) .andExpect(jsonPath("$.code").value("TOO_MANY_ATTEMPTS")); } + @Test + void register_FullQueue_Is409PendingLimitReached() throws Exception { + when(registrationService.register(any(Registration.class), any())).thenThrow(new PendingLimitReachedException()); + + mockMvc.perform(post("/api/registration/new") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"test@example.com","password":"SuperSecret123!"}""")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("PENDING_LIMIT_REACHED")); + } + @ParameterizedTest @CsvSource(delimiter = '|', value = { - " |SuperSecret123!|code", - "no-at-sign|SuperSecret123!|code", - "test@example.com| |code", - "test@example.com|short|code", - "test@example.com|SuperSecret123!| ", + " |SuperSecret123!", + "no-at-sign|SuperSecret123!", + "test@example.com| ", + "test@example.com|short", }) - void register_InvalidInput_Is400WithoutRegistering(String email, String password, String inviteCode) throws Exception { - var request = RegistrationRequest.builder().email(email).password(password).inviteCode(inviteCode).build(); + void register_InvalidInput_Is400WithoutRegistering(String email, String password) throws Exception { + var request = RegistrationRequest.builder().email(email).password(password).build(); mockMvc.perform(post("/api/registration/new") .contentType(MediaType.APPLICATION_JSON) @@ -288,8 +297,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest { // BCrypt uses only 72 bytes: a longer password would mostly be ignored, or refused with a 500. @Test void register_PasswordOver72Bytes_Is400() throws Exception { - var request = RegistrationRequest.builder().email("test@example.com").password("é".repeat(37)) - .inviteCode("code").build(); + var request = RegistrationRequest.builder().email("test@example.com").password("é".repeat(37)).build(); mockMvc.perform(post("/api/registration/new") .contentType(MediaType.APPLICATION_JSON) @@ -301,8 +309,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest { @Test void register_Email_IsTrimmedAndLowerCase() throws Exception { - var request = RegistrationRequest.builder().email(" Bob@Example.COM ").password("SuperSecret123!") - .inviteCode("code").build(); + var request = RegistrationRequest.builder().email(" Bob@Example.COM ").password("SuperSecret123!").build(); when(registrationService.register(any(Registration.class), any())).thenReturn(1L); mockMvc.perform(post("/api/registration/new") @@ -324,7 +331,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest { mockMvc.perform(post("/api/registration/new") .contentType(MediaType.APPLICATION_JSON) .content(""" - {"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code"}""")) + {"email":"test@example.com","password":"SuperSecret123!"}""")) .andExpect(status().isInternalServerError()) .andExpect(jsonPath("$.code").value("INTERNAL_ERROR")); diff --git a/src/test/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryTest.java b/src/test/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryTest.java index aa95917..4047e61 100644 --- a/src/test/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryTest.java +++ b/src/test/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryTest.java @@ -64,6 +64,16 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest { assertEquals(false, settings.get("public_profile")); } + @Test + void save_StoresAPendingRequestAndCountsIt() { + long before = registeredUserRepository.countPending(); + + long id = registeredUserRepository.save(registration("pending@example.com", null)); + + assertEquals("PENDING", jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, id)); + assertEquals(before + 1, registeredUserRepository.countPending()); + } + @Test void save_StoresTheLanguageOfTheRegistrationPage() { Registration registration = registration("lang@example.com", null); diff --git a/src/test/java/auth_service/modules/registration/service/RegistrationAttemptServiceTest.java b/src/test/java/auth_service/modules/registration/service/RegistrationAttemptServiceTest.java index 42908f9..72e81d6 100644 --- a/src/test/java/auth_service/modules/registration/service/RegistrationAttemptServiceTest.java +++ b/src/test/java/auth_service/modules/registration/service/RegistrationAttemptServiceTest.java @@ -40,6 +40,17 @@ class RegistrationAttemptServiceTest extends AbstractUnitTest { assertDoesNotThrow(() -> attempts.acquire("5.6.7.8")); } + @Test + void takesAtMostThreeRequestsPerIp() { + for (int i = 0; i < RegistrationAttemptService.MAX_REQUESTS_PER_IP; i++) { + attempts.countRequest("1.2.3.4"); + } + + assertThrows(TooManyRegistrationAttemptsException.class, () -> attempts.countRequest("1.2.3.4")); + assertDoesNotThrow(() -> attempts.countRequest("5.6.7.8")); + assertDoesNotThrow(() -> attempts.acquire("1.2.3.4")); + } + @Test void successDoesNotUseUpAnAttempt() { for (int i = 0; i < RegistrationAttemptService.MAX_FAILURES_PER_IP * 2; i++) { diff --git a/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java b/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java index 6a13b51..bdcb07f 100644 --- a/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java +++ b/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java @@ -1,8 +1,8 @@ package auth_service.modules.registration.service; import auth_service.modules.registration.exception.EmailAlreadyInUseException; -import auth_service.modules.registration.exception.InvalidInvitationCodeException; import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; +import auth_service.modules.registration.exception.PendingLimitReachedException; import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException; import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.persistence.RegisteredUserRepository; @@ -26,9 +26,6 @@ class RegistrationServiceTest extends AbstractUnitTest { private static final String IP = "203.0.113.7"; - @Mock - private CodeValidationService codeValidationService; - @Mock private RegisteredUserRepository registeredUserRepository; @@ -51,7 +48,6 @@ class RegistrationServiceTest extends AbstractUnitTest { validRegistration = Registration.builder() .email("test@example.com") .rawPassword("SuperSecret123!") - .inviteCode("test-invite-code") .build(); } @@ -59,7 +55,6 @@ class RegistrationServiceTest extends AbstractUnitTest { void register_Success() { // Arrange long expectedUserId = 42L; - when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true); when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false); when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ"); when(registeredUserRepository.save(any(Registration.class))).thenReturn(expectedUserId); @@ -74,16 +69,15 @@ class RegistrationServiceTest extends AbstractUnitTest { assertEquals("hashedPasswordXYZ", validRegistration.getEncryptedPassword()); verify(registrationAttemptService).acquire(IP); verify(registrationAttemptService).release(IP); - verify(accountMailer).userRegistered(expectedUserId); + verify(registrationAttemptService).countRequest(IP); + verify(accountMailer).registrationRequested(expectedUserId); } @Test - void register_InvalidInviteCode() { - // Arrange - when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(false); + void register_AFullQueue_RefusesAndCountsAsAFailedAttempt() { + when(registeredUserRepository.countPending()).thenReturn((long) RegistrationService.MAX_PENDING); - // Act & Assert - assertThrows(InvalidInvitationCodeException.class, () -> registrationService.register(validRegistration, IP)); + assertThrows(PendingLimitReachedException.class, () -> registrationService.register(validRegistration, IP)); verify(registeredUserRepository, never()).existsByEmail(anyString()); verify(registeredUserRepository, never()).save(any()); @@ -91,10 +85,27 @@ class RegistrationServiceTest extends AbstractUnitTest { verifyNoInteractions(accountMailer); } + @Test + void register_OneBelowTheLimit_IsTaken() { + when(registeredUserRepository.countPending()).thenReturn((long) RegistrationService.MAX_PENDING - 1); + when(registeredUserRepository.save(any(Registration.class))).thenReturn(43L); + + assertEquals(43L, registrationService.register(validRegistration, IP)); + } + + @Test + void register_TooManyRequestsFromOneIp_SavesNothing() { + doThrow(new TooManyRegistrationAttemptsException(900)).when(registrationAttemptService).countRequest(IP); + + assertThrows(TooManyRegistrationAttemptsException.class, () -> registrationService.register(validRegistration, IP)); + + verify(registeredUserRepository, never()).save(any()); + verifyNoInteractions(accountMailer); + } + @Test void register_EmailAlreadyInUse() { // Arrange - when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true); when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(true); // Act & Assert @@ -102,13 +113,13 @@ class RegistrationServiceTest extends AbstractUnitTest { verify(registeredUserRepository, never()).save(any()); verify(registrationAttemptService, never()).release(anyString()); + verify(registrationAttemptService, never()).countRequest(anyString()); } @Test void register_DisplayNameTaken() { // Arrange validRegistration.setDisplayName("Lobo"); - when(codeValidationService.isCodeValid(validRegistration.getInviteCode())).thenReturn(true); when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false); when(registeredUserRepository.existsByDisplayName("Lobo")).thenReturn(true); @@ -125,7 +136,7 @@ class RegistrationServiceTest extends AbstractUnitTest { // Act & Assert assertThrows(TooManyRegistrationAttemptsException.class, () -> registrationService.register(validRegistration, IP)); - verifyNoInteractions(codeValidationService, registeredUserRepository); + verifyNoInteractions(registeredUserRepository); verify(registrationAttemptService, never()).release(anyString()); } } diff --git a/src/test/java/auth_service/shared/test/AbstractIntegrationTest.java b/src/test/java/auth_service/shared/test/AbstractIntegrationTest.java index fe99d49..08fffa7 100644 --- a/src/test/java/auth_service/shared/test/AbstractIntegrationTest.java +++ b/src/test/java/auth_service/shared/test/AbstractIntegrationTest.java @@ -20,6 +20,7 @@ import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.util.Base64; import java.util.Date; +import java.util.concurrent.atomic.AtomicInteger; /** * Base class for integration tests. @@ -43,6 +44,16 @@ public abstract class AbstractIntegrationTest { @Autowired private JdbcTemplate baseJdbc; + private static final AtomicInteger NEXT_CLIENT = new AtomicInteger(); + + /** + * For X-Forwarded-For: a new client address per registration, because Auth takes at most 3 per address. + */ + protected static String freshClientIp() { + int client = NEXT_CLIENT.incrementAndGet(); + return "198.18." + client / 250 + "." + (client % 250 + 1); + } + /** * For users a test inserts by SQL. Registration inserts the row itself. */ @@ -58,7 +69,6 @@ public abstract class AbstractIntegrationTest { static void secrets(DynamicPropertyRegistry registry) { registry.add("security.jwt.private-key", () -> Base64.getEncoder().encodeToString(KEYS.getPrivate().getEncoded())); registry.add("security.jwt.public-key", () -> Base64.getEncoder().encodeToString(KEYS.getPublic().getEncoded())); - registry.add("auth.registration.invitation-code", () -> "test-invite-code"); } /**