From 9f0033525812584d4862f2e8de283aa3cca95388 Mon Sep 17 00:00:00 2001 From: Ludy Date: Fri, 31 Oct 2025 17:58:19 +0100 Subject: [PATCH] test(core): add comprehensive unit tests for controllers, services, models, and utilities (#4160) # Description of Changes - **What was changed** - **CI**: Enhanced `build.yml` to publish JaCoCo coverage and post a PR summary comment per matrix job (Spring Security/JDK). Also archives JaCoCo XML reports alongside existing test results. - **Tests (new & expanded)**: Added a broad set of unit tests across `app/common`, `app/core`, and `app/proprietary` modules, e.g.: - Common: `ShowAdminInterfaceTest`, `UnsupportedClaimExceptionTest`, `ExceptionUtilsTest`, `TempDirectoryTest`, etc. - Core: `ConnectedInputStreamTest`, `ReplaceAndInvertColorFactoryTest`, controller/model/service tests (e.g. `SettingsControllerTest`, `ApiEndpointTest`, `FlexibleCSVWriterTest`, `MetricsAggregatorServiceTest`, etc.). - Proprietary: security/database/model/web tests (e.g. `H2SQLConditionTest`, `JPATokenRepositoryImplTest`, `AuditWebFilterTest`, `CorrelationIdFilterTest`, etc.). - **JUnit 5 cleanup**: Consolidated assertion imports (`import static org.junit.jupiter.api.Assertions.*`), standardized on Jupiter APIs, and minor Mockito/Jupiter setup tweaks. - **Fix**: `ReplaceAndInvertColorFactory` now safely returns `null` when `replaceAndInvertOption` is `null` to avoid NPEs. - **Testability refactor**: Broadened visibility of `SPDFApplication#getActiveProfile(String[] args)` (from `private` to `protected`) to enable direct unit testing. - **Chore**: Removed obsolete `ValidationUtil` from `app/common`. - **Why the change was made** - Improve **signal in PRs** via automatic coverage summaries. - Increase **test coverage** and reduce regressions across core and proprietary modules. - Eliminate a potential **NullPointerException** in color strategy selection. - Enable targeted testing of application startup/profile resolution 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) - [x] 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) ### 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. --- .github/workflows/build.yml | 14 + README.md | 2 +- .../software/common/util/ValidationUtil.java | 14 - .../AutoJobPostMappingIntegrationTest.java | 4 +- .../interfaces/ShowAdminInterfaceTest.java | 17 + ...opertiesDynamicYamlPropertySourceTest.java | 4 +- .../software/common/model/FileInfoTest.java | 201 ++++-- .../UnsupportedClaimExceptionTest.java | 34 + .../common/service/ResourceMonitorTest.java | 4 +- .../common/util/CheckProgramInstallTest.java | 5 +- .../common/util/CustomHtmlSanitizerTest.java | 201 +++++- .../software/common/util/EmlToPdfTest.java | 7 +- .../software/common/util/ErrorUtilsTest.java | 3 +- .../common/util/ExceptionUtilsTest.java | 379 ++++++++++ .../software/common/util/FileMonitorTest.java | 3 +- .../software/common/util/FileToPdfTest.java | 4 +- .../common/util/ImageProcessingUtilsTest.java | 4 +- .../software/common/util/PDFToFileTest.java | 4 +- .../software/common/util/PdfUtilsTest.java | 663 +++++++++++++++++- .../common/util/ProcessExecutorTest.java | 5 +- .../common/util/RequestUriUtilsTest.java | 3 +- .../common/util/TempDirectoryTest.java | 96 +++ .../software/common/util/UIScalingTest.java | 4 +- .../software/common/util/UrlUtilsTest.java | 5 +- .../common/util/WebResponseUtilsTest.java | 4 +- .../misc/CustomColorReplaceStrategyTest.java | 5 +- .../HighContrastColorReplaceDeciderTest.java | 3 +- .../misc/InvertFullColorStrategyTest.java | 4 +- .../ReplaceAndInvertColorStrategyTest.java | 3 +- .../ReplaceAndInvertColorFactory.java | 4 + .../software/SPDF/SPDFApplication.java | 4 +- .../util/ConnectedInputStreamTest.java | 86 +++ .../ReplaceAndInvertColorFactoryTest.java | 88 +++ .../software/SPDF/SPDFApplicationTest.java | 59 ++ .../AdditionalLanguageJsControllerTest.java | 59 ++ .../api/RearrangePagesPDFControllerTest.java | 8 +- .../api/RotationControllerTest.java | 4 +- .../api/SettingsControllerTest.java | 99 +++ .../api/pipeline/PipelineProcessorTest.java | 4 +- .../api/security/GetInfoOnPDFTest.java | 52 +- .../software/SPDF/model/ApiEndpointTest.java | 104 +++ .../software/SPDF/model/SortTypesTest.java | 54 ++ .../SPDF/model/api/PDFWithPageNumsTest.java | 84 +++ .../converters/ConvertPDFToMarkdownTest.java | 109 +++ .../api/misc/ScannerEffectRequestTest.java | 130 ++++ .../SPDF/pdf/FlexibleCSVWriterTest.java | 25 + .../software/SPDF/pdf/TextFinderTest.java | 6 +- .../SPDF/service/ApiDocServiceTest.java | 124 ++++ .../CertificateValidationServiceTest.java | 60 +- .../service/LanguageServiceBasicTest.java | 3 +- .../service/MetricsAggregatorServiceTest.java | 75 ++ .../SPDF/service/SignatureServiceTest.java | 5 +- .../ReplaceAndInvertColorServiceTest.java | 77 ++ .../software/proprietary/model/TeamTest.java | 74 ++ .../model/dto/TeamWithUserCountDTOTest.java | 58 ++ .../configuration/DatabaseConfigTest.java | 8 + .../security/database/H2SQLConditionTest.java | 87 +++ .../security/database/ScheduledTasksTest.java | 71 ++ .../JPATokenRepositoryImplTest.java | 141 ++++ .../filter/JwtAuthenticationFilterTest.java | 265 ++++--- .../model/ApiKeyAuthenticationTokenTest.java | 84 +++ .../security/model/AttemptCounterTest.java | 246 +++++++ .../security/model/AuthorityTest.java | 96 +++ .../proprietary/security/model/UserTest.java | 152 ++++ .../BackupNotFoundExceptionTest.java | 34 + .../NoProviderFoundExceptionTest.java | 33 + ...l2AuthenticationRequestRepositoryTest.java | 5 +- .../security/service/EmailServiceTest.java | 3 +- .../security/service/JwtServiceTest.java | 8 +- .../KeyPersistenceServiceInterfaceTest.java | 4 +- .../security/service/MailConfigTest.java | 4 +- .../proprietary/util/SecretMaskerTest.java | 176 +++++ .../proprietary/web/AuditWebFilterTest.java | 317 +++++++++ .../web/CorrelationIdFilterTest.java | 188 +++++ build.gradle | 5 +- 75 files changed, 4719 insertions(+), 362 deletions(-) delete mode 100644 app/common/src/main/java/stirling/software/common/util/ValidationUtil.java create mode 100644 app/common/src/test/java/stirling/software/common/configuration/interfaces/ShowAdminInterfaceTest.java create mode 100644 app/common/src/test/java/stirling/software/common/model/exception/UnsupportedClaimExceptionTest.java create mode 100644 app/common/src/test/java/stirling/software/common/util/ExceptionUtilsTest.java create mode 100644 app/common/src/test/java/stirling/software/common/util/TempDirectoryTest.java create mode 100644 app/core/src/test/java/org/apache/pdfbox/examples/util/ConnectedInputStreamTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/Factories/ReplaceAndInvertColorFactoryTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/AdditionalLanguageJsControllerTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/SettingsControllerTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/model/ApiEndpointTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/model/SortTypesTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/model/api/PDFWithPageNumsTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdownTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/model/api/misc/ScannerEffectRequestTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/pdf/FlexibleCSVWriterTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/service/ApiDocServiceTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/service/MetricsAggregatorServiceTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/service/misc/ReplaceAndInvertColorServiceTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/model/TeamTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/model/dto/TeamWithUserCountDTOTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/database/H2SQLConditionTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/database/ScheduledTasksTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/database/repository/JPATokenRepositoryImplTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationTokenTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/model/AttemptCounterTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/model/AuthorityTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/model/UserTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/model/exception/BackupNotFoundExceptionTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/model/exception/NoProviderFoundExceptionTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/web/AuditWebFilterTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/web/CorrelationIdFilterTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9fb429253..f45316472 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -110,19 +110,33 @@ jobs: with: name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }} path: | + app/core/build/reports/jacoco/test app/core/build/reports/tests/ app/core/build/test-results/ app/core/build/reports/problems/ app/common/build/reports/tests/ app/common/build/test-results/ + app/common/build/reports/jacoco/test app/common/build/reports/problems/ app/proprietary/build/reports/tests/ app/proprietary/build/test-results/ + app/proprietary/build/reports/jacoco/test app/proprietary/build/reports/problems/ build/reports/problems/ retention-days: 3 if-no-files-found: warn + - name: Add coverage to PR with spring security ${{ matrix.spring-security }} and JDK ${{ matrix.jdk-version }} + id: jacoco + uses: madrapps/jacoco-report@50d3aff4548aa991e6753342d9ba291084e63848 # v1.7.2 + with: + paths: | + ${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 10 + min-coverage-changed-files: 0 + comment-type: summary + check-generateOpenApiDocs: if: needs.files-changed.outputs.openapi == 'true' needs: [files-changed, build] diff --git a/README.md b/README.md index fbcca9040..ef37d70fe 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir - **Vector Image to PDF**: Convert vector images (PS, EPS, EPSF) to PDF format #### Convert from PDF -- **PDF to Word**: Convert to documet (docx, doc, odt) format +- **PDF to Word**: Convert to document (docx, doc, odt) format - **PDF to Image**: Extract PDF pages as images - **PDF to RTF (Text)**: Convert to Rich Text Format - **PDF to Presentation**: Convert to presentation (pptx, ppt, odp) format diff --git a/app/common/src/main/java/stirling/software/common/util/ValidationUtil.java b/app/common/src/main/java/stirling/software/common/util/ValidationUtil.java deleted file mode 100644 index 8646f3bb6..000000000 --- a/app/common/src/main/java/stirling/software/common/util/ValidationUtil.java +++ /dev/null @@ -1,14 +0,0 @@ -package stirling.software.common.util; - -import java.util.Collection; - -public class ValidationUtil { - - public static boolean isStringEmpty(String input) { - return input == null || input.isBlank(); - } - - public static boolean isCollectionEmpty(Collection input) { - return input == null || input.isEmpty(); - } -} diff --git a/app/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java b/app/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java index 0b676b9cb..124671062 100644 --- a/app/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java +++ b/app/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java @@ -1,8 +1,6 @@ package stirling.software.common.annotations; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; diff --git a/app/common/src/test/java/stirling/software/common/configuration/interfaces/ShowAdminInterfaceTest.java b/app/common/src/test/java/stirling/software/common/configuration/interfaces/ShowAdminInterfaceTest.java new file mode 100644 index 000000000..a6939d7f1 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/configuration/interfaces/ShowAdminInterfaceTest.java @@ -0,0 +1,17 @@ +package stirling.software.common.configuration.interfaces; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class ShowAdminInterfaceTest { + + // Create a simple implementation for testing + static class TestImpl implements ShowAdminInterface {} + + @Test + void getShowUpdateOnlyAdmins_returnsTrueByDefault() { + ShowAdminInterface instance = new TestImpl(); + assertTrue(instance.getShowUpdateOnlyAdmins(), "Default should return true"); + } +} diff --git a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesDynamicYamlPropertySourceTest.java b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesDynamicYamlPropertySourceTest.java index f8d4cc345..76bf2fc00 100644 --- a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesDynamicYamlPropertySourceTest.java +++ b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesDynamicYamlPropertySourceTest.java @@ -18,7 +18,6 @@ class ApplicationPropertiesDynamicYamlPropertySourceTest { @Test void loads_yaml_into_environment() throws Exception { - // YAML-Config in Temp-Datei schreiben String yaml = """ \ @@ -30,7 +29,6 @@ class ApplicationPropertiesDynamicYamlPropertySourceTest { Path tmp = Files.createTempFile("spdf-settings-", ".yml"); Files.writeString(tmp, yaml); - // Pfad per statischem Mock liefern try (MockedStatic mocked = Mockito.mockStatic(InstallationPathConfig.class)) { mocked.when(InstallationPathConfig::getSettingsPath).thenReturn(tmp.toString()); @@ -38,7 +36,7 @@ class ApplicationPropertiesDynamicYamlPropertySourceTest { ConfigurableEnvironment env = new StandardEnvironment(); ApplicationProperties props = new ApplicationProperties(); - props.dynamicYamlPropertySource(env); // fügt PropertySource an erster Stelle ein + props.dynamicYamlPropertySource(env); assertEquals("My App", env.getProperty("ui.appName")); assertEquals("true", env.getProperty("system.enableAnalytics")); diff --git a/app/common/src/test/java/stirling/software/common/model/FileInfoTest.java b/app/common/src/test/java/stirling/software/common/model/FileInfoTest.java index 06f30c9fd..3ba0d7fc8 100644 --- a/app/common/src/test/java/stirling/software/common/model/FileInfoTest.java +++ b/app/common/src/test/java/stirling/software/common/model/FileInfoTest.java @@ -1,10 +1,12 @@ package stirling.software.common.model; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.*; -import java.io.File; +import java.nio.file.Path; import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -25,12 +27,7 @@ public class FileInfoTest { FileInfo fileInfo = new FileInfo( "example.txt", - File.separator - + "path" - + File.separator - + "to" - + File.separator - + "example.txt", + "/path/to/example.txt", LocalDateTime.now(), fileSize, LocalDateTime.now().minusDays(1)); @@ -38,74 +35,138 @@ public class FileInfoTest { assertEquals(expectedFormattedSize, fileInfo.getFormattedFileSize()); } - @Test - void testGetFilePathAsPath() { - FileInfo fileInfo = - new FileInfo( - "test.pdf", - File.separator + "tmp" + File.separator + "test.pdf", - LocalDateTime.now(), - 1234, - LocalDateTime.now().minusDays(2)); - assertEquals( - File.separator + "tmp" + File.separator + "test.pdf", - fileInfo.getFilePathAsPath().toString()); + @Nested + @DisplayName("getFilePathAsPath") + class GetFilePathAsPathTests { + @Test + @DisplayName("Should convert filePath string into a Path instance") + void shouldConvertStringToPath() { + FileInfo fi = + new FileInfo( + "example.txt", + "/path/to/example.txt", + LocalDateTime.now(), + 123, + LocalDateTime.now().minusDays(1)); + + Path path = fi.getFilePathAsPath(); + + // Basic sanity checks + assertNotNull(path, "Path should not be null"); + assertEquals( + Path.of("/path/to/example.txt"), + path, + "Converted Path should match input string"); + } } - @Test - void testGetFormattedModificationDate() { - LocalDateTime modDate = LocalDateTime.of(2024, 6, 1, 15, 30, 45); - FileInfo fileInfo = - new FileInfo( - "file.txt", - File.separator + "file.txt", - modDate, - 100, - LocalDateTime.of(2024, 5, 31, 10, 0, 0)); - assertEquals("2024-06-01 15:30:45", fileInfo.getFormattedModificationDate()); + @Nested + @DisplayName("Date formatting") + class DateFormattingTests { + @Test + @DisplayName("Should format modificationDate as 'yyyy-MM-dd HH:mm:ss'") + void shouldFormatModificationDate() { + LocalDateTime mod = LocalDateTime.of(2025, 8, 10, 15, 30, 45); + FileInfo fi = + new FileInfo( + "example.txt", + "/path/to/example.txt", + mod, + 1, + LocalDateTime.of(2024, 1, 1, 0, 0, 0)); + + assertEquals("2025-08-10 15:30:45", fi.getFormattedModificationDate()); + } + + @Test + @DisplayName("Should format creationDate as 'yyyy-MM-dd HH:mm:ss'") + void shouldFormatCreationDate() { + LocalDateTime created = LocalDateTime.of(2024, 12, 31, 23, 59, 59); + FileInfo fi = + new FileInfo( + "example.txt", + "/path/to/example.txt", + LocalDateTime.of(2025, 1, 1, 0, 0, 0), + 1, + created); + + assertEquals("2024-12-31 23:59:59", fi.getFormattedCreationDate()); + } + + @Test + @DisplayName("Should throw NPE when modificationDate is null (current behavior)") + void shouldThrowWhenModificationDateNull() { + // Assumption: Current implementation does not guard null -> NPE is expected. + FileInfo fi = + new FileInfo( + "example.txt", + "/path/to/example.txt", + null, // modificationDate null + 1, + LocalDateTime.now()); + + assertThrows( + NullPointerException.class, + fi::getFormattedModificationDate, + "Formatting a null modificationDate should throw NPE with current" + + " implementation"); + } + + @Test + @DisplayName("Should throw NPE when creationDate is null (current behavior)") + void shouldThrowWhenCreationDateNull() { + // Assumption: Current implementation does not guard null -> NPE is expected. + FileInfo fi = + new FileInfo( + "example.txt", + "/path/to/example.txt", + LocalDateTime.now(), + 1, + null); // creationDate null + + assertThrows( + NullPointerException.class, + fi::getFormattedCreationDate, + "Formatting a null creationDate should throw NPE with current implementation"); + } } - @Test - void testGetFormattedCreationDate() { - LocalDateTime creationDate = LocalDateTime.of(2023, 12, 25, 8, 15, 0); - FileInfo fileInfo = - new FileInfo( - "holiday.txt", - File.separator + "holiday.txt", - LocalDateTime.of(2024, 1, 1, 0, 0, 0), - 500, - creationDate); - assertEquals("2023-12-25 08:15:00", fileInfo.getFormattedCreationDate()); - } + @Nested + @DisplayName("Additional size formatting cases") + class AdditionalSizeFormattingTests { - @Test - void testGettersAndSetters() { - LocalDateTime now = LocalDateTime.now(); - FileInfo fileInfo = - new FileInfo( - "doc.pdf", - File.separator + "docs" + File.separator + "doc.pdf", - now, - 2048, - now.minusDays(1)); - // Test getters - assertEquals("doc.pdf", fileInfo.getFileName()); - assertEquals(File.separator + "docs" + File.separator + "doc.pdf", fileInfo.getFilePath()); - assertEquals(now, fileInfo.getModificationDate()); - assertEquals(2048, fileInfo.getFileSize()); - assertEquals(now.minusDays(1), fileInfo.getCreationDate()); + @Test + @DisplayName("Should round to two decimals for KB (e.g., 1536 B -> 1.50 KB)") + void shouldRoundKbToTwoDecimals() { + FileInfo fi = + new FileInfo( + "example.txt", + "/path/to/example.txt", + LocalDateTime.now(), + 1536, // 1.5 KB + LocalDateTime.now().minusDays(1)); - // Test setters - fileInfo.setFileName("new.pdf"); - fileInfo.setFilePath(File.separator + "new" + File.separator + "new.pdf"); - fileInfo.setModificationDate(now.plusDays(1)); - fileInfo.setFileSize(4096); - fileInfo.setCreationDate(now.minusDays(2)); + assertEquals("1.50 KB", fi.getFormattedFileSize()); + } - assertEquals("new.pdf", fileInfo.getFileName()); - assertEquals(File.separator + "new" + File.separator + "new.pdf", fileInfo.getFilePath()); - assertEquals(now.plusDays(1), fileInfo.getModificationDate()); - assertEquals(4096, fileInfo.getFileSize()); - assertEquals(now.minusDays(2), fileInfo.getCreationDate()); + @Test + @DisplayName("Values above 1 TB are still represented in GB (design choice)") + void shouldRepresentTerabytesInGb() { + // 2 TB = 2 * 1024 GB -> 2 * 1024 * 1024^3 bytes + long twoTB = 2L * 1024 * 1024 * 1024 * 1024; // 2 * 2^40 + FileInfo fi = + new FileInfo( + "example.txt", + "/path/to/example.txt", + LocalDateTime.now(), + twoTB, + LocalDateTime.now().minusDays(1)); + + // 2 TB equals 2048.00 GB with current implementation + assertEquals( + "2048.00 GB", + fi.getFormattedFileSize(), + "Current implementation caps at GB and shows TB in GB units"); + } } } diff --git a/app/common/src/test/java/stirling/software/common/model/exception/UnsupportedClaimExceptionTest.java b/app/common/src/test/java/stirling/software/common/model/exception/UnsupportedClaimExceptionTest.java new file mode 100644 index 000000000..74d33cefd --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/model/exception/UnsupportedClaimExceptionTest.java @@ -0,0 +1,34 @@ +package stirling.software.common.model.exception; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Tests for UnsupportedClaimException") +class UnsupportedClaimExceptionTest { + + @Test + @DisplayName("should store message passed to constructor") + void shouldStoreMessageFromConstructor() { + String expectedMessage = "This claim is not supported"; + UnsupportedClaimException exception = new UnsupportedClaimException(expectedMessage); + + // Verify the stored message + assertEquals( + expectedMessage, + exception.getMessage(), + "Constructor should correctly store the provided message"); + } + + @Test + @DisplayName("should allow null message without throwing exception") + void shouldAllowNullMessage() { + UnsupportedClaimException exception = new UnsupportedClaimException(null); + + // Null message should be stored as null + assertNull( + exception.getMessage(), + "Constructor should accept null message and store it as null"); + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java b/app/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java index f17730603..4ccf5ea55 100644 --- a/app/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java +++ b/app/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java @@ -1,8 +1,6 @@ package stirling.software.common.service; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import java.lang.management.MemoryMXBean; import java.lang.management.OperatingSystemMXBean; diff --git a/app/common/src/test/java/stirling/software/common/util/CheckProgramInstallTest.java b/app/common/src/test/java/stirling/software/common/util/CheckProgramInstallTest.java index 170290292..466f52cec 100644 --- a/app/common/src/test/java/stirling/software/common/util/CheckProgramInstallTest.java +++ b/app/common/src/test/java/stirling/software/common/util/CheckProgramInstallTest.java @@ -1,9 +1,6 @@ package stirling.software.common.util; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.times; diff --git a/app/common/src/test/java/stirling/software/common/util/CustomHtmlSanitizerTest.java b/app/common/src/test/java/stirling/software/common/util/CustomHtmlSanitizerTest.java index eaf992f71..baef37251 100644 --- a/app/common/src/test/java/stirling/software/common/util/CustomHtmlSanitizerTest.java +++ b/app/common/src/test/java/stirling/software/common/util/CustomHtmlSanitizerTest.java @@ -1,41 +1,44 @@ package stirling.software.common.util; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.*; 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.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.SsrfProtectionService; +@ExtendWith(MockitoExtension.class) class CustomHtmlSanitizerTest { + @Mock private SsrfProtectionService ssrfProtectionService; + @Mock private ApplicationProperties applicationProperties; + @Mock private ApplicationProperties.System systemProperties; + private CustomHtmlSanitizer customHtmlSanitizer; @BeforeEach void setUp() { - SsrfProtectionService mockSsrfProtectionService = mock(SsrfProtectionService.class); - stirling.software.common.model.ApplicationProperties mockApplicationProperties = - mock(stirling.software.common.model.ApplicationProperties.class); - stirling.software.common.model.ApplicationProperties.System mockSystem = - mock(stirling.software.common.model.ApplicationProperties.System.class); + // Default behavior: allow all URLs and enable sanitization. Lenient stubs avoid + // strict-stubbing failures when individual tests bypass certain branches. + lenient().when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(true); + lenient().when(applicationProperties.getSystem()).thenReturn(systemProperties); + lenient().when(systemProperties.getDisableSanitize()).thenReturn(false); - // Allow all URLs by default for basic tests - when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString())) - .thenReturn(true); - when(mockApplicationProperties.getSystem()).thenReturn(mockSystem); - when(mockSystem.getDisableSanitize()).thenReturn(false); // Enable sanitization for tests - - customHtmlSanitizer = - new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties); + customHtmlSanitizer = new CustomHtmlSanitizer(ssrfProtectionService, applicationProperties); } @ParameterizedTest @@ -56,10 +59,11 @@ class CustomHtmlSanitizerTest { "

This is valid HTML with formatting.

", new String[] {"

", "", ""}), Arguments.of( - "

Text with bold, italic, underline, " - + "emphasis, strong, strikethrough, " - + "strike, subscript, superscript, " - + "teletype, code, big, small.

", + "

Text with bold, italic, underline," + + " emphasis, strong," + + " strikethrough, strike," + + " subscript, superscript, teletype," + + " code, big, small.

", new String[] { "bold", "italic", @@ -163,7 +167,7 @@ class CustomHtmlSanitizerTest { @Test void testSanitizeAllowsImages() { - // Arrange - Testing Sanitizers.IMAGES + // Arrange - Testing custom images policy with SSRF check String htmlWithImage = "\"An"; @@ -182,7 +186,16 @@ class CustomHtmlSanitizerTest { void testSanitizeDisallowsDataUrlImages() { // Arrange String htmlWithDataUrlImage = - "\"SVG"; + "\"SVG"; + + // Changed: Explicitly tell SSRF service to reject data: URLs so the custom AttributePolicy + // drops the src attribute. Without this, a permissive SSRF mock might allow data: URLs. + lenient() + .when( + ssrfProtectionService.isUrlAllowed( + argThat(v -> v != null && v.startsWith("data:")))) + .thenReturn(false); // Act String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithDataUrlImage); @@ -257,9 +270,9 @@ class CustomHtmlSanitizerTest { void testSanitizeRemovesObjectAndEmbed() { // Arrange String htmlWithObjects = - "

Safe content

" - + "" - + ""; + "

Safe content

"; // Act String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithObjects); @@ -295,17 +308,12 @@ class CustomHtmlSanitizerTest { void testSanitizeHandlesComplexHtml() { // Arrange String complexHtml = - "
" - + "

Welcome

" - + "

This is a test with link.

" - + " " - + " " - + " " - + "
NameValue
Item 1100
" - + " \"Test" - + " " - + " " - + "
"; + "

Welcome

This is a" + + " test with link.

" + + "
NameValue
Item" + + " 1100
\"Test"
"; // Act String sanitizedHtml = customHtmlSanitizer.sanitize(complexHtml); @@ -353,4 +361,121 @@ class CustomHtmlSanitizerTest { // Assert assertEquals("", sanitizedHtml, "Null input should result in empty string"); } + + // ----------------------- + // Additional coverage + // ----------------------- + + @Test + @DisplayName("Should return input unchanged when sanitize is disabled via properties") + void shouldBypassSanitizationWhenDisabled() { + // Arrange + String malicious = + "

ok

"; + + // For this test, disable sanitize + when(systemProperties.getDisableSanitize()).thenReturn(true); + + // Also ensure SSRF would block it if sanitization were enabled (to prove bypass) + lenient().when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false); + + // Act + String result = customHtmlSanitizer.sanitize(malicious); + + // Assert + // When disabled, sanitizer must return original string as-is. + assertEquals(malicious, result, "Sanitization disabled should return input as-is"); + } + + @Test + @DisplayName("Should remove img src when SSRF service rejects the URL") + void shouldRemoveImageSrcWhenSsrfRejects() { + // Arrange + String html = "\"x\""; + + // Reject this URL + lenient() + .when(ssrfProtectionService.isUrlAllowed("http://internal/admin")) + .thenReturn(false); + + // Act + String sanitized = customHtmlSanitizer.sanitize(html); + + // Assert + assertTrue(sanitized.contains(""; + + // Explicit allow (clarity) + lenient() + .when(ssrfProtectionService.isUrlAllowed("https://example.com/image.jpg")) + .thenReturn(true); + + // Act + String sanitized = customHtmlSanitizer.sanitize(html); + + // Assert + assertTrue(sanitized.contains(""; + + // Act + String sanitized = customHtmlSanitizer.sanitize(html); + + // Assert + assertTrue(sanitized.contains("mail me"; + String sanitized = customHtmlSanitizer.sanitize(html); + + // Anchor and text should remain + assertTrue(sanitized.contains(""), + "Sanitized output should keep anchor; mailto: may or may not be present" + + " depending on sanitizer configuration"); + } + } } diff --git a/app/common/src/test/java/stirling/software/common/util/EmlToPdfTest.java b/app/common/src/test/java/stirling/software/common/util/EmlToPdfTest.java index b121be6b6..5192f9da6 100644 --- a/app/common/src/test/java/stirling/software/common/util/EmlToPdfTest.java +++ b/app/common/src/test/java/stirling/software/common/util/EmlToPdfTest.java @@ -1,11 +1,6 @@ package stirling.software.common.util; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; diff --git a/app/common/src/test/java/stirling/software/common/util/ErrorUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/ErrorUtilsTest.java index 02e4170b1..d1da37ae5 100644 --- a/app/common/src/test/java/stirling/software/common/util/ErrorUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/ErrorUtilsTest.java @@ -1,7 +1,6 @@ package stirling.software.common.util; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.Test; import org.springframework.ui.Model; diff --git a/app/common/src/test/java/stirling/software/common/util/ExceptionUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/ExceptionUtilsTest.java new file mode 100644 index 000000000..e4e93b84d --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/ExceptionUtilsTest.java @@ -0,0 +1,379 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +/** + * Unit tests for {@link ExceptionUtils}. Assumptions: - PdfErrorUtils.isCorruptedPdfError is a + * static method that returns true for certain exception types. We will mock it in tests for + * handlePdfException and logException. + */ +class ExceptionUtilsTest { + + @Nested + @DisplayName("PDF corruption exception creation") + class PdfCorruptionTests { + + @Test + @DisplayName("should create PdfCorruptedException without context") + void testCreatePdfCorruptedExceptionWithoutContext() { + Exception cause = new Exception("root"); + IOException ex = ExceptionUtils.createPdfCorruptedException(cause); + + assertEquals( + "PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF'" + + " feature first to fix the file before proceeding with this operation.", + ex.getMessage()); + assertSame(cause, ex.getCause()); + } + + @Test + @DisplayName("should create PdfCorruptedException with context") + void testCreatePdfCorruptedExceptionWithContext() { + Exception cause = new Exception("root"); + IOException ex = ExceptionUtils.createPdfCorruptedException("during merge", cause); + + assertTrue( + ex.getMessage() + .startsWith("Error during merge: PDF file appears to be corrupted")); + assertSame(cause, ex.getCause()); + } + + @Test + @DisplayName("should create MultiplePdfCorruptedException") + void testCreateMultiplePdfCorruptedException() { + Exception cause = new Exception("root"); + IOException ex = ExceptionUtils.createMultiplePdfCorruptedException(cause); + + assertTrue(ex.getMessage().startsWith("One or more PDF files appear to be corrupted")); + assertSame(cause, ex.getCause()); + } + } + + @Nested + @DisplayName("PDF encryption and password exception creation") + class PdfSecurityTests { + + @Test + void testCreatePdfEncryptionException() { + Exception cause = new Exception("root"); + IOException ex = ExceptionUtils.createPdfEncryptionException(cause); + assertTrue(ex.getMessage().contains("corrupted encryption data")); + assertSame(cause, ex.getCause()); + } + + @Test + void testCreatePdfPasswordException() { + Exception cause = new Exception("root"); + IOException ex = ExceptionUtils.createPdfPasswordException(cause); + assertTrue(ex.getMessage().contains("passworded")); + assertSame(cause, ex.getCause()); + } + } + + @Nested + @DisplayName("File processing exception creation") + class FileProcessingTests { + + @Test + void testCreateFileProcessingException() { + Exception cause = new Exception("boom"); + IOException ex = ExceptionUtils.createFileProcessingException("merge", cause); + assertTrue(ex.getMessage().contains("while processing the file during merge")); + assertSame(cause, ex.getCause()); + } + } + + @Nested + @DisplayName("Generic exception creation") + class GenericCreationTests { + + @Test + void testCreateIOException() { + IOException ex = + ExceptionUtils.createIOException( + "key", "Default message: {0}", new Exception("cause"), "X"); + assertEquals("Default message: X", ex.getMessage()); + } + + @Test + void testCreateRuntimeException() { + RuntimeException ex = + ExceptionUtils.createRuntimeException( + "key", "Default message: {0}", new Exception("cause"), "Y"); + assertEquals("Default message: Y", ex.getMessage()); + } + + @Test + void testCreateIllegalArgumentException() { + IllegalArgumentException ex = + ExceptionUtils.createIllegalArgumentException("key", "Format {0}", "Z"); + assertEquals("Format Z", ex.getMessage()); + } + } + + @Nested + @DisplayName("Predefined validation exceptions") + class PredefinedValidationTests { + + @Test + void testCreateHtmlFileRequiredException() { + IllegalArgumentException ex = ExceptionUtils.createHtmlFileRequiredException(); + assertTrue(ex.getMessage().contains("HTML or ZIP")); + } + + @Test + void testCreatePdfFileRequiredException() { + IllegalArgumentException ex = ExceptionUtils.createPdfFileRequiredException(); + assertTrue(ex.getMessage().contains("PDF")); + } + + @Test + void testCreateInvalidPageSizeException() { + IllegalArgumentException ex = ExceptionUtils.createInvalidPageSizeException("A5"); + assertTrue(ex.getMessage().contains("page size")); + } + } + + @Nested + @DisplayName("OCR and system requirement exceptions") + class OcrAndSystemTests { + + @Test + void testCreateOcrLanguageRequiredException() { + IOException ex = ExceptionUtils.createOcrLanguageRequiredException(); + assertTrue(ex.getMessage().contains("OCR language")); + } + + @Test + void testCreateOcrInvalidLanguagesException() { + IOException ex = ExceptionUtils.createOcrInvalidLanguagesException(); + assertTrue(ex.getMessage().contains("none of the selected languages")); + } + + @Test + void testCreateOcrToolsUnavailableException() { + IOException ex = ExceptionUtils.createOcrToolsUnavailableException(); + assertTrue(ex.getMessage().contains("OCR tools")); + } + + @Test + void testCreatePythonRequiredForWebpException() { + IOException ex = ExceptionUtils.createPythonRequiredForWebpException(); + assertTrue(ex.getMessage().contains("Python")); + assertTrue(ex.getMessage().contains("WebP conversion")); + } + } + + @Nested + @DisplayName("File operation and compression exceptions") + class FileAndCompressionTests { + + @Test + void testCreateFileNotFoundException() { + IOException ex = ExceptionUtils.createFileNotFoundException("123"); + assertTrue(ex.getMessage().contains("123")); + } + + @Test + void testCreatePdfaConversionFailedException() { + RuntimeException ex = ExceptionUtils.createPdfaConversionFailedException(); + assertTrue(ex.getMessage().contains("PDF/A conversion failed")); + } + + @Test + void testCreateInvalidComparatorException() { + IllegalArgumentException ex = ExceptionUtils.createInvalidComparatorException(); + assertTrue(ex.getMessage().contains("comparator")); + } + + @Test + void testCreateMd5AlgorithmException() { + RuntimeException ex = ExceptionUtils.createMd5AlgorithmException(new Exception("x")); + assertTrue(ex.getMessage().contains("MD5")); + } + + @Test + void testCreateCompressionOptionsException() { + IllegalArgumentException ex = ExceptionUtils.createCompressionOptionsException(); + assertTrue(ex.getMessage().contains("compression")); + } + + @Test + void testCreateGhostscriptCompressionExceptionNoCause() { + IOException ex = ExceptionUtils.createGhostscriptCompressionException(); + assertTrue(ex.getMessage().contains("Ghostscript")); + } + + @Test + void testCreateGhostscriptCompressionExceptionWithCause() { + IOException ex = + ExceptionUtils.createGhostscriptCompressionException(new Exception("cause")); + assertTrue(ex.getMessage().contains("Ghostscript")); + } + + @Test + void testCreateQpdfCompressionException() { + IOException ex = ExceptionUtils.createQpdfCompressionException(new Exception("cause")); + assertTrue(ex.getMessage().contains("QPDF")); + } + } + + @Nested + @DisplayName("PDF exception handling") + class PdfExceptionHandlingTests { + + @Test + void testHandlePdfExceptionWhenCorrupted() { + IOException original = new IOException("corrupted pdf"); + try (MockedStatic mock = mockStatic(PdfErrorUtils.class)) { + mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(true); + IOException result = ExceptionUtils.handlePdfException(original); + assertNotSame(original, result); + assertTrue(result.getMessage().contains("corrupted")); + } + } + + @Test + void testHandlePdfExceptionWhenEncryptionError() { + IOException original = new IOException("BadPaddingException"); + try (MockedStatic mock = mockStatic(PdfErrorUtils.class)) { + mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(false); + IOException result = ExceptionUtils.handlePdfException(original); + assertTrue(result.getMessage().contains("corrupted encryption data")); + } + } + + @Test + void testHandlePdfExceptionWhenPasswordError() { + IOException original = new IOException("password is incorrect"); + try (MockedStatic mock = mockStatic(PdfErrorUtils.class)) { + mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(false); + IOException result = ExceptionUtils.handlePdfException(original); + assertTrue(result.getMessage().contains("passworded")); + } + } + + @Test + void testHandlePdfExceptionWhenNoSpecialError() { + IOException original = new IOException("something else"); + try (MockedStatic mock = mockStatic(PdfErrorUtils.class)) { + mock.when(() -> PdfErrorUtils.isCorruptedPdfError(original)).thenReturn(false); + IOException result = ExceptionUtils.handlePdfException(original); + assertSame(original, result); + } + } + } + + @Nested + @DisplayName("Encryption and password detection") + class ErrorDetectionTests { + + @Test + void testIsEncryptionErrorTrue() { + assertTrue(ExceptionUtils.isEncryptionError(new IOException("BadPaddingException"))); + assertTrue( + ExceptionUtils.isEncryptionError( + new IOException("Given final block not properly padded"))); + assertTrue( + ExceptionUtils.isEncryptionError( + new IOException("AES initialization vector not fully read"))); + assertTrue(ExceptionUtils.isEncryptionError(new IOException("Failed to decrypt"))); + } + + @Test + void testIsEncryptionErrorFalse() { + assertFalse(ExceptionUtils.isEncryptionError(new IOException("other message"))); + assertFalse(ExceptionUtils.isEncryptionError(new IOException((String) null))); + } + + @Test + void testIsPasswordErrorTrue() { + assertTrue(ExceptionUtils.isPasswordError(new IOException("password is incorrect"))); + assertTrue(ExceptionUtils.isPasswordError(new IOException("Password is not provided"))); + assertTrue( + ExceptionUtils.isPasswordError( + new IOException("PDF contains an encryption dictionary"))); + } + + @Test + void testIsPasswordErrorFalse() { + assertFalse(ExceptionUtils.isPasswordError(new IOException("something else"))); + assertFalse(ExceptionUtils.isPasswordError(new IOException((String) null))); + } + } + + @Nested + @DisplayName("Logging behavior") + class LoggingTests { + + @Test + void testLogExceptionWhenCorruptedPdf() { + Exception e = new IOException("corrupted"); + try (MockedStatic mock = mockStatic(PdfErrorUtils.class)) { + mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(true); + // We can't assert log output here without a custom appender, but this ensures no + // exception is thrown + ExceptionUtils.logException("merge", e); + } + } + + @Test + void testLogExceptionWhenEncryptionError() { + IOException e = new IOException("BadPaddingException"); + try (MockedStatic mock = mockStatic(PdfErrorUtils.class)) { + mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(false); + ExceptionUtils.logException("merge", e); + } + } + + @Test + void testLogExceptionWhenPasswordError() { + IOException e = new IOException("password is incorrect"); + try (MockedStatic mock = mockStatic(PdfErrorUtils.class)) { + mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(false); + ExceptionUtils.logException("merge", e); + } + } + + @Test + void testLogExceptionUnexpectedError() { + Exception e = new RuntimeException("unexpected"); + try (MockedStatic mock = mockStatic(PdfErrorUtils.class)) { + mock.when(() -> PdfErrorUtils.isCorruptedPdfError(e)).thenReturn(false); + ExceptionUtils.logException("merge", e); + } + } + } + + @Nested + @DisplayName("Invalid and null argument exceptions") + class ArgumentValidationTests { + + @Test + void testCreateInvalidArgumentExceptionSingle() { + IllegalArgumentException ex = ExceptionUtils.createInvalidArgumentException("arg"); + assertTrue(ex.getMessage().contains("arg")); + } + + @Test + void testCreateInvalidArgumentExceptionWithValue() { + IllegalArgumentException ex = + ExceptionUtils.createInvalidArgumentException("arg", "val"); + assertTrue(ex.getMessage().contains("val")); + } + + @Test + void testCreateNullArgumentException() { + IllegalArgumentException ex = ExceptionUtils.createNullArgumentException("arg"); + assertTrue(ex.getMessage().contains("arg")); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FileMonitorTest.java b/app/common/src/test/java/stirling/software/common/util/FileMonitorTest.java index b7d59eab8..514e9861d 100644 --- a/app/common/src/test/java/stirling/software/common/util/FileMonitorTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FileMonitorTest.java @@ -1,7 +1,6 @@ package stirling.software.common.util; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; diff --git a/app/common/src/test/java/stirling/software/common/util/FileToPdfTest.java b/app/common/src/test/java/stirling/software/common/util/FileToPdfTest.java index bddfad0f7..9fd09ab5e 100644 --- a/app/common/src/test/java/stirling/software/common/util/FileToPdfTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FileToPdfTest.java @@ -1,8 +1,6 @@ package stirling.software.common.util; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; diff --git a/app/common/src/test/java/stirling/software/common/util/ImageProcessingUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/ImageProcessingUtilsTest.java index 8aa248a34..418a8c9be 100644 --- a/app/common/src/test/java/stirling/software/common/util/ImageProcessingUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/ImageProcessingUtilsTest.java @@ -1,8 +1,6 @@ package stirling.software.common.util; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import java.awt.*; import java.awt.image.BufferedImage; diff --git a/app/common/src/test/java/stirling/software/common/util/PDFToFileTest.java b/app/common/src/test/java/stirling/software/common/util/PDFToFileTest.java index 9aac6f907..19c3d4322 100644 --- a/app/common/src/test/java/stirling/software/common/util/PDFToFileTest.java +++ b/app/common/src/test/java/stirling/software/common/util/PDFToFileTest.java @@ -1,8 +1,6 @@ package stirling.software.common.util; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; diff --git a/app/common/src/test/java/stirling/software/common/util/PdfUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/PdfUtilsTest.java index bc68ecb2f..fd7854020 100644 --- a/app/common/src/test/java/stirling/software/common/util/PdfUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/PdfUtilsTest.java @@ -1,27 +1,45 @@ package stirling.software.common.util; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; import java.awt.Color; import java.awt.Graphics2D; import java.awt.image.BufferedImage; +import java.awt.image.ColorModel; +import java.awt.image.RenderedImage; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.util.Collections; -import java.util.HashSet; -import java.util.Set; +import java.util.Arrays; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import javax.imageio.ImageIO; import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode; import org.apache.pdfbox.pdmodel.PDResources; import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.PDXObject; +import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; +import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.rendering.ImageType; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; +import org.mockito.MockedStatic; +import org.springframework.mock.web.MockMultipartFile; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.CustomPDFDocumentFactory; @@ -31,32 +49,77 @@ public class PdfUtilsTest { @Test void testTextToPageSize() { + assertEquals(PDRectangle.A0, PdfUtils.textToPageSize("A0")); + assertEquals(PDRectangle.A1, PdfUtils.textToPageSize("A1")); + assertEquals(PDRectangle.A2, PdfUtils.textToPageSize("A2")); + assertEquals(PDRectangle.A3, PdfUtils.textToPageSize("A3")); assertEquals(PDRectangle.A4, PdfUtils.textToPageSize("A4")); + assertEquals(PDRectangle.A5, PdfUtils.textToPageSize("A5")); + assertEquals(PDRectangle.A6, PdfUtils.textToPageSize("A6")); assertEquals(PDRectangle.LETTER, PdfUtils.textToPageSize("LETTER")); + assertEquals(PDRectangle.LEGAL, PdfUtils.textToPageSize("LEGAL")); assertThrows(IllegalArgumentException.class, () -> PdfUtils.textToPageSize("INVALID")); } @Test - void testHasImagesOnPage() throws IOException { - // Mock a PDPage and its resources - PDPage page = Mockito.mock(PDPage.class); - PDResources resources = Mockito.mock(PDResources.class); - Mockito.when(page.getResources()).thenReturn(resources); + void testGetAllImages() throws Exception { + // Root resources + PDResources root = mock(PDResources.class); - // Case 1: No images in resources - Mockito.when(resources.getXObjectNames()).thenReturn(Collections.emptySet()); - assertFalse(PdfUtils.hasImagesOnPage(page)); + COSName im1 = COSName.getPDFName("Im1"); + COSName form1 = COSName.getPDFName("Form1"); + COSName other1 = COSName.getPDFName("Other1"); + when(root.getXObjectNames()).thenReturn(Arrays.asList(im1, form1, other1)); - // Case 2: Resources with an image - Set xObjectNames = new HashSet<>(); - COSName cosName = Mockito.mock(COSName.class); - xObjectNames.add(cosName); + // Direct image at root + PDImageXObject imgXObj1 = mock(PDImageXObject.class); + BufferedImage img1 = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB); + when(imgXObj1.getImage()).thenReturn(img1); + when(root.getXObject(im1)).thenReturn(imgXObj1); - PDImageXObject imageXObject = Mockito.mock(PDImageXObject.class); - Mockito.when(resources.getXObjectNames()).thenReturn(xObjectNames); - Mockito.when(resources.getXObject(cosName)).thenReturn(imageXObject); + // "Other" XObject that should be ignored + PDXObject otherXObj = mock(PDXObject.class); + when(root.getXObject(other1)).thenReturn(otherXObj); - assertTrue(PdfUtils.hasImagesOnPage(page)); + // Form XObject with its own resources + PDFormXObject formXObj = mock(PDFormXObject.class); + PDResources formRes = mock(PDResources.class); + when(formXObj.getResources()).thenReturn(formRes); + when(root.getXObject(form1)).thenReturn(formXObj); + + // Inside the form: one image and a nested form + COSName im2 = COSName.getPDFName("Im2"); + COSName nestedForm = COSName.getPDFName("NestedForm"); + when(formRes.getXObjectNames()).thenReturn(Arrays.asList(im2, nestedForm)); + + PDImageXObject imgXObj2 = mock(PDImageXObject.class); + BufferedImage img2 = new BufferedImage(3, 3, BufferedImage.TYPE_INT_RGB); + when(imgXObj2.getImage()).thenReturn(img2); + when(formRes.getXObject(im2)).thenReturn(imgXObj2); + + PDFormXObject nestedFormXObj = mock(PDFormXObject.class); + PDResources nestedRes = mock(PDResources.class); + when(nestedFormXObj.getResources()).thenReturn(nestedRes); + when(formRes.getXObject(nestedForm)).thenReturn(nestedFormXObj); + + // Deep nest: another image + COSName im3 = COSName.getPDFName("Im3"); + when(nestedRes.getXObjectNames()).thenReturn(List.of(im3)); + + PDImageXObject imgXObj3 = mock(PDImageXObject.class); + BufferedImage img3 = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); + when(imgXObj3.getImage()).thenReturn(img3); + when(nestedRes.getXObject(im3)).thenReturn(imgXObj3); + + // Act + List result = PdfUtils.getAllImages(root); + + // Assert + assertEquals( + 3, result.size(), "It should find exactly 3 images (root + form + nested form)."); + assertTrue( + result.containsAll(List.of(img1, img2, img3)), + "All expected images must be present."); } @Test @@ -107,7 +170,7 @@ public class PdfUtilsTest { g.fillRect(0, 0, 10, 10); g.dispose(); ByteArrayOutputStream imgOut = new ByteArrayOutputStream(); - javax.imageio.ImageIO.write(image, "png", imgOut); + ImageIO.write(image, "png", imgOut); PdfMetadataService meta = new PdfMetadataService(new ApplicationProperties(), "label", false, null); @@ -120,4 +183,554 @@ public class PdfUtilsTest { assertEquals(1, resultDoc.getNumberOfPages()); } } + + // =============================================================== + // Additional tests (added without modifying existing ones) + // =============================================================== + + /* Helper: create a colored test image */ + private static BufferedImage createImage(int w, int h, Color color) { + BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setColor(color); + g.fillRect(0, 0, w, h); + g.dispose(); + return img; + } + + /* Helper: create a factory like in existing tests */ + private static CustomPDFDocumentFactory factory() { + PdfMetadataService meta = + new PdfMetadataService(new ApplicationProperties(), "label", false, null); + return new CustomPDFDocumentFactory(meta); + } + + @Test + @DisplayName("convertPdfToPdfImage: creates image-PDF with same page count") + void convertPdfToPdfImage_shouldCreateImagePdfWithSamePageCount() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage p1 = new PDPage(PDRectangle.A4); + doc.addPage(p1); + try (PDPageContentStream cs = + new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(50, 750); + cs.showText("Hello PDF"); + cs.endText(); + } + PDPage p2 = new PDPage(PDRectangle.A4); + doc.addPage(p2); + + PDDocument out = PdfUtils.convertPdfToPdfImage(doc); + assertNotNull(out); + assertEquals(2, out.getNumberOfPages(), "Page count should be preserved"); + out.close(); + } + } + + @Test + @DisplayName("imageToPdf: PNG -> single-page PDF (static ImageProcessingUtils mocked)") + void imageToPdf_shouldCreatePdfFromPng() throws Exception { + BufferedImage img = createImage(320, 200, Color.RED); + ByteArrayOutputStream pngOut = new ByteArrayOutputStream(); + ImageIO.write(img, "png", pngOut); + + MockMultipartFile file = + new MockMultipartFile("files", "test.png", "image/png", pngOut.toByteArray()); + + try (MockedStatic mocked = mockStatic(ImageProcessingUtils.class)) { + // Assume: loadImageWithExifOrientation/convertColorType exist – static mock + mocked.when(() -> ImageProcessingUtils.loadImageWithExifOrientation(any())) + .thenReturn(img); + mocked.when( + () -> + ImageProcessingUtils.convertColorType( + any(BufferedImage.class), anyString())) + .thenAnswer(inv -> inv.getArgument(0, BufferedImage.class)); + + byte[] pdfBytes = + PdfUtils.imageToPdf( + new MockMultipartFile[] {file}, + "maintainAspectRatio", + true, + "RGB", + factory()); + + try (PDDocument result = factory().load(pdfBytes)) { + assertEquals(1, result.getNumberOfPages()); + } + } + } + + @Test + @DisplayName("imageToPdf: JPEG -> single-page PDF (JPEGFactory path)") + void imageToPdf_shouldCreatePdfFromJpeg_UsingJpegFactory() throws Exception { + BufferedImage img = createImage(640, 360, Color.BLUE); + ByteArrayOutputStream jpgOut = new ByteArrayOutputStream(); + ImageIO.write(img, "jpg", jpgOut); + + MockMultipartFile file = + new MockMultipartFile("files", "photo.jpg", "image/jpeg", jpgOut.toByteArray()); + + try (MockedStatic mocked = mockStatic(ImageProcessingUtils.class)) { + mocked.when(() -> ImageProcessingUtils.loadImageWithExifOrientation(any())) + .thenReturn(img); + mocked.when( + () -> + ImageProcessingUtils.convertColorType( + any(BufferedImage.class), anyString())) + .thenAnswer(inv -> inv.getArgument(0, BufferedImage.class)); + + byte[] pdfBytes = + PdfUtils.imageToPdf( + new MockMultipartFile[] {file}, "fillPage", false, "RGB", factory()); + + try (PDDocument result = factory().load(pdfBytes)) { + assertEquals(1, result.getNumberOfPages()); + } + } + } + + @Test + @DisplayName("addImageToDocument: fitDocumentToImage -> page size = image size") + void addImageToDocument_shouldUseImageSizeForPage_whenFitDocumentToImage() throws IOException { + try (PDDocument doc = new PDDocument()) { + BufferedImage img = createImage(300, 500, Color.GREEN); + PDImageXObject ximg = LosslessFactory.createFromImage(doc, img); + + PdfUtils.addImageToDocument(doc, ximg, "fitDocumentToImage", false); + + assertEquals(1, doc.getNumberOfPages()); + PDRectangle box = doc.getPage(0).getMediaBox(); + assertEquals(300, (int) box.getWidth()); + assertEquals(500, (int) box.getHeight()); + } + } + + @Test + @DisplayName("addImageToDocument: autoRotate rotates A4 for landscape image") + void addImageToDocument_shouldRotateA4_whenAutoRotateAndLandscape() throws IOException { + try (PDDocument doc = new PDDocument()) { + BufferedImage img = createImage(800, 400, Color.ORANGE); // Landscape + PDImageXObject ximg = LosslessFactory.createFromImage(doc, img); + + PdfUtils.addImageToDocument(doc, ximg, "maintainAspectRatio", true); + + assertEquals(1, doc.getNumberOfPages()); + PDRectangle box = doc.getPage(0).getMediaBox(); + assertTrue( + box.getWidth() > box.getHeight(), + "A4 should be landscape when auto-rotate + landscape"); + } + } + + @Test + @DisplayName("addImageToDocument: fillPage runs without errors") + void addImageToDocument_fillPage_executes() throws IOException { + try (PDDocument doc = new PDDocument()) { + BufferedImage img = createImage(200, 200, Color.MAGENTA); + PDImageXObject ximg = LosslessFactory.createFromImage(doc, img); + + PdfUtils.addImageToDocument(doc, ximg, "fillPage", false); + + assertEquals(1, doc.getNumberOfPages()); + } + } + + @Test + @DisplayName("overlayImage: everyPage=true overlays all pages") + void overlayImage_shouldOverlayAllPages_whenEveryPageTrue() throws IOException { + CustomPDFDocumentFactory factory = factory(); + + // Create PDF with 2 pages + byte[] basePdf; + try (PDDocument doc = factory.createNewDocument()) { + doc.addPage(new PDPage(PDRectangle.A4)); + doc.addPage(new PDPage(PDRectangle.A4)); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + basePdf = baos.toByteArray(); + } + + // Create image bytes + BufferedImage img = createImage(50, 50, Color.BLACK); + ByteArrayOutputStream pngOut = new ByteArrayOutputStream(); + ImageIO.write(img, "png", pngOut); + + byte[] result = PdfUtils.overlayImage(factory, basePdf, pngOut.toByteArray(), 10, 10, true); + + try (PDDocument out = factory.load(result)) { + assertEquals(2, out.getNumberOfPages(), "Page count remains identical"); + } + } + + /* Helper function: document with text on page1/page2 */ + private static PDDocument createDocWithText(String p1, String p2) throws IOException { + PDDocument doc = new PDDocument(); + + PDPage page1 = new PDPage(PDRectangle.A4); + doc.addPage(page1); + try (PDPageContentStream cs = + new PDPageContentStream(doc, page1, AppendMode.APPEND, true, true)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(50, 750); + cs.showText(p1); + cs.endText(); + } + + PDPage page2 = new PDPage(PDRectangle.A4); + doc.addPage(page2); + try (PDPageContentStream cs = + new PDPageContentStream(doc, page2, AppendMode.APPEND, true, true)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(50, 750); + cs.showText(p2); + cs.endText(); + } + + return doc; + } + + @Test + @DisplayName("containsTextInFile: pagesToCheck='all' finds text") + void containsTextInFile_allPages_true() throws IOException { + try (PDDocument doc = createDocWithText("alpha", "beta")) { + assertTrue(PdfUtils.containsTextInFile(doc, "beta", "all")); + } + } + + @Test + @DisplayName("containsTextInFile: single page '2' finds text") + void containsTextInFile_singlePage_two_true() throws IOException { + try (PDDocument doc = createDocWithText("alpha", "beta")) { + assertTrue(PdfUtils.containsTextInFile(doc, "beta", "2")); + } + } + + @Test + @DisplayName("containsTextInFile: range '1-1' finds text on page 1") + void containsTextInFile_range_oneToOne_true() throws IOException { + try (PDDocument doc = createDocWithText("findme", "other")) { + assertTrue(PdfUtils.containsTextInFile(doc, "findme", "1-1")); + } + } + + @Test + @DisplayName("containsTextInFile: list '1,2' finds text (whitespace robust)") + void containsTextInFile_list_pages_true() throws IOException { + try (PDDocument doc = createDocWithText("foo", "bar")) { + assertTrue(PdfUtils.containsTextInFile(doc, "bar", " 1 , 2 ")); + } + } + + @Test + @DisplayName("containsTextInFile: text not present -> false") + void containsTextInFile_textNotPresent_false() throws IOException { + try (PDDocument doc = createDocWithText("xxx", "yyy")) { + assertFalse(PdfUtils.containsTextInFile(doc, "zzz", "all")); + } + } + + @Test + @DisplayName("pageSize: different size returns false") + void pageSize_shouldReturnFalse_whenSizeDoesNotMatch() throws IOException { + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage(PDRectangle.A4)); + assertFalse(PdfUtils.pageSize(doc, "600x842")); + } + } + + // ===================== New: convertFromPdf – coverage ===================== + + @Test + @DisplayName("convertFromPdf: singleImage=true creates combined PNG file (readable)") + void convertFromPdf_singleImagePng_combinedReadable() throws Exception { + // Create two-page PDF + byte[] pdfBytes; + PdfMetadataService meta = + new PdfMetadataService(new ApplicationProperties(), "label", false, null); + CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta); + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage(PDRectangle.A4)); + doc.addPage(new PDPage(PDRectangle.A4)); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + pdfBytes = baos.toByteArray(); + } + + byte[] imageBytes = + PdfUtils.convertFromPdf( + factory, pdfBytes, "png", ImageType.RGB, true, 72, "test.pdf", false); + + // Should be readable as a single combined PNG image + BufferedImage img = ImageIO.read(new java.io.ByteArrayInputStream(imageBytes)); + assertNotNull(img, "PNG should be readable"); + assertTrue(img.getWidth() > 0 && img.getHeight() > 0, "Image dimensions > 0"); + } + + @Test + @DisplayName( + "convertFromPdf: singleImage=false returns ZIP with PNG entries (first image readable)") + void convertFromPdf_multiImagePng_firstReadable() throws Exception { + // Create two-page PDF + byte[] pdfBytes; + PdfMetadataService meta = + new PdfMetadataService(new ApplicationProperties(), "label", false, null); + CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta); + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage(PDRectangle.A4)); + doc.addPage(new PDPage(PDRectangle.A4)); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + pdfBytes = baos.toByteArray(); + } + + // Act: singleImage=false -> ZIP with separate images + byte[] zipBytes = + PdfUtils.convertFromPdf( + factory, pdfBytes, "png", ImageType.RGB, false, 72, "test.pdf", false); + + // Assert: open ZIP, read first entry as PNG + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { + ZipEntry entry = zis.getNextEntry(); + assertNotNull(entry, "ZIP should contain at least one entry"); + + ByteArrayOutputStream imgOut = new ByteArrayOutputStream(); + zis.transferTo(imgOut); + BufferedImage first = ImageIO.read(new ByteArrayInputStream(imgOut.toByteArray())); + + assertNotNull(first, "First PNG entry should be readable"); + assertTrue(first.getWidth() > 0 && first.getHeight() > 0, "Image dimensions > 0"); + } + } + + @Test + @DisplayName("hasText: detects phrase on selected pages ('1', '2', 'all')") + void hasText_shouldDetectPhrase_onSelectedPages() throws Exception { + // Arrange: PDF with 2 pages and text + try (PDDocument doc = new PDDocument()) { + PDPage p1 = new PDPage(PDRectangle.A4); + PDPage p2 = new PDPage(PDRectangle.A4); + doc.addPage(p1); + doc.addPage(p2); + + try (PDPageContentStream cs = + new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(50, 750); + cs.showText("alpha on page 1"); + cs.endText(); + } + try (PDPageContentStream cs = + new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(50, 750); + cs.showText("beta on page 2"); + cs.endText(); + } + + assertTrue(PdfUtils.hasText(doc, "1", "alpha"), "Page 1 should contain 'alpha'"); + } + + // For further checks, create new doc with identical content + try (PDDocument doc = new PDDocument()) { + PDPage p1 = new PDPage(PDRectangle.A4); + PDPage p2 = new PDPage(PDRectangle.A4); + doc.addPage(p1); + doc.addPage(p2); + + try (PDPageContentStream cs = + new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(50, 750); + cs.showText("alpha on page 1"); + cs.endText(); + } + try (PDPageContentStream cs = + new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(50, 750); + cs.showText("beta on page 2"); + cs.endText(); + } + + assertTrue(PdfUtils.hasText(doc, "2", "beta"), "Page 2 should contain 'beta'"); + } + + // Third doc for 'all' + try (PDDocument doc = new PDDocument()) { + PDPage p1 = new PDPage(PDRectangle.A4); + PDPage p2 = new PDPage(PDRectangle.A4); + doc.addPage(p1); + doc.addPage(p2); + + try (PDPageContentStream cs = + new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(50, 750); + cs.showText("gamma"); + cs.endText(); + } + assertTrue(PdfUtils.hasText(doc, "all", "gamma"), "'all' should find text on page 1"); + } + } + + @Test + @DisplayName("hasTextOnPage: true if page contains phrase, else false") + void hasTextOnPage_shouldReturnTrueOnlyForPagesWithPhrase() throws Exception { + try (PDDocument doc = new PDDocument()) { + PDPage p1 = new PDPage(PDRectangle.A4); + PDPage p2 = new PDPage(PDRectangle.A4); + doc.addPage(p1); + doc.addPage(p2); + + try (PDPageContentStream cs = + new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(50, 750); + cs.showText("needle"); + cs.endText(); + } + + assertTrue(PdfUtils.hasTextOnPage(p1, "needle")); + assertTrue(!PdfUtils.hasTextOnPage(p2, "needle")); + } + } + + @Test + @DisplayName("hasImages: detects images on selected pages and 'all'") + void hasImages_shouldDetectImages_onSelectedPages() throws Exception { + // Case 1: Page 1 without image (but resources set) -> false + try (PDDocument doc = new PDDocument()) { + PDPage p1 = new PDPage(PDRectangle.A4); + PDPage p2 = new PDPage(PDRectangle.A4); + p1.setResources(new PDResources()); + p2.setResources(new PDResources()); + doc.addPage(p1); + doc.addPage(p2); + + // Image only on page 2 + BufferedImage bi = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB); + Graphics2D g = bi.createGraphics(); + g.setColor(Color.GREEN); + g.fillRect(0, 0, 20, 20); + g.dispose(); + + PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi); + try (PDPageContentStream cs = + new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) { + cs.drawImage(ximg, 50, 700, 20, 20); + } + + assertTrue(!PdfUtils.hasImages(doc, "1"), "Page 1 should have no image"); + } + + // Case 2: Page 2 with image -> true + try (PDDocument doc = new PDDocument()) { + PDPage p1 = new PDPage(PDRectangle.A4); + PDPage p2 = new PDPage(PDRectangle.A4); + p1.setResources(new PDResources()); + p2.setResources(new PDResources()); + doc.addPage(p1); + doc.addPage(p2); + + BufferedImage bi = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB); + Graphics2D g = bi.createGraphics(); + g.setColor(Color.BLUE); + g.fillRect(0, 0, 20, 20); + g.dispose(); + + PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi); + try (PDPageContentStream cs = + new PDPageContentStream(doc, p2, AppendMode.APPEND, true, true)) { + cs.drawImage(ximg, 50, 700, 20, 20); + } + + assertTrue(PdfUtils.hasImages(doc, "2"), "Page 2 should have an image"); + } + + // Case 3: 'all' detects image + try (PDDocument doc = new PDDocument()) { + PDPage p = new PDPage(PDRectangle.A4); + p.setResources(new PDResources()); + doc.addPage(p); + + BufferedImage bi = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB); + PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi); + try (PDPageContentStream cs = + new PDPageContentStream(doc, p, AppendMode.APPEND, true, true)) { + cs.drawImage(ximg, 20, 730, 10, 10); + } + + assertTrue(PdfUtils.hasImages(doc, "all"), "'all' should detect the image"); + } + } + + @Test + @DisplayName("hasImagesOnPage: true if page contains an image, else false") + void hasImagesOnPage_shouldReturnTrueOnlyForPagesWithImage() throws Exception { + try (PDDocument doc = new PDDocument()) { + PDPage p1 = new PDPage(PDRectangle.A4); + PDPage p2 = new PDPage(PDRectangle.A4); + p1.setResources(new PDResources()); + p2.setResources(new PDResources()); + doc.addPage(p1); + doc.addPage(p2); + + BufferedImage bi = new BufferedImage(12, 12, BufferedImage.TYPE_INT_RGB); + Graphics2D g = bi.createGraphics(); + g.setColor(Color.RED); + g.fillRect(0, 0, 12, 12); + g.dispose(); + + PDImageXObject ximg = LosslessFactory.createFromImage(doc, bi); + try (PDPageContentStream cs = + new PDPageContentStream(doc, p1, AppendMode.APPEND, true, true)) { + cs.drawImage(ximg, 40, 720, 12, 12); + } + + assertTrue(PdfUtils.hasImagesOnPage(p1)); + assertTrue(!PdfUtils.hasImagesOnPage(p2)); + } + } + + @Test + @DisplayName("convertFromPdf: singleImage=true with JPG -> no alpha, white background") + void convertFromPdf_singleImageJpg_noAlphaWhiteBackground() throws Exception { + // small 1-page PDF + byte[] pdfBytes; + PdfMetadataService meta = + new PdfMetadataService(new ApplicationProperties(), "label", false, null); + CustomPDFDocumentFactory factory = new CustomPDFDocumentFactory(meta); + try (PDDocument doc = new PDDocument()) { + PDPage p = new PDPage(PDRectangle.A4); + doc.addPage(p); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + pdfBytes = baos.toByteArray(); + } + + byte[] jpgBytes = + PdfUtils.convertFromPdf( + factory, pdfBytes, "jpg", ImageType.RGB, true, 72, "sample.pdf", false); + + BufferedImage img = ImageIO.read(new ByteArrayInputStream(jpgBytes)); + assertNotNull(img, "JPG should be readable"); + + ColorModel cm = img.getColorModel(); + assertFalse(cm.hasAlpha(), "JPG output should have no alpha channel"); + + // JPG background should be white (approximate check) + int rgb = img.getRGB(img.getWidth() / 2, img.getHeight() / 2) & 0x00FFFFFF; + assertEquals(0xFFFFFF, rgb, "Background pixel should be white"); + } } diff --git a/app/common/src/test/java/stirling/software/common/util/ProcessExecutorTest.java b/app/common/src/test/java/stirling/software/common/util/ProcessExecutorTest.java index 4ee684b7a..52bab2d7b 100644 --- a/app/common/src/test/java/stirling/software/common/util/ProcessExecutorTest.java +++ b/app/common/src/test/java/stirling/software/common/util/ProcessExecutorTest.java @@ -1,9 +1,6 @@ package stirling.software.common.util; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import java.io.IOException; import java.util.ArrayList; diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index be437951a..ac4a8cfbe 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -1,7 +1,6 @@ package stirling.software.common.util; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; diff --git a/app/common/src/test/java/stirling/software/common/util/TempDirectoryTest.java b/app/common/src/test/java/stirling/software/common/util/TempDirectoryTest.java new file mode 100644 index 000000000..acf8e4632 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/TempDirectoryTest.java @@ -0,0 +1,96 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * Unit tests for {@link TempDirectory}. Assumption: TempFileManager has methods + * createTempDirectory() and deleteTempDirectory(Path). + */ +class TempDirectoryTest { + + @Test + @DisplayName("should create temp directory and return correct path info") + void shouldReturnCorrectPathInfo() throws IOException { + TempFileManager manager = mock(TempFileManager.class); + Path tempPath = Files.createTempDirectory("testDir"); + when(manager.createTempDirectory()).thenReturn(tempPath); + + try (TempDirectory tempDir = new TempDirectory(manager)) { + assertEquals( + tempPath, + tempDir.getPath(), + "getPath should return the created directory path"); + assertEquals( + tempPath.toAbsolutePath().toString(), + tempDir.getAbsolutePath(), + "getAbsolutePath should return absolute path"); + assertTrue(tempDir.exists(), "exists should return true when directory exists"); + assertTrue( + tempDir.toString().contains(tempPath.toAbsolutePath().toString()), + "toString should include the absolute path"); + } + } + + @Test + @DisplayName("should call deleteTempDirectory on close") + void shouldDeleteTempDirectoryOnClose() throws IOException { + TempFileManager manager = mock(TempFileManager.class); + Path tempPath = Files.createTempDirectory("testDir"); + when(manager.createTempDirectory()).thenReturn(tempPath); + + try (TempDirectory tempDir = new TempDirectory(manager)) { + // do nothing + } + + ArgumentCaptor captor = ArgumentCaptor.forClass(Path.class); + verify(manager, times(1)).deleteTempDirectory(captor.capture()); + assertEquals( + tempPath, + captor.getValue(), + "deleteTempDirectory should be called with the created path"); + } + + @Test + @DisplayName("should handle multiple close calls without exception") + void shouldHandleMultipleCloseCalls() throws IOException { + TempFileManager manager = mock(TempFileManager.class); + Path tempPath = Files.createTempDirectory("testDir"); + when(manager.createTempDirectory()).thenReturn(tempPath); + + TempDirectory tempDir = new TempDirectory(manager); + tempDir.close(); + assertDoesNotThrow(tempDir::close, "Second close should not throw exception"); + } + + @Test + @DisplayName("should return false for exists if directory does not exist") + void shouldReturnFalseIfDirectoryDoesNotExist() throws IOException { + TempFileManager manager = mock(TempFileManager.class); + Path tempPath = Files.createTempDirectory("testDir"); + Files.delete(tempPath); // delete immediately + when(manager.createTempDirectory()).thenReturn(tempPath); + + try (TempDirectory tempDir = new TempDirectory(manager)) { + assertFalse(tempDir.exists(), "exists should return false when directory is missing"); + } + } + + @Test + @DisplayName("should throw IOException if createTempDirectory fails") + void shouldThrowIfCreateTempDirectoryFails() throws IOException { + TempFileManager manager = mock(TempFileManager.class); + when(manager.createTempDirectory()).thenThrow(new IOException("Disk full")); + + IOException ex = assertThrows(IOException.class, () -> new TempDirectory(manager)); + assertEquals("Disk full", ex.getMessage(), "Exception message should be propagated"); + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/UIScalingTest.java b/app/common/src/test/java/stirling/software/common/util/UIScalingTest.java index 21ce6f2d8..ba94f8bec 100644 --- a/app/common/src/test/java/stirling/software/common/util/UIScalingTest.java +++ b/app/common/src/test/java/stirling/software/common/util/UIScalingTest.java @@ -1,8 +1,6 @@ package stirling.software.common.util; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mockStatic; diff --git a/app/common/src/test/java/stirling/software/common/util/UrlUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/UrlUtilsTest.java index ee63a4106..1e497168a 100644 --- a/app/common/src/test/java/stirling/software/common/util/UrlUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/UrlUtilsTest.java @@ -1,9 +1,6 @@ package stirling.software.common.util; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.when; import java.io.IOException; diff --git a/app/common/src/test/java/stirling/software/common/util/WebResponseUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/WebResponseUtilsTest.java index 6b716442e..1aac5ff85 100644 --- a/app/common/src/test/java/stirling/software/common/util/WebResponseUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/WebResponseUtilsTest.java @@ -1,8 +1,6 @@ package stirling.software.common.util; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.*; import java.io.ByteArrayOutputStream; import java.io.IOException; diff --git a/app/common/src/test/java/stirling/software/common/util/misc/CustomColorReplaceStrategyTest.java b/app/common/src/test/java/stirling/software/common/util/misc/CustomColorReplaceStrategyTest.java index c50cc335c..1e33e4259 100644 --- a/app/common/src/test/java/stirling/software/common/util/misc/CustomColorReplaceStrategyTest.java +++ b/app/common/src/test/java/stirling/software/common/util/misc/CustomColorReplaceStrategyTest.java @@ -1,7 +1,6 @@ package stirling.software.common.util.misc; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.*; import java.io.IOException; import java.lang.reflect.Method; @@ -106,7 +105,7 @@ class CustomColorReplaceStrategyTest { } catch (Exception e) { // If we get here, the test failed - org.junit.jupiter.api.Assertions.fail("Exception occurred: " + e.getMessage()); + fail("Exception occurred: " + e.getMessage()); } } } diff --git a/app/common/src/test/java/stirling/software/common/util/misc/HighContrastColorReplaceDeciderTest.java b/app/common/src/test/java/stirling/software/common/util/misc/HighContrastColorReplaceDeciderTest.java index 9ae3d6eb7..1211cdc66 100644 --- a/app/common/src/test/java/stirling/software/common/util/misc/HighContrastColorReplaceDeciderTest.java +++ b/app/common/src/test/java/stirling/software/common/util/misc/HighContrastColorReplaceDeciderTest.java @@ -1,7 +1,6 @@ package stirling.software.common.util.misc; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.Test; diff --git a/app/common/src/test/java/stirling/software/common/util/misc/InvertFullColorStrategyTest.java b/app/common/src/test/java/stirling/software/common/util/misc/InvertFullColorStrategyTest.java index 85d0d2513..7a6c8359c 100644 --- a/app/common/src/test/java/stirling/software/common/util/misc/InvertFullColorStrategyTest.java +++ b/app/common/src/test/java/stirling/software/common/util/misc/InvertFullColorStrategyTest.java @@ -1,8 +1,6 @@ package stirling.software.common.util.misc; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import java.awt.Color; import java.awt.image.BufferedImage; diff --git a/app/common/src/test/java/stirling/software/common/util/misc/ReplaceAndInvertColorStrategyTest.java b/app/common/src/test/java/stirling/software/common/util/misc/ReplaceAndInvertColorStrategyTest.java index e5ce10fb2..f10d76cef 100644 --- a/app/common/src/test/java/stirling/software/common/util/misc/ReplaceAndInvertColorStrategyTest.java +++ b/app/common/src/test/java/stirling/software/common/util/misc/ReplaceAndInvertColorStrategyTest.java @@ -1,7 +1,6 @@ package stirling.software.common.util.misc; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.*; import java.io.IOException; diff --git a/app/core/src/main/java/stirling/software/SPDF/Factories/ReplaceAndInvertColorFactory.java b/app/core/src/main/java/stirling/software/SPDF/Factories/ReplaceAndInvertColorFactory.java index 6697beb79..2e9ce1e93 100644 --- a/app/core/src/main/java/stirling/software/SPDF/Factories/ReplaceAndInvertColorFactory.java +++ b/app/core/src/main/java/stirling/software/SPDF/Factories/ReplaceAndInvertColorFactory.java @@ -26,6 +26,10 @@ public class ReplaceAndInvertColorFactory { String backGroundColor, String textColor) { + if (replaceAndInvertOption == null) { + return null; + } + return switch (replaceAndInvertOption) { case CUSTOM_COLOR, HIGH_CONTRAST_COLOR -> new CustomColorReplaceStrategy( diff --git a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java index 9322cea23..941d4577b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java +++ b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java @@ -196,7 +196,7 @@ public class SPDFApplication { log.info("Navigate to {}", url); } - private static String[] getActiveProfile(String[] args) { + protected static String[] getActiveProfile(String[] args) { // 1. Check for explicitly passed profiles if (args != null) { for (String arg : args) { @@ -220,7 +220,7 @@ public class SPDFApplication { } } - private static boolean isClassPresent(String className) { + protected static boolean isClassPresent(String className) { try { Class.forName(className, false, SPDFApplication.class.getClassLoader()); return true; diff --git a/app/core/src/test/java/org/apache/pdfbox/examples/util/ConnectedInputStreamTest.java b/app/core/src/test/java/org/apache/pdfbox/examples/util/ConnectedInputStreamTest.java new file mode 100644 index 000000000..48d8b11a4 --- /dev/null +++ b/app/core/src/test/java/org/apache/pdfbox/examples/util/ConnectedInputStreamTest.java @@ -0,0 +1,86 @@ +package org.apache.pdfbox.examples.util; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +class ConnectedInputStreamTest { + + @Test + void delegates_read_skip_available_mark_reset_and_markSupported() throws IOException { + byte[] data = "hello world".getBytes(StandardCharsets.UTF_8); + ByteArrayInputStream base = new ByteArrayInputStream(data); + HttpURLConnection con = mock(HttpURLConnection.class); + + ConnectedInputStream cis = new ConnectedInputStream(con, base); + + // mark support + assertTrue(cis.markSupported()); + + // available at start + assertEquals(data.length, cis.available()); + + // read single byte + int first = cis.read(); + assertEquals('h', first); + + // mark here + cis.mark(100); + + // read next 4 bytes with read(byte[]) + byte[] buf4 = new byte[4]; + int n4 = cis.read(buf4); + assertEquals(4, n4); + assertArrayEquals("ello".getBytes(StandardCharsets.UTF_8), buf4); + + // read next 1 byte with read(byte[], off, len) + byte[] one = new byte[1]; + int n1 = cis.read(one, 0, 1); + assertEquals(1, n1); + assertEquals((int) ' ', one[0] & 0xFF); + + // reset to mark and re-read the same 5 bytes ("ello ") + cis.reset(); + byte[] again5 = new byte[5]; + int n5 = cis.read(again5, 0, 5); + assertEquals(5, n5); + assertArrayEquals("ello ".getBytes(StandardCharsets.UTF_8), again5); + + // skip one byte ('w') + long skipped = cis.skip(1); + assertEquals(1, skipped); + + // remaining should be "orld" (4 bytes) + assertEquals(4, cis.available()); + byte[] rest = new byte[4]; + assertEquals(4, cis.read(rest)); + assertArrayEquals("orld".getBytes(StandardCharsets.UTF_8), rest); + + // end of stream + assertEquals(-1, cis.read()); + cis.close(); + verify(con).disconnect(); + } + + @Test + void close_closes_stream_before_disconnect() throws IOException { + InputStream is = mock(InputStream.class); + HttpURLConnection con = mock(HttpURLConnection.class); + + ConnectedInputStream cis = new ConnectedInputStream(con, is); + cis.close(); + + InOrder inOrder = inOrder(is, con); + inOrder.verify(is).close(); + inOrder.verify(con).disconnect(); + inOrder.verifyNoMoreInteractions(); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/Factories/ReplaceAndInvertColorFactoryTest.java b/app/core/src/test/java/stirling/software/SPDF/Factories/ReplaceAndInvertColorFactoryTest.java new file mode 100644 index 000000000..a0ae013f8 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/Factories/ReplaceAndInvertColorFactoryTest.java @@ -0,0 +1,88 @@ +package stirling.software.SPDF.Factories; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.common.model.api.misc.HighContrastColorCombination; +import stirling.software.common.model.api.misc.ReplaceAndInvert; +import stirling.software.common.util.misc.ColorSpaceConversionStrategy; +import stirling.software.common.util.misc.CustomColorReplaceStrategy; +import stirling.software.common.util.misc.InvertFullColorStrategy; +import stirling.software.common.util.misc.ReplaceAndInvertColorStrategy; + +class ReplaceAndInvertColorFactoryTest { + + private ReplaceAndInvertColorFactory factory; + private MultipartFile file; + + @BeforeEach + void setup() { + factory = new ReplaceAndInvertColorFactory(null); + file = mock(MultipartFile.class); + } + + @Test + void whenCustomColor_thenReturnsCustomColorReplaceStrategy() { + ReplaceAndInvert option = ReplaceAndInvert.CUSTOM_COLOR; + HighContrastColorCombination combo = null; // not used for CUSTOM_COLOR + + ReplaceAndInvertColorStrategy strategy = + factory.replaceAndInvert(file, option, combo, "#FFFFFF", "#000000"); + + assertNotNull(strategy); + assertTrue( + strategy instanceof CustomColorReplaceStrategy, + "Expected CustomColorReplaceStrategy for CUSTOM_COLOR"); + } + + @Test + void whenHighContrastColor_thenReturnsCustomColorReplaceStrategy() { + ReplaceAndInvert option = ReplaceAndInvert.HIGH_CONTRAST_COLOR; + HighContrastColorCombination combo = null; + + ReplaceAndInvertColorStrategy strategy = + factory.replaceAndInvert(file, option, combo, "#FFFFFF", "#000000"); + + assertNotNull(strategy); + assertTrue( + strategy instanceof CustomColorReplaceStrategy, + "Expected CustomColorReplaceStrategy for HIGH_CONTRAST_COLOR"); + } + + @Test + void whenFullInversion_thenReturnsInvertFullColorStrategy() { + ReplaceAndInvert option = ReplaceAndInvert.FULL_INVERSION; + + ReplaceAndInvertColorStrategy strategy = + factory.replaceAndInvert(file, option, null, null, null); + + assertNotNull(strategy); + assertTrue( + strategy instanceof InvertFullColorStrategy, + "Expected InvertFullColorStrategy for FULL_INVERSION"); + } + + @Test + void whenColorSpaceConversion_thenReturnsColorSpaceConversionStrategy() { + ReplaceAndInvert option = ReplaceAndInvert.COLOR_SPACE_CONVERSION; + + ReplaceAndInvertColorStrategy strategy = + factory.replaceAndInvert(file, option, null, null, null); + + assertNotNull(strategy); + assertTrue( + strategy instanceof ColorSpaceConversionStrategy, + "Expected ColorSpaceConversionStrategy for COLOR_SPACE_CONVERSION"); + } + + @Test + void whenNullOption_thenReturnsNull() { + ReplaceAndInvertColorStrategy strategy = + factory.replaceAndInvert(file, null, null, null, null); + assertNull(strategy, "Expected null for unsupported/unknown option"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/SPDFApplicationTest.java b/app/core/src/test/java/stirling/software/SPDF/SPDFApplicationTest.java index dd53d24dd..a5ffc91b1 100644 --- a/app/core/src/test/java/stirling/software/SPDF/SPDFApplicationTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/SPDFApplicationTest.java @@ -1,15 +1,32 @@ package stirling.software.SPDF; +import static org.junit.Assert.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.env.Environment; + +import stirling.software.common.configuration.AppConfig; +import stirling.software.common.model.ApplicationProperties; @ExtendWith(MockitoExtension.class) public class SPDFApplicationTest { + @Mock private Environment env; + + @Mock private ApplicationProperties applicationProperties; + + @InjectMocks private SPDFApplication sPDFApplication; + + @Mock private AppConfig appConfig; + @BeforeEach public void setUp() { SPDFApplication.setServerPortStatic("8080"); @@ -25,4 +42,46 @@ public class SPDFApplicationTest { public void testGetStaticPort() { assertEquals("8080", SPDFApplication.getStaticPort()); } + + @Test + public void testSetServerPortStaticAuto() { + SPDFApplication.setServerPortStatic("auto"); + assertEquals("0", SPDFApplication.getStaticPort()); + } + + @Test + public void testInit() { + when(appConfig.getBaseUrl()).thenReturn("http://localhost"); + when(appConfig.getContextPath()).thenReturn("/app"); + when(appConfig.getServerPort()).thenReturn("8080"); + + sPDFApplication.init(); + + assertEquals("http://localhost", SPDFApplication.getStaticBaseUrl()); + assertEquals("/app", SPDFApplication.getStaticContextPath()); + assertEquals("8080", SPDFApplication.getStaticPort()); + } + + @Test + public void testGetActiveProfileWithArgs() { + String[] args = {"--spring.profiles.active=security"}; + String[] profiles = SPDFApplication.getActiveProfile(args); + assertArrayEquals(new String[] {"security"}, profiles); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISABLE_ADDITIONAL_FEATURES", matches = "true") + public void testGetActiveProfileWithoutArgsAdditionalEnabled() { + String[] args = {}; + String[] profiles = SPDFApplication.getActiveProfile(args); + assertArrayEquals(new String[] {"default"}, profiles); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DISABLE_ADDITIONAL_FEATURES", matches = "false") + public void testGetActiveProfileWithoutArgsAdditionalDisabled() { + String[] args = {}; + String[] profiles = SPDFApplication.getActiveProfile(args); + assertArrayEquals(new String[] {"security"}, profiles); + } } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/AdditionalLanguageJsControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/AdditionalLanguageJsControllerTest.java new file mode 100644 index 000000000..58c1b2e4e --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/AdditionalLanguageJsControllerTest.java @@ -0,0 +1,59 @@ +package stirling.software.SPDF.controller.api; + +import static org.hamcrest.Matchers.containsString; +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.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import stirling.software.SPDF.service.LanguageService; + +class AdditionalLanguageJsControllerTest { + + @Test + void returnsJsWithSupportedLanguagesAndFunction() throws Exception { + LanguageService lang = mock(LanguageService.class); + // LinkedHashSet for deterministic order in the array + when(lang.getSupportedLanguages()) + .thenReturn(new LinkedHashSet<>(List.of("de_DE", "en_GB"))); + + MockMvc mvc = + MockMvcBuilders.standaloneSetup(new AdditionalLanguageJsController(lang)).build(); + + mvc.perform(get("/js/additionalLanguageCode.js")) + .andExpect(status().isOk()) + .andExpect(content().contentType(new MediaType("application", "javascript"))) + .andExpect( + content() + .string( + containsString( + "const supportedLanguages =" + + " [\"de_DE\",\"en_GB\"];"))) + .andExpect(content().string(containsString("function getDetailedLanguageCode()"))) + .andExpect(content().string(containsString("return \"en_GB\";"))); + + verify(lang, times(1)).getSupportedLanguages(); + } + + @Test + void emptySupportedLanguagesYieldsEmptyArray() throws Exception { + LanguageService lang = mock(LanguageService.class); + when(lang.getSupportedLanguages()).thenReturn(Set.of()); + + MockMvc mvc = + MockMvcBuilders.standaloneSetup(new AdditionalLanguageJsController(lang)).build(); + + mvc.perform(get("/js/additionalLanguageCode.js")) + .andExpect(status().isOk()) + .andExpect(content().contentType(new MediaType("application", "javascript"))) + .andExpect(content().string(containsString("const supportedLanguages = [];"))); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/RearrangePagesPDFControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/RearrangePagesPDFControllerTest.java index fcc0a7f0b..e4fa16d70 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/RearrangePagesPDFControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/RearrangePagesPDFControllerTest.java @@ -1,20 +1,21 @@ package stirling.software.SPDF.controller.api; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.*; import java.util.Arrays; import java.util.List; import org.junit.jupiter.api.BeforeEach; 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.CsvSource; import org.mockito.Mock; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.common.service.CustomPDFDocumentFactory; +@ExtendWith({MockitoExtension.class}) class RearrangePagesPDFControllerTest { @Mock private CustomPDFDocumentFactory mockPdfDocumentFactory; @@ -23,7 +24,6 @@ class RearrangePagesPDFControllerTest { @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); sut = new RearrangePagesPDFController(mockPdfDocumentFactory); } diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/RotationControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/RotationControllerTest.java index a16746bd5..ecdafc09a 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/RotationControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/RotationControllerTest.java @@ -1,8 +1,6 @@ package stirling.software.SPDF.controller.api; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/SettingsControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/SettingsControllerTest.java new file mode 100644 index 000000000..b9eb11080 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/SettingsControllerTest.java @@ -0,0 +1,99 @@ +package stirling.software.SPDF.controller.api; + +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.http.MediaType; +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.configuration.InstallationPathConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.GeneralUtils; + +class SettingsControllerTest { + + private MockMvc mockMvc(ApplicationProperties props, EndpointConfiguration endpoints) { + SettingsController controller = new SettingsController(props, endpoints); + return MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void update_enable_analytics_returns_208_when_already_set() throws Exception { + ApplicationProperties props = new ApplicationProperties(); + props.getSystem().setEnableAnalytics(Boolean.FALSE); + + EndpointConfiguration endpoints = mock(EndpointConfiguration.class); + + try (MockedStatic install = + Mockito.mockStatic(InstallationPathConfig.class); + MockedStatic gen = Mockito.mockStatic(GeneralUtils.class)) { + + install.when(InstallationPathConfig::getSettingsPath) + .thenReturn("/etc/spdf/settings.yml"); + + MockMvc mvc = mockMvc(props, endpoints); + + // Act + Assert + mvc.perform( + post("/api/v1/settings/update-enable-analytics") + .contentType(MediaType.APPLICATION_JSON) + .content("true")) + .andExpect(status().isAlreadyReported()) + .andExpect(content().string(containsString("Setting has already been set"))) + .andExpect(content().string(containsString("/etc/spdf/settings.yml"))); + + gen.verifyNoInteractions(); + } + } + + @Test + void update_enable_analytics_sets_value_and_saves_when_not_set() throws Exception { + ApplicationProperties props = new ApplicationProperties(); + props.getSystem().setEnableAnalytics(null); + + EndpointConfiguration endpoints = mock(EndpointConfiguration.class); + + try (MockedStatic gen = Mockito.mockStatic(GeneralUtils.class)) { + MockMvc mvc = mockMvc(props, endpoints); + + // Act + Assert + mvc.perform( + post("/api/v1/settings/update-enable-analytics") + .contentType(MediaType.APPLICATION_JSON) + .content("true")) + .andExpect(status().isOk()) + .andExpect(content().string("Updated")); + + gen.verify( + () -> GeneralUtils.saveKeyToSettings(eq("system.enableAnalytics"), eq(true))); + + assertEquals(Boolean.TRUE, props.getSystem().getEnableAnalytics()); + } + } + + @Test + void get_endpoints_status_returns_map() throws Exception { + ApplicationProperties props = new ApplicationProperties(); + EndpointConfiguration endpoints = mock(EndpointConfiguration.class); + when(endpoints.getEndpointStatuses()) + .thenReturn(Map.of("convert.pdf.markdown", true, "merge", false)); + + MockMvc mvc = mockMvc(props, endpoints); + + mvc.perform(get("/api/v1/settings/get-endpoints-status")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.['convert.pdf.markdown']").value(true)) + .andExpect(jsonPath("$.merge").value(false)); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessorTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessorTest.java index 5f3b56357..f3d0d569f 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessorTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/pipeline/PipelineProcessorTest.java @@ -1,7 +1,7 @@ package stirling.software.SPDF.controller.api.pipeline; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; import java.util.List; diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java index 65456ed3c..12d5977db 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFTest.java @@ -1,11 +1,13 @@ package stirling.software.SPDF.controller.api.security; import java.io.ByteArrayOutputStream; -import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.Path; import java.util.GregorianCalendar; +import java.util.List; import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.*; @@ -56,16 +58,35 @@ class GetInfoOnPDFTest { /** Helper method to load a PDF file from test resources */ private MockMultipartFile loadPdfFromResources(String filename) throws IOException { - String[] possiblePaths = { - "app/common/src/test/resources/" + filename, - "testing/cucumber/exampleFiles/" + filename, - "app/core/src/test/resources/" + filename - }; + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + if (classLoader == null) { + classLoader = getClass().getClassLoader(); + } - for (String path : possiblePaths) { - File file = new File(path); - if (file.exists()) { - byte[] content = Files.readAllBytes(file.toPath()); + if (classLoader != null) { + try (InputStream resourceStream = classLoader.getResourceAsStream(filename)) { + if (resourceStream != null) { + byte[] content = resourceStream.readAllBytes(); + return new MockMultipartFile( + "file", filename, MediaType.APPLICATION_PDF_VALUE, content); + } + } + } + + Path projectRoot = locateProjectRoot(Path.of("").toAbsolutePath()); + List searchDirectories = + List.of( + projectRoot.resolve( + Path.of("app", "core", "src", "test", "resources").toString()), + projectRoot.resolve( + Path.of("app", "common", "src", "test", "resources").toString()), + projectRoot.resolve( + Path.of("testing", "cucumber", "exampleFiles").toString())); + + for (Path directory : searchDirectories) { + Path filePath = directory.resolve(filename); + if (Files.exists(filePath)) { + byte[] content = Files.readAllBytes(filePath); return new MockMultipartFile( "file", filename, MediaType.APPLICATION_PDF_VALUE, content); } @@ -74,6 +95,17 @@ class GetInfoOnPDFTest { throw new IOException("PDF file not found: " + filename); } + private Path locateProjectRoot(Path start) { + Path current = start; + while (current != null) { + if (Files.exists(current.resolve("settings.gradle"))) { + return current; + } + current = current.getParent(); + } + return start; + } + /** Helper method to create a simple PDF document with text */ private PDDocument createSimplePdfWithText(String text) throws IOException { PDDocument document = new PDDocument(); diff --git a/app/core/src/test/java/stirling/software/SPDF/model/ApiEndpointTest.java b/app/core/src/test/java/stirling/software/SPDF/model/ApiEndpointTest.java new file mode 100644 index 000000000..769aa6b9b --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/ApiEndpointTest.java @@ -0,0 +1,104 @@ +package stirling.software.SPDF.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +class ApiEndpointTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + private JsonNode postNodeWithParams(String description, String... names) { + ObjectNode post = mapper.createObjectNode(); + post.put("description", description); + ArrayNode params = mapper.createArrayNode(); + for (String n : names) { + ObjectNode p = mapper.createObjectNode(); + if (n != null) { + p.put("name", n); + } + params.add(p); + } + post.set("parameters", params); + return post; + } + + @Test + void parses_description_and_validates_required_parameters() { + JsonNode post = postNodeWithParams("Convert PDF to Markdown", "file", "mode"); + ApiEndpoint endpoint = new ApiEndpoint("pdfToMd", post); + + assertEquals("Convert PDF to Markdown", endpoint.getDescription()); + + Map provided = new HashMap<>(); + provided.put("file", new byte[] {1}); + provided.put("mode", "fast"); + + assertTrue( + endpoint.areParametersValid(provided), "All required keys present should be valid"); + } + + @Test + void missing_any_required_parameter_returns_false() { + JsonNode post = postNodeWithParams("desc", "file", "mode"); + ApiEndpoint endpoint = new ApiEndpoint("pdfToMd", post); + + Map provided = new HashMap<>(); + provided.put("file", new byte[] {1}); + + assertFalse(endpoint.areParametersValid(provided)); + } + + @Test + void extra_parameters_are_ignored_if_required_are_present() { + JsonNode post = postNodeWithParams("desc", "file"); + ApiEndpoint endpoint = new ApiEndpoint("x", post); + + Map provided = new HashMap<>(); + provided.put("file", new byte[] {1}); + provided.put("extra", 123); + + assertTrue(endpoint.areParametersValid(provided)); + } + + @Test + void no_parameters_defined_accepts_empty_input() { + JsonNode postEmptyArray = postNodeWithParams("desc" /* no names */); + ApiEndpoint endpointA = new ApiEndpoint("a", postEmptyArray); + assertTrue(endpointA.areParametersValid(Map.of())); + + ObjectNode postNoField = mapper.createObjectNode(); + postNoField.put("description", "desc"); + ApiEndpoint endpointB = new ApiEndpoint("b", postNoField); + assertTrue(endpointB.areParametersValid(Map.of())); + } + + @Test + void parameter_without_name_creates_empty_required_key() { + JsonNode post = postNodeWithParams("desc", (String) null); + ApiEndpoint endpoint = new ApiEndpoint("y", post); + + assertFalse(endpoint.areParametersValid(Map.of())); + + assertTrue(endpoint.areParametersValid(Map.of("", 42))); + } + + @Test + void toString_contains_name_and_parameter_names() { + JsonNode post = postNodeWithParams("desc", "file", "mode"); + ApiEndpoint endpoint = new ApiEndpoint("pdfToMd", post); + + String s = endpoint.toString(); + assertTrue(s.contains("pdfToMd")); + assertTrue(s.contains("file")); + assertTrue(s.contains("mode")); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/SortTypesTest.java b/app/core/src/test/java/stirling/software/SPDF/model/SortTypesTest.java new file mode 100644 index 000000000..87fe93305 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/SortTypesTest.java @@ -0,0 +1,54 @@ +package stirling.software.SPDF.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class SortTypesTest { + + private static final Set EXPECTED = + Set.of( + "CUSTOM", + "REVERSE_ORDER", + "DUPLEX_SORT", + "BOOKLET_SORT", + "SIDE_STITCH_BOOKLET_SORT", + "ODD_EVEN_SPLIT", + "ODD_EVEN_MERGE", + "REMOVE_FIRST", + "REMOVE_LAST", + "REMOVE_FIRST_AND_LAST", + "DUPLICATE"); + + @Test + void contains_exactly_expected_constants() { + Set actual = + Arrays.stream(SortTypes.values()).map(Enum::name).collect(Collectors.toSet()); + + assertEquals( + EXPECTED, + actual, + () -> "Enum constants mismatch.\nExpected: " + EXPECTED + "\nActual: " + actual); + } + + @ParameterizedTest + @EnumSource(SortTypes.class) + void valueOf_roundtrip(SortTypes type) { + assertEquals(type, SortTypes.valueOf(type.name())); + } + + @Test + void names_are_unique_and_uppercase() { + String[] names = Arrays.stream(SortTypes.values()).map(Enum::name).toArray(String[]::new); + assertEquals(names.length, Set.of(names).size(), "Duplicate enum names?"); + for (String n : names) { + assertEquals(n, n.toUpperCase(), "Enum name not uppercase: " + n); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/PDFWithPageNumsTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/PDFWithPageNumsTest.java new file mode 100644 index 000000000..50489567c --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/PDFWithPageNumsTest.java @@ -0,0 +1,84 @@ +package stirling.software.SPDF.model.api; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PDFWithPageNumsTest { + + private PDFWithPageNums pdfWithPageNums; + private PDDocument mockDocument; + + @BeforeEach + void setUp() { + pdfWithPageNums = new PDFWithPageNums(); + mockDocument = mock(PDDocument.class); + } + + @Test + void testGetPageNumbersList_AllPages() { + pdfWithPageNums.setPageNumbers("all"); + when(mockDocument.getNumberOfPages()).thenReturn(10); + + List result = pdfWithPageNums.getPageNumbersList(mockDocument, true); + + assertEquals(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10), result); + } + + @Test + void testGetPageNumbersList_135_7Pages() { + pdfWithPageNums.setPageNumbers("1,3,5-7"); + when(mockDocument.getNumberOfPages()).thenReturn(10); + + List result = pdfWithPageNums.getPageNumbersList(mockDocument, true); + + assertEquals(List.of(1, 3, 5, 6, 7), result); + } + + @Test + void testGetPageNumbersList_2nPlus1Pages() { + pdfWithPageNums.setPageNumbers("2n+1"); + when(mockDocument.getNumberOfPages()).thenReturn(10); + + List result = pdfWithPageNums.getPageNumbersList(mockDocument, true); + + assertEquals(List.of(3, 5, 7, 9), result); + } + + @Test + void testGetPageNumbersList_3nPages() { + pdfWithPageNums.setPageNumbers("3n"); + when(mockDocument.getNumberOfPages()).thenReturn(10); + + List result = pdfWithPageNums.getPageNumbersList(mockDocument, true); + + assertEquals(List.of(3, 6, 9), result); + } + + @Test + void testGetPageNumbersList_EmptyInput() { + pdfWithPageNums.setPageNumbers(""); + when(mockDocument.getNumberOfPages()).thenReturn(10); + + List result = pdfWithPageNums.getPageNumbersList(mockDocument, true); + + assertTrue(result.isEmpty()); + } + + @Test + void testGetPageNumbersList_InvalidInput() { + pdfWithPageNums.setPageNumbers("invalid"); + when(mockDocument.getNumberOfPages()).thenReturn(10); + + assertThrows( + IllegalArgumentException.class, + () -> { + pdfWithPageNums.getPageNumbersList(mockDocument, true); + }); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdownTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdownTest.java new file mode 100644 index 000000000..ca24ff46f --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdownTest.java @@ -0,0 +1,109 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.common.util.PDFToFile; + +class ConvertPDFToMarkdownTest { + + private MockMvc mockMvc() { + return MockMvcBuilders.standaloneSetup(new ConvertPDFToMarkdown(null)) + .setControllerAdvice(new GlobalErrorHandler()) + .build(); + } + + @RestControllerAdvice + static class GlobalErrorHandler { + @ExceptionHandler(Exception.class) + ResponseEntity handle(Exception ex) { + String message = ex.getMessage(); + byte[] body = message != null ? message.getBytes(StandardCharsets.UTF_8) : new byte[0]; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body); + } + } + + @Test + void pdfToMarkdownReturnsMarkdownBytes() throws Exception { + byte[] md = "# heading\n\ncontent\n".getBytes(StandardCharsets.UTF_8); + + try (MockedConstruction construction = + Mockito.mockConstruction( + PDFToFile.class, + (mock, ctx) -> { + when(mock.processPdfToMarkdown(any(MultipartFile.class))) + .thenAnswer( + inv -> + ResponseEntity.ok() + .header("Content-Type", "text/markdown") + .body(md)); + })) { + + MockMvc mvc = mockMvc(); + + MockMultipartFile file = + new MockMultipartFile( + "fileInput", // must match the field name in PDFFile + "input.pdf", + "application/pdf", + new byte[] {1, 2, 3}); + + mvc.perform(multipart("/api/v1/convert/pdf/markdown").file(file)) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Type", "text/markdown")) + .andExpect(content().bytes(md)); + + // Verify that exactly one instance was created + assert construction.constructed().size() == 1; + + // And that the uploaded file was passed to processPdfToMarkdown() + PDFToFile created = construction.constructed().get(0); + ArgumentCaptor captor = ArgumentCaptor.forClass(MultipartFile.class); + verify(created, times(1)).processPdfToMarkdown(captor.capture()); + MultipartFile passed = captor.getValue(); + + // Minimal plausibility checks + assertEquals("input.pdf", passed.getOriginalFilename()); + assertEquals("application/pdf", passed.getContentType()); + } + } + + @Test + void pdfToMarkdownWhenServiceThrowsReturns500() throws Exception { + try (MockedConstruction ignored = + Mockito.mockConstruction( + PDFToFile.class, + (mock, ctx) -> { + when(mock.processPdfToMarkdown(any(MultipartFile.class))) + .thenThrow(new RuntimeException("boom")); + })) { + + MockMvc mvc = mockMvc(); + + MockMultipartFile file = + new MockMultipartFile( + "fileInput", "x.pdf", "application/pdf", new byte[] {0x01}); + + mvc.perform(multipart("/api/v1/convert/pdf/markdown").file(file)) + .andExpect(status().isInternalServerError()); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ScannerEffectRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ScannerEffectRequestTest.java new file mode 100644 index 000000000..be9c3ea61 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ScannerEffectRequestTest.java @@ -0,0 +1,130 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; + +class ScannerEffectRequestTest { + + private static Validator validator; + + @BeforeAll + static void setupValidator() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + @DisplayName("fileInput is @NotNull -> violation when missing") + void fileInput_missing_triggersViolation() { + ScannerEffectRequest req = new ScannerEffectRequest(); + + Set> violations = validator.validate(req); + boolean hasFileInputViolation = + violations.stream() + .anyMatch(v -> "fileInput".contentEquals(v.getPropertyPath().toString())); + + assertTrue( + hasFileInputViolation, + () -> + "Expected a validation violation on 'fileInput', but got: " + + violations.stream() + .map(v -> v.getPropertyPath() + " -> " + v.getMessage()) + .collect(Collectors.joining(", "))); + } + + @Test + @DisplayName("fileInput present -> no violation for fileInput") + void fileInput_present_noViolationForThatField() { + ScannerEffectRequest req = new ScannerEffectRequest(); + req.setFileInput( + new MockMultipartFile( + "fileInput", "test.pdf", "application/pdf", new byte[] {1, 2, 3})); + + Set> violations = validator.validate(req); + + boolean hasFileInputViolation = + violations.stream() + .anyMatch(v -> "fileInput".contentEquals(v.getPropertyPath().toString())); + + assertFalse( + hasFileInputViolation, + () -> + "Did not expect a validation violation on 'fileInput', but got: " + + violations.stream() + .map(v -> v.getPropertyPath() + " -> " + v.getMessage()) + .collect(Collectors.joining(", "))); + } + + @Test + @DisplayName("applyHighQualityPreset sets documented values") + void preset_highQuality() { + ScannerEffectRequest req = new ScannerEffectRequest(); + req.applyHighQualityPreset(); + + assertEquals(0.1f, req.getBlur(), 0.0001f); + assertEquals(1.0f, req.getNoise(), 0.0001f); + assertEquals(1.03f, req.getBrightness(), 0.0001f); + assertEquals(1.06f, req.getContrast(), 0.0001f); + assertEquals(150, req.getResolution()); + } + + @Test + @DisplayName("applyMediumQualityPreset sets documented values") + void preset_mediumQuality() { + ScannerEffectRequest req = new ScannerEffectRequest(); + req.applyMediumQualityPreset(); + + assertEquals(0.1f, req.getBlur(), 0.0001f); + assertEquals(1.0f, req.getNoise(), 0.0001f); + assertEquals(1.06f, req.getBrightness(), 0.0001f); + assertEquals(1.12f, req.getContrast(), 0.0001f); + assertEquals(100, req.getResolution()); + } + + @Test + @DisplayName("applyLowQualityPreset sets documented values") + void preset_lowQuality() { + ScannerEffectRequest req = new ScannerEffectRequest(); + req.applyLowQualityPreset(); + + assertEquals(0.9f, req.getBlur(), 0.0001f); + assertEquals(2.5f, req.getNoise(), 0.0001f); + assertEquals(1.08f, req.getBrightness(), 0.0001f); + assertEquals(1.15f, req.getContrast(), 0.0001f); + assertEquals(75, req.getResolution()); + } + + @Test + @DisplayName("getRotationValue() maps enum values to expected degrees") + void rotationValue_mapping() { + ScannerEffectRequest req = new ScannerEffectRequest(); + + // none -> 0 + req.setRotation(ScannerEffectRequest.Rotation.none); + assertEquals(0, req.getRotationValue(), "Rotation 'none' should map to 0°"); + + // slight -> 2 + req.setRotation(ScannerEffectRequest.Rotation.slight); + assertEquals(2, req.getRotationValue(), "Rotation 'slight' should map to 2°"); + + // moderate -> 5 + req.setRotation(ScannerEffectRequest.Rotation.moderate); + assertEquals(5, req.getRotationValue(), "Rotation 'moderate' should map to 5°"); + + // severe -> 8 + req.setRotation(ScannerEffectRequest.Rotation.severe); + assertEquals(8, req.getRotationValue(), "Rotation 'severe' should map to 8°"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/pdf/FlexibleCSVWriterTest.java b/app/core/src/test/java/stirling/software/SPDF/pdf/FlexibleCSVWriterTest.java new file mode 100644 index 000000000..77637ece5 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/pdf/FlexibleCSVWriterTest.java @@ -0,0 +1,25 @@ +package stirling.software.SPDF.pdf; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.apache.commons.csv.CSVFormat; +import org.junit.jupiter.api.Test; + +class FlexibleCSVWriterTest { + + @Test + void testDefaultConstructor() { + FlexibleCSVWriter writer = new FlexibleCSVWriter(); + assertNotNull(writer, "The FlexibleCSVWriter instance should not be null"); + } + + @Test + void testConstructorWithCSVFormat() { + CSVFormat csvFormat = CSVFormat.DEFAULT; + FlexibleCSVWriter writer = new FlexibleCSVWriter(csvFormat); + assertNotNull( + writer, + "The FlexibleCSVWriter instance should not be null when initialized with" + + " CSVFormat"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java b/app/core/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java index 3e5092070..9d1073304 100644 --- a/app/core/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/pdf/TextFinderTest.java @@ -1,10 +1,6 @@ package stirling.software.SPDF.pdf; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import java.io.IOException; import java.util.List; diff --git a/app/core/src/test/java/stirling/software/SPDF/service/ApiDocServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/ApiDocServiceTest.java new file mode 100644 index 000000000..2317abfb9 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/ApiDocServiceTest.java @@ -0,0 +1,124 @@ +package stirling.software.SPDF.service; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.ServletContext; + +import stirling.software.SPDF.model.ApiEndpoint; +import stirling.software.common.service.UserServiceInterface; + +@ExtendWith(MockitoExtension.class) +class ApiDocServiceTest { + + @Mock ServletContext servletContext; + @Mock UserServiceInterface userService; + + ApiDocService apiDocService; + ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + apiDocService = new ApiDocService(servletContext, userService); + } + + private void setApiDocumentation(Map docs) throws Exception { + Field field = ApiDocService.class.getDeclaredField("apiDocumentation"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + Map map = (Map) field.get(apiDocService); + map.clear(); + map.putAll(docs); + } + + private void setApiDocsJsonRootNode() throws Exception { + Field field = ApiDocService.class.getDeclaredField("apiDocsJsonRootNode"); + field.setAccessible(true); + field.set(apiDocService, mapper.createObjectNode()); + } + + @Test + void getExtensionTypesReturnsExpectedList() throws Exception { + String json = "{\"description\": \"Output:PDF\"}"; + JsonNode postNode = mapper.readTree(json); + ApiEndpoint endpoint = new ApiEndpoint("/test", postNode); + + setApiDocumentation(Map.of("/test", endpoint)); + setApiDocsJsonRootNode(); + + List extensions = apiDocService.getExtensionTypes(true, "/test"); + assertEquals(List.of("pdf"), extensions); + } + + @Test + void getExtensionTypesHandlesUnknownOperation() throws Exception { + setApiDocumentation(Map.of()); + + List extensions = apiDocService.getExtensionTypes(true, "/unknown"); + assertNull(extensions); + } + + @Test + void isValidOperationChecksRequiredParameters() throws Exception { + String json = + "{\"description\": \"desc\", \"parameters\": [{\"name\":\"param1\"}, {\"name\":\"param2\"}]}"; + JsonNode postNode = mapper.readTree(json); + ApiEndpoint endpoint = new ApiEndpoint("/op", postNode); + + setApiDocumentation(Map.of("/op", endpoint)); + setApiDocsJsonRootNode(); + + assertTrue(apiDocService.isValidOperation("/op", Map.of("param1", "a", "param2", "b"))); + assertFalse(apiDocService.isValidOperation("/op", Map.of("param1", "a"))); + } + + @Test + void isValidOperationHandlesUnknownOperation() throws Exception { + setApiDocumentation(Map.of()); + + assertFalse(apiDocService.isValidOperation("/unknown", Map.of("param1", "a"))); + } + + @Test + void isMultiInputDetectsTypeMI() throws Exception { + String json = "{\"description\": \"Type:MI\"}"; + JsonNode postNode = mapper.readTree(json); + ApiEndpoint endpoint = new ApiEndpoint("/multi", postNode); + + setApiDocumentation(Map.of("/multi", endpoint)); + setApiDocsJsonRootNode(); + + assertTrue(apiDocService.isMultiInput("/multi")); + } + + @Test + void isMultiInputDetectsUnknownOperation() throws Exception { + setApiDocumentation(Map.of()); + + assertFalse(apiDocService.isMultiInput("/unknown")); + } + + @Test + void isMultiInputHandlesNoDescription() throws Exception { + String json = "{\"parameters\": [{\"name\":\"param1\"}, {\"name\":\"param2\"}]}"; + JsonNode postNode = mapper.readTree(json); + ApiEndpoint endpoint = new ApiEndpoint("/multi", postNode); + + setApiDocumentation(Map.of("/multi", endpoint)); + setApiDocsJsonRootNode(); + + assertFalse(apiDocService.isMultiInput("/multi")); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/CertificateValidationServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/CertificateValidationServiceTest.java index b518d63fc..e5c494356 100644 --- a/app/core/src/test/java/stirling/software/SPDF/service/CertificateValidationServiceTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/service/CertificateValidationServiceTest.java @@ -1,12 +1,14 @@ package stirling.software.SPDF.service; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.lang.reflect.Field; +import java.security.KeyStore; +import java.security.KeyStoreException; import java.security.PublicKey; import java.security.cert.CertificateExpiredException; import java.security.cert.X509Certificate; @@ -77,6 +79,9 @@ class CertificateValidationServiceTest { // Then validation should succeed assertTrue(result, "Certificate with matching issuer and subject should validate"); + + // Ensure no exceptions are thrown during validation + assertDoesNotThrow(() -> validationService.validateTrustWithCustomCert(null, issuingCert)); } @Test @@ -143,4 +148,55 @@ class CertificateValidationServiceTest { // Then validation should fail assertFalse(result, "Certificate chain with failed signing should not validate"); } + + @Test + void testValidateTrustStore_found_returnsTrue() throws Exception { + KeyStore ks = mock(KeyStore.class); + + // A certificate mock that is both in the keystore and being checked: + X509Certificate same = mock(X509Certificate.class); + + when(ks.aliases()) + .thenReturn(java.util.Collections.enumeration(java.util.List.of("alias1"))); + when(ks.getCertificate("alias1")).thenReturn(same); + + // Set trustStore via reflection + var f = CertificateValidationService.class.getDeclaredField("trustStore"); + f.setAccessible(true); + f.set(validationService, ks); + + // same instance -> equals() true without stubbing + assertTrue(validationService.validateTrustStore(same)); + } + + @Test + void testValidateTrustStore_notFound_returnsFalse() throws Exception { + KeyStore ks = mock(KeyStore.class); + + X509Certificate inStore = mock(X509Certificate.class); + X509Certificate probe = mock(X509Certificate.class); + + when(ks.aliases()) + .thenReturn(java.util.Collections.enumeration(java.util.List.of("alias1"))); + when(ks.getCertificate("alias1")).thenReturn(inStore); // != probe + + var f = CertificateValidationService.class.getDeclaredField("trustStore"); + f.setAccessible(true); + f.set(validationService, ks); + + assertFalse(validationService.validateTrustStore(probe)); + } + + @Test + void testValidateTrustStore_keyStoreAliasesThrows_returnsFalse() throws Exception { + KeyStore ks = mock(KeyStore.class); + when(ks.aliases()).thenThrow(new KeyStoreException("boom")); + + Field f = CertificateValidationService.class.getDeclaredField("trustStore"); + f.setAccessible(true); + f.set(validationService, ks); + + X509Certificate probe = mock(X509Certificate.class); + assertFalse(validationService.validateTrustStore(probe)); + } } diff --git a/app/core/src/test/java/stirling/software/SPDF/service/LanguageServiceBasicTest.java b/app/core/src/test/java/stirling/software/SPDF/service/LanguageServiceBasicTest.java index 5bd2cb188..542ebe8af 100644 --- a/app/core/src/test/java/stirling/software/SPDF/service/LanguageServiceBasicTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/service/LanguageServiceBasicTest.java @@ -1,7 +1,6 @@ package stirling.software.SPDF.service; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; diff --git a/app/core/src/test/java/stirling/software/SPDF/service/MetricsAggregatorServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/MetricsAggregatorServiceTest.java new file mode 100644 index 000000000..c14b00e4a --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/MetricsAggregatorServiceTest.java @@ -0,0 +1,75 @@ +package stirling.software.SPDF.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; + +import stirling.software.SPDF.config.EndpointInspector; +import stirling.software.common.service.PostHogService; + +class MetricsAggregatorServiceTest { + + private SimpleMeterRegistry meterRegistry; + private PostHogService postHogService; + private EndpointInspector endpointInspector; + private MetricsAggregatorService metricsAggregatorService; + + @BeforeEach + void setUp() { + meterRegistry = new SimpleMeterRegistry(); + postHogService = mock(PostHogService.class); + endpointInspector = mock(EndpointInspector.class); + when(endpointInspector.getValidGetEndpoints()).thenReturn(Set.of("/getEndpoint")); + when(endpointInspector.isValidGetEndpoint("/getEndpoint")).thenReturn(true); + metricsAggregatorService = + new MetricsAggregatorService(meterRegistry, postHogService, endpointInspector); + } + + @Captor private ArgumentCaptor> captor; + + @Test + void testAggregateAndSendMetrics() { + meterRegistry.counter("http.requests", "method", "GET", "uri", "/getEndpoint").increment(3); + meterRegistry.counter("http.requests", "method", "POST", "uri", "/api/v1/do").increment(2); + + metricsAggregatorService.aggregateAndSendMetrics(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(postHogService).captureEvent(eq("aggregated_metrics"), captor.capture()); + Map metrics = captor.getValue(); + + assertEquals(2, metrics.size()); + assertEquals(3.0, (Double) metrics.get("http_requests_GET__getEndpoint")); + assertEquals(2.0, (Double) metrics.get("http_requests_POST__api_v1_do")); + } + + @Test + void testAggregateAndSendMetricsSendsOnlyDifferences() { + Counter counter = + meterRegistry.counter("http.requests", "method", "GET", "uri", "/getEndpoint"); + counter.increment(5); + metricsAggregatorService.aggregateAndSendMetrics(); + reset(postHogService); + + counter.increment(2); + metricsAggregatorService.aggregateAndSendMetrics(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(postHogService).captureEvent(eq("aggregated_metrics"), captor.capture()); + Map metrics = captor.getValue(); + + assertEquals(1, metrics.size()); + assertEquals(2.0, (Double) metrics.get("http_requests_GET__getEndpoint")); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/SignatureServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/SignatureServiceTest.java index 8f072e1fe..6bcd4368f 100644 --- a/app/core/src/test/java/stirling/software/SPDF/service/SignatureServiceTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/service/SignatureServiceTest.java @@ -1,9 +1,6 @@ package stirling.software.SPDF.service; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mockStatic; import java.io.FileNotFoundException; diff --git a/app/core/src/test/java/stirling/software/SPDF/service/misc/ReplaceAndInvertColorServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/misc/ReplaceAndInvertColorServiceTest.java new file mode 100644 index 000000000..a668eb9f1 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/misc/ReplaceAndInvertColorServiceTest.java @@ -0,0 +1,77 @@ +package stirling.software.SPDF.service.misc; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.core.io.InputStreamResource; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.SPDF.Factories.ReplaceAndInvertColorFactory; +import stirling.software.common.model.api.misc.HighContrastColorCombination; +import stirling.software.common.model.api.misc.ReplaceAndInvert; +import stirling.software.common.util.misc.ReplaceAndInvertColorStrategy; + +class ReplaceAndInvertColorServiceTest { + + @Mock private ReplaceAndInvertColorFactory replaceAndInvertColorFactory; + + @Mock private MultipartFile file; + + @Mock private ReplaceAndInvertColorStrategy replaceAndInvertColorStrategy; + + @InjectMocks private ReplaceAndInvertColorService replaceAndInvertColorService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void testReplaceAndInvertColor() throws IOException { + // Arrange + ReplaceAndInvert replaceAndInvertOption = mock(ReplaceAndInvert.class); + HighContrastColorCombination highContrastColorCombination = + mock(HighContrastColorCombination.class); + String backGroundColor = "#FFFFFF"; + String textColor = "#000000"; + + when(replaceAndInvertColorFactory.replaceAndInvert( + file, + replaceAndInvertOption, + highContrastColorCombination, + backGroundColor, + textColor)) + .thenReturn(replaceAndInvertColorStrategy); + + InputStreamResource expectedResource = mock(InputStreamResource.class); + when(replaceAndInvertColorStrategy.replace()).thenReturn(expectedResource); + + // Act + InputStreamResource result = + replaceAndInvertColorService.replaceAndInvertColor( + file, + replaceAndInvertOption, + highContrastColorCombination, + backGroundColor, + textColor); + + // Assert + assertNotNull(result); + assertEquals(expectedResource, result); + verify(replaceAndInvertColorFactory, times(1)) + .replaceAndInvert( + file, + replaceAndInvertOption, + highContrastColorCombination, + backGroundColor, + textColor); + verify(replaceAndInvertColorStrategy, times(1)).replace(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/model/TeamTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/model/TeamTest.java new file mode 100644 index 000000000..82e564bae --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/model/TeamTest.java @@ -0,0 +1,74 @@ +package stirling.software.proprietary.model; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.proprietary.security.model.User; + +@ExtendWith(MockitoExtension.class) +class TeamTest { + + @Test + void users_isInitializedAndEmpty() { + Team team = new Team(); + assertNotNull(team.getUsers(), "users Set should be initialized"); + assertTrue(team.getUsers().isEmpty(), "users Set should start empty"); + } + + @Test + void addUser_addsToSet_and_setsBackReference() { + Team team = new Team(); + User user = mock(User.class); + + team.addUser(user); + + assertTrue(team.getUsers().contains(user), "Team should contain added user"); + verify(user, times(1)).setTeam(team); + verifyNoMoreInteractions(user); + } + + @Test + void addUser_twice_isIdempotent_dueToSetSemantics() { + Team team = new Team(); + User user = mock(User.class); + + team.addUser(user); + team.addUser(user); + + assertEquals(1, team.getUsers().size(), "Adding same user twice should not duplicate"); + // In our code, setTeam is called twice (we only test Set idempotency) + verify(user, times(2)).setTeam(team); + } + + @Test + void removeUser_removesFromSet_and_clearsBackReference() { + Team team = new Team(); + User user = mock(User.class); + + team.addUser(user); + assertTrue(team.getUsers().contains(user)); + + team.removeUser(user); + + assertFalse(team.getUsers().contains(user), "User should be removed from Team"); + verify(user, times(1)).setTeam(null); + } + + @Test + void removeUser_onUserNotInSet_still_clearsBackReference() { + Team team = new Team(); + User stranger = mock(User.class); + + // not added + team.removeUser(stranger); + + // Set remains empty + assertTrue(team.getUsers().isEmpty()); + // Back-reference is still set to null + verify(stranger, times(1)).setTeam(null); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/model/dto/TeamWithUserCountDTOTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/model/dto/TeamWithUserCountDTOTest.java new file mode 100644 index 000000000..6244584a7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/model/dto/TeamWithUserCountDTOTest.java @@ -0,0 +1,58 @@ +package stirling.software.proprietary.model.dto; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class TeamWithUserCountDTOTest { + + @Test + void allArgsConstructor_setsFields() { + TeamWithUserCountDTO dto = new TeamWithUserCountDTO(1L, "Engineering", 42L); + + assertEquals(1L, dto.getId()); + assertEquals("Engineering", dto.getName()); + assertEquals(42L, dto.getUserCount()); + } + + @Test + void noArgsConstructor_and_setters_work() { + TeamWithUserCountDTO dto = new TeamWithUserCountDTO(); + + assertNull(dto.getId()); + assertNull(dto.getName()); + assertNull(dto.getUserCount()); + + dto.setId(7L); + dto.setName("Ops"); + dto.setUserCount(5L); + + assertEquals(7L, dto.getId()); + assertEquals("Ops", dto.getName()); + assertEquals(5L, dto.getUserCount()); + } + + @Test + void equals_and_hashCode_based_on_fields() { + TeamWithUserCountDTO a = new TeamWithUserCountDTO(10L, "Team", 3L); + TeamWithUserCountDTO b = new TeamWithUserCountDTO(10L, "Team", 3L); + TeamWithUserCountDTO c = new TeamWithUserCountDTO(10L, "Team", 4L); // differs in userCount + + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + + assertNotEquals(a, c); + // Not strictly required but often true when a field differs: + assertNotEquals(a.hashCode(), c.hashCode()); + } + + @Test + void toString_contains_field_values() { + TeamWithUserCountDTO dto = new TeamWithUserCountDTO(2L, "QA", 8L); + String ts = dto.toString(); + + assertTrue(ts.contains("2")); + assertTrue(ts.contains("QA")); + assertTrue(ts.contains("8")); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/DatabaseConfigTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/DatabaseConfigTest.java index eac32eec4..e74c5c56c 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/DatabaseConfigTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/DatabaseConfigTest.java @@ -83,4 +83,12 @@ class DatabaseConfigTest { assertThrows(UnsupportedProviderException.class, () -> databaseConfig.dataSource()); } + + @Test + void getDriverClassName_returnsH2Driver() throws Exception { + var m = DatabaseConfig.class.getDeclaredMethod("getDriverClassName", String.class); + m.setAccessible(true); + String driver = (String) m.invoke(databaseConfig, "h2"); + assertEquals(org.springframework.boot.jdbc.DatabaseDriver.H2.getDriverClassName(), driver); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/database/H2SQLConditionTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/database/H2SQLConditionTest.java new file mode 100644 index 000000000..dead1b44f --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/database/H2SQLConditionTest.java @@ -0,0 +1,87 @@ +package stirling.software.proprietary.security.database; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.type.AnnotatedTypeMetadata; +import org.springframework.mock.env.MockEnvironment; + +class H2SQLConditionTest { + + private final H2SQLCondition condition = new H2SQLCondition(); + + private boolean eval(MockEnvironment env) { + ConditionContext ctx = mock(ConditionContext.class); + when(ctx.getEnvironment()).thenReturn(env); + AnnotatedTypeMetadata md = mock(AnnotatedTypeMetadata.class); + return condition.matches(ctx, md); + } + + @Test + void returnsTrue_whenDisabledOrMissing_and_typeIsH2_caseInsensitive() { + // Flag fehlt, Typ=h2 -> true + MockEnvironment envMissingFlag = + new MockEnvironment().withProperty("system.datasource.type", "h2"); + assertTrue(eval(envMissingFlag)); + + // Flag=false, Typ=H2 -> true + MockEnvironment envFalseFlag = + new MockEnvironment() + .withProperty("system.datasource.enableCustomDatabase", "false") + .withProperty("system.datasource.type", "H2"); + assertTrue(eval(envFalseFlag)); + } + + @Test + void returnsFalse_whenEnableCustomDatabase_true_regardlessOfType() { + // Flag=true, Typ=h2 -> false + MockEnvironment envTrueH2 = + new MockEnvironment() + .withProperty("system.datasource.enableCustomDatabase", "true") + .withProperty("system.datasource.type", "h2"); + assertFalse(eval(envTrueH2)); + + // Flag=true, Typ=postgres -> false + MockEnvironment envTrueOther = + new MockEnvironment() + .withProperty("system.datasource.enableCustomDatabase", "true") + .withProperty("system.datasource.type", "postgresql"); + assertFalse(eval(envTrueOther)); + + // Flag=true, Typ fehlt -> false + MockEnvironment envTrueMissingType = + new MockEnvironment() + .withProperty("system.datasource.enableCustomDatabase", "true"); + assertFalse(eval(envTrueMissingType)); + } + + @Test + void returnsFalse_whenTypeNotH2_orMissing_andFlagNotEnabled() { + // Flag fehlt, Typ=postgres -> false + MockEnvironment envNotH2 = + new MockEnvironment().withProperty("system.datasource.type", "postgresql"); + assertFalse(eval(envNotH2)); + + // Flag=false, Typ fehlt -> false (Default: "") + MockEnvironment envMissingType = + new MockEnvironment() + .withProperty("system.datasource.enableCustomDatabase", "false"); + assertFalse(eval(envMissingType)); + } + + @Test + void returnsFalse_whenEnabled_but_type_not_h2_or_missing() { + MockEnvironment envNotH2 = + new MockEnvironment() + .withProperty("system.datasource.enableCustomDatabase", "true") + .withProperty("system.datasource.type", "postgresql"); + assertFalse(eval(envNotH2)); + + MockEnvironment envMissingType = + new MockEnvironment() + .withProperty("system.datasource.enableCustomDatabase", "true"); + assertFalse(eval(envMissingType)); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/database/ScheduledTasksTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/database/ScheduledTasksTest.java new file mode 100644 index 000000000..cece597a3 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/database/ScheduledTasksTest.java @@ -0,0 +1,71 @@ +package stirling.software.proprietary.security.database; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Method; +import java.sql.SQLException; +import java.util.Arrays; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.annotation.Conditional; +import org.springframework.scheduling.annotation.Scheduled; + +import stirling.software.common.model.exception.UnsupportedProviderException; +import stirling.software.proprietary.security.service.DatabaseServiceInterface; + +@ExtendWith(MockitoExtension.class) +class ScheduledTasksTest { + + @Mock private DatabaseServiceInterface databaseService; + + @Test + void performBackup_calls_exportDatabase() throws Exception { + ScheduledTasks tasks = new ScheduledTasks(databaseService); + + tasks.performBackup(); + + verify(databaseService, times(1)).exportDatabase(); + verifyNoMoreInteractions(databaseService); + } + + @Test + void performBackup_propagates_SQLException() throws Exception { + ScheduledTasks tasks = new ScheduledTasks(databaseService); + doThrow(new SQLException("boom")).when(databaseService).exportDatabase(); + + assertThrows(SQLException.class, tasks::performBackup); + } + + @Test + void performBackup_propagates_UnsupportedProviderException() throws Exception { + ScheduledTasks tasks = new ScheduledTasks(databaseService); + doThrow(new UnsupportedProviderException("nope")).when(databaseService).exportDatabase(); + + assertThrows(UnsupportedProviderException.class, tasks::performBackup); + } + + @Test + void hasScheduledAnnotation_withSpELCron() throws Exception { + Method m = ScheduledTasks.class.getDeclaredMethod("performBackup"); + Scheduled scheduled = m.getAnnotation(Scheduled.class); + assertNotNull(scheduled, "@Scheduled annotation missing on performBackup()"); + assertEquals( + "#{applicationProperties.system.databaseBackup.cron}", + scheduled.cron(), + "Unexpected cron SpEL expression"); + } + + @Test + void classHasConditional_onH2SQLCondition() { + Conditional conditional = ScheduledTasks.class.getAnnotation(Conditional.class); + assertNotNull(conditional, "@Conditional missing on ScheduledTasks class"); + + boolean containsH2 = + Arrays.stream(conditional.value()).anyMatch(c -> c == H2SQLCondition.class); + assertTrue(containsH2, "@Conditional should include H2SQLCondition"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/database/repository/JPATokenRepositoryImplTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/database/repository/JPATokenRepositoryImplTest.java new file mode 100644 index 000000000..381e36a91 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/database/repository/JPATokenRepositoryImplTest.java @@ -0,0 +1,141 @@ +package stirling.software.proprietary.security.database.repository; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.Date; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken; + +import stirling.software.proprietary.security.model.PersistentLogin; + +class JPATokenRepositoryImplTest { + + private final PersistentLoginRepository persistentLoginRepository = + mock(PersistentLoginRepository.class); + private final JPATokenRepositoryImpl tokenRepository = + new JPATokenRepositoryImpl(persistentLoginRepository); + + @Nested + @DisplayName("createNewToken") + class CreateNewTokenTests { + + @Test + @DisplayName("should save new PersistentLogin with correct values") + void shouldSaveNewToken() { + Date date = new Date(); + PersistentRememberMeToken token = + new PersistentRememberMeToken("user1", "series123", "tokenABC", date); + + tokenRepository.createNewToken(token); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PersistentLogin.class); + verify(persistentLoginRepository).save(captor.capture()); + + PersistentLogin saved = captor.getValue(); + assertEquals("series123", saved.getSeries()); + assertEquals("user1", saved.getUsername()); + assertEquals("tokenABC", saved.getToken()); + assertEquals(date.toInstant(), saved.getLastUsed()); + } + } + + @Nested + @DisplayName("updateToken") + class UpdateTokenTests { + + @Test + @DisplayName("should update existing token if found") + void shouldUpdateExistingToken() { + PersistentLogin existing = new PersistentLogin(); + existing.setSeries("series123"); + existing.setUsername("user1"); + existing.setToken("oldToken"); + existing.setLastUsed(new Date().toInstant()); + + when(persistentLoginRepository.findById("series123")).thenReturn(Optional.of(existing)); + + Date newDate = new Date(); + tokenRepository.updateToken("series123", "newToken", newDate); + + assertEquals("newToken", existing.getToken()); + assertEquals(newDate.toInstant(), existing.getLastUsed()); + verify(persistentLoginRepository).save(existing); + } + + @Test + @DisplayName("should do nothing if token not found") + void shouldDoNothingIfNotFound() { + when(persistentLoginRepository.findById("unknownSeries")).thenReturn(Optional.empty()); + + tokenRepository.updateToken("unknownSeries", "newToken", new Date()); + + verify(persistentLoginRepository, never()).save(any()); + } + } + + @Nested + @DisplayName("getTokenForSeries") + class GetTokenForSeriesTests { + + @Test + @DisplayName("should return PersistentRememberMeToken if found") + void shouldReturnTokenIfFound() { + Date date = new Date(); + PersistentLogin login = new PersistentLogin(); + login.setSeries("series123"); + login.setUsername("user1"); + login.setToken("tokenXYZ"); + login.setLastUsed(date.toInstant()); + + when(persistentLoginRepository.findById("series123")).thenReturn(Optional.of(login)); + + PersistentRememberMeToken result = tokenRepository.getTokenForSeries("series123"); + + assertNotNull(result); + assertEquals("user1", result.getUsername()); + assertEquals("series123", result.getSeries()); + assertEquals("tokenXYZ", result.getTokenValue()); + assertEquals(date, result.getDate()); + } + + @Test + @DisplayName("should return null if token not found") + void shouldReturnNullIfNotFound() { + when(persistentLoginRepository.findById("series123")).thenReturn(Optional.empty()); + + PersistentRememberMeToken result = tokenRepository.getTokenForSeries("series123"); + + assertNull(result); + } + } + + @Nested + @DisplayName("removeUserTokens") + class RemoveUserTokensTests { + + @Test + @DisplayName("should call deleteByUsername normally") + void shouldCallDeleteByUsername() { + tokenRepository.removeUserTokens("user1"); + verify(persistentLoginRepository).deleteByUsername("user1"); + } + + @Test + @DisplayName("should swallow exception if deleteByUsername fails") + void shouldSwallowException() { + doThrow(new RuntimeException("DB error")) + .when(persistentLoginRepository) + .deleteByUsername("user1"); + + assertDoesNotThrow(() -> tokenRepository.removeUserTokens("user1")); + verify(persistentLoginRepository).deleteByUsername("user1"); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java index 916bd3721..f1fe72e1c 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java @@ -1,31 +1,30 @@ package stirling.software.proprietary.security.filter; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; +import java.sql.SQLException; import java.util.Collections; import java.util.Map; +import java.util.Optional; -import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.MockedStatic; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.web.AuthenticationEntryPoint; @@ -35,16 +34,23 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.exception.UnsupportedProviderException; +import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; +import stirling.software.proprietary.security.model.AuthenticationType; +import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; import stirling.software.proprietary.security.service.CustomUserDetailsService; import stirling.software.proprietary.security.service.JwtServiceInterface; +import stirling.software.proprietary.security.service.UserService; -@Disabled @ExtendWith(MockitoExtension.class) class JwtAuthenticationFilterTest { @Mock private JwtServiceInterface jwtService; + @Mock private UserService userService; + @Mock private CustomUserDetailsService userDetailsService; @Mock private HttpServletRequest request; @@ -55,11 +61,28 @@ class JwtAuthenticationFilterTest { @Mock private UserDetails userDetails; - @Mock private SecurityContext securityContext; - @Mock private AuthenticationEntryPoint authenticationEntryPoint; - @InjectMocks private JwtAuthenticationFilter jwtAuthenticationFilter; + private JwtAuthenticationFilter jwtAuthenticationFilter; + private ApplicationProperties.Security securityProperties; + + @BeforeEach + void setUp() { + securityProperties = new ApplicationProperties.Security(); + jwtAuthenticationFilter = + new JwtAuthenticationFilter( + jwtService, + userService, + userDetailsService, + authenticationEntryPoint, + securityProperties); + SecurityContextHolder.setContext(new SecurityContextImpl()); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } @Test void shouldNotAuthenticateWhenJwtDisabled() throws ServletException, IOException { @@ -68,69 +91,53 @@ class JwtAuthenticationFilterTest { jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); verify(filterChain).doFilter(request, response); - verify(jwtService, never()).extractToken(any()); + verify(jwtService, never()).extractToken(any(HttpServletRequest.class)); } @Test void shouldNotFilterWhenPageIsLogin() throws ServletException, IOException { when(jwtService.isJwtEnabled()).thenReturn(true); when(request.getRequestURI()).thenReturn("/login"); - when(request.getContextPath()).thenReturn("/login"); + when(request.getContextPath()).thenReturn(""); jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - verify(filterChain, never()).doFilter(request, response); + verify(filterChain).doFilter(request, response); + verify(jwtService, never()).extractToken(any(HttpServletRequest.class)); } @Test - void testDoFilterInternal() throws ServletException, IOException { + void shouldAuthenticateUserWithValidToken() throws ServletException, IOException { String token = "valid-jwt-token"; - String newToken = "new-jwt-token"; String username = "testuser"; Map claims = Map.of("sub", username, "authType", "WEB"); when(jwtService.isJwtEnabled()).thenReturn(true); - when(request.getContextPath()).thenReturn("/"); + when(request.getContextPath()).thenReturn(""); when(request.getRequestURI()).thenReturn("/protected"); when(jwtService.extractToken(request)).thenReturn(token); - doNothing().when(jwtService).validateToken(token); when(jwtService.extractClaims(token)).thenReturn(claims); when(userDetails.getAuthorities()).thenReturn(Collections.emptyList()); when(userDetailsService.loadUserByUsername(username)).thenReturn(userDetails); - try (MockedStatic mockedSecurityContextHolder = - mockStatic(SecurityContextHolder.class)) { - UsernamePasswordAuthenticationToken authToken = - new UsernamePasswordAuthenticationToken( - userDetails, null, userDetails.getAuthorities()); + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - when(securityContext.getAuthentication()).thenReturn(null).thenReturn(authToken); - mockedSecurityContextHolder - .when(SecurityContextHolder::getContext) - .thenReturn(securityContext); - when(jwtService.generateToken( - any(UsernamePasswordAuthenticationToken.class), eq(claims))) - .thenReturn(newToken); + verify(jwtService).validateToken(token); + verify(jwtService).extractClaims(token); + verify(userDetailsService).loadUserByUsername(username); - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - - verify(jwtService).validateToken(token); - verify(jwtService).extractClaims(token); - verify(userDetailsService).loadUserByUsername(username); - verify(securityContext) - .setAuthentication(any(UsernamePasswordAuthenticationToken.class)); - verify(jwtService) - .generateToken(any(UsernamePasswordAuthenticationToken.class), eq(claims)); - verify(jwtService).addToken(response, newToken); - verify(filterChain).doFilter(request, response); - } + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + assertNotNull(authentication); + assertTrue(authentication instanceof UsernamePasswordAuthenticationToken); + assertEquals(userDetails, authentication.getPrincipal()); + verify(filterChain).doFilter(request, response); } @Test - void testDoFilterInternalWithMissingTokenForRootPath() throws ServletException, IOException { + void shouldRedirectToLoginWhenTokenMissing() throws ServletException, IOException { when(jwtService.isJwtEnabled()).thenReturn(true); - when(request.getRequestURI()).thenReturn("/"); - when(request.getMethod()).thenReturn("GET"); + when(request.getContextPath()).thenReturn(""); + when(request.getRequestURI()).thenReturn("/protected"); when(jwtService.extractToken(request)).thenReturn(null); jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); @@ -140,12 +147,12 @@ class JwtAuthenticationFilterTest { } @Test - void validationFailsWithInvalidToken() throws ServletException, IOException { + void shouldHandleInvalidToken() throws ServletException, IOException { String token = "invalid-jwt-token"; when(jwtService.isJwtEnabled()).thenReturn(true); when(request.getRequestURI()).thenReturn("/protected"); - when(request.getContextPath()).thenReturn("/"); + when(request.getContextPath()).thenReturn(""); when(jwtService.extractToken(request)).thenReturn(token); doThrow(new AuthenticationFailureException("Invalid token")) .when(jwtService) @@ -153,6 +160,7 @@ class JwtAuthenticationFilterTest { jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + verify(jwtService).clearToken(response); verify(jwtService).validateToken(token); verify(authenticationEntryPoint) .commence(eq(request), eq(response), any(AuthenticationFailureException.class)); @@ -160,26 +168,7 @@ class JwtAuthenticationFilterTest { } @Test - void validationFailsWithExpiredToken() throws ServletException, IOException { - String token = "expired-jwt-token"; - - when(jwtService.isJwtEnabled()).thenReturn(true); - when(request.getRequestURI()).thenReturn("/protected"); - when(request.getContextPath()).thenReturn("/"); - when(jwtService.extractToken(request)).thenReturn(token); - doThrow(new AuthenticationFailureException("The token has expired")) - .when(jwtService) - .validateToken(token); - - jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - - verify(jwtService).validateToken(token); - verify(authenticationEntryPoint).commence(eq(request), eq(response), any()); - verify(filterChain, never()).doFilter(request, response); - } - - @Test - void exceptionThrown_WhenUserNotFound() throws ServletException, IOException { + void exceptionThrownWhenUserNotFound() throws ServletException, IOException { String token = "valid-jwt-token"; String username = "nonexistentuser"; Map claims = Map.of("sub", username, "authType", "WEB"); @@ -188,49 +177,125 @@ class JwtAuthenticationFilterTest { when(request.getRequestURI()).thenReturn("/protected"); when(request.getContextPath()).thenReturn("/"); when(jwtService.extractToken(request)).thenReturn(token); - doNothing().when(jwtService).validateToken(token); when(jwtService.extractClaims(token)).thenReturn(claims); when(userDetailsService.loadUserByUsername(username)).thenReturn(null); - try (MockedStatic mockedSecurityContextHolder = - mockStatic(SecurityContextHolder.class)) { - when(securityContext.getAuthentication()).thenReturn(null); - mockedSecurityContextHolder - .when(SecurityContextHolder::getContext) - .thenReturn(securityContext); + UsernameNotFoundException result = + assertThrows( + UsernameNotFoundException.class, + () -> + jwtAuthenticationFilter.doFilterInternal( + request, response, filterChain)); - UsernameNotFoundException result = - assertThrows( - UsernameNotFoundException.class, - () -> - jwtAuthenticationFilter.doFilterInternal( - request, response, filterChain)); - - assertEquals("User not found: " + username, result.getMessage()); - verify(userDetailsService).loadUserByUsername(username); - verify(filterChain, never()).doFilter(request, response); - } + assertEquals("User not found: " + username, result.getMessage()); + verify(userDetailsService).loadUserByUsername(username); + verify(filterChain, never()).doFilter(request, response); } @Test - void testAuthenticationEntryPointCalledWithCorrectException() - throws ServletException, IOException { + void shouldAuthenticateWithApiKey() throws ServletException, IOException { + String apiKey = "api-key"; + User user = Mockito.mock(User.class); + when(jwtService.isJwtEnabled()).thenReturn(true); - when(request.getRequestURI()).thenReturn("/protected"); - when(request.getContextPath()).thenReturn("/"); - when(jwtService.extractToken(request)).thenReturn(null); + when(request.getContextPath()).thenReturn(""); + when(request.getRequestURI()).thenReturn("/api/resource"); + when(request.getHeader("X-API-KEY")).thenReturn(apiKey); + when(user.getAuthorities()).thenReturn(Collections.emptySet()); + when(userService.getUserByApiKey(apiKey)).thenReturn(Optional.of(user)); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + assertNotNull(authentication); + assertTrue(authentication instanceof ApiKeyAuthenticationToken); + verify(filterChain).doFilter(request, response); + verify(jwtService, never()).extractToken(any(HttpServletRequest.class)); + } + + @Test + void shouldHandleInvalidApiKey() throws ServletException, IOException { + String apiKey = "api-key"; + + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getContextPath()).thenReturn(""); + when(request.getRequestURI()).thenReturn("/api/resource"); + when(request.getHeader("X-API-KEY")).thenReturn(apiKey); + when(userService.getUserByApiKey(apiKey)).thenReturn(Optional.empty()); jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); verify(authenticationEntryPoint) - .commence( - eq(request), - eq(response), - argThat( - exception -> - exception - .getMessage() - .equals("JWT is missing from the request"))); + .commence(eq(request), eq(response), any(AuthenticationFailureException.class)); + verify(jwtService).extractToken(request); + verify(filterChain, never()).doFilter(request, response); + } + + @Test + void shouldHandleApiKeyAuthenticationException() throws ServletException, IOException { + String apiKey = "api-key"; + + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getContextPath()).thenReturn(""); + when(request.getRequestURI()).thenReturn("/api/resource"); + when(request.getHeader("X-API-KEY")).thenReturn(apiKey); + doThrow(new AuthenticationFailureException("Invalid API Key")) + .when(userService) + .getUserByApiKey(apiKey); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(authenticationEntryPoint) + .commence(eq(request), eq(response), any(AuthenticationFailureException.class)); + verify(jwtService).extractToken(request); + verify(filterChain, never()).doFilter(request, response); + } + + @Test + void shouldProcessOauth2Authentication() + throws ServletException, IOException, SQLException, UnsupportedProviderException { + String token = "valid-jwt-token"; + String username = "oauth-user"; + Map claims = Map.of("sub", username, "authType", "OAUTH2"); + + securityProperties.getOauth2().setAutoCreateUser(true); + + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getContextPath()).thenReturn(""); + when(request.getRequestURI()).thenReturn("/protected"); + when(jwtService.extractToken(request)).thenReturn(token); + when(jwtService.extractClaims(token)).thenReturn(claims); + when(userDetails.getAuthorities()).thenReturn(Collections.emptyList()); + when(userDetailsService.loadUserByUsername(username)).thenReturn(userDetails); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(userService).processSSOPostLogin(username, true, AuthenticationType.OAUTH2); + verify(filterChain).doFilter(request, response); + } + + @Test + void shouldHandleExceptionsDuringAuthentication() + throws ServletException, IOException, SQLException, UnsupportedProviderException { + String token = "valid-jwt-token"; + String username = "saml-user"; + Map claims = Map.of("sub", username, "authType", "SAML2"); + + securityProperties.getSaml2().setAutoCreateUser(false); + + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getContextPath()).thenReturn(""); + when(request.getRequestURI()).thenReturn("/protected"); + when(jwtService.extractToken(request)).thenReturn(token); + when(jwtService.extractClaims(token)).thenReturn(claims); + doThrow(new UnsupportedProviderException("error")) + .when(userService) + .processSSOPostLogin(username, false, AuthenticationType.SAML2); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(authenticationEntryPoint) + .commence(eq(request), eq(response), any(AuthenticationFailureException.class)); verify(filterChain, never()).doFilter(request, response); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationTokenTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationTokenTest.java new file mode 100644 index 000000000..ec0496f62 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/ApiKeyAuthenticationTokenTest.java @@ -0,0 +1,84 @@ +package stirling.software.proprietary.security.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +class ApiKeyAuthenticationTokenTest { + + @Test + void ctor_apiKeyOnly_isUnauthenticated_andStoresApiKey() { + String apiKey = "abc-123"; + ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken(apiKey); + + assertFalse(token.isAuthenticated(), "should be unauthenticated"); + assertNull(token.getPrincipal(), "principal should be null for unauthenticated ctor"); + assertEquals(apiKey, token.getCredentials(), "credentials should store api key"); + // Authorities: do not check version-dependent behavior (can be null or empty depending on + // Spring Security) + } + + @Test + void ctor_withPrincipalAndAuthorities_isAuthenticated_andStoresAll() { + String apiKey = "xyz-999"; + Object principal = new Object(); + var authorities = List.of(new SimpleGrantedAuthority("ROLE_API")); + + ApiKeyAuthenticationToken token = + new ApiKeyAuthenticationToken(principal, apiKey, authorities); + + assertTrue(token.isAuthenticated(), "should be authenticated"); + assertSame(principal, token.getPrincipal(), "principal should be set"); + assertEquals(apiKey, token.getCredentials(), "credentials should store api key"); + assertNotNull(token.getAuthorities()); + assertEquals(1, token.getAuthorities().size()); + assertEquals("ROLE_API", token.getAuthorities().iterator().next().getAuthority()); + } + + @Test + void setAuthenticated_true_throwsIllegalArgumentException() { + ApiKeyAuthenticationToken token = new ApiKeyAuthenticationToken("k"); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> token.setAuthenticated(true)); + assertTrue( + ex.getMessage().toLowerCase().contains("trusted"), + "message should explain to use the constructor with authorities"); + } + + @Test + void setAuthenticated_false_isAllowed_andUnsetsFlag() { + Object principal = new Object(); + ApiKeyAuthenticationToken token = + new ApiKeyAuthenticationToken( + principal, "k", List.of(new SimpleGrantedAuthority("ROLE_API"))); + + assertTrue(token.isAuthenticated()); + + // allowed to set to false (via the override method) + token.setAuthenticated(false); + + assertFalse(token.isAuthenticated()); + assertSame(principal, token.getPrincipal(), "principal remains"); + assertEquals("k", token.getCredentials(), "credentials remain until erased"); + } + + @Test + void eraseCredentials_setsCredentialsNull_butKeepsPrincipal() { + Object principal = new Object(); + ApiKeyAuthenticationToken token = + new ApiKeyAuthenticationToken( + principal, "top-secret", List.of(new SimpleGrantedAuthority("ROLE_API"))); + + assertEquals("top-secret", token.getCredentials()); + assertSame(principal, token.getPrincipal()); + + token.eraseCredentials(); + + assertNull(token.getCredentials(), "credentials should be nulled after erase"); + assertSame(principal, token.getPrincipal(), "principal should remain"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/model/AttemptCounterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/AttemptCounterTest.java new file mode 100644 index 000000000..e6b28c23e --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/AttemptCounterTest.java @@ -0,0 +1,246 @@ +package stirling.software.proprietary.security.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.Field; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class AttemptCounterTest { + + // --- Helper functions for reflection access to private fields --- + + private static void setPrivateLong(Object target, String fieldName, long value) { + try { + Field f = target.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + f.setLong(target, value); + } catch (Exception e) { + fail("Could not set field '" + fieldName + "': " + e.getMessage()); + } + } + + private static void setPrivateInt(Object target, String fieldName, int value) { + try { + Field f = target.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + f.setInt(target, value); + } catch (Exception e) { + fail("Could not set field '" + fieldName + "': " + e.getMessage()); + } + } + + private static long getPrivateLong(Object target, String fieldName) { + try { + Field f = target.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + return f.getLong(target); + } catch (Exception e) { + fail("Could not read field '" + fieldName + "': " + e.getMessage()); + return -1L; // unreachable + } + } + + // --- Tests --- + + @Test + @DisplayName("Constructor: attemptCount=0 and lastAttemptTime within creation period") + void constructor_shouldInitializeFields() { + long before = System.currentTimeMillis(); + AttemptCounter counter = new AttemptCounter(); + long after = System.currentTimeMillis(); + + // Purpose: Ensure that count is 0 and the timestamp lies in the [before, after] window + assertAll( + () -> assertEquals(0, counter.getAttemptCount(), "attemptCount should be 0"), + () -> { + long ts = counter.getLastAttemptTime(); + assertTrue( + ts >= before && ts <= after, + "lastAttemptTime should be between constructor start and end"); + }); + } + + @Test + @DisplayName( + "increment(): increases attemptCount and updates lastAttemptTime (not less than" + + " before)") + void increment_shouldIncreaseCountAndUpdateTime() { + AttemptCounter counter = new AttemptCounter(); + long prevTime = counter.getLastAttemptTime(); + + counter.increment(); + + // Purpose: After increment, count is +1 and timestamp is not older than before + assertAll( + () -> assertEquals(1, counter.getAttemptCount(), "attemptCount should be 1"), + () -> + assertTrue( + counter.getLastAttemptTime() >= prevTime, + "lastAttemptTime should not be less after increment")); + } + + @Test + @DisplayName("reset(): sets attemptCount to 0 and updates lastAttemptTime") + void reset_shouldZeroCountAndRefreshTime() { + AttemptCounter counter = new AttemptCounter(); + counter.increment(); + counter.increment(); + long beforeReset = counter.getLastAttemptTime(); + + counter.reset(); + + // Purpose: Ensure the counter is reset and time is updated + assertAll( + () -> + assertEquals( + 0, + counter.getAttemptCount(), + "attemptCount should be 0 after reset"), + () -> + assertTrue( + counter.getLastAttemptTime() >= beforeReset, + "lastAttemptTime should be updated after reset (>= previous)")); + } + + @Nested + @DisplayName("shouldReset(attemptIncrementTime)") + class ShouldResetTests { + + @Test + @DisplayName("returns FALSE when time difference is smaller than window") + void shouldReturnFalseWhenWithinWindow() { + AttemptCounter counter = new AttemptCounter(); + long window = 500L; // 500 ms + long now = System.currentTimeMillis(); + + // Simulate: last action was (window - 1) ms ago + setPrivateLong(counter, "lastAttemptTime", now - (window - 1)); + + // Purpose: Inside the window -> no reset + assertFalse(counter.shouldReset(window), "Within the window, no reset should occur"); + } + + @Test + @DisplayName( + "returns FALSE when time difference is exactly equal to window (implementation uses" + + " '>')") + void shouldReturnFalseWhenExactlyWindow() { + AttemptCounter counter = new AttemptCounter(); + long window = 200L; + long now = System.currentTimeMillis(); + + // Simulate: last action was exactly 'window' ms ago + setPrivateLong(counter, "lastAttemptTime", now - window); + + // Purpose: Equality -> no reset, because implementation uses '>' + assertFalse( + counter.shouldReset(window), + "With exactly equal difference, no reset should occur"); + } + + @Test + @DisplayName("returns TRUE when time difference is greater than window") + void shouldReturnTrueWhenGreaterThanWindow() { + AttemptCounter counter = new AttemptCounter(); + long window = 100L; + long now = System.currentTimeMillis(); + + // Simulate: last action was (window + 1) ms ago + setPrivateLong(counter, "lastAttemptTime", now - (window + 1)); + + // Purpose: Outside the window -> reset + assertTrue(counter.shouldReset(window), "Outside the window, reset should occur"); + } + } + + @Test + @DisplayName("Getters: return current values") + void getters_shouldReturnCurrentValues() { + AttemptCounter counter = new AttemptCounter(); + assertAll( + // Purpose: Basic getter functionality + () -> + assertEquals( + 0, counter.getAttemptCount(), "Initial attemptCount should be 0"), + () -> + assertTrue( + counter.getLastAttemptTime() <= System.currentTimeMillis(), + "lastAttemptTime should not be in the future")); + + counter.increment(); + int afterInc = counter.getAttemptCount(); + long last = counter.getLastAttemptTime(); + + assertAll( + // Purpose: After increment, getters reflect the new state + () -> assertEquals(1, afterInc, "attemptCount should be 1 after increment"), + () -> + assertEquals( + last, + counter.getLastAttemptTime(), + "lastAttemptTime should be consistent")); + } + + @Test + @DisplayName( + "Multiple increments(): Count increases monotonically and timestamp remains" + + " monotonically non-decreasing") + void multipleIncrements_shouldIncreaseMonotonically() { + AttemptCounter counter = new AttemptCounter(); + long t1 = counter.getLastAttemptTime(); + + counter.increment(); + long t2 = counter.getLastAttemptTime(); + + counter.increment(); + long t3 = counter.getLastAttemptTime(); + + // Purpose: Document monotonic behavior + assertAll( + () -> + assertEquals( + 2, + counter.getAttemptCount(), + "After two increments, count should be 2"), + () -> + assertTrue( + t2 >= t1 && t3 >= t2, + "Timestamps should be monotonically non-decreasing")); + } + + @Test + @DisplayName("Documenting edge case: attemptCount can technically overflow (int)") + void noteOnIntegerOverflowBehavior() { + // Note: This test only documents the current behavior of int overflow in Java. + // It does not enforce that overflow is desired, only makes visible what happens. + AttemptCounter counter = new AttemptCounter(); + + // Set counter close to Integer.MAX_VALUE and increment() + setPrivateInt(counter, "attemptCount", Integer.MAX_VALUE - 1); + counter.increment(); // -> MAX_VALUE + assertEquals( + Integer.MAX_VALUE, + counter.getAttemptCount(), + "Count should reach Integer.MAX_VALUE"); + + counter.increment(); // -> overflow to Integer.MIN_VALUE + assertEquals( + Integer.MIN_VALUE, + counter.getAttemptCount(), + "After increment past MAX_VALUE, int overflows to MIN_VALUE (Java standard" + + " behavior)"); + } + + @Test + @DisplayName("Reflection: getPrivateLong reads the actual lastAttemptTime") + void reflectionGetter_shouldReturnInternalValue() { + AttemptCounter counter = new AttemptCounter(); + long expected = counter.getLastAttemptTime(); + long reflected = getPrivateLong(counter, "lastAttemptTime"); + + assertEquals(expected, reflected, "Reflection getter should match the field value"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/model/AuthorityTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/AuthorityTest.java new file mode 100644 index 000000000..4dab8cf94 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/AuthorityTest.java @@ -0,0 +1,96 @@ +package stirling.software.proprietary.security.model; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.model.Team; + +class AuthorityTest { + + @Test + void noArgsConstructor_allowsSettersAndGetters() { + Authority a = new Authority(); + assertNull(a.getId()); + assertNull(a.getAuthority()); + assertNull(a.getUser()); + + a.setId(42L); + a.setAuthority("ROLE_USER"); + User u = new User(); + a.setUser(u); + + assertEquals(42L, a.getId()); + assertEquals("ROLE_USER", a.getAuthority()); + assertSame(u, a.getUser()); + } + + @Test + void ctorWithUser_setsFields_and_registersInUserAuthorities() { + User u = new User(); + // sanity: authorities set initialized? + assertNotNull(u.getAuthorities()); + assertTrue(u.getAuthorities().isEmpty()); + + Authority a = new Authority("ROLE_ADMIN", u); + + assertEquals("ROLE_ADMIN", a.getAuthority()); + assertSame(u, a.getUser()); + assertTrue(u.getAuthorities().contains(a), "Authority should be registered in user's set"); + assertEquals(1, u.getAuthorities().size()); + } + + @Test + void multipleAuthorities_registerEachInUser() { + User u = new User(); + + Authority a1 = new Authority("ROLE_A", u); + Authority a2 = new Authority("ROLE_B", u); + + assertTrue(u.getAuthorities().contains(a1)); + assertTrue(u.getAuthorities().contains(a2)); + assertEquals(2, u.getAuthorities().size()); + } + + @Test + void ctorWithNullUser_throwsNpe_dueToRegistrationInUserSet() { + assertThrows( + NullPointerException.class, + () -> new Authority("ROLE_X", null), + "Constructor calls user.getAuthorities() and should throw NPE when null"); + } + + @Test + void setUser_doesNotAutoRegisterInUserAuthorities_currentBehavior() { + User u = new User(); + Authority a = new Authority(); + a.setAuthority("ROLE_VIEWER"); + + // only using the setter → no automatic entry in the user's set + a.setUser(u); + + assertSame(u, a.getUser()); + assertTrue( + u.getAuthorities().isEmpty(), + "Current behavior: setUser() does not automatically register in user's set"); + } + + @Test + void toString_equalsHashCode_fromLombok_defaultObjectSemantics() { + // no @EqualsAndHashCode annotation -> default Object semantics + Authority a1 = new Authority(); + Authority a2 = new Authority(); + assertNotEquals(a1, a2); + assertNotEquals(a1.hashCode(), a2.hashCode()); + assertNotNull(a1); + } + + // Optional: shows that User has other fields that don't interfere + @Test + void worksWithUserHavingTeamField() { + User u = new User(); + u.setTeam(new Team()); // just to show that it has no effect + Authority a = new Authority("ROLE_TEST", u); + assertTrue(u.getAuthorities().contains(a)); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/model/UserTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/UserTest.java new file mode 100644 index 000000000..10ea9be32 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/UserTest.java @@ -0,0 +1,152 @@ +package stirling.software.proprietary.security.model; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; + +import java.util.LinkedHashSet; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.model.Team; + +class UserTest { + + @Test + void defaults_collections_initialized() { + User u = new User(); + assertNotNull(u.getAuthorities(), "authorities should be initialized"); + assertTrue(u.getAuthorities().isEmpty()); + assertNotNull(u.getSettings(), "settings should be initialized"); + assertTrue(u.getSettings().isEmpty()); + assertNull(u.getTeam()); + } + + @Test + void addAuthority_adds_to_set_but_doesNot_set_backref_on_authority() { + User u = new User(); + + Authority a = new Authority(); + a.setAuthority("ROLE_A"); + + u.addAuthority(a); + + assertTrue(u.getAuthorities().contains(a)); + // current behavior: addAuthority() does NOT call a.setUser(u) + assertNull( + a.getUser(), "Current behavior: Authority.user is NOT set by User.addAuthority()"); + } + + @Test + void addAuthorities_adds_all() { + User u = new User(); + + Authority a1 = new Authority(); + a1.setAuthority("ROLE_A"); + Authority a2 = new Authority(); + a2.setAuthority("ROLE_B"); + + Set batch = new LinkedHashSet<>(); + batch.add(a1); + batch.add(a2); + + u.addAuthorities(batch); + + assertEquals(2, u.getAuthorities().size()); + assertTrue(u.getAuthorities().contains(a1)); + assertTrue(u.getAuthorities().contains(a2)); + } + + @Test + void getRolesAsString_returns_roles_joined_order_agnostic() { + User u = new User(); + + // We use the Authority constructor that automatically adds itself to u.getAuthorities() + new Authority("ROLE_USER", u); + new Authority("ROLE_ADMIN", u); + + String roles = u.getRolesAsString(); + // Order is not guaranteed due to HashSet -> split/trim and compare as a Set + Set parts = + java.util.Arrays.stream(roles.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(java.util.stream.Collectors.toSet()); + + assertEquals(Set.of("ROLE_USER", "ROLE_ADMIN"), parts); + } + + @Test + void hasPassword_null_empty_and_present() { + User u = new User(); + u.setPassword(null); + assertFalse(u.hasPassword()); + + u.setPassword(""); + assertFalse(u.hasPassword()); + + u.setPassword("secret"); + assertTrue(u.hasPassword()); + } + + @Test + void isFirstLogin_handles_null_false_true() { + User u = new User(); + + // Default is Boolean false (according to field initialization) + assertFalse(u.isFirstLogin()); + + u.setFirstLogin(true); + assertTrue(u.isFirstLogin()); + + // explicitly null -> method returns false + u.setIsFirstLogin(null); + assertFalse(u.isFirstLogin()); + } + + @Test + void setAuthenticationType_lowercases_enum_name() { + User u = new User(); + + // Use an existing value from your AuthenticationType enum (e.g. OAUTH2/SAML2/DATABASE) + // If the name differs, simply adjust below. + AuthenticationType at = AuthenticationType.SSO; + u.setAuthenticationType(at); + + assertEquals("sso", u.getAuthenticationType()); + } + + @Test + void team_setter_getter() { + User u = new User(); + Team t = new Team(); + u.setTeam(t); + assertSame(t, u.getTeam()); + } + + @Test + void getRoleName_delegatesToRole_withRolesAsString() { + User u = new User(); + + // Add authorities (order in HashSet doesn't matter) + new Authority("ROLE_USER", u); + new Authority("ROLE_ADMIN", u); + + // Expected argument created exactly as getRoleName() does internally + String expectedArg = u.getRolesAsString(); + + try (MockedStatic roleMock = mockStatic(Role.class)) { + roleMock.when(() -> Role.getRoleNameByRoleId(expectedArg)).thenReturn("Friendly Name"); + + String result = u.getRoleName(); + + assertEquals("Friendly Name", result); + + // Verify it was delegated exactly with the expected string + roleMock.verify(() -> Role.getRoleNameByRoleId(expectedArg), times(1)); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/model/exception/BackupNotFoundExceptionTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/exception/BackupNotFoundExceptionTest.java new file mode 100644 index 000000000..b8cebcc18 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/exception/BackupNotFoundExceptionTest.java @@ -0,0 +1,34 @@ +package stirling.software.proprietary.security.model.exception; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class BackupNotFoundExceptionTest { + + @Test + void constructor_setsMessage() { + BackupNotFoundException ex = new BackupNotFoundException("not found"); + assertEquals("not found", ex.getMessage()); + assertNull(ex.getCause(), "No cause expected for single-arg constructor"); + } + + @Test + void extendsRuntimeExceptionDirectly() { + assertEquals( + RuntimeException.class, + BackupNotFoundException.class.getSuperclass(), + "BackupNotFoundException should extend RuntimeException directly"); + } + + @Test + void canBeThrownAndCaught() { + BackupNotFoundException ex = + assertThrows( + BackupNotFoundException.class, + () -> { + throw new BackupNotFoundException("missing backup"); + }); + assertEquals("missing backup", ex.getMessage()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/model/exception/NoProviderFoundExceptionTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/exception/NoProviderFoundExceptionTest.java new file mode 100644 index 000000000..8b2dba756 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/exception/NoProviderFoundExceptionTest.java @@ -0,0 +1,33 @@ +package stirling.software.proprietary.security.model.exception; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class NoProviderFoundExceptionTest { + + @Test + void constructor_setsMessage_withoutCause() { + NoProviderFoundException ex = new NoProviderFoundException("no provider"); + assertEquals("no provider", ex.getMessage()); + assertNull(ex.getCause(), "Cause should be null for single-arg constructor"); + } + + @Test + void constructor_setsMessage_andCause() { + Throwable cause = new IllegalStateException("root"); + NoProviderFoundException ex = new NoProviderFoundException("missing", cause); + + assertEquals("missing", ex.getMessage()); + assertSame(cause, ex.getCause()); + } + + @Test + void canBeThrownAndCaught_checkedException() { + try { + throw new NoProviderFoundException("boom"); + } catch (NoProviderFoundException ex) { + assertEquals("boom", ex.getMessage()); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java index 8eac18d04..915c97444 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java @@ -1,9 +1,6 @@ package stirling.software.proprietary.security.saml2; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/EmailServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/EmailServiceTest.java index 8ca8208c9..66719e372 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/EmailServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/EmailServiceTest.java @@ -1,7 +1,6 @@ package stirling.software.proprietary.security.service; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java index 09229b9f6..a9ee8297b 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java @@ -1,12 +1,6 @@ package stirling.software.proprietary.security.service; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.atLeast; import static org.mockito.Mockito.contains; import static org.mockito.Mockito.eq; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterfaceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterfaceTest.java index 56e145634..f16e3b1a0 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterfaceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterfaceTest.java @@ -1,8 +1,6 @@ package stirling.software.proprietary.security.service; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/MailConfigTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/MailConfigTest.java index 3db3493f4..99b63eba1 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/MailConfigTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/MailConfigTest.java @@ -1,8 +1,6 @@ package stirling.software.proprietary.security.service; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java new file mode 100644 index 000000000..d91aec202 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/util/SecretMaskerTest.java @@ -0,0 +1,176 @@ +package stirling.software.proprietary.util; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link SecretMasker}. + * + *

Assumptions: - Key matching is case-insensitive via the pattern in SENSITIVE. - If the key + * matches a sensitive pattern, the value is replaced with "***REDACTED***". - Nested maps and lists + * are searched recursively. - Null maps and null values are ignored or returned as null. - + * Non-sensitive keys/values remain unchanged. + */ +class SecretMaskerTest { + + @Nested + @DisplayName("mask(Map) method") + class MaskMethod { + + @Test + @DisplayName("should return null when input map is null") + void shouldReturnNullWhenInputIsNull() { + assertNull(SecretMasker.mask(null)); + } + + @Test + @DisplayName("should mask simple sensitive keys at root level") + void shouldMaskSimpleSensitiveKeys() { + Map input = + Map.of( + "password", "mySecret", + "username", "john"); + + Map result = SecretMasker.mask(input); + + assertEquals("***REDACTED***", result.get("password")); + assertEquals("john", result.get("username")); + } + + @Test + @DisplayName("should mask keys case-insensitively and with special characters") + void shouldMaskKeysCaseInsensitive() { + Map input = + Map.of( + "Api-Key", "12345", + "TOKEN", "abcde", + "normal", "keepme"); + + Map result = SecretMasker.mask(input); + + assertEquals("***REDACTED***", result.get("Api-Key")); + assertEquals("***REDACTED***", result.get("TOKEN")); + assertEquals("keepme", result.get("normal")); + } + + @Test + @DisplayName("should mask nested map sensitive keys") + void shouldMaskNestedMapSensitiveKeys() { + Map input = + Map.of( + "outer", + Map.of( + "jwt", + "tokenValue", + "inner", + Map.of( + "secret", "deepValue", + "other", "ok"))); + + Map result = SecretMasker.mask(input); + + Map outer = (Map) result.get("outer"); + assertEquals("***REDACTED***", outer.get("jwt")); + Map inner = (Map) outer.get("inner"); + assertEquals("***REDACTED***", inner.get("secret")); + assertEquals("ok", inner.get("other")); + } + + @Test + @DisplayName("should mask sensitive keys inside lists") + void shouldMaskSensitiveKeysInsideLists() { + Map input = + Map.of( + "list", + List.of( + Map.of("token", "abc123"), + Map.of("username", "john"), + "stringValue")); + + Map result = SecretMasker.mask(input); + + List list = (List) result.get("list"); + Map first = (Map) list.get(0); + assertEquals("***REDACTED***", first.get("token")); + Map second = (Map) list.get(1); + assertEquals("john", second.get("username")); + assertEquals("stringValue", list.get(2)); + } + + @Test + @DisplayName("should ignore null values") + void shouldIgnoreNullValues() { + // IMPORTANT: Map.of(...) does not allow nulls -> use a mutable Map instead + Map input = new HashMap<>(); + input.put("password", null); + input.put("normal", null); + + Map result = SecretMasker.mask(input); + + // Null values are completely filtered out + assertFalse(result.containsKey("password")); + assertFalse(result.containsKey("normal")); + assertTrue(result.isEmpty(), "Result map should be empty if all entries were null"); + } + + @Test + @DisplayName("should not mask when key does not match pattern") + void shouldNotMaskWhenKeyNotSensitive() { + Map input = Map.of("email", "test@example.com"); + + Map result = SecretMasker.mask(input); + assertEquals("test@example.com", result.get("email")); + } + } + + @Nested + @DisplayName("Deep masking edge branches") + class DeepMaskBranches { + + @Test + @DisplayName("should filter out null values inside nested map") + void shouldFilterOutNullValuesInsideNestedMap() { + // outer -> { inner -> { "token": null, "username": "john" } } + Map inner = new HashMap<>(); + inner.put("token", null); // <- should be filtered out in the result (branch false) + inner.put("username", "john"); // <- should remain + + Map input = Map.of("outer", Map.of("inner", inner)); + + Map result = SecretMasker.mask(input); + + Map outer = (Map) result.get("outer"); + Map maskedInner = (Map) outer.get("inner"); + + // "token" was null -> should be completely absent (filter branch in deepMask(Map)) + assertFalse(maskedInner.containsKey("token")); + // "username" remains unchanged + assertEquals("john", maskedInner.get("username")); + } + + @Test + @DisplayName("should not mask when key is null (falls back to deepMask(value))") + void shouldNotMaskWhenKeyIsNull() { + // Map with null key: { null: "plainText", "password": "toHide" } + Map sensitive = new HashMap<>(); + sensitive.put(null, "plainText"); // <- key == null -> no masking, value stays + sensitive.put("password", "toHide"); // <- sensitive key -> will be masked + + Map input = Map.of("outer", sensitive); + + Map result = SecretMasker.mask(input); + + Map outer = (Map) result.get("outer"); + assertTrue(outer.containsKey(null), "Null key should be preserved"); + assertEquals("plainText", outer.get(null), "Value for null key must not be masked"); + assertEquals("***REDACTED***", outer.get("password"), "Sensitive keys must be masked"); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/web/AuditWebFilterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/web/AuditWebFilterTest.java new file mode 100644 index 000000000..d1e8e3bd7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/web/AuditWebFilterTest.java @@ -0,0 +1,317 @@ +package stirling.software.proprietary.web; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.util.*; + +import org.junit.jupiter.api.*; +import org.slf4j.MDC; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; + +/** + * Tests for {@link AuditWebFilter}. + * + *

Note: The filter clears the MDC in its finally block. Therefore we capture the MDC values + * inside a special FilterChain before the clear happens (snapshot). + */ +class AuditWebFilterTest { + + private AuditWebFilter filter; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + /** Small helper chain that captures MDC values during the chain invocation. */ + static class CapturingFilterChain implements FilterChain { + final Map captured = new HashMap<>(); + boolean called = false; + + @Override + public void doFilter(ServletRequest req, ServletResponse res) + throws IOException, ServletException { + called = true; + // Snapshot of the MDC keys set by the filter (before the finally-clear) + captured.put("userAgent", MDC.get("userAgent")); + captured.put("referer", MDC.get("referer")); + captured.put("acceptLanguage", MDC.get("acceptLanguage")); + captured.put("contentType", MDC.get("contentType")); + captured.put("userRoles", MDC.get("userRoles")); + captured.put("queryParams", MDC.get("queryParams")); + } + } + + /** Variant that intentionally throws an exception after capturing. */ + static class ThrowingAfterCaptureChain extends CapturingFilterChain { + @Override + public void doFilter(ServletRequest req, ServletResponse res) + throws IOException, ServletException { + super.doFilter(req, res); + throw new IOException("Test Exception"); + } + } + + @BeforeEach + void setUp() { + filter = new AuditWebFilter(); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + MDC.clear(); + SecurityContextHolder.clearContext(); + } + + @AfterEach + void tearDown() { + MDC.clear(); + SecurityContextHolder.clearContext(); + } + + @Nested + @DisplayName("Header and query parameter handling") + class HeaderAndQueryTests { + + @Test + @DisplayName("Should store all provided headers and query parameters in MDC") + void shouldStoreHeadersAndQueryParamsInMdc() throws ServletException, IOException { + request.addHeader("User-Agent", "JUnit-Test-Agent"); + request.addHeader("Referer", "http://example.com"); + request.addHeader("Accept-Language", "de-DE"); + request.addHeader("Content-Type", "application/json"); + request.setParameter("param1", "value1"); + request.setParameter("param2", "value2"); + + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilterInternal(request, response, chain); + + assertTrue(chain.called, "FilterChain should have been called"); + assertEquals("JUnit-Test-Agent", chain.captured.get("userAgent")); + assertEquals("http://example.com", chain.captured.get("referer")); + assertEquals("de-DE", chain.captured.get("acceptLanguage")); + assertEquals("application/json", chain.captured.get("contentType")); + String params = chain.captured.get("queryParams"); + assertNotNull(params); + assertTrue(params.contains("param1")); + assertTrue(params.contains("param2")); + + assertNull(MDC.get("userAgent")); + assertNull(MDC.get("queryParams")); + } + + @Test + @DisplayName("Should only store present headers and set nothing for empty inputs") + void shouldNotStoreNullHeaders() throws ServletException, IOException { + request.setParameter("onlyParam", "123"); + + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilterInternal(request, response, chain); + + assertNull(chain.captured.get("userAgent")); + assertNull(chain.captured.get("referer")); + assertNull(chain.captured.get("acceptLanguage")); + assertNull(chain.captured.get("contentType")); + assertEquals("onlyParam", chain.captured.get("queryParams")); + } + + // New: empty parameter map case (branch: parameterMap != null && !isEmpty() -> false) + @Test + @DisplayName("Should not set queryParams when parameter map is empty") + void shouldNotStoreQueryParamsWhenEmpty() throws ServletException, IOException { + // no request.setParameter(...) + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilterInternal(request, response, chain); + + assertNull( + chain.captured.get("queryParams"), + "With an empty map, queryParams must not be set"); + } + + // New: parameterMap == null (branch: parameterMap != null -> false) + @Test + @DisplayName("Should handle getParameterMap() returning null safely") + void shouldHandleNullParameterMapSafely() throws ServletException, IOException { + MockHttpServletRequest reqWithNullParamMap = + new MockHttpServletRequest() { + @Override + public Map getParameterMap() { + // Assumption: defensive branch in the filter; simulate a broken/unusual + // implementation + return null; + } + }; + + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilterInternal(reqWithNullParamMap, response, chain); + + assertNull( + chain.captured.get("queryParams"), + "With a null parameter map, queryParams must not be set"); + } + } + + @Nested + @DisplayName("Authenticated users") + class AuthenticatedUserTests { + + @Test + @DisplayName("Should store roles of the authenticated user") + void shouldStoreUserRolesInMdc() throws ServletException, IOException { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + "user", + "pass", + Collections.singletonList( + new SimpleGrantedAuthority("ROLE_ADMIN")))); + + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilterInternal(request, response, chain); + + assertEquals("ROLE_ADMIN", chain.captured.get("userRoles")); + assertNull(MDC.get("userRoles")); + } + + @Test + @DisplayName("Should store multiple roles comma-separated") + void shouldStoreMultipleRolesCommaSeparated() throws ServletException, IOException { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + "user", + "pass", + List.of( + new SimpleGrantedAuthority("ROLE_USER"), + new SimpleGrantedAuthority("ROLE_ADMIN")))); + + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilterInternal(request, response, chain); + + String roles = chain.captured.get("userRoles"); + assertNotNull(roles, "Roles should be set"); + assertTrue(roles.contains("ROLE_USER")); + assertTrue(roles.contains("ROLE_ADMIN")); + assertTrue(roles.contains(","), "Roles should be separated by a comma"); + } + + // New: auth == null (branch: auth != null -> false) + @Test + @DisplayName("Should not set userRoles when no Authentication object is present") + void shouldNotStoreUserRolesWhenAuthIsNull() throws ServletException, IOException { + // SecurityContext remains empty + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilterInternal(request, response, chain); + + assertNull(chain.captured.get("userRoles")); + } + + // New: authorities == null (branch: auth != null && authorities != null -> false) + @Test + @DisplayName("Should not set userRoles when authorities are null") + void shouldNotStoreUserRolesWhenAuthoritiesIsNull() throws ServletException, IOException { + Authentication authWithNullAuthorities = + new Authentication() { + @Override + public Collection getAuthorities() { + return null; // important + } + + @Override + public Object getCredentials() { + return "cred"; + } + + @Override + public Object getDetails() { + return null; + } + + @Override + public Object getPrincipal() { + return "user"; + } + + @Override + public boolean isAuthenticated() { + return true; + } + + @Override + public void setAuthenticated(boolean isAuthenticated) + throws IllegalArgumentException {} + + @Override + public String getName() { + return "user"; + } + }; + SecurityContextHolder.getContext().setAuthentication(authWithNullAuthorities); + + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilterInternal(request, response, chain); + + assertNull( + chain.captured.get("userRoles"), + "With null authorities, userRoles must not be set"); + } + + // New: empty authorities list -> reduce(...).orElse("") → empty string is set + @Test + @DisplayName("Should set empty string when authorities list is empty") + void shouldStoreEmptyStringWhenAuthoritiesEmpty() throws ServletException, IOException { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + "user", "pass", Collections.emptyList())); + + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilterInternal(request, response, chain); + + assertEquals( + "", + chain.captured.get("userRoles"), + "With an empty roles list, an empty string should be set"); + } + } + + @Nested + @DisplayName("MDC cleanup logic") + class MdcCleanupTests { + + @Test + @DisplayName("Should clear MDC after processing") + void shouldClearMdcAfterProcessing() throws ServletException, IOException { + request.addHeader("User-Agent", "JUnit-Test-Agent"); + + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilterInternal(request, response, chain); + + assertEquals("JUnit-Test-Agent", chain.captured.get("userAgent")); + assertNull(MDC.get("userAgent"), "MDC should be cleared after processing"); + } + + @Test + @DisplayName("Should clear MDC even when the FilterChain throws") + void shouldClearMdcOnException() throws ServletException, IOException { + request.addHeader("User-Agent", "JUnit-Test-Agent"); + ThrowingAfterCaptureChain chain = new ThrowingAfterCaptureChain(); + + IOException thrown = + assertThrows( + IOException.class, + () -> filter.doFilterInternal(request, response, chain)); + + assertEquals("Test Exception", thrown.getMessage()); + assertEquals("JUnit-Test-Agent", chain.captured.get("userAgent")); + assertNull(MDC.get("userAgent"), "MDC should also be cleared after exceptions"); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/web/CorrelationIdFilterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/web/CorrelationIdFilterTest.java new file mode 100644 index 000000000..9ab5abf8c --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/web/CorrelationIdFilterTest.java @@ -0,0 +1,188 @@ +package stirling.software.proprietary.web; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.*; +import org.slf4j.MDC; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; + +/** + * Tests for {@link CorrelationIdFilter}. + * + *

Important notes: - The filter sets MDC in the try block and clears it in the finally block. + * Therefore, we capture the MDC values inside a special FilterChain before the clear happens + * (snapshot). - The response header is sanitized via Newlines.stripAll(id). The current code does + * NOT sanitize the value stored in the MDC or the request attribute. These tests reflect the + * current behavior. + */ +class CorrelationIdFilterTest { + + private CorrelationIdFilter filter; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + /** Chain that snapshots the MDC and header/attribute values during doFilter(). */ + static class CapturingFilterChain implements FilterChain { + final Map capturedMdc = new HashMap<>(); + String responseHeader; + Object requestAttr; + boolean called = false; + + @Override + public void doFilter(ServletRequest req, ServletResponse res) + throws IOException, ServletException { + called = true; + // Snapshot: MDC and request attributes during chain execution + capturedMdc.put(CorrelationIdFilter.MDC_KEY, MDC.get(CorrelationIdFilter.MDC_KEY)); + requestAttr = ((MockHttpServletRequest) req).getAttribute(CorrelationIdFilter.MDC_KEY); + responseHeader = ((MockHttpServletResponse) res).getHeader(CorrelationIdFilter.HEADER); + } + } + + /** Variant that intentionally throws an exception after capturing (to test cleanup). */ + static class ThrowingAfterCaptureChain extends CapturingFilterChain { + @Override + public void doFilter(ServletRequest req, ServletResponse res) + throws IOException, ServletException { + super.doFilter(req, res); + throw new IOException("boom"); + } + } + + @BeforeEach + void setUp() { + filter = new CorrelationIdFilter(); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + MDC.clear(); + } + + @AfterEach + void tearDown() { + MDC.clear(); + } + + @Nested + @DisplayName("Existing X-Request-Id header") + class ExistingHeader { + + @Test + @DisplayName( + "Should propagate existing ID unchanged to MDC & request attribute, and set it in" + + " the response header") + void shouldPropagateExistingId() throws ServletException, IOException { + String givenId = "abc-123"; + request.addHeader(CorrelationIdFilter.HEADER, givenId); + + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilterInternal(request, response, chain); + + assertTrue(chain.called); + // Set during the chain + assertEquals(givenId, chain.capturedMdc.get(CorrelationIdFilter.MDC_KEY)); + assertEquals(givenId, chain.requestAttr); + assertEquals(givenId, chain.responseHeader); + + // Cleared afterwards + assertNull(MDC.get(CorrelationIdFilter.MDC_KEY)); + } + + @Test + @DisplayName( + "Should strip newlines only in the response header, leaving MDC/attribute" + + " unsanitized (per current code)") + void shouldStripNewlinesOnlyInResponseHeader() throws ServletException, IOException { + String raw = "id-with\r\nnewlines"; + String expectedSanitized = "id-withnewlines"; // Newlines removed + request.addHeader(CorrelationIdFilter.HEADER, raw); + + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilterInternal(request, response, chain); + + // MDC & request attribute get the raw value (per implementation) + assertEquals(raw, chain.capturedMdc.get(CorrelationIdFilter.MDC_KEY)); + assertEquals(raw, chain.requestAttr); + // Response header is sanitized + assertEquals(expectedSanitized, chain.responseHeader); + + assertNull(MDC.get(CorrelationIdFilter.MDC_KEY)); + } + } + + @Nested + @DisplayName("Missing or blank header") + class MissingOrBlankHeader { + + @Test + @DisplayName("Should generate UUID when header is missing") + void shouldGenerateUuidWhenHeaderMissing() throws ServletException, IOException { + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilterInternal(request, response, chain); + + assertTrue(chain.called); + + // Consistency: same value in MDC, request attribute, and response header (no newline + // removal needed) + String mdcId = chain.capturedMdc.get(CorrelationIdFilter.MDC_KEY); + assertNotNull(mdcId); + assertEquals(mdcId, chain.requestAttr); + assertEquals(mdcId, chain.responseHeader); + + // UUID format check + assertDoesNotThrow(() -> UUID.fromString(mdcId)); + + assertNull(MDC.get(CorrelationIdFilter.MDC_KEY)); + } + + @Test + @DisplayName("Should generate UUID when header is blank/whitespace") + void shouldGenerateUuidWhenHeaderBlank() throws ServletException, IOException { + request.addHeader(CorrelationIdFilter.HEADER, " \t "); + + CapturingFilterChain chain = new CapturingFilterChain(); + filter.doFilterInternal(request, response, chain); + + String mdcId = chain.capturedMdc.get(CorrelationIdFilter.MDC_KEY); + assertNotNull(mdcId); + assertEquals(mdcId, chain.requestAttr); + assertEquals(mdcId, chain.responseHeader); + assertDoesNotThrow(() -> UUID.fromString(mdcId)); + + assertNull(MDC.get(CorrelationIdFilter.MDC_KEY)); + } + } + + @Nested + @DisplayName("Cleanup logic (finally)") + class CleanupBehavior { + + @Test + @DisplayName("Should clear MDC even when FilterChain throws") + void shouldClearMdcOnException() throws ServletException, IOException { + request.addHeader(CorrelationIdFilter.HEADER, "req-1"); + ThrowingAfterCaptureChain chain = new ThrowingAfterCaptureChain(); + + IOException ex = + assertThrows( + IOException.class, + () -> filter.doFilterInternal(request, response, chain)); + assertEquals("boom", ex.getMessage()); + + // Was set during the chain… + assertEquals("req-1", chain.capturedMdc.get(CorrelationIdFilter.MDC_KEY)); + // …and cleared afterwards. + assertNull(MDC.get(CorrelationIdFilter.MDC_KEY)); + } + } +} diff --git a/build.gradle b/build.gradle index 22da0c396..664c127da 100644 --- a/build.gradle +++ b/build.gradle @@ -30,7 +30,6 @@ ext { openSamlVersion = "4.3.2" commonmarkVersion = "0.27.0" googleJavaFormatVersion = "1.28.0" - junitPlatformVersion = "1.12.2" tempJrePath = null } @@ -134,7 +133,7 @@ subprojects { testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.mockito:mockito-inline:5.2.0' - testRuntimeOnly "org.junit.platform:junit-platform-launcher:$junitPlatformVersion" + testRuntimeOnly "org.junit.platform:junit-platform-launcher" testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.2.1") testImplementation "com.squareup.okhttp3:mockwebserver" @@ -559,7 +558,7 @@ dependencies { } testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly "org.junit.platform:junit-platform-launcher:$junitPlatformVersion" + testRuntimeOnly "org.junit.platform:junit-platform-launcher" testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.2.1") testImplementation "com.squareup.okhttp3:mockwebserver"