Public Access
feat(mail): mails at registration, block and delete
This commit is contained in:
1 parent
47a03d2e6a
commit
1cac0c6b81
17 files changed
+356
-12
No files matched your search
@@ -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
|
||||
<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
|
||||
`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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ public abstract class ApiMapper {
|
||||
rawPassword(registrationRequest.password()).
|
||||
inviteCode(registrationRequest.inviteCode()).
|
||||
displayName(registrationRequest.displayName()).
|
||||
language(registrationRequest.language()).
|
||||
build();
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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);
|
||||
|
||||
@@ -14,4 +14,5 @@ public class Registration {
|
||||
private String encryptedPassword;
|
||||
private String inviteCode;
|
||||
private String displayName;
|
||||
private String language;
|
||||
}
|
||||
+3
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+10
@@ -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
|
||||
|
||||
Reference in new issue
Block a user