Compare commits

...
2 Commits
Author SHA1 Message Date
lobo 1cac0c6b81 feat(mail): mails at registration, block and delete
Build, Test & Push Auth Service / docker (push) Successful in 1m22s
Build, Test & Push Auth Service / quality-check (push) Successful in 1m12s
2026-10-01 08:30:24 +02:00
lobo 47a03d2e6a feat(mail): outbox, SMTP sender and config 2026-10-01 08:30:24 +02:00
33 changed files with 1213 additions and 14 deletions

No files matched your search

+4
View File
@@ -40,6 +40,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId> <artifactId>spring-boot-starter-cache</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- Rest Caching --> <!-- Rest Caching -->
<dependency> <dependency>
<groupId>com.github.ben-manes.caffeine</groupId> <groupId>com.github.ben-manes.caffeine</groupId>
+31 -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
@@ -148,6 +152,9 @@ the login module writes "view as" to the audit table through `shared/audit/Admin
use each other. use each other.
`hint` (dashboard hints and their admin API) uses `JdbcTemplate` in a `repo` package too, and audits through `hint` (dashboard hints and their admin API) uses `JdbcTemplate` in a `repo` package too, and audits through
`shared/audit/AdminAuditLog`. `shared/audit/AdminAuditLog`.
`mail` writes each mail as a finished text into `mail_outbox` (`JdbcTemplate`, `repo`), in the transaction of the
event, and `MailOutboxService` sends the rows every minute over SMTP. Other modules reach it through the port
`shared/mail/AccountMailer`. Without `SPRING_MAIL_HOST` mail is off: the rows wait and go out once a host is set.
ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer ArchUnit enforces these rules in `src/test/java/auth_service/architecture/ArchitectureTest.java`, so a layer
violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base violation fails the build. It also keeps the modules independent of each other and free of cycles. Test base
@@ -220,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)
@@ -235,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
@@ -303,6 +330,9 @@ Configuration comes from environment variables:
| `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) | | `APP_PUBLIC_URL` | Auth's public address, the Steam OpenID realm (default `http://localhost:8080`) |
| `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) | | `APP_FRONTEND_URL` | Where the Steam link ends (default `http://localhost:5173`) |
| `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) | | `APP_CORE_INTERNAL_URL` | Core for the nightly account cleanup, required (local `http://localhost:8090`, Docker `http://tbp-core-service:8090`) |
| `SPRING_MAIL_HOST`, `SPRING_MAIL_USERNAME`, `SPRING_MAIL_PASSWORD` | SMTP server and its login (port 465, SSL). No host = mail off |
| `APP_MAIL_FROM` | Sender address, the mailbox of the SMTP login. Needed with a host |
| `APP_MAIL_ADMIN_TO` | Gets "new user registered". Needed with a host |
Generate a keypair: Generate a keypair:
@@ -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);
} }
@@ -0,0 +1,4 @@
package auth_service.modules.mail.model;
public record Mail(String subject, String body) {
}
@@ -0,0 +1,106 @@
package auth_service.modules.mail.model;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
/**
* Plain text only: names and addresses go in as they are, and no mail client renders them as HTML.
*/
public enum MailTemplate {
NEW_USER_REGISTERED(
"New registration on ToBePlayed", """
A new user has registered.
Name: %s
Email: %s
""",
"Neue Registrierung bei ToBePlayed", """
Ein neuer Nutzer hat sich registriert.
Name: %s
E-Mail: %s
"""),
ACCOUNT_ACTIVE(
"Your ToBePlayed account is active", """
Hello %s,
your account is active. You can log in now:
%s
ToBePlayed
""",
"Dein ToBePlayed-Konto ist aktiv", """
Hallo %s,
dein Konto ist aktiv. Du kannst dich jetzt anmelden:
%s
ToBePlayed
"""),
ACCOUNT_BLOCKED(
"Your ToBePlayed account is blocked", """
Hello %s,
an administrator has blocked your account, so you cannot log in.
If you think this is a mistake, answer this mail.
ToBePlayed
""",
"Dein ToBePlayed-Konto ist gesperrt", """
Hallo %s,
ein Administrator hat dein Konto gesperrt. Du kannst dich nicht mehr anmelden.
Wenn das ein Fehler ist, antworte auf diese Mail.
ToBePlayed
"""),
ACCOUNT_DELETED(
"Your ToBePlayed account will be deleted", """
Hello %s,
your account is marked for deletion, and you cannot log in any more.
All your data will be deleted from %s.
ToBePlayed
""",
"Dein ToBePlayed-Konto wird gelöscht", """
Hallo %s,
dein Konto ist zum Löschen markiert. Du kannst dich nicht mehr anmelden.
Alle deine Daten werden ab dem %s gelöscht.
ToBePlayed
""");
private static final DateTimeFormatter DATE_DE = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private static final DateTimeFormatter DATE_EN = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.ENGLISH);
private final Mail en;
private final Mail de;
MailTemplate(String subjectEn, String bodyEn, String subjectDe, String bodyDe) {
this.en = new Mail(subjectEn, bodyEn);
this.de = new Mail(subjectDe, bodyDe);
}
/**
* German for "de", English for any other or no language.
*/
public Mail render(String language, Object... args) {
Mail mail = isGerman(language) ? de : en;
return new Mail(mail.subject(), mail.body().formatted(args));
}
public static String date(String language, LocalDate date) {
return (isGerman(language) ? DATE_DE : DATE_EN).format(date);
}
private static boolean isGerman(String language) {
return "de".equals(language);
}
}
@@ -0,0 +1,4 @@
package auth_service.modules.mail.model;
public record OutboxRow(long id, String kind, String toAddress, String subject, String body, int attempts) {
}
@@ -0,0 +1,11 @@
package auth_service.modules.mail.model;
/**
* @param language '' or null when the user never chose one
*/
public record Recipient(String email, String displayName, String language) {
public String greeting() {
return displayName == null || displayName.isBlank() ? email : displayName;
}
}
@@ -0,0 +1,72 @@
package auth_service.modules.mail.repo;
import auth_service.modules.mail.model.Mail;
import auth_service.modules.mail.model.MailTemplate;
import auth_service.modules.mail.model.OutboxRow;
import auth_service.modules.mail.model.Recipient;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@Repository
public class MailOutboxRepository {
private static final int MAX_ERROR_LENGTH = 500;
private final JdbcTemplate jdbc;
public MailOutboxRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public Optional<Recipient> findRecipient(long userId) {
return jdbc.query("""
SELECT u.email, u.display_name, s.language
FROM users u LEFT JOIN user_settings s ON s.user_id = u.id
WHERE u.id = ?""",
(rs, row) -> new Recipient(rs.getString("email"), rs.getString("display_name"), rs.getString("language")),
userId).stream().findFirst();
}
public void insert(MailTemplate kind, long userId, String toAddress, Mail mail) {
jdbc.update("INSERT INTO mail_outbox (kind, user_id, to_address, subject, body) VALUES (?, ?, ?, ?, ?)",
kind.name(), userId, toAddress, mail.subject(), mail.body());
}
public List<OutboxRow> findDue(Instant now, int limit) {
return jdbc.query("""
SELECT id, kind, to_address, subject, body, attempts FROM mail_outbox
WHERE status = 'PENDING' AND next_attempt_at <= ?
ORDER BY id LIMIT ?""",
(rs, row) -> new OutboxRow(rs.getLong("id"), rs.getString("kind"), rs.getString("to_address"),
rs.getString("subject"), rs.getString("body"), rs.getInt("attempts")),
Timestamp.from(now), limit);
}
public void markSent(long id, Instant now) {
jdbc.update("UPDATE mail_outbox SET status = 'SENT', sent_at = ?, last_error = NULL WHERE id = ?",
Timestamp.from(now), id);
}
public void markRetry(long id, int attempts, Instant nextAttemptAt, String error) {
jdbc.update("UPDATE mail_outbox SET attempts = ?, next_attempt_at = ?, last_error = ? WHERE id = ?",
attempts, Timestamp.from(nextAttemptAt), shorten(error), id);
}
public void markFailed(long id, int attempts, String error) {
jdbc.update("UPDATE mail_outbox SET status = 'FAILED', attempts = ?, last_error = ? WHERE id = ?",
attempts, shorten(error), id);
}
public void deleteSentBefore(Instant cutoff) {
jdbc.update("DELETE FROM mail_outbox WHERE status = 'SENT' AND sent_at < ?", Timestamp.from(cutoff));
}
private static String shorten(String error) {
return error == null || error.length() <= MAX_ERROR_LENGTH ? error : error.substring(0, MAX_ERROR_LENGTH);
}
}
@@ -0,0 +1,94 @@
package auth_service.modules.mail.service;
import auth_service.modules.mail.model.OutboxRow;
import auth_service.modules.mail.repo.MailOutboxRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.MailException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
/**
* Sends the rows of {@code mail_outbox}. Mail is off without {@code SPRING_MAIL_HOST}: the rows wait and go out once
* a host is set.
*/
@Service
@Slf4j
public class MailOutboxService {
// About 4 hours of tries with the backoff below.
static final int MAX_ATTEMPTS = 10;
static final Duration KEEP_SENT = Duration.ofDays(30);
private static final int BATCH = 20;
private final MailOutboxRepository repository;
private final JavaMailSender sender;
private final String from;
// An empty SPRING_MAIL_HOST still creates Boot's sender bean, so the host decides, not the bean.
public MailOutboxService(MailOutboxRepository repository, ObjectProvider<JavaMailSender> sender,
@Value("${spring.mail.host:}") String host, @Value("${app.mail.from}") String from,
@Value("${app.mail.admin-to}") String adminTo) {
this.repository = repository;
this.from = from;
if (host.isBlank()) {
this.sender = null;
log.info("Mail is off: no SPRING_MAIL_HOST");
return;
}
if (from.isBlank() || adminTo.isBlank()) {
throw new IllegalStateException("SPRING_MAIL_HOST is set, so APP_MAIL_FROM and APP_MAIL_ADMIN_TO are needed");
}
this.sender = sender.getObject();
}
// At least once: when the server took a mail but markSent fails, the next run sends it again.
// ponytail: no row lock, safe because one Auth instance runs and Spring starts a cron run only after the last
// one ended; use SELECT ... FOR UPDATE SKIP LOCKED before running a second instance.
@Scheduled(cron = "${app.mail.cron}")
public void sendDue() {
if (sender == null) {
return;
}
Instant now = Instant.now();
for (OutboxRow row : repository.findDue(now, BATCH)) {
send(row, now);
}
repository.deleteSentBefore(now.minus(KEEP_SENT));
}
private void send(OutboxRow row, Instant now) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(from);
message.setTo(row.toAddress());
message.setSubject(row.subject());
message.setText(row.body());
try {
sender.send(message);
repository.markSent(row.id(), now);
} catch (MailException e) {
int attempts = row.attempts() + 1;
// The SMTP answer can name the address, so only last_error gets it.
if (attempts >= MAX_ATTEMPTS) {
repository.markFailed(row.id(), attempts, e.getMessage());
log.warn("Mail {} ({}) given up after {} tries: {}", row.id(), row.kind(), attempts,
e.getClass().getSimpleName());
} else {
repository.markRetry(row.id(), attempts, now.plus(backoff(attempts)), e.getMessage());
log.info("Mail {} ({}) failed, try {} of {}: {}", row.id(), row.kind(), attempts, MAX_ATTEMPTS,
e.getClass().getSimpleName());
}
}
}
// 1, 2, 4 ... 32 minutes, then the hour; attempts stay below MAX_ATTEMPTS, so the shift cannot overflow.
static Duration backoff(int attempts) {
return Duration.ofMinutes(Math.min(1L << (attempts - 1), 60));
}
}
@@ -0,0 +1,60 @@
package auth_service.modules.mail.service;
import auth_service.modules.mail.model.MailTemplate;
import auth_service.modules.mail.model.Recipient;
import auth_service.modules.mail.repo.MailOutboxRepository;
import auth_service.shared.mail.AccountMailer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
/**
* Renders each mail when the event happens and stores the finished text, so a later change of the name or the
* language does not change a mail that waits.
*/
@Service
public class MailService implements AccountMailer {
// The admin reads German.
private static final String ADMIN_LANGUAGE = "de";
private final MailOutboxRepository repository;
private final String adminTo;
private final String frontendUrl;
public MailService(MailOutboxRepository repository, @Value("${app.mail.admin-to}") String adminTo,
@Value("${app.frontend-url}") String frontendUrl) {
this.repository = repository;
this.adminTo = adminTo;
this.frontendUrl = frontendUrl;
}
@Override
public void userRegistered(long userId) {
repository.findRecipient(userId).ifPresent(user -> {
// Blank only while mail is off: with a host, MailOutboxService stops the start without it.
if (!adminTo.isBlank()) {
repository.insert(MailTemplate.NEW_USER_REGISTERED, userId, adminTo, MailTemplate.NEW_USER_REGISTERED
.render(ADMIN_LANGUAGE, user.displayName() == null ? "-" : user.displayName(), user.email()));
}
insertForUser(MailTemplate.ACCOUNT_ACTIVE, userId, user, user.greeting(), frontendUrl);
});
}
@Override
public void accountBlocked(long userId) {
repository.findRecipient(userId).ifPresent(user ->
insertForUser(MailTemplate.ACCOUNT_BLOCKED, userId, user, user.greeting()));
}
@Override
public void accountDeleted(long userId, LocalDate deletedFrom) {
repository.findRecipient(userId).ifPresent(user -> insertForUser(MailTemplate.ACCOUNT_DELETED, userId, user,
user.greeting(), MailTemplate.date(user.language(), deletedFrom)));
}
private void insertForUser(MailTemplate template, long userId, Recipient user, Object... args) {
repository.insert(template, userId, user.email(), template.render(user.language(), args));
}
}
@@ -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) {
@@ -0,0 +1,22 @@
package auth_service.shared.mail;
import java.time.LocalDate;
/**
* Port for the mails about an account. Each call writes the mail in the caller's transaction; the mail job sends it
* later, so a call never fails because of the mail server.
*/
public interface AccountMailer {
/**
* "New user registered" to the admin and "your account is active" to the user.
*/
void userRegistered(long userId);
void accountBlocked(long userId);
/**
* @param deletedFrom the first day the nightly cleanup may remove the account
*/
void accountDeleted(long userId, LocalDate deletedFrom);
}
+20
View File
@@ -18,6 +18,16 @@ spring:
threads: threads:
virtual: virtual:
enabled: true enabled: true
# Host, user and password only from the env (SPRING_MAIL_HOST, SPRING_MAIL_USERNAME, SPRING_MAIL_PASSWORD).
# No host = mail off. The values fit Hostinger: SMTP over SSL on port 465.
mail:
port: 465
properties:
"[mail.smtp.auth]": true
"[mail.smtp.ssl.enable]": true
"[mail.smtp.connectiontimeout]": 10000
"[mail.smtp.timeout]": 10000
"[mail.smtp.writetimeout]": 10000
# Actuator & Micrometer Settings # Actuator & Micrometer Settings
@@ -29,6 +39,10 @@ management:
endpoint: endpoint:
health: health:
show-details: never show-details: never
health:
mail:
# A down mail server must not make Auth unhealthy: the outbox waits and retries.
enabled: false
# Security stuff # Security stuff
security: security:
@@ -63,6 +77,12 @@ app:
retention: 30d retention: 30d
# Log only, delete nothing. See the Deployment README before flipping it. # Log only, delete nothing. See the Deployment README before flipping it.
dry-run: ${APP_ACCOUNT_CLEANUP_DRY_RUN:true} dry-run: ${APP_ACCOUNT_CLEANUP_DRY_RUN:true}
mail:
# The mailbox the SMTP login belongs to; Hostinger refuses any other sender.
from: ${APP_MAIL_FROM:}
# Gets "new user registered". Only in the env file, never in git.
admin-to: ${APP_MAIL_ADMIN_TO:}
cron: "0 * * * * *"
# Registration settings # Registration settings
auth: auth:
@@ -0,0 +1,17 @@
-- One row per mail; the mail job sends PENDING rows and retries them. The rows hold an email and a name, so they go
-- with the user at the purge (the admin row too: user_id is the new user).
CREATE TABLE mail_outbox (
id BIGSERIAL PRIMARY KEY,
kind VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
to_address VARCHAR(254) NOT NULL,
subject VARCHAR(200) NOT NULL,
body TEXT NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'SENT', 'FAILED')),
attempts INT NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_error VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
sent_at TIMESTAMPTZ
);
CREATE INDEX idx_mail_outbox_due ON mail_outbox (next_attempt_at) WHERE status = 'PENDING';
@@ -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,53 @@
package auth_service.modules.mail.model;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
class MailTemplateTest extends AbstractUnitTest {
@ParameterizedTest
@EnumSource(MailTemplate.class)
void render_FillsEveryPlaceholderInBothLanguages(MailTemplate template) {
for (String language : new String[]{"de", "en"}) {
Mail mail = template.render(language, "Alice", "alice@example.com");
assertThat(mail.subject()).isNotBlank().doesNotContain("%");
assertThat(mail.body()).contains("Alice").doesNotContain("%s");
}
}
@Test
void render_PicksGermanForDe() {
assertThat(MailTemplate.ACCOUNT_BLOCKED.render("de", "Alice").body()).startsWith("Hallo Alice,");
assertThat(MailTemplate.ACCOUNT_BLOCKED.render("en", "Alice").body()).startsWith("Hello Alice,");
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {"fr", "DE-de"})
void render_FallsBackToEnglish(String language) {
assertThat(MailTemplate.ACCOUNT_BLOCKED.render(language, "Alice").body()).startsWith("Hello Alice,");
}
@Test
void render_KeepsAPercentSignInANameAsText() {
assertThat(MailTemplate.ACCOUNT_BLOCKED.render("en", "100%s").body()).startsWith("Hello 100%s,");
}
@Test
void date_IsWrittenThePerLanguageWay() {
LocalDate date = LocalDate.of(2026, 11, 5);
assertThat(MailTemplate.date("de", date)).isEqualTo("05.11.2026");
assertThat(MailTemplate.date("en", date)).isEqualTo("5 November 2026");
assertThat(MailTemplate.date(null, date)).isEqualTo("5 November 2026");
}
}
@@ -0,0 +1,129 @@
package auth_service.modules.mail.repo;
import auth_service.modules.mail.model.Mail;
import auth_service.modules.mail.model.MailTemplate;
import auth_service.modules.mail.model.OutboxRow;
import auth_service.modules.mail.model.Recipient;
import auth_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Duration;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class MailOutboxRepositoryTest extends AbstractPersistenceTest {
@Autowired
private MailOutboxRepository repository;
@Autowired
private JdbcTemplate jdbc;
private final Instant now = Instant.now();
private long userId;
@BeforeEach
void clean() {
jdbc.update("DELETE FROM mail_outbox");
jdbc.update("DELETE FROM users WHERE email LIKE 'mail-repo%'");
userId = jdbc.queryForObject("INSERT INTO users (email, password, enabled) "
+ "VALUES ('mail-repo-user@example.com', 'x', true) RETURNING id", Long.class);
}
private long insert() {
repository.insert(MailTemplate.ACCOUNT_BLOCKED, userId, "alice@example.com", new Mail("Subject", "Body"));
return jdbc.queryForObject("SELECT MAX(id) FROM mail_outbox", Long.class);
}
private String status(long id) {
return jdbc.queryForObject("SELECT status FROM mail_outbox WHERE id = ?", String.class, id);
}
@Test
void findDue_ReturnsANewRow() {
long id = insert();
assertThat(repository.findDue(now.plusSeconds(1), 10))
.containsExactly(new OutboxRow(id, "ACCOUNT_BLOCKED", "alice@example.com", "Subject", "Body", 0));
}
@Test
void findDue_KeepsTheLimitAndTheOrder() {
long first = insert();
insert();
assertThat(repository.findDue(now.plusSeconds(1), 1)).extracting(OutboxRow::id).containsExactly(first);
}
@Test
void markRetry_HidesTheRowUntilItsTime() {
long id = insert();
repository.markRetry(id, 1, now.plus(Duration.ofMinutes(5)), "refused");
assertThat(repository.findDue(now.plusSeconds(1), 10)).isEmpty();
assertThat(repository.findDue(now.plus(Duration.ofMinutes(6)), 10)).extracting(OutboxRow::attempts).containsExactly(1);
assertThat(jdbc.queryForObject("SELECT last_error FROM mail_outbox WHERE id = ?", String.class, id)).isEqualTo("refused");
}
@Test
void markSentAndMarkFailed_EndTheRow() {
long sent = insert();
long failed = insert();
repository.markSent(sent, now);
repository.markFailed(failed, 10, "rejected");
assertThat(repository.findDue(now.plus(Duration.ofDays(1)), 10)).isEmpty();
assertThat(status(sent)).isEqualTo("SENT");
assertThat(status(failed)).isEqualTo("FAILED");
}
@Test
void markRetry_CutsALongError() {
long id = insert();
repository.markRetry(id, 1, now, "x".repeat(2000));
assertThat(jdbc.queryForObject("SELECT LENGTH(last_error) FROM mail_outbox WHERE id = ?", Integer.class, id))
.isEqualTo(500);
}
@Test
void deleteSentBefore_RemovesOnlyOldSentRows() {
long oldSent = insert();
long newSent = insert();
long failed = insert();
repository.markSent(oldSent, now.minus(Duration.ofDays(40)));
repository.markSent(newSent, now);
repository.markFailed(failed, 10, "rejected");
repository.deleteSentBefore(now.minus(Duration.ofDays(30)));
assertThat(jdbc.queryForList("SELECT id FROM mail_outbox ORDER BY id", Long.class)).containsExactly(newSent, failed);
}
@Test
void thePurgeOfAUser_RemovesTheirRows() {
insert();
jdbc.update("DELETE FROM users WHERE id = ?", userId);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox", Integer.class)).isZero();
}
@Test
void findRecipient_ReadsEmailNameAndLanguage() {
long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, display_name) "
+ "VALUES ('mail-repo@example.com', 'x', true, 'MailRepo') RETURNING id", Long.class);
jdbc.update("INSERT INTO user_settings (user_id, language) VALUES (?, 'de')", id);
assertThat(repository.findRecipient(id)).contains(new Recipient("mail-repo@example.com", "MailRepo", "de"));
assertThat(repository.findRecipient(-1)).isEmpty();
}
}
@@ -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);
}
}
@@ -0,0 +1,142 @@
package auth_service.modules.mail.service;
import auth_service.modules.mail.model.OutboxRow;
import auth_service.modules.mail.repo.MailOutboxRepository;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.mail.MailSendException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class MailOutboxServiceTest extends AbstractUnitTest {
private static final String FROM = "info@example.com";
private static final String ADMIN = "admin@example.com";
@Mock
private MailOutboxRepository repository;
@Mock
private JavaMailSender sender;
@Mock
private ObjectProvider<JavaMailSender> senderProvider;
private MailOutboxService service() {
when(senderProvider.getObject()).thenReturn(sender);
return new MailOutboxService(repository, senderProvider, "smtp.example.com", FROM, ADMIN);
}
private static OutboxRow row(long id, int attempts) {
return new OutboxRow(id, "ACCOUNT_BLOCKED", "alice@example.com", "Subject", "Body", attempts);
}
@Test
void sendDue_SendsADueRowFromTheMailboxAndMarksItSent() {
when(repository.findDue(any(), anyInt())).thenReturn(List.of(row(1, 0)));
service().sendDue();
ArgumentCaptor<SimpleMailMessage> message = ArgumentCaptor.forClass(SimpleMailMessage.class);
verify(sender).send(message.capture());
assertThat(message.getValue().getFrom()).isEqualTo(FROM);
assertThat(message.getValue().getTo()).containsExactly("alice@example.com");
assertThat(message.getValue().getSubject()).isEqualTo("Subject");
assertThat(message.getValue().getText()).isEqualTo("Body");
verify(repository).markSent(eq(1L), any());
}
@Test
void sendDue_SchedulesARetryAfterAFailure() {
when(repository.findDue(any(), anyInt())).thenReturn(List.of(row(1, 2)));
doThrow(new MailSendException("connection refused")).when(sender).send(any(SimpleMailMessage.class));
Instant before = Instant.now();
service().sendDue();
ArgumentCaptor<Instant> next = ArgumentCaptor.forClass(Instant.class);
verify(repository).markRetry(eq(1L), eq(3), next.capture(), eq("connection refused"));
assertThat(next.getValue()).isAfterOrEqualTo(before.plus(Duration.ofMinutes(4)));
verify(repository, never()).markSent(anyLong(), any());
}
@Test
void sendDue_GivesUpAfterTheLastTry() {
when(repository.findDue(any(), anyInt())).thenReturn(List.of(row(1, MailOutboxService.MAX_ATTEMPTS - 1)));
doThrow(new MailSendException("rejected")).when(sender).send(any(SimpleMailMessage.class));
service().sendDue();
verify(repository).markFailed(1L, MailOutboxService.MAX_ATTEMPTS, "rejected");
verify(repository, never()).markRetry(anyLong(), anyInt(), any(), any());
}
@Test
void sendDue_GoesOnWithTheNextRowAfterAFailure() {
when(repository.findDue(any(), anyInt())).thenReturn(List.of(row(1, 0), row(2, 0)));
doThrow(new MailSendException("rejected")).doNothing().when(sender).send(any(SimpleMailMessage.class));
service().sendDue();
verify(repository).markRetry(eq(1L), eq(1), any(), eq("rejected"));
verify(repository).markSent(eq(2L), any());
}
@Test
void sendDue_RemovesOldSentRows() {
when(repository.findDue(any(), anyInt())).thenReturn(List.of());
Instant before = Instant.now();
service().sendDue();
ArgumentCaptor<Instant> cutoff = ArgumentCaptor.forClass(Instant.class);
verify(repository).deleteSentBefore(cutoff.capture());
assertThat(cutoff.getValue()).isBeforeOrEqualTo(Instant.now().minus(MailOutboxService.KEEP_SENT))
.isAfterOrEqualTo(before.minus(MailOutboxService.KEEP_SENT));
}
@Test
void backoff_DoublesUpToAnHour() {
assertThat(MailOutboxService.backoff(1)).isEqualTo(Duration.ofMinutes(1));
assertThat(MailOutboxService.backoff(2)).isEqualTo(Duration.ofMinutes(2));
assertThat(MailOutboxService.backoff(6)).isEqualTo(Duration.ofMinutes(32));
assertThat(MailOutboxService.backoff(7)).isEqualTo(Duration.ofHours(1));
assertThat(MailOutboxService.backoff(40)).isEqualTo(Duration.ofHours(1));
}
@Test
void sendDue_DoesNothingWithoutAHost() {
MailOutboxService off = new MailOutboxService(repository, senderProvider, "", "", "");
off.sendDue();
verifyNoInteractions(repository, senderProvider);
}
@Test
void constructor_RefusesAHostWithoutSenderOrAdminAddress() {
assertThatThrownBy(() -> new MailOutboxService(repository, senderProvider, "smtp.example.com", "", ADMIN))
.isInstanceOf(IllegalStateException.class).hasMessageContaining("APP_MAIL_FROM");
assertThatThrownBy(() -> new MailOutboxService(repository, senderProvider, "smtp.example.com", FROM, " "))
.isInstanceOf(IllegalStateException.class).hasMessageContaining("APP_MAIL_ADMIN_TO");
}
}
@@ -0,0 +1,104 @@
package auth_service.modules.mail.service;
import auth_service.modules.mail.model.Mail;
import auth_service.modules.mail.model.MailTemplate;
import auth_service.modules.mail.model.Recipient;
import auth_service.modules.mail.repo.MailOutboxRepository;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import java.time.LocalDate;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MailServiceTest extends AbstractUnitTest {
private static final long USER = 7L;
private static final String ADMIN = "admin@example.com";
@Mock
private MailOutboxRepository repository;
private MailService service(String adminTo) {
return new MailService(repository, adminTo, "https://tbp.example");
}
private void user(String displayName, String language) {
when(repository.findRecipient(USER)).thenReturn(Optional.of(new Recipient("alice@example.com", displayName, language)));
}
private Mail inserted(MailTemplate template, String to) {
ArgumentCaptor<Mail> mail = ArgumentCaptor.forClass(Mail.class);
verify(repository).insert(eq(template), eq(USER), eq(to), mail.capture());
return mail.getValue();
}
@Test
void userRegistered_TellsTheAdminInGermanAndTheUserInTheirLanguage() {
user("Alice", "en");
service(ADMIN).userRegistered(USER);
assertThat(inserted(MailTemplate.NEW_USER_REGISTERED, ADMIN).body())
.startsWith("Ein neuer Nutzer").contains("Alice", "alice@example.com");
assertThat(inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com").body())
.startsWith("Hello Alice,").contains("https://tbp.example");
}
@Test
void userRegistered_GreetsByEmailWithoutADisplayName() {
user(null, "de");
service(ADMIN).userRegistered(USER);
assertThat(inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com").body()).startsWith("Hallo alice@example.com,");
assertThat(inserted(MailTemplate.NEW_USER_REGISTERED, ADMIN).body()).contains("Name: -");
}
@Test
void userRegistered_WritesNoAdminRowWithoutAnAdminAddress() {
user("Alice", "en");
service("").userRegistered(USER);
verify(repository, never()).insert(eq(MailTemplate.NEW_USER_REGISTERED), anyLong(), anyString(), any());
inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com");
}
@Test
void accountBlocked_WritesOneRow() {
user("Alice", "de");
service(ADMIN).accountBlocked(USER);
assertThat(inserted(MailTemplate.ACCOUNT_BLOCKED, "alice@example.com").subject()).contains("gesperrt");
}
@Test
void accountDeleted_NamesTheDateInTheUserLanguage() {
user("Alice", "de");
service(ADMIN).accountDeleted(USER, LocalDate.of(2026, 11, 5));
assertThat(inserted(MailTemplate.ACCOUNT_DELETED, "alice@example.com").body()).contains("ab dem 05.11.2026");
}
@Test
void anUnknownUserGetsNoMail() {
when(repository.findRecipient(USER)).thenReturn(Optional.empty());
service(ADMIN).accountBlocked(USER);
verify(repository, never()).insert(any(), anyLong(), anyString(), any());
}
}
@@ -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
@@ -8,9 +8,11 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.containers.PostgreSQLContainer;
import java.security.KeyPair; import java.security.KeyPair;
@@ -25,11 +27,16 @@ import java.util.Date;
*/ */
@Tag("IntegrationTest") @Tag("IntegrationTest")
@AutoConfigureMockMvc @AutoConfigureMockMvc
// The account cleanup must not run by itself, and Core is never reachable in tests. // The account cleanup and the mail job must not run on their own, and Core is never reachable in tests. Mail is
// on, with a mocked sender.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"app.account-cleanup.cron=-", "app.core.internal-url=http://127.0.0.1:1"}) properties = {"app.account-cleanup.cron=-", "app.core.internal-url=http://127.0.0.1:1", "app.mail.cron=-",
"spring.mail.host=localhost", "app.mail.from=noreply@test.local", "app.mail.admin-to=admin@test.local"})
public abstract class AbstractIntegrationTest { public abstract class AbstractIntegrationTest {
@MockitoBean
protected JavaMailSender mailSender;
@ServiceConnection @ServiceConnection
static PostgreSQLContainer<?> postgres = TestPostgres.CONTAINER; static PostgreSQLContainer<?> postgres = TestPostgres.CONTAINER;