Public Access
Compare commits
2
Commits
fd79c63441
...
1cac0c6b81
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cac0c6b81 | ||
|
|
47a03d2e6a |
No files matched your search
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -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;
|
||||||
}
|
}
|
||||||
+3
-1
@@ -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);
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
|||||||
+10
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user