Public Access
feat(admin): accept or deny registrations
This commit is contained in:
1 parent
1cac0c6b81
commit
1008034e82
29 files changed
+405
-44
No files matched your search
@@ -10,6 +10,7 @@ import jakarta.servlet.http.Cookie;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
@@ -47,6 +48,9 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private CacheManager cacheManager;
|
||||
|
||||
@Test
|
||||
void steamIdOfAFlaggedUser_CanBeLinkedByANewAccount() throws Exception {
|
||||
long steamId = 76561198099990001L;
|
||||
@@ -174,6 +178,19 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
|
||||
assertThat(totalUsers()).isEqualTo(usersBefore - 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingUser_IsNotCountedAndHasNoPublicProfile() throws Exception {
|
||||
mockMvc.perform(get("/api/public/profiles/" + NAME)).andExpect(status().isOk());
|
||||
cacheManager.getCache("publicStats").clear();
|
||||
long before = totalUsers();
|
||||
|
||||
jdbc.update("UPDATE users SET status = 'PENDING' WHERE email = ?", EMAIL);
|
||||
cacheManager.getCache("publicStats").clear();
|
||||
|
||||
assertThat(totalUsers()).isEqualTo(before - 1);
|
||||
mockMvc.perform(get("/api/public/profiles/" + NAME)).andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registration_CountsInThePublicStatsAtOnce() throws Exception {
|
||||
String newEmail = "delete-flow-registered@example.com";
|
||||
|
||||
@@ -106,6 +106,47 @@ class AdminFlowTest extends AbstractIntegrationTest {
|
||||
Long.class, action, target);
|
||||
}
|
||||
|
||||
private ResultActions decide(Session session, long userId, boolean accepted) throws Exception {
|
||||
return mockMvc.perform(put("/api/admin/users/" + userId + "/decision").header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access()))
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{\"accepted\":" + accepted + "}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingRequest_WaitsForTheAcceptAndThenLogsIn() throws Exception {
|
||||
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", playerId);
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
|
||||
assertThat(login(PLAYER).getStatus()).isEqualTo(403);
|
||||
assertThat(login(PLAYER).getContentAsString()).contains("ACCOUNT_PENDING");
|
||||
mockMvc.perform(get("/api/admin/users").param("status", "PENDING")
|
||||
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access())))
|
||||
.andExpect(jsonPath("$.content[*].email").value(org.hamcrest.Matchers.hasItem(PLAYER)))
|
||||
.andExpect(jsonPath("$.content[?(@.email == '" + PLAYER + "')].status").value("PENDING"));
|
||||
mockMvc.perform(get("/api/admin/users/pending-count").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access())))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.count").isNumber());
|
||||
block(admin, playerId, true).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("TARGET_IS_PENDING"));
|
||||
|
||||
decide(admin, playerId, true).andExpect(status().isNoContent());
|
||||
|
||||
assertThat(login(PLAYER).getStatus()).isEqualTo(200);
|
||||
assertThat(audits("ACCEPT", playerId)).isEqualTo(1);
|
||||
decide(admin, playerId, true).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("NOT_PENDING"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deniedRequest_CannotLogInAndWaitsForTheCleanup() throws Exception {
|
||||
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", playerId);
|
||||
|
||||
decide(Session.of(login(ADMIN)), playerId, false).andExpect(status().isNoContent());
|
||||
|
||||
assertThat(login(PLAYER).getStatus()).isEqualTo(401);
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, playerId)).isEqualTo("DENIED");
|
||||
assertThat(jdbc.queryForObject("SELECT deleted_at IS NOT NULL FROM users WHERE id = ?", Boolean.class, playerId))
|
||||
.isTrue();
|
||||
assertThat(audits("DENY", playerId)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void admin_ListsUsersWithSearchAndPaging() throws Exception {
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.tuple;
|
||||
|
||||
class AdminUserRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@@ -37,29 +38,29 @@ class AdminUserRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
@Test
|
||||
void findPage_SearchesEmailAndNameInAnyCase() {
|
||||
assertThat(repository.findPage("EXAMPLE", 0, 10)).extracting(AdminUser::email)
|
||||
assertThat(repository.findPage("EXAMPLE", "", 0, 10)).extracting(AdminUser::email)
|
||||
.containsExactly("alice@example.com", "bob@example.com");
|
||||
assertThat(repository.findPage("bob", 0, 10)).extracting(AdminUser::email).containsExactly("bob@example.com");
|
||||
assertThat(repository.count("")).isEqualTo(3);
|
||||
assertThat(repository.findPage("bob", "", 0, 10)).extracting(AdminUser::email).containsExactly("bob@example.com");
|
||||
assertThat(repository.count("", "")).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findPage_TreatsLikeCharactersAsText() {
|
||||
assertThat(repository.findPage("%", 0, 10)).extracting(AdminUser::email).containsExactly("carol@sample.org");
|
||||
assertThat(repository.findPage("e_1", 0, 10)).extracting(AdminUser::email).containsExactly("alice@example.com");
|
||||
assertThat(repository.count("_")).isEqualTo(1);
|
||||
assertThat(repository.findPage("%", "", 0, 10)).extracting(AdminUser::email).containsExactly("carol@sample.org");
|
||||
assertThat(repository.findPage("e_1", "", 0, 10)).extracting(AdminUser::email).containsExactly("alice@example.com");
|
||||
assertThat(repository.count("_", "")).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findPage_Pages() {
|
||||
assertThat(repository.findPage("", 1, 2)).extracting(AdminUser::email).containsExactly("carol@sample.org");
|
||||
assertThat(repository.findPage("", "", 1, 2)).extracting(AdminUser::email).containsExactly("carol@sample.org");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findPage_ShowsRoleBlockAndDelete() {
|
||||
jdbc.update("UPDATE users SET role = 'AUDITOR', enabled = false, deleted_at = NOW(), steam_id = 1 WHERE id = ?", alice);
|
||||
|
||||
AdminUser found = repository.findPage("alice", 0, 10).getFirst();
|
||||
AdminUser found = repository.findPage("alice", "", 0, 10).getFirst();
|
||||
|
||||
assertThat(found.role()).isEqualTo(Role.AUDITOR);
|
||||
assertThat(found.blocked()).isTrue();
|
||||
@@ -88,4 +89,34 @@ class AdminUserRepositoryTest extends AbstractPersistenceTest {
|
||||
assertThat(repository.setBlocked(alice, true, Instant.now())).isFalse();
|
||||
assertThat(repository.setBlocked(alice, false, Instant.now())).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findPage_FiltersByStatusAndShowsIt() {
|
||||
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", alice);
|
||||
|
||||
assertThat(repository.findPage("", "PENDING", 0, 10)).extracting(AdminUser::email, AdminUser::status)
|
||||
.containsExactly(tuple("alice@example.com", "PENDING"));
|
||||
assertThat(repository.count("", "PENDING")).isEqualTo(1);
|
||||
assertThat(repository.count("", "ACTIVE")).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void accept_ChangesOnlyAPendingRequest() {
|
||||
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", alice);
|
||||
|
||||
assertThat(repository.findState(alice).orElseThrow().pending()).isTrue();
|
||||
assertThat(repository.accept(alice)).isTrue();
|
||||
assertThat(repository.accept(alice)).isFalse();
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, alice)).isEqualTo("ACTIVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deny_FlagsTheRequestForTheNightlyCleanup() {
|
||||
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", alice);
|
||||
|
||||
assertThat(repository.deny(alice, Instant.now())).isTrue();
|
||||
assertThat(repository.deny(alice, Instant.now())).isFalse();
|
||||
assertThat(jdbc.queryForObject("SELECT status FROM users WHERE id = ?", String.class, alice)).isEqualTo("DENIED");
|
||||
assertThat(repository.findState(alice).orElseThrow().deleted()).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,11 @@ class AdminUserServiceTest extends AbstractUnitTest {
|
||||
}
|
||||
|
||||
private void target(Role role, boolean deleted) {
|
||||
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(role, deleted)));
|
||||
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(role, deleted, false)));
|
||||
}
|
||||
|
||||
private void pendingTarget() {
|
||||
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(Role.USER, false, true)));
|
||||
}
|
||||
|
||||
private String refusal(Runnable action) {
|
||||
@@ -146,9 +150,48 @@ class AdminUserServiceTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void findUsers_ClampsTheSizeAndCountsThePages() {
|
||||
when(adminUserRepository.count("bob")).thenReturn(250L);
|
||||
when(adminUserRepository.count("bob", "PENDING")).thenReturn(250L);
|
||||
|
||||
assertThat(service.findUsers(" bob ", -1, 500).totalPages()).isEqualTo(3);
|
||||
verify(adminUserRepository).findPage("bob", 0, 100);
|
||||
assertThat(service.findUsers(" bob ", "PENDING", -1, 500).totalPages()).isEqualTo(3);
|
||||
verify(adminUserRepository).findPage("bob", "PENDING", 0, 100);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decide_AcceptActivatesAuditsAndMails() {
|
||||
pendingTarget();
|
||||
when(adminUserRepository.accept(TARGET)).thenReturn(true);
|
||||
|
||||
service.decide(ADMIN, TARGET, true);
|
||||
|
||||
verify(adminAuditLog).record(ADMIN, "ACCEPT", TARGET, null);
|
||||
verify(accountMailer).accountAccepted(TARGET);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decide_DenyFlagsAuditsAndMails() {
|
||||
pendingTarget();
|
||||
when(adminUserRepository.deny(eq(TARGET), any())).thenReturn(true);
|
||||
|
||||
service.decide(ADMIN, TARGET, false);
|
||||
|
||||
verify(adminAuditLog).record(ADMIN, "DENY", TARGET, null);
|
||||
verify(accountMailer).registrationDenied(TARGET);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decide_OnlyForAPendingRequest() {
|
||||
target(Role.USER, false);
|
||||
|
||||
assertThat(refusal(() -> service.decide(ADMIN, TARGET, true))).isEqualTo("NOT_PENDING");
|
||||
verifyNoInteractions(adminAuditLog, accountMailer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void blockAndDelete_RefuseAPendingRequest() {
|
||||
pendingTarget();
|
||||
|
||||
assertThat(refusal(() -> service.setBlocked(ADMIN, TARGET, true))).isEqualTo("TARGET_IS_PENDING");
|
||||
assertThat(refusal(() -> service.delete(ADMIN, TARGET))).isEqualTo("TARGET_IS_PENDING");
|
||||
verifyNoInteractions(accountDeleter);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package auth_service.modules.login.api;
|
||||
|
||||
import auth_service.configuration.security.JwtAuthenticationFilter;
|
||||
import auth_service.modules.login.api.dto.request.LoginRequest;
|
||||
import auth_service.modules.login.exception.AccountPendingException;
|
||||
import auth_service.modules.login.exception.InvalidCredentialsException;
|
||||
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||
import auth_service.modules.login.model.Session;
|
||||
@@ -119,6 +120,17 @@ class LoginControllerTest extends AbstractWebMvcTest {
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_PendingRequest_Is403AccountPending() throws Exception {
|
||||
when(loginService.login(any(User.class), any(), any())).thenThrow(new AccountPendingException());
|
||||
|
||||
mockMvc.perform(post("/api/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(loginRequestJson.write(new LoginRequest("test@example.com", "password")).getJson()))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value("ACCOUNT_PENDING"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_TooManyAttempts() throws Exception {
|
||||
// Arrange
|
||||
|
||||
@@ -8,6 +8,14 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class UserTest extends AbstractUnitTest {
|
||||
|
||||
@Test
|
||||
void canLogin_OnlyWhenEnabledNotDeletedAndNotPending() {
|
||||
assertThat(User.builder().enabled(true).build().canLogin()).isTrue();
|
||||
assertThat(User.builder().enabled(true).pending(true).build().canLogin()).isFalse();
|
||||
assertThat(User.builder().enabled(true).deleted(true).build().canLogin()).isFalse();
|
||||
assertThat(User.builder().enabled(false).build().canLogin()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toString_ShowsNoPassword() {
|
||||
assertThat(User.builder().email("a@example.com").password("hash-secret").build().toString())
|
||||
|
||||
@@ -40,9 +40,26 @@ class MinimalUserRepositoryTest extends AbstractPersistenceTest {
|
||||
assertEquals(76561198000000001L, user.getSteamId());
|
||||
assertTrue(user.isEnabled());
|
||||
assertTrue(user.isDeleted());
|
||||
assertFalse(user.isPending());
|
||||
assertEquals(Role.USER, user.getRole());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findById_MapsAPendingRequest() {
|
||||
long id = insertUser("minimal-pending@example.com");
|
||||
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", id);
|
||||
|
||||
assertTrue(minimalUserRepository.findById(id).orElseThrow().isPending());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deniedUser_CannotLoginEvenWithoutDeletedAt() {
|
||||
long id = insertUser("minimal-denied@example.com");
|
||||
jdbc.update("UPDATE users SET status = 'DENIED', deleted_at = NULL WHERE id = ?", id);
|
||||
|
||||
assertFalse(minimalUserRepository.findById(id).orElseThrow().canLogin());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findById_UnknownUser_IsEmpty() {
|
||||
assertFalse(minimalUserRepository.findById(-1).isPresent());
|
||||
|
||||
@@ -3,6 +3,7 @@ package auth_service.modules.login.service;
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.shared.security.TokenGenerator;
|
||||
import auth_service.modules.login.exception.AccountBlockedException;
|
||||
import auth_service.modules.login.exception.AccountPendingException;
|
||||
import auth_service.modules.login.exception.InvalidCredentialsException;
|
||||
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||
import auth_service.modules.login.model.Session;
|
||||
@@ -179,6 +180,28 @@ class LoginServiceTest extends AbstractUnitTest {
|
||||
verify(loginAttemptService).recordSuccess("test@example.com", IP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_PendingUserWithTheRightPassword_IsToldItWaits() {
|
||||
User dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true)
|
||||
.pending(true).build();
|
||||
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
|
||||
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
|
||||
|
||||
assertThrows(AccountPendingException.class, () -> loginService.login(user, IP, null));
|
||||
verify(tokenGenerator, never()).generateToken(any(), any(), any());
|
||||
verify(loginAttemptService).recordSuccess("test@example.com", IP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_PendingUserWithAWrongPassword_LooksLikeAnyWrongPassword() {
|
||||
User dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true)
|
||||
.pending(true).build();
|
||||
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
|
||||
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(false);
|
||||
|
||||
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_BlockedUserWithAWrongPassword_LooksLikeAnyWrongPassword() {
|
||||
var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(false).build();
|
||||
|
||||
@@ -93,6 +93,25 @@ class MailServiceTest extends AbstractUnitTest {
|
||||
assertThat(inserted(MailTemplate.ACCOUNT_DELETED, "alice@example.com").body()).contains("ab dem 05.11.2026");
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountAccepted_TellsTheUserTheAccountIsActive() {
|
||||
user("Alice", "en");
|
||||
|
||||
service(ADMIN).accountAccepted(USER);
|
||||
|
||||
assertThat(inserted(MailTemplate.ACCOUNT_ACTIVE, "alice@example.com").body())
|
||||
.startsWith("Hello Alice,").contains("https://tbp.example");
|
||||
}
|
||||
|
||||
@Test
|
||||
void registrationDenied_TellsTheUserInTheirLanguage() {
|
||||
user("Alice", "de");
|
||||
|
||||
service(ADMIN).registrationDenied(USER);
|
||||
|
||||
assertThat(inserted(MailTemplate.REGISTRATION_DENIED, "alice@example.com").subject()).contains("abgelehnt");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownUserGetsNoMail() {
|
||||
when(repository.findRecipient(USER)).thenReturn(Optional.empty());
|
||||
|
||||
Reference in new issue
Block a user