From 4d5b3e2137050d0eaaa019b085d6866df83ab563 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Thu, 1 Oct 2026 15:05:30 +0900 Subject: [PATCH 1/4] chore: #365 verify dashboard STOMP role revocation boundary --- ...ashboardRoleRevocationIntegrationTest.java | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompDashboardRoleRevocationIntegrationTest.java 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(); + } + } +} From ba60997d60042015e07e7c1fcbafea9b42b100c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Thu, 1 Oct 2026 15:05:52 +0900 Subject: [PATCH 2/4] docs: #365 record STOMP revocation boundary results --- ...tomp-dashboard-role-revocation-boundary.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/test-results/gimin-#365-stomp-dashboard-role-revocation-boundary.md 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..1e087f1d --- /dev/null +++ b/docs/test-results/gimin-#365-stomp-dashboard-role-revocation-boundary.md @@ -0,0 +1,54 @@ +# 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` 역할 전이를 결정적으로 제어했다. 따라서 **실제 DB 역할 변경 API, + Redis 역할 캐시 무효화, 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으로 확인했다. | +| 분리 작업 공간 | 새 경계 테스트·기존 세션 수명주기·대시보드 통합 테스트와 STOMP 단위 테스트 3개 클래스를 `--tests`로 함께 실행 | 총 49건, 실패·건너뜀 0건 | 기존 구독·push와 새 경계 테스트의 관련 회귀를 확인했다. | +| 분리 작업 공간 | `DB_PORT=55433 JWT_SECRET= ./backend/gradlew -p backend test` | JUnit XML 합계 207 suites, 1,249 tests, 실패·오류·건너뜀 0건; `BUILD SUCCESSFUL` | 전체 Backend 기본 테스트에 회귀가 없었다. Gradle에서 별도 태그로 제외한 성능·외부 E2E 시험까지 실행했다는 뜻은 아니다. | + +## 시나리오별 실제 결과 + +| 경계 | 관측 결과 | 의미 | +|---|---|---| +| ADMIN 회수 **후 새 CONNECT** | 현재 역할이 `USER`인 새 세션의 `/topic/dashboard` SUBSCRIBE가 거부되고 브로커 구독이 등록되지 않았다. | 신규 연결은 이전 세션의 ADMIN Principal을 재사용하지 않았다. | +| 회수 **전 연결, 회수 후 새 SUBSCRIBE, 재검증 전** | 기존 세션에 저장된 ADMIN Principal로 구독이 실제 브로커에 등록됐다. | 현재 구현은 회수 직후의 새 구독을 즉시 차단하지 않는다. | +| 위 기존 연결의 **재검증 후** | 재검증이 역할 차이를 찾아 물리 세션을 닫고 브로커 구독을 제거했다. | 회수 반영은 세션 재검증을 거친 뒤에 성립한다. | +| 회수 전 등록된 구독의 **재검증 전 push** | 대시보드 메시지 1건이 실제 클라이언트에 도착했다. | 검사 간격에는 기존 구독으로 관리자 정보가 전달될 수 있다. | +| 같은 구독의 **재검증 후 push** | 세션 제거 후 새로 보낸 메시지는 수신되지 않았다. | 검사 완료 이후에는 해당 세션의 추가 전달이 중단됐다. | +| 역할 변경 없는 정상 ADMIN 회귀 | 기존 `DashboardWebSocketIntegrationTest`의 관리자 구독·push가 통과했다. | 정상 관리자 경로가 이번 테스트 추가로 변하지 않았다. | + +## 결론과 주장할 수 없는 범위 + +이번 결과는 **주기적 회수**가 동작한다는 증거이면서, **즉시 회수는 제공하지 않는다**는 반례다. +테스트에서 재검증 전 새 구독과 기존 구독 push가 모두 허용됐다. 따라서 “역할 회수 API의 성공 응답 +이후 관리자 WebSocket 메시지 0건”이라고 주장하면 안 된다. + +`5s`는 기본 스케줄 간격이지 검증된 최악 지연 상한이 아니다. 스케줄러 지연, Redis·DB 응답 시간, +메시지 전달 경합은 별도 계측이 필요하다. 이 테스트는 로컬 통합 테스트이므로 GCP 3노드 복제 지연, +OpenProxy 라우팅, 실제 DB 커밋 뒤의 역할 조회 일관성을 증명하지 않는다. 즉시 회수가 제품 요구사항이면 +권한 변경 시 열린 세션을 능동적으로 종료하거나, SUBSCRIBE와 push 경계에서 현재 권한을 재확인하는 +별도 수정 설계가 필요하다. From 5ed922af3a8c07dbc4a27e79df297b59ba65473f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Thu, 1 Oct 2026 15:26:27 +0900 Subject: [PATCH 3/4] chore(test): verify real role revocation across DB Redis and STOMP --- ...oardRealRoleRevocationIntegrationTest.java | 288 ++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 backend/src/test/java/com/opensource/docgrid/domain/auth/integration/StompDashboardRealRoleRevocationIntegrationTest.java 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(); + } + } +} From 170283a35ee60356c45820e13cc4977fcded91a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Thu, 1 Oct 2026 15:26:43 +0900 Subject: [PATCH 4/4] docs: record real WebSocket role revocation results --- ...tomp-dashboard-role-revocation-boundary.md | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) 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 index 1e087f1d..c41782d6 100644 --- a/docs/test-results/gimin-#365-stomp-dashboard-role-revocation-boundary.md +++ b/docs/test-results/gimin-#365-stomp-dashboard-role-revocation-boundary.md @@ -14,9 +14,10 @@ HTTP `/admin/**` 권한 확인([#364](https://github.com/DocGrid/docgrid/pull/36 - 로컬 Docker Desktop에서 기존 PostgreSQL 17/pgvector 컨테이너와 Redis 컨테이너를 사용했다. - Spring `test` 프로필의 실제 WebSocket transport와 STOMP broker를 사용했다. -- 테스트의 `RoleAuthorityService`와 `UserRoleRepository`는 `@MockitoBean`으로 대체하고, - 동일 사용자에 대해 `ADMIN → USER` 역할 전이를 결정적으로 제어했다. 따라서 **실제 DB 역할 변경 API, - Redis 역할 캐시 무효화, GCP OpenProxy의 primary/standby 라우팅은 이번 테스트 대상이 아니다.** +- 기존 세 경계 테스트는 `RoleAuthorityService`와 `UserRoleRepository`를 `@MockitoBean`으로 대체해 + `ADMIN → USER` 전이를 결정적으로 제어했다. 추가한 실제 경로 테스트는 이 두 컴포넌트를 대체하지 + 않고 별도 테스트 사용자를 만들어 HTTP 역할 회수 API → PostgreSQL 커밋 → Redis 캐시 무효화 → + WebSocket 재검증을 연결했다. **GCP OpenProxy의 primary/standby 라우팅은 시험하지 않았다.** - 자동 세션 재검증 간격을 테스트에서만 `1h`로 설정했다. 테스트가 재검증 메서드를 직접 호출해 검사 **전**과 **후**를 구분하므로 스케줄러의 우연한 실행 시각에 결과가 좌우되지 않는다. - 실제 운영 기본 재검증 간격은 코드의 `5s`이며, 이 테스트는 5초의 최대 지연을 실측한 결과가 아니다. @@ -27,8 +28,12 @@ HTTP `/admin/**` 권한 확인([#364](https://github.com/DocGrid/docgrid/pull/36 | 로컬 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` | JUnit XML 합계 207 suites, 1,249 tests, 실패·오류·건너뜀 0건; `BUILD SUCCESSFUL` | 전체 Backend 기본 테스트에 회귀가 없었다. Gradle에서 별도 태그로 제외한 성능·외부 E2E 시험까지 실행했다는 뜻은 아니다. | +| 분리 작업 공간 | `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 시험까지 실행했다는 뜻은 아니다. | ## 시나리오별 실제 결과 @@ -41,6 +46,28 @@ HTTP `/admin/**` 권한 확인([#364](https://github.com/DocGrid/docgrid/pull/36 | 같은 구독의 **재검증 후 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` 토큰으로 연결하기 때문에 시각에 민감할 가능성이 있지만, 이번 기록만으로 +실패 원인을 단정할 수 없다. 해당 클래스와 새 테스트를 함께 돌린 재실행, 전체 재실행은 모두 통과했다. + ## 결론과 주장할 수 없는 범위 이번 결과는 **주기적 회수**가 동작한다는 증거이면서, **즉시 회수는 제공하지 않는다**는 반례다. @@ -48,7 +75,7 @@ HTTP `/admin/**` 권한 확인([#364](https://github.com/DocGrid/docgrid/pull/36 이후 관리자 WebSocket 메시지 0건”이라고 주장하면 안 된다. `5s`는 기본 스케줄 간격이지 검증된 최악 지연 상한이 아니다. 스케줄러 지연, Redis·DB 응답 시간, -메시지 전달 경합은 별도 계측이 필요하다. 이 테스트는 로컬 통합 테스트이므로 GCP 3노드 복제 지연, -OpenProxy 라우팅, 실제 DB 커밋 뒤의 역할 조회 일관성을 증명하지 않는다. 즉시 회수가 제품 요구사항이면 +메시지 전달 경합은 별도 계측이 필요하다. 실제 DB 커밋·Redis 무효화 경로는 로컬에서 검증했지만, +GCP 3노드 복제 지연과 OpenProxy 라우팅에서 같은 결과가 나온다는 증거는 아니다. 즉시 회수가 제품 요구사항이면 권한 변경 시 열린 세션을 능동적으로 종료하거나, SUBSCRIBE와 push 경계에서 현재 권한을 재확인하는 별도 수정 설계가 필요하다.