chore(tests): add comprehensive web/controller and security service tests; stabilize AttemptCounter timing (#4822)

# Description of Changes

- **What was changed**
  - Added new MVC tests:
- `ConverterWebControllerTest` covering simple converter routes,
`/pdf-to-cbr` enable/disable behavior via `EndpointConfiguration`,
Python availability flag, and `maxDPI` defaults/overrides for
`/pdf-to-img` and `/pdf-to-video`.
- `GeneralWebControllerTest` covering many editor/organizer routes’
view/model mapping, `/sign` font discovery from classpath and
`/opt/static/fonts`, handling of missing `UserService`, robust filtering
of malformed font entries, and `/pipeline` JSON config discovery with
graceful fallback on `Files.walk` errors.
- `HomeWebControllerTest` covering `/about`, `/releases`, legacy
redirects, root page’s `SHOW_SURVEY` behavior, `/robots.txt` for
`googlevisibility` true/false/null, and `/licenses` JSON parsing with
IOException fallback.
  - Extended proprietary security tests:
- `LoginAttemptServiceTest` (reflective construction) validating
`getRemainingAttempts(...)` for disabled/blank keys, empty cache,
decreasing logic, and intentionally negative values when over the limit
(documented current behavior).
  - Hardened `AttemptCounterTest`:
- Eliminated timing flakiness by using generous windows and setting
`lastAttemptTime` to “now”.
- Added edge-case assertions for zero/negative windows to document
current semantics after switching comparison to `elapsed >=
attemptIncrementTime`.
- **Why the change was made**
- To increase test coverage across critical web endpoints and security
logic, document current edge-case behavior, and prevent regressions
around view resolution, environment/property-driven flags, resource
discovery, and timing-sensitive logic.


---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Ludy 2025-11-05 15:34:12 +01:00 committed by GitHub
parent d673670ebc
commit 2acb3aa6e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1107 additions and 3 deletions

View File

@ -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<Object[]> 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<ApplicationContextProvider> 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<ApplicationContextProvider> 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<ApplicationContextProvider> acp =
org.mockito.Mockito.mockStatic(ApplicationContextProvider.class);
MockedStatic<CheckProgramInstall> 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<ApplicationContextProvider> 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<ApplicationContextProvider> acp =
org.mockito.Mockito.mockStatic(ApplicationContextProvider.class);
MockedStatic<CheckProgramInstall> 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<ApplicationContextProvider> 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"));
}
}
}

View File

@ -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<String, Object> model,
HttpServletRequest request,
HttpServletResponse response) {
// no-op
}
};
}
@SuppressWarnings("unused")
private static Stream<Object[]> 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<SignatureFile> signatures = List.of(new SignatureFile(), new SignatureFile());
when(signatureService.getAvailableSignatures("alice")).thenReturn(signatures);
try (MockedStatic<GeneralUtils> gu = mockStatic(GeneralUtils.class);
MockedStatic<InstallationPathConfig> 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<GeneralUtils> gu = mockStatic(GeneralUtils.class);
MockedStatic<InstallationPathConfig> 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<GeneralUtils> gu = mockStatic(GeneralUtils.class);
MockedStatic<InstallationPathConfig> 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<GeneralUtils> gu = mockStatic(GeneralUtils.class);
MockedStatic<InstallationPathConfig> 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<String, Object> model = mvcResult.getModelAndView().getModel();
@SuppressWarnings("unchecked")
List<String> configsRaw = (List<String>) model.get("pipelineConfigs");
@SuppressWarnings("unchecked")
List<Map<String, String>> configsNamed =
(List<Map<String, String>>) model.get("pipelineConfigsWithNames");
Assertions.assertEquals(2, configsRaw.size());
Assertions.assertEquals(2, configsNamed.size());
Set<String> names = new HashSet<>();
for (Map<String, String> 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> 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<Map<String, String>> configsNamed =
(List<Map<String, String>>)
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"));
}
}
}

View File

@ -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<String, Object> 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, List<Dependency>>
String json = "{\"dependencies\":[{}]}";
try (MockedConstruction<ClassPathResource> 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<ClassPathResource> 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"));
}
}
}
}

View File

@ -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() {

View File

@ -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.
*
* <p>Assumptions: - 'MAX_ATTEMPT' is a private int (possibly static final); we read it via
* reflection (static-aware). - 'attemptsCache' is a ConcurrentHashMap<String, AttemptCounter>. -
* '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<String, AttemptCounter>();
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<String, AttemptCounter>();
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<String, AttemptCounter>();
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<String, AttemptCounter>();
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");
}
}