feat(mail): mails at registration, block and delete
Build, Test & Push Auth Service / quality-check (push) Successful in 1m12s
Build, Test & Push Auth Service / docker (push) Successful in 1m22s

This commit is contained in:
lobo committed 2026-10-01 08:30:24 +02:00
1 parent 47a03d2e6a
commit 1cac0c6b81
17 files changed
+356 -12

No files matched your search

+25 -1
View File
@@ -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. 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 "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`. 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
<date>". 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 - **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 `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 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 A as Auth
participant C as Core participant C as Core
B->>A: DELETE /api/users/me {password} 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->>C: POST /internal/accounts/deleted (service token)
A-->>B: 204, both cookies cleared A-->>B: 204, both cookies cleared
Note over A: an old access token gets 401 within 30 s (activeUsers cache) 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 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). 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 ### 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 Core keeps its own copy of the Steam ID and of the block. After a verified Steam link (also for the account that
@@ -19,6 +19,8 @@ import java.util.List;
@Slf4j @Slf4j
public class AccountCleanupService { public class AccountCleanupService {
static final String ZONE = "Europe/Berlin";
private final AccountRepository accountRepository; private final AccountRepository accountRepository;
private final CoreAccountService coreAccountService; private final CoreAccountService coreAccountService;
private final boolean dryRun; private final boolean dryRun;
@@ -33,7 +35,7 @@ public class AccountCleanupService {
this.retention = retention; this.retention = retention;
} }
@Scheduled(cron = "${app.account-cleanup.cron}", zone = "Europe/Berlin") @Scheduled(cron = "${app.account-cleanup.cron}", zone = ZONE)
public void runNightly() { public void runNightly() {
try { try {
coreAccountService.replaceDeleted(accountRepository.findFlaggedIds()); coreAccountService.replaceDeleted(accountRepository.findFlaggedIds());
@@ -4,17 +4,22 @@ import auth_service.modules.account.exception.InvalidPasswordException;
import auth_service.modules.account.exception.TooManyDeleteAttemptsException; import auth_service.modules.account.exception.TooManyDeleteAttemptsException;
import auth_service.modules.account.repo.AccountRepository; import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.account.AccountDeleter; import auth_service.shared.account.AccountDeleter;
import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.AttemptLimiter; import auth_service.shared.security.AttemptLimiter;
import auth_service.shared.security.RequiresRole; import auth_service.shared.security.RequiresRole;
import auth_service.shared.security.Role; import auth_service.shared.security.Role;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Caching; import org.springframework.cache.annotation.Caching;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionOperations;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -25,17 +30,26 @@ public class AccountService implements AccountDeleter {
private static final Duration LOCK = Duration.ofMinutes(15); private static final Duration LOCK = Duration.ofMinutes(15);
private static final int MAX_WRONG_PASSWORDS = 5; private static final int MAX_WRONG_PASSWORDS = 5;
private static final ZoneId CLEANUP_ZONE = ZoneId.of(AccountCleanupService.ZONE);
private final AccountRepository accountRepository; private final AccountRepository accountRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final CoreAccountService coreAccountService; private final CoreAccountService coreAccountService;
private final AccountMailer accountMailer;
private final TransactionOperations transactions;
private final Duration retention;
private final AttemptLimiter attemptLimiter = new AttemptLimiter(LOCK); private final AttemptLimiter attemptLimiter = new AttemptLimiter(LOCK);
public AccountService(AccountRepository accountRepository, PasswordEncoder passwordEncoder, 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.accountRepository = accountRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.coreAccountService = coreAccountService; this.coreAccountService = coreAccountService;
this.accountMailer = accountMailer;
this.transactions = transactions;
this.retention = retention;
} }
/** /**
@@ -70,8 +84,17 @@ public class AccountService implements AccountDeleter {
return flagAndTellCore(userId); return flagAndTellCore(userId);
} }
// The flag and the mail commit together, and before Core is called.
private boolean flagAndTellCore(long userId) { 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; return false;
} }
log.info("Account of user {} flagged as deleted", userId); log.info("Account of user {} flagged as deleted", userId);
@@ -73,13 +73,17 @@ public class AdminUserRepository {
/** /**
* Blocking also ends every login of the user. * Blocking also ends every login of the user.
*
* @return false when the user already had this state
*/ */
@Transactional @Transactional
public void setBlocked(long userId, boolean blocked, Instant now) { public boolean setBlocked(long userId, boolean blocked, Instant now) {
jdbc.update("UPDATE users SET enabled = ? WHERE id = ?", !blocked, userId); boolean changed = jdbc.update("UPDATE users SET enabled = ? WHERE id = ? AND enabled = ?", !blocked, userId,
blocked) > 0;
if (blocked) { if (blocked) {
jdbc.update("UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL", jdbc.update("UPDATE refresh_tokens SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL",
Timestamp.from(now), userId); Timestamp.from(now), userId);
} }
return changed;
} }
} }
@@ -8,6 +8,7 @@ import auth_service.modules.admin.repo.AdminUserRepository;
import auth_service.shared.account.AccountDeleter; import auth_service.shared.account.AccountDeleter;
import auth_service.shared.account.AccountStatePublisher; import auth_service.shared.account.AccountStatePublisher;
import auth_service.shared.audit.AdminAuditLog; import auth_service.shared.audit.AdminAuditLog;
import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.RequiresRole; import auth_service.shared.security.RequiresRole;
import auth_service.shared.security.Role; import auth_service.shared.security.Role;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -31,6 +32,7 @@ public class AdminUserService {
private final AdminAuditLog adminAuditLog; private final AdminAuditLog adminAuditLog;
private final AccountDeleter accountDeleter; private final AccountDeleter accountDeleter;
private final AccountStatePublisher accountStatePublisher; private final AccountStatePublisher accountStatePublisher;
private final AccountMailer accountMailer;
public AdminUserPage findUsers(String query, int page, int size) { public AdminUserPage findUsers(String query, int page, int size) {
String search = query == null ? "" : query.trim(); String search = query == null ? "" : query.trim();
@@ -49,9 +51,13 @@ public class AdminUserService {
if (target.deleted()) { if (target.deleted()) {
throw new AdminActionRefusedException("ALREADY_DELETED", "The account is already 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); accountStatePublisher.publish(targetUserId);
adminAuditLog.record(actorUserId, blocked ? "BLOCK" : "UNBLOCK", targetUserId, null); 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); log.info("Admin {} {} user {}", actorUserId, blocked ? "blocked" : "unblocked", targetUserId);
} }
@@ -13,6 +13,7 @@ public abstract class ApiMapper {
rawPassword(registrationRequest.password()). rawPassword(registrationRequest.password()).
inviteCode(registrationRequest.inviteCode()). inviteCode(registrationRequest.inviteCode()).
displayName(registrationRequest.displayName()). displayName(registrationRequest.displayName()).
language(registrationRequest.language()).
build(); build();
} }
@@ -19,7 +19,9 @@ import java.nio.charset.StandardCharsets;
public record RegistrationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_LENGTH) String email, public record RegistrationRequest(@NotBlank @Email @Size(max = EmailAddress.MAX_LENGTH) String email,
@NotBlank @Size(min = 8) String password, @NotBlank @Size(min = 8) String password,
@NotBlank String inviteCode, @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 { public RegistrationRequest {
email = EmailAddress.normalize(email); email = EmailAddress.normalize(email);
@@ -14,4 +14,5 @@ public class Registration {
private String encryptedPassword; private String encryptedPassword;
private String inviteCode; private String inviteCode;
private String displayName; private String displayName;
private String language;
} }
@@ -39,7 +39,9 @@ public class RegisteredUserRepositoryImpl implements RegisteredUserRepository {
if (id == null) { if (id == null) {
throw new IllegalStateException("No id for the new user"); 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; return id;
} }
} }
@@ -5,11 +5,13 @@ import auth_service.modules.registration.exception.InvalidInvitationCodeExceptio
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException; import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.RegisteredUserRepository; import auth_service.modules.registration.persistence.RegisteredUserRepository;
import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.Public; import auth_service.shared.security.Public;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.CacheEvict;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service @Service
@Public @Public
@@ -20,7 +22,10 @@ public class RegistrationService {
private final RegisteredUserRepository registeredUserRepository; private final RegisteredUserRepository registeredUserRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final RegistrationAttemptService registrationAttemptService; private final RegistrationAttemptService registrationAttemptService;
private final AccountMailer accountMailer;
// The user and the mail rows commit together.
@Transactional
@CacheEvict(value = "publicStats", allEntries = true) @CacheEvict(value = "publicStats", allEntries = true)
public long register(Registration registration, String clientIp) { public long register(Registration registration, String clientIp) {
registrationAttemptService.acquire(clientIp); registrationAttemptService.acquire(clientIp);
@@ -30,7 +35,9 @@ public class RegistrationService {
var rawPassword = registration.getRawPassword(); var rawPassword = registration.getRawPassword();
registration.setEncryptedPassword(passwordEncoder.encode(rawPassword)); registration.setEncryptedPassword(passwordEncoder.encode(rawPassword));
return registeredUserRepository.save(registration); long userId = registeredUserRepository.save(registration);
accountMailer.userRegistered(userId);
return userId;
} }
private void validate(Registration registration) { private void validate(Registration registration) {
@@ -3,15 +3,24 @@ package auth_service.modules.account.service;
import auth_service.modules.account.exception.InvalidPasswordException; import auth_service.modules.account.exception.InvalidPasswordException;
import auth_service.modules.account.exception.TooManyDeleteAttemptsException; import auth_service.modules.account.exception.TooManyDeleteAttemptsException;
import auth_service.modules.account.repo.AccountRepository; import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.mail.AccountMailer;
import auth_service.shared.test.AbstractUnitTest; import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.springframework.security.crypto.password.PasswordEncoder; 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.List;
import java.util.Optional; 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.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@@ -37,11 +46,15 @@ class AccountServiceTest extends AbstractUnitTest {
@Mock @Mock
private CoreAccountService coreAccountService; private CoreAccountService coreAccountService;
@Mock
private AccountMailer accountMailer;
private AccountService service; private AccountService service;
@BeforeEach @BeforeEach
void setUp() { 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(accountRepository.findPasswordHashOfActive(USER_ID)).thenReturn(Optional.of("hash"));
lenient().when(passwordEncoder.matches("right", "hash")).thenReturn(true); lenient().when(passwordEncoder.matches("right", "hash")).thenReturn(true);
lenient().when(accountRepository.flagDeleted(eq(USER_ID), any())).thenReturn(1); 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)); 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<LocalDate> deletedFrom = ArgumentCaptor.forClass(LocalDate.class);
verify(accountMailer).accountDeleted(eq(USER_ID), deletedFrom.capture());
assertThat(deletedFrom.getValue()).isBetween(earliest, latest);
}
@Test @Test
void deleteAccount_WrongPasswordFlagsNothing() { void deleteAccount_WrongPasswordFlagsNothing() {
assertThatThrownBy(() -> service.deleteAccount(USER_ID, "wrong")).isInstanceOf(InvalidPasswordException.class); assertThatThrownBy(() -> service.deleteAccount(USER_ID, "wrong")).isInstanceOf(InvalidPasswordException.class);
verify(accountRepository, never()).flagDeleted(anyLong(), any()); verify(accountRepository, never()).flagDeleted(anyLong(), any());
verifyNoInteractions(coreAccountService); verifyNoInteractions(coreAccountService);
verifyNoInteractions(accountMailer);
} }
@Test @Test
@@ -91,6 +119,7 @@ class AccountServiceTest extends AbstractUnitTest {
assertThatNoException().isThrownBy(() -> service.deleteAccount(USER_ID, "right")); assertThatNoException().isThrownBy(() -> service.deleteAccount(USER_ID, "right"));
verify(accountRepository).flagDeleted(eq(USER_ID), any()); verify(accountRepository).flagDeleted(eq(USER_ID), any());
verify(accountMailer).accountDeleted(eq(USER_ID), any());
} }
@Test @Test
@@ -100,5 +129,6 @@ class AccountServiceTest extends AbstractUnitTest {
assertThatNoException().isThrownBy(() -> service.deleteAccount(USER_ID, "anything")); assertThatNoException().isThrownBy(() -> service.deleteAccount(USER_ID, "anything"));
verify(accountRepository, never()).flagDeleted(anyLong(), any()); verify(accountRepository, never()).flagDeleted(anyLong(), any());
verifyNoInteractions(coreAccountService); verifyNoInteractions(coreAccountService);
verifyNoInteractions(accountMailer);
} }
} }
@@ -81,4 +81,11 @@ class AdminUserRepositoryTest extends AbstractPersistenceTest {
repository.setBlocked(alice, false, Instant.now()); repository.setBlocked(alice, false, Instant.now());
assertThat(jdbc.queryForObject("SELECT enabled FROM users WHERE id = ?", Boolean.class, alice)).isTrue(); 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();
}
} }
@@ -7,6 +7,7 @@ import auth_service.modules.admin.repo.AdminUserRepository;
import auth_service.shared.account.AccountDeleter; import auth_service.shared.account.AccountDeleter;
import auth_service.shared.account.AccountStatePublisher; import auth_service.shared.account.AccountStatePublisher;
import auth_service.shared.audit.AdminAuditLog; import auth_service.shared.audit.AdminAuditLog;
import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.Role; import auth_service.shared.security.Role;
import auth_service.shared.test.AbstractUnitTest; import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -31,6 +32,9 @@ class AdminUserServiceTest extends AbstractUnitTest {
private static final long ADMIN = 1L; private static final long ADMIN = 1L;
private static final long TARGET = 2L; private static final long TARGET = 2L;
@Mock
private AccountMailer accountMailer;
@Mock @Mock
private AdminUserRepository adminUserRepository; private AdminUserRepository adminUserRepository;
@@ -47,7 +51,8 @@ class AdminUserServiceTest extends AbstractUnitTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new AdminUserService(adminUserRepository, adminAuditLog, accountDeleter, accountStatePublisher); service = new AdminUserService(adminUserRepository, adminAuditLog, accountDeleter, accountStatePublisher,
accountMailer);
} }
private void target(Role role, boolean deleted) { private void target(Role role, boolean deleted) {
@@ -63,15 +68,27 @@ class AdminUserServiceTest extends AbstractUnitTest {
throw new AssertionError("not refused"); 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 @Test
void block_BlocksAndWritesTheAudit() { void block_BlocksAndWritesTheAudit() {
target(Role.USER, false); target(Role.USER, false);
when(adminUserRepository.setBlocked(eq(TARGET), eq(true), any())).thenReturn(true);
service.setBlocked(ADMIN, TARGET, true); service.setBlocked(ADMIN, TARGET, true);
verify(adminUserRepository).setBlocked(eq(TARGET), eq(true), any()); verify(adminUserRepository).setBlocked(eq(TARGET), eq(true), any());
verify(adminAuditLog).record(ADMIN, "BLOCK", TARGET, null); verify(adminAuditLog).record(ADMIN, "BLOCK", TARGET, null);
verify(accountStatePublisher).publish(TARGET); verify(accountStatePublisher).publish(TARGET);
verify(accountMailer).accountBlocked(TARGET);
} }
@Test @Test
@@ -81,6 +98,7 @@ class AdminUserServiceTest extends AbstractUnitTest {
service.setBlocked(ADMIN, TARGET, false); service.setBlocked(ADMIN, TARGET, false);
verify(adminAuditLog).record(ADMIN, "UNBLOCK", TARGET, null); verify(adminAuditLog).record(ADMIN, "UNBLOCK", TARGET, null);
verify(accountMailer, never()).accountBlocked(anyLong());
} }
@Test @Test
@@ -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<Map<String, Object>> 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<SimpleMailMessage> 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);
}
}
@@ -159,6 +159,31 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
.andExpect(jsonPath("$.code").value("DISPLAY_NAME_ALREADY_IN_USE")); .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> 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 @Test
void register_UnreadableBody_Is400ValidationFailed() throws Exception { void register_UnreadableBody_Is400ValidationFailed() throws Exception {
mockMvc.perform(post("/api/registration/new").contentType(MediaType.APPLICATION_JSON).content("{not json")) mockMvc.perform(post("/api/registration/new").contentType(MediaType.APPLICATION_JSON).content("{not json"))
@@ -64,6 +64,16 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
assertEquals(false, settings.get("public_profile")); 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. // V19: a second account under the same email in other case is refused by the database too.
@Test @Test
void emailsThatDifferOnlyInCase_AreOneAccount() { void emailsThatDifferOnlyInCase_AreOneAccount() {
@@ -6,6 +6,7 @@ import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredE
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException; import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
import auth_service.modules.registration.model.Registration; import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.RegisteredUserRepository; import auth_service.modules.registration.persistence.RegisteredUserRepository;
import auth_service.shared.mail.AccountMailer;
import auth_service.shared.test.AbstractUnitTest; import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -37,6 +38,9 @@ class RegistrationServiceTest extends AbstractUnitTest {
@Mock @Mock
private RegistrationAttemptService registrationAttemptService; private RegistrationAttemptService registrationAttemptService;
@Mock
private AccountMailer accountMailer;
@InjectMocks @InjectMocks
private RegistrationService registrationService; private RegistrationService registrationService;
@@ -70,6 +74,7 @@ class RegistrationServiceTest extends AbstractUnitTest {
assertEquals("hashedPasswordXYZ", validRegistration.getEncryptedPassword()); assertEquals("hashedPasswordXYZ", validRegistration.getEncryptedPassword());
verify(registrationAttemptService).acquire(IP); verify(registrationAttemptService).acquire(IP);
verify(registrationAttemptService).release(IP); verify(registrationAttemptService).release(IP);
verify(accountMailer).userRegistered(expectedUserId);
} }
@Test @Test
@@ -83,6 +88,7 @@ class RegistrationServiceTest extends AbstractUnitTest {
verify(registeredUserRepository, never()).existsByEmail(anyString()); verify(registeredUserRepository, never()).existsByEmail(anyString());
verify(registeredUserRepository, never()).save(any()); verify(registeredUserRepository, never()).save(any());
verify(registrationAttemptService, never()).release(anyString()); verify(registrationAttemptService, never()).release(anyString());
verifyNoInteractions(accountMailer);
} }
@Test @Test