Public Access
feat(admin): let admins change user roles
V32 adds role_changed_at and the role_change log. Role changes lock the admin rows, so there is always one admin left; the last admin cannot delete their own account. Login and refresh read the role under the user lock. Tokens issued before a role change are refused.
This commit is contained in:
1 parent
3410cbc598
commit
365b69feb4
44 files changed
+1238
-75
No files matched your search
@@ -93,8 +93,12 @@ class JwtServiceTest extends AbstractUnitTest {
|
||||
void parseLoginToken_ReturnsTheUser() {
|
||||
var token = jwtService.generateToken("0815", 123456789L, Role.USER);
|
||||
|
||||
assertEquals(Optional.of(new UserPrincipal("0815", 123456789L)),
|
||||
jwtService.parseLoginToken(token));
|
||||
UserPrincipal principal = jwtService.parseLoginToken(token).orElseThrow();
|
||||
|
||||
assertEquals("0815", principal.id());
|
||||
assertEquals(123456789L, principal.steamId());
|
||||
assertEquals(Role.USER, principal.role());
|
||||
assertFalse(principal.issuedAt().isAfter(java.time.Instant.now()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -111,6 +115,16 @@ class JwtServiceTest extends AbstractUnitTest {
|
||||
assertTrue(secondsLeft > 14 * 60 && secondsLeft <= 15 * 60, "expires in " + secondsLeft + "s");
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateToken_IsNeverIssuedBeforeNotBefore() {
|
||||
var notBefore = java.time.Instant.now().plusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
|
||||
|
||||
var token = jwtService.generateToken("0815", null, Role.USER, notBefore);
|
||||
|
||||
var claims = Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(token).getPayload();
|
||||
assertEquals(notBefore, claims.getIssuedAt().toInstant());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateProfileTicket_NamesTheUserAndExpiresSoon() {
|
||||
var ticket = jwtService.generateProfileTicket(42L, Role.USER, List.of(), List.of());
|
||||
|
||||
@@ -222,4 +222,31 @@ class DeletedAccountFlowTest extends AbstractIntegrationTest {
|
||||
.content("{\"email\":\"" + EMAIL + "\"}"))
|
||||
.andExpect(jsonPath("$.code").value("EMAIL_ALREADY_IN_USE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theOnlyAdmin_CannotDeleteTheOwnAccount() throws Exception {
|
||||
jdbc.update("UPDATE users SET role = 'USER' WHERE role = 'PLATFORM_ADMIN'");
|
||||
jdbc.update("UPDATE users SET role = 'PLATFORM_ADMIN' WHERE email = ?", EMAIL);
|
||||
String access = login().getCookie(AuthCookies.ACCESS_COOKIE).getValue();
|
||||
|
||||
deleteAccount(access, PASSWORD).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("LAST_ADMIN"));
|
||||
|
||||
assertThat(jdbc.queryForObject("SELECT deleted_at IS NULL FROM users WHERE email = ?", Boolean.class, EMAIL)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void oneOfTwoAdmins_CanDeleteTheOwnAccount() throws Exception {
|
||||
String otherAdmin = "delete-flow-admin@example.com";
|
||||
jdbc.update("UPDATE users SET role = 'USER' WHERE role = 'PLATFORM_ADMIN'");
|
||||
jdbc.update("UPDATE users SET role = 'PLATFORM_ADMIN' WHERE email = ?", EMAIL);
|
||||
jdbc.update("DELETE FROM users WHERE email = ?", otherAdmin);
|
||||
jdbc.update("INSERT INTO users (email, password, enabled, role) VALUES (?, 'x', true, 'PLATFORM_ADMIN')", otherAdmin);
|
||||
String access = login().getCookie(AuthCookies.ACCESS_COOKIE).getValue();
|
||||
|
||||
try {
|
||||
deleteAccount(access, PASSWORD).andExpect(status().isNoContent());
|
||||
} finally {
|
||||
jdbc.update("DELETE FROM users WHERE email = ?", otherAdmin);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,12 +56,28 @@ class AccountRepositoryTest extends AbstractPersistenceTest {
|
||||
void findStates_CarryTheSteamIdTheDisplayNameAndTheBlock() {
|
||||
jdbc.update("update users set enabled = false, steam_id = 76561198000000001, display_name = 'Alice' where id = ?", alice);
|
||||
|
||||
assertThat(repository.findState(alice)).contains(new CoreAccountState(alice, 76561198000000001L, "Alice", true));
|
||||
assertThat(repository.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, "Alice", true),
|
||||
new CoreAccountState(bob, null, null, false));
|
||||
assertThat(repository.findState(alice)).contains(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null));
|
||||
assertThat(repository.findAllStates()).containsExactly(new CoreAccountState(alice, 76561198000000001L, "Alice", true, null),
|
||||
new CoreAccountState(bob, null, null, false, null));
|
||||
assertThat(repository.findState(-1L)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findStates_CarryTheRoleChangeTime() {
|
||||
jdbc.update("update users set role_changed_at = ? where id = ?", Timestamp.from(NOW), alice);
|
||||
|
||||
assertThat(repository.findState(alice)).get().extracting(CoreAccountState::roleChangedAt).isEqualTo(NOW);
|
||||
assertThat(repository.findAllStates()).extracting(CoreAccountState::roleChangedAt).containsExactly(NOW, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockActiveAdmins_ListsTheActiveAdmins() {
|
||||
jdbc.update("update users set role = 'PLATFORM_ADMIN' where id = ?", bob);
|
||||
jdbc.update("update users set role = 'PLATFORM_ADMIN', enabled = false where id = ?", alice);
|
||||
|
||||
assertThat(repository.lockActiveAdmins()).containsExactly(bob);
|
||||
}
|
||||
|
||||
@Test
|
||||
void flagDeleted_SetsTheFlagAndRevokesOnlyThatUsersTokens() {
|
||||
assertThat(repository.flagDeleted(alice, NOW)).isEqualTo(1);
|
||||
|
||||
@@ -31,7 +31,7 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
class AccountCleanupServiceTest extends AbstractUnitTest {
|
||||
|
||||
private static final List<CoreAccountState> STATES = List.of(new CoreAccountState(1L, 76561198000000001L, "lobo", true));
|
||||
private static final List<CoreAccountState> STATES = List.of(new CoreAccountState(1L, 76561198000000001L, "lobo", true, null));
|
||||
|
||||
private static final Duration RETENTION = Duration.ofDays(30);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth_service.modules.account.service;
|
||||
|
||||
import auth_service.modules.account.exception.InvalidPasswordException;
|
||||
import auth_service.modules.account.exception.LastAdminException;
|
||||
import auth_service.modules.account.exception.TooManyAttemptsException;
|
||||
import auth_service.modules.account.repo.AccountRepository;
|
||||
import auth_service.shared.mail.AccountMailer;
|
||||
@@ -83,6 +84,25 @@ class AccountServiceTest extends AbstractUnitTest {
|
||||
assertThat(deletedFrom.getValue()).isBetween(earliest, latest);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteAccount_TheLastAdminIsRefusedAndNothingIsFlagged() {
|
||||
when(accountRepository.lockActiveAdmins()).thenReturn(List.of(USER_ID));
|
||||
|
||||
assertThatThrownBy(() -> service.deleteAccount(USER_ID, "right")).isInstanceOf(LastAdminException.class);
|
||||
|
||||
verify(accountRepository, never()).flagDeleted(anyLong(), any());
|
||||
verifyNoInteractions(accountMailer, coreAccountService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteAccount_OneOfTwoAdminsIsFlagged() {
|
||||
when(accountRepository.lockActiveAdmins()).thenReturn(List.of(USER_ID, 43L));
|
||||
|
||||
service.deleteAccount(USER_ID, "right");
|
||||
|
||||
verify(accountRepository).flagDeleted(eq(USER_ID), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteAccount_WrongPasswordFlagsNothing() {
|
||||
assertThatThrownBy(() -> service.deleteAccount(USER_ID, "wrong")).isInstanceOf(InvalidPasswordException.class);
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -71,8 +72,9 @@ class CoreAccountServiceTest extends AbstractUnitTest {
|
||||
expect("applyStates");
|
||||
expect("replaceStates");
|
||||
|
||||
service.applyStates(List.of(new CoreAccountState(42L, 76561198000000001L, "lobo", true)));
|
||||
service.replaceStates(List.of(new CoreAccountState(42L, null, "lobo", false)));
|
||||
service.applyStates(List.of(new CoreAccountState(42L, 76561198000000001L, "lobo", true,
|
||||
Instant.parse("2026-10-04T12:00:00Z"))));
|
||||
service.replaceStates(List.of(new CoreAccountState(42L, null, "lobo", false, null)));
|
||||
|
||||
core.verify();
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
class CoreAccountStatePublisherTest extends AbstractUnitTest {
|
||||
|
||||
private static final CoreAccountState STATE = new CoreAccountState(7L, 76561198000000001L, "lobo", true);
|
||||
private static final CoreAccountState STATE = new CoreAccountState(7L, 76561198000000001L, "lobo", true, null);
|
||||
|
||||
@Mock
|
||||
private AccountRepository accountRepository;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package auth_service.modules.admin;
|
||||
|
||||
import auth_service.modules.account.exception.LastAdminException;
|
||||
import auth_service.modules.account.service.AccountService;
|
||||
import auth_service.modules.admin.exception.AdminActionRefusedException;
|
||||
import auth_service.modules.admin.service.RoleChangeService;
|
||||
import auth_service.shared.account.ActiveAdmins;
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
import auth_service.shared.test.AbstractIntegrationTest;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.Statement;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Two admins are the only admins. The first transaction locks both and demotes one of them, not committed. The second
|
||||
* write must wait on the locked admin rows and then see the new state. The order is fixed, not timed.
|
||||
*/
|
||||
class AdminLockRaceTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final String PASSWORD = "correct horse battery";
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
@Autowired
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Autowired
|
||||
private RoleChangeService roleChangeService;
|
||||
|
||||
@Autowired
|
||||
private AccountService accountService;
|
||||
|
||||
private long adminA;
|
||||
private long adminB;
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
|
||||
private long insertAdmin(String email) {
|
||||
Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, role, display_name) "
|
||||
+ "VALUES (?, ?, true, 'PLATFORM_ADMIN', ?) RETURNING id", Long.class, email,
|
||||
passwordEncoder.encode(PASSWORD), email.substring(0, 12));
|
||||
addSettingsRows();
|
||||
return id;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbc.update("DELETE FROM users");
|
||||
adminA = insertAdmin("lock-race-a@example.com");
|
||||
adminB = insertAdmin("lock-race-b@example.com");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanUp() {
|
||||
executor.shutdownNow();
|
||||
jdbc.update("DELETE FROM users WHERE email LIKE 'lock-race-%'");
|
||||
}
|
||||
|
||||
private <T> Future<T> asAdmin(long userId, Callable<T> call) {
|
||||
return executor.submit(() -> {
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||
new UserPrincipal(String.valueOf(userId), null, Role.PLATFORM_ADMIN), null,
|
||||
Arrays.stream(Role.values()).map(role -> new SimpleGrantedAuthority("ROLE_" + role.name())).toList()));
|
||||
try {
|
||||
return call.call();
|
||||
} finally {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void waitForLock() throws InterruptedException {
|
||||
long deadline = System.currentTimeMillis() + 10_000;
|
||||
while (jdbc.queryForObject("SELECT count(*) FROM pg_stat_activity WHERE wait_event_type = 'Lock'",
|
||||
Integer.class) == 0) {
|
||||
assertThat(System.currentTimeMillis()).as("the second write waits for the first").isLessThan(deadline);
|
||||
Thread.sleep(20);
|
||||
}
|
||||
}
|
||||
|
||||
private <T> Future<T> secondWrite(long actor, Callable<T> call) throws Exception {
|
||||
try (Connection first = dataSource.getConnection(); Statement statement = first.createStatement()) {
|
||||
first.setAutoCommit(false);
|
||||
statement.executeQuery(ActiveAdmins.LOCK).close();
|
||||
statement.executeUpdate("UPDATE users SET role = 'USER' WHERE id = " + adminB);
|
||||
Future<T> second = asAdmin(actor, call);
|
||||
waitForLock();
|
||||
Thread.sleep(300);
|
||||
assertThat(second.isDone()).as("the second write still waits").isFalse();
|
||||
first.commit();
|
||||
return second;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminDemotedByAnotherTransaction_CannotDemoteTheOtherAdmin() throws Exception {
|
||||
Future<Void> second = secondWrite(adminB, () -> {
|
||||
roleChangeService.changeRole(adminB, adminA, Role.PLATFORM_ADMIN, Role.USER);
|
||||
return null;
|
||||
});
|
||||
|
||||
assertThatThrownBy(() -> second.get(10, TimeUnit.SECONDS)).isInstanceOf(ExecutionException.class)
|
||||
.cause().isInstanceOfSatisfying(AdminActionRefusedException.class,
|
||||
e -> assertThat(e.getCode()).isEqualTo("ACTOR_NOT_ADMIN"));
|
||||
assertThat(jdbc.queryForObject("SELECT role FROM users WHERE id = ?", String.class, adminA))
|
||||
.isEqualTo("PLATFORM_ADMIN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminWhoBecomesTheLastAdminWhileWaiting_CannotDeleteTheAccount() throws Exception {
|
||||
Future<Void> second = secondWrite(adminA, () -> {
|
||||
accountService.deleteAccount(adminA, PASSWORD);
|
||||
return null;
|
||||
});
|
||||
|
||||
assertThatThrownBy(() -> second.get(10, TimeUnit.SECONDS)).isInstanceOf(ExecutionException.class)
|
||||
.cause().isInstanceOf(LastAdminException.class);
|
||||
assertThat(jdbc.queryForObject("SELECT deleted_at IS NULL FROM users WHERE id = ?", Boolean.class, adminA)).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package auth_service.modules.admin;
|
||||
|
||||
import auth_service.configuration.security.ClientHeaderFilter;
|
||||
import auth_service.modules.admin.service.RoleChangeService;
|
||||
import auth_service.shared.api.AuthCookies;
|
||||
import auth_service.shared.security.Role;
|
||||
import auth_service.shared.security.UserPrincipal;
|
||||
import auth_service.shared.test.AbstractIntegrationTest;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
|
||||
/**
|
||||
* A login reads the role before its bcrypt check. A demotion that commits during that check must not leave the login
|
||||
* with an access token that carries the old role and still passes the role change check.
|
||||
*/
|
||||
class RoleChangeLoginRaceTest extends AbstractIntegrationTest {
|
||||
|
||||
private static final String PASSWORD = "correct horse battery";
|
||||
private static final String ADMIN = "login-race-admin@example.com";
|
||||
private static final String VICTIM = "login-race-victim@example.com";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
@MockitoSpyBean
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Autowired
|
||||
private RoleChangeService roleChangeService;
|
||||
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private long adminId;
|
||||
private long victimId;
|
||||
|
||||
private long insertAdmin(String email) {
|
||||
jdbc.update("DELETE FROM users WHERE email = ?", email);
|
||||
Long id = jdbc.queryForObject("INSERT INTO users (email, password, enabled, role, display_name) "
|
||||
+ "VALUES (?, ?, true, 'PLATFORM_ADMIN', ?) RETURNING id", Long.class, email,
|
||||
passwordEncoder.encode(PASSWORD), email.substring(0, 16));
|
||||
addSettingsRows();
|
||||
return id;
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
adminId = insertAdmin(ADMIN);
|
||||
victimId = insertAdmin(VICTIM);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanUp() {
|
||||
executor.shutdownNow();
|
||||
jdbc.update("DELETE FROM role_change WHERE target_user_id = ?", victimId);
|
||||
jdbc.update("DELETE FROM users WHERE email LIKE 'login-race-%'");
|
||||
}
|
||||
|
||||
private void demoteVictimInAnotherThread() throws Exception {
|
||||
executor.submit(() -> {
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
|
||||
new UserPrincipal(String.valueOf(adminId), null, Role.PLATFORM_ADMIN), null,
|
||||
Arrays.stream(Role.values()).map(role -> new SimpleGrantedAuthority("ROLE_" + role.name())).toList()));
|
||||
try {
|
||||
roleChangeService.changeRole(adminId, victimId, Role.PLATFORM_ADMIN, Role.USER);
|
||||
} finally {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
return null;
|
||||
}).get(10, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginRacingADemotionGetsNoTokenWithTheOldRole() throws Exception {
|
||||
AtomicBoolean armed = new AtomicBoolean(true);
|
||||
doAnswer(invocation -> {
|
||||
boolean matches = (boolean) invocation.callRealMethod();
|
||||
if (armed.getAndSet(false)) {
|
||||
demoteVictimInAnotherThread();
|
||||
Instant validFrom = jdbc.queryForObject("SELECT role_changed_at FROM users WHERE id = ?",
|
||||
Timestamp.class, victimId).toInstant();
|
||||
// The bcrypt check of a real login can end after the next second starts.
|
||||
while (Instant.now().isBefore(validFrom.plusMillis(5))) {
|
||||
Thread.sleep(5);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}).when(passwordEncoder).matches(any(), any());
|
||||
|
||||
MockHttpServletResponse login = mockMvc.perform(post("/api/auth/login")
|
||||
.header(ClientHeaderFilter.CLIENT_HEADER, "web").header("X-Forwarded-For", freshClientIp())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"email\":\"" + VICTIM + "\",\"password\":\"" + PASSWORD + "\"}"))
|
||||
.andReturn().getResponse();
|
||||
assertThat(armed.get()).as("the demotion ran inside the login").isFalse();
|
||||
assertThat(jdbc.queryForObject("SELECT role FROM users WHERE id = ?", String.class, victimId)).isEqualTo("USER");
|
||||
|
||||
int adminList = login.getStatus() != 200 ? 401 : mockMvc.perform(get("/api/admin/users")
|
||||
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE,
|
||||
login.getCookie(AuthCookies.ACCESS_COOKIE).getValue())))
|
||||
.andReturn().getResponse().getStatus();
|
||||
|
||||
assertThat(adminList).as("admin list with the token of a login that raced the demotion").isIn(401, 403);
|
||||
}
|
||||
}
|
||||
@@ -337,4 +337,146 @@ class AdminFlowTest extends AbstractIntegrationTest {
|
||||
.andExpect(jsonPath("$.code").value("TARGET_IS_ADMIN"));
|
||||
block(admin, 999_999_999L, true).andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
private ResultActions changeRole(Session session, long userId, String role, String expectedRole) throws Exception {
|
||||
return mockMvc.perform(put("/api/admin/users/" + userId + "/role").header(ClientHeaderFilter.CLIENT_HEADER, "web")
|
||||
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access()))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"role\":\"" + role + "\",\"expectedRole\":\"" + expectedRole + "\"}"));
|
||||
}
|
||||
|
||||
private ResultActions roleChanges(Session session, long userId) throws Exception {
|
||||
return mockMvc.perform(get("/api/admin/users/" + userId + "/role-changes")
|
||||
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access())));
|
||||
}
|
||||
|
||||
private String activeRole(Session session) throws Exception {
|
||||
return com.jayway.jsonpath.JsonPath.read(mockMvc.perform(get("/api/users/me")
|
||||
.cookie(new Cookie(AuthCookies.ACCESS_COOKIE, session.access()))).andReturn().getResponse()
|
||||
.getContentAsString(), "$.activeRole");
|
||||
}
|
||||
|
||||
@Test
|
||||
void roleChange_GrantEndsTheOldTokenAndTheNewOneCarriesTheRole() throws Exception {
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
Session player = Session.of(login(PLAYER));
|
||||
assertThat(me(player)).isEqualTo(200);
|
||||
|
||||
changeRole(admin, playerId, "AUDITOR", "USER").andExpect(status().isNoContent());
|
||||
|
||||
assertThat(me(player)).isEqualTo(401);
|
||||
assertThat(activeRole(refresh(player))).isEqualTo("AUDITOR");
|
||||
roleChanges(admin, playerId).andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.results.length()").value(1))
|
||||
.andExpect(jsonPath("$.results[0].actorEmail").value(ADMIN))
|
||||
.andExpect(jsonPath("$.results[0].oldRole").value("USER"))
|
||||
.andExpect(jsonPath("$.results[0].newRole").value("AUDITOR"));
|
||||
assertThat(audits("ROLE_CHANGE", playerId)).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roleChange_SameRoleWritesNothing() throws Exception {
|
||||
changeRole(Session.of(login(ADMIN)), playerId, "USER", "USER").andExpect(status().isNoContent());
|
||||
|
||||
assertThat(audits("ROLE_CHANGE", playerId)).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM role_change WHERE target_user_id = ?", Long.class, playerId)).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void roleChange_DemotedAdminLosesTheRightsAtOnce() throws Exception {
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
Session other = Session.of(login(OTHER_ADMIN));
|
||||
listUsers(other).andExpect(status().isOk());
|
||||
|
||||
changeRole(admin, otherAdminId, "USER", "PLATFORM_ADMIN").andExpect(status().isNoContent());
|
||||
|
||||
listUsers(other).andExpect(status().isUnauthorized());
|
||||
listUsers(refresh(other)).andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void roleChange_AdminWhoViewsAsUserIsAuditorAfterTheDemotionToAuditor() throws Exception {
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
Session asUser = Session.of(viewAs(Session.of(login(OTHER_ADMIN)), "USER"));
|
||||
|
||||
changeRole(admin, otherAdminId, "AUDITOR", "PLATFORM_ADMIN").andExpect(status().isNoContent());
|
||||
|
||||
assertThat(activeRole(refresh(asUser))).isEqualTo("AUDITOR");
|
||||
}
|
||||
|
||||
@Test
|
||||
void roleChange_RefusesOwnRoleStaleRoleBlockedTargetBadRoleAndUnknownUser() throws Exception {
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
|
||||
changeRole(admin, jdbc.queryForObject("SELECT id FROM users WHERE email = ?", Long.class, ADMIN), "USER",
|
||||
"PLATFORM_ADMIN").andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("SELF_ACTION"));
|
||||
changeRole(admin, playerId, "AUDITOR", "AUDITOR").andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("ROLE_CHANGED"));
|
||||
changeRole(admin, playerId, "GOD", "USER").andExpect(status().isBadRequest());
|
||||
changeRole(admin, 999_999_999L, "AUDITOR", "USER").andExpect(status().isNotFound());
|
||||
block(admin, playerId, true).andExpect(status().isNoContent());
|
||||
changeRole(admin, playerId, "AUDITOR", "USER").andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("TARGET_IS_BLOCKED"));
|
||||
|
||||
assertThat(jdbc.queryForObject("SELECT role FROM users WHERE id = ?", String.class, playerId)).isEqualTo("USER");
|
||||
}
|
||||
|
||||
@Test
|
||||
void roleEndpoints_AreForAdminsOnly() throws Exception {
|
||||
Session player = Session.of(login(PLAYER));
|
||||
jdbc.update("UPDATE users SET role = 'AUDITOR' WHERE email = ?", OTHER_ADMIN);
|
||||
Session auditor = Session.of(login(OTHER_ADMIN));
|
||||
Session viewingAsAuditor = Session.of(viewAs(Session.of(login(ADMIN)), "AUDITOR"));
|
||||
|
||||
for (Session session : new Session[]{player, auditor, viewingAsAuditor}) {
|
||||
changeRole(session, playerId, "AUDITOR", "USER").andExpect(status().isForbidden());
|
||||
roleChanges(session, playerId).andExpect(status().isForbidden());
|
||||
}
|
||||
mockMvc.perform(get("/api/admin/users/" + playerId + "/role-changes")).andExpect(status().isUnauthorized());
|
||||
assertThat(jdbc.queryForObject("SELECT role FROM users WHERE id = ?", String.class, playerId)).isEqualTo("USER");
|
||||
}
|
||||
|
||||
@Test
|
||||
void roleChange_TokenFromTheSecondOfTheDemotionLosesTheAdminRights() throws Exception {
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
while (System.currentTimeMillis() % 1000 > 50) {
|
||||
Thread.onSpinWait();
|
||||
}
|
||||
Session other = Session.of(login(OTHER_ADMIN));
|
||||
|
||||
changeRole(admin, otherAdminId, "USER", "PLATFORM_ADMIN").andExpect(status().isNoContent());
|
||||
|
||||
listUsers(other).andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void roleChange_RefreshInTheSecondOfTheChangeGivesAValidToken() throws Exception {
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
while (System.currentTimeMillis() % 1000 > 50) {
|
||||
Thread.onSpinWait();
|
||||
}
|
||||
Session player = Session.of(login(PLAYER));
|
||||
|
||||
changeRole(admin, playerId, "AUDITOR", "USER").andExpect(status().isNoContent());
|
||||
Session refreshed = refresh(player);
|
||||
|
||||
assertThat(me(player)).isEqualTo(401);
|
||||
assertThat(me(refreshed)).isEqualTo(200);
|
||||
assertThat(activeRole(refreshed)).isEqualTo("AUDITOR");
|
||||
}
|
||||
|
||||
@Test
|
||||
void roleChange_TokenFromBetweenTwoChangesInOneSecondLosesTheRole() throws Exception {
|
||||
Session admin = Session.of(login(ADMIN));
|
||||
Session player = Session.of(login(PLAYER));
|
||||
while (System.currentTimeMillis() % 1000 > 50) {
|
||||
Thread.onSpinWait();
|
||||
}
|
||||
|
||||
changeRole(admin, playerId, "PLATFORM_ADMIN", "USER").andExpect(status().isNoContent());
|
||||
Session promoted = refresh(player);
|
||||
changeRole(admin, playerId, "USER", "PLATFORM_ADMIN").andExpect(status().isNoContent());
|
||||
|
||||
listUsers(promoted).andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package auth_service.modules.admin.repo;
|
||||
|
||||
import auth_service.modules.admin.model.RoleChange;
|
||||
import auth_service.modules.admin.model.RoleTarget;
|
||||
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.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class RoleChangeRepositoryTest extends AbstractPersistenceTest {
|
||||
|
||||
private static final Instant NOW = Instant.now().truncatedTo(ChronoUnit.SECONDS);
|
||||
|
||||
@Autowired
|
||||
private RoleChangeRepository repository;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbc;
|
||||
|
||||
private long admin;
|
||||
private long otherAdmin;
|
||||
private long user;
|
||||
|
||||
private long insert(String email, String role) {
|
||||
return jdbc.queryForObject("INSERT INTO users (email, password, enabled, role) VALUES (?, 'x', true, ?) RETURNING id",
|
||||
Long.class, email, role);
|
||||
}
|
||||
|
||||
private void addLogin(long userId) {
|
||||
jdbc.update("""
|
||||
INSERT INTO refresh_tokens (user_id, token_hash, family_id, expires_at, created_at, active_role)
|
||||
VALUES (?, ?, ?, now() + interval '30 days', now(), 'USER')""", userId, UUID.randomUUID().toString(),
|
||||
UUID.randomUUID());
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
jdbc.update("DELETE FROM role_change");
|
||||
jdbc.update("DELETE FROM users");
|
||||
admin = insert("admin@example.com", "PLATFORM_ADMIN");
|
||||
otherAdmin = insert("other-admin@example.com", "PLATFORM_ADMIN");
|
||||
user = insert("user@example.com", "USER");
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockActiveAdmins_ListsOnlyActiveAdminsByIdAndNoOtherRole() {
|
||||
long blocked = insert("blocked-admin@example.com", "PLATFORM_ADMIN");
|
||||
jdbc.update("UPDATE users SET enabled = false WHERE id = ?", blocked);
|
||||
long deleted = insert("deleted-admin@example.com", "PLATFORM_ADMIN");
|
||||
jdbc.update("UPDATE users SET deleted_at = now() WHERE id = ?", deleted);
|
||||
long pending = insert("pending-admin@example.com", "PLATFORM_ADMIN");
|
||||
jdbc.update("UPDATE users SET status = 'PENDING' WHERE id = ?", pending);
|
||||
|
||||
assertThat(repository.lockActiveAdmins()).containsExactly(admin, otherAdmin);
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockTarget_ReadsRoleBlockDeletedAndStatus() {
|
||||
jdbc.update("UPDATE users SET enabled = false WHERE id = ?", user);
|
||||
|
||||
assertThat(repository.lockTarget(user)).contains(new RoleTarget(Role.USER, true, false, "ACTIVE", null));
|
||||
jdbc.update("UPDATE users SET status = 'DENIED' WHERE id = ?", user);
|
||||
assertThat(repository.lockTarget(user)).get().extracting(RoleTarget::deleted).isEqualTo(true);
|
||||
assertThat(repository.lockTarget(-1L)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateRole_SetsRoleAndTimeAndClearsTheActiveRoleOfOpenLoginsOnly() {
|
||||
addLogin(user);
|
||||
addLogin(user);
|
||||
jdbc.update("UPDATE refresh_tokens SET revoked_at = now() WHERE id = (SELECT min(id) FROM refresh_tokens WHERE user_id = ?)", user);
|
||||
addLogin(otherAdmin);
|
||||
|
||||
repository.updateRole(user, Role.AUDITOR, NOW);
|
||||
|
||||
assertThat(jdbc.queryForObject("SELECT role FROM users WHERE id = ?", String.class, user)).isEqualTo("AUDITOR");
|
||||
assertThat(jdbc.queryForObject("SELECT role_changed_at FROM users WHERE id = ?", Timestamp.class, user).toInstant())
|
||||
.isEqualTo(NOW);
|
||||
assertThat(jdbc.queryForList("SELECT active_role FROM refresh_tokens WHERE user_id = ? AND revoked_at IS NULL",
|
||||
String.class, user)).containsOnlyNulls().hasSize(1);
|
||||
assertThat(jdbc.queryForList("SELECT active_role FROM refresh_tokens WHERE user_id = ? AND revoked_at IS NOT NULL",
|
||||
String.class, user)).containsExactly("USER");
|
||||
assertThat(jdbc.queryForList("SELECT active_role FROM refresh_tokens WHERE user_id = ?", String.class, otherAdmin))
|
||||
.containsExactly("USER");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findChanges_NewestFirstLimitedWithTheActorEmailAndNullForAGoneActor() {
|
||||
repository.insertChange(admin, user, Role.USER, Role.AUDITOR, NOW.minusSeconds(20));
|
||||
repository.insertChange(otherAdmin, user, Role.AUDITOR, Role.PLATFORM_ADMIN, NOW.minusSeconds(10));
|
||||
repository.insertChange(admin, user, Role.PLATFORM_ADMIN, Role.USER, NOW);
|
||||
repository.insertChange(admin, otherAdmin, Role.USER, Role.AUDITOR, NOW);
|
||||
jdbc.update("DELETE FROM users WHERE id = ?", otherAdmin);
|
||||
|
||||
assertThat(repository.findChanges(user, 10)).containsExactly(
|
||||
new RoleChange(admin, "admin@example.com", Role.PLATFORM_ADMIN, Role.USER, NOW),
|
||||
new RoleChange(otherAdmin, null, Role.AUDITOR, Role.PLATFORM_ADMIN, NOW.minusSeconds(10)),
|
||||
new RoleChange(admin, "admin@example.com", Role.USER, Role.AUDITOR, NOW.minusSeconds(20)));
|
||||
assertThat(repository.findChanges(user, 2)).hasSize(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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.RoleChange;
|
||||
import auth_service.modules.admin.model.RoleTarget;
|
||||
import auth_service.modules.admin.repo.RoleChangeRepository;
|
||||
import auth_service.shared.account.AccountStatePublisher;
|
||||
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.ArgumentCaptor;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
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.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
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 RoleChangeServiceTest extends AbstractUnitTest {
|
||||
|
||||
private static final long ACTOR = 1L;
|
||||
private static final long TARGET = 2L;
|
||||
|
||||
@Mock
|
||||
private RoleChangeRepository repository;
|
||||
|
||||
@Mock
|
||||
private AdminAuditLog adminAuditLog;
|
||||
|
||||
@Mock
|
||||
private AccountStatePublisher accountStatePublisher;
|
||||
|
||||
private RoleChangeService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new RoleChangeService(repository, adminAuditLog, accountStatePublisher);
|
||||
lenient().when(repository.lockActiveAdmins()).thenReturn(List.of(ACTOR));
|
||||
target(Role.USER, false, false, "ACTIVE");
|
||||
}
|
||||
|
||||
private void target(Role role, boolean blocked, boolean deleted, String status) {
|
||||
target(role, blocked, deleted, status, null);
|
||||
}
|
||||
|
||||
private void target(Role role, boolean blocked, boolean deleted, String status, Instant roleChangedAt) {
|
||||
lenient().when(repository.lockTarget(TARGET))
|
||||
.thenReturn(Optional.of(new RoleTarget(role, blocked, deleted, status, roleChangedAt)));
|
||||
}
|
||||
|
||||
private String refusal(Role expectedRole, Role newRole) {
|
||||
try {
|
||||
service.changeRole(ACTOR, TARGET, expectedRole, newRole);
|
||||
} catch (AdminActionRefusedException e) {
|
||||
return e.getCode();
|
||||
}
|
||||
throw new AssertionError("not refused");
|
||||
}
|
||||
|
||||
private void verifyNothingWritten() {
|
||||
verify(repository, never()).updateRole(anyLong(), any(), any());
|
||||
verify(repository, never()).insertChange(anyLong(), anyLong(), any(), any(), any());
|
||||
verifyNoInteractions(adminAuditLog, accountStatePublisher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void grant_WritesTheRoleTheLogTheAuditAndTellsCore() {
|
||||
service.changeRole(ACTOR, TARGET, Role.USER, Role.AUDITOR);
|
||||
|
||||
Instant before = Instant.now();
|
||||
ArgumentCaptor<Instant> validFrom = ArgumentCaptor.forClass(Instant.class);
|
||||
verify(repository).updateRole(eq(TARGET), eq(Role.AUDITOR), validFrom.capture());
|
||||
verify(repository).insertChange(eq(ACTOR), eq(TARGET), eq(Role.USER), eq(Role.AUDITOR), any());
|
||||
assertThat(validFrom.getValue()).isEqualTo(validFrom.getValue().truncatedTo(ChronoUnit.SECONDS))
|
||||
.isAfter(before.minusSeconds(1)).isBeforeOrEqualTo(before.plusSeconds(2));
|
||||
verify(adminAuditLog).record(ACTOR, "ROLE_CHANGE", TARGET, "USER>AUDITOR");
|
||||
verify(accountStatePublisher).publish(TARGET);
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondChangeInTheSameSecond_IsValidFromAfterTheFirst() {
|
||||
Instant previous = Instant.now().truncatedTo(ChronoUnit.SECONDS).plusSeconds(1);
|
||||
target(Role.AUDITOR, false, false, "ACTIVE", previous);
|
||||
|
||||
service.changeRole(ACTOR, TARGET, Role.AUDITOR, Role.USER);
|
||||
|
||||
verify(repository).updateRole(TARGET, Role.USER, previous.plusSeconds(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changeRole_LocksTheAdminsBeforeTheTarget() {
|
||||
service.changeRole(ACTOR, TARGET, Role.USER, Role.AUDITOR);
|
||||
|
||||
InOrder order = inOrder(repository);
|
||||
order.verify(repository).lockActiveAdmins();
|
||||
order.verify(repository).lockTarget(TARGET);
|
||||
order.verify(repository).updateRole(eq(TARGET), eq(Role.AUDITOR), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownRole_IsRefusedBeforeAnyLock() {
|
||||
assertThatThrownBy(() -> service.changeRole(ACTOR, ACTOR, Role.PLATFORM_ADMIN, Role.USER))
|
||||
.isInstanceOfSatisfying(AdminActionRefusedException.class, e -> assertThat(e.getCode()).isEqualTo("SELF_ACTION"));
|
||||
|
||||
verifyNoInteractions(repository);
|
||||
verifyNothingWritten();
|
||||
}
|
||||
|
||||
@Test
|
||||
void actorWhoIsNoAdminAnyMore_IsRefused() {
|
||||
when(repository.lockActiveAdmins()).thenReturn(List.of(9L));
|
||||
|
||||
assertThat(refusal(Role.USER, Role.AUDITOR)).isEqualTo("ACTOR_NOT_ADMIN");
|
||||
verifyNothingWritten();
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleExpectedRole_IsRefused() {
|
||||
assertThat(refusal(Role.AUDITOR, Role.PLATFORM_ADMIN)).isEqualTo("ROLE_CHANGED");
|
||||
verifyNothingWritten();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletedBlockedPendingAndUnconfirmedTargets_AreRefused() {
|
||||
target(Role.USER, false, true, "ACTIVE");
|
||||
assertThat(refusal(Role.USER, Role.AUDITOR)).isEqualTo("ALREADY_DELETED");
|
||||
target(Role.USER, true, false, "ACTIVE");
|
||||
assertThat(refusal(Role.USER, Role.AUDITOR)).isEqualTo("TARGET_IS_BLOCKED");
|
||||
target(Role.USER, false, false, "PENDING");
|
||||
assertThat(refusal(Role.USER, Role.AUDITOR)).isEqualTo("TARGET_IS_PENDING");
|
||||
target(Role.USER, false, false, "UNCONFIRMED");
|
||||
assertThat(refusal(Role.USER, Role.AUDITOR)).isEqualTo("TARGET_IS_UNCONFIRMED");
|
||||
|
||||
verifyNothingWritten();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownTarget_IsNotFound() {
|
||||
when(repository.lockTarget(TARGET)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.changeRole(ACTOR, TARGET, Role.USER, Role.AUDITOR))
|
||||
.isInstanceOf(TargetUserNotFoundException.class);
|
||||
verifyNothingWritten();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameRole_WritesNothing() {
|
||||
service.changeRole(ACTOR, TARGET, Role.USER, Role.USER);
|
||||
|
||||
verifyNothingWritten();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findChanges_ReadsTheLog() {
|
||||
List<RoleChange> log = List.of(new RoleChange(ACTOR, "a@example.com", Role.USER, Role.AUDITOR, Instant.now()));
|
||||
when(repository.findChanges(TARGET, 50)).thenReturn(log);
|
||||
|
||||
assertThat(service.findChanges(TARGET)).isEqualTo(log);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
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;
|
||||
@@ -47,9 +45,6 @@ class LoginServiceTest extends AbstractUnitTest {
|
||||
@Mock
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Mock
|
||||
private TokenGenerator tokenGenerator;
|
||||
|
||||
@Mock
|
||||
private LoginAttemptService loginAttemptService;
|
||||
|
||||
@@ -65,8 +60,8 @@ class LoginServiceTest extends AbstractUnitTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
lenient().when(passwordEncoder.encode(anyString())).thenReturn("dummy-hash");
|
||||
loginService = new LoginService(minimalUserRepository, passwordEncoder, tokenGenerator, loginAttemptService,
|
||||
refreshTokenService, cacheManager);
|
||||
loginService = new LoginService(minimalUserRepository, passwordEncoder, loginAttemptService, refreshTokenService,
|
||||
cacheManager);
|
||||
user = User.builder()
|
||||
.email("test@example.com")
|
||||
.password("password")
|
||||
@@ -84,8 +79,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", null, Role.USER)).thenReturn("mock-token");
|
||||
when(refreshTokenService.startFamily(815L, dbUser.getPassword())).thenReturn("mock-refresh");
|
||||
when(refreshTokenService.startSession(815L, dbUser.getPassword())).thenReturn(new Session("mock-token", "mock-refresh"));
|
||||
|
||||
// Act
|
||||
var result = loginService.login(user, IP, null);
|
||||
@@ -176,7 +170,7 @@ class LoginServiceTest extends AbstractUnitTest {
|
||||
|
||||
// Act & Assert
|
||||
assertThrows(AccountBlockedException.class, () -> loginService.login(user, IP, null));
|
||||
verify(tokenGenerator, never()).generateToken(any(), any(), any());
|
||||
verify(refreshTokenService, never()).startSession(anyLong(), any());
|
||||
verify(loginAttemptService).recordSuccess("test@example.com", IP);
|
||||
}
|
||||
|
||||
@@ -188,7 +182,7 @@ class LoginServiceTest extends AbstractUnitTest {
|
||||
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(refreshTokenService, never()).startSession(anyLong(), any());
|
||||
verify(loginAttemptService).recordSuccess("test@example.com", IP);
|
||||
}
|
||||
|
||||
@@ -198,7 +192,7 @@ class LoginServiceTest extends AbstractUnitTest {
|
||||
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
|
||||
|
||||
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
|
||||
verify(tokenGenerator, never()).generateToken(any(), any(), any());
|
||||
verify(refreshTokenService, never()).startSession(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -220,18 +214,6 @@ class LoginServiceTest extends AbstractUnitTest {
|
||||
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
|
||||
}
|
||||
|
||||
@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", null, Role.PLATFORM_ADMIN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void login_TooManyAttempts() {
|
||||
// Arrange
|
||||
@@ -252,6 +234,6 @@ class LoginServiceTest extends AbstractUnitTest {
|
||||
|
||||
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP, null));
|
||||
|
||||
verify(refreshTokenService, never()).startFamily(anyLong(), any());
|
||||
verify(refreshTokenService, never()).startSession(anyLong(), any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package auth_service.shared.security;
|
||||
|
||||
import auth_service.shared.test.AbstractUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class UserTokenStateTest extends AbstractUnitTest {
|
||||
|
||||
private static final Instant CHANGE = Instant.parse("2026-10-04T12:00:00Z");
|
||||
|
||||
@Test
|
||||
void activeWithoutRoleChange_AcceptsAnyToken() {
|
||||
assertTrue(new UserTokenState(true, null).accepts(CHANGE));
|
||||
assertTrue(new UserTokenState(true, null).accepts(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void inactive_RefusesEveryToken() {
|
||||
assertFalse(UserTokenState.INACTIVE.accepts(CHANGE));
|
||||
assertFalse(new UserTokenState(false, CHANGE).accepts(CHANGE.plusSeconds(60)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenFromBeforeTheRoleChange_IsRefused() {
|
||||
assertFalse(new UserTokenState(true, CHANGE).accepts(CHANGE.minusSeconds(1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenFromTheChangeSecondOrLater_IsAccepted() {
|
||||
assertTrue(new UserTokenState(true, CHANGE).accepts(CHANGE));
|
||||
assertTrue(new UserTokenState(true, CHANGE).accepts(CHANGE.plusSeconds(1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenWithoutIssueTime_IsRefusedAfterARoleChange() {
|
||||
assertFalse(new UserTokenState(true, CHANGE).accepts(null));
|
||||
}
|
||||
}
|
||||
@@ -37,12 +37,12 @@
|
||||
"applyStates": {
|
||||
"method": "POST",
|
||||
"path": "/internal/accounts/states",
|
||||
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true}]}
|
||||
"request": {"accounts": [{"userId": 42, "steamId": 76561198000000001, "displayName": "lobo", "blocked": true, "roleChangedAt": "2026-10-04T12:00:00Z"}]}
|
||||
},
|
||||
"replaceStates": {
|
||||
"method": "PUT",
|
||||
"path": "/internal/accounts/states",
|
||||
"request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false}]}
|
||||
"request": {"accounts": [{"userId": 42, "steamId": null, "displayName": "lobo", "blocked": false, "roleChangedAt": null}]}
|
||||
},
|
||||
"purge": {
|
||||
"method": "POST",
|
||||
|
||||
Reference in new issue
Block a user