From 1cac0c6b813e9ee4044fc12fc6ca649feb644ac8 Mon Sep 17 00:00:00 2001 From: lobo Date: Thu, 1 Oct 2026 08:30:24 +0200 Subject: [PATCH] feat(mail): mails at registration, block and delete --- readme.md | 26 ++- .../service/AccountCleanupService.java | 4 +- .../account/service/AccountService.java | 27 ++- .../admin/repo/AdminUserRepository.java | 8 +- .../admin/service/AdminUserService.java | 8 +- .../api/dto/mapper/ApiMapper.java | 1 + .../api/dto/request/RegistrationRequest.java | 4 +- .../registration/model/Registration.java | 1 + .../RegisteredUserRepositoryImpl.java | 4 +- .../service/RegistrationService.java | 9 +- .../account/service/AccountServiceTest.java | 32 +++- .../admin/repo/AdminUserRepositoryTest.java | 7 + .../admin/service/AdminUserServiceTest.java | 20 +- .../modules/mail/service/MailFlowTest.java | 176 ++++++++++++++++++ .../api/RegistrationControllerTest.java | 25 +++ .../RegisteredUserRepositoryTest.java | 10 + .../service/RegistrationServiceTest.java | 6 + 17 files changed, 356 insertions(+), 12 deletions(-) create mode 100644 src/test/java/auth_service/modules/mail/service/MailFlowTest.java diff --git a/readme.md b/readme.md index 979c68d..7c2a795 100644 --- a/readme.md +++ b/readme.md @@ -69,6 +69,10 @@ 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 + 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 profile, no Steam link, not in the landing stats. Email and display name stay taken; the Steam ID is freed, so an @@ -223,7 +227,7 @@ sequenceDiagram participant A as Auth participant C as Core B->>A: DELETE /api/users/me {password} - A->>A: users.deleted_at = now, revoke refresh tokens + A->>A: users.deleted_at = now, revoke refresh tokens, mail row (one transaction) A->>C: POST /internal/accounts/deleted (service token) A-->>B: 204, both cookies cleared Note over A: an old access token gets 401 within 30 s (activeUsers cache) @@ -238,6 +242,26 @@ sequenceDiagram A failed call to Core does not fail the delete: the next night sends the whole flagged set again. Undo within 30 days is `UPDATE users SET deleted_at = NULL` (see the Deployment README). +### Mail flow + +Each mail is a row in `mail_outbox`, written in the transaction of its event, so the event never fails because of +the mail server and no mail is lost when Auth restarts. The text is rendered then, in the user's language (`de`, +else English); the admin mail is German. + +```mermaid +sequenceDiagram + participant B as Browser (Frontend) + participant A as Auth + participant M as SMTP (Hostinger) + B->>A: register / admin blocks / delete account + A->>A: event + mail_outbox row (PENDING), one transaction + loop every minute (MailOutboxService) + A->>M: up to 20 due rows + M-->>A: ok: SENT / error: try again after 1, 2, 4 ... 60 min + end + Note over A: after 10 tries FAILED and a warning in the log; SENT rows are removed after 30 days +``` + ### 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 diff --git a/src/main/java/auth_service/modules/account/service/AccountCleanupService.java b/src/main/java/auth_service/modules/account/service/AccountCleanupService.java index 62dd5c1..9de307d 100644 --- a/src/main/java/auth_service/modules/account/service/AccountCleanupService.java +++ b/src/main/java/auth_service/modules/account/service/AccountCleanupService.java @@ -19,6 +19,8 @@ import java.util.List; @Slf4j public class AccountCleanupService { + static final String ZONE = "Europe/Berlin"; + private final AccountRepository accountRepository; private final CoreAccountService coreAccountService; private final boolean dryRun; @@ -33,7 +35,7 @@ public class AccountCleanupService { this.retention = retention; } - @Scheduled(cron = "${app.account-cleanup.cron}", zone = "Europe/Berlin") + @Scheduled(cron = "${app.account-cleanup.cron}", zone = ZONE) public void runNightly() { try { coreAccountService.replaceDeleted(accountRepository.findFlaggedIds()); diff --git a/src/main/java/auth_service/modules/account/service/AccountService.java b/src/main/java/auth_service/modules/account/service/AccountService.java index 463f968..a383437 100644 --- a/src/main/java/auth_service/modules/account/service/AccountService.java +++ b/src/main/java/auth_service/modules/account/service/AccountService.java @@ -4,17 +4,22 @@ import auth_service.modules.account.exception.InvalidPasswordException; import auth_service.modules.account.exception.TooManyDeleteAttemptsException; import auth_service.modules.account.repo.AccountRepository; import auth_service.shared.account.AccountDeleter; +import auth_service.shared.mail.AccountMailer; import auth_service.shared.security.AttemptLimiter; import auth_service.shared.security.RequiresRole; import auth_service.shared.security.Role; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Caching; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionOperations; import java.time.Duration; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import java.util.Optional; @@ -25,17 +30,26 @@ public class AccountService implements AccountDeleter { private static final Duration LOCK = Duration.ofMinutes(15); private static final int MAX_WRONG_PASSWORDS = 5; + private static final ZoneId CLEANUP_ZONE = ZoneId.of(AccountCleanupService.ZONE); private final AccountRepository accountRepository; private final PasswordEncoder passwordEncoder; private final CoreAccountService coreAccountService; + private final AccountMailer accountMailer; + private final TransactionOperations transactions; + private final Duration retention; private final AttemptLimiter attemptLimiter = new AttemptLimiter(LOCK); public AccountService(AccountRepository accountRepository, PasswordEncoder passwordEncoder, - CoreAccountService coreAccountService) { + CoreAccountService coreAccountService, AccountMailer accountMailer, + TransactionOperations transactions, + @Value("${app.account-cleanup.retention}") Duration retention) { this.accountRepository = accountRepository; this.passwordEncoder = passwordEncoder; this.coreAccountService = coreAccountService; + this.accountMailer = accountMailer; + this.transactions = transactions; + this.retention = retention; } /** @@ -70,8 +84,17 @@ public class AccountService implements AccountDeleter { return flagAndTellCore(userId); } + // The flag and the mail commit together, and before Core is called. private boolean flagAndTellCore(long userId) { - if (accountRepository.flagDeleted(userId, Instant.now()) == 0) { + Instant now = Instant.now(); + boolean flagged = Boolean.TRUE.equals(transactions.execute(status -> { + if (accountRepository.flagDeleted(userId, now) == 0) { + return false; + } + accountMailer.accountDeleted(userId, LocalDate.ofInstant(now.plus(retention), CLEANUP_ZONE)); + return true; + })); + if (!flagged) { return false; } log.info("Account of user {} flagged as deleted", userId); 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 b2d9a1d..538a434 100644 --- a/src/main/java/auth_service/modules/admin/repo/AdminUserRepository.java +++ b/src/main/java/auth_service/modules/admin/repo/AdminUserRepository.java @@ -73,13 +73,17 @@ public class AdminUserRepository { /** * Blocking also ends every login of the user. + * + * @return false when the user already had this state */ @Transactional - public void setBlocked(long userId, boolean blocked, Instant now) { - jdbc.update("UPDATE users SET enabled = ? WHERE id = ?", !blocked, userId); + public boolean setBlocked(long userId, boolean blocked, Instant now) { + boolean changed = jdbc.update("UPDATE users SET enabled = ? WHERE id = ? AND enabled = ?", !blocked, userId, + blocked) > 0; if (blocked) { jdbc.update("UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", Timestamp.from(now), userId); } + return changed; } } 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 647eb12..4da382b 100644 --- a/src/main/java/auth_service/modules/admin/service/AdminUserService.java +++ b/src/main/java/auth_service/modules/admin/service/AdminUserService.java @@ -8,6 +8,7 @@ import auth_service.modules.admin.repo.AdminUserRepository; import auth_service.shared.account.AccountDeleter; import auth_service.shared.account.AccountStatePublisher; import auth_service.shared.audit.AdminAuditLog; +import auth_service.shared.mail.AccountMailer; import auth_service.shared.security.RequiresRole; import auth_service.shared.security.Role; import lombok.RequiredArgsConstructor; @@ -31,6 +32,7 @@ public class AdminUserService { private final AdminAuditLog adminAuditLog; private final AccountDeleter accountDeleter; private final AccountStatePublisher accountStatePublisher; + private final AccountMailer accountMailer; public AdminUserPage findUsers(String query, int page, int size) { String search = query == null ? "" : query.trim(); @@ -49,9 +51,13 @@ public class AdminUserService { if (target.deleted()) { throw new AdminActionRefusedException("ALREADY_DELETED", "The account is already deleted"); } - adminUserRepository.setBlocked(targetUserId, blocked, Instant.now()); + boolean changed = adminUserRepository.setBlocked(targetUserId, blocked, Instant.now()); accountStatePublisher.publish(targetUserId); adminAuditLog.record(actorUserId, blocked ? "BLOCK" : "UNBLOCK", targetUserId, null); + // A second click must not mail again. + if (blocked && changed) { + accountMailer.accountBlocked(targetUserId); + } log.info("Admin {} {} user {}", actorUserId, blocked ? "blocked" : "unblocked", targetUserId); } 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 a69c861..17052d1 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 @@ -13,6 +13,7 @@ public abstract class ApiMapper { 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 d6ad14d..5b94226 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 @@ -19,7 +19,9 @@ import java.nio.charset.StandardCharsets; 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) { + @Pattern(regexp = DisplayNameFormat.PATTERN) String displayName, + // For the mails; null = never chosen. + @Pattern(regexp = "de|en") String language) { public RegistrationRequest { email = EmailAddress.normalize(email); 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 4db641d..9d4a5e0 100644 --- a/src/main/java/auth_service/modules/registration/model/Registration.java +++ b/src/main/java/auth_service/modules/registration/model/Registration.java @@ -14,4 +14,5 @@ public class Registration { private String encryptedPassword; private String inviteCode; private String displayName; + private String language; } 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 317786f..7f0365d 100644 --- a/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java +++ b/src/main/java/auth_service/modules/registration/persistence/RegisteredUserRepositoryImpl.java @@ -39,7 +39,9 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository { if (id == null) { throw new IllegalStateException("No id for the new user"); } - jdbc.update("INSERT INTO user_settings (user_id) VALUES (?)", id); + // '' is the column default: no language chosen. + jdbc.update("INSERT INTO user_settings (user_id, language) VALUES (?, COALESCE(?, ''))", id, + registration.getLanguage()); return id; } } 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 3da400e..0cd4fe4 100644 --- a/src/main/java/auth_service/modules/registration/service/RegistrationService.java +++ b/src/main/java/auth_service/modules/registration/service/RegistrationService.java @@ -5,11 +5,13 @@ import auth_service.modules.registration.exception.InvalidInvitationCodeExceptio import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; 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; @Service @Public @@ -20,7 +22,10 @@ public class RegistrationService { private final RegisteredUserRepository registeredUserRepository; private final PasswordEncoder passwordEncoder; private final RegistrationAttemptService registrationAttemptService; + private final AccountMailer accountMailer; + // The user and the mail rows commit together. + @Transactional @CacheEvict(value = "publicStats", allEntries = true) public long register(Registration registration, String clientIp) { registrationAttemptService.acquire(clientIp); @@ -30,7 +35,9 @@ public class RegistrationService { var rawPassword = registration.getRawPassword(); registration.setEncryptedPassword(passwordEncoder.encode(rawPassword)); - return registeredUserRepository.save(registration); + long userId = registeredUserRepository.save(registration); + accountMailer.userRegistered(userId); + return userId; } private void validate(Registration registration) { diff --git a/src/test/java/auth_service/modules/account/service/AccountServiceTest.java b/src/test/java/auth_service/modules/account/service/AccountServiceTest.java index 31579f5..6e1ffcc 100644 --- a/src/test/java/auth_service/modules/account/service/AccountServiceTest.java +++ b/src/test/java/auth_service/modules/account/service/AccountServiceTest.java @@ -3,15 +3,24 @@ package auth_service.modules.account.service; import auth_service.modules.account.exception.InvalidPasswordException; import auth_service.modules.account.exception.TooManyDeleteAttemptsException; import auth_service.modules.account.repo.AccountRepository; +import auth_service.shared.mail.AccountMailer; import auth_service.shared.test.AbstractUnitTest; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.transaction.support.TransactionOperations; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import java.util.Optional; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -37,11 +46,15 @@ class AccountServiceTest extends AbstractUnitTest { @Mock private CoreAccountService coreAccountService; + @Mock + private AccountMailer accountMailer; + private AccountService service; @BeforeEach void setUp() { - service = new AccountService(accountRepository, passwordEncoder, coreAccountService); + service = new AccountService(accountRepository, passwordEncoder, coreAccountService, accountMailer, + TransactionOperations.withoutTransaction(), Duration.ofDays(30)); lenient().when(accountRepository.findPasswordHashOfActive(USER_ID)).thenReturn(Optional.of("hash")); lenient().when(passwordEncoder.matches("right", "hash")).thenReturn(true); lenient().when(accountRepository.flagDeleted(eq(USER_ID), any())).thenReturn(1); @@ -55,12 +68,27 @@ class AccountServiceTest extends AbstractUnitTest { verify(coreAccountService).markDeleted(List.of(USER_ID)); } + // The day of "flag + 30 days" in Berlin; not today + 30 days, which is one day off around a DST change. + @Test + void deleteAccount_MailsTheDayTheCleanupMayRemoveTheUser() { + ZoneId berlin = ZoneId.of("Europe/Berlin"); + LocalDate earliest = LocalDate.ofInstant(Instant.now().plus(Duration.ofDays(30)), berlin); + + service.deleteAccount(USER_ID, "right"); + + LocalDate latest = LocalDate.ofInstant(Instant.now().plus(Duration.ofDays(30)), berlin); + ArgumentCaptor deletedFrom = ArgumentCaptor.forClass(LocalDate.class); + verify(accountMailer).accountDeleted(eq(USER_ID), deletedFrom.capture()); + assertThat(deletedFrom.getValue()).isBetween(earliest, latest); + } + @Test void deleteAccount_WrongPasswordFlagsNothing() { assertThatThrownBy(() -> service.deleteAccount(USER_ID, "wrong")).isInstanceOf(InvalidPasswordException.class); verify(accountRepository, never()).flagDeleted(anyLong(), any()); verifyNoInteractions(coreAccountService); + verifyNoInteractions(accountMailer); } @Test @@ -91,6 +119,7 @@ class AccountServiceTest extends AbstractUnitTest { assertThatNoException().isThrownBy(() -> service.deleteAccount(USER_ID, "right")); verify(accountRepository).flagDeleted(eq(USER_ID), any()); + verify(accountMailer).accountDeleted(eq(USER_ID), any()); } @Test @@ -100,5 +129,6 @@ class AccountServiceTest extends AbstractUnitTest { assertThatNoException().isThrownBy(() -> service.deleteAccount(USER_ID, "anything")); verify(accountRepository, never()).flagDeleted(anyLong(), any()); verifyNoInteractions(coreAccountService); + verifyNoInteractions(accountMailer); } } 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 d65c63c..5648331 100644 --- a/src/test/java/auth_service/modules/admin/repo/AdminUserRepositoryTest.java +++ b/src/test/java/auth_service/modules/admin/repo/AdminUserRepositoryTest.java @@ -81,4 +81,11 @@ class AdminUserRepositoryTest extends AbstractPersistenceTest { repository.setBlocked(alice, false, Instant.now()); assertThat(jdbc.queryForObject("SELECT enabled FROM users WHERE id = ?", Boolean.class, alice)).isTrue(); } + + @Test + void setBlocked_TellsWhetherTheStateChanged() { + assertThat(repository.setBlocked(alice, true, Instant.now())).isTrue(); + assertThat(repository.setBlocked(alice, true, Instant.now())).isFalse(); + assertThat(repository.setBlocked(alice, false, Instant.now())).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 2bfe5bb..f82adf0 100644 --- a/src/test/java/auth_service/modules/admin/service/AdminUserServiceTest.java +++ b/src/test/java/auth_service/modules/admin/service/AdminUserServiceTest.java @@ -7,6 +7,7 @@ import auth_service.modules.admin.repo.AdminUserRepository; import auth_service.shared.account.AccountDeleter; import auth_service.shared.account.AccountStatePublisher; import auth_service.shared.audit.AdminAuditLog; +import auth_service.shared.mail.AccountMailer; import auth_service.shared.security.Role; import auth_service.shared.test.AbstractUnitTest; import org.junit.jupiter.api.BeforeEach; @@ -31,6 +32,9 @@ class AdminUserServiceTest extends AbstractUnitTest { private static final long ADMIN = 1L; private static final long TARGET = 2L; + @Mock + private AccountMailer accountMailer; + @Mock private AdminUserRepository adminUserRepository; @@ -47,7 +51,8 @@ class AdminUserServiceTest extends AbstractUnitTest { @BeforeEach void setUp() { - service = new AdminUserService(adminUserRepository, adminAuditLog, accountDeleter, accountStatePublisher); + service = new AdminUserService(adminUserRepository, adminAuditLog, accountDeleter, accountStatePublisher, + accountMailer); } private void target(Role role, boolean deleted) { @@ -63,15 +68,27 @@ class AdminUserServiceTest extends AbstractUnitTest { throw new AssertionError("not refused"); } + @Test + void block_OfAnAlreadyBlockedUserSendsNoSecondMail() { + target(Role.USER, false); + when(adminUserRepository.setBlocked(eq(TARGET), eq(true), any())).thenReturn(false); + + service.setBlocked(ADMIN, TARGET, true); + + verify(accountMailer, never()).accountBlocked(anyLong()); + } + @Test void block_BlocksAndWritesTheAudit() { target(Role.USER, false); + when(adminUserRepository.setBlocked(eq(TARGET), eq(true), any())).thenReturn(true); service.setBlocked(ADMIN, TARGET, true); verify(adminUserRepository).setBlocked(eq(TARGET), eq(true), any()); verify(adminAuditLog).record(ADMIN, "BLOCK", TARGET, null); verify(accountStatePublisher).publish(TARGET); + verify(accountMailer).accountBlocked(TARGET); } @Test @@ -81,6 +98,7 @@ class AdminUserServiceTest extends AbstractUnitTest { service.setBlocked(ADMIN, TARGET, false); verify(adminAuditLog).record(ADMIN, "UNBLOCK", TARGET, null); + verify(accountMailer, never()).accountBlocked(anyLong()); } @Test diff --git a/src/test/java/auth_service/modules/mail/service/MailFlowTest.java b/src/test/java/auth_service/modules/mail/service/MailFlowTest.java new file mode 100644 index 0000000..855e065 --- /dev/null +++ b/src/test/java/auth_service/modules/mail/service/MailFlowTest.java @@ -0,0 +1,176 @@ +package auth_service.modules.mail.service; + +import auth_service.configuration.security.ClientHeaderFilter; +import auth_service.shared.api.AuthCookies; +import auth_service.shared.test.AbstractIntegrationTest; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mail.MailSendException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * The mails from the event to the mail server, against a real database and a mocked sender. + */ +class MailFlowTest extends AbstractIntegrationTest { + + private static final String PASSWORD = "correct horse battery"; + private static final String NEW_USER = "mail-flow-new@example.com"; + private static final String ADMIN = "mail-flow-admin@example.com"; + private static final String PLAYER = "mail-flow-player@example.com"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private MailOutboxService mailOutboxService; + + @BeforeEach + void clean() { + jdbc.update("DELETE FROM mail_outbox"); + jdbc.update("DELETE FROM users WHERE email LIKE 'mail-flow-%'"); + } + + private long insertUser(String email, String role) { + Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, role, display_name) VALUES (?, ?, true, ?, ?) " + + "RETURNING id", Long.class, email, passwordEncoder.encode(PASSWORD), role, email.substring(10, 20)); + addSettingsRows(); + return id; + } + + private String accessToken(String email) throws Exception { + return mockMvc.perform(post("/api/auth/login").header(ClientHeaderFilter.CLIENT_HEADER, "web") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + email + "\",\"password\":\"" + PASSWORD + "\"}")) + .andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE).getValue(); + } + + private void register() throws Exception { + mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD + + "\",\"inviteCode\":\"test-invite-code\",\"displayName\":\"MailFlow\",\"language\":\"de\"}")) + .andExpect(status().isOk()); + } + + private List> rows() { + return jdbc.queryForList("SELECT kind, to_address, subject, status, attempts, last_error FROM mail_outbox ORDER BY id"); + } + + @Test + void registration_WritesBothMailsAndTheJobSendsThem() throws Exception { + 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"); + + mailOutboxService.sendDue(); + + ArgumentCaptor sent = ArgumentCaptor.forClass(SimpleMailMessage.class); + verify(mailSender, times(2)).send(sent.capture()); + assertThat(sent.getAllValues()).extracting(SimpleMailMessage::getFrom).containsOnly("noreply@test.local"); + assertThat(rows()).extracting(row -> row.get("status")).containsOnly("SENT"); + } + + @Test + void aFailedSend_IsTriedAgainAndThenGivenUp() throws Exception { + register(); + doThrow(new MailSendException("connection refused")).when(mailSender).send(any(SimpleMailMessage.class)); + + mailOutboxService.sendDue(); + + assertThat(rows()).extracting(row -> row.get("status") + " " + row.get("attempts") + " " + row.get("last_error")) + .containsOnly("PENDING 1 connection refused"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox WHERE next_attempt_at > NOW()", Integer.class)) + .isEqualTo(2); + + jdbc.update("UPDATE mail_outbox SET attempts = ?, next_attempt_at = NOW()", MailOutboxService.MAX_ATTEMPTS - 1); + mailOutboxService.sendDue(); + + assertThat(rows()).extracting(row -> row.get("status")).containsOnly("FAILED"); + } + + @Test + void aFailedRegistration_WritesNoMail() throws Exception { + insertUser(NEW_USER, "USER"); + + mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD + + "\",\"inviteCode\":\"test-invite-code\"}")) + .andExpect(status().isConflict()); + + assertThat(rows()).isEmpty(); + } + + @Test + void block_MailsTheUserAndUnblockDoesNot() throws Exception { + insertUser(ADMIN, "PLATFORM_ADMIN"); + long player = insertUser(PLAYER, "USER"); + String admin = accessToken(ADMIN); + + for (boolean blocked : new boolean[]{true, false}) { + mockMvc.perform(put("/api/admin/users/" + player + "/blocked").header(ClientHeaderFilter.CLIENT_HEADER, "web") + .cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin)) + .contentType(MediaType.APPLICATION_JSON).content("{\"blocked\":" + blocked + "}")) + .andExpect(status().is2xxSuccessful()); + } + + assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address")) + .containsExactly("ACCOUNT_BLOCKED " + PLAYER); + } + + // Core is never reachable in tests, so this also shows that the mail survives a failed Core call. + @Test + void deleteMyAccount_MailsTheUser() throws Exception { + insertUser(PLAYER, "USER"); + + mockMvc.perform(delete("/api/users/me").header(ClientHeaderFilter.CLIENT_HEADER, "web") + .cookie(new Cookie(AuthCookies.ACCESS_COOKIE, accessToken(PLAYER))) + .contentType(MediaType.APPLICATION_JSON).content("{\"password\":\"" + PASSWORD + "\"}")) + .andExpect(status().isNoContent()); + + assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address")) + .containsExactly("ACCOUNT_DELETED " + PLAYER); + } + + @Test + void adminDelete_MailsTheUser() throws Exception { + insertUser(ADMIN, "PLATFORM_ADMIN"); + long player = insertUser(PLAYER, "USER"); + + mockMvc.perform(delete("/api/admin/users/" + player).header(ClientHeaderFilter.CLIENT_HEADER, "web") + .cookie(new Cookie(AuthCookies.ACCESS_COOKIE, accessToken(ADMIN)))) + .andExpect(status().is2xxSuccessful()); + + assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address")) + .containsExactly("ACCOUNT_DELETED " + PLAYER); + } +} 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 5e08cc3..778e16e 100644 --- a/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java +++ b/src/test/java/auth_service/modules/registration/api/RegistrationControllerTest.java @@ -159,6 +159,31 @@ class RegistrationControllerTest extends AbstractWebMvcTest { .andExpect(jsonPath("$.code").value("DISPLAY_NAME_ALREADY_IN_USE")); } + @Test + void register_TakesTheLanguageOfTheRegistrationPage() throws Exception { + when(registrationService.register(any(Registration.class), any())).thenReturn(42L); + + mockMvc.perform(post("/api/registration/new") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","language":"de"}""")) + .andExpect(status().isOk()); + + ArgumentCaptor registration = ArgumentCaptor.forClass(Registration.class); + verify(registrationService).register(registration.capture(), any()); + assertEquals("de", registration.getValue().getLanguage()); + } + + @Test + void register_UnknownLanguage_Is400ValidationFailed() throws Exception { + mockMvc.perform(post("/api/registration/new") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","language":"fr"}""")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")); + } + @Test void register_UnreadableBody_Is400ValidationFailed() throws Exception { mockMvc.perform(post("/api/registration/new").contentType(MediaType.APPLICATION_JSON).content("{not json")) 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 672206c..aa95917 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_StoresTheLanguageOfTheRegistrationPage() { + Registration registration = registration("lang@example.com", null); + registration.setLanguage("de"); + + long id = registeredUserRepository.save(registration); + + assertEquals("de", jdbc.queryForObject("SELECT language FROM user_settings WHERE user_id = ?", String.class, id)); + } + // V19: a second account under the same email in other case is refused by the database too. @Test void emailsThatDifferOnlyInCase_AreOneAccount() { 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 c9fc9e6..6a13b51 100644 --- a/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java +++ b/src/test/java/auth_service/modules/registration/service/RegistrationServiceTest.java @@ -6,6 +6,7 @@ import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredE import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException; import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.persistence.RegisteredUserRepository; +import auth_service.shared.mail.AccountMailer; import auth_service.shared.test.AbstractUnitTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -37,6 +38,9 @@ class RegistrationServiceTest extends AbstractUnitTest { @Mock private RegistrationAttemptService registrationAttemptService; + @Mock + private AccountMailer accountMailer; + @InjectMocks private RegistrationService registrationService; @@ -70,6 +74,7 @@ class RegistrationServiceTest extends AbstractUnitTest { assertEquals("hashedPasswordXYZ", validRegistration.getEncryptedPassword()); verify(registrationAttemptService).acquire(IP); verify(registrationAttemptService).release(IP); + verify(accountMailer).userRegistered(expectedUserId); } @Test @@ -83,6 +88,7 @@ class RegistrationServiceTest extends AbstractUnitTest { verify(registeredUserRepository, never()).existsByEmail(anyString()); verify(registeredUserRepository, never()).save(any()); verify(registrationAttemptService, never()).release(anyString()); + verifyNoInteractions(accountMailer); } @Test