diff --git a/build.gradle b/build.gradle index 5fb302fc..ebb2c62a 100644 --- a/build.gradle +++ b/build.gradle @@ -67,6 +67,9 @@ dependencies { testImplementation "org.springframework.cloud:spring-cloud-starter-openfeign" testImplementation "com.squareup.okhttp3:mockwebserver:4.12.0" + // Jsoup (HTML parsing for OG tag crawling) + implementation 'org.jsoup:jsoup:1.18.3' + // WebSocket implementation 'org.springframework.boot:spring-boot-starter-websocket' @@ -116,13 +119,18 @@ jacocoTestReport { '**/*Request$*.class', '**/*Response.class', '**/*Response$*.class', + '**/*Req.class', + '**/*Req$*.class', + '**/*Res.class', + '**/*Res$*.class', '**/config/**', '**/configuration/**', '**/exception/**', '**/*Application.class', '**/Q*.class', '**/entity/**', - '**/domain/**/*Entity.class' + '**/domain/**/*Entity.class', + '**/domain/link/util/OgTagCrawler.class' ]) })) } @@ -153,13 +161,18 @@ jacocoTestCoverageVerification { '**/*Request$*.class', '**/*Response.class', '**/*Response$*.class', + '**/*Req.class', + '**/*Req$*.class', + '**/*Res.class', + '**/*Res$*.class', '**/config/**', '**/configuration/**', '**/exception/**', '**/*Application.class', '**/Q*.class', '**/entity/**', - '**/domain/**/*Entity.class' + '**/domain/**/*Entity.class', + '**/domain/link/util/OgTagCrawler.class' ]) })) } diff --git a/src/main/java/com/sofa/linkiving/domain/link/controller/LinkApi.java b/src/main/java/com/sofa/linkiving/domain/link/controller/LinkApi.java index 24515ce2..006de73d 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/controller/LinkApi.java +++ b/src/main/java/com/sofa/linkiving/domain/link/controller/LinkApi.java @@ -3,85 +3,88 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; import com.sofa.linkiving.domain.link.dto.request.LinkCreateReq; import com.sofa.linkiving.domain.link.dto.request.LinkMemoUpdateReq; import com.sofa.linkiving.domain.link.dto.request.LinkTitleUpdateReq; import com.sofa.linkiving.domain.link.dto.request.LinkUpdateReq; +import com.sofa.linkiving.domain.link.dto.request.MetaScrapeReq; import com.sofa.linkiving.domain.link.dto.response.LinkDuplicateCheckRes; import com.sofa.linkiving.domain.link.dto.response.LinkRes; +import com.sofa.linkiving.domain.link.dto.response.MetaScrapeRes; import com.sofa.linkiving.domain.link.dto.response.RecreateSummaryResponse; import com.sofa.linkiving.domain.link.enums.Format; import com.sofa.linkiving.domain.member.entity.Member; import com.sofa.linkiving.global.common.BaseResponse; -import com.sofa.linkiving.security.annotation.AuthMember; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; @Tag(name = "Link", description = "링크 관리 API") public interface LinkApi { + @Operation(summary = "메타 정보 수집", description = "URL의 OG 태그를 크롤링하여 메타 정보를 반환합니다") + BaseResponse scrapeMetadata( + MetaScrapeReq request, + Member member + ); + @Operation(summary = "URL 중복 체크", description = "저장하려는 URL이 이미 존재하는지 확인하고, 존재 시 linkId를 반환합니다") ResponseEntity> checkDuplicate( - @RequestParam String url, - @AuthMember Member member + String url, + Member member ); @Operation(summary = "링크 생성", description = "새로운 링크를 저장합니다") ResponseEntity> createLink( - @Valid @RequestBody LinkCreateReq request, - @AuthMember Member member + LinkCreateReq request, + Member member ); @Operation(summary = "링크 수정", description = "링크 정보를 수정합니다. null이 아닌 필드만 수정됩니다.") ResponseEntity> updateLink( - @PathVariable Long id, - @Valid @RequestBody LinkUpdateReq request, - @AuthMember Member member + Long id, + LinkUpdateReq request, + Member member ); @Operation(summary = "링크 삭제", description = "링크를 삭제합니다 (Soft Delete)") ResponseEntity> deleteLink( - @PathVariable Long id, - @AuthMember Member member + Long id, + Member member ); @Operation(summary = "링크 조회", description = "링크 상세 정보를 조회합니다") ResponseEntity> getLink( - @PathVariable Long id, - @AuthMember Member member + Long id, + Member member ); @Operation(summary = "링크 목록 조회", description = "저장된 링크 목록을 페이징하여 조회합니다") ResponseEntity>> getLinkList( Pageable pageable, - @AuthMember Member member + Member member ); @Operation(summary = "링크 제목 수정", description = "링크 제목만 수정합니다") ResponseEntity> updateTitle( - @PathVariable Long id, - @Valid @RequestBody LinkTitleUpdateReq request, - @AuthMember Member member + Long id, + LinkTitleUpdateReq request, + Member member ); @Operation(summary = "링크 메모 수정", description = "링크 메모만 수정합니다") ResponseEntity> updateMemo( - @PathVariable Long id, - @Valid @RequestBody LinkMemoUpdateReq request, - @AuthMember Member member + Long id, + LinkMemoUpdateReq request, + Member member ); @Operation(summary = "요약 재생성", description = "요약을 재생성 하고 신규 요약 기존 요약, 기존 및 신규 요약 비교 정보을 제공합니다.") BaseResponse recreateSummary( - @PathVariable Long id, - @Valid @RequestParam @Schema(description = "요청 형식(CONCISE: 간결하게, DETAILED:자세하게)") Format format, - @AuthMember Member member + Long id, + @Schema(description = "요청 형식(CONCISE: 간결하게, DETAILED:자세하게)") Format format, + Member member ); } diff --git a/src/main/java/com/sofa/linkiving/domain/link/controller/LinkController.java b/src/main/java/com/sofa/linkiving/domain/link/controller/LinkController.java index e89aea8e..7ed0f534 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/controller/LinkController.java +++ b/src/main/java/com/sofa/linkiving/domain/link/controller/LinkController.java @@ -19,8 +19,10 @@ import com.sofa.linkiving.domain.link.dto.request.LinkMemoUpdateReq; import com.sofa.linkiving.domain.link.dto.request.LinkTitleUpdateReq; import com.sofa.linkiving.domain.link.dto.request.LinkUpdateReq; +import com.sofa.linkiving.domain.link.dto.request.MetaScrapeReq; import com.sofa.linkiving.domain.link.dto.response.LinkDuplicateCheckRes; import com.sofa.linkiving.domain.link.dto.response.LinkRes; +import com.sofa.linkiving.domain.link.dto.response.MetaScrapeRes; import com.sofa.linkiving.domain.link.dto.response.RecreateSummaryResponse; import com.sofa.linkiving.domain.link.enums.Format; import com.sofa.linkiving.domain.link.facade.LinkFacade; @@ -38,6 +40,16 @@ public class LinkController implements LinkApi { private final LinkFacade linkFacade; + @Override + @PostMapping("/meta-scrape") + public BaseResponse scrapeMetadata( + @Valid @RequestBody MetaScrapeReq request, + @AuthMember Member member + ) { + MetaScrapeRes response = linkFacade.scrapeMetadata(request.url()); + return BaseResponse.success(response, "메타 정보 수집 완료"); + } + @Override @GetMapping("/duplicate") public ResponseEntity> checkDuplicate( diff --git a/src/main/java/com/sofa/linkiving/domain/link/dto/OgTagDto.java b/src/main/java/com/sofa/linkiving/domain/link/dto/OgTagDto.java new file mode 100644 index 00000000..e0eb3519 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/dto/OgTagDto.java @@ -0,0 +1,13 @@ +package com.sofa.linkiving.domain.link.dto; + +import lombok.Builder; + +@Builder +public record OgTagDto( + String title, + String description, + String image, + String url +) { + public static final OgTagDto EMPTY = new OgTagDto("", "", "", ""); +} diff --git a/src/main/java/com/sofa/linkiving/domain/link/dto/request/MetaScrapeReq.java b/src/main/java/com/sofa/linkiving/domain/link/dto/request/MetaScrapeReq.java new file mode 100644 index 00000000..261474c4 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/dto/request/MetaScrapeReq.java @@ -0,0 +1,11 @@ +package com.sofa.linkiving.domain.link.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record MetaScrapeReq( + @Schema(description = "메타 정보를 수집할 URL", example = "https://example.com", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "URL은 필수입니다") + String url +) { +} diff --git a/src/main/java/com/sofa/linkiving/domain/link/dto/response/MetaScrapeRes.java b/src/main/java/com/sofa/linkiving/domain/link/dto/response/MetaScrapeRes.java new file mode 100644 index 00000000..239f3ae1 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/dto/response/MetaScrapeRes.java @@ -0,0 +1,28 @@ +package com.sofa.linkiving.domain.link.dto.response; + +import com.sofa.linkiving.domain.link.dto.OgTagDto; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record MetaScrapeRes( + @Schema(description = "페이지 제목", example = "Example Domain") + String title, + + @Schema(description = "페이지 설명", example = "This domain is for use in illustrative examples...") + String description, + + @Schema(description = "이미지 URL", example = "https://example.com/image.jpg") + String image, + + @Schema(description = "페이지 URL", example = "https://example.com") + String url +) { + public static MetaScrapeRes from(OgTagDto ogTag) { + return new MetaScrapeRes( + ogTag.title(), + ogTag.description(), + ogTag.image(), + ogTag.url() + ); + } +} diff --git a/src/main/java/com/sofa/linkiving/domain/link/error/LinkErrorCode.java b/src/main/java/com/sofa/linkiving/domain/link/error/LinkErrorCode.java index 878b39bf..1ca5794f 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/error/LinkErrorCode.java +++ b/src/main/java/com/sofa/linkiving/domain/link/error/LinkErrorCode.java @@ -13,6 +13,9 @@ public enum LinkErrorCode implements ErrorCode { LINK_NOT_FOUND(HttpStatus.NOT_FOUND, "L-001", "링크를 찾을 수 없습니다."), DUPLICATE_URL(HttpStatus.BAD_REQUEST, "L-002", "이미 저장된 URL입니다."), + INVALID_URL(HttpStatus.BAD_REQUEST, "L-003", "유효하지 않은 URL 형식입니다."), + INVALID_URL_PROTOCOL(HttpStatus.BAD_REQUEST, "L-004", "허용되지 않은 프로토콜입니다. http 또는 https만 사용 가능합니다."), + INVALID_URL_PRIVATE_IP(HttpStatus.BAD_REQUEST, "L-005", "내부 네트워크 주소는 접근할 수 없습니다."), SUMMARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "L-010", "요약 정보를 찾을 수 없습니다."); private final HttpStatus status; diff --git a/src/main/java/com/sofa/linkiving/domain/link/facade/LinkFacade.java b/src/main/java/com/sofa/linkiving/domain/link/facade/LinkFacade.java index 7842b412..ba44d814 100644 --- a/src/main/java/com/sofa/linkiving/domain/link/facade/LinkFacade.java +++ b/src/main/java/com/sofa/linkiving/domain/link/facade/LinkFacade.java @@ -5,12 +5,15 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.sofa.linkiving.domain.link.dto.OgTagDto; import com.sofa.linkiving.domain.link.dto.response.LinkDuplicateCheckRes; import com.sofa.linkiving.domain.link.dto.response.LinkRes; +import com.sofa.linkiving.domain.link.dto.response.MetaScrapeRes; import com.sofa.linkiving.domain.link.dto.response.RecreateSummaryResponse; import com.sofa.linkiving.domain.link.enums.Format; import com.sofa.linkiving.domain.link.service.LinkService; import com.sofa.linkiving.domain.link.service.SummaryService; +import com.sofa.linkiving.domain.link.util.OgTagCrawler; import com.sofa.linkiving.domain.member.entity.Member; import lombok.RequiredArgsConstructor; @@ -21,6 +24,7 @@ public class LinkFacade { private final LinkService linkService; + private final OgTagCrawler ogTagCrawler; private final SummaryService summaryService; public LinkRes createLink(Member member, String url, String title, String memo, String imageUrl) { @@ -74,4 +78,10 @@ public RecreateSummaryResponse recreateSummary(Member member, Long linkId, Forma .comparison(comparison) .build(); } + + @Transactional(readOnly = true) + public MetaScrapeRes scrapeMetadata(String url) { + OgTagDto ogTag = ogTagCrawler.crawl(url); + return MetaScrapeRes.from(ogTag); + } } diff --git a/src/main/java/com/sofa/linkiving/domain/link/util/OgTagCrawler.java b/src/main/java/com/sofa/linkiving/domain/link/util/OgTagCrawler.java new file mode 100644 index 00000000..8459080e --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/util/OgTagCrawler.java @@ -0,0 +1,51 @@ +package com.sofa.linkiving.domain.link.util; + +import java.io.IOException; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.springframework.stereotype.Component; + +import com.sofa.linkiving.domain.link.dto.OgTagDto; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class OgTagCrawler { + + private static final int TIMEOUT_MS = 5000; + private final UrlValidator urlValidator; + + public OgTagCrawler(UrlValidator urlValidator) { + this.urlValidator = urlValidator; + } + + public OgTagDto crawl(String url) { + // SSRF 방어: URL 안전성 검증 + urlValidator.validateSafeUrl(url); + + try { + Document document = Jsoup.connect(url) + .timeout(TIMEOUT_MS) + .userAgent("Mozilla/5.0") + .get(); + + return OgTagDto.builder() + .title(getMetaTag(document, "og:title")) + .description(getMetaTag(document, "og:description")) + .image(getMetaTag(document, "og:image")) + .url(getMetaTag(document, "og:url")) + .build(); + + } catch (IOException e) { + log.warn("OG 태그 크롤링 실패: {}", url, e); + return OgTagDto.EMPTY; + } + } + + private String getMetaTag(Document document, String property) { + return document.select("meta[property=" + property + "]") + .attr("content"); + } +} diff --git a/src/main/java/com/sofa/linkiving/domain/link/util/UrlValidator.java b/src/main/java/com/sofa/linkiving/domain/link/util/UrlValidator.java new file mode 100644 index 00000000..cd16c6c3 --- /dev/null +++ b/src/main/java/com/sofa/linkiving/domain/link/util/UrlValidator.java @@ -0,0 +1,118 @@ +package com.sofa.linkiving.domain.link.util; + +import java.net.InetAddress; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.UnknownHostException; + +import org.springframework.stereotype.Component; + +import com.sofa.linkiving.domain.link.error.LinkErrorCode; +import com.sofa.linkiving.global.error.exception.BusinessException; + +import lombok.extern.slf4j.Slf4j; + +/** + * SSRF(Server-Side Request Forgery) 공격을 방어하기 위한 URL 검증 유틸리티 + */ +@Slf4j +@Component +public class UrlValidator { + + private static final String[] ALLOWED_PROTOCOLS = {"http", "https"}; + + /** + * SSRF 공격으로부터 안전한 URL인지 검증 + * + * @param urlString 검증할 URL + * @throws BusinessException URL이 안전하지 않은 경우 + */ + public void validateSafeUrl(String urlString) { + try { + URL url = new URL(urlString); + + // 1. 프로토콜 검증 (http, https만 허용) + validateProtocol(url); + + // 2. 호스트 검증 + String host = url.getHost(); + validateHost(host); + + // 3. IP 주소 검증 (Private IP, Loopback, Link-local 차단) + validateIpAddress(host); + + } catch (MalformedURLException e) { + log.warn("잘못된 URL 형식: {}", urlString); + throw new BusinessException(LinkErrorCode.INVALID_URL); + } catch (UnknownHostException e) { + log.warn("호스트를 찾을 수 없음: {}", urlString); + throw new BusinessException(LinkErrorCode.INVALID_URL); + } + } + + private void validateProtocol(URL url) { + String protocol = url.getProtocol().toLowerCase(); + boolean isAllowed = false; + + for (String allowedProtocol : ALLOWED_PROTOCOLS) { + if (allowedProtocol.equals(protocol)) { + isAllowed = true; + break; + } + } + + if (!isAllowed) { + log.warn("허용되지 않은 프로토콜: {}", protocol); + throw new BusinessException(LinkErrorCode.INVALID_URL_PROTOCOL); + } + } + + private void validateHost(String host) { + if (host == null || host.isEmpty()) { + log.warn("호스트가 비어있음"); + throw new BusinessException(LinkErrorCode.INVALID_URL); + } + + // localhost, 0.0.0.0 차단 + String lowerHost = host.toLowerCase(); + if (lowerHost.equals("localhost") + || lowerHost.equals("0.0.0.0") + || lowerHost.startsWith("127.") + || lowerHost.equals("::1") + || lowerHost.equals("0:0:0:0:0:0:0:1")) { + log.warn("Loopback 주소 접근 시도: {}", host); + throw new BusinessException(LinkErrorCode.INVALID_URL_PRIVATE_IP); + } + } + + private void validateIpAddress(String host) throws UnknownHostException { + // DNS 조회하여 실제 IP 주소 확인 + InetAddress address = InetAddress.getByName(host); + + // Private IP 대역 차단 + if (address.isSiteLocalAddress()) { + // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 + log.warn("Private IP 접근 시도: {} -> {}", host, address.getHostAddress()); + throw new BusinessException(LinkErrorCode.INVALID_URL_PRIVATE_IP); + } + + // Loopback 주소 차단 (127.0.0.0/8) + if (address.isLoopbackAddress()) { + log.warn("Loopback IP 접근 시도: {} -> {}", host, address.getHostAddress()); + throw new BusinessException(LinkErrorCode.INVALID_URL_PRIVATE_IP); + } + + // Link-local 주소 차단 (169.254.0.0/16, AWS 메타데이터 서버 포함) + if (address.isLinkLocalAddress()) { + log.warn("Link-local IP 접근 시도: {} -> {}", host, address.getHostAddress()); + throw new BusinessException(LinkErrorCode.INVALID_URL_PRIVATE_IP); + } + + // AWS 메타데이터 서버 명시적 차단 + String ipAddress = address.getHostAddress(); + if (ipAddress.startsWith("169.254.169.254")) { + log.warn("AWS 메타데이터 서버 접근 시도: {}", ipAddress); + throw new BusinessException(LinkErrorCode.INVALID_URL_PRIVATE_IP); + } + } +} diff --git a/src/test/java/com/sofa/linkiving/domain/link/facade/LinkFacadeTest.java b/src/test/java/com/sofa/linkiving/domain/link/facade/LinkFacadeTest.java index bc090934..528a350a 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/facade/LinkFacadeTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/facade/LinkFacadeTest.java @@ -10,15 +10,19 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.sofa.linkiving.domain.link.dto.OgTagDto; import com.sofa.linkiving.domain.link.dto.response.LinkRes; +import com.sofa.linkiving.domain.link.dto.response.MetaScrapeRes; import com.sofa.linkiving.domain.link.dto.response.RecreateSummaryResponse; import com.sofa.linkiving.domain.link.entity.Summary; import com.sofa.linkiving.domain.link.enums.Format; import com.sofa.linkiving.domain.link.service.LinkService; import com.sofa.linkiving.domain.link.service.SummaryService; +import com.sofa.linkiving.domain.link.util.OgTagCrawler; import com.sofa.linkiving.domain.member.entity.Member; @ExtendWith(MockitoExtension.class) +@DisplayName("LinkFacade 단위 테스트") public class LinkFacadeTest { @InjectMocks @@ -30,6 +34,35 @@ public class LinkFacadeTest { @Mock private SummaryService summaryService; + @Mock + private OgTagCrawler ogTagCrawler; + + @Test + @DisplayName("메타데이터 크롤링 성공 시 정상적으로 MetaScrapeRes를 반환한다") + void shouldReturnMetaScrapeResWhenCrawlSucceeds() { + // given + String url = "https://velog.io/@jjeongdong/%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4"; + OgTagDto mockOgTag = OgTagDto.builder() + .title("동시성 제어") + .description("동시성 제어에 대한 설명") + .image("https://velog.io/images/thumbnail.png") + .url(url) + .build(); + + given(ogTagCrawler.crawl(url)).willReturn(mockOgTag); + + // when + MetaScrapeRes result = linkFacade.scrapeMetadata(url); + + // then + assertThat(result).isNotNull(); + assertThat(result.title()).isEqualTo("동시성 제어"); + assertThat(result.description()).isEqualTo("동시성 제어에 대한 설명"); + assertThat(result.image()).isEqualTo("https://velog.io/images/thumbnail.png"); + assertThat(result.url()).isEqualTo(url); + verify(ogTagCrawler, times(1)).crawl(url); + } + @Test @DisplayName("요약 재생성 및 비교 분석 성공 테스트") void shouldReturnRecreateSummaryResponseWhenRecreateSummary() { @@ -71,4 +104,23 @@ void shouldReturnRecreateSummaryResponseWhenRecreateSummary() { verify(summaryService).createSummary(linkId, url, format); verify(summaryService).comparisonSummary(existingSummaryBody, newSummaryBody); } + + @Test + @DisplayName("메타데이터 크롤링 실패 시 빈 값으로 MetaScrapeRes를 반환한다") + void shouldReturnEmptyMetaScrapeResWhenCrawlFails() { + // given + String url = "https://invalid-url.com"; + given(ogTagCrawler.crawl(url)).willReturn(OgTagDto.EMPTY); + + // when + MetaScrapeRes result = linkFacade.scrapeMetadata(url); + + // then + assertThat(result).isNotNull(); + assertThat(result.title()).isEmpty(); + assertThat(result.description()).isEmpty(); + assertThat(result.image()).isEmpty(); + assertThat(result.url()).isEmpty(); + verify(ogTagCrawler, times(1)).crawl(url); + } } diff --git a/src/test/java/com/sofa/linkiving/domain/link/integration/LinkApiIntegrationTest.java b/src/test/java/com/sofa/linkiving/domain/link/integration/LinkApiIntegrationTest.java index 7fa631b1..a4ad7472 100644 --- a/src/test/java/com/sofa/linkiving/domain/link/integration/LinkApiIntegrationTest.java +++ b/src/test/java/com/sofa/linkiving/domain/link/integration/LinkApiIntegrationTest.java @@ -26,6 +26,7 @@ import com.sofa.linkiving.domain.link.dto.request.LinkMemoUpdateReq; import com.sofa.linkiving.domain.link.dto.request.LinkTitleUpdateReq; import com.sofa.linkiving.domain.link.dto.request.LinkUpdateReq; +import com.sofa.linkiving.domain.link.dto.request.MetaScrapeReq; import com.sofa.linkiving.domain.link.entity.Link; import com.sofa.linkiving.domain.link.entity.Summary; import com.sofa.linkiving.domain.link.enums.Format; @@ -614,4 +615,74 @@ void shouldRecreateSummarySuccessfully() throws Exception { .andExpect(jsonPath("$.data.newSummary").value(newSummaryText)) .andExpect(jsonPath("$.data.comparison").value(comparisonText)); } + + @Test + @DisplayName("메타데이터 크롤링 성공 시 OG 태그 정보를 반환한다") + void shouldScrapeMetadataSuccessfully() throws Exception { + // given + MetaScrapeReq req = new MetaScrapeReq( + "https://velog.io/@jjeongdong/%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4"); + + // when & then + mockMvc.perform( + post(BASE_URL + "/meta-scrape") + .with(csrf()) + .with(user(testUserDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req)) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("메타 정보 수집 완료")) + .andExpect(jsonPath("$.data").exists()) + .andExpect(jsonPath("$.data.title").exists()) + .andExpect(jsonPath("$.data.description").exists()) + .andExpect(jsonPath("$.data.image").exists()) + .andExpect(jsonPath("$.data.url").exists()); + } + + @Test + @DisplayName("메타데이터 크롤링 실패 시 빈 값을 반환한다") + void shouldReturnEmptyWhenScrapeFails() throws Exception { + // given - OG 태그가 없는 URL + MetaScrapeReq req = new MetaScrapeReq("https://example.com"); + + // when & then + mockMvc.perform( + post(BASE_URL + "/meta-scrape") + .with(csrf()) + .with(user(testUserDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req)) + .accept(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("메타 정보 수집 완료")) + .andExpect(jsonPath("$.data.title").value("")) + .andExpect(jsonPath("$.data.description").value("")) + .andExpect(jsonPath("$.data.image").value("")) + .andExpect(jsonPath("$.data.url").value("")); + } + + @Test + @DisplayName("메타데이터 크롤링 시 URL이 누락되면 400 BAD_REQUEST 응답") + void shouldFailWhenMetaScrapeUrlIsMissing() throws Exception { + // given + String invalidJson = """ + { + } + """; + + // when & then + mockMvc.perform( + post(BASE_URL + "/meta-scrape") + .with(csrf()) + .with(user(testUserDetails)) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidJson) + ) + .andExpect(status().isBadRequest()); + } } diff --git a/src/test/java/com/sofa/linkiving/domain/link/util/UrlValidatorTest.java b/src/test/java/com/sofa/linkiving/domain/link/util/UrlValidatorTest.java new file mode 100644 index 00000000..67c3a35f --- /dev/null +++ b/src/test/java/com/sofa/linkiving/domain/link/util/UrlValidatorTest.java @@ -0,0 +1,180 @@ +package com.sofa.linkiving.domain.link.util; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.sofa.linkiving.domain.link.error.LinkErrorCode; +import com.sofa.linkiving.global.error.exception.BusinessException; + +class UrlValidatorTest { + + private final UrlValidator urlValidator = new UrlValidator(); + + @Test + @DisplayName("유효한 HTTP URL은 검증을 통과한다") + void validateSafeUrl_ValidHttpUrl_Success() { + // given + String validUrl = "http://example.com"; + + // when & then + assertThatCode(() -> urlValidator.validateSafeUrl(validUrl)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("유효한 HTTPS URL은 검증을 통과한다") + void validateSafeUrl_ValidHttpsUrl_Success() { + // given + String validUrl = "https://www.google.com"; + + // when & then + assertThatCode(() -> urlValidator.validateSafeUrl(validUrl)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("file 프로토콜은 차단된다") + void validateSafeUrl_FileProtocol_ThrowsException() { + // given + String fileUrl = "file:///etc/passwd"; + + // when & then + assertThatThrownBy(() -> urlValidator.validateSafeUrl(fileUrl)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", LinkErrorCode.INVALID_URL_PROTOCOL); + } + + @Test + @DisplayName("ftp 프로토콜은 차단된다") + void validateSafeUrl_FtpProtocol_ThrowsException() { + // given + String ftpUrl = "ftp://example.com"; + + // when & then + assertThatThrownBy(() -> urlValidator.validateSafeUrl(ftpUrl)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", LinkErrorCode.INVALID_URL_PROTOCOL); + } + + @Test + @DisplayName("localhost는 차단된다") + void validateSafeUrl_Localhost_ThrowsException() { + // given + String localhostUrl = "http://localhost:8080"; + + // when & then + assertThatThrownBy(() -> urlValidator.validateSafeUrl(localhostUrl)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", LinkErrorCode.INVALID_URL_PRIVATE_IP); + } + + @Test + @DisplayName("127.0.0.1은 차단된다") + void validateSafeUrl_Loopback127_ThrowsException() { + // given + String loopbackUrl = "http://127.0.0.1:8080"; + + // when & then + assertThatThrownBy(() -> urlValidator.validateSafeUrl(loopbackUrl)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", LinkErrorCode.INVALID_URL_PRIVATE_IP); + } + + @Test + @DisplayName("0.0.0.0은 차단된다") + void validateSafeUrl_ZeroAddress_ThrowsException() { + // given + String zeroUrl = "http://0.0.0.0:8080"; + + // when & then + assertThatThrownBy(() -> urlValidator.validateSafeUrl(zeroUrl)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", LinkErrorCode.INVALID_URL_PRIVATE_IP); + } + + @Test + @DisplayName("Private IP (10.x.x.x)는 차단된다") + void validateSafeUrl_PrivateIp10_ThrowsException() { + // given + String privateUrl = "http://10.0.0.1"; + + // when & then + assertThatThrownBy(() -> urlValidator.validateSafeUrl(privateUrl)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", LinkErrorCode.INVALID_URL_PRIVATE_IP); + } + + @Test + @DisplayName("Private IP (192.168.x.x)는 차단된다") + void validateSafeUrl_PrivateIp192_ThrowsException() { + // given + String privateUrl = "http://192.168.1.1"; + + // when & then + assertThatThrownBy(() -> urlValidator.validateSafeUrl(privateUrl)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", LinkErrorCode.INVALID_URL_PRIVATE_IP); + } + + @Test + @DisplayName("Private IP (172.16.x.x)는 차단된다") + void validateSafeUrl_PrivateIp172_ThrowsException() { + // given + String privateUrl = "http://172.16.0.1"; + + // when & then + assertThatThrownBy(() -> urlValidator.validateSafeUrl(privateUrl)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", LinkErrorCode.INVALID_URL_PRIVATE_IP); + } + + @Test + @DisplayName("AWS 메타데이터 서버 주소는 차단된다") + void validateSafeUrl_AwsMetadata_ThrowsException() { + // given + String awsMetadataUrl = "http://169.254.169.254/latest/meta-data/"; + + // when & then + assertThatThrownBy(() -> urlValidator.validateSafeUrl(awsMetadataUrl)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", LinkErrorCode.INVALID_URL_PRIVATE_IP); + } + + @Test + @DisplayName("잘못된 URL 형식은 예외가 발생한다") + void validateSafeUrl_MalformedUrl_ThrowsException() { + // given + String malformedUrl = "not-a-valid-url"; + + // when & then + assertThatThrownBy(() -> urlValidator.validateSafeUrl(malformedUrl)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", LinkErrorCode.INVALID_URL); + } + + @Test + @DisplayName("빈 URL은 예외가 발생한다") + void validateSafeUrl_EmptyUrl_ThrowsException() { + // given + String emptyUrl = ""; + + // when & then + assertThatThrownBy(() -> urlValidator.validateSafeUrl(emptyUrl)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", LinkErrorCode.INVALID_URL); + } + + @Test + @DisplayName("IPv6 loopback (::1)은 차단된다") + void validateSafeUrl_IPv6Loopback_ThrowsException() { + // given + String ipv6LoopbackUrl = "http://[::1]:8080"; + + // when & then + assertThatThrownBy(() -> urlValidator.validateSafeUrl(ipv6LoopbackUrl)) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", LinkErrorCode.INVALID_URL_PRIVATE_IP); + } +}