diff --git a/src/main/java/com/example/dvely/agent/infrastructure/docker/DockerContainerService.java b/src/main/java/com/example/dvely/agent/infrastructure/docker/DockerContainerService.java index e5c85816..c22bd11e 100644 --- a/src/main/java/com/example/dvely/agent/infrastructure/docker/DockerContainerService.java +++ b/src/main/java/com/example/dvely/agent/infrastructure/docker/DockerContainerService.java @@ -17,6 +17,7 @@ import com.github.dockerjava.api.model.ExposedPort; import com.github.dockerjava.api.model.Frame; import com.github.dockerjava.api.model.HostConfig; +import com.github.dockerjava.api.model.LogConfig; import com.github.dockerjava.api.model.MemoryStatsConfig; import com.github.dockerjava.api.model.Network; import com.github.dockerjava.api.model.Ports; @@ -80,6 +81,22 @@ public class DockerContainerService { public static final long JAVA_MEMORY_LIMIT_BYTES = 2L << 30; // 2 GiB private static final long NANO_CPUS = 1_000_000_000L; // 1.0 vCPU per session, fair-share private static final long PIDS_LIMIT = 256L; // fork-bomb guard; ~4x observed npm install process counts + + /** + * 컨테이너 로그 상한 (Issue #342, 7-6). + * + *

로그 드라이버에 상한이 없으면 dev 서버 stdout 이 TTL 동안 무제한으로 쌓인다 — 사용자 코드가 + * 루프 안에서 찍는 로그 한 줄이 호스트 디스크를 채우는 경로이고, 그 컨테이너가 도는 것은 우리 + * 호스트다. 크기 상한과 회전 개수를 둬 컨테이너 하나가 쓰는 로그를 유계로 만든다.

+ * + *

값의 근거: 로그 조회 API({@code getContainerLogs})는 꼬리만 읽으므로 진단에 필요한 것은 + * "최근"뿐이다. 10 MiB × 2 개면 빌드 실패 원인을 찾기에 넉넉하고, 컨테이너당 20 MiB 로 묶인다. + * 드라이버를 {@code json-file} 로 명시하는 이유는 이 옵션이 그 드라이버의 것이라서다 — 호스트 + * 기본 드라이버가 다르면(journald 등) 옵션이 조용히 무시된다.

+ */ + private static final LogConfig BOUNDED_LOG_CONFIG = new LogConfig( + LogConfig.LoggingType.JSON_FILE, + Map.of("max-size", "10m", "max-file", "2")); private static final String PREVIEW_NETWORK_NAME = "qeploy-preview"; // one-shot `stats` needs ~1s to sample a CPU delta (see getContainerStats); 3s is the // point past which we degrade the /status response instead of blocking the caller. @@ -174,6 +191,7 @@ public String createAndStartContainer(Long userId, .withCapDrop(Capability.ALL) .withCapAdd(Capability.CHOWN, Capability.SETUID, Capability.SETGID) .withSecurityOpts(List.of("no-new-privileges")) + .withLogConfig(BOUNDED_LOG_CONFIG) .withNetworkMode(PREVIEW_NETWORK_NAME)) .withLabels(labels) .withCmd("tail", "-f", "/dev/null") @@ -669,6 +687,7 @@ public String createDatabaseContainer(String networkName, String networkAlias, S .withCapAdd(Capability.CHOWN, Capability.SETUID, Capability.SETGID, Capability.DAC_OVERRIDE, Capability.FOWNER, Capability.SETFCAP) .withSecurityOpts(List.of("no-new-privileges")) + .withLogConfig(BOUNDED_LOG_CONFIG) .withNetworkMode(networkName)) .withAliases(networkAlias) .withLabels(Map.of(AGENT_LABEL, "true")) @@ -952,7 +971,24 @@ private void pullImageIfNeeded() { pullImageIfNeeded(IMAGE); } + /** + * 로컬에 없을 때만 pull 한다 (Issue #342, 7-5). + * + *

예전에는 컨테이너를 만들 때마다 조건 없이 {@code pullImageCmd} 를 돌렸다. 이미 있는 + * 이미지에도 레지스트리 왕복을 하고, 네트워크가 느리거나 레지스트리가 응답하지 않으면 컨테이너 + * 생성이 최대 3 분을 기다린 뒤에야 진행됐다 — 프리뷰를 띄우는 사용자가 그 시간을 그대로 본다. + * 같은 문제를 코딩 에이전트 쪽은 {@code inspectImageCmd} 선확인으로 이미 피하고 있다 + * ({@code CodingAgentContainerRunner#assertImagePresent}).

+ * + *

다만 그쪽과 달리 여기서는 없으면 pull 한다. 코딩 에이전트 이미지는 로컬에서만 빌드하는 + * 것이라 부재가 곧 설정 오류이지만, 이 이미지는 공개 베이스({@code node:20-alpine})라 첫 기동에 + * 받아오는 것이 정상 경로다.

+ */ private void pullImageIfNeeded(String image) { + if (imagePresentLocally(image)) { + log.debug("Docker 이미지가 이미 로컬에 있음(pull 생략): {}", image); + return; + } try { dockerClient.pullImageCmd(image).start().awaitCompletion(3, TimeUnit.MINUTES); log.info("Docker 이미지 준비 완료: {}", image); @@ -961,6 +997,22 @@ private void pullImageIfNeeded(String image) { } } + /** + * 판정이 안 되면 "없다"로 답한다 — 그러면 호출부가 pull 로 떨어져 예전 동작이 된다. 선확인의 + * 목적은 왕복을 줄이는 것이지 새로운 실패 지점을 만드는 것이 아니다. + */ + private boolean imagePresentLocally(String image) { + try { + dockerClient.inspectImageCmd(image).exec(); + return true; + } catch (NotFoundException e) { + return false; + } catch (RuntimeException e) { + log.debug("이미지 로컬 존재 확인 실패(pull 로 진행): image={} {}", image, e.getMessage()); + return false; + } + } + private void putLabel(Map labels, String key, Object value) { if (value != null) { labels.put(key, String.valueOf(value)); diff --git a/src/main/java/com/example/dvely/preview/application/service/PreviewGatewayService.java b/src/main/java/com/example/dvely/preview/application/service/PreviewGatewayService.java index 4b690eba..dffb7756 100644 --- a/src/main/java/com/example/dvely/preview/application/service/PreviewGatewayService.java +++ b/src/main/java/com/example/dvely/preview/application/service/PreviewGatewayService.java @@ -2,7 +2,9 @@ import com.example.dvely.preview.application.port.out.DeadPreviewSessionReclaimer; import com.example.dvely.preview.application.result.PreviewSessionInfo; +import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.io.SequenceInputStream; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -11,6 +13,9 @@ import java.time.Duration; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -76,11 +81,47 @@ public class PreviewGatewayService { */ private static final Duration FETCH_TIMEOUT = Duration.ofSeconds(30); + /** + * 내용 해시가 박힌 불변 자산의 캐시 정책 (Issue #342, 7-2). {@code private} 는 협상 대상이 아니다 — + * 이유는 {@link #cacheControlFor} 참고. + */ + private static final String IMMUTABLE_ASSET_CACHE = "private, max-age=3600, immutable"; + + /** + * 그 밖의 자산: 브라우저가 담아둘 수는 있으나 매번 원본에 물어봐야 한다. 그래서 세션 조회· + * 인가·토큰 회전 판정이 예전과 똑같이 요청마다 돌고, 바뀌지 않았으면 본문만 안 흐른다(304). + */ + private static final String REVALIDATED_ASSET_CACHE = "private, no-cache"; + + /** 장기 캐시를 허용할 확장자 — 번들러가 내용 해시를 붙이는 산출물만. */ + private static final java.util.Set IMMUTABLE_ASSET_EXTENSIONS = java.util.Set.of( + "js", "mjs", "cjs", "css", "map", + "woff", "woff2", "ttf", "otf", "eot", + "png", "jpg", "jpeg", "gif", "svg", "webp", "avif", "ico"); + + /** + * 재작성을 위해 문서를 메모리에 모을 상한 (Issue #342, 7-3). 정상 {@code index.html} 은 수십 KB 라 + * 이 상한과 자릿수가 다르다 — 여기 걸리는 것은 사용자 코드가 끝없이 내보내는 문서 쪽이다. + */ + private static final int HTML_REWRITE_LIMIT_BYTES = 8 * 1024 * 1024; + private final HttpClient httpClient = HttpClient.newBuilder() .followRedirects(HttpClient.Redirect.NORMAL) .connectTimeout(CONNECT_TIMEOUT) .build(); + /** + * 세션당 흡수 단수를 기억하는 상한 (Issue #342, 7-4). 게이트웨이는 host-affine 이고 세션 TTL 은 + * 30 분이라 동시에 살아 있는 세션 수는 이보다 훨씬 작다. + */ + private static final int ABSORB_MEMO_CAPACITY = 512; + + /** + * 세션 → 빌드 base 흡수 단수. 값이 있으면 그 세션의 자산은 선행 세그먼트를 그만큼 벗겨야 맞는다. + * base 가 없는 프로젝트(대다수)는 여기에 들어오지 않는다 — 흡수가 일어난 세션만 기록한다. + */ + private final java.util.Map absorbedDepth = new java.util.concurrent.ConcurrentHashMap<>(); + private final String contentSecurityPolicy; private final boolean reclaimEnabled; private final DeadPreviewSessionReclaimer reclaimer; @@ -106,59 +147,68 @@ String sandboxPolicy() { } /** GET 편의 오버로드(본문 없음). 기존 호출부·테스트가 그대로 쓴다. */ - public ResponseEntity proxy(PreviewSessionInfo session, - String gatewayPrefix, - String path, - String query) { - return proxy("GET", session, gatewayPrefix, path, query, null, null); + public ResponseEntity proxy(PreviewSessionInfo session, + String gatewayPrefix, + String path, + String query) { + return proxy(session, gatewayPrefix, path, query, ProxiedRequest.get()); + } + + /** + * 게이트웨이가 컨테이너로 그대로 넘기는 요청 조각. 인자 수를 줄이려는 묶음이지 도메인 개념이 아니다. + */ + public record ProxiedRequest(String method, byte[] body, String contentType, + String ifNoneMatch, String ifModifiedSince) { + + public static ProxiedRequest get() { + return new ProxiedRequest("GET", null, null, null, null); + } + + boolean isGet() { + return "GET".equalsIgnoreCase(method); + } + + /** 조건부 헤더를 떼어낸 사본 — 문서·SPA 경로에는 넘기지 않기 위한 것(7-2). */ + ProxiedRequest withoutConditions() { + return new ProxiedRequest(method, body, contentType, null, null); + } } /** * 프리뷰 컨테이너로 요청을 프록시한다. GET 뿐 아니라 쓰기(POST/PUT/DELETE/PATCH)도 method·본문을 그대로 * 전달한다 — 에이전트가 만든 앱의 등록·폼이 동작하려면 필요하다. 응답의 HTML 재작성(base 흡수·경로 shim)은 * GET 문서에만 적용되고, 쓰기 응답(대개 JSON)은 그대로 돌려준다. + * + *

본문은 HTML 만 메모리에 모은다(Issue #342, 7-3). 예전에는 {@code ofByteArray} 로 모든 + * 응답을 전량 버퍼링했다 — 큰 이미지·번들 하나가 요청마다 그 크기만큼 힙을 쓰고, HTML 은 String + * 변환으로 한 번 더 복사됐다. 이제 비-HTML 은 {@code ofInputStream} 으로 받아 그대로 흘려보낸다. + * HTML 만 버퍼링하는 이유는 shim 주입·경로 재작성이 본문 전체를 봐야 해서다.

+ * + *

스트리밍 봉투를 {@code StreamingResponseBody} 가 아니라 {@code InputStreamResource} 로 두는 + * 것이 중요하다. 전자는 Spring MVC 의 비동기 디스패치를 켜고, 이 앱에는 MVC 비동기 전용 executor 가 + * 없어 {@code SimpleAsyncTaskExecutor} 가 요청마다 플랫폼 스레드를 하나 새로 만든다 — 자산 + * 수만큼 스레드가 생기는 셈이라 버퍼링보다 나쁘다(그래서 SSE 처럼 수가 적고 오래 사는 스트림에만 + * 쓴다). {@code Resource} 는 {@code ResourceHttpMessageConverter} 가 요청 스레드에서 복사 + * 버퍼로 흘려보내므로, 톰캣 스레드 풀 경계를 그대로 두고 버퍼링만 없앤다.

*/ - public ResponseEntity proxy(String method, - PreviewSessionInfo session, - String gatewayPrefix, - String path, - String query, - byte[] requestBody, - String requestContentType) { + public ResponseEntity proxy(PreviewSessionInfo session, + String gatewayPrefix, + String path, + String query, + ProxiedRequest request) { + HttpResponse upstream = null; try { String safePath = sanitizePath(path); - HttpResponse response = fetch(method, session, safePath, query, requestBody, requestContentType); - if ("GET".equalsIgnoreCase(method)) { - response = absorbBuildBasePath(session, safePath, query, response); - } - - String contentType = response.headers() - .firstValue(HttpHeaders.CONTENT_TYPE) - .orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE); - byte[] body = response.body(); - boolean html = contentType.contains(MediaType.TEXT_HTML_VALUE); - if (html) { - body = rewriteHtml(body, gatewayPrefix); - } - return ResponseEntity.status(response.statusCode()) - .header(HttpHeaders.CONTENT_TYPE, contentType) - // HTML의 no-transform은 CDN이 문서를 건드리지 못하게 한다 (Issue #113). - // Cloudflare는 이 zone의 HTML 응답에 자기 RUM beacon을 주입하는데, 프리뷰 - // 문서는 아래 sandbox로 불투명 오리진이라 그 beacon의 POST가 cross-origin이 - // 되어 콘솔에 CORS 에러만 남긴다(수집도 되지 않는다). 주입은 HTML에만 - // 일어나므로 HTML에만 붙여, 자산 응답의 압축은 그대로 둔다. - .header(HttpHeaders.CACHE_CONTROL, html ? "no-store, no-transform" : "no-store") - // HTML뿐 아니라 모든 프록시 응답에 붙인다. 프리뷰 앱이 자기 JS/워커를 어떤 - // Content-Type으로 내보내든 실행 컨텍스트는 동일하게 격리돼야 한다. - // 불투명 오리진의 CORS 로드(module script 등, Issue #108)는 여기서가 아니라 - // SecurityConfig 의 /api/v1/previews/** 전용 CORS 설정이 허용한다 — 여기서 - // ACAO 를 또 달면 CorsFilter 의 것과 중복되어 브라우저가 거절한다. - .header(CONTENT_SECURITY_POLICY, contentSecurityPolicy) - .body(body); + upstream = request.isGet() + ? fetchForGet(session, safePath, query, request) + : fetch(session, safePath, query, request); + return relay(upstream, safePath, gatewayPrefix); } catch (InterruptedException exception) { + closeQuietly(upstream); Thread.currentThread().interrupt(); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); } catch (Exception exception) { + closeQuietly(upstream); // 안쪽 앱에 아예 도달하지 못했다(연결 거부/리셋). 컨테이너는 살아있어도 그 안의 서버 프로세스가 // 죽으면 이 자리에 온다 — attach·findCurrent 의 컨테이너-생존 확인으로는 못 걸러지는 사각이다. // 한 번 더 빠르게 확인해 일시적 실패가 아니면 세션을 회수한다(EXPIRED + 컨테이너 제거). 그러면 @@ -171,6 +221,89 @@ public ResponseEntity proxy(String method, } } + /** + * 업스트림 응답을 브라우저로 넘긴다 — HTML 은 재작성해 버퍼로, 나머지는 스트림으로. + */ + private ResponseEntity relay(HttpResponse upstream, String path, String gatewayPrefix) + throws java.io.IOException { + String contentType = upstream.headers() + .firstValue(HttpHeaders.CONTENT_TYPE) + .orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE); + boolean html = contentType.contains(MediaType.TEXT_HTML_VALUE); + ResponseEntity.BodyBuilder response = ResponseEntity.status(upstream.statusCode()) + // HTML의 no-transform은 CDN이 문서를 건드리지 못하게 한다 (Issue #113). + // Cloudflare는 이 zone의 HTML 응답에 자기 RUM beacon을 주입하는데, 프리뷰 + // 문서는 아래 sandbox로 불투명 오리진이라 그 beacon의 POST가 cross-origin이 + // 되어 콘솔에 CORS 에러만 남긴다(수집도 되지 않는다). 주입은 HTML에만 + // 일어나므로 HTML에만 붙여, 자산 응답의 압축은 그대로 둔다. + // + // 문서는 캐시하지 않는다(7-2): prefix(회전 토큰이 들어 있다)를 본문에 박아 내보내므로 + // 담아두면 회전 뒤에 죽은 주소를 가리키는 문서가 되살아난다. + .header(HttpHeaders.CACHE_CONTROL, + html ? "no-store, no-transform" : cacheControlFor(upstream.statusCode(), path)) + // HTML뿐 아니라 모든 프록시 응답에 붙인다. 프리뷰 앱이 자기 JS/워커를 어떤 + // Content-Type으로 내보내든 실행 컨텍스트는 동일하게 격리돼야 한다. + // 불투명 오리진의 CORS 로드(module script 등, Issue #108)는 여기서가 아니라 + // SecurityConfig 의 /api/v1/previews/** 전용 CORS 설정이 허용한다 — 여기서 + // ACAO 를 또 달면 CorsFilter 의 것과 중복되어 브라우저가 거절한다. + .header(CONTENT_SECURITY_POLICY, contentSecurityPolicy); + if (!html) { + // 업스트림의 검증자를 그대로 넘겨 다음 요청이 조건부가 되게 한다. 재작성하는 문서에는 + // 붙이지 않는다 — 그 검증자는 우리가 내보낸 본문의 것이 아니다(7-2). + forwardHeader(upstream, response, HttpHeaders.ETAG); + forwardHeader(upstream, response, HttpHeaders.LAST_MODIFIED); + } + + if (upstream.statusCode() == HttpStatus.NOT_MODIFIED.value()) { + // 304 는 본문이 없다. 스트림을 닫아 커넥션만 반납하고 그대로 흘려보낸다 — 신선도 판정은 + // 안쪽 앱이 했고, 게이트웨이는 인가를 거친 뒤 그 판정을 전달할 뿐이다. + closeQuietly(upstream); + return response.build(); + } + + // 304 가 아닌 응답에는 본문이 따라가므로 타입을 단다(304 는 본문이 없어 의미가 없다). + response.header(HttpHeaders.CONTENT_TYPE, contentType); + if (!html) { + // 업스트림이 길이를 알려줬으면 그대로 넘긴다(본문을 변형하지 않으므로 여전히 정확하다). + // 없으면 청크로 나간다 — 어느 쪽이든 본문은 힙을 거치지 않는다. + upstream.headers().firstValue(HttpHeaders.CONTENT_LENGTH) + .ifPresent(length -> response.header(HttpHeaders.CONTENT_LENGTH, length)); + return response.body(new InputStreamResource(upstream.body())); + } + + // 문서도 무제한으로 모으지는 않는다 — 이 본문을 만드는 것은 사용자 코드이고, 끝나지 않는 + // 문서 하나가 힙을 통째로 먹을 수 있다. 상한을 넘으면 재작성을 포기하고 그대로 흘린다 + // (shim 이 빠지는 것이 OOM 보다 낫고, 정상 index.html 은 이 상한과 자릿수가 다르다). + InputStream body = upstream.body(); + byte[] document = body.readNBytes(HTML_REWRITE_LIMIT_BYTES + 1); + if (document.length > HTML_REWRITE_LIMIT_BYTES) { + log.warn("[PreviewGateway] 문서가 재작성 상한({} bytes)을 넘어 원본을 그대로 흘린다", HTML_REWRITE_LIMIT_BYTES); + return response.body(new InputStreamResource( + new SequenceInputStream(new ByteArrayInputStream(document), body))); + } + body.close(); + byte[] rewritten = rewriteHtml(document, gatewayPrefix); + return response.contentLength(rewritten.length).body(new ByteArrayResource(rewritten)); + } + + private void forwardHeader(HttpResponse upstream, + ResponseEntity.BodyBuilder response, + String header) { + upstream.headers().firstValue(header).ifPresent(value -> response.header(header, value)); + } + + /** 버려지는 업스트림 응답의 본문을 닫는다 — 안 닫으면 커넥션이 반납되지 않는다. */ + private void closeQuietly(HttpResponse response) { + if (response == null) { + return; + } + try { + response.body().close(); + } catch (Exception ignored) { + // 이미 끊긴 스트림 — 닫기 실패는 알릴 것이 없다. + } + } + /** * SSE({@code text/event-stream})를 스트리밍으로 프록시한다. 버퍼링 {@code proxy} 는 응답을 * {@code ofByteArray} 로 통째로 모아서 SSE 처럼 끝나지 않는 응답에선 영원히 막힌다 — 그래서 SSE 는 @@ -258,29 +391,95 @@ private boolean isInnerAppUnreachable(PreviewSessionInfo session) { } } - /** GET 편의 오버로드(base 흡수의 내부 재시도용 — 본문 없음). */ - private HttpResponse fetch(PreviewSessionInfo session, String path, String query) - throws java.io.IOException, InterruptedException { - return fetch("GET", session, path, query, null, null); - } - - private HttpResponse fetch(String method, PreviewSessionInfo session, String path, String query, - byte[] body, String contentType) + private HttpResponse fetch(PreviewSessionInfo session, String path, String query, + ProxiedRequest request) throws java.io.IOException, InterruptedException { String target = "http://127.0.0.1:" + session.hostPort() + "/" + path; if (query != null && !query.isBlank()) { target += "?" + query; } + byte[] body = request.body(); HttpRequest.BodyPublisher publisher = (body == null || body.length == 0) ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofByteArray(body); HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(target)) .timeout(FETCH_TIMEOUT) - .method(method, publisher); + .method(request.method(), publisher); + String contentType = request.contentType(); if (contentType != null && !contentType.isBlank() && body != null && body.length > 0) { builder.header(HttpHeaders.CONTENT_TYPE, contentType); } - return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray()); + if (request.isGet()) { + // 브라우저의 조건부 요청을 안쪽 앱에 그대로 물어본다 — 바뀌지 않았으면 앱이 304 로 답하고 + // 본문이 흐르지 않는다. 판정은 앱이 하므로 게이트웨이가 신선도를 추측하지 않는다(7-2). + forwardIfPresent(builder, HttpHeaders.IF_NONE_MATCH, request.ifNoneMatch()); + forwardIfPresent(builder, HttpHeaders.IF_MODIFIED_SINCE, request.ifModifiedSince()); + } + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream()); + } + + private void forwardIfPresent(HttpRequest.Builder builder, String header, String value) { + if (value != null && !value.isBlank()) { + builder.header(header, value); + } + } + + /** + * GET 한 건을 가져오되 빌드 base 어긋남을 흡수한다 — 같은 세션에서 두 번째 자산부터는 한 번에 + * 맞힌다 (Issue #342, 7-4). + * + *

{@link #absorbBuildBasePath} 는 "틀린 경로로 먼저 물어보고, HTML 이 돌아오면 접두를 벗겨 + * 다시 묻는다"로 동작한다. 그 탐색이 자산마다 반복되면 base 를 쓰는 프로젝트의 페이지 한 번은 + * 자산 수 × 최대 3 회의 컨테이너 왕복이 된다. 한 세션의 자산은 같은 빌드 산출물이라 base 도 + * 하나이므로, 처음 알아낸 단수를 기억해 다음 자산부터 바로 적용한다.

+ * + *

기억이 틀렸으면(같은 세션에 base 가 다른 자산이 섞인 경우) 기억을 버리고 원래 경로로 돌아가 + * 예전 탐색을 그대로 한다 — 최적화가 자산을 깨뜨리지 않는다는 것이 이 되돌림의 목적이다.

+ */ + private HttpResponse fetchForGet(PreviewSessionInfo session, String path, String query, + ProxiedRequest request) + throws java.io.IOException, InterruptedException { + // 조건부 헤더(If-None-Match 등)는 자산 경로에만 넘긴다 — 문서는 재작성해서 내보내므로 업스트림의 + // 검증자와 우리가 준 본문이 같은 것을 가리키지 않고, 애초에 문서는 no-store 라 캐시가 없다(7-2). + ProxiedRequest asset = looksLikeStaticAsset(path) ? request : request.withoutConditions(); + Integer remembered = absorbedDepth.get(session.sessionId()); + if (remembered != null && looksLikeStaticAsset(path)) { + String shortcut = stripLeadingSegments(path, remembered); + if (shortcut != null) { + HttpResponse response = fetch(session, shortcut, query, asset); + if (!isHtml(response)) { + return response; // 기억한 단수로 한 번에 맞았다 — 왕복 1 회 + } + closeQuietly(response); + absorbedDepth.remove(session.sessionId()); + } + } + return absorbBuildBasePath(session, path, query, fetch(session, path, query, asset)); + } + + /** 선행 세그먼트 {@code count} 개를 벗긴다. 그만큼 벗길 수 없으면 null. */ + private String stripLeadingSegments(String path, int count) { + String candidate = path; + for (int i = 0; i < count; i++) { + int slash = candidate.indexOf('/'); + if (slash < 0 || slash == candidate.length() - 1) { + return null; + } + candidate = candidate.substring(slash + 1); + } + return candidate; + } + + /** + * 세션은 TTL 로 사라지지만 이 맵은 그 소멸을 알지 못한다. 상한에 닿으면 통째로 비운다 — 잃는 + * 것은 "다음 자산 한 번의 추가 왕복"뿐이고, 그 대가로 무한 성장을 막는다. base 를 쓰지 않는 + * 프로젝트(대다수)는 애초에 여기 들어오지 않으므로 실제 크기는 훨씬 작다. + */ + private void rememberAbsorbedDepth(PreviewSessionInfo session, int depth) { + if (absorbedDepth.size() >= ABSORB_MEMO_CAPACITY) { + absorbedDepth.clear(); + } + absorbedDepth.put(session.sessionId(), depth); } /** @@ -303,10 +502,10 @@ private HttpResponse fetch(String method, PreviewSessionInfo session, St *

재시도가 실패하면 원래 응답을 그대로 돌려준다. 앱이 의도적으로 확장자 경로에서 HTML을 * 내보내는 경우(SPA가 처리하는 가짜 경로 등)에도 동작이 달라지지 않는다.

*/ - private HttpResponse absorbBuildBasePath(PreviewSessionInfo session, - String path, - String query, - HttpResponse original) + private HttpResponse absorbBuildBasePath(PreviewSessionInfo session, + String path, + String query, + HttpResponse original) throws java.io.IOException, InterruptedException { if (!looksLikeStaticAsset(path) || !isHtml(original)) { return original; @@ -320,22 +519,92 @@ private HttpResponse absorbBuildBasePath(PreviewSessionInfo session, return original; } candidate = candidate.substring(slash + 1); - HttpResponse retried = fetch(session, candidate, query); + HttpResponse retried = fetch(session, candidate, query, ProxiedRequest.get()); if (!isHtml(retried)) { - log.info("[PreviewGateway] 빌드 base 흡수: {} -> {}", path, candidate); + log.info("[PreviewGateway] 빌드 base 흡수: {} -> {} (단수 {})", path, candidate, depth + 1); + rememberAbsorbedDepth(session, depth + 1); + closeQuietly(original); return retried; } + closeQuietly(retried); } return original; } + /** + * 자산 응답의 캐시 정책 (Issue #342, 7-2). + * + *

{@code private} 가 이 기능의 안전 조건이다. 프리뷰 주소의 accessToken 은 소유자가 + * 프리뷰를 다시 열 때마다 회전하고, 회전의 목적은 "흘러나간 주소가 곧 죽는 것"이다. 응답이 + * {@code public} 이면 앞단 CDN 같은 공유 캐시가 그 응답을 담아 원본을 거치지 않고 남에게 + * 내주게 되고, 그러면 회전해도 캐시가 만료될 때까지 예전 주소가 계속 열린다 — 회전이 무력화되는 + * 유일한 경로가 그것이라, 여기서는 어떤 분기에서도 {@code public} 을 쓰지 않는다.

+ * + *

브라우저 캐시만 남는 것은 회전을 약화시키지 않는다. 캐시 키가 토큰이 든 전체 URL 이므로 + * 회전 후의 문서는 새 토큰 주소를 참조해 캐시 미스가 되고(다시 인가를 받는다), 예전 주소의 + * 캐시는 그 브라우저가 이미 유효한 토큰으로 받아간 바이트일 뿐 새로 읽을 수 있는 것이 + * 늘지 않는다.

+ * + *

200·304 가 아니면 캐시하지 않는다 — 404·502 를 캐시에 남기면 컨테이너가 되살아난 뒤에도 + * 깨진 화면이 유지된다.

+ */ + private String cacheControlFor(int status, String path) { + if (status != HttpStatus.OK.value() && status != HttpStatus.NOT_MODIFIED.value()) { + return "no-store"; + } + return isContentHashedAsset(path) ? IMMUTABLE_ASSET_CACHE : REVALIDATED_ASSET_CACHE; + } + + /** + * 내용 해시가 파일명에 박힌 자산인지 — 이때만 장기 캐시한다. + * + *

판별을 좁게 잡는다. 확장자가 번들러 산출물의 것이어야 하고, 구분자({@code -} 또는 {@code .}) + * 뒤 확장자까지가 16 진수 8 자 이상이어야 하며, 그 안에 a~f 글자가 하나라도 있어야 한다. + * 마지막 조건이 날짜를 걸러낸다 — {@code photo-20260911.jpg} 의 "20260911" 도 16 진수 8 자라, + * 이 조건이 없으면 사람이 붙인 이름이 불변으로 취급돼 파일을 갈아끼워도 한 시간 동안 예전 것이 + * 보인다. 진짜 내용 해시가 8 자 모두 숫자일 확률은 2% 남짓이고, 걸러져도 아래 재검증 경로로 + * 가므로 손해가 없다 — 애매하면 캐시하지 않는 쪽이 이 판별의 기본값이다.

+ */ + private boolean isContentHashedAsset(String path) { + String segment = lastSegment(path); + int dot = segment.lastIndexOf('.'); + if (dot <= 0) { + return false; + } + if (!IMMUTABLE_ASSET_EXTENSIONS.contains( + segment.substring(dot + 1).toLowerCase(java.util.Locale.ROOT))) { + return false; + } + int separator = Math.max(segment.lastIndexOf('-', dot), segment.lastIndexOf('.', dot - 1)); + if (separator <= 0) { + return false; + } + String hash = segment.substring(separator + 1, dot); + if (hash.length() < 8) { + return false; + } + boolean hasHexLetter = false; + for (int i = 0; i < hash.length(); i++) { + int digit = Character.digit(hash.charAt(i), 16); + if (digit < 0) { + return false; + } + hasHexLetter |= digit > 9; + } + return hasHexLetter; + } + + private String lastSegment(String path) { + int lastSlash = path.lastIndexOf('/'); + return lastSlash < 0 ? path : path.substring(lastSlash + 1); + } + /** * 마지막 세그먼트에 확장자가 있고 그것이 HTML이 아니면 정적 자산 요청으로 본다. * SPA 라우트({@code /todos/42})는 확장자가 없어 걸리지 않으므로 fallback 동작을 건드리지 않는다. */ private boolean looksLikeStaticAsset(String path) { - int lastSlash = path.lastIndexOf('/'); - String lastSegment = lastSlash < 0 ? path : path.substring(lastSlash + 1); + String lastSegment = lastSegment(path); int dot = lastSegment.lastIndexOf('.'); if (dot <= 0 || dot == lastSegment.length() - 1) { return false; @@ -344,7 +613,7 @@ private boolean looksLikeStaticAsset(String path) { return !extension.equals("html") && !extension.equals("htm"); } - private boolean isHtml(HttpResponse response) { + private boolean isHtml(HttpResponse response) { return response.headers() .firstValue(HttpHeaders.CONTENT_TYPE) .filter(type -> type.contains(MediaType.TEXT_HTML_VALUE)) diff --git a/src/main/java/com/example/dvely/preview/application/service/PreviewSessionService.java b/src/main/java/com/example/dvely/preview/application/service/PreviewSessionService.java index 720c2e0a..7737ffdc 100644 --- a/src/main/java/com/example/dvely/preview/application/service/PreviewSessionService.java +++ b/src/main/java/com/example/dvely/preview/application/service/PreviewSessionService.java @@ -30,6 +30,12 @@ @RequiredArgsConstructor public class PreviewSessionService implements DeadPreviewSessionReclaimer { + /** + * 게이트웨이 접근의 만료 연장을 이 간격으로 묶는다 (Issue #342, 7-1). 프리뷰 페이지 한 번의 + * 로드가 자산 수만큼 같은 행을 UPDATE 하던 것을 이 간격당 한 번으로 줄인다. + */ + private static final Duration TOUCH_THROTTLE = Duration.ofSeconds(60); + private final SpringDataPreviewSessionRepository repository; private final DockerContainerService dockerService; private final TaskStore taskStore; @@ -246,6 +252,19 @@ public PreviewAccessGrant grantAccess(String sessionId, Long ownerUserId, Durati ); } + /** + * 게이트웨이가 요청마다 부르는 세션 조회. + * + *

여기서 엔티티를 고치지 않는다는 것이 7-1 의 핵심이다(Issue #342). 예전에는 조회한 + * 엔티티를 {@code touch} 로 고쳐 {@code save} 했고, 그 결과 프리뷰 페이지가 끌어오는 자산 + * 하나하나(JS/CSS/이미지)마다 이 행에 더티 체크 UPDATE 와 쓰기 락이 걸렸다 — 자산이 N 개인 + * 페이지 한 번에 UPDATE N 번이다. 이제 갱신은 {@link #touchThrottled} 가 스로틀을 통과할 때만 + * 단일 UPDATE 로 나간다.

+ * + *

세션 조회 자체는 캐시하지 않는다. accessToken 은 소유자가 프리뷰를 다시 열 때마다 + * 회전하고(이전 주소는 그 순간 404) 그 판정이 이 조회다. 캐시를 두면 회전이 다음 만료까지 미뤄져 + * 유출된 주소의 수명을 늘리게 된다 — 줄일 수 있는 것은 쓰기뿐이다.

+ */ @Transactional public Optional resolveGateway(String sessionId, String accessToken) { return repository.findByIdAndAccessTokenAndStatus( @@ -254,8 +273,13 @@ public Optional resolveGateway(String sessionId, String acce PreviewSessionStatus.ACTIVE.name() ) .filter(session -> session.getExpiresAt().isAfter(LocalDateTime.now())) - .map(this::touch) - .map(PreviewSessionEntity::toInfo); + .map(session -> { + touchThrottled(session); + // 갱신을 벌크 UPDATE 로 보냈으므로 이 엔티티의 expiresAt 은 갱신 전 값이다. + // 게이트웨이는 sessionId·ownerUserId·hostPort 만 쓰므로 문제가 없고, 만료를 + // 응답에 싣는 경로(findCurrent·grantAccess)는 각자 따로 읽는다. + return session.toInfo(); + }); } /** @@ -344,11 +368,36 @@ public void cleanupExpired() { * 12:43 — 30분 뒤였다).

*/ private PreviewSessionEntity touch(PreviewSessionEntity session) { - LocalDateTime next = nextExpiry(); - session.touch(next.isAfter(session.getExpiresAt()) ? next : session.getExpiresAt()); + session.touch(keepFurther(nextExpiry(), session)); return repository.save(session); } + /** + * 게이트웨이 접근의 만료 연장 — {@link #TOUCH_THROTTLE} 안에 이미 갱신됐으면 건너뛴다 (7-1). + * + *

"문서 탐색({@code Sec-Fetch-Dest})일 때만 갱신" 대신 시간 스로틀을 고른 이유는 두 가지다. + * 하나는 동작 보존이다 — 문서 탐색만 갱신하면, 열어둔 프리뷰가 XHR/SSE 로만 계속 쓰이는 + * 동안에는 연장이 끊겨 사용 중인 세션이 만료된다. 다른 하나는 보안 경계다: {@code + * Sec-Fetch-Dest} 는 게이트웨이의 인가 판정(문서 탐색에만 소유권 쿠키 요구)이 쓰는 신호이므로, + * 세션 계층이 같은 헤더를 갱신 정책에 쓰기 시작하면 두 판정이 한 입력에 얽힌다.

+ * + *

TTL 은 30 분이므로 60 초 스로틀이 실제로 깎는 연장은 최대 60 초다. 그 대가로 자산 N 개의 + * UPDATE N 번이 60 초당 1 번이 된다.

+ */ + private void touchThrottled(PreviewSessionEntity session) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime staleBefore = now.minus(TOUCH_THROTTLE); + if (!session.getLastAccessedAt().isBefore(staleBefore)) { + return; + } + repository.touchAccess(session.getId(), now, keepFurther(nextExpiry(), session), staleBefore); + } + + /** 이미 걸려 있는 만료가 더 멀면 그것을 유지한다(유예 보존). */ + private static LocalDateTime keepFurther(LocalDateTime next, PreviewSessionEntity session) { + return next.isAfter(session.getExpiresAt()) ? next : session.getExpiresAt(); + } + /** * 컨테이너 제거를 저장보다 먼저 한다. 예전에는 저장이 먼저였고, 제거가 실패하면 * 트랜잭션 롤백이 상태 변경까지 되돌려 세션이 ACTIVE 로 남아 다음 기회에 다시 회수됐다. diff --git a/src/main/java/com/example/dvely/preview/infrastructure/persistence/repository/SpringDataPreviewSessionRepository.java b/src/main/java/com/example/dvely/preview/infrastructure/persistence/repository/SpringDataPreviewSessionRepository.java index 17c7836f..448046d7 100644 --- a/src/main/java/com/example/dvely/preview/infrastructure/persistence/repository/SpringDataPreviewSessionRepository.java +++ b/src/main/java/com/example/dvely/preview/infrastructure/persistence/repository/SpringDataPreviewSessionRepository.java @@ -6,6 +6,9 @@ import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface SpringDataPreviewSessionRepository extends JpaRepository { @@ -45,4 +48,30 @@ Optional findFirstByProjectIdAndOwnerUserIdAndStatusInOrde // 하나만 남기고 나머지는 스스로 물러난다. ProjectPreviewService#provision 참고. List findByProjectIdAndOwnerUserIdAndStatusIn( Long projectId, Long ownerUserId, Collection statuses); + + /** + * 게이트웨이 접근 흔적만 갱신하는 단일 UPDATE (Issue #342, 7-1). + * + *

엔티티를 고쳐 {@code save} 하는 대신 UPDATE 한 문장을 쓰는 이유는 호출 빈도다. 이 + * 갱신은 프리뷰 페이지가 끌어오는 자산 하나하나마다 불린다 — 엔티티 경로는 그 요청마다 더티 체크 + * UPDATE 와 쓰기 락을 만들었다.

+ * + *

{@code lastAccessedAt < :staleBefore} 조건이 스로틀 자체다. 호출부도 같은 조건을 + * 미리 보고 대부분을 걸러내지만, 같은 페이지의 자산 요청 여럿이 동시에 같은 낡은 행을 읽었을 때는 + * 그 확인이 전부 통과한다. MySQL 의 UPDATE 는 현재 커밋 값을 다시 읽으므로 그 중 먼저 커밋한 + * 하나만 조건에 맞고 나머지는 0 행으로 끝난다.

+ * + *

{@code expiresAt} 은 호출부가 이미 정한 값을 그대로 받는다 — 유예({@code + * holdForBindingApproval})가 준 더 먼 만료를 앞당기지 않기 위한 비교는 호출부에 남는다. + * {@code updatedAt} 은 명시적으로 넣는다: 벌크 UPDATE 는 {@code @UpdateTimestamp} 를 거치지 + * 않으므로 안 넣으면 이 경로에서만 갱신 시각이 멈춘다.

+ */ + @Modifying(flushAutomatically = false, clearAutomatically = false) + @Query("update PreviewSessionEntity s " + + "set s.lastAccessedAt = :now, s.updatedAt = :now, s.expiresAt = :expiresAt " + + "where s.id = :sessionId and s.lastAccessedAt < :staleBefore") + int touchAccess(@Param("sessionId") String sessionId, + @Param("now") LocalDateTime now, + @Param("expiresAt") LocalDateTime expiresAt, + @Param("staleBefore") LocalDateTime staleBefore); } diff --git a/src/main/java/com/example/dvely/preview/presentation/PreviewGatewayController.java b/src/main/java/com/example/dvely/preview/presentation/PreviewGatewayController.java index 3dbdf5ba..dd5b6c25 100644 --- a/src/main/java/com/example/dvely/preview/presentation/PreviewGatewayController.java +++ b/src/main/java/com/example/dvely/preview/presentation/PreviewGatewayController.java @@ -13,6 +13,8 @@ import java.util.Locale; import java.util.Set; import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -33,6 +35,12 @@ public class PreviewGatewayController { static final String SEC_FETCH_DEST = "Sec-Fetch-Dest"; + /** + * 요청 본문 상한 (Issue #342, 7-3). 프리뷰 앱의 폼·업로드가 쓰기에 충분하면서, 컨테이너 메모리 + * 상한(1 GiB)에 비해 작다. + */ + private static final int MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024; + /** 문서를 "여는" 요청들 — iframe/새 탭 진입과 그 변종. 여기에만 소유권 쿠키를 요구한다. */ private static final Set NAVIGATION_DESTS = Set.of("document", "iframe", "frame", "embed", "object"); @@ -56,7 +64,7 @@ public class PreviewGatewayController { "/api/v1/previews/{sessionId}/{accessToken}", "/api/v1/previews/{sessionId}/{accessToken}/**" }) - public ResponseEntity proxy( + public ResponseEntity proxy( @Parameter(description = "Preview 세션 ID") @PathVariable String sessionId, @Parameter(description = "세션 발급 시 함께 생성된 1회성 접근 토큰(랜덤 UUID)") @PathVariable String accessToken, @CookieValue(name = PreviewAccessCookies.COOKIE_NAME, required = false) String accessCookie, @@ -75,21 +83,56 @@ public ResponseEntity proxy( // 본문을 다 읽지 못하면(클라이언트 중단 등) 400 — 컨테이너로 반쪽 요청을 보내지 않는다. byte[] body; try { - body = request.getInputStream().readAllBytes(); + body = readBoundedBody(request); + } catch (RequestBodyTooLargeException e) { + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).build(); } catch (java.io.IOException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); } return previewGatewayService.proxy( - request.getMethod(), session, prefix, path, request.getQueryString(), - body, - request.getContentType() + new PreviewGatewayService.ProxiedRequest( + request.getMethod(), body, request.getContentType(), + // 브라우저의 조건부 요청을 안쪽 앱까지 전달한다 — 자산이 안 바뀌었으면 304 로 + // 끝나 본문이 흐르지 않는다(Issue #342, 7-2). 인가는 예전과 똑같이 매 요청 돈다. + request.getHeader(HttpHeaders.IF_NONE_MATCH), + request.getHeader(HttpHeaders.IF_MODIFIED_SINCE)) ); } + /** + * 요청 본문에 상한을 둔다 (Issue #342, 7-3). + * + *

예전에는 {@code readAllBytes()} 로 무제한으로 읽었다. 이 경로는 서브리소스 요청에 소유권 + * 쿠키를 요구하지 않으므로(회전 accessToken 이 든 URL 자체가 자격 — {@link #isAuthorized} 참고) + * 유효한 프리뷰 주소 하나만 쥐면 로그인 없이 임의 크기의 POST 를 보낼 수 있었고, 그 본문이 + * 곧 힙이다. 상한을 넘으면 413 으로 끊는다.

+ * + *

{@code Content-Length} 를 먼저 보되 그것만 믿지는 않는다 — 청크 전송은 길이를 안 싣고, 실린 + * 값이 사실이라는 보장도 없다. 그래서 실제로 읽는 양도 상한+1 로 끊는다. 상한은 프리뷰 앱의 폼· + * 업로드가 쓰기에 충분하고(10 MiB), 컨테이너 메모리 상한(1 GiB)에 비해 작다.

+ */ + private byte[] readBoundedBody(HttpServletRequest request) throws java.io.IOException { + if (request.getContentLengthLong() > MAX_REQUEST_BODY_BYTES) { + throw new RequestBodyTooLargeException(); + } + byte[] body = request.getInputStream().readNBytes(MAX_REQUEST_BODY_BYTES + 1); + if (body.length > MAX_REQUEST_BODY_BYTES) { + throw new RequestBodyTooLargeException(); + } + return body; + } + + /** 413 으로 갈라 나가기 위한 내부 신호. 밖으로 나가지 않으므로 스택트레이스를 만들지 않는다. */ + private static class RequestBodyTooLargeException extends RuntimeException { + RequestBodyTooLargeException() { + super(null, null, false, false); + } + } + @Operation( summary = "Preview 컨테이너 SSE 스트리밍 프록시", description = "EventSource(text/event-stream) 요청을 스트리밍으로 프록시합니다. 버퍼링 프록시(위 proxy)는 " + diff --git a/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServiceTest.java b/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServiceTest.java index b123f0a5..1b7ba3f7 100644 --- a/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServiceTest.java +++ b/src/test/java/com/example/dvely/agent/infrastructure/docker/DockerContainerServiceTest.java @@ -20,6 +20,7 @@ import com.github.dockerjava.api.command.CreateNetworkCmd; import com.github.dockerjava.api.command.InspectContainerCmd; import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.command.InspectImageCmd; import com.github.dockerjava.api.command.InspectNetworkCmd; import com.github.dockerjava.api.command.ListNetworksCmd; import com.github.dockerjava.api.command.LogContainerCmd; @@ -36,6 +37,7 @@ import com.github.dockerjava.api.model.ExposedPort; import com.github.dockerjava.api.model.Frame; import com.github.dockerjava.api.model.HostConfig; +import com.github.dockerjava.api.model.LogConfig; import com.github.dockerjava.api.model.MemoryStatsConfig; import com.github.dockerjava.api.model.Network; import com.github.dockerjava.api.model.NetworkSettings; @@ -311,6 +313,75 @@ void createAndStartContainerIgnoresConcurrentNetworkCreateConflict() { verify(dockerClient).startContainerCmd("container-1"); } + // --- Issue #342 7-5: 로컬에 있는 이미지는 pull 하지 않는다 --------------------------- + + /** + * 예전에는 컨테이너를 만들 때마다 조건 없이 pull 했다. 이미 있는 이미지에도 레지스트리 왕복을 + * 하고, 레지스트리가 느리면 컨테이너 생성이 최대 3 분을 기다린다 — 프리뷰를 띄우는 사용자가 그 + * 시간을 그대로 본다. 선확인이 성공하면 pull 이 아예 나가지 않아야 한다. + */ + @Test + void createAndStartContainerSkipsThePullWhenTheImageIsAlreadyLocal() { + mockNetworkAlreadyExists(true); + when(dockerClient.inspectImageCmd(anyString())).thenReturn(mock(InspectImageCmd.class)); + mockContainerCreation(); + + service.createAndStartContainer(1L, "session-1", 11L, 21L, "task-1"); + + verify(dockerClient, never()).pullImageCmd(anyString()); + } + + /** + * 반대로 없으면 받아온다 — 이 이미지는 공개 베이스(node:20-alpine)라 첫 기동에 pull 하는 것이 + * 정상 경로다(로컬 빌드 전용인 코딩 에이전트 이미지와 다른 점). + */ + @Test + void createAndStartContainerStillPullsWhenTheImageIsMissing() { + mockNetworkAlreadyExists(true); + InspectImageCmd inspect = mock(InspectImageCmd.class); + when(dockerClient.inspectImageCmd(anyString())).thenReturn(inspect); + when(inspect.exec()).thenThrow(new NotFoundException("no such image")); + mockContainerCreation(); + + service.createAndStartContainer(1L, "session-1", 11L, 21L, "task-1"); + + verify(dockerClient).pullImageCmd(anyString()); + } + + // --- Issue #342 7-6: 컨테이너 로그에 상한이 있다 ------------------------------------- + + /** + * 로그 드라이버에 상한이 없으면 dev 서버 stdout 이 TTL 동안 무제한으로 쌓인다 — 사용자 코드가 + * 루프에서 찍는 로그 한 줄이 우리 호스트 디스크를 채우는 경로다. 드라이버까지 json-file 로 못 + * 박아야 옵션이 조용히 무시되지 않는다. + */ + @Test + void createAndStartContainerBoundsTheContainerLogSize() { + mockNetworkAlreadyExists(true); + mockContainerCreation(); + + service.createAndStartContainer(1L, "session-1", 11L, 21L, "task-1"); + + ArgumentCaptor hostConfigCaptor = ArgumentCaptor.forClass(HostConfig.class); + verify(createCommand).withHostConfig(hostConfigCaptor.capture()); + LogConfig logConfig = hostConfigCaptor.getValue().getLogConfig(); + assertThat(logConfig).isNotNull(); + assertThat(logConfig.getType()).isEqualTo(LogConfig.LoggingType.JSON_FILE); + assertThat(logConfig.getConfig()).containsEntry("max-size", "10m").containsEntry("max-file", "2"); + } + + /** 컨테이너 생성·기동 스텁. 위 세 테스트가 captor 로 쓰는 createCommand 를 남긴다. */ + private CreateContainerCmd createCommand; + + private void mockContainerCreation() { + createCommand = mock(CreateContainerCmd.class, RETURNS_SELF); + CreateContainerResponse createResponse = mock(CreateContainerResponse.class); + when(dockerClient.createContainerCmd(anyString())).thenReturn(createCommand); + when(createCommand.exec()).thenReturn(createResponse); + when(createResponse.getId()).thenReturn("container-1"); + when(dockerClient.startContainerCmd("container-1")).thenReturn(mock(StartContainerCmd.class)); + } + /** * @param iccDisabled whether the mocked inspect response reports the isolation option as * actually applied; the exact-match network is always found either way. diff --git a/src/test/java/com/example/dvely/preview/PreviewGatewayTouchWriteIntegrationTest.java b/src/test/java/com/example/dvely/preview/PreviewGatewayTouchWriteIntegrationTest.java new file mode 100644 index 00000000..e65c4362 --- /dev/null +++ b/src/test/java/com/example/dvely/preview/PreviewGatewayTouchWriteIntegrationTest.java @@ -0,0 +1,127 @@ +package com.example.dvely.preview; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.dvely.preview.application.service.PreviewSessionService; +import java.time.LocalDateTime; +import java.util.UUID; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * 7-1 (Issue #342) 의 실측 가드 — 프리뷰 페이지 1 회 로드가 {@code preview_sessions} 에 + * 보내는 UPDATE 횟수를 실제 MySQL 에서 센다. + * + *

왜 단위 테스트로 부족한가: 예전 경로의 비용은 "엔티티를 고쳐 {@code save} 했다"가 아니라 + * 그 결과로 나간 SQL 이다. 모킹된 리포지토리는 그 SQL 을 보여주지 않는다. 그래서 + * {@code performance_schema} 의 문장 다이제스트 카운터를 전/후로 읽어 증가분을 센다.

+ * + *

세는 범위를 {@code SCHEMA_NAME = DATABASE()} 로 좁힌다 — 이 MySQL 컨테이너는 병렬 워크트리가 + * 스키마만 달리해 공유하므로, 스키마를 안 좁히면 남의 테스트가 섞인다. 같은 스키마 안에서는 + * {@code cleanupExpired} 스케줄러가 남의 테스트가 남긴 만료 행을 정리하며 UPDATE 를 낼 수 있어, + * 단정은 "요청 수보다 현저히 적다"로 둔다(고치기 전에는 요청 수와 같았다).

+ */ +@SpringBootTest +class PreviewGatewayTouchWriteIntegrationTest { + + /** 문서 1 + 자산 19 — 평범한 Vite 빌드 한 페이지가 내는 요청 수와 같은 자릿수. */ + private static final int REQUESTS_PER_PAGE_LOAD = 20; + + @Autowired + private PreviewSessionService sessionService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void onePageLoadDoesNotWriteTheSessionRowOncePerAsset() { + Assumptions.assumeTrue(performanceSchemaAvailable(), "performance_schema 가 꺼져 있어 셀 수 없다"); + String sessionId = insertActiveSession(LocalDateTime.now()); + + long before = updateCount(); + for (int i = 0; i < REQUESTS_PER_PAGE_LOAD; i++) { + assertThat(sessionService.resolveGateway(sessionId, accessTokenOf(sessionId))).isPresent(); + } + long delta = updateCount() - before; + + // 고치기 전: 요청마다 1 번 → 20. 고친 뒤: 스로틀 안이므로 0(다른 테스트가 남긴 만료 행을 + // 스케줄러가 정리하며 내는 UPDATE 여유로 2 까지 허용한다). + assertThat(delta).isLessThanOrEqualTo(2); + } + + /** 스로틀을 넘긴 첫 접근은 여전히 갱신한다 — 만료 연장이 죽어서는 안 된다. */ + @Test + void anAccessAfterTheThrottleWindowStillWritesExactlyOnce() { + Assumptions.assumeTrue(performanceSchemaAvailable(), "performance_schema 가 꺼져 있어 셀 수 없다"); + String sessionId = insertActiveSession(LocalDateTime.now().minusMinutes(5)); + + long before = updateCount(); + for (int i = 0; i < REQUESTS_PER_PAGE_LOAD; i++) { + sessionService.resolveGateway(sessionId, accessTokenOf(sessionId)); + } + long delta = updateCount() - before; + + // 첫 요청이 갱신하고 나머지는 스로틀에 걸린다. 1 이 기대값이고, 스케줄러 여유로 3 까지 본다. + assertThat(delta).isGreaterThanOrEqualTo(1).isLessThanOrEqualTo(3); + LocalDateTime lastAccessed = jdbcTemplate.queryForObject( + "select last_accessed_at from preview_sessions where preview_session_id = ?", + LocalDateTime.class, sessionId); + assertThat(lastAccessed).isAfter(LocalDateTime.now().minusMinutes(1)); + } + + private boolean performanceSchemaAvailable() { + try { + jdbcTemplate.queryForObject( + "select count(*) from performance_schema.events_statements_summary_by_digest", Long.class); + return true; + } catch (RuntimeException e) { + return false; + } + } + + /** 이 스키마에서 {@code preview_sessions} 를 고친 문장의 누적 실행 횟수. */ + private long updateCount() { + Long count = jdbcTemplate.queryForObject( + """ + select coalesce(sum(count_star), 0) + from performance_schema.events_statements_summary_by_digest + where schema_name = database() + and digest_text like 'UPDATE%' + and digest_text like '%preview_sessions%' + """, + Long.class); + return count == null ? 0L : count; + } + + private String accessTokenOf(String sessionId) { + return sessionId.replace("-", ""); + } + + /** + * FK(user_id) 만 채우고 project/chat/task 는 NULL 로 둔다 — 이 테스트가 보는 것은 게이트웨이 + * 조회가 내는 쓰기뿐이고, 그 경로는 이 컬럼들을 읽지 않는다. + */ + private String insertActiveSession(LocalDateTime lastAccessedAt) { + String githubUserId = "u7-touch-" + UUID.randomUUID(); + jdbcTemplate.update("insert into users (github_user_id, user_name) values (?, ?)", + githubUserId, "u7-touch"); + Long userId = jdbcTemplate.queryForObject( + "select user_id from users where github_user_id = ?", Long.class, githubUserId); + + String sessionId = UUID.randomUUID().toString(); + jdbcTemplate.update( + """ + insert into preview_sessions + (preview_session_id, access_token, user_id, container_id, host_port, + status, public_url, expires_at, last_accessed_at) + values (?, ?, ?, ?, ?, 'ACTIVE', ?, ?, ?) + """, + sessionId, accessTokenOf(sessionId), userId, "container-" + sessionId, 32768, + "https://qeploy.test/api/v1/previews/" + sessionId + "/" + accessTokenOf(sessionId) + "/", + LocalDateTime.now().plusMinutes(30), lastAccessedAt); + return sessionId; + } +} diff --git a/src/test/java/com/example/dvely/preview/application/service/PreviewGatewayServiceTest.java b/src/test/java/com/example/dvely/preview/application/service/PreviewGatewayServiceTest.java index 4f32a34d..8af96c8e 100644 --- a/src/test/java/com/example/dvely/preview/application/service/PreviewGatewayServiceTest.java +++ b/src/test/java/com/example/dvely/preview/application/service/PreviewGatewayServiceTest.java @@ -14,6 +14,9 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -29,6 +32,9 @@ class PreviewGatewayServiceTest { private HttpServer container; private PreviewGatewayService service; + // 컨테이너로 실제로 나간 요청 수 — base 흡수의 왕복 수를 세기 위한 것(7-4). + private final java.util.concurrent.atomic.AtomicInteger upstreamRequests = + new java.util.concurrent.atomic.AtomicInteger(); // 안쪽 앱 무응답으로 회수 요청된 sessionId 를 기록한다(게이트웨이가 부르는 reclaimer 대역). private final java.util.List reclaimed = new java.util.ArrayList<>(); @@ -36,6 +42,7 @@ class PreviewGatewayServiceTest { void startFakeContainer() throws IOException { container = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); container.createContext("/", exchange -> { + upstreamRequests.incrementAndGet(); byte[] body = "preview".getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, "text/html"); exchange.sendResponseHeaders(200, body.length); @@ -73,7 +80,7 @@ void configuredFrameAncestorsWidenFramingButNeverTheSandbox() { @Test void sandboxesThePreviewDocumentSoItCannotReachTheParentOrigin() { - ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null); + ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null); String policy = response.getHeaders().getFirst(PreviewGatewayService.CONTENT_SECURITY_POLICY); assertThat(policy).isNotNull(); @@ -88,7 +95,7 @@ void sandboxesThePreviewDocumentSoItCannotReachTheParentOrigin() { /** 스크립트가 도는 미리보기가 목적이므로 격리가 실행 자체를 막아서는 안 된다. */ @Test void stillAllowsTheScriptsAndFormsAPreviewNeeds() { - ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null); + ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null); String policy = response.getHeaders().getFirst(PreviewGatewayService.CONTENT_SECURITY_POLICY); assertThat(policy).contains("allow-scripts").contains("allow-forms").contains("allow-popups"); @@ -105,7 +112,7 @@ void appliesTheSamePolicyToNonHtmlAssets() { exchange.close(); }); - ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "app.js", null); + ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "app.js", null); assertThat(response.getHeaders().getFirst(PreviewGatewayService.CONTENT_SECURITY_POLICY)) .contains("sandbox"); @@ -118,15 +125,15 @@ void appliesTheSamePolicyToNonHtmlAssets() { * 백지가 됐다(Issue #111). 접두를 벗겨 다시 물어보는 것이 이 테스트가 지키는 계약이다. */ @Test - void absorbsTheBuildBasePathSoAssetsResolveToTheServedRoot() { + void absorbsTheBuildBasePathSoAssetsResolveToTheServedRoot() throws IOException { serveAsset("/assets/app.js", "application/javascript", "console.log(1)"); - ResponseEntity response = + ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "my-todo-app/assets/app.js", null); assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)) .contains("application/javascript"); - assertThat(new String(response.getBody(), StandardCharsets.UTF_8)).isEqualTo("console.log(1)"); + assertThat(bodyOf(response)).isEqualTo("console.log(1)"); } /** @@ -139,15 +146,14 @@ void absorbsTheBuildBasePathSoAssetsResolveToTheServedRoot() { void tellsTheCdnNotToInjectIntoThePreviewDocument() { serveAsset("/assets/app.js", "application/javascript", "console.log(1)"); - ResponseEntity document = service.proxy(session(), "/api/v1/previews/s/t/", "", null); - ResponseEntity asset = + ResponseEntity document = service.proxy(session(), "/api/v1/previews/s/t/", "", null); + ResponseEntity asset = service.proxy(session(), "/api/v1/previews/s/t/", "assets/app.js", null); assertThat(document.getHeaders().getCacheControl()).contains("no-transform"); assertThat(asset.getHeaders().getCacheControl()).doesNotContain("no-transform"); - // 캐시 금지는 두 경우 모두 유지된다. + // 문서는 여전히 아예 담지 않는다(7-2 가 자산만 캐시 가능하게 했다). assertThat(document.getHeaders().getCacheControl()).contains("no-store"); - assertThat(asset.getHeaders().getCacheControl()).contains("no-store"); } /** 루트 자산(favicon 등)도 같은 경로로 살아난다. */ @@ -155,7 +161,7 @@ void tellsTheCdnNotToInjectIntoThePreviewDocument() { void absorbsTheBasePathForRootLevelAssetsToo() { serveAsset("/favicon.svg", "image/svg+xml", ""); - ResponseEntity response = + ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "my-todo-app/favicon.svg", null); assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)).contains("image/svg+xml"); @@ -163,13 +169,13 @@ void absorbsTheBasePathForRootLevelAssetsToo() { /** base 가 없는 프로젝트(대다수)는 첫 요청에서 끝나야 한다 — 추가 왕복도, 경로 변형도 없다. */ @Test - void leavesAssetsThatAlreadyResolveUntouched() { + void leavesAssetsThatAlreadyResolveUntouched() throws IOException { serveAsset("/assets/app.js", "application/javascript", "console.log(1)"); - ResponseEntity response = + ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "assets/app.js", null); - assertThat(new String(response.getBody(), StandardCharsets.UTF_8)).isEqualTo("console.log(1)"); + assertThat(bodyOf(response)).isEqualTo("console.log(1)"); } /** @@ -178,7 +184,7 @@ void leavesAssetsThatAlreadyResolveUntouched() { */ @Test void keepsTheSpaFallbackForRoutesThatAreNotAssets() { - ResponseEntity response = + ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "todos/42", null); assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)).contains("text/html"); @@ -187,7 +193,7 @@ void keepsTheSpaFallbackForRoutesThatAreNotAssets() { /** 접두를 벗겨도 없는 자산은 원래 응답을 그대로 돌려준다 — 경로를 무한히 깎지 않는다. */ @Test void fallsBackToTheOriginalResponseWhenStrippingDoesNotHelp() { - ResponseEntity response = + ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "a/b/c/missing.js", null); assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE)).contains("text/html"); @@ -201,7 +207,7 @@ void fallsBackToTheOriginalResponseWhenStrippingDoesNotHelp() { */ @Test void doesNotSetItsOwnCorsHeaderBecauseTheCorsFilterOwnsIt() { - ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null); + ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null); assertThat(response.getHeaders().getAccessControlAllowOrigin()).isNull(); } @@ -221,7 +227,7 @@ void reclaimsTheSessionWhenTheInnerAppIsUnreachable() throws IOException { "session-dead", 1L, 11L, null, null, "container-dead", closedPort, "https://qeploy.com/api/v1/previews/session-dead/token/", LocalDateTime.now().plusMinutes(30)); - ResponseEntity response = service.proxy(dead, "/api/v1/previews/s/t/", "", null); + ResponseEntity response = service.proxy(dead, "/api/v1/previews/s/t/", "", null); assertThat(response.getStatusCode().value()).isEqualTo(502); assertThat(reclaimed).containsExactly("session-dead"); // 안쪽 앱 死 → 세션 회수 요청 @@ -242,7 +248,7 @@ void reclaimDisabled_returns502ButNeverReclaims() throws IOException { "session-dead", 1L, 11L, null, null, "container-dead", closedPort, "https://qeploy.com/api/v1/previews/session-dead/token/", LocalDateTime.now().plusMinutes(30)); - ResponseEntity response = disabled.proxy(dead, "/api/v1/previews/s/t/", "", null); + ResponseEntity response = disabled.proxy(dead, "/api/v1/previews/s/t/", "", null); assertThat(response.getStatusCode().value()).isEqualTo(502); assertThat(reclaimed).isEmpty(); // 킬스위치 off — 회수 안 함 @@ -261,10 +267,10 @@ void doesNotReclaimAHealthySession() { * HTML 에 fetch/XHR 를 감싸 그 요청을 프리뷰 prefix 아래로 재작성하는 shim 이 주입돼야 데이터가 앱에 닿는다. */ @Test - void injectsApiPathShimIntoHtmlSoRootAbsoluteApiCallsReachTheApp() { - ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null); + void injectsApiPathShimIntoHtmlSoRootAbsoluteApiCallsReachTheApp() throws IOException { + ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null); - String html = new String(response.getBody(), StandardCharsets.UTF_8); + String html = bodyOf(response); assertThat(html).contains("window.fetch"); // fetch 래핑 assertThat(html).contains("XMLHttpRequest.prototype.open"); // XHR 래핑(axios 등) assertThat(html).contains("/api/v1/previews/s/t"); // prefix(슬래시 뺀)가 shim 에 박힘 @@ -278,10 +284,10 @@ void injectsApiPathShimIntoHtmlSoRootAbsoluteApiCallsReachTheApp() { * 문서에 주입된다는 계약을 회귀 가드로 고정한다. */ @Test - void injectsNavigationShimSoRootAbsoluteLinksAndFormsStayInThePrefix() { - ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null); + void injectsNavigationShimSoRootAbsoluteLinksAndFormsStayInThePrefix() throws IOException { + ResponseEntity response = service.proxy(session(), "/api/v1/previews/s/t/", "", null); - String html = new String(response.getBody(), StandardCharsets.UTF_8); + String html = bodyOf(response); assertThat(html).contains("addEventListener(\"click\",fixA,true)"); // 앵커 클릭 가로채기 assertThat(html).contains("addEventListener(\"auxclick\",fixA,true)"); // 중클릭(새 탭)도 assertThat(html).contains("addEventListener(\"submit\""); // 폼 action 가로채기 @@ -294,7 +300,7 @@ void injectsNavigationShimSoRootAbsoluteLinksAndFormsStayInThePrefix() { * 프리뷰에서 동작하려면 필요하다. 앱이 method 와 본문을 되돌려주는 엔드포인트로 왕복을 확인한다. */ @Test - void proxiesWriteMethodsWithTheirBodyToTheApp() { + void proxiesWriteMethodsWithTheirBodyToTheApp() throws IOException { container.createContext("/api/entries", exchange -> { byte[] in = exchange.getRequestBody().readAllBytes(); String out = "{\"method\":\"" + exchange.getRequestMethod() + "\",\"echo\":" @@ -307,11 +313,12 @@ void proxiesWriteMethodsWithTheirBodyToTheApp() { }); byte[] reqBody = "{\"name\":\"a\",\"message\":\"hi\"}".getBytes(StandardCharsets.UTF_8); - ResponseEntity response = service.proxy( - "POST", session(), "/api/v1/previews/s/t/", "api/entries", null, reqBody, "application/json"); + ResponseEntity response = service.proxy( + session(), "/api/v1/previews/s/t/", "api/entries", null, + new PreviewGatewayService.ProxiedRequest("POST", reqBody, "application/json", null, null)); assertThat(response.getStatusCode().value()).isEqualTo(201); // 상태 그대로 - String body = new String(response.getBody(), StandardCharsets.UTF_8); + String body = bodyOf(response); assertThat(body).contains("\"method\":\"POST\""); // 메서드 그대로 전달 assertThat(body).contains("\"name\":\"a\""); // 본문 그대로 전달 } @@ -348,9 +355,241 @@ void streamsServerSentEventsWithTheStreamingContract() throws Exception { assertThat(streamed).contains("data: one").contains("data: two"); } + // ── base 흡수 단수 기억 (Issue #342, 7-4) ───────────────────────────────────────── + + /** + * base 를 쓰는 프로젝트에서는 자산마다 "틀린 경로로 먼저 묻고 → 접두를 벗겨 다시 묻는" 탐색이 + * 반복됐다. 자산 수 × 최대 3 회의 컨테이너 왕복이다. 한 세션의 자산은 같은 빌드 산출물이라 + * base 도 하나이므로, 두 번째 자산부터는 한 번에 맞아야 한다. + */ + @Test + void remembersTheAbsorbedBaseSoLaterAssetsCostOneRoundTrip() throws IOException { + serveAsset("/assets/first.js", "application/javascript", "console.log(1)"); + serveAsset("/assets/second.js", "application/javascript", "console.log(2)"); + + service.proxy(session(), "/api/v1/previews/s/t/", "my-todo-app/assets/first.js", null); + int firstAssetRoundTrips = upstreamRequests.get(); + upstreamRequests.set(0); + ResponseEntity second = + service.proxy(session(), "/api/v1/previews/s/t/", "my-todo-app/assets/second.js", null); + + assertThat(firstAssetRoundTrips).isEqualTo(2); // 첫 자산: 틀린 경로 1 + 벗긴 경로 1 + assertThat(upstreamRequests.get()).isEqualTo(1); // 두 번째부터: 바로 맞는다 + assertThat(bodyOf(second)).isEqualTo("console.log(2)"); + } + + /** + * 기억이 틀린 경우(같은 세션에 base 가 다른 자산이 섞임)에도 자산이 살아나야 한다 — 최적화가 + * 동작을 바꾸지 않는다는 것이 이 되돌림의 목적이다. + */ + @Test + void fallsBackToTheSearchWhenTheRememberedDepthDoesNotFit() throws IOException { + serveAsset("/assets/first.js", "application/javascript", "console.log(1)"); + + service.proxy(session(), "/api/v1/previews/s/t/", "my-todo-app/assets/first.js", null); + // 이번에는 접두가 없는 경로 — 기억한 단수를 적용하면 어긋난다. + ResponseEntity direct = + service.proxy(session(), "/api/v1/previews/s/t/", "assets/first.js", null); + + assertThat(bodyOf(direct)).isEqualTo("console.log(1)"); + } + + // ── 자산 캐시 정책 (Issue #342, 7-2) ───────────────────────────────────────────── + + /** + * 이 프로젝트에서 캐시의 유일한 위험은 {@code public} 이다. 프리뷰 주소의 accessToken 은 + * 소유자가 다시 열 때마다 회전하고, 회전의 목적은 흘러나간 주소가 곧 죽는 것이다. 공유 캐시가 + * 응답을 담으면 원본을 거치지 않고 남에게 내주게 되어 그 회전이 무력화된다 — 어떤 분기에서도 + * {@code private} 여야 한다는 것이 이 테스트가 지키는 계약이다. + */ + @Test + void neverLetsAnySharedCacheStoreAPreviewResponse() { + serveAsset("/assets/index-a1b2c3d4.js", "application/javascript", "console.log(1)"); + serveAsset("/assets/app.js", "application/javascript", "console.log(2)"); + + String hashed = cacheControlOf("assets/index-a1b2c3d4.js"); + String plain = cacheControlOf("assets/app.js"); + String document = cacheControlOf(""); + + assertThat(hashed).contains("private").doesNotContain("public"); + assertThat(plain).contains("private").doesNotContain("public"); + // 문서는 아예 담지 않는다 — 회전 토큰이 든 prefix 를 본문에 박아 내보내기 때문이다. + assertThat(document).contains("no-store").doesNotContain("public"); + } + + /** 내용 해시가 박힌 자산만 장기 캐시한다. */ + @Test + void cachesContentHashedAssetsForALongTime() { + serveAsset("/assets/index-a1b2c3d4.js", "application/javascript", "console.log(1)"); + + assertThat(cacheControlOf("assets/index-a1b2c3d4.js")) + .isEqualTo("private, max-age=3600, immutable"); + } + + /** + * 해시로 보이지 않는 이름은 매번 원본에 물어본다 — 세션 조회·인가·토큰 회전 판정이 예전과 + * 똑같이 요청마다 돌고, 절약되는 것은 본문 전송뿐이다. + */ + @Test + void makesEverythingElseRevalidateOnEveryRequest() { + serveAsset("/assets/app.js", "application/javascript", "console.log(1)"); + + assertThat(cacheControlOf("assets/app.js")).isEqualTo("private, no-cache"); + } + + /** + * 날짜가 붙은 사람이 지은 이름을 해시로 오인하면 안 된다 — {@code 20260911} 도 16 진수 8 자라, + * 글자 조건이 없으면 파일을 갈아끼워도 한 시간 동안 예전 것이 보인다. + */ + @Test + void doesNotMistakeAHumanNamedFileForAContentHash() { + serveAsset("/img/photo-20260911.jpg", "image/jpeg", "x"); + serveAsset("/img/logo-v2.png", "image/png", "y"); + + assertThat(cacheControlOf("img/photo-20260911.jpg")).isEqualTo("private, no-cache"); + assertThat(cacheControlOf("img/logo-v2.png")).isEqualTo("private, no-cache"); + } + + /** 오류 응답은 캐시하지 않는다 — 404 를 담아두면 컨테이너가 되살아나도 깨진 화면이 유지된다. */ + @Test + void neverCachesAnErrorResponse() { + container.createContext("/assets/gone-a1b2c3d4.js", exchange -> { + upstreamRequests.incrementAndGet(); + exchange.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, "application/javascript"); + exchange.sendResponseHeaders(404, -1); + exchange.close(); + }); + + assertThat(cacheControlOf("assets/gone-a1b2c3d4.js")).isEqualTo("no-store"); + } + + /** + * 업스트림의 검증자를 넘겨주고, 브라우저가 그것을 되돌려주면 그대로 안쪽 앱에 물어본다. 신선도 + * 판정은 앱이 하고 게이트웨이는 추측하지 않는다 — 바뀌지 않았으면 304 로 본문이 흐르지 않는다. + */ + @Test + void passesValidatorsThroughSoAReloadTransfersNothing() { + container.createContext("/assets/app.js", exchange -> { + upstreamRequests.incrementAndGet(); + String inm = exchange.getRequestHeaders().getFirst(HttpHeaders.IF_NONE_MATCH); + exchange.getResponseHeaders().add(HttpHeaders.ETAG, "\"v1\""); + if ("\"v1\"".equals(inm)) { + exchange.sendResponseHeaders(304, -1); + exchange.close(); + return; + } + byte[] body = "console.log(1)".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, "application/javascript"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + + ResponseEntity first = + service.proxy(session(), "/api/v1/previews/s/t/", "assets/app.js", null); + ResponseEntity revalidated = service.proxy( + session(), "/api/v1/previews/s/t/", "assets/app.js", null, + new PreviewGatewayService.ProxiedRequest("GET", null, null, "\"v1\"", null)); + + assertThat(first.getHeaders().getETag()).isEqualTo("\"v1\""); + assertThat(revalidated.getStatusCode().value()).isEqualTo(304); + assertThat(revalidated.getBody()).isNull(); // 본문이 흐르지 않는다 + assertThat(revalidated.getHeaders().getETag()).isEqualTo("\"v1\""); + // 304 여도 격리는 그대로 붙는다. + assertThat(revalidated.getHeaders().getFirst(PreviewGatewayService.CONTENT_SECURITY_POLICY)) + .contains("sandbox"); + } + + /** + * 문서에는 업스트림 검증자를 넘기지 않는다 — 우리가 내보내는 본문은 shim 을 주입해 재작성한 + * 것이라, 앱의 ETag 는 그 본문의 것이 아니다. 넘기면 브라우저가 shim 없는 원본을 되살린다. + */ + @Test + void neverPassesTheDocumentValidatorThroughBecauseTheDocumentIsRewritten() { + container.createContext("/doc.html", exchange -> { + upstreamRequests.incrementAndGet(); + byte[] body = "doc".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, "text/html"); + exchange.getResponseHeaders().add(HttpHeaders.ETAG, "\"doc-v1\""); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + + ResponseEntity document = + service.proxy(session(), "/api/v1/previews/s/t/", "doc.html", null); + + assertThat(document.getHeaders().getETag()).isNull(); + assertThat(document.getHeaders().getCacheControl()).contains("no-store"); + } + + private String cacheControlOf(String path) { + return service.proxy(session(), "/api/v1/previews/s/t/", path, null) + .getHeaders().getCacheControl(); + } + + /** 응답 본문을 문자열로 읽는다 — 스트리밍 봉투(Resource)로 바뀌어도 테스트가 같은 것을 본다. */ + private String bodyOf(ResponseEntity response) throws IOException { + try (var in = response.getBody().getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + + /** + * 비-HTML 자산은 힙에 모으지 않고 스트림으로 넘어간다 (Issue #342, 7-3). 예전에는 모든 응답을 + * {@code ofByteArray} 로 전량 버퍼링해, 큰 이미지·번들 하나가 요청마다 그 크기만큼 힙을 썼다. + * 봉투 타입이 회귀 가드다 — {@code ByteArrayResource} 로 돌아가면 버퍼링이 돌아온 것이다. + */ + @Test + void streamsNonHtmlAssetsInsteadOfBufferingThemInHeap() { + serveAsset("/assets/app.js", "application/javascript", "console.log(1)"); + + ResponseEntity asset = + service.proxy(session(), "/api/v1/previews/s/t/", "assets/app.js", null); + + assertThat(asset.getBody()).isInstanceOf(InputStreamResource.class); + } + + /** 큰 자산도 내용이 온전히 통과해야 한다 — 스트리밍으로 바꾼 뒤에도 바이트가 깎이지 않는다. */ + @Test + void streamsALargeAssetWithoutLosingBytes() throws IOException { + byte[] large = new byte[3 * 1024 * 1024]; + for (int i = 0; i < large.length; i++) { + large[i] = (byte) (i % 251); + } + container.createContext("/assets/big.bin", exchange -> { + exchange.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, "application/octet-stream"); + exchange.sendResponseHeaders(200, large.length); + exchange.getResponseBody().write(large); + exchange.close(); + }); + + ResponseEntity response = + service.proxy(session(), "/api/v1/previews/s/t/", "assets/big.bin", null); + + assertThat(response.getBody()).isInstanceOf(InputStreamResource.class); + // 업스트림이 길이를 알려줬으므로 그대로 넘긴다(본문을 변형하지 않으니 여전히 정확하다). + assertThat(response.getHeaders().getContentLength()).isEqualTo(large.length); + try (var in = response.getBody().getInputStream()) { + assertThat(in.readAllBytes()).isEqualTo(large); + } + } + + /** + * HTML 은 여전히 버퍼링한다 — shim 주입·경로 재작성이 본문 전체를 봐야 하기 때문이다. 문서가 + * 스트림으로 새면 그 격리·보정이 통째로 빠진다. + */ + @Test + void stillBuffersTheDocumentBecauseItHasToBeRewritten() { + ResponseEntity document = service.proxy(session(), "/api/v1/previews/s/t/", "", null); + + assertThat(document.getBody()).isInstanceOf(ByteArrayResource.class); + } + /** 이 경로에만 실제 파일이 있는 상태를 만든다. 나머지 경로는 @BeforeEach 의 "/" 가 받아 index.html 을 돌려준다(serve -s 와 같은 동작). */ private void serveAsset(String path, String contentType, String content) { container.createContext(path, exchange -> { + upstreamRequests.incrementAndGet(); byte[] body = content.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, contentType); exchange.sendResponseHeaders(200, body.length); diff --git a/src/test/java/com/example/dvely/preview/application/service/PreviewSessionServiceTest.java b/src/test/java/com/example/dvely/preview/application/service/PreviewSessionServiceTest.java index d79fad26..43783f85 100644 --- a/src/test/java/com/example/dvely/preview/application/service/PreviewSessionServiceTest.java +++ b/src/test/java/com/example/dvely/preview/application/service/PreviewSessionServiceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -30,6 +31,8 @@ import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; class PreviewSessionServiceTest { @@ -420,21 +423,99 @@ void aPreviewAccessNeverPullsTheHoldBackIn() { // 유예를 걸어도 바로 뒤에 FE 가 프리뷰를 자동으로 띄우면 게이트웨이 접근이 일어난다. // touch 가 만료를 무조건 now+ttl 로 덮어쓰던 시절에는 그 한 번으로 유예가 통째로 // 지워졌다 — 유예는 프리뷰를 한 번도 열지 않았을 때만 살아남았다(2026-08-18 운영 실측). + // 갱신이 단일 UPDATE 로 바뀐 뒤에도(7-1) 그 UPDATE 가 싣는 만료가 유예여야 한다. SpringDataPreviewSessionRepository repository = mock(SpringDataPreviewSessionRepository.class); - PreviewSessionService service = new PreviewSessionService( - repository, mock(DockerContainerService.class), mock(TaskStore.class), - properties(), gatewayUrlResolver(), accessCookies(), mock(PreviewRuntimeConfigService.class) - ); + PreviewSessionService service = gatewayService(repository); PreviewSessionEntity held = activeSessionExpiringIn(Duration.ofHours(6)); + lastAccessedMinutesAgo(held, 5); // 스로틀을 지나 실제로 갱신이 나가게 한다 when(repository.findByIdAndAccessTokenAndStatus( "session-1", "token-1", PreviewSessionStatus.ACTIVE.name())) .thenReturn(Optional.of(held)); - when(repository.save(any(PreviewSessionEntity.class))) - .thenAnswer(invocation -> invocation.getArgument(0)); service.resolveGateway("session-1", "token-1"); - assertThat(held.getExpiresAt()).isAfter(LocalDateTime.now().plusHours(5)); + ArgumentCaptor expiresAt = ArgumentCaptor.forClass(LocalDateTime.class); + verify(repository).touchAccess(eq("session-1"), any(), expiresAt.capture(), any()); + assertThat(expiresAt.getValue()).isAfter(LocalDateTime.now().plusHours(5)); + } + + // ── 게이트웨이 접근 갱신 스로틀 (Issue #342, 7-1) ────────────────────────────────── + + /** + * 프리뷰 페이지 한 번의 로드는 문서 1 + 자산 N 개의 요청이고, 그 전부가 이 조회를 지난다. + * 예전에는 요청마다 엔티티를 고쳐 {@code save} 했으므로 같은 행에 UPDATE 가 N+1 번 나가고 + * 그만큼 쓰기 락이 잡혔다. 스로틀 안에서는 쓰기가 아예 없어야 한다. + */ + @Test + void assetRequestsWithinTheThrottleWindowWriteNothing() { + SpringDataPreviewSessionRepository repository = mock(SpringDataPreviewSessionRepository.class); + PreviewSessionService service = gatewayService(repository); + // 방금 만든 행 = lastAccessedAt 이 now — 스로틀 안이다. + PreviewSessionEntity session = activeSessionExpiringIn(Duration.ofMinutes(30)); + when(repository.findByIdAndAccessTokenAndStatus( + "session-1", "token-1", PreviewSessionStatus.ACTIVE.name())) + .thenReturn(Optional.of(session)); + + for (int i = 0; i < 20; i++) { + assertThat(service.resolveGateway("session-1", "token-1")).isPresent(); + } + + verify(repository, never()).touchAccess(anyString(), any(), any(), any()); + verify(repository, never()).save(any(PreviewSessionEntity.class)); + } + + /** 스로틀을 넘긴 접근은 엔티티 저장이 아니라 단일 UPDATE 한 번으로 갱신한다. */ + @Test + void anAccessOlderThanTheThrottleWindowIsRefreshedWithOneUpdate() { + SpringDataPreviewSessionRepository repository = mock(SpringDataPreviewSessionRepository.class); + PreviewSessionService service = gatewayService(repository); + PreviewSessionEntity session = activeSessionExpiringIn(Duration.ofMinutes(30)); + lastAccessedMinutesAgo(session, 5); + when(repository.findByIdAndAccessTokenAndStatus( + "session-1", "token-1", PreviewSessionStatus.ACTIVE.name())) + .thenReturn(Optional.of(session)); + + service.resolveGateway("session-1", "token-1"); + + ArgumentCaptor expiresAt = ArgumentCaptor.forClass(LocalDateTime.class); + verify(repository).touchAccess(eq("session-1"), any(), expiresAt.capture(), any()); + assertThat(expiresAt.getValue()).isAfter(LocalDateTime.now().plusMinutes(29)); + verify(repository, never()).save(any(PreviewSessionEntity.class)); + } + + /** + * 스로틀은 Sec-Fetch-Dest 를 보지 않는다. "문서 탐색일 때만 갱신"으로 바꾸면 열어둔 + * 프리뷰가 XHR/SSE 로만 쓰이는 동안 연장이 끊겨 사용 중인 세션이 만료되고, 게이트웨이의 인가 + * 판정이 쓰는 헤더가 갱신 정책에도 얽힌다. 서브리소스 요청 하나만으로도 연장돼야 한다. + */ + @Test + void aSubresourceRequestStillExtendsTheExpiryOnceTheWindowHasPassed() { + SpringDataPreviewSessionRepository repository = mock(SpringDataPreviewSessionRepository.class); + PreviewSessionService service = gatewayService(repository); + PreviewSessionEntity session = activeSessionExpiringIn(Duration.ofMinutes(2)); + lastAccessedMinutesAgo(session, 5); + when(repository.findByIdAndAccessTokenAndStatus( + "session-1", "token-1", PreviewSessionStatus.ACTIVE.name())) + .thenReturn(Optional.of(session)); + + // resolveGateway 는 자산 요청과 문서 요청을 구분하지 않는다 — 같은 한 가지 경로다. + service.resolveGateway("session-1", "token-1"); + + ArgumentCaptor expiresAt = ArgumentCaptor.forClass(LocalDateTime.class); + verify(repository).touchAccess(eq("session-1"), any(), expiresAt.capture(), any()); + assertThat(expiresAt.getValue()).isAfter(LocalDateTime.now().plusMinutes(29)); + } + + private PreviewSessionService gatewayService(SpringDataPreviewSessionRepository repository) { + return new PreviewSessionService( + repository, mock(DockerContainerService.class), mock(TaskStore.class), + properties(), gatewayUrlResolver(), accessCookies(), mock(PreviewRuntimeConfigService.class) + ); + } + + /** 엔티티에 lastAccessedAt 세터가 없으므로(도메인 불변식) 테스트만 필드를 되돌린다. */ + private void lastAccessedMinutesAgo(PreviewSessionEntity session, int minutes) { + ReflectionTestUtils.setField(session, "lastAccessedAt", LocalDateTime.now().minusMinutes(minutes)); } private PreviewSessionEntity activeSessionExpiringIn(Duration remaining) { diff --git a/src/test/java/com/example/dvely/preview/presentation/PreviewGatewayControllerTest.java b/src/test/java/com/example/dvely/preview/presentation/PreviewGatewayControllerTest.java index 336b38f7..8e531e6e 100644 --- a/src/test/java/com/example/dvely/preview/presentation/PreviewGatewayControllerTest.java +++ b/src/test/java/com/example/dvely/preview/presentation/PreviewGatewayControllerTest.java @@ -24,6 +24,8 @@ import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -55,23 +57,23 @@ void setUp() { controller = new PreviewGatewayController(sessionService, gatewayService, accessCookies, properties); when(sessionService.resolveGateway(SESSION_ID, ACCESS_TOKEN)).thenReturn(Optional.of(session())); - when(gatewayService.proxy(anyString(), any(), anyString(), anyString(), any(), any(), any())) - .thenReturn(ResponseEntity.ok("body".getBytes())); + when(gatewayService.proxy(any(), anyString(), anyString(), any(), any())) + .thenReturn(ResponseEntity.ok(new ByteArrayResource("body".getBytes()))); } @Test void rejectsARequestWithoutTheOwnershipCookieEvenWhenTheUrlIsCorrect() { - ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request()); + ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request()); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - verify(gatewayService, never()).proxy(anyString(), any(), anyString(), anyString(), any(), any(), any()); + verify(gatewayService, never()).proxy(any(), anyString(), anyString(), any(), any()); } @Test void rejectsACookieIssuedForAnotherSession() { String foreign = accessCookies.issue("other-session", OWNER, Duration.ofMinutes(30)); - ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, foreign, request()); + ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, foreign, request()); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } @@ -80,7 +82,7 @@ void rejectsACookieIssuedForAnotherSession() { void rejectsACookieIssuedForAnotherUser() { String foreign = accessCookies.issue(SESSION_ID, 99L, Duration.ofMinutes(30)); - ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, foreign, request()); + ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, foreign, request()); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } @@ -89,10 +91,10 @@ void rejectsACookieIssuedForAnotherUser() { void servesTheOwnerWhoPresentsTheIssuedCookie() { String cookie = accessCookies.issue(SESSION_ID, OWNER, Duration.ofMinutes(30)); - ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, cookie, request()); + ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, cookie, request()); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - verify(gatewayService).proxy(anyString(), any(), anyString(), anyString(), any(), any(), any()); + verify(gatewayService).proxy(any(), anyString(), anyString(), any(), any()); } /** 세션 자체가 없으면(만료·오토큰) 쿠키 이전에 404다 — 존재 여부를 인가로 흘리지 않는다. */ @@ -100,7 +102,7 @@ void servesTheOwnerWhoPresentsTheIssuedCookie() { void keepsReturningNotFoundForAnUnknownSession() { when(sessionService.resolveGateway(SESSION_ID, "wrong")).thenReturn(Optional.empty()); - ResponseEntity response = controller.proxy(SESSION_ID, "wrong", null, request()); + ResponseEntity response = controller.proxy(SESSION_ID, "wrong", null, request()); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); } @@ -112,8 +114,8 @@ void keepsReturningNotFoundForAnUnknownSession() { */ @Test void servesSubresourceRequestsWithoutTheCookie() { - ResponseEntity script = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request("script")); - ResponseEntity fetch = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request("empty")); + ResponseEntity script = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request("script")); + ResponseEntity fetch = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request("empty")); assertThat(script.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(fetch.getStatusCode()).isEqualTo(HttpStatus.OK); @@ -133,11 +135,132 @@ void keepsRequiringTheCookieForDocumentNavigations() { void canBeTurnedOffForEnvironmentsWhoseClientHasNotShippedTheAccessCallYet() { properties.setRequireAccessCookie(false); - ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request()); + ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request()); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); } + // ── 요청 본문 상한 (Issue #342, 7-3) ────────────────────────────────────────────── + + /** + * 이 경로는 서브리소스 요청에 소유권 쿠키를 요구하지 않는다(회전 accessToken 이 든 URL 자체가 + * 자격 — {@code isAuthorized} 참고). 그래서 유효한 프리뷰 주소 하나만 쥐면 로그인 없이 임의 + * 크기의 POST 를 보낼 수 있었고, {@code readAllBytes()} 는 그것을 전부 힙에 올렸다. 선언된 + * 길이가 상한을 넘으면 본문을 읽기도 전에 413 이어야 한다. + */ + @Test + void rejectsARequestWhoseDeclaredBodyExceedsTheLimit() { + HttpServletRequest request = request("empty"); + when(request.getMethod()).thenReturn("POST"); + when(request.getContentLengthLong()).thenReturn(11L * 1024 * 1024); + + ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE); + verify(gatewayService, never()).proxy(any(), anyString(), anyString(), any(), any()); + } + + /** + * 선언된 길이만 믿으면 안 된다 — 청크 전송은 길이를 안 싣고({@code -1}), 실린 값이 사실이라는 + * 보장도 없다. 실제로 읽는 양도 끊어야 상한이 상한이다. + */ + @Test + void rejectsABodyThatExceedsTheLimitEvenWhenNoLengthWasDeclared() { + HttpServletRequest request = request("empty"); + when(request.getMethod()).thenReturn("POST"); + when(request.getContentLengthLong()).thenReturn(-1L); // 청크 전송 + stubBodyOf(request, 11 * 1024 * 1024); + + ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.PAYLOAD_TOO_LARGE); + verify(gatewayService, never()).proxy(any(), anyString(), anyString(), any(), any()); + } + + /** 상한 안의 본문은 그대로 컨테이너로 간다 — 앱의 폼·등록이 계속 동작해야 한다. */ + @Test + void stillProxiesABodyWithinTheLimit() { + HttpServletRequest request = request("empty"); + when(request.getMethod()).thenReturn("POST"); + when(request.getContentLengthLong()).thenReturn(-1L); + stubBodyOf(request, 64 * 1024); + + ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(gatewayService).proxy(any(), anyString(), anyString(), any(), any()); + } + + /** + * 본문 상한이 인가보다 앞에 오면 안 된다. 소유권을 증명하지 못한 요청에 413 을 주면 + * "이 프리뷰는 존재하고 본문만 컸다"를 알려주는 셈이고, 무엇보다 인가 전에 본문을 만지기 + * 시작한다는 뜻이다. 순서는 세션 조회 → 쿠키/Sec-Fetch-Dest → 본문이어야 한다. + */ + @Test + void checksOwnershipBeforeTheBodyLimit() { + HttpServletRequest request = request(); // Sec-Fetch-Dest 없음 → 탐색으로 간주, 쿠키 필요 + when(request.getMethod()).thenReturn("POST"); + when(request.getContentLengthLong()).thenReturn(11L * 1024 * 1024); + + ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + /** 세션이 없으면 본문 크기와 무관하게 404 다 — 존재 여부를 413 으로 흘리지 않는다. */ + @Test + void keepsReturningNotFoundForAnUnknownSessionEvenWithAnOversizedBody() { + when(sessionService.resolveGateway(SESSION_ID, "wrong")).thenReturn(Optional.empty()); + HttpServletRequest request = request("empty"); + when(request.getMethod()).thenReturn("POST"); + when(request.getContentLengthLong()).thenReturn(11L * 1024 * 1024); + + ResponseEntity response = controller.proxy(SESSION_ID, "wrong", null, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + /** + * 조건부 요청(브라우저 재검증)도 인가를 먼저 받는다 — 쿠키 없는 문서 탐색은 If-None-Match 를 + * 들고 와도 401 이고, 304 로 새 나가지 않는다. + */ + @Test + void stillRequiresTheCookieForAConditionalDocumentRequest() { + HttpServletRequest request = request("document"); + when(request.getHeader(org.springframework.http.HttpHeaders.IF_NONE_MATCH)).thenReturn("\"v1\""); + + ResponseEntity response = controller.proxy(SESSION_ID, ACCESS_TOKEN, null, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verify(gatewayService, never()).proxy(any(), anyString(), anyString(), any(), any()); + } + + /** 지정한 바이트 수를 게으르게 내보내는 본문. 테스트가 10 MiB 배열을 미리 만들지 않게 한다. */ + private void stubBodyOf(HttpServletRequest request, int bytes) { + ServletInputStream stream = new ServletInputStream() { + private int remaining = bytes; + + @Override public int read() { + return remaining-- > 0 ? 'x' : -1; + } + + @Override public boolean isFinished() { + return remaining <= 0; + } + + @Override public boolean isReady() { + return true; + } + + @Override public void setReadListener(ReadListener listener) { } + }; + try { + when(request.getInputStream()).thenReturn(stream); + } catch (IOException ignored) { + // 스텁은 실제 IO 를 하지 않는다 — checked 예외는 형식상일 뿐. + } + } + private PreviewSessionInfo session() { return new PreviewSessionInfo( SESSION_ID, OWNER, 11L, null, null, "container-1", 32768, diff --git a/src/test/java/com/example/dvely/preview/presentation/PreviewGatewaySseRoutingTest.java b/src/test/java/com/example/dvely/preview/presentation/PreviewGatewaySseRoutingTest.java index e2cd0e78..dfa00975 100644 --- a/src/test/java/com/example/dvely/preview/presentation/PreviewGatewaySseRoutingTest.java +++ b/src/test/java/com/example/dvely/preview/presentation/PreviewGatewaySseRoutingTest.java @@ -18,6 +18,7 @@ import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.test.web.servlet.MockMvc; @@ -52,8 +53,8 @@ void setUp() { properties.setRequireAccessCookie(false); when(sessionService.resolveGateway(SID, TOKEN)).thenReturn(Optional.of(session())); - when(gatewayService.proxy(anyString(), any(), anyString(), any(), any(), any(), any())) - .thenReturn(ResponseEntity.ok("doc".getBytes())); + when(gatewayService.proxy(any(), anyString(), anyString(), any(), any())) + .thenReturn(ResponseEntity.ok(new ByteArrayResource("doc".getBytes()))); when(gatewayService.proxyEventStream(any(), any(), any(), any())) .thenReturn(ResponseEntity.ok(out -> { })); // no-op 스트림 @@ -68,7 +69,7 @@ void routesEventStreamAcceptToTheStreamingHandler() throws Exception { mockMvc.perform(get(BASE).accept(MediaType.TEXT_EVENT_STREAM)); verify(gatewayService).proxyEventStream(any(), anyString(), any(), any()); - verify(gatewayService, never()).proxy(anyString(), any(), anyString(), any(), any(), any(), any()); + verify(gatewayService, never()).proxy(any(), anyString(), anyString(), any(), any()); } /** @@ -80,7 +81,7 @@ void routesBrowserDocumentAcceptToTheBufferingProxyNotTheStreamingHandler() thro mockMvc.perform(get(BASE) .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")); - verify(gatewayService).proxy(anyString(), any(), anyString(), any(), any(), any(), any()); + verify(gatewayService).proxy(any(), anyString(), anyString(), any(), any()); verify(gatewayService, never()).proxyEventStream(any(), any(), any(), any()); } diff --git a/src/test/java/com/example/dvely/preview/presentation/PreviewGatewayStreamingEnvelopeTest.java b/src/test/java/com/example/dvely/preview/presentation/PreviewGatewayStreamingEnvelopeTest.java new file mode 100644 index 00000000..55555cf5 --- /dev/null +++ b/src/test/java/com/example/dvely/preview/presentation/PreviewGatewayStreamingEnvelopeTest.java @@ -0,0 +1,115 @@ +package com.example.dvely.preview.presentation; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.example.dvely.auth.infrastructure.config.JwtProperties; +import com.example.dvely.preview.application.result.PreviewSessionInfo; +import com.example.dvely.preview.application.service.PreviewGatewayService; +import com.example.dvely.preview.application.service.PreviewSessionService; +import com.example.dvely.preview.infrastructure.config.PreviewProperties; +import com.example.dvely.preview.infrastructure.security.PreviewAccessCookies; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +/** + * 스트리밍 봉투가 Spring MVC 를 통과한 뒤에도 예전과 같은 응답을 내는지 고정한다 + * (Issue #342, 7-3). + * + *

이 검증은 서비스 단위 테스트로 못 잡는다. {@code ResponseEntity} 는 Spring 이 + * {@code AbstractMessageConverterMethodProcessor} 에서 특별 취급하기 때문이다 — Resource 를 + * 돌려주면 {@code Accept-Ranges: bytes} 를 달고, {@code Range} 요청이 오면 본문을 + * {@code ResourceRegion} 으로 바꿔 206 으로 내보낸다. 그 경로는 {@code contentLength()} 를 요구하는데 + * 스트림 기반 자원은 그것을 세느라 스트림을 소진해버려 본문이 깨진다.

+ * + *

{@code InputStreamResource} 만은 그 특별 취급에서 제외돼 있어(그 클래스와 정확히 일치할 + * 때만) 우리 자산 응답은 안전하다. 하지만 그것은 봉투 타입에 달린 성질이다 — 누군가 + * 스트리밍 봉투를 다른 {@code Resource} 구현으로 바꾸면 프리뷰 앱의 {@code

+ */ +class PreviewGatewayStreamingEnvelopeTest { + + private static final String SID = "session-1"; + private static final String TOKEN = "token-1"; + private static final String ASSET = "/api/v1/previews/" + SID + "/" + TOKEN + "/assets/app.js"; + private static final String SCRIPT = "console.log(1)"; + + private HttpServer container; + private MockMvc mockMvc; + + @BeforeEach + void setUp() throws IOException { + container = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + container.createContext("/assets/app.js", exchange -> { + byte[] body = SCRIPT.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add(HttpHeaders.CONTENT_TYPE, "application/javascript"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + container.start(); + + PreviewSessionService sessionService = mock(PreviewSessionService.class); + when(sessionService.resolveGateway(SID, TOKEN)).thenReturn(Optional.of(session())); + PreviewProperties properties = new PreviewProperties(); + // 봉투만 본다 — 인가는 PreviewGatewayControllerTest 가 따로 고정한다. + properties.setRequireAccessCookie(false); + + mockMvc = MockMvcBuilders.standaloneSetup(new PreviewGatewayController( + sessionService, + new PreviewGatewayService("'self'", false, id -> false), + new PreviewAccessCookies( + new JwtProperties("test-secret-key-that-is-long-enough-32", 3600000L, 7200000L)), + properties)) + .build(); + } + + @AfterEach + void tearDown() { + container.stop(0); + } + + /** 평범한 요청은 자산 전체를 200 으로 받는다. */ + @Test + void streamsTheWholeAssetThroughMvc() throws Exception { + mockMvc.perform(get(ASSET).header(PreviewGatewayController.SEC_FETCH_DEST, "script")) + .andExpect(status().isOk()) + .andExpect(content().string(SCRIPT)) + .andExpect(header().string(HttpHeaders.CONTENT_TYPE, "application/javascript")); + } + + /** + * Range 요청도 예전(byte[] 봉투)과 똑같이 전체 본문 + 200 이어야 한다. 206 이 나오거나 본문이 + * 비면, 봉투가 Spring 의 Range 특별 취급에 걸린 것이고 프리뷰의 미디어 재생이 깨진다. + */ + @Test + void doesNotLetSpringTurnAStreamedAssetIntoARangeResponse() throws Exception { + mockMvc.perform(get(ASSET) + .header(PreviewGatewayController.SEC_FETCH_DEST, "script") + .header(HttpHeaders.RANGE, "bytes=0-3")) + .andExpect(status().isOk()) + .andExpect(content().string(SCRIPT)) + .andExpect(header().doesNotExist(HttpHeaders.ACCEPT_RANGES)); + } + + private PreviewSessionInfo session() { + return new PreviewSessionInfo( + SID, 7L, 11L, null, null, "container-1", container.getAddress().getPort(), + "https://qeploy.com/api/v1/previews/" + SID + "/" + TOKEN + "/", + LocalDateTime.now().plusMinutes(30)); + } +}