diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompDashboardRealRoleRevocationIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompDashboardRealRoleRevocationIntegrationTest.java
new file mode 100644
index 00000000..5fa7b480
--- /dev/null
+++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompDashboardRealRoleRevocationIntegrationTest.java
@@ -0,0 +1,288 @@
+package com.opensource.docgrid.domain.auth.integration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+import java.lang.reflect.Type;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.UUID;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.messaging.converter.MappingJackson2MessageConverter;
+import org.springframework.messaging.simp.stomp.StompCommand;
+import org.springframework.messaging.simp.stomp.StompFrameHandler;
+import org.springframework.messaging.simp.stomp.StompHeaders;
+import org.springframework.messaging.simp.stomp.StompSession;
+import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter;
+import org.springframework.messaging.simp.user.SimpUser;
+import org.springframework.messaging.simp.user.SimpUserRegistry;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.web.socket.WebSocketHttpHeaders;
+import org.springframework.web.socket.client.standard.StandardWebSocketClient;
+import org.springframework.web.socket.messaging.WebSocketStompClient;
+
+import com.opensource.docgrid.domain.auth.jwt.JwtProvider;
+import com.opensource.docgrid.domain.auth.websocket.StompSessionRevalidationScheduler;
+import com.opensource.docgrid.domain.dashboard.controller.DashboardWebSocketController;
+import com.opensource.docgrid.domain.dashboard.dto.response.DashboardSummaryResponse;
+import com.opensource.docgrid.domain.dashboard.dto.response.DocumentsSummaryResponse;
+import com.opensource.docgrid.domain.dashboard.dto.response.JobsSummaryResponse;
+import com.opensource.docgrid.domain.dashboard.dto.response.SearchSummaryResponse;
+import com.opensource.docgrid.domain.dashboard.dto.response.WorkersSummaryResponse;
+import com.opensource.docgrid.domain.user.entity.Role;
+import com.opensource.docgrid.domain.user.entity.User;
+import com.opensource.docgrid.domain.user.entity.UserRole;
+import com.opensource.docgrid.domain.user.repository.RoleRepository;
+import com.opensource.docgrid.domain.user.repository.UserRepository;
+import com.opensource.docgrid.domain.user.repository.UserRoleRepository;
+
+/**
+ * 실제 HTTP 역할 회수, PostgreSQL 커밋, Redis 캐시 무효화와 STOMP 세션 재검증을 연결한다.
+ *
+ *
테스트 전용 사용자만 생성·삭제하며, 자동 재검증을 늦추고 직접 호출해 회수 직후의
+ * 기존 구독 창과 재검증 후 종료를 구분한다. GCP 복제본 라우팅은 이 로컬 테스트의 범위가 아니다.
+ */
+@Tag("integration")
+@ActiveProfiles("test")
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@DisplayName("STOMP 실제 역할 회수 통합 테스트")
+class StompDashboardRealRoleRevocationIntegrationTest {
+
+ private static final String DASHBOARD_TOPIC = "/topic/dashboard";
+ private static final long TIMEOUT_SECONDS = 5;
+ private static final long NO_DELIVERY_MILLIS = 300;
+
+ @LocalServerPort
+ private int port;
+
+ @Autowired
+ private JwtProvider jwtProvider;
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Autowired
+ private StringRedisTemplate redisTemplate;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private RoleRepository roleRepository;
+
+ @Autowired
+ private UserRoleRepository userRoleRepository;
+
+ @Autowired
+ private SimpUserRegistry simpUserRegistry;
+
+ @Autowired
+ private StompSessionRevalidationScheduler revalidationScheduler;
+
+ @Autowired
+ private DashboardWebSocketController dashboardWebSocketController;
+
+ private WebSocketStompClient stompClient;
+ private User adminCaller;
+ private User targetUser;
+ private StompSession oldSession;
+ private StompSession newSession;
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ registry.add("jwt.secret", () -> "docgrid-stomp-real-role-revocation-integration-test-secret-2026");
+ registry.add("auth.stomp.session-revalidation.interval", () -> "1h");
+ registry.add("spring.datasource.hikari.maximum-pool-size", () -> "2");
+ registry.add("spring.datasource.hikari.minimum-idle", () -> "0");
+ }
+
+ @BeforeEach
+ void setUp() {
+ stompClient = new WebSocketStompClient(new StandardWebSocketClient());
+ stompClient.setMessageConverter(new MappingJackson2MessageConverter());
+ }
+
+ @AfterEach
+ void tearDown() {
+ // 1. 세션을 먼저 닫고 이 테스트가 만든 사용자·역할과 Redis 키만 제거한다.
+ disconnect(newSession);
+ disconnect(oldSession);
+ if (targetUser != null) {
+ redisTemplate.delete(roleCacheKey(targetUser.getId()));
+ redisTemplate.delete(roleEpochKey(targetUser.getId()));
+ deleteTestUser(targetUser.getId());
+ }
+ if (adminCaller != null) {
+ deleteTestUser(adminCaller.getId());
+ }
+ stompClient.stop();
+ }
+
+ @Test
+ @DisplayName("HTTP 역할 회수 커밋이 Redis 캐시와 새·기존 관리자 WebSocket 세션에 반영된다")
+ void revokesRealAdminRole_andRevalidatesDashboardSessions() throws Exception {
+ // 1. seed 역할은 재사용하되 사용자와 매핑은 이 테스트만 소유한다.
+ Role adminRole = roleRepository.findByCode("ADMIN").orElseThrow();
+ adminCaller = createTestUser("caller");
+ targetUser = createTestUser("target");
+ grantAdmin(adminCaller, adminRole);
+ grantAdmin(targetUser, adminRole);
+ String targetEmail = targetUser.getEmail();
+ String targetToken = jwtProvider.generateToken(targetUser.getId(), targetEmail);
+ String cacheKey = roleCacheKey(targetUser.getId());
+ String epochKey = roleEpochKey(targetUser.getId());
+ redisTemplate.delete(cacheKey);
+ redisTemplate.delete(epochKey);
+
+ // 2. 실제 STOMP CONNECT와 SUBSCRIBE가 DB 역할을 Redis에 캐시하고 브로커에 등록한다.
+ BlockingQueue received = new LinkedBlockingQueue<>();
+ oldSession = connect(targetToken, new LinkedBlockingQueue<>());
+ oldSession.subscribe(DASHBOARD_TOPIC, dashboardFrames(received));
+ await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS))
+ .until(() -> hasDashboardSubscription(targetEmail));
+ assertThat(redisTemplate.opsForValue().get(cacheKey)).contains("ADMIN");
+
+ // 3. 실제 관리자 HTTP 요청이 DB 트랜잭션을 커밋한 뒤 Redis 캐시를 무효화한다.
+ HttpHeaders headers = new HttpHeaders();
+ headers.setBearerAuth(jwtProvider.generateToken(adminCaller.getId(), adminCaller.getEmail()));
+ ResponseEntity response = restTemplate.exchange(
+ "/admin/users/" + targetUser.getId() + "/roles/ADMIN",
+ HttpMethod.DELETE,
+ new HttpEntity<>(null, headers),
+ String.class
+ );
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(userRoleRepository.existsByUserIdAndRoleCode(targetUser.getId(), "ADMIN")).isFalse();
+ assertThat(redisTemplate.opsForValue().get(cacheKey)).isNull();
+ assertThat(redisTemplate.opsForValue().get(epochKey)).isEqualTo("1");
+
+ // 4. 주기 검사 전 옛 구독에는 push가 도달하지만 새 연결의 관리자 구독은 거부된다.
+ dashboardWebSocketController.sendDashboardUpdate(sampleSummary(1L));
+ assertThat(received.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isNotNull();
+ BlockingQueue newFailures = new LinkedBlockingQueue<>();
+ newSession = connect(targetToken, newFailures);
+ newSession.subscribe(DASHBOARD_TOPIC, dashboardFrames(new LinkedBlockingQueue<>()));
+ assertThat(newFailures.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isNotNull();
+ await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS))
+ .until(() -> !newSession.isConnected());
+ assertThat(redisTemplate.opsForValue().get(cacheKey)).doesNotContain("ADMIN");
+
+ // 5. 실제 DB를 읽는 재검증 뒤 옛 세션과 구독이 제거되고 새 push는 전달되지 않는다.
+ revalidationScheduler.revalidate();
+ await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS))
+ .until(() -> simpUserRegistry.getUser(targetEmail) == null);
+ assertThat(oldSession.isConnected()).isFalse();
+ dashboardWebSocketController.sendDashboardUpdate(sampleSummary(2L));
+ assertThat(received.poll(NO_DELIVERY_MILLIS, TimeUnit.MILLISECONDS)).isNull();
+ }
+
+ private User createTestUser(String kind) {
+ String email = "stomp-real-revoke-" + kind + "-" + UUID.randomUUID() + "@example.test";
+ return userRepository.save(User.builder()
+ .email(email)
+ .passwordHash("unused-test-hash")
+ .name("STOMP role test")
+ .build());
+ }
+
+ private void grantAdmin(User user, Role adminRole) {
+ userRoleRepository.save(UserRole.builder()
+ .user(user)
+ .role(adminRole)
+ .assignedAt(LocalDateTime.now())
+ .build());
+ }
+
+ private void deleteTestUser(Long userId) {
+ userRoleRepository.deleteAll(userRoleRepository.findAllWithRoleByUserId(userId));
+ userRepository.deleteById(userId);
+ }
+
+ private StompSession connect(String token, BlockingQueue failures) throws Exception {
+ StompHeaders headers = new StompHeaders();
+ headers.add("Authorization", "Bearer " + token);
+ return stompClient.connectAsync(
+ "ws://localhost:" + port + "/ws/websocket",
+ (WebSocketHttpHeaders) null,
+ headers,
+ new StompSessionHandlerAdapter() {
+ @Override
+ public void handleException(
+ StompSession session, StompCommand command, StompHeaders frameHeaders,
+ byte[] payload, Throwable exception
+ ) {
+ failures.add(exception);
+ }
+
+ @Override
+ public void handleTransportError(StompSession session, Throwable exception) {
+ failures.add(exception);
+ }
+ }
+ ).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ }
+
+ private StompFrameHandler dashboardFrames(BlockingQueue received) {
+ return new StompFrameHandler() {
+ @Override
+ public Type getPayloadType(StompHeaders headers) {
+ return DashboardSummaryResponse.class;
+ }
+
+ @Override
+ public void handleFrame(StompHeaders headers, Object payload) {
+ received.add((DashboardSummaryResponse) payload);
+ }
+ };
+ }
+
+ private boolean hasDashboardSubscription(String email) {
+ SimpUser user = simpUserRegistry.getUser(email);
+ return user != null && user.getSessions().stream()
+ .flatMap(session -> session.getSubscriptions().stream())
+ .anyMatch(subscription -> DASHBOARD_TOPIC.equals(subscription.getDestination()));
+ }
+
+ private DashboardSummaryResponse sampleSummary(long total) {
+ return new DashboardSummaryResponse(
+ new DocumentsSummaryResponse(total, total, 0L),
+ new JobsSummaryResponse(0L, 0L, 0L, 0L),
+ new WorkersSummaryResponse(0L, 0L),
+ new SearchSummaryResponse(0L)
+ );
+ }
+
+ private String roleCacheKey(Long userId) {
+ return "auth:roles:" + userId;
+ }
+
+ private String roleEpochKey(Long userId) {
+ return "auth:roles:epoch:" + userId;
+ }
+
+ private void disconnect(StompSession session) {
+ if (session != null && session.isConnected()) {
+ session.disconnect();
+ }
+ }
+}
diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompDashboardRoleRevocationIntegrationTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompDashboardRoleRevocationIntegrationTest.java
new file mode 100644
index 00000000..a3abe315
--- /dev/null
+++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompDashboardRoleRevocationIntegrationTest.java
@@ -0,0 +1,267 @@
+package com.opensource.docgrid.domain.auth.integration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.mock;
+
+import java.lang.reflect.Type;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.messaging.converter.MappingJackson2MessageConverter;
+import org.springframework.messaging.simp.stomp.StompCommand;
+import org.springframework.messaging.simp.stomp.StompFrameHandler;
+import org.springframework.messaging.simp.stomp.StompHeaders;
+import org.springframework.messaging.simp.stomp.StompSession;
+import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter;
+import org.springframework.messaging.simp.user.SimpUser;
+import org.springframework.messaging.simp.user.SimpUserRegistry;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.web.socket.WebSocketHttpHeaders;
+import org.springframework.web.socket.client.standard.StandardWebSocketClient;
+import org.springframework.web.socket.messaging.WebSocketStompClient;
+
+import com.opensource.docgrid.domain.auth.jwt.JwtProvider;
+import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService;
+import com.opensource.docgrid.domain.auth.websocket.StompSessionRevalidationScheduler;
+import com.opensource.docgrid.domain.dashboard.controller.DashboardWebSocketController;
+import com.opensource.docgrid.domain.dashboard.dto.response.DashboardSummaryResponse;
+import com.opensource.docgrid.domain.dashboard.dto.response.DocumentsSummaryResponse;
+import com.opensource.docgrid.domain.dashboard.dto.response.JobsSummaryResponse;
+import com.opensource.docgrid.domain.dashboard.dto.response.SearchSummaryResponse;
+import com.opensource.docgrid.domain.dashboard.dto.response.WorkersSummaryResponse;
+import com.opensource.docgrid.domain.user.entity.Role;
+import com.opensource.docgrid.domain.user.entity.User;
+import com.opensource.docgrid.domain.user.entity.UserRole;
+import com.opensource.docgrid.domain.user.repository.UserRoleRepository;
+
+/**
+ * 실제 WebSocket의 관리자 권한 회수 전후 경계를 신규 연결·새 구독·기존 구독으로 나눠 검증한다.
+ *
+ * 주기 검사를 자동 실행하지 않고 직접 호출해 검사 전 허용 창과 검사 후 물리 세션 종료를
+ * 결정적으로 구분한다. 이 테스트는 즉시 회수를 구현하지 않으며 현재 제품 계약을 기록한다.
+ */
+@Tag("integration")
+@ActiveProfiles("test")
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@DisplayName("STOMP 관리자 권한 회수 경계 통합 테스트")
+class StompDashboardRoleRevocationIntegrationTest {
+
+ private static final Long USER_ID = 92L;
+ private static final String DASHBOARD_TOPIC = "/topic/dashboard";
+ private static final long TIMEOUT_SECONDS = 3;
+ private static final long NO_DELIVERY_MILLIS = 300;
+
+ @LocalServerPort
+ private int port;
+
+ @Autowired
+ private JwtProvider jwtProvider;
+
+ @Autowired
+ private SimpUserRegistry simpUserRegistry;
+
+ @Autowired
+ private StompSessionRevalidationScheduler revalidationScheduler;
+
+ @Autowired
+ private DashboardWebSocketController dashboardWebSocketController;
+
+ @MockitoBean
+ private RoleAuthorityService roleAuthorityService;
+
+ @MockitoBean
+ private UserRoleRepository userRoleRepository;
+
+ private final AtomicReference> currentRoles = new AtomicReference<>();
+ private WebSocketStompClient stompClient;
+
+ @DynamicPropertySource
+ static void configureProperties(DynamicPropertyRegistry registry) {
+ registry.add("jwt.secret", () -> "docgrid-stomp-dashboard-revocation-integration-test-secret-2026");
+ registry.add("auth.stomp.session-revalidation.interval", () -> "1h");
+ registry.add("spring.datasource.hikari.maximum-pool-size", () -> "2");
+ registry.add("spring.datasource.hikari.minimum-idle", () -> "0");
+ }
+
+ @BeforeEach
+ void setUp() {
+ stompClient = new WebSocketStompClient(new StandardWebSocketClient());
+ stompClient.setMessageConverter(new MappingJackson2MessageConverter());
+ currentRoles.set(List.of("ADMIN"));
+ given(roleAuthorityService.getRoles(USER_ID)).willAnswer(ignored -> currentRoles.get());
+ given(userRoleRepository.findAllWithRoleByUserIdIn(anyList())).willAnswer(ignored -> currentRoles.get().stream()
+ .map(roleCode -> userRole(roleCode))
+ .toList());
+ }
+
+ @AfterEach
+ void tearDown() {
+ stompClient.stop();
+ }
+
+ @Test
+ @DisplayName("회수 후 새 연결은 현재 역할로 인증되어 관리자 구독이 거부된다")
+ void rejectsDashboardSubscription_whenConnectingAfterRevocation() throws Exception {
+ String email = "dashboard-new-after-revoke@example.com";
+ BlockingQueue failures = new LinkedBlockingQueue<>();
+
+ // 1. 새 연결이 시작되기 전에 ADMIN을 회수한다.
+ currentRoles.set(List.of("USER"));
+ StompSession session = connect(email, failures);
+ try {
+ // 2. 새 Principal에는 ADMIN이 없으므로 대시보드 구독을 시도한다.
+ session.subscribe(DASHBOARD_TOPIC, dashboardFrames(new LinkedBlockingQueue<>()));
+
+ // 3. 실제 inbound 인가 거부와 브로커 미등록을 함께 확인한다.
+ assertThat(failures.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isNotNull();
+ assertThat(hasDashboardSubscription(email)).isFalse();
+ } finally {
+ disconnect(session);
+ }
+ }
+
+ @Test
+ @DisplayName("기존 ADMIN 연결의 새 구독은 재검증 전 허용되고 재검증 후 세션이 종료된다")
+ void closesOldSession_afterNewSubscriptionInRevalidationWindow() throws Exception {
+ String email = "dashboard-old-new-subscribe@example.com";
+ StompSession session = connect(email, new LinkedBlockingQueue<>());
+ try {
+ // 1. CONNECT 당시 저장된 ADMIN Principal을 유지한 채 DB 역할만 회수한다.
+ currentRoles.set(List.of("USER"));
+
+ // 2. 주기 검사가 아직 실행되지 않은 창의 실제 SUBSCRIBE 결과를 기록한다.
+ session.subscribe(DASHBOARD_TOPIC, dashboardFrames(new LinkedBlockingQueue<>()));
+ await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS))
+ .until(() -> hasDashboardSubscription(email));
+
+ // 3. 재검증 후에는 물리 연결과 브로커 구독이 모두 제거되어야 한다.
+ revalidationScheduler.revalidate();
+ awaitSessionRemoval(email);
+ assertThat(session.isConnected()).isFalse();
+ assertThat(hasDashboardSubscription(email)).isFalse();
+ } finally {
+ disconnect(session);
+ }
+ }
+
+ @Test
+ @DisplayName("기존 관리자 구독은 재검증 전 push를 받지만 재검증 후에는 받지 않는다")
+ void stopsDashboardPush_afterExistingSubscriptionIsRevalidated() throws Exception {
+ String email = "dashboard-old-subscription@example.com";
+ StompSession session = connect(email, new LinkedBlockingQueue<>());
+ BlockingQueue received = new LinkedBlockingQueue<>();
+ try {
+ // 1. 권한 회수 전에 ADMIN 구독이 실제 브로커에 등록된 상태를 만든다.
+ session.subscribe(DASHBOARD_TOPIC, dashboardFrames(received));
+ await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS))
+ .until(() -> hasDashboardSubscription(email));
+
+ // 2. 검사 전에는 저장된 Principal 때문에 기존 구독으로 push가 도달한다.
+ currentRoles.set(List.of("USER"));
+ dashboardWebSocketController.sendDashboardUpdate(sampleSummary(1L));
+ assertThat(received.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isNotNull();
+
+ // 3. 재검증으로 연결을 닫은 뒤 보낸 새 push는 같은 세션에 도달하지 않는다.
+ revalidationScheduler.revalidate();
+ awaitSessionRemoval(email);
+ dashboardWebSocketController.sendDashboardUpdate(sampleSummary(2L));
+ assertThat(received.poll(NO_DELIVERY_MILLIS, TimeUnit.MILLISECONDS)).isNull();
+ } finally {
+ disconnect(session);
+ }
+ }
+
+ private StompSession connect(String email, BlockingQueue failures) throws Exception {
+ StompHeaders headers = new StompHeaders();
+ headers.add("Authorization", "Bearer " + jwtProvider.generateToken(USER_ID, email));
+ return stompClient.connectAsync(
+ "ws://localhost:" + port + "/ws/websocket",
+ (WebSocketHttpHeaders) null,
+ headers,
+ new StompSessionHandlerAdapter() {
+ @Override
+ public void handleException(
+ StompSession session, StompCommand command, StompHeaders frameHeaders,
+ byte[] payload, Throwable exception
+ ) {
+ failures.add(exception);
+ }
+
+ @Override
+ public void handleTransportError(StompSession session, Throwable exception) {
+ failures.add(exception);
+ }
+ }
+ ).get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ }
+
+ private StompFrameHandler dashboardFrames(BlockingQueue received) {
+ return new StompFrameHandler() {
+ @Override
+ public Type getPayloadType(StompHeaders headers) {
+ return DashboardSummaryResponse.class;
+ }
+
+ @Override
+ public void handleFrame(StompHeaders headers, Object payload) {
+ received.add((DashboardSummaryResponse) payload);
+ }
+ };
+ }
+
+ private boolean hasDashboardSubscription(String email) {
+ SimpUser user = simpUserRegistry.getUser(email);
+ return user != null && user.getSessions().stream()
+ .flatMap(session -> session.getSubscriptions().stream())
+ .anyMatch(subscription -> DASHBOARD_TOPIC.equals(subscription.getDestination()));
+ }
+
+ private void awaitSessionRemoval(String email) {
+ await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS))
+ .until(() -> simpUserRegistry.getUser(email) == null);
+ }
+
+ private DashboardSummaryResponse sampleSummary(long total) {
+ return new DashboardSummaryResponse(
+ new DocumentsSummaryResponse(total, total, 0L),
+ new JobsSummaryResponse(0L, 0L, 0L, 0L),
+ new WorkersSummaryResponse(0L, 0L),
+ new SearchSummaryResponse(0L)
+ );
+ }
+
+ private UserRole userRole(String roleCode) {
+ User user = mock(User.class);
+ Role role = mock(Role.class);
+ UserRole userRole = mock(UserRole.class);
+ given(user.getId()).willReturn(USER_ID);
+ given(role.getCode()).willReturn(roleCode);
+ given(userRole.getUser()).willReturn(user);
+ given(userRole.getRole()).willReturn(role);
+ return userRole;
+ }
+
+ private void disconnect(StompSession session) {
+ if (session.isConnected()) {
+ session.disconnect();
+ }
+ }
+}
diff --git a/docs/test-results/gimin-#365-stomp-dashboard-role-revocation-boundary.md b/docs/test-results/gimin-#365-stomp-dashboard-role-revocation-boundary.md
new file mode 100644
index 00000000..c41782d6
--- /dev/null
+++ b/docs/test-results/gimin-#365-stomp-dashboard-role-revocation-boundary.md
@@ -0,0 +1,81 @@
+# STOMP 관리자 권한 회수 경계 검증 결과 (#365)
+
+## 목적과 기준
+
+[#360](https://github.com/DocGrid/docgrid/issues/360)은 열린 STOMP 세션의 인증 상태를 주기적으로
+재검증해 달라진 역할의 물리 연결을 종료한다. 이번 테스트는 관리자 대시보드에서 권한 회수 시점을
+기준으로 **새 연결**, **기존 연결의 새 구독**, **이미 등록된 구독의 push**를 분리해 확인한다.
+HTTP `/admin/**` 권한 확인([#364](https://github.com/DocGrid/docgrid/pull/364))과는 다른 경계다.
+
+검증 시작점은 `origin/develop`의 `2da1eec`이다. 실행일은 2026-10-01 KST이고, 작업 브랜치는
+`test/365`이다. 이번 변경에는 제품 코드가 없다.
+
+## 실행 환경과 방법
+
+- 로컬 Docker Desktop에서 기존 PostgreSQL 17/pgvector 컨테이너와 Redis 컨테이너를 사용했다.
+- Spring `test` 프로필의 실제 WebSocket transport와 STOMP broker를 사용했다.
+- 기존 세 경계 테스트는 `RoleAuthorityService`와 `UserRoleRepository`를 `@MockitoBean`으로 대체해
+ `ADMIN → USER` 전이를 결정적으로 제어했다. 추가한 실제 경로 테스트는 이 두 컴포넌트를 대체하지
+ 않고 별도 테스트 사용자를 만들어 HTTP 역할 회수 API → PostgreSQL 커밋 → Redis 캐시 무효화 →
+ WebSocket 재검증을 연결했다. **GCP OpenProxy의 primary/standby 라우팅은 시험하지 않았다.**
+- 자동 세션 재검증 간격을 테스트에서만 `1h`로 설정했다. 테스트가 재검증 메서드를 직접 호출해
+ 검사 **전**과 **후**를 구분하므로 스케줄러의 우연한 실행 시각에 결과가 좌우되지 않는다.
+- 실제 운영 기본 재검증 간격은 코드의 `5s`이며, 이 테스트는 5초의 최대 지연을 실측한 결과가 아니다.
+
+| 실행 위치 | 명령 | 결과 요약 | 해석 |
+|---|---|---|---|
+| 로컬 Mac | `open -a Docker` | Docker Desktop 시작 | 기존 컨테이너를 이용할 준비를 했다. |
+| 로컬 Mac | `docker start docgrid-postgres17` | 기존 PostgreSQL 컨테이너 시작 | 새 DB나 GCP 자원을 만들지 않았다. Redis는 Docker 시작 시 이미 실행 중이었다. |
+| 분리 작업 공간 | `DB_PORT=55433 JWT_SECRET= ./backend/gradlew -p backend test --tests 'com.opensource.docgrid.domain.auth.integration.StompSessionLifecycleIntegrationTest'` | 기존 통합 테스트 4건 통과 | 기존 세션 재검증 기준선을 확인했다. |
+| 분리 작업 공간 | `DB_PORT=55433 JWT_SECRET= ./backend/gradlew -p backend test --tests 'com.opensource.docgrid.domain.auth.integration.StompDashboardRoleRevocationIntegrationTest'` | 신규 통합 테스트 3건 통과 | 관리자 대시보드의 세 경계를 각각 실제 WebSocket으로 확인했다. |
+| 분리 작업 공간 | `DB_PORT=55433 JWT_SECRET= ./backend/gradlew -p backend test --tests 'com.opensource.docgrid.domain.auth.integration.StompDashboardRealRoleRevocationIntegrationTest'` | 실제 DB·Redis 통합 테스트 1건 통과 | `DELETE /admin/users/{id}/roles/ADMIN`의 커밋·캐시 무효화와 새·기존 STOMP 세션 동작을 연결했다. |
+| 로컬 PostgreSQL 컨테이너 | `psql -U app -d app -tAc "SELECT COUNT(*) FROM docgrid_test.users WHERE email LIKE 'stomp-real-revoke-%@example.test'"` | `0` | 추가 테스트가 만든 사용자가 DB에 남지 않았다. |
+| 분리 작업 공간 | 새 경계 테스트·기존 세션 수명주기·대시보드 통합 테스트와 STOMP 단위 테스트 3개 클래스를 `--tests`로 함께 실행 | 총 49건, 실패·건너뜀 0건 | 기존 구독·push와 새 경계 테스트의 관련 회귀를 확인했다. |
+| 분리 작업 공간 | `DB_PORT=55433 JWT_SECRET= ./backend/gradlew -p backend test` 첫 실행 | 1,250건 중 기존 `StompSessionLifecycleIntegrationTest`의 1초 JWT 만료 사례 1건 연결 실패 | 새 테스트는 통과했지만 전체 실행은 실패했다. 원인을 확정하지 않고 아래처럼 재실행했다. |
+| 분리 작업 공간 | 실패한 기존 클래스와 신규 실제 DB·Redis 클래스만 `--tests`로 함께 실행 | 5건 통과, `BUILD SUCCESSFUL` | 첫 실패가 이 두 클래스 조합에서는 재현되지 않았다. |
+| 분리 작업 공간 | `DB_PORT=55433 JWT_SECRET= ./backend/gradlew -p backend test` 재실행 | JUnit XML 합계 208 suites, 1,250 tests, 실패·오류·건너뜀 0건; `BUILD SUCCESSFUL` | 전체 Backend 기본 테스트가 재실행에서 통과했다. 성능·외부 E2E 시험까지 실행했다는 뜻은 아니다. |
+
+## 시나리오별 실제 결과
+
+| 경계 | 관측 결과 | 의미 |
+|---|---|---|
+| ADMIN 회수 **후 새 CONNECT** | 현재 역할이 `USER`인 새 세션의 `/topic/dashboard` SUBSCRIBE가 거부되고 브로커 구독이 등록되지 않았다. | 신규 연결은 이전 세션의 ADMIN Principal을 재사용하지 않았다. |
+| 회수 **전 연결, 회수 후 새 SUBSCRIBE, 재검증 전** | 기존 세션에 저장된 ADMIN Principal로 구독이 실제 브로커에 등록됐다. | 현재 구현은 회수 직후의 새 구독을 즉시 차단하지 않는다. |
+| 위 기존 연결의 **재검증 후** | 재검증이 역할 차이를 찾아 물리 세션을 닫고 브로커 구독을 제거했다. | 회수 반영은 세션 재검증을 거친 뒤에 성립한다. |
+| 회수 전 등록된 구독의 **재검증 전 push** | 대시보드 메시지 1건이 실제 클라이언트에 도착했다. | 검사 간격에는 기존 구독으로 관리자 정보가 전달될 수 있다. |
+| 같은 구독의 **재검증 후 push** | 세션 제거 후 새로 보낸 메시지는 수신되지 않았다. | 검사 완료 이후에는 해당 세션의 추가 전달이 중단됐다. |
+| 역할 변경 없는 정상 ADMIN 회귀 | 기존 `DashboardWebSocketIntegrationTest`의 관리자 구독·push가 통과했다. | 정상 관리자 경로가 이번 테스트 추가로 변하지 않았다. |
+
+## 실제 DB·Redis 역할 회수 경로
+
+`StompDashboardRealRoleRevocationIntegrationTest`는 관리자 호출자와 대상 사용자만 새로 만들고,
+기존 `ADMIN` 역할을 두 사용자에게 부여했다. 대상의 기존 STOMP 세션을 연결·구독한 다음 Redis의
+`auth:roles:{userId}` 캐시에 `ADMIN`이 저장된 것을 확인했다. 이어 관리자 호출자의 JWT로 실제
+`DELETE /admin/users/{targetId}/roles/ADMIN`을 요청했다.
+
+| 순서 | 직접 확인한 결과 | 해석 |
+|---|---|---|
+| 역할 회수 응답 | HTTP `200 OK` | 테스트 대체 객체가 아닌 실제 관리자 API를 통과했다. |
+| PostgreSQL | 대상의 `ADMIN` 매핑이 존재하지 않음 | 회수 트랜잭션의 DB 변경이 반영됐다. |
+| Redis | 역할 캐시 키 삭제, 대상 세대 키 값 `1` | 커밋 후 무효화가 실행됐다. 새 연결의 재조회 결과에도 `ADMIN`이 다시 캐시되지 않았다. |
+| 재검증 전 | 기존 구독에 dashboard push 1건 도착, 회수 후 새 연결의 관리자 SUBSCRIBE 거부 | 새 구독과 이미 열린 구독의 권한 적용 시점이 다르다. |
+| 재검증 후 | 기존 세션·구독 제거, 이후 push 0건 수신 | DB 역할을 다시 읽는 주기 검사가 뒤늦게 열린 세션을 정리한다. |
+
+자동 재검증은 테스트에서만 `1h`로 늦추고 메서드를 직접 호출했다. 따라서 이 결과는
+회수 직후와 재검증 후의 **동작 경계**를 증명하지, 운영 기본값 `5s`에서의 실제 노출 시간이나
+최악 지연을 측정하지 않는다. 테스트가 만든 사용자·매핑과 대상 Redis 키는 종료 시 제거한다.
+전체 테스트의 첫 실행에서는 기존 JWT 만료 테스트가 STOMP 연결 도중 종료됐다. 그 테스트는
+유효기간 `1s` 토큰으로 연결하기 때문에 시각에 민감할 가능성이 있지만, 이번 기록만으로
+실패 원인을 단정할 수 없다. 해당 클래스와 새 테스트를 함께 돌린 재실행, 전체 재실행은 모두 통과했다.
+
+## 결론과 주장할 수 없는 범위
+
+이번 결과는 **주기적 회수**가 동작한다는 증거이면서, **즉시 회수는 제공하지 않는다**는 반례다.
+테스트에서 재검증 전 새 구독과 기존 구독 push가 모두 허용됐다. 따라서 “역할 회수 API의 성공 응답
+이후 관리자 WebSocket 메시지 0건”이라고 주장하면 안 된다.
+
+`5s`는 기본 스케줄 간격이지 검증된 최악 지연 상한이 아니다. 스케줄러 지연, Redis·DB 응답 시간,
+메시지 전달 경합은 별도 계측이 필요하다. 실제 DB 커밋·Redis 무효화 경로는 로컬에서 검증했지만,
+GCP 3노드 복제 지연과 OpenProxy 라우팅에서 같은 결과가 나온다는 증거는 아니다. 즉시 회수가 제품 요구사항이면
+권한 변경 시 열린 세션을 능동적으로 종료하거나, SUBSCRIBE와 push 경계에서 현재 권한을 재확인하는
+별도 수정 설계가 필요하다.