mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
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:
parent
d673670ebc
commit
2acb3aa6e5
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,11 @@ import org.junit.jupiter.api.DisplayName;
|
|||||||
import org.junit.jupiter.api.Nested;
|
import org.junit.jupiter.api.Nested;
|
||||||
import org.junit.jupiter.api.Test;
|
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 {
|
class AttemptCounterTest {
|
||||||
|
|
||||||
// --- Helper functions for reflection access to private fields ---
|
// --- Helper functions for reflection access to private fields ---
|
||||||
@ -113,11 +118,14 @@ class AttemptCounterTest {
|
|||||||
@DisplayName("returns FALSE when time difference is smaller than window")
|
@DisplayName("returns FALSE when time difference is smaller than window")
|
||||||
void shouldReturnFalseWhenWithinWindow() {
|
void shouldReturnFalseWhenWithinWindow() {
|
||||||
AttemptCounter counter = new AttemptCounter();
|
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();
|
long now = System.currentTimeMillis();
|
||||||
|
|
||||||
// Simulate: last action was (window - 1) ms ago
|
// Changed: Avoid flaky 1ms margin. We set lastAttemptTime to 'now' and choose a large
|
||||||
setPrivateLong(counter, "lastAttemptTime", now - (window - 1));
|
// 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
|
// Purpose: Inside the window -> no reset
|
||||||
assertFalse(counter.shouldReset(window), "Within the window, no reset should occur");
|
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
|
@Test
|
||||||
@DisplayName("Getters: return current values")
|
@DisplayName("Getters: return current values")
|
||||||
void getters_shouldReturnCurrentValues() {
|
void getters_shouldReturnCurrentValues() {
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user