feat(registration): add email confirmation flow
Build, Test & Push Auth Service / quality-check (push) Successful in 58s
Build, Test & Push Auth Service / docker (push) Successful in 1m21s

Registrations become two-phase: first UNCONFIRMED with a 24-hour
single-use email link, then PENDING after confirmation and password
entry. Prevents registering with someone else's address. Admins see
unconfirmed requests and can delete them; they cannot accept them.
This commit is contained in:
lobo committed 2026-10-01 16:20:51 +02:00
1 parent 878ede914b
commit 0af76da9c7
43 files changed
+959 -145

No files matched your search

@@ -192,7 +192,7 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
}
@Test
void registration_WaitsAndDoesNotCountUntilTheAccept() throws Exception {
void registration_IsUnconfirmedAndDoesNotCount() throws Exception {
String newEmail = "delete-flow-registered@example.com";
jdbc.update("DELETE FROM users WHERE email = ?", newEmail);
long before = totalUsers();
@@ -201,12 +201,11 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
mockMvc.perform(post("/api/registration/new")
.header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + newEmail + "\",\"password\":\"" + PASSWORD
+ "\"}"))
.content("{\"email\":\"" + newEmail + "\"}"))
.andExpect(status().is2xxSuccessful());
assertThat(jdbc.queryForObject("SELECT status FROM users WHERE email = ?", String.class, newEmail))
.isEqualTo("PENDING");
.isEqualTo("UNCONFIRMED");
assertThat(totalUsers()).isEqualTo(before);
} finally {
jdbc.update("DELETE FROM users WHERE email = ?", newEmail);
@@ -220,8 +219,7 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
mockMvc.perform(post("/api/registration/new")
.header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + EMAIL + "\",\"password\":\"" + PASSWORD
+ "\"}"))
.content("{\"email\":\"" + EMAIL + "\"}"))
.andExpect(jsonPath("$.code").value("EMAIL_ALREADY_IN_USE"));
}
}
@@ -9,6 +9,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.UUID;
@@ -100,4 +101,35 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
assertThat(count("select count(*) from users where id = ?", bob)).isEqualTo(1);
assertThat(repository.deleteUsers(List.of())).isZero();
}
@Test
void deleteUnconfirmedBefore_RemovesOldUnconfirmedUsersWithTheirSettingsOnly() {
long old = insertUser("old-unconfirmed@example.com");
long recent = insertUser("recent-unconfirmed@example.com");
long oldPending = insertUser("old-pending@example.com");
LocalDateTime now = LocalDateTime.now();
jdbc.update("update users set status = 'UNCONFIRMED', registration_date = ? where id = ?", now.minusDays(8), old);
jdbc.update("update users set status = 'UNCONFIRMED', registration_date = ? where id = ?", now.minusDays(6), recent);
jdbc.update("update users set status = 'PENDING', registration_date = ? where id = ?", now.minusDays(8), oldPending);
assertThat(repository.deleteUnconfirmedBefore(now.minusDays(7))).isEqualTo(1);
assertThat(count("select count(*) from users where id = ?", old)).isZero();
assertThat(count("select count(*) from user_settings where user_id = ?", old)).isZero();
assertThat(count("select count(*) from users where id in (?, ?)", recent, oldPending)).isEqualTo(2);
}
@Test
void deleteUnconfirmedBefore_KeepsAnOldUnconfirmedUserWhoseResentLinkIsStillValid() {
long oldWithValidLink = insertUser("old-valid-link@example.com");
LocalDateTime now = LocalDateTime.now();
jdbc.update("""
update users set status = 'UNCONFIRMED', registration_date = ?, email_confirmation_hash = 'h',
email_confirmation_expires_at = now() + interval '20 hours' where id = ?""",
now.minusDays(8), oldWithValidLink);
assertThat(repository.deleteUnconfirmedBefore(now.minusDays(7))).isZero();
assertThat(count("select count(*) from users where id = ?", oldWithValidLink)).isEqualTo(1);
}
}
@@ -6,17 +6,23 @@ import auth_service.modules.account.repo.AccountRepository;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.springframework.web.client.RestClientException;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
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.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -140,4 +146,19 @@ class AccountCleanupServiceTest extends AbstractUnitTest {
verify(accountRepository).deleteUsers(List.of(7L));
}
@Test
void runNightly_PurgesUnconfirmedRegistrationsEvenInDryRunAndBeforeTheCoreCalls() {
when(coreAccountService.purge(any(), anyBoolean())).thenReturn(core(true));
when(coreAccountService.prune(any(), anyBoolean())).thenReturn(core(true));
service(true).runNightly();
InOrder order = inOrder(accountRepository, coreAccountService);
ArgumentCaptor<LocalDateTime> cutoff = ArgumentCaptor.forClass(LocalDateTime.class);
order.verify(accountRepository).deleteUnconfirmedBefore(cutoff.capture());
order.verify(coreAccountService).replaceDeleted(any());
assertThat(cutoff.getValue()).isBetween(LocalDateTime.now(Clock.systemUTC()).minusDays(7).minusMinutes(1),
LocalDateTime.now(Clock.systemUTC()).minusDays(7).plusMinutes(1));
}
}
@@ -134,6 +134,46 @@ class AdminFlowTest extends AbstractIntegrationTest {
decide(admin, playerId, true).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NOT_PENDING"));
}
private ResultActions deleteUnconfirmed(Session session, long userId) throws Exception {
return mockMvc.perform(delete("/api/admin/users/" + userId + "/unconfirmed")
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access())));
}
@Test
void unconfirmedRequest_IsListedCannotBeAcceptedAndTheAdminDeletesIt() throws Exception {
jdbc.update("UPDATE users SET status = 'UNCONFIRMED' WHERE id = ?", playerId);
Session admin = Session.of(login(ADMIN));
mockMvc.perform(get("/api/admin/users").param("status", "UNCONFIRMED")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access())))
.andExpect(jsonPath("$.content[?(@.email == '" + PLAYER + "')].status").value("UNCONFIRMED"));
decide(admin, playerId, true).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NOT_PENDING"));
block(admin, playerId, true).andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("TARGET_IS_UNCONFIRMED"));
deleteUnconfirmed(admin, playerId).andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE id = ?", Long.class, playerId)).isZero();
assertThat(audits("DELETE_UNCONFIRMED", playerId)).isEqualTo(1);
deleteUnconfirmed(admin, playerId).andExpect(status().isNotFound());
}
@Test
void deleteUnconfirmed_RefusesOtherStatusesAndAUser() throws Exception {
Session admin = Session.of(login(ADMIN));
Session player = Session.of(login(PLAYER));
deleteUnconfirmed(player, playerId).andExpect(status().isForbidden());
deleteUnconfirmed(admin, playerId).andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("NOT_UNCONFIRMED"));
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", playerId);
deleteUnconfirmed(admin, playerId).andExpect(status().isConflict());
deleteUnconfirmed(admin, otherAdminId).andExpect(status().isConflict());
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE id = ?", Long.class, playerId)).isEqualTo(1);
}
@Test
void deniedRequest_CannotLogInAndWaitsForTheCleanup() throws Exception {
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", playerId);
@@ -119,4 +119,27 @@ class AdminUserRepositoryTest extends AbstractPersistenceTest {
assertThat(jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, alice)).isEqualTo("DENIED");
assertThat(repository.findState(alice).orElseThrow().deleted()).isTrue();
}
@Test
void findPageAndCount_ListAnUnconfirmedUserAndFilterByIt() {
jdbc.update("UPDATE users SET status = 'UNCONFIRMED' WHERE id = ?", alice);
assertThat(repository.count("", "")).isEqualTo(3);
assertThat(repository.findPage("", "UNCONFIRMED", 0, 10)).extracting(AdminUser::status)
.containsExactly("UNCONFIRMED");
assertThat(repository.count("", "PENDING")).isZero();
assertThat(repository.findState(alice).orElseThrow().unconfirmed()).isTrue();
}
@Test
void deleteUnconfirmed_RemovesOnlyAnUnconfirmedUserWithItsSettings() {
assertThat(repository.deleteUnconfirmed(alice)).isFalse();
jdbc.update("UPDATE users SET status = 'UNCONFIRMED' WHERE id = ?", alice);
assertThat(repository.deleteUnconfirmed(alice)).isTrue();
assertThat(repository.deleteUnconfirmed(alice)).isFalse();
assertThat(repository.count("", "")).isEqualTo(2);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM user_settings WHERE user_id = ?", Long.class, alice))
.isZero();
}
}
@@ -56,11 +56,15 @@ class AdminUserServiceTest extends AbstractUnitTest {
}
private void target(Role role, boolean deleted) {
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(role, deleted, false)));
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(role, deleted, false, false)));
}
private void pendingTarget() {
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(Role.USER, false, true)));
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(Role.USER, false, true, false)));
}
private void unconfirmedTarget() {
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(Role.USER, false, false, true)));
}
private String refusal(Runnable action) {
@@ -194,4 +198,38 @@ class AdminUserServiceTest extends AbstractUnitTest {
assertThat(refusal(() -> service.delete(ADMIN, TARGET))).isEqualTo("TARGET_IS_PENDING");
verifyNoInteractions(accountDeleter);
}
@Test
void deleteUnconfirmed_DeletesAndAudits() {
unconfirmedTarget();
when(adminUserRepository.deleteUnconfirmed(TARGET)).thenReturn(true);
service.deleteUnconfirmed(ADMIN, TARGET);
verify(adminAuditLog).record(ADMIN, "DELETE_UNCONFIRMED", TARGET, null);
verifyNoInteractions(accountMailer, accountDeleter);
}
@Test
void deleteUnconfirmed_RefusesAnyOtherStatusAndALostRace() {
pendingTarget();
assertThat(refusal(() -> service.deleteUnconfirmed(ADMIN, TARGET))).isEqualTo("NOT_UNCONFIRMED");
target(Role.USER, false);
assertThat(refusal(() -> service.deleteUnconfirmed(ADMIN, TARGET))).isEqualTo("NOT_UNCONFIRMED");
unconfirmedTarget();
when(adminUserRepository.deleteUnconfirmed(TARGET)).thenReturn(false);
assertThat(refusal(() -> service.deleteUnconfirmed(ADMIN, TARGET))).isEqualTo("NOT_UNCONFIRMED");
verifyNoInteractions(adminAuditLog);
}
@Test
void decideBlockAndDelete_RefuseAnUnconfirmedRequest() {
unconfirmedTarget();
assertThat(refusal(() -> service.decide(ADMIN, TARGET, true))).isEqualTo("NOT_PENDING");
assertThat(refusal(() -> service.setBlocked(ADMIN, TARGET, true))).isEqualTo("TARGET_IS_UNCONFIRMED");
assertThat(refusal(() -> service.delete(ADMIN, TARGET))).isEqualTo("TARGET_IS_UNCONFIRMED");
verifyNoInteractions(accountDeleter, accountMailer);
}
}
@@ -1,6 +1,5 @@
package auth_service.modules.login.model;
import auth_service.modules.registration.model.Registration;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
@@ -20,7 +19,5 @@ class UserTest extends AbstractUnitTest {
void toString_ShowsNoPassword() {
assertThat(User.builder().email("a@example.com").password("hash-secret").build().toString())
.contains("a@example.com").doesNotContain("hash-secret");
assertThat(Registration.builder().email("a@example.com").rawPassword("raw-secret").encryptedPassword("hash-secret")
.build().toString()).doesNotContain("raw-secret").doesNotContain("hash-secret");
}
}
@@ -192,6 +192,15 @@ class LoginServiceTest extends AbstractUnitTest {
verify(loginAttemptService).recordSuccess("test@example.com", IP);
}
@Test
void login_UnconfirmedUserWithoutAPassword_LooksLikeAnyWrongPassword() {
User dbUser = User.builder().id(815L).email("test@example.com").enabled(true).build();
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
verify(tokenGenerator, never()).generateToken(any(), any(), any());
}
@Test
void login_PendingUserWithAWrongPassword_LooksLikeAnyWrongPassword() {
User dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true)
@@ -62,4 +62,13 @@ class MailTemplateTest extends AbstractUnitTest {
assertThat(MailTemplate.date("en", date)).isEqualTo("5 November 2026");
assertThat(MailTemplate.date(null, date)).isEqualTo("5 November 2026");
}
@Test
void render_ConfirmEmail_CarriesTheLinkInBothLanguages() {
for (String language : new String[]{"de", "en"}) {
Mail mail = MailTemplate.CONFIRM_EMAIL.render(language, "Alice", "https://tbp.example/confirm-email#tok");
assertThat(mail.body()).contains("https://tbp.example/confirm-email#tok");
}
}
}
@@ -6,7 +6,6 @@ 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;
@@ -14,9 +13,12 @@ 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 org.springframework.test.web.servlet.ResultActions;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -26,6 +28,7 @@ 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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
@@ -36,6 +39,9 @@ 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";
// The per-address mail limit lives in memory for an hour, so the limit tests use an address no other test mails.
private static final String OWNER = "mail-flow-owner@example.com";
private static final String CAPPED = "mail-flow-capped@example.com";
private static final String PLAYER = "mail-flow-player@example.com";
@Autowired
@@ -64,17 +70,19 @@ class MailFlowTest extends AbstractIntegrationTest {
}
private String accessToken(String email) throws Exception {
return login(email).andReturn().getResponse().getCookie(AuthCookies.ACCESS_COOKIE).getValue();
}
private ResultActions login(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();
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + email + "\",\"password\":\"" + PASSWORD + "\"}"));
}
private void register() throws Exception {
mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD
+ "\",\"displayName\":\"MailFlow\",\"language\":\"de\"}"))
.content("{\"email\":\"" + NEW_USER + "\",\"displayName\":\"MailFlow\",\"language\":\"de\"}"))
.andExpect(status().isOk());
}
@@ -83,22 +91,48 @@ class MailFlowTest extends AbstractIntegrationTest {
}
@Test
void registration_WritesBothMailsAndTheJobSendsThem() throws Exception {
void registration_MailsTheLinkFirstAndTheAdminOnlyAfterTheClick() throws Exception {
register();
assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address") + " " + row.get("status"))
.containsExactly("REGISTRATION_REQUESTED admin@test.local PENDING",
"REGISTRATION_RECEIVED " + NEW_USER + " PENDING");
assertThat(rows().get(1).get("subject")).isEqualTo("Deine ToBePlayed-Registrierung ist angekommen");
.containsExactly("CONFIRM_EMAIL " + NEW_USER + " PENDING");
assertThat(rows().get(0).get("subject")).isEqualTo("Bestätige deine E-Mail-Adresse für ToBePlayed");
String body = jdbc.queryForObject("SELECT body FROM mail_outbox", String.class);
Matcher link = Pattern.compile("/confirm-email#(\\S+)").matcher(body);
assertThat(link.find()).isTrue();
String token = link.group(1);
assertThat(body).doesNotContain(jdbc.queryForObject("SELECT email_confirmation_hash FROM users WHERE email = ?",
String.class, NEW_USER));
confirm(token, PASSWORD).andExpect(status().isNoContent());
assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address"))
.containsExactly("CONFIRM_EMAIL " + NEW_USER, "REGISTRATION_REQUESTED admin@test.local");
assertThat(jdbc.queryForObject("SELECT status FROM users WHERE email = ?", String.class, NEW_USER)).isEqualTo("PENDING");
login(NEW_USER).andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value("ACCOUNT_PENDING"));
confirm(token, PASSWORD).andExpect(status().isGone());
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");
verify(mailSender, times(2)).send(any(SimpleMailMessage.class));
assertThat(rows()).extracting(row -> row.get("status")).containsOnly("SENT");
}
@Test
void login_BeforeTheClick_IsRefusedAndTheUserHasNoPassword() throws Exception {
register();
login(NEW_USER).andExpect(status().isUnauthorized());
assertThat(jdbc.queryForObject("SELECT password FROM users WHERE email = ?", String.class, NEW_USER)).isNull();
}
private ResultActions confirm(String token, String password) throws Exception {
return mockMvc.perform(post("/api/registration/confirm").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.header("X-Forwarded-For", freshClientIp()).contentType(MediaType.APPLICATION_JSON)
.content("{\"token\":\"" + token + "\",\"password\":\"" + password + "\"}"));
}
@Test
void acceptAndDeny_MailTheUser() throws Exception {
insertUser(ADMIN, "PLATFORM_ADMIN");
@@ -128,7 +162,7 @@ class MailFlowTest extends AbstractIntegrationTest {
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);
.isEqualTo(1);
jdbc.update("UPDATE mail_outbox SET attempts = ?, next_attempt_at = NOW()", MailOutboxService.MAX_ATTEMPTS - 1);
mailOutboxService.sendDue();
@@ -142,8 +176,7 @@ class MailFlowTest extends AbstractIntegrationTest {
mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + NEW_USER + "\",\"password\":\"" + PASSWORD
+ "\"}"))
.content("{\"email\":\"" + NEW_USER + "\"}"))
.andExpect(status().isConflict());
assertThat(rows()).isEmpty();
@@ -192,4 +225,83 @@ class MailFlowTest extends AbstractIntegrationTest {
assertThat(rows()).extracting(row -> row.get("kind") + " " + row.get("to_address"))
.containsExactly("ACCOUNT_DELETED " + PLAYER);
}
private ResultActions registerAs(String email) throws Exception {
return mockMvc.perform(post("/api/registration/new").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.header("X-Forwarded-For", freshClientIp()).contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + email + "\",\"language\":\"de\"}"));
}
private int confirmMailsTo(String email) {
return jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox WHERE kind = 'CONFIRM_EMAIL' AND to_address = ?",
Integer.class, email);
}
private String newestToken() {
String body = jdbc.queryForObject("SELECT body FROM mail_outbox WHERE kind = 'CONFIRM_EMAIL' ORDER BY id DESC LIMIT 1",
String.class);
Matcher link = Pattern.compile("/confirm-email#(\\S+)").matcher(body);
assertThat(link.find()).isTrue();
return link.group(1);
}
// A stranger who registers an address first sets no password: the owner's click sets it, and only a link from
// the owner's mail can do that. Both mails carry a token of their own; the newest one wins.
@Test
void strangerRegisteringFirst_cannotLogInToTheOwnersAccount() throws Exception {
registerAs(OWNER).andExpect(status().isOk());
String strangerToken = newestToken();
registerAs(OWNER).andExpect(status().isOk()); // the address owner, later: same row, new link
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE email = ?", Integer.class, OWNER)).isEqualTo(1);
confirm(strangerToken, "stranger password 1").andExpect(status().isGone());
confirm(newestToken(), PASSWORD).andExpect(status().isNoContent());
insertAdminAndAccept();
login(OWNER).andExpect(status().isOk());
int strangerLogin = mockMvc.perform(post("/api/auth/login").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + OWNER + "\",\"password\":\"stranger password 1\"}"))
.andReturn().getResponse().getStatus();
assertThat(strangerLogin).isEqualTo(401);
}
private void insertAdminAndAccept() throws Exception {
insertUser(ADMIN, "PLATFORM_ADMIN");
long userId = jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, OWNER);
mockMvc.perform(put("/api/admin/users/" + userId + "/decision").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, accessToken(ADMIN)))
.contentType(MediaType.APPLICATION_JSON).content("{\"accepted\":true}"))
.andExpect(status().isNoContent());
}
// An address has at most 3 mails per hour over all IPs, and every answer stays the same.
@Test
void resendFromManyIps_isCappedPerAddress() throws Exception {
registerAs(CAPPED).andExpect(status().isOk());
for (int i = 0; i < 10; i++) {
mockMvc.perform(post("/api/registration/resend").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.header("X-Forwarded-For", freshClientIp()).contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"" + CAPPED + "\"}"))
.andExpect(status().isNoContent());
}
for (int i = 0; i < 5; i++) {
registerAs(CAPPED).andExpect(status().isOk());
}
assertThat(confirmMailsTo(CAPPED)).isEqualTo(3);
}
@Test
void unconfirmedRegistrations_countTowardsThePendingLimit() throws Exception {
int maxPending = 20; // RegistrationService.MAX_PENDING
for (int i = 0; i < maxPending + 1; i++) {
registerAs("mail-flow-flood-" + i + "@example.com");
}
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM mail_outbox WHERE kind = 'CONFIRM_EMAIL'", Integer.class))
.isEqualTo(maxPending);
}
}
@@ -51,17 +51,26 @@ class MailServiceTest extends AbstractUnitTest {
assertThat(inserted(MailTemplate.REGISTRATION_REQUESTED, ADMIN).body())
.startsWith("Eine neue Registrierung").contains("Alice", "alice@example.com", "https://tbp.example/admin/users");
assertThat(inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com").body()).startsWith("Hello Alice,");
verify(repository, never()).insert(any(), anyLong(), eq("alice@example.com"), any());
}
@Test
void registrationRequested_GreetsByEmailWithoutADisplayName() {
void confirmEmail_MailsTheLinkWithTheTokenInTheFragmentInTheUserLanguage() {
user("Alice", "de");
service(ADMIN).confirmEmail(USER, "raw-token");
Mail mail = inserted(MailTemplate.CONFIRM_EMAIL, "alice@example.com");
assertThat(mail.subject()).contains("Bestätige");
assertThat(mail.body()).startsWith("Hallo Alice,").contains("https://tbp.example/confirm-email#raw-token");
}
@Test
void registrationRequested_NamesTheUserWithoutADisplayNameAsDash() {
user(null, "de");
service(ADMIN).registrationRequested(USER);
assertThat(inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com").body())
.startsWith("Hallo alice@example.com,");
assertThat(inserted(MailTemplate.REGISTRATION_REQUESTED, ADMIN).body()).contains("Name: -");
}
@@ -71,8 +80,7 @@ class MailServiceTest extends AbstractUnitTest {
service("").registrationRequested(USER);
verify(repository, never()).insert(eq(MailTemplate.REGISTRATION_REQUESTED), anyLong(), anyString(), any());
inserted(MailTemplate.REGISTRATION_RECEIVED, "alice@example.com");
verify(repository, never()).insert(any(), anyLong(), anyString(), any());
}
@Test
@@ -9,6 +9,7 @@ import org.springframework.http.converter.HttpMessageNotWritableException;
import org.hibernate.exception.ConstraintViolationException;
import auth_service.configuration.security.JwtService;
import auth_service.modules.registration.api.dto.request.RegistrationRequest;
import auth_service.modules.registration.exception.ConfirmationInvalidException;
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.PendingLimitReachedException;
import auth_service.modules.registration.exception.TooManyRegistrationAttemptsException;
@@ -18,6 +19,7 @@ import auth_service.shared.test.AbstractWebMvcTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.json.AutoConfigureJsonTesters;
@@ -37,6 +39,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -83,7 +86,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
// Arrange
var request = RegistrationRequest.builder()
.email("test@example.com")
.password("SuperSecret123!")
.build();
long expectedUserId = 42L;
@@ -104,7 +106,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
// Arrange
var request = RegistrationRequest.builder()
.email("test@example.com")
.password("SuperSecret123!")
.build();
when(registrationService.register(any(Registration.class), any()))
@@ -124,7 +125,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","inviteCode":"code","steamId":"76561198000000001"}"""))
{"email":"test@example.com","inviteCode":"code","steamId":"76561198000000001"}"""))
.andExpect(status().isOk());
}
@@ -132,7 +133,6 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
void register_DuplicateEmailRace_Is409EmailAlreadyInUse() throws Exception {
var request = RegistrationRequest.builder()
.email("test@example.com")
.password("SuperSecret123!")
.build();
when(registrationService.register(any(Registration.class), any()))
.thenThrow(duplicate("users_email_lower"));
@@ -152,7 +152,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","displayName":"Lobo"}"""))
{"email":"test@example.com","displayName":"Lobo"}"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("DISPLAY_NAME_ALREADY_IN_USE"));
}
@@ -164,7 +164,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","language":"de"}"""))
{"email":"test@example.com","language":"de"}"""))
.andExpect(status().isOk());
ArgumentCaptor<Registration> registration = ArgumentCaptor.forClass(Registration.class);
@@ -177,7 +177,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","language":"fr"}"""))
{"email":"test@example.com","language":"fr"}"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"));
}
@@ -196,7 +196,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!"}"""))
{"email":"test@example.com"}"""))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"))
.andExpect(content().string(not(containsString("secret detail"))));
@@ -207,7 +207,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","displayName":"x"}"""))
{"email":"test@example.com","displayName":"x"}"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.fields[0].field").value("displayName"));
@@ -224,7 +224,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!","displayName":"Lobo"}"""))
{"email":"test@example.com","displayName":"Lobo"}"""))
.andExpect(status().isOk());
// Assert
@@ -243,7 +243,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
.header("X-Forwarded-For", "10.0.0.1, 203.0.113.7")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!"}"""))
{"email":"test@example.com"}"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.userId").value(42L));
}
@@ -258,7 +258,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!"}"""))
{"email":"test@example.com"}"""))
.andExpect(status().isTooManyRequests())
.andExpect(header().string("Retry-After", "900"))
.andExpect(jsonPath("$.code").value("TOO_MANY_ATTEMPTS"));
@@ -271,33 +271,15 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!"}"""))
{"email":"test@example.com"}"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("PENDING_LIMIT_REACHED"));
}
@ParameterizedTest
@CsvSource(delimiter = '|', value = {
" |SuperSecret123!",
"no-at-sign|SuperSecret123!",
"test@example.com| ",
"test@example.com|short",
})
void register_InvalidInput_Is400WithoutRegistering(String email, String password) throws Exception {
var request = RegistrationRequest.builder().email(email).password(password).build();
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content(registrationRequestJson.write(request).getJson()))
.andExpect(status().isBadRequest());
verifyNoInteractions(registrationService);
}
// BCrypt uses only 72 bytes: a longer password would mostly be ignored, or refused with a 500.
@Test
void register_PasswordOver72Bytes_Is400() throws Exception {
var request = RegistrationRequest.builder().email("test@example.com").password("é".repeat(37)).build();
@ValueSource(strings = {" ", "no-at-sign"})
void register_InvalidEmail_Is400WithoutRegistering(String email) throws Exception {
var request = RegistrationRequest.builder().email(email).build();
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
@@ -309,7 +291,7 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
@Test
void register_Email_IsTrimmedAndLowerCase() throws Exception {
var request = RegistrationRequest.builder().email(" Bob@Example.COM ").password("SuperSecret123!").build();
var request = RegistrationRequest.builder().email(" Bob@Example.COM ").build();
when(registrationService.register(any(Registration.class), any())).thenReturn(1L);
mockMvc.perform(post("/api/registration/new")
@@ -331,10 +313,89 @@ class RegistrationControllerTest extends AbstractWebMvcTest {
mockMvc.perform(post("/api/registration/new")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"email":"test@example.com","password":"SuperSecret123!"}"""))
{"email":"test@example.com"}"""))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"));
assertTrue(output.getAll().contains("body not writable"), "the 500 left no trace in the log");
}
@Test
void confirm_Is204AndPassesTokenPasswordAndIp() throws Exception {
mockMvc.perform(post("/api/registration/confirm").header("X-Forwarded-For", "203.0.113.7")
.contentType(MediaType.APPLICATION_JSON).content("{\"token\":\"abc\",\"password\":\"SuperSecret123!\"}"))
.andExpect(status().isNoContent());
verify(registrationService).confirm("abc", "SuperSecret123!", "203.0.113.7");
}
@Test
void confirm_AnInvalidLink_Is410ConfirmationInvalid() throws Exception {
doThrow(new ConfirmationInvalidException()).when(registrationService).confirm(any(), any(), any());
mockMvc.perform(post("/api/registration/confirm").contentType(MediaType.APPLICATION_JSON)
.content("{\"token\":\"abc\",\"password\":\"SuperSecret123!\"}"))
.andExpect(status().isGone())
.andExpect(jsonPath("$.code").value("CONFIRMATION_INVALID"));
}
@ParameterizedTest
@CsvSource(delimiter = '|', value = {
"abc| ",
"abc|short",
"abc|ééééééééééééééééééééééééééééééééééééé",
})
void confirm_InvalidInput_Is400WithoutConfirming(String token, String password) throws Exception {
mockMvc.perform(post("/api/registration/confirm").contentType(MediaType.APPLICATION_JSON)
.content("{\"token\":\"" + token + "\",\"password\":\"" + password + "\"}"))
.andExpect(status().isBadRequest());
verifyNoInteractions(registrationService);
}
@Test
void confirm_BlankToken_Is400() throws Exception {
mockMvc.perform(post("/api/registration/confirm").contentType(MediaType.APPLICATION_JSON)
.content("{\"token\":\" \",\"password\":\"SuperSecret123!\"}"))
.andExpect(status().isBadRequest());
verifyNoInteractions(registrationService);
}
@Test
void confirm_NoPassword_Is400() throws Exception {
mockMvc.perform(post("/api/registration/confirm").contentType(MediaType.APPLICATION_JSON)
.content("{\"token\":\"abc\"}"))
.andExpect(status().isBadRequest());
verifyNoInteractions(registrationService);
}
@Test
void resend_Is204AndNormalizesTheEmail() throws Exception {
mockMvc.perform(post("/api/registration/resend").contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\" Bob@Example.COM \"}"))
.andExpect(status().isNoContent());
verify(registrationService).resend(eq("bob@example.com"), any());
}
@Test
void resend_BadEmail_Is400() throws Exception {
mockMvc.perform(post("/api/registration/resend").contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"no-at-sign\"}"))
.andExpect(status().isBadRequest());
verifyNoInteractions(registrationService);
}
@Test
void resend_TooManyRequests_Is429WithRetryAfter() throws Exception {
doThrow(new TooManyRegistrationAttemptsException(900)).when(registrationService).resend(any(), any());
mockMvc.perform(post("/api/registration/resend").contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"bob@example.com\"}"))
.andExpect(status().isTooManyRequests())
.andExpect(header().string("Retry-After", "900"));
}
}
@@ -7,10 +7,13 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -23,7 +26,7 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
private JdbcTemplate jdbc;
private static Registration registration(String email, String displayName) {
return Registration.builder().email(email).encryptedPassword("hashed").displayName(displayName).build();
return Registration.builder().email(email).displayName(displayName).build();
}
@Test
@@ -48,7 +51,7 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
Map<String, Object> saved = jdbc.queryForMap("SELECT * FROM users WHERE id = ?", id);
assertEquals("save@example.com", saved.get("email"));
assertEquals("hashed", saved.get("password"));
assertNull(saved.get("password"));
assertEquals(true, saved.get("enabled"));
assertEquals("Saver", saved.get("display_name"));
assertEquals("USER", saved.get("role"));
@@ -65,13 +68,13 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
}
@Test
void save_StoresAPendingRequestAndCountsIt() {
long before = registeredUserRepository.countPending();
void save_StoresAnUnconfirmedRequestThatCountsAsWaiting() {
long before = registeredUserRepository.countWaiting();
long id = registeredUserRepository.save(registration("pending@example.com", null));
assertEquals("PENDING", jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, id));
assertEquals(before + 1, registeredUserRepository.countPending());
assertEquals("UNCONFIRMED", jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, id));
assertEquals(before + 1, registeredUserRepository.countWaiting());
}
@Test
@@ -95,4 +98,43 @@ class RegisteredUserRepositoryTest extends AbstractPersistenceTest {
// GlobalExceptionController finds the name in the message, in every server language.
assertTrue(refused.getMostSpecificCause().getMessage().contains("users_email_lower"));
}
@Test
void confirm_ReturnsTheIdOnceAndMakesTheUserPending() {
long id = registeredUserRepository.save(registration("confirm-once@example.com", null));
registeredUserRepository.storeConfirmation(id, "hash-once", Instant.now().plusSeconds(60));
assertEquals(Optional.of(id), registeredUserRepository.confirm("hash-once", "pw-hash", Instant.now()));
assertEquals(Optional.empty(), registeredUserRepository.confirm("hash-once", "pw-hash", Instant.now()));
assertEquals("PENDING", jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, id));
assertEquals("pw-hash", jdbc.queryForObject("SELECT password FROM users WHERE id = ?", String.class, id));
assertEquals(Optional.empty(), registeredUserRepository.findUnconfirmedIdByEmail("confirm-once@example.com"));
}
@Test
void confirm_AnExpiredHashIsEmptyAndKeepsTheUserUnconfirmed() {
long id = registeredUserRepository.save(registration("confirm-late@example.com", null));
registeredUserRepository.storeConfirmation(id, "hash-late", Instant.now().minusSeconds(1));
assertEquals(Optional.empty(), registeredUserRepository.confirm("hash-late", "pw-hash", Instant.now()));
assertEquals(Optional.of(id), registeredUserRepository.findUnconfirmedIdByEmail("Confirm-Late@example.com"));
assertNull(jdbc.queryForObject("SELECT password FROM users WHERE id = ?", String.class, id));
}
@Test
void storeConfirmation_ANewHashInvalidatesTheOldOne() {
long id = registeredUserRepository.save(registration("confirm-resend@example.com", null));
registeredUserRepository.storeConfirmation(id, "hash-old", Instant.now().plusSeconds(60));
registeredUserRepository.storeConfirmation(id, "hash-new", Instant.now().plusSeconds(60));
assertEquals(Optional.empty(), registeredUserRepository.confirm("hash-old", "pw-hash", Instant.now()));
assertEquals(Optional.of(id), registeredUserRepository.confirm("hash-new", "pw-hash", Instant.now()));
}
@Test
void findUnconfirmedIdByEmail_IgnoresAPendingUser() {
jdbc.update("INSERT INTO users (email, password, enabled, status) VALUES ('pend@example.com', 'x', true, 'PENDING')");
assertEquals(Optional.empty(), registeredUserRepository.findUnconfirmedIdByEmail("pend@example.com"));
}
}
@@ -16,6 +16,7 @@ import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -106,4 +107,14 @@ class RegistrationAttemptServiceTest extends AbstractUnitTest {
assertTrue(passed.get() <= RegistrationAttemptService.MAX_FAILURES_PER_IP,
"attempts that passed the limit: " + passed.get());
}
@Test
void allowsThreeConfirmationMailsPerAddressWhateverTheIp() {
for (int i = 0; i < RegistrationAttemptService.MAX_CONFIRMATION_MAILS_PER_ADDRESS; i++) {
assertTrue(attempts.tryConfirmationMail("a@example.com"));
}
assertFalse(attempts.tryConfirmationMail("a@example.com"));
assertTrue(attempts.tryConfirmationMail("b@example.com"));
}
}
@@ -1,5 +1,6 @@
package auth_service.modules.registration.service;
import auth_service.modules.registration.exception.ConfirmationInvalidException;
import auth_service.modules.registration.exception.EmailAlreadyInUseException;
import auth_service.modules.registration.exception.DisplayNameAlreadyRegisteredException;
import auth_service.modules.registration.exception.PendingLimitReachedException;
@@ -7,16 +8,24 @@ import auth_service.modules.registration.exception.TooManyRegistrationAttemptsEx
import auth_service.modules.registration.model.Registration;
import auth_service.modules.registration.persistence.RegisteredUserRepository;
import auth_service.shared.mail.AccountMailer;
import auth_service.shared.security.OpaqueToken;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
@@ -47,7 +56,6 @@ class RegistrationServiceTest extends AbstractUnitTest {
void setUp() {
validRegistration = Registration.builder()
.email("test@example.com")
.rawPassword("SuperSecret123!")
.build();
}
@@ -55,8 +63,8 @@ class RegistrationServiceTest extends AbstractUnitTest {
void register_Success() {
// Arrange
long expectedUserId = 42L;
when(registrationAttemptService.tryConfirmationMail(any())).thenReturn(true);
when(registeredUserRepository.existsByEmail(validRegistration.getEmail())).thenReturn(false);
when(passwordEncoder.encode(validRegistration.getRawPassword())).thenReturn("hashedPasswordXYZ");
when(registeredUserRepository.save(any(Registration.class))).thenReturn(expectedUserId);
// Act
@@ -64,18 +72,85 @@ class RegistrationServiceTest extends AbstractUnitTest {
// Assert
assertEquals(expectedUserId, result);
verify(passwordEncoder).encode("SuperSecret123!");
verifyNoInteractions(passwordEncoder);
verify(registeredUserRepository).save(validRegistration);
assertEquals("hashedPasswordXYZ", validRegistration.getEncryptedPassword());
verify(registrationAttemptService).acquire(IP);
verify(registrationAttemptService).release(IP);
verify(registrationAttemptService).countRequest(IP);
verify(accountMailer).registrationRequested(expectedUserId);
verify(accountMailer, never()).registrationRequested(anyLong());
ArgumentCaptor<String> token = ArgumentCaptor.forClass(String.class);
verify(accountMailer).confirmEmail(eq(expectedUserId), token.capture());
ArgumentCaptor<Instant> expires = ArgumentCaptor.forClass(Instant.class);
verify(registeredUserRepository).storeConfirmation(eq(expectedUserId), eq(OpaqueToken.hash(token.getValue())),
expires.capture());
assertThat(expires.getValue()).isBetween(Instant.now().plus(Duration.ofHours(23)),
Instant.now().plus(Duration.ofHours(25)));
}
@Test
void register_AnUnconfirmedEmail_IsNoConflictNoSecondRowButANewLink() {
when(registrationAttemptService.tryConfirmationMail(any())).thenReturn(true);
when(registeredUserRepository.findUnconfirmedIdByEmail(validRegistration.getEmail())).thenReturn(Optional.of(5L));
assertEquals(5L, registrationService.register(validRegistration, IP));
verify(registeredUserRepository, never()).save(any());
verify(registeredUserRepository, never()).existsByEmail(anyString());
verify(passwordEncoder, never()).encode(any());
verify(registrationAttemptService).release(IP);
verify(registrationAttemptService).countRequest(IP);
verify(accountMailer).confirmEmail(eq(5L), anyString());
}
@Test
void confirm_AValidToken_ReleasesTheAttemptAndTellsTheAdmin() {
when(passwordEncoder.encode("SuperSecret123!")).thenReturn("hashedPasswordXYZ");
when(registeredUserRepository.confirm(eq(OpaqueToken.hash("raw")), eq("hashedPasswordXYZ"), any()))
.thenReturn(Optional.of(9L));
registrationService.confirm("raw", "SuperSecret123!", IP);
verify(registrationAttemptService).acquire(IP);
verify(registrationAttemptService).release(IP);
verify(accountMailer).registrationRequested(9L);
}
@Test
void confirm_AnInvalidToken_CountsAsAFailedAttemptAndMailsNothing() {
when(registeredUserRepository.confirm(anyString(), any(), any())).thenReturn(Optional.empty());
assertThrows(ConfirmationInvalidException.class, () -> registrationService.confirm("raw", "SuperSecret123!", IP));
verify(registrationAttemptService, never()).release(anyString());
verifyNoInteractions(accountMailer);
}
@Test
void resend_AnUnknownAddress_MailsNothingAndDoesNotThrow() {
when(registeredUserRepository.findUnconfirmedIdByEmail("nobody@example.com")).thenReturn(Optional.empty());
registrationService.resend("nobody@example.com", IP);
verify(registrationAttemptService).countRequest(IP);
verifyNoInteractions(accountMailer);
verify(registeredUserRepository, never()).storeConfirmation(anyLong(), anyString(), any());
}
@Test
void resend_AnUnconfirmedAddress_StoresANewHashAndMailsTheLink() {
when(registeredUserRepository.findUnconfirmedIdByEmail("a@example.com")).thenReturn(Optional.of(5L));
when(registrationAttemptService.tryConfirmationMail("a@example.com")).thenReturn(true);
registrationService.resend("a@example.com", IP);
verify(registrationAttemptService).countRequest(IP);
verify(registeredUserRepository).storeConfirmation(eq(5L), anyString(), any());
verify(accountMailer).confirmEmail(eq(5L), anyString());
}
@Test
void register_AFullQueue_RefusesAndCountsAsAFailedAttempt() {
when(registeredUserRepository.countPending()).thenReturn((long) RegistrationService.MAX_PENDING);
when(registeredUserRepository.countWaiting()).thenReturn((long) RegistrationService.MAX_PENDING);
assertThrows(PendingLimitReachedException.class, () -> registrationService.register(validRegistration, IP));
@@ -85,10 +160,43 @@ class RegistrationServiceTest extends AbstractUnitTest {
verifyNoInteractions(accountMailer);
}
@Test
void register_AFullQueue_StillAnswersAnUnconfirmedAddressWithANewLink() {
when(registeredUserRepository.findUnconfirmedIdByEmail(validRegistration.getEmail())).thenReturn(Optional.of(5L));
when(registrationAttemptService.tryConfirmationMail(any())).thenReturn(true);
assertEquals(5L, registrationService.register(validRegistration, IP));
verify(registeredUserRepository, never()).countWaiting();
}
@Test
void register_AnUnconfirmedAddressOverTheMailLimit_AnswersTheSameAndMailsNothing() {
when(registeredUserRepository.findUnconfirmedIdByEmail(validRegistration.getEmail())).thenReturn(Optional.of(5L));
when(registrationAttemptService.tryConfirmationMail(validRegistration.getEmail())).thenReturn(false);
assertEquals(5L, registrationService.register(validRegistration, IP));
verify(registeredUserRepository, never()).storeConfirmation(anyLong(), anyString(), any());
verifyNoInteractions(accountMailer);
}
@Test
void resend_OverTheMailLimit_MailsNothingAndKeepsTheOldLink() {
when(registeredUserRepository.findUnconfirmedIdByEmail("a@example.com")).thenReturn(Optional.of(5L));
when(registrationAttemptService.tryConfirmationMail("a@example.com")).thenReturn(false);
registrationService.resend("a@example.com", IP);
verify(registeredUserRepository, never()).storeConfirmation(anyLong(), anyString(), any());
verifyNoInteractions(accountMailer);
}
@Test
void register_OneBelowTheLimit_IsTaken() {
when(registeredUserRepository.countPending()).thenReturn((long) RegistrationService.MAX_PENDING - 1);
when(registeredUserRepository.countWaiting()).thenReturn((long) RegistrationService.MAX_PENDING - 1);
when(registeredUserRepository.save(any(Registration.class))).thenReturn(43L);
when(registrationAttemptService.tryConfirmationMail(any())).thenReturn(true);
assertEquals(43L, registrationService.register(validRegistration, IP));
}
@@ -0,0 +1,23 @@
package auth_service.shared.security;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class OpaqueTokenTest extends AbstractUnitTest {
@Test
void generate_Is43UrlSafeCharsAndDiffersPerCall() {
String first = OpaqueToken.generate();
assertThat(first).matches("[A-Za-z0-9_-]{43}");
assertThat(OpaqueToken.generate()).isNotEqualTo(first);
}
@Test
void hash_Is64HexCharsAndDeterministic() {
assertThat(OpaqueToken.hash("abc")).matches("[0-9a-f]{64}").isEqualTo(OpaqueToken.hash("abc"))
.isNotEqualTo(OpaqueToken.hash("abd"));
}
}