feat(admin): add roles, view as and the admin users API
Build, Test & Push Auth Service / quality-check (push) Successful in 39s
Build, Test & Push Auth Service / docker (push) Successful in 1m20s

users.role (USER < AUDITOR < PLATFORM_ADMIN) goes into the access token as the active role. POST /api/auth/view-as lowers the own login and is kept per refresh token, never above the stored role. /api/admin/users lists, blocks and deletes users; every action is written to admin_audit. A blocked login answers 403 ACCOUNT_BLOCKED, only after the right password.
This commit is contained in:
lobo committed 2026-09-27 12:18:10 +02:00
1 parent 7dc2fb613e
commit 2ba0d99148
55 files changed
+1184 -43

No files matched your search

@@ -1,5 +1,6 @@
package auth_service.configuration.security;
import auth_service.shared.security.Role;
import auth_service.shared.security.SessionProperties;
import auth_service.shared.security.UserPrincipal;
import auth_service.shared.test.AbstractUnitTest;
@@ -12,9 +13,11 @@ import org.mockito.Mock;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
@@ -41,10 +44,12 @@ class JwtServiceTest extends AbstractUnitTest {
private JwtService jwtService;
private PublicKey publicKey;
private PrivateKey privateKey;
@BeforeEach
void setUp() throws Exception {
KeyPair keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
privateKey = keyPair.getPrivate();
String privateB64 = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
String publicB64 = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
@@ -61,7 +66,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void generateToken_Success() {
var token = jwtService.generateToken("0815", "test@example.com", null);
var token = jwtService.generateToken("0815", "test@example.com", null, Role.USER);
assertNotNull(token);
assertFalse(token.isEmpty());
@@ -69,7 +74,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void generateToken_setsKidHeader() {
var token = jwtService.generateToken("0815", "test@example.com", null);
var token = jwtService.generateToken("0815", "test@example.com", null, Role.USER);
var header = Jwts.parser()
.verifyWith(publicKey)
@@ -83,7 +88,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void parseLoginToken_ReturnsTheUser() {
var token = jwtService.generateToken("0815", "test@example.com", 123456789L);
var token = jwtService.generateToken("0815", "test@example.com", 123456789L, Role.USER);
assertEquals(Optional.of(new UserPrincipal("0815", "test@example.com", 123456789L)),
jwtService.parseLoginToken(token));
@@ -96,7 +101,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void generateToken_ExpiresAfterTheAccessTokenTtl() {
var token = jwtService.generateToken("0815", "test@example.com", null);
var token = jwtService.generateToken("0815", "test@example.com", null, Role.USER);
var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token).getPayload();
long secondsLeft = (claims.getExpiration().getTime() - System.currentTimeMillis()) / 1000;
@@ -156,7 +161,7 @@ class JwtServiceTest extends AbstractUnitTest {
System.arraycopy(prefix, 0, der, 0, prefix.length);
System.arraycopy(raw, 0, der, prefix.length, raw.length);
PublicKey fromJwk = KeyFactory.getInstance("Ed25519").generatePublic(new X509EncodedKeySpec(der));
String token = jwtService.generateToken("7", "a@b.de", null);
String token = jwtService.generateToken("7", "a@b.de", null, Role.USER);
assertEquals("7", Jwts.parser().verifyWith(fromJwk).build().parseSignedClaims(token).getPayload().get("userId"));
}
@@ -172,4 +177,20 @@ class JwtServiceTest extends AbstractUnitTest {
assertTrue(secondsLeft > 0 && secondsLeft <= 60, "expires in " + secondsLeft + "s");
assertEquals(Optional.empty(), jwtService.parseLoginToken(token));
}
@Test
void parseLoginToken_ReadsTheRole() {
String token = jwtService.generateToken("7", "a@b.de", null, Role.AUDITOR);
assertEquals(Role.AUDITOR, jwtService.parseLoginToken(token).orElseThrow().role());
}
@Test
void parseLoginToken_ATokenFromBeforeRolesIsAUser() {
String old = Jwts.builder().header().keyId(KID).and().subject("a@b.de").claim("userId", "7")
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(privateKey, Jwts.SIG.EdDSA).compact();
assertEquals(Role.USER, jwtService.parseLoginToken(old).orElseThrow().role());
}
}
@@ -44,6 +44,7 @@ class AccountServiceTest extends AbstractUnitTest {
service = new AccountService(accountRepository, passwordEncoder, coreAccountService);
lenient().when(accountRepository.findPasswordHashOfActive(USER_ID)).thenReturn(Optional.of("hash"));
lenient().when(passwordEncoder.matches("right", "hash")).thenReturn(true);
lenient().when(accountRepository.flagDeleted(eq(USER_ID), any())).thenReturn(1);
}
@Test
@@ -0,0 +1,234 @@
package auth_service.modules.admin.api;
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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;
/**
* Roles, "view as" and the admin users API against a real database.
*/
@AutoConfigureMockMvc
class AdminFlowTest extends AbstractIntegrationTest {
private static final String PASSWORD = "correct horse battery";
private static final String ADMIN = "admin-flow@example.com";
private static final String OTHER_ADMIN = "admin-flow-2@example.com";
private static final String PLAYER = "admin-flow-player@example.com";
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private PasswordEncoder passwordEncoder;
private long playerId;
private long otherAdminId;
private long insertUser(String email, String role) {
jdbc.update("DELETE FROM users WHERE email = ?", email);
return jdbc.queryForObject("INSERT INTO users (email, password, enabled, role, display_name) VALUES (?, ?, true, ?, ?) "
+ "RETURNING id", Long.class, email, passwordEncoder.encode(PASSWORD), role, email.substring(0, 16));
}
@BeforeEach
void seed() {
insertUser(ADMIN, "PLATFORM_ADMIN");
otherAdminId = insertUser(OTHER_ADMIN, "PLATFORM_ADMIN");
playerId = insertUser(PLAYER, "USER");
}
private MockHttpServletResponse 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();
}
private record Session(String access, String refresh) {
static Session of(MockHttpServletResponse response) {
return new Session(response.getCookie(AuthCookies.ACCESS_COOKIE).getValue(),
response.getCookie(AuthCookies.REFRESH_COOKIE).getValue());
}
}
private ResultActions listUsers(Session session) throws Exception {
return mockMvc.perform(get("/api/admin/users").param("query", "admin-flow")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access())));
}
private MockHttpServletResponse viewAs(Session session, String role) throws Exception {
String body = role == null ? "{\"role\":null}" : "{\"role\":\"" + role + "\"}";
return mockMvc.perform(post("/api/auth/view-as").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.REFRESH_COOKIE, session.refresh()))
.contentType(MediaType.APPLICATION_JSON).content(body))
.andReturn().getResponse();
}
private Session refresh(Session session) throws Exception {
MockHttpServletResponse response = mockMvc.perform(post("/api/auth/refresh")
.header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.REFRESH_COOKIE, session.refresh())))
.andReturn().getResponse();
assertThat(response.getStatus()).isEqualTo(204);
return Session.of(response);
}
private ResultActions block(Session session, long userId, boolean blocked) throws Exception {
return mockMvc.perform(put("/api/admin/users/" + userId + "/blocked").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access()))
.contentType(MediaType.APPLICATION_JSON).content("{\"blocked\":" + blocked + "}"));
}
private long audits(String action, long target) {
return jdbc.queryForObject("SELECT COUNT(*) FROM admin_audit WHERE action = ? AND target_user_id = ?",
Long.class, action, target);
}
@Test
void admin_ListsUsersWithSearchAndPaging() throws Exception {
Session admin = Session.of(login(ADMIN));
listUsers(admin).andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(3))
.andExpect(jsonPath("$.content[0].email").value(ADMIN))
.andExpect(jsonPath("$.content[0].role").value("PLATFORM_ADMIN"));
mockMvc.perform(get("/api/admin/users").param("query", "admin-flow").param("size", "2").param("page", "1")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access())))
.andExpect(jsonPath("$.content.length()").value(1))
.andExpect(jsonPath("$.totalPages").value(2));
}
@Test
void userAndAnonymous_AreRefused() throws Exception {
listUsers(Session.of(login(PLAYER))).andExpect(status().isForbidden());
mockMvc.perform(get("/api/admin/users")).andExpect(status().isUnauthorized());
}
@Test
void viewAs_LowersTheRightsSurvivesARefreshAndGoesBack() throws Exception {
Session admin = Session.of(login(ADMIN));
MockHttpServletResponse asUser = viewAs(admin, "USER");
assertThat(asUser.getStatus()).isEqualTo(204);
Session user = Session.of(asUser);
listUsers(user).andExpect(status().isForbidden());
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, user.access())))
.andExpect(jsonPath("$.role").value("PLATFORM_ADMIN"))
.andExpect(jsonPath("$.activeRole").value("USER"));
Session refreshed = refresh(user);
listUsers(refreshed).andExpect(status().isForbidden());
Session back = Session.of(viewAs(refreshed, null));
listUsers(back).andExpect(status().isOk());
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM admin_audit a JOIN users u ON u.id = a.actor_user_id "
+ "WHERE u.email = ? AND a.action = 'VIEW_AS'", Long.class, ADMIN)).isEqualTo(2);
}
@Test
void viewAs_AboveTheOwnRoleIsRefused() throws Exception {
MockHttpServletResponse response = viewAs(Session.of(login(PLAYER)), "PLATFORM_ADMIN");
assertThat(response.getStatus()).isEqualTo(403);
assertThat(response.getContentAsString()).contains("ROLE_NOT_ALLOWED");
}
@Test
void demotedAdmin_LosesTheRightsAtTheNextRefresh() throws Exception {
Session admin = Session.of(login(ADMIN));
jdbc.update("UPDATE users SET role = 'USER' WHERE email = ?", ADMIN);
listUsers(refresh(admin)).andExpect(status().isForbidden());
}
@Test
void block_EndsTheSessionsAndTheLoginTellsWhy() throws Exception {
Session admin = Session.of(login(ADMIN));
Session player = Session.of(login(PLAYER));
block(admin, playerId, true).andExpect(status().isNoContent());
assertThat(login(PLAYER).getContentAsString()).contains("ACCOUNT_BLOCKED");
assertThat(login(PLAYER).getStatus()).isEqualTo(403);
assertThat(mockMvc.perform(post("/api/auth/refresh").header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.REFRESH_COOKIE, player.refresh()))).andReturn().getResponse().getStatus())
.isEqualTo(401);
assertThat(audits("BLOCK", playerId)).isEqualTo(1);
block(admin, playerId, false).andExpect(status().isNoContent());
assertThat(login(PLAYER).getStatus()).isEqualTo(200);
}
@Test
void delete_FlagsTheUser() throws Exception {
Session admin = Session.of(login(ADMIN));
mockMvc.perform(delete("/api/admin/users/" + playerId).header(ClientHeaderFilter.CLIENT_HEADER, "web")
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, admin.access())))
.andExpect(status().isNoContent());
assertThat(jdbc.queryForObject("SELECT deleted_at FROM users WHERE id = ?", Object.class, playerId)).isNotNull();
assertThat(login(PLAYER).getStatus()).isEqualTo(401);
assertThat(audits("DELETE", playerId)).isEqualTo(1);
}
@Test
void promotedUser_SeesTheNewRoleAfterLoggingInAgain() throws Exception {
Session before = Session.of(login(PLAYER));
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, before.access())))
.andExpect(jsonPath("$.role").value("USER"));
jdbc.update("UPDATE users SET role = 'PLATFORM_ADMIN' WHERE email = ?", PLAYER);
Session after = Session.of(login(PLAYER));
listUsers(after).andExpect(status().isOk());
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, after.access())))
.andExpect(jsonPath("$.activeRole").value("PLATFORM_ADMIN"))
.andExpect(jsonPath("$.role").value("PLATFORM_ADMIN"));
}
@Test
void viewAs_KeepsTheChoiceThroughADemotionAndBack() throws Exception {
Session auditor = Session.of(viewAs(Session.of(login(ADMIN)), "AUDITOR"));
jdbc.update("UPDATE users SET role = 'USER' WHERE email = ?", ADMIN);
Session demoted = refresh(auditor);
jdbc.update("UPDATE users SET role = 'PLATFORM_ADMIN' WHERE email = ?", ADMIN);
Session promoted = refresh(demoted);
mockMvc.perform(get("/api/users/me").cookie(new Cookie(AuthCookies.ACCESS_COOKIE, promoted.access())))
.andExpect(jsonPath("$.activeRole").value("AUDITOR"));
listUsers(Session.of(viewAs(promoted, null))).andExpect(status().isOk());
}
@Test
void guards_RefuseAnotherAdminAndAnUnknownUser() throws Exception {
Session admin = Session.of(login(ADMIN));
block(admin, otherAdminId, true).andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("TARGET_IS_ADMIN"));
block(admin, 999_999_999L, true).andExpect(status().isNotFound());
}
}
@@ -0,0 +1,86 @@
package auth_service.modules.admin.repo;
import auth_service.modules.admin.model.AdminUser;
import auth_service.shared.security.Role;
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.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
@Import(AdminUserRepository.class)
class AdminUserRepositoryTest extends AbstractPersistenceTest {
@Autowired
private AdminUserRepository repository;
@Autowired
private JdbcTemplate jdbc;
private long alice;
private long insert(String email, String name) {
return jdbc.queryForObject("INSERT INTO users (email, password, enabled, display_name) VALUES (?, 'x', true, ?) "
+ "RETURNING id", Long.class, email, name);
}
@BeforeEach
void seed() {
jdbc.update("DELETE FROM users");
alice = insert("alice@example.com", "Alice_1");
insert("bob@example.com", "Bob");
insert("carol@sample.org", "100%Carol");
}
@Test
void findPage_SearchesEmailAndNameInAnyCase() {
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);
}
@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);
}
@Test
void findPage_Pages() {
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();
assertThat(found.role()).isEqualTo(Role.AUDITOR);
assertThat(found.blocked()).isTrue();
assertThat(found.steamLinked()).isTrue();
assertThat(found.deletedAt()).isNotNull();
}
@Test
void setBlocked_RevokesTheLoginsAndUnblockKeepsThemRevoked() {
jdbc.update("INSERT INTO refresh_tokens (user_id, family_id, token_hash, created_at, expires_at) "
+ "VALUES (?, gen_random_uuid(), 'h1', NOW(), NOW() + interval '1 day')", alice);
repository.setBlocked(alice, true, Instant.now());
assertThat(jdbc.queryForObject("SELECT enabled FROM users WHERE id = ?", Boolean.class, alice)).isFalse();
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM refresh_tokens WHERE user_id = ? AND revoked_at IS NULL",
Long.class, alice)).isZero();
repository.setBlocked(alice, false, Instant.now());
assertThat(jdbc.queryForObject("SELECT enabled FROM users WHERE id = ?", Boolean.class, alice)).isTrue();
}
}
@@ -0,0 +1,131 @@
package auth_service.modules.admin.service;
import auth_service.modules.admin.exception.AdminActionRefusedException;
import auth_service.modules.admin.exception.TargetUserNotFoundException;
import auth_service.modules.admin.model.TargetState;
import auth_service.modules.admin.repo.AdminUserRepository;
import auth_service.shared.account.AccountDeleter;
import auth_service.shared.audit.AdminAuditLog;
import auth_service.shared.security.Role;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import java.util.Optional;
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.anyBoolean;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
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 AdminUserServiceTest extends AbstractUnitTest {
private static final long ADMIN = 1L;
private static final long TARGET = 2L;
@Mock
private AdminUserRepository adminUserRepository;
@Mock
private AdminAuditLog adminAuditLog;
@Mock
private AccountDeleter accountDeleter;
private AdminUserService service;
@BeforeEach
void setUp() {
service = new AdminUserService(adminUserRepository, adminAuditLog, accountDeleter);
}
private void target(Role role, boolean deleted) {
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.of(new TargetState(role, deleted)));
}
private String refusal(Runnable action) {
try {
action.run();
} catch (AdminActionRefusedException e) {
return e.getCode();
}
throw new AssertionError("not refused");
}
@Test
void block_BlocksAndWritesTheAudit() {
target(Role.USER, false);
service.setBlocked(ADMIN, TARGET, true);
verify(adminUserRepository).setBlocked(eq(TARGET), eq(true), any());
verify(adminAuditLog).record(ADMIN, "BLOCK", TARGET, null);
}
@Test
void unblock_WritesItsOwnAuditAction() {
target(Role.AUDITOR, false);
service.setBlocked(ADMIN, TARGET, false);
verify(adminAuditLog).record(ADMIN, "UNBLOCK", TARGET, null);
}
@Test
void guards_RefuseTheOwnAccountAnotherAdminAndADeletedUser() {
assertThat(refusal(() -> service.setBlocked(ADMIN, ADMIN, true))).isEqualTo("SELF_ACTION");
assertThat(refusal(() -> service.delete(ADMIN, ADMIN))).isEqualTo("SELF_ACTION");
target(Role.PLATFORM_ADMIN, false);
assertThat(refusal(() -> service.setBlocked(ADMIN, TARGET, true))).isEqualTo("TARGET_IS_ADMIN");
assertThat(refusal(() -> service.delete(ADMIN, TARGET))).isEqualTo("TARGET_IS_ADMIN");
target(Role.USER, true);
assertThat(refusal(() -> service.setBlocked(ADMIN, TARGET, true))).isEqualTo("ALREADY_DELETED");
verify(adminUserRepository, never()).setBlocked(anyLong(), anyBoolean(), any());
verifyNoInteractions(accountDeleter, adminAuditLog);
}
@Test
void unknownUser_IsNotFound() {
when(adminUserRepository.findState(TARGET)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.delete(ADMIN, TARGET)).isInstanceOf(TargetUserNotFoundException.class);
}
@Test
void delete_GoesThroughTheAccountFlagAndIsAudited() {
target(Role.USER, false);
when(accountDeleter.deleteByAdmin(TARGET)).thenReturn(true);
service.delete(ADMIN, TARGET);
verify(adminAuditLog).record(ADMIN, "DELETE", TARGET, null);
}
@Test
void delete_OfAnAlreadyFlaggedUserWritesNoAudit() {
target(Role.USER, true);
when(accountDeleter.deleteByAdmin(TARGET)).thenReturn(false);
service.delete(ADMIN, TARGET);
verifyNoInteractions(adminAuditLog);
}
@Test
void findUsers_ClampsTheSizeAndCountsThePages() {
when(adminUserRepository.count("bob")).thenReturn(250L);
assertThat(service.findUsers(" bob ", -1, 500).totalPages()).isEqualTo(3);
verify(adminUserRepository).findPage("bob", 0, 100);
}
}
@@ -1,6 +1,8 @@
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.InvalidCredentialsException;
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
import auth_service.modules.login.model.Session;
@@ -75,7 +77,7 @@ class LoginServiceTest extends AbstractUnitTest {
.build();
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
when(tokenGenerator.generateToken("815", "test@example.com", null)).thenReturn("mock-token");
when(tokenGenerator.generateToken("815", "test@example.com", null, Role.USER)).thenReturn("mock-token");
when(refreshTokenService.startFamily(815L)).thenReturn("mock-refresh");
// Act
@@ -140,7 +142,7 @@ class LoginServiceTest extends AbstractUnitTest {
}
@Test
void login_DisabledUser() {
void login_BlockedUserWithTheRightPassword_IsToldItIsBlocked() {
// Arrange
var dbUser = User.builder()
.id(815L)
@@ -152,8 +154,30 @@ class LoginServiceTest extends AbstractUnitTest {
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
// Act & Assert
assertThrows(AccountBlockedException.class, () -> loginService.login(user, IP, null));
verify(tokenGenerator, never()).generateToken(any(), any(), any(), any());
verify(loginAttemptService).recordSuccess("test@example.com", IP);
}
@Test
void login_BlockedUserWithAWrongPassword_LooksLikeAnyWrongPassword() {
var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(false).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));
verify(tokenGenerator, never()).generateToken(any(), any(), any());
}
@Test
void login_TheTokenCarriesTheStoredRole() {
var dbUser = User.builder().id(815L).email("test@example.com").password("encodedPassword").enabled(true)
.role(Role.PLATFORM_ADMIN).build();
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
loginService.login(user, IP, null);
verify(tokenGenerator).generateToken("815", "test@example.com", null, Role.PLATFORM_ADMIN);
}
@Test
@@ -1,5 +1,6 @@
package auth_service.modules.user.service;
import auth_service.shared.security.Role;
import auth_service.modules.user.exception.DisplayNameAlreadyInUseException;
import auth_service.modules.user.exception.UserNotFoundException;
import auth_service.modules.user.model.Appearance;
@@ -150,9 +151,9 @@ class UserServiceTest extends AbstractUnitTest {
@Test
void unlinkSteamId_ClearsItAndReturnsATokenWithoutIt() {
when(tokenGenerator.generateToken("1", "test@example.com", null)).thenReturn("token-without-steam");
when(tokenGenerator.generateToken("1", "test@example.com", null, Role.USER)).thenReturn("token-without-steam");
assertEquals("token-without-steam", userService.unlinkSteamId(1L, "test@example.com"));
assertEquals("token-without-steam", userService.unlinkSteamId(1L, "test@example.com", Role.USER));
verify(userRepository).clearSteamId(1L);
}
@@ -0,0 +1,26 @@
package auth_service.shared.security;
import auth_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class RoleTest extends AbstractUnitTest {
@Test
void theLadderGoesUserAuditorAdmin() {
assertTrue(Role.PLATFORM_ADMIN.atLeast(Role.AUDITOR));
assertTrue(Role.AUDITOR.atLeast(Role.AUDITOR));
assertFalse(Role.USER.atLeast(Role.AUDITOR));
assertEquals(Role.USER, Role.lower(Role.PLATFORM_ADMIN, Role.USER));
}
@Test
void parse_UnknownOrMissingIsUser() {
assertEquals(Role.AUDITOR, Role.parse("AUDITOR"));
assertEquals(Role.USER, Role.parse(null));
assertEquals(Role.USER, Role.parse("ROOT"));
}
}