From 38a1b6f7ce56f66e6298af4882502e06094e0c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:22:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9A=94=EC=B2=AD=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=EC=9D=84=20authorized=20=ED=95=98=EB=82=98=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=ED=95=98=EA=B3=A0=20=ED=97=A4=EB=93=9C?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EA=B1=B7=EC=96=B4=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - headlessFirst(직행 최적화)를 제거한다. 불완전 승격이 들어온 뒤로는 정책 없이도 같은 결과에 도달해, 이 힌트는 도메인당 fetch 1회와 LLM 1회를 아끼는 값이 됐고 넣을 유인이 사라졌다. product.extract.headless_first 메트릭도 함께 제거 - headlessAllowed 게이트를 제거한다. 브라우저로 여는 것 자체는 신원을 밝히고 하는 일이라 허락을 전제하지 않는다 - 승격 3경로가 허가 뒤에 있던 구조를 풀어, 기본 상태에서도 CSR 페이지가 뚫린다 - 대신 authorized 를 받아 렌더 경계까지 전달만 한다. 이 값이 여는 것은 렌더 서비스의 우회 수단(지문 보정·프록시)이고 판정 원장은 호출자에 있다(무상태) - 전략 계약(LinkExtractionStrategy)에도 authorized 를 실었다. plain 은 우회 개념이 없어 무시하지만, 두 전략이 같은 시그니처를 갖는 편이 호출부가 분기를 들고 있는 것보다 낫다 - stub·fake 에 전달된 값을 기록해 렌더 경계까지 도달하는지 케이스로 고정한다 - 이 전달이 끊기면 허락받은 대상이 조용히 정직 모드로 가거나 그 반대가 되는데 반환값으로는 안 드러난다 --- .../extractor/api/ExtractionController.java | 11 +- .../extractor/api/LinkExtractionRequest.java | 14 +- .../DefaultProductLinkExtractor.java | 5 +- .../FallbackProductLinkExtractor.java | 73 ++------ .../HeadlessProductLinkExtractor.java | 4 +- .../extraction/LinkExtractionStrategy.java | 9 +- .../extraction/ProductLinkExtractor.java | 15 +- .../headless/HeadlessRenderRequest.java | 5 + .../extraction/headless/HeadlessRenderer.java | 6 +- .../headless/HttpHeadlessRenderer.java | 12 +- .../api/LinkExtractionRequestTest.java | 30 ++- .../DefaultProductLinkExtractorTest.java | 6 +- .../FallbackProductLinkExtractorTest.java | 173 +++++------------- .../HeadlessProductLinkExtractorTest.java | 10 +- .../extraction/ProductLinkExtractE2ETest.java | 2 +- .../headless/HttpHeadlessRendererTest.java | 28 +-- .../support/StubHeadlessRenderer.java | 8 +- 17 files changed, 144 insertions(+), 267 deletions(-) diff --git a/src/main/java/com/depromeet/piki/extractor/api/ExtractionController.java b/src/main/java/com/depromeet/piki/extractor/api/ExtractionController.java index 18040bc..dbfcdfc 100644 --- a/src/main/java/com/depromeet/piki/extractor/api/ExtractionController.java +++ b/src/main/java/com/depromeet/piki/extractor/api/ExtractionController.java @@ -34,20 +34,17 @@ public ExtractionResponse extractLink( ProductLink link = ProductLink.parse(request.url()); // model 은 호출자가 백오피스에서 지정한 값이라 원장에 남긴다 — 추출 품질이 흔들릴 때 "그때 어느 모델이었나"를 // 되짚는 유일한 근거다(자유 문자열이라 메트릭 라벨로는 못 쓴다). - // headlessAllowed 도 원장에 남긴다 — 허가 없는 대상이 브라우저로 갔는지(또는 허가가 왜 안 왔는지)를 - // 사후에 되짚을 수 있는 유일한 근거다. + // authorized 도 원장에 남긴다 — 우회 수단이 열린 요청이었는지를 사후에 되짚을 수 있는 유일한 근거다. log.info( - "extract request correlationId={} headlessFirst={} headlessAllowed={} model={} url={}", + "extract request correlationId={} authorized={} model={} url={}", correlationId, - request.headlessFirst(), - request.headlessAllowed(), + request.authorized(), request.model(), link.safeLogString() ); ProductSnapshot snapshot = productLinkExtractor.extract( link, - request.headlessFirst(), - request.headlessAllowed(), + request.authorized(), request.model() ); return ExtractionResponse.from(snapshot); diff --git a/src/main/java/com/depromeet/piki/extractor/api/LinkExtractionRequest.java b/src/main/java/com/depromeet/piki/extractor/api/LinkExtractionRequest.java index e79d63c..337e01c 100644 --- a/src/main/java/com/depromeet/piki/extractor/api/LinkExtractionRequest.java +++ b/src/main/java/com/depromeet/piki/extractor/api/LinkExtractionRequest.java @@ -6,20 +6,20 @@ *

url 형식 검증을 Bean Validation 이 아니라 {@code ProductLink.parse} 에 맡긴다 — blank·형식·스킴 * 위반이 전부 계약 코드 INVALID_URL 하나로 떨어져야 하기 때문이다. * - *

headlessFirst·headlessAllowed 를 primitive boolean 이 아니라 Boolean 으로 받는 이유: Jackson 3 는 + *

authorized 를 primitive boolean 이 아니라 Boolean 으로 받는 이유: Jackson 3 는 * FAIL_ON_NULL_FOR_PRIMITIVES 가 기본 on 이라, 이 선택 필드를 안 보내는 구버전 호출자의 요청이 400 으로 - * 깨진다(통합 테스트로 실측). 의미는 {@code ProductLinkExtractor.extract} 의 같은 이름 파라미터와 같다. + * 깨진다(통합 테스트로 실측). * - *

headlessAllowed 의 누락 정규화가 false 인 것은 fail-safe 다 — 이 필드를 모르는 구버전 호출자의 요청은 - * "허가 없음"으로 떨어져 헤드리스를 타지 않는다. 반대로 두면 허가 계약이 배포 순서에 따라 조용히 뚫린다. + *

authorized 는 "이 대상이 플랫폼의 명시적 허락을 받았는가"다. 누락 정규화가 false 인 것은 fail-safe 다 — + * 이 필드를 모르는 구버전 호출자의 요청은 "허락 없음"으로 떨어져 우회 수단이 열리지 않는다. 판정의 원장은 + * 호출자(core)에 있고 이 서비스는 요청 단위로 받아 렌더 서비스까지 전달만 한다. * *

model 은 호출자가 지정한 LLM 모델이며 선택 필드다. 안 보내면 null 이 되어 기본 모델을 쓴다(String 이라 * primitive 함정은 없다). 빈 문자열·공백 처리는 GeminiHttpClient 의 후보 계산이 흡수한다. */ -public record LinkExtractionRequest(String url, Boolean headlessFirst, Boolean headlessAllowed, String model) { +public record LinkExtractionRequest(String url, Boolean authorized, String model) { public LinkExtractionRequest { - headlessFirst = Boolean.TRUE.equals(headlessFirst); - headlessAllowed = Boolean.TRUE.equals(headlessAllowed); + authorized = Boolean.TRUE.equals(authorized); } } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractor.java index 69d53b2..6008802 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractor.java @@ -8,6 +8,9 @@ import org.springframework.stereotype.Component; /** + * 정적 HTTP fetch 기반 추출 전략. authorized 는 쓰지 않는다 — 우리 IP 로 문서를 받아오는 것뿐이라 + * 우회할 대상이 없다(그 개념은 렌더 서비스에만 있다). + * * 정적 HTTP fetch 기반 추출 전략. fetch 는 1회뿐이고, 이후 파싱(구조화 우선 → 미달이면 같은 HTML 로 LLM * fallback, 재fetch 없음)은 헤드리스 전략과 공유하는 {@link HtmlSnapshotPipeline} 이 맡는다. */ @@ -19,7 +22,7 @@ public class DefaultProductLinkExtractor implements LinkExtractionStrategy { private final HtmlSnapshotPipeline htmlSnapshotPipeline; @Override - public ProductSnapshot extract(ProductLink link, String model) { + public ProductSnapshot extract(ProductLink link, boolean authorized, String model) { long fetchStart = System.nanoTime(); PageContent page = pageFetcher.fetch(link); long fetchMs = (System.nanoTime() - fetchStart) / 1_000_000; diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java index 9d47dee..4a035da 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java @@ -17,14 +17,12 @@ *

* - *

단 헤드리스 진입 3경로(직행·차단 승격·불완전 승격)는 전부 요청의 headlessAllowed 허가 뒤에 있다 — 허가가 - * 없으면 "비싸고 느려서" 가 아니라 "써서는 안 되므로" 열리지 않는다. 이 서비스는 허가 대상을 알지 못하고 판정도 - * 하지 않는다(무상태): 허가의 단일 진실은 호출자 쪽에 있고 여기서는 요청 단위 플래그로만 받는다. + *

추출 경로는 요청의 authorized 와 무관하게 항상 같다 — 브라우저로 여는 것 자체는 우리 신원을 밝히고 하는 + * 일이라 허락을 전제하지 않는다. authorized 가 여는 것은 렌더 서비스의 우회 수단(지문 보정·프록시)뿐이고, + * 이 클래스는 그 값을 판단 없이 전략에 넘기기만 한다(무상태 — 원장은 호출자에 있다). * *

에스컬레이션 축(plain 확정 → headless)은 호출자 outbox 의 재시도 축(일시 오류 → 같은 plain 재시도)과 * 직교한다. 차단·불완전 결과는 재시도 축에서 이미 확정 실패(422)라 그 슬롯(attemptCount)에 얹을 수 없다. 그래서 @@ -36,11 +34,6 @@ public class FallbackProductLinkExtractor implements ProductLinkExtractor { private static final String ESCALATION_METRIC = "product.extract.escalation"; - /** - * 직행 경로의 추세 축. 라벨 키는 {@code outcome} 뿐 — plain 실패가 없어 category 축이 존재하지 않는다 - * (메트릭 이름이 달라 escalation 의 {@code outcome, category} 키 집합과 충돌하지 않는다). - */ - private static final String HEADLESS_FIRST_METRIC = "product.extract.headless_first"; private static final String TAG_OUTCOME = "outcome"; private static final String TAG_CATEGORY = "category"; private static final String OUTCOME_SUCCESS = "success"; @@ -58,71 +51,31 @@ public class FallbackProductLinkExtractor implements ProductLinkExtractor { private final HeadlessExtractionProperties headlessProperties; @Override - public ProductSnapshot extract(ProductLink link, boolean headlessFirst, boolean headlessAllowed, String model) { - // 두 조건이 모두 서 있어야 헤드리스가 열린다. enabled 는 이 서비스의 운영 비상 차단(호출자 정책이 앞설 수 - // 없다), headlessAllowed 는 이 대상에 브라우저를 써도 되는지에 대한 호출자의 허가다. 둘 중 하나라도 - // 없으면 plain 만 타고 헤드리스 진입 3경로(직행·차단 승격·불완전 승격)가 한꺼번에 닫힌다 — 아래 분기가 - // 전부 이 가드 뒤에 있는 것이 그 보장이다. headlessFirst 는 허가가 선 뒤에만 의미를 갖는 라우팅 힌트다. - if (!headlessProperties.enabled() || !headlessAllowed) { - return plain.extract(link, model); - } - - // 호출자(core)의 브라우저 직행 정책(DB, 백오피스에서 배포 없이 변경) — plain 이 항상 차단되는 host 의 - // 느린-실패(fetch 타임아웃을 다 기다린 뒤 에스컬레이트) 낭비를 없앤다. 직행 실패는 plain 으로 되돌리지 - // 않고 그대로 전파한다: 재시도는 호출자 outbox recover 축이, 정책 오지정은 백오피스 롤백이 진다. - if (headlessFirst) { - return extractHeadlessFirst(link, model); + public ProductSnapshot extract(ProductLink link, boolean authorized, String model) { + // 이 서비스의 운영 비상 차단. 꺼져 있으면 승격 자체가 없어 정적 fetch 결과가 그대로 나간다. + if (!headlessProperties.enabled()) { + return plain.extract(link, authorized, model); } ProductSnapshot plainSnapshot; try { - plainSnapshot = plain.extract(link, model); + plainSnapshot = plain.extract(link, authorized, model); } catch (RuntimeException e) { if (!shouldEscalate(e)) { throw e; } - return escalateToHeadless(link, categoryOf(e), model); + return escalateToHeadless(link, categoryOf(e), authorized, model); } if (plainSnapshot.missingReadyField()) { // 이대로 반환하면 응답 경계(ExtractionResponse.from)가 확정 실패(UNTRUSTWORTHY_VALUE)로 닫는다 — // 확정 전에 브라우저 렌더 DOM 으로 한 번 더 시도한다. 승격은 plain 의 try 바깥이라 headless 실패가 // 위 catch 로 새어 재승격되는 일이 없고, 승격 결과가 여전히 불완전하면 그때 경계가 같은 확정 실패로 // 닫는다(재승격 없음). - return escalateToHeadless(link, CATEGORY_INCOMPLETE_SNAPSHOT, model); + return escalateToHeadless(link, CATEGORY_INCOMPLETE_SNAPSHOT, authorized, model); } return plainSnapshot; } - /** - * 브라우저 직행(정책 힌트). outcome 을 별도 카운터로 집계한다 — escalation 카운터는 에스컬레이션 축만 - * 커버해서, 직행 볼륨·성공률이 시계열에 없으면 호출자의 직행 정책 오지정(실제로는 plain 이 통하는 host)이 - * 로그 grep 전까지 조용히 지속된다(메트릭=추세, 로그=원장). - * - *

outcome=success 는 "요청을 살렸다"(완전한 READY snapshot 확보)다 — 판정 규칙은 - * {@link #outcomeOf(ProductSnapshot)} 참조. - */ - private ProductSnapshot extractHeadlessFirst(ProductLink link, String model) { - log.info("extract route=headless_first url={}", link.safeLogString()); - try { - ProductSnapshot snapshot = headless.extract(link, model); - headlessFirstCounter(outcomeOf(snapshot)).increment(); - return snapshot; - } catch (Throwable failure) { - // escalateToHeadless 와 같은 이유로 Throwable — Error 실패도 집계에서 빠지지 않게 하고 그대로 rethrow. - headlessFirstCounter(OUTCOME_FAILED).increment(); - log.warn( - "extract route=headless_first outcome=failed cause={} url={}", - failure.getClass().getSimpleName(), - link.safeLogString() - ); - throw failure; - } - } - - private Counter headlessFirstCounter(String outcome) { - return meterRegistry.counter(HEADLESS_FIRST_METRIC, TAG_OUTCOME, outcome); - } - /** * plain 으로 못 끝내(차단 또는 불완전 결과) headless 로 넘긴다. 결과를 outcome 으로 집계하되 "무엇이 escalate * 됐나"를 category(fetch 실패 코드명 또는 INCOMPLETE_SNAPSHOT)로 쪼갠다 — 무조건 폴백이라 낭비(특히 일시 @@ -133,11 +86,11 @@ private Counter headlessFirstCounter(String outcome) { * outcome=success 는 "요청을 살렸다"(완전한 READY snapshot 확보)다 — 판정 규칙은 * {@link #outcomeOf(ProductSnapshot)} 참조. */ - private ProductSnapshot escalateToHeadless(ProductLink link, String category, String model) { + private ProductSnapshot escalateToHeadless(ProductLink link, String category, boolean authorized, String model) { Objects.requireNonNull(category, "category"); log.info("extract escalate=headless plainCategory={} url={}", category, link.safeLogString()); try { - ProductSnapshot snapshot = headless.extract(link, model); + ProductSnapshot snapshot = headless.extract(link, authorized, model); escalationCounter(outcomeOf(snapshot), category).increment(); return snapshot; } catch (Throwable headlessFailure) { diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java index 26a6bdd..b628b91 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java @@ -21,9 +21,9 @@ public class HeadlessProductLinkExtractor implements LinkExtractionStrategy { private final HtmlSnapshotPipeline htmlSnapshotPipeline; @Override - public ProductSnapshot extract(ProductLink link, String model) { + public ProductSnapshot extract(ProductLink link, boolean authorized, String model) { long renderStart = System.nanoTime(); - PageContent page = headlessRenderer.render(link); + PageContent page = headlessRenderer.render(link, authorized); long renderMs = (System.nanoTime() - renderStart) / 1_000_000; return htmlSnapshotPipeline.extract(page, "render=" + renderMs + "ms", model); diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/LinkExtractionStrategy.java b/src/main/java/com/depromeet/piki/extractor/extraction/LinkExtractionStrategy.java index 49b55e5..d3d5218 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/LinkExtractionStrategy.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/LinkExtractionStrategy.java @@ -18,6 +18,11 @@ public interface LinkExtractionStrategy { String PLAIN = "plainLinkExtractionStrategy"; String HEADLESS = "headlessLinkExtractionStrategy"; - /** @param model 호출자가 지정한 LLM 모델(없으면 null). 전략은 해석하지 않고 파이프라인까지 흘려보낸다. */ - ProductSnapshot extract(ProductLink link, String model); + /** + * @param authorized 이 대상이 허락을 받았는가. 정적 fetch 전략에는 우회 개념이 없어 무시되고, 헤드리스 + * 전략만 렌더 서비스로 전달한다. 두 전략이 같은 시그니처를 갖는 편이 호출부(Fallback)가 분기를 + * 들고 있는 것보다 낫다 — 전략이 늘어도 호출부가 안 바뀐다. + * @param model 호출자가 지정한 LLM 모델(없으면 null). 전략은 해석하지 않고 파이프라인까지 흘려보낸다. + */ + ProductSnapshot extract(ProductLink link, boolean authorized, String model); } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractor.java index fa78e46..20f9f3b 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractor.java @@ -7,17 +7,14 @@ public interface ProductLinkExtractor { /** - * @param headlessFirst 호출자(core)의 플랫폼 라우팅 정책 힌트. 정책의 단일 진실은 호출자 쪽 동적 설정(DB, - * 백오피스)에 있고, 무상태인 이 서비스는 요청 단위 힌트로만 받는다. true 면 plain(정적 fetch)을 건너뛰고 - * 처음부터 헤드리스로 추출한다 — 단 이 서비스의 헤드리스 스위치가 꺼져 있으면 무시된다. 힌트일 뿐이라 - * headlessAllowed 가 false 면 함께 무시된다(허가가 라우팅보다 앞선다). - * @param headlessAllowed 이 대상에 헤드리스를 써도 되는지에 대한 호출자의 허가. false 면 어떤 경로로도 - * 헤드리스를 타지 않는다 — 직행(headlessFirst)·차단 승격·불완전 승격 셋 다 닫힌다. 허가 대상의 단일 - * 진실은 호출자(core) 쪽에 있고, 무상태인 이 서비스는 요청 단위로만 받는다. 누락은 false 로 정규화되는 + * @param authorized 이 대상이 플랫폼의 명시적 허락을 받았는가. 추출 경로 자체는 이 값과 무관하다 — + * 정적 fetch 로 시작해 필요하면 브라우저로 승격하는 흐름은 항상 같다. 이 값이 여는 것은 렌더 서비스의 + * 우회 수단(지문 보정·프록시)뿐이며, 여기서는 판단하지 않고 그대로 전달만 한다. 허락 대상의 단일 + * 진실은 호출자(core)에 있고, 무상태인 이 서비스는 요청 단위로만 받는다. 누락은 false 로 정규화되는 * fail-safe 다({@code LinkExtractionRequest}). - * @param model 호출자가 지정한 LLM 모델 힌트. headlessFirst 와 같은 성질이다 — 정책의 단일 진실은 호출자 쪽 + * @param model 호출자가 지정한 LLM 모델 힌트. authorized 와 같은 성질이다 — 정책의 단일 진실은 호출자 쪽 * 동적 설정(DB, 백오피스)이고 무상태인 이 서비스는 요청 단위로만 받는다. null 이면 기본 모델을 쓰며, * 지정 모델이 사라졌으면 기본 모델로 대체된다. */ - ProductSnapshot extract(ProductLink link, boolean headlessFirst, boolean headlessAllowed, String model); + ProductSnapshot extract(ProductLink link, boolean authorized, String model); } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderRequest.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderRequest.java index 92bd1ec..a8f7888 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderRequest.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderRequest.java @@ -8,11 +8,16 @@ *

include_html 을 켜는 이유: renderer 는 파싱하지 않으므로(HTML 렌더러) 파싱(구조화/LLM)은 우리가 렌더된 * HTML 로 직접 한다. * + *

authorized 는 "이 대상이 허락을 받았는가" 다 — 렌더 서비스는 이 값이 true 일 때만 우회 수단(지문 보정· + * 프록시)을 연다. 이 필드를 모르는 구버전 renderer 는 무시하고 정직 모드로 돌므로, 배포 순서와 무관하게 + * 안전한 쪽으로만 어긋난다. + * *

compress 를 모르는 구버전 renderer 는 이 필드를 무시하고(pydantic 기본) plain JSON 을 준다 — 그래서 * 해제 판별을 요청이 아니라 응답 헤더로 두면(HttpHeadlessRenderer 참조) 켠 채로도 배포 순서와 무관하게 안전하다. */ record HeadlessRenderRequest( String url, + boolean authorized, @JsonProperty("include_html") boolean includeHtml, boolean compress ) { diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderer.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderer.java index 1fb1da3..b0baaf8 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderer.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderer.java @@ -11,5 +11,9 @@ */ public interface HeadlessRenderer { - PageContent render(ProductLink link); + /** + * @param authorized 허락받은 대상인가. 렌더 서비스는 이 값이 true 일 때만 우회 수단(지문 보정·프록시)을 + * 연다. 이 경계는 판정하지 않고 전달만 한다 — 원장은 호출자(core)에 있다. + */ + PageContent render(ProductLink link, boolean authorized); } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java index d772318..41a1075 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java @@ -84,18 +84,18 @@ public HttpHeadlessRenderer( } @Override - public PageContent render(ProductLink link) { + public PageContent render(ProductLink link, boolean authorized) { try { // 여기서 걸리면 PageFetchException.blockedHost 로 — plain 경로와 같은 계약 코드로 떨어진다. internalHostGuard.verify(link); - return renderVerified(link); + return renderVerified(link, authorized); } finally { dnsResolver.clear(); } } - private PageContent renderVerified(ProductLink link) { - HeadlessRenderResponse response = requestRender(link); + private PageContent renderVerified(ProductLink link, boolean authorized) { + HeadlessRenderResponse response = requestRender(link, authorized); String verdict = response.verdict(); if (VERDICT_BLOCK.equals(verdict)) { @@ -127,13 +127,13 @@ private PageContent renderVerified(ProductLink link) { return new PageContent(link, capHtml(html), resolveFinalUrl(response.finalUrl(), link)); } - private HeadlessRenderResponse requestRender(ProductLink link) { + private HeadlessRenderResponse requestRender(ProductLink link, boolean authorized) { ResponseEntity entity; try { entity = restClient.post() .uri(RENDER_PATH) .contentType(MediaType.APPLICATION_JSON) - .body(new HeadlessRenderRequest(link.value().toString(), true, properties.compress())) + .body(new HeadlessRenderRequest(link.value().toString(), authorized, true, properties.compress())) .retrieve() .toEntity(byte[].class); } catch (RestClientResponseException e) { diff --git a/src/test/java/com/depromeet/piki/extractor/api/LinkExtractionRequestTest.java b/src/test/java/com/depromeet/piki/extractor/api/LinkExtractionRequestTest.java index 0835a4a..96a78fd 100644 --- a/src/test/java/com/depromeet/piki/extractor/api/LinkExtractionRequestTest.java +++ b/src/test/java/com/depromeet/piki/extractor/api/LinkExtractionRequestTest.java @@ -6,33 +6,25 @@ import org.junit.jupiter.api.Test; /** - * 요청 계약의 선택 플래그 정규화. 특히 headlessAllowed 의 기본값은 안전 기본값(fail-safe)이라 계약의 일부다 — - * 이 필드를 모르는 구버전 호출자·필드를 빠뜨린 요청이 "허가받은 것"으로 흘러가면 허가 게이트가 배포 순서에 따라 - * 조용히 뚫린다. + * 요청 계약의 선택 플래그 정규화. authorized 의 기본값은 안전 기본값(fail-safe)이라 계약의 일부다 — + * 이 필드를 모르는 구버전 호출자·필드를 빠뜨린 요청이 "허락받은 것"으로 흘러가면 우회 수단이 배포 순서에 따라 + * 조용히 열린다. */ class LinkExtractionRequestTest { @Test - @DisplayName("headlessAllowed 를 안 보내면 허가 없음(false)으로 정규화된다") - void missingHeadlessAllowedDefaultsToDenied() { - LinkExtractionRequest request = new LinkExtractionRequest("https://shop.example.com/p", null, null, null); + @DisplayName("authorized 를 안 보내면 허락 없음(false)으로 정규화된다") + void missingAuthorizedDefaultsToDenied() { + LinkExtractionRequest request = new LinkExtractionRequest("https://shop.example.com/p", null, null); - assertEquals(Boolean.FALSE, request.headlessAllowed()); + assertEquals(Boolean.FALSE, request.authorized()); } @Test - @DisplayName("headlessAllowed=true 를 보내면 허가로 그대로 전달된다") - void explicitHeadlessAllowedIsPreserved() { - LinkExtractionRequest request = new LinkExtractionRequest("https://shop.example.com/p", null, true, null); + @DisplayName("authorized=true 를 보내면 허락으로 그대로 전달된다") + void explicitAuthorizedIsPreserved() { + LinkExtractionRequest request = new LinkExtractionRequest("https://shop.example.com/p", true, null); - assertEquals(Boolean.TRUE, request.headlessAllowed()); - } - - @Test - @DisplayName("headlessFirst 를 안 보내면 false 로 정규화된다") - void missingHeadlessFirstDefaultsToFalse() { - LinkExtractionRequest request = new LinkExtractionRequest("https://shop.example.com/p", null, null, null); - - assertEquals(Boolean.FALSE, request.headlessFirst()); + assertEquals(Boolean.TRUE, request.authorized()); } } diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractorTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractorTest.java index 642aea9..bd332be 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractorTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractorTest.java @@ -53,7 +53,7 @@ void reclassifiesNoDataOnEmptyShell() { // 게이트(LlmInputGate)가 LLM 전에 끊으므로 stub 세팅이 없어야 정상 — LLM 이 불리면 default throw 로 드러난다. PageFetchException e = assertThrows( PageFetchException.class, - () -> extractorFetching(SHELL_HTML).extract(link, null) + () -> extractorFetching(SHELL_HTML).extract(link, false, null) ); assertEquals(ExtractionErrorCode.EMPTY_SHELL, e.code()); @@ -71,7 +71,7 @@ void propagatesNoDataOnContentRichPage() { ProductSnapshotException e = assertThrows( ProductSnapshotException.class, - () -> extractorFetching(CONTENT_HTML).extract(link, null) + () -> extractorFetching(CONTENT_HTML).extract(link, false, null) ); assertEquals(ExtractionErrorCode.NOT_PRODUCT_PAGE, e.code()); @@ -88,6 +88,6 @@ void propagatesLlmUpstreamFailureUntouched() { throw GeminiApiException.upstreamError(new RuntimeException("gemini 503")); }; - assertThrows(GeminiApiException.class, () -> extractorFetching(dataIslandShell).extract(link, null)); + assertThrows(GeminiApiException.class, () -> extractorFetching(dataIslandShell).extract(link, false, null)); } } diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractorTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractorTest.java index 37af825..b9330fb 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractorTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractorTest.java @@ -21,8 +21,8 @@ * 여기 단위에서 한다. 두 전략은 실제 빈이 네트워크/브라우저를 요구해 단위로 세울 수 없어, * LinkExtractionStrategy fake 로 "전략의 결과"만 주입한다. * - *

헤드리스가 도는 케이스는 전부 headlessAllowed=true 를 넘긴다 — 허가가 헤드리스 진입의 전제 조건이기 - * 때문이다. 허가가 없을 때 3경로가 모두 닫히는지는 아래 headlessAllowed* 케이스가 따로 못박는다. + *

authorized 는 헤드리스 진입 조건이 아니다 — 승격 경로는 그 값과 무관하게 같고, 이 플래그는 렌더 경계로 + * 전달만 된다. 전달이 끊기지 않는지는 아래 authorized* 케이스가 못박는다. */ class FallbackProductLinkExtractorTest { @@ -40,9 +40,12 @@ private static class FakeStrategy implements LinkExtractionStrategy { this.fn = fn; } + Boolean lastAuthorized; + @Override - public ProductSnapshot extract(ProductLink link, String model) { + public ProductSnapshot extract(ProductLink link, boolean authorized, String model) { calls++; + lastAuthorized = authorized; return fn.apply(link); } } @@ -73,7 +76,7 @@ void headlessOffDelegatesToPlain() { throw new IllegalStateException("headless 는 호출되면 안 됨"); }); - ProductSnapshot result = fallback(false, plain, headless).extract(link, false, true, null); + ProductSnapshot result = fallback(false, plain, headless).extract(link, false, null); assertEquals(snapshot, result); assertEquals(1, plain.calls); @@ -88,7 +91,7 @@ void headlessOffPropagatesEscalatableFailure() { }); FakeStrategy headless = new FakeStrategy(l -> snapshot); - assertThrows(PageFetchException.class, () -> fallback(false, plain, headless).extract(link, false, true, null)); + assertThrows(PageFetchException.class, () -> fallback(false, plain, headless).extract(link, false, null)); assertEquals(0, headless.calls); } @@ -103,7 +106,7 @@ void escalatesToHeadlessOnEscalatableFailure() { FakeStrategy headless = new FakeStrategy(l -> headlessSnapshot); SimpleMeterRegistry registry = new SimpleMeterRegistry(); - ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, true, null); + ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, null); assertEquals(headlessSnapshot, result); assertEquals(1, plain.calls); @@ -123,7 +126,7 @@ void headlessOnButPlainSucceeds() { throw new IllegalStateException("headless 는 호출되면 안 됨"); }); - ProductSnapshot result = fallback(true, plain, headless).extract(link, false, true, null); + ProductSnapshot result = fallback(true, plain, headless).extract(link, false, null); assertEquals(snapshot, result); assertEquals(1, plain.calls); @@ -143,7 +146,7 @@ void headlessErrorIsCountedAndPropagated() { }); SimpleMeterRegistry registry = new SimpleMeterRegistry(); - assertThrows(Error.class, () -> fallback(true, plain, headless, registry).extract(link, false, true, null)); + assertThrows(Error.class, () -> fallback(true, plain, headless, registry).extract(link, false, null)); assertEquals( 1.0, registry.counter("product.extract.escalation", "outcome", "failed", "category", "FETCH_CLIENT_ERROR").count() @@ -163,7 +166,7 @@ void headlessFailureIsCountedAndPropagated() { }); SimpleMeterRegistry registry = new SimpleMeterRegistry(); - assertThrows(RuntimeException.class, () -> fallback(true, plain, headless, registry).extract(link, false, true, null)); + assertThrows(RuntimeException.class, () -> fallback(true, plain, headless, registry).extract(link, false, null)); assertEquals(1, plain.calls); assertEquals(1, headless.calls); assertEquals( @@ -172,57 +175,8 @@ void headlessFailureIsCountedAndPropagated() { ); } - @Test - @DisplayName("headlessFirst 힌트가 오면 plain 을 건너뛰고 headless 로 직행하며 success 로 집계한다") - void headlessFirstSkipsPlain() { - FakeStrategy plain = new FakeStrategy(l -> { - throw new IllegalStateException("plain 은 호출되면 안 됨"); - }); - ProductSnapshot headlessSnapshot = - new ProductSnapshot(null, "헤드리스 결과", "https://cdn.example.com/h.png", 50_000, null); - FakeStrategy headless = new FakeStrategy(l -> headlessSnapshot); - SimpleMeterRegistry registry = new SimpleMeterRegistry(); - - ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, true, true, null); - - assertEquals(headlessSnapshot, result); - assertEquals(0, plain.calls); - assertEquals(1, headless.calls); - assertEquals(1.0, registry.counter("product.extract.headless_first", "outcome", "success").count()); - } - - @Test - @DisplayName("headlessFirst 직행이 실패하면 plain 으로 되돌리지 않고 failed 로 집계한 뒤 그대로 전파한다") - void headlessFirstFailurePropagatesWithoutPlainFallback() { - // 직행 정책은 "plain 이 항상 차단되는 host" 지정이라 실패해도 plain 재시도는 낭비다 — 재시도는 호출자 - // outbox recover 축이, 정책 오지정은 백오피스 롤백이 진다. outcome=failed 시계열이 그 오지정의 추세 신호다. - FakeStrategy plain = new FakeStrategy(l -> snapshot); - FakeStrategy headless = new FakeStrategy(l -> { - throw new RuntimeException("headless 실패"); - }); - SimpleMeterRegistry registry = new SimpleMeterRegistry(); - - assertThrows(RuntimeException.class, () -> fallback(true, plain, headless, registry).extract(link, true, true, null)); - assertEquals(0, plain.calls); - assertEquals(1, headless.calls); - assertEquals(1.0, registry.counter("product.extract.headless_first", "outcome", "failed").count()); - } - - @Test - @DisplayName("headless 가 꺼져 있으면 headlessFirst 힌트도 무시하고 plain 에 위임한다") - void headlessFirstIgnoredWhenDisabled() { - // 호출자(core)의 라우팅 정책이 이 서비스의 스위치보다 앞설 수 없다 — 스위치가 꺼진 동안은 zero-diff. - FakeStrategy plain = new FakeStrategy(l -> snapshot); - FakeStrategy headless = new FakeStrategy(l -> { - throw new IllegalStateException("headless 는 호출되면 안 됨"); - }); - ProductSnapshot result = fallback(false, plain, headless).extract(link, true, true, null); - assertEquals(snapshot, result); - assertEquals(1, plain.calls); - assertEquals(0, headless.calls); - } @Test @DisplayName("headless 가 켜져 있어도 escalatable 이 아닌 실패는 전파하고 headless 를 호출하지 않는다") @@ -233,7 +187,7 @@ void nonEscalatableFailureIsNeverEscalated() { }); FakeStrategy headless = new FakeStrategy(l -> snapshot); - assertThrows(PageFetchException.class, () -> fallback(true, plain, headless).extract(link, false, true, null)); + assertThrows(PageFetchException.class, () -> fallback(true, plain, headless).extract(link, false, null)); assertEquals(0, headless.calls); } @@ -249,7 +203,7 @@ void escalatesToHeadlessOnIncompleteSnapshot() { FakeStrategy headless = new FakeStrategy(l -> headlessSnapshot); SimpleMeterRegistry registry = new SimpleMeterRegistry(); - ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, true, null); + ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, null); assertEquals(headlessSnapshot, result); assertEquals(1, plain.calls); @@ -269,7 +223,7 @@ void incompleteEscalationReturnsHeadlessResultAsIs() { FakeStrategy headless = new FakeStrategy(l -> incomplete); SimpleMeterRegistry registry = new SimpleMeterRegistry(); - ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, true, null); + ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, null); assertEquals(incomplete, result); assertEquals(1, headless.calls); @@ -279,21 +233,6 @@ void incompleteEscalationReturnsHeadlessResultAsIs() { ); } - @Test - @DisplayName("headlessFirst 직행 결과가 불완전하면 그대로 반환하되 failed 로 집계한다") - void headlessFirstIncompleteResultIsCountedFailed() { - // 직행 성공률은 정책 오지정(직행해도 못 살리는 host)의 추세 신호다 — 경계에서 확정 실패로 닫힐 결과를 - // success 로 세면 그 신호가 실제보다 부푼다. - FakeStrategy plain = new FakeStrategy(l -> snapshot); - FakeStrategy headless = new FakeStrategy(l -> incomplete); - SimpleMeterRegistry registry = new SimpleMeterRegistry(); - - ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, true, true, null); - - assertEquals(incomplete, result); - assertEquals(0, plain.calls); - assertEquals(1.0, registry.counter("product.extract.headless_first", "outcome", "failed").count()); - } @Test @DisplayName("불완전 승격에서 headless 가 escalatable 실패를 던져도 재진입 없이 전파하고 failed 로 집계한다") @@ -305,7 +244,7 @@ void incompleteEscalationFailurePropagatesWithoutReentry() { }); SimpleMeterRegistry registry = new SimpleMeterRegistry(); - assertThrows(PageFetchException.class, () -> fallback(true, plain, headless, registry).extract(link, false, true, null)); + assertThrows(PageFetchException.class, () -> fallback(true, plain, headless, registry).extract(link, false, null)); assertEquals(1, headless.calls); assertEquals( 1.0, @@ -321,7 +260,7 @@ void headlessOffReturnsIncompleteAsIs() { throw new IllegalStateException("headless 는 호출되면 안 됨"); }); - ProductSnapshot result = fallback(false, plain, headless).extract(link, false, true, null); + ProductSnapshot result = fallback(false, plain, headless).extract(link, false, null); assertEquals(incomplete, result); assertEquals(0, headless.calls); @@ -338,79 +277,55 @@ void productSnapshotFailureIsNotEscalated() { }); FakeStrategy headless = new FakeStrategy(l -> snapshot); - assertThrows(ProductSnapshotException.class, () -> fallback(true, plain, headless).extract(link, false, true, null)); + assertThrows(ProductSnapshotException.class, () -> fallback(true, plain, headless).extract(link, false, null)); assertEquals(0, headless.calls); } + + + @Test - @DisplayName("허가가 없으면 plain 이 escalatable 차단으로 막혀도 headless 로 승격하지 않고 예외를 전파한다") - void headlessNotAllowedBlocksEscalation() { - // 허가는 "비싸니 아낀다"가 아니라 "써서는 안 된다"는 계약이라, 차단당했다는 사정이 허가를 대신하지 못한다. + @DisplayName("허락 없는 요청도 escalatable 이 아닌 실패는 그대로 전파한다 (SSRF 차단은 승격 대상이 아니다)") + void unauthorizedPropagatesNonEscalatableFailure() { FakeStrategy plain = new FakeStrategy(l -> { - throw PageFetchException.clientError(new RuntimeException("403")); + throw PageFetchException.blockedHost(); }); FakeStrategy headless = new FakeStrategy(l -> { throw new IllegalStateException("headless 는 호출되면 안 됨"); }); - SimpleMeterRegistry registry = new SimpleMeterRegistry(); - assertThrows( - PageFetchException.class, - () -> fallback(true, plain, headless, registry).extract(link, false, false, null)); - assertEquals(1, plain.calls); + assertThrows(PageFetchException.class, () -> fallback(true, plain, headless).extract(link, false, null)); assertEquals(0, headless.calls); - assertEquals( - 0.0, - registry.counter("product.extract.escalation", "outcome", "success", "category", "FETCH_CLIENT_ERROR").count() - ); } @Test - @DisplayName("허가가 없으면 plain 결과가 불완전해도 headless 로 승격하지 않고 그대로 반환한다") - void headlessNotAllowedBlocksIncompleteEscalation() { - // 불완전 승격은 세 진입 경로 중 유일하게 plain 이 예외 없이 끝나는 경로다 — 여기로 허가가 새면 - // 게이트가 반쪽이 된다. 반환값은 응답 경계가 확정 실패(UNTRUSTWORTHY_VALUE)로 닫는 기존 계약 그대로다. + @DisplayName("허락이 없어도 불완전 결과는 headless 로 승격한다 (허락은 승격 조건이 아니다)") + void unauthorizedStillEscalates() { + // 브라우저로 여는 것 자체는 신원을 밝히고 하는 일이라 허락을 전제하지 않는다. 허락이 여는 것은 + // 렌더 서비스의 우회 수단뿐이고, 그 판단은 이 클래스가 아니라 렌더 경계 너머에 있다. FakeStrategy plain = new FakeStrategy(l -> incomplete); - FakeStrategy headless = new FakeStrategy(l -> { - throw new IllegalStateException("headless 는 호출되면 안 됨"); - }); - - ProductSnapshot result = fallback(true, plain, headless).extract(link, false, false, null); - - assertEquals(incomplete, result); - assertEquals(1, plain.calls); - assertEquals(0, headless.calls); - } - - @Test - @DisplayName("허가가 없으면 headlessFirst 직행 힌트가 와도 무시하고 plain 만 탄다") - void headlessNotAllowedBeatsHeadlessFirstHint() { - // 라우팅 힌트(어디로 보낼까)와 허가(써도 되나)는 다른 질문이다 — 힌트가 허가를 만들어내지 못한다. - FakeStrategy plain = new FakeStrategy(l -> snapshot); - FakeStrategy headless = new FakeStrategy(l -> { - throw new IllegalStateException("headless 는 호출되면 안 됨"); - }); - SimpleMeterRegistry registry = new SimpleMeterRegistry(); + ProductSnapshot rescued = + new ProductSnapshot(null, "톡딜 상품", "https://cdn.example.com/p.png", 23_900, null); + FakeStrategy headless = new FakeStrategy(l -> rescued); - ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, true, false, null); + ProductSnapshot result = fallback(true, plain, headless).extract(link, false, null); - assertEquals(snapshot, result); - assertEquals(1, plain.calls); - assertEquals(0, headless.calls); - assertEquals(0.0, registry.counter("product.extract.headless_first", "outcome", "success").count()); + assertEquals(rescued, result); + assertEquals(1, headless.calls); + assertEquals(Boolean.FALSE, headless.lastAuthorized); } @Test - @DisplayName("허가가 없으면 escalatable 이 아닌 실패도 그대로 전파한다 (plain 단독 경로의 기존 계약)") - void headlessNotAllowedPropagatesNonEscalatableFailure() { + @DisplayName("허락 플래그는 headless 전략까지 그대로 전달된다") + void authorizationReachesHeadlessStrategy() { + // 이 전달이 끊기면 허락받은 대상이 조용히 정직 모드로 가거나 그 반대가 된다 — 반환값으로는 안 드러난다. FakeStrategy plain = new FakeStrategy(l -> { - throw PageFetchException.blockedHost(); - }); - FakeStrategy headless = new FakeStrategy(l -> { - throw new IllegalStateException("headless 는 호출되면 안 됨"); + throw PageFetchException.clientError(new RuntimeException("403")); }); + FakeStrategy headless = new FakeStrategy(l -> snapshot); - assertThrows(PageFetchException.class, () -> fallback(true, plain, headless).extract(link, false, false, null)); - assertEquals(0, headless.calls); + fallback(true, plain, headless).extract(link, true, null); + + assertEquals(Boolean.TRUE, headless.lastAuthorized); } } diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java index 3ec96ad..2aa9ae5 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java @@ -31,7 +31,7 @@ class HeadlessProductLinkExtractorTest { private final StubGeminiClient stubGemini = new StubGeminiClient(); private HeadlessProductLinkExtractor extractorWith(Function render) { - HeadlessRenderer renderer = render::apply; + HeadlessRenderer renderer = (target, authorized) -> render.apply(target); return new HeadlessProductLinkExtractor( renderer, new HtmlSnapshotPipeline( @@ -51,7 +51,7 @@ void structuredDataFromRenderedHtml() { + ""; HeadlessProductLinkExtractor extractor = extractorWith(l -> PageContent.of(l, html)); - ProductSnapshot snapshot = extractor.extract(link, null); + ProductSnapshot snapshot = extractor.extract(link, false, null); assertEquals("렌더 상품", snapshot.name()); assertEquals(209_000, snapshot.currentPrice()); @@ -67,7 +67,7 @@ void llmFallbackOnRenderedHtml() { "" + "구조화 데이터 없이 렌더된 상품 상세 설명 텍스트. ".repeat(3) + "" )); - ProductSnapshot snapshot = extractor.extract(link, null); + ProductSnapshot snapshot = extractor.extract(link, false, null); assertEquals("엘엘엠 상품", snapshot.name()); assertEquals(1, stubGemini.invocations()); @@ -85,7 +85,7 @@ void renderedShellFailsPermanentlyWithoutLlm() { ProductSnapshotException e = assertThrows( ProductSnapshotException.class, - () -> extractor.extract(link, null) + () -> extractor.extract(link, false, null) ); assertEquals(ExtractionErrorCode.NO_EXTRACTABLE_CONTENT, e.code()); @@ -99,6 +99,6 @@ void renderFailurePropagates() { throw HeadlessRenderException.blocked(); }); - assertThrows(HeadlessRenderException.class, () -> extractor.extract(link, null)); + assertThrows(HeadlessRenderException.class, () -> extractor.extract(link, false, null)); } } diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractE2ETest.java b/src/test/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractE2ETest.java index 04f1f21..b6c02aa 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractE2ETest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractE2ETest.java @@ -78,7 +78,7 @@ void measureLatencyAndResults() { long started = System.nanoTime(); Outcome outcome; try { - ProductSnapshot product = extractor.extract(link, null); + ProductSnapshot product = extractor.extract(link, false, null); long ms = (System.nanoTime() - started) / 1_000_000; outcome = new Outcome(rawUrl, i + 1, "OK", product.name(), product.currentPrice(), ms, null); } catch (Exception e) { diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java index 2147adf..0e765f9 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRendererTest.java @@ -92,7 +92,7 @@ void okRenderReturnsPageContent() { MediaType.APPLICATION_JSON ))); - PageContent page = renderer.render(link); + PageContent page = renderer.render(link, false); assertEquals(html, page.html()); // 정체성(원본 link)은 유지하고, baseUri 용 finalUrl 은 렌더가 따라간 최종 URL 을 쓴다. @@ -111,7 +111,7 @@ void anyNonBlockVerdictWithHtmlProceeds() { MediaType.APPLICATION_JSON ))); - assertEquals("rendered dom", renderer.render(link).html(), "verdict=" + verdict); + assertEquals("rendered dom", renderer.render(link, false).html(), "verdict=" + verdict); } } @@ -122,7 +122,7 @@ void blockIsTransient() { .expect(requestTo(BASE_URL + "/render")) .andRespond(withSuccess("{\"verdict\":\"BLOCK\",\"status\":429}", MediaType.APPLICATION_JSON))); - HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link)); + HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link, false)); assertEquals(ExtractionErrorCode.HEADLESS_BLOCKED, ex.code()); assertFalse(ex.permanent()); @@ -140,7 +140,7 @@ void missingHtmlIsTransient() { .expect(requestTo(BASE_URL + "/render")) .andRespond(withSuccess(body, MediaType.APPLICATION_JSON))); - HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link)); + HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link, false)); assertEquals(ExtractionErrorCode.HEADLESS_UPSTREAM, ex.code()); assertFalse(ex.permanent()); @@ -155,7 +155,7 @@ void zstdResponseIsDecompressed() { .expect(requestTo(BASE_URL + "/render")) .andRespond(withSuccess(packed, MediaType.APPLICATION_OCTET_STREAM).headers(zstdHeaders("")))); - assertEquals("compressed dom", renderer.render(link).html()); + assertEquals("compressed dom", renderer.render(link, false).html()); } @Test @@ -172,7 +172,7 @@ void zstdDictResponseUsesSharedDictionary() { .andRespond(withSuccess(packed, MediaType.APPLICATION_OCTET_STREAM).headers(zstdHeaders("mall-v1.dict"))) ); - assertEquals("dict compressed", renderer.render(link).html()); + assertEquals("dict compressed", renderer.render(link, false).html()); } @Test @@ -183,7 +183,7 @@ void unknownZstdDictIsTransient() { .expect(requestTo(BASE_URL + "/render")) .andRespond(withSuccess(packed, MediaType.APPLICATION_OCTET_STREAM).headers(zstdHeaders("future-v2.dict")))); - HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link)); + HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link, false)); assertEquals(ExtractionErrorCode.HEADLESS_UPSTREAM, ex.code()); assertFalse(ex.permanent()); @@ -197,7 +197,7 @@ void corruptZstdIsTransient() { .expect(requestTo(BASE_URL + "/render")) .andRespond(withSuccess(garbage, MediaType.APPLICATION_OCTET_STREAM).headers(zstdHeaders("")))); - HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link)); + HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link, false)); assertEquals(ExtractionErrorCode.HEADLESS_UPSTREAM, ex.code()); assertFalse(ex.permanent()); @@ -210,7 +210,7 @@ void nonJsonBodyIsTransient() { .expect(requestTo(BASE_URL + "/render")) .andRespond(withSuccess("not-json", MediaType.TEXT_PLAIN))); - HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link)); + HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link, false)); assertEquals(ExtractionErrorCode.HEADLESS_UPSTREAM, ex.code()); assertFalse(ex.permanent()); @@ -225,7 +225,7 @@ void internalHostIsBlockedBeforeRender() { HttpHeadlessRenderer renderer = rendererWith(HeadlessExtractionProperties.of(true), internalIp, ZstdDictionaries.none(), server -> { }); - PageFetchException ex = assertThrows(PageFetchException.class, () -> renderer.render(link)); + PageFetchException ex = assertThrows(PageFetchException.class, () -> renderer.render(link, false)); assertEquals(ExtractionErrorCode.BLOCKED_HOST, ex.code()); assertTrue(ex.permanent()); @@ -242,7 +242,7 @@ void invalidFinalUrlFallsBackToLink() { MediaType.APPLICATION_JSON ))); - assertEquals(link, renderer.render(link).finalUrl()); + assertEquals(link, renderer.render(link, false).finalUrl()); } } @@ -264,7 +264,7 @@ void internalFinalUrlIsBlocked() { MediaType.APPLICATION_JSON ))); - PageFetchException ex = assertThrows(PageFetchException.class, () -> renderer.render(link)); + PageFetchException ex = assertThrows(PageFetchException.class, () -> renderer.render(link, false)); assertEquals(ExtractionErrorCode.BLOCKED_HOST, ex.code()); } @@ -276,7 +276,7 @@ void renderServiceErrorIsTransient() { .expect(requestTo(BASE_URL + "/render")) .andRespond(withServerError())); - HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link)); + HeadlessRenderException ex = assertThrows(HeadlessRenderException.class, () -> renderer.render(link, false)); assertEquals(ExtractionErrorCode.HEADLESS_UPSTREAM, ex.code()); assertFalse(ex.permanent()); @@ -295,7 +295,7 @@ void htmlIsCappedAtMaxChars() { MediaType.APPLICATION_JSON ))); - assertEquals("0123456789", renderer.render(link).html()); + assertEquals("0123456789", renderer.render(link, false).html()); } @Test diff --git a/src/test/java/com/depromeet/piki/extractor/support/StubHeadlessRenderer.java b/src/test/java/com/depromeet/piki/extractor/support/StubHeadlessRenderer.java index 319e650..280e303 100644 --- a/src/test/java/com/depromeet/piki/extractor/support/StubHeadlessRenderer.java +++ b/src/test/java/com/depromeet/piki/extractor/support/StubHeadlessRenderer.java @@ -9,6 +9,9 @@ * 헤드리스 렌더 외부 경계를 통합 테스트에서 격리하는 stub. 통합 컨텍스트는 헤드리스 스위치가 꺼진 기본 구성이라 * 정상 흐름에선 호출되지 않는다 — 설정이 바뀌어도 통합 테스트가 실제 렌더 서비스로 나가지 않게 하는 안전망이자, * 켜서 검증할 때의 주입 지점이다. default build 는 throw(명시 세팅 강제). + * + *

lastAuthorized 를 기록하는 이유: 허락 플래그가 렌더 경계까지 그대로 도달하는지는 반환값으로 드러나지 + * 않는다. 이 값이 끊기면 우회 수단이 열려야 할 요청이 조용히 정직 모드로 가거나 그 반대가 된다. */ public class StubHeadlessRenderer implements HeadlessRenderer { @@ -16,8 +19,11 @@ public class StubHeadlessRenderer implements HeadlessRenderer { throw new IllegalStateException("stub.build 를 테스트 본문에서 명시 세팅해야 한다. CLAUDE.md '테스트' 절 참고."); }; + public Boolean lastAuthorized; + @Override - public PageContent render(ProductLink link) { + public PageContent render(ProductLink link, boolean authorized) { + lastAuthorized = authorized; return build.apply(link); } }