diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/service/query/PrimaryRoleQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/service/query/PrimaryRoleQueryService.java index 3d72eab6..c40dac9d 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/service/query/PrimaryRoleQueryService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/service/query/PrimaryRoleQueryService.java @@ -1,6 +1,8 @@ package com.opensource.docgrid.domain.auth.service.query; +import java.util.HashSet; import java.util.List; +import java.util.Set; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; @@ -12,7 +14,7 @@ import lombok.RequiredArgsConstructor; /** - * HTTP 관리자 인가에 필요한 역할을 Redis나 standby 없이 현재 primary에서 확인한다. + * HTTP 관리자 인가와 대시보드 push에 필요한 역할을 Redis나 standby 없이 현재 primary에서 확인한다. * *

호출자의 read-only 트랜잭션을 상속하지 않으며, OpenProxy가 잘못 라우팅하면 * 권한을 추정하지 않고 요청을 실패시킨다. 일반 API·WebSocket의 역할 캐시는 담당하지 않는다. @@ -21,19 +23,44 @@ @RequiredArgsConstructor public class PrimaryRoleQueryService { + private static final int MAX_BATCH_SIZE = 500; + private final EntityManager entityManager; private final UserRoleRepository userRoleRepository; @Transactional(propagation = Propagation.REQUIRES_NEW) public List findCurrentRoles(Long userId) { // 1. 명시적 read-write 트랜잭션 안에서 OpenProxy의 실제 도착 역할을 확인한다. + requirePrimary(); + + // 2. 같은 트랜잭션의 최신 primary에서 역할을 조회한다. + return userRoleRepository.findRoleCodesByUserId(userId); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Set findCurrentAdminUserIds(List userIds) { + if (userIds.isEmpty()) { + return Set.of(); + } + + // 1. 단일 read-write 트랜잭션에서 primary를 확인해 standby의 낡은 ADMIN을 쓰지 않는다. + requirePrimary(); + + // 2. 큰 세션 집합도 크기가 제한된 IN 쿼리로 읽되, 결과는 이 push에만 쓰는 불변 집합으로 만든다. + Set admins = new HashSet<>(); + for (int start = 0; start < userIds.size(); start += MAX_BATCH_SIZE) { + admins.addAll(userRoleRepository.findAdminUserIdsByUserIdIn( + userIds.subList(start, Math.min(start + MAX_BATCH_SIZE, userIds.size())) + )); + } + return Set.copyOf(admins); + } + + private void requirePrimary() { Object inRecovery = entityManager.createNativeQuery("SELECT pg_is_in_recovery()") .getSingleResult(); if (!Boolean.FALSE.equals(inRecovery)) { throw new IllegalStateException("Primary role verification is unavailable"); } - - // 2. 같은 트랜잭션의 최신 primary에서 역할을 조회한다. - return userRoleRepository.findRoleCodesByUserId(userId); } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/DashboardAuthorizationSnapshot.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/DashboardAuthorizationSnapshot.java new file mode 100644 index 00000000..e045fc81 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/DashboardAuthorizationSnapshot.java @@ -0,0 +1,36 @@ +package com.opensource.docgrid.domain.auth.websocket; + +import java.util.Map; +import java.util.Set; + +/** + * 대시보드 push 한 번에 대해 후보 물리 세션과 primary에서 확인한 ADMIN 사용자만 내부 메시지에 전달한다. + * + *

브로커가 복제한 수신자별 MESSAGE에서만 읽으며 클라이언트 native STOMP 헤더에는 싣지 않는다. + * 다른 push에 재사용하지 않고, 로그에 사용자 ID가 노출되지 않도록 문자열 표현도 제한한다. + */ +public record DashboardAuthorizationSnapshot(Map candidateSessions, Set adminUserIds) { + + public static final String HEADER = "docgrid.dashboard.authorizationSnapshot"; + + public DashboardAuthorizationSnapshot { + candidateSessions = Map.copyOf(candidateSessions); + adminUserIds = Set.copyOf(adminUserIds); + if (!Set.copyOf(candidateSessions.values()).containsAll(adminUserIds)) { + throw new IllegalArgumentException("ADMIN 판정은 이번 push의 후보 사용자에 한정해야 합니다."); + } + } + + public boolean wasCandidate(String sessionId, Long userId) { + return userId.equals(candidateSessions.get(sessionId)); + } + + public boolean allows(String sessionId, Long userId) { + return wasCandidate(sessionId, userId) && adminUserIds.contains(userId); + } + + @Override + public String toString() { + return "DashboardAuthorizationSnapshot[redacted]"; + } +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompDashboardOutboundAuthorizationInterceptor.java b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompDashboardOutboundAuthorizationInterceptor.java index 0bf20aab..e9605ee1 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompDashboardOutboundAuthorizationInterceptor.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/auth/websocket/StompDashboardOutboundAuthorizationInterceptor.java @@ -7,27 +7,24 @@ import org.springframework.messaging.support.ChannelInterceptor; import org.springframework.stereotype.Component; -import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService; - import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; /** - * 기존 대시보드 구독에 대한 실제 outbound MESSAGE 전송을 현재 primary 역할로 제한한다. + * 기존 대시보드 구독에 대한 실제 outbound MESSAGE 전송을 push별 primary 판정으로 제한한다. * *

SUBSCRIBE 이후 역할이 회수되어도 브로커에는 구독이 남을 수 있다. 이 경계는 각 물리 세션에 - * 전달할 때 다시 확인하며, 역할 조회가 불가능하거나 ADMIN이 아니면 메시지를 버리고 세션을 닫는다. + * 전달할 때 실제 물리 세션과 서버 내부의 불변 판정 결과를 대조한다. 판정 결과가 없거나 ADMIN이 + * 아니면 메시지를 버린다. 판정 수집 뒤 연결된 세션은 닫지 않고, 후보였지만 ADMIN이 회수된 + * 세션만 닫는다. * RAG 개인 알림과 연결 제어 프레임은 이 검사 대상이 아니다. */ @Component @RequiredArgsConstructor -@Slf4j public class StompDashboardOutboundAuthorizationInterceptor implements ChannelInterceptor { private static final String DASHBOARD_TOPIC = "/topic/dashboard"; private final StompSessionRegistry stompSessionRegistry; - private final RoleAuthorityService roleAuthorityService; @Override public Message preSend(Message message, MessageChannel channel) { @@ -47,17 +44,19 @@ public Message preSend(Message message, MessageChannel channel) { return null; } - // 3. 현재 primary의 역할을 확인한다. 조회 실패도 이전 ADMIN snapshot으로 우회하지 않는다. - try { - if (roleAuthorityService.getRolesForAdmin(authorization.userId()).contains("ADMIN")) { - return message; - } - } catch (RuntimeException exception) { - log.warn("STOMP 대시보드 전송의 최신 역할을 확인할 수 없어 차단합니다: {}", exception.getMessage()); + // 3. 이 push의 primary 판정이 없으면 이전 CONNECT 역할로 폴백하지 않고 차단한다. + Object decision = message.getHeaders().get(DashboardAuthorizationSnapshot.HEADER); + if (!(decision instanceof DashboardAuthorizationSnapshot snapshot)) { + return null; } - // 4. 회수되었거나 확인할 수 없는 세션은 메시지를 버리고 다음 전송도 받지 않도록 닫는다. - stompSessionRegistry.close(sessionId); + // 4. 늦게 연결된 세션은 이번 push만 건너뛰고, 확인된 비ADMIN 후보는 연결도 닫는다. + if (snapshot.allows(sessionId, authorization.userId())) { + return message; + } + if (snapshot.wasCandidate(sessionId, authorization.userId())) { + stompSessionRegistry.close(sessionId); + } return null; } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/dashboard/controller/DashboardWebSocketController.java b/backend/src/main/java/com/opensource/docgrid/domain/dashboard/controller/DashboardWebSocketController.java index b3ee6c0c..033d6ea8 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/dashboard/controller/DashboardWebSocketController.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/dashboard/controller/DashboardWebSocketController.java @@ -1,8 +1,18 @@ package com.opensource.docgrid.domain.dashboard.controller; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessageType; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Component; +import com.opensource.docgrid.domain.auth.service.query.PrimaryRoleQueryService; +import com.opensource.docgrid.domain.auth.websocket.DashboardAuthorizationSnapshot; +import com.opensource.docgrid.domain.auth.websocket.StompSessionRegistry; import com.opensource.docgrid.domain.dashboard.dto.response.DashboardSummaryResponse; import lombok.RequiredArgsConstructor; @@ -12,7 +22,9 @@ * *

이 컨트롤러는 직접 지표를 집계하지 않는다. 호출하는 쪽(재처리 트리거, 상태 전이 이벤트 * 리스너 등)이 {@code DashboardQueryService}로 최신 Snapshot을 계산해 넘겨주면 그대로 - * 브로드캐스트만 한다. + * 브로드캐스트한다. 호출자가 요약을 먼저 계산한 뒤 이 메서드에서 권한을 판정해야, + * 회수 전 판정으로 회수 후 새로 계산한 요약을 승인하지 않는다. 판정은 push 사이에 재사용하지 않는다. + * 구독자별 DB 조회를 반복하지 않도록 각 push의 ADMIN 후보를 primary에서 일괄 확인한다. */ @Component @RequiredArgsConstructor @@ -21,8 +33,30 @@ public class DashboardWebSocketController { private static final String DASHBOARD_TOPIC = "/topic/dashboard"; private final SimpMessagingTemplate messagingTemplate; + private final StompSessionRegistry stompSessionRegistry; + private final PrimaryRoleQueryService primaryRoleQueryService; public void sendDashboardUpdate(DashboardSummaryResponse summary) { - messagingTemplate.convertAndSend(DASHBOARD_TOPIC, summary); + // 1. 이 백엔드의 열린 CONNECT-ADMIN 세션만 후보로 모으고 같은 사용자의 탭은 중복 제거한다. + Map candidateSessions = stompSessionRegistry.authenticatedSessions().stream() + .filter(session -> session.authorization().roles().contains("ADMIN")) + .collect(Collectors.toUnmodifiableMap(session -> session.sessionId(), + session -> session.authorization().userId())); + List candidateUserIds = candidateSessions.values().stream() + .distinct() + .toList(); + + // 2. 후보가 없으면 DB 트랜잭션도 열지 않는다. 실패 시에는 이전 권한으로 우회하지 않는다. + DashboardAuthorizationSnapshot snapshot = new DashboardAuthorizationSnapshot(candidateSessions, + candidateUserIds.isEmpty() + ? Set.of() + : primaryRoleQueryService.findCurrentAdminUserIds(candidateUserIds) + ); + + // 3. native STOMP 헤더가 아닌 서버 내부 헤더로만 이 push의 판정을 브로커에 전달한다. + SimpMessageHeaderAccessor headers = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE); + headers.setHeader(DashboardAuthorizationSnapshot.HEADER, snapshot); + headers.setLeaveMutable(true); + messagingTemplate.convertAndSend(DASHBOARD_TOPIC, summary, headers.getMessageHeaders()); } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/dashboard/service/command/EmbeddingJobRetryService.java b/backend/src/main/java/com/opensource/docgrid/domain/dashboard/service/command/EmbeddingJobRetryService.java index 5ad37df1..d44f884c 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/dashboard/service/command/EmbeddingJobRetryService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/dashboard/service/command/EmbeddingJobRetryService.java @@ -18,7 +18,8 @@ /** * 관리자 재처리 버튼 클릭을 A 담당자의 {@link EmbeddingJobManualRetryService}로 위임하고, - * 성공 시 최신 대시보드 집계를 WebSocket으로 push하는 Command Service. + * 성공 시 최신 대시보드 집계를 WebSocket으로 push하는 Command Service. 알림 인가 조회가 + * 실패해도 이미 커밋된 재처리 결과를 HTTP 실패로 잘못 보고하지 않는다. * *

{@code embedding_jobs}를 직접 update하지 않는다 — 상태 전환은 전부 A의 Service를 경유한다. * FAILED 목록 조회만 {@link EmbeddingJobRepository}를 직접 읽는다(쓰기가 아니므로 A/B 경계 위반이 @@ -49,7 +50,7 @@ public ManualRetriedIndexingJobResponse retryJob(Long jobId) { // 1. 상태 전환은 A Service에 위임한다 — 여기서 예외가 나면(404/409) 그대로 전파시킨다. ManualRetriedIndexingJobResponse response = embeddingJobManualRetryService.retry(jobId); // 2. 재처리 성공 후에만 최신 집계를 다시 계산해서 push한다. - dashboardWebSocketController.sendDashboardUpdate(dashboardQueryService.getSummary()); + pushDashboardUpdateAfterRetry(); return response; } @@ -82,7 +83,7 @@ public RetryAllJobsResponse retryAllFailedJobs() { // 3. 실제로 바뀐 게 있을 때만(1건 이상 성공) push한다 — 전부 실패하면 push할 변경사항이 없다. if (retriedCount > 0) { - dashboardWebSocketController.sendDashboardUpdate(dashboardQueryService.getSummary()); + pushDashboardUpdateAfterRetry(); } return new RetryAllJobsResponse( @@ -93,4 +94,14 @@ public RetryAllJobsResponse retryAllFailedJobs() { RETRY_ALL_MESSAGE_FORMAT.formatted(retriedCount, skippedCount, failedCount) ); } + + private void pushDashboardUpdateAfterRetry() { + try { + // 재처리는 이미 커밋됐다. 대시보드 알림만 실패하면 운영 결과를 실패로 뒤집지 않는다. + dashboardWebSocketController.sendDashboardUpdate(dashboardQueryService.getSummary()); + } catch (RuntimeException exception) { + log.warn("재처리는 완료됐으나 대시보드 알림을 보내지 못했습니다. cause={}", + exception.getClass().getSimpleName()); + } + } } diff --git a/backend/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java index ce131452..587b8b50 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/user/repository/UserRoleRepository.java @@ -30,6 +30,10 @@ public interface UserRoleRepository extends JpaRepository { @Query("SELECT ur.role.code FROM UserRole ur WHERE ur.user.id = :userId") List findRoleCodesByUserId(@Param("userId") Long userId); + /** 한 대시보드 push의 후보 사용자 중 현재 ADMIN인 ID만 primary 트랜잭션에 반환한다. */ + @Query("SELECT DISTINCT ur.user.id FROM UserRole ur WHERE ur.user.id IN :userIds AND ur.role.code = 'ADMIN'") + List findAdminUserIdsByUserIdIn(@Param("userIds") List userIds); + boolean existsByUserIdAndRoleCode(Long userId, String roleCode); Optional findByUserIdAndRoleCode(Long userId, String roleCode); 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 index dff7ff98..b8717519 100644 --- 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 @@ -2,14 +2,22 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.ArgumentMatchers.anyList; 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.CompletableFuture; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -20,12 +28,16 @@ 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.data.redis.core.script.RedisScript; 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.Message; import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessageType; import org.springframework.messaging.simp.stomp.StompCommand; import org.springframework.messaging.simp.stomp.StompFrameHandler; import org.springframework.messaging.simp.stomp.StompHeaders; @@ -36,12 +48,15 @@ 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.MockitoSpyBean; 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.DashboardAuthorizationSnapshot; +import com.opensource.docgrid.domain.auth.websocket.StompDashboardOutboundAuthorizationInterceptor; 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; @@ -59,7 +74,9 @@ * 실제 HTTP 역할 회수, PostgreSQL 커밋, Redis 캐시 무효화와 STOMP 전송 인가를 연결한다. * *

테스트 전용 사용자만 생성·삭제하며, 자동 재검증을 늦춘 채 회수 직후의 - * 새 구독과 기존 구독 push가 차단되는지 확인한다. GCP 복제본 라우팅은 범위 밖이다. + * 새 구독과 기존 구독 push가 차단되는지 확인한다. 권한 확인을 마친 전송을 멈춰 + * 회수 응답 뒤에 도착할 수 있는 기존 진행 중 메시지도 별도로 재현한다. + * GCP 복제본 라우팅은 범위 밖이다. */ @Tag("integration") @ActiveProfiles("test") @@ -80,7 +97,7 @@ class StompDashboardRealRoleRevocationIntegrationTest { @Autowired private TestRestTemplate restTemplate; - @Autowired + @MockitoSpyBean private StringRedisTemplate redisTemplate; @Autowired @@ -98,6 +115,9 @@ class StompDashboardRealRoleRevocationIntegrationTest { @Autowired private DashboardWebSocketController dashboardWebSocketController; + @MockitoSpyBean + private StompDashboardOutboundAuthorizationInterceptor outboundAuthorizationInterceptor; + private WebSocketStompClient stompClient; private User adminCaller; private User targetUser; @@ -211,6 +231,165 @@ void revokesRealAdminRole_andBlocksDashboardSessionsBeforeScheduledRevalidation( assertThat(redisTemplate.opsForValue().get(cacheKey)).isEqualTo("ADMIN"); } + @Test + @DisplayName("권한 확인을 마친 진행 중 메시지는 회수 응답 뒤에도 도착할 수 있다") + void inFlightMessage_canArriveAfterRevocationResponse_whenAuthorizationFinishedFirst() throws Exception { + // 1. 실제 DB 역할과 WebSocket 구독을 준비하고, 전송 직전의 대기 지점만 테스트용 spy로 제어한다. + Role adminRole = roleRepository.findByCode("ADMIN").orElseThrow(); + adminCaller = createTestUser("race-caller"); + targetUser = createTestUser("race-target"); + grantAdmin(adminCaller, adminRole); + grantAdmin(targetUser, adminRole); + BlockingQueue received = new LinkedBlockingQueue<>(); + oldSession = connect( + jwtProvider.generateToken(targetUser.getId(), targetUser.getEmail()), + new LinkedBlockingQueue<>() + ); + oldSession.subscribe(DASHBOARD_TOPIC, dashboardFrames(received)); + await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS)) + .until(() -> hasDashboardSubscription(targetUser.getEmail())); + + CountDownLatch authorizationFinished = new CountDownLatch(1); + CountDownLatch allowDelivery = new CountDownLatch(1); + AtomicBoolean holdOneMessage = new AtomicBoolean(true); + doAnswer(invocation -> { + Message authorized = (Message) invocation.callRealMethod(); + if (authorized != null + && SimpMessageType.MESSAGE.equals(SimpMessageHeaderAccessor.getMessageType(authorized.getHeaders())) + && DASHBOARD_TOPIC.equals(SimpMessageHeaderAccessor.getDestination(authorized.getHeaders())) + && holdOneMessage.compareAndSet(true, false)) { + authorizationFinished.countDown(); + if (!allowDelivery.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("테스트 전송 대기 시간이 초과됐습니다."); + } + } + return authorized; + }).when(outboundAuthorizationInterceptor).preSend(any(), any()); + + CompletableFuture dispatch = CompletableFuture.runAsync( + () -> dashboardWebSocketController.sendDashboardUpdate(sampleSummary(42L)) + ); + try { + assertThat(authorizationFinished.await(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + assertThat(received.poll(NO_DELIVERY_MILLIS, TimeUnit.MILLISECONDS)).isNull(); + + // 2. 이미 ADMIN으로 허용된 메시지를 붙잡은 동안 실제 HTTP 회수·DB 커밋을 완료한다. + 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(received.poll(NO_DELIVERY_MILLIS, TimeUnit.MILLISECONDS)).isNull(); + + // 3. 회수 응답 뒤에 전송을 풀어, 새로 허용된 전송과 기존 진행 중 전송을 구별한다. + allowDelivery.countDown(); + dispatch.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + DashboardSummaryResponse inFlightMessage = received.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(inFlightMessage).isNotNull(); + assertThat(inFlightMessage.documents().total()).isEqualTo(42L); + } finally { + allowDelivery.countDown(); + dispatch.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + } + + @Test + @DisplayName("Redis 무효화 실패로 낡은 ADMIN 캐시가 남아도 새 대시보드 push를 차단한다") + @SuppressWarnings("unchecked") + void redisInvalidationFailure_doesNotAuthorizeRevokedDashboardSession() throws Exception { + // 1. 실제 구독과 캐시를 만들고 역할 무효화 Lua 호출만 시험에서 실패시킨다. + Role adminRole = roleRepository.findByCode("ADMIN").orElseThrow(); + adminCaller = createTestUser("redis-failure-caller"); + targetUser = createTestUser("redis-failure-target"); + grantAdmin(adminCaller, adminRole); + grantAdmin(targetUser, adminRole); + BlockingQueue received = new LinkedBlockingQueue<>(); + oldSession = connect( + jwtProvider.generateToken(targetUser.getId(), targetUser.getEmail()), + new LinkedBlockingQueue<>() + ); + oldSession.subscribe(DASHBOARD_TOPIC, dashboardFrames(received)); + await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS)) + .until(() -> hasDashboardSubscription(targetUser.getEmail())); + redisTemplate.opsForValue().set(roleCacheKey(targetUser.getId()), "ADMIN", Duration.ofSeconds(30)); + doThrow(new IllegalStateException("simulated Redis invalidate failure")) + .when(redisTemplate).execute(any(RedisScript.class), anyList()); + + // 2. DB 커밋과 HTTP 200은 완료되지만 Redis의 이전 ADMIN 캐시는 그대로 남는다. + 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(roleCacheKey(targetUser.getId()))).isEqualTo("ADMIN"); + + // 3. 다음 push는 Redis가 아닌 primary 일괄 조회를 사용하므로 메시지를 버린다. + dashboardWebSocketController.sendDashboardUpdate(sampleSummary(43L)); + assertThat(received.poll(NO_DELIVERY_MILLIS, TimeUnit.MILLISECONDS)).isNull(); + await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS)).until(() -> !oldSession.isConnected()); + } + + @Test + @DisplayName("실제 발행 코드의 primary 판정은 outbound에 남고 STOMP 클라이언트에는 노출되지 않는다") + void serverOnlyHeader_reachesOutboundButNotClient() throws Exception { + // 1. 실제 구독을 만들고 수신자의 프레임 헤더와 서버 outbound 헤더를 따로 관측한다. + Role adminRole = roleRepository.findByCode("ADMIN").orElseThrow(); + targetUser = createTestUser("internal-header"); + grantAdmin(targetUser, adminRole); + BlockingQueue clientHeaders = new LinkedBlockingQueue<>(); + BlockingQueue received = new LinkedBlockingQueue<>(); + oldSession = connect( + jwtProvider.generateToken(targetUser.getId(), targetUser.getEmail()), + new LinkedBlockingQueue<>() + ); + oldSession.subscribe(DASHBOARD_TOPIC, new StompFrameHandler() { + @Override + public Type getPayloadType(StompHeaders headers) { + return DashboardSummaryResponse.class; + } + + @Override + public void handleFrame(StompHeaders headers, Object payload) { + clientHeaders.add(headers); + received.add((DashboardSummaryResponse) payload); + } + }); + await().atMost(Duration.ofSeconds(TIMEOUT_SECONDS)) + .until(() -> hasDashboardSubscription(targetUser.getEmail())); + + AtomicReference outboundHeader = new AtomicReference<>(); + doAnswer(invocation -> { + Message message = invocation.getArgument(0); + if (DASHBOARD_TOPIC.equals(SimpMessageHeaderAccessor.getDestination(message.getHeaders()))) { + outboundHeader.set(message.getHeaders().get(DashboardAuthorizationSnapshot.HEADER)); + } + return invocation.callRealMethod(); + }).when(outboundAuthorizationInterceptor).preSend(any(), any()); + + // 2. 실제 발행 경로가 내부 판정을 만들고 브로커의 수신자별 MESSAGE까지 전파한다. + dashboardWebSocketController.sendDashboardUpdate(sampleSummary(3L)); + + // 3. outbound까지 전달됐더라도 클라이언트 프레임에서는 보이지 않아야 한다. + assertThat(received.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isNotNull(); + assertThat(outboundHeader.get()).isInstanceOf(DashboardAuthorizationSnapshot.class); + DashboardAuthorizationSnapshot snapshot = (DashboardAuthorizationSnapshot) outboundHeader.get(); + assertThat(snapshot.adminUserIds()).contains(targetUser.getId()); + assertThat(snapshot.candidateSessions()).containsValue(targetUser.getId()); + StompHeaders frame = clientHeaders.poll(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertThat(frame).isNotNull(); + assertThat(frame.getFirst(DashboardAuthorizationSnapshot.HEADER)).isNull(); + } + private User createTestUser(String kind) { String email = "stomp-real-revoke-" + kind + "-" + UUID.randomUUID() + "@example.test"; return userRepository.save(User.builder() 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 index 5a576232..ec9770dd 100644 --- 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 @@ -7,6 +7,7 @@ import java.lang.reflect.Type; import java.time.Duration; import java.util.List; +import java.util.Set; import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; @@ -38,6 +39,7 @@ import com.opensource.docgrid.domain.auth.jwt.JwtProvider; import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService; +import com.opensource.docgrid.domain.auth.service.query.PrimaryRoleQueryService; 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; @@ -50,6 +52,7 @@ * *

주기 검사를 늦춘 상태에서 새 구독과 기존 구독 push를 각각 검사해, * 회수 직후의 관리자 데이터 차단이 scheduler 없이도 동작하는지 확인한다. + * CONNECT·SUBSCRIBE 역할과 push별 primary 일괄 판정을 같은 가변 시험 상태로 제어한다. */ @Tag("integration") @ActiveProfiles("test") @@ -77,6 +80,9 @@ class StompDashboardRoleRevocationIntegrationTest { @MockitoBean private RoleAuthorityService roleAuthorityService; + @MockitoBean + private PrimaryRoleQueryService primaryRoleQueryService; + private final AtomicReference> currentRoles = new AtomicReference<>(); private WebSocketStompClient stompClient; @@ -95,6 +101,8 @@ void setUp() { currentRoles.set(List.of("ADMIN")); given(roleAuthorityService.getRoles(USER_ID)).willAnswer(ignored -> currentRoles.get()); given(roleAuthorityService.getRolesForAdmin(USER_ID)).willAnswer(ignored -> currentRoles.get()); + given(primaryRoleQueryService.findCurrentAdminUserIds(List.of(USER_ID))) + .willAnswer(ignored -> currentRoles.get().contains("ADMIN") ? Set.of(USER_ID) : Set.of()); } @AfterEach diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/service/query/PrimaryRoleQueryServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/service/query/PrimaryRoleQueryServiceTest.java index 4ee93cc2..220f6b30 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/auth/service/query/PrimaryRoleQueryServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/service/query/PrimaryRoleQueryServiceTest.java @@ -6,6 +6,8 @@ import static org.mockito.BDDMockito.then; import java.util.List; +import java.util.Set; +import java.util.stream.LongStream; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -56,6 +58,56 @@ void findCurrentRoles_rejectsStandby() { then(userRoleRepository).shouldHaveNoInteractions(); } + @Test + @DisplayName("한 push의 ADMIN 후보를 primary에서 일괄 조회한다") + void findCurrentAdminUserIds_readsOnlyCurrentAdminsOnPrimary() { + given(entityManager.createNativeQuery("SELECT pg_is_in_recovery()")) + .willReturn(query); + given(query.getSingleResult()).willReturn(false); + given(userRoleRepository.findAdminUserIdsByUserIdIn(List.of(1L, 2L))) + .willReturn(List.of(1L)); + + assertThat(service.findCurrentAdminUserIds(List.of(1L, 2L))).isEqualTo(Set.of(1L)); + then(userRoleRepository).should().findAdminUserIdsByUserIdIn(List.of(1L, 2L)); + } + + @Test + @DisplayName("standby 또는 primary 확인 실패 시 일괄 역할 SQL을 실행하지 않는다") + void findCurrentAdminUserIds_rejectsStandby() { + given(entityManager.createNativeQuery("SELECT pg_is_in_recovery()")) + .willReturn(query); + given(query.getSingleResult()).willReturn(true); + + assertThatThrownBy(() -> service.findCurrentAdminUserIds(List.of(1L))) + .isInstanceOf(IllegalStateException.class); + then(userRoleRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("후보가 없으면 primary와 역할 SQL 모두 호출하지 않는다") + void findCurrentAdminUserIds_skipsQueriesForEmptyCandidates() { + assertThat(service.findCurrentAdminUserIds(List.of())).isEmpty(); + then(entityManager).shouldHaveNoInteractions(); + then(userRoleRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("500명을 넘는 후보는 같은 primary 트랜잭션에서 제한된 크기로 나눠 조회한다") + void findCurrentAdminUserIds_splitsLargeCandidateSet() { + List userIds = LongStream.rangeClosed(1, 501).boxed().toList(); + given(entityManager.createNativeQuery("SELECT pg_is_in_recovery()")) + .willReturn(query); + given(query.getSingleResult()).willReturn(false); + given(userRoleRepository.findAdminUserIdsByUserIdIn(userIds.subList(0, 500))) + .willReturn(List.of(1L)); + given(userRoleRepository.findAdminUserIdsByUserIdIn(userIds.subList(500, 501))) + .willReturn(List.of(501L)); + + assertThat(service.findCurrentAdminUserIds(userIds)).isEqualTo(Set.of(1L, 501L)); + then(userRoleRepository).should().findAdminUserIdsByUserIdIn(userIds.subList(0, 500)); + then(userRoleRepository).should().findAdminUserIdsByUserIdIn(userIds.subList(500, 501)); + } + @Test @DisplayName("호출자의 read-only 경계를 상속하지 않는 새 트랜잭션을 요구한다") void findCurrentRoles_startsIndependentReadWriteTransaction() throws NoSuchMethodException { diff --git a/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompDashboardOutboundAuthorizationInterceptorTest.java b/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompDashboardOutboundAuthorizationInterceptorTest.java index 8f2ff640..f45fc90e 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompDashboardOutboundAuthorizationInterceptorTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/auth/websocket/StompDashboardOutboundAuthorizationInterceptorTest.java @@ -6,7 +6,7 @@ import static org.mockito.Mockito.never; import java.time.Instant; -import java.util.List; +import java.util.Map; import java.util.Set; import org.junit.jupiter.api.DisplayName; @@ -21,10 +21,8 @@ import org.springframework.messaging.simp.SimpMessageType; import org.springframework.messaging.support.MessageBuilder; -import com.opensource.docgrid.domain.auth.jwt.RoleAuthorityService; - /** - * 브로커가 기존 구독자에게 보내는 대시보드 메시지의 전달 직전 권한 판단을 검증한다. + * 브로커가 수신자별로 복제한 대시보드 메시지를 push별 primary 판정과 대조한다. */ @ExtendWith(MockitoExtension.class) @DisplayName("STOMP 대시보드 outbound 인가 단위 테스트") @@ -33,9 +31,6 @@ class StompDashboardOutboundAuthorizationInterceptorTest { @Mock private StompSessionRegistry stompSessionRegistry; - @Mock - private RoleAuthorityService roleAuthorityService; - @Mock private MessageChannel channel; @@ -43,11 +38,10 @@ class StompDashboardOutboundAuthorizationInterceptorTest { private StompDashboardOutboundAuthorizationInterceptor interceptor; @Test - @DisplayName("현재 primary에서도 ADMIN이면 기존 구독으로 보낸 메시지를 허용한다") + @DisplayName("push별 primary 판정에 ADMIN이면 기존 구독으로 보낸 메시지를 허용한다") void preSend_allowsDashboardMessage_whenStillAdmin() { - Message message = message("/topic/dashboard", "session-1"); + Message message = message("/topic/dashboard", "session-1", snapshot(Set.of(1L))); given(stompSessionRegistry.authorizationFor("session-1")).willReturn(authorization()); - given(roleAuthorityService.getRolesForAdmin(1L)).willReturn(List.of("ADMIN")); assertThat(interceptor.preSend(message, channel)).isSameAs(message); then(stompSessionRegistry).should(never()).close("session-1"); @@ -56,50 +50,76 @@ void preSend_allowsDashboardMessage_whenStillAdmin() { @Test @DisplayName("회수 뒤에는 기존 구독 메시지를 버리고 물리 세션을 닫는다") void preSend_dropsDashboardMessage_whenAdminWasRevoked() { - Message message = message("/topic/dashboard", "session-1"); + Message message = message("/topic/dashboard", "session-1", snapshot(Set.of())); given(stompSessionRegistry.authorizationFor("session-1")).willReturn(authorization()); - given(roleAuthorityService.getRolesForAdmin(1L)).willReturn(List.of("USER")); assertThat(interceptor.preSend(message, channel)).isNull(); then(stompSessionRegistry).should().close("session-1"); } @Test - @DisplayName("primary 역할 확인 실패도 기존 권한으로 통과시키지 않는다") - void preSend_dropsDashboardMessage_whenPrimaryIsUnavailable() { - Message message = message("/topic/dashboard", "session-1"); + @DisplayName("primary 판정 헤더가 없으면 이전 CONNECT 권한으로 통과시키지 않는다") + void preSend_dropsDashboardMessage_whenDecisionIsMissing() { + Message message = message("/topic/dashboard", "session-1", null); given(stompSessionRegistry.authorizationFor("session-1")).willReturn(authorization()); - given(roleAuthorityService.getRolesForAdmin(1L)).willThrow(new IllegalStateException("primary unavailable")); assertThat(interceptor.preSend(message, channel)).isNull(); - then(stompSessionRegistry).should().close("session-1"); + then(stompSessionRegistry).should(never()).close("session-1"); + } + + @Test + @DisplayName("판정 수집 뒤 연결된 ADMIN 세션은 이번 메시지만 건너뛰고 연결을 유지한다") + void preSend_dropsLateSessionWithoutClosingIt() { + Message message = message("/topic/dashboard", "session-1", + new DashboardAuthorizationSnapshot(Map.of(), Set.of())); + given(stompSessionRegistry.authorizationFor("session-1")).willReturn(authorization()); + + assertThat(interceptor.preSend(message, channel)).isNull(); + then(stompSessionRegistry).should(never()).close("session-1"); + } + + @Test + @DisplayName("같은 사용자라도 판정 뒤 새로 연결한 세션에는 이 push를 전달하지 않는다") + void preSend_dropsLateSessionOfAlreadyApprovedUser() { + Message message = message("/topic/dashboard", "session-late", snapshot(Set.of(1L))); + given(stompSessionRegistry.authorizationFor("session-late")).willReturn(authorization()); + + assertThat(interceptor.preSend(message, channel)).isNull(); + then(stompSessionRegistry).should(never()).close("session-late"); } @Test @DisplayName("추적되지 않은 세션과 sessionId 없는 대시보드 메시지는 전달하지 않는다") void preSend_dropsDashboardMessage_whenSessionIsUnknown() { - assertThat(interceptor.preSend(message("/topic/dashboard", "unknown"), channel)).isNull(); + assertThat(interceptor.preSend(message("/topic/dashboard", "unknown", snapshot(Set.of(1L))), channel)).isNull(); then(stompSessionRegistry).should().close("unknown"); - assertThat(interceptor.preSend(message("/topic/dashboard", null), channel)).isNull(); + assertThat(interceptor.preSend(message("/topic/dashboard", null, snapshot(Set.of(1L))), channel)).isNull(); } @Test @DisplayName("대시보드 외 메시지는 추가 DB 조회 없이 통과한다") void preSend_passesOtherDestinations() { - Message message = message("/queue/rag-answer", "session-1"); + Message message = message("/queue/rag-answer", "session-1", null); assertThat(interceptor.preSend(message, channel)).isSameAs(message); - then(roleAuthorityService).shouldHaveNoInteractions(); + then(stompSessionRegistry).shouldHaveNoInteractions(); } private StompSessionAuthorization authorization() { return new StompSessionAuthorization(1L, "jti-1", Instant.now().plusSeconds(60), Set.of("ADMIN")); } - private Message message(String destination, String sessionId) { + private DashboardAuthorizationSnapshot snapshot(Set admins) { + return new DashboardAuthorizationSnapshot(Map.of("session-1", 1L), admins); + } + + private Message message(String destination, String sessionId, DashboardAuthorizationSnapshot snapshot) { SimpMessageHeaderAccessor accessor = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE); accessor.setDestination(destination); + if (snapshot != null) { + accessor.setHeader(DashboardAuthorizationSnapshot.HEADER, snapshot); + } if (sessionId != null) { accessor.setSessionId(sessionId); } diff --git a/backend/src/test/java/com/opensource/docgrid/domain/dashboard/controller/DashboardWebSocketControllerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/dashboard/controller/DashboardWebSocketControllerTest.java new file mode 100644 index 00000000..4633caf0 --- /dev/null +++ b/backend/src/test/java/com/opensource/docgrid/domain/dashboard/controller/DashboardWebSocketControllerTest.java @@ -0,0 +1,98 @@ +package com.opensource.docgrid.domain.dashboard.controller; + +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.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +import com.opensource.docgrid.domain.auth.service.query.PrimaryRoleQueryService; +import com.opensource.docgrid.domain.auth.websocket.DashboardAuthorizationSnapshot; +import com.opensource.docgrid.domain.auth.websocket.StompSessionAuthorization; +import com.opensource.docgrid.domain.auth.websocket.StompSessionRegistry; +import com.opensource.docgrid.domain.auth.websocket.StompSessionRegistry.SessionSnapshot; +import com.opensource.docgrid.domain.dashboard.dto.response.DashboardSummaryResponse; + +/** + * 대시보드 발행 직전에 로컬 후보를 중복 제거하고 push별 primary 판정만 전달하는 경계를 검증한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("대시보드 WebSocket 발행 권한 일괄 조회 단위 테스트") +class DashboardWebSocketControllerTest { + + @InjectMocks private DashboardWebSocketController controller; + @Mock private SimpMessagingTemplate messagingTemplate; + @Mock private StompSessionRegistry stompSessionRegistry; + @Mock private PrimaryRoleQueryService primaryRoleQueryService; + + @Test + @DisplayName("같은 사용자의 여러 세션은 primary에서 한 번만 확인하고 내부 헤더에만 판정을 넣는다") + @SuppressWarnings({"rawtypes", "unchecked"}) + void sendDashboardUpdate_deduplicatesUsersAndAttachesDecision() { + DashboardSummaryResponse summary = org.mockito.Mockito.mock(DashboardSummaryResponse.class); + given(stompSessionRegistry.authenticatedSessions()).willReturn(List.of( + session("session-1", 1L, "ADMIN"), + session("session-2", 1L, "ADMIN"), + session("session-3", 2L, "USER") + )); + given(primaryRoleQueryService.findCurrentAdminUserIds(List.of(1L))).willReturn(Set.of(1L)); + ArgumentCaptor headers = ArgumentCaptor.forClass(Map.class); + + controller.sendDashboardUpdate(summary); + + then(primaryRoleQueryService).should().findCurrentAdminUserIds(List.of(1L)); + then(messagingTemplate).should().convertAndSend(eq("/topic/dashboard"), same(summary), headers.capture()); + Object decision = headers.getValue().get(DashboardAuthorizationSnapshot.HEADER); + assertThat(decision).isInstanceOf(DashboardAuthorizationSnapshot.class); + DashboardAuthorizationSnapshot snapshot = (DashboardAuthorizationSnapshot) decision; + assertThat(snapshot.candidateSessions()).isEqualTo(Map.of("session-1", 1L, "session-2", 1L)); + assertThat(snapshot.adminUserIds()).isEqualTo(Set.of(1L)); + assertThat(headers.getValue()).doesNotContainKey("nativeHeaders"); + } + + @Test + @DisplayName("ADMIN 후보가 없으면 primary 트랜잭션을 열지 않는다") + void sendDashboardUpdate_skipsPrimaryWhenNoAdminCandidate() { + DashboardSummaryResponse summary = org.mockito.Mockito.mock(DashboardSummaryResponse.class); + given(stompSessionRegistry.authenticatedSessions()).willReturn(List.of()); + + controller.sendDashboardUpdate(summary); + + then(primaryRoleQueryService).shouldHaveNoInteractions(); + then(messagingTemplate).should().convertAndSend(eq("/topic/dashboard"), same(summary), any(Map.class)); + } + + @Test + @DisplayName("primary 판정 실패 시 이전 ADMIN 권한으로 발행하지 않는다") + void sendDashboardUpdate_doesNotPublishWhenPrimaryFails() { + DashboardSummaryResponse summary = org.mockito.Mockito.mock(DashboardSummaryResponse.class); + given(stompSessionRegistry.authenticatedSessions()).willReturn(List.of(session("session-1", 1L, "ADMIN"))); + given(primaryRoleQueryService.findCurrentAdminUserIds(List.of(1L))) + .willThrow(new IllegalStateException("primary unavailable")); + + assertThatThrownBy(() -> controller.sendDashboardUpdate(summary)) + .isInstanceOf(IllegalStateException.class); + then(messagingTemplate).shouldHaveNoInteractions(); + } + + private SessionSnapshot session(String sessionId, Long userId, String role) { + return new SessionSnapshot(sessionId, + new StompSessionAuthorization(userId, "test-jti", Instant.now().plusSeconds(60), Set.of(role))); + } +} diff --git a/backend/src/test/java/com/opensource/docgrid/domain/dashboard/event/DashboardPushSchedulerTest.java b/backend/src/test/java/com/opensource/docgrid/domain/dashboard/event/DashboardPushSchedulerTest.java index 352c70e5..6609bddf 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/dashboard/event/DashboardPushSchedulerTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/dashboard/event/DashboardPushSchedulerTest.java @@ -4,6 +4,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.mock; import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -68,4 +69,18 @@ void pushIfDirty_restoresDirtyFlagAndRethrows_whenSendingUpdateFails() { .hasMessage("집계 실패"); then(dashboardUpdateFlag).should().markDirty(); } + + @Test + @DisplayName("primary 판정 실패로 push하지 못하면 dirty 신호를 복구해 다음 주기에 재시도한다") + void pushIfDirty_restoresDirtyFlag_whenAuthorizationFails() { + DashboardSummaryResponse summary = mock(DashboardSummaryResponse.class); + given(dashboardUpdateFlag.consumeIfDirty()).willReturn(true); + given(dashboardQueryService.getSummary()).willReturn(summary); + willThrow(new IllegalStateException("primary unavailable")) + .given(dashboardWebSocketController).sendDashboardUpdate(summary); + + assertThatThrownBy(scheduler::pushIfDirty) + .isInstanceOf(IllegalStateException.class); + then(dashboardUpdateFlag).should().markDirty(); + } } diff --git a/backend/src/test/java/com/opensource/docgrid/domain/dashboard/service/command/EmbeddingJobRetryServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/dashboard/service/command/EmbeddingJobRetryServiceTest.java index 6e0ef843..c0a6ee6b 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/dashboard/service/command/EmbeddingJobRetryServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/dashboard/service/command/EmbeddingJobRetryServiceTest.java @@ -56,6 +56,20 @@ void retryJob_delegatesToAService_andPushesLatestSummary() { then(dashboardWebSocketController).should().sendDashboardUpdate(summary); } + @Test + @DisplayName("재처리 커밋 뒤 primary 판정 실패는 대시보드만 차단하고 재처리 성공 응답을 유지한다") + void retryJob_keepsCommittedResult_whenDashboardAuthorizationFails() { + ManualRetriedIndexingJobResponse retryResponse = mock(ManualRetriedIndexingJobResponse.class); + DashboardSummaryResponse summary = mock(DashboardSummaryResponse.class); + given(embeddingJobManualRetryService.retry(42L)).willReturn(retryResponse); + given(dashboardQueryService.getSummary()).willReturn(summary); + willThrow(new IllegalStateException("primary unavailable")) + .given(dashboardWebSocketController).sendDashboardUpdate(summary); + + assertThat(embeddingJobRetryService.retryJob(42L)).isSameAs(retryResponse); + then(dashboardWebSocketController).should().sendDashboardUpdate(summary); + } + @Test @DisplayName("정상 케이스: 대상 제외와 성공 건수를 분리하고 1건 이상 성공하면 push한다") void retryAllFailedJobs_separatesSkippedJobs_whenSomeJobsAreNotEligible() { diff --git a/docs/test-results/evidence/issue-376/README.md b/docs/test-results/evidence/issue-376/README.md new file mode 100644 index 00000000..6fcc3a9c --- /dev/null +++ b/docs/test-results/evidence/issue-376/README.md @@ -0,0 +1,19 @@ +# 이슈 #376 로컬 시험 실행 목록 + +각 목적·재실행을 별도 파일로 보존한다. 시각은 KST다. 명령의 포트는 로컬 격리 시험 포트이며, 시험용 JWT 문자열도 값 대신 `[TEST_ONLY_VALUE]`로 기록한다. 공개하지 않은 Gradle/JUnit 원문에는 로컬 호스트명 등 환경 식별자가 포함될 수 있어 이 한국어 요약만 저장한다. + +| 실행 ID | 목적 | 결과 | 로그 | +| --- | --- | --- | --- | +| header-01 | 내부 헤더 관문 첫 실행 | Gradle 캐시 권한 거부, 시험 본문 0건 | [header-01](header-01.md) | +| header-02 | 동일 관문 재실행 | 1/1 통과 | [header-02](header-02.md) | +| unit-01 | 초기 단위 회귀 | 21/21 통과 | [unit-01](unit-01.md) | +| integration-01 | 실제 역할 회수·헤더 | 3건 중 1건 실패 | [integration-01](integration-01.md) | +| integration-02 | 시험 입력 보정 후 재실행 | 3/3 통과 | [integration-02](integration-02.md) | +| redis-01 | Redis 무효화 실패 포함 | 4/4 통과 | [redis-01](redis-01.md) | +| regression-01 | 기존 WebSocket 회귀 | 12/12 통과 | [regression-01](regression-01.md) | +| combined-01 | 세션 ID 보완 전 관련 10개 클래스 | 48/48 통과 | [combined-01](combined-01.md) | +| full-01 | 기본 전체 시험 첫 실행 | 1,251건 중 24건 실패 | [full-01](full-01.md) | +| full-02 | 시험용 JWT 설정 후 전체 재실행 | 1,270/1,270 통과 | [full-02](full-02.md) | +| combined-02 | 세션 ID 경합 수정 후 첫 회귀 | 잘못 지정한 클래스 경로로 9개 클래스·45/45 통과 | [combined-02](combined-02.md) | +| combined-03 | 누락 클래스 포함 회귀 재실행 | 10개 클래스·49/49 통과 | [combined-03](combined-03.md) | +| full-03 | 세션 ID 경합 수정 후 전체 재실행 | 210개 suite·1,271/1,271 통과 | [full-03](full-03.md) | diff --git a/docs/test-results/evidence/issue-376/combined-01.md b/docs/test-results/evidence/issue-376/combined-01.md new file mode 100644 index 00000000..2cc1d5c1 --- /dev/null +++ b/docs/test-results/evidence/issue-376/combined-01.md @@ -0,0 +1,9 @@ +# combined-01 — 세션 ID 보완 전 변경 관련 회귀 + +| 항목 | 기록 | +| --- | --- | +| 종료 시각·위치 | 2026-10-02 01:49:37 KST, 로컬 JVM·격리 PostgreSQL·Redis·HTTP·STOMP | +| 목적·성공 조건 | 변경 관련 단위·실제 통합 10개 클래스 전체 통과 | +| 실행 | `DB_PORT=[LOCAL_TEST_PORT] REDIS_PORT=[LOCAL_TEST_PORT] TEST_DB_SCHEMA=[TEST_SCHEMA] ./backend/gradlew -p backend test --tests '...PrimaryRoleQueryServiceTest' --tests '...StompDashboardOutboundAuthorizationInterceptorTest' --tests '...DashboardWebSocketControllerTest' --tests '...EmbeddingJobRetryServiceTest' --tests '...DashboardPushSchedulerTest' --tests '...RoleAuthorityServiceTest' --tests '...StompDashboardRealRoleRevocationIntegrationTest' --tests '...StompDashboardRoleRevocationIntegrationTest' --tests '...DashboardWebSocketIntegrationTest' --tests '...StompDestinationAuthorizationIntegrationTest' --rerun-tasks --quiet` | +| 결과 | **10개 클래스·48/48 통과**, 실패 0·오류 0·건너뜀 0 | +| 해석·한계 | 실제 발행 코드의 내부 판정 전달·비노출과 캐시 무효화 실패를 포함. GCP OpenProxy·부하 성능은 아직 시험하지 않음 | diff --git a/docs/test-results/evidence/issue-376/combined-02.md b/docs/test-results/evidence/issue-376/combined-02.md new file mode 100644 index 00000000..f2e4a1b3 --- /dev/null +++ b/docs/test-results/evidence/issue-376/combined-02.md @@ -0,0 +1,9 @@ +# combined-02 — 세션 ID 경합 수정 후 첫 회귀 + +| 항목 | 기록 | +| --- | --- | +| 종료 시각·위치 | 2026-10-02 01:57:54 KST, 로컬 JVM·격리 PostgreSQL·Redis·HTTP·STOMP | +| 목적·성공 조건 | 후보 물리 세션 ID를 저장한 변경과 새 경합 시험을 포함해 관련 테스트를 재실행. 지정한 10개 클래스 전부 실행·통과가 목표 | +| 실행 | `DB_PORT=[LOCAL_TEST_PORT] REDIS_PORT=[LOCAL_TEST_PORT] TEST_DB_SCHEMA=[TEST_SCHEMA] JWT_SECRET=[TEST_ONLY_VALUE] ./backend/gradlew -p backend test --tests '...PrimaryRoleQueryServiceTest' --tests '...StompDashboardOutboundAuthorizationInterceptorTest' --tests '...DashboardWebSocketControllerTest' --tests '...EmbeddingJobRetryServiceTest' --tests '...DashboardPushSchedulerTest' --tests '...RoleAuthorityServiceTest' --tests '...StompDashboardRealRoleRevocationIntegrationTest' --tests '...StompDashboardRoleRevocationIntegrationTest' --tests '...dashboard.integration.DashboardWebSocketIntegrationTest' --tests '...StompDestinationAuthorizationIntegrationTest' --rerun-tasks --quiet` | +| 결과 | Gradle 종료 코드 0. **9개 suite·45/45 통과**, 실패 0·오류 0·건너뜀 0. 지정한 `DashboardWebSocketIntegrationTest`의 package가 실제 `dashboard.websocket`인데 `dashboard.integration`으로 잘못 기입돼 1개 클래스가 실행되지 않음 | +| 해석·다음 조치 | 실행된 테스트는 통과했지만 10개 클래스라는 성공 기준에는 미달. 클래스 경로를 고친 combined-03을 별도로 실행했으며 이 결과를 최종 전체 회귀로 사용하지 않음 | diff --git a/docs/test-results/evidence/issue-376/combined-03.md b/docs/test-results/evidence/issue-376/combined-03.md new file mode 100644 index 00000000..e98d2f29 --- /dev/null +++ b/docs/test-results/evidence/issue-376/combined-03.md @@ -0,0 +1,9 @@ +# combined-03 — 관련 10개 클래스 최종 회귀 + +| 항목 | 기록 | +| --- | --- | +| 종료 시각·위치 | 2026-10-02 01:58:41 KST, 로컬 JVM·격리 PostgreSQL·Redis·HTTP·STOMP | +| 목적·성공 조건 | 세션 ID 기반 후보 판정과 같은 사용자의 늦은 신규 세션 차단을 포함해 관련 10개 클래스 모두 통과 | +| 실행 | `DB_PORT=[LOCAL_TEST_PORT] REDIS_PORT=[LOCAL_TEST_PORT] TEST_DB_SCHEMA=[TEST_SCHEMA] JWT_SECRET=[TEST_ONLY_VALUE] ./backend/gradlew -p backend test --tests '...PrimaryRoleQueryServiceTest' --tests '...StompDashboardOutboundAuthorizationInterceptorTest' --tests '...DashboardWebSocketControllerTest' --tests '...EmbeddingJobRetryServiceTest' --tests '...DashboardPushSchedulerTest' --tests '...RoleAuthorityServiceTest' --tests '...StompDashboardRealRoleRevocationIntegrationTest' --tests '...StompDashboardRoleRevocationIntegrationTest' --tests '...dashboard.websocket.DashboardWebSocketIntegrationTest' --tests '...StompDestinationAuthorizationIntegrationTest' --rerun-tasks --quiet` | +| 결과 | Gradle 종료 코드 0. **10개 suite·49/49 통과**, 실패 0·오류 0·건너뜀 0 | +| 해석·한계 | 실제 HTTP 역할 회수·Redis·DB·STOMP와 늦은 신규 세션 경계가 로컬에서 통과. 분산 백엔드·OpenProxy·고부하의 실측은 아님 | diff --git a/docs/test-results/evidence/issue-376/full-01.md b/docs/test-results/evidence/issue-376/full-01.md new file mode 100644 index 00000000..58faf162 --- /dev/null +++ b/docs/test-results/evidence/issue-376/full-01.md @@ -0,0 +1,9 @@ +# full-01 — 필수 JWT 환경값 누락 상태의 전체 시험 + +| 항목 | 기록 | +| --- | --- | +| 종료 시각·위치 | 2026-10-02 01:50:59 KST, 로컬 JVM·격리 PostgreSQL·Redis | +| 목적·성공 조건 | 기본 Gradle 전체 테스트 통과 | +| 실행 | `DB_PORT=[LOCAL_TEST_PORT] REDIS_PORT=[LOCAL_TEST_PORT] TEST_DB_SCHEMA=[TEST_SCHEMA] ./backend/gradlew -p backend test --rerun-tasks --quiet` | +| 결과 | **1,251건 실행·24건 실패**. 해당 14개 suite의 Spring Context 생성 실패 경로에서 `JWT_SECRET` placeholder 누락을 확인. 종료 코드 1 | +| 해석·수정 | 작업 트리에 `.env`가 없어 필요한 값을 제공하지 못한 시험 환경 오류. 실제 운영 비밀 대신 격리 시험 전용 값을 환경 변수로 설정해 full-02로 재실행. 실패 건수·첫 실행은 삭제하지 않음 | diff --git a/docs/test-results/evidence/issue-376/full-02.md b/docs/test-results/evidence/issue-376/full-02.md new file mode 100644 index 00000000..91d26888 --- /dev/null +++ b/docs/test-results/evidence/issue-376/full-02.md @@ -0,0 +1,9 @@ +# full-02 — 시험 전용 JWT 설정 후 전체 재실행 + +| 항목 | 기록 | +| --- | --- | +| 종료 시각·위치 | 2026-10-02 01:52:55 KST, 로컬 JVM·격리 PostgreSQL·Redis | +| 목적·성공 조건 | 필요한 테스트 환경값을 채운 뒤 기본 Gradle 전체 시험 실패·오류·건너뜀 0건 | +| 실행 | `DB_PORT=[LOCAL_TEST_PORT] REDIS_PORT=[LOCAL_TEST_PORT] TEST_DB_SCHEMA=[TEST_SCHEMA] JWT_SECRET=[TEST_ONLY_VALUE] ./backend/gradlew -p backend test --rerun-tasks --quiet` | +| 결과 | **210개 suite·1,270/1,270 통과**, 실패 0·오류 0·건너뜀 0. Gradle 종료 코드 0 | +| 해석·한계 | full-01의 필수 설정 누락을 보완하자 기본 전체 시험이 통과했다. 이 값은 시험 전용이며 실제 운영 JWT는 사용하지 않았다. GCP 부하·OpenProxy 장애 시험의 대체 근거는 아니다 | diff --git a/docs/test-results/evidence/issue-376/full-03.md b/docs/test-results/evidence/issue-376/full-03.md new file mode 100644 index 00000000..934f994f --- /dev/null +++ b/docs/test-results/evidence/issue-376/full-03.md @@ -0,0 +1,10 @@ +# full-03 — 최종 코드 전체 Gradle 시험 + +| 항목 | 기록 | +| --- | --- | +| 종료 시각·위치 | 2026-10-02 02:00:05 KST, 로컬 JVM·격리 PostgreSQL·Redis | +| 목적·성공 조건 | 세션 ID 경합 보완 후 전체 테스트 실패·오류·건너뜀 0건 | +| 실행 | `DB_PORT=[LOCAL_TEST_PORT] REDIS_PORT=[LOCAL_TEST_PORT] TEST_DB_SCHEMA=[TEST_SCHEMA] JWT_SECRET=[TEST_ONLY_VALUE] ./backend/gradlew -p backend test --rerun-tasks --quiet` | +| 결과 | Gradle 종료 코드 0. **210개 suite·1,271/1,271 통과**, 실패 0·오류 0·건너뜀 0 | +| 해석·한계 | 최종 코드의 로컬 전체 회귀 통과. GCP 3노드/OpenProxy 부하 성능과 장애 전환의 증거는 아니며 별도 Test PR에서 같은 조건으로 측정해야 함 | +| 정리 | 이번 시험용 격리 PostgreSQL·Redis 컨테이너 두 개만 `docker stop`으로 종료. 이어서 `docker ps -a --filter name=docgrid-stomp-batch-376` 결과가 0줄임을 확인. 기존 GCP 부하 환경은 변경하지 않음 | diff --git a/docs/test-results/evidence/issue-376/header-01.md b/docs/test-results/evidence/issue-376/header-01.md new file mode 100644 index 00000000..8149b641 --- /dev/null +++ b/docs/test-results/evidence/issue-376/header-01.md @@ -0,0 +1,9 @@ +# header-01 — Gradle 실행 전 권한 오류 + +| 항목 | 기록 | +| --- | --- | +| 시각·위치 | 2026-10-02 KST, 로컬 작업 트리·격리 PostgreSQL/Redis. 정확한 분·초는 별도 수집하지 못함 | +| 목적·성공 조건 | 내부 헤더가 outbound에는 남고 클라이언트 STOMP에는 보이지 않는지 1건 통과 | +| 실행 | `DB_PORT=[LOCAL_TEST_PORT] REDIS_PORT=[LOCAL_TEST_PORT] TEST_DB_SCHEMA=[TEST_SCHEMA] ./backend/gradlew -p backend test --tests '...StompDashboardRealRoleRevocationIntegrationTest.serverOnlyHeader_reachesOutboundButNotClient' --rerun-tasks --quiet` | +| 결과 | 테스트 본문 **0건**. Gradle wrapper 캐시의 잠금 파일 접근이 샌드박스에서 거부됨. 종료 코드 1 | +| 해석·다음 단계 | 기능 실패가 아니다. 동일 컨테이너와 코드에서 Gradle 캐시 접근 권한만 허용해 재실행(header-02) | diff --git a/docs/test-results/evidence/issue-376/header-02.md b/docs/test-results/evidence/issue-376/header-02.md new file mode 100644 index 00000000..ac403e8e --- /dev/null +++ b/docs/test-results/evidence/issue-376/header-02.md @@ -0,0 +1,9 @@ +# header-02 — 내부 헤더 전달·비노출 관문 + +| 항목 | 기록 | +| --- | --- | +| 종료 시각·위치 | 2026-10-02 01:40:47 KST, 로컬 격리 PostgreSQL 17+pgvector·Redis 7·실제 STOMP | +| 목적·성공 조건 | 서버 내부 헤더가 수신자별 outbound에 남고 클라이언트 STOMP 헤더에는 없는지 확인 | +| 실행 | `DB_PORT=[LOCAL_TEST_PORT] REDIS_PORT=[LOCAL_TEST_PORT] TEST_DB_SCHEMA=[TEST_SCHEMA] ./backend/gradlew -p backend test --tests '...StompDashboardRealRoleRevocationIntegrationTest.serverOnlyHeader_reachesOutboundButNotClient' --rerun-tasks --quiet` | +| 결과 | **1/1 통과**, 실패 0. 테스트 XML의 해당 케이스 시간 0.479초. 전송 헤더는 outbound에 있고 STOMP 프레임에는 없었음 | +| 해석·한계 | Spring 브로커 전달 관문 통과. 이 실행은 구현 전 수동 헤더를 사용했으므로 최종 변경의 실제 발행 경로는 combined-01에서 다시 검사 | diff --git a/docs/test-results/evidence/issue-376/integration-01.md b/docs/test-results/evidence/issue-376/integration-01.md new file mode 100644 index 00000000..2a3fc6d3 --- /dev/null +++ b/docs/test-results/evidence/issue-376/integration-01.md @@ -0,0 +1,9 @@ +# integration-01 — 내부 판정 누락으로 인한 시험 입력 실패 + +| 항목 | 기록 | +| --- | --- | +| 종료 시각·위치 | 2026-10-02 01:45:20 KST, 로컬 격리 PostgreSQL·Redis·HTTP·STOMP | +| 목적·성공 조건 | 실제 역할 회수·진행 중 메시지·서버 헤더 관문 3건 통과 | +| 실행 | `DB_PORT=[LOCAL_TEST_PORT] REDIS_PORT=[LOCAL_TEST_PORT] TEST_DB_SCHEMA=[TEST_SCHEMA] ./backend/gradlew -p backend test --tests '...StompDashboardRealRoleRevocationIntegrationTest' --rerun-tasks --quiet` | +| 결과 | **3건 중 2건 통과·1건 실패**. 헤더 관문 시험에서 직접 만든 MESSAGE에 새 필수 권한 snapshot이 없어 outbound가 메시지를 버림. 종료 코드 1 | +| 해석·수정 | fail-closed 구현은 의도대로였다. 시험 입력에 유효한 서버 내부 snapshot을 넣어 재실행. 뒤에는 수동 전송보다 실제 발행 메서드를 관측하도록 시험을 강화 | diff --git a/docs/test-results/evidence/issue-376/integration-02.md b/docs/test-results/evidence/issue-376/integration-02.md new file mode 100644 index 00000000..ee4157cd --- /dev/null +++ b/docs/test-results/evidence/issue-376/integration-02.md @@ -0,0 +1,9 @@ +# integration-02 — 실제 회수·헤더 시험 재실행 + +| 항목 | 기록 | +| --- | --- | +| 종료 시각·위치 | 2026-10-02 01:45:55 KST, 로컬 격리 PostgreSQL·Redis·HTTP·STOMP | +| 목적·성공 조건 | integration-01의 동일한 3건에서 시험 입력을 보정해 모두 통과 | +| 실행 | `DB_PORT=[LOCAL_TEST_PORT] REDIS_PORT=[LOCAL_TEST_PORT] TEST_DB_SCHEMA=[TEST_SCHEMA] ./backend/gradlew -p backend test --tests '...StompDashboardRealRoleRevocationIntegrationTest' --rerun-tasks --quiet` | +| 결과 | **3/3 통과**, 실패 0·건너뜀 0. 회수 후 새 push 차단, 회수 전 허용 메시지의 늦은 수신, 내부 헤더 비노출 | +| 해석·한계 | 결정적 순서 구분이 유지됐다. 이후 Redis 실패 사례와 실제 발행 메서드 검사로 확장(redis-01·combined-01) | diff --git a/docs/test-results/evidence/issue-376/redis-01.md b/docs/test-results/evidence/issue-376/redis-01.md new file mode 100644 index 00000000..9e9908b5 --- /dev/null +++ b/docs/test-results/evidence/issue-376/redis-01.md @@ -0,0 +1,9 @@ +# redis-01 — 무효화 실패에도 이전 ADMIN 우회 없음 + +| 항목 | 기록 | +| --- | --- | +| 종료 시각·위치 | 2026-10-02 01:46:52 KST, 로컬 격리 PostgreSQL·Redis·HTTP·STOMP | +| 목적·성공 조건 | Redis 역할 무효화 Lua 호출 실패 → DB 회수 200 → 이전 ADMIN 캐시 잔존 → 새 push 수신 0건 | +| 실행 | `DB_PORT=[LOCAL_TEST_PORT] REDIS_PORT=[LOCAL_TEST_PORT] TEST_DB_SCHEMA=[TEST_SCHEMA] ./backend/gradlew -p backend test --tests '...StompDashboardRealRoleRevocationIntegrationTest' --rerun-tasks --quiet` | +| 결과 | 클래스 **4/4 통과**. 실패를 주입한 테스트에서 HTTP 200, DB ADMIN 없음, Redis `ADMIN` 캐시 잔존, 새 대시보드 프레임 수신 0건, 기존 세션 종료 확인 | +| 해석·한계 | WebSocket 발행은 Redis 캐시를 신뢰하지 않았다. Redis 전체 서버 다운이나 GCP failover 주입은 아니다. 시험용 계정·키는 `@AfterEach`에서 삭제 | diff --git a/docs/test-results/evidence/issue-376/regression-01.md b/docs/test-results/evidence/issue-376/regression-01.md new file mode 100644 index 00000000..6e7053c1 --- /dev/null +++ b/docs/test-results/evidence/issue-376/regression-01.md @@ -0,0 +1,9 @@ +# regression-01 — 기존 WebSocket 경계 + +| 항목 | 기록 | +| --- | --- | +| 종료 시각·위치 | 2026-10-02 01:47:46 KST, 로컬 격리 PostgreSQL·Redis·STOMP | +| 목적·성공 조건 | 기존 관리자 구독, 대시보드 push, 목적지별 인가가 회귀 없이 통과 | +| 실행 | `DB_PORT=[LOCAL_TEST_PORT] REDIS_PORT=[LOCAL_TEST_PORT] TEST_DB_SCHEMA=[TEST_SCHEMA] ./backend/gradlew -p backend test --tests '...StompDashboardRoleRevocationIntegrationTest' --tests '...DashboardWebSocketIntegrationTest' --tests '...StompDestinationAuthorizationIntegrationTest' --rerun-tasks --quiet` | +| 결과 | **12/12 통과**(3+4+5), 실패 0·건너뜀 0 | +| 해석·한계 | 모의 역할 변경 기반 시험은 새 primary batch 모의 결과도 같은 역할 상태로 맞췄다. 실제 DB 회수는 redis-01·combined-01에서 별도 확인 | diff --git a/docs/test-results/evidence/issue-376/unit-01.md b/docs/test-results/evidence/issue-376/unit-01.md new file mode 100644 index 00000000..c89a76c1 --- /dev/null +++ b/docs/test-results/evidence/issue-376/unit-01.md @@ -0,0 +1,9 @@ +# unit-01 — 초기 일괄 판정 단위 시험 + +| 항목 | 기록 | +| --- | --- | +| 시각·위치 | 2026-10-02 KST, 로컬 JVM. 정확한 분·초는 별도 수집하지 못함 | +| 목적·성공 조건 | 사용자 중복 제거, primary/standby 차단, 내부 판정 누락 차단, 재처리 응답 유지 | +| 실행 | `./backend/gradlew -p backend test --tests '...PrimaryRoleQueryServiceTest' --tests '...StompDashboardOutboundAuthorizationInterceptorTest' --tests '...DashboardWebSocketControllerTest' --tests '...EmbeddingJobRetryServiceTest' --rerun-tasks --quiet` | +| 결과 | **21/21 통과**(6+6+3+6), 실패 0·건너뜀 0 | +| 해석·한계 | 초기 코드의 단위 계약 통과. 이후 chunking·dirty 재시도 테스트를 추가해 최종 48건 실행에 포함 | diff --git a/docs/test-results/gimin-376-stomp-dashboard-batch-authorization-local-20261002.md b/docs/test-results/gimin-376-stomp-dashboard-batch-authorization-local-20261002.md new file mode 100644 index 00000000..33df07e6 --- /dev/null +++ b/docs/test-results/gimin-376-stomp-dashboard-batch-authorization-local-20261002.md @@ -0,0 +1,49 @@ +# 대시보드 WebSocket primary 일괄 인가 로컬 검증 — 이슈 #376 + +> 2026-10-02 KST. 범위는 로컬 격리 PostgreSQL 17+pgvector, Redis 7, 실제 HTTP/STOMP 통합 시험이다. GCP 3노드/OpenProxy 성능·장애 검증은 수행하지 않았다. 계정·호스트명·내부 주소·서명 문자열은 결과 파일에 기록하지 않았다. + +## 문제와 변경 + +기존에는 대시보드 push의 물리 수신 세션마다 `pg_is_in_recovery()`와 역할 조회를 수행했다. [#374 측정](gimin-374-stomp-dashboard-outbound-load-20261001.md)의 같은 ADMIN 50세션 조건에서는 수신 지연 p95 중앙값 2,443.7ms와 30초 수신 588건이 관측됐다. 이 결과는 새 변경의 성능 향상을 증명하지 않는다. + +이번 변경은 한 push의 열린 CONNECT-ADMIN 세션에서 사용자 ID를 중복 제거한 다음, 현재 primary에서 ADMIN 사용자 ID를 일괄 조회한다. 후보의 물리 세션 ID→사용자 ID 대응과 확인된 ADMIN 집합을 불변 서버 내부 헤더로 브로커에 전달하고, outbound는 실제 수신 세션 ID와 그 결과를 대조한다. 조회 실패·standby·헤더 누락은 이전 CONNECT 역할이나 Redis 캐시로 우회하지 않는다. 판정 당시 없던 새 세션은 **같은 사용자의 다른 세션이 허용됐더라도** 이번 메시지만 버리고, 후보였지만 ADMIN이 아닌 세션은 버리고 닫는다. + +역할 회수 HTTP 200 **이후 시작한** 새 primary 판정에서 이전 ADMIN을 승인하지 않는 것이 완료 기준이다. 회수 전에 이미 판정을 마친 메시지는 HTTP 200 뒤 늦게 수신될 수 있으므로 별도 분류한다. 이는 클라이언트 수신 기준 0건 보장이 아니다. 비동기 failover에서 회수 커밋 자체가 남는지(RPO)도 이 시험 범위 밖이다. + +## 변경 경계 + +| 코드 | 역할 | +| --- | --- | +| `PrimaryRoleQueryService`, `UserRoleRepository` | read-write 트랜잭션에서 primary를 확인하고 후보 ADMIN ID를 최대 500명씩 조회 | +| `DashboardWebSocketController` | 요약을 받은 뒤 사용자 ID 중복 제거 → primary 판정 → 비-native 서버 내부 헤더로 발행. 후보 0명은 DB 조회 생략 | +| `DashboardAuthorizationSnapshot`, `StompDashboardOutboundAuthorizationInterceptor` | 이 push의 물리 세션 후보와 허용 사용자 집합을 실제 세션과 대조. 헤더 누락·비허용은 fail-closed | +| `EmbeddingJobRetryService` | 알림 발행 실패가 이미 커밋된 재처리 성공 응답을 실패로 뒤집지 않음. 스케줄러는 기존대로 dirty 신호를 되살려 재시도 | +| 단위·통합 테스트 | 실제 HTTP 회수·Redis 캐시·PostgreSQL·WebSocket, 내부 헤더 비노출, 실패 경로와 기존 RAG/목적지 경계 확인 | + +## 실행 결과 + +각 실행은 [개별 한국어 로그](evidence/issue-376/README.md)에 분리했다. Gradle의 JUnit XML은 동일 경로가 재실행 때 덮어써지고 로컬 호스트명·개발 환경 출력이 포함될 수 있어 원문을 공개하지 않았다. 아래 숫자는 실행 직후 결과 또는 종료 코드와 XML의 suite 합계를 읽어 기록했다. + +| 실행 | 목적 | 결과 | 해석 | +| --- | --- | --- | --- | +| header-01/02 | 내부 헤더 전달·비노출 관문 | 첫 시도는 Gradle 캐시 접근 거부로 본문 미실행, 재실행 1/1 통과 | 권한을 달리해 실행한 뒤 실제 브로커·STOMP 프레임 확인 | +| unit-01 | 일괄 판정·실패 정책 단위 시험 | 21/21 통과 | 구현 중간 결과. 이후 테스트가 추가되어 최종 수치는 아래 48건에 포함 | +| integration-01/02 | 실제 회수·경합·헤더 시험 | 첫 실행 3건 중 1건 실패, 입력에 필수 인가 snapshot을 넣은 뒤 3/3 통과 | 첫 실패는 fail-closed가 의도대로 작동한 시험 입력 오류 | +| redis-01 | Redis 무효화 실패에도 새 push 차단 | 4/4 통과 | 실제 DB 회수 후 낡은 Redis `ADMIN`이 남아도 primary 판정으로 차단 | +| regression-01 | 기존 WebSocket 경계 | 12/12 통과 | 기존 구독·RAG 목적지 규칙 회귀 없음 | +| combined-01 | 변경 관련 10개 클래스 | 48/48 통과 | 단위와 실제 HTTP·DB·Redis·STOMP 통합 결과 | +| full-01/02 | 기본 Gradle 전체 시험 | 첫 실행 1,251건 중 24건 실패, 시험용 `JWT_SECRET` 지정 후 1,270/1,270 통과 | 첫 실패는 필수 환경값 누락으로 인한 컨텍스트 생성 실패. 실제 비밀은 사용하지 않음 | +| combined-02/03 | 같은 사용자·새 세션 경합 수정 후 회귀 | 첫 실행은 경로 오타로 9개 클래스·45/45만 실행, 정확한 경로로 10개 클래스·49/49 통과 | 늦게 연결된 같은 사용자 세션도 이번 push에서 제외하는 회귀 시험 포함 | +| full-03 | 최종 코드의 전체 Gradle 시험 | 210개 suite·1,271/1,271 통과 | 최종 코드의 로컬 전체 회귀 결과. GCP 부하·장애 검증은 별도 | + +## 주요 판정과 한계 + +- 실제 발행 메서드가 만든 snapshot은 수신자별 outbound까지 전달됐고 클라이언트의 STOMP 헤더에는 없었다. 일반 `Map`을 native 헤더로 취급하는 구현으로 바뀌면 이 회귀 시험이 실패해야 한다. 후보는 사용자 ID뿐 아니라 물리 세션 ID도 보존해 같은 사용자의 새 세션이 이전 push를 받지 않게 한다. +- 실제 HTTP 회수 200·DB 역할 삭제 뒤 **새 push**는 차단됐다. Redis 무효화 Lua 실행을 시험에서 실패시켜 낡은 `ADMIN` 캐시가 남은 경우도 차단됐다. Redis 전체 장애를 실제로 발생시키거나 GCP OpenProxy를 경유해 확인한 시험은 아니다. +- 결정적 latch 시험에서는 **회수 전에 이미 허용된 메시지 1건**이 200 뒤 수신됐다. 이를 새 판정 오류로 세지 않는다. +- primary 일괄 조회 실패 단위 시험에서는 발행 0건, 스케줄러 dirty 신호 복구, 이미 커밋된 재처리 성공 응답 유지가 확인됐다. 실제 GCP primary 프로세스 장애 주입은 별도 HA 시험이다. +- 성능 개선량, 두 백엔드의 SimpleBroker 이벤트 분배, 장애 전환 시 RPO는 측정하지 않았다. 동일한 새 GCP 환경에서 변경 전후 부하 재측정이 다음 작업이다. + +## 정리·비식별 + +통합 테스트의 시험용 사용자·역할·Redis 키는 각 테스트가 정리했다. 최종 전체 시험 후 이번 실행만을 위해 만든 로컬 PostgreSQL·Redis 컨테이너 두 개를 정확한 이름으로 종료했고, `docker ps -a`에서 두 컨테이너가 남지 않은 것을 확인했다. 기존 GCP 부하 환경과는 별개이며 변경하지 않았다. 원문 XML·로그에 포함될 수 있는 로컬 호스트명, DB 접속 주소, 계정, 서명 문자열을 공개본에 복사하지 않았다. 저장소 결과 파일에 운영 비밀·GCP 프로젝트 ID·내부 IP를 기록하지 않았다. diff --git a/docs/test-results/stomp-revocation-dispatch-race-local-20261002-run01.md b/docs/test-results/stomp-revocation-dispatch-race-local-20261002-run01.md new file mode 100644 index 00000000..ed22a103 --- /dev/null +++ b/docs/test-results/stomp-revocation-dispatch-race-local-20261002-run01.md @@ -0,0 +1,22 @@ +# STOMP 권한 회수·진행 중 전송 경합 재현 — 로컬 실행 1 + +| 항목 | 내용 | +| --- | --- | +| 실행 ID | `stomp-revocation-dispatch-race-local-20261002-run01` | +| 목적 | 수신자별 primary 권한 확인이 끝난 뒤 실제 전송을 멈추고, 그 사이 HTTP 관리자 역할 회수가 200으로 끝나면 기존 진행 중 메시지가 클라이언트에 도착하는지 판정 | +| 위치 | 로컬 백엔드 통합 테스트·격리된 PostgreSQL/Redis 컨테이너. GCP/OpenProxy 시험 아님 | +| 코드 | `StompDashboardRealRoleRevocationIntegrationTest.inFlightMessage_canArriveAfterRevocationResponse_whenAuthorizationFinishedFirst` | +| 명령 | `DB_HOST=127.0.0.1 DB_PORT=55433 REDIS_PORT=6380 ./backend/gradlew -p backend test --tests 'com.opensource.docgrid.domain.auth.integration.StompDashboardRealRoleRevocationIntegrationTest' --console=plain` | +| 성공 기준 | 기존 새 push 차단 시험 통과. 새 경합 시험에서 일시정지 중 수신 0건, 실제 회수 HTTP 200·DB ADMIN 삭제, 해제 후 진행 중 메시지 1건 수신. 시험용 사용자·Redis 키와 격리 컨테이너 정리 | +| 상태 | **실패 — 환경 준비 단계**. 경합 코드 실행 전 Flyway 마이그레이션 중단 | + +**판정 경계:** 이 시험의 수신 1건은 회수 응답 후 **새로 권한을 확인한** 메시지가 아니라, 응답 전 이미 허용됐지만 전송이 보류된 메시지다. 두 유형을 혼합해 “회수 후 신규 승인”이라고 주장하지 않는다. + +## 01:02 KST 실행 결과 + +| 관측 | 수치·결과 | 해석 | +| --- | --- | --- | +| Gradle 테스트 작업 | 테스트 2개가 컨텍스트 로딩에서 실패, 경합 본문 실행 0건 | 테스트 결과로 경합을 판정할 수 없음 | +| 첫 원인 | Flyway `V32__convert_vector_columns_to_pgvector.sql`: PostgreSQL `vector` 타입 없음 | 새 격리 DB에 pgvector 확장을 활성화하지 않은 환경 준비 오류 | +| 재시도 계획 | 이 실행만을 위한 격리 DB에 `CREATE EXTENSION IF NOT EXISTS vector` 후 별도 실행 ID로 재시험 | 기존 로컬 DB·Redis의 데이터·설정과 GCP 3노드는 변경하지 않음 | +| 정리 상태 | 격리 컨테이너는 재시도 2~4 이후 정확한 두 이름으로 중지했고 자동 삭제 잔여 0개 확인 | Docker Desktop 시작 시 기존 Redis 컨테이너는 자동 기동됐으며 그대로 둠 | diff --git a/docs/test-results/stomp-revocation-dispatch-race-local-20261002-run02.md b/docs/test-results/stomp-revocation-dispatch-race-local-20261002-run02.md new file mode 100644 index 00000000..9e1a73a7 --- /dev/null +++ b/docs/test-results/stomp-revocation-dispatch-race-local-20261002-run02.md @@ -0,0 +1,21 @@ +# STOMP 권한 회수·진행 중 전송 경합 재현 — 로컬 실행 2 + +| 항목 | 내용 | +| --- | --- | +| 실행 ID | `stomp-revocation-dispatch-race-local-20261002-run02` | +| 목적 | [첫 실행](stomp-revocation-dispatch-race-local-20261002-run01.md)의 격리 DB 확장 누락을 바로잡고 실제 HTTP 회수·WebSocket 전송 경합을 판정 | +| 위치 | 로컬 백엔드 통합 테스트·격리 PostgreSQL/Redis. GCP/OpenProxy 시험 아님 | +| 사전 조건 | 격리 PostgreSQL DB의 pgvector 확장 활성화, 기존 로컬 DB·Redis 무변경 | +| 명령 | `DB_HOST=127.0.0.1 DB_PORT=55433 REDIS_PORT=6380 ./backend/gradlew -p backend test --tests 'com.opensource.docgrid.domain.auth.integration.StompDashboardRealRoleRevocationIntegrationTest' --console=plain` | +| 성공 기준 | 두 테스트 통과. 새 경합 시험에서는 권한 확인 이후 전송 보류 → HTTP 200·ADMIN 삭제 → 전송 해제 후 메시지 수신 순서가 관측되어야 함 | +| 상태 | **통과** — 2026-10-02 01:03 KST, Gradle `BUILD SUCCESSFUL` | + +## 관측 결과 + +| 구분 | 숫자·상태 | 해석 | +| --- | --- | --- | +| JUnit XML | 테스트 2개, 실패 0, 오류 0, 건너뜀 0, 실행 1.887초 | 기존 회수 후 새 push 차단과 새 진행 중 전송 경합이 함께 통과 | +| 일시정지 중 | 메시지 수신 0건 | 실제 outbound 권한 검사 종료 뒤, 다음 전송 단계를 잠금 | +| HTTP 회수 | 200 OK, DB ADMIN 매핑 없음 | 메시지 전송 잠금 상태에서 실제 DB 커밋 완료 | +| 잠금 해제 뒤 | 기존 진행 중 메시지 1건 수신, 시험 메시지 `documents.total=42` | **회수 응답 후 수신**은 재현. 회수 뒤 새로 권한을 허용한 메시지라는 뜻은 아님 | +| 정리 상태 | 시험 전용 사용자·Redis 키는 테스트 `@AfterEach`가 정리. 격리 컨테이너는 반복 시험 후 중지·자동 삭제, 잔여 0개 확인 | 사용자 데이터와 반복 시험 인프라의 정리 단계를 구분 | diff --git a/docs/test-results/stomp-revocation-dispatch-race-local-20261002-run03.md b/docs/test-results/stomp-revocation-dispatch-race-local-20261002-run03.md new file mode 100644 index 00000000..88806e7a --- /dev/null +++ b/docs/test-results/stomp-revocation-dispatch-race-local-20261002-run03.md @@ -0,0 +1,16 @@ +# STOMP 권한 회수·진행 중 전송 경합 재현 — 로컬 실행 3 + +| 항목 | 내용 | +| --- | --- | +| 실행 ID | `stomp-revocation-dispatch-race-local-20261002-run03` | +| 목적 | [실행 2](stomp-revocation-dispatch-race-local-20261002-run02.md)의 실제 HTTP 회수·전송 경합 결과가 반복되는지 확인 | +| 위치 | 로컬 격리 PostgreSQL/Redis 및 백엔드 통합 테스트. GCP 시험 아님 | +| 명령 | `DB_HOST=127.0.0.1 DB_PORT=55433 REDIS_PORT=6380 ./backend/gradlew -p backend test --tests 'com.opensource.docgrid.domain.auth.integration.StompDashboardRealRoleRevocationIntegrationTest' --rerun-tasks --console=plain` | +| 성공 기준 | 두 테스트 통과, 실제 회수 200·역할 삭제 이후 보류 중 메시지 수신 재현. 시험 전용 데이터 정리 | +| 상태 | **통과** — 2026-10-02 약 01:04 KST, Gradle `BUILD SUCCESSFUL` | + +| 관측 | 수치·결과 | 해석 | +| --- | --- | --- | +| JUnit XML을 종료 직후 확인 | 테스트 2개, 실패 0, 오류 0, 건너뜀 0, 실행 1.914초 | 기존 차단 시험과 진행 중 전송 경합 재현이 모두 반복 통과 | +| 경합 테스트의 필수 순서 | 보류 중 수신 0건 → 회수 HTTP 200·DB 역할 삭제 → 해제 뒤 `documents.total=42` 메시지 1건 수신 | 회수 뒤 **새 승인**이 아닌 기존 진행 중 전송의 늦은 수신 재현 | +| 정리 | 테스트 `@AfterEach`의 일회용 데이터 정리 수행. 실행 4 후 격리 컨테이너 자동 삭제 잔여 0개 확인 | 최종 데이터·인프라 정리를 별도 확인 | diff --git a/docs/test-results/stomp-revocation-dispatch-race-local-20261002-run04.md b/docs/test-results/stomp-revocation-dispatch-race-local-20261002-run04.md new file mode 100644 index 00000000..af446673 --- /dev/null +++ b/docs/test-results/stomp-revocation-dispatch-race-local-20261002-run04.md @@ -0,0 +1,17 @@ +# STOMP 권한 회수·진행 중 전송 경합 재현 — 로컬 실행 4 + +| 항목 | 내용 | +| --- | --- | +| 실행 ID | `stomp-revocation-dispatch-race-local-20261002-run04` | +| 목적 | [실행 2](stomp-revocation-dispatch-race-local-20261002-run02.md)·[실행 3](stomp-revocation-dispatch-race-local-20261002-run03.md)에 이어 경합 결과의 세 번째 성공 재현 확인 | +| 위치 | 로컬 격리 PostgreSQL/Redis 및 백엔드 통합 테스트. GCP 시험 아님 | +| 명령 | `DB_HOST=127.0.0.1 DB_PORT=55433 REDIS_PORT=6380 ./backend/gradlew -p backend test --tests 'com.opensource.docgrid.domain.auth.integration.StompDashboardRealRoleRevocationIntegrationTest' --rerun-tasks --console=plain` | +| 성공 기준 | 두 테스트 통과, 실제 회수 200·역할 삭제 이후 보류 중 메시지 수신 재현. 시험 전용 데이터·격리 컨테이너 정리 | +| 상태 | **통과** — 2026-10-02 01:04:54 KST 시작, Gradle `BUILD SUCCESSFUL` | + +| 관측 | 수치·결과 | 해석 | +| --- | --- | --- | +| JUnit XML을 종료 직후 확인 | 테스트 2개, 실패 0, 오류 0, 건너뜀 0, 실행 1.881초 | 기존 차단 시험과 진행 중 전송 경합 재현이 세 번째로 통과 | +| 경합 테스트의 필수 순서 | 보류 중 수신 0건 → 회수 HTTP 200·DB 역할 삭제 → 해제 뒤 `documents.total=42` 메시지 1건 수신 | 반복 가능한 진행 중 메시지 수신 경계 확인 | +| 시험 데이터 | 해당 패턴의 DB 사용자 0건, 격리 Redis 역할 키 0건 | 테스트가 만든 사용자·캐시 데이터 잔여 없음 | +| 격리 컨테이너 | 정확한 두 이름으로 `docker stop` 후 `docker ps -a` 잔여 **0개** | 자동 삭제 완료. 기존 DB·Redis 데이터·설정과 GCP 노드 무변경; Docker Desktop 시작으로 기존 Redis는 자동 기동 | diff --git a/docs/test-results/stomp-revocation-dispatch-race-local-20261002-summary.md b/docs/test-results/stomp-revocation-dispatch-race-local-20261002-summary.md new file mode 100644 index 00000000..2517df34 --- /dev/null +++ b/docs/test-results/stomp-revocation-dispatch-race-local-20261002-summary.md @@ -0,0 +1,18 @@ +# STOMP 권한 회수·진행 중 전송 경합 — 로컬 시험 종합 + +목적: 권한 확인이 끝난 MESSAGE의 실제 전송을 의도적으로 멈추고, 그 사이 HTTP 회수 200·DB 커밋이 완료되면 클라이언트가 해당 진행 중 MESSAGE를 나중에 받을 수 있는지 확인한다. 이 문서는 로컬 PostgreSQL·Redis·WebSocket 통합 시험의 종합이며 **GCP 3노드/OpenProxy나 부하 측정 결과가 아니다**. + +| 실행 ID | 목적·조건 | 결과 | 판정·근거 | +| --- | --- | --- | --- | +| [run01](stomp-revocation-dispatch-race-local-20261002-run01.md) | 새 격리 DB에서 첫 실행 | 테스트 2개 컨텍스트 실패, 경합 본문 0건 | pgvector 확장 누락으로 Flyway V32 중단. 기능 판정 제외 | +| [run02](stomp-revocation-dispatch-race-local-20261002-run02.md) | 격리 DB에 pgvector를 활성화한 재실행 | **2/2 통과**, 1.887초 | 실제 HTTP 회수 후 진행 중 메시지 1건 수신 재현 | +| [run03](stomp-revocation-dispatch-race-local-20261002-run03.md) | 같은 조건 강제 재실행 | **2/2 통과**, 1.914초 | 동일 경합 재현 | +| [run04](stomp-revocation-dispatch-race-local-20261002-run04.md) | 세 번째 성공 재현 | **2/2 통과**, 1.881초 | 동일 경합 재현, 시험 사용자·Redis 역할 키 잔여 0건 | + +관측한 순서는 `primary 역할 확인 완료 → outbound 전송 보류 → HTTP 회수 200·DB ADMIN 삭제 → 전송 해제 → 보류 중 메시지 수신`이다. 회수 이후 **새로 시작한 push**를 차단하는 기존 시험도 성공 반복 3회에 함께 통과했다. 따라서 이번 결과는 “회수 뒤 새 권한 확인이 잘못 ADMIN을 반환한다”가 아니라 **회수 전에 허용한 진행 중 메시지의 늦은 전달**을 가리킨다. + +테스트는 `@MockitoSpyBean`으로 실제 outbound 인터셉터의 `preSend`를 먼저 실행한 뒤 반환을 latch로 보류한다. 역할 변경은 mock이 아니라 실제 HTTP API·PostgreSQL 트랜잭션·Redis 무효화 경로를 사용한다. 테스트 전용 Java 코드 외 프로덕션 코드는 변경하지 않았다. + +정리: 테스트 전용 사용자 **0건**, 격리 Redis 역할 키 **0건**을 확인했다. 이번 시험에 만든 PostgreSQL·Redis 컨테이너 두 개를 정확한 이름으로 중지했고 자동 삭제 후 `docker ps -a` 잔여 **0개**를 확인했다. 기존 로컬 DB·Redis의 데이터·설정과 GCP 3노드는 변경하지 않았다. 다만 Docker Desktop을 시작하면서 기존 Redis 컨테이너가 자동 기동됐고, 이 컨테이너는 그대로 두었다. + +제한: Gradle의 기본 JUnit XML 경로는 반복 실행 시 같은 파일을 다시 쓰므로, 첫 세 성공 실행의 XML 원문은 별도 보존하지 못했다. 각 실행 직후 확인한 건수·실행 시간은 위 실행별 한국어 로그에 분리해 기록했다. 실제 GCP 3노드, 두 백엔드, 부하 중 권한 회수, 전송 시작 시각과 클라이언트 수신 시각의 분산 계측은 아직 수행하지 않았다.