package com.ruoyi.office.service; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLSocketFactory; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.file.FileUtils; import com.ruoyi.office.domain.TtCode; import com.ruoyi.office.domain.TtFile; import com.ruoyi.office.mapper.TtCodeMapper; import com.ruoyi.office.mapper.TtFileMapper; import com.sun.net.httpserver.HttpServer; import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.mock.web.MockHttpServletResponse; import static org.mockito.Mockito.when; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.ArgumentMatchers.any; @RunWith(MockitoJUnitRunner.class) public class TtScreenshotArchiveServiceTest { @InjectMocks private TtScreenshotArchiveService service; @Mock private TtCodeMapper codeMapper; @Mock private TtFileMapper fileMapper; private HttpServer imageServer; private String imageBaseUrl; @Before public void setUp() throws IOException { imageServer = HttpServer.create(new InetSocketAddress(0), 0); imageServer.createContext("/home.png", exchange -> { byte[] content = "first-image".getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "image/png"); exchange.sendResponseHeaders(200, content.length); exchange.getResponseBody().write(content); exchange.close(); }); imageServer.createContext("/redirect-home", exchange -> { exchange.getResponseHeaders().set("Location", "/home.png"); exchange.sendResponseHeaders(301, -1); exchange.close(); }); imageServer.createContext("/redirect-loop", exchange -> { exchange.getResponseHeaders().set("Location", "/redirect-loop"); exchange.sendResponseHeaders(302, -1); exchange.close(); }); imageServer.createContext("/detail", exchange -> { byte[] content = "second-image".getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "image/jpeg"); exchange.sendResponseHeaders(200, content.length); exchange.getResponseBody().write(content); exchange.close(); }); imageServer.createContext("/中文截图.png", exchange -> { byte[] content = "unicode-image".getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "image/png"); exchange.sendResponseHeaders(200, content.length); exchange.getResponseBody().write(content); exchange.close(); }); imageServer.start(); imageBaseUrl = "http://127.0.0.1:" + imageServer.getAddress().getPort(); } @After public void tearDown() { imageServer.stop(0); } @Test public void downloadsScreenshotsWithStableNumberedNames() throws Exception { TtCode code = new TtCode(); code.setCodeId(9L); code.setCodeName("【K001】药店进销存系统"); TtFile first = screenshot("首页", imageBaseUrl + "/redirect-home"); TtFile second = screenshot("详情图.jpg", imageBaseUrl + "/detail"); when(codeMapper.selectTtCodeByCodeId(9L)).thenReturn(code); when(fileMapper.selectTtFileByCodeName(code.getCodeName())) .thenReturn(Arrays.asList(first, second)); MockHttpServletResponse response = new MockHttpServletResponse(); service.download(9L, response); Assert.assertEquals("application/zip", response.getContentType()); Assert.assertEquals(FileUtils.percentEncode("【K001】药店进销存系统截图.zip"), response.getHeader("download-filename")); List names = new ArrayList(); List contents = new ArrayList(); try (ZipInputStream zip = new ZipInputStream( new ByteArrayInputStream(response.getContentAsByteArray()), StandardCharsets.UTF_8)) { ZipEntry entry; while ((entry = zip.getNextEntry()) != null) { names.add(entry.getName()); contents.add(readText(zip)); } } Assert.assertEquals(Arrays.asList("01_首页.png", "02_详情图.jpg"), names); Assert.assertEquals(Arrays.asList("first-image", "second-image"), contents); } @Test public void rejectsProjectWithoutScreenshots() throws Exception { TtCode code = new TtCode(); code.setCodeName("无截图项目"); when(codeMapper.selectTtCodeByCodeId(10L)).thenReturn(code); when(fileMapper.selectTtFileByCodeName(code.getCodeName())).thenReturn(new ArrayList()); try { service.download(10L, new MockHttpServletResponse()); Assert.fail("应拒绝下载空压缩包"); } catch (ServiceException exception) { Assert.assertEquals("该项目暂无截图", exception.getMessage()); } } @Test public void downloadsScreenshotWithUnicodeUrlPath() throws Exception { TtCode code = new TtCode(); code.setCodeName("中文路径项目"); when(codeMapper.selectTtCodeByCodeId(14L)).thenReturn(code); when(fileMapper.selectTtFileByCodeName(code.getCodeName())) .thenReturn(Arrays.asList(screenshot("中文截图", imageBaseUrl + "/中文截图.png"))); MockHttpServletResponse response = new MockHttpServletResponse(); service.download(14L, response); try (ZipInputStream zip = new ZipInputStream( new ByteArrayInputStream(response.getContentAsByteArray()), StandardCharsets.UTF_8)) { Assert.assertEquals("01_中文截图.png", zip.getNextEntry().getName()); Assert.assertEquals("unicode-image", readText(zip)); } } @Test public void rejectsMissingProject() throws Exception { when(codeMapper.selectTtCodeByCodeId(12L)).thenReturn(null); try { service.download(12L, new MockHttpServletResponse()); Assert.fail("应拒绝下载不存在项目的截图"); } catch (ServiceException exception) { Assert.assertEquals("源码项目不存在", exception.getMessage()); } } @Test public void rejectsUnsupportedScreenshotUrl() throws Exception { TtCode code = new TtCode(); code.setCodeName("无效链接项目"); when(codeMapper.selectTtCodeByCodeId(13L)).thenReturn(code); when(fileMapper.selectTtFileByCodeName(code.getCodeName())) .thenReturn(Arrays.asList(screenshot("本地图片", "file:///tmp/image.png"))); try { service.download(13L, new MockHttpServletResponse()); Assert.fail("应拒绝非HTTP截图地址"); } catch (ServiceException exception) { Assert.assertEquals("截图“本地图片”仅支持HTTP或HTTPS图片链接", exception.getMessage()); } } @Test public void rejectsRedirectLoop() throws Exception { TtCode code = new TtCode(); code.setCodeName("循环跳转项目"); when(codeMapper.selectTtCodeByCodeId(11L)).thenReturn(code); when(fileMapper.selectTtFileByCodeName(code.getCodeName())) .thenReturn(Arrays.asList(screenshot("循环图片", imageBaseUrl + "/redirect-loop"))); try { service.download(11L, new MockHttpServletResponse()); Assert.fail("应拒绝循环重定向"); } catch (ServiceException exception) { Assert.assertEquals("截图“循环图片”重定向次数过多", exception.getMessage()); } } @Test public void sanitizesInvalidCharactersInScreenshotName() { Assert.assertEquals("03_功能_首页.webp", TtScreenshotArchiveService.buildEntryName( 3, "功能/首页", "https://example.com/image", "image/webp")); } @Test public void configuresCertificateBypassOnlyForHttpsConnections() { HttpsURLConnection https = mock(HttpsURLConnection.class); TtScreenshotArchiveService.disableCertificateValidation(https); verify(https).setSSLSocketFactory(any(SSLSocketFactory.class)); } private TtFile screenshot(String name, String url) { TtFile file = new TtFile(); file.setFileName(name); file.setFileUrl(url); return file; } private String readText(ZipInputStream input) throws IOException { ByteArrayOutputStream output = new ByteArrayOutputStream(); byte[] buffer = new byte[128]; int length; while ((length = input.read(buffer)) != -1) { output.write(buffer, 0, length); } return new String(output.toByteArray(), StandardCharsets.UTF_8); } }