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

@@ -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<LocalDate> 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);
}
}
@@ -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();
}
}
@@ -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
@@ -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"));
}
@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
void register_UnreadableBody_Is400ValidationFailed() throws Exception {
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"));
}
@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() {
@@ -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