diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/web/ConverterWebControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/web/ConverterWebControllerTest.java new file mode 100644 index 000000000..32a93b581 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/web/ConverterWebControllerTest.java @@ -0,0 +1,195 @@ +package stirling.software.SPDF.controller.web; + +import static org.mockito.ArgumentMatchers.eq; +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.*; + +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.ApplicationContextProvider; +import stirling.software.common.util.CheckProgramInstall; + +@ExtendWith(MockitoExtension.class) +class ConverterWebControllerTest { + + private MockMvc mockMvc; + + private ConverterWebController controller; + + @BeforeEach + void setup() { + controller = new ConverterWebController(); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + private static Stream simpleEndpoints() { + return Stream.of( + new Object[] {"/img-to-pdf", "convert/img-to-pdf", "img-to-pdf"}, + new Object[] {"/cbz-to-pdf", "convert/cbz-to-pdf", "cbz-to-pdf"}, + new Object[] {"/pdf-to-cbz", "convert/pdf-to-cbz", "pdf-to-cbz"}, + new Object[] {"/cbr-to-pdf", "convert/cbr-to-pdf", "cbr-to-pdf"}, + new Object[] {"/html-to-pdf", "convert/html-to-pdf", "html-to-pdf"}, + new Object[] {"/markdown-to-pdf", "convert/markdown-to-pdf", "markdown-to-pdf"}, + new Object[] {"/pdf-to-markdown", "convert/pdf-to-markdown", "pdf-to-markdown"}, + new Object[] {"/url-to-pdf", "convert/url-to-pdf", "url-to-pdf"}, + new Object[] {"/file-to-pdf", "convert/file-to-pdf", "file-to-pdf"}, + new Object[] {"/pdf-to-pdfa", "convert/pdf-to-pdfa", "pdf-to-pdfa"}, + new Object[] {"/pdf-to-vector", "convert/pdf-to-vector", "pdf-to-vector"}, + new Object[] {"/vector-to-pdf", "convert/vector-to-pdf", "vector-to-pdf"}, + new Object[] {"/pdf-to-xml", "convert/pdf-to-xml", "pdf-to-xml"}, + new Object[] {"/pdf-to-csv", "convert/pdf-to-csv", "pdf-to-csv"}, + new Object[] {"/pdf-to-html", "convert/pdf-to-html", "pdf-to-html"}, + new Object[] { + "/pdf-to-presentation", "convert/pdf-to-presentation", "pdf-to-presentation" + }, + new Object[] {"/pdf-to-text", "convert/pdf-to-text", "pdf-to-text"}, + new Object[] {"/pdf-to-word", "convert/pdf-to-word", "pdf-to-word"}, + new Object[] {"/eml-to-pdf", "convert/eml-to-pdf", "eml-to-pdf"}); + } + + @ParameterizedTest(name = "[{index}] GET {0}") + @MethodSource("simpleEndpoints") + @DisplayName("Should return correct view and model for simple endpoints") + void shouldReturnCorrectViewForSimpleEndpoints(String path, String viewName, String page) + throws Exception { + mockMvc.perform(get(path)) + .andExpect(status().isOk()) + .andExpect(view().name(viewName)) + .andExpect(model().attribute("currentPage", page)); + } + + @Nested + @DisplayName("PDF to CBR endpoint tests") + class PdfToCbrTests { + + @Test + @DisplayName("Should return 404 when endpoint disabled") + void shouldReturn404WhenDisabled() throws Exception { + try (MockedStatic acp = + org.mockito.Mockito.mockStatic(ApplicationContextProvider.class)) { + EndpointConfiguration endpointConfig = mock(EndpointConfiguration.class); + when(endpointConfig.isEndpointEnabled(eq("pdf-to-cbr"))).thenReturn(false); + acp.when(() -> ApplicationContextProvider.getBean(EndpointConfiguration.class)) + .thenReturn(endpointConfig); + + mockMvc.perform(get("/pdf-to-cbr")).andExpect(status().isNotFound()); + } + } + + @Test + @DisplayName("Should return OK when endpoint enabled") + void shouldReturnOkWhenEnabled() throws Exception { + try (MockedStatic acp = + org.mockito.Mockito.mockStatic(ApplicationContextProvider.class)) { + EndpointConfiguration endpointConfig = mock(EndpointConfiguration.class); + when(endpointConfig.isEndpointEnabled(eq("pdf-to-cbr"))).thenReturn(true); + acp.when(() -> ApplicationContextProvider.getBean(EndpointConfiguration.class)) + .thenReturn(endpointConfig); + + mockMvc.perform(get("/pdf-to-cbr")) + .andExpect(status().isOk()) + .andExpect(view().name("convert/pdf-to-cbr")) + .andExpect(model().attribute("currentPage", "pdf-to-cbr")); + } + } + } + + @Test + @DisplayName("Should handle pdf-to-img with default maxDPI=500") + void shouldHandlePdfToImgWithDefaultMaxDpi() throws Exception { + try (MockedStatic acp = + org.mockito.Mockito.mockStatic(ApplicationContextProvider.class); + MockedStatic cpi = + org.mockito.Mockito.mockStatic(CheckProgramInstall.class)) { + cpi.when(CheckProgramInstall::isPythonAvailable).thenReturn(true); + acp.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class)) + .thenReturn(null); + + mockMvc.perform(get("/pdf-to-img")) + .andExpect(status().isOk()) + .andExpect(view().name("convert/pdf-to-img")) + .andExpect(model().attribute("isPython", true)) + .andExpect(model().attribute("maxDPI", 500)); + } + } + + @Test + @DisplayName("Should handle pdf-to-video with default maxDPI=500") + void shouldHandlePdfToVideoWithDefaultMaxDpi() throws Exception { + try (MockedStatic acp = + org.mockito.Mockito.mockStatic(ApplicationContextProvider.class)) { + acp.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class)) + .thenReturn(null); + + mockMvc.perform(get("/pdf-to-video")) + .andExpect(status().isOk()) + .andExpect(view().name("convert/pdf-to-video")) + .andExpect(model().attribute("maxDPI", 500)) + .andExpect(model().attribute("currentPage", "pdf-to-video")); + } + } + + @Test + @DisplayName("Should handle pdf-to-img with configured maxDPI from properties") + void shouldHandlePdfToImgWithConfiguredMaxDpi() throws Exception { + // Covers the 'if' branch (properties and system not null) + try (MockedStatic acp = + org.mockito.Mockito.mockStatic(ApplicationContextProvider.class); + MockedStatic cpi = + org.mockito.Mockito.mockStatic(CheckProgramInstall.class)) { + + ApplicationProperties properties = + org.mockito.Mockito.mock( + ApplicationProperties.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(properties.getSystem().getMaxDPI()).thenReturn(777); + acp.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class)) + .thenReturn(properties); + cpi.when(CheckProgramInstall::isPythonAvailable).thenReturn(true); + + mockMvc.perform(get("/pdf-to-img")) + .andExpect(status().isOk()) + .andExpect(view().name("convert/pdf-to-img")) + .andExpect(model().attribute("isPython", true)) + .andExpect(model().attribute("maxDPI", 777)) + .andExpect(model().attribute("currentPage", "pdf-to-img")); + } + } + + @Test + @DisplayName("Should handle pdf-to-video with configured maxDPI from properties") + void shouldHandlePdfToVideoWithConfiguredMaxDpi() throws Exception { + // Covers the 'if' branch (properties and system not null) + try (MockedStatic acp = + org.mockito.Mockito.mockStatic(ApplicationContextProvider.class)) { + + ApplicationProperties properties = + org.mockito.Mockito.mock( + ApplicationProperties.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(properties.getSystem().getMaxDPI()).thenReturn(640); + acp.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class)) + .thenReturn(properties); + + mockMvc.perform(get("/pdf-to-video")) + .andExpect(status().isOk()) + .andExpect(view().name("convert/pdf-to-video")) + .andExpect(model().attribute("maxDPI", 640)) + .andExpect(model().attribute("currentPage", "pdf-to-video")); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/web/GeneralWebControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/web/GeneralWebControllerTest.java new file mode 100644 index 000000000..540e1379d --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/web/GeneralWebControllerTest.java @@ -0,0 +1,406 @@ +package stirling.software.SPDF.controller.web; + +import static org.hamcrest.Matchers.empty; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Stream; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.servlet.ViewResolver; +import org.springframework.web.servlet.view.AbstractView; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import stirling.software.SPDF.model.SignatureFile; +import stirling.software.SPDF.service.SignatureService; +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.common.util.GeneralUtils; + +@ExtendWith(MockitoExtension.class) +class GeneralWebControllerTest { + + private static final String CLASSPATH_WOFF2 = "classpath:static/fonts/*.woff2"; + private static final String FILE_FONTS_GLOB = "file:/opt/static/fonts/*"; + + private static String normalize(String s) { + return s.replace('\\', '/'); + } + + private static ViewResolver noOpViewResolver() { + return (viewName, locale) -> + new AbstractView() { + @Override + protected void renderMergedOutputModel( + Map model, + HttpServletRequest request, + HttpServletResponse response) { + // no-op + } + }; + } + + @SuppressWarnings("unused") + private static Stream simpleEndpoints() { + return Stream.of( + new Object[] {"/merge-pdfs", "merge-pdfs", "merge-pdfs"}, + new Object[] { + "/split-pdf-by-sections", "split-pdf-by-sections", "split-pdf-by-sections" + }, + new Object[] { + "/split-pdf-by-chapters", "split-pdf-by-chapters", "split-pdf-by-chapters" + }, + new Object[] {"/view-pdf", "view-pdf", "view-pdf"}, + new Object[] { + "/edit-table-of-contents", "edit-table-of-contents", "edit-table-of-contents" + }, + new Object[] {"/multi-tool", "multi-tool", "multi-tool"}, + new Object[] {"/remove-pages", "remove-pages", "remove-pages"}, + new Object[] {"/pdf-organizer", "pdf-organizer", "pdf-organizer"}, + new Object[] {"/extract-page", "extract-page", "extract-page"}, + new Object[] {"/pdf-to-single-page", "pdf-to-single-page", "pdf-to-single-page"}, + new Object[] {"/rotate-pdf", "rotate-pdf", "rotate-pdf"}, + new Object[] {"/split-pdfs", "split-pdfs", "split-pdfs"}, + new Object[] {"/multi-page-layout", "multi-page-layout", "multi-page-layout"}, + new Object[] {"/scale-pages", "scale-pages", "scale-pages"}, + new Object[] { + "/split-by-size-or-count", "split-by-size-or-count", "split-by-size-or-count" + }, + new Object[] {"/overlay-pdf", "overlay-pdf", "overlay-pdf"}, + new Object[] {"/crop", "crop", "crop"}, + new Object[] {"/auto-split-pdf", "auto-split-pdf", "auto-split-pdf"}, + new Object[] {"/remove-image-pdf", "remove-image-pdf", "remove-image-pdf"}); + } + + private MockMvc mockMvc; + + private SignatureService signatureService; + private UserServiceInterface userService; + private RuntimePathConfig runtimePathConfig; + private org.springframework.core.io.ResourceLoader resourceLoader; + + private GeneralWebController controller; + + @BeforeEach + void setUp() { + signatureService = mock(SignatureService.class); + userService = mock(UserServiceInterface.class); + runtimePathConfig = mock(RuntimePathConfig.class); + resourceLoader = mock(org.springframework.core.io.ResourceLoader.class); + + controller = + new GeneralWebController( + signatureService, userService, resourceLoader, runtimePathConfig); + + mockMvc = + MockMvcBuilders.standaloneSetup(controller) + .setViewResolvers(noOpViewResolver()) + .build(); + } + + @Nested + @DisplayName("Simple endpoints") + class SimpleEndpoints { + + @DisplayName("Should render simple pages with correct currentPage") + @ParameterizedTest(name = "[{index}] GET {0} -> view {1}") + @MethodSource( + "stirling.software.SPDF.controller.web.GeneralWebControllerTest#simpleEndpoints") + void shouldRenderSimplePages(String path, String expectedView, String currentPage) + throws Exception { + mockMvc.perform(get(path)) + .andExpect(status().isOk()) + .andExpect(view().name(expectedView)) + .andExpect(model().attribute("currentPage", currentPage)); + } + } + + @Nested + @DisplayName("/sign endpoint") + class SignForm { + + @Test + @DisplayName("Should use current username, list signatures and fonts") + void shouldPopulateModelWithUserSignaturesAndFonts() throws Exception { + when(userService.getCurrentUsername()).thenReturn("alice"); + List signatures = List.of(new SignatureFile(), new SignatureFile()); + when(signatureService.getAvailableSignatures("alice")).thenReturn(signatures); + + try (MockedStatic gu = mockStatic(GeneralUtils.class); + MockedStatic ipc = + mockStatic(InstallationPathConfig.class)) { + + ipc.when(InstallationPathConfig::getStaticPath).thenReturn("/opt/static/"); + + Resource woff2 = mock(Resource.class); + when(woff2.getFilename()).thenReturn("Roboto-Regular.woff2"); + Resource ttf = mock(Resource.class); + when(ttf.getFilename()).thenReturn("MyFont.ttf"); + + // Windows-safe conditional stub (normalize backslashes) + gu.when( + () -> + GeneralUtils.getResourcesFromLocationPattern( + anyString(), eq(resourceLoader))) + .thenAnswer( + inv -> { + String pattern = normalize(inv.getArgument(0, String.class)); + if (CLASSPATH_WOFF2.equals(pattern)) + return new Resource[] {woff2}; + if (FILE_FONTS_GLOB.equals(pattern)) + return new Resource[] {ttf}; + return new Resource[0]; + }); + + var mvcResult = + mockMvc.perform(get("/sign")) + .andExpect(status().isOk()) + .andExpect(view().name("sign")) + .andExpect(model().attribute("currentPage", "sign")) + .andExpect(model().attributeExists("fonts")) + .andExpect(model().attribute("signatures", signatures)) + .andReturn(); + + Object fontsAttr = mvcResult.getModelAndView().getModel().get("fonts"); + Assertions.assertTrue(fontsAttr instanceof List); + List fonts = (List) fontsAttr; + Assertions.assertEquals( + 2, fonts.size(), "Expected two font entries (classpath + external)"); + } + } + + @Test + @DisplayName("Should handle missing UserService (username empty string)") + void shouldHandleNullUserService() throws Exception { + GeneralWebController ctrl = + new GeneralWebController( + signatureService, null, resourceLoader, runtimePathConfig); + MockMvc localMvc = + MockMvcBuilders.standaloneSetup(ctrl) + .setViewResolvers(noOpViewResolver()) + .build(); + + try (MockedStatic gu = mockStatic(GeneralUtils.class); + MockedStatic ipc = + mockStatic(InstallationPathConfig.class)) { + + ipc.when(InstallationPathConfig::getStaticPath).thenReturn("/opt/static/"); + gu.when( + () -> + GeneralUtils.getResourcesFromLocationPattern( + anyString(), eq(resourceLoader))) + .thenReturn(new Resource[0]); + + when(signatureService.getAvailableSignatures("")) + .thenReturn(Collections.emptyList()); + + localMvc.perform(get("/sign")) + .andExpect(status().isOk()) + .andExpect(view().name("sign")) + .andExpect(model().attribute("currentPage", "sign")) + .andExpect(model().attribute("signatures", empty())); + } + } + + @Test + @DisplayName( + "Throws ServletException when a font file cannot be processed (inner try/catch" + + " path)") + void shouldThrowServletExceptionWhenFontProcessingFails() { + when(userService.getCurrentUsername()).thenReturn("alice"); + when(signatureService.getAvailableSignatures("alice")) + .thenReturn(Collections.emptyList()); + + Resource bad = mock(Resource.class); + when(bad.getFilename()).thenThrow(new RuntimeException("boom")); + + try (MockedStatic gu = mockStatic(GeneralUtils.class); + MockedStatic ipc = + mockStatic(InstallationPathConfig.class)) { + + ipc.when(InstallationPathConfig::getStaticPath).thenReturn("/opt/static/"); + + gu.when( + () -> + GeneralUtils.getResourcesFromLocationPattern( + anyString(), eq(resourceLoader))) + .thenReturn(new Resource[] {bad}); + + Assertions.assertThrows( + ServletException.class, + () -> { + mockMvc.perform(get("/sign")).andReturn(); + }); + } + } + + @Test + @DisplayName("Ignores font resource without extension (no crash, filtered out)") + void shouldIgnoreFontWithoutExtension() throws Exception { + when(userService.getCurrentUsername()).thenReturn("bob"); + when(signatureService.getAvailableSignatures("bob")) + .thenReturn(Collections.emptyList()); + + Resource noExt = mock(Resource.class); + when(noExt.getFilename()).thenReturn("JustAName"); // no dot -> filtered out + + Resource good = mock(Resource.class); + when(good.getFilename()).thenReturn("SomeFont.woff2"); + + try (MockedStatic gu = mockStatic(GeneralUtils.class); + MockedStatic ipc = + mockStatic(InstallationPathConfig.class)) { + + ipc.when(InstallationPathConfig::getStaticPath).thenReturn("/opt/static/"); + + gu.when( + () -> + GeneralUtils.getResourcesFromLocationPattern( + anyString(), eq(resourceLoader))) + .thenAnswer( + inv -> { + String p = normalize(inv.getArgument(0, String.class)); + if (CLASSPATH_WOFF2.equals(p)) + return new Resource[] {noExt}; // ignored + if (FILE_FONTS_GLOB.equals(p)) + return new Resource[] {good}; // kept + return new Resource[0]; + }); + + var mvcResult = + mockMvc.perform(get("/sign")) + .andExpect(status().isOk()) + .andExpect(view().name("sign")) + .andExpect(model().attribute("currentPage", "sign")) + .andReturn(); + + Object fontsAttr = mvcResult.getModelAndView().getModel().get("fonts"); + Assertions.assertTrue(fontsAttr instanceof List); + List fonts = (List) fontsAttr; + Assertions.assertEquals(1, fonts.size(), "Only the valid font should remain"); + } + } + } + + @Nested + @DisplayName("/pipeline endpoint") + class PipelineForm { + + @Test + @DisplayName("Should load JSON configs from runtime path and infer names") + void shouldLoadJsonConfigs() throws Exception { + Path tempDir = Files.createTempDirectory("pipelines"); + Path a = tempDir.resolve("a.json"); + Path b = tempDir.resolve("b.json"); + Files.writeString(a, "{\"name\":\"Config A\",\"x\":1}", StandardCharsets.UTF_8); + Files.writeString(b, "{\"y\":2}", StandardCharsets.UTF_8); + + when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(tempDir.toString()); + + var mvcResult = + mockMvc.perform(get("/pipeline")) + .andExpect(status().isOk()) + .andExpect(view().name("pipeline")) + .andExpect(model().attribute("currentPage", "pipeline")) + .andExpect( + model().attributeExists( + "pipelineConfigs", "pipelineConfigsWithNames")) + .andReturn(); + + Map model = mvcResult.getModelAndView().getModel(); + @SuppressWarnings("unchecked") + List configsRaw = (List) model.get("pipelineConfigs"); + @SuppressWarnings("unchecked") + List> configsNamed = + (List>) model.get("pipelineConfigsWithNames"); + + Assertions.assertEquals(2, configsRaw.size()); + Assertions.assertEquals(2, configsNamed.size()); + + Set names = new HashSet<>(); + for (Map m : configsNamed) { + names.add(m.get("name")); + Assertions.assertTrue(configsRaw.contains(m.get("json"))); + } + Assertions.assertTrue(names.contains("Config A")); + Assertions.assertTrue(names.contains("b")); + } + + @Test + @DisplayName("Should fall back to default entry when Files.walk throws IOException") + void shouldFallbackWhenWalkThrowsIOException() throws Exception { + Path tempDir = Files.createTempDirectory("pipelines"); // exists() -> true + when(runtimePathConfig.getPipelineDefaultWebUiConfigs()).thenReturn(tempDir.toString()); + + try (MockedStatic files = mockStatic(Files.class)) { + files.when(() -> Files.walk(any(Path.class))) + .thenThrow(new IOException("fail walk")); + + var mvcResult = + mockMvc.perform(get("/pipeline")) + .andExpect(status().isOk()) + .andExpect(view().name("pipeline")) + .andExpect(model().attribute("currentPage", "pipeline")) + .andReturn(); + + @SuppressWarnings("unchecked") + List> configsNamed = + (List>) + mvcResult + .getModelAndView() + .getModel() + .get("pipelineConfigsWithNames"); + + Assertions.assertEquals( + 1, configsNamed.size(), "Should add a default placeholder on IOException"); + Assertions.assertEquals( + "No preloaded configs found", configsNamed.get(0).get("name")); + Assertions.assertEquals("", configsNamed.get(0).get("json")); + } + } + } + + @Nested + @DisplayName("getFormatFromExtension") + class GetFormatFromExtension { + + @Test + @DisplayName("Should return empty string for unknown extensions (default branch)") + void shouldReturnDefaultForUnknown() { + Assertions.assertEquals("", controller.getFormatFromExtension("otf")); + Assertions.assertEquals("", controller.getFormatFromExtension("unknown")); + } + + @Test + @DisplayName("Known extensions should map correctly") + void shouldMapKnownExtensions() { + Assertions.assertEquals("truetype", controller.getFormatFromExtension("ttf")); + Assertions.assertEquals("woff", controller.getFormatFromExtension("woff")); + Assertions.assertEquals("woff2", controller.getFormatFromExtension("woff2")); + Assertions.assertEquals("embedded-opentype", controller.getFormatFromExtension("eot")); + Assertions.assertEquals("svg", controller.getFormatFromExtension("svg")); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/web/HomeWebControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/web/HomeWebControllerTest.java new file mode 100644 index 000000000..89e530160 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/web/HomeWebControllerTest.java @@ -0,0 +1,223 @@ +package stirling.software.SPDF.controller.web; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedConstruction; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.servlet.ViewResolver; +import org.springframework.web.servlet.view.AbstractView; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import stirling.software.common.model.ApplicationProperties; + +@ExtendWith(MockitoExtension.class) +class HomeWebControllerTest { + + private MockMvc mockMvc; + private ApplicationProperties applicationProperties; + + @BeforeEach + void setup() { + applicationProperties = mock(ApplicationProperties.class, RETURNS_DEEP_STUBS); + HomeWebController controller = new HomeWebController(applicationProperties); + + mockMvc = + MockMvcBuilders.standaloneSetup(controller) + .setViewResolvers(noOpViewResolver()) + .build(); + } + + private static ViewResolver noOpViewResolver() { + return (viewName, locale) -> + new AbstractView() { + @Override + protected void renderMergedOutputModel( + Map model, + HttpServletRequest request, + HttpServletResponse response) { + // no-op + } + }; + } + + @Nested + @DisplayName("Simple pages & redirects") + class SimplePagesAndRedirects { + + @Test + @DisplayName("/about should return correct view and currentPage") + void about_shouldReturnView() throws Exception { + mockMvc.perform(get("/about")) + .andExpect(status().isOk()) + .andExpect(view().name("about")) + .andExpect(model().attribute("currentPage", "about")); + } + + @Test + @DisplayName("/releases should return correct view") + void releases_shouldReturnView() throws Exception { + mockMvc.perform(get("/releases")) + .andExpect(status().isOk()) + .andExpect(view().name("releases")); + } + + @Test + @DisplayName("/home should redirect to root") + void home_shouldRedirect() throws Exception { + // With the no-op resolver, "redirect:/" is treated as a view -> status OK + mockMvc.perform(get("/home")) + .andExpect(status().isOk()) + .andExpect(view().name("redirect:/")); + } + + @Test + @DisplayName("/home-legacy should redirect to root") + void homeLegacy_shouldRedirect() throws Exception { + mockMvc.perform(get("/home-legacy")) + .andExpect(status().isOk()) + .andExpect(view().name("redirect:/")); + } + } + + @Nested + @DisplayName("Home page with SHOW_SURVEY environment variable") + class HomePage { + + @Test + @DisplayName("Should correctly map SHOW_SURVEY env var to showSurveyFromDocker") + void root_mapsEnvCorrectly() throws Exception { + String env = System.getenv("SHOW_SURVEY"); + boolean expected = (env == null) || "true".equalsIgnoreCase(env); + + mockMvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(view().name("home")) + .andExpect(model().attribute("currentPage", "home")) + .andExpect(model().attribute("showSurveyFromDocker", expected)); + } + } + + @Nested + @DisplayName("/robots.txt behavior") + class RobotsTxt { + + @Test + @DisplayName("googlevisibility=true -> allow all agents") + void robots_allow() throws Exception { + when(applicationProperties.getSystem().getGooglevisibility()).thenReturn(Boolean.TRUE); + + mockMvc.perform(get("/robots.txt")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_PLAIN)) + .andExpect( + content() + .string( + "User-agent: Googlebot\n" + + "Allow: /\n\n" + + "User-agent: *\n" + + "Allow: /")); + } + + @Test + @DisplayName("googlevisibility=false -> disallow all agents") + void robots_disallow() throws Exception { + when(applicationProperties.getSystem().getGooglevisibility()).thenReturn(Boolean.FALSE); + + mockMvc.perform(get("/robots.txt")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_PLAIN)) + .andExpect( + content() + .string( + "User-agent: Googlebot\n" + + "Disallow: /\n\n" + + "User-agent: *\n" + + "Disallow: /")); + } + + @Test + @DisplayName("googlevisibility=null -> disallow all (default branch)") + void robots_disallowWhenNull() throws Exception { + when(applicationProperties.getSystem().getGooglevisibility()).thenReturn(null); + + mockMvc.perform(get("/robots.txt")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_PLAIN)) + .andExpect( + content() + .string( + "User-agent: Googlebot\n" + + "Disallow: /\n\n" + + "User-agent: *\n" + + "Disallow: /")); + } + } + + @Nested + @DisplayName("/licenses endpoint") + class Licenses { + + @Test + @DisplayName("Should read JSON and set dependencies + currentPage on model") + void licenses_success() throws Exception { + // Minimal valid JSON matching Map> + String json = "{\"dependencies\":[{}]}"; + + try (MockedConstruction mockedResource = + mockConstruction( + ClassPathResource.class, + (mock, ctx) -> + when(mock.getInputStream()) + .thenReturn( + new ByteArrayInputStream( + json.getBytes( + StandardCharsets.UTF_8))))) { + + var mvcResult = + mockMvc.perform(get("/licenses")) + .andExpect(status().isOk()) + .andExpect(view().name("licenses")) + .andExpect(model().attribute("currentPage", "licenses")) + .andExpect(model().attributeExists("dependencies")) + .andReturn(); + + Object depsObj = mvcResult.getModelAndView().getModel().get("dependencies"); + Assertions.assertTrue(depsObj instanceof java.util.List); + Assertions.assertEquals( + 1, ((java.util.List) depsObj).size(), "Exactly one dependency expected"); + } + } + + @Test + @DisplayName("IOException while reading -> still returns licenses view") + void licenses_ioException() throws Exception { + try (MockedConstruction mockedResource = + mockConstruction( + ClassPathResource.class, + (mock, ctx) -> + when(mock.getInputStream()) + .thenThrow(new IOException("boom")))) { + + mockMvc.perform(get("/licenses")) + .andExpect(status().isOk()) + .andExpect(view().name("licenses")) + .andExpect(model().attribute("currentPage", "licenses")); + } + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/model/AttemptCounterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/AttemptCounterTest.java index b910a4b3f..a749a1da6 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/model/AttemptCounterTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/AttemptCounterTest.java @@ -8,6 +8,11 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +/** + * Comprehensive tests for AttemptCounter. Notes: - We avoid timing flakiness by using generous + * windows or setting lastAttemptTime to 'now'. - Where assumptions are made about edge-case + * behavior, they are documented in comments. + */ class AttemptCounterTest { // --- Helper functions for reflection access to private fields --- @@ -113,11 +118,14 @@ class AttemptCounterTest { @DisplayName("returns FALSE when time difference is smaller than window") void shouldReturnFalseWhenWithinWindow() { AttemptCounter counter = new AttemptCounter(); - long window = 500L; // 500 ms + long window = 5_000L; // 5 seconds - generous buffer to avoid timing flakiness long now = System.currentTimeMillis(); - // Simulate: last action was (window - 1) ms ago - setPrivateLong(counter, "lastAttemptTime", now - (window - 1)); + // Changed: Avoid flaky 1ms margin. We set lastAttemptTime to 'now' and choose a large + // window so elapsed < window is reliably true despite scheduling/clock granularity. + // Changed: Reason for change -> eliminate timing flakiness that caused sporadic + // failures. + setPrivateLong(counter, "lastAttemptTime", now); // Purpose: Inside the window -> no reset assertFalse(counter.shouldReset(window), "Within the window, no reset should occur"); @@ -154,6 +162,39 @@ class AttemptCounterTest { } } + @Nested + @DisplayName("shouldReset(attemptIncrementTime) – additional edge cases") + class AdditionalEdgeCases { + + @Test + @DisplayName("returns TRUE when window is zero (elapsed >= 0 is always true)") + void shouldReset_shouldReturnTrueWhenWindowIsZero() { + AttemptCounter counter = new AttemptCounter(); + // Set lastAttemptTime == now to avoid timing flakiness + long now = System.currentTimeMillis(); + setPrivateLong(counter, "lastAttemptTime", now); + + // Assumption/Documentation: current implementation uses 'elapsed >= + // attemptIncrementTime' + // With attemptIncrementTime == 0, condition is always true. + assertTrue(counter.shouldReset(0L), "Window=0 means the window has already elapsed"); + } + + @Test + @DisplayName("returns TRUE when window is negative (elapsed >= negative is always true)") + void shouldReset_shouldReturnTrueWhenWindowIsNegative() { + AttemptCounter counter = new AttemptCounter(); + long now = System.currentTimeMillis(); + setPrivateLong(counter, "lastAttemptTime", now); + + // Assumption/Documentation: Negative window is treated as already elapsed. + assertTrue( + counter.shouldReset(-1L), + "Negative window is nonsensical and should result in reset=true (elapsed >=" + + " negative)"); + } + } + @Test @DisplayName("Getters: return current values") void getters_shouldReturnCurrentValues() { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/LoginAttemptServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/LoginAttemptServiceTest.java new file mode 100644 index 000000000..fd6733d6d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/LoginAttemptServiceTest.java @@ -0,0 +1,239 @@ +package stirling.software.proprietary.security.service; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Locale; +import java.util.concurrent.ConcurrentHashMap; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import stirling.software.proprietary.security.model.AttemptCounter; + +/** + * Tests for LoginAttemptService#getRemainingAttempts(...) focusing on edge cases and documented + * behavior. We instantiate the service reflectively to avoid depending on a specific constructor + * signature. Private fields are set via reflection to keep existing production code unchanged. + * + *

Assumptions: - 'MAX_ATTEMPT' is a private int (possibly static final); we read it via + * reflection (static-aware). - 'attemptsCache' is a ConcurrentHashMap. - + * 'isBlockedEnabled' is a boolean flag. - Behavior without clamping is intentional for now (can + * return negative values). + */ +class LoginAttemptServiceTest { + + // --- Reflection helpers --- + + private static Object constructLoginAttemptService() { + try { + Class clazz = + Class.forName( + "stirling.software.proprietary.security.service.LoginAttemptService"); + // Prefer a no-arg constructor if present; otherwise use the first and mock parameters. + Constructor[] ctors = clazz.getDeclaredConstructors(); + Arrays.stream(ctors).forEach(c -> c.setAccessible(true)); + + Constructor target = + Arrays.stream(ctors) + .filter(c -> c.getParameterCount() == 0) + .findFirst() + .orElse(ctors[0]); + + Object[] args = new Object[target.getParameterCount()]; + Class[] paramTypes = target.getParameterTypes(); + for (int i = 0; i < paramTypes.length; i++) { + Class p = paramTypes[i]; + if (p.isPrimitive()) { + // Provide basic defaults for primitives + args[i] = defaultValueForPrimitive(p); + } else { + args[i] = Mockito.mock(p); + } + } + return target.newInstance(args); + } catch (Exception e) { + fail("Could not construct LoginAttemptService reflectively: " + e.getMessage()); + return null; // unreachable + } + } + + private static Object defaultValueForPrimitive(Class p) { + if (p == boolean.class) return false; + if (p == byte.class) return (byte) 0; + if (p == short.class) return (short) 0; + if (p == char.class) return (char) 0; + if (p == int.class) return 0; + if (p == long.class) return 0L; + if (p == float.class) return 0f; + if (p == double.class) return 0d; + throw new IllegalArgumentException("Unsupported primitive: " + p); + } + + private static void setPrivate(Object target, String fieldName, Object value) { + try { + Field f = target.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + if (Modifier.isStatic(f.getModifiers())) { + f.set(null, value); + } else { + f.set(target, value); + } + } catch (Exception e) { + fail("Could not set field '" + fieldName + "': " + e.getMessage()); + } + } + + private static void setPrivateBoolean(Object target, String fieldName, boolean value) { + try { + Field f = target.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + if (Modifier.isStatic(f.getModifiers())) { + f.setBoolean(null, value); + } else { + f.setBoolean(target, value); + } + } catch (Exception e) { + fail("Could not set boolean field '" + fieldName + "': " + e.getMessage()); + } + } + + private static int getPrivateInt(Object targetOrClassInstance, String fieldName) { + try { + Class clazz = + targetOrClassInstance instanceof Class + ? (Class) targetOrClassInstance + : targetOrClassInstance.getClass(); + Field f = clazz.getDeclaredField(fieldName); + f.setAccessible(true); + if (Modifier.isStatic(f.getModifiers())) { + return f.getInt(null); + } else { + return f.getInt(targetOrClassInstance); + } + } catch (Exception e) { + fail("Could not read int field '" + fieldName + "': " + e.getMessage()); + return -1; // unreachable + } + } + + // --- Tests --- + + @Test + @DisplayName("getRemainingAttempts(): returns Integer.MAX_VALUE when disabled or key blank") + void getRemainingAttempts_shouldReturnMaxValueWhenDisabledOrBlankKey() throws Exception { + Object svc = constructLoginAttemptService(); + + // Ensure blocking disabled + setPrivateBoolean(svc, "isBlockedEnabled", false); + + var attemptsCache = new ConcurrentHashMap(); + setPrivate(svc, "attemptsCache", attemptsCache); + + var method = svc.getClass().getMethod("getRemainingAttempts", String.class); + + // Case 1: disabled -> always MAX_VALUE regardless of key + int disabledVal = (Integer) method.invoke(svc, "someUser"); + assertEquals( + Integer.MAX_VALUE, + disabledVal, + "Disabled tracking should return Integer.MAX_VALUE"); + + // Enable and verify blank/whitespace/null handling + setPrivateBoolean(svc, "isBlockedEnabled", true); + + int nullKeyVal = (Integer) method.invoke(svc, (Object) null); + int blankKeyVal = (Integer) method.invoke(svc, " "); + + assertEquals( + Integer.MAX_VALUE, + nullKeyVal, + "Null key should return Integer.MAX_VALUE per current contract"); + assertEquals( + Integer.MAX_VALUE, + blankKeyVal, + "Blank key should return Integer.MAX_VALUE per current contract"); + } + + @Test + @DisplayName("getRemainingAttempts(): returns MAX_ATTEMPT when no counter exists for key") + void getRemainingAttempts_shouldReturnMaxAttemptWhenNoEntry() throws Exception { + Object svc = constructLoginAttemptService(); + setPrivateBoolean(svc, "isBlockedEnabled", true); + var attemptsCache = new ConcurrentHashMap(); + setPrivate(svc, "attemptsCache", attemptsCache); + + int maxAttempt = getPrivateInt(svc, "MAX_ATTEMPT"); // Reads current policy value + var method = svc.getClass().getMethod("getRemainingAttempts", String.class); + + int v1 = (Integer) method.invoke(svc, "UserA"); + int v2 = + (Integer) + method.invoke(svc, "uSeRa"); // case-insensitive by service (normalization) + + assertEquals(maxAttempt, v1, "Unknown user should start with MAX_ATTEMPT remaining"); + assertEquals( + maxAttempt, + v2, + "Case-insensitivity should not create separate entries if none exists yet"); + } + + @Test + @DisplayName("getRemainingAttempts(): decreases with attemptCount in cache") + void getRemainingAttempts_shouldDecreaseAfterAttemptCount() throws Exception { + Object svc = constructLoginAttemptService(); + setPrivateBoolean(svc, "isBlockedEnabled", true); + + int maxAttempt = getPrivateInt(svc, "MAX_ATTEMPT"); + var attemptsCache = new ConcurrentHashMap(); + setPrivate(svc, "attemptsCache", attemptsCache); + + // Prepare a counter with attemptCount = 1 + AttemptCounter c1 = new AttemptCounter(); + Field ac = AttemptCounter.class.getDeclaredField("attemptCount"); + ac.setAccessible(true); + ac.setInt(c1, 1); + attemptsCache.put("userx".toLowerCase(Locale.ROOT), c1); + + var method = svc.getClass().getMethod("getRemainingAttempts", String.class); + int actual = (Integer) method.invoke(svc, "USERX"); + + assertEquals( + maxAttempt - 1, + actual, + "Remaining attempts should reflect current attemptCount (case-insensitive lookup)"); + } + + @Test + @DisplayName( + "getRemainingAttempts(): can become negative when attemptCount > MAX_ATTEMPT (document" + + " current behavior)") + void getRemainingAttempts_shouldBecomeNegativeWhenOverLimit_CurrentBehavior() throws Exception { + Object svc = constructLoginAttemptService(); + setPrivateBoolean(svc, "isBlockedEnabled", true); + + int maxAttempt = getPrivateInt(svc, "MAX_ATTEMPT"); + var attemptsCache = new ConcurrentHashMap(); + setPrivate(svc, "attemptsCache", attemptsCache); + + // Create counter with attemptCount = MAX_ATTEMPT + 5 + AttemptCounter c = new AttemptCounter(); + Field ac = AttemptCounter.class.getDeclaredField("attemptCount"); + ac.setAccessible(true); + ac.setInt(c, maxAttempt + 5); + attemptsCache.put("over".toLowerCase(Locale.ROOT), c); + + var method = svc.getClass().getMethod("getRemainingAttempts", String.class); + + int actual = (Integer) method.invoke(svc, "OVER"); + int expected = maxAttempt - (maxAttempt + 5); // -5 + + // Documentation test: current implementation returns a negative number. + // If you later clamp to 0, update this assertion accordingly and add a new test. + assertEquals(expected, actual, "Current behavior returns negative values without clamping"); + } +}