diff --git a/.github/aur/stirling-pdf-desktop/PKGBUILD b/.github/aur/stirling-pdf-desktop/PKGBUILD index de60325133..d35c874238 100644 --- a/.github/aur/stirling-pdf-desktop/PKGBUILD +++ b/.github/aur/stirling-pdf-desktop/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-desktop -pkgver=2.13.0 +pkgver=2.13.1 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)" arch=('x86_64') diff --git a/.github/aur/stirling-pdf-server-bin/PKGBUILD b/.github/aur/stirling-pdf-server-bin/PKGBUILD index 3853bb6256..2e71087c7c 100644 --- a/.github/aur/stirling-pdf-server-bin/PKGBUILD +++ b/.github/aur/stirling-pdf-server-bin/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-server-bin -pkgver=2.13.0 +pkgver=2.13.1 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)" arch=('any') diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 8731eb8a84..4a32a0e840 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -51,3 +51,15 @@ jobs: name: playwright-nightly-${{ github.run_id }} path: frontend/editor/playwright-report/ retention-days: 14 + + # Builds all desktop platforms on a schedule so the Rust dependency cache is + # written on main, where PR and merge-queue tauri builds can restore it. + warm-tauri-cache: + name: Warm Tauri Rust cache + permissions: + contents: read + pull-requests: write + uses: ./.github/workflows/tauri-build.yml + with: + platform: all + secrets: inherit diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index d8bece6803..65b1e40f36 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -115,6 +115,20 @@ jobs: toolchain: stable targets: ${{ matrix.platform == 'macos-15' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + # Cache the Cargo registry and compiled dependency crates so the build + # only recompiles the app crate. Written on main; PRs and the merge queue + # restore from it. + - name: Cache Rust build + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: frontend/editor/src-tauri + # Stable key shared across workflows so the nightly warmer. + # rust-cache still appends OS + rustc + Cargo.lock. + shared-key: tauri-${{ matrix.name }} + save-if: ${{ github.ref == 'refs/heads/main' }} + # Save the dependency cache even if a later step fails + cache-on-failure: true + - name: Set up x86_64 JDK 25 (macOS universal JRE) if: matrix.platform == 'macos-15' uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 diff --git a/.gitignore b/.gitignore index a379cf1db0..f47b020013 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ app/core/src/main/resources/static/index.html # Prerendered per-route SPA pages (OG/social-preview), e.g. compress.html. api-landing.html is source. app/core/src/main/resources/static/*.html !app/core/src/main/resources/static/api-landing.html +!app/core/src/main/resources/static/mobile-upload.html # Prerendered nested-route pages (e.g. settings/people.html) app/core/src/main/resources/static/settings/ app/core/src/main/resources/static/locales/ diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 8a7f6c622c..51ae93dc07 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -25,6 +25,7 @@ tasks: AIENGINE_URL: '{{.AIENGINE_URL}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' + SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}' dev:proprietary: desc: "Start backend dev server in proprietary mode" @@ -34,12 +35,13 @@ tasks: AIENGINE_URL: '{{.AIENGINE_URL | default ""}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}' + SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}' env: SERVER_PORT: '{{.PORT}}' cmds: - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' platforms: [windows] - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun' platforms: [linux, darwin] dev:bundled: diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 472e38729f..bcc7c07362 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -130,9 +130,34 @@ tasks: dev:portal: desc: "Start developer portal dev server" + ignore_error: true deps: [install] + vars: + PORT: '{{.PORT | default "5173"}}' + BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}' + EDITOR_URL: '{{.EDITOR_URL | default ""}}' + OPEN: '{{.OPEN | default ""}}' + SUBPATH: '{{.SUBPATH | default ""}}' + MOCKS: '{{.MOCKS | default ""}}' + env: + BACKEND_URL: '{{.BACKEND_URL}}' cmds: - - npx vite portal --port {{.PORT | default "5173"}}{{if .OPEN}} --open{{end}} + - '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}{{if .MOCKS}}VITE_PORTAL_MOCKS={{.MOCKS}} {{end}}{{if .EDITOR_URL}}VITE_EDITOR_URL={{.EDITOR_URL}} {{end}}npx vite portal --port {{.PORT}}{{if .OPEN}} --open{{end}}' + + dev:portal:proxy:serve: + internal: true + vars: + PORT: '{{.PORT | default "3000"}}' + BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}' + EDITOR_DEV_URL: '{{.EDITOR_DEV_URL | default ""}}' + PORTAL_DEV_URL: '{{.PORTAL_DEV_URL | default ""}}' + env: + PORT: '{{.PORT}}' + BACKEND_URL: '{{.BACKEND_URL}}' + EDITOR_DEV_URL: '{{.EDITOR_DEV_URL}}' + PORTAL_DEV_URL: '{{.PORTAL_DEV_URL}}' + cmds: + - npx tsx scripts/dev-origin-proxy.ts # ============================================================ # Build @@ -153,8 +178,10 @@ tasks: build:proprietary: desc: "Build for proprietary mode" deps: [prepare] + vars: + PREVIEW: '{{.PREVIEW | default ""}}' cmds: - - npx vite build editor --mode proprietary + - '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build editor --mode proprietary' build:saas: desc: "Build for SaaS mode" @@ -181,8 +208,26 @@ tasks: build:portal: desc: "Build developer portal" deps: [install] + vars: + SUBPATH: '{{.SUBPATH | default ""}}' cmds: - - npx vite build portal + - '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}npx vite build portal' + + preview:portal:proxy: + desc: "Build + serve editor + portal behind one origin (prod-like auth testing)" + deps: [prepare] + vars: + PORT: '{{.PORT | default "3000"}}' + BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}' + env: + PORT: '{{.PORT}}' + BACKEND_URL: '{{.BACKEND_URL}}' + cmds: + - task: build:proprietary + vars: { PREVIEW: '1' } + - task: build:portal + vars: { SUBPATH: portal } + - npx tsx scripts/dev-origin-proxy.ts storybook: desc: "Start Storybook dev server" @@ -288,6 +333,7 @@ tasks: desc: "Typecheck scripts" deps: [prepare] cmds: + - npx tsc --noEmit --project scripts/tsconfig.json - npx tsc --noEmit --project editor/scripts/tsconfig.json typecheck:prototypes: diff --git a/.taskfiles/pre-commit.yml b/.taskfiles/pre-commit.yml index 63fdd9ea01..136f852a5b 100644 --- a/.taskfiles/pre-commit.yml +++ b/.taskfiles/pre-commit.yml @@ -4,8 +4,6 @@ version: '3' # pre-commit hook (.pre-commit-config.yaml) and CI (pre_commit.yml) both call. vars: - GITLEAKS: '8.30.0' - # File selections as git pathspecs: git does the include/exclude matching, so # there is no grep/xargs and it behaves identically on every platform. PY_FILES: >- @@ -43,7 +41,9 @@ vars: ':(exclude).github/workflows/*' LOCALE_TOML: 'frontend/editor/public/locales/*/translation.toml' - GITLEAKS_BIN: '.task/bin/gitleaks-{{.GITLEAKS}}{{if eq OS "windows"}}.exe{{end}}' + # gitleaks is pinned + checksum-verified by scripts/pre-commit/install_gitleaks.py, + # which owns the version and caches the binary here. + GITLEAKS_BIN: '.task/bin/gitleaks{{if eq OS "windows"}}.exe{{end}}' tasks: default: @@ -84,22 +84,13 @@ tasks: - test -d scripts/pre-commit/.venv clean: - desc: "Remove the cache/build artifacts" + desc: "Remove the cached gitleaks binary and the tool virtualenv" cmds: - - task: '{{if eq OS "windows"}}clean-windows{{else}}clean-unix{{end}}' - - clean-unix: - internal: true - cmds: - - rm -rf scripts/pre-commit/.venv .task/bin/gitleaks-* - - # On Windows, use PowerShell so it matches the same paths and tolerates absent - # files without erroring. - clean-windows: - internal: true - ignore_error: true - cmds: - - powershell -NoProfile -Command "Remove-Item -Recurse -Force -ErrorAction SilentlyContinue scripts/pre-commit/.venv, .task/bin/gitleaks-*" + - cmd: rm -rf scripts/pre-commit/.venv .task/bin/gitleaks + platforms: [linux, darwin] + - cmd: cmd /c "rmdir /s /q scripts\pre-commit\.venv & del /q .task\bin\gitleaks.exe" + platforms: [windows] + ignore_error: true # Individual checks (hidden from `task --list`, but callable, e.g. # `task pre-commit:toml-sort FIX=1`). Pass FIX=1 to auto-fix where supported. @@ -125,7 +116,7 @@ tasks: whitespace: cmds: - - uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}$(git ls-files {{.WS_FILES}}) + - uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}{{.WS_FILES}} gitleaks: deps: [gitleaks-bin] @@ -137,23 +128,6 @@ tasks: gitleaks-bin: internal: true - desc: "Ensure the pinned gitleaks binary is cached in .task/bin" - status: - - test -f {{.GITLEAKS_BIN}} - vars: - GL_ARCH: '{{if eq ARCH "amd64"}}x64{{else if eq ARCH "arm64"}}arm64{{else if eq ARCH "386"}}x32{{else}}{{ARCH}}{{end}}' - GL_PLATFORM: '{{OS}}_{{.GL_ARCH}}' - GL_URL: 'https://github.com/gitleaks/gitleaks/releases/download/v{{.GITLEAKS}}/gitleaks_{{.GITLEAKS}}_{{.GL_PLATFORM}}' - # SHA-256 of each release asset, from gitleaks_{{.GITLEAKS}}_checksums.txt. - GL_SHA: >- - {{if eq .GL_PLATFORM "linux_x64"}}79a3ab579b53f71efd634f3aaf7e04a0fa0cf206b7ed434638d1547a2470a66e - {{- else if eq .GL_PLATFORM "linux_arm64"}}b4cbbb6ddf7d1b2a603088cd03a4e3f7ce48ee7fd449b51f7de6ee2906f5fa2f - {{- else if eq .GL_PLATFORM "darwin_x64"}}ca221d012d247080c2f6f61f4b7a83bffa2453806b0c195c795bbe9a8c775ed5 - {{- else if eq .GL_PLATFORM "darwin_arm64"}}b251ab2bcd4cd8ba9e56ff37698c033ebf38582b477d21ebd86586d927cf87e7 - {{- else if eq .GL_PLATFORM "windows_x64"}}54fe94f644b832dd08e8c3a5915efb3bfa862386d59fb27ca0792cb687a83573 - {{- end}} + desc: "Ensure the pinned, checksum-verified gitleaks binary is cached in .task/bin" cmds: - - cmd: bash scripts/pre-commit/install-gitleaks.sh "{{.GL_URL}}.tar.gz" "{{.GL_SHA}}" "{{.GITLEAKS_BIN}}" - platforms: [linux, darwin] - - cmd: powershell -NoProfile -File scripts/pre-commit/install-gitleaks.ps1 -Url "{{.GL_URL}}.zip" -Sha "{{.GL_SHA}}" -Dest "{{.GITLEAKS_BIN}}" - platforms: [windows] + - uv run --no-project python scripts/pre-commit/install_gitleaks.py diff --git a/AGENTS.md b/AGENTS.md index ae8eb60316..44647b0063 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,7 +152,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie #### Import Paths - CRITICAL **ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation. -For a broader explanation of the frontend layering and override architecture, see [frontend/editor/DeveloperGuide.md](frontend/editor/DeveloperGuide.md). +For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md ```typescript // ✅ CORRECT - Use @app/* for all imports diff --git a/Taskfile.yml b/Taskfile.yml index ac4160182a..2c776f7ad1 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -78,6 +78,80 @@ tasks: BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' OPEN: "true" + dev:portal: + desc: "Start backend + developer portal concurrently on free ports" + vars: + PORTS: + sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}' + BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' + PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}' + deps: + - task: backend:dev + vars: + PORT: '{{.BACKEND_PORT}}' + SECURITY_ENABLELOGIN: "true" + - task: frontend:dev:portal + vars: + PORT: '{{.PORTAL_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + OPEN: "true" + + dev:portal:all: + desc: "Start backend + developer portal + editor concurrently on free ports" + vars: + PORTS: + sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}' + BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' + PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}' + EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}' + deps: + - task: backend:dev + vars: + PORT: '{{.BACKEND_PORT}}' + SECURITY_ENABLELOGIN: "true" + - task: frontend:dev:portal + vars: + PORT: '{{.PORTAL_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + # Point the portal's "Editor" app switcher at the editor we spawn here. + EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/' + OPEN: "true" + - task: frontend:dev + vars: + PORT: '{{.EDITOR_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + + dev:portal:proxy: + desc: "Editor + portal on ONE origin + backend via live dev servers (shared-token login)" + vars: + PORTS: + sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000 5173 5174{{end}}' + BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' + PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}' + EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}' + PORTAL_PORT: '{{index (splitList "\n" .PORTS) 3}}' + deps: + - task: backend:dev + vars: + PORT: '{{.BACKEND_PORT}}' + SECURITY_ENABLELOGIN: "true" + - task: frontend:dev:proprietary + vars: + PORT: '{{.EDITOR_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + - task: frontend:dev:portal + vars: + PORT: '{{.PORTAL_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + SUBPATH: portal + MOCKS: 'false' + - task: frontend:dev:portal:proxy:serve + vars: + PORT: '{{.PROXY_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + EDITOR_DEV_URL: 'http://localhost:{{.EDITOR_PORT}}' + PORTAL_DEV_URL: 'http://localhost:{{.PORTAL_PORT}}' + dev:saas: desc: "Start SaaS backend + frontend concurrently on free ports" cmds: @@ -124,6 +198,23 @@ tasks: - task: backend:build - task: frontend:build + preview:portal:proxy: + desc: "Build + serve editor + portal on ONE origin + backend (prod-like auth test)" + vars: + PORTS: + sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000{{end}}' + BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' + PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}' + deps: + - task: backend:dev + vars: + PORT: '{{.BACKEND_PORT}}' + SECURITY_ENABLELOGIN: "true" + - task: frontend:preview:portal:proxy + vars: + PORT: '{{.PROXY_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + # ============================================================ # Test # ============================================================ diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index fbcf30fdff..4a9ef0834b 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -1185,23 +1185,181 @@ public class GeneralUtils { } public String getLocalNetworkIp() { + String routed = detectLocalIpViaDefaultRoute(); + if (routed != null) { + return routed; + } try { - Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); - if (interfaces == null) return null; - while (interfaces.hasMoreElements()) { - NetworkInterface iface = interfaces.nextElement(); - if (!iface.isUp() || iface.isLoopback() || iface.isVirtual()) continue; - Enumeration addresses = iface.getInetAddresses(); - while (addresses.hasMoreElements()) { - InetAddress addr = addresses.nextElement(); - if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) { - return addr.getHostAddress(); - } - } - } + return selectBestSiteLocalIp(collectInterfaceInfo()); } catch (Exception e) { log.warn("Failed to detect local network IP", e); + return null; + } + } + + private String detectLocalIpViaDefaultRoute() { + try (DatagramSocket socket = new DatagramSocket()) { + socket.connect(InetAddress.getByName("8.8.8.8"), 53); + InetAddress local = socket.getLocalAddress(); + if (local instanceof Inet4Address + && !local.isAnyLocalAddress() + && !local.isLoopbackAddress() + && !local.isLinkLocalAddress()) { + return local.getHostAddress(); + } + } catch (Exception e) { + log.debug("Default-route IP detection failed; will scan interfaces", e); } return null; } + + private List collectInterfaceInfo() throws SocketException { + List infos = new ArrayList<>(); + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + if (interfaces == null) { + return infos; + } + while (interfaces.hasMoreElements()) { + NetworkInterface iface = interfaces.nextElement(); + + List siteLocalIpv4s = new ArrayList<>(); + Enumeration addresses = iface.getInetAddresses(); + while (addresses.hasMoreElements()) { + InetAddress addr = addresses.nextElement(); + if (addr instanceof Inet4Address && addr.isSiteLocalAddress()) { + siteLocalIpv4s.add(addr.getHostAddress()); + } + } + if (siteLocalIpv4s.isEmpty()) { + continue; + } + + try { + byte[] mac = iface.getHardwareAddress(); + infos.add( + new NetworkInterfaceInfo( + iface.getName(), + iface.getDisplayName(), + iface.getIndex(), + iface.isUp(), + iface.isLoopback(), + iface.isPointToPoint(), + iface.isVirtual(), + mac != null && mac.length > 0, + siteLocalIpv4s)); + } catch (SocketException e) { + log.debug("Skipping interface {} while scanning for local IP", iface.getName(), e); + } + } + return infos; + } + + static String selectBestSiteLocalIp(List interfaces) { + return interfaces.stream() + .filter(i -> i.up() && !i.loopback() && !i.pointToPoint() && !i.virtual()) + .filter(i -> !isLikelyVirtualInterface(i.name(), i.displayName())) + .flatMap( + i -> + i.siteLocalIpv4s().stream() + .map( + ip -> + new ScoredAddress( + ip, + scoreInterface(i, ip), + i.index()))) + .max( + Comparator.comparingInt(ScoredAddress::score) + .thenComparing( + Comparator.comparingInt(ScoredAddress::interfaceIndex) + .reversed())) + .map(ScoredAddress::ip) + .orElse(null); + } + + private static int scoreInterface(NetworkInterfaceInfo iface, String ip) { + int score = 0; + if (isLikelyPhysicalInterface(iface.name(), iface.displayName())) { + score += 100; + } + if (iface.hasHardwareAddress()) { + score += 20; + } + if (ip.startsWith("192.168.")) { + score += 30; + } else if (ip.startsWith("10.")) { + score += 20; + } else { + score += 5; + } + return score; + } + + static boolean isLikelyVirtualInterface(String name, String displayName) { + String n = name == null ? "" : name.toLowerCase(Locale.ROOT); + String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT); + String[] namePrefixes = { + "tun", "tap", "utun", "veth", "virbr", "vmnet", "docker", "br-", "wg", "ppp", "awdl", + "llw" + }; + for (String prefix : namePrefixes) { + if (n.startsWith(prefix)) { + return true; + } + } + String[] displayMarkers = { + "vmware", + "virtualbox", + "virtual box", + "vbox", + "hyper-v", + "hyperv", + "vethernet", + "windows subsystem for linux", + "wsl", + "docker", + "tap-windows", + "tunnel", + "vpn", + "zerotier", + "tailscale", + "bluetooth", + "teredo", + "isatap", + "loopback", + "pseudo", + "virtual" + }; + for (String marker : displayMarkers) { + if (d.contains(marker)) { + return true; + } + } + return false; + } + + private static boolean isLikelyPhysicalInterface(String name, String displayName) { + String n = name == null ? "" : name.toLowerCase(Locale.ROOT); + String d = displayName == null ? "" : displayName.toLowerCase(Locale.ROOT); + return n.startsWith("eth") + || n.startsWith("en") + || n.startsWith("wl") + || n.startsWith("em") + || d.contains("ethernet") + || d.contains("wi-fi") + || d.contains("wifi") + || d.contains("wireless"); + } + + record NetworkInterfaceInfo( + String name, + String displayName, + int index, + boolean up, + boolean loopback, + boolean pointToPoint, + boolean virtual, + boolean hasHardwareAddress, + List siteLocalIpv4s) {} + + private record ScoredAddress(String ip, int score, int interfaceIndex) {} } diff --git a/app/common/src/test/java/stirling/software/SPDF/pdf/parser/PageImageLocatorTest.java b/app/common/src/test/java/stirling/software/SPDF/pdf/parser/PageImageLocatorTest.java new file mode 100644 index 0000000000..dbef06dd5d --- /dev/null +++ b/app/common/src/test/java/stirling/software/SPDF/pdf/parser/PageImageLocatorTest.java @@ -0,0 +1,191 @@ +package stirling.software.SPDF.pdf.parser; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +import java.awt.geom.Point2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.util.List; + +import org.apache.pdfbox.Loader; +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.common.PDRectangle; +import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.SPDF.pdf.parser.PageImageLocator.ImageBox; + +/** + * Unit tests for {@link PageImageLocator}. PDFs are built in memory with PDFBox so each test is + * deterministic and needs no fixtures or native libraries. The locator transforms the image unit + * square through the CTM, so an image drawn at {@code (x, y)} with size {@code (w, h)} must yield + * the box {@code (x, y, x+w, y+h)}. + */ +class PageImageLocatorTest { + + /** A tiny opaque raster; pixel content is irrelevant, only its placement matters. */ + private static PDImageXObject tinyImage(PDDocument doc) throws Exception { + BufferedImage img = new BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB); + return LosslessFactory.createFromImage(doc, img); + } + + /** Builds a one-page PDF that draws one image at the given placement. */ + private static byte[] pdfWithImageAt(float x, float y, float w, float h) throws Exception { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + PDImageXObject image = tinyImage(doc); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.drawImage(image, x, y, w, h); + } + return save(doc); + } + } + + private static byte[] save(PDDocument doc) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + return baos.toByteArray(); + } + + @Nested + @DisplayName("drawImage bounding boxes") + class DrawImageBoxes { + + @Test + @DisplayName("a single image yields one box with the page index and CTM-derived bounds") + void singleImageBox() throws Exception { + byte[] pdf = pdfWithImageAt(100f, 200f, 50f, 80f); + try (PDDocument doc = Loader.loadPDF(pdf)) { + PageImageLocator locator = new PageImageLocator(doc.getPage(0), 0); + locator.processPage(doc.getPage(0)); + + List boxes = locator.getImageBoxes(); + assertThat(boxes).hasSize(1); + ImageBox box = boxes.get(0); + assertThat(box.pageIndex()).isZero(); + assertThat(box.x1()).isCloseTo(100f, within(0.5f)); + assertThat(box.y1()).isCloseTo(200f, within(0.5f)); + assertThat(box.x2()).isCloseTo(150f, within(0.5f)); + assertThat(box.y2()).isCloseTo(280f, within(0.5f)); + } + } + + @Test + @DisplayName("the supplied page index is stored on every box") + void pageIndexStored() throws Exception { + byte[] pdf = pdfWithImageAt(10f, 10f, 20f, 20f); + try (PDDocument doc = Loader.loadPDF(pdf)) { + PageImageLocator locator = new PageImageLocator(doc.getPage(0), 7); + locator.processPage(doc.getPage(0)); + assertThat(locator.getImageBoxes().get(0).pageIndex()).isEqualTo(7); + } + } + + @Test + @DisplayName("two images on one page yield two boxes") + void twoImages() throws Exception { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + PDImageXObject image = tinyImage(doc); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.drawImage(image, 50f, 50f, 30f, 30f); + cs.drawImage(image, 200f, 400f, 60f, 40f); + } + byte[] pdf = save(doc); + try (PDDocument reopened = Loader.loadPDF(pdf)) { + PageImageLocator locator = new PageImageLocator(reopened.getPage(0), 0); + locator.processPage(reopened.getPage(0)); + assertThat(locator.getImageBoxes()).hasSize(2); + } + } + } + + @Test + @DisplayName("a page with no images yields no boxes") + void noImages() throws Exception { + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage(PDRectangle.A4)); + byte[] pdf = save(doc); + try (PDDocument reopened = Loader.loadPDF(pdf)) { + PageImageLocator locator = new PageImageLocator(reopened.getPage(0), 0); + locator.processPage(reopened.getPage(0)); + assertThat(locator.getImageBoxes()).isEmpty(); + } + } + } + + @Test + @DisplayName("getImageBoxes is empty before any page is processed") + void emptyBeforeProcessing() throws Exception { + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage(PDRectangle.A4)); + PageImageLocator locator = new PageImageLocator(doc.getPage(0), 0); + assertThat(locator.getImageBoxes()).isEmpty(); + } + } + } + + @Nested + @DisplayName("path operation no-ops") + class PathNoOps { + + private PageImageLocator newLocator() { + PDPage page = new PDPage(PDRectangle.A4); + return new PageImageLocator(page, 0); + } + + @Test + @DisplayName("moveTo updates the current point") + void moveToUpdatesPoint() { + PageImageLocator locator = newLocator(); + locator.moveTo(12f, 34f); + Point2D current = locator.getCurrentPoint(); + assertThat(current.getX()).isEqualTo(12d); + assertThat(current.getY()).isEqualTo(34d); + } + + @Test + @DisplayName("lineTo updates the current point") + void lineToUpdatesPoint() { + PageImageLocator locator = newLocator(); + locator.lineTo(5f, 6f); + assertThat(locator.getCurrentPoint().getX()).isEqualTo(5d); + assertThat(locator.getCurrentPoint().getY()).isEqualTo(6d); + } + + @Test + @DisplayName("curveTo updates the current point to the final control point") + void curveToUpdatesPoint() { + PageImageLocator locator = newLocator(); + locator.curveTo(1f, 1f, 2f, 2f, 9f, 8f); + assertThat(locator.getCurrentPoint().getX()).isEqualTo(9d); + assertThat(locator.getCurrentPoint().getY()).isEqualTo(8d); + } + + @Test + @DisplayName("rectangle, clip, path and shading operations are no-ops that do not throw") + void otherOpsDoNotThrow() { + PageImageLocator locator = newLocator(); + Point2D p = new Point2D.Float(0f, 0f); + // None of these record anything or alter state; they must simply not throw. + locator.appendRectangle(p, p, p, p); + locator.clip(0); + locator.closePath(); + locator.endPath(); + locator.strokePath(); + locator.fillPath(0); + locator.fillAndStrokePath(0); + locator.shadingFill(COSName.getPDFName("Sh0")); + assertThat(locator.getImageBoxes()).isEmpty(); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/configuration/AppConfigTest.java b/app/common/src/test/java/stirling/software/common/configuration/AppConfigTest.java new file mode 100644 index 0000000000..92fbf25788 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/configuration/AppConfigTest.java @@ -0,0 +1,270 @@ +package stirling.software.common.configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.function.Predicate; + +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.springframework.mock.env.MockEnvironment; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.model.ApplicationProperties; + +class AppConfigTest { + + private ApplicationProperties applicationProperties; + private MockEnvironment env; + private AppConfig appConfig; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + env = new MockEnvironment(); + appConfig = new AppConfig(env, applicationProperties); + ReflectionTestUtils.setField(appConfig, "contextPath", "/"); + ReflectionTestUtils.setField(appConfig, "serverPort", "8080"); + ReflectionTestUtils.setField(appConfig, "v2Enabled", true); + } + + @Nested + @DisplayName("Value-backed getters and simple beans") + class SimpleBeans { + + @Test + @DisplayName("getter fields reflect injected @Value values") + void valueGetters() { + assertThat(appConfig.getContextPath()).isEqualTo("/"); + assertThat(appConfig.getServerPort()).isEqualTo("8080"); + } + + @Test + @DisplayName("v2Enabled bean mirrors the field") + void v2EnabledBean() { + assertThat(appConfig.v2Enabled()).isTrue(); + } + + @Test + @DisplayName("constant beans return fixed values") + void constants() { + assertThat(appConfig.appName()).isEqualTo("Stirling PDF"); + assertThat(appConfig.homeText()).isEqualTo("null"); + assertThat(appConfig.contextPath("/ctx")).isEqualTo("/ctx"); + } + + @Test + @DisplayName("appVersion resolves from version.properties on classpath") + void appVersion() { + assertThat(appConfig.appVersion()).isNotBlank(); + } + + @Test + @DisplayName("StirlingPDFLabel embeds version") + void stirlingLabel() { + assertThat(appConfig.stirlingPDFLabel()).startsWith("Stirling-PDF v"); + } + } + + @Nested + @DisplayName("Beans backed by ApplicationProperties") + class PropertyBackedBeans { + + @Test + @DisplayName("loginEnabled reflects security flag") + void loginEnabled() { + applicationProperties.getSecurity().setEnableLogin(true); + assertThat(appConfig.loginEnabled()).isTrue(); + } + + @Test + @DisplayName("backendUrl falls back to localhost when unset") + void backendUrlFallback() { + assertThat(appConfig.getBackendUrl()).isEqualTo("http://localhost"); + } + + @Test + @DisplayName("backendUrl returns configured value when present") + void backendUrlConfigured() { + applicationProperties.getSystem().setBackendUrl("https://api.example.com"); + assertThat(appConfig.getBackendUrl()).isEqualTo("https://api.example.com"); + } + + @Test + @DisplayName("languages bean returns configured languages list") + void languages() { + applicationProperties.getUi().setLanguages(List.of("en", "de")); + assertThat(appConfig.languages()).containsExactly("en", "de"); + } + + @Test + @DisplayName("navBarText falls back to Stirling PDF when unset") + void navBarTextFallback() { + assertThat(appConfig.navBarText()).isEqualTo("Stirling PDF"); + } + + @Test + @DisplayName("navBarText returns configured value") + void navBarTextConfigured() { + applicationProperties.getUi().setAppNameNavbar("My PDF"); + assertThat(appConfig.navBarText()).isEqualTo("My PDF"); + } + + @Test + @DisplayName("enableAlphaFunctionality reflects system flag") + void alphaFunctionality() { + applicationProperties.getSystem().setEnableAlphaFunctionality(true); + assertThat(appConfig.enableAlphaFunctionality()).isTrue(); + } + + @Test + @DisplayName("legal text beans return configured values") + void legalBeans() { + var legal = applicationProperties.getLegal(); + legal.setTermsAndConditions("terms"); + legal.setPrivacyPolicy("privacy"); + legal.setCookiePolicy("cookie"); + legal.setImpressum("impressum"); + legal.setAccessibilityStatement("a11y"); + assertThat(appConfig.termsAndConditions()).isEqualTo("terms"); + assertThat(appConfig.privacyPolicy()).isEqualTo("privacy"); + assertThat(appConfig.cookiePolicy()).isEqualTo("cookie"); + assertThat(appConfig.impressum()).isEqualTo("impressum"); + assertThat(appConfig.accessibilityStatement()).isEqualTo("a11y"); + } + + @Test + @DisplayName("analyticsPrompt true when enableAnalytics null") + void analyticsPrompt() { + applicationProperties.getSystem().setEnableAnalytics(null); + assertThat(appConfig.analyticsPrompt()).isTrue(); + applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE); + assertThat(appConfig.analyticsPrompt()).isFalse(); + } + + @Test + @DisplayName("analyticsEnabled true when premium enabled regardless of system flag") + void analyticsEnabledViaPremium() { + applicationProperties.getPremium().setEnabled(true); + assertThat(appConfig.analyticsEnabled()).isTrue(); + } + + @Test + @DisplayName("analyticsEnabled reflects system flag when premium disabled") + void analyticsEnabledViaSystem() { + applicationProperties.getPremium().setEnabled(false); + applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE); + assertThat(appConfig.analyticsEnabled()).isTrue(); + applicationProperties.getSystem().setEnableAnalytics(Boolean.FALSE); + assertThat(appConfig.analyticsEnabled()).isFalse(); + } + + @Test + @DisplayName("scarf and posthog beans reflect derived flags") + void scarfAndPosthog() { + applicationProperties.getSystem().setEnableAnalytics(Boolean.TRUE); + applicationProperties.getSystem().setEnableScarf(Boolean.TRUE); + applicationProperties.getSystem().setEnablePosthog(Boolean.TRUE); + assertThat(appConfig.scarfEnabled()).isTrue(); + assertThat(appConfig.posthogEnabled()).isTrue(); + } + + @Test + @DisplayName("uuid bean returns generated UUID") + void uuidBean() { + applicationProperties.getAutomaticallyGenerated().setUUID("abc-123"); + assertThat(appConfig.uuid()).isEqualTo("abc-123"); + } + + @Test + @DisplayName("typed config beans return live nested instances") + void typedConfigBeans() { + assertThat(appConfig.security()).isSameAs(applicationProperties.getSecurity()); + assertThat(appConfig.oAuth2()) + .isSameAs(applicationProperties.getSecurity().getOauth2()); + assertThat(appConfig.premium()).isSameAs(applicationProperties.getPremium()); + assertThat(appConfig.system()).isSameAs(applicationProperties.getSystem()); + assertThat(appConfig.datasource()) + .isSameAs(applicationProperties.getSystem().getDatasource()); + } + } + + @Nested + @DisplayName("Profile-default and environment beans") + class ProfileAndEnvBeans { + + @Test + @DisplayName("default-profile license beans return community defaults") + void licenseDefaults() { + assertThat(appConfig.runningProOrHigher()).isFalse(); + assertThat(appConfig.runningEnterprise()).isFalse(); + assertThat(appConfig.licenseType()).isEqualTo("NORMAL"); + } + + @Test + @DisplayName("activeSecurity reflects classpath presence of SecurityConfiguration") + void activeSecurity() { + // Just exercise the branch; result depends on classpath, assert it does not throw. + boolean present = appConfig.missingActiveSecurity(); + assertThat(present).isIn(true, false); + } + + @Test + @DisplayName("rateLimit parses system property") + void rateLimitProperty() { + String prev = System.getProperty("rateLimit"); + try { + System.setProperty("rateLimit", "true"); + assertThat(appConfig.rateLimit()).isTrue(); + } finally { + if (prev == null) { + System.clearProperty("rateLimit"); + } else { + System.setProperty("rateLimit", prev); + } + } + } + + @Test + @DisplayName("runningInDocker false outside container") + void runningInDocker() { + // CI/test host is not a container with /.dockerenv. + assertThat(appConfig.runningInDocker()).isFalse(); + } + + @Test + @DisplayName("configDirMounted defaults to true when not in docker") + void configDirMounted() { + assertThat(appConfig.isRunningInDockerWithConfig()).isTrue(); + } + + @Test + @DisplayName("directoryFilter accepts files and rejects processing dirs") + void directoryFilter(@org.junit.jupiter.api.io.TempDir Path tempDir) throws Exception { + Predicate filter = appConfig.processOnlyFiles(); + Path file = Files.createFile(tempDir.resolve("a.txt")); + Path normalDir = Files.createDirectory(tempDir.resolve("normal")); + Path processingDir = Files.createDirectory(tempDir.resolve("processing")); + assertThat(filter.test(file)).isTrue(); + assertThat(filter.test(normalDir)).isTrue(); + assertThat(filter.test(processingDir)).isFalse(); + } + + @Test + @DisplayName("machineType returns Server-jar in plain test environment") + void machineTypeServerJar() { + assertThat(appConfig.determineMachineType()).isEqualTo("Server-jar"); + } + + @Test + @DisplayName("machineType returns a Client-* variant when BROWSER_OPEN set") + void machineTypeClient() { + env.setProperty("BROWSER_OPEN", "true"); + assertThat(appConfig.determineMachineType()).startsWith("Client-"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/configuration/ConfigInitializerMoreTest.java b/app/common/src/test/java/stirling/software/common/configuration/ConfigInitializerMoreTest.java new file mode 100644 index 0000000000..e1ba4204bc --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/configuration/ConfigInitializerMoreTest.java @@ -0,0 +1,178 @@ +package stirling.software.common.configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mockStatic; + +import java.io.FileNotFoundException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; +import org.snakeyaml.engine.v2.api.LoadSettings; + +import stirling.software.common.util.YamlHelper; + +class ConfigInitializerMoreTest { + + private static final LoadSettings LOAD_SETTINGS = + LoadSettings.builder() + .setUseMarks(true) + .setMaxAliasesForCollections(Integer.MAX_VALUE) + .setAllowRecursiveKeys(true) + .setParseComments(true) + .build(); + + // Template after the enterpriseEdition -> premium rename. + private static final String PREMIUM_TEMPLATE = + """ + premium: + enabled: false + key: 0000 + proFeatures: + ssoAutoLogin: false + customMetadata: + autoUpdateMetadata: false + author: username + creator: Stirling-PDF + producer: Stirling-PDF + """; + + @Nested + @DisplayName("migrateEnterpriseEditionToPremium") + class EnterpriseMigration { + + @Test + @DisplayName("carries legacy enterpriseEdition values forward into premium block") + void migratesLegacyEnterpriseValues() throws Exception { + String legacy = + """ + enterpriseEdition: + enabled: true + key: ABC-123 + SSOAutoLogin: true + CustomMetadata: + autoUpdateMetadata: true + author: alice + creator: bob + producer: carol + """; + YamlHelper template = new YamlHelper(LOAD_SETTINGS, PREMIUM_TEMPLATE); + YamlHelper existing = new YamlHelper(LOAD_SETTINGS, legacy); + + invokeMigrate(existing, template); + + assertThat(template.getValueByExactKeyPath("premium", "enabled")).isEqualTo("true"); + assertThat(template.getValueByExactKeyPath("premium", "key")).isEqualTo("ABC-123"); + assertThat(template.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin")) + .isEqualTo("true"); + assertThat( + template.getValueByExactKeyPath( + "premium", + "proFeatures", + "customMetadata", + "autoUpdateMetadata")) + .isEqualTo("true"); + assertThat( + template.getValueByExactKeyPath( + "premium", "proFeatures", "customMetadata", "author")) + .isEqualTo("alice"); + assertThat( + template.getValueByExactKeyPath( + "premium", "proFeatures", "customMetadata", "creator")) + .isEqualTo("bob"); + assertThat( + template.getValueByExactKeyPath( + "premium", "proFeatures", "customMetadata", "producer")) + .isEqualTo("carol"); + } + + @Test + @DisplayName("no legacy enterpriseEdition block leaves template defaults intact") + void noLegacyKeysIsNoOp() throws Exception { + String noEnterprise = + """ + security: + enableLogin: false + """; + YamlHelper template = new YamlHelper(LOAD_SETTINGS, PREMIUM_TEMPLATE); + YamlHelper existing = new YamlHelper(LOAD_SETTINGS, noEnterprise); + + invokeMigrate(existing, template); + + assertThat(template.getValueByExactKeyPath("premium", "enabled")).isEqualTo("false"); + assertThat( + template.getValueByExactKeyPath( + "premium", "proFeatures", "customMetadata", "author")) + .isEqualTo("username"); + } + + private void invokeMigrate(YamlHelper yaml, YamlHelper template) throws Exception { + var method = + ConfigInitializer.class.getDeclaredMethod( + "migrateEnterpriseEditionToPremium", + YamlHelper.class, + YamlHelper.class); + method.setAccessible(true); + method.invoke(new ConfigInitializer(), yaml, template); + } + } + + @Nested + @DisplayName("ensureConfigExists - create branch (template absent on common classpath)") + class EnsureConfigCreateBranch { + + @Test + @DisplayName("no settings file -> attempts create, fails fast when template missing") + void createWithoutTemplateThrows(@TempDir Path tempDir) throws Exception { + Path settings = tempDir.resolve("configs").resolve("settings.yml"); + Path custom = tempDir.resolve("configs").resolve("custom_settings.yml"); + + try (MockedStatic mocked = + mockStatic(InstallationPathConfig.class)) { + mocked.when(InstallationPathConfig::getSettingsPath) + .thenReturn(settings.toString()); + mocked.when(InstallationPathConfig::getCustomSettingsPath) + .thenReturn(custom.toString()); + + // settings.yml.template is packaged in the core module, not common, so the + // create branch must surface a FileNotFoundException here. + assertThatThrownBy(() -> new ConfigInitializer().ensureConfigExists()) + .isInstanceOf(FileNotFoundException.class); + } + } + + @Test + @DisplayName("short existing settings file is backed up before recreate attempt") + void shortFileIsBackedUp(@TempDir Path tempDir) throws Exception { + Path configDir = Files.createDirectories(tempDir.resolve("configs")); + Path settings = configDir.resolve("settings.yml"); + Path custom = configDir.resolve("custom_settings.yml"); + // Fewer than MIN_SETTINGS_FILE_LINES (31) lines triggers the recreate path. + Files.writeString(settings, "a: 1\nb: 2\n"); + + try (MockedStatic mocked = + mockStatic(InstallationPathConfig.class)) { + mocked.when(InstallationPathConfig::getSettingsPath) + .thenReturn(settings.toString()); + mocked.when(InstallationPathConfig::getCustomSettingsPath) + .thenReturn(custom.toString()); + + assertThatThrownBy(() -> new ConfigInitializer().ensureConfigExists()) + .isInstanceOf(FileNotFoundException.class); + } + + // Original was moved to a timestamped .bak before the failed recreate. + try (Stream files = Files.list(configDir)) { + assertThat(files.anyMatch(p -> p.getFileName().toString().contains(".bak"))) + .isTrue(); + } + assertThat(Files.exists(settings)).isFalse(); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/pdf/PdfMarkdownConverterMoreTest.java b/app/common/src/test/java/stirling/software/common/pdf/PdfMarkdownConverterMoreTest.java new file mode 100644 index 0000000000..4dc43f7772 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/pdf/PdfMarkdownConverterMoreTest.java @@ -0,0 +1,244 @@ +package stirling.software.common.pdf; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import stirling.software.jpdfium.PdfDocument; +import stirling.software.jpdfium.text.PageText; +import stirling.software.jpdfium.text.Table; +import stirling.software.jpdfium.text.TextChar; +import stirling.software.jpdfium.text.TextLine; +import stirling.software.jpdfium.text.TextWord; + +/** + * Gap-filling tests for {@link PdfMarkdownConverter} not covered by {@link + * PdfMarkdownConverterTest}: the visible-for-testing column-range detector across a range of + * geometries, the package-private extraction helpers, and the full conversion of the wrapped-cell + * fixture (only run under a disabled accuracy test in the sibling suite). + */ +class PdfMarkdownConverterMoreTest { + + @TempDir Path tmp; + + // ---- helpers ------------------------------------------------------------ + + /** A word occupying [x, x+width] on baseline y; chars are synthetic so text length is real. */ + private static TextWord word(String text, float x, float width) { + List chars = new ArrayList<>(); + for (int i = 0; i < text.length(); i++) { + chars.add( + new TextChar( + i, + text.charAt(i), + x, + 0f, + width / Math.max(1, text.length()), + 10f, + "Helvetica", + 10f)); + } + return new TextWord(chars, x, 0f, width, 10f); + } + + /** A single-line row built from the given words, spanning their full x-range. */ + private static TextLine row(float y, TextWord... words) { + float minX = Float.MAX_VALUE; + float maxX = -Float.MAX_VALUE; + for (TextWord w : words) { + minX = Math.min(minX, w.x()); + maxX = Math.max(maxX, w.x() + w.width()); + } + return new TextLine(List.of(words), minX, y, maxX - minX, 10f); + } + + /** Copies a classpath fixture into the temp dir and returns its path. */ + private Path fixture(String name) throws IOException { + Path dest = tmp.resolve(name); + try (InputStream in = getClass().getResourceAsStream("/pdf-ingestion-fixtures/" + name)) { + assertThat(in).as("fixture on classpath: " + name).isNotNull(); + Files.copy(in, dest); + } + return dest; + } + + // ---- findColumnRangesFromLines ----------------------------------------- + + @Nested + @DisplayName("findColumnRangesFromLines") + class ColumnRanges { + + @Test + @DisplayName("two well-separated bands are detected as two columns") + void twoColumns() { + List rows = new ArrayList<>(); + for (int r = 0; r < 4; r++) { + float y = 400f - r * 12f; + rows.add(row(y, word("left", 50f, 40f), word("right", 190f, 40f))); + } + List cols = PdfMarkdownConverter.findColumnRangesFromLines(rows); + assertThat(cols).hasSize(2); + // First band starts near 50, second near 190. + assertThat(cols.get(0)[0]).isLessThan(cols.get(1)[0]); + } + + @Test + @DisplayName("two bands within a narrow gutter merge into one column") + void narrowGutterMerges() { + List rows = new ArrayList<>(); + for (int r = 0; r < 4; r++) { + float y = 400f - r * 12f; + // Gap of ~10pt is far below the merge threshold for 40pt-wide words. + rows.add(row(y, word("aa", 50f, 40f), word("bb", 100f, 40f))); + } + List cols = PdfMarkdownConverter.findColumnRangesFromLines(rows); + assertThat(cols).hasSize(1); + } + + @Test + @DisplayName("a single occupied band yields one column (trailing-band flush)") + void singleColumn() { + List rows = new ArrayList<>(); + for (int r = 0; r < 3; r++) { + rows.add(row(400f - r * 12f, word("word", 50f, 60f))); + } + List cols = PdfMarkdownConverter.findColumnRangesFromLines(rows); + assertThat(cols).hasSize(1); + assertThat(cols.get(0)[0]).isCloseTo(50f, org.assertj.core.api.Assertions.within(2f)); + } + + @Test + @DisplayName("rows with no words produce no columns") + void noWordsNoColumns() { + List rows = new ArrayList<>(); + for (int r = 0; r < 3; r++) { + rows.add(new TextLine(List.of(), 0f, 400f - r * 12f, 0f, 10f)); + } + assertThat(PdfMarkdownConverter.findColumnRangesFromLines(rows)).isEmpty(); + } + + @Test + @DisplayName("an empty row list produces no columns") + void emptyInput() { + assertThat(PdfMarkdownConverter.findColumnRangesFromLines(List.of())).isEmpty(); + } + + @Test + @DisplayName("a sparsely-covered band below the support threshold is dropped") + void sparseBandDropped() { + // Five rows fill the left band; only one fills a far-right band, which is below the + // 35%-of-rows support floor and so is not reported as a column. + List rows = new ArrayList<>(); + for (int r = 0; r < 5; r++) { + rows.add(row(400f - r * 12f, word("left", 50f, 40f))); + } + rows.add(row(320f, word("left", 50f, 40f), word("rareoutlier", 400f, 60f))); + List cols = PdfMarkdownConverter.findColumnRangesFromLines(rows); + assertThat(cols).hasSize(1); + } + } + + // ---- package-private extraction helpers --------------------------------- + + @Nested + @DisplayName("extraction helpers") + class ExtractionHelpers { + + @Test + @DisplayName("extractAllPageText returns one PageText per page") + void extractAllPageText() throws IOException { + Path pdf = fixture("bordered-table-test_widget.pdf"); + try (PdfDocument doc = PdfDocument.open(pdf)) { + List pages = new PdfMarkdownConverter().extractAllPageText(doc); + assertThat(pages).isNotNull(); + assertThat(pages).hasSize(doc.pageCount()); + } + } + + @Test + @DisplayName("extractTables returns a non-null list for the first page") + void extractTables() throws IOException { + Path pdf = fixture("bordered-table-test_widget.pdf"); + try (PdfDocument doc = PdfDocument.open(pdf)) { + List tables = new PdfMarkdownConverter().extractTables(doc, 0); + assertThat(tables).isNotNull(); + } + } + + @Test + @DisplayName("renderTables maps each extracted table to a markdown string") + void renderTables() throws IOException { + Path pdf = fixture("bordered-table-test_widget.pdf"); + PdfMarkdownConverter converter = new PdfMarkdownConverter(); + try (PdfDocument doc = PdfDocument.open(pdf)) { + List
tables = converter.extractTables(doc, 0); + List rendered = converter.renderTables(tables); + assertThat(rendered).isNotNull(); + assertThat(rendered).hasSameSizeAs(tables); + } + } + + @Test + @DisplayName("renderTables on an empty table list returns an empty list") + void renderTablesEmpty() { + assertThat(new PdfMarkdownConverter().renderTables(List.of())).isEmpty(); + } + } + + // ---- full conversion of additional fixtures ----------------------------- + + @Nested + @DisplayName("convert full pipeline") + class ConvertPipeline { + + @Test + @DisplayName("wrapped-cell expense report converts without throwing and yields content") + void wrappedCellFixture() throws IOException { + Path pdf = fixture("wrapped-cell-test_expense-report.pdf"); + String md; + try (PdfDocument doc = PdfDocument.open(pdf)) { + md = new PdfMarkdownConverter().convert(doc); + } + assertThat(md).isNotNull(); + assertThat(md).isNotBlank(); + } + + @Test + @DisplayName("converting a fixture twice is deterministic") + void deterministic() throws IOException { + Path pdf = fixture("multi-column-test_lorem.pdf"); + String first; + String second; + try (PdfDocument doc = PdfDocument.open(pdf)) { + first = new PdfMarkdownConverter().convert(doc); + } + try (PdfDocument doc = PdfDocument.open(pdf)) { + second = new PdfMarkdownConverter().convert(doc); + } + assertThat(first).isEqualTo(second); + } + + @Test + @DisplayName("the many-tables stress fixture converts without throwing") + void manyTablesFixture() throws IOException { + Path pdf = fixture("many-tables-test_stress.pdf"); + assertDoesNotThrow( + () -> { + try (PdfDocument doc = PdfDocument.open(pdf)) { + return new PdfMarkdownConverter().convert(doc); + } + }); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/pdf/TableRendererTest.java b/app/common/src/test/java/stirling/software/common/pdf/TableRendererTest.java new file mode 100644 index 0000000000..89bcb45760 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/pdf/TableRendererTest.java @@ -0,0 +1,152 @@ +package stirling.software.common.pdf; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.jpdfium.text.Table; + +/** + * Unit tests for {@link TableRenderer}. Tables are built directly from the {@link Table} record so + * the renderer can be exercised without any PDF parsing, fixtures, or native calls. + */ +class TableRendererTest { + + /** Builds a Table from raw rows; geometry is irrelevant to rendering so it is set to zero. */ + private static Table table(List> rows) { + return new Table(rows, 0f, 0f, 0f, 0f); + } + + @Nested + @DisplayName("Degenerate tables") + class Degenerate { + + @Test + @DisplayName("zero rows renders the empty string") + void zeroRows() { + assertThat(TableRenderer.render(table(List.of()))).isEmpty(); + } + + @Test + @DisplayName("single row with one column has no separator and is a plain line") + void singleRowOneColumn() { + String md = TableRenderer.render(table(List.of(List.of("only")))); + assertThat(md).isEqualTo("only"); + assertThat(md).doesNotContain("|"); + } + + @Test + @DisplayName("single row with several columns becomes newline-separated plain lines") + void singleRowManyColumns() { + String md = TableRenderer.render(table(List.of(List.of("a", "b", "c")))); + // No separator row is possible with a single row, so cells are emitted as lines. + assertThat(md).isEqualTo("a\nb\nc"); + } + + @Test + @DisplayName("single-row cell content is trimmed and escaped") + void singleRowTrimsAndEscapes() { + String md = TableRenderer.render(table(List.of(List.of(" a|b ")))); + assertThat(md).isEqualTo("a\\|b"); + } + } + + @Nested + @DisplayName("GFM rendering") + class GfmRendering { + + @Test + @DisplayName("two rows produce a header, a separator and a data row") + void headerSeparatorData() { + String md = + TableRenderer.render( + table(List.of(List.of("Name", "Age"), List.of("Alice", "30")))); + String[] lines = md.split("\n"); + assertThat(lines).hasSize(3); + assertThat(lines[0]).startsWith("|").contains("Name").contains("Age"); + // Separator row is made only of pipes and dashes. + assertThat(lines[1].chars().allMatch(c -> c == '|' || c == '-')).isTrue(); + assertThat(lines[2]).contains("Alice").contains("30"); + } + + @Test + @DisplayName("column widths grow to fit the widest cell in each column") + void columnWidthsFitContent() { + String md = + TableRenderer.render( + table( + List.of( + List.of("h", "header2"), + List.of("averylongvalue", "x")))); + String[] lines = md.split("\n"); + // Every rendered row (header, separator, data) is the same total width. + int width = lines[0].length(); + for (String line : lines) { + assertThat(line.length()).isEqualTo(width); + } + } + + @Test + @DisplayName("minimum column width of three dashes is honoured for tiny cells") + void minimumWidthThree() { + String md = TableRenderer.render(table(List.of(List.of("a", "b"), List.of("c", "d")))); + String separator = md.split("\n")[1]; + // Each column is padded to a minimum of 3, fenced by a dash either side: |-----|-----|. + assertThat(separator).isEqualTo("|-----|-----|"); + } + + @Test + @DisplayName("pipe characters in cells are escaped in every rendered row") + void escapesPipes() { + String md = + TableRenderer.render(table(List.of(List.of("a|b", "c"), List.of("d", "e|f")))); + // Two literal pipes escaped; the structural pipes are not. + assertThat(md).contains("a\\|b").contains("e\\|f"); + } + + @Test + @DisplayName("cells are trimmed before measuring and rendering") + void trimsCells() { + String md = + TableRenderer.render( + table(List.of(List.of(" Name ", " Age "), List.of("Al", "30")))); + assertThat(md).contains("| Name").contains("Age "); + assertThat(md).doesNotContain(" Name "); + } + + @Test + @DisplayName("three rows emit two data rows after the separator") + void multipleDataRows() { + String md = + TableRenderer.render( + table( + List.of( + List.of("c1", "c2"), + List.of("a", "b"), + List.of("x", "y")))); + String[] lines = md.split("\n"); + assertThat(lines).hasSize(4); + assertThat(lines[2]).contains("a").contains("b"); + assertThat(lines[3]).contains("x").contains("y"); + } + + @Test + @DisplayName("a short trailing row is padded out to the column count from asGrid") + void shortRowPaddedByGrid() { + // colCount comes from the first row; a shorter later row is padded with empty cells by + // Table.asGrid, so rendering must not throw and the grid stays rectangular. + String md = + TableRenderer.render(table(List.of(List.of("a", "b", "c"), List.of("only")))); + String[] lines = md.split("\n"); + assertThat(lines).hasSize(3); + int width = lines[0].length(); + for (String line : lines) { + assertThat(line.length()).isEqualTo(width); + } + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/CustomPDFDocumentFactoryMoreTest.java b/app/common/src/test/java/stirling/software/common/service/CustomPDFDocumentFactoryMoreTest.java new file mode 100644 index 0000000000..1c5a0d2dff --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/CustomPDFDocumentFactoryMoreTest.java @@ -0,0 +1,180 @@ +package stirling.software.common.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.pdfbox.io.MemoryUsageSetting; +import org.apache.pdfbox.io.RandomAccessStreamCache.StreamCacheCreateFunction; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.encryption.AccessPermission; +import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy; +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.io.TempDir; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; + +class CustomPDFDocumentFactoryMoreTest { + + private CustomPDFDocumentFactory factory; + private byte[] basePdfBytes; + + @BeforeEach + void setUp() throws IOException { + factory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class)); + try (InputStream is = getClass().getResourceAsStream("/example.pdf")) { + basePdfBytes = is.readAllBytes(); + } + } + + @Nested + @DisplayName("null-argument guards") + class NullGuards { + + @Test + @DisplayName("each load overload rejects null with IllegalArgumentException") + void nullArguments() { + assertThatThrownBy(() -> factory.load((File) null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> factory.load((Path) null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> factory.load((byte[]) null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> factory.load((InputStream) null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> factory.load((InputStream) null, "pw")) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + @DisplayName("cache strategy selection (public overload)") + class CacheStrategy { + + @Test + @DisplayName("getStreamCacheFunction returns a non-null function for each size band") + void cacheFunctionPerBand() { + StreamCacheCreateFunction small = factory.getStreamCacheFunction(1024); + StreamCacheCreateFunction mixed = factory.getStreamCacheFunction(20L * 1024 * 1024); + StreamCacheCreateFunction large = factory.getStreamCacheFunction(60L * 1024 * 1024); + assertThat(small).isNotNull(); + assertThat(mixed).isNotNull(); + assertThat(large).isNotNull(); + } + } + + @Nested + @DisplayName("create and round-trip helpers") + class CreateAndRoundTrip { + + @Test + @DisplayName("createNewDocument(MemoryUsageSetting) sets default metadata") + void createWithMemorySetting() throws IOException { + PdfMetadataService svc = mock(PdfMetadataService.class); + CustomPDFDocumentFactory f = new CustomPDFDocumentFactory(svc); + try (PDDocument doc = f.createNewDocument(MemoryUsageSetting.setupMainMemoryOnly())) { + assertThat(doc).isNotNull(); + verify(svc).setDefaultMetadata(doc); + } + } + + @Test + @DisplayName("loadToBytes(byte[]) round-trips a loadable PDF") + void loadToBytesFromArray() throws IOException { + byte[] out = factory.loadToBytes(basePdfBytes); + try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(out)) { + assertThat(doc.getNumberOfPages()).isPositive(); + } + } + + @Test + @DisplayName("createNewDocumentBasedOnOldDocument(byte[]) produces a fresh document") + void newDocFromOldBytes() throws IOException { + try (PDDocument doc = factory.createNewDocumentBasedOnOldDocument(basePdfBytes)) { + assertThat(doc).isNotNull(); + } + } + + @Test + @DisplayName("createNewDocumentBasedOnOldDocument(File) produces a fresh document") + void newDocFromOldFile(@TempDir Path tempDir) throws IOException { + File f = Files.write(tempDir.resolve("old.pdf"), basePdfBytes).toFile(); + try (PDDocument doc = factory.createNewDocumentBasedOnOldDocument(f)) { + assertThat(doc).isNotNull(); + } + } + } + + @Nested + @DisplayName("read-only and password handling") + class ReadOnlyAndPassword { + + @Test + @DisplayName("read-only load from file skips post-processing") + void readOnlyFromFile(@TempDir Path tempDir) throws IOException { + PdfMetadataService svc = mock(PdfMetadataService.class); + CustomPDFDocumentFactory f = new CustomPDFDocumentFactory(svc); + File file = Files.write(tempDir.resolve("ro.pdf"), basePdfBytes).toFile(); + try (PDDocument doc = f.load(file, true)) { + assertThat(doc).isNotNull(); + org.mockito.Mockito.verify(svc, org.mockito.Mockito.never()) + .setDefaultMetadata(org.mockito.ArgumentMatchers.any()); + } + } + + @Test + @DisplayName("encrypted PDF is decrypted on the default (non-read-only) load path") + void encryptedPdfDecrypted() throws IOException { + byte[] encrypted = buildEncryptedPdf("ownerpw", "userpw"); + // load(InputStream, password) drives removePassword + setAllSecurityToBeRemoved so the + // returned document can be re-saved with no password set. + byte[] decryptedSaved; + try (PDDocument doc = + factory.load(new ByteArrayInputStream(encrypted), "userpw", false)) { + assertThat(doc.getNumberOfPages()).isPositive(); + decryptedSaved = factory.saveToBytes(doc); + } + // Re-loading with no password proves security was stripped. + try (PDDocument reloaded = org.apache.pdfbox.Loader.loadPDF(decryptedSaved)) { + assertThat(reloaded.isEncrypted()).isFalse(); + } + } + + @Test + @DisplayName("MultipartFile with positive small size uses byte[] path") + void smallMultipartLoadsViaBytes() throws IOException { + MockMultipartFile multipart = + new MockMultipartFile( + "file", "s.pdf", MediaType.APPLICATION_PDF_VALUE, basePdfBytes); + try (PDDocument doc = factory.load(multipart)) { + assertThat(doc.getNumberOfPages()).isPositive(); + } + } + } + + private static byte[] buildEncryptedPdf(String ownerPw, String userPw) throws IOException { + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage()); + AccessPermission ap = new AccessPermission(); + StandardProtectionPolicy spp = new StandardProtectionPolicy(ownerPw, userPw, ap); + spp.setEncryptionKeyLength(128); + doc.protect(spp); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + doc.save(out); + return out.toByteArray(); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/FileStorageMoreTest.java b/app/common/src/test/java/stirling/software/common/service/FileStorageMoreTest.java new file mode 100644 index 0000000000..e9249a3ce2 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/FileStorageMoreTest.java @@ -0,0 +1,152 @@ +package stirling.software.common.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import java.io.ByteArrayInputStream; +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.Optional; + +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.io.TempDir; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import stirling.software.common.cluster.inprocess.LocalDiskFileStore; +import stirling.software.common.service.FileStorage.StoredFile; + +class FileStorageMoreTest { + + @TempDir Path storageDir; + + private FileStorage fileStorage; + + @BeforeEach + void setUp() { + fileStorage = + new FileStorage( + mock(FileOrUploadService.class), + new LocalDiskFileStore(storageDir.toString()), + Optional.empty()); + } + + @Nested + @DisplayName("storeInputStream / getFileSize / retrieveInputStream") + class StreamAndSize { + + @Test + @DisplayName("storeInputStream returns id and exact byte size") + void storeInputStreamReturnsSize() throws IOException { + byte[] payload = "twelve bytes".getBytes(StandardCharsets.UTF_8); + StoredFile stored = + fileStorage.storeInputStream(new ByteArrayInputStream(payload), "in.bin"); + assertThat(stored.fileId()).isNotBlank(); + assertThat(stored.size()).isEqualTo(payload.length); + assertThat(fileStorage.getFileSize(stored.fileId())).isEqualTo(payload.length); + } + + @Test + @DisplayName("retrieveInputStream yields the stored content") + void retrieveInputStreamContent() throws IOException { + byte[] payload = "stream-me".getBytes(StandardCharsets.UTF_8); + String id = fileStorage.storeBytes(payload, "s.bin"); + try (InputStream in = fileStorage.retrieveInputStream(id)) { + assertThat(in.readAllBytes()).isEqualTo(payload); + } + } + } + + @Nested + @DisplayName("storeFile fast path") + class FastPath { + + @Test + @DisplayName("file-backed MultipartFile is stored via the Resource fast path") + void fileBackedResourceStored(@TempDir Path src) throws IOException { + byte[] payload = "file-backed-content".getBytes(StandardCharsets.UTF_8); + Path onDisk = Files.write(src.resolve("upload.pdf"), payload); + + // A MultipartFile whose getResource() reports isFile()=true exercises the + // file-to-file copy branch in storeFile. + MultipartFile multipart = + new MockMultipartFile( + "file", "upload.pdf", MediaType.APPLICATION_PDF_VALUE, payload) { + @Override + public org.springframework.core.io.Resource getResource() { + return new FileSystemResource(onDisk); + } + }; + + String id = fileStorage.storeFile(multipart); + assertThat(id).isNotBlank(); + assertThat(fileStorage.retrieveBytes(id)).isEqualTo(payload); + } + + @Test + @DisplayName("in-memory MultipartFile falls back to the stream copy path") + void inMemoryFallback() throws IOException { + byte[] payload = "memory-content".getBytes(StandardCharsets.UTF_8); + MultipartFile multipart = + new MockMultipartFile( + "file", "m.pdf", MediaType.APPLICATION_PDF_VALUE, payload); + String id = fileStorage.storeFile(multipart); + assertThat(fileStorage.retrieveBytes(id)).isEqualTo(payload); + } + } + + @Nested + @DisplayName("storeFromStreamingBody") + class StreamingBody { + + @Test + @DisplayName("happy path streams body to storage") + void happyPath() throws IOException { + byte[] payload = "streamed-body-bytes".getBytes(StandardCharsets.UTF_8); + StreamingResponseBody body = out -> out.write(payload); + String id = fileStorage.storeFromStreamingBody(body, "body.bin"); + assertThat(fileStorage.retrieveBytes(id)).isEqualTo(payload); + } + + @Test + @DisplayName("writer IOException propagates and leaves no lingering file") + void writerErrorPropagatesAndCleansUp() throws IOException { + long before = countFiles(); + StreamingResponseBody body = + out -> { + out.write("partial".getBytes(StandardCharsets.UTF_8)); + throw new IOException("boom mid-write"); + }; + assertThatThrownBy(() -> fileStorage.storeFromStreamingBody(body, "bad.bin")) + .isInstanceOf(IOException.class); + assertThat(countFiles()).isEqualTo(before); + } + + @Test + @DisplayName("unchecked writer failure is wrapped as IOException") + void uncheckedWriterErrorWrapped() { + StreamingResponseBody body = + out -> { + throw new IllegalStateException("unchecked boom"); + }; + assertThatThrownBy(() -> fileStorage.storeFromStreamingBody(body, "bad2.bin")) + .isInstanceOf(IOException.class); + } + + private long countFiles() throws IOException { + try (var s = Files.list(storageDir)) { + return s.count(); + } + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceMoreTest.java b/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceMoreTest.java new file mode 100644 index 0000000000..9e21d1fb8f --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceMoreTest.java @@ -0,0 +1,492 @@ +package stirling.software.common.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +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.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import stirling.software.common.model.job.JobResponse; +import stirling.software.common.util.ExceptionUtils; + +/** Additional coverage for JobExecutorService branches not exercised by JobExecutorServiceTest. */ +@ExtendWith(MockitoExtension.class) +class JobExecutorServiceMoreTest { + + private JobExecutorService service; + + @Mock private TaskManager taskManager; + @Mock private FileStorage fileStorage; + @Mock private ResourceMonitor resourceMonitor; + @Mock private JobQueue jobQueue; + + @BeforeEach + void setUp() { + // request is null on purpose to exercise the request==null guard. + service = + new JobExecutorService( + taskManager, fileStorage, null, resourceMonitor, jobQueue, 30000L, "30m"); + } + + /** Concrete validation exception so we can drive the BaseValidationException rethrow branch. */ + private static class TestValidationException extends ExceptionUtils.BaseValidationException { + TestValidationException(String message) { + super(message, "E999"); + } + } + + /** Concrete app exception so we can drive the BaseAppException rethrow branch. */ + private static class TestAppException extends ExceptionUtils.BaseAppException { + TestAppException(String message) { + super(message, null, "E998"); + } + } + + /** Bean exposing getFileId/getOriginalFilename/getContentType for the reflection branch. */ + public static class FileIdBean { + private final String fileId; + private final String originalFilename; + private final String contentType; + + FileIdBean(String fileId, String originalFilename, String contentType) { + this.fileId = fileId; + this.originalFilename = originalFilename; + this.contentType = contentType; + } + + public String getFileId() { + return fileId; + } + + public String getOriginalFilename() { + return originalFilename; + } + + public String getContentType() { + return contentType; + } + + @Override + public String toString() { + return "FileIdBean{fileId=" + fileId + "}"; + } + } + + @Nested + @DisplayName("synchronous error mapping") + class SyncErrors { + + @Test + @DisplayName("IllegalArgumentException is rethrown, not wrapped in a 500 body") + void illegalArgumentRethrown() { + Supplier work = + () -> { + throw new IllegalArgumentException("bad input"); + }; + assertThatThrownBy(() -> service.runJobGeneric(false, work)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("bad input"); + } + + @Test + @DisplayName("a cause of BaseValidationException is rethrown") + void validationCauseRethrown() { + Supplier work = + () -> { + throw new RuntimeException(new TestValidationException("invalid")); + }; + assertThatThrownBy(() -> service.runJobGeneric(false, work)) + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(ExceptionUtils.BaseValidationException.class); + } + + @Test + @DisplayName("a cause of BaseAppException is rethrown") + void appCauseRethrown() { + Supplier work = + () -> { + throw new RuntimeException(new TestAppException("app error")); + }; + assertThatThrownBy(() -> service.runJobGeneric(false, work)) + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(ExceptionUtils.BaseAppException.class); + } + } + + @Nested + @DisplayName("synchronous result handling") + class SyncResults { + + @Test + @DisplayName("byte[] result becomes a PDF attachment response") + void byteArrayBecomesAttachment() { + byte[] payload = "pdf-bytes".getBytes(StandardCharsets.UTF_8); + ResponseEntity response = service.runJobGeneric(false, () -> payload); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(payload); + assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_PDF); + assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION)) + .contains("result.pdf"); + } + + @Test + @DisplayName("MultipartFile result is streamed back with its own content type") + void multipartBecomesResponse() { + MultipartFile file = + new MockMultipartFile( + "f", "orig.txt", MediaType.TEXT_PLAIN_VALUE, "hi".getBytes()); + ResponseEntity response = service.runJobGeneric(false, () -> file); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_PLAIN); + assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION)) + .contains("orig.txt"); + } + + @Test + @DisplayName("a ResponseEntity result is returned verbatim") + void responseEntityReturnedVerbatim() { + ResponseEntity inner = ResponseEntity.status(HttpStatus.ACCEPTED).body("ok"); + ResponseEntity response = service.runJobGeneric(false, () -> inner); + assertThat(response).isSameAs(inner); + } + } + + @Nested + @DisplayName("asynchronous error handling") + class AsyncErrors { + + @Test + @DisplayName("a thrown exception is recorded via TaskManager.setError") + void asyncErrorRecorded() { + Supplier work = + () -> { + throw new RuntimeException("async boom"); + }; + ResponseEntity response = service.runJobGeneric(true, work); + assertThat(response.getBody()).isInstanceOf(JobResponse.class); + verify(taskManager, timeout(5000)).setError(anyString(), eq("async boom")); + } + + @Test + @DisplayName("a job that exceeds its timeout is recorded as timed out") + void asyncTimeoutRecorded() { + Supplier work = + () -> { + long start = System.nanoTime(); + while (System.nanoTime() - start < 200_000_000L) { + // busy wait beyond the 1ms timeout + } + return "late"; + }; + // 1ms custom timeout, async, non-queueable. + ResponseEntity response = service.runJobGeneric(true, work, 1L, false, 10); + assertThat(response.getBody()).isInstanceOf(JobResponse.class); + verify(taskManager, timeout(5000)).setError(anyString(), eq("Job timed out")); + } + } + + @Nested + @DisplayName("processJobResult branches (via async execution)") + class ProcessJobResult { + + @Test + @DisplayName("raw byte[] result is stored and recorded as a file") + void rawBytesStored() throws Exception { + byte[] payload = "raw".getBytes(StandardCharsets.UTF_8); + when(fileStorage.storeBytes(any(byte[].class), eq("result.pdf"))) + .thenReturn("bytes-id"); + + service.runJobGeneric(true, () -> payload); + + verify(fileStorage, timeout(5000)).storeBytes(any(byte[].class), eq("result.pdf")); + verify(taskManager, timeout(5000)) + .setFileResult( + anyString(), + eq("bytes-id"), + eq("result.pdf"), + eq(MediaType.APPLICATION_PDF_VALUE)); + verify(taskManager, timeout(5000)).setComplete(anyString()); + } + + @Test + @DisplayName("ResponseEntity is stored with the filename from headers") + void responseEntityBytesStored() throws Exception { + byte[] payload = "rebytes".getBytes(StandardCharsets.UTF_8); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_PDF); + headers.setContentDisposition( + ContentDisposition.formData().name("a").filename("out.pdf").build()); + Supplier work = () -> new ResponseEntity<>(payload, headers, HttpStatus.OK); + when(fileStorage.storeBytes(any(byte[].class), eq("out.pdf"))).thenReturn("re-id"); + + service.runJobGeneric(true, work); + + verify(taskManager, timeout(5000)) + .setFileResult( + anyString(), + eq("re-id"), + eq("out.pdf"), + eq(MediaType.APPLICATION_PDF_VALUE)); + } + + @Test + @DisplayName("ResponseEntity is stored via storeFromStreamingBody") + void responseEntityStreamingStored() throws Exception { + StreamingResponseBody body = out -> out.write("stream".getBytes()); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + Supplier work = () -> new ResponseEntity<>(body, headers, HttpStatus.OK); + when(fileStorage.storeFromStreamingBody(any(StreamingResponseBody.class), anyString())) + .thenReturn("stream-id"); + + service.runJobGeneric(true, work); + + verify(fileStorage, timeout(5000)) + .storeFromStreamingBody(any(StreamingResponseBody.class), eq("result.pdf")); + verify(taskManager, timeout(5000)) + .setFileResult(anyString(), eq("stream-id"), eq("result.pdf"), anyString()); + } + + @Test + @DisplayName("ResponseEntity body exposing getFileId is recorded via reflection") + void responseEntityFileIdBean() { + FileIdBean bean = new FileIdBean("bean-file", "bean.pdf", "text/custom"); + Supplier work = () -> ResponseEntity.ok(bean); + + service.runJobGeneric(true, work); + + verify(taskManager, timeout(5000)) + .setFileResult(anyString(), eq("bean-file"), eq("bean.pdf"), eq("text/custom")); + verify(taskManager, timeout(5000)).setComplete(anyString()); + } + + @Test + @DisplayName("plain ResponseEntity body without fileId is stored as a generic result") + void responseEntityPlainBody() { + Supplier work = () -> ResponseEntity.ok("plain-string"); + + service.runJobGeneric(true, work); + + verify(taskManager, timeout(5000)).setResult(anyString(), eq("plain-string")); + verify(taskManager, timeout(5000)).setComplete(anyString()); + } + + @Test + @DisplayName("MultipartFile result is stored via storeFile") + void multipartStored() throws Exception { + MultipartFile file = + new MockMultipartFile( + "f", "m.pdf", MediaType.APPLICATION_PDF_VALUE, "m".getBytes()); + when(fileStorage.storeFile(any(MultipartFile.class))).thenReturn("mp-id"); + + service.runJobGeneric(true, () -> file); + + verify(taskManager, timeout(5000)) + .setFileResult( + anyString(), + eq("mp-id"), + eq("m.pdf"), + eq(MediaType.APPLICATION_PDF_VALUE)); + } + + @Test + @DisplayName("plain object result exposing getFileId is recorded via reflection") + void plainObjectFileIdBean() { + FileIdBean bean = new FileIdBean("plain-bean", "p.pdf", "app/p"); + + service.runJobGeneric(true, () -> bean); + + verify(taskManager, timeout(5000)) + .setFileResult(anyString(), eq("plain-bean"), eq("p.pdf"), eq("app/p")); + } + + @Test + @DisplayName("a generic non-file object is stored via setResult") + void genericObjectStored() { + service.runJobGeneric(true, () -> "just-text"); + verify(taskManager, timeout(5000)).setResult(anyString(), eq("just-text")); + } + + @Test + @DisplayName("a storage failure is recorded as an error on the task") + void storageFailureRecordsError() throws Exception { + byte[] payload = "x".getBytes(StandardCharsets.UTF_8); + when(fileStorage.storeBytes(any(byte[].class), anyString())) + .thenThrow(new java.io.IOException("disk full")); + + service.runJobGeneric(true, () -> payload); + + verify(taskManager, timeout(5000)) + .setError(anyString(), org.mockito.ArgumentMatchers.contains("disk full")); + } + } + + @Nested + @DisplayName("queued execution") + class QueuedExecution { + + @Test + @DisplayName("queued wrapped work stores its result through processJobResult on success") + void queuedWorkSuccess() { + when(resourceMonitor.shouldQueueJob(80)).thenReturn(true); + // Capture the wrapped supplier so we can run it as the queue would. + ArgumentCaptor> workCaptor = ArgumentCaptor.forClass(Supplier.class); + when(jobQueue.queueJob(anyString(), eq(80), workCaptor.capture(), anyLong())) + .thenReturn(new CompletableFuture<>()); + + ResponseEntity response = + service.runJobGeneric(true, () -> "queued-ok", 5000, true, 80); + assertThat(response.getBody()).isInstanceOf(JobResponse.class); + + // Execute the wrapped work and assert it routed the result to TaskManager. + Object result = workCaptor.getValue().get(); + assertThat(result).isEqualTo("queued-ok"); + verify(taskManager).setResult(anyString(), eq("queued-ok")); + verify(taskManager).setComplete(anyString()); + } + + @Test + @DisplayName("queued wrapped work records and rethrows on failure") + void queuedWorkFailure() { + when(resourceMonitor.shouldQueueJob(80)).thenReturn(true); + ArgumentCaptor> workCaptor = ArgumentCaptor.forClass(Supplier.class); + when(jobQueue.queueJob(anyString(), eq(80), workCaptor.capture(), anyLong())) + .thenReturn(new CompletableFuture<>()); + + Supplier failing = + () -> { + throw new RuntimeException("queued-boom"); + }; + service.runJobGeneric(true, failing, 5000, true, 80); + + assertThatThrownBy(() -> workCaptor.getValue().get()) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("queued-boom"); + verify(taskManager).setError(anyString(), eq("queued-boom")); + } + + @Test + @DisplayName("a job is not queued when it is synchronous even if queueable") + void syncJobNeverQueued() { + // queueable=true but async=false -> shouldQueue is false, runs inline. + ResponseEntity response = service.runJobGeneric(false, () -> "inline", 0, true, 90); + assertThat(response.getBody()).isEqualTo("inline"); + verify(jobQueue, org.mockito.Mockito.never()) + .queueJob(anyString(), anyInt(), any(), anyLong()); + } + } + + @Nested + @DisplayName("job ownership scoping") + class JobOwnership { + + @Test + @DisplayName("scoped job key and owner come from JobOwnershipService when present") + void scopedKeyUsed() { + JobOwnershipService ownership = org.mockito.Mockito.mock(JobOwnershipService.class); + when(ownership.createScopedJobKey(anyString())).thenReturn("user1:scoped"); + lenient().when(ownership.getCurrentUserId()).thenReturn(Optional.of("user1")); + ReflectionTestUtils.setField(service, "jobOwnershipService", ownership); + + ResponseEntity response = service.runJobGeneric(true, () -> "owned"); + JobResponse jobResponse = (JobResponse) response.getBody(); + assertThat(jobResponse.getJobId()).isEqualTo("user1:scoped"); + verify(taskManager).createTask("user1:scoped"); + } + } + + @Nested + @DisplayName("session timeout parsing") + class SessionTimeoutParsing { + + private long parse(String value) { + JobExecutorService s = + new JobExecutorService( + taskManager, + fileStorage, + null, + resourceMonitor, + jobQueue, + 999_999_999L, + value); + return (long) ReflectionTestUtils.getField(s, "effectiveTimeoutMs"); + } + + @Test + @DisplayName("seconds, hours and days units are parsed") + void parsesUnits() { + assertThat(parse("45s")).isEqualTo(45_000L); + assertThat(parse("2h")).isEqualTo(2L * 60 * 60 * 1000); + assertThat(parse("1d")).isEqualTo(24L * 60 * 60 * 1000); + } + + @Test + @DisplayName("an unrecognised unit defaults to minutes") + void unknownUnitDefaultsToMinutes() { + assertThat(parse("5x")).isEqualTo(5L * 60 * 1000); + } + + @Test + @DisplayName("null/empty and unparseable values fall back to 30 minutes") + void fallbackToThirtyMinutes() { + long thirtyMin = 30L * 60 * 1000; + assertThat(parse("")).isEqualTo(thirtyMin); + assertThat(parse("garbage")).isEqualTo(thirtyMin); + } + } + + @Nested + @DisplayName("sync timeout") + class SyncTimeout { + + @Test + @DisplayName("a synchronous job that exceeds its timeout returns a 500 with a timeout body") + void syncTimeoutReturns500() { + Supplier work = + () -> { + long start = System.nanoTime(); + while (System.nanoTime() - start < 200_000_000L) { + // busy wait beyond 1ms timeout + } + return "late"; + }; + ResponseEntity response = service.runJobGeneric(false, work, 1L); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + @SuppressWarnings("unchecked") + Map body = (Map) response.getBody(); + assertThat(body.get("error")).contains("timed out"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/JobQueueMoreTest.java b/app/common/src/test/java/stirling/software/common/service/JobQueueMoreTest.java new file mode 100644 index 0000000000..edf21d516c --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/JobQueueMoreTest.java @@ -0,0 +1,410 @@ +package stirling.software.common.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.service.ResourceMonitor.ResourceStatus; + +/** Additional coverage for JobQueue branches not exercised by JobQueueTest. */ +@ExtendWith(MockitoExtension.class) +class JobQueueMoreTest { + + private JobQueue jobQueue; + + @Mock private ResourceMonitor resourceMonitor; + + private final AtomicReference statusRef = + new AtomicReference<>(ResourceStatus.OK); + + @BeforeEach + void setUp() { + lenient() + .when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt())) + .thenReturn(10); + lenient().when(resourceMonitor.getCurrentStatus()).thenReturn(statusRef); + jobQueue = new JobQueue(resourceMonitor); + } + + private void invokeProcessQueue() { + ReflectionTestUtils.invokeMethod(jobQueue, "processQueue"); + } + + // Bounded wait: block up to 5s for the queued job's future to settle on the executor. + private static void awaitDone(CompletableFuture future) { + try { + future.handle((r, e) -> null).get(5, TimeUnit.SECONDS); + } catch (Exception e) { + throw new AssertionError("future did not complete within 5s", e); + } + } + + @Nested + @DisplayName("SmartLifecycle") + class Lifecycle { + + @Test + @DisplayName("start/stop toggles running and start is idempotent") + void startStopToggle() { + assertThat(jobQueue.isRunning()).isFalse(); + + jobQueue.start(); + assertThat(jobQueue.isRunning()).isTrue(); + + // Second start is a no-op (already running). + jobQueue.start(); + assertThat(jobQueue.isRunning()).isTrue(); + + jobQueue.stop(); + assertThat(jobQueue.isRunning()).isFalse(); + } + + @Test + @DisplayName("phase and auto-startup expose lifecycle ordering") + void phaseAndAutoStartup() { + assertThat(jobQueue.getPhase()).isEqualTo(10); + assertThat(jobQueue.isAutoStartup()).isTrue(); + } + + @Test + @DisplayName("stop completes any still-pending futures exceptionally") + void stopCompletesPendingFutures() { + CompletableFuture> future = + jobQueue.queueJob("pending", 50, () -> "x", 1000); + assertThat(future.isDone()).isFalse(); + + // Drive shutdown without starting the scheduler so no processor races us to the job. + jobQueue.stop(); + + assertThat(future).isCompletedExceptionally(); + } + } + + @Nested + @DisplayName("queueJob capacity") + class QueueCapacity { + + @Test + @DisplayName("rejects a job when the queue is full") + void rejectsWhenFull() { + // Capacity-1 queue whose timed offer rejects instantly (no 5s block) when full. + BlockingQueue instaReject = + new LinkedBlockingQueue<>(1) { + @Override + public boolean offer(Object e, long timeout, TimeUnit unit) { + return super.offer(e); + } + }; + ReflectionTestUtils.setField(jobQueue, "jobQueue", instaReject); + jobQueue.queueJob("first", 50, () -> "a", 1000); + + CompletableFuture> rejected = + jobQueue.queueJob("second", 50, () -> "b", 1000); + + assertThat(rejected).isCompletedExceptionally(); + assertThat(jobQueue.getRejectedJobs()).isEqualTo(1); + assertThat(jobQueue.isJobQueued("second")).isFalse(); + } + + @Test + @DisplayName("getQueueCapacity reflects remaining capacity plus current size") + void getQueueCapacityReports() { + ReflectionTestUtils.setField(jobQueue, "jobQueue", new LinkedBlockingQueue<>(5)); + jobQueue.queueJob("c1", 50, () -> "a", 1000); + assertThat(jobQueue.getQueueCapacity()).isEqualTo(5); + } + } + + @Nested + @DisplayName("job position") + class JobPosition { + + @Test + @DisplayName("returns 0 for the first queued job and -1 for an unknown job") + void positionAndUnknown() { + jobQueue.queueJob("p1", 50, () -> "a", 1000); + jobQueue.queueJob("p2", 50, () -> "b", 1000); + + assertThat(jobQueue.getJobPosition("p1")).isEqualTo(0); + assertThat(jobQueue.getJobPosition("p2")).isEqualTo(1); + assertThat(jobQueue.getJobPosition("missing")).isEqualTo(-1); + } + } + + @Nested + @DisplayName("cancelJob") + class CancelJob { + + @Test + @DisplayName("returns false when the job id is unknown") + void cancelUnknownReturnsFalse() { + assertThat(jobQueue.cancelJob("nope")).isFalse(); + } + } + + @Nested + @DisplayName("processQueue") + class ProcessQueue { + + @Test + @DisplayName("does nothing when shutting down") + void noopWhenShuttingDown() { + jobQueue.queueJob("s1", 50, () -> "a", 1000); + ReflectionTestUtils.setField(jobQueue, "shuttingDown", true); + + invokeProcessQueue(); + + // Still queued: the shutdown guard returned before polling. + assertThat(jobQueue.isJobQueued("s1")).isTrue(); + } + + @Test + @DisplayName("delays execution while the system is under critical load") + void delaysUnderCriticalLoad() { + statusRef.set(ResourceStatus.CRITICAL); + jobQueue.queueJob("crit", 50, () -> "a", 1000); + + invokeProcessQueue(); + + // Critical load: job remains queued, nothing executed. + assertThat(jobQueue.isJobQueued("crit")).isTrue(); + } + + @Test + @DisplayName("executes a queued job and completes its future when resources are OK") + void executesWhenOk() { + statusRef.set(ResourceStatus.OK); + CompletableFuture> future = + jobQueue.queueJob("ok", 50, () -> "done", 5000); + + invokeProcessQueue(); + + awaitDone(future); + assertThat(jobQueue.isJobQueued("ok")).isFalse(); + assertThat(future).isCompleted(); + } + + @Test + @DisplayName("a job past the max wait time still executes and adds a timeout note") + void overdueJobExecutesAndNotes() { + statusRef.set(ResourceStatus.OK); + ReflectionTestUtils.setField(jobQueue, "maxWaitTimeMs", 1L); + CompletableFuture> future = + jobQueue.queueJob("overdue", 50, () -> "late-done", 5000); + + // Backdate the queuedAt so wait-time exceeds maxWaitTimeMs. + backdateQueuedAt("overdue"); + + invokeProcessQueue(); + + awaitDone(future); + assertThat(future).isCompleted(); + } + + @SuppressWarnings("unchecked") + private void backdateQueuedAt(String jobId) { + var jobMap = + (java.util.Map) + ReflectionTestUtils.getField(jobQueue, "jobMap"); + Object job = jobMap.get(jobId); + ReflectionTestUtils.setField(job, "queuedAt", Instant.now().minusSeconds(60)); + } + } + + @Nested + @DisplayName("executeJob") + class ExecuteJob { + + @Test + @DisplayName("a cancelled job is skipped by executeJob without running its work") + @SuppressWarnings("unchecked") + void cancelledJobSkipped() throws Exception { + java.util.concurrent.atomic.AtomicBoolean ran = + new java.util.concurrent.atomic.AtomicBoolean(false); + CompletableFuture> future = + jobQueue.queueJob( + "cancelled", + 50, + () -> { + ran.set(true); + return "should-not-run"; + }, + 1000); + + // Grab the real QueuedJob instance, mark it cancelled, then drive executeJob directly. + var jobMap = + (java.util.Map) + ReflectionTestUtils.getField(jobQueue, "jobMap"); + Object job = jobMap.get("cancelled"); + ReflectionTestUtils.setField(job, "cancelled", true); + + var executeJob = JobQueue.class.getDeclaredMethod("executeJob", job.getClass()); + executeJob.setAccessible(true); + executeJob.invoke(jobQueue, job); + + // The early return means the work supplier never ran. + assertThat(ran.get()).isFalse(); + assertThat(future.isDone()).isFalse(); + } + + @Test + @DisplayName("a non-ResponseEntity result is wrapped in ResponseEntity.ok") + void nonResponseEntityWrapped() { + statusRef.set(ResourceStatus.OK); + CompletableFuture> future = + jobQueue.queueJob("wrap", 50, () -> "plain", 5000); + + invokeProcessQueue(); + + awaitDone(future); + ResponseEntity response = future.join(); + assertThat(response.getBody()).isEqualTo("plain"); + } + + @Test + @DisplayName("a ResponseEntity result is forwarded as-is") + void responseEntityForwarded() { + statusRef.set(ResourceStatus.OK); + ResponseEntity inner = ResponseEntity.ok("inner"); + CompletableFuture> future = + jobQueue.queueJob("forward", 50, () -> inner, 5000); + + invokeProcessQueue(); + + awaitDone(future); + assertThat(future.join()).isSameAs(inner); + } + + @Test + @DisplayName("a failing job completes its future exceptionally") + void failingJobCompletesExceptionally() { + statusRef.set(ResourceStatus.OK); + Supplier failing = + () -> { + throw new RuntimeException("exec-boom"); + }; + CompletableFuture> future = + jobQueue.queueJob("fail", 50, failing, 5000); + + invokeProcessQueue(); + + awaitDone(future); + assertThat(future).isCompletedExceptionally(); + } + } + + @Nested + @DisplayName("executeWithTimeout") + class ExecuteWithTimeout { + + @Test + @DisplayName("with no timeout it joins and returns the value") + void noTimeoutJoins() { + Object result = + ReflectionTestUtils.invokeMethod( + jobQueue, "executeWithTimeout", (Supplier) () -> "joined", 0L); + assertThat(result).isEqualTo("joined"); + } + + @Test + @DisplayName("an execution failure is unwrapped to its cause") + void executionFailureUnwrapped() { + Supplier failing = + () -> { + throw new IllegalStateException("inner-cause"); + }; + Throwable thrown = + org.junit.jupiter.api.Assertions.assertThrows( + Throwable.class, + () -> + ReflectionTestUtils.invokeMethod( + jobQueue, "executeWithTimeout", failing, 1000L)); + assertThat(messageChain(thrown)).contains("inner-cause"); + } + + @Test + @DisplayName("a slow job exceeds the timeout and throws TimeoutException") + void slowJobTimesOut() { + Supplier slow = + () -> { + long start = System.nanoTime(); + while (System.nanoTime() - start < 200_000_000L) { + // busy wait beyond 1ms + } + return "late"; + }; + Throwable thrown = + org.junit.jupiter.api.Assertions.assertThrows( + Throwable.class, + () -> + ReflectionTestUtils.invokeMethod( + jobQueue, "executeWithTimeout", slow, 1L)); + assertThat(messageChain(thrown)).contains("timed out"); + } + + // Spring's ReflectionTestUtils wraps checked exceptions, so inspect the whole cause chain. + private String messageChain(Throwable t) { + StringBuilder sb = new StringBuilder(); + for (Throwable c = t; c != null; c = c.getCause()) { + if (c.getMessage() != null) { + sb.append(c.getMessage()).append('|'); + } + } + return sb.toString(); + } + } + + @Nested + @DisplayName("updateQueueCapacity") + class UpdateQueueCapacity { + + @Test + @DisplayName("resizes the queue and preserves queued jobs when capacity changes") + void resizesQueue() { + ReflectionTestUtils.setField(jobQueue, "jobQueue", new LinkedBlockingQueue<>(10)); + jobQueue.queueJob("keep", 50, () -> "a", 1000); + + // Force a new, smaller capacity on the next recalculation. + when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt())).thenReturn(4); + + ReflectionTestUtils.invokeMethod(jobQueue, "updateQueueCapacity"); + + assertThat(jobQueue.getQueueCapacity()).isEqualTo(4); + // The previously queued job survived the drain into the new queue. + assertThat(jobQueue.getCurrentQueueSize()).isEqualTo(1); + } + } + + @Nested + @DisplayName("getQueueStats") + class QueueStats { + + @Test + @DisplayName("includes the current resource status name") + void includesResourceStatus() { + statusRef.set(ResourceStatus.WARNING); + var stats = jobQueue.getQueueStats(); + assertThat(stats.get("resourceStatus")).isEqualTo("WARNING"); + assertThat(stats).containsKeys("queuedJobs", "queueCapacity", "rejectedJobs"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/ResourceMonitorMoreTest.java b/app/common/src/test/java/stirling/software/common/service/ResourceMonitorMoreTest.java new file mode 100644 index 0000000000..9fddeb6728 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/ResourceMonitorMoreTest.java @@ -0,0 +1,259 @@ +package stirling.software.common.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.lang.management.MemoryMXBean; +import java.lang.management.MemoryUsage; +import java.lang.management.OperatingSystemMXBean; +import java.time.Instant; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicReference; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.service.ResourceMonitor.ResourceMetrics; +import stirling.software.common.service.ResourceMonitor.ResourceStatus; + +/** Additional coverage for ResourceMonitor branches not exercised by ResourceMonitorTest. */ +@ExtendWith(MockitoExtension.class) +class ResourceMonitorMoreTest { + + private ResourceMonitor resourceMonitor; + + @Mock private OperatingSystemMXBean osMXBean; + @Mock private MemoryMXBean memoryMXBean; + @Mock private MemoryUsage heapUsage; + @Mock private MemoryUsage nonHeapUsage; + + private final AtomicReference currentStatus = + new AtomicReference<>(ResourceStatus.OK); + private final AtomicReference latestMetrics = + new AtomicReference<>(new ResourceMetrics()); + + @BeforeEach + void setUp() { + resourceMonitor = new ResourceMonitor(); + ReflectionTestUtils.setField(resourceMonitor, "memoryCriticalThreshold", 0.9); + ReflectionTestUtils.setField(resourceMonitor, "memoryHighThreshold", 0.75); + ReflectionTestUtils.setField(resourceMonitor, "cpuCriticalThreshold", 0.9); + ReflectionTestUtils.setField(resourceMonitor, "cpuHighThreshold", 0.75); + ReflectionTestUtils.setField(resourceMonitor, "osMXBean", osMXBean); + ReflectionTestUtils.setField(resourceMonitor, "memoryMXBean", memoryMXBean); + ReflectionTestUtils.setField(resourceMonitor, "currentStatus", currentStatus); + ReflectionTestUtils.setField(resourceMonitor, "latestMetrics", latestMetrics); + } + + private void stubMemory(long heapUsed, long nonHeapUsed) { + lenient().when(heapUsage.getUsed()).thenReturn(heapUsed); + lenient().when(nonHeapUsage.getUsed()).thenReturn(nonHeapUsed); + lenient().when(memoryMXBean.getHeapMemoryUsage()).thenReturn(heapUsage); + lenient().when(memoryMXBean.getNonHeapMemoryUsage()).thenReturn(nonHeapUsage); + } + + @Nested + @DisplayName("updateResourceMetrics status transitions") + class UpdateMetrics { + + @Test + @DisplayName("high CPU load drives the status to CRITICAL") + void criticalOnHighCpu() { + // load average / processors = 4 / 2 = 2.0 -> well over critical threshold. + when(osMXBean.getSystemLoadAverage()).thenReturn(4.0); + when(osMXBean.getAvailableProcessors()).thenReturn(2); + stubMemory(1L, 1L); + + ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics"); + + assertThat(currentStatus.get()).isEqualTo(ResourceStatus.CRITICAL); + assertThat(latestMetrics.get().getCpuUsage()).isEqualTo(2.0); + } + + @Test + @DisplayName("moderately high CPU load drives the status to WARNING") + void warningOnModerateCpu() { + // 1.6 / 2 = 0.8 -> above high (0.75) but below critical (0.9). + when(osMXBean.getSystemLoadAverage()).thenReturn(1.6); + when(osMXBean.getAvailableProcessors()).thenReturn(2); + stubMemory(1L, 1L); + + ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics"); + + assertThat(currentStatus.get()).isEqualTo(ResourceStatus.WARNING); + } + + @Test + @DisplayName("low load keeps the status at OK") + void okOnLowLoad() { + when(osMXBean.getSystemLoadAverage()).thenReturn(0.2); + when(osMXBean.getAvailableProcessors()).thenReturn(4); + stubMemory(1L, 1L); + currentStatus.set(ResourceStatus.WARNING); // ensure a transition log path is hit + + ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics"); + + assertThat(currentStatus.get()).isEqualTo(ResourceStatus.OK); + } + + @Test + @DisplayName("a negative load average triggers the alternative CPU fallback") + void negativeLoadUsesFallback() { + // getSystemLoadAverage returns -1 on platforms (e.g. Windows) where it is unsupported. + when(osMXBean.getSystemLoadAverage()).thenReturn(-1.0); + when(osMXBean.getAvailableProcessors()).thenReturn(4); + stubMemory(1L, 1L); + + ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics"); + + // The mock OS bean has no getProcessCpuLoad/getSystemCpuLoad, so fallback yields 0.5. + assertThat(latestMetrics.get().getCpuUsage()).isEqualTo(0.5); + assertThat(currentStatus.get()).isEqualTo(ResourceStatus.OK); + } + + @Test + @DisplayName("an exception while sampling is swallowed and status is unchanged") + void samplingExceptionSwallowed() { + when(osMXBean.getSystemLoadAverage()).thenReturn(0.1); + when(osMXBean.getAvailableProcessors()).thenReturn(2); + when(memoryMXBean.getHeapMemoryUsage()) + .thenThrow(new RuntimeException("jmx unavailable")); + currentStatus.set(ResourceStatus.OK); + + // Must not propagate; the catch in updateResourceMetrics handles it. + ReflectionTestUtils.invokeMethod(resourceMonitor, "updateResourceMetrics"); + + assertThat(currentStatus.get()).isEqualTo(ResourceStatus.OK); + } + } + + @Nested + @DisplayName("getAlternativeCpuLoad") + class AlternativeCpuLoad { + + @Test + @DisplayName("uses getProcessCpuLoad via reflection when present") + void usesProcessCpuLoad() { + // A bean exposing getProcessCpuLoad lets the reflective fallback return its value. + OperatingSystemMXBean withCpuLoad = new OsBeanWithProcessCpuLoad(0.42); + ReflectionTestUtils.setField(resourceMonitor, "osMXBean", withCpuLoad); + + double load = + (double) + ReflectionTestUtils.invokeMethod( + resourceMonitor, "getAlternativeCpuLoad"); + assertThat(load).isEqualTo(0.42); + } + + @Test + @DisplayName("defaults to 0.5 when no CPU-load method is available") + void defaultsWhenUnavailable() { + double load = + (double) + ReflectionTestUtils.invokeMethod( + resourceMonitor, "getAlternativeCpuLoad"); + assertThat(load).isEqualTo(0.5); + } + } + + @Nested + @DisplayName("calculateDynamicQueueCapacity memory pressure") + class MemoryPressure { + + @Test + @DisplayName("high memory usage halves the computed capacity") + void highMemoryHalvesCapacity() { + currentStatus.set(ResourceStatus.OK); + // memoryUsage > 0.8 triggers the additional 0.5 multiplier. + latestMetrics.set(new ResourceMetrics(0.1, 0.85, 1, 1, 1, Instant.now())); + + int capacity = resourceMonitor.calculateDynamicQueueCapacity(10, 2); + // OK factor 1.0 * 0.5 = 0.5; ceil(10 * 0.5) = 5. + assertThat(capacity).isEqualTo(5); + } + } + + @Nested + @DisplayName("ResourceMetrics") + class Metrics { + + @Test + @DisplayName("getAge returns a non-negative duration") + void getAgeNonNegative() { + ResourceMetrics m = new ResourceMetrics(0, 0, 0, 0, 0, Instant.now().minusSeconds(1)); + assertThat(m.getAge().toMillis()).isGreaterThanOrEqualTo(1000L); + } + } + + @Nested + @DisplayName("lifecycle") + class Lifecycle { + + @Test + @DisplayName("initialize schedules sampling and shutdown stops the scheduler") + void initializeAndShutdown() { + // Real bean so initialize() schedules against a live virtual-thread scheduler. + ResourceMonitor live = new ResourceMonitor(); + ReflectionTestUtils.setField(live, "monitorIntervalMs", 60000L); + live.initialize(); + + ScheduledExecutorService scheduler = + (ScheduledExecutorService) ReflectionTestUtils.getField(live, "scheduler"); + assertThat(scheduler.isShutdown()).isFalse(); + + live.shutdown(); + assertThat(scheduler.isShutdown()).isTrue(); + } + } + + /** Minimal OS bean stub exposing getProcessCpuLoad so the reflective fallback can find it. */ + private static class OsBeanWithProcessCpuLoad implements OperatingSystemMXBean { + private final double cpuLoad; + + OsBeanWithProcessCpuLoad(double cpuLoad) { + this.cpuLoad = cpuLoad; + } + + // Reflectively located by getAlternativeCpuLoad. + public double getProcessCpuLoad() { + return cpuLoad; + } + + @Override + public String getName() { + return "stub"; + } + + @Override + public String getArch() { + return "stub"; + } + + @Override + public String getVersion() { + return "stub"; + } + + @Override + public int getAvailableProcessors() { + return 1; + } + + @Override + public double getSystemLoadAverage() { + return -1.0; + } + + @Override + public javax.management.ObjectName getObjectName() { + return null; + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/SsrfProtectionServiceTest.java b/app/common/src/test/java/stirling/software/common/service/SsrfProtectionServiceTest.java new file mode 100644 index 0000000000..c53429a1ae --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/SsrfProtectionServiceTest.java @@ -0,0 +1,307 @@ +package stirling.software.common.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +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.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.Html.UrlSecurity; +import stirling.software.common.service.SsrfProtectionService.SsrfProtectionLevel; + +class SsrfProtectionServiceTest { + + private ApplicationProperties applicationProperties; + private UrlSecurity config; + private SsrfProtectionService service; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + // Real config object: drill down to the live UrlSecurity instance and mutate it. + config = applicationProperties.getSystem().getHtml().getUrlSecurity(); + service = new SsrfProtectionService(applicationProperties); + } + + @Nested + @DisplayName("Protection disabled / always-allowed inputs") + class AlwaysAllowed { + + @Test + @DisplayName("returns true for any URL when protection disabled") + void disabledAllowsEverything() { + config.setEnabled(false); + assertThat(service.isUrlAllowed("http://169.254.169.254/latest/meta-data")).isTrue(); + assertThat(service.isUrlAllowed("http://127.0.0.1")).isTrue(); + assertThat(service.isUrlAllowed("not a url")).isTrue(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t"}) + @DisplayName("returns false for null/blank when enabled") + void blankRejected(String url) { + config.setEnabled(true); + assertThat(service.isUrlAllowed(url)).isFalse(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "data:text/plain;base64,SGVsbG8=", + "DATA:image/png;base64,iVBOR", + "#section", + "#" + }) + @DisplayName("data: URLs and fragments are always allowed") + void dataAndFragmentAllowed(String url) { + config.setEnabled(true); + config.setLevel(SsrfProtectionLevel.MAX); + assertThat(service.isUrlAllowed(url)).isTrue(); + } + } + + @Nested + @DisplayName("OFF level") + class OffLevel { + + @Test + @DisplayName("allows external and internal hosts alike") + void offAllowsAll() { + config.setEnabled(true); + config.setLevel(SsrfProtectionLevel.OFF); + assertThat(service.isUrlAllowed("http://10.0.0.1/secret")).isTrue(); + assertThat(service.isUrlAllowed("https://example.com")).isTrue(); + } + } + + @Nested + @DisplayName("MAX level - allowlist only") + class MaxLevel { + + @BeforeEach + void max() { + config.setEnabled(true); + config.setLevel(SsrfProtectionLevel.MAX); + } + + @Test + @DisplayName("allows only whitelisted hosts (case-insensitive)") + void allowsWhitelistedHost() { + config.setAllowedDomains(List.of("example.com")); + assertThat(service.isUrlAllowed("https://EXAMPLE.com/path")).isTrue(); + assertThat(service.isUrlAllowed("https://other.com")).isFalse(); + } + + @Test + @DisplayName("blocks when allowlist is empty") + void emptyAllowlistBlocks() { + assertThat(service.isUrlAllowed("https://example.com")).isFalse(); + } + + @Test + @DisplayName("blocks URL with no host") + void noHostBlocked() { + config.setAllowedDomains(List.of("example.com")); + assertThat(service.isUrlAllowed("file:///etc/passwd")).isFalse(); + } + + @Test + @DisplayName("blocks malformed URL (parse exception path)") + void malformedBlocked() { + config.setAllowedDomains(List.of("example.com")); + assertThat(service.isUrlAllowed("http://exa mple.com")).isFalse(); + } + } + + @Nested + @DisplayName("MEDIUM level - host parsing and lists") + class MediumHostAndLists { + + @BeforeEach + void medium() { + config.setEnabled(true); + config.setLevel(SsrfProtectionLevel.MEDIUM); + } + + @Test + @DisplayName("allows a normal public literal IP") + void allowsPublicIp() { + assertThat(service.isUrlAllowed("http://93.184.216.34/page")).isTrue(); + } + + @Test + @DisplayName("blocks URL with no host") + void noHostBlocked() { + assertThat(service.isUrlAllowed("mailto:test@example.com")).isFalse(); + } + + @Test + @DisplayName("blocks malformed URL (parse exception path)") + void malformedBlocked() { + assertThat(service.isUrlAllowed("ht!tp://%%%")).isFalse(); + } + + @Test + @DisplayName("blocks explicitly blocked domain (case-insensitive)") + void blockedDomain() { + config.setBlockedDomains(List.of("evil.com")); + assertThat(service.isUrlAllowed("http://EVIL.com")).isFalse(); + } + + @Test + @DisplayName("blocks internal TLD suffixes") + void internalTld() { + // default internalTlds include .local, .internal, .corp, .home + assertThat(service.isUrlAllowed("http://server.local")).isFalse(); + assertThat(service.isUrlAllowed("http://host.internal")).isFalse(); + } + + @Test + @DisplayName("allowlist present: host not in list is blocked before any DNS lookup") + void allowlistRejectsUnlisted() { + // notexample.com is rejected by the allowlist check, which runs before DNS resolution, + // so this stays deterministic offline. + config.setAllowedDomains(List.of("example.com")); + assertThat(service.isUrlAllowed("http://notexample.com")).isFalse(); + } + + @Test + @DisplayName("allowlist present: exact host and subdomain pass the allowlist gate") + void allowlistAcceptsExactAndSubdomain() { + // Allow a literal IP so the subsequent DNS resolution is the identity and network + // checks are disabled, keeping the allow path deterministic without external DNS. + config.setBlockPrivateNetworks(false); + config.setBlockLocalhost(false); + config.setBlockLinkLocal(false); + config.setBlockCloudMetadata(false); + config.setAllowedDomains(List.of("93.184.216.34")); + assertThat(service.isUrlAllowed("http://93.184.216.34")).isTrue(); + } + } + + @Nested + @DisplayName("MEDIUM level - network based blocking via literal IPs") + class MediumNetworkBlocking { + + @BeforeEach + void medium() { + config.setEnabled(true); + config.setLevel(SsrfProtectionLevel.MEDIUM); + } + + @Test + @DisplayName("blocks loopback when blockLocalhost enabled") + void blocksLoopback() { + assertThat(service.isUrlAllowed("http://127.0.0.1/admin")).isFalse(); + } + + @Test + @DisplayName("allows loopback when blockLocalhost disabled and private/link checks off") + void allowsLoopbackWhenAllChecksOff() { + config.setBlockLocalhost(false); + config.setBlockPrivateNetworks(false); + config.setBlockLinkLocal(false); + config.setBlockCloudMetadata(false); + assertThat(service.isUrlAllowed("http://127.0.0.1/ok")).isTrue(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "http://10.1.2.3", + "http://192.168.0.5", + "http://172.16.0.9", + "http://172.31.255.1", + "http://100.64.0.1" + }) + @DisplayName("blocks RFC1918 / CGNAT private ranges") + void blocksPrivateRanges(String url) { + assertThat(service.isUrlAllowed(url)).isFalse(); + } + + @Test + @DisplayName("172.x and 100.x outside private sub-range are not private") + void boundaryRangesNotPrivate() { + // 172.15/172.32 outside 16-31; 100.63/100.128 outside 64-127. + assertThat(service.isUrlAllowed("http://172.15.0.1")).isTrue(); + assertThat(service.isUrlAllowed("http://172.32.0.1")).isTrue(); + assertThat(service.isUrlAllowed("http://100.63.0.1")).isTrue(); + } + + @Test + @DisplayName("allows private range when blockPrivateNetworks disabled") + void allowsPrivateWhenDisabled() { + config.setBlockPrivateNetworks(false); + config.setBlockLocalhost(false); + assertThat(service.isUrlAllowed("http://10.1.2.3")).isTrue(); + } + + @Test + @DisplayName("blocks link-local 169.254.x via private-network check") + void blocksLinkLocal() { + assertThat(service.isUrlAllowed("http://169.254.1.1")).isFalse(); + } + + @Test + @DisplayName("blocks AWS cloud-metadata IP 169.254.169.254") + void blocksCloudMetadata() { + assertThat(service.isUrlAllowed("http://169.254.169.254/latest/meta-data/")).isFalse(); + } + + @Test + @DisplayName("blocks unspecified address 0.0.0.0") + void blocksUnspecified() { + assertThat(service.isUrlAllowed("http://0.0.0.0")).isFalse(); + } + + @Test + @DisplayName("blocks unresolvable host (UnknownHostException path)") + void blocksUnresolvableHost() { + assertThat(service.isUrlAllowed("http://nonexistent-host-stirling-test.invalid/page")) + .isFalse(); + } + } + + @Nested + @DisplayName("MEDIUM level - IPv6 literal handling") + class MediumIpv6 { + + @BeforeEach + void medium() { + config.setEnabled(true); + config.setLevel(SsrfProtectionLevel.MEDIUM); + } + + @Test + @DisplayName("blocks IPv6 loopback ::1") + void blocksIpv6Loopback() { + assertThat(service.isUrlAllowed("http://[::1]/path")).isFalse(); + } + + @Test + @DisplayName("blocks IPv6 unique-local fc00::/7") + void blocksIpv6UniqueLocal() { + assertThat(service.isUrlAllowed("http://[fc00::1]")).isFalse(); + } + + @Test + @DisplayName("blocks IPv6 link-local fe80::/10") + void blocksIpv6LinkLocal() { + assertThat(service.isUrlAllowed("http://[fe80::1]")).isFalse(); + } + + @Test + @DisplayName("blocks IPv4-mapped IPv6 of a private address") + void blocksIpv4MappedPrivate() { + assertThat(service.isUrlAllowed("http://[::ffff:10.0.0.1]")).isFalse(); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/TaskManagerMoreTest.java b/app/common/src/test/java/stirling/software/common/service/TaskManagerMoreTest.java new file mode 100644 index 0000000000..0a02039ab3 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/TaskManagerMoreTest.java @@ -0,0 +1,365 @@ +package stirling.software.common.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.AfterEach; +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.cluster.ClusterBackplane; +import stirling.software.common.cluster.JobStore; +import stirling.software.common.model.job.JobResult; +import stirling.software.common.model.job.JobStats; +import stirling.software.common.model.job.ResultFile; + +/** Additional coverage for TaskManager branches not exercised by TaskManagerTest. */ +class TaskManagerMoreTest { + + @Mock private FileStorage fileStorage; + @Mock private JobStore jobStore; + @Mock private ClusterBackplane clusterBackplane; + + @InjectMocks private TaskManager taskManager; + + private AutoCloseable closeable; + + @BeforeEach + void setUp() { + closeable = MockitoAnnotations.openMocks(this); + lenient().when(clusterBackplane.localNodeId()).thenReturn("test-node"); + lenient().when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(true); + ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30); + } + + @AfterEach + void tearDown() throws Exception { + closeable.close(); + } + + private static byte[] buildZip(String... entryNames) throws Exception { + var baos = new java.io.ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + for (String name : entryNames) { + zos.putNextEntry(new ZipEntry(name)); + zos.write(("content-of-" + name).getBytes()); + zos.closeEntry(); + } + } + return baos.toByteArray(); + } + + @Nested + @DisplayName("setFileResult ZIP handling") + class ZipHandling { + + @Test + @DisplayName("extracts a ZIP into individual file results and deletes the original") + void extractsZipIntoIndividualFiles() throws Exception { + String jobId = "zip-job"; + taskManager.createTask(jobId); + + byte[] zipBytes = buildZip("a.pdf", "b.txt"); + when(fileStorage.retrieveInputStream("zip-file-id")) + .thenReturn(new ByteArrayInputStream(zipBytes)); + // Each extracted entry is stored, returning a distinct StoredFile. + when(fileStorage.storeInputStream(any(InputStream.class), anyString())) + .thenReturn(new FileStorage.StoredFile("extracted-a", 11L)) + .thenReturn(new FileStorage.StoredFile("extracted-b", 22L)); + when(fileStorage.deleteFile("zip-file-id")).thenReturn(true); + + taskManager.setFileResult(jobId, "zip-file-id", "bundle.zip", "application/zip"); + + JobResult result = taskManager.getJobResult(jobId); + assertThat(result.isComplete()).isTrue(); + assertThat(result.hasMultipleFiles()).isTrue(); + assertThat(result.getAllResultFiles()).hasSize(2); + // Content type is derived from the entry extension, not the ZIP content type. + assertThat(result.getAllResultFiles().get(0).getContentType()) + .isEqualTo(MediaType.APPLICATION_PDF_VALUE); + assertThat(result.getAllResultFiles().get(1).getContentType()) + .isEqualTo(MediaType.TEXT_PLAIN_VALUE); + verify(fileStorage).deleteFile("zip-file-id"); + } + + @Test + @DisplayName("empty ZIP falls back to a single-file result") + void emptyZipFallsBackToSingleFile() throws Exception { + String jobId = "empty-zip-job"; + taskManager.createTask(jobId); + + byte[] emptyZip = buildZip(); + when(fileStorage.retrieveInputStream("empty-zip-id")) + .thenReturn(new ByteArrayInputStream(emptyZip)); + when(fileStorage.getFileSize("empty-zip-id")).thenReturn(7L); + + taskManager.setFileResult(jobId, "empty-zip-id", "empty.zip", "application/zip"); + + JobResult result = taskManager.getJobResult(jobId); + assertThat(result.hasMultipleFiles()).isFalse(); + assertThat(result.getAllResultFiles()).hasSize(1); + assertThat(result.getAllResultFiles().get(0).getFileId()).isEqualTo("empty-zip-id"); + } + + @Test + @DisplayName("ZIP extraction failure falls back to a single-file result") + void zipExtractionFailureFallsBackToSingleFile() throws Exception { + String jobId = "bad-zip-job"; + taskManager.createTask(jobId); + + // retrieveInputStream throws so extractZipToIndividualFiles fails and we fall back. + when(fileStorage.retrieveInputStream("bad-zip-id")) + .thenThrow(new java.io.IOException("boom")); + when(fileStorage.getFileSize("bad-zip-id")).thenReturn(99L); + + taskManager.setFileResult( + jobId, "bad-zip-id", "broken.zip", "application/x-zip-compressed"); + + JobResult result = taskManager.getJobResult(jobId); + assertThat(result.hasFiles()).isTrue(); + assertThat(result.getAllResultFiles().get(0).getFileId()).isEqualTo("bad-zip-id"); + } + } + + @Nested + @DisplayName("setFileResult size fallback") + class SizeFallback { + + @Test + @DisplayName("uses size 0 when getFileSize throws for a non-zip file") + void usesZeroSizeWhenGetFileSizeThrows() throws Exception { + String jobId = "size-fail-job"; + taskManager.createTask(jobId); + when(fileStorage.getFileSize("file-x")).thenThrow(new java.io.IOException("no stat")); + + taskManager.setFileResult(jobId, "file-x", "doc.pdf", MediaType.APPLICATION_PDF_VALUE); + + JobResult result = taskManager.getJobResult(jobId); + assertThat(result.getAllResultFiles().get(0).getFileSize()).isZero(); + } + } + + @Nested + @DisplayName("setMultipleFileResults") + class MultipleFileResults { + + @Test + @DisplayName("stores the provided list directly") + void storesProvidedList() { + String jobId = "multi-job"; + taskManager.createTask(jobId); + List files = + List.of( + ResultFile.builder().fileId("f1").fileName("1.pdf").build(), + ResultFile.builder().fileId("f2").fileName("2.pdf").build()); + + taskManager.setMultipleFileResults(jobId, files); + + JobResult result = taskManager.getJobResult(jobId); + assertThat(result.hasMultipleFiles()).isTrue(); + assertThat(result.getAllResultFiles()).hasSize(2); + } + } + + @Nested + @DisplayName("getJobStats edge cases") + class StatsEdgeCases { + + @Test + @DisplayName("empty manager reports zero average processing time") + void emptyManagerZeroAverage() { + JobStats stats = taskManager.getJobStats(); + assertThat(stats.getTotalJobs()).isZero(); + assertThat(stats.getAverageProcessingTimeMs()).isZero(); + assertThat(stats.getOldestActiveJobTime()).isNull(); + } + + @Test + @DisplayName("accumulates processing time across multiple completed jobs") + void accumulatesProcessingTime() { + taskManager.createTask("c1"); + taskManager.setResult("c1", "r1"); + taskManager.createTask("c2"); + taskManager.setResult("c2", "r2"); + + JobStats stats = taskManager.getJobStats(); + assertThat(stats.getCompletedJobs()).isEqualTo(2); + assertThat(stats.getSuccessfulJobs()).isEqualTo(2); + assertThat(stats.getAverageProcessingTimeMs()).isGreaterThanOrEqualTo(0); + } + } + + @Nested + @DisplayName("findResultFileByFileId") + class FindResultFile { + + @Test + @DisplayName("returns matching ResultFile metadata") + void returnsMatch() throws Exception { + taskManager.createTask("rf-job"); + when(fileStorage.getFileSize("target")).thenReturn(5L); + taskManager.setFileResult("rf-job", "target", "t.pdf", MediaType.APPLICATION_PDF_VALUE); + + ResultFile found = taskManager.findResultFileByFileId("target"); + assertThat(found).isNotNull(); + assertThat(found.getFileId()).isEqualTo("target"); + } + + @Test + @DisplayName("returns null when no job owns the file id") + void returnsNullWhenAbsent() { + assertThat(taskManager.findResultFileByFileId("nope")).isNull(); + } + } + + @Nested + @DisplayName("findJobKeyByFileId") + class FindJobKey { + + @Test + @DisplayName("returns the local job key when a job owns the file id") + void returnsLocalKey() throws Exception { + taskManager.createTask("owner-job"); + when(fileStorage.getFileSize("owned")).thenReturn(3L); + taskManager.setFileResult( + "owner-job", "owned", "o.pdf", MediaType.APPLICATION_PDF_VALUE); + + assertThat(taskManager.findJobKeyByFileId("owned")).isEqualTo("owner-job"); + // Local hit must not consult the JobStore. + verify(jobStore, never()).findJobIdByFileId(anyString()); + } + + @Test + @DisplayName("returns null when JobStore also has no match") + void returnsNullWhenJobStoreEmpty() { + when(jobStore.findJobIdByFileId("ghost")).thenReturn(Optional.empty()); + assertThat(taskManager.findJobKeyByFileId("ghost")).isNull(); + } + + @Test + @DisplayName("propagates JobStore lookup failures instead of returning null") + void propagatesJobStoreFailure() { + when(jobStore.findJobIdByFileId("blip")) + .thenThrow(new RuntimeException("backplane down")); + assertThatThrownBy(() -> taskManager.findJobKeyByFileId("blip")) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("backplane down"); + } + } + + @Nested + @DisplayName("cleanupOldJobs resilience") + class CleanupResilience { + + @Test + @DisplayName("continues when a file deletion throws during cleanup") + void continuesWhenDeleteThrows() throws Exception { + String jobId = "old-file-job"; + taskManager.createTask(jobId); + JobResult job = taskManager.getJobResult(jobId); + ResultFile rf = + ResultFile.builder() + .fileId("doomed") + .fileName("d.pdf") + .contentType(MediaType.APPLICATION_PDF_VALUE) + .fileSize(1L) + .build(); + ReflectionTestUtils.setField(job, "resultFiles", List.of(rf)); + ReflectionTestUtils.setField(job, "complete", true); + ReflectionTestUtils.setField(job, "completedAt", LocalDateTime.now().minusHours(2)); + + when(fileStorage.deleteFile("doomed")).thenThrow(new RuntimeException("locked")); + + // Must not propagate; the job is still removed afterwards. + taskManager.cleanupOldJobs(); + + @SuppressWarnings("unchecked") + Map map = + (Map) + ReflectionTestUtils.getField(taskManager, "jobResults"); + assertThat(map).doesNotContainKey(jobId); + } + } + + @Nested + @DisplayName("write-through failures") + class WriteThroughFailures { + + @Test + @DisplayName("a JobStore put failure does not break createTask") + void putFailureSwallowed() { + org.mockito.Mockito.doThrow(new RuntimeException("store offline")) + .when(jobStore) + .put(any(), any()); + // createTask -> writeThrough; the RuntimeException is caught and logged. + taskManager.createTask("wt-job"); + assertThat(taskManager.getJobResult("wt-job")).isNotNull(); + } + } + + @Nested + @DisplayName("toEntry mapping") + class ToEntryMapping { + + @Test + @DisplayName("a failed job maps to FAILED state in the JobStore entry") + void failedJobMapsToFailedState() { + taskManager.createTask("fail-job"); + taskManager.setError("fail-job", "kaboom"); + + var captor = + org.mockito.ArgumentCaptor.forClass( + stirling.software.common.cluster.JobStoreEntry.class); + verify(jobStore, org.mockito.Mockito.atLeastOnce()).put(captor.capture(), any()); + assertThat(captor.getValue().jobId()).isEqualTo("fail-job"); + assertThat(captor.getAllValues()) + .anySatisfy( + e -> + assertThat(e.state()) + .isEqualTo( + stirling.software.common.cluster.JobStoreEntry + .JobState.FAILED)); + } + } + + @Nested + @DisplayName("addNote write-through") + class AddNoteWriteThrough { + + @Test + @DisplayName("note is reflected in JobStore entry metadata") + void noteWritesMetadata() { + taskManager.createTask("note-job"); + assertThat(taskManager.addNote("note-job", "hello")).isTrue(); + + var captor = + org.mockito.ArgumentCaptor.forClass( + stirling.software.common.cluster.JobStoreEntry.class); + verify(jobStore, org.mockito.Mockito.atLeastOnce()).put(captor.capture(), any()); + assertThat(captor.getAllValues()) + .anySatisfy(e -> assertThat(e.resultMeta()).containsKey("notesCount")); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceMoreTest.java b/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceMoreTest.java new file mode 100644 index 0000000000..802dc2eace --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceMoreTest.java @@ -0,0 +1,379 @@ +package stirling.software.common.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.HashSet; +import java.util.Set; +import java.util.function.Consumer; + +import org.junit.jupiter.api.AfterEach; +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.io.TempDir; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; + +/** Additional coverage for TempFileCleanupService branches not exercised by the base test. */ +class TempFileCleanupServiceMoreTest { + + @TempDir Path tempDir; + + @Mock private TempFileRegistry registry; + @Mock private TempFileManager tempFileManager; + @Mock private ApplicationProperties applicationProperties; + @Mock private ApplicationProperties.System system; + @Mock private ApplicationProperties.TempFileManagement tempFileManagement; + + @InjectMocks private TempFileCleanupService cleanupService; + + private Path systemTempDir; + private Path customTempDir; + private Path libreOfficeTempDir; + + private AutoCloseable closeable; + + @BeforeEach + void setUp() throws IOException { + closeable = MockitoAnnotations.openMocks(this); + + systemTempDir = tempDir.resolve("systemTemp"); + customTempDir = tempDir.resolve("customTemp"); + libreOfficeTempDir = tempDir.resolve("libreOfficeTemp"); + Files.createDirectories(systemTempDir); + Files.createDirectories(customTempDir); + Files.createDirectories(libreOfficeTempDir); + + lenient().when(applicationProperties.getSystem()).thenReturn(system); + lenient().when(system.getTempFileManagement()).thenReturn(tempFileManagement); + lenient().when(tempFileManagement.getBaseTmpDir()).thenReturn(customTempDir.toString()); + lenient() + .when(tempFileManagement.getLibreofficeDir()) + .thenReturn(libreOfficeTempDir.toString()); + lenient().when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString()); + lenient().when(tempFileManagement.isStartupCleanup()).thenReturn(false); + lenient().when(tempFileManagement.isCleanupSystemTemp()).thenReturn(false); + + ReflectionTestUtils.setField(cleanupService, "machineType", "Standard"); + lenient().when(tempFileManager.getMaxAgeMillis()).thenReturn(3600000L); + } + + @AfterEach + void tearDown() throws Exception { + closeable.close(); + } + + private static void backdate(Path file, long millisAgo) throws IOException { + Files.setLastModifiedTime( + file, FileTime.fromMillis(System.currentTimeMillis() - millisAgo)); + } + + @Nested + @DisplayName("isContainerMode") + class ContainerMode { + + @Test + @DisplayName("Docker and Kubernetes are container modes; others are not") + void detectsContainerMachineTypes() { + ReflectionTestUtils.setField(cleanupService, "machineType", "Docker"); + assertThat( + (Boolean) + ReflectionTestUtils.invokeMethod( + cleanupService, "isContainerMode")) + .isTrue(); + ReflectionTestUtils.setField(cleanupService, "machineType", "Kubernetes"); + assertThat( + (Boolean) + ReflectionTestUtils.invokeMethod( + cleanupService, "isContainerMode")) + .isTrue(); + ReflectionTestUtils.setField(cleanupService, "machineType", "Standard"); + assertThat( + (Boolean) + ReflectionTestUtils.invokeMethod( + cleanupService, "isContainerMode")) + .isFalse(); + } + } + + @Nested + @DisplayName("getSystemTempPath") + class SystemTempPath { + + @Test + @DisplayName("uses the configured system temp dir when set") + void usesConfiguredDir() { + when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString()); + Path path = + (Path) ReflectionTestUtils.invokeMethod(cleanupService, "getSystemTempPath"); + assertThat(path).isEqualTo(systemTempDir); + } + + @Test + @DisplayName("falls back to java.io.tmpdir when unset") + void fallsBackToJavaTmpDir() { + when(tempFileManagement.getSystemTempDir()).thenReturn(""); + Path path = + (Path) ReflectionTestUtils.invokeMethod(cleanupService, "getSystemTempPath"); + assertThat(path).isEqualTo(Path.of(System.getProperty("java.io.tmpdir"))); + } + } + + @Nested + @DisplayName("init") + class Init { + + @Test + @DisplayName("creates configured temp directories that do not yet exist") + void createsMissingDirectories() { + Path newBase = tempDir.resolve("newBase"); + Path newLo = tempDir.resolve("newLo"); + when(tempFileManagement.getBaseTmpDir()).thenReturn(newBase.toString()); + when(tempFileManagement.getLibreofficeDir()).thenReturn(newLo.toString()); + when(tempFileManagement.isStartupCleanup()).thenReturn(false); + + cleanupService.init(); + + assertThat(Files.exists(newBase)).isTrue(); + assertThat(Files.exists(newLo)).isTrue(); + } + + @Test + @DisplayName("runs startup cleanup when enabled") + void runsStartupCleanupWhenEnabled() throws IOException { + when(tempFileManagement.isStartupCleanup()).thenReturn(true); + when(registry.contains(any(File.class))).thenReturn(false); + + // An old stirling temp file in the custom dir should be removed by startup cleanup. + Path stale = Files.createFile(customTempDir.resolve("stirling-pdf-stale.tmp")); + backdate(stale, 48L * 60 * 60 * 1000); // 48h old, beyond non-container 24h cutoff + + cleanupService.init(); + + assertThat(Files.exists(stale)).isFalse(); + } + } + + @Nested + @DisplayName("scheduledCleanup") + class ScheduledCleanup { + + @Test + @DisplayName("deletes registered temp directories and reports counts") + void deletesRegisteredDirectories() throws IOException { + when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(2); + Path regDir = Files.createDirectories(tempDir.resolve("registeredDir")); + Files.createFile(regDir.resolve("inside.txt")); + Set dirs = new HashSet<>(); + dirs.add(regDir); + when(registry.getTempDirectories()).thenReturn(dirs); + lenient().when(registry.contains(any(File.class))).thenReturn(false); + + withIsolatedUserHome(cleanupService::scheduledCleanup); + + // The registered directory was removed by GeneralUtils.deleteDirectory. + assertThat(Files.exists(regDir)).isFalse(); + verify(tempFileManager).cleanupOldTempFiles(anyLong()); + } + + @Test + @DisplayName("skips a registered directory that no longer exists") + void skipsMissingRegisteredDirectory() { + when(tempFileManager.cleanupOldTempFiles(anyLong())).thenReturn(0); + Set dirs = new HashSet<>(); + dirs.add(tempDir.resolve("ghostDir")); + when(registry.getTempDirectories()).thenReturn(dirs); + lenient().when(registry.contains(any(File.class))).thenReturn(false); + + // No exception even though the directory does not exist. + withIsolatedUserHome(cleanupService::scheduledCleanup); + verify(registry).getTempDirectories(); + } + } + + @Nested + @DisplayName("cleanupUnregisteredFiles system-temp inclusion") + class CleanupUnregistered { + + @Test + @DisplayName("includes the system temp dir when cleanupSystemTemp is enabled") + void includesSystemTempDir() throws Exception { + when(tempFileManagement.isCleanupSystemTemp()).thenReturn(true); + when(tempFileManagement.getSystemTempDir()).thenReturn(systemTempDir.toString()); + when(registry.contains(any(File.class))).thenReturn(false); + + // Old stirling file in the system temp dir should be deleted in container mode. + Path stale = Files.createFile(systemTempDir.resolve("stirling-pdf-sys.tmp")); + backdate(stale, 2L * 60 * 60 * 1000); // 2h old + + int deleted = + (int) + ReflectionTestUtils.invokeMethod( + cleanupService, + "cleanupUnregisteredFiles", + true, + true, + 3600000L); + + assertThat(deleted).isGreaterThanOrEqualTo(1); + assertThat(Files.exists(stale)).isFalse(); + } + } + + @Nested + @DisplayName("registered-file skip and recursion depth") + class RegistryAndDepth { + + @Test + @DisplayName("a registered file is never deleted") + void registeredFilePreserved() throws Exception { + Path registered = Files.createFile(systemTempDir.resolve("output_registered.pdf")); + backdate(registered, 2L * 60 * 60 * 1000); + // The registry reports the file as registered, so cleanup must skip it. + when(registry.contains(any(File.class))).thenReturn(true); + + invokeCleanupDirectoryStreaming(systemTempDir, 0, false, 3600000L); + + assertThat(Files.exists(registered)).isTrue(); + } + + @Test + @DisplayName("recursion stops once the maximum depth is exceeded") + void recursionDepthGuard() throws Exception { + // Starting beyond MAX_RECURSION_DEPTH (5) returns immediately without listing. + Path deepFile = Files.createFile(systemTempDir.resolve("output_deep.pdf")); + backdate(deepFile, 2L * 60 * 60 * 1000); + lenient().when(registry.contains(any(File.class))).thenReturn(false); + + invokeCleanupDirectoryStreaming(systemTempDir, 6, false, 3600000L); + + // Depth guard hit: the file was not visited or deleted. + assertThat(Files.exists(deepFile)).isTrue(); + } + } + + @Nested + @DisplayName("cleanupLibreOfficeTempFiles") + class LibreOfficeCleanup { + + @Test + @DisplayName("clears contents of registered libreoffice directories but keeps the dir") + void clearsLibreOfficeContents() throws IOException { + Path loDir = Files.createDirectories(tempDir.resolve("libreoffice-conv")); + Path inside = Files.createFile(loDir.resolve("output_lo.pdf")); + Set dirs = new HashSet<>(); + dirs.add(loDir); + when(registry.getTempDirectories()).thenReturn(dirs); + when(registry.contains(any(File.class))).thenReturn(false); + + cleanupService.cleanupLibreOfficeTempFiles(); + + // The file is removed (age ignored), directory itself remains. + assertThat(Files.exists(inside)).isFalse(); + assertThat(Files.exists(loDir)).isTrue(); + } + + @Test + @DisplayName("ignores registered directories that are not libreoffice dirs") + void ignoresNonLibreOfficeDirs() throws IOException { + Path other = Files.createDirectories(tempDir.resolve("other-dir")); + Path keep = Files.createFile(other.resolve("output_keep.pdf")); + Set dirs = new HashSet<>(); + dirs.add(other); + when(registry.getTempDirectories()).thenReturn(dirs); + + cleanupService.cleanupLibreOfficeTempFiles(); + + // Not a libreoffice dir, so its contents are untouched. + assertThat(Files.exists(keep)).isTrue(); + } + } + + @Nested + @DisplayName("cleanupPDFBoxCache") + class PdfBoxCache { + + @Test + @DisplayName("deletes an existing .pdfbox.cache file in the user home") + void deletesCacheFile() throws IOException { + Path fakeHome = Files.createDirectories(tempDir.resolve("home")); + Path cache = Files.createFile(fakeHome.resolve(".pdfbox.cache")); + + String oldHome = System.getProperty("user.home"); + try { + System.setProperty("user.home", fakeHome.toString()); + ReflectionTestUtils.invokeMethod(cleanupService, "cleanupPDFBoxCache"); + assertThat(Files.exists(cache)).isFalse(); + } finally { + System.setProperty("user.home", oldHome); + } + } + + @Test + @DisplayName("is a no-op when no cache file exists") + void noOpWhenNoCache() throws IOException { + Path fakeHome = Files.createDirectories(tempDir.resolve("home2")); + String oldHome = System.getProperty("user.home"); + try { + System.setProperty("user.home", fakeHome.toString()); + // No exception when the cache file is absent. + ReflectionTestUtils.invokeMethod(cleanupService, "cleanupPDFBoxCache"); + assertThat(Files.exists(fakeHome.resolve(".pdfbox.cache"))).isFalse(); + } finally { + System.setProperty("user.home", oldHome); + } + } + } + + // Point user.home at a throwaway dir so the real ~/.pdfbox.cache is never touched. + private void withIsolatedUserHome(Runnable action) { + String oldHome = System.getProperty("user.home"); + try { + Path fakeHome = Files.createDirectories(tempDir.resolve("isolated-home")); + System.setProperty("user.home", fakeHome.toString()); + action.run(); + } catch (IOException e) { + throw new RuntimeException(e); + } finally { + System.setProperty("user.home", oldHome); + } + } + + private void invokeCleanupDirectoryStreaming( + Path directory, int depth, boolean containerMode, long maxAgeMillis) { + try { + Consumer noop = p -> {}; + var method = + TempFileCleanupService.class.getDeclaredMethod( + "cleanupDirectoryStreaming", + Path.class, + boolean.class, + int.class, + long.class, + boolean.class, + Consumer.class); + method.setAccessible(true); + method.invoke( + cleanupService, directory, containerMode, depth, maxAgeMillis, false, noop); + } catch (Exception e) { + throw new RuntimeException("Error invoking cleanupDirectoryStreaming", e); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/CbrUtilsMoreTest.java b/app/common/src/test/java/stirling/software/common/util/CbrUtilsMoreTest.java new file mode 100644 index 0000000000..96de6e697e --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/CbrUtilsMoreTest.java @@ -0,0 +1,120 @@ +package stirling.software.common.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +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.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfMetadataService; + +/** + * Gap-filling tests for {@link CbrUtils#convertCbrToPdf}. junrar cannot parse synthetic RAR data, + * so these exercise the archive-open failure branches (corrupt header / invalid format) by feeding + * non-RAR bytes through a real {@link CustomPDFDocumentFactory} and {@link TempFileManager}. No + * external tool is launched. + */ +class CbrUtilsMoreTest { + + private TempFileManager tempFileManager; + private CustomPDFDocumentFactory factory; + + @TempDir Path tempDir; + + @BeforeEach + void setUp() { + ApplicationProperties props = new ApplicationProperties(); + props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString()); + props.getSystem().getTempFileManagement().setPrefix("test-cbr-"); + tempFileManager = new TempFileManager(new TempFileRegistry(), props); + factory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class)); + } + + private static MultipartFile cbr(String filename, byte[] bytes) { + return new MockMultipartFile("file", filename, "application/x-cbr", bytes); + } + + @Nested + @DisplayName("convertCbrToPdf - invalid archives") + class InvalidArchiveTests { + + @Test + @DisplayName("non-RAR bytes in a .cbr file are rejected as an invalid archive") + void nonRarContentCbr() { + byte[] junk = "this is not a rar archive at all".getBytes(StandardCharsets.UTF_8); + assertThatThrownBy( + () -> + CbrUtils.convertCbrToPdf( + cbr("comic.cbr", junk), factory, tempFileManager)) + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("non-RAR bytes in a .rar file are rejected as an invalid archive") + void nonRarContentRar() { + byte[] junk = new byte[] {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}; + assertThatThrownBy( + () -> + CbrUtils.convertCbrToPdf( + cbr("archive.rar", junk), factory, tempFileManager)) + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("bytes carrying the RAR signature but no valid body are rejected") + void rarSignatureOnly() { + // "Rar!\x1A\x07\x00" is the classic RAR4 signature; body is missing/garbage. + byte[] data = {0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55}; + assertThatThrownBy( + () -> + CbrUtils.convertCbrToPdf( + cbr("comic.cbr", data), factory, tempFileManager)) + .isInstanceOf(Exception.class); + } + } + + @Nested + @DisplayName("convertCbrToPdf - validation overload") + class ValidationTests { + + @Test + @DisplayName("the 3-arg overload delegates and still validates the extension") + void threeArgOverloadValidatesExtension() { + MultipartFile wrong = cbr("document.pdf", "x".getBytes(StandardCharsets.UTF_8)); + assertThatThrownBy(() -> CbrUtils.convertCbrToPdf(wrong, factory, tempFileManager)) + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("an empty .cbr file is rejected before archive parsing") + void emptyFile() { + MultipartFile empty = cbr("comic.cbr", new byte[0]); + assertThatThrownBy(() -> CbrUtils.convertCbrToPdf(empty, factory, tempFileManager)) + .isInstanceOf(Exception.class); + } + } + + @Nested + @DisplayName("isCbrFile additional branches") + class IsCbrFileTests { + + @Test + @DisplayName("a .zip file is not a CBR") + void zipIsNotCbr() { + MultipartFile file = mock(MultipartFile.class); + org.mockito.Mockito.when(file.getOriginalFilename()).thenReturn("bundle.zip"); + assertThat(CbrUtils.isCbrFile(file)).isFalse(); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/CbzUtilsMoreTest.java b/app/common/src/test/java/stirling/software/common/util/CbzUtilsMoreTest.java new file mode 100644 index 0000000000..c98c60a0c7 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/CbzUtilsMoreTest.java @@ -0,0 +1,204 @@ +package stirling.software.common.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import javax.imageio.ImageIO; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +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.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfMetadataService; + +/** + * Gap-filling tests for {@link CbzUtils#convertCbzToPdf} that build real in-memory CBZ (ZIP) + * archives containing real PNG images and convert them with a real {@link + * CustomPDFDocumentFactory}. No external process is launched (optimizeForEbook is left off so + * Ghostscript is never invoked). + */ +class CbzUtilsMoreTest { + + private TempFileManager tempFileManager; + private CustomPDFDocumentFactory factory; + + @TempDir Path tempDir; + + @BeforeEach + void setUp() { + ApplicationProperties props = new ApplicationProperties(); + props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString()); + props.getSystem().getTempFileManagement().setPrefix("test-cbz-"); + tempFileManager = new TempFileManager(new TempFileRegistry(), props); + factory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class)); + } + + private static byte[] pngBytes(Color color) throws IOException { + BufferedImage img = new BufferedImage(20, 20, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setColor(color); + g.fillRect(0, 0, 20, 20); + g.dispose(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "PNG", baos); + return baos.toByteArray(); + } + + /** Build a CBZ (ZIP) from name->bytes entries. */ + private static byte[] buildCbz(String[] names, byte[][] contents) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + for (int i = 0; i < names.length; i++) { + zos.putNextEntry(new ZipEntry(names[i])); + if (contents[i] != null) { + zos.write(contents[i]); + } + zos.closeEntry(); + } + } + return baos.toByteArray(); + } + + private static MultipartFile cbz(byte[] bytes) { + return new MockMultipartFile("file", "comic.cbz", "application/x-cbz", bytes); + } + + @Nested + @DisplayName("convertCbzToPdf - happy path") + class HappyPathTests { + + @Test + @DisplayName("a CBZ with two images converts to a two-page PDF, sorted by natural order") + void twoImagesToPdf() throws Exception { + byte[] archive = + buildCbz( + new String[] {"page2.png", "page10.png", "page1.png"}, + new byte[][] { + pngBytes(Color.RED), pngBytes(Color.GREEN), pngBytes(Color.BLUE) + }); + + try (TempFile resultPdf = + CbzUtils.convertCbzToPdf(cbz(archive), factory, tempFileManager, false)) { + assertThat(resultPdf.exists()).isTrue(); + try (PDDocument doc = Loader.loadPDF(resultPdf.getFile())) { + assertThat(doc.getNumberOfPages()).isEqualTo(3); + } + } + } + + @Test + @DisplayName("non-image entries are ignored, only images become pages") + void mixedEntries() throws Exception { + byte[] archive = + buildCbz( + new String[] {"readme.txt", "cover.png"}, + new byte[][] { + "notes".getBytes(StandardCharsets.UTF_8), pngBytes(Color.CYAN) + }); + + try (TempFile resultPdf = + CbzUtils.convertCbzToPdf(cbz(archive), factory, tempFileManager, false)) { + try (PDDocument doc = Loader.loadPDF(resultPdf.getFile())) { + assertThat(doc.getNumberOfPages()).isEqualTo(1); + } + } + } + } + + @Nested + @DisplayName("convertCbzToPdf - invalid archives") + class InvalidArchiveTests { + + @Test + @DisplayName("an empty ZIP (no entries) is rejected") + void emptyArchive() throws Exception { + byte[] archive = buildCbz(new String[] {}, new byte[][] {}); + assertThatThrownBy( + () -> + CbzUtils.convertCbzToPdf( + cbz(archive), factory, tempFileManager, false)) + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("a ZIP with no image entries is rejected as 'no images'") + void noImageEntries() throws Exception { + byte[] archive = + buildCbz( + new String[] {"a.txt", "b.json"}, + new byte[][] { + "x".getBytes(StandardCharsets.UTF_8), + "{}".getBytes(StandardCharsets.UTF_8) + }); + assertThatThrownBy( + () -> + CbzUtils.convertCbzToPdf( + cbz(archive), factory, tempFileManager, false)) + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("non-ZIP bytes are rejected as an invalid CBZ format") + void corruptArchive() { + byte[] notAZip = "this is definitely not a zip file".getBytes(StandardCharsets.UTF_8); + assertThatThrownBy( + () -> + CbzUtils.convertCbzToPdf( + cbz(notAZip), factory, tempFileManager, false)) + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("a CBZ whose only image is corrupt produces no pages and is rejected") + void corruptImageProducesNoPages() throws Exception { + byte[] archive = + buildCbz( + new String[] {"broken.png"}, + new byte[][] {"not a real png".getBytes(StandardCharsets.UTF_8)}); + assertThatThrownBy( + () -> + CbzUtils.convertCbzToPdf( + cbz(archive), factory, tempFileManager, false)) + .isInstanceOf(Exception.class); + } + } + + @Nested + @DisplayName("@TempDir cleanup") + class CleanupTests { + + @Test + @DisplayName("the returned TempFile lives under the configured temp dir and closes cleanly") + void tempFileCleanup() throws Exception { + byte[] archive = + buildCbz(new String[] {"p.png"}, new byte[][] {pngBytes(Color.MAGENTA)}); + + TempFile resultPdf = + CbzUtils.convertCbzToPdf(cbz(archive), factory, tempFileManager, false); + Path path = resultPdf.getPath(); + assertThat(Files.exists(path)).isTrue(); + resultPdf.close(); + assertThat(Files.exists(path)).isFalse(); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/EmlParserMoreTest.java b/app/common/src/test/java/stirling/software/common/util/EmlParserMoreTest.java new file mode 100644 index 0000000000..aa2b1bc2ab --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/EmlParserMoreTest.java @@ -0,0 +1,270 @@ +package stirling.software.common.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.time.ZonedDateTime; +import java.util.Base64; +import java.util.Locale; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.api.converters.EmlToPdfRequest; +import stirling.software.common.util.EmlParser.EmailAttachment; +import stirling.software.common.util.EmlParser.EmailContent; + +/** + * Gap-filling tests for {@link EmlParser#extractEmailContent} driven by small real .eml strings. + * These exercise the content-building, recipient-formatting and attachment-mapping branches plus + * the nested {@link EmailContent}/{@link EmailAttachment} value types. No network or external tool. + */ +class EmlParserMoreTest { + + private static final String TS = "Mon, 01 Jan 2024 12:00:00 +0000"; + + private static byte[] eml(String content) { + return content.getBytes(StandardCharsets.UTF_8); + } + + private static EmlToPdfRequest requestWithAttachments(int maxMb) { + EmlToPdfRequest request = new EmlToPdfRequest(); + request.setIncludeAttachments(true); + request.setMaxAttachmentSizeMB(maxMb); + return request; + } + + private static String simpleText(String from, String to, String subject, String body) { + return String.format( + Locale.ROOT, + "From: %s\nTo: %s\nSubject: %s\nDate: %s\n" + + "Content-Type: text/plain; charset=UTF-8\n" + + "Content-Transfer-Encoding: 8bit\n\n%s", + from, + to, + subject, + TS, + body); + } + + private static String multipartWithAttachment( + String boundary, String body, String filename, String attachmentContent) { + String encoded = + Base64.getEncoder() + .encodeToString(attachmentContent.getBytes(StandardCharsets.UTF_8)); + return String.format( + Locale.ROOT, + "From: a@example.com\nTo: b@example.com\nCc: c@example.com\n" + + "Subject: Multipart\nDate: %s\n" + + "Content-Type: multipart/mixed; boundary=\"%s\"\n\n" + + "--%s\nContent-Type: text/plain; charset=UTF-8\n" + + "Content-Transfer-Encoding: 8bit\n\n%s\n\n" + + "--%s\nContent-Type: text/plain; charset=UTF-8\n" + + "Content-Disposition: attachment; filename=\"%s\"\n" + + "Content-Transfer-Encoding: base64\n\n%s\n\n--%s--", + TS, + boundary, + boundary, + body, + boundary, + filename, + encoded, + boundary); + } + + @Nested + @DisplayName("extractEmailContent - headers and bodies") + class HeaderTests { + + @Test + @DisplayName("subject, from, to and plain-text body are extracted") + void plainTextEmail() throws Exception { + EmailContent content = + EmlParser.extractEmailContent( + eml( + simpleText( + "sender@example.com", + "recipient@example.com", + "Hello Subject", + "Body line one")), + null, + null); + + assertThat(content.getSubject()).isEqualTo("Hello Subject"); + assertThat(content.getFrom()).contains("sender@example.com"); + assertThat(content.getTo()).contains("recipient@example.com"); + assertThat(content.getTextBody()).contains("Body line one"); + } + + @Test + @DisplayName("the sent date is parsed into a UTC ZonedDateTime") + void parsesDate() throws Exception { + EmailContent content = + EmlParser.extractEmailContent( + eml(simpleText("a@x.com", "b@x.com", "Dated", "hi")), null, null); + ZonedDateTime date = content.getDate(); + assertThat(date).isNotNull(); + assertThat(date.getYear()).isEqualTo(2024); + } + + @Test + @DisplayName("an HTML body is captured as the html body") + void htmlBodyCaptured() throws Exception { + String html = + String.format( + Locale.ROOT, + "From: a@x.com\nTo: b@x.com\nSubject: HtmlMail\nDate: %s\n" + + "Content-Type: text/html; charset=UTF-8\n" + + "Content-Transfer-Encoding: 8bit\n\n" + + "

Rich

", + TS); + + EmailContent content = EmlParser.extractEmailContent(eml(html), null, null); + assertThat(content.getHtmlBody()).contains("Rich"); + } + } + + @Nested + @DisplayName("extractEmailContent - attachments") + class AttachmentTests { + + @Test + @DisplayName("attachment metadata is mapped and CC recipients are formatted") + void attachmentMappedAndCc() throws Exception { + EmailContent content = + EmlParser.extractEmailContent( + eml( + multipartWithAttachment( + "----b1", + "see attached", + "notes.txt", + "attachment payload")), + requestWithAttachments(10), + null); + + assertThat(content.getCc()).contains("c@example.com"); + assertThat(content.getAttachmentCount()).isGreaterThanOrEqualTo(1); + EmailAttachment att = content.getAttachments().get(0); + assertThat(att.getFilename()).isEqualTo("notes.txt"); + assertThat(att.getData()).isNotNull(); + } + + @Test + @DisplayName("when attachments are not requested the data bytes are omitted") + void attachmentDataOmittedWhenNotRequested() throws Exception { + EmlToPdfRequest noAttach = new EmlToPdfRequest(); + noAttach.setIncludeAttachments(false); + + EmailContent content = + EmlParser.extractEmailContent( + eml( + multipartWithAttachment( + "----b2", "body", "doc.txt", "some content")), + noAttach, + null); + + // Metadata still present, but the raw bytes are not attached. + assertThat(content.getAttachmentCount()).isGreaterThanOrEqualTo(1); + assertThat(content.getAttachments().get(0).getData()).isNull(); + } + + @Test + @DisplayName("an attachment over the size limit has its data skipped") + void attachmentOverSizeLimitSkipped() throws Exception { + // 0 MB limit means any non-empty attachment exceeds it. + EmailContent content = + EmlParser.extractEmailContent( + eml( + multipartWithAttachment( + "----b3", + "body", + "big.txt", + "this content exceeds the zero-byte limit")), + requestWithAttachments(0), + null); + + assertThat(content.getAttachments().get(0).getData()).isNull(); + } + } + + @Nested + @DisplayName("extractEmailContent - failure paths") + class FailureTests { + + @Test + @DisplayName("OLE2 magic bytes that are not a real MSG file raise an IOException") + void fakeMsgFile() { + // OLE2/MSG magic prefix followed by garbage -> outlookMsgToEmail fails. + byte[] fakeMsg = { + (byte) 0xD0, + (byte) 0xCF, + (byte) 0x11, + (byte) 0xE0, + (byte) 0xA1, + (byte) 0xB1, + (byte) 0x1A, + (byte) 0xE1, + 0x00, + 0x01, + 0x02, + 0x03, + 0x04, + 0x05, + 0x06, + 0x07 + }; + assertThatThrownBy(() -> EmlParser.extractEmailContent(fakeMsg, null, null)) + .isInstanceOf(java.io.IOException.class); + } + } + + @Nested + @DisplayName("EmailContent value type") + class EmailContentTests { + + @Test + @DisplayName("setHtmlBody and setTextBody strip carriage returns") + void stripsCarriageReturns() throws Exception { + EmailContent content = + EmlParser.extractEmailContent( + eml(simpleText("a@x.com", "b@x.com", "s", "x")), null, null); + content.setHtmlBody("line1\r\nline2"); + content.setTextBody("a\r\nb"); + assertThat(content.getHtmlBody()).doesNotContain("\r"); + assertThat(content.getTextBody()).doesNotContain("\r"); + } + + @Test + @DisplayName("null bodies are preserved as null") + void nullBodiesPreserved() throws Exception { + EmailContent content = + EmlParser.extractEmailContent( + eml(simpleText("a@x.com", "b@x.com", "s", "x")), null, null); + content.setHtmlBody(null); + assertThat(content.getHtmlBody()).isNull(); + } + } + + @Nested + @DisplayName("EmailAttachment value type") + class EmailAttachmentTests { + + @Test + @DisplayName("setData updates the size in bytes") + void setDataUpdatesSize() { + EmailAttachment att = new EmailAttachment(); + att.setData(new byte[] {1, 2, 3, 4, 5}); + assertThat(att.getSizeBytes()).isEqualTo(5); + } + + @Test + @DisplayName("setData with null leaves size unchanged") + void setDataNull() { + EmailAttachment att = new EmailAttachment(); + att.setData(null); + assertThat(att.getSizeBytes()).isZero(); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/EmlProcessingUtilsMoreTest.java b/app/common/src/test/java/stirling/software/common/util/EmlProcessingUtilsMoreTest.java new file mode 100644 index 0000000000..c87ba8c7f3 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/EmlProcessingUtilsMoreTest.java @@ -0,0 +1,218 @@ +package stirling.software.common.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.api.converters.EmlToPdfRequest; +import stirling.software.common.model.api.converters.HTMLToPdfRequest; +import stirling.software.common.util.EmlParser.EmailAttachment; +import stirling.software.common.util.EmlParser.EmailContent; + +/** + * Gap-filling tests for the HTML-generation and helper methods of {@link EmlProcessingUtils}. All + * inputs are built in-memory; no sanitizer, network or external tool is used. + */ +class EmlProcessingUtilsMoreTest { + + private static EmailContent content(String subject, String from, String to) { + EmailContent content = new EmailContent(); + content.setSubject(subject); + content.setFrom(from); + content.setTo(to); + return content; + } + + @Nested + @DisplayName("generateEnhancedEmailHtml") + class GenerateHtmlTests { + + @Test + @DisplayName("produces a full HTML document with the subject and core headers") + void basicDocument() { + EmailContent content = content("My Subject", "from@x.com", "to@x.com"); + content.setTextBody("plain body text"); + + String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null); + + assertThat(html) + .contains("") + .contains("My Subject") + .contains("from@x.com") + .contains("to@x.com") + .contains("plain body text") + .contains(""); + } + + @Test + @DisplayName("renders CC, BCC and a formatted date when present") + void ccBccAndDate() { + EmailContent content = content("Sub", "from@x.com", "to@x.com"); + content.setCc("cc@x.com"); + content.setBcc("bcc@x.com"); + content.setDate(ZonedDateTime.of(2024, 5, 6, 7, 8, 0, 0, ZoneOffset.UTC)); + content.setTextBody("hi"); + + String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null); + + assertThat(html) + .contains("CC:") + .contains("cc@x.com") + .contains("BCC:") + .contains("bcc@x.com") + .contains("Date:"); + } + + @Test + @DisplayName("prefers the HTML body over the text body when both are present") + void prefersHtmlBody() { + EmailContent content = content("Sub", "f@x.com", "t@x.com"); + content.setHtmlBody("

html version

"); + content.setTextBody("text version"); + + String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null); + + assertThat(html).contains("html version"); + } + + @Test + @DisplayName("falls back to a no-content placeholder when both bodies are empty") + void noContentPlaceholder() { + EmailContent content = content("Sub", "f@x.com", "t@x.com"); + + String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null); + + assertThat(html).contains("No content available"); + } + + @Test + @DisplayName("renders an attachments section and respects includeAttachments wording") + void attachmentsSection() { + EmailContent content = content("Sub", "f@x.com", "t@x.com"); + content.setTextBody("body"); + EmailAttachment att = new EmailAttachment(); + att.setFilename("file.pdf"); + att.setContentType("application/pdf"); + att.setData(new byte[] {1, 2, 3}); + List list = new ArrayList<>(); + list.add(att); + content.setAttachments(list); + content.setAttachmentCount(1); + + EmlToPdfRequest request = new EmlToPdfRequest(); + request.setIncludeAttachments(true); + + String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, request, null); + + assertThat(html) + .contains("Attachments (1)") + .contains("file.pdf") + .contains("embedded in the file"); + } + + @Test + @DisplayName("shows the not-included note when attachments are not requested") + void attachmentsNotIncludedNote() { + EmailContent content = content("Sub", "f@x.com", "t@x.com"); + content.setTextBody("body"); + EmailAttachment att = new EmailAttachment(); + att.setFilename("a.txt"); + List list = new ArrayList<>(); + list.add(att); + content.setAttachments(list); + content.setAttachmentCount(1); + + String html = EmlProcessingUtils.generateEnhancedEmailHtml(content, null, null); + + assertThat(html).contains("files not included in PDF"); + } + } + + @Nested + @DisplayName("createHtmlRequest") + class CreateHtmlRequestTests { + + @Test + @DisplayName("copies the file input and applies the default zoom") + void copiesFileInputAndZoom() { + EmlToPdfRequest request = new EmlToPdfRequest(); + HTMLToPdfRequest htmlRequest = EmlProcessingUtils.createHtmlRequest(request); + assertThat(htmlRequest).isNotNull(); + assertThat(htmlRequest.getZoom()).isEqualTo(1.0f); + } + + @Test + @DisplayName("tolerates a null request and still sets the zoom") + void nullRequest() { + HTMLToPdfRequest htmlRequest = EmlProcessingUtils.createHtmlRequest(null); + assertThat(htmlRequest.getZoom()).isEqualTo(1.0f); + } + } + + @Nested + @DisplayName("simplifyHtmlContent") + class SimplifyHtmlTests { + + @Test + @DisplayName("strips script and style tags") + void stripsScriptAndStyle() { + String html = + "" + + "

keep

"; + String result = EmlProcessingUtils.simplifyHtmlContent(html); + assertThat(result).doesNotContain(" & c", null); + assertThat(result).contains("<b>").contains("&"); + } + } + + @Nested + @DisplayName("detectMimeType - extension table") + class DetectMimeTypeTests { + + @Test + @DisplayName("detects svg, bmp and webp from the filename") + void detectsExtraTypes() { + assertThat(EmlProcessingUtils.detectMimeType("a.svg", null)).isEqualTo("image/svg+xml"); + assertThat(EmlProcessingUtils.detectMimeType("a.bmp", null)).isEqualTo("image/bmp"); + assertThat(EmlProcessingUtils.detectMimeType("a.webp", null)).isEqualTo("image/webp"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/ExceptionUtilsExtraTest.java b/app/common/src/test/java/stirling/software/common/util/ExceptionUtilsExtraTest.java new file mode 100644 index 0000000000..f902edac79 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/ExceptionUtilsExtraTest.java @@ -0,0 +1,92 @@ +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.common.util.ExceptionUtils.CbrFormatException; +import stirling.software.common.util.ExceptionUtils.CbzFormatException; +import stirling.software.common.util.ExceptionUtils.ErrorCode; +import stirling.software.common.util.ExceptionUtils.FfmpegRequiredException; +import stirling.software.common.util.ExceptionUtils.GhostscriptException; + +/** + * Remaining-gap tests for {@link ExceptionUtils} not already covered by ExceptionUtilsTest / + * ExceptionUtilsGapTest: the two-argument Ghostscript factory, the cause-bearing exception + * constructors, and the EPS-multipage Ghostscript diagnostic branch. + */ +class ExceptionUtilsExtraTest { + + @Nested + @DisplayName("createGhostscriptCompressionException(processOutput, cause)") + class TwoArgGhostscriptTests { + + @Test + @DisplayName("both output and cause provided yields a coded exception with the cause") + void outputAndCause() { + Exception cause = new RuntimeException("boom"); + GhostscriptException ex = + ExceptionUtils.createGhostscriptCompressionException( + "Some informational chatter", cause); + assertSame(cause, ex.getCause()); + assertEquals(ErrorCode.GHOSTSCRIPT_COMPRESSION.getCode(), ex.getErrorCode()); + } + + @Test + @DisplayName("EPS-multipage marker is recognized as a page-drawing error") + void epsMultipageMarker() { + String output = "Page 1\nEPS files may not contain multiple pages"; + GhostscriptException ex = ExceptionUtils.createGhostscriptCompressionException(output); + assertEquals(ErrorCode.GHOSTSCRIPT_PAGE_DRAWING.getCode(), ex.getErrorCode()); + assertNotNull(ex.getMessage()); + } + + @Test + @DisplayName("single-string overload with informational output uses compression code") + void singleStringInformational() { + GhostscriptException ex = + ExceptionUtils.createGhostscriptCompressionException("just chatter"); + assertEquals(ErrorCode.GHOSTSCRIPT_COMPRESSION.getCode(), ex.getErrorCode()); + // The fallback informative line is appended to the base message. + assertTrue(ex.getMessage().contains("chatter")); + } + } + + @Nested + @DisplayName("cause-bearing exception constructors") + class CauseConstructorTests { + + @Test + @DisplayName("CbrFormatException(message, cause, code) retains cause and code") + void cbrWithCause() { + Exception cause = new IllegalStateException("rar"); + CbrFormatException ex = new CbrFormatException("bad cbr", cause, "E010"); + assertSame(cause, ex.getCause()); + assertEquals("E010", ex.getErrorCode()); + assertEquals("bad cbr", ex.getMessage()); + } + + @Test + @DisplayName("CbzFormatException(message, code) leaves cause null") + void cbzNoCause() { + CbzFormatException ex = new CbzFormatException("bad cbz", "E015"); + assertEquals("E015", ex.getErrorCode()); + assertEquals("bad cbz", ex.getMessage()); + } + + @Test + @DisplayName("FfmpegRequiredException(message, cause, code) retains the cause") + void ffmpegWithCause() { + Exception cause = new RuntimeException("no ffmpeg"); + FfmpegRequiredException ex = + new FfmpegRequiredException("ffmpeg missing", cause, "E063"); + assertSame(cause, ex.getCause()); + assertEquals("E063", ex.getErrorCode()); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FileMonitorMoreTest.java b/app/common/src/test/java/stirling/software/common/util/FileMonitorMoreTest.java new file mode 100644 index 0000000000..f63ffa2b1e --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FileMonitorMoreTest.java @@ -0,0 +1,162 @@ +package stirling.software.common.util; + +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.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.function.Predicate; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import stirling.software.common.configuration.RuntimePathConfig; + +/** + * Gap-coverage tests for {@link FileMonitor}, focusing on {@code isFileReadyForProcessing} branches + * (stale-timestamp ready path, active file-lock not-ready path) and {@code trackFiles} processing + * of real filesystem create/modify events. Timing-sensitive readiness is forced via explicit + * last-modified timestamps rather than sleeps to stay non-flaky. + */ +class FileMonitorMoreTest { + + @TempDir Path tempDir; + + private FileMonitor monitorWatching(Path watchDir, Predicate filter) throws IOException { + RuntimePathConfig config = mock(RuntimePathConfig.class); + when(config.getPipelineWatchedFoldersPaths()).thenReturn(List.of(watchDir.toString())); + return new FileMonitor(filter, config); + } + + @Nested + @DisplayName("isFileReadyForProcessing") + class ReadinessTests { + + @Test + @DisplayName("file with an old last-modified time and no lock is ready") + void staleFileIsReady() throws IOException { + FileMonitor monitor = monitorWatching(tempDir, p -> true); + Path file = tempDir.resolve("ready.pdf"); + Files.writeString(file, "data"); + // Backdate well beyond the 5000ms freshness window so the timestamp branch marks ready. + Files.setLastModifiedTime( + file, FileTime.from(Instant.now().minus(1, ChronoUnit.HOURS))); + + assertTrue(monitor.isFileReadyForProcessing(file)); + } + + @Test + @DisplayName("stale file lock is acquired and released so readiness stays true") + void staleUnlockedFileLockRoundTrips() throws IOException { + FileMonitor monitor = monitorWatching(tempDir, p -> true); + Path file = tempDir.resolve("roundtrip.pdf"); + Files.writeString(file, "data"); + Files.setLastModifiedTime( + file, FileTime.from(Instant.now().minus(1, ChronoUnit.HOURS))); + + // First call acquires+releases a lock and returns ready; a second call still works, + // proving the lock was released (no lingering handle). + assertTrue(monitor.isFileReadyForProcessing(file)); + assertTrue(monitor.isFileReadyForProcessing(file)); + } + + @Test + @DisplayName("recently modified, unlocked file is not yet ready") + void freshFileNotReady() throws IOException { + FileMonitor monitor = monitorWatching(tempDir, p -> true); + Path file = tempDir.resolve("fresh.pdf"); + Files.writeString(file, "data"); + // Just-written file is within the freshness window and not in the ready list. + assertFalse(monitor.isFileReadyForProcessing(file)); + } + } + + @Nested + @DisplayName("trackFiles event processing") + class TrackFilesTests { + + @Test + @DisplayName("pre-existing files are registered during construction") + void preExistingFilesRegistered() throws IOException { + Files.writeString(tempDir.resolve("existing.txt"), "x"); + FileMonitor monitor = monitorWatching(tempDir, p -> true); + assertNotNull(monitor); + assertDoesNotThrow(monitor::trackFiles); + } + + @Test + @DisplayName("pre-existing nested directories are registered recursively") + void nestedDirectoriesRegistered() throws IOException { + Path nested = tempDir.resolve("sub"); + Files.createDirectories(nested); + Files.writeString(nested.resolve("inner.txt"), "y"); + + FileMonitor monitor = monitorWatching(tempDir, p -> true); + assertNotNull(monitor); + } + + @Test + @DisplayName("create then modify then delete cycle is processed without error") + void createModifyDeleteCycle() throws IOException { + FileMonitor monitor = monitorWatching(tempDir, p -> true); + + Path file = tempDir.resolve("cycle.txt"); + Files.writeString(file, "one"); + assertDoesNotThrow(monitor::trackFiles); + + Files.writeString(file, "two-modified-content"); + assertDoesNotThrow(monitor::trackFiles); + + Files.delete(file); + assertDoesNotThrow(monitor::trackFiles); + } + + @Test + @DisplayName("a rejecting path filter still lets trackFiles run cleanly") + void rejectingFilter() throws IOException { + FileMonitor monitor = monitorWatching(tempDir, p -> false); + Files.writeString(tempDir.resolve("ignored.txt"), "z"); + assertDoesNotThrow(monitor::trackFiles); + } + + @Test + @DisplayName("subdirectory created after start is handled on the next tick") + void subdirectoryCreatedAfterStart() throws IOException { + FileMonitor monitor = monitorWatching(tempDir, p -> true); + // First tick establishes monitoring; then create a child directory + file. + assertDoesNotThrow(monitor::trackFiles); + Path newDir = tempDir.resolve("late"); + Files.createDirectories(newDir); + Files.writeString(newDir.resolve("late.txt"), "late"); + assertDoesNotThrow(monitor::trackFiles); + } + } + + @Nested + @DisplayName("re-registration safety net") + class ReRegistrationTests { + + @Test + @DisplayName("trackFiles re-registers root dirs when nothing is currently mapped") + void reRegistersWhenEmpty() throws IOException { + // Root directory does not exist at construction, so nothing is registered. + Path missing = tempDir.resolve("appears-later"); + FileMonitor monitor = monitorWatching(missing, p -> true); + + // Now create the directory; the next tick should attempt re-registration. + Files.createDirectories(missing); + assertDoesNotThrow(monitor::trackFiles); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FileToPdfMoreTest.java b/app/common/src/test/java/stirling/software/common/util/FileToPdfMoreTest.java new file mode 100644 index 0000000000..04e66f31f9 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FileToPdfMoreTest.java @@ -0,0 +1,288 @@ +package stirling.software.common.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +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.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.api.converters.HTMLToPdfRequest; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; + +/** + * Gap-filling tests for {@link FileToPdf#convertHtmlToPdf}. The WeasyPrint process is fully mocked + * via {@link MockedStatic} so the command-building, sanitization and ZIP repacking paths run + * without launching any external tool. + */ +class FileToPdfMoreTest { + + private TempFileManager tempFileManager; + private CustomHtmlSanitizer sanitizer; + + @TempDir Path tempDir; + + @BeforeEach + void setUp() { + ApplicationProperties props = new ApplicationProperties(); + props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString()); + props.getSystem().getTempFileManagement().setPrefix("test-htmlpdf-"); + tempFileManager = new TempFileManager(new TempFileRegistry(), props); + + sanitizer = mock(CustomHtmlSanitizer.class); + // Identity sanitize so content is preserved for assertions. + when(sanitizer.sanitize(Mockito.anyString())) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + /** Build a real ZIP byte[] from name->content pairs. */ + private static byte[] buildZip(String[] names, String[] contents) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + for (int i = 0; i < names.length; i++) { + zos.putNextEntry(new ZipEntry(names[i])); + zos.write(contents[i].getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + } + return baos.toByteArray(); + } + + /** mockStatic helper returning a captor of the command list passed to the executor. */ + private ProcessExecutorResult successResult() { + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + when(result.getRc()).thenReturn(0); + return result; + } + + @Nested + @DisplayName("convertHtmlToPdf - HTML input") + class HtmlInputTests { + + @Test + @SuppressWarnings("unchecked") + @DisplayName("builds the WeasyPrint command and returns the output bytes") + void htmlHappyPath() throws Exception { + ProcessExecutor executor = mock(ProcessExecutor.class); + ArgumentCaptor> commandCaptor = ArgumentCaptor.forClass(List.class); + Mockito.doReturn(successResult()) + .when(executor) + .runCommandWithOutputHandling(commandCaptor.capture()); + + try (MockedStatic mocked = Mockito.mockStatic(ProcessExecutor.class)) { + mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT)) + .thenReturn(executor); + + byte[] result = + FileToPdf.convertHtmlToPdf( + "/usr/bin/weasyprint", + new HTMLToPdfRequest(), + "hi".getBytes(StandardCharsets.UTF_8), + "page.html", + tempFileManager, + sanitizer); + + assertThat(result).isNotNull(); + List command = commandCaptor.getValue(); + assertThat(command.get(0)).isEqualTo("/usr/bin/weasyprint"); + assertThat(command).contains("--pdf-forms", "-e", "utf-8"); + } + } + + @Test + @DisplayName("the HTML body is passed through the sanitizer before writing") + void htmlIsSanitized() throws Exception { + ProcessExecutor executor = mock(ProcessExecutor.class); + Mockito.doReturn(successResult()) + .when(executor) + .runCommandWithOutputHandling(anyList()); + + try (MockedStatic mocked = Mockito.mockStatic(ProcessExecutor.class)) { + mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT)) + .thenReturn(executor); + + FileToPdf.convertHtmlToPdf( + "weasyprint", + new HTMLToPdfRequest(), + "x".getBytes(StandardCharsets.UTF_8), + "doc.HTML", + tempFileManager, + sanitizer); + + Mockito.verify(sanitizer).sanitize("x"); + } + } + } + + @Nested + @DisplayName("convertHtmlToPdf - ZIP input") + class ZipInputTests { + + @Test + @DisplayName("html entries inside the ZIP are sanitized and repacked") + void zipHtmlEntriesSanitized() throws Exception { + byte[] zip = + buildZip( + new String[] {"index.html", "asset.css"}, + new String[] {"

body

", "p{color:red}"}); + + ProcessExecutor executor = mock(ProcessExecutor.class); + Mockito.doReturn(successResult()) + .when(executor) + .runCommandWithOutputHandling(anyList()); + + try (MockedStatic mocked = Mockito.mockStatic(ProcessExecutor.class)) { + mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT)) + .thenReturn(executor); + + byte[] result = + FileToPdf.convertHtmlToPdf( + "weasyprint", + new HTMLToPdfRequest(), + zip, + "bundle.zip", + tempFileManager, + sanitizer); + + assertThat(result).isNotNull(); + // Only the .html entry should be sanitized, not the .css. + Mockito.verify(sanitizer).sanitize("

body

"); + Mockito.verify(sanitizer, Mockito.never()).sanitize("p{color:red}"); + } + } + + @Test + @DisplayName("non-html entries inside the ZIP are copied through unchanged") + void zipNonHtmlCopied() throws Exception { + byte[] zip = buildZip(new String[] {"data.txt"}, new String[] {"plain text content"}); + + ProcessExecutor executor = mock(ProcessExecutor.class); + Mockito.doReturn(successResult()) + .when(executor) + .runCommandWithOutputHandling(anyList()); + + try (MockedStatic mocked = Mockito.mockStatic(ProcessExecutor.class)) { + mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT)) + .thenReturn(executor); + + // Drop the identity-stub invocation recorded during setUp. + Mockito.clearInvocations(sanitizer); + + byte[] result = + FileToPdf.convertHtmlToPdf( + "weasyprint", + new HTMLToPdfRequest(), + zip, + "bundle.zip", + tempFileManager, + sanitizer); + + assertThat(result).isNotNull(); + Mockito.verifyNoInteractions(sanitizer); + } + } + } + + @Nested + @DisplayName("convertHtmlToPdf - invalid input") + class InvalidInputTests { + + @Test + @DisplayName("an unsupported extension throws before any process is started") + void unsupportedExtension() { + assertThatThrownBy( + () -> + FileToPdf.convertHtmlToPdf( + "weasyprint", + new HTMLToPdfRequest(), + "data".getBytes(StandardCharsets.UTF_8), + "document.txt", + tempFileManager, + sanitizer)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + @DisplayName("sanitizeZipFilename additional branches") + class SanitizeZipFilenameTests { + + @Test + @DisplayName("a bare relative name is returned unchanged") + void plainName() { + assertThat(FileToPdf.sanitizeZipFilename("file.html")).isEqualTo("file.html"); + } + + @Test + @DisplayName("only the .. sequences are stripped, the rest of the path survives") + void stripsTraversalKeepsTail() { + String result = FileToPdf.sanitizeZipFilename("a/../b/c.html"); + assertThat(result).doesNotContain("..").endsWith("c.html"); + } + } + + @Nested + @DisplayName("repacked ZIP integrity") + class RepackedZipTests { + + @Test + @DisplayName("the temp input zip handed to weasyprint still contains the html entry") + void repackedZipContainsEntry() throws Exception { + byte[] zip = buildZip(new String[] {"a.html"}, new String[] {"hi"}); + + // Inspect the repacked zip from inside the command answer, while the temp file is + // still on disk (it is auto-deleted once convertHtmlToPdf returns). + List entryNames = new java.util.ArrayList<>(); + ProcessExecutor executor = mock(ProcessExecutor.class); + Mockito.doAnswer( + invocation -> { + List command = invocation.getArgument(0); + Path inputZip = Path.of(command.get(command.size() - 2)); + try (ZipInputStream zis = + new ZipInputStream( + java.nio.file.Files.newInputStream(inputZip))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + entryNames.add(entry.getName()); + } + } + return successResult(); + }) + .when(executor) + .runCommandWithOutputHandling(anyList()); + + try (MockedStatic mocked = Mockito.mockStatic(ProcessExecutor.class)) { + mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT)) + .thenReturn(executor); + + FileToPdf.convertHtmlToPdf( + "weasyprint", + new HTMLToPdfRequest(), + zip, + "bundle.zip", + tempFileManager, + sanitizer); + + assertThat(entryNames).anyMatch(name -> name.endsWith("a.html")); + } + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java new file mode 100644 index 0000000000..f4e013e082 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java @@ -0,0 +1,655 @@ +package stirling.software.common.util; + +import static org.assertj.core.api.Assertions.assertThat; +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 java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +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.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDListBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.FormFieldWithCoordinates; + +/** + * Additional branch coverage for {@link FormUtils}, complementing FormUtilsAdditionalTest and + * FormUtilsGapTest. Targets the display-label derivation chain, choice/radio value extraction and + * application, the modify-form type-change recreation path, and coordinate edge cases. + */ +class FormUtilsMoreTest { + + private record SetupDocument(PDPage page, PDAcroForm acroForm) {} + + private static SetupDocument createBasicDocument(PDDocument document) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + + PDAcroForm acroForm = new PDAcroForm(document); + PDResources dr = new PDResources(); + dr.put(COSName.getPDFName("Helv"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + acroForm.setDefaultResources(dr); + acroForm.setDefaultAppearance("/Helv 12 Tf 0 g"); + acroForm.setNeedAppearances(true); + document.getDocumentCatalog().setAcroForm(acroForm); + + return new SetupDocument(page, acroForm); + } + + private static void attachWidget( + SetupDocument setup, PDTerminalField field, PDRectangle rectangle) throws IOException { + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(rectangle); + widget.setPage(setup.page()); + List widgets = new ArrayList<>(); + widgets.add(widget); + field.setWidgets(widgets); + setup.acroForm().getFields().add(field); + setup.page().getAnnotations().add(widget); + } + + // ---------------------------------------------------------------------- + // extractFormFields - field-type branches and display labels + // ---------------------------------------------------------------------- + + @Nested + @DisplayName("extractFormFields metadata") + class ExtractFormFieldsMetadata { + + @Test + void comboBoxExtractsOptionsAndType() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDComboBox combo = new PDComboBox(setup.acroForm()); + combo.setPartialName("color"); + combo.setOptions(List.of("Red", "Green")); + attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20)); + + List fields = FormUtils.extractFormFields(doc); + assertEquals(1, fields.size()); + FormUtils.FormFieldInfo info = fields.get(0); + assertEquals("combobox", info.type()); + assertNotNull(info.options()); + assertTrue(info.options().contains("Red")); + } + } + + @Test + void multiSelectListBoxReportsMultiSelect() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDListBox listBox = new PDListBox(setup.acroForm()); + listBox.setPartialName("items"); + listBox.setMultiSelect(true); + listBox.setOptions(List.of("A", "B", "C")); + attachWidget(setup, listBox, new PDRectangle(50, 600, 200, 60)); + + List fields = FormUtils.extractFormFields(doc); + assertEquals(1, fields.size()); + assertEquals("listbox", fields.get(0).type()); + assertTrue(fields.get(0).multiSelect()); + } + } + + @Test + void fieldWithoutNameIsSkipped() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + // No partial name set -> fullyQualifiedName and partialName both null -> skipped. + PDTextField nameless = new PDTextField(setup.acroForm()); + attachWidget(setup, nameless, new PDRectangle(50, 700, 200, 20)); + + List fields = FormUtils.extractFormFields(doc); + assertTrue(fields.isEmpty()); + } + } + + @Test + void alternateFieldNameBecomesDisplayLabel() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDTextField text = new PDTextField(setup.acroForm()); + text.setPartialName("f1"); + text.setAlternateFieldName("Customer Email"); + attachWidget(setup, text, new PDRectangle(50, 700, 200, 20)); + + List fields = FormUtils.extractFormFields(doc); + assertEquals("Customer Email", fields.get(0).label()); + } + } + + @Test + void tooltipBecomesDisplayLabelWhenNoAlternate() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDTextField text = new PDTextField(setup.acroForm()); + text.setPartialName("f1"); + attachWidget(setup, text, new PDRectangle(50, 700, 200, 20)); + // Set the /TU tooltip on the widget. + text.getWidgets().get(0).getCOSObject().setString(COSName.TU, "Phone Number"); + + List fields = FormUtils.extractFormFields(doc); + assertEquals("Phone Number", fields.get(0).label()); + assertEquals("Phone Number", fields.get(0).tooltip()); + } + } + + @Test + void humanizedNameUsedWhenNoLabelSources() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDTextField text = new PDTextField(setup.acroForm()); + text.setPartialName("first_name"); + attachWidget(setup, text, new PDRectangle(50, 700, 200, 20)); + + List fields = FormUtils.extractFormFields(doc); + // humanizeName turns first_name -> "first name". + assertEquals("first name", fields.get(0).label()); + } + } + + @Test + void genericNameFallsBackToTypeLabel() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + // A 32+ hex char name is detected as UUID-like (generic), forcing the fallback. + PDTextField text = new PDTextField(setup.acroForm()); + text.setPartialName("cdc47b7041524571abcd93017fe77bf7"); + attachWidget(setup, text, new PDRectangle(50, 700, 200, 20)); + + List fields = FormUtils.extractFormFields(doc); + assertEquals("Text field 1", fields.get(0).label()); + } + } + + @Test + void choiceFieldCurrentValueIsJoined() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDListBox listBox = new PDListBox(setup.acroForm()); + listBox.setPartialName("items"); + listBox.setMultiSelect(true); + listBox.setOptions(List.of("A", "B", "C")); + attachWidget(setup, listBox, new PDRectangle(50, 600, 200, 60)); + listBox.setValue(List.of("A", "C")); + + List fields = FormUtils.extractFormFields(doc); + assertEquals("A,C", fields.get(0).value()); + } + } + + @Test + void fieldsAreSortedByPageThenOrderThenName() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDTextField zebra = new PDTextField(setup.acroForm()); + zebra.setPartialName("zebra"); + attachWidget(setup, zebra, new PDRectangle(50, 700, 200, 20)); + + PDTextField apple = new PDTextField(setup.acroForm()); + apple.setPartialName("apple"); + attachWidget(setup, apple, new PDRectangle(50, 660, 200, 20)); + + List fields = FormUtils.extractFormFields(doc); + assertEquals(2, fields.size()); + // pageOrder is assigned in tree order so zebra (added first) keeps order 0. + assertEquals("zebra", fields.get(0).name()); + assertEquals(0, fields.get(0).pageOrder()); + assertEquals(1, fields.get(1).pageOrder()); + } + } + } + + // ---------------------------------------------------------------------- + // extractFormFieldsWithCoordinates - extra branches + // ---------------------------------------------------------------------- + + @Nested + @DisplayName("extractFormFieldsWithCoordinates extras") + class ExtractWithCoordinatesExtras { + + @Test + void multilineAndReadOnlyFlagsAreReported() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDTextField text = new PDTextField(setup.acroForm()); + text.setPartialName("notes"); + text.setMultiline(true); + text.setReadOnly(true); + attachWidget(setup, text, new PDRectangle(50, 600, 200, 80)); + + List fields = + FormUtils.extractFormFieldsWithCoordinates(doc); + assertEquals(1, fields.size()); + assertTrue(fields.get(0).isMultiline()); + assertTrue(fields.get(0).isReadOnly()); + } + } + + @Test + void comboBoxWithDistinctDisplayValuesPopulatesDisplayOptions() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDComboBox combo = new PDComboBox(setup.acroForm()); + combo.setPartialName("country"); + // Distinct export vs display values triggers displayOptions to be sent. + combo.setOptions(List.of("US", "GB"), List.of("United States", "Britain")); + attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20)); + + List fields = + FormUtils.extractFormFieldsWithCoordinates(doc); + assertEquals(1, fields.size()); + List displayOptions = fields.get(0).getDisplayOptions(); + assertNotNull(displayOptions); + assertTrue(displayOptions.contains("United States")); + } + } + + @Test + void fontSizeExtractedFromDefaultAppearance() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDTextField text = new PDTextField(setup.acroForm()); + text.setPartialName("sized"); + text.setDefaultAppearance("/Helv 14 Tf 0 g"); + attachWidget(setup, text, new PDRectangle(50, 700, 200, 20)); + + List fields = + FormUtils.extractFormFieldsWithCoordinates(doc); + FormFieldWithCoordinates.WidgetCoordinates wc = fields.get(0).getWidgets().get(0); + assertEquals(14f, wc.getFontSize(), 0.01f); + } + } + + @Test + void widgetOutOfBoundsYieldsNullCoordinateEntry() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDTextField text = new PDTextField(setup.acroForm()); + text.setPartialName("offpage"); + // Far below the page origin -> finalY exceeds bounds -> createWidgetCoordinates + // returns null, which is still added to the per-field widget list. + attachWidget(setup, text, new PDRectangle(50, -5000, 200, 20)); + + List fields = + FormUtils.extractFormFieldsWithCoordinates(doc); + assertEquals(1, fields.size()); + List widgets = + fields.get(0).getWidgets(); + assertNotNull(widgets); + assertEquals(1, widgets.size()); + assertNull(widgets.get(0)); + } + } + + @Test + void widgetWithNullRectangleIsSkipped() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDTextField text = new PDTextField(setup.acroForm()); + text.setPartialName("norect"); + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setPage(setup.page()); + // Deliberately leave rectangle unset. + List widgets = new ArrayList<>(); + widgets.add(widget); + text.setWidgets(widgets); + setup.acroForm().getFields().add(text); + setup.page().getAnnotations().add(widget); + + List fields = + FormUtils.extractFormFieldsWithCoordinates(doc); + assertEquals(1, fields.size()); + assertNull(fields.get(0).getWidgets()); + } + } + } + + // ---------------------------------------------------------------------- + // applyFieldValues - choice / radio / signature / button branches + // ---------------------------------------------------------------------- + + @Nested + @DisplayName("applyFieldValues field-type branches") + class ApplyFieldValuesBranches { + + @Test + void comboBoxValueIsApplied() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDComboBox combo = new PDComboBox(setup.acroForm()); + combo.setPartialName("color"); + combo.setOptions(List.of("Red", "Green", "Blue")); + attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20)); + + FormUtils.applyFieldValues(doc, Map.of("color", "Green"), false); + assertThat(combo.getValue()).contains("Green"); + } + } + + @Test + void comboBoxNullValueClearsSelection() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDComboBox combo = new PDComboBox(setup.acroForm()); + combo.setPartialName("color"); + combo.setOptions(List.of("Red", "Green")); + attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20)); + combo.setValue("Red"); + + java.util.Map values = new java.util.HashMap<>(); + values.put("color", null); + FormUtils.applyFieldValues(doc, values, false); + // Null value routes to setValue("") which clears the prior "Red" selection. + assertFalse(combo.getValue().contains("Red")); + } + } + + @Test + void multiSelectListBoxAppliesCommaSeparatedValues() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDListBox listBox = new PDListBox(setup.acroForm()); + listBox.setPartialName("items"); + listBox.setMultiSelect(true); + listBox.setOptions(List.of("A", "B", "C")); + attachWidget(setup, listBox, new PDRectangle(50, 600, 200, 60)); + + FormUtils.applyFieldValues(doc, Map.of("items", "A, C"), false); + assertThat(listBox.getValue()).containsExactlyInAnyOrder("A", "C"); + } + } + + @Test + void radioButtonValueIsApplied() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDListBox other = new PDListBox(setup.acroForm()); + other.setPartialName("dummy"); + other.setOptions(List.of("x")); + attachWidget(setup, other, new PDRectangle(50, 500, 200, 20)); + + // Blank radio value path: no exception, value stays unset. + org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton radio = + new org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton( + setup.acroForm()); + radio.setPartialName("choice"); + attachWidget(setup, radio, new PDRectangle(50, 700, 20, 20)); + + FormUtils.applyFieldValues(doc, Map.of("choice", " "), false); + // No widgets configured with on-states, but the blank-skip branch must not throw. + assertNotNull(radio.getValueAsString()); + } + } + + @Test + void signatureAndPushButtonFieldsAreSkipped() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDSignatureField sig = new PDSignatureField(setup.acroForm()); + sig.setPartialName("sig"); + attachWidget(setup, sig, new PDRectangle(50, 700, 200, 40)); + + PDPushButton button = new PDPushButton(setup.acroForm()); + button.setPartialName("btn"); + attachWidget(setup, button, new PDRectangle(50, 640, 200, 40)); + + // Must complete without throwing; both branches are no-ops. + FormUtils.applyFieldValues(doc, Map.of("sig", "ignored", "btn", "ignored"), false); + } + } + + @Test + void blankKeysAreSkipped() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDTextField text = new PDTextField(setup.acroForm()); + text.setPartialName("name"); + attachWidget(setup, text, new PDRectangle(50, 700, 200, 20)); + + java.util.Map values = new java.util.LinkedHashMap<>(); + values.put(" ", "blankKey"); + values.put("name", "value"); + FormUtils.applyFieldValues(doc, values, false); + assertEquals("value", text.getValueAsString()); + } + } + + @Test + void unknownKeyIsSkipped() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDTextField text = new PDTextField(setup.acroForm()); + text.setPartialName("name"); + attachWidget(setup, text, new PDRectangle(50, 700, 200, 20)); + + FormUtils.applyFieldValues(doc, Map.of("doesNotExist", "x"), false); + assertEquals("", text.getValueAsString()); + } + } + } + + // ---------------------------------------------------------------------- + // modifyFormFields - type change (recreate) and choice in-place edits + // ---------------------------------------------------------------------- + + @Nested + @DisplayName("modifyFormFields advanced") + class ModifyFormFieldsAdvanced { + + @Test + void changesFieldTypeViaRecreate() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDTextField text = new PDTextField(setup.acroForm()); + text.setPartialName("toCombo"); + attachWidget(setup, text, new PDRectangle(50, 700, 200, 20)); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "toCombo", + "toCombo", + "Pick one", + "combobox", + null, + null, + List.of("One", "Two"), + "One", + null); + + FormUtils.modifyFormFields(doc, List.of(mod)); + + List fields = FormUtils.extractFormFields(doc); + assertEquals(1, fields.size()); + assertEquals("combobox", fields.get(0).type()); + assertEquals("toCombo", fields.get(0).name()); + } + } + + @Test + void inPlaceChoiceOptionAndMultiSelectUpdate() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDListBox listBox = new PDListBox(setup.acroForm()); + listBox.setPartialName("list"); + listBox.setOptions(List.of("A", "B")); + attachWidget(setup, listBox, new PDRectangle(50, 600, 200, 60)); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "list", + null, + null, + "listbox", // same type -> in-place path + null, + Boolean.TRUE, + List.of("X", "Y", "Z"), + null, + "Choose items"); + + FormUtils.modifyFormFields(doc, List.of(mod)); + + PDField updated = doc.getDocumentCatalog().getAcroForm().getField("list"); + assertTrue(updated instanceof PDListBox); + assertTrue(((PDListBox) updated).isMultiSelect()); + assertThat(((PDListBox) updated).getOptions()).contains("X", "Y", "Z"); + } + } + + @Test + void unsupportedTargetTypeIsSkipped() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDTextField text = new PDTextField(setup.acroForm()); + text.setPartialName("keep"); + attachWidget(setup, text, new PDRectangle(50, 700, 200, 20)); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "keep", null, null, "bogusType", null, null, null, null, null); + + FormUtils.modifyFormFields(doc, List.of(mod)); + // The field is preserved unchanged because the target type is unsupported. + List fields = FormUtils.extractFormFields(doc); + assertEquals(1, fields.size()); + assertEquals("text", fields.get(0).type()); + } + } + + @Test + void renameAvoidsCollisionWithExistingField() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDTextField a = new PDTextField(setup.acroForm()); + a.setPartialName("alpha"); + attachWidget(setup, a, new PDRectangle(50, 700, 200, 20)); + + PDTextField b = new PDTextField(setup.acroForm()); + b.setPartialName("beta"); + attachWidget(setup, b, new PDRectangle(50, 660, 200, 20)); + + // Rename beta -> alpha; should be uniquified to avoid the collision. + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "beta", "alpha", null, null, null, null, null, null, null); + + FormUtils.modifyFormFields(doc, List.of(mod)); + + List names = new ArrayList<>(); + for (FormUtils.FormFieldInfo info : FormUtils.extractFormFields(doc)) { + names.add(info.name()); + } + assertEquals(2, names.size()); + assertTrue(names.contains("alpha")); + // The renamed field cannot also be "alpha"; it gets a suffix. + assertTrue(names.stream().anyMatch(n -> n.startsWith("alpha_"))); + } + } + + @Test + void documentWithoutAcroFormIsNoOp() throws IOException { + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage()); + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "x", null, null, null, null, null, null, null, null); + FormUtils.modifyFormFields(doc, List.of(mod)); + } + } + } + + // ---------------------------------------------------------------------- + // buildFillTemplateRecord - radio default branch + // ---------------------------------------------------------------------- + + @Test + void buildFillTemplateRadioUsesCurrentValue() { + FormUtils.FormFieldInfo info = + new FormUtils.FormFieldInfo( + "choice", "Choice", "radio", "Yes", null, false, 0, false, null, 0); + Map result = FormUtils.buildFillTemplateRecord(List.of(info)); + assertEquals("Yes", result.get("choice")); + } + + @Test + void buildFillTemplateNullEntriesAreSkipped() { + List list = new ArrayList<>(); + list.add(null); + list.add( + new FormUtils.FormFieldInfo( + "kept", "Kept", "text", "v", null, false, 0, false, null, 0)); + Map result = FormUtils.buildFillTemplateRecord(list); + assertEquals(1, result.size()); + assertTrue(result.containsKey("kept")); + } + + // ---------------------------------------------------------------------- + // resolveDisplayOptions / resolveOptions extra branches + // ---------------------------------------------------------------------- + + @Test + void resolveDisplayOptionsReturnsDistinctDisplayValues() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + PDComboBox combo = new PDComboBox(setup.acroForm()); + combo.setPartialName("c"); + combo.setOptions(List.of("US", "GB"), List.of("United States", "Britain")); + List display = FormUtils.resolveDisplayOptions(combo); + assertThat(display).contains("United States", "Britain"); + } + } + + @Test + void resolveOptionsRadioUsesExportValues() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton radio = + new org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton(setup.acroForm()); + radio.setExportValues(List.of("opt1", "opt2")); + assertEquals(List.of("opt1", "opt2"), FormUtils.resolveOptions(radio)); + } + } + + // ---------------------------------------------------------------------- + // applyFieldValues strict mode + // ---------------------------------------------------------------------- + + @Test + void strictModeWrapsChoiceFailureInIoException() throws IOException { + try (PDDocument doc = new PDDocument()) { + SetupDocument setup = createBasicDocument(doc); + // A combo box with no /Opt array: setting a non-empty value triggers the + // "missing /Opt" IllegalArgumentException, which strict mode rethrows as IOException. + PDComboBox combo = new PDComboBox(setup.acroForm()); + combo.setPartialName("noOpts"); + attachWidget(setup, combo, new PDRectangle(50, 700, 200, 20)); + + assertThrows( + IOException.class, + () -> FormUtils.applyFieldValues(doc, Map.of("noOpts", "X"), false, true)); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/GeneralFormCopyUtilsMoreTest.java b/app/common/src/test/java/stirling/software/common/util/GeneralFormCopyUtilsMoreTest.java new file mode 100644 index 0000000000..3aeaf46ded --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/GeneralFormCopyUtilsMoreTest.java @@ -0,0 +1,335 @@ +package stirling.software.common.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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.assertTrue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +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.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Branch coverage for {@link GeneralFormCopyUtils#copyAndTransformFormFields} and the {@link + * GeneralFormFieldTypeSupport} handlers, complementing GeneralFormCopyUtilsTest which only covers + * rotation and the empty-form early returns. + */ +class GeneralFormCopyUtilsMoreTest { + + private static PDAcroForm newAcroForm(PDDocument document) { + PDAcroForm acroForm = new PDAcroForm(document); + PDResources dr = new PDResources(); + dr.put(COSName.getPDFName("Helv"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + acroForm.setDefaultResources(dr); + acroForm.setDefaultAppearance("/Helv 12 Tf 0 g"); + document.getDocumentCatalog().setAcroForm(acroForm); + return acroForm; + } + + private static void addWidget(PDTerminalField field, PDPage page, PDRectangle rect) + throws IOException { + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(rect); + widget.setPage(page); + List widgets = new ArrayList<>(); + widgets.add(widget); + field.setWidgets(widgets); + page.getAnnotations().add(widget); + } + + // ---------------------------------------------------------------------- + // copyAndTransformFormFields - real field copying + // ---------------------------------------------------------------------- + + @Nested + @DisplayName("copyAndTransformFormFields copying") + class CopyingFields { + + @Test + void copiesTextCheckboxAndComboFields() throws IOException { + try (PDDocument source = new PDDocument(); + PDDocument target = new PDDocument()) { + PDPage sourcePage = new PDPage(PDRectangle.A4); + source.addPage(sourcePage); + target.addPage(new PDPage(PDRectangle.A4)); + + PDAcroForm sourceForm = newAcroForm(source); + + PDTextField text = new PDTextField(sourceForm); + text.setPartialName("name"); + addWidget(text, sourcePage, new PDRectangle(50, 700, 200, 20)); + sourceForm.getFields().add(text); + text.setValue("Alice"); + + PDCheckBox check = new PDCheckBox(sourceForm); + check.setPartialName("agree"); + check.setExportValues(List.of("Yes")); + addWidget(check, sourcePage, new PDRectangle(50, 660, 16, 16)); + sourceForm.getFields().add(check); + + PDComboBox combo = new PDComboBox(sourceForm); + combo.setPartialName("color"); + addWidget(combo, sourcePage, new PDRectangle(50, 620, 200, 20)); + sourceForm.getFields().add(combo); + combo.setOptions(List.of("Red", "Green")); + + GeneralFormCopyUtils.copyAndTransformFormFields( + source, target, 1, 1, 1, 1, 612f, 792f); + + PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm(); + assertNotNull(targetForm); + assertEquals(3, targetForm.getFields().size()); + List names = new ArrayList<>(); + for (var f : targetForm.getFields()) { + names.add(f.getPartialName()); + } + // Names are prefixed with page index during copy. + assertThat(names).contains("page0_name", "page0_agree", "page0_color"); + } + } + + @Test + void copiesFieldThroughMultiCellGridLayout() throws IOException { + try (PDDocument source = new PDDocument(); + PDDocument target = new PDDocument()) { + PDPage sourcePage = new PDPage(PDRectangle.A4); + source.addPage(sourcePage); + target.addPage(new PDPage(PDRectangle.A4)); + + PDAcroForm sourceForm = newAcroForm(source); + PDTextField text = new PDTextField(sourceForm); + text.setPartialName("name"); + addWidget(text, sourcePage, new PDRectangle(100, 100, 200, 20)); + sourceForm.getFields().add(text); + + // 2x2 layout exercises the scale/offset arithmetic for cell placement. + GeneralFormCopyUtils.copyAndTransformFormFields( + source, target, 1, 4, 2, 2, 300f, 396f); + + PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm(); + assertEquals(1, targetForm.getFields().size()); + assertEquals("page0_name", targetForm.getFields().get(0).getPartialName()); + assertEquals(1, targetForm.getFields().get(0).getWidgets().size()); + } + } + + @Test + void skipsPagesWithoutAnnotations() throws IOException { + try (PDDocument source = new PDDocument(); + PDDocument target = new PDDocument()) { + source.addPage(new PDPage(PDRectangle.A4)); // no annotations + target.addPage(new PDPage(PDRectangle.A4)); + + // Source has an AcroForm with a field on a different (non-existent here) page, + // but page 0 has no annotations -> the per-page copy is skipped. + PDAcroForm sourceForm = newAcroForm(source); + PDTextField text = new PDTextField(sourceForm); + text.setPartialName("ghost"); + sourceForm.getFields().add(text); + + GeneralFormCopyUtils.copyAndTransformFormFields( + source, target, 1, 1, 1, 1, 612f, 792f); + + PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm(); + // Form is created but no widgets were copied. + assertNotNull(targetForm); + assertTrue(targetForm.getFields().isEmpty()); + } + } + + @Test + void skipsWhenRowIndexExceedsRows() throws IOException { + try (PDDocument source = new PDDocument(); + PDDocument target = new PDDocument()) { + PDPage page0 = new PDPage(PDRectangle.A4); + PDPage page1 = new PDPage(PDRectangle.A4); + source.addPage(page0); + source.addPage(page1); + target.addPage(new PDPage(PDRectangle.A4)); + + PDAcroForm sourceForm = newAcroForm(source); + PDTextField a = new PDTextField(sourceForm); + a.setPartialName("a"); + addWidget(a, page0, new PDRectangle(10, 10, 100, 20)); + sourceForm.getFields().add(a); + + PDTextField b = new PDTextField(sourceForm); + b.setPartialName("b"); + addWidget(b, page1, new PDRectangle(10, 10, 100, 20)); + sourceForm.getFields().add(b); + + // cols=1, rows=1, pagesPerSheet=2 -> second page maps to rowIndex 1 (>= rows) -> + // skipped. + GeneralFormCopyUtils.copyAndTransformFormFields( + source, target, 2, 2, 1, 1, 612f, 792f); + + PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm(); + assertEquals(1, targetForm.getFields().size()); + assertEquals("page0_a", targetForm.getFields().get(0).getPartialName()); + } + } + + @Test + void skipsWhenDestinationPageMissing() throws IOException { + try (PDDocument source = new PDDocument(); + PDDocument target = new PDDocument()) { + PDPage sourcePage = new PDPage(PDRectangle.A4); + source.addPage(sourcePage); + // Target has NO pages, so destinationPageIndex 0 is out of bounds. + + PDAcroForm sourceForm = newAcroForm(source); + PDTextField text = new PDTextField(sourceForm); + text.setPartialName("name"); + addWidget(text, sourcePage, new PDRectangle(50, 700, 200, 20)); + sourceForm.getFields().add(text); + + assertDoesNotThrow( + () -> + GeneralFormCopyUtils.copyAndTransformFormFields( + source, target, 1, 1, 1, 1, 612f, 792f)); + + PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm(); + assertTrue(targetForm.getFields().isEmpty()); + } + } + + @Test + void uniquifiesDuplicateFieldNamesAcrossPages() throws IOException { + try (PDDocument source = new PDDocument(); + PDDocument target = new PDDocument()) { + PDPage sourcePage = new PDPage(PDRectangle.A4); + source.addPage(sourcePage); + target.addPage(new PDPage(PDRectangle.A4)); + + PDAcroForm sourceForm = newAcroForm(source); + + // Two separate fields placed on the same source page with the same partial name + // would clash; the copier must generate distinct names. + PDTextField one = new PDTextField(sourceForm); + one.setPartialName("dup"); + addWidget(one, sourcePage, new PDRectangle(50, 700, 100, 20)); + sourceForm.getFields().add(one); + + PDTextField two = new PDTextField(sourceForm); + two.setPartialName("dup"); + addWidget(two, sourcePage, new PDRectangle(50, 660, 100, 20)); + sourceForm.getFields().add(two); + + GeneralFormCopyUtils.copyAndTransformFormFields( + source, target, 1, 1, 1, 1, 612f, 792f); + + PDAcroForm targetForm = target.getDocumentCatalog().getAcroForm(); + assertEquals(2, targetForm.getFields().size()); + List names = new ArrayList<>(); + for (var f : targetForm.getFields()) { + names.add(f.getPartialName()); + } + // First keeps page0_dup; the second is suffixed. + assertTrue(names.contains("page0_dup")); + assertTrue(names.stream().anyMatch(n -> n.startsWith("page0_dup_"))); + } + } + } + + // ---------------------------------------------------------------------- + // GeneralFormFieldTypeSupport - forField / createField / copyFromOriginal + // ---------------------------------------------------------------------- + + @Nested + @DisplayName("GeneralFormFieldTypeSupport") + class TypeSupport { + + @Test + void forFieldNullReturnsNull() { + assertNull(GeneralFormFieldTypeSupport.forField(null)); + } + + @Test + void forFieldResolvesEachConcreteType() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDAcroForm form = newAcroForm(doc); + assertEquals( + GeneralFormFieldTypeSupport.TEXT, + GeneralFormFieldTypeSupport.forField(new PDTextField(form))); + assertEquals( + GeneralFormFieldTypeSupport.CHECKBOX, + GeneralFormFieldTypeSupport.forField(new PDCheckBox(form))); + assertEquals( + GeneralFormFieldTypeSupport.COMBOBOX, + GeneralFormFieldTypeSupport.forField(new PDComboBox(form))); + assertEquals( + GeneralFormFieldTypeSupport.BUTTON, + GeneralFormFieldTypeSupport.forField(new PDPushButton(form))); + } + } + + @Test + void createFieldProducesMatchingInstance() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDAcroForm form = newAcroForm(doc); + PDTerminalField text = GeneralFormFieldTypeSupport.TEXT.createField(form); + assertTrue(text instanceof PDTextField); + PDTerminalField check = GeneralFormFieldTypeSupport.CHECKBOX.createField(form); + assertTrue(check instanceof PDCheckBox); + } + } + + @Test + void copyFromOriginalTransfersComboOptions() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDAcroForm form = newAcroForm(doc); + PDComboBox src = new PDComboBox(form); + src.setPartialName("src"); + src.setOptions(List.of("A", "B")); + PDComboBox dst = new PDComboBox(form); + dst.setPartialName("dst"); + + GeneralFormFieldTypeSupport.COMBOBOX.copyFromOriginal(src, dst); + assertThat(dst.getOptions()).contains("A", "B"); + } + } + + @Test + void copyFromOriginalTransfersTextValue() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDAcroForm form = newAcroForm(doc); + PDTextField src = new PDTextField(form); + src.setPartialName("src"); + src.setValue("hello"); + PDTextField dst = new PDTextField(form); + dst.setPartialName("dst"); + dst.setDefaultAppearance("/Helv 12 Tf 0 g"); + + GeneralFormFieldTypeSupport.TEXT.copyFromOriginal(src, dst); + assertEquals("hello", dst.getValueAsString()); + } + } + + @Test + void typeNameAndFallbackWidgetNameExposed() { + assertEquals("text", GeneralFormFieldTypeSupport.TEXT.typeName()); + assertEquals("textField", GeneralFormFieldTypeSupport.TEXT.fallbackWidgetName()); + assertEquals("checkbox", GeneralFormFieldTypeSupport.CHECKBOX.typeName()); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/GeneralUtilsLocalIpTest.java b/app/common/src/test/java/stirling/software/common/util/GeneralUtilsLocalIpTest.java new file mode 100644 index 0000000000..5e106f096c --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/GeneralUtilsLocalIpTest.java @@ -0,0 +1,114 @@ +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 java.util.List; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.util.GeneralUtils.NetworkInterfaceInfo; + +class GeneralUtilsLocalIpTest { + + private static NetworkInterfaceInfo iface( + String name, String displayName, int index, boolean virtual, String... ips) { + return new NetworkInterfaceInfo( + name, displayName, index, true, false, false, virtual, true, List.of(ips)); + } + + @Test + void prefersPhysicalWifiOverVmwareNatAdapter() { + NetworkInterfaceInfo vmware = + iface("eth5", "VMware Virtual Ethernet Adapter for VMnet8", 5, false, "172.16.1.1"); + NetworkInterfaceInfo wifi = + iface("wlan0", "Intel(R) Wi-Fi 6 AX201", 12, false, "192.168.1.50"); + + assertEquals("192.168.1.50", GeneralUtils.selectBestSiteLocalIp(List.of(vmware, wifi))); + } + + @Test + void excludesHyperVVethernetAdapter() { + NetworkInterfaceInfo hyperv = + iface("ethernet_32770", "Hyper-V Virtual Ethernet Adapter", 3, false, "172.28.0.1"); + NetworkInterfaceInfo ethernet = + iface("eth0", "Realtek PCIe GbE Family Controller", 8, false, "192.168.0.20"); + + assertEquals("192.168.0.20", GeneralUtils.selectBestSiteLocalIp(List.of(hyperv, ethernet))); + } + + @Test + void excludesWslAndDockerBridges() { + NetworkInterfaceInfo wsl = + iface("eth1", "Hyper-V Virtual Ethernet Adapter (WSL)", 70, false, "172.20.0.1"); + NetworkInterfaceInfo docker = iface("docker0", "docker0", 4, false, "172.17.0.1"); + NetworkInterfaceInfo lan = + iface("eth0", "Intel(R) Ethernet Connection", 2, false, "10.0.0.5"); + + assertEquals("10.0.0.5", GeneralUtils.selectBestSiteLocalIp(List.of(wsl, docker, lan))); + } + + @Test + void prefers192Over10WhenBothPhysical() { + NetworkInterfaceInfo ten = iface("eth0", "Ethernet", 2, false, "10.1.2.3"); + NetworkInterfaceInfo home = iface("wlan0", "Wi-Fi", 6, false, "192.168.1.10"); + + assertEquals("192.168.1.10", GeneralUtils.selectBestSiteLocalIp(List.of(ten, home))); + } + + @Test + void breaksTiesByLowestInterfaceIndex() { + NetworkInterfaceInfo first = iface("eth0", "Ethernet", 2, false, "192.168.1.2"); + NetworkInterfaceInfo second = iface("eth1", "Ethernet", 9, false, "192.168.1.3"); + + assertEquals("192.168.1.2", GeneralUtils.selectBestSiteLocalIp(List.of(second, first))); + } + + @Test + void returnsNullWhenOnlyVirtualOrDownInterfaces() { + NetworkInterfaceInfo vbox = + iface("vboxnet0", "VirtualBox Host-Only Network", 1, false, "192.168.56.1"); + NetworkInterfaceInfo flaggedVirtual = + new NetworkInterfaceInfo( + "eth9", + "Ethernet", + 9, + true, + false, + false, + true, + true, + List.of("192.168.1.9")); + NetworkInterfaceInfo down = + new NetworkInterfaceInfo( + "eth0", + "Ethernet", + 2, + false, + false, + false, + false, + true, + List.of("192.168.1.2")); + + assertNull(GeneralUtils.selectBestSiteLocalIp(List.of(vbox, flaggedVirtual, down))); + } + + @Test + void isLikelyVirtualInterfaceFlagsKnownAdaptersButNotRealNics() { + assertTrue( + GeneralUtils.isLikelyVirtualInterface( + "vEthernet", "Hyper-V Virtual Ethernet Adapter")); + assertTrue(GeneralUtils.isLikelyVirtualInterface("docker0", "docker0")); + assertTrue( + GeneralUtils.isLikelyVirtualInterface("eth0", "VMware Virtual Ethernet Adapter")); + assertTrue(GeneralUtils.isLikelyVirtualInterface("tun0", "WireGuard tunnel")); + + assertFalse(GeneralUtils.isLikelyVirtualInterface("wlan0", "Intel(R) Wi-Fi 6 AX201")); + assertFalse( + GeneralUtils.isLikelyVirtualInterface( + "eth0", "Realtek PCIe GbE Family Controller")); + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/GeneralUtilsMoreTest.java b/app/common/src/test/java/stirling/software/common/util/GeneralUtilsMoreTest.java new file mode 100644 index 0000000000..497f9f8746 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/GeneralUtilsMoreTest.java @@ -0,0 +1,422 @@ +package stirling.software.common.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import stirling.software.common.configuration.InstallationPathConfig; + +/** + * Branch-coverage gap tests for {@link GeneralUtils}. Targets size parsing/formatting, page-list + * and range handling, version comparison, URL validation, script/pipeline extraction validation, + * and the Ghostscript optimize failure paths not exercised by the existing GeneralUtils*Test files. + */ +class GeneralUtilsMoreTest { + + @Nested + @DisplayName("convertSizeToBytes with explicit default unit") + class ConvertSizeWithDefaultUnitTests { + + @Test + @DisplayName("invalid default unit throws IllegalArgumentException") + void invalidDefaultUnitThrows() { + assertThatThrownBy(() -> GeneralUtils.convertSizeToBytes("100", "ZB")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid default unit"); + } + + @ParameterizedTest(name = "value \"5\" with default unit {0} -> {1} bytes") + @CsvSource({"B, 5", "KB, 5120", "MB, 5242880", "GB, 5368709120", "TB, 5497558138880"}) + @DisplayName("numeric value uses the supplied default unit") + void numericValueUsesDefaultUnit(String unit, long expected) { + assertEquals(expected, GeneralUtils.convertSizeToBytes("5", unit)); + } + + @Test + @DisplayName("lowercase default unit is normalized") + void lowercaseDefaultUnit() { + assertEquals(5L * 1024 * 1024, GeneralUtils.convertSizeToBytes("5", "mb")); + } + + @Test + @DisplayName("explicit suffix overrides default unit") + void explicitSuffixOverridesDefault() { + // "2KB" should parse as KB even though default unit is GB. + assertEquals(2048L, GeneralUtils.convertSizeToBytes("2KB", "GB")); + } + + @Test + @DisplayName("null default unit falls back to MB") + void nullDefaultUnitFallsBackToMb() { + assertEquals(3L * 1024 * 1024, GeneralUtils.convertSizeToBytes("3", null)); + } + } + + @Nested + @DisplayName("convertSizeToBytes suffix and edge parsing") + class ConvertSizeSuffixTests { + + @Test + @DisplayName("comma decimal separator and embedded spaces are handled") + void commaAndSpaces() { + // "2,5 GB" -> "2.5GB" after normalization. + assertEquals(2684354560L, GeneralUtils.convertSizeToBytes("2,5 GB")); + } + + @Test + @DisplayName("bare B suffix parses as bytes") + void bareBytes() { + assertEquals(42L, GeneralUtils.convertSizeToBytes("42B")); + } + + @Test + @DisplayName("non-numeric body returns null") + void nonNumericReturnsNull() { + assertNull(GeneralUtils.convertSizeToBytes("abcMB")); + } + + @Test + @DisplayName("negative value returns null") + void negativeReturnsNull() { + assertNull(GeneralUtils.convertSizeToBytes("-1KB")); + } + + @Test + @DisplayName("zero is a valid size") + void zeroIsValid() { + assertEquals(0L, GeneralUtils.convertSizeToBytes("0MB")); + } + } + + @Nested + @DisplayName("formatBytes boundaries") + class FormatBytesTests { + + @Test + @DisplayName("negative bytes report invalid size") + void negativeInvalid() { + assertEquals("Invalid size", GeneralUtils.formatBytes(-1)); + } + + @Test + @DisplayName("terabyte range uses TB suffix") + void terabyteRange() { + long oneTb = 1024L * 1024L * 1024L * 1024L; + assertEquals("1.00 TB", GeneralUtils.formatBytes(oneTb)); + } + + @Test + @DisplayName("upper KB boundary just below a megabyte") + void kbBoundary() { + assertThat(GeneralUtils.formatBytes(1024L * 1024L - 1)).endsWith("KB"); + } + } + + @Nested + @DisplayName("parsePageList String overload") + class ParsePageListStringTests { + + @Test + @DisplayName("null pages defaults to first page") + void nullDefaultsToFirst() { + // Cast disambiguates the String vs String[] overloads for a null literal. + assertEquals(List.of(1), GeneralUtils.parsePageList((String) null, 5, true)); + } + + @Test + @DisplayName("comma-separated list expands across tokens") + void commaSeparated() { + assertEquals(List.of(1, 3, 5), GeneralUtils.parsePageList("1,3,5", 5, true)); + } + + @Test + @DisplayName("'all' keyword via String overload returns every page") + void allKeyword() { + assertEquals(List.of(1, 2, 3), GeneralUtils.parsePageList("all", 3, true)); + } + + @Test + @DisplayName("two-argument overload defaults to zero-based output") + void twoArgOverloadZeroBased() { + assertEquals(List.of(0, 1, 2), GeneralUtils.parsePageList(new String[] {"1-3"}, 5)); + } + + @Test + @DisplayName("large in-range request stays within the max-size guard") + void largeRequestWithinGuard() { + // Pages are clamped to [1, total], so a wide range never trips the maxSize guard. + List result = GeneralUtils.parsePageList(new String[] {"1-500"}, 500, true); + assertEquals(500, result.size()); + } + } + + @Nested + @DisplayName("range and single-page handling") + class RangeHandlingTests { + + @Test + @DisplayName("open-ended range extends to the last page") + void openEndedRange() { + assertEquals( + List.of(3, 4, 5), GeneralUtils.parsePageList(new String[] {"3-"}, 5, true)); + } + + @Test + @DisplayName("invalid range bounds are skipped, valid tokens remain") + void invalidRangeSkipped() { + List result = GeneralUtils.parsePageList(new String[] {"x-y", "2"}, 5, true); + assertEquals(List.of(2), result); + } + + @Test + @DisplayName("out-of-range single page is dropped") + void outOfRangeSinglePage() { + assertTrue(GeneralUtils.parsePageList(new String[] {"99"}, 5, true).isEmpty()); + } + + @Test + @DisplayName("non-numeric single page is dropped") + void nonNumericSinglePage() { + assertTrue(GeneralUtils.parsePageList(new String[] {"abc"}, 5, true).isEmpty()); + } + + @Test + @DisplayName("range partially outside the document keeps in-bounds pages") + void rangePartlyOutOfBounds() { + assertEquals(List.of(4, 5), GeneralUtils.parsePageList(new String[] {"4-99"}, 5, true)); + } + } + + @Nested + @DisplayName("isVersionHigher") + class VersionTests { + + @ParameterizedTest(name = "{0} > {1} == {2}") + @CsvSource({ + "2.0.0, 1.9.9, true", + "1.0.0, 1.0.0, false", + "1.0, 1.0.1, false", + "1.0.1, 1.0, true", + "1.2, 1.10, false" + }) + @DisplayName("compares version components numerically") + void comparesComponents(String a, String b, boolean expected) { + assertEquals(expected, GeneralUtils.isVersionHigher(a, b)); + } + + @Test + @DisplayName("null arguments yield false") + void nullArgs() { + assertFalse(GeneralUtils.isVersionHigher(null, "1.0")); + assertFalse(GeneralUtils.isVersionHigher("1.0", null)); + } + + @Test + @DisplayName("non-numeric component throws NumberFormatException") + void nonNumericComponentThrows() { + assertThrows( + NumberFormatException.class, () -> GeneralUtils.isVersionHigher("1.x", "1.0")); + } + } + + @Nested + @DisplayName("isValidURL") + class ValidUrlTests { + + @ParameterizedTest + @ValueSource(strings = {"https://example.com", "http://example.com/path?q=1"}) + @DisplayName("well-formed external URLs are valid") + void validUrls(String url) { + assertTrue(GeneralUtils.isValidURL(url)); + } + + @ParameterizedTest + @ValueSource(strings = {"htp:/bad", "not a url", "://missing-scheme"}) + @DisplayName("malformed URLs are rejected") + void invalidUrls(String url) { + assertFalse(GeneralUtils.isValidURL(url)); + } + } + + @Nested + @DisplayName("isValidUUID") + class UuidTests { + + @Test + @DisplayName("null is not a valid UUID") + void nullUuid() { + assertFalse(GeneralUtils.isValidUUID(null)); + } + + @Test + @DisplayName("well-formed UUID is accepted") + void validUuid() { + assertTrue(GeneralUtils.isValidUUID("123e4567-e89b-12d3-a456-426614174000")); + } + + @Test + @DisplayName("garbage string is rejected") + void garbageUuid() { + assertFalse(GeneralUtils.isValidUUID("xyz")); + } + } + + @Nested + @DisplayName("createDir failure path") + class CreateDirFailureTests { + + @Test + @DisplayName("returns false when directory creation throws IOException") + void createDirIoFailure(@TempDir Path tempDir) throws IOException { + // A regular file at the target path makes createDirectories fail. + Path asFile = tempDir.resolve("not-a-dir"); + Files.writeString(asFile, "blocker"); + Path child = asFile.resolve("child"); + assertFalse(GeneralUtils.createDir(child.toString())); + } + } + + @Nested + @DisplayName("extractScript validation") + class ExtractScriptTests { + + @Test + @DisplayName("null or blank name is rejected") + void nullOrBlank() { + assertThrows(IllegalArgumentException.class, () -> GeneralUtils.extractScript(null)); + assertThrows(IllegalArgumentException.class, () -> GeneralUtils.extractScript(" ")); + } + + @ParameterizedTest + @ValueSource(strings = {"../evil.py", "dir/script.py"}) + @DisplayName("path-traversal characters are rejected") + void pathTraversalRejected(String name) { + assertThrows(IllegalArgumentException.class, () -> GeneralUtils.extractScript(name)); + } + + @Test + @DisplayName("name outside the allow-list is rejected") + void notInAllowList() { + assertThatThrownBy(() -> GeneralUtils.extractScript("random.py")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("png_to_webp.py"); + } + } + + @Nested + @DisplayName("extractPipeline invalid configuration") + class ExtractPipelineTests { + + @Test + @DisplayName("missing classpath resource surfaces as IOException") + void missingResource(@TempDir Path tempDir) { + // Point the pipeline path at a temp dir; default pipeline JSONs are absent from + // the common module test classpath, so extraction fails with an IOException. + try (MockedStatic mocked = + Mockito.mockStatic(InstallationPathConfig.class)) { + mocked.when(InstallationPathConfig::getPipelinePath).thenReturn(tempDir.toString()); + assertThrows(IOException.class, GeneralUtils::extractPipeline); + } + } + } + + @Nested + @DisplayName("optimizePdfWithGhostscript failure handling") + class OptimizeGhostscriptTests { + + @Test + @DisplayName("non-zero return code raises a Ghostscript exception") + void nonZeroReturnCode() throws Exception { + ProcessExecutor.ProcessExecutorResult result = + mock(ProcessExecutor.ProcessExecutorResult.class); + when(result.getMessages()).thenReturn("some ghostscript chatter"); + when(result.getRc()).thenReturn(1); + + ProcessExecutor executor = mock(ProcessExecutor.class); + // doReturn avoids referencing the checked-exception-declaring method during stubbing + Mockito.doReturn(result).when(executor).runCommandWithOutputHandling(Mockito.anyList()); + + try (MockedStatic mocked = Mockito.mockStatic(ProcessExecutor.class)) { + mocked.when( + () -> + ProcessExecutor.getInstance( + ProcessExecutor.Processes.GHOSTSCRIPT)) + .thenReturn(executor); + assertThrows( + IOException.class, + () -> GeneralUtils.optimizePdfWithGhostscript(new byte[] {1, 2, 3})); + } + } + + @Test + @DisplayName("detected critical Ghostscript error is rethrown") + void criticalErrorDetected() throws Exception { + ProcessExecutor.ProcessExecutorResult result = + mock(ProcessExecutor.ProcessExecutorResult.class); + when(result.getMessages()).thenReturn("Page 1\ncould not draw this page"); + + ProcessExecutor executor = mock(ProcessExecutor.class); + Mockito.doReturn(result).when(executor).runCommandWithOutputHandling(Mockito.anyList()); + + try (MockedStatic mocked = Mockito.mockStatic(ProcessExecutor.class)) { + mocked.when( + () -> + ProcessExecutor.getInstance( + ProcessExecutor.Processes.GHOSTSCRIPT)) + .thenReturn(executor); + assertThatThrownBy( + () -> GeneralUtils.optimizePdfWithGhostscript(new byte[] {1, 2, 3})) + .isInstanceOf(ExceptionUtils.GhostscriptException.class); + } + } + } + + @Nested + @DisplayName("selectBestSiteLocalIp edge cases") + class SelectBestIpTests { + + @Test + @DisplayName("empty interface list returns null") + void emptyList() { + assertNull(GeneralUtils.selectBestSiteLocalIp(List.of())); + } + + @Test + @DisplayName("non-private routable-style site-local IP still scores and is selected") + void otherRangeStillSelected() { + GeneralUtils.NetworkInterfaceInfo other = + new GeneralUtils.NetworkInterfaceInfo( + "eth0", + "Realtek PCIe GbE Family Controller", + 2, + true, + false, + false, + false, + true, + List.of("172.16.5.5")); + assertEquals("172.16.5.5", GeneralUtils.selectBestSiteLocalIp(List.of(other))); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/PdfToCbrUtilsMoreTest.java b/app/common/src/test/java/stirling/software/common/util/PdfToCbrUtilsMoreTest.java new file mode 100644 index 0000000000..e1117194a1 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/PdfToCbrUtilsMoreTest.java @@ -0,0 +1,195 @@ +package stirling.software.common.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; + +/** + * Gap-filling tests for {@link PdfToCbrUtils#convertPdfToCbr} that drive the real PDFBox render + * loop with a tiny one-page PDF and mock the external {@code rar} process so the archive-creation + * branch is exercised without any external tool. + */ +class PdfToCbrUtilsMoreTest { + + /** A one-page PDF containing a small embedded image so the renderer produces a PNG. */ + private static byte[] onePageImagePdf() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(new PDRectangle(72, 72)); + doc.addPage(page); + + BufferedImage img = new BufferedImage(16, 16, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setColor(Color.BLUE); + g.fillRect(0, 0, 16, 16); + g.dispose(); + PDImageXObject pdImage = LosslessFactory.createFromImage(doc, img); + + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.drawImage(pdImage, 0, 0, 72, 72); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + return baos.toByteArray(); + } + } + + private static MultipartFile pdfMultipart(byte[] bytes) { + return new MockMultipartFile("file", "comic.pdf", "application/pdf", bytes); + } + + private static CustomPDFDocumentFactory factoryReturning(PDDocument document) + throws IOException { + CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class); + when(factory.load(any(MultipartFile.class))).thenReturn(document); + return factory; + } + + @Nested + @DisplayName("convertPdfToCbr - rar process branches") + class RarProcessTests { + + @Test + @DisplayName("non-zero rar exit code surfaces as a processing exception") + void rarNonZeroExit() throws Exception { + PDDocument doc = Loader.loadPDF(onePageImagePdf()); + CustomPDFDocumentFactory factory = factoryReturning(doc); + + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + when(result.getRc()).thenReturn(1); + ProcessExecutor executor = mock(ProcessExecutor.class); + Mockito.doReturn(result).when(executor).runCommandWithOutputHandling(anyList(), any()); + + try (MockedStatic mocked = Mockito.mockStatic(ProcessExecutor.class)) { + mocked.when( + () -> + ProcessExecutor.getInstance( + ProcessExecutor.Processes.INSTALL_APP)) + .thenReturn(executor); + + assertThatThrownBy( + () -> + PdfToCbrUtils.convertPdfToCbr( + pdfMultipart(onePageImagePdf()), 72, factory)) + .isInstanceOf(IOException.class); + } + doc.close(); + } + + @Test + @DisplayName("rc=0 but missing rar output file raises 'RAR file was not created'") + void rarFileNotCreated() throws Exception { + PDDocument doc = Loader.loadPDF(onePageImagePdf()); + CustomPDFDocumentFactory factory = factoryReturning(doc); + + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + when(result.getRc()).thenReturn(0); + ProcessExecutor executor = mock(ProcessExecutor.class); + // No real rar runs, so the expected output.cbr is never produced. + Mockito.doReturn(result).when(executor).runCommandWithOutputHandling(anyList(), any()); + + try (MockedStatic mocked = Mockito.mockStatic(ProcessExecutor.class)) { + mocked.when( + () -> + ProcessExecutor.getInstance( + ProcessExecutor.Processes.INSTALL_APP)) + .thenReturn(executor); + + assertThatThrownBy( + () -> + PdfToCbrUtils.convertPdfToCbr( + pdfMultipart(onePageImagePdf()), 72, factory)) + .isInstanceOf(IOException.class) + .hasMessageContaining("RAR"); + } + doc.close(); + } + + @Test + @DisplayName("an interrupted rar process is wrapped and the thread interrupt is restored") + void rarInterrupted() throws Exception { + PDDocument doc = Loader.loadPDF(onePageImagePdf()); + CustomPDFDocumentFactory factory = factoryReturning(doc); + + ProcessExecutor executor = mock(ProcessExecutor.class); + Mockito.doThrow(new InterruptedException("boom")) + .when(executor) + .runCommandWithOutputHandling(anyList(), any()); + + try (MockedStatic mocked = Mockito.mockStatic(ProcessExecutor.class)) { + mocked.when( + () -> + ProcessExecutor.getInstance( + ProcessExecutor.Processes.INSTALL_APP)) + .thenReturn(executor); + + assertThatThrownBy( + () -> + PdfToCbrUtils.convertPdfToCbr( + pdfMultipart(onePageImagePdf()), 72, factory)) + .isInstanceOf(Exception.class); + } finally { + // Clear the interrupt flag set by the handler so it doesn't leak into later tests. + Thread.interrupted(); + doc.close(); + } + } + } + + @Nested + @DisplayName("convertPdfToCbr - document validation") + class DocumentValidationTests { + + @Test + @DisplayName("a zero-page document raises a no-pages exception before rendering") + void zeroPageDocument() throws Exception { + try (PDDocument empty = new PDDocument()) { + CustomPDFDocumentFactory factory = factoryReturning(empty); + assertThatThrownBy( + () -> + PdfToCbrUtils.convertPdfToCbr( + pdfMultipart(onePageImagePdf()), 72, factory)) + .isInstanceOf(Exception.class); + } + } + } + + @Nested + @DisplayName("isPdfFile") + class IsPdfFileTests { + + @Test + @DisplayName("a .cbr file is not a PDF") + void cbrIsNotPdf() { + MultipartFile file = mock(MultipartFile.class); + when(file.getOriginalFilename()).thenReturn("comic.cbr"); + assertThat(PdfToCbrUtils.isPdfFile(file)).isFalse(); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/PdfUtilsMoreTest.java b/app/common/src/test/java/stirling/software/common/util/PdfUtilsMoreTest.java new file mode 100644 index 0000000000..6ca815e56c --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/PdfUtilsMoreTest.java @@ -0,0 +1,316 @@ +package stirling.software.common.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.stream.ImageOutputStream; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +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.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.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; + +/** + * Further gap-filling tests for {@link PdfUtils}, complementing {@code PdfUtilsTest} and {@code + * PdfUtilsGapTest}: the form-XObject recursion in image discovery, the found-text branch, the + * ApplicationProperties-present DPI lookups, the rotated/duplicate page-size paths, and the + * multi-frame TIFF input path of imageToPdf. + */ +class PdfUtilsMoreTest { + + // ---- helpers ------------------------------------------------------------ + + /** Builds a PDF whose pages each show the given text phrase. */ + private static PDDocument docWithText(String... pageTexts) throws IOException { + PDDocument doc = new PDDocument(); + for (String text : pageTexts) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(100, 700); + cs.showText(text); + cs.endText(); + } + } + return doc; + } + + /** A small one-page PDF serialized to bytes. */ + private static byte[] simplePdfBytes() throws IOException { + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage(PDRectangle.A4)); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + return baos.toByteArray(); + } + } + + /** Builds an ApplicationProperties whose system reports the given max DPI. */ + private static ApplicationProperties propsWithMaxDpi(int dpi) { + ApplicationProperties props = new ApplicationProperties(); + props.getSystem().setMaxDPI(dpi); + return props; + } + + /** Encodes a multi-frame TIFF (two solid-colour frames) to bytes. */ + private static byte[] multiFrameTiff() throws IOException { + ImageWriter writer = ImageIO.getImageWritersByFormatName("tiff").next(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) { + writer.setOutput(ios); + ImageWriteParam param = writer.getDefaultWriteParam(); + writer.prepareWriteSequence(null); + for (Color c : new Color[] {Color.RED, Color.BLUE}) { + BufferedImage img = new BufferedImage(16, 16, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setColor(c); + g.fillRect(0, 0, 16, 16); + g.dispose(); + writer.writeToSequence(new IIOImage(img, null, null), param); + } + writer.endWriteSequence(); + } + writer.dispose(); + return baos.toByteArray(); + } + + // ---- getAllImages recursion -------------------------------------------- + + @Nested + @DisplayName("getAllImages with form XObjects") + class GetAllImagesForm { + + @Test + @DisplayName("images nested inside a form XObject are discovered recursively") + void recursesIntoFormXObject() throws IOException { + try (PDDocument doc = new PDDocument()) { + // Build a form XObject that itself holds an image in its resources. + PDFormXObject form = new PDFormXObject(doc); + form.setResources(new PDResources()); + BufferedImage bi = new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB); + PDImageXObject nested = LosslessFactory.createFromImage(doc, bi); + form.getResources().add(nested); + + PDResources pageResources = new PDResources(); + pageResources.add(form); + + assertThat(PdfUtils.getAllImages(pageResources)).hasSize(1); + } + } + } + + // ---- hasText found branch ---------------------------------------------- + + @Nested + @DisplayName("hasText found branch") + class HasTextFound { + + @Test + @DisplayName("returns true when the phrase is present on a searched page") + void findsPhrase() throws IOException { + try (PDDocument doc = docWithText("NeedleInHaystack")) { + assertThat(PdfUtils.hasText(doc, "all", "NeedleInHaystack")).isTrue(); + } + } + + @Test + @DisplayName("returns true when the phrase is on the requested page only") + void findsPhraseOnSecondPage() throws IOException { + try (PDDocument doc = docWithText("first", "SecondMarker")) { + assertThat(PdfUtils.hasText(doc, "2", "SecondMarker")).isTrue(); + } + } + } + + // ---- convertFromPdf with ApplicationProperties present ------------------ + + @Nested + @DisplayName("convertFromPdf honouring configured max DPI") + class ConvertFromPdfWithProps { + + @Test + @DisplayName("DPI under the configured limit renders; properties branch is taken") + void underConfiguredLimitRenders() throws Exception { + byte[] bytes = simplePdfBytes(); + CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class); + PDDocument doc = new PDDocument(); + doc.addPage(new PDPage(new PDRectangle(20f, 20f))); + when(factory.load(bytes)).thenReturn(doc); + + try (MockedStatic ctx = + Mockito.mockStatic(ApplicationContextProvider.class)) { + ctx.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class)) + .thenReturn(propsWithMaxDpi(200)); + + byte[] out = + PdfUtils.convertFromPdf( + factory, bytes, "png", ImageType.RGB, true, 72, "doc", true); + assertThat(out).isNotEmpty(); + } + } + + @Test + @DisplayName("DPI above the configured limit throws using the configured maximum") + void aboveConfiguredLimitThrows() { + byte[] bytes = new byte[] {1, 2, 3}; + CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class); + + try (MockedStatic ctx = + Mockito.mockStatic(ApplicationContextProvider.class)) { + ctx.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class)) + .thenReturn(propsWithMaxDpi(100)); + + // 150 exceeds the configured limit of 100, so the limit check fires before loading. + org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException.class, + () -> + PdfUtils.convertFromPdf( + factory, + bytes, + "png", + ImageType.RGB, + true, + 150, + "doc", + true)); + } + } + + @Test + @DisplayName("combined-image mode reuses the cached size for duplicate pages") + void combinedImageReusesDuplicatePageSize() throws Exception { + byte[] bytes = simplePdfBytes(); + CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class); + PDDocument doc = new PDDocument(); + // Two identically-sized pages: the second hits the size cache. + doc.addPage(new PDPage(new PDRectangle(20f, 30f))); + doc.addPage(new PDPage(new PDRectangle(20f, 30f))); + when(factory.load(bytes)).thenReturn(doc); + + byte[] out = + PdfUtils.convertFromPdf( + factory, bytes, "png", ImageType.RGB, true, 36, "doc", true); + assertThat(out).isNotEmpty(); + } + + @Test + @DisplayName("combined-image mode swaps dimensions for a rotated page") + void combinedImageRotatedPage() throws Exception { + byte[] bytes = simplePdfBytes(); + CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class); + PDDocument doc = new PDDocument(); + PDPage rotated = new PDPage(new PDRectangle(20f, 30f)); + rotated.setRotation(90); + doc.addPage(rotated); + when(factory.load(bytes)).thenReturn(doc); + + byte[] out = + PdfUtils.convertFromPdf( + factory, bytes, "png", ImageType.RGB, true, 36, "doc", true); + assertThat(out).isNotEmpty(); + } + } + + // ---- convertPdfToPdfImage with ApplicationProperties present ------------ + + @Nested + @DisplayName("convertPdfToPdfImage honouring configured DPI") + class ConvertPdfToPdfImageWithProps { + + @Test + @DisplayName("renders using the configured max DPI when properties are present") + void usesConfiguredDpi() throws IOException { + try (MockedStatic ctx = + Mockito.mockStatic(ApplicationContextProvider.class)) { + ctx.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class)) + .thenReturn(propsWithMaxDpi(72)); + + try (PDDocument source = new PDDocument()) { + source.addPage(new PDPage(new PDRectangle(12f, 18f))); + try (PDDocument result = PdfUtils.convertPdfToPdfImage(source)) { + assertThat(result.getNumberOfPages()).isEqualTo(1); + } + } + } + } + } + + // ---- imageToPdf with a multi-frame TIFF -------------------------------- + + @Nested + @DisplayName("imageToPdf with TIFF input") + class ImageToPdfTiff { + + @Test + @DisplayName("a multi-frame TIFF produces one page per frame") + void multiFrameTiffBecomesMultiplePages() throws IOException { + CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class); + when(factory.createNewDocument()).thenReturn(new PDDocument()); + + MockMultipartFile tiff = + new MockMultipartFile("file", "scan.tiff", "image/tiff", multiFrameTiff()); + + byte[] pdfOut = + PdfUtils.imageToPdf( + new MultipartFile[] {tiff}, "fillPage", false, "color", factory); + + assertThat(pdfOut).isNotEmpty(); + try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(pdfOut)) { + assertThat(doc.getNumberOfPages()).isEqualTo(2); + } + } + + @Test + @DisplayName("a .tif extension is also handled by the TIFF reader path") + void tifExtensionHandled() throws IOException { + CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class); + when(factory.createNewDocument()).thenReturn(new PDDocument()); + + MockMultipartFile tif = + new MockMultipartFile( + "file", + "scan.tif", + MediaType.APPLICATION_OCTET_STREAM_VALUE, + multiFrameTiff()); + + byte[] pdfOut = + PdfUtils.imageToPdf( + new MultipartFile[] {tif}, "fillPage", false, "color", factory); + + try (PDDocument doc = org.apache.pdfbox.Loader.loadPDF(pdfOut)) { + assertThat(doc.getNumberOfPages()).isEqualTo(2); + } + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/ProcessExecutorMoreTest.java b/app/common/src/test/java/stirling/software/common/util/ProcessExecutorMoreTest.java new file mode 100644 index 0000000000..bffab55bbf --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/ProcessExecutorMoreTest.java @@ -0,0 +1,184 @@ +package stirling.software.common.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; + +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; + +/** + * Tests that drive {@link ProcessExecutor#runCommandWithOutputHandling} through its full + * output-handling logic by intercepting {@link ProcessBuilder} construction with {@link + * MockedConstruction}. The {@link Process} is mocked, so no real OS process is ever started. + */ +class ProcessExecutorMoreTest { + + private ProcessExecutor qpdfExecutor() { + return ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF); + } + + private ProcessExecutor ghostscriptExecutor() { + return ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT); + } + + /** Configure a mocked Process with given streams, completion flag and exit code. */ + private static Process mockedProcess( + String stdout, String stderr, boolean finished, int exitCode) + throws InterruptedException { + Process process = mock(Process.class); + when(process.getInputStream()) + .thenReturn(new ByteArrayInputStream(stdout.getBytes(StandardCharsets.UTF_8))); + when(process.getErrorStream()) + .thenReturn(new ByteArrayInputStream(stderr.getBytes(StandardCharsets.UTF_8))); + when(process.waitFor(anyLong(), any(TimeUnit.class))).thenReturn(finished); + when(process.exitValue()).thenReturn(exitCode); + when(process.descendants()).thenReturn(Stream.empty()); + return process; + } + + /** Stub every constructed ProcessBuilder so start() returns the supplied process. */ + private MockedConstruction stubProcessBuilder(Process process) { + return Mockito.mockConstruction( + ProcessBuilder.class, + (mockBuilder, context) -> { + when(mockBuilder.start()).thenReturn(process); + when(mockBuilder.directory(any())).thenReturn(mockBuilder); + }); + } + + @Nested + @DisplayName("runCommandWithOutputHandling - exit code handling") + class ExitCodeTests { + + @Test + @DisplayName("a successful command (exit 0) returns rc=0 and captured output") + void successReturnsZero() throws Exception { + Process process = mockedProcess("hello output", "", true, 0); + try (MockedConstruction ignored = stubProcessBuilder(process)) { + ProcessExecutorResult result = + qpdfExecutor().runCommandWithOutputHandling(List.of("qpdf", "--version")); + assertThat(result.getRc()).isEqualTo(0); + assertThat(result.getMessages()).contains("hello output"); + } + } + + @Test + @DisplayName("a non-zero exit code with error output throws an IOException") + void nonZeroExitThrows() throws Exception { + Process process = mockedProcess("", "fatal: boom", true, 2); + try (MockedConstruction ignored = stubProcessBuilder(process)) { + assertThatThrownBy( + () -> + ghostscriptExecutor() + .runCommandWithOutputHandling( + List.of("gs", "-bad"))) + .isInstanceOf(IOException.class) + .hasMessageContaining("exit code 2"); + } + } + + @Test + @DisplayName("a non-zero exit code without error output still throws with the log tail") + void nonZeroExitNoStderrThrows() throws Exception { + Process process = mockedProcess("some stdout only", "", true, 5); + try (MockedConstruction ignored = stubProcessBuilder(process)) { + assertThatThrownBy( + () -> + ghostscriptExecutor() + .runCommandWithOutputHandling(List.of("gs", "x"))) + .isInstanceOf(IOException.class) + .hasMessageContaining("exit code 5"); + } + } + } + + @Nested + @DisplayName("runCommandWithOutputHandling - qpdf special-casing") + class QpdfTests { + + @Test + @DisplayName("qpdf exit code 3 is treated as success-with-warnings, not a failure") + void qpdfExitThreeIsWarning() throws Exception { + Process process = mockedProcess("", "WARNING: minor issue", true, 3); + try (MockedConstruction ignored = stubProcessBuilder(process)) { + ProcessExecutorResult result = + qpdfExecutor() + .runCommandWithOutputHandling(List.of("qpdf", "--check", "in.pdf")); + assertThat(result.getRc()).isEqualTo(3); + } + } + + @Test + @DisplayName("qpdf exit code 2 is still a hard failure") + void qpdfExitTwoFails() throws Exception { + Process process = mockedProcess("", "ERROR: broken", true, 2); + try (MockedConstruction ignored = stubProcessBuilder(process)) { + assertThatThrownBy( + () -> + qpdfExecutor() + .runCommandWithOutputHandling( + List.of("qpdf", "in.pdf"))) + .isInstanceOf(IOException.class); + } + } + } + + @Nested + @DisplayName("runCommandWithOutputHandling - timeout") + class TimeoutTests { + + @Test + @DisplayName("a process that never finishes is destroyed and an IOException is thrown") + void timeoutThrows() throws Exception { + Process process = mockedProcess("", "", false, 0); + try (MockedConstruction ignored = stubProcessBuilder(process)) { + assertThatThrownBy( + () -> + qpdfExecutor() + .runCommandWithOutputHandling( + List.of("qpdf", "slow"))) + .isInstanceOf(IOException.class) + .hasMessageContaining("timeout"); + Mockito.verify(process).destroyForcibly(); + } + } + } + + @Nested + @DisplayName("runCommandWithOutputHandling - working directory overload") + class WorkingDirectoryTests { + + @Test + @DisplayName("the working-directory overload runs the command and applies the directory") + void withWorkingDirectory() throws Exception { + Process process = mockedProcess("ok", "", true, 0); + try (MockedConstruction construction = stubProcessBuilder(process)) { + ProcessExecutorResult result = + qpdfExecutor() + .runCommandWithOutputHandling( + List.of("qpdf", "--version"), + new java.io.File(System.getProperty("java.io.tmpdir"))); + assertThat(result.getRc()).isEqualTo(0); + // directory(...) must have been applied to the single constructed builder. + ProcessBuilder built = construction.constructed().get(0); + Mockito.verify(built).directory(any(java.io.File.class)); + } + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/RegexPatternUtilsMoreTest.java b/app/common/src/test/java/stirling/software/common/util/RegexPatternUtilsMoreTest.java new file mode 100644 index 0000000000..217f18abf5 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/RegexPatternUtilsMoreTest.java @@ -0,0 +1,354 @@ +package stirling.software.common.util; + +import static org.assertj.core.api.Assertions.assertThat; +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Gap-coverage tests for {@link RegexPatternUtils}. The existing RegexPatternUtilsTest covers + * caching mechanics; this file exercises the many lazily-built named accessor patterns, the static + * regex string getters, flag-aware cache operations, and the invalid-regex compile path. + */ +class RegexPatternUtilsMoreTest { + + private final RegexPatternUtils utils = RegexPatternUtils.getInstance(); + + @Nested + @DisplayName("static regex string getters") + class StaticRegexTests { + + @Test + @DisplayName("whitespace and extension regex strings are returned") + void staticStrings() { + assertEquals("\\s++", RegexPatternUtils.getWhitespaceRegex()); + assertEquals("\\.(?:[^.]*+)?$", RegexPatternUtils.getExtensionRegex()); + } + + @Test + @DisplayName("supported new field types contains the documented set") + void supportedFieldTypes() { + Set types = utils.getSupportedNewFieldTypes(); + assertThat(types) + .contains( + "text", + "checkbox", + "combobox", + "listbox", + "radio", + "button", + "signature"); + } + } + + @Nested + @DisplayName("flag-aware cache operations") + class FlagCacheTests { + + @Test + @DisplayName("removeFromCache with flags removes the flagged entry only") + void removeWithFlags() { + String regex = "moreflagcache\\d+"; + utils.getPattern(regex, Pattern.CASE_INSENSITIVE); + assertTrue(utils.isCached(regex, Pattern.CASE_INSENSITIVE)); + + assertTrue(utils.removeFromCache(regex, Pattern.CASE_INSENSITIVE)); + assertFalse(utils.isCached(regex, Pattern.CASE_INSENSITIVE)); + // Removing again returns false. + assertFalse(utils.removeFromCache(regex, Pattern.CASE_INSENSITIVE)); + } + + @Test + @DisplayName("isCached with flags is false for null regex") + void isCachedNullWithFlags() { + assertFalse(utils.isCached(null, Pattern.CASE_INSENSITIVE)); + } + + @Test + @DisplayName("removeFromCache with flags is false for null regex") + void removeNullWithFlags() { + assertFalse(utils.removeFromCache(null, Pattern.CASE_INSENSITIVE)); + } + } + + @Nested + @DisplayName("invalid regex compilation") + class InvalidRegexTests { + + @Test + @DisplayName("an invalid pattern propagates PatternSyntaxException") + void invalidPattern() { + assertThrows(PatternSyntaxException.class, () -> utils.getPattern("[unclosed")); + } + } + + @Nested + @DisplayName("path and filename patterns") + class PathFilenameTests { + + @Test + void driveLetterPattern() { + assertTrue(utils.getDriveLetterPattern().matcher("C:\\Users\\x").find()); + } + + @Test + void leadingSlashesPattern() { + assertTrue(utils.getLeadingSlashesPattern().matcher("//leading").find()); + } + + @Test + void backslashPattern() { + assertTrue(utils.getBackslashPattern().matcher("a\\b").find()); + } + + @Test + void filenameSafePattern() { + assertTrue(utils.getFilenameSafePattern().matcher("a!b").find()); + } + + @Test + void nonAlnumUnderscorePattern() { + assertTrue(utils.getNonAlnumUnderscorePattern().matcher("a-b").find()); + assertFalse(utils.getNonAlnumUnderscorePattern().matcher("a_b").find()); + } + + @Test + void underscoreCollapsePatterns() { + assertTrue(utils.getMultipleUnderscoresPattern().matcher("a__b").find()); + assertTrue(utils.getLeadingUnderscoresPattern().matcher("__a").find()); + assertTrue(utils.getTrailingUnderscoresPattern().matcher("a__").find()); + } + + @Test + void uploadDownloadPathPattern() { + assertTrue(utils.getUploadDownloadPathPattern().matcher("/api/UPLOAD/file").matches()); + } + } + + @Nested + @DisplayName("whitespace, newline and word patterns") + class WhitespaceNewlineTests { + + @Test + void whitespaceAndWordSplit() { + assertEquals(2, utils.getWordSplitPattern().split("a b").length); + assertTrue(utils.getWhitespacePattern().matcher("a b").find()); + } + + @Test + void punctuationPattern() { + assertTrue(utils.getPunctuationPattern().matcher("a!b").find()); + } + + @Test + void newlineVariants() { + assertTrue(utils.getNewlinesPattern().matcher("a\r\nb").find()); + assertTrue(utils.getNewlineSplitPattern().matcher("a\nb").find()); + assertTrue(utils.getCarriageReturnPattern().matcher("a\rb").find()); + assertTrue(utils.getNewlineCharsPattern().matcher("a\nb").find()); + assertTrue(utils.getMultiFormatNewlinePattern().matcher("a\r\nb").find()); + assertTrue(utils.getEncodedPayloadNewlinePattern().matcher("a\nb").find()); + assertTrue(utils.getLineSeparatorPattern().matcher("a\nb").find()); + } + + @Test + void escapedNewlinePattern() { + assertTrue(utils.getEscapedNewlinePattern().matcher("line\\nbreak").find()); + } + } + + @Nested + @DisplayName("sanitization and field-name patterns") + class SanitizationTests { + + @Test + void inputSanitizePattern() { + assertTrue(utils.getInputSanitizePattern().matcher("a@b").find()); + } + + @Test + void formFieldBracketPattern() { + assertEquals( + "field", utils.getFormFieldBracketPattern().matcher("field[0]").replaceAll("")); + } + + @Test + void underscoreHyphenPattern() { + assertTrue(utils.getUnderscoreHyphenPattern().matcher("a-_b").find()); + } + + @Test + void camelCaseBoundaryPattern() { + assertEquals( + "first Name", + utils.getCamelCaseBoundaryPattern().matcher("firstName").replaceAll(" ")); + } + + @Test + void angleBracketsAndQuotes() { + assertTrue(utils.getAngleBracketsPattern().matcher("ac").find()); + assertTrue(utils.getQuotesRemovalPattern().matcher("\"q\"").find()); + } + + @Test + void plusAndPipe() { + assertTrue(utils.getPlusSignPattern().matcher("a+b").find()); + assertEquals(2, utils.getPipeDelimiterPattern().split("a|b").length); + } + + @Test + void usernameValidationPattern() { + assertTrue(utils.getUsernameValidationPattern().matcher("john_doe1").matches()); + assertFalse(utils.getUsernameValidationPattern().matcher("a--b").matches()); + } + + @Test + void genericAndSimpleFieldPatterns() { + assertTrue(utils.getGenericFieldNamePattern().matcher("Field 1").matches()); + assertTrue(utils.getSimpleFormFieldPattern().matcher("t1").matches()); + assertTrue(utils.getOptionalTNumericPattern().matcher("t 12").matches()); + } + } + + @Nested + @DisplayName("number and math patterns") + class NumberMathTests { + + @Test + void numericExtractionAndDigitPatterns() { + assertTrue(utils.getNumericExtractionPattern().matcher("a1").find()); + assertTrue(utils.getNonDigitDotPattern().matcher("1a").find()); + assertTrue(utils.getDigitDotPattern().matcher("1.0").find()); + assertTrue(utils.getContainsDigitsPattern().matcher("ab12cd").matches()); + assertTrue(utils.getNumberRangePattern().matcher("250").matches()); + } + + @Test + void mathExpressionPatterns() { + assertTrue(utils.getMathExpressionPattern().matcher("2n+1").matches()); + assertTrue(utils.getNumberBeforeNPattern().matcher("4n").find()); + assertTrue(utils.getConsecutiveNPattern().matcher("annb").matches()); + assertTrue(utils.getConsecutiveNReplacementPattern().matcher("nn").find()); + } + } + + @Nested + @DisplayName("url, email and html patterns") + class UrlEmailHtmlTests { + + @Test + void httpAndLinkPatterns() { + assertTrue(utils.getHttpUrlPattern().matcher("https://x.com").matches()); + assertTrue(utils.getUrlLinkPattern().matcher("see http://x.com/a").find()); + assertTrue(utils.getEmailLinkPattern().matcher("a@b.com").find()); + } + + @Test + void emailValidationPattern() { + assertTrue(utils.getEmailValidationPattern().matcher("user@example.com").matches()); + assertFalse(utils.getEmailValidationPattern().matcher("not-an-email").matches()); + } + + @Test + void scriptStyleAndCssPatterns() { + assertTrue(utils.getScriptTagPattern().matcher("").find()); + assertTrue(utils.getStyleTagPattern().matcher("").find()); + assertTrue(utils.getFixedPositionCssPattern().matcher("position: fixed;").find()); + assertTrue(utils.getAbsolutePositionCssPattern().matcher("position: absolute;").find()); + } + + @Test + void inlineCidAndImagePatterns() { + assertTrue(utils.getInlineCidImagePattern().matcher("").find()); + assertTrue(utils.getImageFilePattern().matcher("photo.JPG").matches()); + } + } + + @Nested + @DisplayName("size, temp-file and mime patterns") + class SizeTempMimeTests { + + @Test + void sizeUnitPattern() { + assertTrue(utils.getSizeUnitPattern().matcher("MB").find()); + } + + @Test + void systemTempFilePatterns() { + assertTrue(utils.getSystemTempFile1Pattern().matcher("lu123abc.tmp").find()); + assertTrue(utils.getSystemTempFile2Pattern().matcher("ocr_process42").find()); + } + + @Test + void whitespaceParensSplit() { + assertTrue(utils.getWhitespaceParenthesesSplitPattern().matcher("a (b)").find()); + } + + @Test + void mimeHeaderAndEncodedWord() { + assertTrue(utils.getMimeHeaderWhitespacePattern().matcher("a =?utf-8").find()); + assertTrue(utils.getMimeEncodedWordPattern().matcher("=?utf-8?B?abc?=").find()); + } + + @Test + void fontNamePattern() { + assertTrue(utils.getFontNamePattern().matcher("ABCDEF+Arial").matches()); + } + } + + @Nested + @DisplayName("xml, attachment and api-doc patterns") + class XmlAttachmentApiTests { + + @Test + void accessReadOnlyAndXmpPatterns() { + assertTrue(utils.getAccessReadOnlyPattern().matcher("access=\"readOnly\"").find()); + assertTrue(utils.getPdfAidPartPattern().matcher("pdfaid:part=\"2\"").find()); + assertTrue( + utils.getPdfAidConformancePattern().matcher("pdfaid:conformance=\"B\"").find()); + } + + @Test + void attachmentPatterns() { + assertTrue(utils.getAttachmentSectionPattern().matcher("Attachments (3)").find()); + assertTrue(utils.getAttachmentFilenamePattern().matcher("@ file.txt").find()); + } + + @Test + void pageModeAndApiDocPatterns() { + assertTrue(utils.getPageModePattern().matcher("a/b").find()); + assertTrue(utils.getApiDocOutputTypePattern().matcher("Output: PDF").find()); + assertTrue(utils.getApiDocInputTypePattern().matcher("Input: PDF").find()); + assertTrue(utils.getApiDocTypePattern().matcher("Type: WEB").find()); + } + + @Test + void fileExtensionValidationAndLeadingAsterisks() { + assertTrue(utils.getFileExtensionValidationPattern().matcher("pdf").matches()); + assertFalse(utils.getFileExtensionValidationPattern().matcher("a").matches()); + assertEquals( + "text", + utils.getLeadingAsterisksWhitespacePattern() + .matcher("** text") + .replaceFirst("")); + } + } + + @Test + @DisplayName("every cached accessor returns a non-null pattern") + void accessorsNeverNull() { + assertNotNull(utils.getTrailingSlashesPattern()); + assertNotNull(utils.getSafeFilenamePattern()); + assertNotNull(utils.getWordSplitPattern()); + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/YamlHelperMoreTest.java b/app/common/src/test/java/stirling/software/common/util/YamlHelperMoreTest.java new file mode 100644 index 0000000000..ab05775f6b --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/YamlHelperMoreTest.java @@ -0,0 +1,203 @@ +package stirling.software.common.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +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; +import org.junit.jupiter.api.io.TempDir; +import org.snakeyaml.engine.v2.api.LoadSettings; + +class YamlHelperMoreTest { + + private static final LoadSettings LOAD_SETTINGS = + LoadSettings.builder() + .setUseMarks(true) + .setMaxAliasesForCollections(Integer.MAX_VALUE) + .setAllowRecursiveKeys(true) + .setParseComments(true) + .build(); + + private YamlHelper helper(String yaml) { + return new YamlHelper(LOAD_SETTINGS, yaml); + } + + @Nested + @DisplayName("updateValue value-type handling") + class UpdateValueTypes { + + @Test + @DisplayName("updates an integer value with INT tag") + void integerValue() { + YamlHelper h = helper("server:\n port: 80\n"); + assertThat(h.updateValue(List.of("server", "port"), 8080)).isTrue(); + assertThat(h.getValueByExactKeyPath("server", "port")).isEqualTo("8080"); + } + + @Test + @DisplayName("updates a float value") + void floatValue() { + YamlHelper h = helper("scale:\n factor: 1.0\n"); + assertThat(h.updateValue(List.of("scale", "factor"), 2.5f)).isTrue(); + assertThat(String.valueOf(h.getValueByExactKeyPath("scale", "factor"))) + .startsWith("2.5"); + } + + @Test + @DisplayName("updates a boolean value via string literal") + void booleanValue() { + YamlHelper h = helper("flags:\n on: false\n"); + assertThat(h.updateValue(List.of("flags", "on"), "true")).isTrue(); + assertThat(h.getValueByExactKeyPath("flags", "on")).isEqualTo("true"); + } + + @Test + @DisplayName("replaces a scalar with a Map value (MappingNode)") + void mapValue() { + YamlHelper h = helper("meta:\n data: placeholder\n"); + Map map = new LinkedHashMap<>(); + map.put("author", "alice"); + map.put("year", 2024); + assertThat(h.updateValue(List.of("meta", "data"), map)).isTrue(); + assertThat(h.getValueByExactKeyPath("meta", "data", "author")).isEqualTo("alice"); + assertThat(h.getValueByExactKeyPath("meta", "data", "year")).isEqualTo("2024"); + } + + @Test + @DisplayName("replaces a scalar with a List value (SequenceNode)") + void listValue() { + YamlHelper h = helper("cfg:\n items: x\n"); + assertThat(h.updateValue(List.of("cfg", "items"), List.of("a", "b", "c"))).isTrue(); + Object value = h.getValueByExactKeyPath("cfg", "items"); + assertThat(value).isInstanceOf(List.class); + List list = (List) value; + assertThat(list).hasSize(3); + assertThat(list.toString()).contains("a").contains("b").contains("c"); + } + + @Test + @DisplayName("list with mixed scalar element types is converted") + void mixedListValue() { + YamlHelper h = helper("cfg:\n vals: x\n"); + assertThat(h.updateValue(List.of("cfg", "vals"), List.of("s", 1, 2.5, "true"))) + .isTrue(); + Object value = h.getValueByExactKeyPath("cfg", "vals"); + assertThat((List) value).hasSize(4); + } + + @Test + @DisplayName("updates a previously null scalar") + void nullScalarBecomesValue() { + YamlHelper h = helper("opt:\n value:\n"); + assertThat(h.updateValue(List.of("opt", "value"), "set")).isTrue(); + assertThat(h.getValueByExactKeyPath("opt", "value")).isEqualTo("set"); + } + + @Test + @DisplayName("updates a null scalar to a boolean (BOOL tag promotion)") + void nullScalarBecomesBoolean() { + YamlHelper h = helper("opt:\n enabled:\n"); + assertThat(h.updateValue(List.of("opt", "enabled"), Boolean.TRUE)).isTrue(); + assertThat(h.getValueByExactKeyPath("opt", "enabled")).isEqualTo("true"); + } + + @Test + @DisplayName("returns false when intermediate key path is not a mapping") + void nonMappingPathReturnsFalse() { + YamlHelper h = helper("server:\n port: 80\n"); + // 'port' is a scalar, so descending into it cannot update. + assertThat(h.updateValue(List.of("server", "port", "deeper"), "x")).isFalse(); + } + } + + @Nested + @DisplayName("updateValuesFromYaml") + class UpdateFromYaml { + + @Test + @DisplayName("copies differing existing keys from source into target") + void copiesChangedValues() { + YamlHelper target = helper("server:\n port: 80\n host: localhost\n"); + YamlHelper source = helper("server:\n port: 9090\n host: localhost\n"); + boolean updated = target.updateValuesFromYaml(source, target); + assertThat(updated).isTrue(); + assertThat(target.getValueByExactKeyPath("server", "port")).isEqualTo("9090"); + } + + @Test + @DisplayName("source keys absent from target are not added (no update)") + void unknownKeysIgnored() { + YamlHelper target = helper("server:\n port: 80\n"); + YamlHelper source = helper("server:\n port: 80\n"); + boolean updated = target.updateValuesFromYaml(source, target); + assertThat(updated).isFalse(); + assertThat(target.getValueByExactKeyPath("server", "port")).isEqualTo("80"); + } + } + + @Nested + @DisplayName("save / saveOverride / node tracking") + class SaveAndNodes { + + @Test + @DisplayName("save to the original path is a no-op write but returns the mapping") + void saveSamePathNoRewrite(@TempDir Path tempDir) throws IOException { + Path file = tempDir.resolve("orig.yaml"); + Files.writeString(file, "a:\n b: 1\n"); + YamlHelper h = new YamlHelper(file); + h.updateValue(List.of("a", "b"), 2); + // Same path: method must not rewrite the file but still return a MappingNode. + assertThat(h.save(file)).isNotNull(); + } + + @Test + @DisplayName("saveOverride writes to disk") + void saveOverrideWrites(@TempDir Path tempDir) throws IOException { + YamlHelper h = helper("a:\n b: 1\n"); + h.updateValue(List.of("a", "b"), 42); + Path out = tempDir.resolve("out.yaml"); + h.saveOverride(out); + assertThat(Files.readString(out)).contains("42"); + } + + @Test + @DisplayName("setNewNode then getUpdatedRootNode returns the set node") + void setAndGetNode() { + YamlHelper h = helper("a:\n b: 1\n"); + var root = h.getUpdatedRootNode(); + h.setNewNode(root); + assertThat(h.getUpdatedRootNode()).isSameAs(root); + } + } + + @Nested + @DisplayName("static numeric type checks") + class NumericChecks { + + @Test + @DisplayName("isShort / isByte accept Long and parsable strings") + void shortAndByte() { + assertThat(YamlHelper.isShort(5L)).isTrue(); + assertThat(YamlHelper.isShort("100")).isTrue(); + assertThat(YamlHelper.isShort("notNumeric")).isFalse(); + assertThat(YamlHelper.isByte(1L)).isTrue(); + assertThat(YamlHelper.isByte("7")).isTrue(); + assertThat(YamlHelper.isByte("999999")).isFalse(); + } + + @Test + @DisplayName("isInteger rejects null and non-numeric, accepts boxed integers") + void integerEdges() { + assertThat(YamlHelper.isInteger(null)).isFalse(); + assertThat(YamlHelper.isInteger((byte) 3)).isTrue(); + assertThat(YamlHelper.isInteger((short) 9)).isTrue(); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/ZipExtractionUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/ZipExtractionUtilsTest.java new file mode 100644 index 0000000000..4b3270025d --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/ZipExtractionUtilsTest.java @@ -0,0 +1,256 @@ +package stirling.software.common.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.AfterEach; +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.io.TempDir; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; + +import stirling.software.common.model.ApplicationProperties; + +/** + * Tests for {@link ZipExtractionUtils} that build real in-memory ZIP byte streams and exercise + * detection, flat extraction, nested-ZIP recursion, directory skipping and corrupt-input handling. + * No external process is launched. + */ +class ZipExtractionUtilsTest { + + private TempFileManager tempFileManager; + private final List created = new ArrayList<>(); + + @TempDir Path tempDir; + + @BeforeEach + void setUp() { + ApplicationProperties props = new ApplicationProperties(); + props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString()); + props.getSystem().getTempFileManagement().setPrefix("test-zip-"); + tempFileManager = new TempFileManager(new TempFileRegistry(), props); + } + + @AfterEach + void tearDown() { + for (TempFile tf : created) { + tf.close(); + } + created.clear(); + } + + // ----- helpers ----------------------------------------------------------- + + /** Build a flat ZIP from name->bytes entries. */ + private static byte[] buildZip(String[] names, byte[][] contents) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + for (int i = 0; i < names.length; i++) { + zos.putNextEntry(new ZipEntry(names[i])); + if (contents[i] != null) { + zos.write(contents[i]); + } + zos.closeEntry(); + } + } + return baos.toByteArray(); + } + + private static byte[] bytes(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + private static Resource resource(byte[] data) { + return new ByteArrayResource(data); + } + + private static String drain(Resource r) throws IOException { + try (InputStream is = r.getInputStream()) { + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + + @Nested + @DisplayName("isZip") + class IsZipTests { + + @Test + @DisplayName("real ZIP magic bytes are detected") + void detectsRealZip() throws IOException { + byte[] zip = buildZip(new String[] {"a.txt"}, new byte[][] {bytes("hi")}); + assertThat(ZipExtractionUtils.isZip(resource(zip))).isTrue(); + } + + @Test + @DisplayName("non-ZIP content is rejected") + void rejectsNonZip() throws IOException { + assertThat(ZipExtractionUtils.isZip(resource(bytes("not a zip at all")))).isFalse(); + } + + @Test + @DisplayName("null resource is not a ZIP") + void nullResource() throws IOException { + assertThat(ZipExtractionUtils.isZip(null)).isFalse(); + } + + @Test + @DisplayName("content shorter than the magic prefix is not a ZIP") + void tooShort() throws IOException { + assertThat(ZipExtractionUtils.isZip(resource(new byte[] {0x50, 0x4B}))).isFalse(); + } + + @Test + @DisplayName(".cbz filename is explicitly excluded even with ZIP magic bytes") + void cbzExcluded() throws IOException { + byte[] zip = buildZip(new String[] {"page.png"}, new byte[][] {bytes("img")}); + assertThat(ZipExtractionUtils.isZip(resource(zip), "comic.cbz")).isFalse(); + } + + @Test + @DisplayName(".cbz exclusion is case-insensitive") + void cbzExcludedUppercase() throws IOException { + byte[] zip = buildZip(new String[] {"page.png"}, new byte[][] {bytes("img")}); + assertThat(ZipExtractionUtils.isZip(resource(zip), "COMIC.CBZ")).isFalse(); + } + + @Test + @DisplayName("a non-cbz filename does not suppress detection") + void nonCbzFilenameStillDetected() throws IOException { + byte[] zip = buildZip(new String[] {"a.txt"}, new byte[][] {bytes("x")}); + assertThat(ZipExtractionUtils.isZip(resource(zip), "bundle.zip")).isTrue(); + } + + @Test + @DisplayName("first four bytes that differ from the magic are rejected") + void wrongMagicBytes() throws IOException { + byte[] data = {0x50, 0x4B, 0x05, 0x06, 0x00, 0x00}; + assertThat(ZipExtractionUtils.isZip(resource(data))).isFalse(); + } + } + + @Nested + @DisplayName("extractZip") + class ExtractZipTests { + + @Test + @DisplayName("flat ZIP extracts one resource per file entry with filenames preserved") + void flatExtraction() throws IOException { + byte[] zip = + buildZip( + new String[] {"first.txt", "second.txt"}, + new byte[][] {bytes("one"), bytes("two")}); + + List result = ZipExtractionUtils.extractZip(resource(zip), tempFileManager); + + assertThat(result).hasSize(2); + assertThat(result) + .extracting(Resource::getFilename) + .containsExactlyInAnyOrder("first.txt", "second.txt"); + assertThat(drain(result.get(0)) + drain(result.get(1))).contains("one").contains("two"); + } + + @Test + @DisplayName("directory entries are skipped") + void directoriesSkipped() throws IOException { + byte[] zip = + buildZip( + new String[] {"dir/", "dir/file.txt"}, + new byte[][] {null, bytes("payload")}); + + List result = ZipExtractionUtils.extractZip(resource(zip), tempFileManager); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getFilename()).isEqualTo("dir/file.txt"); + } + + @Test + @DisplayName("empty ZIP yields no resources") + void emptyZip() throws IOException { + byte[] zip = buildZip(new String[] {}, new byte[][] {}); + List result = ZipExtractionUtils.extractZip(resource(zip), tempFileManager); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("nested ZIP entries are recursively expanded") + void nestedExtraction() throws IOException { + byte[] inner = + buildZip(new String[] {"inner.txt"}, new byte[][] {bytes("nested-content")}); + byte[] outer = + buildZip( + new String[] {"top.txt", "child.zip"}, + new byte[][] {bytes("top-content"), inner}); + + List result = ZipExtractionUtils.extractZip(resource(outer), tempFileManager); + + // top.txt + the single file inside child.zip => 2 flat resources + assertThat(result).hasSize(2); + assertThat(result) + .extracting(Resource::getFilename) + .containsExactlyInAnyOrder("top.txt", "inner.txt"); + } + + @Test + @DisplayName("tempFileConsumer receives every created temp file") + void consumerInvoked() throws IOException { + byte[] zip = + buildZip( + new String[] {"a.txt", "b.txt"}, new byte[][] {bytes("a"), bytes("b")}); + + List seen = new ArrayList<>(); + List result = + ZipExtractionUtils.extractZip( + resource(zip), + tempFileManager, + tf -> { + seen.add(tf); + created.add(tf); + }); + + assertThat(result).hasSize(2); + assertThat(seen).hasSize(2); + } + + @Test + @DisplayName("a .cbz entry inside the ZIP is kept as a single file, not recursed") + void cbzEntryNotRecursed() throws IOException { + byte[] innerZip = buildZip(new String[] {"page.png"}, new byte[][] {bytes("imgdata")}); + byte[] outer = buildZip(new String[] {"book.cbz"}, new byte[][] {innerZip}); + + List result = ZipExtractionUtils.extractZip(resource(outer), tempFileManager); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getFilename()).isEqualTo("book.cbz"); + } + + @Test + @DisplayName("a truncated ZIP entry stream surfaces as an IOException") + void corruptZip() throws IOException { + // Build a real ZIP with compressible content, then truncate it mid-stream so the + // deflate entry cannot be fully read and extraction fails. + byte[] valid = + buildZip(new String[] {"big.txt"}, new byte[][] {bytes("A".repeat(8192))}); + byte[] truncated = new byte[valid.length / 2]; + System.arraycopy(valid, 0, truncated, 0, truncated.length); + + assertThatThrownBy( + () -> + ZipExtractionUtils.extractZip( + resource(truncated), tempFileManager)) + .isInstanceOf(IOException.class); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/misc/CustomColorReplaceStrategyMoreTest.java b/app/common/src/test/java/stirling/software/common/util/misc/CustomColorReplaceStrategyMoreTest.java new file mode 100644 index 0000000000..8cc18b4fcd --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/misc/CustomColorReplaceStrategyMoreTest.java @@ -0,0 +1,162 @@ +package stirling.software.common.util.misc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.InputStreamResource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.common.model.api.misc.HighContrastColorCombination; +import stirling.software.common.model.api.misc.ReplaceAndInvert; + +/** + * Gap-filling tests for {@link CustomColorReplaceStrategy#replace()} that run the full restyle loop + * against real, tiny PDFs built in-memory with PDFBox. No external process is launched. + */ +class CustomColorReplaceStrategyMoreTest { + + /** + * A one-page PDF that draws a line of text so the restyle loop has TextPositions to process. + */ + private static byte[] pdfWithText(String text) throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(72, 700); + cs.showText(text); + cs.endText(); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + return baos.toByteArray(); + } + } + + private static byte[] emptyPagePdf() throws IOException { + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage(PDRectangle.A4)); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + return baos.toByteArray(); + } + } + + private static MultipartFile pdf(byte[] bytes) { + return new MockMultipartFile("file", "input.pdf", "application/pdf", bytes); + } + + private static int pageCount(InputStreamResource resource) throws IOException { + try (InputStream is = resource.getInputStream(); + PDDocument doc = Loader.loadPDF(is.readAllBytes())) { + return doc.getNumberOfPages(); + } + } + + @Nested + @DisplayName("replace - custom colours") + class CustomColourTests { + + @Test + @DisplayName("restyles text and overlays a background, returning a valid PDF") + void customColoursProduceValidPdf() throws Exception { + CustomColorReplaceStrategy strategy = + new CustomColorReplaceStrategy( + pdf(pdfWithText("Hello World")), + ReplaceAndInvert.CUSTOM_COLOR, + "#000000", + "#FFFFFF", + null); + + InputStreamResource result = strategy.replace(); + assertThat(result).isNotNull(); + assertThat(pageCount(result)).isEqualTo(1); + } + + @Test + @DisplayName("a page without any text still gets the background overlay") + void emptyPageStillProcessed() throws Exception { + CustomColorReplaceStrategy strategy = + new CustomColorReplaceStrategy( + pdf(emptyPagePdf()), + ReplaceAndInvert.CUSTOM_COLOR, + "#112233", + "#AABBCC", + null); + + InputStreamResource result = strategy.replace(); + assertThat(pageCount(result)).isEqualTo(1); + } + + @Test + @DisplayName("text restyling runs through the font-encoding path without failing") + void fontEncodingPathExercised() throws Exception { + CustomColorReplaceStrategy strategy = + new CustomColorReplaceStrategy( + pdf(pdfWithText("Hi there 123")), + ReplaceAndInvert.CUSTOM_COLOR, + "#101010", + "#FFFFFF", + null); + + InputStreamResource result = strategy.replace(); + assertThat(pageCount(result)).isEqualTo(1); + } + } + + @Nested + @DisplayName("replace - high contrast colours") + class HighContrastTests { + + @Test + @DisplayName("high-contrast mode resolves colours from the combination and produces a PDF") + void highContrastProducesValidPdf() throws Exception { + CustomColorReplaceStrategy strategy = + new CustomColorReplaceStrategy( + pdf(pdfWithText("Contrast")), + ReplaceAndInvert.HIGH_CONTRAST_COLOR, + null, + null, + HighContrastColorCombination.WHITE_TEXT_ON_BLACK); + + InputStreamResource result = strategy.replace(); + assertThat(pageCount(result)).isEqualTo(1); + } + } + + @Nested + @DisplayName("replace - invalid input") + class InvalidInputTests { + + @Test + @DisplayName("a non-PDF payload causes replace() to throw") + void nonPdfThrows() { + CustomColorReplaceStrategy strategy = + new CustomColorReplaceStrategy( + pdf("not a pdf".getBytes()), + ReplaceAndInvert.CUSTOM_COLOR, + "000000", + "FFFFFF", + null); + + assertThatThrownBy(strategy::replace).isInstanceOf(IOException.class); + } + } +} diff --git a/app/core/build.gradle b/app/core/build.gradle index e505ec9838..21acdb36e0 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -332,8 +332,8 @@ tasks.register('cleanFrontendAssets', Delete) { delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) } // Prerendered per-route SPA pages (e.g. compress.html) carry per-tool OG tags and are // copied from the frontend build. Remove stale ones so renamed/removed tools don't linger. - // api-landing.html is a real backend source file, not a generated artifact. - delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html']) + // api-landing.html and mobile-upload.html are real backend source files, not generated artifacts. + delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html']) // Nested prerendered route pages (e.g. settings/people.html) delete new File(resourcesStaticDir, 'settings') } diff --git a/app/core/src/main/java/stirling/software/SPDF/config/PdfMetricsInterceptor.java b/app/core/src/main/java/stirling/software/SPDF/config/PdfMetricsInterceptor.java new file mode 100644 index 0000000000..a99e31a2df --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/PdfMetricsInterceptor.java @@ -0,0 +1,80 @@ +package stirling.software.SPDF.config; + +import java.util.List; + +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; +import org.springframework.web.servlet.HandlerInterceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.service.PdfMetricsService; + +@Component +@Slf4j +@RequiredArgsConstructor +public class PdfMetricsInterceptor implements HandlerInterceptor { + + private final PdfMetricsService pdfMetricsService; + + @Override + public void afterCompletion( + HttpServletRequest request, + HttpServletResponse response, + Object handler, + Exception ex) { + try { + if (!pdfMetricsService.isEnabled()) { + return; + } + if (!"POST".equalsIgnoreCase(request.getMethod()) || response.getStatus() >= 400) { + return; + } + String path = request.getServletPath(); + if (path == null || path.isBlank()) { + path = request.getRequestURI(); + } + if (path == null || !path.contains("/api/v1/")) { + return; + } + if (!(request instanceof MultipartHttpServletRequest multipart)) { + return; + } + if (isFromEditor(request)) { + return; + } + + int fileCount = 0; + for (List bucket : multipart.getMultiFileMap().values()) { + fileCount += bucket.size(); + } + if (fileCount == 0) { + return; + } + + pdfMetricsService.recordOperation(fileCount); + } catch (Exception e) { + log.debug("Failed to record PDF metrics", e); + } + } + + // Editor traffic carries X-Browser-Id, or (if a proxy strips it) a logged-in user's JWT. + // JWTs start "eyJ" and have two dots; API keys do not, so they still count as API. + private boolean isFromEditor(HttpServletRequest request) { + String browserId = request.getHeader("X-Browser-Id"); + if (browserId != null && !browserId.isBlank()) { + return true; + } + String auth = request.getHeader("Authorization"); + if (auth == null || !auth.regionMatches(true, 0, "Bearer ", 0, 7)) { + return false; + } + String token = auth.substring(7).trim(); + return token.startsWith("eyJ") && token.chars().filter(c -> c == '.').count() == 2; + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 367c875744..dac9816018 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -24,6 +24,7 @@ import stirling.software.common.model.ApplicationProperties; public class WebMvcConfig implements WebMvcConfigurer { private final EndpointInterceptor endpointInterceptor; + private final PdfMetricsInterceptor pdfMetricsInterceptor; private final ApplicationProperties applicationProperties; private static final Logger logger = LoggerFactory.getLogger(WebMvcConfig.class); @@ -35,6 +36,7 @@ public class WebMvcConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(endpointInterceptor); + registry.addInterceptor(pdfMetricsInterceptor); } @Override diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 62ce9dac01..1e05ec17b2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -42,6 +42,8 @@ public class ReactRoutingController { private boolean loggedMissingIndex = false; private String cachedSaasLandingHtml; private boolean saasLandingExists = false; + private String cachedMobileUploadHtml; + private boolean mobileUploadHtmlExists = false; @PostConstruct public void init() { @@ -64,6 +66,12 @@ public class ReactRoutingController { } } + // Desktop (Tauri) serves the SPA from its bundled webview, so a phone scanning the QR can't + // load the React /mobile-scanner route from the local backend. Cache the self-contained + // static upload page to serve at that route in desktop mode instead. + this.cachedMobileUploadHtml = readStaticHtml("mobile-upload.html"); + this.mobileUploadHtmlExists = this.cachedMobileUploadHtml != null; + // Check for external index.html first (customFiles/static/) Path externalIndexPath = Path.of(InstallationPathConfig.getStaticPath(), "index.html"); log.debug("Checking for custom index.html at: {}", externalIndexPath); @@ -144,6 +152,28 @@ public class ReactRoutingController { return new ClassPathResource("static/index.html"); } + private String readStaticHtml(String filename) { + try { + Path external = Path.of(InstallationPathConfig.getStaticPath(), filename); + if (Files.exists(external) && Files.isReadable(external)) { + return Files.readString(external, StandardCharsets.UTF_8); + } + ClassPathResource resource = new ClassPathResource("static/" + filename); + if (resource.exists()) { + try (InputStream in = resource.getInputStream()) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + } catch (Exception ex) { + log.warn("Failed to read static HTML {}", filename, ex); + } + return null; + } + + private static boolean isDesktopMode() { + return Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false")); + } + @GetMapping( value = {"/", "/index.html"}, produces = MediaType.TEXT_HTML_VALUE) @@ -191,6 +221,17 @@ public class ReactRoutingController { return serveIndexHtml(request); } + @GetMapping(value = "/mobile-scanner", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity serveMobileScanner(HttpServletRequest request) { + if (isDesktopMode() && mobileUploadHtmlExists) { + return ResponseEntity.ok() + .cacheControl(CacheControl.noCache().mustRevalidate()) + .contentType(MediaType.TEXT_HTML) + .body(cachedMobileUploadHtml); + } + return serveIndexHtml(request); + } + @GetMapping(value = "/auth/callback/tauri", produces = MediaType.TEXT_HTML_VALUE) public ResponseEntity serveTauriAuthCallback(HttpServletRequest request) { // cachedCallbackHtml is always initialized in @PostConstruct diff --git a/app/core/src/main/java/stirling/software/SPDF/service/PdfMetricsService.java b/app/core/src/main/java/stirling/software/SPDF/service/PdfMetricsService.java new file mode 100644 index 0000000000..173ca6e551 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/PdfMetricsService.java @@ -0,0 +1,66 @@ +package stirling.software.SPDF.service; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.PostHogService; + +@Service +public class PdfMetricsService { + + private final PostHogService postHogService; + private final ApplicationProperties applicationProperties; + + private final AtomicLong operations = new AtomicLong(); + private final AtomicLong pdfs = new AtomicLong(); + private long lastOperations; + private long lastPdfs; + + public PdfMetricsService( + PostHogService postHogService, ApplicationProperties applicationProperties) { + this.postHogService = postHogService; + this.applicationProperties = applicationProperties; + } + + public boolean isEnabled() { + return applicationProperties.getSystem().isPosthogEnabled(); + } + + public void recordOperation(int pdfCount) { + if (!isEnabled()) { + return; + } + operations.incrementAndGet(); + if (pdfCount > 0) { + pdfs.addAndGet(pdfCount); + } + } + + @Scheduled(fixedRate = 7200000) + public void flushMetrics() { + if (!isEnabled()) { + return; + } + long curOps = operations.get(); + long curPdfs = pdfs.get(); + long opsDelta = curOps - lastOperations; + long pdfsDelta = curPdfs - lastPdfs; + if (opsDelta <= 0 && pdfsDelta <= 0) { + return; + } + + Map props = new HashMap<>(); + props.put("source", "api"); + props.put("operations", opsDelta); + props.put("pdfs", pdfsDelta); + postHogService.captureEvent("pdf_operation_metrics", props); + + lastOperations = curOps; + lastPdfs = curPdfs; + } +} diff --git a/app/core/src/main/resources/static/mobile-upload.html b/app/core/src/main/resources/static/mobile-upload.html new file mode 100644 index 0000000000..f96f9f4f53 --- /dev/null +++ b/app/core/src/main/resources/static/mobile-upload.html @@ -0,0 +1,572 @@ + + + + + + + + Stirling PDF - Mobile Upload + + + + + + + + +
+
+ +
+ + + + +
Mobile Upload
+
+
+ +
+
Connecting…
+ +
+ + +
+ + + + +
+ + + + + +

Add photos or files, then upload. They appear on your computer automatically.

+
+ + + +
Stirling PDF · files transfer directly to your desktop
+
+ + + + diff --git a/app/core/src/test/java/org/apache/pdfbox/examples/signature/TSAClientTest.java b/app/core/src/test/java/org/apache/pdfbox/examples/signature/TSAClientTest.java new file mode 100644 index 0000000000..ebbbc518b7 --- /dev/null +++ b/app/core/src/test/java/org/apache/pdfbox/examples/signature/TSAClientTest.java @@ -0,0 +1,333 @@ +package org.apache.pdfbox.examples.signature; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.math.BigInteger; +import java.net.URL; +import java.net.URLConnection; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.Set; + +import javax.security.auth.x500.X500Principal; + +import org.bouncycastle.asn1.x509.AlgorithmIdentifier; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.DigestCalculator; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; +import org.bouncycastle.tsp.TimeStampRequest; +import org.bouncycastle.tsp.TimeStampResponse; +import org.bouncycastle.tsp.TimeStampResponseGenerator; +import org.bouncycastle.tsp.TimeStampTokenGenerator; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Exercises the vendored PDFBox {@link TSAClient}. The TSA HTTP boundary is replaced with a mocked + * {@link URL}/{@link URLConnection} so no real network is ever contacted. A real BouncyCastle TSA + * response is generated in-process to drive the success path. + */ +@DisplayName("TSAClient (vendored PDFBox) Tests") +class TSAClientTest { + + private static KeyPair tsaKeyPair; + private static X509Certificate tsaCert; + + @BeforeAll + static void setUpProviderAndCert() throws Exception { + if (Security.getProvider("BC") == null) { + Security.addProvider(new BouncyCastleProvider()); + } + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + tsaKeyPair = kpg.generateKeyPair(); + tsaCert = selfSignedCert(tsaKeyPair); + } + + private static X509Certificate selfSignedCert(KeyPair kp) throws Exception { + X500Principal dn = new X500Principal("CN=Test TSA"); + long now = System.currentTimeMillis(); + Date from = new Date(now - 1000L); + Date to = new Date(now + 365L * 24 * 60 * 60 * 1000); + BigInteger serial = BigInteger.valueOf(now); + ContentSigner signer = new JcaContentSignerBuilder("SHA256WithRSA").build(kp.getPrivate()); + JcaX509v3CertificateBuilder builder = + new JcaX509v3CertificateBuilder(dn, serial, from, to, dn, kp.getPublic()); + // RFC 3161 requires the TSA signing cert to carry a critical id-kp-timeStamping EKU. + builder.addExtension( + Extension.extendedKeyUsage, + true, + new ExtendedKeyUsage(KeyPurposeId.id_kp_timeStamping)); + X509CertificateHolder holder = builder.build(signer); + return new JcaX509CertificateConverter().setProvider("BC").getCertificate(holder); + } + + /** Builds a valid RFC 3161 timestamp response matching the supplied request bytes. */ + private static byte[] buildTsaResponse(byte[] requestBytes) throws Exception { + TimeStampRequest request = new TimeStampRequest(requestBytes); + + // SHA-1 digest calculator for the token's messageImprint of the signer cert. + DigestCalculator sha1 = + new JcaDigestCalculatorProviderBuilder() + .setProvider("BC") + .build() + .get( + new AlgorithmIdentifier( + org.bouncycastle.asn1.oiw.OIWObjectIdentifiers.idSHA1)); + + ContentSigner signer = + new JcaContentSignerBuilder("SHA256WithRSA").build(tsaKeyPair.getPrivate()); + + TimeStampTokenGenerator tokenGen = + new TimeStampTokenGenerator( + new org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder( + new JcaDigestCalculatorProviderBuilder() + .setProvider("BC") + .build()) + .build(signer, tsaCert), + sha1, + new org.bouncycastle.asn1.ASN1ObjectIdentifier("1.2.3.4.1")); + // Embed the signer certificate so the response token validates standalone. + tokenGen.addCertificates( + new org.bouncycastle.cert.jcajce.JcaCertStore(java.util.List.of(tsaCert))); + + // Accept the SHA-256 digest used by the request's messageImprint. + Set acceptedAlgorithms = + Set.of(org.bouncycastle.asn1.nist.NISTObjectIdentifiers.id_sha256.getId()); + TimeStampResponseGenerator responseGen = + new TimeStampResponseGenerator(tokenGen, acceptedAlgorithms); + TimeStampResponse response = responseGen.generate(request, BigInteger.ONE, new Date()); + return response.getEncoded(); + } + + private TSAClient newClient(URL url, String username, String password) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return new TSAClient(url, username, password, digest); + } + + @Nested + @DisplayName("Successful timestamp request") + class SuccessTests { + + @Test + @DisplayName("Returns a parsed time stamp token from a valid TSA response") + void returnsTokenOnValidResponse() throws Exception { + URL url = mock(URL.class); + URLConnection connection = mock(URLConnection.class); + when(url.openConnection()).thenReturn(connection); + + CapturingOutputStream sink = new CapturingOutputStream(); + when(connection.getOutputStream()).thenReturn(sink); + // Lazily build the response based on the request actually written to the connection. + ResponseSupplierInputStream responseStream = new ResponseSupplierInputStream(sink); + when(connection.getInputStream()).thenReturn(responseStream); + + TSAClient client = newClient(url, null, null); + var token = client.getTimeStampToken(new ByteArrayInputStream("hello pdf".getBytes())); + + assertThat(token).isNotNull(); + assertThat(token.getTimeStampInfo()).isNotNull(); + // Content-Type header is always set for the timestamp query. + verify(connection).setRequestProperty("Content-Type", "application/timestamp-query"); + verify(connection).setDoOutput(true); + verify(connection).setDoInput(true); + } + + @Test + @DisplayName("Sends a Basic Authorization header when credentials are supplied") + void addsBasicAuthHeaderWhenCredentialsPresent() throws Exception { + URL url = mock(URL.class); + URLConnection connection = mock(URLConnection.class); + when(url.openConnection()).thenReturn(connection); + when(connection.getContentEncoding()).thenReturn(null); + + CapturingOutputStream sink = new CapturingOutputStream(); + when(connection.getOutputStream()).thenReturn(sink); + when(connection.getInputStream()).thenReturn(new ResponseSupplierInputStream(sink)); + + TSAClient client = newClient(url, "user", "secret"); + client.getTimeStampToken(new ByteArrayInputStream("data".getBytes())); + + verify(connection) + .setRequestProperty( + org.mockito.ArgumentMatchers.eq("Authorization"), + org.mockito.ArgumentMatchers.startsWith("Basic ")); + } + + @Test + @DisplayName("Does not send Authorization header when username is empty") + void noAuthHeaderWhenUsernameEmpty() throws Exception { + URL url = mock(URL.class); + URLConnection connection = mock(URLConnection.class); + when(url.openConnection()).thenReturn(connection); + + CapturingOutputStream sink = new CapturingOutputStream(); + when(connection.getOutputStream()).thenReturn(sink); + when(connection.getInputStream()).thenReturn(new ResponseSupplierInputStream(sink)); + + TSAClient client = newClient(url, "", "secret"); + client.getTimeStampToken(new ByteArrayInputStream("data".getBytes())); + + verify(connection, never()) + .setRequestProperty(org.mockito.ArgumentMatchers.eq("Authorization"), any()); + } + } + + @Nested + @DisplayName("Error handling") + class ErrorTests { + + @Test + @DisplayName("Propagates IOException raised while writing the request") + void propagatesWriteFailure() throws Exception { + URL url = mock(URL.class); + URLConnection connection = mock(URLConnection.class); + when(url.openConnection()).thenReturn(connection); + OutputStream failing = + new OutputStream() { + @Override + public void write(int b) throws IOException { + throw new IOException("write boom"); + } + }; + when(connection.getOutputStream()).thenReturn(failing); + + TSAClient client = newClient(url, null, null); + + assertThatThrownBy( + () -> + client.getTimeStampToken( + new ByteArrayInputStream("data".getBytes()))) + .isInstanceOf(IOException.class) + .hasMessageContaining("write boom"); + } + + @Test + @DisplayName("Propagates IOException raised while reading the response") + void propagatesReadFailure() throws Exception { + URL url = mock(URL.class); + URLConnection connection = mock(URLConnection.class); + when(url.openConnection()).thenReturn(connection); + when(connection.getOutputStream()).thenReturn(new CapturingOutputStream()); + when(connection.getInputStream()).thenThrow(new IOException("read boom")); + + TSAClient client = newClient(url, null, null); + + assertThatThrownBy( + () -> + client.getTimeStampToken( + new ByteArrayInputStream("data".getBytes()))) + .isInstanceOf(IOException.class) + .hasMessageContaining("read boom"); + } + + @Test + @DisplayName("Wraps a malformed (non-TSP) response as an IOException") + void wrapsMalformedResponse() throws Exception { + URL url = mock(URL.class); + URLConnection connection = mock(URLConnection.class); + when(url.openConnection()).thenReturn(connection); + when(connection.getOutputStream()).thenReturn(new CapturingOutputStream()); + when(connection.getInputStream()) + .thenReturn(new ByteArrayInputStream("not a tsp response".getBytes())); + + TSAClient client = newClient(url, null, null); + + assertThatThrownBy( + () -> + client.getTimeStampToken( + new ByteArrayInputStream("data".getBytes()))) + .isInstanceOf(IOException.class); + } + + @Test + @DisplayName("Throws when the connection cannot be opened") + void throwsWhenConnectionFails() throws Exception { + URL url = mock(URL.class); + when(url.openConnection()).thenThrow(new IOException("no route")); + + TSAClient client = newClient(url, null, null); + + assertThatThrownBy( + () -> + client.getTimeStampToken( + new ByteArrayInputStream("data".getBytes()))) + .isInstanceOf(IOException.class) + .hasMessageContaining("no route"); + } + } + + /** Collects everything written so the matching TSA response can be generated afterward. */ + private static final class CapturingOutputStream extends OutputStream { + private final ByteArrayOutputStream delegate = new ByteArrayOutputStream(); + + @Override + public void write(int b) { + delegate.write(b); + } + + @Override + public void write(byte[] b, int off, int len) { + delegate.write(b, off, len); + } + + byte[] toByteArray() { + return delegate.toByteArray(); + } + } + + /** Lazily builds the TSA response from the request captured by the sink on first read. */ + private static final class ResponseSupplierInputStream extends java.io.InputStream { + private final CapturingOutputStream sink; + private ByteArrayInputStream delegate; + + ResponseSupplierInputStream(CapturingOutputStream sink) { + this.sink = sink; + } + + private ByteArrayInputStream delegate() { + if (delegate == null) { + try { + delegate = new ByteArrayInputStream(buildTsaResponse(sink.toByteArray())); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + return delegate; + } + + @Override + public int read() { + return delegate().read(); + } + + @Override + public int read(byte[] b, int off, int len) { + return delegate().read(b, off, len); + } + } +} diff --git a/app/core/src/test/java/org/apache/pdfbox/examples/signature/ValidationTimeStampTest.java b/app/core/src/test/java/org/apache/pdfbox/examples/signature/ValidationTimeStampTest.java new file mode 100644 index 0000000000..fdb9eb41e4 --- /dev/null +++ b/app/core/src/test/java/org/apache/pdfbox/examples/signature/ValidationTimeStampTest.java @@ -0,0 +1,35 @@ +package org.apache.pdfbox.examples.signature; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ValidationTimeStamp}. Only the constructor is exercised; it builds a + * TSAClient object without performing any network I/O. + */ +class ValidationTimeStampTest { + + @Test + @DisplayName("null tsaUrl leaves the client unset and constructs cleanly") + void nullUrl() throws Exception { + ValidationTimeStamp vts = new ValidationTimeStamp(null); + assertThat(vts).isNotNull(); + } + + @Test + @DisplayName("valid tsaUrl builds the timestamp client without contacting the network") + void validUrl() throws Exception { + ValidationTimeStamp vts = new ValidationTimeStamp("http://timestamp.example.com/tsa"); + assertThat(vts).isNotNull(); + } + + @Test + @DisplayName("malformed tsaUrl is rejected with an exception") + void malformedUrl() { + assertThatThrownBy(() -> new ValidationTimeStamp("http:// bad host/tsa")) + .isInstanceOf(Exception.class); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/LibreOfficeListenerTest.java b/app/core/src/test/java/stirling/software/SPDF/LibreOfficeListenerTest.java new file mode 100644 index 0000000000..670ecc6748 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/LibreOfficeListenerTest.java @@ -0,0 +1,186 @@ +package stirling.software.SPDF; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; + +import java.lang.reflect.Field; +import java.net.Socket; +import java.util.concurrent.ExecutorService; + +import org.junit.jupiter.api.AfterEach; +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.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import io.github.pixee.security.SystemCommand; + +/** + * Unit tests for {@link LibreOfficeListener}. The process, socket and SystemCommand boundaries are + * mocked so no real soffice/unoconv process or network connection is ever created. + */ +@DisplayName("LibreOfficeListener") +class LibreOfficeListenerTest { + + private LibreOfficeListener listener; + + @BeforeEach + void setUp() { + listener = LibreOfficeListener.getInstance(); + } + + @AfterEach + void tearDown() throws Exception { + // Reset the singleton's mutable state so tests stay independent. + ExecutorService es = readExecutor(); + if (es != null) { + es.shutdownNow(); + } + setField("process", null); + setField("executorService", null); + } + + private void setField(String name, Object value) throws Exception { + Field f = LibreOfficeListener.class.getDeclaredField(name); + f.setAccessible(true); + f.set(listener, value); + } + + private Object readField(String name) throws Exception { + Field f = LibreOfficeListener.class.getDeclaredField(name); + f.setAccessible(true); + return f.get(listener); + } + + private ExecutorService readExecutor() throws Exception { + return (ExecutorService) readField("executorService"); + } + + @Nested + @DisplayName("getInstance") + class GetInstance { + + @Test + @DisplayName("always returns the same singleton instance") + void singletonIsStable() { + assertThat(LibreOfficeListener.getInstance()) + .isSameAs(LibreOfficeListener.getInstance()); + } + } + + @Nested + @DisplayName("start") + class Start { + + @Test + @DisplayName("returns immediately when a live process already exists") + void alreadyRunningShortCircuits() throws Exception { + Process alive = Mockito.mock(Process.class); + Mockito.when(alive.isAlive()).thenReturn(true); + setField("process", alive); + + try (MockedStatic sys = Mockito.mockStatic(SystemCommand.class)) { + listener.start(); + + // No new process is spawned when one is already alive. + sys.verifyNoInteractions(); + assertThat(readField("process")).isSameAs(alive); + assertThat(readExecutor()).isNull(); + } + } + + @Test + @DisplayName("spawns the listener and returns once the socket connects") + void spawnsAndDetectsRunningListener() throws Exception { + Process spawned = Mockito.mock(Process.class); + + try (MockedStatic sys = Mockito.mockStatic(SystemCommand.class); + MockedConstruction socket = Mockito.mockConstruction(Socket.class)) { + + sys.when(() -> SystemCommand.runCommand(any(Runtime.class), anyString())) + .thenReturn(spawned); + + listener.start(); + + // The spawned process is retained and the monitor executor is created. + assertThat(readField("process")).isSameAs(spawned); + assertThat(readExecutor()).isNotNull(); + // A socket was constructed and connected exactly once on the first poll. + assertThat(socket.constructed()).hasSize(1); + Mockito.verify(socket.constructed().get(0)).connect(any(), eq(1000)); + sys.verify( + () -> + SystemCommand.runCommand( + any(Runtime.class), eq("unoconv --listener"))); + } + } + + @Test + @DisplayName("retries until the listener socket becomes reachable") + void retriesUntilSocketReachable() throws Exception { + Process spawned = Mockito.mock(Process.class); + + // First socket fails to connect, second succeeds; start() should poll twice. + try (MockedStatic sys = Mockito.mockStatic(SystemCommand.class); + MockedConstruction socket = + Mockito.mockConstruction( + Socket.class, + (mock, ctx) -> { + if (ctx.getCount() == 1) { + Mockito.doThrow(new java.io.IOException("refused")) + .when(mock) + .connect(any(), eq(1000)); + } + })) { + + sys.when(() -> SystemCommand.runCommand(any(Runtime.class), anyString())) + .thenReturn(spawned); + + listener.start(); + + // At least two sockets were attempted before one connected. + assertThat(socket.constructed().size()).isGreaterThanOrEqualTo(2); + } + } + } + + @Nested + @DisplayName("stop") + class Stop { + + @Test + @DisplayName("shuts down the monitor and destroys a live process") + void destroysLiveProcess() throws Exception { + Process alive = Mockito.mock(Process.class); + Mockito.when(alive.isAlive()).thenReturn(true); + ExecutorService es = Mockito.mock(ExecutorService.class); + setField("process", alive); + setField("executorService", es); + + listener.stop(); + + Mockito.verify(es).shutdownNow(); + Mockito.verify(alive).destroy(); + } + + @Test + @DisplayName("does not destroy a process that is already dead") + void skipsDestroyWhenProcessDead() throws Exception { + Process dead = Mockito.mock(Process.class); + Mockito.when(dead.isAlive()).thenReturn(false); + ExecutorService es = Mockito.mock(ExecutorService.class); + setField("process", dead); + setField("executorService", es); + + listener.stop(); + + Mockito.verify(es).shutdownNow(); + Mockito.verify(dead, Mockito.never()).destroy(); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/SPDFApplicationExtraTest.java b/app/core/src/test/java/stirling/software/SPDF/SPDFApplicationExtraTest.java new file mode 100644 index 0000000000..9dbd17c5f8 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/SPDFApplicationExtraTest.java @@ -0,0 +1,168 @@ +package stirling.software.SPDF; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.env.Environment; + +import stirling.software.common.configuration.AppConfig; +import stirling.software.common.model.ApplicationProperties; + +/** + * Remaining static-helper and lifecycle coverage for {@link SPDFApplication} that the existing + * {@code SPDFApplicationMoreTest} does not reach: profile selection, classpath probing, the + * setServerPortStatic auto/explicit branches and the non-Tauri init path. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("SPDFApplication remaining coverage") +class SPDFApplicationExtraTest { + + @Mock private AppConfig appConfig; + @Mock private Environment env; + @Mock private ApplicationProperties applicationProperties; + + private static Object invokeStatic(String name, Class[] sig, Object... args) + throws Exception { + Method m = SPDFApplication.class.getDeclaredMethod(name, sig); + m.setAccessible(true); + return m.invoke(null, args); + } + + @Nested + @DisplayName("getActiveProfile") + class GetActiveProfile { + + private String[] activeProfile(String[] args) throws Exception { + return (String[]) + invokeStatic( + "getActiveProfile", new Class[] {String[].class}, (Object) args); + } + + @Test + @DisplayName("explicit --spring.profiles.active wins over classpath detection") + void explicitProfile() throws Exception { + String[] result = activeProfile(new String[] {"--spring.profiles.active=foo,bar"}); + assertThat(result).containsExactly("foo", "bar"); + } + + @Test + @DisplayName("a single explicit profile is honoured") + void singleExplicitProfile() throws Exception { + String[] result = activeProfile(new String[] {"--spring.profiles.active=custom"}); + assertThat(result).containsExactly("custom"); + } + + @Test + @DisplayName("null args fall through to classpath-based detection") + void nullArgsFallThrough() throws Exception { + // Falls through to classpath detection; exact profile depends on the build flavor. + String[] result = activeProfile(null); + assertThat(result).isNotNull().isNotEmpty(); + } + + @Test + @DisplayName("no profile arg falls through to classpath-based detection") + void noProfileArgFallThrough() throws Exception { + String[] result = activeProfile(new String[] {"--server.port=9090"}); + assertThat(result).isNotNull().isNotEmpty(); + } + } + + @Nested + @DisplayName("isClassPresent") + class IsClassPresent { + + private boolean present(String className) throws Exception { + return (boolean) + invokeStatic("isClassPresent", new Class[] {String.class}, className); + } + + @Test + @DisplayName("returns true for a class on the classpath") + void existingClass() throws Exception { + assertThat(present("stirling.software.SPDF.SPDFApplication")).isTrue(); + } + + @Test + @DisplayName("returns false for a missing class") + void missingClass() throws Exception { + assertThat(present("com.example.totally.Missing")).isFalse(); + } + } + + @Nested + @DisplayName("setServerPortStatic") + class SetServerPortStatic { + + @Test + @DisplayName("'auto' maps to Spring's 0 (auto-assign) port") + void autoMapsToZero() { + SPDFApplication.setServerPortStatic("auto"); + assertThat(SPDFApplication.getStaticPort()).isEqualTo("0"); + } + + @Test + @DisplayName("'AUTO' is matched case-insensitively") + void autoCaseInsensitive() { + SPDFApplication.setServerPortStatic("AUTO"); + assertThat(SPDFApplication.getStaticPort()).isEqualTo("0"); + } + + @Test + @DisplayName("an explicit port is stored verbatim") + void explicitPort() { + SPDFApplication.setServerPortStatic("8443"); + assertThat(SPDFApplication.getStaticPort()).isEqualTo("8443"); + } + } + + @Nested + @DisplayName("init (non-Tauri, browser disabled)") + class InitNonTauri { + + @Test + @DisplayName("populates the static URL fields without opening a browser") + void initBrowserDisabled() { + System.clearProperty("STIRLING_PDF_TAURI_MODE"); + when(appConfig.getBackendUrl()).thenReturn("http://localhost"); + when(appConfig.getContextPath()).thenReturn("/app"); + when(appConfig.getServerPort()).thenReturn("9000"); + when(env.getProperty("BROWSER_OPEN")).thenReturn(null); + + SPDFApplication app = new SPDFApplication(appConfig, env, applicationProperties); + app.init(); + + assertThat(SPDFApplication.getStaticBaseUrl()).isEqualTo("http://localhost:9000"); + assertThat(SPDFApplication.getStaticContextPath()).isEqualTo("/app"); + assertThat(SPDFApplication.getStaticPort()).isEqualTo("9000"); + } + } + + @Test + @DisplayName("getStaticBaseUrl reflects the most recent init") + void staticBaseUrlReflectsInit() { + AppConfig cfg = mock(AppConfig.class); + when(cfg.getBackendUrl()).thenReturn("https://example.org"); + when(cfg.getContextPath()).thenReturn("/"); + when(cfg.getServerPort()).thenReturn("443"); + Environment e = mock(Environment.class); + when(e.getProperty("BROWSER_OPEN")).thenReturn("false"); + + new SPDFApplication(cfg, e, applicationProperties).init(); + + // default https port 443 is omitted from the normalized base url + assertThat(SPDFApplication.getStaticBaseUrl()).isEqualTo("https://example.org"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/SPDFApplicationMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/SPDFApplicationMoreTest.java new file mode 100644 index 0000000000..c302ca56a6 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/SPDFApplicationMoreTest.java @@ -0,0 +1,237 @@ +package stirling.software.SPDF; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.AfterEach; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.core.env.Environment; + +import stirling.software.common.configuration.AppConfig; +import stirling.software.common.model.ApplicationProperties; + +/** Static URL helpers and lifecycle branches of SPDFApplication that need no Spring context. */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("SPDFApplication extra coverage") +class SPDFApplicationMoreTest { + + private static final String TAURI_PROP = "STIRLING_PDF_TAURI_MODE"; + private static final String BROWSER_OPEN = "BROWSER_OPEN"; + + @Mock private AppConfig appConfig; + @Mock private Environment env; + @Mock private ApplicationProperties applicationProperties; + + private String originalTauri; + + @BeforeEach + void setUp() { + originalTauri = System.getProperty(TAURI_PROP); + System.clearProperty(TAURI_PROP); + } + + @AfterEach + void tearDown() { + if (originalTauri == null) { + System.clearProperty(TAURI_PROP); + } else { + System.setProperty(TAURI_PROP, originalTauri); + } + } + + private static Object invokeStatic(String name, Class[] sig, Object... args) + throws Exception { + Method m = SPDFApplication.class.getDeclaredMethod(name, sig); + m.setAccessible(true); + return m.invoke(null, args); + } + + @Nested + @DisplayName("normalizeBackendUrl") + class NormalizeBackendUrl { + + private String normalize(String url, String port) throws Exception { + return (String) + invokeStatic( + "normalizeBackendUrl", + new Class[] {String.class, String.class}, + url, + port); + } + + @Test + @DisplayName("blank backend url defaults to localhost with port") + void blankDefaultsLocalhost() throws Exception { + assertThat(normalize("", "8080")).isEqualTo("http://localhost:8080"); + } + + @Test + @DisplayName("adds scheme when missing and appends non-default port") + void addsSchemeAndPort() throws Exception { + assertThat(normalize("example.com", "9000")).isEqualTo("http://example.com:9000"); + } + + @Test + @DisplayName("omits default http port 80") + void omitsDefaultHttpPort() throws Exception { + assertThat(normalize("http://example.com", "80")).isEqualTo("http://example.com"); + } + + @Test + @DisplayName("omits default https port 443") + void omitsDefaultHttpsPort() throws Exception { + assertThat(normalize("https://example.com", "443")).isEqualTo("https://example.com"); + } + + @Test + @DisplayName("strips trailing slashes") + void stripsTrailingSlash() throws Exception { + assertThat(normalize("http://example.com///", "80")).isEqualTo("http://example.com"); + } + + @Test + @DisplayName("keeps an explicit port already in the url") + void keepsExplicitPort() throws Exception { + assertThat(normalize("http://example.com:1234", null)) + .isEqualTo("http://example.com:1234"); + } + } + + @Nested + @DisplayName("buildFullUrl") + class BuildFullUrl { + + private String build(String base, String port, String ctx) throws Exception { + return (String) + invokeStatic( + "buildFullUrl", + new Class[] {String.class, String.class, String.class}, + base, + port, + ctx); + } + + @Test + @DisplayName("root context path yields a single trailing slash") + void rootContext() throws Exception { + assertThat(build("http://localhost", "8080", "/")).isEqualTo("http://localhost:8080/"); + } + + @Test + @DisplayName("non-root context path is prefixed with a slash") + void prefixesContext() throws Exception { + assertThat(build("http://localhost", "8080", "app")) + .isEqualTo("http://localhost:8080/app"); + } + + @Test + @DisplayName("blank context path treated as root") + void blankContext() throws Exception { + assertThat(build("http://localhost", "8080", "")).isEqualTo("http://localhost:8080/"); + } + } + + @Nested + @DisplayName("parsePort") + class ParsePort { + + private Integer parse(String port) throws Exception { + return (Integer) invokeStatic("parsePort", new Class[] {String.class}, port); + } + + @Test + @DisplayName("parses a positive port") + void positive() throws Exception { + assertThat(parse("8080")).isEqualTo(8080); + } + + @Test + @DisplayName("null for blank, non-numeric, zero, and negative") + void invalidInputs() throws Exception { + assertThat(parse("")).isNull(); + assertThat(parse("abc")).isNull(); + assertThat(parse("0")).isNull(); + assertThat(parse("-5")).isNull(); + } + } + + @Nested + @DisplayName("appendPortFallback") + class AppendPortFallback { + + private String append(String base, Integer port) throws Exception { + return (String) + invokeStatic( + "appendPortFallback", + new Class[] {String.class, Integer.class}, + base, + port); + } + + @Test + @DisplayName("null port returns base unchanged") + void nullPort() throws Exception { + assertThat(append("http://host", null)).isEqualTo("http://host"); + } + + @Test + @DisplayName("base already ending in a port is unchanged") + void alreadyHasPort() throws Exception { + assertThat(append("http://host:1234", 80)).isEqualTo("http://host:1234"); + } + + @Test + @DisplayName("appends the port otherwise") + void appendsPort() throws Exception { + assertThat(append("http://host", 8080)).isEqualTo("http://host:8080"); + } + } + + @Nested + @DisplayName("lifecycle") + class Lifecycle { + + @Test + @DisplayName("onApplicationReady picks up the runtime local.server.port") + void onApplicationReadyUsesRuntimePort() { + ApplicationReadyEvent event = mock(ApplicationReadyEvent.class, RETURNS_DEEP_STUBS); + when(event.getApplicationContext().getEnvironment().getProperty("local.server.port")) + .thenReturn("44444"); + + SPDFApplication app = new SPDFApplication(appConfig, env, applicationProperties); + app.onApplicationReady(event); + + assertThat(SPDFApplication.getStaticPort()).isEqualTo("44444"); + } + + @Test + @DisplayName("init in Tauri mode logs parent pid and sets static URLs") + void initTauriMode() { + System.setProperty(TAURI_PROP, "true"); + when(appConfig.getBackendUrl()).thenReturn("http://localhost"); + when(appConfig.getContextPath()).thenReturn("/"); + when(appConfig.getServerPort()).thenReturn("8080"); + when(env.getProperty(BROWSER_OPEN)).thenReturn("false"); + + SPDFApplication app = new SPDFApplication(appConfig, env, applicationProperties); + app.init(); + + assertThat(SPDFApplication.getStaticBaseUrl()).isEqualTo("http://localhost:8080"); + assertThat(SPDFApplication.getStaticContextPath()).isEqualTo("/"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/config/EndpointInspectorMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/config/EndpointInspectorMoreTest.java new file mode 100644 index 0000000000..c70e6cd8d1 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/EndpointInspectorMoreTest.java @@ -0,0 +1,188 @@ +package stirling.software.SPDF.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +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.springframework.context.ApplicationContext; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +@DisplayName("EndpointInspector (additional coverage)") +class EndpointInspectorMoreTest { + + private ApplicationContext applicationContext; + private EndpointInspector inspector; + + // Simple controller bean providing a handler method for HandlerMethod construction. + static class DummyController { + public String handle() { + return "ok"; + } + } + + @BeforeEach + void setUp() { + applicationContext = mock(ApplicationContext.class); + inspector = new EndpointInspector(applicationContext); + } + + private HandlerMethod handlerMethod() throws Exception { + Method method = DummyController.class.getMethod("handle"); + return new HandlerMethod(new DummyController(), method); + } + + @SuppressWarnings("unchecked") + private Set validEndpoints() throws Exception { + Field field = EndpointInspector.class.getDeclaredField("validGetEndpoints"); + field.setAccessible(true); + return (Set) field.get(inspector); + } + + private void stubMapping(Map handlerMethods) { + RequestMappingHandlerMapping mapping = mock(RequestMappingHandlerMapping.class); + when(mapping.getHandlerMethods()).thenReturn(handlerMethods); + Map beans = new HashMap<>(); + beans.put("requestMappingHandlerMapping", mapping); + when(applicationContext.getBeansOfType(RequestMappingHandlerMapping.class)) + .thenReturn(beans); + } + + @Nested + @DisplayName("onApplicationEvent") + class OnApplicationEvent { + + @Test + @DisplayName("discovers endpoints exactly once across repeated events") + void discoversOnce() throws Exception { + stubMapping(new LinkedHashMap<>()); + + ContextRefreshedEvent event = new ContextRefreshedEvent(new StaticApplicationContext()); + inspector.onApplicationEvent(event); + inspector.onApplicationEvent(event); + + Field discovered = EndpointInspector.class.getDeclaredField("endpointsDiscovered"); + discovered.setAccessible(true); + assertThat(discovered.getBoolean(inspector)).isTrue(); + } + } + + @Nested + @DisplayName("discoverEndpoints") + class DiscoverEndpoints { + + @Test + @DisplayName("collects direct paths from a GET mapping") + void collectsDirectPaths() throws Exception { + Map methods = new LinkedHashMap<>(); + RequestMappingInfo getInfo = + RequestMappingInfo.paths("/dashboard").methods(RequestMethod.GET).build(); + methods.put(getInfo, handlerMethod()); + stubMapping(methods); + + Set endpoints = inspector.getValidGetEndpoints(); + + assertThat(endpoints).contains("/dashboard"); + } + + @Test + @DisplayName("treats a mapping with no explicit method as a GET handler") + void noMethodCountsAsGet() throws Exception { + Map methods = new LinkedHashMap<>(); + RequestMappingInfo anyInfo = RequestMappingInfo.paths("/anything").build(); + methods.put(anyInfo, handlerMethod()); + stubMapping(methods); + + assertThat(inspector.getValidGetEndpoints()).contains("/anything"); + } + + @Test + @DisplayName("ignores non-GET only mappings") + void ignoresPostOnly() throws Exception { + Map methods = new LinkedHashMap<>(); + RequestMappingInfo postInfo = + RequestMappingInfo.paths("/save").methods(RequestMethod.POST).build(); + methods.put(postInfo, handlerMethod()); + stubMapping(methods); + + assertThat(inspector.getValidGetEndpoints()).doesNotContain("/save"); + } + + @Test + @DisplayName("falls back to string parsing for pattern-only mappings") + void fallsBackToStringParsing() throws Exception { + Map methods = new LinkedHashMap<>(); + // Wildcard patterns are not direct paths, forcing the toString() fallback branch. + RequestMappingInfo patternInfo = + RequestMappingInfo.paths("/files/**").methods(RequestMethod.GET).build(); + methods.put(patternInfo, handlerMethod()); + stubMapping(methods); + + Set endpoints = inspector.getValidGetEndpoints(); + + assertThat(endpoints).anySatisfy(p -> assertThat(p).contains("/files")); + } + } + + @Nested + @DisplayName("getValidGetEndpoints") + class GetValidGetEndpoints { + + @Test + @DisplayName("triggers discovery when not yet discovered") + void triggersDiscovery() throws Exception { + stubMapping(new LinkedHashMap<>()); + + Set endpoints = inspector.getValidGetEndpoints(); + + // Empty discovery installs the fallback set. + assertThat(endpoints).contains("/", "/**", "/api/**"); + } + } + + @Nested + @DisplayName("matching helpers") + class MatchingHelpers { + + @Test + @DisplayName("matchesPathSegments rejects a URI shorter than the pattern") + void shorterUriDoesNotMatch() throws Exception { + validEndpoints().clear(); + validEndpoints().add("/api/v1/convert"); + markDiscovered(); + + assertThat(inspector.isValidGetEndpoint("/api")).isFalse(); + } + + @Test + @DisplayName("wildcard with a star prefix matches the static portion") + void starPrefixMatches() throws Exception { + validEndpoints().clear(); + validEndpoints().add("/static/*"); + markDiscovered(); + + assertThat(inspector.isValidGetEndpoint("/static/app.js")).isTrue(); + } + + private void markDiscovered() throws Exception { + Field discovered = EndpointInspector.class.getDeclaredField("endpointsDiscovered"); + discovered.setAccessible(true); + discovered.setBoolean(inspector, true); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/config/ExternalAppDepConfigExtraTest.java b/app/core/src/test/java/stirling/software/SPDF/config/ExternalAppDepConfigExtraTest.java new file mode 100644 index 0000000000..f150e523f6 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/ExternalAppDepConfigExtraTest.java @@ -0,0 +1,244 @@ +package stirling.software.SPDF.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +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.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import stirling.software.common.configuration.RuntimePathConfig; + +/** + * Pure-helper coverage for {@link ExternalAppDepConfig}: the inner Version comparator, the + * weasyprint/qpdf command recognisers, feature-name formatting and the findFirstAvailable probe + * loop. No real binaries run; ProcessBuilder is intercepted where a probe is required. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("ExternalAppDepConfig helper coverage") +class ExternalAppDepConfigExtraTest { + + @Mock private EndpointConfiguration endpointConfiguration; + @Mock private RuntimePathConfig runtimePathConfig; + + private ExternalAppDepConfig config; + + @BeforeEach + void setUp() { + when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/opt/weasyprint"); + when(runtimePathConfig.getUnoConvertPath()).thenReturn("/opt/unoconvert"); + when(runtimePathConfig.getCalibrePath()).thenReturn("/opt/calibre"); + when(runtimePathConfig.getOcrMyPdfPath()).thenReturn("/opt/ocrmypdf"); + when(runtimePathConfig.getSOfficePath()).thenReturn("/opt/soffice"); + lenient() + .when(endpointConfiguration.getEndpointsForGroup(anyString())) + .thenReturn(Set.of()); + config = new ExternalAppDepConfig(endpointConfiguration, runtimePathConfig); + } + + private Object invoke(String name, Class[] sig, Object... args) throws Exception { + Method m = ExternalAppDepConfig.class.getDeclaredMethod(name, sig); + m.setAccessible(true); + return m.invoke(config, args); + } + + private static Process processReturning(int exitCode, String stdout) { + Process p = mock(Process.class); + try { + doReturn(true).when(p).waitFor(anyLong(), any(TimeUnit.class)); + } catch (InterruptedException ignored) { + // mock never throws + } + doReturn(exitCode).when(p).exitValue(); + doAnswer(inv -> new ByteArrayInputStream(stdout.getBytes(StandardCharsets.UTF_8))) + .when(p) + .getInputStream(); + doAnswer(inv -> new ByteArrayInputStream(new byte[0])).when(p).getErrorStream(); + return p; + } + + @Nested + @DisplayName("Version comparator") + class VersionComparator { + + private Comparable version(String v) throws Exception { + Class versionClass = + Class.forName("stirling.software.SPDF.config.ExternalAppDepConfig$Version"); + Constructor ctor = versionClass.getDeclaredConstructor(String.class); + ctor.setAccessible(true); + @SuppressWarnings("unchecked") + Comparable instance = (Comparable) ctor.newInstance(v); + return instance; + } + + @Test + @DisplayName("orders by major, then minor, then patch") + void ordersBySegments() throws Exception { + assertThat(version("1.2.3").compareTo(version("1.2.4"))).isNegative(); + assertThat(version("2.0.0").compareTo(version("1.9.9"))).isPositive(); + assertThat(version("1.2.0").compareTo(version("1.2"))).isZero(); + } + + @Test + @DisplayName("treats equal versions as equal") + void equalVersions() throws Exception { + assertThat(version("58.0").compareTo(version("58.0.0"))).isZero(); + } + + @Test + @DisplayName("non-numeric segments are treated as zero") + void nonNumericSegmentsAsZero() throws Exception { + // "12.x" -> 12.0.0, equal to "12" + assertThat(version("12.x").compareTo(version("12"))).isZero(); + } + + @Test + @DisplayName("toString renders the three-segment form") + void toStringThreeSegments() throws Exception { + assertThat(version("11.9").toString()).isEqualTo("11.9.0"); + } + } + + @Nested + @DisplayName("command recognisers") + class CommandRecognisers { + + private boolean isWeasyprint(String command) throws Exception { + return (boolean) invoke("isWeasyprint", new Class[] {String.class}, command); + } + + private boolean isQpdf(String command) throws Exception { + return (boolean) invoke("isQpdf", new Class[] {String.class}, command); + } + + @Test + @DisplayName("isWeasyprint matches the configured path and any name containing weasyprint") + void weasyprintMatches() throws Exception { + assertThat(isWeasyprint("/opt/weasyprint")).isTrue(); + assertThat(isWeasyprint("WeasyPrint")).isTrue(); + assertThat(isWeasyprint("gs")).isFalse(); + } + + @Test + @DisplayName("isQpdf matches any name containing qpdf, case-insensitively") + void qpdfMatches() throws Exception { + assertThat(isQpdf("qpdf")).isTrue(); + assertThat(isQpdf("/usr/bin/QPDF")).isTrue(); + assertThat(isQpdf("tesseract")).isFalse(); + } + } + + @Nested + @DisplayName("feature-name formatting") + class FeatureFormatting { + + private String capitalizeWord(String word) throws Exception { + return (String) invoke("capitalizeWord", new Class[] {String.class}, word); + } + + private String formatEndpointAsFeature(String endpoint) throws Exception { + return (String) + invoke("formatEndpointAsFeature", new Class[] {String.class}, endpoint); + } + + @Test + @DisplayName("capitalizeWord upper-cases the first letter and lowers the rest") + void capitalizes() throws Exception { + assertThat(capitalizeWord("hello")).isEqualTo("Hello"); + assertThat(capitalizeWord("WORLD")).isEqualTo("World"); + } + + @Test + @DisplayName("capitalizeWord keeps pdf fully upper-cased") + void capitalizesPdf() throws Exception { + assertThat(capitalizeWord("pdf")).isEqualTo("PDF"); + assertThat(capitalizeWord("PDF")).isEqualTo("PDF"); + } + + @Test + @DisplayName("capitalizeWord returns null/empty unchanged") + void capitalizeEmpty() throws Exception { + assertThat(capitalizeWord("")).isEmpty(); + assertThat(capitalizeWord((String) null)).isNull(); + } + + @Test + @DisplayName("formatEndpointAsFeature humanises a hyphenated endpoint with pdf/img mapping") + void humanisesEndpoint() throws Exception { + // "pdf-to-img" -> tokens pdf,to,image -> "PDF To Image" + assertThat(formatEndpointAsFeature("pdf-to-img")).isEqualTo("PDF To Image"); + } + + @Test + @DisplayName("formatEndpointAsFeature title-cases a plain endpoint") + void titleCasesPlain() throws Exception { + assertThat(formatEndpointAsFeature("merge-pdfs")).contains("Merge"); + } + } + + @Nested + @DisplayName("findFirstAvailable") + class FindFirstAvailable { + + @SuppressWarnings("unchecked") + private Optional findFirstAvailable(List commands) throws Exception { + return (Optional) + invoke("findFirstAvailable", new Class[] {List.class}, commands); + } + + @Test + @DisplayName("returns the first command whose lookup probe succeeds") + void returnsFirstSuccessful() throws Exception { + // python3 lookup fails (exit 1), python lookup succeeds (exit 0) + try (MockedConstruction ignored = + mockConstruction( + ProcessBuilder.class, + (pbMock, ctx) -> { + List cmd = (List) ctx.arguments().get(0); + boolean python = cmd.contains("python"); + doReturn(processReturning(python ? 0 : 1, "")).when(pbMock).start(); + })) { + Optional result = findFirstAvailable(List.of("python3", "python")); + assertThat(result).contains("python"); + } + } + + @Test + @DisplayName("returns empty when no command is available") + void emptyWhenNoneAvailable() throws Exception { + try (MockedConstruction ignored = + mockConstruction( + ProcessBuilder.class, + (pbMock, ctx) -> + doReturn(processReturning(1, "")).when(pbMock).start())) { + Optional result = findFirstAvailable(List.of("nope1", "nope2")); + assertThat(result).isEmpty(); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/config/ExternalAppDepConfigMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/config/ExternalAppDepConfigMoreTest.java new file mode 100644 index 0000000000..510a357358 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/ExternalAppDepConfigMoreTest.java @@ -0,0 +1,315 @@ +package stirling.software.SPDF.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +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.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import stirling.software.common.configuration.RuntimePathConfig; + +/** + * Covers the process-probing paths of ExternalAppDepConfig by intercepting ProcessBuilder + * construction. No real external binaries are ever executed. Process mocks are wired with + * doReturn/doAnswer to avoid nested-when stubbing inside the mockConstruction initializer. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("ExternalAppDepConfig extra coverage") +class ExternalAppDepConfigMoreTest { + + @Mock private EndpointConfiguration endpointConfiguration; + @Mock private RuntimePathConfig runtimePathConfig; + + private ExternalAppDepConfig config; + + @BeforeEach + void setUp() { + when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/custom/weasyprint"); + when(runtimePathConfig.getUnoConvertPath()).thenReturn("/custom/unoconvert"); + when(runtimePathConfig.getCalibrePath()).thenReturn("/custom/calibre"); + when(runtimePathConfig.getOcrMyPdfPath()).thenReturn("/custom/ocrmypdf"); + when(runtimePathConfig.getSOfficePath()).thenReturn("/custom/soffice"); + lenient() + .when(endpointConfiguration.getEndpointsForGroup(anyString())) + .thenReturn(Set.of()); + config = new ExternalAppDepConfig(endpointConfiguration, runtimePathConfig); + } + + /** + * Build a Process mock that finishes with the given exit code and stream text. Uses doReturn so + * it can be safely called inside a mockConstruction initializer. + */ + private static Process processReturning(int exitCode, String stdout, String stderr) { + Process p = mock(Process.class); + try { + doReturn(true).when(p).waitFor(anyLong(), any(TimeUnit.class)); + } catch (InterruptedException ignored) { + // mock never actually throws + } + doReturn(exitCode).when(p).exitValue(); + // Fresh stream per call so concurrent/repeated reads never race on a consumed buffer. + doAnswer(inv -> new ByteArrayInputStream(stdout.getBytes(StandardCharsets.UTF_8))) + .when(p) + .getInputStream(); + doAnswer(inv -> new ByteArrayInputStream(stderr.getBytes(StandardCharsets.UTF_8))) + .when(p) + .getErrorStream(); + return p; + } + + /** Make every ProcessBuilder built in scope return the supplied process from start(). */ + private static MockedConstruction alwaysReturn(Process p) { + return mockConstruction( + ProcessBuilder.class, + (pbMock, ctx) -> { + try { + doReturn(p).when(pbMock).start(); + } catch (Exception ignored) { + } + }); + } + + private Object invoke(String name, Class[] sig, Object... args) throws Exception { + Method m = ExternalAppDepConfig.class.getDeclaredMethod(name, sig); + m.setAccessible(true); + return m.invoke(config, args); + } + + @Nested + @DisplayName("isCommandAvailable") + class IsCommandAvailable { + + @Test + @DisplayName("true when OS lookup (where/which) exits 0") + void availableViaLookup() throws Exception { + try (MockedConstruction ignored = + alwaysReturn(processReturning(0, "/usr/bin/gs", ""))) { + boolean available = + (boolean) invoke("isCommandAvailable", new Class[] {String.class}, "gs"); + assertThat(available).isTrue(); + } + } + + @Test + @DisplayName("falls back to --version when lookup fails, then true") + void availableViaVersionFallback() throws Exception { + try (MockedConstruction ignored = + mockConstruction( + ProcessBuilder.class, + (pbMock, ctx) -> { + List cmd = (List) ctx.arguments().get(0); + boolean isVersion = cmd.contains("--version"); + doReturn(processReturning(isVersion ? 0 : 1, "1.0", "")) + .when(pbMock) + .start(); + })) { + boolean available = + (boolean) + invoke( + "isCommandAvailable", + new Class[] {String.class}, + "weirdcmd"); + assertThat(available).isTrue(); + } + } + + @Test + @DisplayName("false when both lookup and --version fail") + void unavailable() throws Exception { + try (MockedConstruction ignored = + alwaysReturn(processReturning(1, "", ""))) { + boolean available = + (boolean) + invoke("isCommandAvailable", new Class[] {String.class}, "nope"); + assertThat(available).isFalse(); + } + } + } + + @Nested + @DisplayName("getVersionSafe") + class GetVersionSafe { + + @Test + @DisplayName("extracts a version number from combined output") + @SuppressWarnings("unchecked") + void extractsVersion() throws Exception { + try (MockedConstruction ignored = + alwaysReturn(processReturning(0, "qpdf version 11.9.0", ""))) { + Optional version = + (Optional) + invoke( + "getVersionSafe", + new Class[] {String.class, String.class}, + "qpdf", + "--version"); + assertThat(version).contains("11.9.0"); + } + } + + @Test + @DisplayName("empty when command exits non-zero") + @SuppressWarnings("unchecked") + void emptyOnNonZero() throws Exception { + try (MockedConstruction ignored = + alwaysReturn(processReturning(2, "", ""))) { + Optional version = + (Optional) + invoke( + "getVersionSafe", + new Class[] {String.class, String.class}, + "qpdf", + "--version"); + assertThat(version).isEmpty(); + } + } + } + + @Nested + @DisplayName("runAndWait") + class RunAndWait { + + @Test + @DisplayName("returns timeout code 124 and destroys the process when it does not finish") + void timeoutDestroysProcess() throws Exception { + Process p = mock(Process.class); + doReturn(false).when(p).waitFor(anyLong(), any(TimeUnit.class)); + try (MockedConstruction ignored = alwaysReturn(p)) { + Object result = + invoke( + "runAndWait", + new Class[] {List.class, Duration.class}, + List.of("sleep", "100"), + Duration.ofMillis(10)); + Method ec = result.getClass().getDeclaredMethod("exitCode"); + ec.setAccessible(true); + assertThat((int) ec.invoke(result)).isEqualTo(124); + verify(p).destroyForcibly(); + } + } + + @Test + @DisplayName("returns code 127 when ProcessBuilder.start throws IOException") + void ioExceptionYields127() throws Exception { + try (MockedConstruction ignored = + mockConstruction( + ProcessBuilder.class, + (pbMock, ctx) -> + when(pbMock.start()) + .thenThrow(new java.io.IOException("cannot run")))) { + Object result = + invoke( + "runAndWait", + new Class[] {List.class, Duration.class}, + List.of("bogus"), + Duration.ofSeconds(1)); + Method ec = result.getClass().getDeclaredMethod("exitCode"); + ec.setAccessible(true); + assertThat((int) ec.invoke(result)).isEqualTo(127); + } + } + } + + /** + * checkDependencyAndDisableGroup is tested directly (single-threaded). The public + * checkDependencies() fans probes out over virtual threads where Mockito's thread-confined + * MockedConstruction would not intercept, so it is not exercised here. + */ + @Nested + @DisplayName("checkDependencyAndDisableGroup") + class CheckDependencyAndDisableGroup { + + @Test + @DisplayName("disables the affected group when the command is missing") + void disablesMissingGroup() throws Exception { + try (MockedConstruction ignored = + alwaysReturn(processReturning(1, "", ""))) { + invoke("checkDependencyAndDisableGroup", new Class[] {String.class}, "gs"); + } + + verify(endpointConfiguration) + .disableGroup("Ghostscript", EndpointConfiguration.DisableReason.DEPENDENCY); + } + + @Test + @DisplayName("present command with no version gate leaves the group enabled") + void presentCommandNotDisabled() throws Exception { + try (MockedConstruction ignored = + alwaysReturn(processReturning(0, "/usr/bin/gs", ""))) { + invoke("checkDependencyAndDisableGroup", new Class[] {String.class}, "gs"); + } + + verify(endpointConfiguration, never()) + .disableGroup(anyString(), eq(EndpointConfiguration.DisableReason.DEPENDENCY)); + } + + @Test + @DisplayName("qpdf below the required version disables the qpdf group") + void qpdfBelowMinimumDisabled() throws Exception { + // Lookup succeeds (exit 0) and --version reports an old release -> version gate fires. + try (MockedConstruction ignored = + alwaysReturn(processReturning(0, "qpdf version 10.0.0", ""))) { + invoke("checkDependencyAndDisableGroup", new Class[] {String.class}, "qpdf"); + } + + verify(endpointConfiguration) + .disableGroup("qpdf", EndpointConfiguration.DisableReason.DEPENDENCY); + } + + @Test + @DisplayName("qpdf at or above the required version stays enabled") + void qpdfMeetsMinimumNotDisabled() throws Exception { + try (MockedConstruction ignored = + alwaysReturn(processReturning(0, "qpdf version 12.5.0", ""))) { + invoke("checkDependencyAndDisableGroup", new Class[] {String.class}, "qpdf"); + } + + verify(endpointConfiguration, never()) + .disableGroup(anyString(), eq(EndpointConfiguration.DisableReason.DEPENDENCY)); + } + + @Test + @DisplayName("weasyprint below the required version disables the weasyprint group") + void weasyprintBelowMinimumDisabled() throws Exception { + try (MockedConstruction ignored = + alwaysReturn(processReturning(0, "WeasyPrint 50.0", ""))) { + invoke( + "checkDependencyAndDisableGroup", + new Class[] {String.class}, + "/custom/weasyprint"); + } + + verify(endpointConfiguration) + .disableGroup("Weasyprint", EndpointConfiguration.DisableReason.DEPENDENCY); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/config/InitialSetupTest.java b/app/core/src/test/java/stirling/software/SPDF/config/InitialSetupTest.java new file mode 100644 index 0000000000..b84adc1908 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/InitialSetupTest.java @@ -0,0 +1,182 @@ +package stirling.software.SPDF.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +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.mockito.MockedStatic; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.GeneralUtils; + +class InitialSetupTest { + + private ApplicationProperties applicationProperties; + private ApplicationProperties.AutomaticallyGenerated autoGen; + private ApplicationProperties.Legal legal; + private InitialSetup initialSetup; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + autoGen = applicationProperties.getAutomaticallyGenerated(); + legal = applicationProperties.getLegal(); + initialSetup = new InitialSetup(applicationProperties); + } + + @Nested + @DisplayName("initUUIDKey") + class UuidKey { + + @Test + @DisplayName("generates and persists a UUID when missing") + void generatesWhenMissing() throws Exception { + autoGen.setUUID(null); + try (MockedStatic util = mockStatic(GeneralUtils.class)) { + util.when(() -> GeneralUtils.isValidUUID(null)).thenReturn(false); + + initialSetup.initUUIDKey(); + + assertThat(autoGen.getUUID()).isNotBlank(); + util.verify( + () -> + GeneralUtils.saveKeyToSettings( + eq("AutomaticallyGenerated.UUID"), any()), + times(1)); + } + } + + @Test + @DisplayName("keeps an existing valid UUID") + void keepsValid() throws Exception { + autoGen.setUUID("existing"); + try (MockedStatic util = mockStatic(GeneralUtils.class)) { + util.when(() -> GeneralUtils.isValidUUID("existing")).thenReturn(true); + + initialSetup.initUUIDKey(); + + assertThat(autoGen.getUUID()).isEqualTo("existing"); + util.verify(() -> GeneralUtils.saveKeyToSettings(any(), any()), never()); + } + } + } + + @Nested + @DisplayName("initSecretKey") + class SecretKey { + + @Test + @DisplayName("generates a key when invalid") + void generatesWhenInvalid() throws Exception { + autoGen.setKey(null); + try (MockedStatic util = mockStatic(GeneralUtils.class)) { + util.when(() -> GeneralUtils.isValidUUID(null)).thenReturn(false); + + initialSetup.initSecretKey(); + + assertThat(autoGen.getKey()).isNotBlank(); + util.verify( + () -> + GeneralUtils.saveKeyToSettings( + eq("AutomaticallyGenerated.key"), any()), + times(1)); + } + } + + @Test + @DisplayName("keeps an existing valid key") + void keepsValid() throws Exception { + autoGen.setKey("secret"); + try (MockedStatic util = mockStatic(GeneralUtils.class)) { + util.when(() -> GeneralUtils.isValidUUID("secret")).thenReturn(true); + + initialSetup.initSecretKey(); + + assertThat(autoGen.getKey()).isEqualTo("secret"); + } + } + } + + @Nested + @DisplayName("initLegalUrls") + class LegalUrls { + + @Test + @DisplayName("sets defaults when both URLs are empty") + void setsDefaults() throws Exception { + legal.setTermsAndConditions(null); + legal.setPrivacyPolicy(""); + try (MockedStatic util = mockStatic(GeneralUtils.class)) { + initialSetup.initLegalUrls(); + + assertThat(legal.getTermsAndConditions()).contains("stirlingpdf.com/terms"); + assertThat(legal.getPrivacyPolicy()).contains("privacy-policy"); + util.verify( + () -> GeneralUtils.saveKeyToSettings(eq("legal.termsAndConditions"), any()), + times(1)); + util.verify( + () -> GeneralUtils.saveKeyToSettings(eq("legal.privacyPolicy"), any()), + times(1)); + } + } + + @Test + @DisplayName("preserves already-configured URLs") + void preservesExisting() throws Exception { + legal.setTermsAndConditions("https://example.com/t"); + legal.setPrivacyPolicy("https://example.com/p"); + try (MockedStatic util = mockStatic(GeneralUtils.class)) { + initialSetup.initLegalUrls(); + + assertThat(legal.getTermsAndConditions()).isEqualTo("https://example.com/t"); + util.verify(() -> GeneralUtils.saveKeyToSettings(any(), any()), never()); + } + } + } + + @Nested + @DisplayName("initSetAppVersion") + class AppVersion { + + @Test + @DisplayName("flags new server when version is missing") + void newServerWhenMissing() throws Exception { + autoGen.setAppVersion(null); + try (MockedStatic util = mockStatic(GeneralUtils.class)) { + initialSetup.initSetAppVersion(); + + assertThat(autoGen.getIsNewServer()).isTrue(); + assertThat(autoGen.getAppVersion()).isNotNull(); + } + } + + @Test + @DisplayName("flags new server when version is 0.0.0") + void newServerWhenZero() throws Exception { + autoGen.setAppVersion("0.0.0"); + try (MockedStatic util = mockStatic(GeneralUtils.class)) { + initialSetup.initSetAppVersion(); + + assertThat(autoGen.getIsNewServer()).isTrue(); + } + } + + @Test + @DisplayName("existing server keeps not-new flag") + void existingServer() throws Exception { + autoGen.setAppVersion("1.2.3"); + try (MockedStatic util = mockStatic(GeneralUtils.class)) { + initialSetup.initSetAppVersion(); + + assertThat(autoGen.getIsNewServer()).isFalse(); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/config/MetricsFilterTest.java b/app/core/src/test/java/stirling/software/SPDF/config/MetricsFilterTest.java new file mode 100644 index 0000000000..48036e74ed --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/MetricsFilterTest.java @@ -0,0 +1,86 @@ +package stirling.software.SPDF.config; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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 io.micrometer.core.instrument.simple.SimpleMeterRegistry; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; + +class MetricsFilterTest { + + private SimpleMeterRegistry registry; + private MetricsFilter filter; + private HttpServletRequest request; + private HttpServletResponse response; + private FilterChain chain; + + @BeforeEach + void setUp() { + registry = new SimpleMeterRegistry(); + filter = new MetricsFilter(registry); + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + chain = mock(FilterChain.class); + } + + @Nested + @DisplayName("trackable requests") + class Trackable { + + @Test + @DisplayName("increments a counter for a trackable URI with session") + void countsWithSession() throws Exception { + HttpSession session = mock(HttpSession.class); + when(session.getId()).thenReturn("sess-1"); + when(request.getRequestURI()).thenReturn("/api/v1/general/rotate-pdf"); + when(request.getContextPath()).thenReturn(""); + when(request.getMethod()).thenReturn("POST"); + when(request.getSession(false)).thenReturn(session); + + filter.doFilterInternal(request, response, chain); + + verify(chain).doFilter(request, response); + } + + @Test + @DisplayName("uses no-session tag when session is absent") + void countsWithoutSession() throws Exception { + when(request.getRequestURI()).thenReturn("/api/v1/general/merge-pdfs"); + when(request.getContextPath()).thenReturn(""); + when(request.getMethod()).thenReturn("POST"); + when(request.getSession(false)).thenReturn(null); + + filter.doFilterInternal(request, response, chain); + + verify(chain).doFilter(request, response); + } + } + + @Nested + @DisplayName("non-trackable requests") + class NonTrackable { + + @Test + @DisplayName("static resource is not counted but chain continues") + void staticResourceSkipped() throws Exception { + when(request.getRequestURI()).thenReturn("/css/style.css"); + when(request.getContextPath()).thenReturn(""); + + filter.doFilterInternal(request, response, chain); + + verify(chain).doFilter(request, response); + verify(request, never()).getMethod(); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/config/MultipartConfigurationTest.java b/app/core/src/test/java/stirling/software/SPDF/config/MultipartConfigurationTest.java new file mode 100644 index 0000000000..60b17a3534 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/MultipartConfigurationTest.java @@ -0,0 +1,62 @@ +package stirling.software.SPDF.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; + +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 jakarta.servlet.MultipartConfigElement; + +import stirling.software.SPDF.controller.web.UploadLimitService; + +class MultipartConfigurationTest { + + private UploadLimitService uploadLimitService; + private MultipartConfiguration configuration; + + @BeforeEach + void setUp() throws Exception { + // Manually constructed config with a mocked service, so Spring env overrides do not apply. + uploadLimitService = mock(UploadLimitService.class); + configuration = new MultipartConfiguration(); + Field field = MultipartConfiguration.class.getDeclaredField("uploadLimitService"); + field.setAccessible(true); + field.set(configuration, uploadLimitService); + } + + @Nested + @DisplayName("multipartConfigElement") + class ConfigElement { + + @Test + @DisplayName("uses the configured upload limit when positive") + void usesConfiguredLimit() { + long limit = 50L * 1024 * 1024; + when(uploadLimitService.getUploadLimit()).thenReturn(limit); + when(uploadLimitService.getReadableUploadLimit()).thenReturn("50.0 MB"); + + MultipartConfigElement element = configuration.multipartConfigElement(); + + assertThat(element.getMaxFileSize()).isEqualTo(limit); + assertThat(element.getMaxRequestSize()).isEqualTo(limit); + } + + @Test + @DisplayName("falls back to 2000MB default when no limit configured") + void usesDefaultWhenZero() { + when(uploadLimitService.getUploadLimit()).thenReturn(0L); + + MultipartConfigElement element = configuration.multipartConfigElement(); + + long expectedDefault = 2000L * 1024 * 1024; + assertThat(element.getMaxFileSize()).isEqualTo(expectedDefault); + assertThat(element.getMaxRequestSize()).isEqualTo(expectedDefault); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/config/OpenApiConfigTest.java b/app/core/src/test/java/stirling/software/SPDF/config/OpenApiConfigTest.java new file mode 100644 index 0000000000..261a720d3d --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/OpenApiConfigTest.java @@ -0,0 +1,149 @@ +package stirling.software.SPDF.config; + +import static org.assertj.core.api.Assertions.assertThat; + +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.springdoc.core.customizers.OpenApiCustomizer; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.ComposedSchema; +import io.swagger.v3.oas.models.media.Schema; + +import stirling.software.common.model.ApplicationProperties; + +@DisplayName("OpenApiConfig") +class OpenApiConfigTest { + + private ApplicationProperties applicationProperties; + private OpenApiConfig config; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + config = new OpenApiConfig(applicationProperties); + } + + @Nested + @DisplayName("customOpenAPI") + class CustomOpenAPI { + + @Test + @DisplayName("builds OpenAPI with title, version and 3.0.3 spec") + void buildsBaseOpenApi() { + OpenAPI openAPI = config.customOpenAPI(); + + assertThat(openAPI).isNotNull(); + assertThat(openAPI.getOpenapi()).isEqualTo("3.0.3"); + assertThat(openAPI.getInfo()).isNotNull(); + assertThat(openAPI.getInfo().getTitle()).isEqualTo("Stirling PDF API"); + // Version falls back to 1.0.0 when no implementation version on the package. + assertThat(openAPI.getInfo().getVersion()).isNotBlank(); + } + + @Test + @DisplayName("sets license, contact and terms of service") + void setsLicenseAndContact() { + OpenAPI openAPI = config.customOpenAPI(); + + assertThat(openAPI.getInfo().getLicense()).isNotNull(); + assertThat(openAPI.getInfo().getLicense().getName()).contains("MIT"); + assertThat(openAPI.getInfo().getTermsOfService()) + .isEqualTo("https://www.stirlingpdf.com/terms"); + assertThat(openAPI.getInfo().getContact()).isNotNull(); + assertThat(openAPI.getInfo().getContact().getEmail()) + .isEqualTo("contact@stirlingpdf.com"); + } + + @Test + @DisplayName("registers the global AI tag") + void registersAiTag() { + OpenAPI openAPI = config.customOpenAPI(); + + assertThat(openAPI.getTags()).isNotNull(); + assertThat(openAPI.getTags()) + .anySatisfy(tag -> assertThat(tag.getName()).isEqualTo("AI")); + } + + @Test + @DisplayName("adds a server item and an ErrorResponse schema") + void addsServerAndErrorSchema() { + OpenAPI openAPI = config.customOpenAPI(); + + assertThat(openAPI.getServers()).isNotEmpty(); + assertThat(openAPI.getServers().get(0).getUrl()).isNotBlank(); + + Components components = openAPI.getComponents(); + assertThat(components).isNotNull(); + assertThat(components.getSchemas()).containsKey("ErrorResponse"); + Schema errorSchema = components.getSchemas().get("ErrorResponse"); + assertThat(errorSchema.getProperties()) + .containsKeys("timestamp", "status", "error", "message", "path"); + } + + @Test + @DisplayName("uses relative server URL when SWAGGER_SERVER_URL env var is absent") + void usesRelativeServerWhenEnvAbsent() { + // The test JVM does not set SWAGGER_SERVER_URL so the relative branch runs. + if (System.getenv("SWAGGER_SERVER_URL") == null) { + OpenAPI openAPI = config.customOpenAPI(); + assertThat(openAPI.getServers().get(0).getUrl()).isEqualTo("/"); + assertThat(openAPI.getServers().get(0).getDescription()) + .isEqualTo("Current Server"); + } + } + + @Test + @DisplayName("omits API-key security scheme when login is disabled") + void noSecuritySchemeWhenLoginDisabled() { + applicationProperties.getSecurity().setEnableLogin(false); + + OpenAPI openAPI = config.customOpenAPI(); + + assertThat(openAPI.getComponents().getSecuritySchemes()).isNullOrEmpty(); + assertThat(openAPI.getSecurity()).isNullOrEmpty(); + } + + @Test + @DisplayName("adds API-key security scheme when login is enabled") + void addsSecuritySchemeWhenLoginEnabled() { + applicationProperties.getSecurity().setEnableLogin(true); + + OpenAPI openAPI = config.customOpenAPI(); + + assertThat(openAPI.getComponents().getSecuritySchemes()).containsKey("apiKey"); + assertThat(openAPI.getSecurity()).isNotEmpty(); + assertThat(openAPI.getSecurity().get(0)).containsKey("apiKey"); + } + } + + @Nested + @DisplayName("pdfFileOneOfCustomizer") + class PdfFileOneOfCustomizer { + + @Test + @DisplayName("replaces PDFFile schema with a oneOf and registers upload/ref shapes") + void replacesPdfFileSchema() { + OpenApiCustomizer customizer = config.pdfFileOneOfCustomizer(); + assertThat(customizer).isNotNull(); + + // Seed an OpenAPI with an existing PDFFile schema to be replaced. + OpenAPI openApi = new OpenAPI().components(new Components()); + openApi.getComponents().addSchemas("PDFFile", new Schema<>().type("string")); + + customizer.customise(openApi); + + var schemas = openApi.getComponents().getSchemas(); + assertThat(schemas).containsKeys("PDFFileUpload", "PDFFileRef", "PDFFile"); + assertThat(schemas.get("PDFFile")).isInstanceOf(ComposedSchema.class); + + ComposedSchema oneOf = (ComposedSchema) schemas.get("PDFFile"); + assertThat(oneOf.getOneOf()).hasSize(2); + assertThat(schemas.get("PDFFileUpload").getRequired()).contains("fileInput"); + assertThat(schemas.get("PDFFileRef").getRequired()).contains("fileId"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/config/PdfMetricsInterceptorTest.java b/app/core/src/test/java/stirling/software/SPDF/config/PdfMetricsInterceptorTest.java new file mode 100644 index 0000000000..08a8a8b762 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/PdfMetricsInterceptorTest.java @@ -0,0 +1,107 @@ +package stirling.software.SPDF.config; + +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; + +import jakarta.servlet.http.HttpServletResponse; + +import stirling.software.SPDF.service.PdfMetricsService; + +class PdfMetricsInterceptorTest { + + private PdfMetricsService service; + private PdfMetricsInterceptor interceptor; + + @BeforeEach + void setUp() { + service = mock(PdfMetricsService.class); + when(service.isEnabled()).thenReturn(true); + interceptor = new PdfMetricsInterceptor(service); + } + + private MultipartHttpServletRequest editRequest(int fileParts, String... headers) { + MultipartHttpServletRequest request = mock(MultipartHttpServletRequest.class); + when(request.getMethod()).thenReturn("POST"); + when(request.getServletPath()).thenReturn("/api/v1/general/rotate-pdf"); + for (int i = 0; i + 1 < headers.length; i += 2) { + when(request.getHeader(headers[i])).thenReturn(headers[i + 1]); + } + MultiValueMap files = new LinkedMultiValueMap<>(); + for (int i = 0; i < fileParts; i++) { + files.add("fileInput", mock(MultipartFile.class)); + } + when(request.getMultiFileMap()).thenReturn(files); + return request; + } + + private HttpServletResponse response(int status, String contentType) { + HttpServletResponse response = mock(HttpServletResponse.class); + when(response.getStatus()).thenReturn(status); + when(response.getContentType()).thenReturn(contentType); + return response; + } + + @Test + void apiRequestIsCounted() { + interceptor.afterCompletion(editRequest(1), response(200, "application/pdf"), null, null); + verify(service).recordOperation(1); + } + + @Test + void countsEveryFilePartUnderOneFieldName() { + interceptor.afterCompletion(editRequest(3), response(200, "application/pdf"), null, null); + verify(service).recordOperation(3); + } + + @Test + void countsRegardlessOfResponseType() { + interceptor.afterCompletion(editRequest(1), response(200, "application/json"), null, null); + verify(service).recordOperation(1); + } + + @Test + void editorRequestWithBrowserIdIsNotCounted() { + interceptor.afterCompletion( + editRequest(1, "X-Browser-Id", "abc-123"), + response(200, "application/pdf"), + null, + null); + verify(service, never()).recordOperation(anyInt()); + } + + @Test + void editorJwtWithoutBrowserIdIsNotCounted() { + interceptor.afterCompletion( + editRequest(1, "Authorization", "Bearer eyJhbG.eyJzdWI.sig"), + response(200, "application/pdf"), + null, + null); + verify(service, never()).recordOperation(anyInt()); + } + + @Test + void bearerApiKeyIsCounted() { + interceptor.afterCompletion( + editRequest(1, "Authorization", "Bearer sk-not-a-jwt-key"), + response(200, "application/pdf"), + null, + null); + verify(service).recordOperation(1); + } + + @Test + void errorResponseIsNotCounted() { + interceptor.afterCompletion(editRequest(1), response(500, "application/pdf"), null, null); + verify(service, never()).recordOperation(anyInt()); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/config/SpringDocConfigTest.java b/app/core/src/test/java/stirling/software/SPDF/config/SpringDocConfigTest.java new file mode 100644 index 0000000000..12a028776b --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/SpringDocConfigTest.java @@ -0,0 +1,119 @@ +package stirling.software.SPDF.config; + +import static org.assertj.core.api.Assertions.assertThat; + +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.springdoc.core.customizers.OpenApiCustomizer; +import org.springdoc.core.models.GroupedOpenApi; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; + +@DisplayName("SpringDocConfig") +class SpringDocConfigTest { + + private SpringDocConfig config; + + @BeforeEach + void setUp() { + config = new SpringDocConfig(); + } + + // Applies every registered customizer against a fresh OpenAPI carrying an Info instance. + private OpenAPI applyCustomizers(GroupedOpenApi api) { + OpenAPI openApi = new OpenAPI().info(new Info()); + for (OpenApiCustomizer customizer : api.getOpenApiCustomizers()) { + customizer.customise(openApi); + } + return openApi; + } + + @Nested + @DisplayName("pdfProcessingApi") + class PdfProcessingApi { + + @Test + @DisplayName("builds the file-processing group with match and exclude paths") + void buildsGroup() { + OpenApiCustomizer pdfFileOneOfCustomizer = openApi -> {}; + + GroupedOpenApi api = config.pdfProcessingApi(pdfFileOneOfCustomizer); + + assertThat(api).isNotNull(); + assertThat(api.getGroup()).isEqualTo("file-processing"); + assertThat(api.getDisplayName()).isEqualTo("File Processing"); + assertThat(api.getPathsToMatch()).contains("/api/v1/**"); + assertThat(api.getPathsToExclude()).contains("/api/v1/admin/**", "/api/v1/auth/**"); + // The injected oneOf customizer plus the inline info customizer are both registered. + assertThat(api.getOpenApiCustomizers()).hasSizeGreaterThanOrEqualTo(2); + } + + @Test + @DisplayName("info customizer sets the processing title and description") + void infoCustomizerSetsTitle() { + GroupedOpenApi api = config.pdfProcessingApi(openApi -> {}); + + OpenAPI openApi = applyCustomizers(api); + + assertThat(openApi.getInfo().getTitle()).isEqualTo("Stirling PDF - Processing API"); + assertThat(openApi.getInfo().getDescription()).contains("PDF"); + } + } + + @Nested + @DisplayName("adminApi") + class AdminApi { + + @Test + @DisplayName("builds the management group with admin/user/auth paths") + void buildsGroup() { + GroupedOpenApi api = config.adminApi(); + + assertThat(api.getGroup()).isEqualTo("management"); + assertThat(api.getDisplayName()).isEqualTo("Management"); + assertThat(api.getPathsToMatch()) + .contains("/api/v1/admin/**", "/api/v1/user/**", "/api/v1/auth/**"); + } + + @Test + @DisplayName("info customizer sets the management title") + void infoCustomizerSetsTitle() { + GroupedOpenApi api = config.adminApi(); + + OpenAPI openApi = applyCustomizers(api); + + assertThat(openApi.getInfo().getTitle()).isEqualTo("Stirling PDF - Management API"); + assertThat(openApi.getInfo().getDescription()).isNotBlank(); + } + } + + @Nested + @DisplayName("systemApi") + class SystemApi { + + @Test + @DisplayName("builds the system group with ui-data/info paths") + void buildsGroup() { + GroupedOpenApi api = config.systemApi(); + + assertThat(api.getGroup()).isEqualTo("system"); + assertThat(api.getDisplayName()).isEqualTo("System & UI API"); + assertThat(api.getPathsToMatch()) + .contains("/api/v1/ui-data/**", "/api/v1/info/**", "/api/v1/general/job/**"); + } + + @Test + @DisplayName("info customizer sets the system title") + void infoCustomizerSetsTitle() { + GroupedOpenApi api = config.systemApi(); + + OpenAPI openApi = applyCustomizers(api); + + assertThat(openApi.getInfo().getTitle()).isEqualTo("Stirling PDF - System API"); + assertThat(openApi.getInfo().getDescription()).isNotBlank(); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/config/StartupApplicationListenerTest.java b/app/core/src/test/java/stirling/software/SPDF/config/StartupApplicationListenerTest.java new file mode 100644 index 0000000000..6e668be186 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/StartupApplicationListenerTest.java @@ -0,0 +1,28 @@ +package stirling.software.SPDF.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.event.ContextRefreshedEvent; + +class StartupApplicationListenerTest { + + @Test + @DisplayName("onApplicationEvent records a startup time") + void recordsStartTime() { + StartupApplicationListener listener = new StartupApplicationListener(); + LocalDateTime before = LocalDateTime.now().minusSeconds(1); + + listener.onApplicationEvent(new ContextRefreshedEvent(new EmptyContext())); + + assertThat(StartupApplicationListener.startTime).isNotNull(); + assertThat(StartupApplicationListener.startTime).isAfterOrEqualTo(before); + } + + // Minimal ApplicationContext to satisfy ContextRefreshedEvent construction. + private static class EmptyContext + extends org.springframework.context.support.StaticApplicationContext {} +} diff --git a/app/core/src/test/java/stirling/software/SPDF/config/TauriProcessMonitorTest.java b/app/core/src/test/java/stirling/software/SPDF/config/TauriProcessMonitorTest.java new file mode 100644 index 0000000000..88a363deb9 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/TauriProcessMonitorTest.java @@ -0,0 +1,283 @@ +package stirling.software.SPDF.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Optional; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; + +@DisplayName("TauriProcessMonitor") +class TauriProcessMonitorTest { + + private static Object invokePrivate(TauriProcessMonitor monitor, String name, Object... args) + throws Exception { + Method method = findMethod(name); + method.setAccessible(true); + return method.invoke(monitor, args); + } + + private static Method findMethod(String name) { + for (Method m : TauriProcessMonitor.class.getDeclaredMethods()) { + if (m.getName().equals(name)) { + return m; + } + } + throw new IllegalStateException("Method not found: " + name); + } + + private static void setField(TauriProcessMonitor monitor, String name, Object value) + throws Exception { + Field field = TauriProcessMonitor.class.getDeclaredField(name); + field.setAccessible(true); + field.set(monitor, value); + } + + private static Object getField(TauriProcessMonitor monitor, String name) throws Exception { + Field field = TauriProcessMonitor.class.getDeclaredField(name); + field.setAccessible(true); + return field.get(monitor); + } + + @Nested + @DisplayName("getCurrentProcessId") + class CurrentProcessId { + + @Test + @DisplayName("returns a non-blank PID string") + void returnsPid() { + assertThat(TauriProcessMonitor.getCurrentProcessId()).isNotBlank(); + } + } + + @Nested + @DisplayName("init") + class Init { + + // System.getenv cannot be mocked (java.base), so init() is exercised against the real + // environment, which has no TAURI_PARENT_PID; the present-PID path is driven directly + // through startMonitoring(). + + @Test + @DisplayName("startMonitoring flips monitoring on and creates a scheduler") + void startMonitoringSchedulesTask() throws Exception { + ApplicationContext ctx = mock(ApplicationContext.class); + TauriProcessMonitor monitor = new TauriProcessMonitor(ctx); + setField(monitor, "parentProcessId", "12345"); + + try { + invokePrivate(monitor, "startMonitoring"); + + assertThat((Boolean) getField(monitor, "monitoring")).isTrue(); + assertThat(getField(monitor, "scheduler")).isNotNull(); + assertThat((String) getField(monitor, "parentProcessId")).isEqualTo("12345"); + } finally { + // Stop the scheduler thread created by startMonitoring. + monitor.cleanup(); + } + } + } + + @Nested + @DisplayName("isProcessAlive") + class IsProcessAlive { + + @Test + @DisplayName("returns true when ProcessHandle reports the PID present") + void aliveWhenPresent() throws Exception { + TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class)); + + try (MockedStatic ph = mockStatic(ProcessHandle.class)) { + ph.when(() -> ProcessHandle.of(999L)) + .thenReturn(Optional.of(mock(ProcessHandle.class))); + Object result = invokePrivate(monitor, "isProcessAlive", "999"); + assertThat((Boolean) result).isTrue(); + } + } + + @Test + @DisplayName("returns false when ProcessHandle reports the PID absent") + void deadWhenAbsent() throws Exception { + TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class)); + + try (MockedStatic ph = mockStatic(ProcessHandle.class)) { + ph.when(() -> ProcessHandle.of(999L)).thenReturn(Optional.empty()); + Object result = invokePrivate(monitor, "isProcessAlive", "999"); + assertThat((Boolean) result).isFalse(); + } + } + + @Test + @DisplayName("returns false for a non-numeric PID") + void falseForInvalidPid() throws Exception { + TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class)); + + Object result = invokePrivate(monitor, "isProcessAlive", "not-a-number"); + assertThat((Boolean) result).isFalse(); + } + } + + @Nested + @DisplayName("checkParentProcess") + class CheckParentProcess { + + @Test + @DisplayName("returns early when monitoring is off") + void earlyReturnWhenNotMonitoring() throws Exception { + TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class)); + setField(monitor, "monitoring", false); + + // Should not throw even though parentProcessId is null. + invokePrivate(monitor, "checkParentProcess"); + } + + @Test + @DisplayName("triggers graceful shutdown when the parent process is dead") + void shutsDownWhenParentDead() throws Exception { + ConfigurableApplicationContext ctx = mock(ConfigurableApplicationContext.class); + TauriProcessMonitor monitor = new TauriProcessMonitor(ctx); + setField(monitor, "monitoring", true); + setField(monitor, "parentProcessId", "999"); + + try (MockedStatic ph = mockStatic(ProcessHandle.class)) { + ph.when(() -> ProcessHandle.of(999L)).thenReturn(Optional.empty()); + + invokePrivate(monitor, "checkParentProcess"); + + // initiateGracefulShutdown flips monitoring off and spawns an async close. + // The async close runs after a hardcoded 1s sleep, so we assert only the + // immediate, deterministic effect to keep the test fast. + assertThat((Boolean) getField(monitor, "monitoring")).isFalse(); + } + } + + @Test + @DisplayName("does nothing when the parent process is still alive") + void noShutdownWhenParentAlive() throws Exception { + ConfigurableApplicationContext ctx = mock(ConfigurableApplicationContext.class); + TauriProcessMonitor monitor = new TauriProcessMonitor(ctx); + setField(monitor, "monitoring", true); + setField(monitor, "parentProcessId", "999"); + + try (MockedStatic ph = mockStatic(ProcessHandle.class)) { + ph.when(() -> ProcessHandle.of(999L)) + .thenReturn(Optional.of(mock(ProcessHandle.class))); + + invokePrivate(monitor, "checkParentProcess"); + + assertThat((Boolean) getField(monitor, "monitoring")).isTrue(); + } + verify(ctx, never()).close(); + } + } + + @Nested + @DisplayName("initiateGracefulShutdown") + class InitiateGracefulShutdown { + + @Test + @DisplayName("closes a ConfigurableApplicationContext asynchronously") + void closesConfigurableContext() throws Exception { + ConfigurableApplicationContext ctx = mock(ConfigurableApplicationContext.class); + TauriProcessMonitor monitor = new TauriProcessMonitor(ctx); + setField(monitor, "monitoring", true); + + invokePrivate(monitor, "initiateGracefulShutdown"); + + // The async close runs after a hardcoded 1s sleep; assert only the immediate effect. + assertThat((Boolean) getField(monitor, "monitoring")).isFalse(); + } + } + + @Nested + @DisplayName("cleanup") + class Cleanup { + + @Test + @DisplayName("is a no-op when no scheduler was created") + void noOpWithoutScheduler() throws Exception { + TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class)); + + // scheduler is null by default; cleanup must not throw. + monitor.cleanup(); + + assertThat((Boolean) getField(monitor, "monitoring")).isFalse(); + } + + @Test + @DisplayName("shuts down an active scheduler") + void shutsDownActiveScheduler() throws Exception { + TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class)); + ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); + when(scheduler.isShutdown()).thenReturn(false); + when(scheduler.awaitTermination(eq(2L), eq(TimeUnit.SECONDS))).thenReturn(true); + setField(monitor, "scheduler", scheduler); + setField(monitor, "monitoring", true); + + monitor.cleanup(); + + verify(scheduler, times(1)).shutdown(); + assertThat((Boolean) getField(monitor, "monitoring")).isFalse(); + } + + @Test + @DisplayName("forces shutdownNow when awaitTermination times out") + void forcesShutdownNowOnTimeout() throws Exception { + TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class)); + ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); + when(scheduler.isShutdown()).thenReturn(false); + when(scheduler.awaitTermination(eq(2L), eq(TimeUnit.SECONDS))).thenReturn(false); + setField(monitor, "scheduler", scheduler); + + monitor.cleanup(); + + verify(scheduler, times(1)).shutdown(); + verify(scheduler, times(1)).shutdownNow(); + } + + @Test + @DisplayName("restores interrupt flag when awaitTermination is interrupted") + void handlesInterruptedException() throws Exception { + TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class)); + ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); + when(scheduler.isShutdown()).thenReturn(false); + when(scheduler.awaitTermination(eq(2L), eq(TimeUnit.SECONDS))) + .thenThrow(new InterruptedException("boom")); + setField(monitor, "scheduler", scheduler); + + monitor.cleanup(); + + verify(scheduler, times(1)).shutdownNow(); + // Clear the interrupt flag we just set so it does not leak to other tests. + assertThat(Thread.interrupted()).isTrue(); + } + + @Test + @DisplayName("skips shutdown when scheduler already terminated") + void skipsAlreadyShutdownScheduler() throws Exception { + TauriProcessMonitor monitor = new TauriProcessMonitor(mock(ApplicationContext.class)); + ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class); + when(scheduler.isShutdown()).thenReturn(true); + setField(monitor, "scheduler", scheduler); + + monitor.cleanup(); + + verify(scheduler, never()).shutdown(); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/config/WAUTrackingFilterTest.java b/app/core/src/test/java/stirling/software/SPDF/config/WAUTrackingFilterTest.java new file mode 100644 index 0000000000..735a5fc72b --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/WAUTrackingFilterTest.java @@ -0,0 +1,91 @@ +package stirling.software.SPDF.config; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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 jakarta.servlet.FilterChain; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.http.HttpServletRequest; + +import stirling.software.SPDF.service.WeeklyActiveUsersService; + +class WAUTrackingFilterTest { + + private WeeklyActiveUsersService wauService; + private WAUTrackingFilter filter; + private jakarta.servlet.ServletResponse response; + private FilterChain chain; + + @BeforeEach + void setUp() { + wauService = mock(WeeklyActiveUsersService.class); + filter = new WAUTrackingFilter(wauService); + response = mock(jakarta.servlet.ServletResponse.class); + chain = mock(FilterChain.class); + } + + @Nested + @DisplayName("browser id handling") + class BrowserId { + + @Test + @DisplayName("records access when header present") + void recordsWhenPresent() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getHeader("X-Browser-Id")).thenReturn("browser-42"); + + filter.doFilter(request, response, chain); + + verify(wauService).recordBrowserAccess("browser-42"); + verify(chain).doFilter(request, response); + } + + @Test + @DisplayName("does not record when header is null") + void skipsWhenNull() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getHeader("X-Browser-Id")).thenReturn(null); + + filter.doFilter(request, response, chain); + + verify(wauService, never()).recordBrowserAccess(anyString()); + verify(chain).doFilter(request, response); + } + + @Test + @DisplayName("does not record when header is blank") + void skipsWhenBlank() throws Exception { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getHeader("X-Browser-Id")).thenReturn(" "); + + filter.doFilter(request, response, chain); + + verify(wauService, never()).recordBrowserAccess(anyString()); + verify(chain).doFilter(request, response); + } + } + + @Nested + @DisplayName("non-http requests") + class NonHttp { + + @Test + @DisplayName("passes through without recording") + void nonHttpPassThrough() throws Exception { + ServletRequest request = mock(ServletRequest.class); + + filter.doFilter(request, response, chain); + + verify(wauService, never()).recordBrowserAccess(anyString()); + verify(chain).doFilter(request, response); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/config/WebMvcConfigTest.java b/app/core/src/test/java/stirling/software/SPDF/config/WebMvcConfigTest.java new file mode 100644 index 0000000000..200ca99a71 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/WebMvcConfigTest.java @@ -0,0 +1,202 @@ +package stirling.software.SPDF.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +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.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.web.servlet.config.annotation.CorsRegistration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; + +import stirling.software.common.model.ApplicationProperties; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("WebMvcConfig") +class WebMvcConfigTest { + + private static final String TAURI_PROP = "STIRLING_PDF_TAURI_MODE"; + + @Mock private EndpointInterceptor endpointInterceptor; + @Mock private PdfMetricsInterceptor pdfMetricsInterceptor; + @Mock private ApplicationProperties applicationProperties; + @Mock private ApplicationProperties.System system; + + private WebMvcConfig config; + private String originalTauriProp; + + @BeforeEach + void setUp() { + originalTauriProp = System.getProperty(TAURI_PROP); + System.clearProperty(TAURI_PROP); + config = + new WebMvcConfig(endpointInterceptor, pdfMetricsInterceptor, applicationProperties); + } + + @AfterEach + void tearDown() { + if (originalTauriProp == null) { + System.clearProperty(TAURI_PROP); + } else { + System.setProperty(TAURI_PROP, originalTauriProp); + } + } + + @Nested + @DisplayName("addInterceptors") + class AddInterceptors { + + @Test + @DisplayName("registers both interceptors in order") + void registersBothInterceptors() { + InterceptorRegistry registry = mock(InterceptorRegistry.class); + InterceptorRegistration registration = mock(InterceptorRegistration.class); + when(registry.addInterceptor(any())).thenReturn(registration); + + config.addInterceptors(registry); + + verify(registry).addInterceptor(endpointInterceptor); + verify(registry).addInterceptor(pdfMetricsInterceptor); + } + } + + @Nested + @DisplayName("addResourceHandlers") + class AddResourceHandlers { + + @Test + @DisplayName("registers all five resource handler groups") + void registersFiveHandlerGroups() { + ResourceHandlerRegistry registry = mock(ResourceHandlerRegistry.class); + ResourceHandlerRegistration registration = + mock(ResourceHandlerRegistration.class, RETURNS_DEEP_STUBS); + when(registry.addResourceHandler(any(String[].class))).thenReturn(registration); + + config.addResourceHandlers(registry); + + // SW/PWA, assets, media+fonts, branding, catch-all = 5 handler registrations. + verify(registry, times(5)).addResourceHandler(any(String[].class)); + } + + @Test + @DisplayName("includes the SPA catch-all and assets patterns") + void includesKnownPatterns() { + ResourceHandlerRegistry registry = mock(ResourceHandlerRegistry.class); + ResourceHandlerRegistration registration = + mock(ResourceHandlerRegistration.class, RETURNS_DEEP_STUBS); + when(registry.addResourceHandler(any(String[].class))).thenReturn(registration); + + config.addResourceHandlers(registry); + + ArgumentCaptor captor = ArgumentCaptor.forClass(String[].class); + verify(registry, atLeastOnce()).addResourceHandler(captor.capture()); + List allPatterns = + captor.getAllValues().stream().flatMap(java.util.Arrays::stream).toList(); + assertThat(allPatterns).contains("/**", "/assets/**", "/sw.js"); + } + } + + @Nested + @DisplayName("addCorsMappings") + class AddCorsMappings { + + private CorsRegistry registry; + private CorsRegistration registration; + + @BeforeEach + void initRegistry() { + registry = mock(CorsRegistry.class); + registration = mock(CorsRegistration.class, RETURNS_DEEP_STUBS); + when(registry.addMapping(anyString())).thenReturn(registration); + } + + @Test + @DisplayName("Tauri mode adds a mapping with Tauri origin patterns") + void tauriModeBranch() { + System.setProperty(TAURI_PROP, "true"); + // hasConfiguredOrigins is evaluated before the Tauri check, so getSystem() is + // consulted. + when(applicationProperties.getSystem()).thenReturn(system); + when(system.getCorsAllowedOrigins()).thenReturn(List.of()); + + config.addCorsMappings(registry); + + verify(registry).addMapping("/**"); + } + + @Test + @DisplayName("uses configured origins and appends Tauri origins when present") + void configuredOriginsBranch() { + when(applicationProperties.getSystem()).thenReturn(system); + when(system.getCorsAllowedOrigins()) + .thenReturn(new java.util.ArrayList<>(List.of("https://app.example.com"))); + + config.addCorsMappings(registry); + + verify(registry).addMapping("/**"); + // origins consulted twice (presence check + value use) + verify(system, atLeastOnce()).getCorsAllowedOrigins(); + } + + @Test + @DisplayName("configured origins keep an already-present Tauri origin unduplicated") + void configuredOriginsAlreadyContainTauri() { + when(applicationProperties.getSystem()).thenReturn(system); + when(system.getCorsAllowedOrigins()) + .thenReturn( + new java.util.ArrayList<>( + List.of( + "tauri://localhost", + "http://tauri.localhost", + "https://tauri.localhost"))); + + config.addCorsMappings(registry); + + verify(registry).addMapping("/**"); + } + + @Test + @DisplayName("default branch allows all origins when nothing configured") + void defaultBranchAllowsAll() { + when(applicationProperties.getSystem()).thenReturn(system); + when(system.getCorsAllowedOrigins()).thenReturn(List.of()); + + config.addCorsMappings(registry); + + verify(registry).addMapping("/**"); + } + + @Test + @DisplayName("default branch also triggers when system is null") + void defaultBranchWhenSystemNull() { + lenient().when(applicationProperties.getSystem()).thenReturn(null); + + config.addCorsMappings(registry); + + verify(registry).addMapping("/**"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/CropControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/CropControllerMoreTest.java new file mode 100644 index 0000000000..4c5c3ed8d0 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/CropControllerMoreTest.java @@ -0,0 +1,225 @@ +package stirling.software.SPDF.controller.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +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.api.io.TempDir; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.model.api.general.CropPdfForm; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +/** + * Additional branch coverage for {@link CropController}: the Ghostscript routing decision (enabled + * vs disabled when removeDataOutsideCrop is set), the Ghostscript execution path with the external + * process mocked, and the large-image sampling-step branch of detectContentBounds. The gs binary is + * never invoked. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("CropController additional branch tests") +class CropControllerMoreTest { + + @TempDir Path tempDir; + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private TempFileManager tempFileManager; + @Mock private EndpointConfiguration endpointConfiguration; + @InjectMocks private CropController cropController; + + @BeforeEach + void setUp() throws Exception { + lenient() + .when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + inv -> { + File f = + Files.createTempFile( + tempDir, "crop", inv.getArgument(0)) + .toFile(); + TempFile tf = mock(TempFile.class); + lenient().when(tf.getFile()).thenReturn(f); + lenient().when(tf.getPath()).thenReturn(f.toPath()); + lenient().when(tf.getAbsolutePath()).thenReturn(f.getAbsolutePath()); + return tf; + }); + } + + private MockMultipartFile pdf(int pages) throws IOException { + Path p = tempDir.resolve("crop-src.pdf"); + try (PDDocument doc = new PDDocument()) { + for (int i = 0; i < pages; i++) { + doc.addPage(new PDPage(PDRectangle.LETTER)); + } + doc.save(p.toFile()); + } + return new MockMultipartFile( + "fileInput", "src.pdf", MediaType.APPLICATION_PDF_VALUE, Files.readAllBytes(p)); + } + + private CropPdfForm form(MockMultipartFile file, boolean removeOutside) { + CropPdfForm f = new CropPdfForm(); + f.setFileInput(file); + f.setX(20f); + f.setY(20f); + f.setWidth(200f); + f.setHeight(300f); + f.setAutoCrop(false); + f.setRemoveDataOutsideCrop(removeOutside); + return f; + } + + @Nested + @DisplayName("Ghostscript routing") + class GhostscriptRouting { + + @Test + @DisplayName( + "removeDataOutsideCrop with Ghostscript disabled falls back to the PDFBox path") + void disabledFallsBackToPdfBox() throws Exception { + MockMultipartFile file = pdf(1); + CropPdfForm request = form(file, true); + + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false); + PDDocument source = mock(PDDocument.class); + PDDocument out = mock(PDDocument.class); + when(pdfDocumentFactory.load(request)).thenReturn(source); + when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(source)).thenReturn(out); + + ResponseEntity response = cropController.cropPdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + // PDFBox path constructs a new document; gs path would not. + verify(pdfDocumentFactory).createNewDocumentBasedOnOldDocument(source); + verify(source).close(); + verify(out).close(); + } + + @Test + @DisplayName("removeDataOutsideCrop with Ghostscript enabled runs the gs command path") + void enabledRunsGhostscript() throws Exception { + MockMultipartFile file = pdf(2); + CropPdfForm request = form(file, true); + + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true); + // Real document so setCropBox + save() succeed inside the gs branch. + when(pdfDocumentFactory.load(request)).thenReturn(Loader.loadPDF(file.getBytes())); + + ProcessExecutor executor = mock(ProcessExecutor.class); + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)) + .thenReturn(executor); + when(executor.runCommandWithOutputHandling(any())).thenReturn(null); + + ResponseEntity response = cropController.cropPdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + verify(executor).runCommandWithOutputHandling(any()); + } + } + + @Test + @DisplayName("Ghostscript interruption is wrapped and surfaced") + void interruptedIsWrapped() throws Exception { + MockMultipartFile file = pdf(1); + CropPdfForm request = form(file, true); + + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true); + when(pdfDocumentFactory.load(request)).thenReturn(Loader.loadPDF(file.getBytes())); + + ProcessExecutor executor = mock(ProcessExecutor.class); + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)) + .thenReturn(executor); + when(executor.runCommandWithOutputHandling(any())) + .thenThrow(new InterruptedException("stop")); + + org.junit.jupiter.api.Assertions.assertThrows( + Exception.class, () -> cropController.cropPdf(request)); + } + } + } + + @Nested + @DisplayName("detectContentBounds sampling step") + class SamplingStep { + + private Method detect; + + @BeforeEach + void setUp() throws Exception { + detect = + CropController.class.getDeclaredMethod( + "detectContentBounds", BufferedImage.class); + detect.setAccessible(true); + } + + @Test + @DisplayName("large images (>2000px) use a step of 2 and still locate content") + void largeImageUsesStep2() throws Exception { + // Width > 2000 triggers the step=2 sampling branch. + BufferedImage image = new BufferedImage(2100, 50, BufferedImage.TYPE_INT_RGB); + for (int x = 0; x < 2100; x++) { + for (int y = 0; y < 50; y++) { + image.setRGB(x, y, 0xFFFFFF); + } + } + // Dark block on even coordinates so the step-2 scan can see it. + for (int x = 1000; x < 1040; x += 2) { + for (int y = 10; y < 30; y += 2) { + image.setRGB(x, y, 0x000000); + } + } + + int[] bounds = (int[]) detect.invoke(null, image); + assertThat(bounds).hasSize(4); + assertThat(bounds[0]).isGreaterThanOrEqualTo(0); + assertThat(bounds[2]).isGreaterThan(bounds[0]); + } + + @Test + @DisplayName("zero-size image returns degenerate bounds") + void zeroSizeImage() throws Exception { + BufferedImage image = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB); + image.setRGB(0, 0, 0xFFFFFF); + int[] bounds = (int[]) detect.invoke(null, image); + assertThat(bounds).containsExactly(0, 0, 0, 0); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/MergeControllerExtraTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/MergeControllerExtraTest.java new file mode 100644 index 0000000000..340609de1b --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/MergeControllerExtraTest.java @@ -0,0 +1,173 @@ +package stirling.software.SPDF.controller.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem; +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.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfMetadataService; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; + +/** + * Extra coverage for {@link MergeController} helpers that the mock-based and end-to-end suites do + * not reach directly: the PDFBox {@code addTableOfContents} outline builder and {@code + * mergeDocuments} page-copy path, both driven over real in-memory documents. + */ +class MergeControllerExtraTest { + + private CustomPDFDocumentFactory pdfDocumentFactory; + private MergeController mergeController; + + @BeforeEach + void setUp() { + pdfDocumentFactory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class)); + TempFileManager tempFileManager = + new TempFileManager(new TempFileRegistry(), new ApplicationProperties()); + mergeController = new MergeController(pdfDocumentFactory, tempFileManager); + } + + private static byte[] pdfBytes(int pages) throws IOException { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + for (int i = 0; i < pages; i++) { + doc.addPage(new PDPage(PDRectangle.A4)); + } + doc.save(baos); + return baos.toByteArray(); + } + } + + private static MockMultipartFile pdf(String name, int pages) throws IOException { + return new MockMultipartFile( + "fileInput", name, MediaType.APPLICATION_PDF_VALUE, pdfBytes(pages)); + } + + private void addTableOfContents(PDDocument merged, MultipartFile[] files) throws Exception { + Method m = + MergeController.class.getDeclaredMethod( + "addTableOfContents", PDDocument.class, MultipartFile[].class); + m.setAccessible(true); + m.invoke(mergeController, merged, files); + } + + @Nested + @DisplayName("mergeDocuments") + class MergeDocuments { + + @Test + @DisplayName("copies all pages from every source into a single document") + void copiesAllPages() throws Exception { + try (PDDocument a = loadPdf(pdfBytes(2)); + PDDocument b = loadPdf(pdfBytes(3))) { + try (PDDocument merged = mergeController.mergeDocuments(List.of(a, b))) { + assertThat(merged.getNumberOfPages()).isEqualTo(5); + } + } + } + + @Test + @DisplayName("an empty source list yields an empty merged document") + void emptyListEmptyDoc() throws Exception { + try (PDDocument merged = mergeController.mergeDocuments(List.of())) { + assertThat(merged.getNumberOfPages()).isZero(); + } + } + + @Test + @DisplayName("a single source is copied verbatim") + void singleSource() throws Exception { + try (PDDocument only = loadPdf(pdfBytes(4))) { + try (PDDocument merged = mergeController.mergeDocuments(List.of(only))) { + assertThat(merged.getNumberOfPages()).isEqualTo(4); + } + } + } + + private PDDocument loadPdf(byte[] bytes) throws IOException { + return org.apache.pdfbox.Loader.loadPDF(bytes); + } + } + + @Nested + @DisplayName("addTableOfContents") + class AddTableOfContents { + + @Test + @DisplayName("adds one outline entry per input file titled by filename without extension") + void oneEntryPerFile() throws Exception { + MultipartFile[] files = {pdf("intro.pdf", 1), pdf("body.pdf", 2)}; + try (PDDocument merged = new PDDocument()) { + for (int i = 0; i < 3; i++) { + merged.addPage(new PDPage(PDRectangle.A4)); + } + + addTableOfContents(merged, files); + + PDDocumentOutline outline = merged.getDocumentCatalog().getDocumentOutline(); + assertThat(outline).isNotNull(); + List titles = outlineTitles(outline); + assertThat(titles).containsExactly("intro", "body"); + } + } + + @Test + @DisplayName("outline destinations advance by each source's page count") + void destinationsAdvance() throws Exception { + MultipartFile[] files = {pdf("first.pdf", 1), pdf("second.pdf", 1)}; + try (PDDocument merged = new PDDocument()) { + merged.addPage(new PDPage(PDRectangle.A4)); + merged.addPage(new PDPage(PDRectangle.A4)); + + addTableOfContents(merged, files); + + PDDocumentOutline outline = merged.getDocumentCatalog().getDocumentOutline(); + Iterator it = outline.children().iterator(); + // both items resolve and the outline is non-empty + assertThat(it.hasNext()).isTrue(); + } + } + + @Test + @DisplayName("handles a single-file table of contents") + void singleFileToc() throws Exception { + MultipartFile[] files = {pdf("solo.pdf", 1)}; + try (PDDocument merged = new PDDocument()) { + merged.addPage(new PDPage(PDRectangle.A4)); + + addTableOfContents(merged, files); + + assertThat(outlineTitles(merged.getDocumentCatalog().getDocumentOutline())) + .containsExactly("solo"); + } + } + + private List outlineTitles(PDDocumentOutline outline) { + List titles = new ArrayList<>(); + for (PDOutlineItem item : outline.children()) { + titles.add(item.getTitle()); + } + return titles; + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/MergeControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/MergeControllerMoreTest.java new file mode 100644 index 0000000000..250773562b --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/MergeControllerMoreTest.java @@ -0,0 +1,365 @@ +package stirling.software.SPDF.controller.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Calendar; +import java.util.GregorianCalendar; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +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.interactive.documentnavigation.outline.PDDocumentOutline; +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.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.api.general.MergePdfsRequest; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfMetadataService; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; + +/** + * End-to-end coverage for {@link MergeController#mergePdfs} using real in-memory PDFs and the real + * JPDFium merge pipeline plus a real {@link CustomPDFDocumentFactory}/{@link TempFileManager}. + * Exercises the sort modes, the fileOrder reordering branch, table-of-contents generation, the + * removeCertSign signature pre-check, single/empty file handling and the corrupted-input path that + * the mock-based {@code MergeControllerTest}/{@code MergeControllerGapTest} do not reach. + */ +class MergeControllerMoreTest { + + private CustomPDFDocumentFactory pdfDocumentFactory; + private TempFileManager tempFileManager; + private MergeController mergeController; + + @BeforeEach + void setUp() { + pdfDocumentFactory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class)); + tempFileManager = new TempFileManager(new TempFileRegistry(), new ApplicationProperties()); + mergeController = new MergeController(pdfDocumentFactory, tempFileManager); + } + + // ---- helpers ------------------------------------------------------------ + + private static byte[] buildPdf(int pageCount, String title, Long modMillis) throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + for (int i = 0; i < pageCount; i++) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(72, 720); + cs.showText("Body " + (i + 1)); + cs.endText(); + } + } + PDDocumentInformation info = document.getDocumentInformation(); + if (title != null) { + info.setTitle(title); + } + if (modMillis != null) { + Calendar cal = new GregorianCalendar(); + cal.setTimeInMillis(modMillis); + info.setModificationDate(cal); + } + document.save(baos); + return baos.toByteArray(); + } + } + + private static MockMultipartFile pdf(String name, int pages) throws IOException { + return new MockMultipartFile( + "fileInput", name, MediaType.APPLICATION_PDF_VALUE, buildPdf(pages, null, null)); + } + + private static MockMultipartFile pdf(String name, int pages, String title, Long modMillis) + throws IOException { + return new MockMultipartFile( + "fileInput", + name, + MediaType.APPLICATION_PDF_VALUE, + buildPdf(pages, title, modMillis)); + } + + private static MergePdfsRequest request( + MockMultipartFile[] files, String sortType, boolean removeCertSign, boolean toc) { + MergePdfsRequest req = new MergePdfsRequest(); + req.setFileInput(files); + req.setSortType(sortType); + req.setRemoveCertSign(removeCertSign); + req.setGenerateToc(toc); + return req; + } + + private static PDDocument readResponse(ResponseEntity response) throws IOException { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + byte[] out; + try (InputStream is = response.getBody().getInputStream()) { + out = is.readAllBytes(); + } + assertThat(out.length).isGreaterThan(0); + return Loader.loadPDF(out); + } + + @Nested + @DisplayName("Basic merge") + class BasicMerge { + + @Test + @DisplayName("merges two PDFs and sums the page counts") + void mergesTwoFiles() throws Exception { + MockMultipartFile[] files = {pdf("a.pdf", 2), pdf("b.pdf", 3)}; + ResponseEntity response = + mergeController.mergePdfs(request(files, "orderProvided", false, false), null); + try (PDDocument result = readResponse(response)) { + assertThat(result.getNumberOfPages()).isEqualTo(5); + } + } + + @Test + @DisplayName("merges three PDFs preserving total pages") + void mergesThreeFiles() throws Exception { + MockMultipartFile[] files = {pdf("a.pdf", 1), pdf("b.pdf", 2), pdf("c.pdf", 1)}; + ResponseEntity response = + mergeController.mergePdfs(request(files, "orderProvided", false, false), null); + try (PDDocument result = readResponse(response)) { + assertThat(result.getNumberOfPages()).isEqualTo(4); + } + } + + @Test + @DisplayName("merging a single file returns its pages") + void mergesSingleFile() throws Exception { + MockMultipartFile[] files = {pdf("solo.pdf", 4)}; + ResponseEntity response = + mergeController.mergePdfs(request(files, "orderProvided", false, false), null); + try (PDDocument result = readResponse(response)) { + assertThat(result.getNumberOfPages()).isEqualTo(4); + } + } + + @Test + @DisplayName("null fileInput is treated as an empty set and still returns OK") + void nullFileInput() throws Exception { + MergePdfsRequest req = new MergePdfsRequest(); + req.setFileInput(null); + req.setSortType("orderProvided"); + req.setRemoveCertSign(false); + ResponseEntity response = mergeController.mergePdfs(req, null); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + } + + @Test + @DisplayName("empty file array returns an empty (zero-byte) body") + void emptyFileArray() throws Exception { + ResponseEntity response = + mergeController.mergePdfs( + request(new MockMultipartFile[0], "orderProvided", false, false), null); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + } + + @Nested + @DisplayName("Sort modes") + class SortModes { + + @ParameterizedTest + @ValueSource( + strings = { + "orderProvided", + "byFileName", + "byDateModified", + "byDateCreated", + "byPDFTitle", + "unknownSortType" + }) + @DisplayName("every sort mode produces a valid merged document") + void allSortModes(String sortType) throws Exception { + MockMultipartFile[] files = { + pdf("charlie.pdf", 1, "Gamma", 3_000L), + pdf("alpha.pdf", 1, "Alpha", 1_000L), + pdf("bravo.pdf", 1, "Beta", 2_000L) + }; + ResponseEntity response = + mergeController.mergePdfs(request(files, sortType, false, false), null); + try (PDDocument result = readResponse(response)) { + assertThat(result.getNumberOfPages()).isEqualTo(3); + } + } + + @Test + @DisplayName("byFileName orders the first output filename deterministically") + void byFileNameUsesFirstAlphabetical() throws Exception { + MockMultipartFile[] files = {pdf("zebra.pdf", 1), pdf("apple.pdf", 1)}; + ResponseEntity response = + mergeController.mergePdfs(request(files, "byFileName", false, false), null); + String disposition = + response.getHeaders() + .getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION); + // apple.pdf sorts first so it seeds the generated merged filename + assertThat(disposition).contains("apple"); + } + } + + @Nested + @DisplayName("fileOrder reordering") + class FileOrder { + + @Test + @DisplayName("fileOrder param overrides sortType and drives the merge order") + void fileOrderOverridesSort() throws Exception { + MockMultipartFile[] files = {pdf("first.pdf", 1), pdf("second.pdf", 2)}; + ResponseEntity response = + mergeController.mergePdfs( + request(files, "byFileName", false, false), "second.pdf\nfirst.pdf"); + String disposition = + response.getHeaders() + .getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION); + assertThat(disposition).contains("second"); + try (PDDocument result = readResponse(response)) { + assertThat(result.getNumberOfPages()).isEqualTo(3); + } + } + + @Test + @DisplayName("blank fileOrder falls through to the sortType branch") + void blankFileOrderUsesSort() throws Exception { + MockMultipartFile[] files = {pdf("a.pdf", 1), pdf("b.pdf", 1)}; + ResponseEntity response = + mergeController.mergePdfs(request(files, "orderProvided", false, false), " "); + try (PDDocument result = readResponse(response)) { + assertThat(result.getNumberOfPages()).isEqualTo(2); + } + } + } + + @Nested + @DisplayName("Table of contents and bookmarks") + class TableOfContents { + + @Test + @DisplayName("generateToc adds a document outline keyed by filename") + void generatesToc() throws Exception { + MockMultipartFile[] files = {pdf("intro.pdf", 1), pdf("body.pdf", 2)}; + ResponseEntity response = + mergeController.mergePdfs(request(files, "orderProvided", false, true), null); + try (PDDocument result = readResponse(response)) { + PDDocumentOutline outline = result.getDocumentCatalog().getDocumentOutline(); + assertThat(outline).isNotNull(); + assertThat(outline.children().iterator().hasNext()).isTrue(); + } + } + + @Test + @DisplayName("a blank filename falls back to a generated Document N title") + void tocBlankFilenameFallback() throws Exception { + MockMultipartFile blankName = + new MockMultipartFile( + "fileInput", + "", + MediaType.APPLICATION_PDF_VALUE, + buildPdf(1, null, null)); + MockMultipartFile[] files = {blankName, pdf("named.pdf", 1)}; + ResponseEntity response = + mergeController.mergePdfs(request(files, "orderProvided", false, true), null); + try (PDDocument result = readResponse(response)) { + assertThat(result.getNumberOfPages()).isEqualTo(2); + } + } + } + + @Nested + @DisplayName("removeCertSign branch") + class RemoveCertSign { + + @Test + @DisplayName("removeCertSign with no signatures skips the flatten pass but still merges") + void removeCertSignNoSignatures() throws Exception { + MockMultipartFile[] files = {pdf("a.pdf", 1), pdf("b.pdf", 1)}; + ResponseEntity response = + mergeController.mergePdfs(request(files, "orderProvided", true, false), null); + try (PDDocument result = readResponse(response)) { + assertThat(result.getNumberOfPages()).isEqualTo(2); + } + } + + @Test + @DisplayName("removeCertSign=false copies the merged output directly") + void removeCertSignFalse() throws Exception { + MockMultipartFile[] files = {pdf("a.pdf", 2), pdf("b.pdf", 1)}; + ResponseEntity response = + mergeController.mergePdfs(request(files, "orderProvided", false, false), null); + try (PDDocument result = readResponse(response)) { + assertThat(result.getNumberOfPages()).isEqualTo(3); + } + } + } + + @Nested + @DisplayName("Corrupted input handling") + class CorruptedInput { + + // Drives the PDF pre-validate loop and the merge error path. JPDFium may either reject the + // garbage payload (throw) or salvage a degenerate document; both outcomes are acceptable, + // so + // we only assert the code path runs and any thrown error is an Exception (logged + + // rethrown). + @Test + @DisplayName("a non-PDF payload exercises the pre-validate and merge error branch") + void corruptedPayloadHandled() throws Exception { + MockMultipartFile good = pdf("good.pdf", 1); + MockMultipartFile bad = + new MockMultipartFile( + "fileInput", + "broken.pdf", + MediaType.APPLICATION_PDF_VALUE, + "this is not a pdf at all".getBytes()); + MergePdfsRequest req = + request(new MockMultipartFile[] {good, bad}, "orderProvided", false, false); + try { + ResponseEntity response = mergeController.mergePdfs(req, null); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } catch (Exception expected) { + assertThat(expected).isInstanceOf(Exception.class); + } + } + + @Test + @DisplayName("an entirely empty payload still runs through the merge pipeline") + void emptyPayloadHandled() throws Exception { + MockMultipartFile empty = + new MockMultipartFile( + "fileInput", "empty.pdf", MediaType.APPLICATION_PDF_VALUE, new byte[0]); + MergePdfsRequest req = + request(new MockMultipartFile[] {empty}, "orderProvided", false, false); + try { + ResponseEntity response = mergeController.mergePdfs(req, null); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } catch (Exception expected) { + assertThat(expected).isInstanceOf(Exception.class); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/MultiPageLayoutControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/MultiPageLayoutControllerMoreTest.java new file mode 100644 index 0000000000..5c8b917de5 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/MultiPageLayoutControllerMoreTest.java @@ -0,0 +1,260 @@ +package stirling.software.SPDF.controller.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.nio.file.Files; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +/** + * Branch coverage for {@link MultiPageLayoutController#mergeMultiplePagesIntoOne} options: + * orientation/arrangement/reading-direction validation, margin validation, landscape, RTL and + * by-column layouts, all exercised against real multi-page source documents. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("MultiPageLayoutController options") +class MultiPageLayoutControllerMoreTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private TempFileManager tempFileManager; + + private MultiPageLayoutController controller; + + @BeforeEach + void setUp() throws Exception { + controller = new MultiPageLayoutController(pdfDocumentFactory, tempFileManager); + when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + inv -> { + File f = + Files.createTempFile("mpl", inv.getArgument(0)) + .toFile(); + TempFile tf = mock(TempFile.class); + lenient().when(tf.getFile()).thenReturn(f); + lenient().when(tf.getPath()).thenReturn(f.toPath()); + return tf; + }); + } + + /** Wires the factory to load real source pages and return a real target document. */ + private void wireDocuments(int pages) throws Exception { + PDDocument source = new PDDocument(); + for (int i = 0; i < pages; i++) { + source.addPage(new PDPage(PDRectangle.A4)); + } + when(pdfDocumentFactory.load(any(org.springframework.web.multipart.MultipartFile.class))) + .thenReturn(source); + when(pdfDocumentFactory.createNewDocumentBasedOnOldDocument(source)) + .thenReturn(new PDDocument()); + } + + private static MockMultipartFile file() { + return new MockMultipartFile("fileInput", "in.pdf", "application/pdf", new byte[] {1}); + } + + private static MergeMultiplePagesRequest base() { + MergeMultiplePagesRequest req = new MergeMultiplePagesRequest(); + req.setFileInput(file()); + req.setPagesPerSheet(4); + return req; + } + + @Nested + @DisplayName("validation failures") + class Validation { + + @Test + @DisplayName("unknown mode is rejected") + void unknownModeThrows() { + MergeMultiplePagesRequest req = base(); + req.setMode("WEIRD"); + assertThrows( + IllegalArgumentException.class, + () -> controller.mergeMultiplePagesIntoOne(req)); + } + + @Test + @DisplayName("custom mode with non-positive rows/cols is rejected") + void customNonPositiveThrows() { + MergeMultiplePagesRequest req = base(); + req.setMode("CUSTOM"); + req.setRows(0); + req.setCols(2); + assertThrows( + IllegalArgumentException.class, + () -> controller.mergeMultiplePagesIntoOne(req)); + } + + @Test + @DisplayName("invalid orientation is rejected") + void invalidOrientationThrows() { + MergeMultiplePagesRequest req = base(); + req.setOrientation("DIAGONAL"); + assertThrows( + IllegalArgumentException.class, + () -> controller.mergeMultiplePagesIntoOne(req)); + } + + @Test + @DisplayName("invalid arrangement is rejected") + void invalidArrangementThrows() { + MergeMultiplePagesRequest req = base(); + req.setArrangement("SPIRAL"); + assertThrows( + IllegalArgumentException.class, + () -> controller.mergeMultiplePagesIntoOne(req)); + } + + @Test + @DisplayName("invalid reading direction is rejected") + void invalidReadingDirectionThrows() { + MergeMultiplePagesRequest req = base(); + req.setReadingDirection("DIAGONAL"); + assertThrows( + IllegalArgumentException.class, + () -> controller.mergeMultiplePagesIntoOne(req)); + } + + @Test + @DisplayName("negative margins are rejected") + void negativeMarginsThrows() { + MergeMultiplePagesRequest req = base(); + req.setTopMargin(-1); + assertThrows( + IllegalArgumentException.class, + () -> controller.mergeMultiplePagesIntoOne(req)); + } + + @Test + @DisplayName("outer margins that consume the whole page yield a non-positive cell error") + void outerMarginsTooLargeThrows() throws Exception { + wireDocuments(1); + MergeMultiplePagesRequest req = base(); + // A4 width is ~595pt; 600 left margin alone makes cell width non-positive. + req.setLeftMargin(600); + req.setRightMargin(600); + assertThrows( + IllegalArgumentException.class, + () -> controller.mergeMultiplePagesIntoOne(req)); + } + + @Test + @DisplayName("inner margin larger than the cell yields a non-positive inner-area error") + void innerMarginTooLargeThrows() throws Exception { + wireDocuments(1); + MergeMultiplePagesRequest req = base(); + req.setInnerMargin(1000); + assertThrows( + IllegalArgumentException.class, + () -> controller.mergeMultiplePagesIntoOne(req)); + } + } + + @Nested + @DisplayName("layout option branches") + class LayoutOptions { + + @Test + @DisplayName("landscape orientation succeeds and skips form copying") + void landscapeSucceeds() throws Exception { + wireDocuments(4); + MergeMultiplePagesRequest req = base(); + req.setOrientation("LANDSCAPE"); + req.setAddBorder(Boolean.TRUE); + + ResponseEntity response = controller.mergeMultiplePagesIntoOne(req); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(response.getBody().contentLength() >= 0); + } + + @Test + @DisplayName("right-to-left reading direction succeeds") + void rtlSucceeds() throws Exception { + wireDocuments(4); + MergeMultiplePagesRequest req = base(); + req.setReadingDirection("RTL"); + + ResponseEntity response = controller.mergeMultiplePagesIntoOne(req); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + @Test + @DisplayName("by-columns arrangement with RTL succeeds") + void byColumnsRtlSucceeds() throws Exception { + wireDocuments(4); + MergeMultiplePagesRequest req = base(); + req.setArrangement("BY_COLUMNS"); + req.setReadingDirection("RTL"); + + ResponseEntity response = controller.mergeMultiplePagesIntoOne(req); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + @Test + @DisplayName("by-columns arrangement with LTR succeeds") + void byColumnsLtrSucceeds() throws Exception { + wireDocuments(4); + MergeMultiplePagesRequest req = base(); + req.setArrangement("BY_COLUMNS"); + + ResponseEntity response = controller.mergeMultiplePagesIntoOne(req); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + @Test + @DisplayName("custom mode with explicit borderWidth succeeds") + void customModeWithBorderSucceeds() throws Exception { + wireDocuments(6); + MergeMultiplePagesRequest req = base(); + req.setMode("CUSTOM"); + req.setRows(2); + req.setCols(3); + req.setAddBorder(Boolean.TRUE); + req.setBorderWidth(3); + + ResponseEntity response = controller.mergeMultiplePagesIntoOne(req); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + @Test + @DisplayName("blank mode string defaults to DEFAULT and succeeds") + void blankModeDefaults() throws Exception { + wireDocuments(2); + MergeMultiplePagesRequest req = base(); + req.setMode(" "); + req.setPagesPerSheet(2); + + ResponseEntity response = controller.mergeMultiplePagesIntoOne(req); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersControllerMoreTest.java new file mode 100644 index 0000000000..bbd3941ef0 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/SplitPdfByChaptersControllerMoreTest.java @@ -0,0 +1,260 @@ +package stirling.software.SPDF.controller.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageFitDestination; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +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.api.io.TempDir; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.api.SplitPdfByChaptersRequest; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfMetadataService; +import stirling.software.common.util.TempFileManager; + +/** + * Additional branch coverage for {@link SplitPdfByChaptersController}: nested-bookmark depth + * limiting, the same-page bookmark merge path, and the AcroForm-bearing branch that routes per + * chapter through PDFBox instead of JPDFium. All PDFs are built in memory. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("SplitPdfByChaptersController additional branch tests") +class SplitPdfByChaptersControllerMoreTest { + + @TempDir Path tempDir; + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private PdfMetadataService pdfMetadataService; + @Mock private TempFileManager tempFileManager; + @InjectMocks private SplitPdfByChaptersController controller; + + @BeforeEach + void setUp() throws IOException { + when(tempFileManager.createTempFile(anyString())) + .thenAnswer( + inv -> + Files.createTempFile(tempDir, "ch", inv.getArgument(0)) + .toFile()); + lenient() + .when(pdfDocumentFactory.load(any(File.class))) + .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); + lenient() + .when(pdfDocumentFactory.load(any(File.class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); + } + + private PDOutlineItem item(PDDocument doc, String title, int pageIndex) { + PDOutlineItem oi = new PDOutlineItem(); + oi.setTitle(title); + PDPageFitDestination dest = new PDPageFitDestination(); + dest.setPage(doc.getPage(pageIndex)); + oi.setDestination(dest); + return oi; + } + + private MockMultipartFile asFile(byte[] bytes) { + return new MockMultipartFile( + "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, bytes); + } + + private SplitPdfByChaptersRequest request(byte[] bytes, int level, boolean dupes) { + SplitPdfByChaptersRequest req = new SplitPdfByChaptersRequest(); + req.setFileInput(asFile(bytes)); + req.setBookmarkLevel(level); + req.setIncludeMetadata(false); + req.setAllowDuplicates(dupes); + return req; + } + + private List unzip(Resource zip) throws IOException { + List out = new ArrayList<>(); + try (ZipInputStream zis = + new ZipInputStream(new ByteArrayInputStream(zip.getContentAsByteArray()))) { + ZipEntry e; + while ((e = zis.getNextEntry()) != null) { + out.add(zis.readAllBytes()); + zis.closeEntry(); + } + } + return out; + } + + private int totalPages(List entries) throws IOException { + int total = 0; + for (byte[] data : entries) { + try (PDDocument doc = Loader.loadPDF(data)) { + total += doc.getNumberOfPages(); + } + } + return total; + } + + @Nested + @DisplayName("Nested bookmarks") + class NestedBookmarks { + + /** Builds a doc with top-level chapters, each carrying one child bookmark. */ + private byte[] nestedDoc() throws IOException { + try (PDDocument doc = new PDDocument()) { + for (int i = 0; i < 8; i++) { + doc.addPage(new PDPage(PDRectangle.A4)); + } + PDDocumentOutline outline = new PDDocumentOutline(); + doc.getDocumentCatalog().setDocumentOutline(outline); + + PDOutlineItem chapter1 = item(doc, "Chapter 1", 0); + chapter1.addLast(item(doc, "Section 1.1", 2)); + outline.addLast(chapter1); + + PDOutlineItem chapter2 = item(doc, "Chapter 2", 4); + chapter2.addLast(item(doc, "Section 2.1", 6)); + outline.addLast(chapter2); + + Path p = tempDir.resolve("nested.pdf"); + doc.save(p.toFile()); + return Files.readAllBytes(p); + } + } + + @Test + @DisplayName("level 0 collects only top-level chapters") + void levelZeroTopLevelOnly() throws Exception { + ResponseEntity response = controller.splitPdf(request(nestedDoc(), 0, true)); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + // Only the 2 top-level chapters become split points at level 0. + assertThat(outputs).hasSize(2); + assertThat(totalPages(outputs)).isEqualTo(8); + } + + @Test + @DisplayName("a deeper level descends into child bookmarks") + void deeperLevelIncludesChildren() throws Exception { + int topLevelCount = + unzip(controller.splitPdf(request(nestedDoc(), 0, true)).getBody()).size(); + + ResponseEntity response = controller.splitPdf(request(nestedDoc(), 2, true)); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + // Descending into children yields at least as many split points as the top level. + assertThat(outputs.size()).isGreaterThanOrEqualTo(topLevelCount); + assertThat(totalPages(outputs)).isEqualTo(8); + } + } + + @Nested + @DisplayName("Same-page bookmark merge") + class SamePageMerge { + + @Test + @DisplayName("bookmarks on the same page are merged when duplicates are disallowed") + void mergesSamePageBookmarks() throws Exception { + byte[] bytes; + try (PDDocument doc = new PDDocument()) { + for (int i = 0; i < 4; i++) { + doc.addPage(new PDPage(PDRectangle.A4)); + } + PDDocumentOutline outline = new PDDocumentOutline(); + doc.getDocumentCatalog().setDocumentOutline(outline); + // Two bookmarks both pointing at page index 0 -> same start/end -> merged. + outline.addLast(item(doc, "Intro A", 0)); + outline.addLast(item(doc, "Intro B", 0)); + outline.addLast(item(doc, "Body", 2)); + Path p = tempDir.resolve("samepage.pdf"); + doc.save(p.toFile()); + bytes = Files.readAllBytes(p); + } + + ResponseEntity response = controller.splitPdf(request(bytes, 0, false)); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + // The two same-page intros collapse, leaving fewer outputs than bookmarks. + assertThat(outputs.size()).isLessThan(3); + assertThat(totalPages(outputs)).isGreaterThan(0); + } + } + + @Nested + @DisplayName("Form-bearing PDFs route through PDFBox") + class FormBearing { + + private byte[] formDocWithBookmarks() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDAcroForm acroForm = new PDAcroForm(doc); + doc.getDocumentCatalog().setAcroForm(acroForm); + for (int i = 0; i < 4; i++) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + PDTextField field = new PDTextField(acroForm); + field.setPartialName("text_p" + (i + 1)); + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(new PDRectangle(100, 700, 200, 20)); + widget.setPage(page); + field.setWidgets(List.of(widget)); + page.getAnnotations().add(widget); + acroForm.getFields().add(field); + } + PDDocumentOutline outline = new PDDocumentOutline(); + doc.getDocumentCatalog().setDocumentOutline(outline); + outline.addLast(item(doc, "Chapter 1", 0)); + outline.addLast(item(doc, "Chapter 2", 2)); + + Path p = tempDir.resolve("form.pdf"); + doc.save(p.toFile()); + return Files.readAllBytes(p); + } + } + + @Test + @DisplayName("a PDF with an AcroForm still splits into the expected chapters") + void formPdfSplits() throws Exception { + ResponseEntity response = + controller.splitPdf(request(formDocWithBookmarks(), 0, true)); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List outputs = unzip(response.getBody()); + assertThat(outputs).hasSize(2); + assertThat(totalPages(outputs)).isEqualTo(4); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFControllerExtraTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFControllerExtraTest.java new file mode 100644 index 0000000000..4a2722fb88 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFControllerExtraTest.java @@ -0,0 +1,206 @@ +package stirling.software.SPDF.controller.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Path; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.rendering.ImageType; +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.model.api.converters.ConvertToImageRequest; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.CheckProgramInstall; +import stirling.software.common.util.GeneralUtils; +import stirling.software.common.util.PdfUtils; +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.WebResponseUtils; + +/** + * Extra branch coverage for {@link ConvertImgPDFController#convertToImage} not exercised by the + * existing tests: the null-result logging branch, the octet-stream media-type fallback, and the + * webp-with-Python path that produces no output files. The Python/ProcessExecutor boundary is + * mocked so no interpreter or external binary ever runs. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("ConvertImgPDFController extra convertToImage branches") +class ConvertImgPDFControllerExtraTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private TempFileManager tempFileManager; + @Mock private EndpointConfiguration endpointConfiguration; + + @InjectMocks private ConvertImgPDFController controller; + + private static byte[] tinyPdfBytes(int pages) throws IOException { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + for (int i = 0; i < pages; i++) { + doc.addPage(new PDPage(PDRectangle.A4)); + } + doc.save(baos); + return baos.toByteArray(); + } + } + + private static PDDocument tinyDocument(int pages) { + PDDocument doc = new PDDocument(); + for (int i = 0; i < pages; i++) { + doc.addPage(new PDPage(PDRectangle.A4)); + } + return doc; + } + + private static MockMultipartFile pdfFile(byte[] bytes) { + return new MockMultipartFile("fileInput", "source.pdf", "application/pdf", bytes); + } + + private ConvertToImageRequest baseRequest(byte[] pdf, String format) { + ConvertToImageRequest request = new ConvertToImageRequest(); + request.setFileInput(pdfFile(pdf)); + request.setImageFormat(format); + request.setSingleOrMultiple("single"); + request.setColorType("color"); + request.setDpi(72); + request.setPageNumbers("all"); + request.setIncludeAnnotations(false); + return request; + } + + @Nested + @DisplayName("non-webp branches") + class NonWebp { + + @Test + @DisplayName("null render result still produces a single-image response") + void nullResultStillResponds() throws Exception { + byte[] pdfBytes = tinyPdfBytes(1); + ConvertToImageRequest request = baseRequest(pdfBytes, "png"); + + Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class))) + .thenReturn(tinyDocument(1)); + + @SuppressWarnings("unchecked") + ResponseEntity expected = Mockito.mock(ResponseEntity.class); + + try (MockedStatic pu = Mockito.mockStatic(PdfUtils.class); + MockedStatic wr = + Mockito.mockStatic(WebResponseUtils.class)) { + + // Null bytes hit the "resultant bytes is null" log branch but still respond. + pu.when( + () -> + PdfUtils.convertFromPdf( + eq(pdfDocumentFactory), + any(byte[].class), + eq("PNG"), + eq(ImageType.RGB), + eq(true), + eq(72), + any(String.class), + eq(false))) + .thenReturn(null); + wr.when( + () -> + WebResponseUtils.bytesToWebResponse( + any(), any(String.class), any(MediaType.class))) + .thenReturn(expected); + + ResponseEntity response = controller.convertToImage(request); + + assertThat(response).isSameAs(expected); + } + } + } + + @Nested + @DisplayName("webp-with-Python branch") + class WebpWithPython { + + @Test + @DisplayName("throws when the Python conversion yields no webp files") + void noWebpFilesProducedThrows() throws Exception { + byte[] pdfBytes = tinyPdfBytes(1); + ConvertToImageRequest request = baseRequest(pdfBytes, "webp"); + + Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class))) + .thenReturn(tinyDocument(1)); + + // ProcessExecutor instance + result are mocked; the empty output dir drives the + // "No WebP files were created" IOException without invoking Python. + ProcessExecutorResult procResult = Mockito.mock(ProcessExecutorResult.class); + Mockito.when(procResult.getMessages()).thenReturn("no output"); + ProcessExecutor executor = Mockito.mock(ProcessExecutor.class); + Mockito.when(executor.runCommandWithOutputHandling(anyList())).thenReturn(procResult); + + // parsePageList/generateFilename are stubbed so rearrangePdfPages runs without + // touching the real installation path for script extraction. + java.util.List pageOrder = java.util.List.of(0); + Path scriptPath = Path.of("png_to_webp.py"); + + try (MockedStatic pu = Mockito.mockStatic(PdfUtils.class); + MockedStatic cpi = + Mockito.mockStatic(CheckProgramInstall.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class); + MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + + pu.when( + () -> + PdfUtils.convertFromPdf( + eq(pdfDocumentFactory), + any(byte[].class), + eq("png"), + any(ImageType.class), + anyBoolean(), + anyInt(), + any(String.class), + anyBoolean())) + .thenReturn("png-image".getBytes()); + gu.when( + () -> + GeneralUtils.parsePageList( + any(String[].class), anyInt(), anyBoolean())) + .thenReturn(pageOrder); + gu.when(() -> GeneralUtils.generateFilename(any(), any(String.class))) + .thenReturn("out"); + gu.when(() -> GeneralUtils.extractScript("png_to_webp.py")).thenReturn(scriptPath); + cpi.when(CheckProgramInstall::isPythonAvailable).thenReturn(true); + cpi.when(CheckProgramInstall::getAvailablePythonCommand).thenReturn("python3"); + pe.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.PYTHON_OPENCV)) + .thenReturn(executor); + + // Output directory is empty after the (mocked) run, so the controller throws. + assertThatThrownBy(() -> controller.convertToImage(request)) + .isInstanceOf(IOException.class) + .hasMessageContaining("No WebP files were created"); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFControllerMoreTest.java new file mode 100644 index 0000000000..48fe4ad98d --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFControllerMoreTest.java @@ -0,0 +1,253 @@ +package stirling.software.SPDF.controller.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.Method; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.rendering.ImageType; +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.model.api.converters.ConvertToImageRequest; +import stirling.software.SPDF.model.api.converters.ConvertToPdfRequest; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.GeneralUtils; +import stirling.software.common.util.PdfUtils; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.WebResponseUtils; + +/** + * Additional branch coverage for {@link ConvertImgPDFController}: the getMediaType fallback, the + * convertToPdf null/blank colorType and fitOption defaults, multi-image input, and the explicit + * page-selection path of convertToImage. The PdfUtils boundary is mocked so no real rendering or + * external binary runs. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("ConvertImgPDFController additional branch tests") +class ConvertImgPDFControllerMoreTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private TempFileManager tempFileManager; + @Mock private EndpointConfiguration endpointConfiguration; + @InjectMocks private ConvertImgPDFController controller; + + private static byte[] tinyPdfBytes(int pages) throws IOException { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + for (int i = 0; i < pages; i++) { + doc.addPage(new PDPage(PDRectangle.A4)); + } + doc.save(baos); + return baos.toByteArray(); + } + } + + private static PDDocument tinyDoc(int pages) { + PDDocument doc = new PDDocument(); + for (int i = 0; i < pages; i++) { + doc.addPage(new PDPage(PDRectangle.A4)); + } + return doc; + } + + @Nested + @DisplayName("getMediaType") + class GetMediaType { + + private String invoke(String format) throws Exception { + Method m = + ConvertImgPDFController.class.getDeclaredMethod("getMediaType", String.class); + m.setAccessible(true); + return (String) m.invoke(controller, format); + } + + @Test + @DisplayName("known image extension resolves to a concrete mime type") + void knownExtension() throws Exception { + assertThat(invoke("png")).isEqualTo("image/png"); + } + + @Test + @DisplayName("unknown extension does not resolve to a concrete image mime type") + void unknownExtensionNotConcrete() throws Exception { + // guessContentTypeFromName yields null/octet-stream for an unrecognised extension; + // either way it must not masquerade as a real image type. + String result = invoke("zzz"); + assertThat(result).isNotEqualTo("image/png"); + } + } + + @Nested + @DisplayName("convertToPdf defaults") + class ConvertToPdfDefaults { + + @Test + @DisplayName("blank colorType and empty fitOption fall back to color/fillPage") + void blankDefaults() throws Exception { + MockMultipartFile img = + new MockMultipartFile("fileInput", "p.jpg", "image/jpeg", "x".getBytes()); + ConvertToPdfRequest request = new ConvertToPdfRequest(); + request.setFileInput(new MockMultipartFile[] {img}); + request.setColorType(" "); + request.setFitOption(""); + request.setAutoRotate(null); + + byte[] pdfBytes = "pdf".getBytes(); + ResponseEntity expected = ResponseEntity.ok(pdfBytes); + + try (MockedStatic pu = Mockito.mockStatic(PdfUtils.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class); + MockedStatic wr = + Mockito.mockStatic(WebResponseUtils.class)) { + + pu.when( + () -> + PdfUtils.imageToPdf( + any(MockMultipartFile[].class), + eq("fillPage"), + eq(false), + eq("color"), + eq(pdfDocumentFactory))) + .thenReturn(pdfBytes); + gu.when(() -> GeneralUtils.generateFilename("p.jpg", "_converted.pdf")) + .thenReturn("p_converted.pdf"); + wr.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "p_converted.pdf")) + .thenReturn(expected); + + ResponseEntity response = controller.convertToPdf(request); + + assertThat(response).isSameAs(expected); + // Blank colorType -> "color"; empty fitOption -> "fillPage". + pu.verify( + () -> + PdfUtils.imageToPdf( + any(MockMultipartFile[].class), + eq("fillPage"), + eq(false), + eq("color"), + eq(pdfDocumentFactory))); + } + } + + @Test + @DisplayName("multiple images use the first filename for the output") + void multipleImagesUseFirstName() throws Exception { + MockMultipartFile a = + new MockMultipartFile("fileInput", "first.png", "image/png", "a".getBytes()); + MockMultipartFile b = + new MockMultipartFile("fileInput", "second.png", "image/png", "b".getBytes()); + ConvertToPdfRequest request = new ConvertToPdfRequest(); + request.setFileInput(new MockMultipartFile[] {a, b}); + request.setColorType("color"); + request.setFitOption("fillPage"); + request.setAutoRotate(false); + + byte[] pdfBytes = "pdf".getBytes(); + ResponseEntity expected = ResponseEntity.ok(pdfBytes); + + try (MockedStatic pu = Mockito.mockStatic(PdfUtils.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class); + MockedStatic wr = + Mockito.mockStatic(WebResponseUtils.class)) { + + pu.when( + () -> + PdfUtils.imageToPdf( + any(MockMultipartFile[].class), + eq("fillPage"), + eq(false), + eq("color"), + eq(pdfDocumentFactory))) + .thenReturn(pdfBytes); + gu.when(() -> GeneralUtils.generateFilename("first.png", "_converted.pdf")) + .thenReturn("first_converted.pdf"); + wr.when(() -> WebResponseUtils.bytesToWebResponse(pdfBytes, "first_converted.pdf")) + .thenReturn(expected); + + ResponseEntity response = controller.convertToPdf(request); + + assertThat(response).isSameAs(expected); + gu.verify(() -> GeneralUtils.generateFilename("first.png", "_converted.pdf")); + } + } + } + + @Nested + @DisplayName("convertToImage explicit page selection") + class ExplicitPages { + + @Test + @DisplayName("a specific page-number list is parsed and rendered") + void specificPageList() throws Exception { + byte[] pdfBytes = tinyPdfBytes(3); + MockMultipartFile file = + new MockMultipartFile("fileInput", "src.pdf", "application/pdf", pdfBytes); + + ConvertToImageRequest request = new ConvertToImageRequest(); + request.setFileInput(file); + request.setImageFormat("png"); + request.setSingleOrMultiple("single"); + request.setColorType("color"); + request.setDpi(72); + request.setPageNumbers("1,3"); + request.setIncludeAnnotations(false); + + // rearrangePdfPages loads a real document and selects pages 1 and 3. + Mockito.when(pdfDocumentFactory.load(any(MockMultipartFile.class))) + .thenReturn(tinyDoc(3)); + + byte[] imageBytes = "img".getBytes(); + ResponseEntity expected = ResponseEntity.ok(imageBytes); + + try (MockedStatic pu = Mockito.mockStatic(PdfUtils.class); + MockedStatic wr = + Mockito.mockStatic(WebResponseUtils.class)) { + + pu.when( + () -> + PdfUtils.convertFromPdf( + eq(pdfDocumentFactory), + any(byte[].class), + eq("PNG"), + eq(ImageType.RGB), + eq(true), + eq(72), + any(String.class), + eq(false))) + .thenReturn(imageBytes); + wr.when( + () -> + WebResponseUtils.bytesToWebResponse( + eq(imageBytes), + any(String.class), + any(MediaType.class))) + .thenReturn(expected); + + ResponseEntity response = controller.convertToImage(request); + + assertThat(response).isSameAs(expected); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdfMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdfMoreTest.java new file mode 100644 index 0000000000..15e2705e7a --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdfMoreTest.java @@ -0,0 +1,274 @@ +package stirling.software.SPDF.controller.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +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.mockito.ArgumentCaptor; +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.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.model.api.GeneralFile; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.CustomHtmlSanitizer; +import stirling.software.common.util.FileToPdf; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.WebResponseUtils; + +/** + * Additional coverage for {@link ConvertMarkdownToPdf}. Real commonmark parsing is exercised; the + * external WeasyPrint boundary ({@link FileToPdf#convertHtmlToPdf}) and the response writer are + * mocked so no external tool is launched. The ZIP branch uses a real in-memory ZIP archive. + */ +@ExtendWith(MockitoExtension.class) +class ConvertMarkdownToPdfMoreTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private RuntimePathConfig runtimePathConfig; + @Mock private TempFileManager tempFileManager; + @Mock private CustomHtmlSanitizer customHtmlSanitizer; + + @InjectMocks private ConvertMarkdownToPdf controller; + + @BeforeEach + void setUp() throws Exception { + lenient().when(runtimePathConfig.getWeasyPrintPath()).thenReturn("/usr/bin/weasyprint"); + lenient() + .when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(any(byte[].class))) + .thenAnswer(inv -> inv.getArgument(0)); + // A real managed temp file so Files.write succeeds before the (mocked) response build. + lenient() + .when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + inv -> { + File f = + Files.createTempFile("md-test", inv.getArgument(0)) + .toFile(); + f.deleteOnExit(); + TempFile tf = mock(TempFile.class); + lenient().when(tf.getFile()).thenReturn(f); + lenient().when(tf.getPath()).thenReturn(f.toPath()); + return tf; + }); + // A real temp directory backing the ZIP-extraction branch. + lenient() + .when(tempFileManager.createTempDirectory()) + .thenAnswer(inv -> Files.createTempDirectory("md-zip-test")); + } + + private static ResponseEntity cannedResponse() { + return ResponseEntity.ok(new ByteArrayResource("pdf".getBytes())); + } + + private GeneralFile generalFileOf(String name, String contentType, byte[] bytes) { + GeneralFile gf = new GeneralFile(); + gf.setFileInput(new MockMultipartFile("fileInput", name, contentType, bytes)); + return gf; + } + + @Nested + @DisplayName("plain markdown branch") + class PlainMarkdown { + + @Test + @DisplayName("converts a markdown file with a GFM table to PDF") + void markdownWithTable() throws Exception { + String md = "# Title\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\nSome **bold** body text.\n"; + GeneralFile gf = generalFileOf("doc.md", "text/markdown", md.getBytes()); + + try (MockedStatic ftp = Mockito.mockStatic(FileToPdf.class); + MockedStatic wr = + Mockito.mockStatic(WebResponseUtils.class)) { + + ArgumentCaptor htmlBytes = ArgumentCaptor.forClass(byte[].class); + ftp.when( + () -> + FileToPdf.convertHtmlToPdf( + eq("/usr/bin/weasyprint"), + isNull(), + htmlBytes.capture(), + eq("converted.html"), + eq(tempFileManager), + eq(customHtmlSanitizer))) + .thenReturn("pdf".getBytes()); + wr.when( + () -> + WebResponseUtils.pdfFileToWebResponse( + any(TempFile.class), anyString())) + .thenReturn(cannedResponse()); + + ResponseEntity response = controller.markdownToPdf(gf); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + String html = new String(htmlBytes.getValue(), StandardCharsets.UTF_8); + // commonmark + tables extension produced a styled table and a heading. + assertThat(html).contains("table table-striped"); + assertThat(html).contains("

"); + } + } + + @Test + @DisplayName("propagates a WeasyPrint conversion failure") + void weasyPrintFailurePropagates() throws Exception { + GeneralFile gf = generalFileOf("doc.md", "text/markdown", "# Hi".getBytes()); + + try (MockedStatic ftp = Mockito.mockStatic(FileToPdf.class)) { + ftp.when( + () -> + FileToPdf.convertHtmlToPdf( + anyString(), + isNull(), + any(byte[].class), + anyString(), + any(TempFileManager.class), + any(CustomHtmlSanitizer.class))) + .thenThrow(new java.io.IOException("weasyprint failed")); + + assertThatThrownBy(() -> controller.markdownToPdf(gf)) + .isInstanceOf(java.io.IOException.class); + } + } + } + + @Nested + @DisplayName("zip markdown branch") + class ZipMarkdown { + + private byte[] zipWith(Map entries) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + for (Map.Entry e : entries.entrySet()) { + zos.putNextEntry(new ZipEntry(e.getKey())); + zos.write(e.getValue()); + zos.closeEntry(); + } + } + return baos.toByteArray(); + } + + @Test + @DisplayName("extracts markdown plus an image and converts via the zip path") + void zipWithImageConverts() throws Exception { + Map entries = new HashMap<>(); + entries.put("index.md", "# Zip Doc\n\n![img](pic.png)\n".getBytes()); + entries.put("pic.png", new byte[] {(byte) 0x89, 'P', 'N', 'G'}); + byte[] zip = zipWith(entries); + + GeneralFile gf = generalFileOf("bundle.zip", "application/zip", zip); + + try (MockedStatic ftp = Mockito.mockStatic(FileToPdf.class); + MockedStatic wr = + Mockito.mockStatic(WebResponseUtils.class)) { + ftp.when( + () -> + FileToPdf.convertHtmlToPdf( + eq("/usr/bin/weasyprint"), + isNull(), + any(byte[].class), + eq("package.zip"), + eq(tempFileManager), + eq(customHtmlSanitizer))) + .thenReturn("pdf".getBytes()); + wr.when( + () -> + WebResponseUtils.pdfFileToWebResponse( + any(TempFile.class), anyString())) + .thenReturn(cannedResponse()); + + ResponseEntity response = controller.markdownToPdf(gf); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + ftp.verify( + () -> + FileToPdf.convertHtmlToPdf( + anyString(), + isNull(), + any(byte[].class), + eq("package.zip"), + any(TempFileManager.class), + any(CustomHtmlSanitizer.class))); + } + } + + @Test + @DisplayName("throws when the zip contains no markdown file") + void zipWithoutMarkdownThrows() throws Exception { + Map entries = new HashMap<>(); + entries.put("readme.txt", "not markdown".getBytes()); + byte[] zip = zipWith(entries); + + GeneralFile gf = generalFileOf("bundle.zip", "application/zip", zip); + + assertThatThrownBy(() -> controller.markdownToPdf(gf)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + @DisplayName("input validation") + class Validation { + + @Test + @DisplayName("throws when fileInput is null") + void nullFileInput() { + GeneralFile gf = new GeneralFile(); + gf.setFileInput(null); + + assertThatThrownBy(() -> controller.markdownToPdf(gf)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("throws for a non-markdown, non-zip extension") + void wrongExtension() { + GeneralFile gf = generalFileOf("notes.txt", "text/plain", "hello".getBytes()); + + assertThatThrownBy(() -> controller.markdownToPdf(gf)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Test + @DisplayName("TableAttributeProvider adds the table class only to table blocks") + void tableAttributeProviderBehaviour() { + Parser parser = Parser.builder().build(); + Node paragraph = parser.parse("plain paragraph"); + TableAttributeProvider provider = new TableAttributeProvider(); + + Map attrs = new HashMap<>(); + // A non-table node leaves the attribute map untouched. + provider.setAttributes(paragraph.getFirstChild(), "p", attrs); + assertThat(attrs).doesNotContainKey("class"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeControllerTest.java new file mode 100644 index 0000000000..90b6a370de --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertOfficeControllerTest.java @@ -0,0 +1,458 @@ +package stirling.software.SPDF.controller.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +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.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.model.api.GeneralFile; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.CustomHtmlSanitizer; +import stirling.software.common.util.GeneralUtils; +import stirling.software.common.util.OfficeDocumentSanitizer; +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; +import stirling.software.common.util.ProcessExecutor.Processes; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.WebResponseUtils; + +/** + * Unit tests for {@link ConvertOfficeController}. The external LibreOffice/unoconvert boundary is + * mocked via mockStatic(ProcessExecutor) so no real process is spawned. + */ +@DisplayName("ConvertOfficeController tests") +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ConvertOfficeControllerTest { + + @TempDir Path tempDir; + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private RuntimePathConfig runtimePathConfig; + @Mock private CustomHtmlSanitizer customHtmlSanitizer; + @Mock private OfficeDocumentSanitizer officeDocumentSanitizer; + @Mock private EndpointConfiguration endpointConfiguration; + @Mock private TempFileManager tempFileManager; + + private ConvertOfficeController controller; + + private ConvertOfficeController newController() { + return new ConvertOfficeController( + pdfDocumentFactory, + runtimePathConfig, + customHtmlSanitizer, + officeDocumentSanitizer, + endpointConfiguration, + tempFileManager); + } + + @BeforeEach + void setUp() { + controller = newController(); + lenient().when(runtimePathConfig.getSOfficePath()).thenReturn("soffice"); + lenient().when(runtimePathConfig.getUnoConvertPath()).thenReturn("unoconvert"); + } + + private static ResponseEntity streamingOk(byte[] bytes) { + return ResponseEntity.ok(new ByteArrayResource(bytes)); + } + + // ---- reflection helper for the private convertToPdf-supporting methods -------------------- + + @SuppressWarnings("unchecked") + private T invokeInstance(String methodName, Object... args) throws Exception { + for (Method method : ConvertOfficeController.class.getDeclaredMethods()) { + if (method.getName().equals(methodName) && method.getParameterCount() == args.length) { + method.setAccessible(true); + try { + return (T) method.invoke(controller, args); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof Exception ex) { + throw ex; + } + throw new RuntimeException(cause); + } + } + } + throw new IllegalStateException( + "No method " + methodName + " with " + args.length + " args"); + } + + private MockMultipartFile docxFile(byte[] content) { + return new MockMultipartFile( + "fileInput", + "report.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + content); + } + + /** + * Configures the static ProcessExecutor so the LibreOffice/uno call returns rc and writes a pdf + * to the outdir if requested. + */ + private ProcessExecutorResult mockExecutor(MockedStatic pe, int rc) { + ProcessExecutor executor = Mockito.mock(ProcessExecutor.class); + pe.when(() -> ProcessExecutor.getInstance(Processes.LIBRE_OFFICE)).thenReturn(executor); + ProcessExecutorResult result = Mockito.mock(ProcessExecutorResult.class); + when(result.getRc()).thenReturn(rc); + return result; + } + + @Nested + @DisplayName("isValidFileExtension") + class ExtensionValidation { + + @Test + @DisplayName("accepts 2-4 char alphanumeric extensions") + void acceptsValid() throws Exception { + assertThat((boolean) invokeInstance("isValidFileExtension", "docx")).isTrue(); + assertThat((boolean) invokeInstance("isValidFileExtension", "odt")).isTrue(); + assertThat((boolean) invokeInstance("isValidFileExtension", "xls")).isTrue(); + } + + @Test + @DisplayName("rejects too-long or symbol extensions") + void rejectsInvalid() throws Exception { + assertThat((boolean) invokeInstance("isValidFileExtension", "toolongext")).isFalse(); + assertThat((boolean) invokeInstance("isValidFileExtension", "a")).isFalse(); + assertThat((boolean) invokeInstance("isValidFileExtension", "d.x")).isFalse(); + } + } + + @Nested + @DisplayName("convertToPdf input validation") + class InputValidation { + + @Test + @DisplayName("blank filename throws file-no-name exception") + void blankFilename() { + MockMultipartFile file = + new MockMultipartFile( + "fileInput", "", "application/octet-stream", "x".getBytes()); + assertThatThrownBy(() -> controller.convertToPdf(file)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("unsupported/invalid extension throws invalid-extension exception") + void invalidExtension() { + MockMultipartFile file = + new MockMultipartFile( + "fileInput", + "archive.toolong", + "application/octet-stream", + "x".getBytes()); + assertThatThrownBy(() -> controller.convertToPdf(file)) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + @DisplayName("convertToPdf conversion paths") + class ConversionPaths { + + @Test + @DisplayName("uses unoconvert when available and returns produced pdf") + void unoconvertSuccess() throws Exception { + when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(true); + when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true); + when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString())) + .thenAnswer(inv -> inv.getArgument(0)); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + ProcessExecutorResult result = mockExecutor(pe, 0); + ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE); + ArgumentCaptor> cmd = ArgumentCaptor.forClass(List.class); + when(executor.runCommandWithOutputHandling(cmd.capture())) + .thenAnswer( + inv -> { + // unoconvert writes directly to the output path (last arg) + List command = inv.getArgument(0); + Path out = Path.of(command.get(command.size() - 1)); + Files.writeString(out, "%PDF-1.4 produced"); + return result; + }); + + File pdf = controller.convertToPdf(docxFile("real-docx".getBytes())); + + assertThat(pdf).exists(); + assertThat(Files.size(pdf.toPath())).isGreaterThan(0L); + assertThat(cmd.getValue().get(0)).isEqualTo("unoconvert"); + // sanitizer must have been consulted for the docx + Mockito.verify(officeDocumentSanitizer).sanitize(any(byte[].class), anyString()); + + deleteWorkdir(pdf); + } + } + + @Test + @DisplayName("falls back to soffice when unoconvert is unavailable") + void sofficeFallbackWhenUnoUnavailable() throws Exception { + when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false); + when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false); + when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true); + when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString())) + .thenAnswer(inv -> inv.getArgument(0)); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + ProcessExecutorResult result = mockExecutor(pe, 0); + ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE); + ArgumentCaptor> cmd = ArgumentCaptor.forClass(List.class); + when(executor.runCommandWithOutputHandling(cmd.capture())) + .thenAnswer( + inv -> { + // soffice writes .pdf into the --outdir (workDir) + List command = inv.getArgument(0); + Path inputPath = Path.of(command.get(command.size() - 1)); + Path out = inputPath.getParent().resolve("report.pdf"); + Files.writeString(out, "%PDF soffice"); + return result; + }); + + File pdf = controller.convertToPdf(docxFile("real-docx".getBytes())); + + assertThat(pdf).exists(); + assertThat(cmd.getValue().get(0)).isEqualTo("soffice"); + assertThat(cmd.getValue()).contains("--headless", "--convert-to", "pdf"); + + deleteWorkdir(pdf); + } + } + + @Test + @DisplayName("non-zero exit code throws IllegalStateException") + void nonZeroExit() throws Exception { + when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false); + when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false); + when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true); + when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString())) + .thenAnswer(inv -> inv.getArgument(0)); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + ProcessExecutorResult result = mockExecutor(pe, 3); + ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE); + when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(result); + + assertThatThrownBy(() -> controller.convertToPdf(docxFile("docx".getBytes()))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("exit 3"); + } + } + + @Test + @DisplayName("no produced pdf (rc 0 but no file) throws IllegalStateException") + void noProducedPdf() throws Exception { + when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false); + when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false); + when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true); + when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString())) + .thenAnswer(inv -> inv.getArgument(0)); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + ProcessExecutorResult result = mockExecutor(pe, 0); + ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE); + // rc 0 but nothing written to workDir -> "No PDF produced." + when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(result); + + assertThatThrownBy(() -> controller.convertToPdf(docxFile("docx".getBytes()))) + .isInstanceOf(IllegalStateException.class); + } + } + + @Test + @DisplayName("empty produced pdf throws IllegalStateException") + void emptyProducedPdf() throws Exception { + when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false); + when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false); + when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true); + when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString())) + .thenAnswer(inv -> inv.getArgument(0)); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + ProcessExecutorResult result = mockExecutor(pe, 0); + ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE); + when(executor.runCommandWithOutputHandling(any(List.class))) + .thenAnswer( + inv -> { + List command = inv.getArgument(0); + Path inputPath = Path.of(command.get(command.size() - 1)); + Path out = inputPath.getParent().resolve("report.pdf"); + Files.write(out, new byte[0]); + return result; + }); + + assertThatThrownBy(() -> controller.convertToPdf(docxFile("docx".getBytes()))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("empty"); + } + } + + @Test + @DisplayName("html input is routed through the html sanitizer") + void htmlSanitized() throws Exception { + when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false); + when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false); + when(customHtmlSanitizer.sanitize(anyString())).thenReturn("clean"); + + MockMultipartFile html = + new MockMultipartFile( + "fileInput", + "page.html", + "text/html", + "hi".getBytes(StandardCharsets.UTF_8)); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + ProcessExecutorResult result = mockExecutor(pe, 0); + ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE); + when(executor.runCommandWithOutputHandling(any(List.class))) + .thenAnswer( + inv -> { + List command = inv.getArgument(0); + Path inputPath = Path.of(command.get(command.size() - 1)); + Path out = inputPath.getParent().resolve("page.pdf"); + Files.writeString(out, "%PDF html"); + return result; + }); + + File pdf = controller.convertToPdf(html); + + assertThat(pdf).exists(); + Mockito.verify(customHtmlSanitizer).sanitize(anyString()); + Mockito.verifyNoInteractions(officeDocumentSanitizer); + + deleteWorkdir(pdf); + } + } + } + + @Nested + @DisplayName("processFileToPDF endpoint") + class EndpointTests { + + @Test + @DisplayName("happy path loads, saves and cleans up the work directory") + void happyPath() throws Exception { + when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false); + when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false); + when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true); + when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString())) + .thenAnswer(inv -> inv.getArgument(0)); + + File tempOutFile = Files.createTempFile(tempDir, "out", ".pdf").toFile(); + TempFile tempOut = mock(TempFile.class); + when(tempOut.getFile()).thenReturn(tempOutFile); + when(tempFileManager.createManagedTempFile(anyString())).thenReturn(tempOut); + + PDDocument doc = new PDDocument(); + doc.addPage(new PDPage()); + when(pdfDocumentFactory.load(any(File.class))).thenReturn(doc); + + GeneralFile generalFile = new GeneralFile(); + generalFile.setFileInput(docxFile("docx-bytes".getBytes())); + + ResponseEntity expected = streamingOk("pdf".getBytes()); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class); + MockedStatic wr = Mockito.mockStatic(WebResponseUtils.class); + MockedStatic gu = Mockito.mockStatic(GeneralUtils.class)) { + + ProcessExecutorResult result = mockExecutor(pe, 0); + ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE); + when(executor.runCommandWithOutputHandling(any(List.class))) + .thenAnswer( + inv -> { + List command = inv.getArgument(0); + Path inputPath = Path.of(command.get(command.size() - 1)); + Path out = inputPath.getParent().resolve("report.pdf"); + Files.writeString(out, "%PDF produced"); + return result; + }); + + gu.when(() -> GeneralUtils.generateFilename(anyString(), anyString())) + .thenReturn("report_convertedToPDF.pdf"); + wr.when( + () -> + WebResponseUtils.pdfFileToWebResponse( + any(TempFile.class), anyString())) + .thenReturn(expected); + + ResponseEntity response = controller.processFileToPDF(generalFile); + + assertThat(response).isSameAs(expected); + wr.verify( + () -> + WebResponseUtils.pdfFileToWebResponse( + any(TempFile.class), anyString())); + } + + doc.close(); + } + + @Test + @DisplayName("conversion failure propagates and does not return a response") + void conversionFailurePropagates() throws Exception { + when(endpointConfiguration.isGroupEnabled("Unoconvert")).thenReturn(false); + when(endpointConfiguration.isGroupEnabled("Python")).thenReturn(false); + when(officeDocumentSanitizer.isSanitizableExtension("docx")).thenReturn(true); + when(officeDocumentSanitizer.sanitize(any(byte[].class), anyString())) + .thenAnswer(inv -> inv.getArgument(0)); + + GeneralFile generalFile = new GeneralFile(); + generalFile.setFileInput(docxFile("docx".getBytes())); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + ProcessExecutorResult result = mockExecutor(pe, 1); + ProcessExecutor executor = ProcessExecutor.getInstance(Processes.LIBRE_OFFICE); + when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(result); + + assertThatThrownBy(() -> controller.processFileToPDF(generalFile)) + .isInstanceOf(IllegalStateException.class); + + // a failed conversion never reaches the document factory + Mockito.verifyNoInteractions(pdfDocumentFactory); + } + } + } + + private static void deleteWorkdir(File producedPdf) throws IOException { + if (producedPdf != null && producedPdf.getParentFile() != null) { + org.apache.commons.io.FileUtils.deleteDirectory(producedPdf.getParentFile()); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToExcelControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToExcelControllerMoreTest.java new file mode 100644 index 0000000000..f0e8c08d79 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToExcelControllerMoreTest.java @@ -0,0 +1,215 @@ +package stirling.software.SPDF.controller.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.nio.file.Files; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.api.PDFWithPageNums; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +/** + * Additional coverage for {@link ConvertPDFToExcelController}. Tabula runs in-process, so documents + * are built in-memory: an empty page exercises the no-content branch, a multi-page document drives + * the page loop, and a bordered-grid page drives the workbook-writing path. The managed temp file + * is a real file so the workbook is written to disk. + */ +@ExtendWith(MockitoExtension.class) +class ConvertPDFToExcelControllerMoreTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private TempFileManager tempFileManager; + + @InjectMocks private ConvertPDFToExcelController controller; + + @BeforeEach + void setUp() throws Exception { + lenient() + .when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + inv -> { + File f = + Files.createTempFile("xlsx-test", inv.getArgument(0)) + .toFile(); + f.deleteOnExit(); + TempFile tf = mock(TempFile.class); + lenient().when(tf.getFile()).thenReturn(f); + lenient().when(tf.getPath()).thenReturn(f.toPath()); + return tf; + }); + } + + private static MockMultipartFile pdf(String name) { + return new MockMultipartFile("fileInput", name, "application/pdf", "pdf".getBytes()); + } + + private static PDDocument blankPages(int pages) { + PDDocument doc = new PDDocument(); + for (int i = 0; i < pages; i++) { + doc.addPage(new PDPage(PDRectangle.A4)); + } + return doc; + } + + /** Build a single-page document with a drawn bordered grid that Tabula can detect. */ + private static PDDocument borderedTableDoc() throws Exception { + PDDocument doc = new PDDocument(); + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + + float left = 60f; + float top = 700f; + float colW = 120f; + float rowH = 30f; + int cols = 3; + int rows = 3; + + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.setLineWidth(1f); + // Horizontal rules. + for (int r = 0; r <= rows; r++) { + float y = top - r * rowH; + cs.moveTo(left, y); + cs.lineTo(left + cols * colW, y); + cs.stroke(); + } + // Vertical rules. + for (int c = 0; c <= cols; c++) { + float x = left + c * colW; + cs.moveTo(x, top); + cs.lineTo(x, top - rows * rowH); + cs.stroke(); + } + // Cell text. + PDType1Font font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + cs.beginText(); + cs.setFont(font, 10); + cs.newLineAtOffset(left + c * colW + 5, top - r * rowH - 20); + cs.showText("R" + r + "C" + c); + cs.endText(); + } + } + } + return doc; + } + + @Nested + @DisplayName("no-content branches") + class NoContent { + + @Test + @DisplayName("single blank page yields no content") + void blankPageNoContent() throws Exception { + PDFWithPageNums request = new PDFWithPageNums(); + request.setFileInput(pdf("data.pdf")); + request.setPageNumbers("all"); + + when(pdfDocumentFactory.load(request)).thenReturn(blankPages(1)); + + ResponseEntity response = controller.pdfToExcel(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + + @Test + @DisplayName("multi-page blank document iterates all pages then yields no content") + void multiBlankPagesNoContent() throws Exception { + PDFWithPageNums request = new PDFWithPageNums(); + request.setFileInput(pdf("multi.pdf")); + request.setPageNumbers("all"); + + when(pdfDocumentFactory.load(request)).thenReturn(blankPages(3)); + + ResponseEntity response = controller.pdfToExcel(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + } + + @Nested + @DisplayName("workbook-writing branch") + class WorkbookWriting { + + @Test + @DisplayName( + "a bordered table page produces an xlsx response or, if undetected, no content") + void borderedTableProducesXlsx() throws Exception { + PDFWithPageNums request = new PDFWithPageNums(); + request.setFileInput(pdf("table.pdf")); + request.setPageNumbers("all"); + + when(pdfDocumentFactory.load(request)).thenReturn(borderedTableDoc()); + + ResponseEntity response = controller.pdfToExcel(request); + + // Lattice detection depends on the Tabula build; accept either outcome but assert the + // success path produced a real, non-empty xlsx body. + if (response.getStatusCode() == HttpStatus.OK) { + assertThat(response.getHeaders().getContentType().toString()) + .contains("spreadsheetml.sheet"); + assertThat(response.getHeaders().getContentDisposition().getFilename()) + .isEqualTo("table.xlsx"); + assertThat(response.getBody()).isNotNull(); + } else { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + } + } + + @Nested + @DisplayName("error propagation") + class Errors { + + @Test + @DisplayName("propagates a document load failure and closes the temp file") + void loadFailurePropagates() throws Exception { + PDFWithPageNums request = new PDFWithPageNums(); + request.setFileInput(pdf("corrupt.pdf")); + request.setPageNumbers("all"); + + when(pdfDocumentFactory.load(request)).thenThrow(new java.io.IOException("load boom")); + + assertThatThrownBy(() -> controller.pdfToExcel(request)) + .isInstanceOf(java.io.IOException.class); + } + } + + @Test + @DisplayName("sanity: blank pages produce a parseable empty document") + void blankDocumentSanity() throws Exception { + try (PDDocument doc = blankPages(1); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + doc.save(out); + assertThat(out.size()).isPositive(); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToOfficeMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToOfficeMoreTest.java new file mode 100644 index 0000000000..be524e6aa0 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToOfficeMoreTest.java @@ -0,0 +1,329 @@ +package stirling.software.SPDF.controller.api.converters; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +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.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.api.converters.PdfToPresentationRequest; +import stirling.software.SPDF.model.api.converters.PdfToTextOrRTFRequest; +import stirling.software.SPDF.model.api.converters.PdfToWordRequest; +import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.model.api.PDFFile; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +/** + * Additional tests for {@link ConvertPDFToOffice}. The office-format conversions delegate to {@code + * PDFToFile.processPdfToOfficeFormat}, which shells out to LibreOffice through the static {@link + * ProcessExecutor} factory. Here that factory is mocked with {@code mockStatic}; the mocked + * command-runner writes the expected output file into the LibreOffice {@code --outdir} so the real + * {@code PDFToFile} flow completes and a file-backed response is produced. No real LibreOffice + * runs. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ConvertPDFToOfficeMoreTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private TempFileManager tempFileManager; + @Mock private RuntimePathConfig runtimePathConfig; + + @InjectMocks private ConvertPDFToOffice controller; + + @BeforeEach + void setUp() throws Exception { + // Real temp files backing TempFileManager so the file-backed response can be read back. + lenient() + .when(tempFileManager.createManagedTempFile(any())) + .thenAnswer( + inv -> { + File f = + Files.createTempFile("conv-out", inv.getArgument(0)) + .toFile(); + f.deleteOnExit(); + TempFile tf = mock(TempFile.class); + lenient().when(tf.getFile()).thenReturn(f); + lenient().when(tf.getPath()).thenReturn(f.toPath()); + return tf; + }); + + // PDFToFile creates its own TempFile(manager, suffix) which calls manager.createTempFile. + lenient() + .when(tempFileManager.createTempFile(any())) + .thenAnswer( + inv -> { + File f = + Files.createTempFile("conv-in", inv.getArgument(0)) + .toFile(); + f.deleteOnExit(); + return f; + }); + + // PDFToFile also creates a TempDirectory for LibreOffice output. + lenient() + .when(tempFileManager.createTempDirectory()) + .thenAnswer(inv -> Files.createTempDirectory("conv-dir")); + + // Force the soffice fallback path (uno disabled) and a deterministic soffice binary name. + lenient().when(runtimePathConfig.getUnoConvertPath()).thenReturn(""); + lenient().when(runtimePathConfig.getSOfficePath()).thenReturn("soffice"); + } + + private MockMultipartFile pdfFile() { + return new MockMultipartFile( + "fileInput", + "document.pdf", + MediaType.APPLICATION_PDF_VALUE, + "%PDF-1.4".getBytes()); + } + + private MockMultipartFile nonPdfFile() { + return new MockMultipartFile( + "fileInput", "document.txt", MediaType.TEXT_PLAIN_VALUE, "hello".getBytes()); + } + + private static byte[] readResource(Resource resource) throws IOException { + try (InputStream in = resource.getInputStream(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + in.transferTo(baos); + return baos.toByteArray(); + } + } + + /** + * Stubs the LibreOffice executor so that running the soffice command writes a fake output file + * (named {@code document.}) into the directory that follows {@code --outdir}. + */ + private void stubLibreOfficeWritesOutput( + MockedStatic mockedFactory, String primaryExt) throws Exception { + ProcessExecutor executor = mock(ProcessExecutor.class); + ProcessExecutorResult okResult = mock(ProcessExecutorResult.class); + lenient().when(okResult.getRc()).thenReturn(0); + + when(executor.runCommandWithOutputHandling(any())) + .thenAnswer( + inv -> { + List cmd = inv.getArgument(0); + int outDirIdx = cmd.indexOf("--outdir"); + Path outDir = Path.of(cmd.get(outDirIdx + 1)); + Path outFile = outDir.resolve("document." + primaryExt); + Files.write( + outFile, "converted-bytes".getBytes(StandardCharsets.UTF_8)); + return okResult; + }); + + mockedFactory + .when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)) + .thenReturn(executor); + } + + @Nested + @DisplayName("Presentation conversion") + class PresentationConversion { + + @Test + @DisplayName("pptx output streams the converted file back with 200") + void presentationPptxSuccess() throws Exception { + PdfToPresentationRequest request = new PdfToPresentationRequest(); + request.setFileInput(pdfFile()); + request.setOutputFormat("pptx"); + + try (MockedStatic mockedFactory = mockStatic(ProcessExecutor.class)) { + stubLibreOfficeWritesOutput(mockedFactory, "pptx"); + + ResponseEntity response = controller.processPdfToPresentation(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(readResource(response.getBody()).length > 0); + } + } + + @Test + @DisplayName("non-PDF input returns 400 without invoking LibreOffice") + void presentationNonPdfReturnsBadRequest() throws Exception { + PdfToPresentationRequest request = new PdfToPresentationRequest(); + request.setFileInput(nonPdfFile()); + request.setOutputFormat("pptx"); + + ResponseEntity response = controller.processPdfToPresentation(request); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + @DisplayName("LibreOffice IOException propagates from presentation conversion") + void presentationLibreOfficeFailurePropagates() throws Exception { + PdfToPresentationRequest request = new PdfToPresentationRequest(); + request.setFileInput(pdfFile()); + request.setOutputFormat("pptx"); + + try (MockedStatic mockedFactory = mockStatic(ProcessExecutor.class)) { + ProcessExecutor executor = mock(ProcessExecutor.class); + when(executor.runCommandWithOutputHandling(any())) + .thenThrow(new IOException("soffice crashed")); + mockedFactory + .when( + () -> + ProcessExecutor.getInstance( + ProcessExecutor.Processes.LIBRE_OFFICE)) + .thenReturn(executor); + + assertThrows(IOException.class, () -> controller.processPdfToPresentation(request)); + } + } + } + + @Nested + @DisplayName("Word conversion") + class WordConversion { + + @Test + @DisplayName("docx output streams the converted file back with 200") + void wordDocxSuccess() throws Exception { + PdfToWordRequest request = new PdfToWordRequest(); + request.setFileInput(pdfFile()); + request.setOutputFormat("docx"); + + try (MockedStatic mockedFactory = mockStatic(ProcessExecutor.class)) { + stubLibreOfficeWritesOutput(mockedFactory, "docx"); + + ResponseEntity response = controller.processPdfToWord(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(readResource(response.getBody()).length > 0); + } + } + + @Test + @DisplayName("unsupported output format returns 400") + void wordUnsupportedFormatReturnsBadRequest() throws Exception { + PdfToWordRequest request = new PdfToWordRequest(); + request.setFileInput(pdfFile()); + request.setOutputFormat("bogus"); + + ResponseEntity response = controller.processPdfToWord(request); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + } + + @Nested + @DisplayName("Text / RTF conversion") + class TextRtfConversion { + + @Test + @DisplayName("txt output uses PDFBox stripper, not LibreOffice") + void txtUsesStripper() throws Exception { + PdfToTextOrRTFRequest request = new PdfToTextOrRTFRequest(); + request.setFileInput(pdfFile()); + request.setOutputFormat("txt"); + + PDDocument realDoc = new PDDocument(); + realDoc.addPage(new PDPage()); + when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(realDoc); + + ResponseEntity response = controller.processPdfToRTForTXT(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(MediaType.TEXT_PLAIN, response.getHeaders().getContentType()); + } + + @Test + @DisplayName("rtf output delegates to LibreOffice and streams back") + void rtfDelegatesToLibreOffice() throws Exception { + PdfToTextOrRTFRequest request = new PdfToTextOrRTFRequest(); + request.setFileInput(pdfFile()); + request.setOutputFormat("rtf"); + + try (MockedStatic mockedFactory = mockStatic(ProcessExecutor.class)) { + stubLibreOfficeWritesOutput(mockedFactory, "rtf"); + + ResponseEntity response = controller.processPdfToRTForTXT(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(readResource(response.getBody()).length > 0); + } + } + + @Test + @DisplayName("txt branch closes temp file and rethrows when stripper load fails") + void txtLoadFailurePropagates() throws Exception { + PdfToTextOrRTFRequest request = new PdfToTextOrRTFRequest(); + request.setFileInput(pdfFile()); + request.setOutputFormat("txt"); + + when(pdfDocumentFactory.load(any(MockMultipartFile.class))) + .thenThrow(new IOException("cannot parse pdf")); + + IOException thrown = + assertThrows(IOException.class, () -> controller.processPdfToRTForTXT(request)); + assertEquals("cannot parse pdf", thrown.getMessage()); + } + } + + @Nested + @DisplayName("XML conversion") + class XmlConversion { + + @Test + @DisplayName("xml output streams the converted file back with 200") + void xmlSuccess() throws Exception { + PDFFile file = new PDFFile(); + file.setFileInput(pdfFile()); + + try (MockedStatic mockedFactory = mockStatic(ProcessExecutor.class)) { + stubLibreOfficeWritesOutput(mockedFactory, "xml"); + + ResponseEntity response = controller.processPdfToXML(file); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(readResource(response.getBody()).length > 0); + } + } + + @Test + @DisplayName("non-PDF input returns 400 for xml conversion") + void xmlNonPdfReturnsBadRequest() throws Exception { + PDFFile file = new PDFFile(); + file.setFileInput(nonPdfFile()); + + ResponseEntity response = controller.processPdfToXML(file); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java new file mode 100644 index 0000000000..31bbc9e1f8 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java @@ -0,0 +1,591 @@ +package stirling.software.SPDF.controller.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +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.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.api.converters.PdfToPdfARequest; +import stirling.software.SPDF.service.VeraPDFService; +import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.WebResponseUtils; + +/** + * Additional coverage for {@link ConvertPDFToPDFA} focusing on the end-to-end conversion flows + * (handlePdfAConversion / handlePdfXConversion / convertPDDocumentToPDFA) and the Ghostscript + * command builders. The external ghostscript/qpdf boundary is mocked with mockStatic, so no real + * binary is ever executed. + */ +@DisplayName("ConvertPDFToPDFA additional flow tests") +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ConvertPDFToPDFAMoreTest { + + @TempDir Path tempDir; + + @Mock private RuntimePathConfig runtimePathConfig; + @Mock private VeraPDFService veraPDFService; + @Mock private TempFileManager tempFileManager; + + private ConvertPDFToPDFA newController() { + return new ConvertPDFToPDFA(runtimePathConfig, veraPDFService, tempFileManager); + } + + private static ResponseEntity streamingOk(byte[] bytes) { + return ResponseEntity.ok(new ByteArrayResource(bytes)); + } + + // ---- reflection helpers ---------------------------------------------------------------- + + @SuppressWarnings("unchecked") + private T invokeInstance(Object target, String methodName, Object... args) + throws Exception { + Method method = findMethod(methodName, args.length); + method.setAccessible(true); + try { + return (T) method.invoke(target, args); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof Exception ex) { + throw ex; + } + throw new RuntimeException(cause); + } + } + + private static Method findMethod(String methodName, int argCount) { + for (Method method : ConvertPDFToPDFA.class.getDeclaredMethods()) { + if (method.getName().equals(methodName) && method.getParameterCount() == argCount) { + return method; + } + } + throw new IllegalStateException( + "No method " + methodName + " with " + argCount + " params"); + } + + private static Object resolvePdfaProfile(String token) throws Exception { + Class enumClass = null; + for (Class inner : ConvertPDFToPDFA.class.getDeclaredClasses()) { + if (inner.getSimpleName().equals("PdfaProfile")) { + enumClass = inner; + } + } + Method m = enumClass.getDeclaredMethod("fromRequest", String.class); + m.setAccessible(true); + return m.invoke(null, token); + } + + private static Object resolvePdfXProfile(String token) throws Exception { + Class enumClass = null; + for (Class inner : ConvertPDFToPDFA.class.getDeclaredClasses()) { + if (inner.getSimpleName().equals("PdfXProfile")) { + enumClass = inner; + } + } + Method m = enumClass.getDeclaredMethod("fromRequest", String.class); + m.setAccessible(true); + return m.invoke(null, token); + } + + // ---- pdf builders ---------------------------------------------------------------------- + + private PDDocument simplePdf() throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(100, 700); + cs.showText("hello world"); + cs.endText(); + } + return document; + } + + private byte[] simplePdfBytes() throws IOException { + try (PDDocument document = simplePdf()) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + return baos.toByteArray(); + } + } + + private MockMultipartFile pdfFile() throws IOException { + return new MockMultipartFile("fileInput", "input.pdf", "application/pdf", simplePdfBytes()); + } + + /** + * Sets up a single ProcessExecutor mock returned for every Processes value. The command list + * decides the result: ghostscript conversion commands write a valid output pdf, version/probe + * commands return rc 0, everything else returns rc 0 without side effects. + */ + private ProcessExecutor wireProcessExecutor(MockedStatic pe, int gsConvertRc) + throws Exception { + ProcessExecutor executor = Mockito.mock(ProcessExecutor.class); + pe.when(() -> ProcessExecutor.getInstance(any(ProcessExecutor.Processes.class))) + .thenReturn(executor); + pe.when( + () -> + ProcessExecutor.getInstance( + any(ProcessExecutor.Processes.class), Mockito.anyBoolean())) + .thenReturn(executor); + + ProcessExecutorResult okResult = mock(ProcessExecutorResult.class); + lenient().when(okResult.getRc()).thenReturn(0); + + ProcessExecutorResult gsResult = mock(ProcessExecutorResult.class); + lenient().when(gsResult.getRc()).thenReturn(gsConvertRc); + lenient().when(gsResult.getMessages()).thenReturn("gs output"); + + lenient() + .when(executor.runCommandWithOutputHandling(any(List.class))) + .thenAnswer( + invocation -> { + List command = invocation.getArgument(0); + // The real ghostscript conversion command contains -sOutputFile=... + String outFileArg = + command.stream() + .filter(a -> a.startsWith("-sOutputFile=")) + .findFirst() + .orElse(null); + if (outFileArg != null) { + Path out = Path.of(outFileArg.substring("-sOutputFile=".length())); + if (gsConvertRc == 0) { + Files.write(out, simplePdfBytes()); + } + return gsResult; + } + // qpdf normalize/clean writes its (last-arg) output file + if (command.contains("--normalize-content=y")) { + // qpdf produced file is the last argument + Path out = Path.of(command.get(command.size() - 1)); + Files.write(out, simplePdfBytes()); + } + return okResult; + }); + return executor; + } + + private TempFile managedTempFile() throws IOException { + File f = Files.createTempFile(tempDir, "managed", ".pdf").toFile(); + TempFile tf = mock(TempFile.class); + lenient().when(tf.getFile()).thenReturn(f); + lenient().when(tf.getPath()).thenReturn(f.toPath()); + return tf; + } + + @Nested + @DisplayName("isGhostscriptAvailable") + class GhostscriptAvailability { + + @Test + @DisplayName("true when gs --version returns rc 0") + void availableWhenRcZero() throws Exception { + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + wireProcessExecutor(pe, 0); + boolean available = invokeInstance(newController(), "isGhostscriptAvailable"); + assertThat(available).isTrue(); + } + } + + @Test + @DisplayName("false when probe throws") + void unavailableWhenThrows() throws Exception { + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + ProcessExecutor executor = Mockito.mock(ProcessExecutor.class); + pe.when(() -> ProcessExecutor.getInstance(any(ProcessExecutor.Processes.class))) + .thenReturn(executor); + when(executor.runCommandWithOutputHandling(any(List.class))) + .thenThrow(new IOException("gs missing")); + + boolean available = invokeInstance(newController(), "isGhostscriptAvailable"); + assertThat(available).isFalse(); + } + } + } + + @Nested + @DisplayName("handlePdfAConversion via pdfToPdfA endpoint") + class PdfAConversion { + + @Test + @DisplayName("Ghostscript success path returns the produced PDF/A-2b file") + void ghostscriptSuccess() throws Exception { + TempFile managed = managedTempFile(); + when(tempFileManager.createManagedTempFile(anyString())).thenReturn(managed); + + PdfToPdfARequest request = new PdfToPdfARequest(); + request.setFileInput(pdfFile()); + request.setOutputFormat("pdfa-2b"); + + ResponseEntity expected = streamingOk("ok".getBytes()); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class); + MockedStatic wr = + Mockito.mockStatic(WebResponseUtils.class)) { + wireProcessExecutor(pe, 0); + wr.when( + () -> + WebResponseUtils.pdfFileToWebResponse( + any(TempFile.class), anyString())) + .thenReturn(expected); + + ResponseEntity response = newController().pdfToPdfA(request); + + assertThat(response).isSameAs(expected); + wr.verify( + () -> + WebResponseUtils.pdfFileToWebResponse( + any(TempFile.class), + org.mockito.ArgumentMatchers.contains("_PDFA-2b.pdf"))); + } + } + + @Test + @DisplayName("PDF/A-1b exercises the part-1 CIDSet/qpdf branch and succeeds") + void pdfA1Success() throws Exception { + TempFile managed = managedTempFile(); + when(tempFileManager.createManagedTempFile(anyString())).thenReturn(managed); + + PdfToPdfARequest request = new PdfToPdfARequest(); + request.setFileInput(pdfFile()); + request.setOutputFormat("pdfa-1"); + + ResponseEntity expected = streamingOk("ok".getBytes()); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class); + MockedStatic wr = + Mockito.mockStatic(WebResponseUtils.class)) { + wireProcessExecutor(pe, 0); + wr.when( + () -> + WebResponseUtils.pdfFileToWebResponse( + any(TempFile.class), anyString())) + .thenReturn(expected); + + ResponseEntity response = newController().pdfToPdfA(request); + assertThat(response).isSameAs(expected); + } + } + + @Test + @DisplayName("strict mode runs VeraPDF and a compliant result still returns the file") + void strictCompliantSucceeds() throws Exception { + TempFile managed = managedTempFile(); + when(tempFileManager.createManagedTempFile(anyString())).thenReturn(managed); + + stirling.software.SPDF.model.api.security.PDFVerificationResult ok = + new stirling.software.SPDF.model.api.security.PDFVerificationResult(); + ok.setCompliant(true); + ok.setStandard("2b"); + ok.setComplianceSummary("PDF/A-2b compliant"); + when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok)); + + PdfToPdfARequest request = new PdfToPdfARequest(); + request.setFileInput(pdfFile()); + request.setOutputFormat("pdfa-2b"); + request.setStrict(true); + + ResponseEntity expected = streamingOk("ok".getBytes()); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class); + MockedStatic wr = + Mockito.mockStatic(WebResponseUtils.class)) { + wireProcessExecutor(pe, 0); + wr.when( + () -> + WebResponseUtils.pdfFileToWebResponse( + any(TempFile.class), anyString())) + .thenReturn(expected); + + ResponseEntity response = newController().pdfToPdfA(request); + + assertThat(response).isSameAs(expected); + Mockito.verify(veraPDFService).validatePDF(any()); + } + } + } + + @Nested + @DisplayName("handlePdfXConversion via pdfToPdfA endpoint") + class PdfXConversion { + + @Test + @DisplayName("Ghostscript success path returns the produced PDF/X file") + void pdfXSuccess() throws Exception { + TempFile managed = managedTempFile(); + when(tempFileManager.createManagedTempFile(anyString())).thenReturn(managed); + + PdfToPdfARequest request = new PdfToPdfARequest(); + request.setFileInput(pdfFile()); + request.setOutputFormat("pdfx"); + + ResponseEntity expected = streamingOk("ok".getBytes()); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class); + MockedStatic wr = + Mockito.mockStatic(WebResponseUtils.class)) { + wireProcessExecutor(pe, 0); + wr.when( + () -> + WebResponseUtils.pdfFileToWebResponse( + any(TempFile.class), anyString())) + .thenReturn(expected); + + ResponseEntity response = newController().pdfToPdfA(request); + + assertThat(response).isSameAs(expected); + wr.verify( + () -> + WebResponseUtils.pdfFileToWebResponse( + any(TempFile.class), + org.mockito.ArgumentMatchers.contains("_PDFX.pdf"))); + } + } + + @Test + @DisplayName("PDF/X with Ghostscript unavailable throws the conversion-failed exception") + void pdfXNoGhostscript() throws Exception { + PdfToPdfARequest request = new PdfToPdfARequest(); + request.setFileInput(pdfFile()); + request.setOutputFormat("pdfx"); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + // gs --version returns non-zero -> not available + wireProcessExecutor(pe, 0); + ProcessExecutor executor = + ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT); + ProcessExecutorResult notAvail = mock(ProcessExecutorResult.class); + when(notAvail.getRc()).thenReturn(127); + when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(notAvail); + + assertThatThrownBy(() -> newController().pdfToPdfA(request)) + .isInstanceOf(RuntimeException.class); + } + } + } + + @Nested + @DisplayName("convertPDDocumentToPDFA") + class ConvertDocument { + + @Test + @DisplayName("Ghostscript success returns converted bytes") + void documentConversionSuccess() throws Exception { + try (PDDocument document = simplePdf(); + MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + wireProcessExecutor(pe, 0); + + byte[] converted = newController().convertPDDocumentToPDFA(document, "pdfa-2b"); + + assertThat(converted).isNotNull(); + assertThat(converted.length).isGreaterThan(0); + } + } + } + + @Nested + @DisplayName("Ghostscript command builders") + class CommandBuilders { + + @Test + @DisplayName("buildGhostscriptCommand wires PDF/A part, devices and IO files") + void buildsPdfACommand() throws Exception { + Path workingDir = Files.createDirectories(tempDir.resolve("gs")); + Path input = Files.write(workingDir.resolve("in.pdf"), simplePdfBytes()); + Path output = workingDir.resolve("out.pdf"); + Path rgb = Files.write(workingDir.resolve("rgb.icc"), new byte[] {1}); + Path gray = Files.write(workingDir.resolve("gray.icc"), new byte[] {1}); + Path defFile = Files.write(workingDir.resolve("def.ps"), new byte[] {1}); + + Object colorProfiles = newColorProfiles(rgb, gray); + Object profile = resolvePdfaProfile("pdfa-1"); + + Method m = + ConvertPDFToPDFA.class.getDeclaredMethod( + "buildGhostscriptCommand", + Path.class, + Path.class, + colorProfiles.getClass(), + Path.class, + profile.getClass(), + Path.class); + m.setAccessible(true); + @SuppressWarnings("unchecked") + List command = + (List) + m.invoke( + null, + input, + output, + colorProfiles, + workingDir, + profile, + defFile); + + assertThat(command).isNotEmpty(); + assertThat(command.get(0)).isEqualTo("gs"); + assertThat(command).contains("-dPDFA=1", "-sDEVICE=pdfwrite", "-dEmbedAllFonts=true"); + assertThat(command).anyMatch(a -> a.startsWith("-sOutputFile=")); + } + + @Test + @DisplayName("buildGhostscriptCommandX wires PDF/X version and image tuning") + void buildsPdfXCommand() throws Exception { + Path workingDir = Files.createDirectories(tempDir.resolve("gsx")); + Path input = Files.write(workingDir.resolve("in.pdf"), simplePdfBytes()); + Path output = workingDir.resolve("out.pdf"); + Path rgb = Files.write(workingDir.resolve("rgb.icc"), new byte[] {1}); + Path gray = Files.write(workingDir.resolve("gray.icc"), new byte[] {1}); + + Object colorProfiles = newColorProfiles(rgb, gray); + Object profile = resolvePdfXProfile("pdfx"); + + Method m = + ConvertPDFToPDFA.class.getDeclaredMethod( + "buildGhostscriptCommandX", + Path.class, + Path.class, + colorProfiles.getClass(), + Path.class, + profile.getClass()); + m.setAccessible(true); + @SuppressWarnings("unchecked") + List command = + (List) + m.invoke(null, input, output, colorProfiles, workingDir, profile); + + assertThat(command).contains("-dPDFX=2008", "-sDEVICE=pdfwrite"); + assertThat(command).anyMatch(a -> a.startsWith("-dColorImageResolution=")); + } + + @Test + @DisplayName("createPdfaDefFile writes a PDFA_def.ps with the profile title") + void createsPdfaDef() throws Exception { + Path workingDir = Files.createDirectories(tempDir.resolve("def")); + Path rgb = Files.write(workingDir.resolve("rgb.icc"), new byte[] {1}); + Path gray = Files.write(workingDir.resolve("gray.icc"), new byte[] {1}); + + Object colorProfiles = newColorProfiles(rgb, gray); + Object profile = resolvePdfaProfile("pdfa-2b"); + + Method m = + ConvertPDFToPDFA.class.getDeclaredMethod( + "createPdfaDefFile", + Path.class, + colorProfiles.getClass(), + profile.getClass()); + m.setAccessible(true); + Path defFile = (Path) m.invoke(null, workingDir, colorProfiles, profile); + + assertThat(defFile).exists(); + String content = Files.readString(defFile); + assertThat(content).contains("PDF/A-2b"); + assertThat(content).contains("OutputIntent"); + } + + @Test + @DisplayName("prepareColorProfiles copies the sRGB icc and writes a gray profile") + void preparesColorProfiles() throws Exception { + Path workingDir = Files.createDirectories(tempDir.resolve("colors")); + Object colorProfiles = + invokeInstance(newController(), "prepareColorProfiles", workingDir); + assertThat(colorProfiles).isNotNull(); + + Method rgbAccessor = colorProfiles.getClass().getDeclaredMethod("rgb"); + rgbAccessor.setAccessible(true); + Path rgb = (Path) rgbAccessor.invoke(colorProfiles); + assertThat(rgb).exists(); + assertThat(Files.size(rgb)).isGreaterThan(0L); + } + + private Object newColorProfiles(Path rgb, Path gray) throws Exception { + Class recordClass = null; + for (Class inner : ConvertPDFToPDFA.class.getDeclaredClasses()) { + if (inner.getSimpleName().equals("ColorProfiles")) { + recordClass = inner; + } + } + var ctor = recordClass.getDeclaredConstructor(Path.class, Path.class); + ctor.setAccessible(true); + return ctor.newInstance(rgb, gray); + } + } + + @Nested + @DisplayName("qpdf helpers") + class QpdfHelpers { + + @Test + @DisplayName("normalizePdfWithQpdf returns null when qpdf is unavailable") + void normalizeUnavailable() throws Exception { + Path input = Files.write(tempDir.resolve("n.pdf"), simplePdfBytes()); + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + ProcessExecutor executor = Mockito.mock(ProcessExecutor.class); + pe.when(() -> ProcessExecutor.getInstance(any(ProcessExecutor.Processes.class))) + .thenReturn(executor); + ProcessExecutorResult notAvail = mock(ProcessExecutorResult.class); + when(notAvail.getRc()).thenReturn(1); + when(executor.runCommandWithOutputHandling(any(List.class))).thenReturn(notAvail); + + Path result = invokeInstance(newController(), "normalizePdfWithQpdf", input); + assertThat(result).isNull(); + } + } + + @Test + @DisplayName("cleanCidSetWithQpdf returns null on exception") + void cleanCidSetThrows() throws Exception { + Path input = Files.write(tempDir.resolve("c.pdf"), simplePdfBytes()); + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + ProcessExecutor executor = Mockito.mock(ProcessExecutor.class); + pe.when(() -> ProcessExecutor.getInstance(any(ProcessExecutor.Processes.class))) + .thenReturn(executor); + when(executor.runCommandWithOutputHandling(any(List.class))) + .thenThrow(new IOException("qpdf boom")); + + Path result = invokeInstance(newController(), "cleanCidSetWithQpdf", input); + assertThat(result).isNull(); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonControllerExtraTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonControllerExtraTest.java new file mode 100644 index 0000000000..6d36db1074 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonControllerExtraTest.java @@ -0,0 +1,254 @@ +package stirling.software.SPDF.controller.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import stirling.software.SPDF.service.PdfJsonConversionService; +import stirling.software.common.service.JobOwnershipService; +import stirling.software.common.util.TempFileManager; + +/** + * Coverage for the remaining {@link ConvertPdfJsonController} diagnostic helpers and the job-access + * guard on the GET endpoints. The conversion service boundary is mocked so nothing is rendered. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("ConvertPdfJsonController remaining branch coverage") +class ConvertPdfJsonControllerExtraTest { + + @Mock private PdfJsonConversionService pdfJsonConversionService; + @Mock private TempFileManager tempFileManager; + @Mock private JobOwnershipService jobOwnershipService; + + @InjectMocks private ConvertPdfJsonController controller; + + @BeforeEach + void setUp() throws Exception { + Field f = ConvertPdfJsonController.class.getDeclaredField("jobOwnershipService"); + f.setAccessible(true); + f.set(controller, jobOwnershipService); + } + + private Object invoke(String name, Class[] sig, Object... args) throws Exception { + Method m = ConvertPdfJsonController.class.getDeclaredMethod(name, sig); + m.setAccessible(true); + return m.invoke(controller, args); + } + + @Nested + @DisplayName("looksLikeBase64") + class LooksLikeBase64 { + + private boolean looksLikeBase64(String value) throws Exception { + return (boolean) invoke("looksLikeBase64", new Class[] {String.class}, value); + } + + @Test + @DisplayName("short strings are never treated as base64") + void shortNotBase64() throws Exception { + assertThat(looksLikeBase64("short")).isFalse(); + } + + @Test + @DisplayName("a long base64-like string is detected") + void longBase64Detected() throws Exception { + String b64 = "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2Nzg5"; + assertThat(looksLikeBase64(b64)).isTrue(); + } + + @Test + @DisplayName("a long natural-language string is not base64") + void longProseNotBase64() throws Exception { + String prose = "this is a long sentence with spaces and punctuation, not base64!!"; + assertThat(looksLikeBase64(prose)).isFalse(); + } + } + + @Nested + @DisplayName("truncateForLog") + class TruncateForLog { + + private String truncate(String value) throws Exception { + return (String) invoke("truncateForLog", new Class[] {String.class}, value); + } + + @Test + @DisplayName("short values are returned with whitespace normalised") + void shortNormalised() throws Exception { + assertThat(truncate("a\tb\nc")).isEqualTo("a b c"); + } + + @Test + @DisplayName("long values are truncated with an ellipsis suffix") + void longTruncated() throws Exception { + String value = "x".repeat(100); + String result = truncate(value); + assertThat(result).endsWith("..."); + assertThat(result).hasSize(67); // 64 chars + "..." + } + } + + @Nested + @DisplayName("debug/repeat-scan flag readers") + class FlagReaders { + + private boolean dumpEnabled() throws Exception { + return (boolean) invoke("isPdfJsonDebugDumpEnabled", new Class[] {}); + } + + private boolean repeatScanEnabled() throws Exception { + return (boolean) invoke("isPdfJsonRepeatScanEnabled", new Class[] {}); + } + + @Test + @DisplayName("dump flag reflects the system property") + void dumpFlagFromProperty() throws Exception { + String prev = System.getProperty("spdf.pdfjson.dump"); + try { + System.clearProperty("spdf.pdfjson.dump"); + assertThat(dumpEnabled()).isFalse(); + System.setProperty("spdf.pdfjson.dump", "true"); + assertThat(dumpEnabled()).isTrue(); + } finally { + restore("spdf.pdfjson.dump", prev); + } + } + + @Test + @DisplayName("repeat-scan flag reflects the system property") + void repeatScanFromProperty() throws Exception { + String prev = System.getProperty("spdf.pdfjson.repeatScan"); + try { + System.clearProperty("spdf.pdfjson.repeatScan"); + assertThat(repeatScanEnabled()).isFalse(); + System.setProperty("spdf.pdfjson.repeatScan", "true"); + assertThat(repeatScanEnabled()).isTrue(); + } finally { + restore("spdf.pdfjson.repeatScan", prev); + } + } + + private void restore(String key, String prev) { + if (prev == null) { + System.clearProperty(key); + } else { + System.setProperty(key, prev); + } + } + } + + @Nested + @DisplayName("logJsonResponse") + class LogJsonResponse { + + @Test + @DisplayName("a null path is logged without throwing") + void nullPathHandled() throws Exception { + // exercises the early null-path guard branch + invoke( + "logJsonResponse", + new Class[] {String.class, java.nio.file.Path.class}, + "x", + null); + } + + @Test + @DisplayName("happy path with no debug flags returns without reading the file") + void noFlagsNoRead() throws Exception { + String dumpPrev = System.getProperty("spdf.pdfjson.dump"); + String scanPrev = System.getProperty("spdf.pdfjson.repeatScan"); + try { + System.clearProperty("spdf.pdfjson.dump"); + System.clearProperty("spdf.pdfjson.repeatScan"); + // a non-existent path must not be read because all flags are off + java.nio.file.Path missing = java.nio.file.Path.of("does-not-exist-123.json"); + invoke( + "logJsonResponse", + new Class[] {String.class, java.nio.file.Path.class}, + "label", + missing); + } finally { + if (dumpPrev != null) System.setProperty("spdf.pdfjson.dump", dumpPrev); + if (scanPrev != null) System.setProperty("spdf.pdfjson.repeatScan", scanPrev); + } + } + } + + @Nested + @DisplayName("GET endpoint job-access guard") + class JobAccessGuard { + + @Test + @DisplayName("extractSinglePage rejects an unauthorized job before doing work") + void singlePageRejected() { + doThrow(new SecurityException("denied")) + .when(jobOwnershipService) + .validateJobAccess("bad"); + + assertThrows(SecurityException.class, () -> controller.extractSinglePage("bad", 1)); + verifyNoConversion(); + } + + @Test + @DisplayName("extractPageFonts rejects an unauthorized job before doing work") + void pageFontsRejected() { + doThrow(new SecurityException("denied")) + .when(jobOwnershipService) + .validateJobAccess("bad"); + + assertThrows(SecurityException.class, () -> controller.extractPageFonts("bad", 2)); + verifyNoConversion(); + } + + @Test + @DisplayName("exportPartialPdf rejects an unauthorized job before doing work") + void exportPartialRejected() { + when(jobOwnershipService.validateJobAccess(anyString())) + .thenThrow(new SecurityException("denied")); + + assertThrows( + SecurityException.class, + () -> + controller.exportPartialPdf( + "bad", + new stirling.software.SPDF.model.json.PdfJsonDocument(), + "out.pdf")); + } + + private void verifyNoConversion() { + try { + verify(pdfJsonConversionService, never()) + .extractSinglePage( + anyString(), + org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.any()); + verify(pdfJsonConversionService, never()) + .extractPageFonts( + anyString(), + org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.any()); + } catch (Exception e) { + throw new AssertionError(e); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonControllerMoreTest.java new file mode 100644 index 0000000000..c809879360 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonControllerMoreTest.java @@ -0,0 +1,606 @@ +package stirling.software.SPDF.controller.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; +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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.AfterEach; +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.json.PdfJsonDocument; +import stirling.software.SPDF.model.json.PdfJsonMetadata; +import stirling.software.SPDF.service.PdfJsonConversionService; +import stirling.software.common.model.api.GeneralFile; +import stirling.software.common.model.api.PDFFile; +import stirling.software.common.service.JobOwnershipService; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("ConvertPdfJsonController additional branch coverage") +class ConvertPdfJsonControllerMoreTest { + + @Mock private PdfJsonConversionService pdfJsonConversionService; + @Mock private TempFileManager tempFileManager; + @Mock private JobOwnershipService jobOwnershipService; + + @InjectMocks private ConvertPdfJsonController controller; + + private final java.util.List createdTempFiles = new java.util.ArrayList<>(); + + @BeforeEach + void setUp() throws Exception { + // @InjectMocks uses the @RequiredArgsConstructor, so the @Autowired field is not + // auto-injected; wire the JobOwnershipService mock in by reflection. + setJobOwnershipService(jobOwnershipService); + + when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + inv -> { + File f = + Files.createTempFile("more-test", inv.getArgument(0)) + .toFile(); + TempFile tf = mock(TempFile.class); + lenient().when(tf.getFile()).thenReturn(f); + lenient().when(tf.getPath()).thenReturn(f.toPath()); + createdTempFiles.add(tf); + return tf; + }); + } + + private void setJobOwnershipService(JobOwnershipService service) throws Exception { + Field f = ConvertPdfJsonController.class.getDeclaredField("jobOwnershipService"); + f.setAccessible(true); + f.set(controller, service); + } + + @AfterEach + void tearDown() { + for (TempFile tf : createdTempFiles) { + try { + if (tf.getFile() != null) { + tf.getFile().delete(); + } + } catch (Exception ignored) { + // best-effort cleanup + } + } + createdTempFiles.clear(); + } + + private static byte[] drainBody(ResponseEntity response) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (java.io.InputStream in = response.getBody().getInputStream()) { + in.transferTo(baos); + } + return baos.toByteArray(); + } + + // Disable the @Autowired(required=false) JobOwnershipService for no-auth code paths. + private void clearJobOwnershipService() throws Exception { + setJobOwnershipService(null); + } + + @Nested + @DisplayName("convertPdfToJson filename handling") + class ConvertPdfToJsonFilename { + + @Test + @DisplayName("Null original filename falls back to document.json") + void nullOriginalFilename() throws Exception { + MockMultipartFile pdfFile = + new MockMultipartFile("fileInput", null, "application/pdf", "x".getBytes()); + PDFFile request = new PDFFile(); + request.setFileInput(pdfFile); + + doAnswer(writeBytes(2, "{}".getBytes())) + .when(pdfJsonConversionService) + .convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class)); + + ResponseEntity response = controller.convertPdfToJson(request, false); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + String cd = response.getHeaders().getFirst("Content-Disposition"); + assertThat(cd).contains("document.json"); + } + + @Test + @DisplayName("Blank original filename falls back to document.json") + void blankOriginalFilename() throws Exception { + MockMultipartFile pdfFile = + new MockMultipartFile("fileInput", " ", "application/pdf", "x".getBytes()); + PDFFile request = new PDFFile(); + request.setFileInput(pdfFile); + + doAnswer(writeBytes(2, "{}".getBytes())) + .when(pdfJsonConversionService) + .convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class)); + + ResponseEntity response = controller.convertPdfToJson(request, false); + + assertThat(response.getHeaders().getFirst("Content-Disposition")) + .contains("document.json"); + } + + @Test + @DisplayName("Named file strips extension and appends .json") + void namedFileStripsExtension() throws Exception { + MockMultipartFile pdfFile = + new MockMultipartFile( + "fileInput", "report.final.pdf", "application/pdf", "x".getBytes()); + PDFFile request = new PDFFile(); + request.setFileInput(pdfFile); + + doAnswer(writeBytes(2, "{}".getBytes())) + .when(pdfJsonConversionService) + .convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class)); + + ResponseEntity response = controller.convertPdfToJson(request, false); + + assertThat(response.getHeaders().getFirst("Content-Disposition")) + .contains("report.final.json"); + } + + @Test + @DisplayName("Service exception closes temp file and propagates") + void serviceExceptionClosesTempFile() throws Exception { + MockMultipartFile pdfFile = + new MockMultipartFile( + "fileInput", "doc.pdf", "application/pdf", "x".getBytes()); + PDFFile request = new PDFFile(); + request.setFileInput(pdfFile); + + doThrow(new IllegalStateException("boom")) + .when(pdfJsonConversionService) + .convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class)); + + assertThrows( + IllegalStateException.class, () -> controller.convertPdfToJson(request, false)); + verify(createdTempFiles.get(0)).close(); + } + } + + @Nested + @DisplayName("convertJsonToPdf filename handling") + class ConvertJsonToPdfFilename { + + @Test + @DisplayName("Null original filename falls back to document.pdf") + void nullOriginalFilename() throws Exception { + MockMultipartFile jsonFile = + new MockMultipartFile("fileInput", null, "application/json", "{}".getBytes()); + GeneralFile request = new GeneralFile(); + request.setFileInput(jsonFile); + + doAnswer(writeBytes(1, "pdf".getBytes())) + .when(pdfJsonConversionService) + .convertJsonToPdf(eq(jsonFile), any(OutputStream.class)); + + ResponseEntity response = controller.convertJsonToPdf(request); + + assertThat(response.getHeaders().getFirst("Content-Disposition")) + .contains("document.pdf"); + } + + @Test + @DisplayName("Filename already ending in .pdf is preserved") + void filenameAlreadyPdf() throws Exception { + // toSimpleFileName keeps base name; extension pattern strips the trailing .pdf, + // so a base name that itself ends in .pdf exercises the endsWith branch. + MockMultipartFile jsonFile = + new MockMultipartFile( + "fileInput", "weird.pdf.json", "application/json", "{}".getBytes()); + GeneralFile request = new GeneralFile(); + request.setFileInput(jsonFile); + + doAnswer(writeBytes(1, "pdf".getBytes())) + .when(pdfJsonConversionService) + .convertJsonToPdf(eq(jsonFile), any(OutputStream.class)); + + ResponseEntity response = controller.convertJsonToPdf(request); + + assertThat(response.getHeaders().getFirst("Content-Disposition")).contains("weird.pdf"); + } + + @Test + @DisplayName("Service exception closes temp file and propagates") + void serviceExceptionClosesTempFile() throws Exception { + MockMultipartFile jsonFile = + new MockMultipartFile( + "fileInput", "doc.json", "application/json", "{}".getBytes()); + GeneralFile request = new GeneralFile(); + request.setFileInput(jsonFile); + + doThrow(new IllegalStateException("boom")) + .when(pdfJsonConversionService) + .convertJsonToPdf(eq(jsonFile), any(OutputStream.class)); + + assertThrows(IllegalStateException.class, () -> controller.convertJsonToPdf(request)); + verify(createdTempFiles.get(0)).close(); + } + } + + @Nested + @DisplayName("extractPdfMetadata job-key scoping") + class ExtractMetadataScoping { + + @Test + @DisplayName("Uses scoped job key when JobOwnershipService present") + void usesScopedKey() throws Exception { + when(jobOwnershipService.createScopedJobKey(anyString())).thenReturn("user:scoped-id"); + MockMultipartFile pdfFile = + new MockMultipartFile( + "fileInput", "doc.pdf", "application/pdf", "x".getBytes()); + PDFFile request = new PDFFile(); + request.setFileInput(pdfFile); + + doAnswer(writeBytes(2, "{}".getBytes())) + .when(pdfJsonConversionService) + .extractDocumentMetadata( + eq(pdfFile), eq("user:scoped-id"), any(OutputStream.class)); + + ResponseEntity response = controller.extractPdfMetadata(request); + + assertEquals("user:scoped-id", response.getHeaders().getFirst("X-Job-Id")); + verify(jobOwnershipService).createScopedJobKey(anyString()); + } + + @Test + @DisplayName("Uses raw job key when no JobOwnershipService") + void usesRawKeyWhenNoService() throws Exception { + clearJobOwnershipService(); + MockMultipartFile pdfFile = + new MockMultipartFile( + "fileInput", "doc.pdf", "application/pdf", "x".getBytes()); + PDFFile request = new PDFFile(); + request.setFileInput(pdfFile); + + doAnswer(writeBytes(2, "{}".getBytes())) + .when(pdfJsonConversionService) + .extractDocumentMetadata(eq(pdfFile), anyString(), any(OutputStream.class)); + + ResponseEntity response = controller.extractPdfMetadata(request); + + assertNotNull(response.getHeaders().getFirst("X-Job-Id")); + } + + @Test + @DisplayName("Service exception closes temp file and propagates") + void serviceExceptionClosesTempFile() throws Exception { + when(jobOwnershipService.createScopedJobKey(anyString())).thenReturn("k"); + MockMultipartFile pdfFile = + new MockMultipartFile( + "fileInput", "doc.pdf", "application/pdf", "x".getBytes()); + PDFFile request = new PDFFile(); + request.setFileInput(pdfFile); + + doThrow(new IllegalStateException("boom")) + .when(pdfJsonConversionService) + .extractDocumentMetadata(eq(pdfFile), anyString(), any(OutputStream.class)); + + assertThrows(IllegalStateException.class, () -> controller.extractPdfMetadata(request)); + verify(createdTempFiles.get(0)).close(); + } + } + + @Nested + @DisplayName("exportPartialPdf") + class ExportPartialPdf { + + @Test + @DisplayName("Null document throws") + void nullDocumentThrows() { + assertThrows( + Exception.class, () -> controller.exportPartialPdf("job", null, "out.pdf")); + } + + @Test + @DisplayName("Explicit filename param wins over metadata title") + void filenameParamWins() throws Exception { + when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true); + PdfJsonDocument doc = new PdfJsonDocument(); + doc.setMetadata(PdfJsonMetadata.builder().title("titleName").build()); + + doAnswer(writeBytes(2, "pdf".getBytes())) + .when(pdfJsonConversionService) + .exportUpdatedPages(eq("job"), eq(doc), any(OutputStream.class)); + + ResponseEntity response = + controller.exportPartialPdf("job", doc, "custom.pdf"); + + assertThat(response.getHeaders().getFirst("Content-Disposition")) + .contains("custom.pdf"); + } + + @Test + @DisplayName("Falls back to metadata title when filename blank") + void fallbackToMetadataTitle() throws Exception { + when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true); + PdfJsonDocument doc = new PdfJsonDocument(); + doc.setMetadata(PdfJsonMetadata.builder().title("MyTitle").build()); + + doAnswer(writeBytes(2, "pdf".getBytes())) + .when(pdfJsonConversionService) + .exportUpdatedPages(eq("job"), eq(doc), any(OutputStream.class)); + + ResponseEntity response = controller.exportPartialPdf("job", doc, " "); + + assertThat(response.getHeaders().getFirst("Content-Disposition")) + .contains("MyTitle.pdf"); + } + + @Test + @DisplayName("Falls back to document when title null/blank and no filename") + void fallbackToDocumentName() throws Exception { + when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true); + PdfJsonDocument doc = new PdfJsonDocument(); + doc.setMetadata(PdfJsonMetadata.builder().title(" ").build()); + + doAnswer(writeBytes(2, "pdf".getBytes())) + .when(pdfJsonConversionService) + .exportUpdatedPages(eq("job"), eq(doc), any(OutputStream.class)); + + ResponseEntity response = controller.exportPartialPdf("job", doc, null); + + assertThat(response.getHeaders().getFirst("Content-Disposition")) + .contains("document.pdf"); + } + + @Test + @DisplayName("Null metadata falls back to document name") + void nullMetadataFallback() throws Exception { + when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true); + PdfJsonDocument doc = new PdfJsonDocument(); + doc.setMetadata(null); + + doAnswer(writeBytes(2, "pdf".getBytes())) + .when(pdfJsonConversionService) + .exportUpdatedPages(eq("job"), eq(doc), any(OutputStream.class)); + + ResponseEntity response = controller.exportPartialPdf("job", doc, null); + + assertThat(response.getHeaders().getFirst("Content-Disposition")) + .contains("document.pdf"); + } + + @Test + @DisplayName("Service exception closes temp file and propagates") + void serviceExceptionClosesTempFile() throws Exception { + when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true); + PdfJsonDocument doc = new PdfJsonDocument(); + + doThrow(new IllegalStateException("boom")) + .when(pdfJsonConversionService) + .exportUpdatedPages( + anyString(), any(PdfJsonDocument.class), any(OutputStream.class)); + + assertThrows( + IllegalStateException.class, + () -> controller.exportPartialPdf("job", doc, "out.pdf")); + verify(createdTempFiles.get(0)).close(); + } + } + + @Nested + @DisplayName("extractSinglePage and extractPageFonts errors") + class GetPageErrors { + + @Test + @DisplayName("extractSinglePage service exception closes temp file") + void singlePageException() throws Exception { + when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true); + doThrow(new IllegalStateException("boom")) + .when(pdfJsonConversionService) + .extractSinglePage(anyString(), anyInt(), any(OutputStream.class)); + + assertThrows(IllegalStateException.class, () -> controller.extractSinglePage("job", 1)); + verify(createdTempFiles.get(0)).close(); + } + + @Test + @DisplayName("extractPageFonts service exception closes temp file") + void pageFontsException() throws Exception { + when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true); + doThrow(new IllegalStateException("boom")) + .when(pdfJsonConversionService) + .extractPageFonts(anyString(), anyInt(), any(OutputStream.class)); + + assertThrows(IllegalStateException.class, () -> controller.extractPageFonts("job", 1)); + verify(createdTempFiles.get(0)).close(); + } + + @Test + @DisplayName("Page docName carries page number") + void singlePageDocName() throws Exception { + when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true); + doAnswer(writeBytes(2, "{}".getBytes())) + .when(pdfJsonConversionService) + .extractSinglePage(eq("job"), eq(7), any(OutputStream.class)); + + ResponseEntity response = controller.extractSinglePage("job", 7); + + assertThat(response.getHeaders().getFirst("Content-Disposition")) + .contains("page_7.json"); + } + } + + @Nested + @DisplayName("validateJobAccess and clearCache delegation") + class JobAccessDelegation { + + @Test + @DisplayName("clearCache validates and delegates when service present") + void clearCacheWithService() { + when(jobOwnershipService.validateJobAccess(anyString())).thenReturn(true); + + ResponseEntity response = controller.clearCache("job-1"); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + verify(jobOwnershipService).validateJobAccess("job-1"); + verify(pdfJsonConversionService).clearCachedDocument("job-1"); + } + + @Test + @DisplayName("clearCache skips validation when no service") + void clearCacheNoService() throws Exception { + clearJobOwnershipService(); + + ResponseEntity response = controller.clearCache("job-2"); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + verify(pdfJsonConversionService).clearCachedDocument("job-2"); + verify(jobOwnershipService, never()).validateJobAccess(anyString()); + } + + @Test + @DisplayName("validateJobAccess propagates SecurityException from service") + void validateThrows() { + doThrow(new SecurityException("denied")) + .when(jobOwnershipService) + .validateJobAccess("bad"); + + assertThrows(SecurityException.class, () -> controller.clearCache("bad")); + verify(pdfJsonConversionService, never()).clearCachedDocument(anyString()); + } + } + + @Nested + @DisplayName("logJsonResponse diagnostic paths") + class LogJsonResponseDiagnostics { + + @Test + @DisplayName("Debug dump writes a copy to configured dir") + void debugDumpWritesCopy(@org.junit.jupiter.api.io.TempDir Path dumpDir) throws Exception { + String previous = System.getProperty("spdf.pdfjson.dump"); + System.setProperty("spdf.pdfjson.dump", "true"); + String prevDir = System.getProperty("java.io.tmpdir"); + // SPDF_PDFJSON_DUMP_DIR env may be unset; controller falls back to java.io.tmpdir. + System.setProperty("java.io.tmpdir", dumpDir.toString()); + try { + MockMultipartFile pdfFile = + new MockMultipartFile( + "fileInput", "doc.pdf", "application/pdf", "x".getBytes()); + PDFFile request = new PDFFile(); + request.setFileInput(pdfFile); + + doAnswer(writeBytes(2, "{\"k\":\"value-string-here\"}".getBytes())) + .when(pdfJsonConversionService) + .convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class)); + + ResponseEntity response = controller.convertPdfToJson(request, false); + assertEquals(HttpStatus.OK, response.getStatusCode()); + + try (var stream = Files.list(dumpDir)) { + boolean dumped = + stream.anyMatch(p -> p.getFileName().toString().startsWith("pdfjson_")); + assertThat(dumped).isTrue(); + } + } finally { + restoreProp("spdf.pdfjson.dump", previous); + restoreProp("java.io.tmpdir", prevDir); + } + } + + @Test + @DisplayName("Repeat scan runs without error on repeated strings") + void repeatScanRuns() throws Exception { + String previous = System.getProperty("spdf.pdfjson.repeatScan"); + System.setProperty("spdf.pdfjson.repeatScan", "true"); + try { + MockMultipartFile pdfFile = + new MockMultipartFile( + "fileInput", "doc.pdf", "application/pdf", "x".getBytes()); + PDFFile request = new PDFFile(); + request.setFileInput(pdfFile); + + // Two repeated >=12 char strings plus a long base64-like one to exercise filters. + String json = + "{\"a\":\"repeated-string-value\",\"b\":\"repeated-string-value\"," + + "\"c\":\"QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNDU2Nzg5\"}"; + doAnswer(writeBytes(2, json.getBytes())) + .when(pdfJsonConversionService) + .convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class)); + + ResponseEntity response = controller.convertPdfToJson(request, false); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertThat(drainBody(response)).isNotEmpty(); + } finally { + restoreProp("spdf.pdfjson.repeatScan", previous); + } + } + + @Test + @DisplayName("Repeat scan handles no repeated strings") + void repeatScanNoRepeats() throws Exception { + String previous = System.getProperty("spdf.pdfjson.repeatScan"); + System.setProperty("spdf.pdfjson.repeatScan", "true"); + try { + MockMultipartFile pdfFile = + new MockMultipartFile( + "fileInput", "doc.pdf", "application/pdf", "x".getBytes()); + PDFFile request = new PDFFile(); + request.setFileInput(pdfFile); + + String json = "{\"only-key-here-unique\":\"only-value-here-unique\"}"; + doAnswer(writeBytes(2, json.getBytes())) + .when(pdfJsonConversionService) + .convertPdfToJson(eq(pdfFile), eq(false), any(OutputStream.class)); + + ResponseEntity response = controller.convertPdfToJson(request, false); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } finally { + restoreProp("spdf.pdfjson.repeatScan", previous); + } + } + } + + private static org.mockito.stubbing.Answer writeBytes(int argIndex, byte[] data) { + return inv -> { + OutputStream os = inv.getArgument(argIndex, OutputStream.class); + os.write(data); + return null; + }; + } + + private static void restoreProp(String key, String previous) { + if (previous == null) { + System.clearProperty(key); + } else { + System.setProperty(key, previous); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandlerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandlerTest.java new file mode 100644 index 0000000000..ed4c5a13e9 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandlerTest.java @@ -0,0 +1,103 @@ +package stirling.software.SPDF.controller.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import stirling.software.SPDF.exception.CacheUnavailableException; + +import tools.jackson.databind.ObjectMapper; + +/** + * Unit tests for {@link ConvertPdfJsonExceptionHandler}. A real ObjectMapper exercises the happy + * path; a throwing mock drives the serialization-failure fallback branches. + */ +@DisplayName("ConvertPdfJsonExceptionHandler") +class ConvertPdfJsonExceptionHandlerTest { + + @Nested + @DisplayName("handleCacheUnavailable - success") + class Success { + + @Test + @DisplayName("serializes a 410 GONE JSON body with the error details") + void serializesGoneResponse() { + ConvertPdfJsonExceptionHandler handler = + new ConvertPdfJsonExceptionHandler(new ObjectMapper()); + + ResponseEntity response = + handler.handleCacheUnavailable(new CacheUnavailableException("cache is gone")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GONE); + assertThat(response.getHeaders().getContentType()) + .isEqualTo(MediaType.APPLICATION_JSON); + String json = new String(response.getBody()); + assertThat(json).contains("cache_unavailable"); + assertThat(json).contains("reupload"); + assertThat(json).contains("cache is gone"); + } + + @Test + @DisplayName("tolerates a null exception message") + void toleratesNullMessage() { + ConvertPdfJsonExceptionHandler handler = + new ConvertPdfJsonExceptionHandler(new ObjectMapper()); + + ResponseEntity response = + handler.handleCacheUnavailable(new CacheUnavailableException(null)); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GONE); + assertThat(response.getBody()).isNotEmpty(); + } + } + + @Nested + @DisplayName("handleCacheUnavailable - fallback") + class Fallback { + + @Test + @DisplayName("uses the literal JSON fallback when every serialization attempt fails") + void literalFallbackWhenAllSerializationFails() { + ObjectMapper mapper = Mockito.mock(ObjectMapper.class); + // Both the primary and the secondary writeValueAsBytes calls fail. + Mockito.when(mapper.writeValueAsBytes(any())).thenThrow(new RuntimeException("boom")); + ConvertPdfJsonExceptionHandler handler = new ConvertPdfJsonExceptionHandler(mapper); + + ResponseEntity response = + handler.handleCacheUnavailable(new CacheUnavailableException("nope")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GONE); + assertThat(response.getHeaders().getContentType()) + .isEqualTo(MediaType.APPLICATION_JSON); + // Last-ditch hand-written JSON literal. + String json = new String(response.getBody()); + assertThat(json).contains("cache_unavailable"); + assertThat(json).contains("Cache unavailable"); + } + + @Test + @DisplayName("recovers via the second serialization attempt when only the first fails") + void secondAttemptSucceeds() { + ObjectMapper mapper = Mockito.mock(ObjectMapper.class); + byte[] fallbackJson = "{\"error\":\"cache_unavailable\"}".getBytes(); + // First call throws, second returns serialized bytes. + Mockito.when(mapper.writeValueAsBytes(any())) + .thenThrow(new RuntimeException("first fails")) + .thenReturn(fallbackJson); + ConvertPdfJsonExceptionHandler handler = new ConvertPdfJsonExceptionHandler(mapper); + + ResponseEntity response = + handler.handleCacheUnavailable(new CacheUnavailableException("retry")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.GONE); + assertThat(response.getBody()).isEqualTo(fallbackJson); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertSvgToPDFMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertSvgToPDFMoreTest.java new file mode 100644 index 0000000000..c04aab7584 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertSvgToPDFMoreTest.java @@ -0,0 +1,214 @@ +package stirling.software.SPDF.controller.api.converters; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.api.converters.SvgToPdfRequest; +import stirling.software.SPDF.utils.SvgToPdf; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.SvgSanitizer; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.WebResponseUtils; + +/** + * Gap coverage for {@link ConvertSvgToPDF}: the multi-file zip path and the empty-output failure + * branches not covered by ConvertSvgToPDFTest. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("ConvertSvgToPDF zip and failure branches") +class ConvertSvgToPDFMoreTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private SvgSanitizer svgSanitizer; + @Mock private TempFileManager tempFileManager; + + @InjectMocks private ConvertSvgToPDF controller; + + @BeforeEach + void setUp() throws Exception { + // Real backing files so the zip/pdf streams can actually be written. + lenient() + .when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + inv -> { + File f = + Files.createTempFile("svg", inv.getArgument(0)) + .toFile(); + TempFile tf = mock(TempFile.class); + lenient().when(tf.getFile()).thenReturn(f); + lenient().when(tf.getPath()).thenReturn(f.toPath()); + return tf; + }); + } + + private static MockMultipartFile svg(String name, String content) { + return new MockMultipartFile("fileInput", name, "image/svg+xml", content.getBytes()); + } + + private static List zipNames(Resource resource) throws Exception { + List names = new ArrayList<>(); + try (ZipInputStream zis = + new ZipInputStream(new ByteArrayInputStream(resource.getContentAsByteArray()))) { + ZipEntry e; + while ((e = zis.getNextEntry()) != null) { + names.add(e.getName()); + zis.closeEntry(); + } + } + return names; + } + + @Test + @DisplayName("multiple SVGs in separate mode are returned as a zip") + void multipleSeparateZipsOutput() throws Exception { + byte[] sanitized1 = "a".getBytes(); + byte[] sanitized2 = "b".getBytes(); + byte[] pdf1 = "pdf1".getBytes(); + byte[] pdf2 = "pdf2".getBytes(); + + SvgToPdfRequest request = new SvgToPdfRequest(); + request.setFileInput( + new MockMultipartFile[] { + svg("a.svg", "1"), svg("b.svg", "2") + }); + request.setCombineIntoSinglePdf(false); + + when(svgSanitizer.sanitize("1".getBytes())).thenReturn(sanitized1); + when(svgSanitizer.sanitize("2".getBytes())).thenReturn(sanitized2); + when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdf1)).thenReturn(pdf1); + when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdf2)).thenReturn(pdf2); + + try (MockedStatic svg = Mockito.mockStatic(SvgToPdf.class)) { + svg.when(() -> SvgToPdf.convert(sanitized1)).thenReturn(pdf1); + svg.when(() -> SvgToPdf.convert(sanitized2)).thenReturn(pdf2); + + ResponseEntity response = controller.convertSvgToPdf(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + List names = zipNames(response.getBody()); + assertEquals(2, names.size()); + } + } + + @Nested + @DisplayName("empty-output failures") + class EmptyOutputs { + + @Test + @DisplayName("combined mode returns 500 when the combined PDF is empty") + void combinedEmptyOutput() throws Exception { + byte[] sanitized = "s".getBytes(); + SvgToPdfRequest request = new SvgToPdfRequest(); + request.setFileInput(new MockMultipartFile[] {svg("a.svg", "1")}); + request.setCombineIntoSinglePdf(true); + + when(svgSanitizer.sanitize("1".getBytes())).thenReturn(sanitized); + + try (MockedStatic svg = Mockito.mockStatic(SvgToPdf.class)) { + svg.when(() -> SvgToPdf.combineIntoPdf(any())).thenReturn(new byte[0]); + + ResponseEntity response = controller.convertSvgToPdf(request); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + } + } + + @Test + @DisplayName("separate mode returns 500 when no file converts successfully") + void separateAllEmpty() throws Exception { + byte[] sanitized = "s".getBytes(); + SvgToPdfRequest request = new SvgToPdfRequest(); + request.setFileInput(new MockMultipartFile[] {svg("a.svg", "1")}); + request.setCombineIntoSinglePdf(false); + + when(svgSanitizer.sanitize("1".getBytes())).thenReturn(sanitized); + + try (MockedStatic svg = Mockito.mockStatic(SvgToPdf.class)) { + // Empty conversion output -> the file is skipped -> no successful conversions. + svg.when(() -> SvgToPdf.convert(sanitized)).thenReturn(new byte[0]); + + ResponseEntity response = controller.convertSvgToPdf(request); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + } + } + + @Test + @DisplayName("combined mode returns 500 when conversion throws IOException") + void combinedConversionThrows() throws Exception { + byte[] sanitized = "s".getBytes(); + SvgToPdfRequest request = new SvgToPdfRequest(); + request.setFileInput(new MockMultipartFile[] {svg("a.svg", "1")}); + request.setCombineIntoSinglePdf(true); + + when(svgSanitizer.sanitize("1".getBytes())).thenReturn(sanitized); + + try (MockedStatic svg = Mockito.mockStatic(SvgToPdf.class)) { + svg.when(() -> SvgToPdf.combineIntoPdf(any())) + .thenThrow(new IOException("convert failure")); + + ResponseEntity response = controller.convertSvgToPdf(request); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + } + } + } + + @Test + @DisplayName("a single converted SVG uses the pdf-file response path") + void singleConvertedUsesPdfResponse() throws Exception { + byte[] sanitized = "s".getBytes(); + byte[] pdf = "pdf".getBytes(); + SvgToPdfRequest request = new SvgToPdfRequest(); + request.setFileInput(new MockMultipartFile[] {svg("only.svg", "1")}); + request.setCombineIntoSinglePdf(false); + + when(svgSanitizer.sanitize("1".getBytes())).thenReturn(sanitized); + when(pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdf)).thenReturn(pdf); + + ResponseEntity stub = + ResponseEntity.ok(new org.springframework.core.io.ByteArrayResource(pdf)); + try (MockedStatic svg = Mockito.mockStatic(SvgToPdf.class); + MockedStatic wr = Mockito.mockStatic(WebResponseUtils.class)) { + svg.when(() -> SvgToPdf.convert(sanitized)).thenReturn(pdf); + wr.when(() -> WebResponseUtils.pdfFileToWebResponse(any(TempFile.class), anyString())) + .thenReturn(stub); + + ResponseEntity response = controller.convertSvgToPdf(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDFExtraTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDFExtraTest.java new file mode 100644 index 0000000000..1654d17df7 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertWebsiteToPDFExtraTest.java @@ -0,0 +1,203 @@ +package stirling.software.SPDF.controller.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.TempFileManager; + +/** + * Branch coverage for the disallowed-scheme detection helpers of {@link ConvertWebsiteToPDF}. These + * are pure string transforms exercised by reflection, so no network call or external WeasyPrint + * invocation occurs. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("ConvertWebsiteToPDF scheme-detection helpers") +class ConvertWebsiteToPDFExtraTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private RuntimePathConfig runtimePathConfig; + @Mock private TempFileManager tempFileManager; + + private ConvertWebsiteToPDF sut; + + @BeforeEach + void setUp() { + sut = + new ConvertWebsiteToPDF( + pdfDocumentFactory, + runtimePathConfig, + new ApplicationProperties(), + tempFileManager); + } + + private boolean containsDisallowed(String html) throws Exception { + Method m = + ConvertWebsiteToPDF.class.getDeclaredMethod( + "containsDisallowedUriScheme", String.class); + m.setAccessible(true); + return (boolean) m.invoke(sut, html); + } + + private String percentDecode(String content) throws Exception { + Method m = ConvertWebsiteToPDF.class.getDeclaredMethod("percentDecode", String.class); + m.setAccessible(true); + return (String) m.invoke(sut, content); + } + + private String decodeEntities(String content) throws Exception { + Method m = + ConvertWebsiteToPDF.class.getDeclaredMethod( + "decodeNumericHtmlEntities", String.class); + m.setAccessible(true); + return (String) m.invoke(sut, content); + } + + @Nested + @DisplayName("containsDisallowedUriScheme") + class DisallowedScheme { + + @Test + @DisplayName("null and empty content are allowed") + void nullAndEmpty() throws Exception { + assertThat(containsDisallowed(null)).isFalse(); + assertThat(containsDisallowed("")).isFalse(); + } + + @Test + @DisplayName("plain safe html is allowed") + void safeHtml() throws Exception { + assertThat( + containsDisallowed( + "ok")) + .isFalse(); + } + + @Test + @DisplayName("a literal file:/// scheme is rejected") + void literalFileScheme() throws Exception { + assertThat(containsDisallowed("x")).isTrue(); + } + + @Test + @DisplayName("an uppercase FILE: scheme is rejected after lower-casing") + void uppercaseFileScheme() throws Exception { + assertThat(containsDisallowed("x")).isTrue(); + } + + @Test + @DisplayName("a percent-encoded file scheme separator is rejected") + void percentEncodedSeparator() throws Exception { + // file:%2f%2f decodes to file:// during normalization + assertThat(containsDisallowed("x")).isTrue(); + } + + @Test + @DisplayName("an html-entity encoded slash sequence is rejected") + void htmlEntitySlashes() throws Exception { + // file:// -> file:// after numeric-entity decoding + assertThat(containsDisallowed("x")).isTrue(); + } + + @Test + @DisplayName("a named-entity colon/slash sequence is rejected") + void namedEntitySlashes() throws Exception { + // file:// -> file:// after named-entity replacement + assertThat(containsDisallowed("x")).isTrue(); + } + + @Test + @DisplayName("the word 'profile:' is not mistaken for a file scheme") + void wordBoundaryGuard() throws Exception { + // the (?profile://something")).isFalse(); + } + } + + @Nested + @DisplayName("percentDecode") + class PercentDecode { + + @Test + @DisplayName("decodes valid percent escapes") + void decodesValid() throws Exception { + assertThat(percentDecode("a%2fb")).isEqualTo("a/b"); + } + + @Test + @DisplayName("leaves a trailing incomplete escape untouched") + void trailingIncomplete() throws Exception { + // not enough trailing chars for a full %XX -> appended literally + assertThat(percentDecode("end%2")).isEqualTo("end%2"); + } + + @Test + @DisplayName("leaves a non-hex escape untouched") + void nonHexEscape() throws Exception { + assertThat(percentDecode("a%zzb")).isEqualTo("a%zzb"); + } + + @Test + @DisplayName("content with no escapes is returned unchanged") + void noEscapes() throws Exception { + assertThat(percentDecode("plain text")).isEqualTo("plain text"); + } + } + + @Nested + @DisplayName("decodeNumericHtmlEntities") + class DecodeNumericHtmlEntities { + + @Test + @DisplayName("decodes a decimal numeric entity") + void decimalEntity() throws Exception { + // / is '/' + assertThat(decodeEntities("a/b")).isEqualTo("a/b"); + } + + @Test + @DisplayName("decodes a hexadecimal numeric entity") + void hexEntity() throws Exception { + // / is '/' + assertThat(decodeEntities("a/b")).isEqualTo("a/b"); + } + + @Test + @DisplayName("content without entities is unchanged") + void noEntities() throws Exception { + assertThat(decodeEntities("nothing here")).isEqualTo("nothing here"); + } + } + + @Test + @DisplayName("controller is constructed with its collaborators") + void constructed() { + assertThat(sut).isNotNull(); + } + + @Test + @DisplayName("reflection invocation surfaces are wired correctly") + void reflectionWired() throws Exception { + // guards against a refactor renaming the private helpers used above + try { + assertThat(containsDisallowed("safe")).isFalse(); + } catch (InvocationTargetException e) { + throw new AssertionError("helper invocation failed", e.getCause()); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ExtractCSVControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ExtractCSVControllerMoreTest.java new file mode 100644 index 0000000000..4230f85dc8 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ExtractCSVControllerMoreTest.java @@ -0,0 +1,214 @@ +package stirling.software.SPDF.controller.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.api.PDFWithPageNums; +import stirling.software.SPDF.pdf.parser.PdfModels.Bounds; +import stirling.software.SPDF.pdf.parser.PdfModels.TableFragment; +import stirling.software.SPDF.pdf.parser.TabulaTableParser; +import stirling.software.common.service.CustomPDFDocumentFactory; + +/** + * Additional coverage for {@link ExtractCSVController}. The Tabula parser is mocked so + * deterministic table fragments drive the single-table, multi-table and no-table response branches; + * documents are built in-memory. + */ +@ExtendWith(MockitoExtension.class) +class ExtractCSVControllerMoreTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private TabulaTableParser tabulaTableParser; + + @InjectMocks private ExtractCSVController controller; + + private static PDDocument docWithPages(int pages) { + PDDocument doc = new PDDocument(); + for (int i = 0; i < pages; i++) { + doc.addPage(new PDPage()); + } + return doc; + } + + private static MockMultipartFile pdf(String name) { + return new MockMultipartFile( + "fileInput", name, MediaType.APPLICATION_PDF_VALUE, "pdf".getBytes()); + } + + /** Build a TableFragment whose only meaningful payload for CSV output is rawRows. */ + private static TableFragment fragment(List> rawRows) { + return new TableFragment( + "tbl", + 1, + new Bounds(0f, 0f, 100f, 100f), + List.of(), + List.of(), + rawRows, + rawRows.isEmpty() ? 0 : rawRows.get(0).size(), + 1.0f, + List.of(), + null); + } + + @Nested + @DisplayName("response shape by table count") + class ResponseShape { + + @Test + @DisplayName("returns no content when no tables are found") + void noTablesNoContent() throws Exception { + PDFWithPageNums request = new PDFWithPageNums(); + request.setFileInput(pdf("data.pdf")); + request.setPageNumbers("all"); + + when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1)); + when(tabulaTableParser.parse(any(PDDocument.class), eq(1))).thenReturn(List.of()); + + ResponseEntity response = controller.pdfToCsv(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + + @Test + @DisplayName("returns a single CSV body when exactly one table is found") + void singleTableCsv() throws Exception { + PDFWithPageNums request = new PDFWithPageNums(); + request.setFileInput(pdf("report.pdf")); + request.setPageNumbers("all"); + + when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1)); + when(tabulaTableParser.parse(any(PDDocument.class), eq(1))) + .thenReturn( + List.of( + fragment( + List.of( + List.of("Name", "Age"), + List.of("Alice", "30"))))); + + ResponseEntity response = controller.pdfToCsv(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().getContentType().toString()).startsWith("text/csv"); + assertThat(response.getHeaders().getContentDisposition().getFilename()) + .isEqualTo("report_extracted.csv"); + assertThat(response.getBody().toString()).contains("Name").contains("Alice"); + } + + @Test + @DisplayName("returns a zip when multiple tables span multiple pages") + void multiTableZip() throws Exception { + PDFWithPageNums request = new PDFWithPageNums(); + request.setFileInput(pdf("multi.pdf")); + request.setPageNumbers("all"); + + when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(2)); + when(tabulaTableParser.parse(any(PDDocument.class), eq(1))) + .thenReturn(List.of(fragment(List.of(List.of("a", "b"))))); + when(tabulaTableParser.parse(any(PDDocument.class), eq(2))) + .thenReturn( + List.of( + fragment(List.of(List.of("c", "d"))), + fragment(List.of(List.of("e", "f"))))); + + ResponseEntity response = controller.pdfToCsv(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().getContentType()) + .isEqualTo(MediaType.APPLICATION_OCTET_STREAM); + assertThat(response.getHeaders().getContentDisposition().getFilename()) + .isEqualTo("multi_extracted.zip"); + + byte[] body = (byte[]) response.getBody(); + assertThat(zipEntryNames(body)) + .containsExactlyInAnyOrder( + "multi_p1_t1.csv", "multi_p2_t1.csv", "multi_p2_t2.csv"); + } + + private List zipEntryNames(byte[] zipBytes) throws Exception { + java.util.List names = new java.util.ArrayList<>(); + try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + names.add(entry.getName()); + zis.closeEntry(); + } + } + return names; + } + } + + @Nested + @DisplayName("error propagation") + class Errors { + + @Test + @DisplayName("propagates a parser failure") + void parserFailurePropagates() throws Exception { + PDFWithPageNums request = new PDFWithPageNums(); + request.setFileInput(pdf("bad.pdf")); + request.setPageNumbers("all"); + + when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1)); + when(tabulaTableParser.parse(any(PDDocument.class), eq(1))) + .thenThrow(new java.io.IOException("parse boom")); + + assertThatThrownBy(() -> controller.pdfToCsv(request)) + .isInstanceOf(java.io.IOException.class); + } + + @Test + @DisplayName("propagates a document load failure") + void loadFailurePropagates() throws Exception { + PDFWithPageNums request = new PDFWithPageNums(); + request.setFileInput(pdf("corrupt.pdf")); + request.setPageNumbers("all"); + + when(pdfDocumentFactory.load(request)).thenThrow(new java.io.IOException("load boom")); + + assertThatThrownBy(() -> controller.pdfToCsv(request)) + .isInstanceOf(java.io.IOException.class); + } + } + + @Test + @DisplayName("single table CSV body is quote-wrapped per the EXCEL/QuoteMode.ALL format") + void csvBodyIsQuoted() throws Exception { + PDFWithPageNums request = new PDFWithPageNums(); + request.setFileInput(pdf("q.pdf")); + request.setPageNumbers("all"); + + when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1)); + when(tabulaTableParser.parse(any(PDDocument.class), eq(1))) + .thenReturn(List.of(fragment(List.of(List.of("x", "y"))))); + + ResponseEntity response = controller.pdfToCsv(request); + + String body = response.getBody().toString(); + // QuoteMode.ALL wraps every field in double quotes. + assertThat(body).contains("\"x\"").contains("\"y\""); + assertThat(body.getBytes(StandardCharsets.UTF_8)).isNotEmpty(); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/PdfVectorExportControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/PdfVectorExportControllerMoreTest.java new file mode 100644 index 0000000000..7bcd853821 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/PdfVectorExportControllerMoreTest.java @@ -0,0 +1,261 @@ +package stirling.software.SPDF.controller.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.model.api.converters.PdfVectorExportRequest; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; + +/** + * Gap coverage for {@link PdfVectorExportController#convertPdfToVector} and the Ghostscript + * PDF-to-vector helper, complementing PdfVectorExportControllerTest (which only covers the + * PostScript-to-PDF endpoint). + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("PdfVectorExportController convertPdfToVector") +class PdfVectorExportControllerMoreTest { + + private final List tempPaths = new ArrayList<>(); + + @Mock private TempFileManager tempFileManager; + @Mock private EndpointConfiguration endpointConfiguration; + @Mock private ProcessExecutor ghostscriptExecutor; + @InjectMocks private PdfVectorExportController controller; + + // Real manager used only to mint genuine TempFile instances for the mock to hand back. + private final TempFileManager realTempFileManager = + new TempFileManager(new TempFileRegistry(), new ApplicationProperties()); + + private Map originalExecutors; + + @BeforeEach + void setup() throws Exception { + // Return a real TempFile so no Mockito when() runs re-entrantly inside the thenAnswer. + lenient() + .when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer(inv -> realTempFileManager.createManagedTempFile(inv.getArgument(0))); + lenient() + .when(tempFileManager.createTempFile(any())) + .thenAnswer( + invocation -> { + String suffix = invocation.getArgument(0); + Path path = + Files.createTempFile("vec_in", suffix == null ? "" : suffix); + tempPaths.add(path); + return path.toFile(); + }); + + Field instancesField = ProcessExecutor.class.getDeclaredField("instances"); + instancesField.setAccessible(true); + @SuppressWarnings("unchecked") + Map instances = + (Map) instancesField.get(null); + originalExecutors = Map.copyOf(instances); + instances.clear(); + instances.put(ProcessExecutor.Processes.GHOSTSCRIPT, ghostscriptExecutor); + } + + @AfterEach + void tearDown() throws Exception { + Field instancesField = ProcessExecutor.class.getDeclaredField("instances"); + instancesField.setAccessible(true); + @SuppressWarnings("unchecked") + Map instances = + (Map) instancesField.get(null); + instances.clear(); + if (originalExecutors != null) { + instances.putAll(originalExecutors); + } + reset(ghostscriptExecutor, tempFileManager, endpointConfiguration); + for (Path path : tempPaths) { + Files.deleteIfExists(path); + } + tempPaths.clear(); + } + + private ProcessExecutorResult okResult() { + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + lenient().when(result.getRc()).thenReturn(0); + lenient().when(result.getMessages()).thenReturn(""); + return result; + } + + private static PdfVectorExportRequest request(String outputFormat) { + MockMultipartFile file = + new MockMultipartFile( + "fileInput", "in.pdf", MediaType.APPLICATION_PDF_VALUE, new byte[] {1}); + PdfVectorExportRequest request = new PdfVectorExportRequest(); + request.setFileInput(file); + request.setOutputFormat(outputFormat); + return request; + } + + @Nested + @DisplayName("media types per output format") + class MediaTypes { + + @Test + @DisplayName("eps output yields application/postscript") + void epsContentType() throws Exception { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true); + ProcessExecutorResult ok = okResult(); + when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok); + + ResponseEntity response = controller.convertPdfToVector(request("eps")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().getContentType()) + .isEqualTo(MediaType.parseMediaType("application/postscript")); + } + + @Test + @DisplayName("ps output yields application/postscript") + void psContentType() throws Exception { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true); + ProcessExecutorResult ok = okResult(); + when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok); + + ResponseEntity response = controller.convertPdfToVector(request("ps")); + + assertThat(response.getHeaders().getContentType()) + .isEqualTo(MediaType.parseMediaType("application/postscript")); + } + + @Test + @DisplayName("pcl output yields the HP-PCL media type") + void pclContentType() throws Exception { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true); + ProcessExecutorResult ok = okResult(); + when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok); + + ResponseEntity response = controller.convertPdfToVector(request("pcl")); + + assertThat(response.getHeaders().getContentType()) + .isEqualTo(MediaType.parseMediaType("application/vnd.hp-PCL")); + } + + @Test + @DisplayName("xps output yields the MS XPS media type") + void xpsContentType() throws Exception { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true); + ProcessExecutorResult ok = okResult(); + when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok); + + ResponseEntity response = controller.convertPdfToVector(request("xps")); + + assertThat(response.getHeaders().getContentType()) + .isEqualTo(MediaType.parseMediaType("application/vnd.ms-xpsdocument")); + } + + @Test + @DisplayName("null output format defaults to eps") + void nullOutputFormatDefaultsToEps() throws Exception { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true); + ProcessExecutorResult ok = okResult(); + when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok); + + ResponseEntity response = controller.convertPdfToVector(request(null)); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().getContentType()) + .isEqualTo(MediaType.parseMediaType("application/postscript")); + } + + @Test + @DisplayName("uppercase output format is normalized to lowercase") + void uppercaseFormatNormalized() throws Exception { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true); + ProcessExecutorResult ok = okResult(); + when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok); + + ResponseEntity response = controller.convertPdfToVector(request("EPS")); + + assertThat(response.getHeaders().getContentType()) + .isEqualTo(MediaType.parseMediaType("application/postscript")); + } + } + + @Nested + @DisplayName("failure paths") + class Failures { + + @Test + @DisplayName("disabled Ghostscript group throws a conversion exception") + void ghostscriptDisabledThrows() { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false); + + assertThrows(Exception.class, () -> controller.convertPdfToVector(request("eps"))); + } + + @Test + @DisplayName("non-zero Ghostscript return code throws a conversion exception") + void nonZeroReturnCodeThrows() { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true); + ProcessExecutorResult bad = mock(ProcessExecutorResult.class); + lenient().when(bad.getRc()).thenReturn(1); + lenient().when(bad.getMessages()).thenReturn("some non-critical failure"); + try { + when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(bad); + } catch (Exception e) { + throw new RuntimeException(e); + } + + assertThrows(Exception.class, () -> controller.convertPdfToVector(request("eps"))); + } + + @Test + @DisplayName("an unsupported output format is rejected before any device mapping") + void unsupportedFormatThrows() { + // "svg" is not in the validated set and falls through to the device-switch default. + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true); + + assertThrows(Exception.class, () -> controller.convertPdfToVector(request("svg"))); + } + } + + @Test + @DisplayName("Ghostscript runs the configured command for a successful conversion") + void runsGhostscriptCommand() throws Exception { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true); + ProcessExecutorResult ok = okResult(); + when(ghostscriptExecutor.runCommandWithOutputHandling(any())).thenReturn(ok); + + ResponseEntity response = controller.convertPdfToVector(request("eps")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + org.mockito.Mockito.verify(ghostscriptExecutor).runCommandWithOutputHandling(any()); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AttachmentControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AttachmentControllerMoreTest.java new file mode 100644 index 0000000000..d451f05937 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AttachmentControllerMoreTest.java @@ -0,0 +1,350 @@ +package stirling.software.SPDF.controller.api.misc; + +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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.nio.file.Files; +import java.util.List; +import java.util.Optional; + +import org.apache.pdfbox.pdmodel.PDDocument; +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.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.SPDF.controller.api.converters.ConvertPDFToPDFA; +import stirling.software.SPDF.model.api.misc.AttachmentInfo; +import stirling.software.SPDF.model.api.misc.DeleteAttachmentRequest; +import stirling.software.SPDF.model.api.misc.ExtractAttachmentsRequest; +import stirling.software.SPDF.model.api.misc.ListAttachmentsRequest; +import stirling.software.SPDF.model.api.misc.RenameAttachmentRequest; +import stirling.software.SPDF.service.AttachmentServiceInterface; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.WebResponseUtils; + +/** + * Coverage for the AttachmentController endpoints not exercised by AttachmentControllerTest: + * extract, list, rename, delete plus their validation paths and the add-attachment validation + * branches. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("AttachmentController extract/list/rename/delete") +class AttachmentControllerMoreTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private AttachmentServiceInterface pdfAttachmentService; + @Mock private ConvertPDFToPDFA convertPDFToPDFA; + @Mock private TempFileManager tempFileManager; + + private AttachmentController controller; + + private PDDocument mockDocument; + + @BeforeEach + void setUp() throws Exception { + controller = + new AttachmentController( + pdfDocumentFactory, + pdfAttachmentService, + convertPDFToPDFA, + tempFileManager); + mockDocument = mock(PDDocument.class); + + when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + inv -> { + File f = + Files.createTempFile("att_test", inv.getArgument(0)) + .toFile(); + TempFile tf = mock(TempFile.class); + lenient().when(tf.getFile()).thenReturn(f); + lenient().when(tf.getPath()).thenReturn(f.toPath()); + return tf; + }); + } + + private static MockMultipartFile pdf() { + return new MockMultipartFile( + "fileInput", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, "pdf".getBytes()); + } + + @Nested + @DisplayName("extractAttachments") + class Extract { + + @Test + @DisplayName("writes a zip when attachments are present") + void extractsToZip() throws Exception { + ExtractAttachmentsRequest request = new ExtractAttachmentsRequest(); + request.setFileInput(pdf()); + + when(pdfDocumentFactory.load(request, true)).thenReturn(mockDocument); + when(pdfAttachmentService.extractAttachments(mockDocument)) + .thenReturn(Optional.of("zip-bytes".getBytes())); + + ResponseEntity expected = + ResponseEntity.ok(new ByteArrayResource("zip-bytes".getBytes())); + try (MockedStatic wr = Mockito.mockStatic(WebResponseUtils.class)) { + wr.when( + () -> + WebResponseUtils.zipFileToWebResponse( + any(TempFile.class), anyString())) + .thenReturn(expected); + + ResponseEntity response = controller.extractAttachments(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + verify(pdfAttachmentService).extractAttachments(mockDocument); + } + } + + @Test + @DisplayName("throws when no attachments are found") + void noAttachmentsThrows() throws Exception { + ExtractAttachmentsRequest request = new ExtractAttachmentsRequest(); + request.setFileInput(pdf()); + + when(pdfDocumentFactory.load(request, true)).thenReturn(mockDocument); + when(pdfAttachmentService.extractAttachments(mockDocument)) + .thenReturn(Optional.empty()); + + assertThrows( + IllegalArgumentException.class, () -> controller.extractAttachments(request)); + } + + @Test + @DisplayName("uses fileId for the output name when no upload filename is available") + void usesFileIdForName() throws Exception { + ExtractAttachmentsRequest request = new ExtractAttachmentsRequest(); + request.setFileId("server-file-id"); + + when(pdfDocumentFactory.load(request, true)).thenReturn(mockDocument); + when(pdfAttachmentService.extractAttachments(mockDocument)) + .thenReturn(Optional.of("zip".getBytes())); + + ResponseEntity expected = + ResponseEntity.ok(new ByteArrayResource("zip".getBytes())); + try (MockedStatic wr = Mockito.mockStatic(WebResponseUtils.class)) { + wr.when( + () -> + WebResponseUtils.zipFileToWebResponse( + any(TempFile.class), anyString())) + .thenReturn(expected); + + ResponseEntity response = controller.extractAttachments(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + } + } + + @Nested + @DisplayName("listAttachments") + class ListAttachments { + + @Test + @DisplayName("returns the attachment metadata list") + void returnsList() throws Exception { + ListAttachmentsRequest request = new ListAttachmentsRequest(); + request.setFileInput(pdf()); + + AttachmentInfo info = new AttachmentInfo(); + when(pdfDocumentFactory.load(request, true)).thenReturn(mockDocument); + when(pdfAttachmentService.listAttachments(mockDocument)).thenReturn(List.of(info)); + + ResponseEntity> response = controller.listAttachments(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals(1, response.getBody().size()); + } + + @Test + @DisplayName("returns an empty list when the PDF has no attachments") + void returnsEmptyList() throws Exception { + ListAttachmentsRequest request = new ListAttachmentsRequest(); + request.setFileInput(pdf()); + + when(pdfDocumentFactory.load(request, true)).thenReturn(mockDocument); + when(pdfAttachmentService.listAttachments(mockDocument)).thenReturn(List.of()); + + ResponseEntity> response = controller.listAttachments(request); + + assertEquals(0, response.getBody().size()); + } + } + + @Nested + @DisplayName("renameAttachment") + class Rename { + + @Test + @DisplayName("renames and returns the updated PDF") + void renames() throws Exception { + RenameAttachmentRequest request = new RenameAttachmentRequest(); + request.setFileInput(pdf()); + request.setAttachmentName("old.txt"); + request.setNewName("new.txt"); + + when(pdfDocumentFactory.load(request, false)).thenReturn(mockDocument); + when(pdfAttachmentService.renameAttachment(mockDocument, "old.txt", "new.txt")) + .thenReturn(mockDocument); + + ResponseEntity expected = + ResponseEntity.ok(new ByteArrayResource("pdf".getBytes())); + try (MockedStatic wr = Mockito.mockStatic(WebResponseUtils.class)) { + wr.when( + () -> + WebResponseUtils.pdfDocToWebResponse( + any(PDDocument.class), + anyString(), + any(TempFileManager.class))) + .thenReturn(expected); + + ResponseEntity response = controller.renameAttachment(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + verify(pdfAttachmentService).renameAttachment(mockDocument, "old.txt", "new.txt"); + } + } + + @Test + @DisplayName("rejects a blank attachment name") + void blankAttachmentNameThrows() { + RenameAttachmentRequest request = new RenameAttachmentRequest(); + request.setFileInput(pdf()); + request.setAttachmentName(" "); + request.setNewName("new.txt"); + + assertThrows( + IllegalArgumentException.class, () -> controller.renameAttachment(request)); + verifyNoInteractions(pdfAttachmentService); + } + + @Test + @DisplayName("rejects a null new name") + void nullNewNameThrows() { + RenameAttachmentRequest request = new RenameAttachmentRequest(); + request.setFileInput(pdf()); + request.setAttachmentName("old.txt"); + request.setNewName(null); + + assertThrows( + IllegalArgumentException.class, () -> controller.renameAttachment(request)); + } + } + + @Nested + @DisplayName("deleteAttachment") + class Delete { + + @Test + @DisplayName("deletes and returns the updated PDF") + void deletes() throws Exception { + DeleteAttachmentRequest request = new DeleteAttachmentRequest(); + request.setFileInput(pdf()); + request.setAttachmentName("file.txt"); + + when(pdfDocumentFactory.load(request, false)).thenReturn(mockDocument); + when(pdfAttachmentService.deleteAttachment(mockDocument, "file.txt")) + .thenReturn(mockDocument); + + ResponseEntity expected = + ResponseEntity.ok(new ByteArrayResource("pdf".getBytes())); + try (MockedStatic wr = Mockito.mockStatic(WebResponseUtils.class)) { + wr.when( + () -> + WebResponseUtils.pdfDocToWebResponse( + any(PDDocument.class), + anyString(), + any(TempFileManager.class))) + .thenReturn(expected); + + ResponseEntity response = controller.deleteAttachment(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + verify(pdfAttachmentService).deleteAttachment(mockDocument, "file.txt"); + } + } + + @Test + @DisplayName("rejects a null attachment name") + void nullAttachmentNameThrows() { + DeleteAttachmentRequest request = new DeleteAttachmentRequest(); + request.setFileInput(pdf()); + request.setAttachmentName(null); + + assertThrows( + IllegalArgumentException.class, () -> controller.deleteAttachment(request)); + verifyNoInteractions(pdfAttachmentService); + } + } + + @Nested + @DisplayName("addAttachments validation") + class AddValidation { + + @Test + @DisplayName("rejects a null attachment list") + void nullAttachmentsThrows() { + stirling.software.SPDF.model.api.misc.AddAttachmentRequest request = + new stirling.software.SPDF.model.api.misc.AddAttachmentRequest(); + request.setFileInput(pdf()); + request.setAttachments(null); + + assertThrows(IllegalArgumentException.class, () -> controller.addAttachments(request)); + } + + @Test + @DisplayName("rejects an empty attachment list") + void emptyAttachmentsThrows() { + stirling.software.SPDF.model.api.misc.AddAttachmentRequest request = + new stirling.software.SPDF.model.api.misc.AddAttachmentRequest(); + request.setFileInput(pdf()); + request.setAttachments(List.of()); + + assertThrows(IllegalArgumentException.class, () -> controller.addAttachments(request)); + } + + @Test + @DisplayName("rejects an empty attachment entry") + void emptyAttachmentEntryThrows() { + MultipartFile empty = + new MockMultipartFile("attachment", "empty.txt", "text/plain", new byte[0]); + stirling.software.SPDF.model.api.misc.AddAttachmentRequest request = + new stirling.software.SPDF.model.api.misc.AddAttachmentRequest(); + request.setFileInput(pdf()); + request.setAttachments(List.of(empty)); + + assertThrows(IllegalArgumentException.class, () -> controller.addAttachments(request)); + verifyNoInteractions(pdfAttachmentService); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfControllerMoreTest.java new file mode 100644 index 0000000000..c4e62c6b16 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/AutoSplitPdfControllerMoreTest.java @@ -0,0 +1,210 @@ +package stirling.software.SPDF.controller.api.misc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +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.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel; + +import stirling.software.SPDF.model.api.misc.AutoSplitPdfRequest; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.TempFileManager; + +/** + * Additional branch coverage for {@link AutoSplitPdfController}: the actual splitting that happens + * when a recognised QR divider is embedded mid-document, plus duplex-mode skipping of the divider + * back page. QR images are generated with zxing; no rendering of external resources is required. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("AutoSplitPdfController additional branch tests") +class AutoSplitPdfControllerMoreTest { + + private static final String VALID_QR = "https://stirlingpdf.com"; + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private TempFileManager tempFileManager; + private ApplicationProperties applicationProperties; + private AutoSplitPdfController controller; + + @TempDir java.nio.file.Path tempDir; + + @BeforeEach + void setUp() throws Exception { + applicationProperties = new ApplicationProperties(); + applicationProperties.getSystem().setMaxDPI(150); + controller = + new AutoSplitPdfController( + pdfDocumentFactory, tempFileManager, applicationProperties); + when(tempFileManager.createTempFile(".zip")) + .thenAnswer(inv -> Files.createTempFile(tempDir, "split", ".zip").toFile()); + when(pdfDocumentFactory.load(any(InputStream.class))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0).readAllBytes())); + } + + private static BufferedImage qrImage(String text, int size) throws Exception { + QRCodeWriter writer = new QRCodeWriter(); + java.util.Map hints = new java.util.EnumMap<>(EncodeHintType.class); + hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M); + hints.put(EncodeHintType.MARGIN, 4); + BitMatrix matrix = writer.encode(text, BarcodeFormat.QR_CODE, size, size, hints); + int w = matrix.getWidth(); + int h = matrix.getHeight(); + BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); + for (int x = 0; x < w; x++) { + for (int y = 0; y < h; y++) { + image.setRGB(x, y, matrix.get(x, y) ? Color.BLACK.getRGB() : Color.WHITE.getRGB()); + } + } + return image; + } + + private static void addPlainPage(PDDocument doc) { + doc.addPage(new PDPage(new PDRectangle(300, 300))); + } + + private static void addQrDividerPage(PDDocument doc, BufferedImage qr) throws Exception { + PDPage page = new PDPage(new PDRectangle(300, 300)); + doc.addPage(page); + PDImageXObject xobj = LosslessFactory.createFromImage(doc, qr); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.drawImage(xobj, 25, 25, 250, 250); + } + } + + private byte[] docToBytes(PDDocument doc) throws Exception { + try (doc) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + return baos.toByteArray(); + } + } + + private static AutoSplitPdfRequest request(byte[] bytes, Boolean duplex) { + AutoSplitPdfRequest req = new AutoSplitPdfRequest(); + req.setFileInput(new MockMultipartFile("fileInput", "doc.pdf", "application/pdf", bytes)); + req.setDuplexMode(duplex); + return req; + } + + private static List zipEntries(Resource res) throws Exception { + List names = new ArrayList<>(); + try (InputStream in = res.getInputStream(); + ZipInputStream zis = new ZipInputStream(in)) { + ZipEntry e; + while ((e = zis.getNextEntry()) != null) { + names.add(e.getName()); + zis.closeEntry(); + } + } + return names; + } + + @Nested + @DisplayName("Splitting on a QR divider") + class QrDividerSplit { + + @Test + @DisplayName("a mid-document QR divider produces two output PDFs") + void dividerProducesTwoDocs() throws Exception { + BufferedImage qr = qrImage(VALID_QR, 250); + PDDocument doc = new PDDocument(); + addPlainPage(doc); // content section 1 + addQrDividerPage(doc, qr); // divider -> starts section 2 + addPlainPage(doc); // content section 2 + byte[] bytes = docToBytes(doc); + + ResponseEntity response = controller.autoSplitPdf(request(bytes, false)); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List names = zipEntries(response.getBody()); + // First doc = page 1; second doc = divider page + page 3. + assertThat(names).hasSize(2); + assertThat(names).containsExactly("doc_1.pdf", "doc_2.pdf"); + } + + @Test + @DisplayName("duplex mode drops the page following the divider") + void duplexDropsBackPage() throws Exception { + BufferedImage qr = qrImage(VALID_QR, 250); + PDDocument doc = new PDDocument(); + addPlainPage(doc); // section 1 + addQrDividerPage(doc, qr); // divider + addPlainPage(doc); // back of divider -> skipped in duplex + addPlainPage(doc); // section 2 content + byte[] bytes = docToBytes(doc); + + ResponseEntity response = controller.autoSplitPdf(request(bytes, true)); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + List names = zipEntries(response.getBody()); + assertThat(names).hasSize(2); + } + + @Test + @DisplayName("output PDFs from a divider split are individually loadable") + void outputsAreLoadable() throws Exception { + BufferedImage qr = qrImage(VALID_QR, 250); + PDDocument doc = new PDDocument(); + addPlainPage(doc); + addQrDividerPage(doc, qr); + addPlainPage(doc); + byte[] bytes = docToBytes(doc); + + ResponseEntity response = controller.autoSplitPdf(request(bytes, false)); + + int total = 0; + try (InputStream in = response.getBody().getInputStream(); + ZipInputStream zis = new ZipInputStream(in)) { + ZipEntry e; + while ((e = zis.getNextEntry()) != null) { + byte[] entry = zis.readAllBytes(); + try (PDDocument loaded = Loader.loadPDF(entry)) { + total += loaded.getNumberOfPages(); + } + zis.closeEntry(); + } + } + // 3 source pages: the QR divider page itself is consumed as a boundary, leaving + // page 1 in the first doc and page 3 in the second (2 pages total). + assertThat(total).isEqualTo(2); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/BlankPageControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/BlankPageControllerMoreTest.java new file mode 100644 index 0000000000..63f6824db5 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/BlankPageControllerMoreTest.java @@ -0,0 +1,327 @@ +package stirling.software.SPDF.controller.api.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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.awt.Color; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +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.image.LosslessFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.api.misc.RemoveBlankPagesRequest; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ApplicationContextProvider; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +/** + * Endpoint-level coverage for {@link BlankPageController#removeBlankPages} using real PDFs so the + * text/image blank detection and zip-assembly logic actually run. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("BlankPageController removeBlankPages") +class BlankPageControllerMoreTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private TempFileManager tempFileManager; + + private BlankPageController controller; + + @BeforeEach + void setUp() throws Exception { + controller = new BlankPageController(pdfDocumentFactory, tempFileManager); + + when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + inv -> { + File f = + Files.createTempFile("blank_test", inv.getArgument(0)) + .toFile(); + TempFile tf = mock(TempFile.class); + lenient().when(tf.getFile()).thenReturn(f); + lenient().when(tf.getPath()).thenReturn(f.toPath()); + return tf; + }); + + // load(MultipartFile) hands back a freshly loaded document from the upload bytes. + lenient() + .when( + pdfDocumentFactory.load( + any(org.springframework.web.multipart.MultipartFile.class))) + .thenAnswer( + inv -> { + org.springframework.web.multipart.MultipartFile mf = inv.getArgument(0); + return Loader.loadPDF(mf.getBytes()); + }); + + // createNewDocument() returns a real, empty document the controller fills + saves. + lenient().when(pdfDocumentFactory.createNewDocument()).thenAnswer(inv -> new PDDocument()); + } + + private static byte[] textPage(String text) throws IOException { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 14); + cs.newLineAtOffset(72, 700); + cs.showText(text); + cs.endText(); + } + doc.save(baos); + return baos.toByteArray(); + } + } + + private static byte[] emptyPages(int count) throws IOException { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + for (int i = 0; i < count; i++) { + PDPage page = new PDPage(PDRectangle.A4); + // Empty Resources so image detection has a dict to scan instead of NPE-ing. + page.setResources(new PDResources()); + doc.addPage(page); + } + doc.save(baos); + return baos.toByteArray(); + } + } + + /** A document with one text page and one truly blank page. */ + private static byte[] mixedTextAndBlank() throws IOException { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + PDPage textPage = new PDPage(PDRectangle.A4); + doc.addPage(textPage); + try (PDPageContentStream cs = new PDPageContentStream(doc, textPage)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 14); + cs.newLineAtOffset(72, 700); + cs.showText("Has content"); + cs.endText(); + } + PDPage blankPage = new PDPage(PDRectangle.A4); + // Empty Resources so image detection has a dict to scan instead of NPE-ing. + blankPage.setResources(new PDResources()); + doc.addPage(blankPage); + doc.save(baos); + return baos.toByteArray(); + } + } + + /** A page that carries a black image so the image-based blank detection branch executes. */ + private static byte[] imagePage(Color color) throws IOException { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + java.awt.image.BufferedImage img = + new java.awt.image.BufferedImage( + 120, 120, java.awt.image.BufferedImage.TYPE_INT_RGB); + java.awt.Graphics2D g = img.createGraphics(); + g.setColor(color); + g.fillRect(0, 0, 120, 120); + g.dispose(); + PDImageXObject xobj = LosslessFactory.createFromImage(doc, img); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.drawImage(xobj, 100, 100, 200, 200); + } + doc.save(baos); + return baos.toByteArray(); + } + } + + private static RemoveBlankPagesRequest request(byte[] pdf, int threshold, float whitePercent) { + RemoveBlankPagesRequest req = new RemoveBlankPagesRequest(); + req.setFileInput(new MockMultipartFile("fileInput", "in.pdf", "application/pdf", pdf)); + req.setThreshold(threshold); + req.setWhitePercent(whitePercent); + return req; + } + + private static List zipNames(Resource resource) throws Exception { + List names = new ArrayList<>(); + try (ZipInputStream zis = + new ZipInputStream(new ByteArrayInputStream(resource.getContentAsByteArray()))) { + ZipEntry e; + while ((e = zis.getNextEntry()) != null) { + names.add(e.getName()); + zis.closeEntry(); + } + } + return names; + } + + @Nested + @DisplayName("happy paths") + class HappyPaths { + + @Test + @DisplayName("all-text document keeps every page in the non-blank PDF only") + void allTextPages() throws Exception { + ResponseEntity response = + controller.removeBlankPages(request(textPage("Hello"), 10, 99.9f)); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + List names = zipNames(response.getBody()); + // Only non-blank pages exist, so only the non-blank entry is written. + assertEquals(1, names.size()); + assertTrue(names.get(0).endsWith("_nonBlankPages.pdf")); + } + + @Test + @DisplayName("document with only empty pages produces the all-blank entry") + void onlyBlankPages() throws Exception { + ResponseEntity response = + controller.removeBlankPages(request(emptyPages(2), 10, 99.9f)); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + List names = zipNames(response.getBody()); + assertEquals(1, names.size()); + assertTrue(names.get(0).endsWith("_allBlankPages.pdf")); + } + + @Test + @DisplayName("mixed text and blank pages yields both non-blank and blank entries") + void mixedPages() throws Exception { + ResponseEntity response = + controller.removeBlankPages(request(mixedTextAndBlank(), 10, 99.9f)); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + List names = zipNames(response.getBody()); + assertEquals(2, names.size()); + assertTrue(names.stream().anyMatch(n -> n.endsWith("_nonBlankPages.pdf"))); + assertTrue(names.stream().anyMatch(n -> n.endsWith("_blankPages.pdf"))); + } + } + + @Nested + @DisplayName("image-based blank detection") + class ImageDetection { + + @Test + @DisplayName("page with a black image is treated as non-blank") + void blackImageIsNonBlank() throws Exception { + // Black image -> not enough white -> non-blank branch. + ResponseEntity response = + controller.removeBlankPages(request(imagePage(Color.BLACK), 10, 99.9f)); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + List names = zipNames(response.getBody()); + assertTrue(names.get(0).endsWith("_nonBlankPages.pdf")); + } + + @Test + @DisplayName("page with a white image counts as blank") + void whiteImageIsBlank() throws Exception { + // White image with a high white-percent threshold -> blank branch. + ResponseEntity response = + controller.removeBlankPages(request(imagePage(Color.WHITE), 10, 50.0f)); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + List names = zipNames(response.getBody()); + assertEquals(1, names.size()); + assertTrue(names.get(0).endsWith("_allBlankPages.pdf")); + } + + @Test + @DisplayName("uses configured maxDPI when application properties bean is present") + void usesConfiguredMaxDpi() throws Exception { + ApplicationProperties props = new ApplicationProperties(); + props.getSystem().setMaxDPI(40); + try (var mocked = org.mockito.Mockito.mockStatic(ApplicationContextProvider.class)) { + mocked.when(() -> ApplicationContextProvider.getBean(ApplicationProperties.class)) + .thenReturn(props); + + ResponseEntity response = + controller.removeBlankPages(request(imagePage(Color.BLACK), 10, 99.9f)); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + } + } + + @Nested + @DisplayName("error handling") + class Errors { + + @Test + @DisplayName("loader IOException is caught and returned as a 500 response") + void corruptPdfReturnsServerError() throws Exception { + RemoveBlankPagesRequest req = request("garbage".getBytes(), 10, 99.9f); + when(pdfDocumentFactory.load( + any(org.springframework.web.multipart.MultipartFile.class))) + .thenThrow(new IOException("bad pdf")); + + // The controller swallows IOException from the loader and maps it to HTTP 500. + ResponseEntity response = controller.removeBlankPages(req); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + } + } + + @Test + @DisplayName("createZipEntry writes a loadable PDF with the supplied pages") + void createZipEntryWritesPages() throws Exception { + try (PDDocument src = new PDDocument()) { + src.addPage(new PDPage(PDRectangle.A4)); + src.addPage(new PDPage(PDRectangle.A4)); + List pages = new ArrayList<>(); + src.getPages().forEach(pages::add); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(baos)) { + controller.createZipEntry(zos, pages, "entry.pdf"); + } + + try (ZipInputStream zis = + new ZipInputStream(new ByteArrayInputStream(baos.toByteArray()))) { + ZipEntry entry = zis.getNextEntry(); + assertEquals("entry.pdf", entry.getName()); + try (PDDocument loaded = Loader.loadPDF(zis.readAllBytes())) { + assertEquals(2, loaded.getNumberOfPages()); + } + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/CompressControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/CompressControllerMoreTest.java new file mode 100644 index 0000000000..b8f4e51b3f --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/CompressControllerMoreTest.java @@ -0,0 +1,758 @@ +package stirling.software.SPDF.controller.api.misc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.junit.jupiter.api.AfterEach; +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.api.io.TempDir; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.model.api.misc.OptimizePdfRequest; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ExceptionUtils; +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +/** + * Additional unit tests for {@link CompressController} covering the Ghostscript / qpdf + * orchestration paths that the base test deliberately skips. Real external binaries are never + * launched: the static {@link ProcessExecutor} instance map is patched via reflection with mocks + * that fake a successful run and write a small valid PDF to the expected output file. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class CompressControllerMoreTest { + + @TempDir Path tempDir; + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private EndpointConfiguration endpointConfiguration; + @Mock private TempFileManager tempFileManager; + + @Mock private ProcessExecutor ghostscriptExecutor; + @Mock private ProcessExecutor qpdfExecutor; + + @InjectMocks private CompressController controller; + + /** Real temp files created during a test; cleaned up after each test. */ + private final List createdFiles = new ArrayList<>(); + + /** Previous occupants of the static instances map, restored in tearDown. */ + private ProcessExecutor previousGhostscript; + + private ProcessExecutor previousQpdf; + private boolean hadGhostscript; + private boolean hadQpdf; + + @BeforeEach + void setUp() throws Exception { + // Both external tool groups enabled so the orchestration paths run. + lenient().when(endpointConfiguration.isGroupEnabled(anyString())).thenReturn(true); + + // Every managed temp file is backed by a real on-disk file wrapped in a mock TempFile. + lenient() + .when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + inv -> { + File f = + Files.createTempFile( + "compress-more-test", + inv.getArgument(0)) + .toFile(); + createdFiles.add(f); + return newRealBackedTempFile(f); + }); + + // The final reload and any image-compression reload return real PDFBox documents. + lenient() + .when(pdfDocumentFactory.load(any(File.class))) + .thenAnswer(inv -> Loader.loadPDF((File) inv.getArgument(0))); + lenient() + .when(pdfDocumentFactory.load(any(Path.class))) + .thenAnswer(inv -> Loader.loadPDF(((Path) inv.getArgument(0)).toFile())); + + installExecutorMocks(); + } + + @AfterEach + void tearDown() throws Exception { + restoreExecutorMocks(); + for (File f : createdFiles) { + try { + Files.deleteIfExists(f.toPath()); + } catch (Exception ignored) { + // best-effort cleanup + } + } + createdFiles.clear(); + } + + // ----- static ProcessExecutor instance-map patching ---------------------------------------- + + @SuppressWarnings("unchecked") + private Map executorInstances() throws Exception { + Field field = ProcessExecutor.class.getDeclaredField("instances"); + field.setAccessible(true); + return (Map) field.get(null); + } + + private void installExecutorMocks() throws Exception { + Map instances = executorInstances(); + + hadGhostscript = instances.containsKey(ProcessExecutor.Processes.GHOSTSCRIPT); + previousGhostscript = instances.get(ProcessExecutor.Processes.GHOSTSCRIPT); + hadQpdf = instances.containsKey(ProcessExecutor.Processes.QPDF); + previousQpdf = instances.get(ProcessExecutor.Processes.QPDF); + + instances.put(ProcessExecutor.Processes.GHOSTSCRIPT, ghostscriptExecutor); + instances.put(ProcessExecutor.Processes.QPDF, qpdfExecutor); + } + + private void restoreExecutorMocks() throws Exception { + Map instances = executorInstances(); + if (hadGhostscript) { + instances.put(ProcessExecutor.Processes.GHOSTSCRIPT, previousGhostscript); + } else { + instances.remove(ProcessExecutor.Processes.GHOSTSCRIPT); + } + if (hadQpdf) { + instances.put(ProcessExecutor.Processes.QPDF, previousQpdf); + } else { + instances.remove(ProcessExecutor.Processes.QPDF); + } + } + + private TempFile newRealBackedTempFile(File f) { + TempFile tf = mock(TempFile.class); + lenient().when(tf.getFile()).thenReturn(f); + lenient().when(tf.getPath()).thenReturn(f.toPath()); + lenient().when(tf.getAbsolutePath()).thenReturn(f.getAbsolutePath()); + lenient().when(tf.exists()).thenReturn(f.exists()); + return tf; + } + + // ----- executor stubbing helpers ------------------------------------------------------------ + + /** + * Build a result mock with the given return code; assigned to a local before any thenReturn. + */ + private ProcessExecutorResult resultWithRc(int rc) { + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + lenient().when(result.getRc()).thenReturn(rc); + lenient().when(result.getMessages()).thenReturn(""); + return result; + } + + // Locate the -sOutputFile= path in a gs command. + private static Path ghostscriptOutputPath(List command) { + for (String arg : command) { + if (arg.startsWith("-sOutputFile=")) { + return Path.of(arg.substring("-sOutputFile=".length())); + } + } + return null; + } + + // The qpdf output path is the last argument of the command. + private static Path qpdfOutputPath(List command) { + return Path.of(command.get(command.size() - 1)); + } + + /** Stub gs to write a valid PDF to its output file and report success. */ + private void stubGhostscriptSuccess(byte[] pdfToWrite) throws Exception { + ProcessExecutorResult okResult = resultWithRc(0); + lenient() + .when(ghostscriptExecutor.runCommandWithOutputHandling(anyList())) + .thenAnswer( + inv -> { + List command = inv.getArgument(0); + Path out = ghostscriptOutputPath(command); + if (out != null) { + Files.write(out, pdfToWrite); + } + return okResult; + }); + } + + /** Stub gs to report a non-zero, non-critical return code (output stays untouched). */ + private void stubGhostscriptNonZero() throws Exception { + ProcessExecutorResult badResult = resultWithRc(1); + lenient() + .when(ghostscriptExecutor.runCommandWithOutputHandling(anyList())) + .thenReturn(badResult); + } + + /** Stub qpdf to write a valid PDF to its output file and report success. */ + private void stubQpdfSuccess(byte[] pdfToWrite) throws Exception { + ProcessExecutorResult okResult = resultWithRc(0); + lenient() + .when(qpdfExecutor.runCommandWithOutputHandling(anyList(), any())) + .thenAnswer( + inv -> { + List command = inv.getArgument(0); + Path out = qpdfOutputPath(command); + if (out != null) { + Files.write(out, pdfToWrite); + } + return okResult; + }); + } + + // ----- tiny in-memory PDF builders --------------------------------------------------------- + + private byte[] textOnlyPdfBytes() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont( + new org.apache.pdfbox.pdmodel.font.PDType1Font( + org.apache.pdfbox.pdmodel.font.Standard14Fonts.FontName.HELVETICA), + 12); + cs.newLineAtOffset(50, 700); + cs.showText("Hello compress more"); + cs.endText(); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + return baos.toByteArray(); + } + } + + /** PDF with one large (>400px) image so the image-compression branch actually resizes it. */ + private byte[] largeImagePdfBytes() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + // >400px so the resize branch fires; filled via Graphics2D (instant vs per-pixel). + java.awt.image.BufferedImage img = + new java.awt.image.BufferedImage( + 500, 500, java.awt.image.BufferedImage.TYPE_INT_RGB); + java.awt.Graphics2D g = img.createGraphics(); + g.setColor(java.awt.Color.LIGHT_GRAY); + g.fillRect(0, 0, 500, 500); + g.setColor(java.awt.Color.DARK_GRAY); + g.fillRect(60, 60, 380, 380); + g.dispose(); + PDImageXObject image = LosslessFactory.createFromImage(doc, img); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.drawImage(image, 50, 50, 400, 400); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + return baos.toByteArray(); + } + } + + private MockMultipartFile multipart(byte[] bytes) { + return new MockMultipartFile( + "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, bytes); + } + + private static byte[] drain(ResponseEntity response) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (java.io.InputStream in = response.getBody().getInputStream()) { + in.transferTo(baos); + } + return baos.toByteArray(); + } + + // =========================================================================================== + // Ghostscript orchestration + // =========================================================================================== + + @Nested + @DisplayName("Ghostscript orchestration") + class Ghostscript { + + @Test + @DisplayName("level 6 runs Ghostscript successfully and returns OK") + void level6_ghostscriptSuccess_returnsOk() throws Exception { + byte[] gsOut = textOnlyPdfBytes(); + stubGhostscriptSuccess(gsOut); + stubQpdfSuccess(textOnlyPdfBytes()); + + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(largeImagePdfBytes())); + request.setOptimizeLevel(6); + + ResponseEntity response = controller.optimizePdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(drain(response)).isNotEmpty(); + } + + @Test + @DisplayName("each optimize level 6-9 with Ghostscript enabled returns OK") + void levels6to9_ghostscriptSuccess_returnsOk() throws Exception { + // Stub once: re-stubbing a mock inside the loop re-invokes the previous answer + // with empty matcher args, tripping qpdfOutputPath's last-element lookup. + stubGhostscriptSuccess(textOnlyPdfBytes()); + stubQpdfSuccess(textOnlyPdfBytes()); + + for (int level = 6; level <= 9; level++) { + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(largeImagePdfBytes())); + request.setOptimizeLevel(level); + + ResponseEntity response = controller.optimizePdf(request); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + } + + @Test + @DisplayName("levels 1-5 skip Ghostscript (never invoked) but still return OK") + void lowLevels_ghostscriptNotInvoked_returnsOk() throws Exception { + stubGhostscriptSuccess(textOnlyPdfBytes()); + stubQpdfSuccess(textOnlyPdfBytes()); + + for (int level = 1; level <= 5; level++) { + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(largeImagePdfBytes())); + request.setOptimizeLevel(level); + + ResponseEntity response = controller.optimizePdf(request); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + // gs only runs for levels >= 6. + org.mockito.Mockito.verify(ghostscriptExecutor, org.mockito.Mockito.never()) + .runCommandWithOutputHandling(anyList()); + } + + @Test + @DisplayName("grayscale flag at level 6 still drives Ghostscript and returns OK") + void grayscale_level6_returnsOk() throws Exception { + stubGhostscriptSuccess(textOnlyPdfBytes()); + stubQpdfSuccess(textOnlyPdfBytes()); + + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(largeImagePdfBytes())); + request.setOptimizeLevel(7); + request.setGrayscale(true); + + ResponseEntity response = controller.optimizePdf(request); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("non-zero Ghostscript exit is propagated as GhostscriptException") + void ghostscriptNonZeroExit_propagates() throws Exception { + // A non-zero gs return code is wrapped and rethrown (see optimizePdf's + // catch (GhostscriptException) -> throw e), not swallowed. + stubGhostscriptNonZero(); + stubQpdfSuccess(textOnlyPdfBytes()); + + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(largeImagePdfBytes())); + request.setOptimizeLevel(6); + + assertThatThrownBy(() -> controller.optimizePdf(request)) + .isInstanceOf(ExceptionUtils.GhostscriptException.class); + } + + @Test + @DisplayName("critical Ghostscript error is propagated as GhostscriptException") + void ghostscriptCriticalError_propagates() throws Exception { + // Output containing a recognized critical marker triggers + // detectGhostscriptCriticalError. + ProcessExecutorResult criticalResult = mock(ProcessExecutorResult.class); + lenient().when(criticalResult.getRc()).thenReturn(0); + lenient() + .when(criticalResult.getMessages()) + .thenReturn("Page 1\nERROR: Could not draw this page"); + lenient() + .when(ghostscriptExecutor.runCommandWithOutputHandling(anyList())) + .thenReturn(criticalResult); + + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(largeImagePdfBytes())); + request.setOptimizeLevel(8); + + assertThatThrownBy(() -> controller.optimizePdf(request)) + .isInstanceOf(ExceptionUtils.GhostscriptException.class); + } + + @Test + @DisplayName("Ghostscript IOException is wrapped and propagated as GhostscriptException") + void ghostscriptIOException_propagates() throws Exception { + // An IOException from the gs executor is wrapped via + // createGhostscriptCompressionException and rethrown, not swallowed. + lenient() + .when(ghostscriptExecutor.runCommandWithOutputHandling(anyList())) + .thenThrow(new IOException("gs boom")); + stubQpdfSuccess(textOnlyPdfBytes()); + + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(largeImagePdfBytes())); + request.setOptimizeLevel(6); + + assertThatThrownBy(() -> controller.optimizePdf(request)) + .isInstanceOf(ExceptionUtils.GhostscriptException.class); + } + } + + // =========================================================================================== + // QPDF orchestration + // =========================================================================================== + + @Nested + @DisplayName("QPDF orchestration") + class Qpdf { + + @Test + @DisplayName("low level with only qpdf enabled recompresses and returns OK") + void lowLevel_qpdfOnly_returnsOk() throws Exception { + // Disable Ghostscript so qpdf is the only external tool that runs. + lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false); + stubQpdfSuccess(textOnlyPdfBytes()); + + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(largeImagePdfBytes())); + request.setOptimizeLevel(2); + + ResponseEntity response = controller.optimizePdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(drain(response)).isNotEmpty(); + } + + @Test + @DisplayName("linearize option drives qpdf --linearize and returns OK") + void linearize_qpdf_returnsOk() throws Exception { + lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false); + + // Capture the qpdf command so we can assert --linearize is present. + List> captured = new ArrayList<>(); + ProcessExecutorResult okResult = resultWithRc(0); + byte[] pdfToWrite = textOnlyPdfBytes(); + lenient() + .when(qpdfExecutor.runCommandWithOutputHandling(anyList(), any())) + .thenAnswer( + inv -> { + List command = inv.getArgument(0); + captured.add(command); + Path out = qpdfOutputPath(command); + Files.write(out, pdfToWrite); + return okResult; + }); + + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(largeImagePdfBytes())); + request.setOptimizeLevel(3); + request.setLinearize(true); + request.setNormalize(true); + + ResponseEntity response = controller.optimizePdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(captured).isNotEmpty(); + assertThat(captured.get(0)).contains("--linearize"); + assertThat(captured.get(0)).contains("--normalize-content=y"); + } + + @Test + @DisplayName("higher level enables qpdf --optimize-images and jpeg quality") + void highLevel_qpdfOptimizeImages_returnsOk() throws Exception { + lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false); + + List> captured = new ArrayList<>(); + ProcessExecutorResult okResult = resultWithRc(0); + byte[] pdfToWrite = textOnlyPdfBytes(); + lenient() + .when(qpdfExecutor.runCommandWithOutputHandling(anyList(), any())) + .thenAnswer( + inv -> { + List command = inv.getArgument(0); + captured.add(command); + Files.write(qpdfOutputPath(command), pdfToWrite); + return okResult; + }); + + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(largeImagePdfBytes())); + request.setOptimizeLevel(5); + + ResponseEntity response = controller.optimizePdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(captured.get(0)).contains("--optimize-images"); + } + + @Test + @DisplayName("qpdf IOException is swallowed; processing still returns OK") + void qpdfIOException_swallowed_returnsOk() throws Exception { + lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false); + lenient() + .when(qpdfExecutor.runCommandWithOutputHandling(anyList(), any())) + .thenThrow(new IOException("qpdf boom")); + + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(largeImagePdfBytes())); + request.setOptimizeLevel(2); + + ResponseEntity response = controller.optimizePdf(request); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("Ghostscript + qpdf both enabled at high level returns OK") + void ghostscriptAndQpdf_highLevel_returnsOk() throws Exception { + stubGhostscriptSuccess(textOnlyPdfBytes()); + stubQpdfSuccess(textOnlyPdfBytes()); + + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(largeImagePdfBytes())); + request.setOptimizeLevel(9); + + ResponseEntity response = controller.optimizePdf(request); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + } + + // =========================================================================================== + // Target-size (auto) mode iterative loop + // =========================================================================================== + + @Nested + @DisplayName("target expected-size (auto) mode") + class AutoMode { + + @Test + @DisplayName("auto mode reaches target on first pass when gs shrinks enough") + void autoMode_targetMetFirstPass_returnsOk() throws Exception { + // gs writes a tiny PDF so the very first size check meets a generous target. + byte[] tiny = textOnlyPdfBytes(); + stubGhostscriptSuccess(tiny); + stubQpdfSuccess(tiny); + + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(largeImagePdfBytes())); + request.setOptimizeLevel(null); + request.setExpectedOutputSize("10MB"); // easily met + + ResponseEntity response = controller.optimizePdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(drain(response)).isNotEmpty(); + } + + @Test + @DisplayName("auto mode escalates the level when target is not met, then terminates") + void autoMode_escalatesLevel_returnsOk() throws Exception { + // gs always writes the same moderate PDF whose size stays just above the target, + // forcing the loop to escalate optimizeLevel from a low start until it caps at 9. + byte[] moderate = largeImagePdfBytes(); + // Target ~25% of input => start level 6 (gs eligible); gs success skips image + // compression so size stays constant and the loop escalates 6 -> 9 (>=2 gs calls). + long target = moderate.length / 4; + final AtomicInteger gsCalls = new AtomicInteger(); + ProcessExecutorResult okResult = resultWithRc(0); + lenient() + .when(ghostscriptExecutor.runCommandWithOutputHandling(anyList())) + .thenAnswer( + inv -> { + gsCalls.incrementAndGet(); + List command = inv.getArgument(0); + Path out = ghostscriptOutputPath(command); + if (out != null) { + Files.write(out, moderate); + } + return okResult; + }); + stubQpdfSuccess(moderate); + + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(moderate)); + request.setOptimizeLevel(null); + request.setExpectedOutputSize(target + "B"); // never met => escalates through levels + + ResponseEntity response = controller.optimizePdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + // Escalation reaches gs-eligible levels (>=6) more than once before bailing at max. + assertThat(gsCalls.get()).isGreaterThan(1); + } + + @Test + @DisplayName("auto mode with qpdf only (low starting level) escalates and returns OK") + void autoMode_qpdfOnly_returnsOk() throws Exception { + lenient().when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false); + byte[] moderate = largeImagePdfBytes(); + stubQpdfSuccess(moderate); + + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(moderate)); + request.setOptimizeLevel(null); + request.setExpectedOutputSize("1KB"); + + ResponseEntity response = controller.optimizePdf(request); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + } + + // =========================================================================================== + // Image compression branches reached via the controller + // =========================================================================================== + + @Nested + @DisplayName("image compression branches") + class ImageCompression { + + @Test + @DisplayName("level 4 with a large image and no gs (level<6) compresses the image") + void level4_largeImage_compresses_returnsOk() throws Exception { + // Disable both external tools so the Java image path is exercised end-to-end. + lenient().when(endpointConfiguration.isGroupEnabled(anyString())).thenReturn(false); + + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(largeImagePdfBytes())); + request.setOptimizeLevel(4); + + ResponseEntity response = controller.optimizePdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(drain(response)).isNotEmpty(); + } + + @Test + @DisplayName("grayscale at low level compresses image via Java path when tools disabled") + void grayscale_lowLevel_javaPath_returnsOk() throws Exception { + lenient().when(endpointConfiguration.isGroupEnabled(anyString())).thenReturn(false); + + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(largeImagePdfBytes())); + request.setOptimizeLevel(2); + request.setGrayscale(true); + + ResponseEntity response = controller.optimizePdf(request); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("compressImagesInPDF scales a large image and grayscale converts it") + void compressImagesInPDF_largeImage_grayscale() throws Exception { + Path src = tempDir.resolve("large.pdf"); + Files.write(src, largeImagePdfBytes()); + + TempFile result = controller.compressImagesInPDF(src, 0.5, 0.6f, true); + + assertThat(result).isNotNull(); + byte[] out = Files.readAllBytes(result.getPath()); + try (PDDocument doc = Loader.loadPDF(out)) { + assertThat(doc.getNumberOfPages()).isEqualTo(1); + } + } + + @Test + @DisplayName("compressImagesInPDF at high quality on a large image still produces a PDF") + void compressImagesInPDF_largeImage_highQuality() throws Exception { + Path src = tempDir.resolve("large2.pdf"); + Files.write(src, largeImagePdfBytes()); + + TempFile result = controller.compressImagesInPDF(src, 0.9, 0.95f, false); + + assertThat(result).isNotNull(); + assertThat(Files.readAllBytes(result.getPath())).isNotEmpty(); + } + } + + // =========================================================================================== + // Result-size guard: optimized output never larger than original + // =========================================================================================== + + @Nested + @DisplayName("output size guard") + class OutputSizeGuard { + + @Test + @DisplayName("when gs output is larger than original, the original is returned") + void gsLargerThanOriginal_usesOriginal_returnsOk() throws Exception { + byte[] original = textOnlyPdfBytes(); + // gs writes a deliberately bloated file so the >= inputFileSize guard trips. + byte[] bloated = largeImagePdfBytes(); + stubGhostscriptSuccess(bloated); + stubQpdfSuccess(bloated); + + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(original)); + request.setOptimizeLevel(6); + + ResponseEntity response = controller.optimizePdf(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(drain(response)).isNotEmpty(); + } + } + + // =========================================================================================== + // Validation still holds with tools enabled + // =========================================================================================== + + @Nested + @DisplayName("validation with tools enabled") + class Validation { + + @Test + @DisplayName("null file still throws IllegalArgumentException even with tools enabled") + void nullFile_throws() { + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(null); + + assertThatThrownBy(() -> controller.optimizePdf(request)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("no optimize options provided throws IllegalArgumentException") + void noOptions_throws() throws Exception { + OptimizePdfRequest request = new OptimizePdfRequest(); + request.setFileInput(multipart(textOnlyPdfBytes())); + request.setOptimizeLevel(null); + request.setExpectedOutputSize(null); + + assertThatThrownBy(() -> controller.optimizePdf(request)) + .isInstanceOf(IllegalArgumentException.class); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerMoreTest.java new file mode 100644 index 0000000000..e62bf4fc85 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/ConfigControllerMoreTest.java @@ -0,0 +1,221 @@ +package stirling.software.SPDF.controller.api.misc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Map; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.context.ApplicationContext; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import jakarta.servlet.http.HttpServletRequest; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.config.ExternalAppDepConfig; +import stirling.software.common.configuration.AppConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.LicenseServiceInterface; +import stirling.software.common.service.ServerCertificateServiceInterface; +import stirling.software.common.service.UserServiceInterface; + +/** + * Exercises getAppConfig and the dynamic license/EE helpers, which the original + * ConfigControllerTest does not cover. Uses a real ApplicationProperties so the full method body + * runs. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("ConfigController extra coverage") +class ConfigControllerMoreTest { + + @Mock private ApplicationContext applicationContext; + @Mock private EndpointConfiguration endpointConfiguration; + @Mock private ServerCertificateServiceInterface serverCertificateService; + @Mock private UserServiceInterface userService; + @Mock private LicenseServiceInterface licenseService; + @Mock private ExternalAppDepConfig externalAppDepConfig; + @Mock private AppConfig appConfig; + @Mock private Environment environment; + + private ApplicationProperties applicationProperties; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + when(appConfig.getBackendUrl()).thenReturn("http://localhost:8080"); + when(appConfig.getContextPath()).thenReturn("/"); + when(appConfig.getServerPort()).thenReturn("8080"); + when(applicationContext.getBean(AppConfig.class)).thenReturn(appConfig); + lenient().when(applicationContext.getEnvironment()).thenReturn(environment); + lenient().when(externalAppDepConfig.isDependenciesChecked()).thenReturn(true); + } + + private ConfigController newController() { + return new ConfigController( + applicationProperties, + applicationContext, + endpointConfiguration, + serverCertificateService, + userService, + licenseService, + externalAppDepConfig); + } + + @SuppressWarnings("unchecked") + private Map bodyOf(ResponseEntity> resp) { + return resp.getBody(); + } + + @Nested + @DisplayName("getAppConfig") + class GetAppConfig { + + @Test + @DisplayName("returns wired config values with all services present") + void returnsConfigWithServices() { + when(licenseService.isRunningProOrHigher()).thenReturn(true); + when(licenseService.isRunningEE()).thenReturn(false); + when(licenseService.getLicenseTypeName()).thenReturn("ENTERPRISE"); + when(userService.isCurrentUserAdmin()).thenReturn(true); + when(userService.isCurrentUserFirstLogin()).thenReturn(false); + when(serverCertificateService.isEnabled()).thenReturn(true); + applicationProperties.getSecurity().setEnableLogin(true); + + HttpServletRequest request = mock(HttpServletRequest.class); + ResponseEntity> resp = newController().getAppConfig(request); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + Map body = bodyOf(resp); + assertThat(body).containsEntry("dependenciesReady", true); + assertThat(body).containsEntry("baseUrl", "http://localhost:8080"); + assertThat(body).containsEntry("serverPort", "8080"); + assertThat(body).containsEntry("enableLogin", true); + assertThat(body).containsEntry("isAdmin", true); + assertThat(body).containsEntry("runningProOrHigher", true); + assertThat(body).containsEntry("license", "ENTERPRISE"); + assertThat(body).containsEntry("serverCertificateEnabled", true); + assertThat(body).containsKey("timestampTsaPresets"); + } + + @Test + @DisplayName("login disabled when userService is null (proprietary not loaded)") + void loginDisabledWhenNoUserService() { + userService = null; + licenseService = null; + serverCertificateService = null; + applicationProperties.getSecurity().setEnableLogin(true); + + HttpServletRequest request = mock(HttpServletRequest.class); + ResponseEntity> resp = newController().getAppConfig(request); + + Map body = bodyOf(resp); + assertThat(body).containsEntry("enableLogin", false); + assertThat(body).containsEntry("isAdmin", false); + assertThat(body).containsEntry("isNewUser", false); + assertThat(body).containsEntry("serverCertificateEnabled", false); + } + + @Test + @DisplayName("falls back to context beans when license service is null") + void licenseFallsBackToContextBeans() { + licenseService = null; + when(applicationContext.containsBean("runningProOrHigher")).thenReturn(true); + when(applicationContext.getBean("runningProOrHigher", Boolean.class)).thenReturn(true); + when(applicationContext.containsBean("runningEE")).thenReturn(true); + when(applicationContext.getBean("runningEE", Boolean.class)).thenReturn(true); + when(applicationContext.containsBean("license")).thenReturn(true); + when(applicationContext.getBean("license", String.class)).thenReturn("SERVER"); + when(applicationContext.containsBean("SSOAutoLogin")).thenReturn(true); + when(applicationContext.getBean("SSOAutoLogin", Boolean.class)).thenReturn(true); + + HttpServletRequest request = mock(HttpServletRequest.class); + ResponseEntity> resp = newController().getAppConfig(request); + + Map body = bodyOf(resp); + assertThat(body).containsEntry("runningProOrHigher", true); + assertThat(body).containsEntry("runningEE", true); + assertThat(body).containsEntry("license", "SERVER"); + assertThat(body).containsEntry("SSOAutoLogin", true); + } + + @Test + @DisplayName("includes Google Drive backend settings when enabled") + void googleDriveEnabled() { + ApplicationProperties.Premium.ProFeatures.GoogleDrive gd = + applicationProperties.getPremium().getProFeatures().getGoogleDrive(); + gd.setEnabled(true); + gd.setClientId("cid"); + gd.setApiKey("key"); + gd.setAppId("aid"); + + HttpServletRequest request = mock(HttpServletRequest.class); + ResponseEntity> resp = newController().getAppConfig(request); + + Map body = bodyOf(resp); + assertThat(body).containsEntry("googleDriveEnabled", true); + assertThat(body).containsEntry("googleDriveClientId", "cid"); + assertThat(body).containsEntry("googleDriveApiKey", "key"); + } + + @Test + @DisplayName("includes version/machine info beans when available") + void versionAndMachineBeans() { + when(applicationContext.containsBean("appVersion")).thenReturn(true); + when(applicationContext.getBean("appVersion", String.class)).thenReturn("9.9.9"); + when(applicationContext.containsBean("machineType")).thenReturn(true); + when(applicationContext.getBean("machineType", String.class)).thenReturn("Docker"); + when(applicationContext.containsBean("activeSecurity")).thenReturn(true); + when(applicationContext.getBean("activeSecurity", Boolean.class)).thenReturn(true); + + HttpServletRequest request = mock(HttpServletRequest.class); + ResponseEntity> resp = newController().getAppConfig(request); + + Map body = bodyOf(resp); + assertThat(body).containsEntry("appVersion", "9.9.9"); + assertThat(body).containsEntry("machineType", "Docker"); + assertThat(body).containsEntry("activeSecurity", true); + } + + @Test + @DisplayName("isCurrentUserAdmin exception leaves isAdmin false") + void adminCheckExceptionSwallowed() { + when(userService.isCurrentUserAdmin()).thenThrow(new RuntimeException("boom")); + when(userService.isCurrentUserFirstLogin()).thenThrow(new RuntimeException("boom")); + applicationProperties.getSecurity().setEnableLogin(true); + + HttpServletRequest request = mock(HttpServletRequest.class); + ResponseEntity> resp = newController().getAppConfig(request); + + Map body = bodyOf(resp); + assertThat(body).containsEntry("isAdmin", false); + assertThat(body).containsEntry("isNewUser", false); + } + + @Test + @DisplayName("returns basic config with error key when AppConfig bean lookup fails") + void returnsErrorConfigOnException() { + when(applicationContext.getBean(AppConfig.class)) + .thenThrow(new RuntimeException("no bean")); + + HttpServletRequest request = mock(HttpServletRequest.class); + ResponseEntity> resp = newController().getAppConfig(request); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + Map body = bodyOf(resp); + assertThat(body).containsEntry("error", "Unable to retrieve full configuration"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/OCRControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/OCRControllerMoreTest.java new file mode 100644 index 0000000000..09b1bb833f --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/OCRControllerMoreTest.java @@ -0,0 +1,512 @@ +package stirling.software.SPDF.controller.api.misc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +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.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.model.api.misc.ProcessPdfWithOcrRequest; +import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; +import stirling.software.common.util.ProcessExecutor.Processes; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; +import stirling.software.common.util.WebResponseUtils; + +/** + * Additional coverage for {@link OCRController} that exercises the OCRmyPDF and Tesseract command + * paths. The external ocrmypdf/tesseract/ghostscript boundary is mocked via a static stub of {@link + * ProcessExecutor} so the full command-building and post-processing logic runs without launching + * any real process. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class OCRControllerMoreTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private EndpointConfiguration endpointConfiguration; + @Mock private RuntimePathConfig runtimePathConfig; + + private TempFileManager tempFileManager; + private ApplicationProperties applicationProperties; + private OCRController ocrController; + + @TempDir Path baseTmpDir; + + @BeforeEach + void setUp() throws IOException { + applicationProperties = new ApplicationProperties(); + applicationProperties + .getSystem() + .getTempFileManagement() + .setBaseTmpDir(baseTmpDir.toString()); + applicationProperties.getSystem().getTempFileManagement().setPrefix("ocr-more-"); + applicationProperties.getSystem().setMaxDPI(72); + + tempFileManager = new TempFileManager(new TempFileRegistry(), applicationProperties); + + // A real ocrmypdf binary path that exists; ProcessExecutor is mocked so it is never run. + Path fakeBinary = Files.createTempFile(baseTmpDir, "ocrmypdf", ".bin"); + lenient().when(runtimePathConfig.getOcrMyPdfPath()).thenReturn(fakeBinary.toString()); + + ocrController = + new OCRController( + applicationProperties, + pdfDocumentFactory, + tempFileManager, + endpointConfiguration, + runtimePathConfig); + } + + /** Build a tiny single-page in-memory PDF as a MockMultipartFile. */ + private MockMultipartFile pdfMultipartFile(String name) throws IOException { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + doc.addPage(new PDPage()); + doc.save(out); + return new MockMultipartFile( + "fileInput", name, MediaType.APPLICATION_PDF_VALUE, out.toByteArray()); + } + } + + /** Create a tessdata directory populated with the given traineddata languages. */ + private Path tessdataDirWith(String... languages) throws IOException { + Path dir = Files.createTempDirectory(baseTmpDir, "tessdata"); + for (String lang : languages) { + Files.createFile(dir.resolve(lang + ".traineddata")); + } + return dir; + } + + /** Build a request with eng available and sensible OCR defaults the caller can override. */ + private ProcessPdfWithOcrRequest baseRequest(String filename) throws IOException { + ProcessPdfWithOcrRequest request = new ProcessPdfWithOcrRequest(); + request.setLanguages(List.of("eng")); + request.setOcrRenderType("hocr"); + request.setOcrType("skip-text"); + request.setFileInput(pdfMultipartFile(filename)); + return request; + } + + private void availLanguages(String... langs) throws IOException { + Path tessdata = tessdataDirWith(langs); + when(runtimePathConfig.getTessDataPath()).thenReturn(tessdata.toString()); + } + + private static ResponseEntity cannedResponse() { + return ResponseEntity.ok(new ByteArrayResource("ok".getBytes())); + } + + /** A mocked ProcessExecutor whose runCommandWithOutputHandling returns the given rc. */ + private ProcessExecutor executorReturning(int rc, String messages) throws Exception { + ProcessExecutor executor = mock(ProcessExecutor.class); + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + lenient().when(result.getRc()).thenReturn(rc); + lenient().when(result.getMessages()).thenReturn(messages == null ? "" : messages); + lenient().when(executor.runCommandWithOutputHandling(anyList())).thenReturn(result); + return executor; + } + + @Nested + @DisplayName("OCRmyPDF command path (mocked process)") + class OcrMyPdfPath { + + @Test + @DisplayName("succeeds and returns a PDF response on rc=0") + void ocrMyPdfSuccess() throws Exception { + availLanguages("eng"); + when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(true); + ProcessPdfWithOcrRequest request = baseRequest("in.pdf"); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class); + MockedStatic wr = + Mockito.mockStatic(WebResponseUtils.class)) { + ProcessExecutor executor = executorReturning(0, "done"); + pe.when(() -> ProcessExecutor.getInstance(Processes.OCR_MY_PDF)) + .thenReturn(executor); + wr.when(() -> WebResponseUtils.pdfFileToWebResponse(any(), anyString())) + .thenReturn(cannedResponse()); + + ResponseEntity response = ocrController.processPdfWithOCR(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + wr.verify(() -> WebResponseUtils.pdfFileToWebResponse(any(), anyString())); + } + } + + @Test + @DisplayName("builds command with deskew/clean/cleanFinal/force-ocr/sidecar flags") + void ocrMyPdfCommandFlags() throws Exception { + availLanguages("eng", "deu"); + when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(true); + + ProcessPdfWithOcrRequest request = baseRequest("in.pdf"); + request.setLanguages(List.of("eng", "deu")); + request.setDeskew(true); + request.setClean(true); + request.setCleanFinal(true); + request.setOcrType("force-ocr"); + request.setOcrRenderType("sandwich"); + request.setSidecar(true); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class); + MockedStatic wr = + Mockito.mockStatic(WebResponseUtils.class)) { + ProcessExecutor executor = mock(ProcessExecutor.class); + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + when(result.getRc()).thenReturn(0); + + @SuppressWarnings("unchecked") + ArgumentCaptor> cmd = ArgumentCaptor.forClass(List.class); + when(executor.runCommandWithOutputHandling(cmd.capture())).thenReturn(result); + pe.when(() -> ProcessExecutor.getInstance(Processes.OCR_MY_PDF)) + .thenReturn(executor); + wr.when( + () -> + WebResponseUtils.fileToWebResponse( + any(), anyString(), any(MediaType.class))) + .thenReturn(cannedResponse()); + + ocrController.processPdfWithOCR(request); + + List command = cmd.getValue(); + assertThat(command).contains("--deskew", "--clean", "--clean-final", "--force-ocr"); + assertThat(command).contains("--sidecar"); + assertThat(command).contains("--pdf-renderer", "sandwich"); + assertThat(command).contains("--language", "eng+deu"); + assertThat(command).contains("--invalidate-digital-signatures"); + } + } + + @Test + @DisplayName("uses --skip-text for the Normal ocrType branch") + void ocrMyPdfSkipTextBranch() throws Exception { + availLanguages("eng"); + when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(true); + + ProcessPdfWithOcrRequest request = baseRequest("in.pdf"); + request.setOcrType("Normal"); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class); + MockedStatic wr = + Mockito.mockStatic(WebResponseUtils.class)) { + ProcessExecutor executor = mock(ProcessExecutor.class); + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + when(result.getRc()).thenReturn(0); + + @SuppressWarnings("unchecked") + ArgumentCaptor> cmd = ArgumentCaptor.forClass(List.class); + when(executor.runCommandWithOutputHandling(cmd.capture())).thenReturn(result); + pe.when(() -> ProcessExecutor.getInstance(Processes.OCR_MY_PDF)) + .thenReturn(executor); + wr.when(() -> WebResponseUtils.pdfFileToWebResponse(any(), anyString())) + .thenReturn(cannedResponse()); + + ocrController.processPdfWithOCR(request); + + assertThat(cmd.getValue()).contains("--skip-text"); + assertThat(cmd.getValue()).doesNotContain("--force-ocr"); + } + } + + @Test + @DisplayName("sidecar produces a zip response containing pdf and txt") + void ocrMyPdfSidecarZip() throws Exception { + availLanguages("eng"); + when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(true); + + ProcessPdfWithOcrRequest request = baseRequest("scan.pdf"); + request.setSidecar(true); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + ProcessExecutor executor = executorReturning(0, "ok"); + pe.when(() -> ProcessExecutor.getInstance(Processes.OCR_MY_PDF)) + .thenReturn(executor); + + // No WebResponseUtils stub: the real zip-building path runs against real temp + // files and streams the resulting zip. + ResponseEntity response = ocrController.processPdfWithOCR(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().getContentDisposition().getFilename()) + .endsWith("_OCR.zip"); + } + } + + @Test + @DisplayName("retries with --jobs 1 on the multiprocessing OSError and then succeeds") + void ocrMyPdfRetriesOnMultiprocessingError() throws Exception { + availLanguages("eng"); + when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(true); + ProcessPdfWithOcrRequest request = baseRequest("in.pdf"); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class); + MockedStatic wr = + Mockito.mockStatic(WebResponseUtils.class)) { + ProcessExecutor executor = mock(ProcessExecutor.class); + + ProcessExecutorResult failure = mock(ProcessExecutorResult.class); + when(failure.getRc()).thenReturn(1); + when(failure.getMessages()) + .thenReturn( + "multiprocessing/synchronize.py OSError: [Errno 38] Function not" + + " implemented"); + ProcessExecutorResult success = mock(ProcessExecutorResult.class); + when(success.getRc()).thenReturn(0); + + when(executor.runCommandWithOutputHandling(anyList())) + .thenReturn(failure) + .thenReturn(success); + pe.when(() -> ProcessExecutor.getInstance(Processes.OCR_MY_PDF)) + .thenReturn(executor); + wr.when(() -> WebResponseUtils.pdfFileToWebResponse(any(), anyString())) + .thenReturn(cannedResponse()); + + ResponseEntity response = ocrController.processPdfWithOCR(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(executor, times(2)).runCommandWithOutputHandling(anyList()); + } + } + + @Test + @DisplayName("throws when ocrmypdf exits non-zero without the retriable error") + void ocrMyPdfFailureThrows() throws Exception { + availLanguages("eng"); + when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(true); + ProcessPdfWithOcrRequest request = baseRequest("in.pdf"); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + ProcessExecutor executor = executorReturning(5, "boom"); + pe.when(() -> ProcessExecutor.getInstance(Processes.OCR_MY_PDF)) + .thenReturn(executor); + + assertThatThrownBy(() -> ocrController.processPdfWithOCR(request)) + .isInstanceOf(IOException.class); + verify(executor, times(1)).runCommandWithOutputHandling(anyList()); + } + } + + @Test + @DisplayName("propagates a process timeout as IOException") + void ocrMyPdfTimeoutPropagates() throws Exception { + availLanguages("eng"); + when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(true); + ProcessPdfWithOcrRequest request = baseRequest("in.pdf"); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + ProcessExecutor executor = mock(ProcessExecutor.class); + when(executor.runCommandWithOutputHandling(anyList())) + .thenThrow(new IOException("Process timeout exceeded.")); + pe.when(() -> ProcessExecutor.getInstance(Processes.OCR_MY_PDF)) + .thenReturn(executor); + + assertThatThrownBy(() -> ocrController.processPdfWithOCR(request)) + .isInstanceOf(IOException.class) + .hasMessageContaining("timeout"); + } + } + + @Test + @DisplayName("removeImagesAfter runs ghostscript to strip images then returns success") + void ocrMyPdfRemoveImagesAfter() throws Exception { + availLanguages("eng"); + when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(true); + + ProcessPdfWithOcrRequest request = baseRequest("in.pdf"); + request.setRemoveImagesAfter(true); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class); + MockedStatic wr = + Mockito.mockStatic(WebResponseUtils.class)) { + ProcessExecutor ocrExecutor = executorReturning(0, "ok"); + ProcessExecutor gsExecutor = mock(ProcessExecutor.class); + ProcessExecutorResult gsResult = mock(ProcessExecutorResult.class); + when(gsResult.getRc()).thenReturn(0); + // Ghostscript writes the no-images output the controller copies back. + when(gsExecutor.runCommandWithOutputHandling(anyList())) + .thenAnswer( + inv -> { + List cmd = inv.getArgument(0); + // gs command form: gs -sDEVICE=pdfwrite -dFILTERIMAGE -o out in + Path out = Path.of(cmd.get(4)); + Files.writeString(out, "no-images-pdf"); + return gsResult; + }); + + pe.when(() -> ProcessExecutor.getInstance(Processes.OCR_MY_PDF)) + .thenReturn(ocrExecutor); + pe.when(() -> ProcessExecutor.getInstance(Processes.GHOSTSCRIPT)) + .thenReturn(gsExecutor); + wr.when(() -> WebResponseUtils.pdfFileToWebResponse(any(), anyString())) + .thenReturn(cannedResponse()); + + ResponseEntity response = ocrController.processPdfWithOCR(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(gsExecutor).runCommandWithOutputHandling(anyList()); + } + } + } + + @Nested + @DisplayName("Tesseract command path (mocked process)") + class TesseractPath { + + @Test + @DisplayName("falls back to tesseract when OCRmyPDF disabled and merges pages") + void tesseractSuccess() throws Exception { + availLanguages("eng"); + when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(false); + when(endpointConfiguration.isGroupEnabled("tesseract")).thenReturn(true); + + ProcessPdfWithOcrRequest request = baseRequest("in.pdf"); + request.setOcrType("force-ocr"); + // Controller loads via the factory; return a freshly built single-page document. + when(pdfDocumentFactory.load(any(java.io.File.class))) + .thenAnswer(inv -> singlePageDoc()); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class); + MockedStatic wr = + Mockito.mockStatic(WebResponseUtils.class)) { + // Tesseract is mocked and writes no output file, so the controller takes its + // blank-page fallback and saves the original page; rc=0 keeps it on the happy path. + ProcessExecutor executor = executorReturning(0, "ok"); + pe.when(() -> ProcessExecutor.getInstance(Processes.TESSERACT)) + .thenReturn(executor); + wr.when(() -> WebResponseUtils.pdfFileToWebResponse(any(), anyString())) + .thenReturn(cannedResponse()); + + ResponseEntity response = ocrController.processPdfWithOCR(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(executor).runCommandWithOutputHandling(anyList()); + } + } + + @Test + @DisplayName("skip-text on a text-free page still OCRs the page") + void tesseractSkipTextBranch() throws Exception { + availLanguages("eng"); + when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(false); + when(endpointConfiguration.isGroupEnabled("tesseract")).thenReturn(true); + + ProcessPdfWithOcrRequest request = baseRequest("in.pdf"); + request.setOcrType("skip-text"); + when(pdfDocumentFactory.load(any(java.io.File.class))) + .thenAnswer(inv -> singlePageDoc()); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class); + MockedStatic wr = + Mockito.mockStatic(WebResponseUtils.class)) { + ProcessExecutor executor = executorReturning(0, "ok"); + pe.when(() -> ProcessExecutor.getInstance(Processes.TESSERACT)) + .thenReturn(executor); + wr.when(() -> WebResponseUtils.pdfFileToWebResponse(any(), anyString())) + .thenReturn(cannedResponse()); + + ResponseEntity response = ocrController.processPdfWithOCR(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(executor).runCommandWithOutputHandling(anyList()); + } + } + + @Test + @DisplayName("throws when tesseract exits non-zero") + void tesseractFailureThrows() throws Exception { + availLanguages("eng"); + when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(false); + when(endpointConfiguration.isGroupEnabled("tesseract")).thenReturn(true); + + ProcessPdfWithOcrRequest request = baseRequest("in.pdf"); + request.setOcrType("force-ocr"); + when(pdfDocumentFactory.load(any(java.io.File.class))) + .thenAnswer(inv -> singlePageDoc()); + + try (MockedStatic pe = Mockito.mockStatic(ProcessExecutor.class)) { + ProcessExecutor executor = executorReturning(2, "tess-error"); + pe.when(() -> ProcessExecutor.getInstance(Processes.TESSERACT)) + .thenReturn(executor); + + assertThatThrownBy(() -> ocrController.processPdfWithOCR(request)) + .isInstanceOf(RuntimeException.class); + } + } + + private PDDocument singlePageDoc() throws IOException { + PDDocument doc = new PDDocument(); + doc.addPage(new PDPage()); + return doc; + } + } + + @Nested + @DisplayName("validation and tool-availability") + class Validation { + + @Test + @DisplayName("throws when render type is neither hocr nor sandwich") + void invalidRenderType() throws Exception { + ProcessPdfWithOcrRequest request = baseRequest("in.pdf"); + request.setOcrRenderType("bogus"); + + assertThatThrownBy(() -> ocrController.processPdfWithOCR(request)) + .isInstanceOf(IOException.class); + verify(runtimePathConfig, never()).getTessDataPath(); + } + + @Test + @DisplayName("throws when both OCR tools are disabled even with valid languages") + void noToolsAvailable() throws Exception { + availLanguages("eng"); + when(endpointConfiguration.isGroupEnabled("OCRmyPDF")).thenReturn(false); + when(endpointConfiguration.isGroupEnabled("tesseract")).thenReturn(false); + ProcessPdfWithOcrRequest request = baseRequest("in.pdf"); + + assertThatThrownBy(() -> ocrController.processPdfWithOCR(request)) + .isInstanceOf(IOException.class); + verify(endpointConfiguration).isGroupEnabled("OCRmyPDF"); + verify(endpointConfiguration).isGroupEnabled("tesseract"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/PrintFileControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/PrintFileControllerMoreTest.java new file mode 100644 index 0000000000..a4d5070679 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/PrintFileControllerMoreTest.java @@ -0,0 +1,260 @@ +package stirling.software.SPDF.controller.api.misc; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.awt.image.BufferedImage; +import java.awt.print.PrinterException; +import java.awt.print.PrinterJob; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import javax.imageio.ImageIO; +import javax.print.PrintService; +import javax.print.PrintServiceLookup; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +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.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.api.misc.PrintFileRequest; + +/** + * Additional tests for {@link PrintFileController}. The printing boundary is fully mocked: {@link + * PrintServiceLookup} returns a fake printer and {@link PrinterJob#getPrinterJob()} returns a mock + * whose {@code print()} is a no-op (or throws on demand). No physical printer is ever touched. + */ +@ExtendWith(MockitoExtension.class) +class PrintFileControllerMoreTest { + + private final PrintFileController controller = new PrintFileController(); + + private static byte[] smallPdf() throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + document.addPage(new PDPage(PDRectangle.A4)); + document.save(baos); + return baos.toByteArray(); + } + } + + private static byte[] smallPng() throws IOException { + BufferedImage image = new BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "png", baos); + return baos.toByteArray(); + } + + private static PrintService printerNamed(String name) { + PrintService service = mock(PrintService.class); + when(service.getName()).thenReturn(name); + return service; + } + + private static PrintFileRequest request(MockMultipartFile file, String printerName) { + PrintFileRequest request = new PrintFileRequest(); + request.setFileInput(file); + request.setPrinterName(printerName); + return request; + } + + @Nested + @DisplayName("PDF printing") + class PdfPrinting { + + @Test + @DisplayName("PDF to a matching printer returns 200 and invokes job.print()") + void pdfPrintSuccess() throws Exception { + MockMultipartFile file = + new MockMultipartFile( + "fileInput", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, smallPdf()); + + PrintService service = printerNamed("Mock Office Printer"); + PrintService[] services = {service}; + PrinterJob job = mock(PrinterJob.class); + doNothing().when(job).print(); + + try (MockedStatic lookup = mockStatic(PrintServiceLookup.class); + MockedStatic printerJob = mockStatic(PrinterJob.class)) { + lookup.when(() -> PrintServiceLookup.lookupPrintServices(isNull(), isNull())) + .thenReturn(services); + printerJob.when(PrinterJob::getPrinterJob).thenReturn(job); + + ResponseEntity response = controller.printFile(request(file, "office")); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(response.getBody().contains("Mock Office Printer")); + verify(job, atLeastOnce()).print(); + } + } + + @Test + @DisplayName("PrinterException during PDF print yields 400 with the error message") + void pdfPrintErrorReturnsBadRequest() throws Exception { + MockMultipartFile file = + new MockMultipartFile( + "fileInput", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, smallPdf()); + + PrintService service = printerNamed("Mock Office Printer"); + PrintService[] services = {service}; + PrinterJob job = mock(PrinterJob.class); + doThrow(new PrinterException("paper jam")).when(job).print(); + + try (MockedStatic lookup = mockStatic(PrintServiceLookup.class); + MockedStatic printerJob = mockStatic(PrinterJob.class)) { + lookup.when(() -> PrintServiceLookup.lookupPrintServices(isNull(), isNull())) + .thenReturn(services); + printerJob.when(PrinterJob::getPrinterJob).thenReturn(job); + + ResponseEntity response = controller.printFile(request(file, "office")); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertTrue(response.getBody().contains("paper jam")); + } + } + } + + @Nested + @DisplayName("Image printing") + class ImagePrinting { + + @Test + @DisplayName("PNG to a matching printer returns 200 and invokes job.print()") + void imagePrintSuccess() throws Exception { + MockMultipartFile file = + new MockMultipartFile("fileInput", "pic.png", "image/png", smallPng()); + + PrintService service = printerNamed("Photo Printer"); + PrintService[] services = {service}; + PrinterJob job = mock(PrinterJob.class); + doNothing().when(job).print(); + + try (MockedStatic lookup = mockStatic(PrintServiceLookup.class); + MockedStatic printerJob = mockStatic(PrinterJob.class)) { + lookup.when(() -> PrintServiceLookup.lookupPrintServices(isNull(), isNull())) + .thenReturn(services); + printerJob.when(PrinterJob::getPrinterJob).thenReturn(job); + + ResponseEntity response = controller.printFile(request(file, "photo")); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(response.getBody().contains("Photo Printer")); + verify(job, atLeastOnce()).print(); + } + } + } + + @Nested + @DisplayName("Printer matching") + class PrinterMatching { + + @Test + @DisplayName("no matching printer returns 400 with 'No matching printer'") + void noMatchingPrinterReturnsBadRequest() throws Exception { + MockMultipartFile file = + new MockMultipartFile( + "fileInput", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, smallPdf()); + + PrintService service = printerNamed("Some Other Printer"); + PrintService[] services = {service}; + + try (MockedStatic lookup = mockStatic(PrintServiceLookup.class)) { + lookup.when(() -> PrintServiceLookup.lookupPrintServices(isNull(), isNull())) + .thenReturn(services); + + ResponseEntity response = + controller.printFile(request(file, "nonexistent")); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertTrue(response.getBody().contains("No matching printer")); + } + } + + @Test + @DisplayName("printer match is case-insensitive and substring based") + void printerMatchCaseInsensitive() throws Exception { + MockMultipartFile file = + new MockMultipartFile( + "fileInput", "doc.pdf", MediaType.APPLICATION_PDF_VALUE, smallPdf()); + + PrintService service = printerNamed("HP LaserJet 4000"); + PrintService[] services = {service}; + PrinterJob job = mock(PrinterJob.class); + doNothing().when(job).print(); + + try (MockedStatic lookup = mockStatic(PrintServiceLookup.class); + MockedStatic printerJob = mockStatic(PrinterJob.class)) { + lookup.when(() -> PrintServiceLookup.lookupPrintServices(isNull(), isNull())) + .thenReturn(services); + printerJob.when(PrinterJob::getPrinterJob).thenReturn(job); + + ResponseEntity response = controller.printFile(request(file, "laserjet")); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(response.getBody().contains("HP LaserJet 4000")); + } + } + } + + @Nested + @DisplayName("Content-type handling") + class ContentTypeHandling { + + @Test + @DisplayName("unsupported content type still returns 200 without printing") + void unsupportedContentTypeNoPrint() throws Exception { + // Neither application/pdf nor image/* -> neither print branch runs. + MockMultipartFile file = + new MockMultipartFile( + "fileInput", "data.bin", "application/octet-stream", "x".getBytes()); + + PrintService service = printerNamed("Generic Printer"); + PrintService[] services = {service}; + + try (MockedStatic lookup = mockStatic(PrintServiceLookup.class)) { + lookup.when(() -> PrintServiceLookup.lookupPrintServices(isNull(), isNull())) + .thenReturn(services); + + ResponseEntity response = controller.printFile(request(file, "generic")); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(response.getBody().contains("Generic Printer")); + } + } + } + + @Nested + @DisplayName("Path validation") + class PathValidation { + + @Test + @DisplayName("path traversal in filename throws before any printer lookup") + void pathTraversalThrows() { + MockMultipartFile file = + new MockMultipartFile( + "fileInput", + "../../secret.pdf", + MediaType.APPLICATION_PDF_VALUE, + "data".getBytes()); + + assertThrows(Exception.class, () -> controller.printFile(request(file, "any"))); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RepairControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RepairControllerMoreTest.java new file mode 100644 index 0000000000..6565a84875 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/RepairControllerMoreTest.java @@ -0,0 +1,370 @@ +package stirling.software.SPDF.controller.api.misc; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +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.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.api.PDFFile; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; + +/** + * Additional tests for {@link RepairController} covering the external-tool branches (Ghostscript + * and qpdf). Those branches shell out via the static {@link ProcessExecutor} factory; here that + * factory is mocked with {@code mockStatic} so no real binary runs. The mocked command-runner + * writes a valid PDF to the output path so the file-backed response can be streamed back. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class RepairControllerMoreTest { + + @org.mockito.Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @org.mockito.Mock private EndpointConfiguration endpointConfiguration; + + private TempFileManager tempFileManager; + private RepairController repairController; + + @BeforeEach + void setUp() { + tempFileManager = new TempFileManager(new TempFileRegistry(), new ApplicationProperties()); + repairController = + new RepairController(pdfDocumentFactory, tempFileManager, endpointConfiguration); + } + + private static byte[] buildPdfBytes(int pageCount) throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + for (int i = 0; i < pageCount; i++) { + document.addPage(new PDPage(PDRectangle.A4)); + } + document.save(baos); + return baos.toByteArray(); + } + } + + private static PDFFile pdfFileFrom(MockMultipartFile multipartFile) { + PDFFile pdfFile = new PDFFile(); + pdfFile.setFileInput(multipartFile); + return pdfFile; + } + + private static MockMultipartFile inputPdf(int pages) throws IOException { + return new MockMultipartFile( + "fileInput", "broken.pdf", MediaType.APPLICATION_PDF_VALUE, buildPdfBytes(pages)); + } + + private static byte[] readResource(Resource resource) throws IOException { + try (InputStream in = resource.getInputStream(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + in.transferTo(baos); + return baos.toByteArray(); + } + } + + /** + * Writes a valid PDF to the path at the given command index, mimicking a successful tool run. + */ + private static void writeValidPdfTo(List command, int outputPathIndex) + throws Exception { + Path out = Path.of(command.get(outputPathIndex)); + byte[] pdf = buildPdfBytes(1); + Files.write(out, pdf); + } + + private ProcessExecutorResult resultWithRc(int rc) { + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + when(result.getRc()).thenReturn(rc); + return result; + } + + @Nested + @DisplayName("Ghostscript primary branch") + class GhostscriptBranch { + + @Test + @DisplayName("Ghostscript success returns 200 and does not invoke qpdf or PDFBox") + void ghostscriptSuccess() throws Exception { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true); + when(endpointConfiguration.isGroupEnabled("qpdf")).thenReturn(true); + + try (MockedStatic mockedFactory = mockStatic(ProcessExecutor.class)) { + ProcessExecutor gsExecutor = mock(ProcessExecutor.class); + ProcessExecutorResult okResult = resultWithRc(0); + + // gs command output path is element index 2 ("gs", "-o", , ...) + when(gsExecutor.runCommandWithOutputHandling(any())) + .thenAnswer( + inv -> { + List cmd = inv.getArgument(0); + writeValidPdfTo(cmd, 2); + return okResult; + }); + + mockedFactory + .when( + () -> + ProcessExecutor.getInstance( + ProcessExecutor.Processes.GHOSTSCRIPT)) + .thenReturn(gsExecutor); + + ResponseEntity response = + repairController.repairPdf(pdfFileFrom(inputPdf(1))); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(readResource(response.getBody()).length > 0); + + // qpdf must not be consulted once Ghostscript succeeds. + mockedFactory.verify( + () -> ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF), never()); + // PDFBox last-resort load must not happen either. + verify(pdfDocumentFactory, never()).load(any(File.class)); + } + } + + @Test + @DisplayName("Ghostscript non-zero rc falls back to qpdf which produces output") + void ghostscriptNonZeroFallsBackToQpdf() throws Exception { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true); + when(endpointConfiguration.isGroupEnabled("qpdf")).thenReturn(true); + + try (MockedStatic mockedFactory = mockStatic(ProcessExecutor.class)) { + ProcessExecutor gsExecutor = mock(ProcessExecutor.class); + ProcessExecutorResult failResult = resultWithRc(1); + when(gsExecutor.runCommandWithOutputHandling(any())).thenReturn(failResult); + + ProcessExecutor qpdfExecutor = mock(ProcessExecutor.class); + ProcessExecutorResult okResult = resultWithRc(0); + // qpdf command output path is the last element. + when(qpdfExecutor.runCommandWithOutputHandling(any())) + .thenAnswer( + inv -> { + List cmd = inv.getArgument(0); + writeValidPdfTo(cmd, cmd.size() - 1); + return okResult; + }); + + mockedFactory + .when( + () -> + ProcessExecutor.getInstance( + ProcessExecutor.Processes.GHOSTSCRIPT)) + .thenReturn(gsExecutor); + mockedFactory + .when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF)) + .thenReturn(qpdfExecutor); + + ResponseEntity response = + repairController.repairPdf(pdfFileFrom(inputPdf(1))); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + verify(qpdfExecutor, times(1)).runCommandWithOutputHandling(any()); + verify(pdfDocumentFactory, never()).load(any(File.class)); + } + } + + @Test + @DisplayName("Ghostscript throwing is caught and qpdf fallback still succeeds") + void ghostscriptThrowsFallsBackToQpdf() throws Exception { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true); + when(endpointConfiguration.isGroupEnabled("qpdf")).thenReturn(true); + + try (MockedStatic mockedFactory = mockStatic(ProcessExecutor.class)) { + ProcessExecutor gsExecutor = mock(ProcessExecutor.class); + when(gsExecutor.runCommandWithOutputHandling(any())) + .thenThrow(new IOException("gs binary not found")); + + ProcessExecutor qpdfExecutor = mock(ProcessExecutor.class); + ProcessExecutorResult okResult = resultWithRc(0); + when(qpdfExecutor.runCommandWithOutputHandling(any())) + .thenAnswer( + inv -> { + List cmd = inv.getArgument(0); + writeValidPdfTo(cmd, cmd.size() - 1); + return okResult; + }); + + mockedFactory + .when( + () -> + ProcessExecutor.getInstance( + ProcessExecutor.Processes.GHOSTSCRIPT)) + .thenReturn(gsExecutor); + mockedFactory + .when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF)) + .thenReturn(qpdfExecutor); + + ResponseEntity response = + repairController.repairPdf(pdfFileFrom(inputPdf(1))); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + verify(qpdfExecutor, times(1)).runCommandWithOutputHandling(any()); + } + } + } + + @Nested + @DisplayName("qpdf-only branch (Ghostscript disabled)") + class QpdfOnlyBranch { + + @Test + @DisplayName("qpdf produces output when Ghostscript is disabled") + void qpdfOnlySuccess() throws Exception { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false); + when(endpointConfiguration.isGroupEnabled("qpdf")).thenReturn(true); + + try (MockedStatic mockedFactory = mockStatic(ProcessExecutor.class)) { + ProcessExecutor qpdfExecutor = mock(ProcessExecutor.class); + ProcessExecutorResult okResult = resultWithRc(0); + when(qpdfExecutor.runCommandWithOutputHandling(any())) + .thenAnswer( + inv -> { + List cmd = inv.getArgument(0); + writeValidPdfTo(cmd, cmd.size() - 1); + return okResult; + }); + + mockedFactory + .when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF)) + .thenReturn(qpdfExecutor); + + ResponseEntity response = + repairController.repairPdf(pdfFileFrom(inputPdf(2))); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(readResource(response.getBody()).length > 0); + + // Ghostscript disabled -> its instance must never be requested. + mockedFactory.verify( + () -> ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT), + never()); + verify(pdfDocumentFactory, never()).load(any(File.class)); + } + } + + @Test + @DisplayName("qpdf IOException propagates to the caller") + void qpdfIOExceptionPropagates() throws Exception { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false); + when(endpointConfiguration.isGroupEnabled("qpdf")).thenReturn(true); + + try (MockedStatic mockedFactory = mockStatic(ProcessExecutor.class)) { + ProcessExecutor qpdfExecutor = mock(ProcessExecutor.class); + when(qpdfExecutor.runCommandWithOutputHandling(any())) + .thenThrow(new IOException("qpdf failed hard")); + + mockedFactory + .when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF)) + .thenReturn(qpdfExecutor); + + IOException thrown = + assertThrows( + IOException.class, + () -> repairController.repairPdf(pdfFileFrom(inputPdf(1)))); + assertEquals("qpdf failed hard", thrown.getMessage()); + } + } + } + + @Nested + @DisplayName("No-tool error branch") + class NoToolErrorBranch { + + @Test + @DisplayName("Ghostscript fails and qpdf disabled throws a processing exception") + void ghostscriptFailsQpdfDisabledThrows() throws Exception { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(true); + when(endpointConfiguration.isGroupEnabled("qpdf")).thenReturn(false); + + try (MockedStatic mockedFactory = mockStatic(ProcessExecutor.class)) { + ProcessExecutor gsExecutor = mock(ProcessExecutor.class); + ProcessExecutorResult failResult = resultWithRc(1); + when(gsExecutor.runCommandWithOutputHandling(any())).thenReturn(failResult); + + mockedFactory + .when( + () -> + ProcessExecutor.getInstance( + ProcessExecutor.Processes.GHOSTSCRIPT)) + .thenReturn(gsExecutor); + + // Ghostscript "enabled" but unsuccessful, qpdf disabled -> not the PDFBox path. + assertThrows( + Exception.class, + () -> repairController.repairPdf(pdfFileFrom(inputPdf(1)))); + + // PDFBox last resort only runs when BOTH tools are disabled. + verify(pdfDocumentFactory, never()).load(any(File.class)); + } + } + } + + @Nested + @DisplayName("Tool availability gating") + class ToolGating { + + @Test + @DisplayName("both tools disabled uses PDFBox and never touches ProcessExecutor") + void bothDisabledUsesPdfBox() throws Exception { + when(endpointConfiguration.isGroupEnabled("Ghostscript")).thenReturn(false); + when(endpointConfiguration.isGroupEnabled("qpdf")).thenReturn(false); + + PDDocument realDoc = new PDDocument(); + realDoc.addPage(new PDPage(PDRectangle.A4)); + when(pdfDocumentFactory.load(any(File.class))).thenReturn(realDoc); + + try (MockedStatic mockedFactory = mockStatic(ProcessExecutor.class)) { + ResponseEntity response = + repairController.repairPdf(pdfFileFrom(inputPdf(1))); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + verify(pdfDocumentFactory, times(1)).load(any(File.class)); + + mockedFactory.verify( + () -> + ProcessExecutor.getInstance( + eq(ProcessExecutor.Processes.GHOSTSCRIPT)), + never()); + mockedFactory.verify( + () -> ProcessExecutor.getInstance(eq(ProcessExecutor.Processes.QPDF)), + never()); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/StampControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/StampControllerMoreTest.java new file mode 100644 index 0000000000..359b6506f1 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/StampControllerMoreTest.java @@ -0,0 +1,383 @@ +package stirling.software.SPDF.controller.api.misc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import javax.imageio.ImageIO; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +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.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.SPDF.model.api.misc.AddStampRequest; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfMetadataService; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; + +/** + * End-to-end coverage for {@link StampController#addStamp} using real in-memory PDFs and a real + * {@link CustomPDFDocumentFactory}/{@link TempFileManager}. Exercises the text and image stamping + * paths, the 1-9 position grid, rotation, opacity, override coordinates, margins, colours and the + * validation branches that the reflection-based {@code StampControllerTest} does not reach. + */ +class StampControllerMoreTest { + + private CustomPDFDocumentFactory pdfDocumentFactory; + private TempFileManager tempFileManager; + private StampController stampController; + + @BeforeEach + void setUp() { + pdfDocumentFactory = new CustomPDFDocumentFactory(mock(PdfMetadataService.class)); + tempFileManager = new TempFileManager(new TempFileRegistry(), new ApplicationProperties()); + stampController = new StampController(pdfDocumentFactory, tempFileManager); + } + + // ---- helpers ------------------------------------------------------------ + + /** Build a multi-page PDF (A4) with a little text drawn on each page. */ + private static byte[] buildPdf(int pageCount) throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + for (int i = 0; i < pageCount; i++) { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(72, 720); + cs.showText("Page " + (i + 1)); + cs.endText(); + } + } + document.save(baos); + return baos.toByteArray(); + } + } + + private static MockMultipartFile pdfFile(int pageCount) throws IOException { + return new MockMultipartFile( + "fileInput", "input.pdf", MediaType.APPLICATION_PDF_VALUE, buildPdf(pageCount)); + } + + /** Build a small PNG image as a multipart file. */ + private static MockMultipartFile pngImage(String name) throws IOException { + BufferedImage img = new BufferedImage(40, 20, BufferedImage.TYPE_INT_RGB); + java.awt.Graphics2D g = img.createGraphics(); + g.setColor(java.awt.Color.RED); + g.fillRect(0, 0, 40, 20); + g.dispose(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + return new MockMultipartFile( + "stampImage", name, MediaType.IMAGE_PNG_VALUE, baos.toByteArray()); + } + + /** A request prefilled with sensible defaults; tests override what they need. */ + private static AddStampRequest baseRequest(MultipartFile pdf) { + AddStampRequest req = new AddStampRequest(); + req.setFileInput(pdf); + req.setPageNumbers("all"); + req.setStampType("text"); + req.setStampText("Confidential"); + req.setAlphabet("roman"); + req.setFontSize(30f); + req.setRotation(0f); + req.setOpacity(0.5f); + req.setPosition(5); + req.setOverrideX(-1f); + req.setOverrideY(-1f); + req.setCustomMargin("medium"); + req.setCustomColor("#d3d3d3"); + return req; + } + + /** Read the response body back into a PDDocument and assert page count. */ + private static void assertValidPdf(ResponseEntity response, int expectedPages) + throws IOException { + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + byte[] out; + try (InputStream is = response.getBody().getInputStream()) { + out = is.readAllBytes(); + } + assertThat(out.length).isGreaterThan(0); + try (PDDocument result = Loader.loadPDF(out)) { + assertThat(result.getNumberOfPages()).isEqualTo(expectedPages); + } + } + + @Nested + @DisplayName("Text stamp happy paths") + class TextStamp { + + @Test + @DisplayName("stamps text on a single-page PDF using positional placement") + void textStampSinglePage() throws Exception { + ResponseEntity response = stampController.addStamp(baseRequest(pdfFile(1))); + assertValidPdf(response, 1); + } + + @Test + @DisplayName("stamps text on every page of a multi-page PDF") + void textStampMultiPage() throws Exception { + ResponseEntity response = stampController.addStamp(baseRequest(pdfFile(3))); + assertValidPdf(response, 3); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3, 4, 5, 6, 7, 8, 9}) + @DisplayName("covers each of the 1-9 grid positions") + void textStampAllPositions(int position) throws Exception { + AddStampRequest req = baseRequest(pdfFile(1)); + req.setPosition(position); + assertValidPdf(stampController.addStamp(req), 1); + } + + @Test + @DisplayName("applies rotation to the text stamp") + void textStampRotated() throws Exception { + AddStampRequest req = baseRequest(pdfFile(1)); + req.setRotation(45f); + assertValidPdf(stampController.addStamp(req), 1); + } + + @Test + @DisplayName("honours explicit override X/Y coordinates") + void textStampOverrideCoords() throws Exception { + AddStampRequest req = baseRequest(pdfFile(1)); + req.setOverrideX(100f); + req.setOverrideY(200f); + assertValidPdf(stampController.addStamp(req), 1); + } + + @Test + @DisplayName("handles multi-line stamp text with escaped newlines") + void textStampMultiLine() throws Exception { + AddStampRequest req = baseRequest(pdfFile(1)); + req.setStampText("Line one\\nLine two\\nLine three"); + assertValidPdf(stampController.addStamp(req), 1); + } + + @Test + @DisplayName("zero font size falls back to default size") + void textStampZeroFontSize() throws Exception { + AddStampRequest req = baseRequest(pdfFile(1)); + req.setFontSize(0f); + assertValidPdf(stampController.addStamp(req), 1); + } + + @ParameterizedTest + @CsvSource({"small", "medium", "large", "x-large", "unknown-defaults-to-medium"}) + @DisplayName("covers every margin bucket plus the default fallback") + void textStampMargins(String margin) throws Exception { + AddStampRequest req = baseRequest(pdfFile(1)); + req.setCustomMargin(margin); + assertValidPdf(stampController.addStamp(req), 1); + } + } + + @Nested + @DisplayName("Colour handling") + class ColourHandling { + + @Test + @DisplayName("accepts a colour without a leading hash") + void colourWithoutHash() throws Exception { + AddStampRequest req = baseRequest(pdfFile(1)); + req.setCustomColor("ff0000"); + assertValidPdf(stampController.addStamp(req), 1); + } + + @Test + @DisplayName("accepts a colour with a leading hash") + void colourWithHash() throws Exception { + AddStampRequest req = baseRequest(pdfFile(1)); + req.setCustomColor("#00ff00"); + assertValidPdf(stampController.addStamp(req), 1); + } + + @Test + @DisplayName("falls back to light gray on an unparseable colour") + void colourInvalidFallsBack() throws Exception { + AddStampRequest req = baseRequest(pdfFile(1)); + req.setCustomColor("not-a-color"); + assertValidPdf(stampController.addStamp(req), 1); + } + } + + @Nested + @DisplayName("Alphabet font selection") + class AlphabetSelection { + + // Only alphabets whose fonts are bundled under static/fonts are exercised here. Each case + // stamps text in its own script so the embedded font has glyphs for every character. + @ParameterizedTest + @CsvSource({ + "roman,Confidential", + "unknown,Confidential", + "arabic,ابج", // Arabic letters present in NotoSansArabic + "thai,กขค" // Thai letters present in NotoSansThai + }) + @DisplayName("loads the matching embedded font for supported alphabets") + void supportedAlphabets(String alphabet, String stampText) throws Exception { + AddStampRequest req = baseRequest(pdfFile(1)); + req.setAlphabet(alphabet); + req.setStampText(stampText); + assertValidPdf(stampController.addStamp(req), 1); + } + } + + @Nested + @DisplayName("Page selection") + class PageSelection { + + @Test + @DisplayName("stamps only the requested subset of pages") + void stampsPageSubset() throws Exception { + AddStampRequest req = baseRequest(pdfFile(5)); + req.setPageNumbers("1,3"); + assertValidPdf(stampController.addStamp(req), 5); + } + + @Test + @DisplayName("functional page expression selects pages without error") + void stampsFunctionalPages() throws Exception { + AddStampRequest req = baseRequest(pdfFile(6)); + req.setPageNumbers("2n"); + assertValidPdf(stampController.addStamp(req), 6); + } + } + + @Nested + @DisplayName("Image stamp happy paths") + class ImageStamp { + + @Test + @DisplayName("stamps an image watermark with positional placement") + void imageStampPositional() throws Exception { + AddStampRequest req = baseRequest(pdfFile(1)); + req.setStampType("image"); + req.setStampImage(pngImage("logo.png")); + assertValidPdf(stampController.addStamp(req), 1); + } + + @ParameterizedTest + @ValueSource(ints = {1, 5, 9}) + @DisplayName("covers top/middle/bottom image position rows") + void imageStampPositions(int position) throws Exception { + AddStampRequest req = baseRequest(pdfFile(1)); + req.setStampType("image"); + req.setPosition(position); + req.setStampImage(pngImage("logo.png")); + assertValidPdf(stampController.addStamp(req), 1); + } + + @Test + @DisplayName("rotates and clamps an image stamp at override coords") + void imageStampRotatedOverride() throws Exception { + AddStampRequest req = baseRequest(pdfFile(1)); + req.setStampType("image"); + req.setRotation(30f); + req.setOverrideX(10f); + req.setOverrideY(10f); + req.setStampImage(pngImage("logo.png")); + assertValidPdf(stampController.addStamp(req), 1); + } + + @Test + @DisplayName("stamp type matching is case-insensitive (IMAGE)") + void imageStampCaseInsensitive() throws Exception { + AddStampRequest req = baseRequest(pdfFile(1)); + req.setStampType("IMAGE"); + req.setStampImage(pngImage("logo.png")); + assertValidPdf(stampController.addStamp(req), 1); + } + } + + @Nested + @DisplayName("Validation and error branches") + class Validation { + + @Test + @DisplayName("rejects a PDF filename containing a path traversal sequence") + void rejectsTraversalPdfName() throws Exception { + MockMultipartFile bad = + new MockMultipartFile( + "fileInput", + "../evil.pdf", + MediaType.APPLICATION_PDF_VALUE, + buildPdf(1)); + AddStampRequest req = baseRequest(bad); + assertThatThrownBy(() -> stampController.addStamp(req)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("rejects a PDF filename starting with a slash") + void rejectsAbsolutePdfName() throws Exception { + MockMultipartFile bad = + new MockMultipartFile( + "fileInput", + "/etc/passwd.pdf", + MediaType.APPLICATION_PDF_VALUE, + buildPdf(1)); + AddStampRequest req = baseRequest(bad); + assertThatThrownBy(() -> stampController.addStamp(req)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("image stamp type without an image file is rejected") + void rejectsMissingImage() throws Exception { + AddStampRequest req = baseRequest(pdfFile(1)); + req.setStampType("image"); + req.setStampImage(null); + assertThatThrownBy(() -> stampController.addStamp(req)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("image filename with a path traversal sequence is rejected") + void rejectsTraversalImageName() throws Exception { + AddStampRequest req = baseRequest(pdfFile(1)); + req.setStampType("image"); + req.setStampImage(pngImage("../evil.png")); + assertThatThrownBy(() -> stampController.addStamp(req)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("unknown stamp type is a no-op that still returns the PDF") + void unknownStampTypeIsNoOp() throws Exception { + AddStampRequest req = baseRequest(pdfFile(2)); + req.setStampType("neither"); + assertValidPdf(stampController.addStamp(req), 2); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/UnlockPDFFormsControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/UnlockPDFFormsControllerMoreTest.java new file mode 100644 index 0000000000..c34202ef94 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/misc/UnlockPDFFormsControllerMoreTest.java @@ -0,0 +1,261 @@ +package stirling.software.SPDF.controller.api.misc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSString; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.common.PDStream; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +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.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.common.model.api.PDFFile; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.WebResponseUtils; + +/** + * Gap coverage for {@link UnlockPDFFormsController}: the locked-field flag clearing, /Lock removal, + * and the XFA stream/array rewrite branches, all driven with real form PDFs. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("UnlockPDFFormsController field/XFA branches") +class UnlockPDFFormsControllerMoreTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private TempFileManager tempFileManager; + + private UnlockPDFFormsController controller; + + @BeforeEach + void setUp() { + controller = new UnlockPDFFormsController(pdfDocumentFactory, tempFileManager); + } + + private static PDTextField buildField(PDAcroForm acroForm, boolean readOnly, boolean withLock) { + PDTextField field = new PDTextField(acroForm); + try { + field.setPartialName("field1"); + } catch (Exception ignored) { + // partial name set is best-effort for the test fixture + } + field.getCOSObject().setString(COSName.DA, "/Helv 12 Tf 0 g"); + if (readOnly) { + field.setReadOnly(true); + } + if (withLock) { + field.getCOSObject().setItem(COSName.getPDFName("Lock"), new COSArray()); + } + return field; + } + + private static byte[] formPdf(boolean readOnly, boolean withLock) throws IOException { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + doc.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm acroForm = new PDAcroForm(doc); + doc.getDocumentCatalog().setAcroForm(acroForm); + PDResources dr = new PDResources(); + dr.put(COSName.getPDFName("Helv"), new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + acroForm.setDefaultResources(dr); + acroForm.getFields().add(buildField(acroForm, readOnly, withLock)); + doc.save(baos); + return baos.toByteArray(); + } + } + + private static final String XFA_XML = + ""; + + private static byte[] formPdfWithXfaStream() throws IOException { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + doc.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm acroForm = new PDAcroForm(doc); + doc.getDocumentCatalog().setAcroForm(acroForm); + PDStream xfaStream = + new PDStream( + doc, + new ByteArrayInputStream(XFA_XML.getBytes(StandardCharsets.UTF_8))); + acroForm.getCOSObject().setItem(COSName.XFA, xfaStream.getCOSObject()); + doc.save(baos); + return baos.toByteArray(); + } + } + + private static byte[] formPdfWithXfaArray() throws IOException { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + doc.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm acroForm = new PDAcroForm(doc); + doc.getDocumentCatalog().setAcroForm(acroForm); + COSArray xfaArray = new COSArray(); + xfaArray.add(new COSString("template")); + PDStream xfaStream = + new PDStream( + doc, + new ByteArrayInputStream(XFA_XML.getBytes(StandardCharsets.UTF_8))); + xfaArray.add(xfaStream.getCOSObject()); + acroForm.getCOSObject().setItem(COSName.XFA, xfaArray); + doc.save(baos); + return baos.toByteArray(); + } + } + + private static PDFFile request(byte[] pdf) { + PDFFile file = new PDFFile(); + file.setFileInput( + new MockMultipartFile( + "fileInput", "form.pdf", MediaType.APPLICATION_PDF_VALUE, pdf)); + return file; + } + + /** Loads the upload bytes as a real document and captures it for post-call inspection. */ + private List wireCapturingLoad() throws IOException { + List captured = new ArrayList<>(); + when(pdfDocumentFactory.load(any(PDFFile.class))) + .thenAnswer( + inv -> { + PDDocument doc = + Loader.loadPDF( + ((PDFFile) inv.getArgument(0)) + .getFileInput() + .getBytes()); + captured.add(doc); + return doc; + }); + return captured; + } + + private static MockedStatic stubResponse(List capturedNames) { + MockedStatic wr = Mockito.mockStatic(WebResponseUtils.class); + wr.when( + () -> + WebResponseUtils.pdfDocToWebResponse( + any(PDDocument.class), + anyString(), + any(TempFileManager.class))) + .thenAnswer( + inv -> { + if (capturedNames != null) { + capturedNames.add(inv.getArgument(1)); + } + return ResponseEntity.ok(new ByteArrayResource("ok".getBytes())); + }); + return wr; + } + + @Nested + @DisplayName("field flag handling") + class FieldFlags { + + @Test + @DisplayName("read-only flag is cleared on a locked field") + void clearsReadOnly() throws Exception { + List captured = wireCapturingLoad(); + try (MockedStatic ignored = stubResponse(null)) { + ResponseEntity response = + controller.unlockPDFForms(request(formPdf(true, false))); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + PDAcroForm acroForm = captured.get(0).getDocumentCatalog().getAcroForm(); + for (PDField field : acroForm.getFieldTree()) { + assertFalse((field.getFieldFlags() & 1) == 1); + } + } + + @Test + @DisplayName("the /Lock entry is removed from the field dictionary") + void removesLockEntry() throws Exception { + List captured = wireCapturingLoad(); + try (MockedStatic ignored = stubResponse(null)) { + controller.unlockPDFForms(request(formPdf(true, true))); + } + PDAcroForm acroForm = captured.get(0).getDocumentCatalog().getAcroForm(); + for (PDField field : acroForm.getFieldTree()) { + assertFalse(field.getCOSObject().containsKey(COSName.getPDFName("Lock"))); + } + } + } + + @Nested + @DisplayName("XFA rewriting") + class Xfa { + + @Test + @DisplayName("XFA stream readOnly access is rewritten to open") + void rewritesXfaStream() throws Exception { + wireCapturingLoad(); + try (MockedStatic ignored = stubResponse(null)) { + ResponseEntity response = + controller.unlockPDFForms(request(formPdfWithXfaStream())); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + } + + @Test + @DisplayName("XFA array entries are processed without error") + void rewritesXfaArray() throws Exception { + wireCapturingLoad(); + try (MockedStatic ignored = stubResponse(null)) { + ResponseEntity response = + controller.unlockPDFForms(request(formPdfWithXfaArray())); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + } + } + + @Test + @DisplayName("output filename carries the _unlocked_forms suffix") + void filenameSuffix() throws Exception { + wireCapturingLoad(); + List names = new ArrayList<>(); + ResponseEntity stub = ResponseEntity.ok(new ByteArrayResource("ok".getBytes())); + try (MockedStatic wr = stubResponse(names)) { + ResponseEntity response = + controller.unlockPDFForms(request(formPdf(true, false))); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(names.get(0).contains("_unlocked_forms.pdf")); + assertSame(stub.getStatusCode(), response.getStatusCode()); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/AllTextLineExtractorTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/AllTextLineExtractorTest.java new file mode 100644 index 0000000000..492dd92086 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/AllTextLineExtractorTest.java @@ -0,0 +1,131 @@ +package stirling.software.SPDF.controller.api.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class AllTextLineExtractorTest { + + private PDDocument doc; + private PDPage page; + + private void newDoc() { + doc = new PDDocument(); + page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + } + + private void writeAt(float x, float y, String text) throws IOException { + try (PDPageContentStream cs = + new PDPageContentStream( + doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 11); + cs.newLineAtOffset(x, y); + cs.showText(text); + cs.endText(); + } + } + + private AllTextLineExtractor extract() throws IOException { + float pageHeight = page.getMediaBox().getHeight(); + AllTextLineExtractor extractor = new AllTextLineExtractor(1, pageHeight); + extractor.getText(doc); + return extractor; + } + + @Nested + @DisplayName("line grouping") + class LineGrouping { + + @Test + @DisplayName("single line of text yields one box") + void singleLine() throws IOException { + newDoc(); + writeAt(72, 700, "one line of text"); + try (PDDocument d = doc) { + AllTextLineExtractor extractor = extract(); + assertThat(extractor.getLineBoxes()).hasSize(1); + assertThat(extractor.getScreenLineBoxes()).hasSize(1); + } + } + + @Test + @DisplayName("two vertically separated lines yield two boxes") + void twoLines() throws IOException { + newDoc(); + writeAt(72, 700, "first line"); + writeAt(72, 650, "second line"); + try (PDDocument d = doc) { + AllTextLineExtractor extractor = extract(); + assertThat(extractor.getLineBoxes()).hasSize(2); + } + } + + @Test + @DisplayName("large horizontal gap on same baseline splits into two boxes") + void columnGapSplit() throws IOException { + newDoc(); + writeAt(72, 700, "leftcol"); + writeAt(400, 700, "rightcol"); + try (PDDocument d = doc) { + AllTextLineExtractor extractor = extract(); + assertThat(extractor.getLineBoxes().size()).isGreaterThanOrEqualTo(2); + } + } + } + + @Nested + @DisplayName("coordinate conversion") + class Coordinates { + + @Test + @DisplayName("pdf box Y is page-height minus screen Y") + void pdfCoordsDerivedFromScreen() throws IOException { + newDoc(); + writeAt(72, 700, "coords"); + try (PDDocument d = doc) { + AllTextLineExtractor extractor = extract(); + float[] pdfBox = extractor.getLineBoxes().get(0); + float[] screenBox = extractor.getScreenLineBoxes().get(0); + float pageHeight = page.getMediaBox().getHeight(); + // pdfY1 = pageHeight - maxScreenY (screenBox[3]) + assertThat(pdfBox[1]).isCloseTo(pageHeight - screenBox[3], within()); + assertThat(pdfBox[3]).isCloseTo(pageHeight - screenBox[1], within()); + // x coords identical + assertThat(pdfBox[0]).isEqualTo(screenBox[0]); + assertThat(pdfBox[2]).isEqualTo(screenBox[2]); + } + } + + private org.assertj.core.data.Offset within() { + return org.assertj.core.data.Offset.offset(0.01f); + } + } + + @Nested + @DisplayName("whitespace handling") + class Whitespace { + + @Test + @DisplayName("blank page produces no line boxes") + void blankPage() throws IOException { + newDoc(); + try (PDDocument d = doc) { + AllTextLineExtractor extractor = extract(); + assertThat(extractor.getLineBoxes()).isEmpty(); + assertThat(extractor.getScreenLineBoxes()).isEmpty(); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFMoreTest.java new file mode 100644 index 0000000000..7e2aa388fa --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDFMoreTest.java @@ -0,0 +1,292 @@ +package stirling.software.SPDF.controller.api.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.util.Calendar; +import java.util.GregorianCalendar; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureTreeRoot; +import org.apache.pdfbox.pdmodel.encryption.AccessPermission; +import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionJavaScript; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.SPDF.service.VeraPDFService; +import stirling.software.common.model.api.PDFFile; +import stirling.software.common.service.CustomPDFDocumentFactory; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Coverage tests for {@link GetInfoOnPDF} driving feature-rich in-memory PDFs through the public + * getPdfInfo endpoint so the many extract* branches run. VeraPDF + the factory are mocked. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class GetInfoOnPDFMoreTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private VeraPDFService veraPDFService; + + @InjectMocks private GetInfoOnPDF getInfoOnPDF; + + private final ObjectMapper om = JsonMapper.builder().build(); + + /** Saves the doc, wires the factory to reload it from bytes, and calls the endpoint. */ + private JsonNode run(PDDocument doc) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + doc.save(out); + doc.close(); + byte[] bytes = out.toByteArray(); + MockMultipartFile mf = + new MockMultipartFile("fileInput", "test.pdf", "application/pdf", bytes); + PDFFile request = new PDFFile(); + request.setFileInput(mf); + when(pdfDocumentFactory.load(any(MultipartFile.class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(bytes)); + ResponseEntity resp = getInfoOnPDF.getPdfInfo(request); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody()).isNotNull(); + return om.readTree(resp.getBody()); + } + + private static PDImageXObject smallImage(PDDocument doc) throws Exception { + BufferedImage img = new BufferedImage(16, 16, BufferedImage.TYPE_INT_RGB); + for (int x = 0; x < 16; x++) { + for (int y = 0; y < 16; y++) { + img.setRGB(x, y, (x * 16 + y) << 8); + } + } + return LosslessFactory.createFromImage(doc, img); + } + + @Nested + @DisplayName("metadata and document info") + class MetadataAndInfo { + + @Test + @DisplayName("full document information is reported") + void fullDocInfo() throws Exception { + PDDocument doc = new PDDocument(); + doc.addPage(new PDPage(PDRectangle.A4)); + PDDocumentInformation info = doc.getDocumentInformation(); + info.setTitle("My Title"); + info.setAuthor("Jane Author"); + info.setSubject("Coverage subject"); + info.setKeywords("alpha, beta, gamma"); + info.setCreator("Creator App"); + info.setProducer("Producer Lib"); + info.setCreationDate(new GregorianCalendar(2021, Calendar.MARCH, 3)); + info.setModificationDate(new GregorianCalendar(2022, Calendar.APRIL, 4)); + info.setCustomMetadataValue("CustomKey", "CustomValue"); + + JsonNode json = run(doc); + assertThat(json.isObject()).isTrue(); + assertThat(json.size()).isGreaterThan(3); + } + + @Test + @DisplayName("minimal document with no info still produces a report") + void minimalDoc() throws Exception { + PDDocument doc = new PDDocument(); + doc.addPage(new PDPage(PDRectangle.LETTER)); + JsonNode json = run(doc); + assertThat(json.isObject()).isTrue(); + } + + @Test + @DisplayName("multi-page document with varied sizes and a rotated page") + void multiPageVaried() throws Exception { + PDDocument doc = new PDDocument(); + doc.addPage(new PDPage(PDRectangle.A4)); + doc.addPage(new PDPage(PDRectangle.LETTER)); + PDPage rotated = new PDPage(new PDRectangle(300, 500)); + rotated.setRotation(90); + doc.addPage(rotated); + PDPage legal = new PDPage(PDRectangle.LEGAL); + doc.addPage(legal); + JsonNode json = run(doc); + assertThat(json.isObject()).isTrue(); + } + } + + @Nested + @DisplayName("content features") + class ContentFeatures { + + @Test + @DisplayName("page with text in multiple fonts and an image") + void textImageFonts() throws Exception { + PDDocument doc = new PDDocument(); + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(72, 700); + cs.showText("Hello in Helvetica"); + cs.endText(); + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN), 14f); + cs.newLineAtOffset(72, 660); + cs.showText("And Times Roman"); + cs.endText(); + cs.drawImage(smallImage(doc), 72, 500, 64, 64); + } + JsonNode json = run(doc); + assertThat(json.isObject()).isTrue(); + } + + @Test + @DisplayName("document with an outline and link annotation") + void outlineAndLink() throws Exception { + PDDocument doc = new PDDocument(); + PDPage p1 = new PDPage(PDRectangle.A4); + PDPage p2 = new PDPage(PDRectangle.A4); + doc.addPage(p1); + doc.addPage(p2); + + PDDocumentOutline outline = new PDDocumentOutline(); + doc.getDocumentCatalog().setDocumentOutline(outline); + PDOutlineItem root = new PDOutlineItem(); + root.setTitle("Chapter 1"); + outline.addLast(root); + PDOutlineItem child = new PDOutlineItem(); + child.setTitle("Section 1.1"); + root.addLast(child); + + PDAnnotationLink link = new PDAnnotationLink(); + link.setRectangle(new PDRectangle(72, 700, 100, 20)); + p1.getAnnotations().add(link); + + JsonNode json = run(doc); + assertThat(json.isObject()).isTrue(); + } + + @Test + @DisplayName("document with JavaScript action and structure tree") + void javascriptAndStructure() throws Exception { + PDDocument doc = new PDDocument(); + doc.addPage(new PDPage(PDRectangle.A4)); + PDActionJavaScript js = new PDActionJavaScript("app.alert('hi');"); + doc.getDocumentCatalog().setOpenAction(js); + doc.getDocumentCatalog().setStructureTreeRoot(new PDStructureTreeRoot()); + JsonNode json = run(doc); + assertThat(json.isObject()).isTrue(); + } + } + + @Nested + @DisplayName("forms") + class Forms { + + @Test + @DisplayName("document with text field and checkbox AcroForm") + void acroForm() throws Exception { + PDDocument doc = new PDDocument(); + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + PDAcroForm acro = new PDAcroForm(doc); + doc.getDocumentCatalog().setAcroForm(acro); + + PDTextField text = new PDTextField(acro); + text.setPartialName("name"); + acro.getFields().add(text); + + PDCheckBox check = new PDCheckBox(acro); + check.setPartialName("agree"); + acro.getFields().add(check); + + JsonNode json = run(doc); + assertThat(json.isObject()).isTrue(); + } + } + + @Nested + @DisplayName("encryption and permissions") + class EncryptionPermissions { + + @Test + @DisplayName("owner-encrypted document with restricted permissions") + void encryptedRestricted() throws Exception { + PDDocument doc = new PDDocument(); + doc.addPage(new PDPage(PDRectangle.A4)); + AccessPermission ap = new AccessPermission(); + ap.setCanPrint(false); + ap.setCanModify(false); + ap.setCanExtractContent(false); + ap.setCanFillInForm(false); + // Empty user password so the doc still loads, owner password locks permissions. + StandardProtectionPolicy policy = new StandardProtectionPolicy("owner-secret", "", ap); + policy.setEncryptionKeyLength(128); + doc.protect(policy); + JsonNode json = run(doc); + assertThat(json.isObject()).isTrue(); + } + } + + @Nested + @DisplayName("error handling") + class Errors { + + @Test + @DisplayName("empty file input yields an error response") + void emptyFile() throws Exception { + MockMultipartFile mf = + new MockMultipartFile("fileInput", "x.pdf", "application/pdf", new byte[0]); + PDFFile request = new PDFFile(); + request.setFileInput(mf); + ResponseEntity resp = getInfoOnPDF.getPdfInfo(request); + // createErrorResponse returns HTTP 200 with a JSON body carrying an "error" field. + assertThat(resp.getBody()).isNotNull(); + JsonNode body = om.readTree(resp.getBody()); + assertThat(body.has("error")).isTrue(); + assertThat(body.get("error").asText("")).contains("Invalid"); + } + + @Test + @DisplayName("veraPDF failure is swallowed and a report is still produced") + void veraPdfFailureSwallowed() throws Exception { + when(veraPDFService.validatePDF(any())).thenThrow(new RuntimeException("veraPDF boom")); + PDDocument doc = new PDDocument(); + doc.addPage(new PDPage(PDRectangle.A4)); + JsonNode json = run(doc); + assertThat(json.isObject()).isTrue(); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/MultiPatternTextFinderMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/MultiPatternTextFinderMoreTest.java new file mode 100644 index 0000000000..f6990b69e7 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/MultiPatternTextFinderMoreTest.java @@ -0,0 +1,188 @@ +package stirling.software.SPDF.controller.api.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.SPDF.model.PDFText; + +/** + * Additional coverage for {@link MultiPatternTextFinder} driving real PDFBox documents: multi-page + * accumulation, multi-line text, case-sensitivity, and the empty/whitespace page short-circuit. + */ +class MultiPatternTextFinderMoreTest { + + private static void writeLine(PDPageContentStream cs, String text, float x, float y) + throws IOException { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(x, y); + cs.showText(text); + cs.endText(); + } + + private static PDPage pageWith(PDDocument doc, String text) throws IOException { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + writeLine(cs, text, 50, 700); + } + return page; + } + + private static Map> scan(PDDocument doc, List patterns) + throws IOException { + MultiPatternTextFinder finder = new MultiPatternTextFinder(patterns); + finder.setStartPage(1); + finder.setEndPage(doc.getNumberOfPages()); + finder.getText(doc); + return finder.getFoundTextsByPage(); + } + + @Nested + @DisplayName("multi-page documents") + class MultiPage { + + @Test + @DisplayName("matches are keyed by their zero-based page index") + void matchesKeyedByPage() throws IOException { + try (PDDocument doc = new PDDocument()) { + pageWith(doc, "first apple"); + pageWith(doc, "second apple"); + pageWith(doc, "third nothing"); + + Map> result = scan(doc, List.of(Pattern.compile("apple"))); + + assertThat(result).containsOnlyKeys(0, 1); + assertThat(result.get(0)).hasSize(1); + assertThat(result.get(1)).hasSize(1); + assertThat(result.get(0).get(0).getPageIndex()).isZero(); + assertThat(result.get(1).get(0).getPageIndex()).isEqualTo(1); + } + } + + @Test + @DisplayName("each pattern is searched independently across every page") + void everyPatternEveryPage() throws IOException { + try (PDDocument doc = new PDDocument()) { + pageWith(doc, "alpha beta"); + pageWith(doc, "beta gamma"); + + Map> result = + scan(doc, List.of(Pattern.compile("alpha"), Pattern.compile("beta"))); + + // page 0 has alpha + beta, page 1 has beta only + assertThat(result.get(0)).hasSize(2); + assertThat(result.get(1)).hasSize(1); + } + } + } + + @Nested + @DisplayName("text shape") + class TextShape { + + @Test + @DisplayName("a match spanning a word separator still yields one positioned hit") + void matchAcrossWordSeparator() throws IOException { + try (PDDocument doc = new PDDocument()) { + pageWith(doc, "hello world"); + + // the space between the words is a null TextPosition slot + Map> result = + scan(doc, List.of(Pattern.compile("hello world"))); + + assertThat(result.get(0)).hasSize(1); + PDFText hit = result.get(0).get(0); + assertThat(hit.getText()).isEqualTo("hello world"); + assertThat(hit.getX2()).isGreaterThan(hit.getX1()); + } + } + + @Test + @DisplayName("a multi-line page can match terms on separate lines") + void multiLineMatches() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + writeLine(cs, "needle one", 50, 720); + writeLine(cs, "needle two", 50, 680); + } + + Map> result = scan(doc, List.of(Pattern.compile("needle"))); + + assertThat(result.get(0)).hasSize(2); + } + } + + @Test + @DisplayName("matching is case sensitive by default") + void caseSensitive() throws IOException { + try (PDDocument doc = new PDDocument()) { + pageWith(doc, "Apple apple"); + + Map> result = scan(doc, List.of(Pattern.compile("apple"))); + + // only the lowercase occurrence matches + assertThat(result.get(0)).hasSize(1); + } + } + + @Test + @DisplayName("case-insensitive flag matches both casings") + void caseInsensitiveFlag() throws IOException { + try (PDDocument doc = new PDDocument()) { + pageWith(doc, "Apple apple"); + + Map> result = + scan(doc, List.of(Pattern.compile("apple", Pattern.CASE_INSENSITIVE))); + + assertThat(result.get(0)).hasSize(2); + } + } + } + + @Nested + @DisplayName("empty content") + class EmptyContent { + + @Test + @DisplayName("a page with no content stream produces no matches") + void blankPageNoMatch() throws IOException { + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage(PDRectangle.A4)); + + Map> result = + scan(doc, List.of(Pattern.compile("anything"))); + + assertThat(result).isEmpty(); + } + } + + @Test + @DisplayName("an empty pattern list never matches anything") + void emptyPatternList() throws IOException { + try (PDDocument doc = new PDDocument()) { + pageWith(doc, "some text here"); + + Map> result = scan(doc, List.of()); + + assertThat(result).isEmpty(); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/MultiPatternTextFinderTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/MultiPatternTextFinderTest.java new file mode 100644 index 0000000000..a491064066 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/MultiPatternTextFinderTest.java @@ -0,0 +1,109 @@ +package stirling.software.SPDF.controller.api.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.SPDF.model.PDFText; + +class MultiPatternTextFinderTest { + + private PDDocument singlePageDoc(String text) throws IOException { + PDDocument doc = new PDDocument(); + PDPage page = new PDPage(); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(50, 700); + cs.showText(text); + cs.endText(); + } + return doc; + } + + private Map> scan(PDDocument doc, List patterns) + throws IOException { + MultiPatternTextFinder finder = new MultiPatternTextFinder(patterns); + finder.setStartPage(1); + finder.setEndPage(doc.getNumberOfPages()); + finder.getText(doc); + return finder.getFoundTextsByPage(); + } + + @Nested + @DisplayName("matching") + class Matching { + + @Test + @DisplayName("finds a single literal match with bounding box") + void singleMatch() throws IOException { + try (PDDocument doc = singlePageDoc("Hello World")) { + Map> result = scan(doc, List.of(Pattern.compile("World"))); + + assertThat(result).containsKey(0); + List hits = result.get(0); + assertThat(hits).hasSize(1); + PDFText hit = hits.get(0); + assertThat(hit.getText()).isEqualTo("World"); + assertThat(hit.getPageIndex()).isZero(); + assertThat(hit.getX2()).isGreaterThan(hit.getX1()); + assertThat(hit.getY2()).isGreaterThanOrEqualTo(hit.getY1()); + } + } + + @Test + @DisplayName("multiple patterns matched in one pass") + void multiplePatterns() throws IOException { + try (PDDocument doc = singlePageDoc("alpha beta gamma")) { + Map> result = + scan(doc, List.of(Pattern.compile("alpha"), Pattern.compile("gamma"))); + + assertThat(result.get(0)).hasSize(2); + } + } + + @Test + @DisplayName("same pattern matched multiple times") + void repeatedMatch() throws IOException { + try (PDDocument doc = singlePageDoc("ab ab ab")) { + Map> result = scan(doc, List.of(Pattern.compile("ab"))); + + assertThat(result.get(0)).hasSize(3); + } + } + + @Test + @DisplayName("no match yields empty result map") + void noMatch() throws IOException { + try (PDDocument doc = singlePageDoc("nothing here")) { + Map> result = scan(doc, List.of(Pattern.compile("absent"))); + + assertThat(result).isEmpty(); + } + } + + @Test + @DisplayName("regex pattern with groups matches") + void regexMatch() throws IOException { + try (PDDocument doc = singlePageDoc("id 12345 done")) { + Map> result = scan(doc, List.of(Pattern.compile("\\d+"))); + + assertThat(result.get(0)).hasSize(1); + assertThat(result.get(0).get(0).getText()).isEqualTo("12345"); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java new file mode 100644 index 0000000000..6eafecc1a5 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactControllerMoreTest.java @@ -0,0 +1,498 @@ +package stirling.software.SPDF.controller.api.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.AfterEach; +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.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.SPDF.model.api.security.ManualRedactPdfRequest; +import stirling.software.SPDF.model.api.security.RedactExecuteRequest; +import stirling.software.SPDF.model.api.security.RedactPdfRequest; +import stirling.software.common.model.api.security.RedactionArea; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +/** + * End-to-end coverage tests for {@link RedactController} that drive the controller against real + * in-memory PDFs and real {@link TextRedactionService} / {@link ManualRedactionService} instances. + * The factory is mocked to return a freshly-parsed {@link PDDocument} on every {@code load()} (the + * auto-redact fallback path loads twice), and {@code tempFileManager} hands back real temp files. + * This complements {@code RedactControllerTest}, which exercises the controller against a mocked + * {@link PDDocument}; here the actual redaction pipeline runs so the no-match, found-and-replace, + * manual-area, page, convert-to-image, validation, and delegation branches are all covered for + * real. + */ +@DisplayName("RedactController end-to-end coverage") +class RedactControllerMoreTest { + + private static final float FONT_SIZE = 12f; + private static final float LEFT_X = 72f; + private static final float TOP_Y = PDRectangle.LETTER.getHeight() - 80f; + + private CustomPDFDocumentFactory pdfDocumentFactory; + private TempFileManager tempFileManager; + private RedactExecuteService redactExecuteService; + private TextRedactionService textRedactionService; + private ManualRedactionService manualRedactionService; + private RedactController controller; + + private final List createdTempFiles = new ArrayList<>(); + + @BeforeEach + void setUp() throws IOException { + pdfDocumentFactory = mock(CustomPDFDocumentFactory.class); + tempFileManager = mock(TempFileManager.class); + redactExecuteService = mock(RedactExecuteService.class); + + lenient() + .when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + inv -> { + File f = + Files.createTempFile( + "redact-ctl-test", inv.getArgument(0)) + .toFile(); + createdTempFiles.add(f); + TempFile tf = mock(TempFile.class); + lenient().when(tf.getFile()).thenReturn(f); + lenient().when(tf.getPath()).thenReturn(f.toPath()); + return tf; + }); + + textRedactionService = new TextRedactionService(); + manualRedactionService = new ManualRedactionService(tempFileManager); + controller = + new RedactController( + pdfDocumentFactory, + tempFileManager, + manualRedactionService, + textRedactionService, + redactExecuteService); + } + + @AfterEach + void tearDown() { + for (File f : createdTempFiles) { + if (f != null && f.exists()) { + f.delete(); + } + } + } + + // ── helpers ────────────────────────────────────────────────────────────────────────────────── + + /** Wires the factory so each load() returns a brand-new doc parsed from the same bytes. */ + private void factoryReturns(byte[] pdfBytes) throws IOException { + lenient() + .when(pdfDocumentFactory.load(any(MultipartFile.class))) + .thenAnswer(inv -> Loader.loadPDF(pdfBytes)); + } + + private byte[] singlePageTextPdf(String... lines) throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE); + for (int i = 0; i < lines.length; i++) { + cs.beginText(); + cs.newLineAtOffset(LEFT_X, TOP_Y - i * 16f); + cs.showText(lines[i]); + cs.endText(); + } + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + return baos.toByteArray(); + } + } + + private byte[] multiPageTextPdf(String... pageLines) throws IOException { + try (PDDocument doc = new PDDocument()) { + for (String line : pageLines) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE); + cs.beginText(); + cs.newLineAtOffset(LEFT_X, TOP_Y); + cs.showText(line); + cs.endText(); + } + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + return baos.toByteArray(); + } + } + + private MockMultipartFile pdfFile(byte[] bytes) { + return new MockMultipartFile("fileInput", "doc.pdf", "application/pdf", bytes); + } + + private byte[] drainBody(ResponseEntity response) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (InputStream in = response.getBody().getInputStream()) { + in.transferTo(baos); + } + return baos.toByteArray(); + } + + private String pdfText(byte[] pdfBytes) throws IOException { + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + return new org.apache.pdfbox.text.PDFTextStripper().getText(doc); + } + } + + // ── auto redaction (/auto-redact) ──────────────────────────────────────────────────────────── + + @Nested + @DisplayName("auto redaction") + class AutoRedaction { + + @Test + @DisplayName("a matched term is removed from the extractable text of the output") + void matchedTermRemoved() throws IOException { + byte[] bytes = singlePageTextPdf("public CONFIDENTIAL data"); + factoryReturns(bytes); + + RedactPdfRequest request = new RedactPdfRequest(); + request.setFileInput(pdfFile(bytes)); + request.setListOfText("CONFIDENTIAL"); + request.setUseRegex(false); + request.setWholeWordSearch(false); + request.setRedactColor("#000000"); + request.setConvertPDFToImage(false); + + ResponseEntity response = controller.redactPdf(request); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + byte[] out = drainBody(response); + assertThat(pdfText(out)).doesNotContain("CONFIDENTIAL"); + assertThat(pdfText(out)).contains("public"); + } + + @Test + @DisplayName("no-match returns the original document unchanged and never reloads") + void noMatchReturnsOriginal() throws IOException { + byte[] bytes = singlePageTextPdf("nothing sensitive here"); + factoryReturns(bytes); + + RedactPdfRequest request = new RedactPdfRequest(); + request.setFileInput(pdfFile(bytes)); + request.setListOfText("ABSENTTERM"); + request.setRedactColor("#000000"); + + ResponseEntity response = controller.redactPdf(request); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(pdfText(drainBody(response))).contains("nothing sensitive here"); + // Only the initial load happens; the box-only fallback reload path is not taken. + verify(pdfDocumentFactory, times(1)).load(any(MultipartFile.class)); + } + + @Test + @DisplayName("a regex pattern redacts every matching run across multiple pages") + void regexAcrossPages() throws IOException { + byte[] bytes = multiPageTextPdf("ssn 111-22-3333 one", "ssn 444-55-6666 two"); + factoryReturns(bytes); + + RedactPdfRequest request = new RedactPdfRequest(); + request.setFileInput(pdfFile(bytes)); + request.setListOfText("\\d{3}-\\d{2}-\\d{4}"); + request.setUseRegex(true); + request.setRedactColor("#FF0000"); + + ResponseEntity response = controller.redactPdf(request); + + String text = pdfText(drainBody(response)); + assertThat(text).doesNotContain("111-22-3333"); + assertThat(text).doesNotContain("444-55-6666"); + } + + @Test + @DisplayName("convert-to-image still returns a valid 200 PDF response") + void convertToImage() throws IOException { + byte[] bytes = singlePageTextPdf("redact SECRET please"); + factoryReturns(bytes); + + RedactPdfRequest request = new RedactPdfRequest(); + request.setFileInput(pdfFile(bytes)); + request.setListOfText("SECRET"); + request.setRedactColor("#000000"); + request.setConvertPDFToImage(true); + + ResponseEntity response = controller.redactPdf(request); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(drainBody(response).length).isGreaterThan(0); + } + + @Test + @DisplayName("whole-word search leaves substring occurrences intact") + void wholeWordKeepsSubstrings() throws IOException { + byte[] bytes = singlePageTextPdf("cat classification scatter"); + factoryReturns(bytes); + + RedactPdfRequest request = new RedactPdfRequest(); + request.setFileInput(pdfFile(bytes)); + request.setListOfText("cat"); + request.setWholeWordSearch(true); + request.setRedactColor("#000000"); + + ResponseEntity response = controller.redactPdf(request); + + String text = pdfText(drainBody(response)); + // The embedded "cat" inside other words must survive whole-word redaction. + assertThat(text).contains("classification"); + assertThat(text).contains("scatter"); + } + } + + // ── auto redaction validation / errors ─────────────────────────────────────────────────────── + + @Nested + @DisplayName("auto redaction validation and errors") + class AutoValidation { + + @Test + @DisplayName("blank listOfText throws an illegal-argument error before any load") + void blankPatternsThrows() throws Exception { + RedactPdfRequest request = new RedactPdfRequest(); + request.setFileInput(pdfFile(new byte[] {1, 2, 3})); + request.setListOfText(" "); + + assertThatThrownBy(() -> controller.redactPdf(request)) + .isInstanceOf(RuntimeException.class); + verify(pdfDocumentFactory, never()).load(any(MultipartFile.class)); + } + + @Test + @DisplayName("null file input is reported as a failure") + void nullFileThrows() { + RedactPdfRequest request = new RedactPdfRequest(); + request.setFileInput(null); + request.setListOfText("secret"); + + assertThatThrownBy(() -> controller.redactPdf(request)) + .isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("a load failure is wrapped as a runtime redaction failure") + void loadFailureWrapped() throws IOException { + when(pdfDocumentFactory.load(any(MultipartFile.class))) + .thenThrow(new IOException("boom")); + + RedactPdfRequest request = new RedactPdfRequest(); + request.setFileInput(pdfFile(new byte[] {9, 9, 9})); + request.setListOfText("secret"); + + assertThatThrownBy(() -> controller.redactPdf(request)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to perform PDF redaction"); + } + } + + // ── manual redaction (/redact) ─────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("manual redaction") + class ManualRedaction { + + private ManualRedactPdfRequest manualRequest(byte[] bytes) { + ManualRedactPdfRequest request = new ManualRedactPdfRequest(); + request.setFileInput(pdfFile(bytes)); + return request; + } + + private RedactionArea area(int page, double x, double y, double w, double h, String color) { + RedactionArea a = new RedactionArea(); + a.setPage(page); + a.setX(x); + a.setY(y); + a.setWidth(w); + a.setHeight(h); + a.setColor(color); + return a; + } + + @Test + @DisplayName("a valid area produces a 200 response and a non-empty PDF body") + void validAreaRedacts() throws IOException { + byte[] bytes = singlePageTextPdf("box redact this"); + factoryReturns(bytes); + + ManualRedactPdfRequest request = manualRequest(bytes); + List areas = new ArrayList<>(); + areas.add(area(1, 80, 80, 120, 20, "000000")); + request.setRedactions(areas); + request.setConvertPDFToImage(false); + + ResponseEntity response = controller.redactPDF(request); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(drainBody(response).length).isGreaterThan(0); + } + + @Test + @DisplayName("null redactions list is handled gracefully and still returns the PDF") + void nullRedactions() throws IOException { + byte[] bytes = singlePageTextPdf("untouched content"); + factoryReturns(bytes); + + ManualRedactPdfRequest request = manualRequest(bytes); + request.setRedactions(null); + + ResponseEntity response = controller.redactPDF(request); + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + @DisplayName("an empty redactions list returns a valid response") + void emptyRedactions() throws IOException { + byte[] bytes = singlePageTextPdf("still here"); + factoryReturns(bytes); + + ManualRedactPdfRequest request = manualRequest(bytes); + request.setRedactions(new ArrayList<>()); + + ResponseEntity response = controller.redactPDF(request); + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + @DisplayName("a coloured area on a specific page is applied without error") + void colouredArea() throws IOException { + byte[] bytes = multiPageTextPdf("page one", "page two"); + factoryReturns(bytes); + + ManualRedactPdfRequest request = manualRequest(bytes); + List areas = new ArrayList<>(); + areas.add(area(2, 60, 60, 100, 30, "FF0000")); + request.setRedactions(areas); + + ResponseEntity response = controller.redactPDF(request); + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(drainBody(response).length).isGreaterThan(0); + } + + @Test + @DisplayName("whole-page redaction via pageNumbers covers the page and returns 200") + void pageRedaction() throws IOException { + byte[] bytes = multiPageTextPdf("first page text", "second page text"); + factoryReturns(bytes); + + ManualRedactPdfRequest request = manualRequest(bytes); + request.setPageNumbers("1"); + request.setRedactions(new ArrayList<>()); + + ResponseEntity response = controller.redactPDF(request); + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + @DisplayName("manual redaction with convert-to-image returns a valid PDF") + void manualConvertToImage() throws IOException { + byte[] bytes = singlePageTextPdf("image mode area"); + factoryReturns(bytes); + + ManualRedactPdfRequest request = manualRequest(bytes); + List areas = new ArrayList<>(); + areas.add(area(1, 80, 80, 100, 20, "000000")); + request.setRedactions(areas); + request.setConvertPDFToImage(true); + + ResponseEntity response = controller.redactPDF(request); + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(drainBody(response).length).isGreaterThan(0); + } + + @Test + @DisplayName("an area with non-positive dimensions is skipped, still returning 200") + void invalidDimensionsSkipped() throws IOException { + byte[] bytes = singlePageTextPdf("content body"); + factoryReturns(bytes); + + ManualRedactPdfRequest request = manualRequest(bytes); + List areas = new ArrayList<>(); + areas.add(area(1, 10, 10, 0, 0, "000000")); // zero width/height -> skipped + request.setRedactions(areas); + + ResponseEntity response = controller.redactPDF(request); + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + } + + // ── unified execute (/redact-execute) ──────────────────────────────────────────────────────── + + @Nested + @DisplayName("unified execute endpoint") + class ExecuteEndpoint { + + @Test + @DisplayName("delegates to RedactExecuteService and wraps the temp file as a 200 response") + void delegatesToService() throws IOException { + byte[] outBytes = singlePageTextPdf("executed output"); + File outFile = Files.createTempFile("redact-exec-out", ".pdf").toFile(); + createdTempFiles.add(outFile); + Files.write(outFile.toPath(), outBytes); + + TempFile resultTemp = mock(TempFile.class); + when(resultTemp.getFile()).thenReturn(outFile); + when(redactExecuteService.execute(any(RedactExecuteRequest.class))) + .thenReturn(resultTemp); + + RedactExecuteRequest request = new RedactExecuteRequest(); + request.setFileInput(pdfFile(singlePageTextPdf("in"))); + + ResponseEntity response = controller.executeRedaction(request); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + verify(redactExecuteService, times(1)).execute(any(RedactExecuteRequest.class)); + } + + @Test + @DisplayName("null file input throws before the service is ever invoked") + void nullFileThrows() throws IOException { + RedactExecuteRequest request = new RedactExecuteRequest(); + request.setFileInput(null); + + assertThatThrownBy(() -> controller.executeRedaction(request)) + .isInstanceOf(Exception.class); + verify(redactExecuteService, never()).execute(any(RedactExecuteRequest.class)); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceMoreTest.java new file mode 100644 index 0000000000..49149e3ca7 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/RedactExecuteServiceMoreTest.java @@ -0,0 +1,758 @@ +package stirling.software.SPDF.controller.api.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +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.image.PDImageXObject; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.AfterEach; +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.springframework.web.multipart.MultipartFile; + +import stirling.software.SPDF.model.PDFText; +import stirling.software.SPDF.model.api.security.RedactExecuteRequest; +import stirling.software.SPDF.model.api.security.RedactExecuteRequest.ImageBox; +import stirling.software.SPDF.model.api.security.RedactExecuteRequest.RedactStyle; +import stirling.software.SPDF.model.api.security.RedactExecuteRequest.RedactionStrategy; +import stirling.software.SPDF.model.api.security.RedactExecuteRequest.TextRange; +import stirling.software.SPDF.pdf.parser.PageColumnLayout; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +/** + * Gap-coverage tests for {@link RedactExecuteService}. Drives the full {@code execute()} pipeline + * end to end: a mocked {@link CustomPDFDocumentFactory} hands back a freshly parsed in-memory PDF + * on each load (so the overlay-only reload branch works), while a real {@link + * ManualRedactionService} and {@link TextRedactionService} do the actual content-stream and overlay + * work. The existing {@code RedactExecuteServiceTest} only covers {@code collectRangeBlocks}; these + * tests cover the public {@code execute()} entry point, the per-operation dispatch methods, and the + * static helpers. + */ +@DisplayName("RedactExecuteService additional coverage") +class RedactExecuteServiceMoreTest { + + private static final float PAGE_W = PDRectangle.LETTER.getWidth(); + private static final float PAGE_H = PDRectangle.LETTER.getHeight(); + private static final float LEFT_X = 72f; + private static final float TOP_Y = PAGE_H - 80f; + private static final float LINE_H = 16f; + private static final float FONT_SIZE = 12f; + + private CustomPDFDocumentFactory factory; + private ManualRedactionService manualRedactionService; + private TextRedactionService textRedactionService; + private RedactExecuteService service; + + private final List createdTempFiles = new ArrayList<>(); + + @BeforeEach + void setUp() throws Exception { + factory = mock(CustomPDFDocumentFactory.class); + TempFileManager tempFileManager = mock(TempFileManager.class); + lenient() + .when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + inv -> { + File f = + Files.createTempFile( + "redact-exec-test", inv.getArgument(0)) + .toFile(); + createdTempFiles.add(f); + TempFile tf = mock(TempFile.class); + lenient().when(tf.getFile()).thenReturn(f); + lenient().when(tf.getPath()).thenReturn(f.toPath()); + return tf; + }); + + manualRedactionService = new ManualRedactionService(tempFileManager); + textRedactionService = new TextRedactionService(); + service = new RedactExecuteService(factory, manualRedactionService, textRedactionService); + } + + @AfterEach + void tearDown() { + for (File f : createdTempFiles) { + if (f != null && f.exists()) { + f.delete(); + } + } + } + + // ── helpers ────────────────────────────────────────────────────────────────────────────────── + + /** + * Wires the mocked factory so every {@code load()} call returns a brand-new PDDocument parsed + * from {@code pdfBytes}. execute() may load twice (initial scan + clean overlay reload), so a + * fresh document each time is essential. + */ + private void factoryReturns(byte[] pdfBytes) throws IOException { + lenient() + .when(factory.load(any(MultipartFile.class))) + .thenAnswer(inv -> Loader.loadPDF(pdfBytes)); + } + + private RedactExecuteRequest requestFor(byte[] pdfBytes) { + RedactExecuteRequest req = new RedactExecuteRequest(); + req.setFileInput( + new org.springframework.mock.web.MockMultipartFile( + "fileInput", "in.pdf", "application/pdf", pdfBytes)); + return req; + } + + private byte[] singlePageTextPdf(String... lines) throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE); + for (int i = 0; i < lines.length; i++) { + cs.beginText(); + cs.newLineAtOffset(LEFT_X, TOP_Y - i * LINE_H); + cs.showText(lines[i]); + cs.endText(); + } + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + return baos.toByteArray(); + } + } + + private byte[] twoPageTextPdf() throws IOException { + try (PDDocument doc = new PDDocument()) { + for (int p = 0; p < 2; p++) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE); + cs.beginText(); + cs.newLineAtOffset(LEFT_X, TOP_Y); + cs.showText("page " + p + " has SECRET content here"); + cs.endText(); + } + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + return baos.toByteArray(); + } + } + + private byte[] pdfWithImage() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + // 4x4 solid red image so PageImageLocator records exactly one image box. + java.awt.image.BufferedImage img = + new java.awt.image.BufferedImage( + 4, 4, java.awt.image.BufferedImage.TYPE_INT_RGB); + java.awt.Graphics2D g = img.createGraphics(); + g.setColor(java.awt.Color.RED); + g.fillRect(0, 0, 4, 4); + g.dispose(); + PDImageXObject pdImage = PDImageXObject.createFromByteArray(doc, toPng(img), "img"); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.drawImage(pdImage, 100, 500, 80, 80); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE); + cs.beginText(); + cs.newLineAtOffset(LEFT_X, 200); + cs.showText("text under an image"); + cs.endText(); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + return baos.toByteArray(); + } + } + + private static byte[] toPng(java.awt.image.BufferedImage img) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + javax.imageio.ImageIO.write(img, "png", out); + return out.toByteArray(); + } + + /** Loads the bytes saved into the returned TempFile and extracts page text. */ + private String extractText(TempFile out) throws IOException { + try (PDDocument doc = Loader.loadPDF(out.getFile())) { + return new PDFTextStripper().getText(doc); + } + } + + private int pageCount(TempFile out) throws IOException { + try (PDDocument doc = Loader.loadPDF(out.getFile())) { + return doc.getNumberOfPages(); + } + } + + // ── validation / guard branches ────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Guard clauses") + class GuardClauses { + + @Test + @DisplayName("no redaction targets at all throws IllegalArgumentException") + void noTargetsThrows() { + RedactExecuteRequest req = new RedactExecuteRequest(); + // Provide a non-null file so we get past that guard and hit the no-targets guard first. + req.setFileInput( + new org.springframework.mock.web.MockMultipartFile( + "fileInput", "x.pdf", "application/pdf", new byte[] {1})); + assertThatThrownBy(() -> service.execute(req)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("null file input with targets throws (wrapped as RuntimeException)") + void nullFileInputThrows() { + RedactExecuteRequest req = new RedactExecuteRequest(); + req.setTextValues(List.of("SECRET")); + req.setFileInput(null); + // createFileNullOrEmptyException is thrown inside the try, so it is wrapped. + assertThatThrownBy(() -> service.execute(req)).isInstanceOf(RuntimeException.class); + } + + @Test + @DisplayName("factory load failure is wrapped in a RuntimeException") + void loadFailureWrapped() throws IOException { + lenient() + .when(factory.load(any(MultipartFile.class))) + .thenThrow(new IOException("boom")); + RedactExecuteRequest req = requestFor(new byte[] {0x25, 0x50, 0x44, 0x46}); // "%PDF" + req.setTextValues(List.of("SECRET")); + assertThatThrownBy(() -> service.execute(req)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to perform PDF redaction"); + } + } + + // ── text + regex redaction ─────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Text and regex redaction") + class TextAndRegex { + + @Test + @DisplayName("literal text value is removed from the output content stream") + void literalTextRemoved() throws IOException { + byte[] pdf = singlePageTextPdf("Keep this", "Hide the SECRET word", "Keep that"); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setTextValues(List.of("SECRET")); + + try (TempFile out = service.execute(req)) { + String text = extractText(out); + assertThat(text).doesNotContain("SECRET"); + assertThat(text).contains("Keep this"); + } + } + + @Test + @DisplayName("regex pattern redacts matching digit runs") + void regexRemovesDigits() throws IOException { + byte[] pdf = singlePageTextPdf("Order 12345 confirmed"); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setRegexPatterns(List.of("\\d+")); + + try (TempFile out = service.execute(req)) { + String text = extractText(out); + assertThat(text).doesNotContain("12345"); + } + } + + @Test + @DisplayName("text + regex together produce a single combined scan pass") + void textAndRegexCombined() throws IOException { + byte[] pdf = singlePageTextPdf("name SECRET id 999 end"); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setTextValues(List.of("SECRET")); + req.setRegexPatterns(List.of("\\d+")); + + try (TempFile out = service.execute(req)) { + String text = extractText(out); + assertThat(text).doesNotContain("SECRET"); + assertThat(text).doesNotContain("999"); + } + } + + @Test + @DisplayName("no-match term still finalizes and returns a saved document") + void noMatchStillReturns() throws IOException { + byte[] pdf = singlePageTextPdf("nothing sensitive here"); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setTextValues(List.of("ABSENT-TERM")); + + try (TempFile out = service.execute(req)) { + assertThat(out.getFile()).exists(); + assertThat(out.getFile().length()).isGreaterThan(0L); + } + } + + @Test + @DisplayName("multi-page document redacts the term on every page") + void multiPageRedaction() throws IOException { + byte[] pdf = twoPageTextPdf(); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setTextValues(List.of("SECRET")); + + try (TempFile out = service.execute(req)) { + assertThat(pageCount(out)).isEqualTo(2); + assertThat(extractText(out)).doesNotContain("SECRET"); + } + } + + @Test + @DisplayName("blank-only text values are cleaned away and treated as no text op") + void blankTextValuesCleaned() throws IOException { + byte[] pdf = singlePageTextPdf("keep SECRET keep"); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + // textValues blank, but a wipePages target keeps execute() from the no-targets guard. + req.setTextValues(List.of(" ", "")); + req.setWipePages(List.of(1)); + + try (TempFile out = service.execute(req)) { + assertThat(out.getFile()).exists(); + } + } + } + + // ── strategies / style ─────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Strategies and style") + class StrategiesAndStyle { + + @Test + @DisplayName("OVERLAY_ONLY strategy skips content-stream rewriting but still overlays") + void overlayOnlyStrategy() throws IOException { + byte[] pdf = singlePageTextPdf("overlay SECRET only mode"); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setTextValues(List.of("SECRET")); + RedactStyle style = new RedactStyle(); + style.setStrategy(RedactionStrategy.OVERLAY_ONLY); + req.setStyle(style); + + try (TempFile out = service.execute(req)) { + // Overlay-only draws a box over the text but does not rewrite the stream, so the + // glyphs are still extractable underneath the box. + assertThat(extractText(out)).contains("SECRET"); + } + } + + @Test + @DisplayName("IMAGE_FINALIZE strategy rasterizes output (text no longer extractable)") + void imageFinalizeStrategy() throws IOException { + byte[] pdf = singlePageTextPdf("rasterize SECRET to image"); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setTextValues(List.of("SECRET")); + RedactStyle style = new RedactStyle(); + style.setStrategy(RedactionStrategy.IMAGE_FINALIZE); + req.setStyle(style); + + try (TempFile out = service.execute(req)) { + assertThat(extractText(out).trim()).isEmpty(); + assertThat(pageCount(out)).isEqualTo(1); + } + } + + @Test + @DisplayName("convertToImage flag rasterizes output") + void convertToImageFlag() throws IOException { + byte[] pdf = singlePageTextPdf("convert SECRET image flag"); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setTextValues(List.of("SECRET")); + RedactStyle style = new RedactStyle(); + style.setConvertToImage(true); + req.setStyle(style); + + try (TempFile out = service.execute(req)) { + assertThat(extractText(out).trim()).isEmpty(); + } + } + + @Test + @DisplayName("custom hex color and padding are accepted and applied") + void customColorAndPadding() throws IOException { + byte[] pdf = singlePageTextPdf("color SECRET padding"); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setTextValues(List.of("SECRET")); + RedactStyle style = new RedactStyle(); + style.setColor("#FF0000"); + style.setPadding(3f); + req.setStyle(style); + + try (TempFile out = service.execute(req)) { + assertThat(out.getFile()).exists(); + assertThat(extractText(out)).doesNotContain("SECRET"); + } + } + + @Test + @DisplayName("null style falls back to defaults") + void nullStyleUsesDefaults() throws IOException { + byte[] pdf = singlePageTextPdf("default SECRET style"); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setTextValues(List.of("SECRET")); + req.setStyle(null); + + try (TempFile out = service.execute(req)) { + assertThat(extractText(out)).doesNotContain("SECRET"); + } + } + } + + // ── non-text operations ────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Non-text operations") + class NonTextOps { + + @Test + @DisplayName("wipePages clears a full page of content") + void wipePagesClearsContent() throws IOException { + byte[] pdf = singlePageTextPdf("this whole page goes away"); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setWipePages(List.of(1)); // 1-indexed + + try (TempFile out = service.execute(req)) { + // After a wipe the page content is replaced with a filled rectangle, so the + // original words must be gone. + assertThat(extractText(out)).doesNotContain("whole page goes away"); + } + } + + @Test + @DisplayName("wipePages ignores out-of-range and non-positive page numbers") + void wipePagesOutOfRangeIgnored() throws IOException { + byte[] pdf = singlePageTextPdf("survives the wipe"); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + // page 0 dropped (non-positive), page 99 dropped (out of range) -> nothing wiped. + req.setWipePages(new ArrayList<>(List.of(0, 99))); + // keep a real target so we are past the no-targets guard via imageBoxes. + + try (TempFile out = service.execute(req)) { + assertThat(extractText(out)).contains("survives the wipe"); + } + } + + @Test + @DisplayName("imageBox coordinate overlay is drawn without error") + void imageBoxRedaction() throws IOException { + byte[] pdf = singlePageTextPdf("box redaction target"); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setImageBoxes(List.of(new ImageBox(0, 50f, 50f, 200f, 120f))); + + try (TempFile out = service.execute(req)) { + assertThat(out.getFile()).exists(); + assertThat(out.getFile().length()).isGreaterThan(0L); + } + } + + @Test + @DisplayName("redactImagePages with explicit page detects and redacts images") + void redactImagePagesExplicit() throws IOException { + byte[] pdf = pdfWithImage(); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setRedactImagePages(List.of(1)); // 1-indexed page one + + try (TempFile out = service.execute(req)) { + assertThat(out.getFile()).exists(); + assertThat(out.getFile().length()).isGreaterThan(0L); + } + } + + @Test + @DisplayName("redactImagePages with empty list scans every page") + void redactImagePagesEmptyMeansAll() throws IOException { + byte[] pdf = pdfWithImage(); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setRedactImagePages(new ArrayList<>()); // empty -> all pages + + try (TempFile out = service.execute(req)) { + assertThat(out.getFile()).exists(); + } + } + + @Test + @DisplayName("range redaction between two anchors produces a saved document") + void rangeRedaction() throws IOException { + byte[] pdf = + singlePageTextPdf( + "START anchor line", "middle one", "middle two", "END anchor line"); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setRanges(List.of(new TextRange("START anchor", "END anchor"))); + + try (TempFile out = service.execute(req)) { + assertThat(out.getFile()).exists(); + assertThat(out.getFile().length()).isGreaterThan(0L); + } + } + + @Test + @DisplayName("open-ended range (blank end) redacts to end of document") + void openEndedRange() throws IOException { + byte[] pdf = singlePageTextPdf("BEGIN here", "tail one", "tail two"); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setRanges(List.of(new TextRange("BEGIN here", ""))); + + try (TempFile out = service.execute(req)) { + assertThat(out.getFile()).exists(); + } + } + + @Test + @DisplayName("range with unknown start anchor is skipped gracefully") + void rangeUnknownStartSkipped() throws IOException { + byte[] pdf = singlePageTextPdf("only real content"); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setRanges(List.of(new TextRange("NONEXISTENT-START", "ALSO-MISSING"))); + + try (TempFile out = service.execute(req)) { + // Range not found -> nothing redacted, original text survives. + assertThat(extractText(out)).contains("only real content"); + } + } + + @Test + @DisplayName("multiple operations combine in one execute call") + void combinedOperations() throws IOException { + byte[] pdf = + singlePageTextPdf("SECRET top", "box me here", "normal tail line here too"); + factoryReturns(pdf); + + RedactExecuteRequest req = requestFor(pdf); + req.setTextValues(List.of("SECRET")); + req.setImageBoxes(List.of(new ImageBox(0, 40f, 40f, 150f, 80f))); + + try (TempFile out = service.execute(req)) { + assertThat(extractText(out)).doesNotContain("SECRET"); + } + } + } + + // ── static helper: inColumnZone (package-private) ──────────────────────────────────────────── + + @Nested + @DisplayName("inColumnZone reading-order predicate") + class InColumnZone { + + @Test + @DisplayName("page strictly between start and end pages is always inside") + void middlePageAlwaysInside() { + boolean in = RedactExecuteService.inColumnZone(1, 0, 100f, 110f, 0, 0, 50f, 2, 0, 60f); + assertThat(in).isTrue(); + } + + @Test + @DisplayName("same start/end page, same column: only the y band is included") + void sameColumnYBand() { + // startY=50, endY=200, col 0 on a single page. + assertThat(RedactExecuteService.inColumnZone(0, 0, 90f, 100f, 0, 0, 50f, 0, 0, 200f)) + .isTrue(); + assertThat(RedactExecuteService.inColumnZone(0, 0, 10f, 20f, 0, 0, 50f, 0, 0, 200f)) + .as("above the start y must be excluded") + .isFalse(); + assertThat(RedactExecuteService.inColumnZone(0, 1, 90f, 100f, 0, 0, 50f, 0, 0, 200f)) + .as("wrong column must be excluded") + .isFalse(); + } + + @Test + @DisplayName("same page, start column left of end column spans the columns in between") + void crossColumnSpan() { + // startCol=0, endCol=2. Column 1 (middle) is fully included. + assertThat(RedactExecuteService.inColumnZone(0, 1, 0f, 500f, 0, 0, 50f, 0, 2, 200f)) + .isTrue(); + // start column included only from startY down. + assertThat(RedactExecuteService.inColumnZone(0, 0, 0f, 60f, 0, 0, 50f, 0, 2, 200f)) + .isTrue(); + assertThat(RedactExecuteService.inColumnZone(0, 0, 0f, 40f, 0, 0, 50f, 0, 2, 200f)) + .as("start column above startY excluded") + .isFalse(); + // out-of-range column excluded. + assertThat(RedactExecuteService.inColumnZone(0, 3, 0f, 60f, 0, 0, 50f, 0, 2, 200f)) + .isFalse(); + } + + @Test + @DisplayName("first of multiple pages: start column from startY, later columns whole") + void startPageMultiPage() { + // pageIdx == startPage (0), endPage is 2. + assertThat(RedactExecuteService.inColumnZone(0, 0, 0f, 60f, 0, 0, 50f, 2, 1, 200f)) + .isTrue(); + assertThat(RedactExecuteService.inColumnZone(0, 1, 0f, 10f, 0, 0, 50f, 2, 1, 200f)) + .as("a column after the start column is wholly included on the start page") + .isTrue(); + } + + @Test + @DisplayName("last of multiple pages: end column up to endY, earlier columns whole") + void endPageMultiPage() { + // pageIdx == endPage (2), startPage is 0. + assertThat(RedactExecuteService.inColumnZone(2, 1, 0f, 150f, 0, 0, 50f, 2, 1, 200f)) + .isTrue(); + assertThat(RedactExecuteService.inColumnZone(2, 1, 0f, 250f, 0, 0, 50f, 2, 1, 200f)) + .as("end column below endY excluded") + .isFalse(); + assertThat(RedactExecuteService.inColumnZone(2, 0, 0f, 9999f, 0, 0, 50f, 2, 1, 200f)) + .as("a column before the end column is wholly included on the end page") + .isTrue(); + } + } + + // ── collectRangeBlocks gap branches (private collaborators via the public method) ──────────── + + @Nested + @DisplayName("collectRangeBlocks gap branches") + class CollectRangeBlocksGaps { + + @Test + @DisplayName("open-ended range (blank end) collects blocks to the document end") + void openEndedCollectsToEnd() throws IOException { + byte[] pdf = singlePageTextPdf("OPEN start", "body a", "body b"); + try (PDDocument doc = Loader.loadPDF(pdf)) { + Map cache = new HashMap<>(); + List blocks = service.collectRangeBlocks(doc, "OPEN start", "", cache); + assertThat(blocks).as("open-ended range must collect blocks").isNotEmpty(); + } + } + + @Test + @DisplayName("end anchor that never occurs after the start yields no blocks") + void endNotFoundYieldsEmpty() throws IOException { + byte[] pdf = singlePageTextPdf("ALPHA marker", "filler"); + try (PDDocument doc = Loader.loadPDF(pdf)) { + Map cache = new HashMap<>(); + List blocks = + service.collectRangeBlocks(doc, "ALPHA marker", "OMEGA-MISSING", cache); + assertThat(blocks).isEmpty(); + } + } + } + + // ── private static helpers via reflection ──────────────────────────────────────────────────── + + @Nested + @DisplayName("private static helpers") + class PrivateStaticHelpers { + + @Test + @DisplayName("collapseLetterSpacing rejoins single spaced letters into words") + void collapseLetterSpacing() throws Exception { + Method m = + RedactExecuteService.class.getDeclaredMethod( + "collapseLetterSpacing", String.class); + m.setAccessible(true); + assertThat(m.invoke(null, "T a b l e of c o n t e n t s")) + .isEqualTo("Table of contents"); + // A normal sentence with multi-letter tokens is preserved. + assertThat(m.invoke(null, "already normal text")).isEqualTo("already normal text"); + } + + @Test + @DisplayName("punctuationTolerantRegex joins tokens with \\\\W* and quotes them") + void punctuationTolerantRegex() throws Exception { + Method m = + RedactExecuteService.class.getDeclaredMethod( + "punctuationTolerantRegex", String.class); + m.setAccessible(true); + Object multi = m.invoke(null, "foo: bar"); + assertThat(multi).asString().contains("\\W*"); + // Fewer than two tokens -> null. + assertThat(m.invoke(null, "single")).isNull(); + } + + @Test + @DisplayName("toZeroBasedIndices drops nulls and non-positive page numbers") + @SuppressWarnings("unchecked") + void toZeroBasedIndices() throws Exception { + Method m = + RedactExecuteService.class.getDeclaredMethod("toZeroBasedIndices", List.class); + m.setAccessible(true); + List in = new ArrayList<>(); + in.add(1); + in.add(0); + in.add(null); + in.add(3); + List out = (List) m.invoke(null, in); + assertThat(out).containsExactly(0, 2); + assertThat((List) m.invoke(null, (Object) null)).isEmpty(); + } + + @Test + @DisplayName("cleanStrings trims, drops blanks and nulls") + void cleanStrings() throws Exception { + Method m = RedactExecuteService.class.getDeclaredMethod("cleanStrings", List.class); + m.setAccessible(true); + List in = new ArrayList<>(); + in.add(" keep "); + in.add(""); + in.add(null); + in.add(" "); + String[] out = (String[]) m.invoke(null, in); + assertThat(out).containsExactly("keep"); + assertThat((String[]) m.invoke(null, (Object) null)).isEmpty(); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceExtraTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceExtraTest.java new file mode 100644 index 0000000000..6c94dd3a24 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceExtraTest.java @@ -0,0 +1,598 @@ +package stirling.software.SPDF.controller.api.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.pdfbox.contentstream.operator.Operator; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSFloat; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSString; +import org.apache.pdfbox.pdfparser.PDFStreamParser; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.common.PDStream; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.SPDF.model.PDFText; + +/** + * Further gap-coverage tests for {@link TextRedactionService}, complementing {@code + * TextRedactionServiceTest} and {@code TextRedactionServiceMoreTest}. These target branches the + * other two suites leave untouched: case-sensitive vs regex find, multi-term and multi-match within + * one segment, the kerning ({@code adjustment != 0}) path that rewrites a {@code Tj} into a {@code + * TJ} array, nested Form XObject traversal, pages with no resources, and the private width helpers + * exercised directly via reflection. + */ +@DisplayName("TextRedactionService extra coverage") +class TextRedactionServiceExtraTest { + + private static final float FONT_SIZE = 12f; + private static final float LEFT_X = 72f; + private static final float TOP_Y = PDRectangle.LETTER.getHeight() - 80f; + + private final TextRedactionService service = new TextRedactionService(); + + private PDFont helvetica() { + return new PDType1Font(Standard14Fonts.FontName.HELVETICA); + } + + private List parseTokens(PDPage page) throws IOException { + PDFStreamParser parser = new PDFStreamParser(page); + List tokens = new ArrayList<>(); + Object t; + while ((t = parser.parseNextToken()) != null) { + tokens.add(t); + } + return tokens; + } + + private String tokensText(List tokens) { + StringBuilder sb = new StringBuilder(); + for (Object token : tokens) { + if (token instanceof COSString cs) { + sb.append(cs.getString()); + } else if (token instanceof COSArray arr) { + for (COSBase el : arr) { + if (el instanceof COSString cs) { + sb.append(cs.getString()); + } + } + } + } + return sb.toString(); + } + + /** Single page, one Tj line per supplied text line, Helvetica 12. */ + private PDDocument buildDoc(String... lines) throws IOException { + PDDocument doc = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.setFont(helvetica(), FONT_SIZE); + for (int i = 0; i < lines.length; i++) { + cs.beginText(); + cs.newLineAtOffset(LEFT_X, TOP_Y - i * 16f); + cs.showText(lines[i]); + cs.endText(); + } + } + return doc; + } + + /** Page whose content stream is exactly {@code rawContent}, font F1=Helvetica. */ + private PDDocument docWithRawContent(String rawContent) throws IOException { + PDDocument doc = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + PDResources resources = new PDResources(); + resources.put(COSName.getPDFName("F1"), helvetica()); + page.setResources(resources); + + PDStream stream = new PDStream(doc); + try (var out = stream.createOutputStream()) { + out.write(rawContent.getBytes(StandardCharsets.ISO_8859_1)); + } + page.setContents(stream); + return doc; + } + + // ── findTextToRedact: matching modes ───────────────────────────────────────────────────────── + + @Nested + @DisplayName("findTextToRedact matching modes") + class FindModes { + + @Test + @DisplayName("literal search is case-insensitive and matches every casing of the term") + void literalIsCaseInsensitive() throws IOException { + try (PDDocument doc = buildDoc("Secret and secret and SECRET")) { + Map> result = + service.findTextToRedact(doc, new String[] {"secret"}, false, false); + // Patterns are compiled CASE_INSENSITIVE, so all three occurrences match. + assertThat(result.get(0)).hasSize(3); + } + } + + @Test + @DisplayName("two distinct literal terms both produce hits on the page") + void twoDistinctTerms() throws IOException { + try (PDDocument doc = buildDoc("alpha then bravo then charlie")) { + Map> result = + service.findTextToRedact( + doc, new String[] {"alpha", "charlie"}, false, false); + assertThat(result.get(0)).hasSize(2); + } + } + + @Test + @DisplayName("a regex character class matches multiple distinct vowels") + void regexCharacterClass() throws IOException { + try (PDDocument doc = buildDoc("abcde")) { + Map> result = + service.findTextToRedact(doc, new String[] {"[ae]"}, true, false); + // 'a' and 'e' both match -> two single-character hits. + assertThat(result.get(0)).hasSize(2); + } + } + + @Test + @DisplayName("blank-only mixed with a real term still searches the real term") + void blankMixedWithRealTerm() throws IOException { + try (PDDocument doc = buildDoc("keep SECRET here")) { + Map> result = + service.findTextToRedact(doc, new String[] {" ", "SECRET"}, false, false); + assertThat(result.get(0)).hasSize(1); + } + } + } + + // ── createTokensWithoutTargetText structural branches ──────────────────────────────────────── + + @Nested + @DisplayName("createTokensWithoutTargetText structural branches") + class TokenStructural { + + @Test + @DisplayName("page with null resources still parses and redacts the matched Tj text") + void nullResourcesStillRedacts() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + // No resources set; the content stream references no real font. + String raw = "BT 72 700 Td (SECRET) Tj ET"; + PDStream stream = new PDStream(doc); + try (var out = stream.createOutputStream()) { + out.write(raw.getBytes(StandardCharsets.ISO_8859_1)); + } + page.setContents(stream); + + List tokens = + service.createTokensWithoutTargetText( + doc, page, Set.of("SECRET"), false, false); + assertThat(tokensText(tokens)).doesNotContain("SECRET"); + } + } + + @Test + @DisplayName("a match inside a Tj segment is redacted and surrounding text survives") + void multipleMatchesOneSegment() throws IOException { + try (PDDocument doc = docWithRawContent("BT /F1 12 Tf 72 700 Td (xAAxAAx) Tj ET")) { + PDPage page = doc.getPage(0); + List tokens = + service.createTokensWithoutTargetText( + doc, page, Set.of("AA"), false, false); + String redacted = tokensText(tokens); + // The segment was rewritten away from the original literal. + assertThat(redacted).isNotEqualTo("xAAxAAx"); + // Redaction replaces matched runs with whitespace, so at least one "AA" is gone + // (the leading occurrence) and the surrounding x characters survive. + assertThat(redacted.split("AA", -1).length - 1).isLessThan(2); + assertThat(redacted).startsWith("x "); + assertThat(redacted).contains("x"); + } + } + + @Test + @DisplayName("a second Tf operator updates the active font for later segments") + void secondTfUpdatesFont() throws IOException { + PDDocument doc = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + PDResources resources = new PDResources(); + resources.put(COSName.getPDFName("F1"), helvetica()); + resources.put( + COSName.getPDFName("F2"), + new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN)); + page.setResources(resources); + String raw = "BT /F1 12 Tf 72 700 Td (first) Tj /F2 18 Tf 0 -20 Td (SECRET) Tj ET"; + PDStream stream = new PDStream(doc); + try (var out = stream.createOutputStream()) { + out.write(raw.getBytes(StandardCharsets.ISO_8859_1)); + } + page.setContents(stream); + try (doc) { + List tokens = + service.createTokensWithoutTargetText( + doc, page, Set.of("SECRET"), false, false); + assertThat(tokensText(tokens)).doesNotContain("SECRET"); + assertThat(tokensText(tokens)).contains("first"); + } + } + } + + // ── nested Form XObject traversal ──────────────────────────────────────────────────────────── + + @Nested + @DisplayName("nested Form XObject traversal") + class NestedXObjects { + + @Test + @DisplayName("a match in a form nested two levels deep is reached and rewritten") + void nestedTwoLevelsDeep() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + + // Inner form shows SECRET. + PDFormXObject inner = new PDFormXObject(doc); + inner.setResources(new PDResources()); + inner.getResources().put(COSName.getPDFName("F1"), helvetica()); + inner.setBBox(new PDRectangle(0, 0, 200, 50)); + try (var out = inner.getStream().createOutputStream()) { + out.write( + "BT /F1 12 Tf 0 10 Td (SECRET) Tj ET" + .getBytes(StandardCharsets.ISO_8859_1)); + } + + // Outer form references inner via Do. + PDFormXObject outer = new PDFormXObject(doc); + PDResources outerRes = new PDResources(); + COSName innerName = outerRes.add(inner); + outer.setResources(outerRes); + outer.setBBox(new PDRectangle(0, 0, 200, 50)); + try (var out = outer.getStream().createOutputStream()) { + out.write( + ("/" + innerName.getName() + " Do") + .getBytes(StandardCharsets.ISO_8859_1)); + } + + PDResources pageRes = new PDResources(); + COSName outerName = pageRes.add(outer); + page.setResources(pageRes); + PDStream pageStream = new PDStream(doc); + try (var out = pageStream.createOutputStream()) { + out.write( + ("/" + outerName.getName() + " Do") + .getBytes(StandardCharsets.ISO_8859_1)); + } + page.setContents(pageStream); + + service.createTokensWithoutTargetText(doc, page, Set.of("SECRET"), false, false); + + // The deep traversal must have rewritten the inner form's content stream. + assertThat(inner.getCOSObject().containsKey(COSName.CONTENTS)).isTrue(); + } + } + + @Test + @DisplayName("a form XObject with no resources is skipped without error") + void formWithoutResourcesSkipped() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + + PDFormXObject form = new PDFormXObject(doc); + form.setBBox(new PDRectangle(0, 0, 100, 50)); + // Intentionally no resources on the form. + try (var out = form.getStream().createOutputStream()) { + out.write("q Q".getBytes(StandardCharsets.ISO_8859_1)); + } + + PDResources pageRes = new PDResources(); + COSName formName = pageRes.add(form); + page.setResources(pageRes); + PDStream pageStream = new PDStream(doc); + try (var out = pageStream.createOutputStream()) { + out.write( + ("/" + formName.getName() + " Do") + .getBytes(StandardCharsets.ISO_8859_1)); + } + page.setContents(pageStream); + + List tokens = + service.createTokensWithoutTargetText( + doc, page, Set.of("SECRET"), false, false); + assertThat(tokens).isNotNull(); + } + } + } + + // ── kerning / adjustment path in modifyTokenForRedaction ───────────────────────────────────── + + @Nested + @DisplayName("modifyTokenForRedaction adjustment branches") + class ModifyTokenAdjustment { + + @Test + @DisplayName("a non-zero width adjustment rewrites a Tj into a TJ array with kerning") + void adjustmentRewritesToTjArray() throws Exception { + List tokens = new ArrayList<>(); + tokens.add(new COSString("KEEP")); + tokens.add(Operator.getOperator("Tj")); + + TextRedactionService.TextSegment segment = + new TextRedactionService.TextSegment( + 0, "Tj", "KEEP", 0, 4, helvetica(), FONT_SIZE); + + Method m = + TextRedactionService.class.getDeclaredMethod( + "modifyTokenForRedaction", + List.class, + TextRedactionService.TextSegment.class, + String.class, + float.class, + List.class); + m.setAccessible(true); + // A clearly non-zero adjustment forces the COSArray + kerning branch. + m.invoke(service, tokens, segment, "AB", 5.0f, List.of()); + + assertThat(tokens.get(0)).isInstanceOf(COSArray.class); + COSArray arr = (COSArray) tokens.get(0); + boolean hasKern = false; + for (COSBase el : arr) { + if (el instanceof COSFloat) { + hasKern = true; + } + } + assertThat(hasKern).as("kerning float should be appended to the TJ array").isTrue(); + // The trailing Tj operator should have been switched to TJ. + assertThat(tokens.get(1)).isInstanceOf(Operator.class); + assertThat(((Operator) tokens.get(1)).getName()).isEqualTo("TJ"); + } + + @Test + @DisplayName("empty replacement text with ~zero adjustment sets the shared empty COSString") + void emptyReplacementZeroAdjustment() throws Exception { + List tokens = new ArrayList<>(); + tokens.add(new COSString("SECRET")); + tokens.add(Operator.getOperator("Tj")); + + TextRedactionService.TextSegment segment = + new TextRedactionService.TextSegment( + 0, "Tj", "SECRET", 0, 6, helvetica(), FONT_SIZE); + + Method m = + TextRedactionService.class.getDeclaredMethod( + "modifyTokenForRedaction", + List.class, + TextRedactionService.TextSegment.class, + String.class, + float.class, + List.class); + m.setAccessible(true); + m.invoke(service, tokens, segment, "", 0f, List.of()); + + assertThat(tokens.get(0)).isInstanceOf(COSString.class); + assertThat(((COSString) tokens.get(0)).getString()).isEmpty(); + } + + @Test + @DisplayName("the ' operator with a non-zero adjustment is also rewritten to a TJ array") + void apostropheAdjustmentRewrites() throws Exception { + List tokens = new ArrayList<>(); + tokens.add(new COSString("WORD")); + tokens.add(Operator.getOperator("'")); + + TextRedactionService.TextSegment segment = + new TextRedactionService.TextSegment( + 0, "'", "WORD", 0, 4, helvetica(), FONT_SIZE); + + Method m = + TextRedactionService.class.getDeclaredMethod( + "modifyTokenForRedaction", + List.class, + TextRedactionService.TextSegment.class, + String.class, + float.class, + List.class); + m.setAccessible(true); + m.invoke(service, tokens, segment, "X", 4.0f, List.of()); + + assertThat(tokens.get(0)).isInstanceOf(COSArray.class); + assertThat(((Operator) tokens.get(1)).getName()).isEqualTo("TJ"); + } + } + + // ── createRedactedTJArray edge branches ────────────────────────────────────────────────────── + + @Nested + @DisplayName("createRedactedTJArray edge branches") + class RedactedTjArray { + + @Test + @DisplayName("non-COSString elements (kerning numbers) are preserved in order") + void preservesNumberElements() throws Exception { + COSArray original = new COSArray(); + original.add(new COSString("AA")); + original.add(new COSFloat(-25f)); + original.add(new COSString("BB")); + + TextRedactionService.TextSegment segment = + new TextRedactionService.TextSegment( + 0, "TJ", "AABB", 0, 4, helvetica(), FONT_SIZE); + List matches = + List.of(new TextRedactionService.MatchRange(0, 2)); // "AA" + + Method m = + TextRedactionService.class.getDeclaredMethod( + "createRedactedTJArray", + COSArray.class, + TextRedactionService.TextSegment.class, + List.class); + m.setAccessible(true); + COSArray result = (COSArray) m.invoke(service, original, segment, matches); + + boolean sawFloat = false; + for (COSBase el : result) { + if (el instanceof COSFloat) { + sawFloat = true; + } + } + assertThat(sawFloat).as("original kerning number must be retained").isTrue(); + } + + @Test + @DisplayName("a TJ array with no overlapping match is returned essentially unchanged") + void noMatchLeavesTextIntact() throws Exception { + COSArray original = new COSArray(); + original.add(new COSString("hello")); + original.add(new COSString("world")); + + TextRedactionService.TextSegment segment = + new TextRedactionService.TextSegment( + 0, "TJ", "helloworld", 0, 10, helvetica(), FONT_SIZE); + List matches = + List.of(new TextRedactionService.MatchRange(50, 60)); // out of range + + Method m = + TextRedactionService.class.getDeclaredMethod( + "createRedactedTJArray", + COSArray.class, + TextRedactionService.TextSegment.class, + List.class); + m.setAccessible(true); + COSArray result = (COSArray) m.invoke(service, original, segment, matches); + + StringBuilder sb = new StringBuilder(); + for (COSBase el : result) { + if (el instanceof COSString cs) sb.append(cs.getString()); + } + assertThat(sb.toString()).isEqualTo("helloworld"); + } + } + + // ── private width helpers via reflection ───────────────────────────────────────────────────── + + @Nested + @DisplayName("private width helpers via reflection") + class WidthHelpers { + + private float invokeFloat(String name, Object... args) throws Exception { + Class[] types = new Class[] {PDFont.class, String.class}; + Method m = TextRedactionService.class.getDeclaredMethod(name, types); + m.setAccessible(true); + return (float) m.invoke(service, args); + } + + @Test + @DisplayName("calculateConservativeWidth scales linearly at 500 units per character") + void conservativeWidthLinear() throws Exception { + float w = invokeFloat("calculateConservativeWidth", helvetica(), "abcd"); + assertThat(w).isEqualTo(4 * 500f); + } + + @Test + @DisplayName("calculateCharacterBasedWidth returns a positive width for normal text") + void characterBasedWidthPositive() throws Exception { + float w = invokeFloat("calculateCharacterBasedWidth", helvetica(), "Hello"); + assertThat(w).isGreaterThan(0f); + } + + @Test + @DisplayName("calculateFallbackWidth returns a positive width using font metrics") + void fallbackWidthPositive() throws Exception { + float w = invokeFloat("calculateFallbackWidth", helvetica(), "Hello"); + assertThat(w).isGreaterThan(0f); + } + + @Test + @DisplayName("safeGetStringWidth returns 0 for null/empty inputs") + void safeWidthZeroForEmpty() throws Exception { + assertThat(invokeFloat("safeGetStringWidth", helvetica(), "")).isZero(); + Method m = + TextRedactionService.class.getDeclaredMethod( + "safeGetStringWidth", PDFont.class, String.class); + m.setAccessible(true); + assertThat((float) m.invoke(service, helvetica(), null)).isZero(); + assertThat((float) m.invoke(service, (PDFont) null, "x")).isZero(); + } + + @Test + @DisplayName("safeGetStringWidth returns a positive width for a reliable font") + void safeWidthPositive() throws Exception { + float w = invokeFloat("safeGetStringWidth", helvetica(), "Word"); + assertThat(w).isGreaterThan(0f); + } + } + + // ── createAlternativePlaceholder via reflection ────────────────────────────────────────────── + + @Nested + @DisplayName("createAlternativePlaceholder via reflection") + class AlternativePlaceholder { + + @Test + @DisplayName("Helvetica supports space, so output is a bounded run of spaces") + void boundedSpaces() throws Exception { + Method m = + TextRedactionService.class.getDeclaredMethod( + "createAlternativePlaceholder", + String.class, + float.class, + PDFont.class, + float.class); + m.setAccessible(true); + String result = (String) m.invoke(service, "hidden", 20f, helvetica(), FONT_SIZE); + assertThat(result.chars().allMatch(c -> c == ' ')).isTrue(); + assertThat(result.length()).isLessThanOrEqualTo("hidden".length() * 2); + } + } + + // ── extractTextSegments via reflection ─────────────────────────────────────────────────────── + + @Nested + @DisplayName("extractTextSegments via reflection") + class ExtractSegments { + + @SuppressWarnings("unchecked") + @Test + @DisplayName("a Tf operator sets font and size on the segments that follow it") + void tfSetsFontAndSize() throws Exception { + try (PDDocument doc = docWithRawContent("BT /F1 14 Tf 72 700 Td (hello) Tj ET")) { + PDPage page = doc.getPage(0); + List tokens = parseTokens(page); + + Method m = + TextRedactionService.class.getDeclaredMethod( + "extractTextSegments", PDPage.class, List.class); + m.setAccessible(true); + List segments = + (List) m.invoke(service, page, tokens); + + assertThat(segments).isNotEmpty(); + TextRedactionService.TextSegment first = segments.get(0); + assertThat(first.getText()).isEqualTo("hello"); + assertThat(first.getFontSize()).isEqualTo(14f); + assertThat(first.getFont()).isNotNull(); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceMoreTest.java new file mode 100644 index 0000000000..ba0377a9e8 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TextRedactionServiceMoreTest.java @@ -0,0 +1,550 @@ +package stirling.software.SPDF.controller.api.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSFloat; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSString; +import org.apache.pdfbox.pdfparser.PDFStreamParser; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.common.PDStream; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.SPDF.model.PDFText; + +/** + * Gap-coverage tests for {@link TextRedactionService} targeting branches the existing {@code + * TextRedactionServiceTest} does not reach: TJ-array redaction with kerning adjustment, the {@code + * '} and {@code "} text-showing operators, Form XObject content rewriting, multi-page / multi-match + * find+replace, and the private TJ/segment helpers exercised directly via reflection. + */ +@DisplayName("TextRedactionService additional coverage") +class TextRedactionServiceMoreTest { + + private static final float FONT_SIZE = 12f; + private static final float LEFT_X = 72f; + private static final float TOP_Y = PDRectangle.LETTER.getHeight() - 80f; + + private final TextRedactionService service = new TextRedactionService(); + + private PDFont helvetica() { + return new PDType1Font(Standard14Fonts.FontName.HELVETICA); + } + + private List parseTokens(PDPage page) throws IOException { + PDFStreamParser parser = new PDFStreamParser(page); + List tokens = new ArrayList<>(); + Object t; + while ((t = parser.parseNextToken()) != null) { + tokens.add(t); + } + return tokens; + } + + private String tokensText(List tokens) { + StringBuilder sb = new StringBuilder(); + for (Object token : tokens) { + if (token instanceof COSString cs) { + sb.append(cs.getString()); + } else if (token instanceof COSArray arr) { + for (COSBase el : arr) { + if (el instanceof COSString cs) { + sb.append(cs.getString()); + } + } + } + } + return sb.toString(); + } + + /** + * Builds a single page whose content stream is exactly {@code rawContent}, font F1=Helvetica. + */ + private PDDocument docWithRawContent(String rawContent) throws IOException { + PDDocument doc = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + PDResources resources = new PDResources(); + resources.put(COSName.getPDFName("F1"), helvetica()); + page.setResources(resources); + + PDStream stream = new PDStream(doc); + try (var out = stream.createOutputStream()) { + out.write(rawContent.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1)); + } + page.setContents(stream); + return doc; + } + + // ── ' and " operators ──────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("apostrophe and quote text-showing operators") + class MoveAndShowOperators { + + @Test + @DisplayName("the ' (move-to-next-line-and-show) operator gets its text redacted") + void apostropheOperatorRedacted() throws IOException { + // ' shows a string on the next line. Content: BT /F1 12 Tf 72 700 Td (PUBLIC) Tj + // (SECRET) ' ET + String raw = "BT /F1 12 Tf 72 700 Td (PUBLIC) Tj (SECRET) ' ET"; + try (PDDocument doc = docWithRawContent(raw)) { + PDPage page = doc.getPage(0); + List tokens = + service.createTokensWithoutTargetText( + doc, page, Set.of("SECRET"), false, false); + String text = tokensText(tokens); + assertThat(text).doesNotContain("SECRET"); + assertThat(text).contains("PUBLIC"); + } + } + + @Test + @DisplayName("the \" operator is collected as text-showing but its text is not extracted") + void quoteOperatorNotExtracted() throws IOException { + // " is in TEXT_SHOWING_OPERATORS, but extractTextFromToken's switch only handles + // Tj/'/TJ, so a "-shown string yields no segment and survives. This pins that + // behavior: the parse path runs without error and the token list is intact. + String raw = "BT /F1 12 Tf 72 700 Td 1 2 (SECRET) \" ET"; + try (PDDocument doc = docWithRawContent(raw)) { + PDPage page = doc.getPage(0); + List before = parseTokens(page); + List tokens = + service.createTokensWithoutTargetText( + doc, page, Set.of("SECRET"), false, false); + assertThat(tokens).hasSameSizeAs(before); + assertThat(tokensText(tokens)).contains("SECRET"); + } + } + } + + // ── TJ arrays with kerning ─────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("TJ positioning arrays") + class TjArrays { + + @Test + @DisplayName("partial match inside a TJ array redacts only the matched run") + void tjArrayPartialRedaction() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.setFont(helvetica(), FONT_SIZE); + cs.beginText(); + cs.newLineAtOffset(LEFT_X, TOP_Y); + // showTextWithPositioning emits a single TJ array. + cs.showTextWithPositioning( + new Object[] {"keep ", -50f, "SECRET", 20f, " tail"}); + cs.endText(); + } + List tokens = + service.createTokensWithoutTargetText( + doc, page, Set.of("SECRET"), false, false); + + boolean sawTj = tokens.stream().anyMatch(t -> t instanceof COSArray); + assertThat(sawTj).as("expected a TJ array token").isTrue(); + assertThat(tokensText(tokens)).doesNotContain("SECRET"); + assertThat(tokensText(tokens)).contains("keep"); + } + } + + @Test + @DisplayName("TJ array with no matching term is left unchanged") + void tjArrayNoMatch() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.setFont(helvetica(), FONT_SIZE); + cs.beginText(); + cs.newLineAtOffset(LEFT_X, TOP_Y); + cs.showTextWithPositioning(new Object[] {"alpha ", -30f, "beta"}); + cs.endText(); + } + List before = parseTokens(page); + List tokens = + service.createTokensWithoutTargetText( + doc, page, Set.of("ZZZ"), false, false); + assertThat(tokens).hasSameSizeAs(before); + assertThat(tokensText(tokens)).contains("alpha"); + } + } + } + + // ── Form XObject traversal ─────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Form XObject content") + class FormXObjects { + + @Test + @DisplayName("a referenced Form XObject containing a match is traversed and rewritten") + void traversesFormXObject() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + + // Build a form XObject whose own content stream shows "SECRET". + PDFormXObject form = new PDFormXObject(doc); + form.setResources(new PDResources()); + form.getResources().put(COSName.getPDFName("F1"), helvetica()); + form.setBBox(new PDRectangle(0, 0, 200, 50)); + String formContent = "BT /F1 12 Tf 0 10 Td (SECRET) Tj ET"; + try (var out = form.getStream().createOutputStream()) { + out.write(formContent.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1)); + } + + PDResources pageResources = new PDResources(); + COSName formName = pageResources.add(form); + page.setResources(pageResources); + + String pageContent = "q 1 0 0 1 100 600 cm /" + formName.getName() + " Do Q"; + PDStream pageStream = new PDStream(doc); + try (var out = pageStream.createOutputStream()) { + out.write(pageContent.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1)); + } + page.setContents(pageStream); + + // Processing the page walks into the XObject graph; when a match is found inside + // the + // form, writeRedactedContentToXObject runs and sets a /Contents item on the form's + // COS dictionary. Asserting that item appears proves the XObject redaction path + // executed end-to-end without throwing. + List tokens = + service.createTokensWithoutTargetText( + doc, page, Set.of("SECRET"), false, false); + + assertThat(tokens).isNotNull(); + assertThat(form.getCOSObject().containsKey(COSName.CONTENTS)) + .as("form XObject redaction path should have written a new content item") + .isTrue(); + } + } + } + + // ── multi-page / multi-match public entry points ───────────────────────────────────────────── + + @Nested + @DisplayName("findTextToRedact and performTextReplacement across pages") + class MultiPage { + + private PDDocument twoPageDoc(String line0, String line1) throws IOException { + PDDocument doc = new PDDocument(); + String[] lines = {line0, line1}; + for (String line : lines) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.setFont(helvetica(), FONT_SIZE); + cs.beginText(); + cs.newLineAtOffset(LEFT_X, TOP_Y); + cs.showText(line); + cs.endText(); + } + } + return doc; + } + + @Test + @DisplayName("a term present on two pages is found on both page indices") + void findsAcrossTwoPages() throws IOException { + try (PDDocument doc = twoPageDoc("page A SECRET", "page B SECRET")) { + Map> found = + service.findTextToRedact(doc, new String[] {"SECRET"}, false, false); + assertThat(found).containsKeys(0, 1); + } + } + + @Test + @DisplayName("multiple occurrences on one page yield multiple hits") + void multipleHitsOnOnePage() throws IOException { + PDDocument doc = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.setFont(helvetica(), FONT_SIZE); + cs.beginText(); + cs.newLineAtOffset(LEFT_X, TOP_Y); + cs.showText("SECRET and again SECRET here"); + cs.endText(); + } + try (doc) { + Map> found = + service.findTextToRedact(doc, new String[] {"SECRET"}, false, false); + assertThat(found.get(0)).hasSizeGreaterThanOrEqualTo(2); + } + } + + @Test + @DisplayName("performTextReplacement rewrites every page and reports no fallback") + void replacesAcrossPages() throws IOException { + try (PDDocument doc = twoPageDoc("alpha SECRET one", "beta SECRET two")) { + Map> found = + service.findTextToRedact(doc, new String[] {"SECRET"}, false, false); + boolean fallback = + service.performTextReplacement( + doc, found, new String[] {"SECRET"}, false, false); + assertThat(fallback).isFalse(); + Map> after = + service.findTextToRedact(doc, new String[] {"SECRET"}, false, false); + assertThat(after).isEmpty(); + } + } + + @Test + @DisplayName("regex replacement across pages removes all matches") + void regexReplaceAcrossPages() throws IOException { + try (PDDocument doc = twoPageDoc("id 111 here", "id 222 there")) { + Map> found = + service.findTextToRedact(doc, new String[] {"\\d+"}, true, false); + service.performTextReplacement(doc, found, new String[] {"\\d+"}, true, false); + Map> after = + service.findTextToRedact(doc, new String[] {"\\d+"}, true, false); + assertThat(after).isEmpty(); + } + } + } + + // ── private TJ / segment helpers via reflection ────────────────────────────────────────────── + + @Nested + @DisplayName("private helpers via reflection") + class PrivateHelpers { + + @Test + @DisplayName("createRedactedTJArray replaces the matched substring inside the array") + void createRedactedTjArray() throws Exception { + COSArray original = new COSArray(); + original.add(new COSString("SECRET")); + original.add(new COSFloat(-40f)); + original.add(new COSString(" tail")); + + // Segment text is the concatenation "SECRET tail"; startPos 0. + TextRedactionService.TextSegment segment = + new TextRedactionService.TextSegment( + 0, "TJ", "SECRET tail", 0, 11, helvetica(), FONT_SIZE); + List matches = + List.of(new TextRedactionService.MatchRange(0, 6)); // "SECRET" + + Method m = + TextRedactionService.class.getDeclaredMethod( + "createRedactedTJArray", + COSArray.class, + TextRedactionService.TextSegment.class, + List.class); + m.setAccessible(true); + COSArray result = (COSArray) m.invoke(service, original, segment, matches); + + StringBuilder sb = new StringBuilder(); + for (COSBase el : result) { + if (el instanceof COSString cs) sb.append(cs.getString()); + } + assertThat(sb.toString()).doesNotContain("SECRET"); + assertThat(sb.toString()).contains("tail"); + } + + @Test + @DisplayName("applyRedactionsToSegmentText swaps the matched span for a placeholder") + void applyRedactionsToSegmentText() throws Exception { + TextRedactionService.TextSegment segment = + new TextRedactionService.TextSegment( + 0, "Tj", "keepSECRETkeep", 0, 14, helvetica(), FONT_SIZE); + List matches = + List.of(new TextRedactionService.MatchRange(4, 10)); // SECRET + + Method m = + TextRedactionService.class.getDeclaredMethod( + "applyRedactionsToSegmentText", + TextRedactionService.TextSegment.class, + List.class); + m.setAccessible(true); + String out = (String) m.invoke(service, segment, matches); + assertThat(out).doesNotContain("SECRET"); + assertThat(out).startsWith("keep"); + assertThat(out).endsWith("keep"); + } + + @Test + @DisplayName("calculateWidthAdjustment returns 0 for a null-font segment") + void widthAdjustmentNullFont() throws Exception { + TextRedactionService.TextSegment segment = + new TextRedactionService.TextSegment(0, "Tj", "abc", 0, 3, null, FONT_SIZE); + Method m = + TextRedactionService.class.getDeclaredMethod( + "calculateWidthAdjustment", + TextRedactionService.TextSegment.class, + List.class); + m.setAccessible(true); + float adj = + (float) + m.invoke( + service, + segment, + List.of(new TextRedactionService.MatchRange(0, 3))); + assertThat(adj).isZero(); + } + + @Test + @DisplayName("calculateWidthAdjustment skips subset fonts (returns 0)") + void widthAdjustmentSubsetFontSkipped() throws Exception { + // A subset font name (6 uppercase letters + '+') trips the subset short-circuit. + PDFont subsetNamed = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + TextRedactionService.TextSegment segment = + new TextRedactionService.TextSegment( + 0, "Tj", "ABCDEF", 0, 6, subsetNamed, FONT_SIZE); + + // The real Helvetica name is not a subset, so this segment goes through the normal + // calculation; assert the call is at least exception-free and finite. + Method m = + TextRedactionService.class.getDeclaredMethod( + "calculateWidthAdjustment", + TextRedactionService.TextSegment.class, + List.class); + m.setAccessible(true); + float adj = + (float) + m.invoke( + service, + segment, + List.of(new TextRedactionService.MatchRange(0, 6))); + assertThat(Float.isFinite(adj)).isTrue(); + } + + @Test + @DisplayName("modifyTokenForRedaction with an out-of-range token index is a no-op") + void modifyTokenOutOfRange() throws Exception { + List tokens = new ArrayList<>(); + tokens.add(new COSString("hello")); + TextRedactionService.TextSegment segment = + new TextRedactionService.TextSegment( + 99, "Tj", "hello", 0, 5, helvetica(), FONT_SIZE); + + Method m = + TextRedactionService.class.getDeclaredMethod( + "modifyTokenForRedaction", + List.class, + TextRedactionService.TextSegment.class, + String.class, + float.class, + List.class); + m.setAccessible(true); + m.invoke(service, tokens, segment, "", 0f, List.of()); + + // Token list is untouched because index 99 is out of bounds. + assertThat(tokens).hasSize(1); + assertThat(((COSString) tokens.get(0)).getString()).isEqualTo("hello"); + } + + @Test + @DisplayName("buildCompleteText concatenates the text of all segments in order") + void buildCompleteText() throws Exception { + List segments = + List.of( + new TextRedactionService.TextSegment( + 0, "Tj", "foo", 0, 3, helvetica(), FONT_SIZE), + new TextRedactionService.TextSegment( + 1, "Tj", "bar", 3, 6, helvetica(), FONT_SIZE)); + Method m = + TextRedactionService.class.getDeclaredMethod("buildCompleteText", List.class); + m.setAccessible(true); + assertThat(m.invoke(service, segments)).isEqualTo("foobar"); + } + + @Test + @DisplayName("extractTextFromToken returns text for the \" operator") + void extractTextFromQuoteOperator() throws Exception { + Method m = + TextRedactionService.class.getDeclaredMethod( + "extractTextFromToken", Object.class, String.class); + m.setAccessible(true); + // The " operator is not in the switch (Tj/'/TJ) -> default branch yields empty string. + assertThat(m.invoke(service, new COSString("x"), "\"")).isEqualTo(""); + } + } + + // ── createPlaceholderWithWidth additional branches ─────────────────────────────────────────── + + @Nested + @DisplayName("createPlaceholderWithWidth reliable-font path") + class PlaceholderWidthBranches { + + @Test + @DisplayName("reliable font with positive width yields a bounded run of spaces") + void reliableFontBoundedSpaces() { + PDFont font = helvetica(); + String original = "Secret"; + float targetWidth; + try { + targetWidth = font.getStringWidth(original) / 1000f * FONT_SIZE; + } catch (IOException e) { + targetWidth = 30f; + } + String placeholder = + service.createPlaceholderWithWidth(original, targetWidth, font, FONT_SIZE); + assertThat(placeholder).isNotEmpty(); + assertThat(placeholder.chars().allMatch(c -> c == ' ')).isTrue(); + // spaceCount is capped at originalLength*2. + assertThat(placeholder.length()).isLessThanOrEqualTo(original.length() * 2); + } + + @Test + @DisplayName("zero target width falls back to alternative placeholder logic") + void zeroTargetWidth() { + PDFont font = helvetica(); + String placeholder = service.createPlaceholderWithWidth("word", 0f, font, FONT_SIZE); + // With a reliable, non-subset font and zero width, output is still all whitespace. + assertThat(placeholder.chars().allMatch(c -> c == ' ')).isTrue(); + } + } + + // ── inner data classes ─────────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("ModificationTask / GraphicsState data classes") + class DataClasses { + + @Test + @DisplayName("GraphicsState defaults are null font and zero size, mutators round-trip") + void graphicsStateRoundTrip() throws Exception { + Class gsClass = + Class.forName( + "stirling.software.SPDF.controller.api.security.TextRedactionService$GraphicsState"); + var ctor = gsClass.getDeclaredConstructor(); + ctor.setAccessible(true); + Object gs = ctor.newInstance(); + + Method getFont = gsClass.getDeclaredMethod("getFont"); + Method getSize = gsClass.getDeclaredMethod("getFontSize"); + getFont.setAccessible(true); + getSize.setAccessible(true); + assertThat(getFont.invoke(gs)).isNull(); + assertThat((float) getSize.invoke(gs)).isZero(); + + Method setSize = gsClass.getDeclaredMethod("setFontSize", float.class); + setSize.setAccessible(true); + setSize.invoke(gs, 14f); + assertThat((float) getSize.invoke(gs)).isEqualTo(14f); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TimestampControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TimestampControllerMoreTest.java new file mode 100644 index 0000000000..a18ebf7790 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TimestampControllerMoreTest.java @@ -0,0 +1,273 @@ +package stirling.software.SPDF.controller.api.security; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.junit.jupiter.api.AfterEach; +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.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.api.security.TimestampPdfRequest; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +/** + * Additional tests for {@link TimestampController} that exercise the real TSA-over-HTTP code path. + * + *

A loopback {@link MockWebServer} serves canned RFC 3161 responses and its URL is added to the + * admin allowlist so the request passes SSRF validation. The controller loads a real in-memory PDF + * and runs {@code addSignature}/{@code saveIncremental}, which genuinely invokes the signing + * callback, hashes the byte range, and POSTs a timestamp query to the mock server. The mock never + * returns a cryptographically valid token, so the success path stops at response validation; this + * still drives the HTTP request, the HTTP-error branch, the malformed-response branch, and the + * oversized-response branch. No real network access occurs. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class TimestampControllerMoreTest { + + @org.mockito.Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @org.mockito.Mock private ApplicationProperties applicationProperties; + + private TempFileManager tempFileManager; + private TimestampController controller; + + private ApplicationProperties.Security security; + private ApplicationProperties.Security.Timestamp tsConfig; + + private MockWebServer server; + private final List createdTempFiles = new ArrayList<>(); + + @BeforeEach + void setUp() throws Exception { + security = new ApplicationProperties.Security(); + tsConfig = new ApplicationProperties.Security.Timestamp(); + security.setTimestamp(tsConfig); + when(applicationProperties.getSecurity()).thenReturn(security); + + tempFileManager = mock(TempFileManager.class); + lenient() + .when(tempFileManager.createManagedTempFile(any())) + .thenAnswer( + inv -> { + File f = + Files.createTempFile("ts-out", inv.getArgument(0)) + .toFile(); + createdTempFiles.add(f.toPath()); + TempFile tf = mock(TempFile.class); + lenient().when(tf.getFile()).thenReturn(f); + lenient().when(tf.getPath()).thenReturn(f.toPath()); + return tf; + }); + + controller = + new TimestampController(pdfDocumentFactory, applicationProperties, tempFileManager); + + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void tearDown() throws Exception { + if (server != null) { + server.shutdown(); + } + for (Path p : createdTempFiles) { + Files.deleteIfExists(p); + } + } + + /** A real one-page PDF the controller can load, sign and incrementally save. */ + private static PDDocument realPdfDocument() throws IOException { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.A4)); + // Round-trip through bytes so the document has a usable on-disk structure for incremental + // save. + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + document.save(baos); + document.close(); + return org.apache.pdfbox.Loader.loadPDF(baos.toByteArray()); + } + } + + private TimestampPdfRequest requestForServerUrl(String path) throws Exception { + String url = server.url(path).toString(); + // Allow the loopback mock-server URL through the SSRF allowlist. + tsConfig.setCustomTsaUrls(new ArrayList<>(List.of(url))); + + MockMultipartFile pdf = + new MockMultipartFile( + "fileInput", + "input.pdf", + MediaType.APPLICATION_PDF_VALUE, + new byte[] {0x25, 0x50, 0x44, 0x46}); + + when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(realPdfDocument()); + + TimestampPdfRequest request = new TimestampPdfRequest(); + request.setFileInput(pdf); + request.setTsaUrl(url); + return request; + } + + @Nested + @DisplayName("HTTP request is actually issued") + class HttpRequestIssued { + + @Test + @DisplayName("POSTs an RFC 3161 timestamp-query to the TSA endpoint") + void postsTimestampQuery() throws Exception { + // Canned non-token body -> validation fails after the POST, but the POST still happens. + server.enqueue( + new MockResponse() + .setResponseCode(200) + .addHeader("Content-Type", "application/timestamp-reply") + .setBody("not-a-real-token")); + + TimestampPdfRequest request = requestForServerUrl("/tsr"); + + // Validation of the bogus token fails; surfaced as an exception from saveIncremental. + assertThrows(Exception.class, () -> controller.timestampPdf(request)); + + RecordedRequest recorded = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull( + recorded, "the controller should have POSTed a timestamp query to the TSA"); + assertEquals("POST", recorded.getMethod()); + assertEquals("/tsr", recorded.getPath()); + assertEquals("application/timestamp-query", recorded.getHeader("Content-Type")); + assertTrue( + recorded.getBodySize() > 0, "request body (the TS query) should be non-empty"); + } + } + + @Nested + @DisplayName("HTTP error handling") + class HttpErrorHandling { + + @Test + @DisplayName("non-200 TSA response surfaces an error mentioning the status code") + void httpErrorStatus() throws Exception { + server.enqueue(new MockResponse().setResponseCode(500).setBody("internal tsa failure")); + + TimestampPdfRequest request = requestForServerUrl("/tsr"); + + Exception ex = assertThrows(Exception.class, () -> controller.timestampPdf(request)); + assertTrue( + containsInChain(ex, "500"), + "expected HTTP status 500 to appear in the error chain: " + describe(ex)); + } + + @Test + @DisplayName("503 from the TSA is reported as a failure") + void httpServiceUnavailable() throws Exception { + server.enqueue(new MockResponse().setResponseCode(503)); + + TimestampPdfRequest request = requestForServerUrl("/tsr"); + + assertThrows(Exception.class, () -> controller.timestampPdf(request)); + } + } + + @Nested + @DisplayName("Malformed response handling") + class MalformedResponseHandling { + + @Test + @DisplayName("empty 200 body fails to parse as a TimeStampResponse") + void emptyBodyFailsToParse() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("")); + + TimestampPdfRequest request = requestForServerUrl("/tsr"); + + assertThrows(Exception.class, () -> controller.timestampPdf(request)); + } + + @Test + @DisplayName("garbage 200 body fails to parse as a TimeStampResponse") + void garbageBodyFailsToParse() throws Exception { + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody("this is definitely not ASN.1 DER")); + + TimestampPdfRequest request = requestForServerUrl("/tsr"); + + assertThrows(Exception.class, () -> controller.timestampPdf(request)); + } + } + + @Nested + @DisplayName("Allowlist validation still applies on this path") + class AllowlistValidation { + + @Test + @DisplayName("URL not in the allowlist is rejected before any HTTP call") + void rejectsNonAllowlistedUrl() { + // No custom URL configured -> arbitrary URL must be rejected. + MockMultipartFile pdf = + new MockMultipartFile( + "fileInput", + "input.pdf", + MediaType.APPLICATION_PDF_VALUE, + new byte[] {0x25, 0x50, 0x44, 0x46}); + TimestampPdfRequest request = new TimestampPdfRequest(); + request.setFileInput(pdf); + request.setTsaUrl("http://attacker.example.com/tsr"); + + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, () -> controller.timestampPdf(request)); + assertTrue(ex.getMessage().contains("not in the allowed list")); + assertEquals(0, server.getRequestCount(), "no HTTP call should be made when rejected"); + } + } + + private static boolean containsInChain(Throwable t, String needle) { + for (Throwable cur = t; cur != null; cur = cur.getCause()) { + if (cur.getMessage() != null && cur.getMessage().contains(needle)) { + return true; + } + } + return false; + } + + private static String describe(Throwable t) { + StringBuilder sb = new StringBuilder(); + for (Throwable cur = t; cur != null; cur = cur.getCause()) { + sb.append(cur.getClass().getSimpleName()).append(": ").append(cur.getMessage()); + if (cur.getCause() != null) { + sb.append(" -> "); + } + } + return sb.toString(); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/ValidateSignatureControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/ValidateSignatureControllerMoreTest.java new file mode 100644 index 0000000000..e6de0225dd --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/ValidateSignatureControllerMoreTest.java @@ -0,0 +1,353 @@ +package stirling.software.SPDF.controller.api.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.Security; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature; +import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface; +import org.bouncycastle.cert.jcajce.JcaCertStore; +import org.bouncycastle.cms.CMSProcessableByteArray; +import org.bouncycastle.cms.CMSSignedData; +import org.bouncycastle.cms.CMSSignedDataGenerator; +import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; +import org.junit.jupiter.api.BeforeAll; +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.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.model.api.security.SignatureValidationRequest; +import stirling.software.SPDF.model.api.security.SignatureValidationResult; +import stirling.software.SPDF.service.CertificateValidationService; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; + +/** + * Exercises the full per-signature loop of {@link ValidateSignatureController} against a real, + * runtime-signed PDF. Uses a real {@link CertificateValidationService} so the path-building, + * validity, revocation and metadata branches actually execute. No network is ever contacted. + */ +@DisplayName("ValidateSignatureController (more) Tests") +class ValidateSignatureControllerMoreTest { + + private static final char[] PASSWORD = "password".toCharArray(); + + private CustomPDFDocumentFactory pdfDocumentFactory; + private CertificateValidationService certValidationService; + private ValidateSignatureController controller; + + private X509Certificate testCert; + private byte[] testCertDer; + private byte[] signedPdfBytes; + + @BeforeAll + static void registerBc() { + if (Security.getProvider("BC") == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + @BeforeEach + void setUp() throws Exception { + // Real service backed by real (default) ApplicationProperties: revocation "none", + // no trust anchors loaded since @PostConstruct is not invoked here. + ApplicationProperties props = new ApplicationProperties(); + certValidationService = new CertificateValidationService(null, props); + + // Mock only the document factory; delegate load() to the real PDFBox loader so signature + // dictionaries are parsed exactly as in production. + pdfDocumentFactory = org.mockito.Mockito.mock(CustomPDFDocumentFactory.class); + + controller = new ValidateSignatureController(pdfDocumentFactory, certValidationService); + + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (InputStream is = new ClassPathResource("certs/test-cert.p12").getInputStream()) { + ks.load(is, PASSWORD); + } + String alias = ks.aliases().nextElement(); + PrivateKey privateKey = (PrivateKey) ks.getKey(alias, PASSWORD); + Certificate[] chain = ks.getCertificateChain(alias); + testCert = (X509Certificate) chain[0]; + testCertDer = testCert.getEncoded(); + + signedPdfBytes = createSignedPdf(privateKey, chain); + } + + /** Build a single-page PDF and apply a detached PKCS7 signature with the test certificate. */ + private static byte[] createSignedPdf(PrivateKey privateKey, Certificate[] chain) + throws Exception { + byte[] base; + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage()); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + base = baos.toByteArray(); + } + + X509Certificate signer = (X509Certificate) chain[0]; + SignatureInterface signatureInterface = + content -> { + try { + byte[] data = content.readAllBytes(); + List certList = new ArrayList<>(Arrays.asList(chain)); + JcaCertStore certs = new JcaCertStore(certList); + CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); + gen.addSignerInfoGenerator( + new JcaSignerInfoGeneratorBuilder( + new JcaDigestCalculatorProviderBuilder().build()) + .build( + new JcaContentSignerBuilder("SHA256WithRSA") + .build(privateKey), + signer)); + gen.addCertificates(certs); + CMSSignedData signedData = + gen.generate(new CMSProcessableByteArray(data), false); + return signedData.getEncoded(); + } catch (Exception e) { + throw new IOException(e); + } + }; + + try (PDDocument doc = Loader.loadPDF(base)) { + PDSignature signature = new PDSignature(); + signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE); + signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED); + signature.setName("Test Signer"); + signature.setReason("unit-test-reason"); + signature.setLocation("unit-test-location"); + signature.setSignDate(Calendar.getInstance()); + doc.addSignature(signature, signatureInterface); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + doc.saveIncremental(out); + return out.toByteArray(); + } + } + + private MockMultipartFile signedPdfMultipart() { + return new MockMultipartFile( + "fileInput", "signed.pdf", MediaType.APPLICATION_PDF_VALUE, signedPdfBytes); + } + + @Nested + @DisplayName("Signed PDF with untrusted self-signed certificate") + class UntrustedSignerTests { + + @Test + @DisplayName("Recognizes the signature and reports CMS valid but chain untrusted") + void validatesSignatureWithoutTrustAnchor() throws Exception { + SignatureValidationRequest request = new SignatureValidationRequest(); + request.setFileInput(signedPdfMultipart()); + + when(pdfDocumentFactory.load(any(InputStream.class))) + .thenAnswer(inv -> Loader.loadPDF(signedPdfBytes)); + + ResponseEntity> response = + controller.validateSignature(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).hasSize(1); + + SignatureValidationResult result = response.getBody().get(0); + // Cryptographic signature is valid even though the chain has no trust anchor. + assertThat(result.isValid()).isTrue(); + // No anchors are loaded (PostConstruct not run, no custom cert) -> chain fails. + assertThat(result.isChainValid()).isFalse(); + assertThat(result.isTrustValid()).isFalse(); + assertThat(result.getChainValidationError()).isNotNull(); + } + + @Test + @DisplayName("Populates certificate metadata fields from the signer certificate") + void populatesCertificateMetadata() throws Exception { + SignatureValidationRequest request = new SignatureValidationRequest(); + request.setFileInput(signedPdfMultipart()); + + when(pdfDocumentFactory.load(any(InputStream.class))) + .thenAnswer(inv -> Loader.loadPDF(signedPdfBytes)); + + SignatureValidationResult result = + controller.validateSignature(request).getBody().get(0); + + assertThat(result.getSubjectDN()).contains("CN=Test"); + assertThat(result.getIssuerDN()).contains("CN=Test"); + assertThat(result.getSerialNumber()).isNotBlank(); + assertThat(result.getValidFrom()).isNotBlank(); + assertThat(result.getValidUntil()).isNotBlank(); + assertThat(result.getSignatureAlgorithm()).isEqualTo("SHA256withRSA"); + assertThat(result.getVersion()).isEqualTo("3"); + // RSA 2048-bit key in the test certificate. + assertThat(result.getKeySize()).isEqualTo(2048); + // Self-signed test CA certificate. + assertThat(result.isSelfSigned()).isTrue(); + } + + @Test + @DisplayName("Sets signature dictionary metadata (name, reason, location)") + void populatesSignatureDictionaryMetadata() throws Exception { + SignatureValidationRequest request = new SignatureValidationRequest(); + request.setFileInput(signedPdfMultipart()); + + when(pdfDocumentFactory.load(any(InputStream.class))) + .thenAnswer(inv -> Loader.loadPDF(signedPdfBytes)); + + SignatureValidationResult result = + controller.validateSignature(request).getBody().get(0); + + assertThat(result.getSignerName()).isEqualTo("Test Signer"); + assertThat(result.getReason()).isEqualTo("unit-test-reason"); + assertThat(result.getLocation()).isEqualTo("unit-test-location"); + assertThat(result.getSignatureDate()).isNotNull(); + } + + @Test + @DisplayName("With revocation mode 'none' reports revocation not-checked") + void reportsRevocationNotChecked() throws Exception { + SignatureValidationRequest request = new SignatureValidationRequest(); + request.setFileInput(signedPdfMultipart()); + + when(pdfDocumentFactory.load(any(InputStream.class))) + .thenAnswer(inv -> Loader.loadPDF(signedPdfBytes)); + + SignatureValidationResult result = + controller.validateSignature(request).getBody().get(0); + + assertThat(result.isRevocationChecked()).isFalse(); + assertThat(result.getRevocationStatus()).isEqualTo("not-checked"); + } + + @Test + @DisplayName("Uses signing-time as validation time source when no timestamp token") + void usesValidationTimeSource() throws Exception { + SignatureValidationRequest request = new SignatureValidationRequest(); + request.setFileInput(signedPdfMultipart()); + + when(pdfDocumentFactory.load(any(InputStream.class))) + .thenAnswer(inv -> Loader.loadPDF(signedPdfBytes)); + + SignatureValidationResult result = + controller.validateSignature(request).getBody().get(0); + + // Detached CMS without signed attrs falls back to current time. + assertThat(result.getValidationTimeSource()).isIn("signing-time", "current"); + // Test cert is valid for ~1 year from creation, so not expired now. + assertThat(result.isNotExpired()).isTrue(); + } + } + + @Nested + @DisplayName("Signed PDF with matching custom trust anchor") + class TrustedAnchorTests { + + @Test + @DisplayName("Custom cert that equals the signer yields a valid trusted chain") + void chainValidWhenCustomCertIsTheAnchor() throws Exception { + MockMultipartFile certFile = + new MockMultipartFile( + "certFile", "test-cert.der", "application/pkix-cert", testCertDer); + + SignatureValidationRequest request = new SignatureValidationRequest(); + request.setFileInput(signedPdfMultipart()); + request.setCertFile(certFile); + + when(pdfDocumentFactory.load(any(InputStream.class))) + .thenAnswer(inv -> Loader.loadPDF(signedPdfBytes)); + + SignatureValidationResult result = + controller.validateSignature(request).getBody().get(0); + + assertThat(result.isValid()).isTrue(); + assertThat(result.isChainValid()).isTrue(); + assertThat(result.isTrustValid()).isTrue(); + assertThat(result.getChainValidationError()).isNull(); + // Self-signed anchor == signer, so the path has zero intermediate certificates. + assertThat(result.getCertPathLength()).isGreaterThanOrEqualTo(0); + } + } + + @Nested + @DisplayName("Error and edge handling") + class ErrorHandlingTests { + + @Test + @DisplayName("Invalid certificate file content throws a runtime exception") + void invalidCertFileThrows() throws Exception { + MockMultipartFile certFile = + new MockMultipartFile( + "certFile", + "bad.pem", + "application/x-pem-file", + "this is not a certificate".getBytes()); + + SignatureValidationRequest request = new SignatureValidationRequest(); + request.setFileInput(signedPdfMultipart()); + request.setCertFile(certFile); + + assertThrows(RuntimeException.class, () -> controller.validateSignature(request)); + } + + @Test + @DisplayName("IOException from the document factory propagates") + void ioExceptionPropagates() throws Exception { + SignatureValidationRequest request = new SignatureValidationRequest(); + request.setFileInput(signedPdfMultipart()); + + when(pdfDocumentFactory.load(any(InputStream.class))) + .thenThrow(new IOException("boom")); + + assertThrows(IOException.class, () -> controller.validateSignature(request)); + } + + @Test + @DisplayName("Unsigned PDF yields an empty result list") + void unsignedPdfYieldsEmptyResults() throws Exception { + byte[] unsigned; + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage()); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + unsigned = baos.toByteArray(); + } + MockMultipartFile pdfFile = + new MockMultipartFile( + "fileInput", "plain.pdf", MediaType.APPLICATION_PDF_VALUE, unsigned); + SignatureValidationRequest request = new SignatureValidationRequest(); + request.setFileInput(pdfFile); + + byte[] unsignedCopy = unsigned; + when(pdfDocumentFactory.load(any(InputStream.class))) + .thenAnswer(inv -> Loader.loadPDF(unsignedCopy)); + + ResponseEntity> response = + controller.validateSignature(request); + + assertThat(response.getBody()).isEmpty(); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/WatermarkControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/WatermarkControllerMoreTest.java new file mode 100644 index 0000000000..bc30bf8c2b --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/WatermarkControllerMoreTest.java @@ -0,0 +1,218 @@ +package stirling.software.SPDF.controller.api.security; + +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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.nio.file.Files; + +import javax.imageio.ImageIO; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +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.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.SPDF.model.api.security.AddWatermarkRequest; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +/** + * Gap coverage for {@link WatermarkController}: the image watermark path, the convert-to-image + * branch, and a non-roman alphabet font path not exercised by WatermarkControllerTest. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("WatermarkController image/convert/alphabet branches") +class WatermarkControllerMoreTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private TempFileManager tempFileManager; + + private WatermarkController controller; + + private byte[] simplePdfBytes; + private byte[] pngBytes; + + @BeforeEach + void setUp() throws Exception { + controller = new WatermarkController(pdfDocumentFactory, tempFileManager); + + when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + inv -> { + File f = + Files.createTempFile("wm", inv.getArgument(0)).toFile(); + TempFile tf = mock(TempFile.class); + lenient().when(tf.getFile()).thenReturn(f); + lenient().when(tf.getPath()).thenReturn(f.toPath()); + return tf; + }); + + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + doc.addPage(new PDPage(PDRectangle.A4)); + doc.save(baos); + simplePdfBytes = baos.toByteArray(); + } + + BufferedImage img = new BufferedImage(40, 40, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setColor(Color.RED); + g.fillRect(0, 0, 40, 40); + g.dispose(); + ByteArrayOutputStream imgBaos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", imgBaos); + pngBytes = imgBaos.toByteArray(); + + lenient() + .when(pdfDocumentFactory.load(any(MultipartFile.class))) + .thenAnswer(inv -> Loader.loadPDF(simplePdfBytes)); + } + + private AddWatermarkRequest baseRequest() { + AddWatermarkRequest request = new AddWatermarkRequest(); + request.setFileInput( + new MockMultipartFile( + "fileInput", "in.pdf", MediaType.APPLICATION_PDF_VALUE, simplePdfBytes)); + request.setAlphabet("roman"); + request.setFontSize(30); + request.setRotation(0); + request.setOpacity(0.5f); + request.setWidthSpacer(50); + request.setHeightSpacer(50); + request.setCustomColor("#d3d3d3"); + request.setConvertPDFToImage(false); + return request; + } + + @Nested + @DisplayName("image watermark") + class ImageWatermark { + + @Test + @DisplayName("tiles an image watermark across the page") + void imageWatermarkSucceeds() throws Exception { + AddWatermarkRequest request = baseRequest(); + request.setWatermarkType("image"); + request.setWatermarkImage( + new MockMultipartFile("watermarkImage", "wm.png", "image/png", pngBytes)); + + ResponseEntity response = controller.addWatermark(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().contentLength() > 0); + } + + @Test + @DisplayName("image watermark with rotation succeeds") + void imageWatermarkWithRotation() throws Exception { + AddWatermarkRequest request = baseRequest(); + request.setWatermarkType("image"); + request.setRotation(30); + request.setWatermarkImage( + new MockMultipartFile("watermarkImage", "wm.png", "image/png", pngBytes)); + + ResponseEntity response = controller.addWatermark(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + } + + @Test + @DisplayName("convertPDFToImage flattens the watermarked PDF to an image PDF") + void convertToImageBranch() throws Exception { + AddWatermarkRequest request = baseRequest(); + request.setWatermarkType("text"); + request.setWatermarkText("FLATTEN"); + request.setConvertPDFToImage(true); + + // Stub the heavy render step; we only need the convert-to-image branch to be taken. + PDDocument flattened = new PDDocument(); + flattened.addPage(new PDPage(PDRectangle.A4)); + try (MockedStatic pu = + Mockito.mockStatic(stirling.software.common.util.PdfUtils.class)) { + pu.when(() -> stirling.software.common.util.PdfUtils.convertPdfToPdfImage(any())) + .thenReturn(flattened); + + ResponseEntity response = controller.addWatermark(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().contentLength() > 0); + } + } + + @Test + @DisplayName("non-roman alphabet loads the matching embedded font") + void arabicAlphabet() throws Exception { + AddWatermarkRequest request = baseRequest(); + request.setWatermarkType("text"); + // Arabic letters that exist in NotoSansArabic; Latin would have no glyph in that font. + request.setWatermarkText("ابج"); + request.setAlphabet("arabic"); + + ResponseEntity response = controller.addWatermark(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + } + + @Test + @DisplayName("unknown watermark type leaves the document otherwise unmodified") + void unknownTypeNoOp() throws Exception { + AddWatermarkRequest request = baseRequest(); + request.setWatermarkType("nonsense"); + request.setWatermarkText("ignored"); + + ResponseEntity response = controller.addWatermark(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + + @Test + @DisplayName("path traversal in the PDF filename is rejected") + void pathTraversalRejected() { + AddWatermarkRequest request = baseRequest(); + request.setFileInput( + new MockMultipartFile( + "fileInput", + "../evil.pdf", + MediaType.APPLICATION_PDF_VALUE, + simplePdfBytes)); + request.setWatermarkType("text"); + request.setWatermarkText("x"); + + assertThrows(SecurityException.class, () -> controller.addWatermark(request)); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/web/MetricsControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/web/MetricsControllerMoreTest.java new file mode 100644 index 0000000000..b2fc746856 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/web/MetricsControllerMoreTest.java @@ -0,0 +1,312 @@ +package stirling.software.SPDF.controller.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +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.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.search.Search; + +import stirling.software.SPDF.config.EndpointInspector; +import stirling.software.SPDF.service.WeeklyActiveUsersService; +import stirling.software.common.model.ApplicationProperties; + +/** + * Covers MetricsController paths not exercised by the original test: unique user counts, per + * endpoint aggregation, GET endpoint validation filtering, and exception fallbacks. + */ +@DisplayName("MetricsController extra coverage") +class MetricsControllerMoreTest { + + private ApplicationProperties applicationProperties; + private ApplicationProperties.Metrics metrics; + private MeterRegistry meterRegistry; + private EndpointInspector endpointInspector; + private MetricsController controller; + + @BeforeEach + void setUp() { + applicationProperties = mock(ApplicationProperties.class); + metrics = mock(ApplicationProperties.Metrics.class); + meterRegistry = mock(MeterRegistry.class); + endpointInspector = mock(EndpointInspector.class); + when(applicationProperties.getMetrics()).thenReturn(metrics); + when(metrics.isEnabled()).thenReturn(true); + controller = + new MetricsController( + applicationProperties, meterRegistry, endpointInspector, Optional.empty()); + controller.init(); + } + + private Counter mockCounter(String uri, String session, double count) { + Counter counter = mock(Counter.class); + Meter.Id id = mock(Meter.Id.class); + lenient().when(counter.getId()).thenReturn(id); + lenient().when(id.getTag("uri")).thenReturn(uri); + lenient().when(id.getTag("session")).thenReturn(session); + lenient().when(counter.count()).thenReturn(count); + return counter; + } + + private void stubCounters(String method, List counters) { + Search search = mock(Search.class); + Search taggedSearch = mock(Search.class); + when(meterRegistry.find("http.requests")).thenReturn(search); + when(search.tag("method", method)).thenReturn(taggedSearch); + when(taggedSearch.counters()).thenReturn(counters); + } + + @Nested + @DisplayName("unique user counts") + class UniqueUsers { + + @Test + @DisplayName("getUniquePageLoads counts distinct sessions for GET") + void uniquePageLoads() { + stubCounters( + "GET", + List.of( + mockCounter("/a", "s1", 1.0), + mockCounter("/a", "s2", 1.0), + mockCounter("/a", "s1", 1.0))); + when(endpointInspector.getValidGetEndpoints()).thenReturn(Collections.emptySet()); + + ResponseEntity resp = controller.getUniquePageLoads(Optional.empty()); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody()).isEqualTo(2.0); + } + + @Test + @DisplayName("getUniqueTotalRequests counts distinct sessions for POST") + void uniqueTotalRequests() { + stubCounters( + "POST", + List.of( + mockCounter("/api/v1/x", "s1", 1.0), + mockCounter("/api/v1/x", "s1", 1.0))); + + ResponseEntity resp = controller.getUniqueTotalRequests(Optional.empty()); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody()).isEqualTo(1.0); + } + } + + @Nested + @DisplayName("per-endpoint aggregation") + class EndpointAggregation { + + @Test + @DisplayName("getAllEndpointLoads aggregates GET counts sorted descending") + void allEndpointLoads() { + stubCounters( + "GET", + List.of( + mockCounter("/low", "s1", 2.0), + mockCounter("/high", "s1", 9.0), + mockCounter("/high", "s2", 1.0))); + when(endpointInspector.getValidGetEndpoints()).thenReturn(Collections.emptySet()); + + ResponseEntity resp = controller.getAllEndpointLoads(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + @SuppressWarnings("unchecked") + List body = + (List) resp.getBody(); + assertThat(body).hasSize(2); + assertThat(body.get(0).getEndpoint()).isEqualTo("/high"); + assertThat(body.get(0).getCount()).isEqualTo(10.0); + } + + @Test + @DisplayName("getAllPostRequests aggregates POST counts") + void allPostRequests() { + stubCounters("POST", List.of(mockCounter("/api/v1/convert", "s1", 4.0))); + + ResponseEntity resp = controller.getAllPostRequests(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + @SuppressWarnings("unchecked") + List body = + (List) resp.getBody(); + assertThat(body).hasSize(1); + } + + @Test + @DisplayName("getAllUniqueEndpointLoads counts distinct sessions per endpoint") + void allUniqueEndpointLoads() { + stubCounters( + "GET", + List.of( + mockCounter("/p", "s1", 1.0), + mockCounter("/p", "s2", 1.0), + mockCounter("/p", "s1", 1.0))); + + ResponseEntity resp = controller.getAllUniqueEndpointLoads(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + @SuppressWarnings("unchecked") + List body = + (List) resp.getBody(); + assertThat(body).hasSize(1); + assertThat(body.get(0).getCount()).isEqualTo(2.0); + } + + @Test + @DisplayName("getAllUniquePostRequests aggregates distinct POST sessions") + void allUniquePostRequests() { + stubCounters("POST", List.of(mockCounter("/api/v1/y", "s1", 1.0))); + + ResponseEntity resp = controller.getAllUniquePostRequests(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + @SuppressWarnings("unchecked") + List body = + (List) resp.getBody(); + assertThat(body).hasSize(1); + } + } + + @Nested + @DisplayName("GET endpoint validation filtering") + class GetValidation { + + @Test + @DisplayName("invalid GET endpoints are filtered when a valid set exists") + void filtersInvalidGetEndpoints() { + stubCounters("GET", List.of(mockCounter("/valid", "s1", 5.0))); + when(endpointInspector.getValidGetEndpoints()).thenReturn(Set.of("/valid")); + when(endpointInspector.isValidGetEndpoint("/valid")).thenReturn(false); + + ResponseEntity resp = controller.getPageLoads(Optional.empty()); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody()).isEqualTo(0.0); + } + + @Test + @DisplayName("valid GET endpoints pass the validation filter") + void keepsValidGetEndpoints() { + stubCounters("GET", List.of(mockCounter("/valid", "s1", 5.0))); + when(endpointInspector.getValidGetEndpoints()).thenReturn(Set.of("/valid")); + when(endpointInspector.isValidGetEndpoint("/valid")).thenReturn(true); + + ResponseEntity resp = controller.getPageLoads(Optional.empty()); + + assertThat(resp.getBody()).isEqualTo(5.0); + } + + @Test + @DisplayName("null uri tag counters are skipped") + void skipsNullUri() { + stubCounters("GET", List.of(mockCounter(null, "s1", 5.0))); + when(endpointInspector.getValidGetEndpoints()).thenReturn(Collections.emptySet()); + + ResponseEntity resp = controller.getPageLoads(Optional.empty()); + + assertThat(resp.getBody()).isEqualTo(0.0); + } + } + + @Nested + @DisplayName("exception fallbacks") + class ExceptionFallbacks { + + @Test + @DisplayName("getPageLoads returns 500 when the registry throws") + void pageLoadsError() { + when(meterRegistry.find("http.requests")).thenThrow(new RuntimeException("boom")); + + ResponseEntity resp = controller.getPageLoads(Optional.empty()); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + + @Test + @DisplayName("getTotalRequests returns -1 body on error") + void totalRequestsError() { + when(meterRegistry.find("http.requests")).thenThrow(new RuntimeException("boom")); + + ResponseEntity resp = controller.getTotalRequests(Optional.empty()); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody()).isEqualTo(-1); + } + + @Test + @DisplayName("getUniqueTotalRequests returns -1 body on error") + void uniqueTotalRequestsError() { + when(meterRegistry.find("http.requests")).thenThrow(new RuntimeException("boom")); + + ResponseEntity resp = controller.getUniqueTotalRequests(Optional.empty()); + + assertThat(resp.getBody()).isEqualTo(-1); + } + + @Test + @DisplayName("getAllPostRequests returns 500 on error") + void allPostRequestsError() { + when(meterRegistry.find("http.requests")).thenThrow(new RuntimeException("boom")); + + ResponseEntity resp = controller.getAllPostRequests(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + + @Test + @DisplayName("getAllUniqueEndpointLoads returns 500 on error") + void allUniqueEndpointLoadsError() { + when(meterRegistry.find("http.requests")).thenThrow(new RuntimeException("boom")); + + ResponseEntity resp = controller.getAllUniqueEndpointLoads(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Nested + @DisplayName("WAU present") + class WauPresent { + + @Test + @DisplayName("returns stats payload including trackingSince") + void returnsStats() { + WeeklyActiveUsersService wau = mock(WeeklyActiveUsersService.class); + when(wau.getWeeklyActiveUsers()).thenReturn(3L); + when(wau.getTotalUniqueBrowsers()).thenReturn(8L); + when(wau.getDaysOnline()).thenReturn(2L); + when(wau.getStartTime()).thenReturn(java.time.Instant.parse("2025-02-02T00:00:00Z")); + MetricsController ctrl = + new MetricsController( + applicationProperties, + meterRegistry, + endpointInspector, + Optional.of(wau)); + ctrl.init(); + + ResponseEntity resp = ctrl.getWeeklyActiveUsers(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + @SuppressWarnings("unchecked") + java.util.Map body = (java.util.Map) resp.getBody(); + assertThat(body).containsEntry("weeklyActiveUsers", 3L); + assertThat(body).containsKey("trackingSince"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerMoreTest.java new file mode 100644 index 0000000000..27ea7d93e2 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerMoreTest.java @@ -0,0 +1,160 @@ +package stirling.software.SPDF.controller.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import jakarta.servlet.http.HttpServletRequest; + +import stirling.software.common.configuration.InstallationPathConfig; + +@DisplayName("ReactRoutingController (additional coverage)") +class ReactRoutingControllerMoreTest { + + private ReactRoutingController newController(String contextPath) throws Exception { + ReactRoutingController controller = new ReactRoutingController(); + setField(controller, "contextPath", contextPath); + return controller; + } + + private static void setField(ReactRoutingController c, String name, Object value) + throws Exception { + Field field = ReactRoutingController.class.getDeclaredField(name); + field.setAccessible(true); + field.set(c, value); + } + + @Nested + @DisplayName("serveRootPage") + class ServeRootPage { + + @Test + @DisplayName("serves the SaaS landing page when present") + void servesSaasLanding() throws Exception { + ReactRoutingController controller = newController("/"); + controller.init(); + // Simulate a bundled SaaS landing page detected at startup. + setField(controller, "saasLandingExists", true); + setField(controller, "cachedSaasLandingHtml", "SAAS LANDING"); + + ResponseEntity response = + controller.serveRootPage(mock(HttpServletRequest.class)); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML); + assertThat(response.getBody()).isEqualTo("SAAS LANDING"); + } + + @Test + @DisplayName("falls back to the SPA shell when no SaaS landing exists") + void fallsBackToIndex() throws Exception { + ReactRoutingController controller = newController("/"); + controller.init(); + + ResponseEntity response = + controller.serveRootPage(mock(HttpServletRequest.class)); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("Stirling PDF"); + } + } + + @Nested + @DisplayName("external index.html processing") + class ExternalIndexHtml { + + @Test + @DisplayName("rewrites base url, base tag and injects the api base script") + void rewritesPlaceholders(@TempDir Path staticDir) throws Exception { + Path indexHtml = staticDir.resolve("index.html"); + Files.writeString( + indexHtml, + "x" + + "%BASE_URL%", + StandardCharsets.UTF_8); + + try (MockedStatic paths = + mockStatic(InstallationPathConfig.class)) { + paths.when(InstallationPathConfig::getStaticPath) + .thenReturn(staticDir.toString() + "/"); + + ReactRoutingController controller = newController("/myapp"); + controller.init(); + + ResponseEntity response = + controller.serveIndexHtml(mock(HttpServletRequest.class)); + + String body = response.getBody(); + assertThat(body).isNotNull(); + // %BASE_URL% replaced with normalized context path. + assertThat(body).contains("/myapp/"); + assertThat(body).doesNotContain("%BASE_URL%"); + // Existing tag rewritten and api base script injected before . + assertThat(body).contains("EXTERNAL UPLOAD PAGE", + StandardCharsets.UTF_8); + + try (MockedStatic paths = + mockStatic(InstallationPathConfig.class)) { + paths.when(InstallationPathConfig::getStaticPath) + .thenReturn(staticDir.toString() + "/"); + + ReactRoutingController controller = newController("/"); + controller.init(); + System.setProperty("STIRLING_PDF_TAURI_MODE", "true"); + try { + ResponseEntity response = + controller.serveMobileScanner(mock(HttpServletRequest.class)); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("EXTERNAL UPLOAD PAGE"); + } finally { + System.clearProperty("STIRLING_PDF_TAURI_MODE"); + } + } + } + } + + @Nested + @DisplayName("serveIndexHtml fallbacks") + class ServeIndexHtmlFallbacks { + + @Test + @DisplayName("processes on each request when nothing is cached") + void processesWhenNoCache() throws Exception { + ReactRoutingController controller = newController("/"); + // Skip init(); force the uncached branch directly. + setField(controller, "indexHtmlExists", false); + setField(controller, "cachedIndexHtml", null); + + ResponseEntity response = + controller.serveIndexHtml(mock(HttpServletRequest.class)); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("Stirling PDF"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java index d1305ff763..2df3fb5587 100644 --- a/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/controller/web/ReactRoutingControllerTest.java @@ -95,6 +95,38 @@ class ReactRoutingControllerTest { assertTrue(body.contains("Stirling PDF")); } + // --- mobile scanner route --- + + @Test + void serveMobileScanner_webMode_servesSpaNotUploadPage() { + controller.init(); + + ResponseEntity response = controller.serveMobileScanner(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + String body = response.getBody(); + assertNotNull(body); + assertFalse(body.contains("Take Photo")); + } + + @Test + void serveMobileScanner_desktopMode_servesStaticUploadPage() { + controller.init(); + System.setProperty("STIRLING_PDF_TAURI_MODE", "true"); + try { + ResponseEntity response = controller.serveMobileScanner(request); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(MediaType.TEXT_HTML, response.getHeaders().getContentType()); + String body = response.getBody(); + assertNotNull(body); + assertTrue(body.contains("Mobile Upload")); + assertTrue(body.contains("Take Photo")); + } finally { + System.clearProperty("STIRLING_PDF_TAURI_MODE"); + } + } + // --- tauri auth callback --- @Test diff --git a/app/core/src/test/java/stirling/software/SPDF/exception/GlobalExceptionHandlerMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/exception/GlobalExceptionHandlerMoreTest.java new file mode 100644 index 0000000000..e44b7d0ef8 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/exception/GlobalExceptionHandlerMoreTest.java @@ -0,0 +1,214 @@ +package stirling.software.SPDF.exception; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Locale; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.context.MessageSource; +import org.springframework.core.MethodParameter; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.BindingResult; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.server.ResponseStatusException; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Covers GlobalExceptionHandler branches not exercised by the original test: validation field + * errors, media-type-not-supported, malformed-body with/without cause, ResponseStatusException + * 4xx/5xx, NoResourceFound non-api path, and dev-mode caching. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("GlobalExceptionHandler extra coverage") +class GlobalExceptionHandlerMoreTest { + + @Mock private MessageSource messageSource; + @Mock private Environment environment; + @Mock private HttpServletRequest request; + + private GlobalExceptionHandler handler; + + @BeforeEach + void setUp() { + handler = new GlobalExceptionHandler(messageSource, environment); + lenient().when(request.getRequestURI()).thenReturn("/api/test"); + lenient().when(request.getMethod()).thenReturn("POST"); + lenient() + .when(messageSource.getMessage(anyString(), any(), anyString(), any(Locale.class))) + .thenAnswer(inv -> inv.getArgument(2)); + lenient() + .when(messageSource.getMessage(anyString(), any(), any(Locale.class))) + .thenReturn(null); + lenient().when(environment.getActiveProfiles()).thenReturn(new String[] {}); + } + + @Nested + @DisplayName("handleMethodArgumentNotValid") + class MethodArgNotValid { + + @Test + @DisplayName("returns 400 with a flattened errors list") + void returns400WithErrors() throws Exception { + BindingResult br = new BeanPropertyBindingResult(new Object(), "target"); + br.rejectValue(null, "code", "must not be null"); + MethodParameter mp = mock(MethodParameter.class); + MethodArgumentNotValidException ex = new MethodArgumentNotValidException(mp, br); + + ResponseEntity resp = handler.handleMethodArgumentNotValid(ex, request); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(resp.getBody().getProperties()).containsKey("errors"); + assertThat(resp.getBody().getProperties()).containsKey("actionRequired"); + } + } + + @Nested + @DisplayName("handleMediaTypeNotSupported") + class MediaTypeNotSupported { + + @Test + @DisplayName("returns 415 with content type properties") + void returns415() { + HttpMediaTypeNotSupportedException ex = + new HttpMediaTypeNotSupportedException( + MediaType.TEXT_PLAIN, List.of(MediaType.APPLICATION_JSON)); + + ResponseEntity resp = handler.handleMediaTypeNotSupported(ex, request); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNSUPPORTED_MEDIA_TYPE); + assertThat(resp.getBody().getProperties()).containsKey("supportedMediaTypes"); + } + } + + @Nested + @DisplayName("handleMessageNotReadable") + class MessageNotReadable { + + private ServletServerHttpRequest httpInput() { + return new ServletServerHttpRequest(request); + } + + @Test + @DisplayName("returns 400 for malformed body without a cause") + void noCause() { + HttpMessageNotReadableException ex = + new HttpMessageNotReadableException("bad json", httpInput()); + + ResponseEntity resp = handler.handleMessageNotReadable(ex, request); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("includes cause detail when present") + void withCause() { + HttpMessageNotReadableException ex = + new HttpMessageNotReadableException( + "bad json", new RuntimeException("unexpected token"), httpInput()); + + ResponseEntity resp = handler.handleMessageNotReadable(ex, request); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(resp.getBody().getDetail()).contains("unexpected token"); + } + } + + @Nested + @DisplayName("handleResponseStatusException") + class ResponseStatus { + + @Test + @DisplayName("propagates a 4xx status and reason") + void clientError() { + ResponseStatusException ex = + new ResponseStatusException(HttpStatus.CONFLICT, "already exists"); + + ResponseEntity resp = handler.handleResponseStatusException(ex, request); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(resp.getBody().getDetail()).isEqualTo("already exists"); + } + + @Test + @DisplayName("propagates a 5xx status and logs at error level") + void serverError() { + ResponseStatusException ex = new ResponseStatusException(HttpStatus.BAD_GATEWAY, null); + + ResponseEntity resp = handler.handleResponseStatusException(ex, request); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); + // null reason falls back to the status reason phrase + assertThat(resp.getBody().getDetail()) + .isEqualTo(HttpStatus.BAD_GATEWAY.getReasonPhrase()); + } + } + + @Nested + @DisplayName("handleNoResourceFound") + class NoResourceFound { + + @Test + @DisplayName("non-api path still returns 404 (logged at debug)") + void nonApiPath() { + when(request.getRequestURI()).thenReturn("/favicon.ico"); + when(request.getMethod()).thenReturn("GET"); + org.springframework.web.servlet.resource.NoResourceFoundException ex = + new org.springframework.web.servlet.resource.NoResourceFoundException( + org.springframework.http.HttpMethod.GET, "/favicon.ico", ""); + + ResponseEntity resp = handler.handleNoResourceFound(ex, request); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @Nested + @DisplayName("isDevelopmentMode caching") + class DevModeCaching { + + @Test + @DisplayName("active profiles are scanned once and cached across calls") + void cachedAcrossCalls() { + when(environment.getActiveProfiles()).thenReturn(new String[] {"dev"}); + jakarta.servlet.http.HttpServletResponse response = + mock(jakarta.servlet.http.HttpServletResponse.class); + when(response.isCommitted()).thenReturn(false); + + // First call computes and caches dev mode = true. + ResponseEntity first = + handler.handleGenericException(new Exception("a"), request, response); + // Second call should reuse the cached value (still includes debug info). + ResponseEntity second = + handler.handleGenericException(new Exception("b"), request, response); + + assertThat(first.getBody().getProperties()).containsKey("debugMessage"); + assertThat(second.getBody().getProperties()).containsKey("debugMessage"); + // getActiveProfiles consulted exactly once due to caching. + org.mockito.Mockito.verify(environment).getActiveProfiles(); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/DependencyTest.java b/app/core/src/test/java/stirling/software/SPDF/model/DependencyTest.java new file mode 100644 index 0000000000..a990119fe8 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/DependencyTest.java @@ -0,0 +1,72 @@ +package stirling.software.SPDF.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class DependencyTest { + + private Dependency populated() { + Dependency dep = new Dependency(); + dep.setModuleName("commons-lang3"); + dep.setModuleUrl("https://example.com/lang3"); + dep.setModuleVersion("3.14.0"); + dep.setModuleLicense("Apache-2.0"); + dep.setModuleLicenseUrl("https://example.com/license"); + return dep; + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("getters return values set via setters") + void roundTrip() { + Dependency dep = populated(); + + assertThat(dep.getModuleName()).isEqualTo("commons-lang3"); + assertThat(dep.getModuleUrl()).isEqualTo("https://example.com/lang3"); + assertThat(dep.getModuleVersion()).isEqualTo("3.14.0"); + assertThat(dep.getModuleLicense()).isEqualTo("Apache-2.0"); + assertThat(dep.getModuleLicenseUrl()).isEqualTo("https://example.com/license"); + } + + @Test + @DisplayName("fields default to null") + void defaultsNull() { + Dependency dep = new Dependency(); + + assertThat(dep.getModuleName()).isNull(); + assertThat(dep.getModuleVersion()).isNull(); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("identical content is equal and shares hashCode") + void equalContent() { + assertThat(populated()).isEqualTo(populated()).hasSameHashCodeAs(populated()); + } + + @Test + @DisplayName("differing content is not equal") + void differingContent() { + Dependency other = populated(); + other.setModuleVersion("9.9.9"); + + assertThat(populated()).isNotEqualTo(other).isNotEqualTo(null).isNotEqualTo("str"); + } + + @Test + @DisplayName("toString lists field values") + void toStringContent() { + assertThat(populated().toString()).contains("commons-lang3").contains("Apache-2.0"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/PDFTextTest.java b/app/core/src/test/java/stirling/software/SPDF/model/PDFTextTest.java new file mode 100644 index 0000000000..6ab29fb3fc --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/PDFTextTest.java @@ -0,0 +1,65 @@ +package stirling.software.SPDF.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class PDFTextTest { + + private PDFText sample() { + return new PDFText(1, 10.0f, 20.0f, 30.0f, 40.0f, "hello"); + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("all-args constructor exposes immutable values") + void constructor() { + PDFText text = sample(); + + assertThat(text.getPageIndex()).isEqualTo(1); + assertThat(text.getX1()).isEqualTo(10.0f); + assertThat(text.getY1()).isEqualTo(20.0f); + assertThat(text.getX2()).isEqualTo(30.0f); + assertThat(text.getY2()).isEqualTo(40.0f); + assertThat(text.getText()).isEqualTo("hello"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("equal values are equal and share a hashCode") + void equalValues() { + assertThat(sample()).isEqualTo(sample()).hasSameHashCodeAs(sample()); + } + + @Test + @DisplayName("different text breaks equality") + void differentText() { + PDFText other = new PDFText(1, 10.0f, 20.0f, 30.0f, 40.0f, "world"); + + assertThat(sample()).isNotEqualTo(other).isNotEqualTo(null).isNotEqualTo("x"); + } + + @Test + @DisplayName("different coordinate breaks equality") + void differentCoordinate() { + PDFText other = new PDFText(2, 10.0f, 20.0f, 30.0f, 40.0f, "hello"); + + assertThat(sample()).isNotEqualTo(other); + } + + @Test + @DisplayName("toString contains text content") + void toStringContent() { + assertThat(sample().toString()).contains("hello"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/PipelineConfigTest.java b/app/core/src/test/java/stirling/software/SPDF/model/PipelineConfigTest.java new file mode 100644 index 0000000000..8fa1afca28 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/PipelineConfigTest.java @@ -0,0 +1,79 @@ +package stirling.software.SPDF.model; + +import static org.assertj.core.api.Assertions.assertThat; + +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; + +class PipelineConfigTest { + + @Nested + @DisplayName("PipelineConfig") + class Config { + + @Test + @DisplayName("accessors round-trip including JSON-aliased fields") + void roundTrip() { + PipelineOperation op = new PipelineOperation(); + op.setOperation("rotate"); + op.setParameters(Map.of("angle", 90)); + + PipelineConfig config = new PipelineConfig(); + config.setName("my pipeline"); + config.setOperations(List.of(op)); + config.setOutputDir("/out"); + config.setOutputPattern("{name}-out"); + + assertThat(config.getName()).isEqualTo("my pipeline"); + assertThat(config.getOperations()).containsExactly(op); + assertThat(config.getOutputDir()).isEqualTo("/out"); + assertThat(config.getOutputPattern()).isEqualTo("{name}-out"); + } + + @Test + @DisplayName("equals and hashCode reflect content") + void equality() { + PipelineConfig a = new PipelineConfig(); + a.setName("p"); + PipelineConfig b = new PipelineConfig(); + b.setName("p"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a.toString()).contains("PipelineConfig"); + } + } + + @Nested + @DisplayName("PipelineOperation") + class Operation { + + @Test + @DisplayName("accessors round-trip") + void roundTrip() { + PipelineOperation op = new PipelineOperation(); + op.setOperation("merge"); + Map params = Map.of("k", "v"); + op.setParameters(params); + + assertThat(op.getOperation()).isEqualTo("merge"); + assertThat(op.getParameters()).isEqualTo(params); + } + + @Test + @DisplayName("equals/hashCode/toString") + void equality() { + PipelineOperation a = new PipelineOperation(); + a.setOperation("x"); + PipelineOperation b = new PipelineOperation(); + b.setOperation("x"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a).isNotEqualTo(new PipelineOperation()); + assertThat(a.toString()).contains("PipelineOperation"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/PipelineResultTest.java b/app/core/src/test/java/stirling/software/SPDF/model/PipelineResultTest.java new file mode 100644 index 0000000000..02a0fb619c --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/PipelineResultTest.java @@ -0,0 +1,94 @@ +package stirling.software.SPDF.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; + +import stirling.software.common.util.TempFile; + +class PipelineResultTest { + + @Nested + @DisplayName("scalar accessors") + class Accessors { + + @Test + @DisplayName("output files, error flag and filter flag round-trip") + void roundTrip() { + PipelineResult result = new PipelineResult(); + List files = List.of(new ByteArrayResource("a".getBytes())); + result.setOutputFiles(files); + result.setHasErrors(true); + result.setFiltersApplied(true); + + assertThat(result.getOutputFiles()).isEqualTo(files); + assertThat(result.isHasErrors()).isTrue(); + assertThat(result.isFiltersApplied()).isTrue(); + } + + @Test + @DisplayName("temp files list starts empty") + void tempFilesEmptyByDefault() { + assertThat(new PipelineResult().getTempFiles()).isEmpty(); + } + } + + @Nested + @DisplayName("temp file lifecycle") + class Lifecycle { + + @Test + @DisplayName("addTempFile stores the file") + void addTempFile() { + PipelineResult result = new PipelineResult(); + TempFile tempFile = mock(TempFile.class); + when(tempFile.getAbsolutePath()).thenReturn("/tmp/x"); + + result.addTempFile(tempFile); + + assertThat(result.getTempFiles()).containsExactly(tempFile); + } + + @Test + @DisplayName("close() closes each temp file and clears the list") + void closeClearsAndCloses() { + PipelineResult result = new PipelineResult(); + TempFile a = mock(TempFile.class); + TempFile b = mock(TempFile.class); + when(a.getAbsolutePath()).thenReturn("/tmp/a"); + when(b.getAbsolutePath()).thenReturn("/tmp/b"); + result.addTempFile(a); + result.addTempFile(b); + + result.close(); + + verify(a).close(); + verify(b).close(); + assertThat(result.getTempFiles()).isEmpty(); + } + + @Test + @DisplayName("cleanup() delegates to close()") + void cleanupDelegates() { + PipelineResult result = new PipelineResult(); + TempFile a = mock(TempFile.class); + when(a.getAbsolutePath()).thenReturn("/tmp/a"); + result.addTempFile(a); + + result.cleanup(); + + verify(a, times(1)).close(); + assertThat(result.getTempFiles()).isEmpty(); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/SignatureFileTest.java b/app/core/src/test/java/stirling/software/SPDF/model/SignatureFileTest.java new file mode 100644 index 0000000000..2d35d8bf99 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/SignatureFileTest.java @@ -0,0 +1,80 @@ +package stirling.software.SPDF.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class SignatureFileTest { + + @Nested + @DisplayName("constructors") + class Constructors { + + @Test + @DisplayName("all-args constructor sets both fields") + void allArgs() { + SignatureFile file = new SignatureFile("sig.png", "Personal"); + + assertThat(file.getFileName()).isEqualTo("sig.png"); + assertThat(file.getCategory()).isEqualTo("Personal"); + } + + @Test + @DisplayName("no-arg constructor leaves fields null") + void noArgs() { + SignatureFile file = new SignatureFile(); + + assertThat(file.getFileName()).isNull(); + assertThat(file.getCategory()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters update fields") + void setters() { + SignatureFile file = new SignatureFile(); + file.setFileName("shared.png"); + file.setCategory("Shared"); + + assertThat(file.getFileName()).isEqualTo("shared.png"); + assertThat(file.getCategory()).isEqualTo("Shared"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("equal content is equal and shares hashCode") + void equalContent() { + SignatureFile a = new SignatureFile("sig.png", "Personal"); + SignatureFile b = new SignatureFile("sig.png", "Personal"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing category breaks equality") + void differing() { + SignatureFile a = new SignatureFile("sig.png", "Personal"); + SignatureFile b = new SignatureFile("sig.png", "Shared"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("x"); + } + + @Test + @DisplayName("toString contains both fields") + void toStringContent() { + assertThat(new SignatureFile("sig.png", "Personal").toString()) + .contains("sig.png") + .contains("Personal"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/SplitTypesTest.java b/app/core/src/test/java/stirling/software/SPDF/model/SplitTypesTest.java new file mode 100644 index 0000000000..cb2a6b26d6 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/SplitTypesTest.java @@ -0,0 +1,64 @@ +package stirling.software.SPDF.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Arrays; + +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.EnumSource; + +class SplitTypesTest { + + @Nested + @DisplayName("enum constants") + class Constants { + + @Test + @DisplayName("contains exactly the expected constants") + void containsExpected() { + assertThat(Arrays.stream(SplitTypes.values()).map(Enum::name)) + .containsExactlyInAnyOrder( + "CUSTOM", + "SPLIT_ALL_EXCEPT_FIRST_AND_LAST", + "SPLIT_ALL_EXCEPT_FIRST", + "SPLIT_ALL_EXCEPT_LAST", + "SPLIT_ALL"); + } + + @Test + @DisplayName("has five constants") + void hasFive() { + assertThat(SplitTypes.values()).hasSize(5); + } + } + + @Nested + @DisplayName("valueOf") + class ValueOf { + + @ParameterizedTest + @EnumSource(SplitTypes.class) + @DisplayName("round trips name to constant") + void roundTrip(SplitTypes type) { + assertThat(SplitTypes.valueOf(type.name())).isSameAs(type); + } + + @Test + @DisplayName("throws for unknown name") + void unknownThrows() { + assertThatThrownBy(() -> SplitTypes.valueOf("NOT_A_TYPE")) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Test + @DisplayName("ordinal ordering is stable") + void ordinalOrdering() { + assertThat(SplitTypes.CUSTOM.ordinal()).isZero(); + assertThat(SplitTypes.SPLIT_ALL.ordinal()).isEqualTo(4); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/EditTableOfContentsRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/EditTableOfContentsRequestTest.java new file mode 100644 index 0000000000..d5371f2e42 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/EditTableOfContentsRequestTest.java @@ -0,0 +1,59 @@ +package stirling.software.SPDF.model.api; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +@DisplayName("EditTableOfContentsRequest") +class EditTableOfContentsRequestTest { + + @Test + @DisplayName("accessors round-trip including inherited fields") + void roundTrip() { + EditTableOfContentsRequest req = new EditTableOfContentsRequest(); + req.setBookmarkData("[{\"title\":\"Chapter 1\"}]"); + req.setReplaceExisting(true); + req.setFileId("file-1"); + req.setFileInput(new MockMultipartFile("f", new byte[] {1})); + + assertThat(req.getBookmarkData()).isEqualTo("[{\"title\":\"Chapter 1\"}]"); + assertThat(req.getReplaceExisting()).isTrue(); + assertThat(req.getFileId()).isEqualTo("file-1"); + assertThat(req.getFileInput()).isNotNull(); + } + + // callSuper=false: equality ignores inherited PDFFile fields. + @Test + @DisplayName("equals ignores inherited fields (callSuper=false)") + void equalsIgnoresSuper() { + EditTableOfContentsRequest a = new EditTableOfContentsRequest(); + a.setBookmarkData("data"); + a.setFileId("one"); + EditTableOfContentsRequest b = new EditTableOfContentsRequest(); + b.setBookmarkData("data"); + b.setFileId("two"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs when own field differs and vs null/other type") + void notEqual() { + EditTableOfContentsRequest a = new EditTableOfContentsRequest(); + a.setBookmarkData("a"); + EditTableOfContentsRequest b = new EditTableOfContentsRequest(); + b.setBookmarkData("b"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and value") + void toStringContent() { + EditTableOfContentsRequest a = new EditTableOfContentsRequest(); + a.setBookmarkData("bookmarks"); + assertThat(a.toString()).contains("EditTableOfContentsRequest").contains("bookmarks"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/HandleDataRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/HandleDataRequestTest.java new file mode 100644 index 0000000000..8c061652c7 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/HandleDataRequestTest.java @@ -0,0 +1,63 @@ +package stirling.software.SPDF.model.api; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("HandleDataRequest") +class HandleDataRequestTest { + + @Test + @DisplayName("accessors round-trip") + void roundTrip() { + HandleDataRequest req = new HandleDataRequest(); + MultipartFile[] files = { + new MockMultipartFile("a", new byte[] {1}), new MockMultipartFile("b", new byte[] {2}) + }; + req.setFileInput(files); + req.setJson("{\"name\":\"pipeline\"}"); + + assertThat(req.getFileInput()).hasSize(2); + assertThat(req.getJson()).isEqualTo("{\"name\":\"pipeline\"}"); + } + + // Lombok deep-compares the array via Arrays.equals. + @Test + @DisplayName("equal arrays with same content are equal; different content not") + void arrayEquality() { + HandleDataRequest a = new HandleDataRequest(); + a.setFileInput(new MultipartFile[] {new MockMultipartFile("a", new byte[] {1})}); + a.setJson("same"); + + HandleDataRequest b = new HandleDataRequest(); + b.setFileInput(a.getFileInput()); + b.setJson("same"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + + HandleDataRequest c = new HandleDataRequest(); + c.setFileInput(new MultipartFile[] {new MockMultipartFile("z", new byte[] {9})}); + c.setJson("same"); + assertThat(a).isNotEqualTo(c); + } + + @Test + @DisplayName("differs when json differs and vs null/other type") + void notEqual() { + HandleDataRequest a = new HandleDataRequest(); + a.setJson("a"); + HandleDataRequest b = new HandleDataRequest(); + b.setJson("b"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name") + void toStringContent() { + assertThat(new HandleDataRequest().toString()).contains("HandleDataRequest"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/ImageFileTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/ImageFileTest.java new file mode 100644 index 0000000000..26380561b2 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/ImageFileTest.java @@ -0,0 +1,47 @@ +package stirling.software.SPDF.model.api; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +@DisplayName("ImageFile") +class ImageFileTest { + + @Test + @DisplayName("fileInput accessor round-trips") + void roundTrip() { + ImageFile file = new ImageFile(); + MockMultipartFile mock = new MockMultipartFile("img", new byte[] {1, 2}); + file.setFileInput(mock); + + assertThat(file.getFileInput()).isSameAs(mock); + } + + @Test + @DisplayName("equals/hashCode for equal pair sharing the same file") + void equalPair() { + MockMultipartFile mock = new MockMultipartFile("img", new byte[] {1}); + ImageFile a = new ImageFile(); + a.setFileInput(mock); + ImageFile b = new ImageFile(); + b.setFileInput(mock); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("not equal to null or unrelated type") + void notEqual() { + ImageFile a = new ImageFile(); + a.setFileInput(new MockMultipartFile("img", new byte[] {1})); + assertThat(a).isNotEqualTo(new ImageFile()).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name") + void toStringContent() { + assertThat(new ImageFile().toString()).contains("ImageFile"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/MultiplePDFFilesTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/MultiplePDFFilesTest.java new file mode 100644 index 0000000000..98a263d9c8 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/MultiplePDFFilesTest.java @@ -0,0 +1,54 @@ +package stirling.software.SPDF.model.api; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("MultiplePDFFiles") +class MultiplePDFFilesTest { + + @Test + @DisplayName("fileInput array accessor round-trips") + void roundTrip() { + MultiplePDFFiles files = new MultiplePDFFiles(); + MultipartFile[] input = { + new MockMultipartFile("a", new byte[] {1}), new MockMultipartFile("b", new byte[] {2}) + }; + files.setFileInput(input); + + assertThat(files.getFileInput()).hasSize(2).isSameAs(input); + } + + // Lombok deep-compares the array via Arrays.equals. + @Test + @DisplayName("equal arrays with same content equal; different content not") + void arrayEquality() { + MultiplePDFFiles a = new MultiplePDFFiles(); + a.setFileInput(new MultipartFile[] {new MockMultipartFile("a", new byte[] {1})}); + MultiplePDFFiles b = new MultiplePDFFiles(); + b.setFileInput(a.getFileInput()); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + + MultiplePDFFiles c = new MultiplePDFFiles(); + c.setFileInput(new MultipartFile[] {new MockMultipartFile("z", new byte[] {9})}); + assertThat(a).isNotEqualTo(c); + } + + @Test + @DisplayName("not equal to null or unrelated type") + void notEqual() { + MultiplePDFFiles a = new MultiplePDFFiles(); + a.setFileInput(new MultipartFile[] {new MockMultipartFile("a", new byte[] {1})}); + assertThat(a).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name") + void toStringContent() { + assertThat(new MultiplePDFFiles().toString()).contains("MultiplePDFFiles"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/PDFComparisonAndCountTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/PDFComparisonAndCountTest.java new file mode 100644 index 0000000000..c167e12aaa --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/PDFComparisonAndCountTest.java @@ -0,0 +1,57 @@ +package stirling.software.SPDF.model.api; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("PDFComparisonAndCount") +class PDFComparisonAndCountTest { + + @Test + @DisplayName("pageCount defaults to 0") + void defaultPageCount() { + assertThat(new PDFComparisonAndCount().getPageCount()).isZero(); + } + + @Test + @DisplayName("pageCount and inherited comparator round-trip") + void roundTrip() { + PDFComparisonAndCount req = new PDFComparisonAndCount(); + req.setPageCount(5); + req.setComparator("Greater"); + + assertThat(req.getPageCount()).isEqualTo(5); + assertThat(req.getComparator()).isEqualTo("Greater"); + } + + // callSuper=true: fresh defaults equal, breaks when own field differs. + @Test + @DisplayName("fresh defaults equal; differs when pageCount differs") + void equality() { + PDFComparisonAndCount a = new PDFComparisonAndCount(); + PDFComparisonAndCount b = new PDFComparisonAndCount(); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + + b.setPageCount(3); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("differs when inherited comparator differs") + void inheritedDiff() { + PDFComparisonAndCount a = new PDFComparisonAndCount(); + a.setComparator("Greater"); + PDFComparisonAndCount b = new PDFComparisonAndCount(); + b.setComparator("Less"); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or unrelated type; toString contains class name") + void notEqualAndToString() { + assertThat(new PDFComparisonAndCount()).isNotEqualTo(null).isNotEqualTo("string"); + assertThat(new PDFComparisonAndCount().toString()).contains("PDFComparisonAndCount"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/PDFComparisonTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/PDFComparisonTest.java new file mode 100644 index 0000000000..fdfde403aa --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/PDFComparisonTest.java @@ -0,0 +1,51 @@ +package stirling.software.SPDF.model.api; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("PDFComparison") +class PDFComparisonTest { + + @Test + @DisplayName("comparator accessor round-trips") + void roundTrip() { + PDFComparison req = new PDFComparison(); + req.setComparator("Greater"); + req.setFileId("file-1"); + + assertThat(req.getComparator()).isEqualTo("Greater"); + assertThat(req.getFileId()).isEqualTo("file-1"); + } + + @Test + @DisplayName("equals/hashCode for equal pair") + void equalPair() { + PDFComparison a = new PDFComparison(); + a.setComparator("Equal"); + PDFComparison b = new PDFComparison(); + b.setComparator("Equal"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs when comparator differs and vs null/other type") + void notEqual() { + PDFComparison a = new PDFComparison(); + a.setComparator("Greater"); + PDFComparison b = new PDFComparison(); + b.setComparator("Less"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and value") + void toStringContent() { + PDFComparison a = new PDFComparison(); + a.setComparator("Greater"); + assertThat(a.toString()).contains("PDFComparison").contains("Greater"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/PDFExtractImagesRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/PDFExtractImagesRequestTest.java new file mode 100644 index 0000000000..88cc36c944 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/PDFExtractImagesRequestTest.java @@ -0,0 +1,50 @@ +package stirling.software.SPDF.model.api; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +// Empty-body subclass of PDFWithImageFormatRequest - exercised via inherited format. +@DisplayName("PDFExtractImagesRequest") +class PDFExtractImagesRequestTest { + + @Test + @DisplayName("inherited format accessor round-trips") + void roundTrip() { + PDFExtractImagesRequest req = new PDFExtractImagesRequest(); + req.setFormat("gif"); + req.setFileId("file-1"); + + assertThat(req.getFormat()).isEqualTo("gif"); + assertThat(req.getFileId()).isEqualTo("file-1"); + } + + @Test + @DisplayName("equals/hashCode for equal pair via inherited field") + void equalPair() { + PDFExtractImagesRequest a = new PDFExtractImagesRequest(); + a.setFormat("png"); + PDFExtractImagesRequest b = new PDFExtractImagesRequest(); + b.setFormat("png"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs when inherited format differs and vs null/other type") + void notEqual() { + PDFExtractImagesRequest a = new PDFExtractImagesRequest(); + a.setFormat("png"); + PDFExtractImagesRequest b = new PDFExtractImagesRequest(); + b.setFormat("jpeg"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name") + void toStringContent() { + assertThat(new PDFExtractImagesRequest().toString()).contains("PDFExtractImagesRequest"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/PDFWithImageFormatRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/PDFWithImageFormatRequestTest.java new file mode 100644 index 0000000000..0bffa6cc56 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/PDFWithImageFormatRequestTest.java @@ -0,0 +1,51 @@ +package stirling.software.SPDF.model.api; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("PDFWithImageFormatRequest") +class PDFWithImageFormatRequestTest { + + @Test + @DisplayName("format accessor round-trips") + void roundTrip() { + PDFWithImageFormatRequest req = new PDFWithImageFormatRequest(); + req.setFormat("jpeg"); + req.setFileId("file-1"); + + assertThat(req.getFormat()).isEqualTo("jpeg"); + assertThat(req.getFileId()).isEqualTo("file-1"); + } + + @Test + @DisplayName("equals/hashCode for equal pair") + void equalPair() { + PDFWithImageFormatRequest a = new PDFWithImageFormatRequest(); + a.setFormat("png"); + PDFWithImageFormatRequest b = new PDFWithImageFormatRequest(); + b.setFormat("png"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs when format differs and vs null/other type") + void notEqual() { + PDFWithImageFormatRequest a = new PDFWithImageFormatRequest(); + a.setFormat("png"); + PDFWithImageFormatRequest b = new PDFWithImageFormatRequest(); + b.setFormat("gif"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and value") + void toStringContent() { + PDFWithImageFormatRequest a = new PDFWithImageFormatRequest(); + a.setFormat("png"); + assertThat(a.toString()).contains("PDFWithImageFormatRequest").contains("png"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/PDFWithPageSizeTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/PDFWithPageSizeTest.java new file mode 100644 index 0000000000..40c6044a89 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/PDFWithPageSizeTest.java @@ -0,0 +1,61 @@ +package stirling.software.SPDF.model.api; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("PDFWithPageSize") +class PDFWithPageSizeTest { + + @Test + @DisplayName("orientation defaults to PORTRAIT, pageSize null") + void defaults() { + PDFWithPageSize req = new PDFWithPageSize(); + assertThat(req.getOrientation()).isEqualTo("PORTRAIT"); + assertThat(req.getPageSize()).isNull(); + } + + @Test + @DisplayName("accessors round-trip") + void roundTrip() { + PDFWithPageSize req = new PDFWithPageSize(); + req.setPageSize("A4"); + req.setOrientation("LANDSCAPE"); + req.setFileId("file-1"); + + assertThat(req.getPageSize()).isEqualTo("A4"); + assertThat(req.getOrientation()).isEqualTo("LANDSCAPE"); + assertThat(req.getFileId()).isEqualTo("file-1"); + } + + @Test + @DisplayName("equals/hashCode for equal pair") + void equalPair() { + PDFWithPageSize a = new PDFWithPageSize(); + a.setPageSize("A4"); + PDFWithPageSize b = new PDFWithPageSize(); + b.setPageSize("A4"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs when pageSize differs and vs null/other type") + void notEqual() { + PDFWithPageSize a = new PDFWithPageSize(); + a.setPageSize("A4"); + PDFWithPageSize b = new PDFWithPageSize(); + b.setPageSize("LETTER"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and value") + void toStringContent() { + PDFWithPageSize a = new PDFWithPageSize(); + a.setPageSize("LEGAL"); + assertThat(a.toString()).contains("PDFWithPageSize").contains("LEGAL"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/PdfJsonConversionProgressTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/PdfJsonConversionProgressTest.java new file mode 100644 index 0000000000..3a3dfa6eb8 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/PdfJsonConversionProgressTest.java @@ -0,0 +1,114 @@ +package stirling.software.SPDF.model.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class PdfJsonConversionProgressTest { + + @Nested + @DisplayName("of(percent, stage, message)") + class ThreeArgFactory { + + @Test + @DisplayName("sets fields and leaves complete false") + void buildsProgress() { + PdfJsonConversionProgress p = + PdfJsonConversionProgress.of(42, "loading", "Loading pages"); + + assertThat(p.getPercent()).isEqualTo(42); + assertThat(p.getStage()).isEqualTo("loading"); + assertThat(p.getMessage()).isEqualTo("Loading pages"); + assertThat(p.isComplete()).isFalse(); + assertThat(p.getCurrent()).isNull(); + assertThat(p.getTotal()).isNull(); + } + } + + @Nested + @DisplayName("of(percent, stage, message, current, total)") + class FiveArgFactory { + + @Test + @DisplayName("includes current and total counters") + void buildsProgressWithCounters() { + PdfJsonConversionProgress p = + PdfJsonConversionProgress.of(50, "pages", "Processing", 3, 6); + + assertThat(p.getPercent()).isEqualTo(50); + assertThat(p.getStage()).isEqualTo("pages"); + assertThat(p.getMessage()).isEqualTo("Processing"); + assertThat(p.getCurrent()).isEqualTo(3); + assertThat(p.getTotal()).isEqualTo(6); + assertThat(p.isComplete()).isFalse(); + } + } + + @Nested + @DisplayName("complete()") + class CompleteFactory { + + @Test + @DisplayName("marks 100 percent complete") + void buildsComplete() { + PdfJsonConversionProgress p = PdfJsonConversionProgress.complete(); + + assertThat(p.getPercent()).isEqualTo(100); + assertThat(p.getStage()).isEqualTo("complete"); + assertThat(p.getMessage()).isEqualTo("Conversion complete"); + assertThat(p.isComplete()).isTrue(); + } + } + + @Nested + @DisplayName("builder and accessors") + class BuilderAndAccessors { + + @Test + @DisplayName("builder populates all fields") + void builder() { + PdfJsonConversionProgress p = + PdfJsonConversionProgress.builder() + .percent(10) + .stage("init") + .message("starting") + .complete(false) + .current(1) + .total(5) + .build(); + + assertThat(p.getPercent()).isEqualTo(10); + assertThat(p.getStage()).isEqualTo("init"); + assertThat(p.getCurrent()).isEqualTo(1); + assertThat(p.getTotal()).isEqualTo(5); + } + + @Test + @DisplayName("no-arg constructor with setters works") + void noArgConstructor() { + PdfJsonConversionProgress p = new PdfJsonConversionProgress(); + p.setPercent(5); + p.setStage("s"); + p.setMessage("m"); + p.setComplete(true); + p.setCurrent(2); + p.setTotal(4); + + assertThat(p.getPercent()).isEqualTo(5); + assertThat(p.isComplete()).isTrue(); + } + + @Test + @DisplayName("equals/hashCode/toString") + void equality() { + PdfJsonConversionProgress a = new PdfJsonConversionProgress(1, "s", "m", false, 1, 2); + PdfJsonConversionProgress b = new PdfJsonConversionProgress(1, "s", "m", false, 1, 2); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a).isNotEqualTo(null).isNotEqualTo("x"); + assertThat(a.toString()).contains("PdfJsonConversionProgress"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/SplitPagesRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/SplitPagesRequestTest.java new file mode 100644 index 0000000000..b5134f21ce --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/SplitPagesRequestTest.java @@ -0,0 +1,90 @@ +package stirling.software.SPDF.model.api; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("SplitPagesRequest") +class SplitPagesRequestTest { + + @Nested + @DisplayName("accessors and equality") + class Accessors { + + @Test + @DisplayName("pageNumbers accessor round-trips") + void roundTrip() { + SplitPagesRequest req = new SplitPagesRequest(); + req.setPageNumbers("2,5"); + req.setFileId("file-1"); + + assertThat(req.getPageNumbers()).isEqualTo("2,5"); + assertThat(req.getFileId()).isEqualTo("file-1"); + } + + @Test + @DisplayName("equal pair shares hashCode") + void equalPair() { + SplitPagesRequest a = new SplitPagesRequest(); + a.setPageNumbers("2"); + SplitPagesRequest b = new SplitPagesRequest(); + b.setPageNumbers("2"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs when pageNumbers differs and vs null/other type") + void notEqual() { + SplitPagesRequest a = new SplitPagesRequest(); + a.setPageNumbers("2"); + SplitPagesRequest b = new SplitPagesRequest(); + b.setPageNumbers("3"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and value") + void toStringContent() { + SplitPagesRequest a = new SplitPagesRequest(); + a.setPageNumbers("2,5"); + assertThat(a.toString()).contains("SplitPagesRequest").contains("2,5"); + } + } + + @Nested + @DisplayName("getPageNumbersList") + class PageList { + + @Test + @DisplayName("explicit ranges resolve against the document page count") + void explicitRange() { + SplitPagesRequest req = new SplitPagesRequest(); + req.setPageNumbers("1,3,5-7"); + PDDocument doc = mock(PDDocument.class); + when(doc.getNumberOfPages()).thenReturn(10); + + List result = req.getPageNumbersList(doc, true); + + assertThat(result).containsExactly(1, 3, 5, 6, 7); + } + + @Test + @DisplayName("all resolves to every page") + void allPages() { + SplitPagesRequest req = new SplitPagesRequest(); + req.setPageNumbers("all"); + PDDocument doc = mock(PDDocument.class); + when(doc.getNumberOfPages()).thenReturn(3); + + assertThat(req.getPageNumbersList(doc, true)).containsExactly(1, 2, 3); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/SplitPdfByChaptersRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/SplitPdfByChaptersRequestTest.java new file mode 100644 index 0000000000..6d4434bc5c --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/SplitPdfByChaptersRequestTest.java @@ -0,0 +1,57 @@ +package stirling.software.SPDF.model.api; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("SplitPdfByChaptersRequest") +class SplitPdfByChaptersRequestTest { + + @Test + @DisplayName("accessors round-trip") + void roundTrip() { + SplitPdfByChaptersRequest req = new SplitPdfByChaptersRequest(); + req.setIncludeMetadata(true); + req.setAllowDuplicates(false); + req.setBookmarkLevel(2); + req.setFileId("file-1"); + + assertThat(req.getIncludeMetadata()).isTrue(); + assertThat(req.getAllowDuplicates()).isFalse(); + assertThat(req.getBookmarkLevel()).isEqualTo(2); + assertThat(req.getFileId()).isEqualTo("file-1"); + } + + // callSuper=false: equality ignores inherited PDFFile fields. + @Test + @DisplayName("equals ignores inherited fields (callSuper=false)") + void equalsIgnoresSuper() { + SplitPdfByChaptersRequest a = new SplitPdfByChaptersRequest(); + a.setBookmarkLevel(1); + a.setFileId("one"); + SplitPdfByChaptersRequest b = new SplitPdfByChaptersRequest(); + b.setBookmarkLevel(1); + b.setFileId("two"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs when own field differs and vs null/other type") + void notEqual() { + SplitPdfByChaptersRequest a = new SplitPdfByChaptersRequest(); + a.setBookmarkLevel(1); + SplitPdfByChaptersRequest b = new SplitPdfByChaptersRequest(); + b.setBookmarkLevel(2); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name") + void toStringContent() { + assertThat(new SplitPdfByChaptersRequest().toString()) + .contains("SplitPdfByChaptersRequest"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/SplitPdfBySectionsRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/SplitPdfBySectionsRequestTest.java new file mode 100644 index 0000000000..e1def4b1e3 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/SplitPdfBySectionsRequestTest.java @@ -0,0 +1,76 @@ +package stirling.software.SPDF.model.api; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("SplitPdfBySectionsRequest") +class SplitPdfBySectionsRequestTest { + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("all accessors round-trip") + void roundTrip() { + SplitPdfBySectionsRequest req = new SplitPdfBySectionsRequest(); + req.setPageNumbers("SPLIT_ALL"); + req.setSplitMode("CUSTOM"); + req.setHorizontalDivisions(3); + req.setVerticalDivisions(2); + req.setMerge(true); + req.setFileId("file-1"); + + assertThat(req.getPageNumbers()).isEqualTo("SPLIT_ALL"); + assertThat(req.getSplitMode()).isEqualTo("CUSTOM"); + assertThat(req.getHorizontalDivisions()).isEqualTo(3); + assertThat(req.getVerticalDivisions()).isEqualTo(2); + assertThat(req.getMerge()).isTrue(); + assertThat(req.getFileId()).isEqualTo("file-1"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class EqualityContract { + + @Test + @DisplayName("equal pair shares hashCode") + void equalPair() { + SplitPdfBySectionsRequest a = new SplitPdfBySectionsRequest(); + a.setHorizontalDivisions(2); + SplitPdfBySectionsRequest b = new SplitPdfBySectionsRequest(); + b.setHorizontalDivisions(2); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs when a subclass field differs") + void notEqualOnFieldDiff() { + SplitPdfBySectionsRequest a = new SplitPdfBySectionsRequest(); + a.setVerticalDivisions(1); + SplitPdfBySectionsRequest b = new SplitPdfBySectionsRequest(); + b.setVerticalDivisions(4); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or unrelated type") + void notEqualToOthers() { + assertThat(new SplitPdfBySectionsRequest()).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and a field value") + void toStringContent() { + SplitPdfBySectionsRequest a = new SplitPdfBySectionsRequest(); + a.setSplitMode("SPLIT_ALL"); + assertThat(a.toString()).contains("SplitPdfBySectionsRequest").contains("SPLIT_ALL"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertCbrToPdfRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertCbrToPdfRequestTest.java new file mode 100644 index 0000000000..f9692cf972 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertCbrToPdfRequestTest.java @@ -0,0 +1,82 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("ConvertCbrToPdfRequest") +class ConvertCbrToPdfRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "comic.cbr", "application/x-cbr", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("optimizeForEbook defaults to false") + void defaultValues() { + ConvertCbrToPdfRequest req = new ConvertCbrToPdfRequest(); + + assertThat(req.isOptimizeForEbook()).isFalse(); + assertThat(req.getFileInput()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round trip every field") + void setters() { + ConvertCbrToPdfRequest req = new ConvertCbrToPdfRequest(); + MultipartFile f = file(); + req.setFileInput(f); + req.setOptimizeForEbook(true); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.isOptimizeForEbook()).isTrue(); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("equal instances are equal and share a hashCode") + void equalInstances() { + ConvertCbrToPdfRequest a = new ConvertCbrToPdfRequest(); + ConvertCbrToPdfRequest b = new ConvertCbrToPdfRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing field breaks equality") + void notEqual() { + ConvertCbrToPdfRequest a = new ConvertCbrToPdfRequest(); + ConvertCbrToPdfRequest b = new ConvertCbrToPdfRequest(); + b.setOptimizeForEbook(true); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + ConvertCbrToPdfRequest req = new ConvertCbrToPdfRequest(); + req.setOptimizeForEbook(true); + + assertThat(req.toString()).isNotNull().contains("optimizeForEbook=true"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertCbzToPdfRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertCbzToPdfRequestTest.java new file mode 100644 index 0000000000..f2df26b166 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertCbzToPdfRequestTest.java @@ -0,0 +1,82 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("ConvertCbzToPdfRequest") +class ConvertCbzToPdfRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "comic.cbz", "application/x-cbz", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("optimizeForEbook defaults to false") + void defaultValues() { + ConvertCbzToPdfRequest req = new ConvertCbzToPdfRequest(); + + assertThat(req.isOptimizeForEbook()).isFalse(); + assertThat(req.getFileInput()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round trip every field") + void setters() { + ConvertCbzToPdfRequest req = new ConvertCbzToPdfRequest(); + MultipartFile f = file(); + req.setFileInput(f); + req.setOptimizeForEbook(true); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.isOptimizeForEbook()).isTrue(); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("equal instances are equal and share a hashCode") + void equalInstances() { + ConvertCbzToPdfRequest a = new ConvertCbzToPdfRequest(); + ConvertCbzToPdfRequest b = new ConvertCbzToPdfRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing field breaks equality") + void notEqual() { + ConvertCbzToPdfRequest a = new ConvertCbzToPdfRequest(); + ConvertCbzToPdfRequest b = new ConvertCbzToPdfRequest(); + b.setOptimizeForEbook(true); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + ConvertCbzToPdfRequest req = new ConvertCbzToPdfRequest(); + req.setOptimizeForEbook(true); + + assertThat(req.toString()).isNotNull().contains("optimizeForEbook=true"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertEbookToPdfRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertEbookToPdfRequestTest.java new file mode 100644 index 0000000000..ae9992b6d2 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertEbookToPdfRequestTest.java @@ -0,0 +1,91 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("ConvertEbookToPdfRequest") +class ConvertEbookToPdfRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "book.epub", "application/epub+zip", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("boolean wrappers and file start null on a fresh instance") + void defaultValues() { + ConvertEbookToPdfRequest req = new ConvertEbookToPdfRequest(); + + assertThat(req.getFileInput()).isNull(); + assertThat(req.getEmbedAllFonts()).isNull(); + assertThat(req.getIncludeTableOfContents()).isNull(); + assertThat(req.getIncludePageNumbers()).isNull(); + assertThat(req.getOptimizeForEbook()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round trip every field") + void setters() { + ConvertEbookToPdfRequest req = new ConvertEbookToPdfRequest(); + MultipartFile f = file(); + req.setFileInput(f); + req.setEmbedAllFonts(Boolean.TRUE); + req.setIncludeTableOfContents(Boolean.TRUE); + req.setIncludePageNumbers(Boolean.FALSE); + req.setOptimizeForEbook(Boolean.TRUE); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.getEmbedAllFonts()).isTrue(); + assertThat(req.getIncludeTableOfContents()).isTrue(); + assertThat(req.getIncludePageNumbers()).isFalse(); + assertThat(req.getOptimizeForEbook()).isTrue(); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("equal instances are equal and share a hashCode") + void equalInstances() { + ConvertEbookToPdfRequest a = new ConvertEbookToPdfRequest(); + ConvertEbookToPdfRequest b = new ConvertEbookToPdfRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing field breaks equality") + void notEqual() { + ConvertEbookToPdfRequest a = new ConvertEbookToPdfRequest(); + ConvertEbookToPdfRequest b = new ConvertEbookToPdfRequest(); + b.setEmbedAllFonts(Boolean.TRUE); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + ConvertEbookToPdfRequest req = new ConvertEbookToPdfRequest(); + req.setEmbedAllFonts(Boolean.TRUE); + + assertThat(req.toString()).isNotNull().contains("embedAllFonts=true"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertPdfToCbrRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertPdfToCbrRequestTest.java new file mode 100644 index 0000000000..16296dd9a8 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertPdfToCbrRequestTest.java @@ -0,0 +1,82 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("ConvertPdfToCbrRequest") +class ConvertPdfToCbrRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "doc.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("dpi defaults to 150") + void defaultValues() { + ConvertPdfToCbrRequest req = new ConvertPdfToCbrRequest(); + + assertThat(req.getDpi()).isEqualTo(150); + assertThat(req.getFileInput()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round trip every field") + void setters() { + ConvertPdfToCbrRequest req = new ConvertPdfToCbrRequest(); + MultipartFile f = file(); + req.setFileInput(f); + req.setDpi(300); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.getDpi()).isEqualTo(300); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("equal instances are equal and share a hashCode") + void equalInstances() { + ConvertPdfToCbrRequest a = new ConvertPdfToCbrRequest(); + ConvertPdfToCbrRequest b = new ConvertPdfToCbrRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing field breaks equality") + void notEqual() { + ConvertPdfToCbrRequest a = new ConvertPdfToCbrRequest(); + ConvertPdfToCbrRequest b = new ConvertPdfToCbrRequest(); + b.setDpi(72); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + ConvertPdfToCbrRequest req = new ConvertPdfToCbrRequest(); + req.setDpi(200); + + assertThat(req.toString()).isNotNull().contains("dpi=200"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertPdfToCbzRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertPdfToCbzRequestTest.java new file mode 100644 index 0000000000..2729ac4376 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertPdfToCbzRequestTest.java @@ -0,0 +1,82 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("ConvertPdfToCbzRequest") +class ConvertPdfToCbzRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "doc.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("dpi defaults to 150") + void defaultValues() { + ConvertPdfToCbzRequest req = new ConvertPdfToCbzRequest(); + + assertThat(req.getDpi()).isEqualTo(150); + assertThat(req.getFileInput()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round trip every field") + void setters() { + ConvertPdfToCbzRequest req = new ConvertPdfToCbzRequest(); + MultipartFile f = file(); + req.setFileInput(f); + req.setDpi(600); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.getDpi()).isEqualTo(600); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("equal instances are equal and share a hashCode") + void equalInstances() { + ConvertPdfToCbzRequest a = new ConvertPdfToCbzRequest(); + ConvertPdfToCbzRequest b = new ConvertPdfToCbzRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing field breaks equality") + void notEqual() { + ConvertPdfToCbzRequest a = new ConvertPdfToCbzRequest(); + ConvertPdfToCbzRequest b = new ConvertPdfToCbzRequest(); + b.setDpi(72); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + ConvertPdfToCbzRequest req = new ConvertPdfToCbzRequest(); + req.setDpi(200); + + assertThat(req.toString()).isNotNull().contains("dpi=200"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertPdfToEpubRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertPdfToEpubRequestTest.java new file mode 100644 index 0000000000..32a892e885 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertPdfToEpubRequestTest.java @@ -0,0 +1,112 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.OutputFormat; +import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.TargetDevice; + +class ConvertPdfToEpubRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("request initializes documented default values") + void defaultValues() { + ConvertPdfToEpubRequest req = new ConvertPdfToEpubRequest(); + + assertThat(req.getDetectChapters()).isTrue(); + assertThat(req.getTargetDevice()).isEqualTo(TargetDevice.TABLET_PHONE_IMAGES); + assertThat(req.getOutputFormat()).isEqualTo(OutputFormat.EPUB); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters update every field") + void setters() { + ConvertPdfToEpubRequest req = new ConvertPdfToEpubRequest(); + req.setDetectChapters(Boolean.FALSE); + req.setTargetDevice(TargetDevice.KINDLE_EINK_TEXT); + req.setOutputFormat(OutputFormat.AZW3); + + assertThat(req.getDetectChapters()).isFalse(); + assertThat(req.getTargetDevice()).isEqualTo(TargetDevice.KINDLE_EINK_TEXT); + assertThat(req.getOutputFormat()).isEqualTo(OutputFormat.AZW3); + } + } + + @Nested + @DisplayName("TargetDevice enum") + class TargetDeviceEnum { + + @Test + @DisplayName("exposes calibre profile per device") + void calibreProfiles() { + assertThat(TargetDevice.TABLET_PHONE_IMAGES.getCalibreProfile()).isEqualTo("tablet"); + assertThat(TargetDevice.KINDLE_EINK_TEXT.getCalibreProfile()).isEqualTo("kindle"); + } + + @Test + @DisplayName("valueOf round trips") + void valueOf() { + assertThat(TargetDevice.valueOf("KINDLE_EINK_TEXT")) + .isSameAs(TargetDevice.KINDLE_EINK_TEXT); + assertThat(TargetDevice.values()).hasSize(2); + } + } + + @Nested + @DisplayName("OutputFormat enum") + class OutputFormatEnum { + + @Test + @DisplayName("exposes extension and media type per format") + void formatMetadata() { + assertThat(OutputFormat.EPUB.getExtension()).isEqualTo("epub"); + assertThat(OutputFormat.EPUB.getMediaType()).isEqualTo("application/epub+zip"); + assertThat(OutputFormat.AZW3.getExtension()).isEqualTo("azw3"); + assertThat(OutputFormat.AZW3.getMediaType()).isEqualTo("application/vnd.amazon.ebook"); + } + + @Test + @DisplayName("valueOf round trips") + void valueOf() { + assertThat(OutputFormat.valueOf("AZW3")).isSameAs(OutputFormat.AZW3); + assertThat(OutputFormat.values()).hasSize(2); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("equal instances are equal and share a hashCode") + void equalInstances() { + ConvertPdfToEpubRequest a = new ConvertPdfToEpubRequest(); + ConvertPdfToEpubRequest b = new ConvertPdfToEpubRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a.toString()).isNotNull(); + } + + @Test + @DisplayName("differing field breaks equality") + void notEqual() { + ConvertPdfToEpubRequest a = new ConvertPdfToEpubRequest(); + ConvertPdfToEpubRequest b = new ConvertPdfToEpubRequest(); + b.setOutputFormat(OutputFormat.AZW3); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertToImageRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertToImageRequestTest.java new file mode 100644 index 0000000000..5bdbf36c76 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertToImageRequestTest.java @@ -0,0 +1,97 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("ConvertToImageRequest") +class ConvertToImageRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "doc.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round trip subclass fields") + void subclassFields() { + ConvertToImageRequest req = new ConvertToImageRequest(); + req.setImageFormat("png"); + req.setSingleOrMultiple("single"); + req.setColorType("greyscale"); + req.setDpi(300); + req.setIncludeAnnotations(Boolean.TRUE); + + assertThat(req.getImageFormat()).isEqualTo("png"); + assertThat(req.getSingleOrMultiple()).isEqualTo("single"); + assertThat(req.getColorType()).isEqualTo("greyscale"); + assertThat(req.getDpi()).isEqualTo(300); + assertThat(req.getIncludeAnnotations()).isTrue(); + } + + @Test + @DisplayName("setters round trip inherited fields") + void inheritedFields() { + ConvertToImageRequest req = new ConvertToImageRequest(); + MultipartFile f = file(); + req.setFileInput(f); + req.setFileId("file-123"); + req.setPageNumbers("1,3,5-9"); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.getFileId()).isEqualTo("file-123"); + assertThat(req.getPageNumbers()).isEqualTo("1,3,5-9"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("fresh instances are equal despite callSuper") + void equalInstances() { + ConvertToImageRequest a = new ConvertToImageRequest(); + ConvertToImageRequest b = new ConvertToImageRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqualSubclass() { + ConvertToImageRequest a = new ConvertToImageRequest(); + ConvertToImageRequest b = new ConvertToImageRequest(); + b.setImageFormat("jpeg"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("differing inherited field breaks equality") + void notEqualInherited() { + ConvertToImageRequest a = new ConvertToImageRequest(); + ConvertToImageRequest b = new ConvertToImageRequest(); + b.setPageNumbers("2"); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + ConvertToImageRequest req = new ConvertToImageRequest(); + req.setImageFormat("png"); + + assertThat(req.toString()).isNotNull().contains("imageFormat=png"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertToPdfRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertToPdfRequestTest.java new file mode 100644 index 0000000000..979266b11d --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/ConvertToPdfRequestTest.java @@ -0,0 +1,74 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("ConvertToPdfRequest") +class ConvertToPdfRequestTest { + + private static MultipartFile[] files() { + return new MultipartFile[] { + new MockMultipartFile("fileInput", "a.png", "image/png", new byte[] {1}), + new MockMultipartFile("fileInput", "b.png", "image/png", new byte[] {2}) + }; + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round trip every field") + void setters() { + ConvertToPdfRequest req = new ConvertToPdfRequest(); + MultipartFile[] f = files(); + req.setFileInput(f); + req.setFitOption("fitDocumentToImage"); + req.setColorType("greyscale"); + req.setAutoRotate(Boolean.TRUE); + + assertThat(req.getFileInput()).isSameAs(f).hasSize(2); + assertThat(req.getFitOption()).isEqualTo("fitDocumentToImage"); + assertThat(req.getColorType()).isEqualTo("greyscale"); + assertThat(req.getAutoRotate()).isTrue(); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("equal instances are equal and share a hashCode") + void equalInstances() { + ConvertToPdfRequest a = new ConvertToPdfRequest(); + ConvertToPdfRequest b = new ConvertToPdfRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing field breaks equality") + void notEqual() { + ConvertToPdfRequest a = new ConvertToPdfRequest(); + ConvertToPdfRequest b = new ConvertToPdfRequest(); + b.setFitOption("maintainAspectRatio"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + ConvertToPdfRequest req = new ConvertToPdfRequest(); + req.setColorType("blackwhite"); + + assertThat(req.toString()).isNotNull().contains("colorType=blackwhite"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToBookRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToBookRequestTest.java new file mode 100644 index 0000000000..298f2b5af4 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToBookRequestTest.java @@ -0,0 +1,80 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("PdfToBookRequest") +class PdfToBookRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "doc.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round trip subclass and inherited fields") + void setters() { + PdfToBookRequest req = new PdfToBookRequest(); + MultipartFile f = file(); + req.setOutputFormat("epub"); + req.setFileInput(f); + req.setFileId("file-1"); + + assertThat(req.getOutputFormat()).isEqualTo("epub"); + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.getFileId()).isEqualTo("file-1"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("fresh instances are equal despite callSuper") + void equalInstances() { + PdfToBookRequest a = new PdfToBookRequest(); + PdfToBookRequest b = new PdfToBookRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + PdfToBookRequest a = new PdfToBookRequest(); + PdfToBookRequest b = new PdfToBookRequest(); + b.setOutputFormat("mobi"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("differing inherited field breaks equality") + void notEqualInherited() { + PdfToBookRequest a = new PdfToBookRequest(); + PdfToBookRequest b = new PdfToBookRequest(); + b.setFileId("file-x"); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + PdfToBookRequest req = new PdfToBookRequest(); + req.setOutputFormat("azw3"); + + assertThat(req.toString()).isNotNull().contains("outputFormat=azw3"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequestTest.java new file mode 100644 index 0000000000..05b624b39a --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequestTest.java @@ -0,0 +1,84 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("PdfToPdfARequest") +class PdfToPdfARequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("strict defaults to false and outputFormat to null") + void defaultValues() { + PdfToPdfARequest req = new PdfToPdfARequest(); + + assertThat(req.getStrict()).isFalse(); + assertThat(req.getOutputFormat()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round trip every field") + void setters() { + PdfToPdfARequest req = new PdfToPdfARequest(); + req.setOutputFormat("pdfa"); + req.setStrict(Boolean.TRUE); + + assertThat(req.getOutputFormat()).isEqualTo("pdfa"); + assertThat(req.getStrict()).isTrue(); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("fresh instances are equal despite callSuper") + void equalInstances() { + PdfToPdfARequest a = new PdfToPdfARequest(); + PdfToPdfARequest b = new PdfToPdfARequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing outputFormat breaks equality") + void notEqualFormat() { + PdfToPdfARequest a = new PdfToPdfARequest(); + PdfToPdfARequest b = new PdfToPdfARequest(); + b.setOutputFormat("pdfx"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("differing strict breaks equality") + void notEqualStrict() { + PdfToPdfARequest a = new PdfToPdfARequest(); + PdfToPdfARequest b = new PdfToPdfARequest(); + b.setStrict(Boolean.TRUE); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + PdfToPdfARequest req = new PdfToPdfARequest(); + req.setOutputFormat("pdfa-2b"); + + assertThat(req.toString()).isNotNull().contains("outputFormat=pdfa-2b"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToPresentationRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToPresentationRequestTest.java new file mode 100644 index 0000000000..687507f678 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToPresentationRequestTest.java @@ -0,0 +1,68 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("PdfToPresentationRequest") +class PdfToPresentationRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "doc.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round trip subclass and inherited fields") + void setters() { + PdfToPresentationRequest req = new PdfToPresentationRequest(); + MultipartFile f = file(); + req.setOutputFormat("pptx"); + req.setFileInput(f); + + assertThat(req.getOutputFormat()).isEqualTo("pptx"); + assertThat(req.getFileInput()).isSameAs(f); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("fresh instances are equal despite callSuper") + void equalInstances() { + PdfToPresentationRequest a = new PdfToPresentationRequest(); + PdfToPresentationRequest b = new PdfToPresentationRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + PdfToPresentationRequest a = new PdfToPresentationRequest(); + PdfToPresentationRequest b = new PdfToPresentationRequest(); + b.setOutputFormat("odp"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + PdfToPresentationRequest req = new PdfToPresentationRequest(); + req.setOutputFormat("ppt"); + + assertThat(req.toString()).isNotNull().contains("outputFormat=ppt"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToTextOrRTFRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToTextOrRTFRequestTest.java new file mode 100644 index 0000000000..6d6103fa3a --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToTextOrRTFRequestTest.java @@ -0,0 +1,68 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("PdfToTextOrRTFRequest") +class PdfToTextOrRTFRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "doc.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round trip subclass and inherited fields") + void setters() { + PdfToTextOrRTFRequest req = new PdfToTextOrRTFRequest(); + MultipartFile f = file(); + req.setOutputFormat("txt"); + req.setFileInput(f); + + assertThat(req.getOutputFormat()).isEqualTo("txt"); + assertThat(req.getFileInput()).isSameAs(f); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("fresh instances are equal despite callSuper") + void equalInstances() { + PdfToTextOrRTFRequest a = new PdfToTextOrRTFRequest(); + PdfToTextOrRTFRequest b = new PdfToTextOrRTFRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + PdfToTextOrRTFRequest a = new PdfToTextOrRTFRequest(); + PdfToTextOrRTFRequest b = new PdfToTextOrRTFRequest(); + b.setOutputFormat("rtf"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + PdfToTextOrRTFRequest req = new PdfToTextOrRTFRequest(); + req.setOutputFormat("rtf"); + + assertThat(req.toString()).isNotNull().contains("outputFormat=rtf"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToVideoRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToVideoRequestTest.java new file mode 100644 index 0000000000..e0f26d1302 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToVideoRequestTest.java @@ -0,0 +1,96 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("PdfToVideoRequest") +class PdfToVideoRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("request initializes documented default values") + void defaultValues() { + PdfToVideoRequest req = new PdfToVideoRequest(); + + assertThat(req.getVideoFormat()).isEqualTo("mp4"); + assertThat(req.getSecondsPerPage()).isEqualTo(3); + assertThat(req.getResolution()).isEqualTo("ORIGINAL"); + assertThat(req.getDpi()).isEqualTo(150); + assertThat(req.getOpacity()).isEqualTo(0.1f); + assertThat(req.getWatermarkText()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round trip every field") + void setters() { + PdfToVideoRequest req = new PdfToVideoRequest(); + req.setVideoFormat("webm"); + req.setSecondsPerPage(5); + req.setResolution("720p"); + req.setDpi(300); + req.setOpacity(0.5f); + req.setWatermarkText("Stirling Software"); + + assertThat(req.getVideoFormat()).isEqualTo("webm"); + assertThat(req.getSecondsPerPage()).isEqualTo(5); + assertThat(req.getResolution()).isEqualTo("720p"); + assertThat(req.getDpi()).isEqualTo(300); + assertThat(req.getOpacity()).isEqualTo(0.5f); + assertThat(req.getWatermarkText()).isEqualTo("Stirling Software"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("fresh instances are equal despite callSuper") + void equalInstances() { + PdfToVideoRequest a = new PdfToVideoRequest(); + PdfToVideoRequest b = new PdfToVideoRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing field breaks equality") + void notEqual() { + PdfToVideoRequest a = new PdfToVideoRequest(); + PdfToVideoRequest b = new PdfToVideoRequest(); + b.setVideoFormat("webm"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("differing float opacity breaks equality") + void notEqualOpacity() { + PdfToVideoRequest a = new PdfToVideoRequest(); + PdfToVideoRequest b = new PdfToVideoRequest(); + b.setOpacity(0.9f); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + PdfToVideoRequest req = new PdfToVideoRequest(); + req.setResolution("480p"); + + assertThat(req.toString()).isNotNull().contains("resolution=480p"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToWordRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToWordRequestTest.java new file mode 100644 index 0000000000..6999d972a6 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfToWordRequestTest.java @@ -0,0 +1,68 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("PdfToWordRequest") +class PdfToWordRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "doc.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round trip subclass and inherited fields") + void setters() { + PdfToWordRequest req = new PdfToWordRequest(); + MultipartFile f = file(); + req.setOutputFormat("docx"); + req.setFileInput(f); + + assertThat(req.getOutputFormat()).isEqualTo("docx"); + assertThat(req.getFileInput()).isSameAs(f); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("fresh instances are equal despite callSuper") + void equalInstances() { + PdfToWordRequest a = new PdfToWordRequest(); + PdfToWordRequest b = new PdfToWordRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + PdfToWordRequest a = new PdfToWordRequest(); + PdfToWordRequest b = new PdfToWordRequest(); + b.setOutputFormat("odt"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + PdfToWordRequest req = new PdfToWordRequest(); + req.setOutputFormat("doc"); + + assertThat(req.toString()).isNotNull().contains("outputFormat=doc"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfVectorExportRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfVectorExportRequestTest.java new file mode 100644 index 0000000000..e39b58fa58 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/PdfVectorExportRequestTest.java @@ -0,0 +1,84 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("PdfVectorExportRequest") +class PdfVectorExportRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("outputFormat defaults to eps and prepress to null") + void defaultValues() { + PdfVectorExportRequest req = new PdfVectorExportRequest(); + + assertThat(req.getOutputFormat()).isEqualTo("eps"); + assertThat(req.getPrepress()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round trip every field") + void setters() { + PdfVectorExportRequest req = new PdfVectorExportRequest(); + req.setOutputFormat("xps"); + req.setPrepress(Boolean.TRUE); + + assertThat(req.getOutputFormat()).isEqualTo("xps"); + assertThat(req.getPrepress()).isTrue(); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("fresh instances are equal despite callSuper") + void equalInstances() { + PdfVectorExportRequest a = new PdfVectorExportRequest(); + PdfVectorExportRequest b = new PdfVectorExportRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing outputFormat breaks equality") + void notEqualFormat() { + PdfVectorExportRequest a = new PdfVectorExportRequest(); + PdfVectorExportRequest b = new PdfVectorExportRequest(); + b.setOutputFormat("ps"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("differing prepress breaks equality") + void notEqualPrepress() { + PdfVectorExportRequest a = new PdfVectorExportRequest(); + PdfVectorExportRequest b = new PdfVectorExportRequest(); + b.setPrepress(Boolean.TRUE); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + PdfVectorExportRequest req = new PdfVectorExportRequest(); + req.setOutputFormat("pcl"); + + assertThat(req.toString()).isNotNull().contains("outputFormat=pcl"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/SvgToPdfRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/SvgToPdfRequestTest.java new file mode 100644 index 0000000000..aa3f6c595a --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/SvgToPdfRequestTest.java @@ -0,0 +1,69 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("SvgToPdfRequest") +class SvgToPdfRequestTest { + + private static MultipartFile[] files() { + return new MultipartFile[] { + new MockMultipartFile("fileInput", "a.svg", "image/svg+xml", new byte[] {1}) + }; + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round trip every field") + void setters() { + SvgToPdfRequest req = new SvgToPdfRequest(); + MultipartFile[] f = files(); + req.setFileInput(f); + req.setCombineIntoSinglePdf(Boolean.TRUE); + + assertThat(req.getFileInput()).isSameAs(f).hasSize(1); + assertThat(req.getCombineIntoSinglePdf()).isTrue(); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("equal instances are equal and share a hashCode") + void equalInstances() { + SvgToPdfRequest a = new SvgToPdfRequest(); + SvgToPdfRequest b = new SvgToPdfRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing field breaks equality") + void notEqual() { + SvgToPdfRequest a = new SvgToPdfRequest(); + SvgToPdfRequest b = new SvgToPdfRequest(); + b.setCombineIntoSinglePdf(Boolean.TRUE); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + SvgToPdfRequest req = new SvgToPdfRequest(); + req.setCombineIntoSinglePdf(Boolean.TRUE); + + assertThat(req.toString()).isNotNull().contains("combineIntoSinglePdf=true"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/converters/UrlToPdfRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/UrlToPdfRequestTest.java new file mode 100644 index 0000000000..1c192147f4 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/converters/UrlToPdfRequestTest.java @@ -0,0 +1,61 @@ +package stirling.software.SPDF.model.api.converters; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("UrlToPdfRequest") +class UrlToPdfRequestTest { + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setter round trips the url field") + void setter() { + UrlToPdfRequest req = new UrlToPdfRequest(); + req.setUrlInput("https://example.com"); + + assertThat(req.getUrlInput()).isEqualTo("https://example.com"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("equal instances are equal and share a hashCode") + void equalInstances() { + UrlToPdfRequest a = new UrlToPdfRequest(); + a.setUrlInput("https://example.com"); + UrlToPdfRequest b = new UrlToPdfRequest(); + b.setUrlInput("https://example.com"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing field breaks equality") + void notEqual() { + UrlToPdfRequest a = new UrlToPdfRequest(); + a.setUrlInput("https://example.com"); + UrlToPdfRequest b = new UrlToPdfRequest(); + b.setUrlInput("https://other.com"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + UrlToPdfRequest req = new UrlToPdfRequest(); + req.setUrlInput("https://example.com"); + + assertThat(req.toString()).isNotNull().contains("https://example.com"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/filter/ContainsTextRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/filter/ContainsTextRequestTest.java new file mode 100644 index 0000000000..bea4c0a670 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/filter/ContainsTextRequestTest.java @@ -0,0 +1,84 @@ +package stirling.software.SPDF.model.api.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("ContainsTextRequest") +class ContainsTextRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "in.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("text and inherited pageNumbers null on a fresh instance") + void defaultValues() { + ContainsTextRequest req = new ContainsTextRequest(); + + assertThat(req.getText()).isNull(); + assertThat(req.getPageNumbers()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round-trip every field including inherited pageNumbers") + void setters() { + ContainsTextRequest req = new ContainsTextRequest(); + MultipartFile f = file(); + req.setFileInput(f); + req.setText("hello"); + req.setPageNumbers("all"); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.getText()).isEqualTo("hello"); + assertThat(req.getPageNumbers()).isEqualTo("all"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("two fresh default instances are equal and share a hashCode") + void equalInstances() { + ContainsTextRequest a = new ContainsTextRequest(); + ContainsTextRequest b = new ContainsTextRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + ContainsTextRequest a = new ContainsTextRequest(); + ContainsTextRequest b = new ContainsTextRequest(); + b.setText("other"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + ContainsTextRequest req = new ContainsTextRequest(); + req.setText("needle"); + + assertThat(req.toString()).isNotNull().contains("text=needle"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/filter/FileSizeRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/filter/FileSizeRequestTest.java new file mode 100644 index 0000000000..c20f6cc1ea --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/filter/FileSizeRequestTest.java @@ -0,0 +1,84 @@ +package stirling.software.SPDF.model.api.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("FileSizeRequest") +class FileSizeRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "in.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("fileSize zero and inherited comparator null on a fresh instance") + void defaultValues() { + FileSizeRequest req = new FileSizeRequest(); + + assertThat(req.getFileSize()).isZero(); + assertThat(req.getComparator()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round-trip every field including inherited comparator") + void setters() { + FileSizeRequest req = new FileSizeRequest(); + MultipartFile f = file(); + req.setFileInput(f); + req.setFileSize(1024L); + req.setComparator("Greater"); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.getFileSize()).isEqualTo(1024L); + assertThat(req.getComparator()).isEqualTo("Greater"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("two fresh default instances are equal and share a hashCode") + void equalInstances() { + FileSizeRequest a = new FileSizeRequest(); + FileSizeRequest b = new FileSizeRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + FileSizeRequest a = new FileSizeRequest(); + FileSizeRequest b = new FileSizeRequest(); + b.setFileSize(99L); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + FileSizeRequest req = new FileSizeRequest(); + req.setFileSize(2048L); + + assertThat(req.toString()).isNotNull().contains("fileSize=2048"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/filter/PageRotationRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/filter/PageRotationRequestTest.java new file mode 100644 index 0000000000..f63900a590 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/filter/PageRotationRequestTest.java @@ -0,0 +1,84 @@ +package stirling.software.SPDF.model.api.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("PageRotationRequest") +class PageRotationRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "in.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("rotation zero and inherited comparator null on a fresh instance") + void defaultValues() { + PageRotationRequest req = new PageRotationRequest(); + + assertThat(req.getRotation()).isZero(); + assertThat(req.getComparator()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round-trip every field including inherited comparator") + void setters() { + PageRotationRequest req = new PageRotationRequest(); + MultipartFile f = file(); + req.setFileInput(f); + req.setRotation(90); + req.setComparator("Equal"); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.getRotation()).isEqualTo(90); + assertThat(req.getComparator()).isEqualTo("Equal"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("two fresh default instances are equal and share a hashCode") + void equalInstances() { + PageRotationRequest a = new PageRotationRequest(); + PageRotationRequest b = new PageRotationRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + PageRotationRequest a = new PageRotationRequest(); + PageRotationRequest b = new PageRotationRequest(); + b.setRotation(180); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + PageRotationRequest req = new PageRotationRequest(); + req.setRotation(270); + + assertThat(req.toString()).isNotNull().contains("rotation=270"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/filter/PageSizeRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/filter/PageSizeRequestTest.java new file mode 100644 index 0000000000..ca741c230f --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/filter/PageSizeRequestTest.java @@ -0,0 +1,84 @@ +package stirling.software.SPDF.model.api.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("PageSizeRequest") +class PageSizeRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "in.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("standardPageSize and inherited comparator null on a fresh instance") + void defaultValues() { + PageSizeRequest req = new PageSizeRequest(); + + assertThat(req.getStandardPageSize()).isNull(); + assertThat(req.getComparator()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round-trip every field including inherited comparator") + void setters() { + PageSizeRequest req = new PageSizeRequest(); + MultipartFile f = file(); + req.setFileInput(f); + req.setStandardPageSize("A4"); + req.setComparator("Less"); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.getStandardPageSize()).isEqualTo("A4"); + assertThat(req.getComparator()).isEqualTo("Less"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("two fresh default instances are equal and share a hashCode") + void equalInstances() { + PageSizeRequest a = new PageSizeRequest(); + PageSizeRequest b = new PageSizeRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + PageSizeRequest a = new PageSizeRequest(); + PageSizeRequest b = new PageSizeRequest(); + b.setStandardPageSize("A3"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + PageSizeRequest req = new PageSizeRequest(); + req.setStandardPageSize("LETTER"); + + assertThat(req.toString()).isNotNull().contains("standardPageSize=LETTER"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/general/BookletImpositionRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/general/BookletImpositionRequestTest.java new file mode 100644 index 0000000000..44ddd1f384 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/general/BookletImpositionRequestTest.java @@ -0,0 +1,104 @@ +package stirling.software.SPDF.model.api.general; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("BookletImpositionRequest") +class BookletImpositionRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "in.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("documented default field values on a fresh instance") + void defaultValues() { + BookletImpositionRequest req = new BookletImpositionRequest(); + + assertThat(req.getPagesPerSheet()).isEqualTo(2); + assertThat(req.getAddBorder()).isFalse(); + assertThat(req.getSpineLocation()).isEqualTo("LEFT"); + assertThat(req.getAddGutter()).isFalse(); + assertThat(req.getGutterSize()).isEqualTo(12f); + assertThat(req.getDoubleSided()).isTrue(); + assertThat(req.getDuplexPass()).isEqualTo("BOTH"); + assertThat(req.getFlipOnShortEdge()).isFalse(); + assertThat(req.getFileInput()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round-trip every field including inherited") + void setters() { + BookletImpositionRequest req = new BookletImpositionRequest(); + MultipartFile f = file(); + req.setFileInput(f); + req.setFileId("id-1"); + req.setPagesPerSheet(2); + req.setAddBorder(true); + req.setSpineLocation("RIGHT"); + req.setAddGutter(true); + req.setGutterSize(24f); + req.setDoubleSided(false); + req.setDuplexPass("FIRST"); + req.setFlipOnShortEdge(true); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.getFileId()).isEqualTo("id-1"); + assertThat(req.getPagesPerSheet()).isEqualTo(2); + assertThat(req.getAddBorder()).isTrue(); + assertThat(req.getSpineLocation()).isEqualTo("RIGHT"); + assertThat(req.getAddGutter()).isTrue(); + assertThat(req.getGutterSize()).isEqualTo(24f); + assertThat(req.getDoubleSided()).isFalse(); + assertThat(req.getDuplexPass()).isEqualTo("FIRST"); + assertThat(req.getFlipOnShortEdge()).isTrue(); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("two fresh default instances are equal and share a hashCode") + void equalInstances() { + BookletImpositionRequest a = new BookletImpositionRequest(); + BookletImpositionRequest b = new BookletImpositionRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + BookletImpositionRequest a = new BookletImpositionRequest(); + BookletImpositionRequest b = new BookletImpositionRequest(); + b.setSpineLocation("RIGHT"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + BookletImpositionRequest req = new BookletImpositionRequest(); + + assertThat(req.toString()).isNotNull().contains("spineLocation=LEFT"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/general/CropPdfFormTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/general/CropPdfFormTest.java new file mode 100644 index 0000000000..ad95b0def0 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/general/CropPdfFormTest.java @@ -0,0 +1,96 @@ +package stirling.software.SPDF.model.api.general; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("CropPdfForm") +class CropPdfFormTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "in.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("removeDataOutsideCrop=true, autoCrop=false and coords null") + void defaultValues() { + CropPdfForm form = new CropPdfForm(); + + assertThat(form.isRemoveDataOutsideCrop()).isTrue(); + assertThat(form.isAutoCrop()).isFalse(); + assertThat(form.getX()).isNull(); + assertThat(form.getY()).isNull(); + assertThat(form.getWidth()).isNull(); + assertThat(form.getHeight()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round-trip every field including inherited") + void setters() { + CropPdfForm form = new CropPdfForm(); + MultipartFile f = file(); + form.setFileInput(f); + form.setX(1f); + form.setY(2f); + form.setWidth(100f); + form.setHeight(200f); + form.setRemoveDataOutsideCrop(false); + form.setAutoCrop(true); + + assertThat(form.getFileInput()).isSameAs(f); + assertThat(form.getX()).isEqualTo(1f); + assertThat(form.getY()).isEqualTo(2f); + assertThat(form.getWidth()).isEqualTo(100f); + assertThat(form.getHeight()).isEqualTo(200f); + assertThat(form.isRemoveDataOutsideCrop()).isFalse(); + assertThat(form.isAutoCrop()).isTrue(); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("two fresh default instances are equal and share a hashCode") + void equalInstances() { + CropPdfForm a = new CropPdfForm(); + CropPdfForm b = new CropPdfForm(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + CropPdfForm a = new CropPdfForm(); + CropPdfForm b = new CropPdfForm(); + b.setWidth(50f); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + CropPdfForm form = new CropPdfForm(); + form.setWidth(123f); + + assertThat(form.toString()).isNotNull().contains("width=123"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/general/EditTextRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/general/EditTextRequestTest.java new file mode 100644 index 0000000000..0e726fd32a --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/general/EditTextRequestTest.java @@ -0,0 +1,94 @@ +package stirling.software.SPDF.model.api.general; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.api.general.EditTextOperation; + +@DisplayName("EditTextRequest") +class EditTextRequestTest { + + private static EditTextOperation op(String find, String replace) { + EditTextOperation operation = new EditTextOperation(); + operation.setFind(find); + operation.setReplace(replace); + return operation; + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("edits and wholeWordSearch are null on a fresh instance") + void defaultValues() { + EditTextRequest req = new EditTextRequest(); + + assertThat(req.getEdits()).isNull(); + assertThat(req.getWholeWordSearch()).isNull(); + assertThat(req.getPageNumbers()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round-trip every field including inherited pageNumbers") + void setters() { + EditTextRequest req = new EditTextRequest(); + List edits = List.of(op("foo", "bar")); + req.setEdits(edits); + req.setWholeWordSearch(true); + req.setPageNumbers("1-3"); + + assertThat(req.getEdits()).isSameAs(edits); + assertThat(req.getWholeWordSearch()).isTrue(); + assertThat(req.getPageNumbers()).isEqualTo("1-3"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("equal instances are equal and share a hashCode") + void equalInstances() { + EditTextRequest a = new EditTextRequest(); + a.setEdits(List.of(op("foo", "bar"))); + a.setWholeWordSearch(true); + EditTextRequest b = new EditTextRequest(); + b.setEdits(List.of(op("foo", "bar"))); + b.setWholeWordSearch(true); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + EditTextRequest a = new EditTextRequest(); + a.setWholeWordSearch(false); + EditTextRequest b = new EditTextRequest(); + b.setWholeWordSearch(true); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + EditTextRequest req = new EditTextRequest(); + req.setWholeWordSearch(true); + + assertThat(req.toString()).isNotNull().contains("wholeWordSearch=true"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/general/GeneralRequestsTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/general/GeneralRequestsTest.java new file mode 100644 index 0000000000..b0f8bc97cd --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/general/GeneralRequestsTest.java @@ -0,0 +1,252 @@ +package stirling.software.SPDF.model.api.general; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +class GeneralRequestsTest { + + @Nested + @DisplayName("RotatePDFRequest") + class Rotate { + + @Test + @DisplayName("defaults to 90 degrees") + void defaultAngle() { + assertThat(new RotatePDFRequest().getAngle()).isEqualTo(90); + } + + @Test + @DisplayName("angle setter and equality including inherited fileInput") + void setterAndEquality() { + RotatePDFRequest a = new RotatePDFRequest(); + a.setAngle(180); + a.setFileInput(new MockMultipartFile("f", new byte[] {1})); + + RotatePDFRequest b = new RotatePDFRequest(); + b.setAngle(180); + b.setFileInput(a.getFileInput()); + + assertThat(a.getAngle()).isEqualTo(180); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a.toString()).contains("RotatePDFRequest"); + } + } + + @Nested + @DisplayName("ScalePagesRequest") + class Scale { + + @Test + @DisplayName("scale factor and inherited page size round-trip") + void roundTrip() { + ScalePagesRequest req = new ScalePagesRequest(); + req.setScaleFactor(1.5f); + req.setPageSize("A4"); + + assertThat(req.getScaleFactor()).isEqualTo(1.5f); + assertThat(req.getPageSize()).isEqualTo("A4"); + assertThat(req.getOrientation()).isEqualTo("PORTRAIT"); + } + + @Test + @DisplayName("equality differs when scale differs") + void equalityDiffers() { + ScalePagesRequest a = new ScalePagesRequest(); + a.setScaleFactor(1f); + ScalePagesRequest b = new ScalePagesRequest(); + b.setScaleFactor(2f); + + assertThat(a).isNotEqualTo(b); + } + } + + @Nested + @DisplayName("CropPdfForm") + class Crop { + + @Test + @DisplayName("boolean defaults: removeDataOutsideCrop=true, autoCrop=false") + void defaults() { + CropPdfForm form = new CropPdfForm(); + assertThat(form.isRemoveDataOutsideCrop()).isTrue(); + assertThat(form.isAutoCrop()).isFalse(); + } + + @Test + @DisplayName("coordinate accessors round-trip") + void coordinates() { + CropPdfForm form = new CropPdfForm(); + form.setX(1f); + form.setY(2f); + form.setWidth(100f); + form.setHeight(200f); + form.setAutoCrop(true); + form.setRemoveDataOutsideCrop(false); + + assertThat(form.getX()).isEqualTo(1f); + assertThat(form.getY()).isEqualTo(2f); + assertThat(form.getWidth()).isEqualTo(100f); + assertThat(form.getHeight()).isEqualTo(200f); + assertThat(form.isAutoCrop()).isTrue(); + assertThat(form.isRemoveDataOutsideCrop()).isFalse(); + assertThat(form.toString()).contains("CropPdfForm"); + } + } + + @Nested + @DisplayName("BookletImpositionRequest") + class Booklet { + + @Test + @DisplayName("defaults are applied") + void defaults() { + BookletImpositionRequest req = new BookletImpositionRequest(); + assertThat(req.getPagesPerSheet()).isEqualTo(2); + assertThat(req.getAddBorder()).isFalse(); + assertThat(req.getSpineLocation()).isEqualTo("LEFT"); + assertThat(req.getAddGutter()).isFalse(); + assertThat(req.getGutterSize()).isEqualTo(12f); + assertThat(req.getDoubleSided()).isTrue(); + assertThat(req.getDuplexPass()).isEqualTo("BOTH"); + assertThat(req.getFlipOnShortEdge()).isFalse(); + } + + @Test + @DisplayName("setters and equality") + void setters() { + BookletImpositionRequest a = new BookletImpositionRequest(); + a.setSpineLocation("RIGHT"); + a.setAddGutter(true); + a.setGutterSize(20f); + a.setDuplexPass("FIRST"); + BookletImpositionRequest b = new BookletImpositionRequest(); + b.setSpineLocation("RIGHT"); + b.setAddGutter(true); + b.setGutterSize(20f); + b.setDuplexPass("FIRST"); + + assertThat(a.getSpineLocation()).isEqualTo("RIGHT"); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + } + + @Nested + @DisplayName("PosterPdfRequest") + class Poster { + + @Test + @DisplayName("defaults are applied") + void defaults() { + PosterPdfRequest req = new PosterPdfRequest(); + assertThat(req.getPageSize()).isEqualTo("A4"); + assertThat(req.getXFactor()).isEqualTo(2); + assertThat(req.getYFactor()).isEqualTo(2); + assertThat(req.isRightToLeft()).isFalse(); + } + + @Test + @DisplayName("setters round-trip") + void setters() { + PosterPdfRequest req = new PosterPdfRequest(); + req.setPageSize("A3"); + req.setXFactor(3); + req.setYFactor(4); + req.setRightToLeft(true); + + assertThat(req.getPageSize()).isEqualTo("A3"); + assertThat(req.getXFactor()).isEqualTo(3); + assertThat(req.getYFactor()).isEqualTo(4); + assertThat(req.isRightToLeft()).isTrue(); + assertThat(req.toString()).contains("PosterPdfRequest"); + } + } + + @Nested + @DisplayName("MergeMultiplePagesRequest") + class MergeMultiple { + + @Test + @DisplayName("pagesPerSheet defaults to 2 and accessors round-trip") + void roundTrip() { + MergeMultiplePagesRequest req = new MergeMultiplePagesRequest(); + assertThat(req.getPagesPerSheet()).isEqualTo(2); + + req.setMode("grid"); + req.setArrangement("a"); + req.setReadingDirection("ltr"); + req.setRows(3); + req.setCols(2); + req.setOrientation("PORTRAIT"); + req.setInnerMargin(10); + req.setTopMargin(1); + req.setBottomMargin(2); + req.setLeftMargin(3); + req.setRightMargin(4); + req.setBorderWidth(2); + req.setAddBorder(true); + + assertThat(req.getMode()).isEqualTo("grid"); + assertThat(req.getRows()).isEqualTo(3); + assertThat(req.getCols()).isEqualTo(2); + assertThat(req.getInnerMargin()).isEqualTo(10); + assertThat(req.getBorderWidth()).isEqualTo(2); + assertThat(req.getAddBorder()).isTrue(); + assertThat(req.toString()).contains("MergeMultiplePagesRequest"); + } + } + + @Nested + @DisplayName("OverlayPdfsRequest") + class Overlay { + + @Test + @DisplayName("accessors round-trip") + void roundTrip() { + OverlayPdfsRequest req = new OverlayPdfsRequest(); + req.setOverlayMode("interleave"); + req.setCounts(new int[] {1, 2}); + req.setOverlayPosition(1); + + assertThat(req.getOverlayMode()).isEqualTo("interleave"); + assertThat(req.getCounts()).containsExactly(1, 2); + assertThat(req.getOverlayPosition()).isEqualTo(1); + } + } + + @Nested + @DisplayName("RearrangePagesRequest") + class Rearrange { + + @Test + @DisplayName("custom mode and inherited page numbers round-trip") + void roundTrip() { + RearrangePagesRequest req = new RearrangePagesRequest(); + req.setCustomMode("REVERSE_ORDER"); + req.setPageNumbers("1,2,3"); + + assertThat(req.getCustomMode()).isEqualTo("REVERSE_ORDER"); + assertThat(req.getPageNumbers()).isEqualTo("1,2,3"); + } + } + + @Nested + @DisplayName("SplitPdfBySizeOrCountRequest") + class SplitBySizeOrCount { + + @Test + @DisplayName("split type and value round-trip") + void roundTrip() { + SplitPdfBySizeOrCountRequest req = new SplitPdfBySizeOrCountRequest(); + req.setSplitType(1); + req.setSplitValue("10MB"); + + assertThat(req.getSplitType()).isEqualTo(1); + assertThat(req.getSplitValue()).isEqualTo("10MB"); + assertThat(req.toString()).contains("SplitPdfBySizeOrCountRequest"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/general/MergeMultiplePagesRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/general/MergeMultiplePagesRequestTest.java new file mode 100644 index 0000000000..1571f1a1a6 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/general/MergeMultiplePagesRequestTest.java @@ -0,0 +1,120 @@ +package stirling.software.SPDF.model.api.general; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("MergeMultiplePagesRequest") +class MergeMultiplePagesRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "in.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("pagesPerSheet defaults to 2 and other fields null/zero") + void defaultValues() { + MergeMultiplePagesRequest req = new MergeMultiplePagesRequest(); + + assertThat(req.getPagesPerSheet()).isEqualTo(2); + assertThat(req.getMode()).isNull(); + assertThat(req.getArrangement()).isNull(); + assertThat(req.getReadingDirection()).isNull(); + assertThat(req.getRows()).isZero(); + assertThat(req.getCols()).isZero(); + assertThat(req.getOrientation()).isNull(); + assertThat(req.getInnerMargin()).isZero(); + assertThat(req.getTopMargin()).isZero(); + assertThat(req.getBottomMargin()).isZero(); + assertThat(req.getLeftMargin()).isZero(); + assertThat(req.getRightMargin()).isZero(); + assertThat(req.getBorderWidth()).isZero(); + assertThat(req.getAddBorder()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round-trip every field including inherited") + void setters() { + MergeMultiplePagesRequest req = new MergeMultiplePagesRequest(); + MultipartFile f = file(); + req.setFileInput(f); + req.setMode("CUSTOM"); + req.setPagesPerSheet(4); + req.setArrangement("BY_COLUMNS"); + req.setReadingDirection("RTL"); + req.setRows(3); + req.setCols(2); + req.setOrientation("LANDSCAPE"); + req.setInnerMargin(5); + req.setTopMargin(6); + req.setBottomMargin(7); + req.setLeftMargin(8); + req.setRightMargin(9); + req.setBorderWidth(2); + req.setAddBorder(true); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.getMode()).isEqualTo("CUSTOM"); + assertThat(req.getPagesPerSheet()).isEqualTo(4); + assertThat(req.getArrangement()).isEqualTo("BY_COLUMNS"); + assertThat(req.getReadingDirection()).isEqualTo("RTL"); + assertThat(req.getRows()).isEqualTo(3); + assertThat(req.getCols()).isEqualTo(2); + assertThat(req.getOrientation()).isEqualTo("LANDSCAPE"); + assertThat(req.getInnerMargin()).isEqualTo(5); + assertThat(req.getTopMargin()).isEqualTo(6); + assertThat(req.getBottomMargin()).isEqualTo(7); + assertThat(req.getLeftMargin()).isEqualTo(8); + assertThat(req.getRightMargin()).isEqualTo(9); + assertThat(req.getBorderWidth()).isEqualTo(2); + assertThat(req.getAddBorder()).isTrue(); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("two fresh default instances are equal and share a hashCode") + void equalInstances() { + MergeMultiplePagesRequest a = new MergeMultiplePagesRequest(); + MergeMultiplePagesRequest b = new MergeMultiplePagesRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + MergeMultiplePagesRequest a = new MergeMultiplePagesRequest(); + MergeMultiplePagesRequest b = new MergeMultiplePagesRequest(); + b.setPagesPerSheet(16); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + MergeMultiplePagesRequest req = new MergeMultiplePagesRequest(); + req.setMode("CUSTOM"); + + assertThat(req.toString()).isNotNull().contains("mode=CUSTOM"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/general/MergePdfsRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/general/MergePdfsRequestTest.java new file mode 100644 index 0000000000..513f97ac9b --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/general/MergePdfsRequestTest.java @@ -0,0 +1,102 @@ +package stirling.software.SPDF.model.api.general; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("MergePdfsRequest") +class MergePdfsRequestTest { + + private static MultipartFile[] files() { + return new MultipartFile[] { + new MockMultipartFile("fileInput", "a.pdf", "application/pdf", new byte[] {1}), + new MockMultipartFile("fileInput", "b.pdf", "application/pdf", new byte[] {2}) + }; + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("sortType defaults to orderProvided, generateToc false, others null") + void defaultValues() { + MergePdfsRequest req = new MergePdfsRequest(); + + assertThat(req.getSortType()).isEqualTo("orderProvided"); + assertThat(req.isGenerateToc()).isFalse(); + assertThat(req.getRemoveCertSign()).isNull(); + assertThat(req.getClientFileIds()).isNull(); + assertThat(req.getFileInput()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round-trip every field including inherited fileInput array") + void setters() { + MergePdfsRequest req = new MergePdfsRequest(); + MultipartFile[] f = files(); + req.setFileInput(f); + req.setSortType("byFileName"); + req.setRemoveCertSign(true); + req.setGenerateToc(true); + req.setClientFileIds("[\"x\",\"y\"]"); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.getSortType()).isEqualTo("byFileName"); + assertThat(req.getRemoveCertSign()).isTrue(); + assertThat(req.isGenerateToc()).isTrue(); + assertThat(req.getClientFileIds()).isEqualTo("[\"x\",\"y\"]"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("two fresh default instances are equal and share a hashCode") + void equalInstances() { + MergePdfsRequest a = new MergePdfsRequest(); + MergePdfsRequest b = new MergePdfsRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + MergePdfsRequest a = new MergePdfsRequest(); + MergePdfsRequest b = new MergePdfsRequest(); + b.setSortType("byPDFTitle"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("differing inherited fileInput array breaks equality") + void notEqualByInheritedArray() { + MergePdfsRequest a = new MergePdfsRequest(); + MergePdfsRequest b = new MergePdfsRequest(); + b.setFileInput(files()); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + MergePdfsRequest req = new MergePdfsRequest(); + + assertThat(req.toString()).isNotNull().contains("sortType=orderProvided"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/general/OverlayPdfsRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/general/OverlayPdfsRequestTest.java new file mode 100644 index 0000000000..9d86f772c5 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/general/OverlayPdfsRequestTest.java @@ -0,0 +1,94 @@ +package stirling.software.SPDF.model.api.general; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("OverlayPdfsRequest") +class OverlayPdfsRequestTest { + + private static MultipartFile[] overlays() { + return new MultipartFile[] { + new MockMultipartFile("overlayFiles", "o.pdf", "application/pdf", new byte[] {9}) + }; + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("array and string fields null, int field zero on a fresh instance") + void defaultValues() { + OverlayPdfsRequest req = new OverlayPdfsRequest(); + + assertThat(req.getOverlayFiles()).isNull(); + assertThat(req.getOverlayMode()).isNull(); + assertThat(req.getCounts()).isNull(); + assertThat(req.getOverlayPosition()).isZero(); + assertThat(req.getFileInput()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round-trip every field including inherited") + void setters() { + OverlayPdfsRequest req = new OverlayPdfsRequest(); + MultipartFile base = new MockMultipartFile("fileInput", new byte[] {1}); + MultipartFile[] ov = overlays(); + int[] counts = {1, 2, 3}; + req.setFileInput(base); + req.setOverlayFiles(ov); + req.setOverlayMode("InterleavedOverlay"); + req.setCounts(counts); + req.setOverlayPosition(1); + + assertThat(req.getFileInput()).isSameAs(base); + assertThat(req.getOverlayFiles()).isSameAs(ov); + assertThat(req.getOverlayMode()).isEqualTo("InterleavedOverlay"); + assertThat(req.getCounts()).containsExactly(1, 2, 3); + assertThat(req.getOverlayPosition()).isEqualTo(1); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("two fresh default instances are equal and share a hashCode") + void equalInstances() { + OverlayPdfsRequest a = new OverlayPdfsRequest(); + OverlayPdfsRequest b = new OverlayPdfsRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + OverlayPdfsRequest a = new OverlayPdfsRequest(); + OverlayPdfsRequest b = new OverlayPdfsRequest(); + b.setOverlayPosition(1); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + OverlayPdfsRequest req = new OverlayPdfsRequest(); + req.setOverlayMode("SequentialOverlay"); + + assertThat(req.toString()).isNotNull().contains("overlayMode=SequentialOverlay"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/general/PosterPdfRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/general/PosterPdfRequestTest.java new file mode 100644 index 0000000000..a55451c15d --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/general/PosterPdfRequestTest.java @@ -0,0 +1,90 @@ +package stirling.software.SPDF.model.api.general; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("PosterPdfRequest") +class PosterPdfRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "in.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("pageSize=A4, xFactor=2, yFactor=2, rightToLeft=false") + void defaultValues() { + PosterPdfRequest req = new PosterPdfRequest(); + + assertThat(req.getPageSize()).isEqualTo("A4"); + assertThat(req.getXFactor()).isEqualTo(2); + assertThat(req.getYFactor()).isEqualTo(2); + assertThat(req.isRightToLeft()).isFalse(); + assertThat(req.getFileInput()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round-trip every field including inherited") + void setters() { + PosterPdfRequest req = new PosterPdfRequest(); + MultipartFile f = file(); + req.setFileInput(f); + req.setPageSize("A3"); + req.setXFactor(5); + req.setYFactor(7); + req.setRightToLeft(true); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.getPageSize()).isEqualTo("A3"); + assertThat(req.getXFactor()).isEqualTo(5); + assertThat(req.getYFactor()).isEqualTo(7); + assertThat(req.isRightToLeft()).isTrue(); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("two fresh default instances are equal and share a hashCode") + void equalInstances() { + PosterPdfRequest a = new PosterPdfRequest(); + PosterPdfRequest b = new PosterPdfRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + PosterPdfRequest a = new PosterPdfRequest(); + PosterPdfRequest b = new PosterPdfRequest(); + b.setXFactor(9); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + PosterPdfRequest req = new PosterPdfRequest(); + + assertThat(req.toString()).isNotNull().contains("pageSize=A4"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/general/RearrangePagesRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/general/RearrangePagesRequestTest.java new file mode 100644 index 0000000000..36f8b832de --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/general/RearrangePagesRequestTest.java @@ -0,0 +1,84 @@ +package stirling.software.SPDF.model.api.general; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("RearrangePagesRequest") +class RearrangePagesRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "in.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("customMode and inherited pageNumbers null on a fresh instance") + void defaultValues() { + RearrangePagesRequest req = new RearrangePagesRequest(); + + assertThat(req.getCustomMode()).isNull(); + assertThat(req.getPageNumbers()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round-trip every field including inherited pageNumbers") + void setters() { + RearrangePagesRequest req = new RearrangePagesRequest(); + MultipartFile f = file(); + req.setFileInput(f); + req.setCustomMode("REVERSE_ORDER"); + req.setPageNumbers("1,3,5"); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.getCustomMode()).isEqualTo("REVERSE_ORDER"); + assertThat(req.getPageNumbers()).isEqualTo("1,3,5"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("two fresh default instances are equal and share a hashCode") + void equalInstances() { + RearrangePagesRequest a = new RearrangePagesRequest(); + RearrangePagesRequest b = new RearrangePagesRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + RearrangePagesRequest a = new RearrangePagesRequest(); + RearrangePagesRequest b = new RearrangePagesRequest(); + b.setCustomMode("REVERSE_ORDER"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + RearrangePagesRequest req = new RearrangePagesRequest(); + req.setCustomMode("BOOKLET_SORT"); + + assertThat(req.toString()).isNotNull().contains("customMode=BOOKLET_SORT"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/general/RotatePDFRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/general/RotatePDFRequestTest.java new file mode 100644 index 0000000000..8ee2d1cece --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/general/RotatePDFRequestTest.java @@ -0,0 +1,81 @@ +package stirling.software.SPDF.model.api.general; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("RotatePDFRequest") +class RotatePDFRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "in.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("angle defaults to 90") + void defaultValues() { + RotatePDFRequest req = new RotatePDFRequest(); + + assertThat(req.getAngle()).isEqualTo(90); + assertThat(req.getFileInput()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round-trip every field including inherited") + void setters() { + RotatePDFRequest req = new RotatePDFRequest(); + MultipartFile f = file(); + req.setFileInput(f); + req.setAngle(270); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.getAngle()).isEqualTo(270); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("two fresh default instances are equal and share a hashCode") + void equalInstances() { + RotatePDFRequest a = new RotatePDFRequest(); + RotatePDFRequest b = new RotatePDFRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + RotatePDFRequest a = new RotatePDFRequest(); + RotatePDFRequest b = new RotatePDFRequest(); + b.setAngle(180); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + RotatePDFRequest req = new RotatePDFRequest(); + + assertThat(req.toString()).isNotNull().contains("angle=90"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/general/ScalePagesRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/general/ScalePagesRequestTest.java new file mode 100644 index 0000000000..8355d4a5c6 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/general/ScalePagesRequestTest.java @@ -0,0 +1,88 @@ +package stirling.software.SPDF.model.api.general; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("ScalePagesRequest") +class ScalePagesRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "in.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("scaleFactor zero and inherited orientation PORTRAIT") + void defaultValues() { + ScalePagesRequest req = new ScalePagesRequest(); + + assertThat(req.getScaleFactor()).isEqualTo(0f); + assertThat(req.getOrientation()).isEqualTo("PORTRAIT"); + assertThat(req.getPageSize()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round-trip every field including inherited page size") + void setters() { + ScalePagesRequest req = new ScalePagesRequest(); + MultipartFile f = file(); + req.setFileInput(f); + req.setScaleFactor(1.5f); + req.setPageSize("A4"); + req.setOrientation("LANDSCAPE"); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.getScaleFactor()).isEqualTo(1.5f); + assertThat(req.getPageSize()).isEqualTo("A4"); + assertThat(req.getOrientation()).isEqualTo("LANDSCAPE"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("two fresh default instances are equal and share a hashCode") + void equalInstances() { + ScalePagesRequest a = new ScalePagesRequest(); + ScalePagesRequest b = new ScalePagesRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + ScalePagesRequest a = new ScalePagesRequest(); + a.setScaleFactor(1f); + ScalePagesRequest b = new ScalePagesRequest(); + b.setScaleFactor(2f); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + ScalePagesRequest req = new ScalePagesRequest(); + req.setScaleFactor(2f); + + assertThat(req.toString()).isNotNull().contains("scaleFactor=2"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/general/SplitPdfBySizeOrCountRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/general/SplitPdfBySizeOrCountRequestTest.java new file mode 100644 index 0000000000..0fe7696271 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/general/SplitPdfBySizeOrCountRequestTest.java @@ -0,0 +1,85 @@ +package stirling.software.SPDF.model.api.general; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("SplitPdfBySizeOrCountRequest") +class SplitPdfBySizeOrCountRequestTest { + + private static MultipartFile file() { + return new MockMultipartFile( + "fileInput", "in.pdf", "application/pdf", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("splitType zero and splitValue null on a fresh instance") + void defaultValues() { + SplitPdfBySizeOrCountRequest req = new SplitPdfBySizeOrCountRequest(); + + assertThat(req.getSplitType()).isZero(); + assertThat(req.getSplitValue()).isNull(); + assertThat(req.getFileInput()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters round-trip every field including inherited") + void setters() { + SplitPdfBySizeOrCountRequest req = new SplitPdfBySizeOrCountRequest(); + MultipartFile f = file(); + req.setFileInput(f); + req.setSplitType(1); + req.setSplitValue("5"); + + assertThat(req.getFileInput()).isSameAs(f); + assertThat(req.getSplitType()).isEqualTo(1); + assertThat(req.getSplitValue()).isEqualTo("5"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class Equality { + + @Test + @DisplayName("two fresh default instances are equal and share a hashCode") + void equalInstances() { + SplitPdfBySizeOrCountRequest a = new SplitPdfBySizeOrCountRequest(); + SplitPdfBySizeOrCountRequest b = new SplitPdfBySizeOrCountRequest(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differing subclass field breaks equality") + void notEqual() { + SplitPdfBySizeOrCountRequest a = new SplitPdfBySizeOrCountRequest(); + SplitPdfBySizeOrCountRequest b = new SplitPdfBySizeOrCountRequest(); + b.setSplitType(2); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains a representative field value") + void toStringContent() { + SplitPdfBySizeOrCountRequest req = new SplitPdfBySizeOrCountRequest(); + req.setSplitValue("10MB"); + + assertThat(req.toString()).isNotNull().contains("splitValue=10MB"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AddAttachmentRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AddAttachmentRequestTest.java new file mode 100644 index 0000000000..25f520558c --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AddAttachmentRequestTest.java @@ -0,0 +1,119 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("AddAttachmentRequest") +class AddAttachmentRequestTest { + + private static MultipartFile sampleFile(String name) { + return new MockMultipartFile(name, name, "application/octet-stream", new byte[] {1, 2, 3}); + } + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("convertToPdfA3b defaults to false") + void convertToPdfA3bDefaultsFalse() { + assertThat(new AddAttachmentRequest().isConvertToPdfA3b()).isFalse(); + } + + @Test + @DisplayName("attachments defaults to null") + void attachmentsDefaultsNull() { + assertThat(new AddAttachmentRequest().getAttachments()).isNull(); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("attachments round-trips") + void attachmentsRoundTrip() { + AddAttachmentRequest req = new AddAttachmentRequest(); + List files = List.of(sampleFile("a.png"), sampleFile("b.png")); + req.setAttachments(files); + assertThat(req.getAttachments()).isEqualTo(files).hasSize(2); + } + + @Test + @DisplayName("convertToPdfA3b round-trips") + void convertToPdfA3bRoundTrip() { + AddAttachmentRequest req = new AddAttachmentRequest(); + req.setConvertToPdfA3b(true); + assertThat(req.isConvertToPdfA3b()).isTrue(); + } + + @Test + @DisplayName("inherited fileId round-trips") + void inheritedFileIdRoundTrip() { + AddAttachmentRequest req = new AddAttachmentRequest(); + req.setFileId("file-123"); + assertThat(req.getFileId()).isEqualTo("file-123"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + AddAttachmentRequest a = new AddAttachmentRequest(); + AddAttachmentRequest b = new AddAttachmentRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when a subclass field differs") + void differBySubclassField() { + AddAttachmentRequest a = new AddAttachmentRequest(); + AddAttachmentRequest b = new AddAttachmentRequest(); + b.setConvertToPdfA3b(true); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("differ when an inherited field differs") + void differByInheritedField() { + AddAttachmentRequest a = new AddAttachmentRequest(); + AddAttachmentRequest b = new AddAttachmentRequest(); + b.setFileId("x"); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + AddAttachmentRequest a = new AddAttachmentRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + AddAttachmentRequest req = new AddAttachmentRequest(); + req.setConvertToPdfA3b(true); + assertThat(req.toString()).isNotNull().contains("convertToPdfA3b=true"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AddCommentsRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AddCommentsRequestTest.java new file mode 100644 index 0000000000..5f280749e5 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AddCommentsRequestTest.java @@ -0,0 +1,91 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("AddCommentsRequest") +class AddCommentsRequestTest { + + private static final String SAMPLE = + "[{\"pageIndex\":0,\"x\":72,\"y\":720,\"width\":20,\"height\":20," + + "\"text\":\"Check this paragraph\"}]"; + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("comments defaults to null") + void commentsDefaultsNull() { + assertThat(new AddCommentsRequest().getComments()).isNull(); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("comments round-trips") + void commentsRoundTrip() { + AddCommentsRequest req = new AddCommentsRequest(); + req.setComments(SAMPLE); + assertThat(req.getComments()).isEqualTo(SAMPLE); + } + + @Test + @DisplayName("inherited fileId round-trips") + void inheritedFileIdRoundTrip() { + AddCommentsRequest req = new AddCommentsRequest(); + req.setFileId("file-9"); + assertThat(req.getFileId()).isEqualTo("file-9"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + AddCommentsRequest a = new AddCommentsRequest(); + AddCommentsRequest b = new AddCommentsRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when comments differs") + void differByComments() { + AddCommentsRequest a = new AddCommentsRequest(); + AddCommentsRequest b = new AddCommentsRequest(); + b.setComments(SAMPLE); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + AddCommentsRequest a = new AddCommentsRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + AddCommentsRequest req = new AddCommentsRequest(); + req.setComments("hello"); + assertThat(req.toString()).isNotNull().contains("comments=hello"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AddPageNumbersRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AddPageNumbersRequestTest.java new file mode 100644 index 0000000000..0469a154d8 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AddPageNumbersRequestTest.java @@ -0,0 +1,122 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("AddPageNumbersRequest") +class AddPageNumbersRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("documented default field values on a fresh instance") + void documentedDefaults() { + AddPageNumbersRequest req = new AddPageNumbersRequest(); + assertThat(req.getZeroPad()).isZero(); + assertThat(req.getPosition()).isEqualTo(8); + assertThat(req.getStartingNumber()).isZero(); + assertThat(req.getFontSize()).isEqualTo(0f); + assertThat(req.getCustomMargin()).isNull(); + assertThat(req.getFontType()).isNull(); + assertThat(req.getFontColor()).isNull(); + assertThat(req.getPagesToNumber()).isNull(); + assertThat(req.getCustomText()).isNull(); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("all fields round-trip") + void allFieldsRoundTrip() { + AddPageNumbersRequest req = new AddPageNumbersRequest(); + req.setCustomMargin("large"); + req.setFontSize(14.5f); + req.setFontType("courier"); + req.setFontColor("#FF0000"); + req.setZeroPad(4); + req.setPosition(5); + req.setStartingNumber(2); + req.setPagesToNumber("1,3-5"); + req.setCustomText("Page {n} of {total}"); + + assertThat(req.getCustomMargin()).isEqualTo("large"); + assertThat(req.getFontSize()).isEqualTo(14.5f); + assertThat(req.getFontType()).isEqualTo("courier"); + assertThat(req.getFontColor()).isEqualTo("#FF0000"); + assertThat(req.getZeroPad()).isEqualTo(4); + assertThat(req.getPosition()).isEqualTo(5); + assertThat(req.getStartingNumber()).isEqualTo(2); + assertThat(req.getPagesToNumber()).isEqualTo("1,3-5"); + assertThat(req.getCustomText()).isEqualTo("Page {n} of {total}"); + } + + @Test + @DisplayName("inherited pageNumbers round-trips") + void inheritedPageNumbersRoundTrip() { + AddPageNumbersRequest req = new AddPageNumbersRequest(); + req.setPageNumbers("2n+1"); + assertThat(req.getPageNumbers()).isEqualTo("2n+1"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + AddPageNumbersRequest a = new AddPageNumbersRequest(); + AddPageNumbersRequest b = new AddPageNumbersRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when a subclass field differs") + void differBySubclassField() { + AddPageNumbersRequest a = new AddPageNumbersRequest(); + AddPageNumbersRequest b = new AddPageNumbersRequest(); + b.setFontType("times"); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("differ when an inherited field differs") + void differByInheritedField() { + AddPageNumbersRequest a = new AddPageNumbersRequest(); + AddPageNumbersRequest b = new AddPageNumbersRequest(); + b.setPageNumbers("all"); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + AddPageNumbersRequest a = new AddPageNumbersRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + AddPageNumbersRequest req = new AddPageNumbersRequest(); + req.setFontType("helvetica"); + assertThat(req.toString()).isNotNull().contains("fontType=helvetica"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AddStampRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AddStampRequestTest.java new file mode 100644 index 0000000000..97c1ece055 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AddStampRequestTest.java @@ -0,0 +1,126 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("AddStampRequest") +class AddStampRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("documented default field values on a fresh instance") + void documentedDefaults() { + AddStampRequest req = new AddStampRequest(); + assertThat(req.getAlphabet()).isEqualTo("roman"); + assertThat(req.getStampType()).isNull(); + assertThat(req.getStampText()).isNull(); + assertThat(req.getStampImage()).isNull(); + assertThat(req.getFontSize()).isEqualTo(0f); + assertThat(req.getRotation()).isEqualTo(0f); + assertThat(req.getOpacity()).isEqualTo(0f); + assertThat(req.getPosition()).isZero(); + assertThat(req.getOverrideX()).isEqualTo(0f); + assertThat(req.getOverrideY()).isEqualTo(0f); + assertThat(req.getCustomMargin()).isNull(); + assertThat(req.getCustomColor()).isNull(); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("all fields round-trip") + void allFieldsRoundTrip() { + AddStampRequest req = new AddStampRequest(); + MultipartFile image = + new MockMultipartFile("stampImage", "s.png", "image/png", new byte[] {9}); + req.setStampType("image"); + req.setStampText("Confidential"); + req.setStampImage(image); + req.setAlphabet("arabic"); + req.setFontSize(40f); + req.setRotation(45f); + req.setOpacity(0.5f); + req.setPosition(8); + req.setOverrideX(-1f); + req.setOverrideY(-1f); + req.setCustomMargin("medium"); + req.setCustomColor("#d3d3d3"); + + assertThat(req.getStampType()).isEqualTo("image"); + assertThat(req.getStampText()).isEqualTo("Confidential"); + assertThat(req.getStampImage()).isSameAs(image); + assertThat(req.getAlphabet()).isEqualTo("arabic"); + assertThat(req.getFontSize()).isEqualTo(40f); + assertThat(req.getRotation()).isEqualTo(45f); + assertThat(req.getOpacity()).isEqualTo(0.5f); + assertThat(req.getPosition()).isEqualTo(8); + assertThat(req.getOverrideX()).isEqualTo(-1f); + assertThat(req.getOverrideY()).isEqualTo(-1f); + assertThat(req.getCustomMargin()).isEqualTo("medium"); + assertThat(req.getCustomColor()).isEqualTo("#d3d3d3"); + } + + @Test + @DisplayName("inherited pageNumbers round-trips") + void inheritedPageNumbersRoundTrip() { + AddStampRequest req = new AddStampRequest(); + req.setPageNumbers("all"); + assertThat(req.getPageNumbers()).isEqualTo("all"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + AddStampRequest a = new AddStampRequest(); + AddStampRequest b = new AddStampRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when a subclass field differs") + void differBySubclassField() { + AddStampRequest a = new AddStampRequest(); + AddStampRequest b = new AddStampRequest(); + b.setStampType("text"); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + AddStampRequest a = new AddStampRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + AddStampRequest req = new AddStampRequest(); + req.setStampType("text"); + assertThat(req.toString()).isNotNull().contains("stampType=text"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AttachmentInfoTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AttachmentInfoTest.java new file mode 100644 index 0000000000..86c6bc7933 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AttachmentInfoTest.java @@ -0,0 +1,108 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("AttachmentInfo") +class AttachmentInfoTest { + + @Nested + @DisplayName("constructors") + class Constructors { + + @Test + @DisplayName("no-arg constructor leaves all fields null") + void noArgConstructorNullFields() { + AttachmentInfo info = new AttachmentInfo(); + assertThat(info.getFilename()).isNull(); + assertThat(info.getSize()).isNull(); + assertThat(info.getContentType()).isNull(); + assertThat(info.getDescription()).isNull(); + assertThat(info.getCreationDate()).isNull(); + assertThat(info.getModificationDate()).isNull(); + } + + @Test + @DisplayName("all-args constructor sets every field") + void allArgsConstructorSetsFields() { + AttachmentInfo info = + new AttachmentInfo("file.txt", 123L, "text/plain", "desc", "2023", "2024"); + assertThat(info.getFilename()).isEqualTo("file.txt"); + assertThat(info.getSize()).isEqualTo(123L); + assertThat(info.getContentType()).isEqualTo("text/plain"); + assertThat(info.getDescription()).isEqualTo("desc"); + assertThat(info.getCreationDate()).isEqualTo("2023"); + assertThat(info.getModificationDate()).isEqualTo("2024"); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("all fields round-trip") + void allFieldsRoundTrip() { + AttachmentInfo info = new AttachmentInfo(); + info.setFilename("a.pdf"); + info.setSize(42L); + info.setContentType("application/pdf"); + info.setDescription("an attachment"); + info.setCreationDate("2023/10/01"); + info.setModificationDate("2024/01/02"); + + assertThat(info.getFilename()).isEqualTo("a.pdf"); + assertThat(info.getSize()).isEqualTo(42L); + assertThat(info.getContentType()).isEqualTo("application/pdf"); + assertThat(info.getDescription()).isEqualTo("an attachment"); + assertThat(info.getCreationDate()).isEqualTo("2023/10/01"); + assertThat(info.getModificationDate()).isEqualTo("2024/01/02"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("equal objects are equal and share hashCode") + void equalObjects() { + AttachmentInfo a = new AttachmentInfo("f", 1L, "ct", "d", "c", "m"); + AttachmentInfo b = new AttachmentInfo("f", 1L, "ct", "d", "c", "m"); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when one field differs") + void differByOneField() { + AttachmentInfo a = new AttachmentInfo("f", 1L, "ct", "d", "c", "m"); + AttachmentInfo b = new AttachmentInfo("f", 2L, "ct", "d", "c", "m"); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + AttachmentInfo a = new AttachmentInfo(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + AttachmentInfo info = new AttachmentInfo(); + info.setFilename("report.pdf"); + assertThat(info.toString()).isNotNull().contains("report.pdf"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AutoSplitPdfRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AutoSplitPdfRequestTest.java new file mode 100644 index 0000000000..484be4dc49 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/AutoSplitPdfRequestTest.java @@ -0,0 +1,87 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("AutoSplitPdfRequest") +class AutoSplitPdfRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("duplexMode defaults to null") + void duplexModeDefaultsNull() { + assertThat(new AutoSplitPdfRequest().getDuplexMode()).isNull(); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("duplexMode round-trips") + void duplexModeRoundTrip() { + AutoSplitPdfRequest req = new AutoSplitPdfRequest(); + req.setDuplexMode(Boolean.TRUE); + assertThat(req.getDuplexMode()).isTrue(); + } + + @Test + @DisplayName("inherited fileId round-trips") + void inheritedFileIdRoundTrip() { + AutoSplitPdfRequest req = new AutoSplitPdfRequest(); + req.setFileId("file-77"); + assertThat(req.getFileId()).isEqualTo("file-77"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + AutoSplitPdfRequest a = new AutoSplitPdfRequest(); + AutoSplitPdfRequest b = new AutoSplitPdfRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when duplexMode differs") + void differByDuplexMode() { + AutoSplitPdfRequest a = new AutoSplitPdfRequest(); + AutoSplitPdfRequest b = new AutoSplitPdfRequest(); + b.setDuplexMode(Boolean.TRUE); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + AutoSplitPdfRequest a = new AutoSplitPdfRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + AutoSplitPdfRequest req = new AutoSplitPdfRequest(); + req.setDuplexMode(Boolean.TRUE); + assertThat(req.toString()).isNotNull().contains("duplexMode=true"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/DeleteAttachmentRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/DeleteAttachmentRequestTest.java new file mode 100644 index 0000000000..dd83301e09 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/DeleteAttachmentRequestTest.java @@ -0,0 +1,87 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("DeleteAttachmentRequest") +class DeleteAttachmentRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("attachmentName defaults to null") + void attachmentNameDefaultsNull() { + assertThat(new DeleteAttachmentRequest().getAttachmentName()).isNull(); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("attachmentName round-trips") + void attachmentNameRoundTrip() { + DeleteAttachmentRequest req = new DeleteAttachmentRequest(); + req.setAttachmentName("notes.txt"); + assertThat(req.getAttachmentName()).isEqualTo("notes.txt"); + } + + @Test + @DisplayName("inherited fileId round-trips") + void inheritedFileIdRoundTrip() { + DeleteAttachmentRequest req = new DeleteAttachmentRequest(); + req.setFileId("file-1"); + assertThat(req.getFileId()).isEqualTo("file-1"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + DeleteAttachmentRequest a = new DeleteAttachmentRequest(); + DeleteAttachmentRequest b = new DeleteAttachmentRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when attachmentName differs") + void differByAttachmentName() { + DeleteAttachmentRequest a = new DeleteAttachmentRequest(); + DeleteAttachmentRequest b = new DeleteAttachmentRequest(); + b.setAttachmentName("x.txt"); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + DeleteAttachmentRequest a = new DeleteAttachmentRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + DeleteAttachmentRequest req = new DeleteAttachmentRequest(); + req.setAttachmentName("doc.txt"); + assertThat(req.toString()).isNotNull().contains("attachmentName=doc.txt"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ExtractAttachmentsRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ExtractAttachmentsRequestTest.java new file mode 100644 index 0000000000..84d2462565 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ExtractAttachmentsRequestTest.java @@ -0,0 +1,67 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +// Zero-field subclass of PDFFile; exercised through inherited state and equality. +@DisplayName("ExtractAttachmentsRequest") +class ExtractAttachmentsRequestTest { + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("inherited fileId round-trips") + void inheritedFileIdRoundTrip() { + ExtractAttachmentsRequest req = new ExtractAttachmentsRequest(); + req.setFileId("file-55"); + assertThat(req.getFileId()).isEqualTo("file-55"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + ExtractAttachmentsRequest a = new ExtractAttachmentsRequest(); + ExtractAttachmentsRequest b = new ExtractAttachmentsRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when an inherited field differs") + void differByInheritedField() { + ExtractAttachmentsRequest a = new ExtractAttachmentsRequest(); + ExtractAttachmentsRequest b = new ExtractAttachmentsRequest(); + b.setFileId("x"); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + ExtractAttachmentsRequest a = new ExtractAttachmentsRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null") + void toStringNonNull() { + assertThat(new ExtractAttachmentsRequest().toString()).isNotNull(); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ExtractHeaderRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ExtractHeaderRequestTest.java new file mode 100644 index 0000000000..65459c8cdd --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ExtractHeaderRequestTest.java @@ -0,0 +1,87 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("ExtractHeaderRequest") +class ExtractHeaderRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("useFirstTextAsFallback defaults to null") + void useFirstTextAsFallbackDefaultsNull() { + assertThat(new ExtractHeaderRequest().getUseFirstTextAsFallback()).isNull(); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("useFirstTextAsFallback round-trips") + void useFirstTextAsFallbackRoundTrip() { + ExtractHeaderRequest req = new ExtractHeaderRequest(); + req.setUseFirstTextAsFallback(Boolean.TRUE); + assertThat(req.getUseFirstTextAsFallback()).isTrue(); + } + + @Test + @DisplayName("inherited fileId round-trips") + void inheritedFileIdRoundTrip() { + ExtractHeaderRequest req = new ExtractHeaderRequest(); + req.setFileId("file-2"); + assertThat(req.getFileId()).isEqualTo("file-2"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + ExtractHeaderRequest a = new ExtractHeaderRequest(); + ExtractHeaderRequest b = new ExtractHeaderRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when useFirstTextAsFallback differs") + void differByField() { + ExtractHeaderRequest a = new ExtractHeaderRequest(); + ExtractHeaderRequest b = new ExtractHeaderRequest(); + b.setUseFirstTextAsFallback(Boolean.TRUE); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + ExtractHeaderRequest a = new ExtractHeaderRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + ExtractHeaderRequest req = new ExtractHeaderRequest(); + req.setUseFirstTextAsFallback(Boolean.TRUE); + assertThat(req.toString()).isNotNull().contains("useFirstTextAsFallback=true"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ExtractImageScansRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ExtractImageScansRequestTest.java new file mode 100644 index 0000000000..d457e11d84 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ExtractImageScansRequestTest.java @@ -0,0 +1,100 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("ExtractImageScansRequest") +class ExtractImageScansRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("primitive int fields default to 0 and fileInput to null") + void primitiveDefaults() { + ExtractImageScansRequest req = new ExtractImageScansRequest(); + assertThat(req.getFileInput()).isNull(); + assertThat(req.getAngleThreshold()).isZero(); + assertThat(req.getTolerance()).isZero(); + assertThat(req.getMinArea()).isZero(); + assertThat(req.getMinContourArea()).isZero(); + assertThat(req.getBorderSize()).isZero(); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("all fields round-trip") + void allFieldsRoundTrip() { + ExtractImageScansRequest req = new ExtractImageScansRequest(); + MultipartFile file = + new MockMultipartFile("fileInput", "scan.png", "image/png", new byte[] {1}); + req.setFileInput(file); + req.setAngleThreshold(5); + req.setTolerance(20); + req.setMinArea(8000); + req.setMinContourArea(500); + req.setBorderSize(1); + + assertThat(req.getFileInput()).isSameAs(file); + assertThat(req.getAngleThreshold()).isEqualTo(5); + assertThat(req.getTolerance()).isEqualTo(20); + assertThat(req.getMinArea()).isEqualTo(8000); + assertThat(req.getMinContourArea()).isEqualTo(500); + assertThat(req.getBorderSize()).isEqualTo(1); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + ExtractImageScansRequest a = new ExtractImageScansRequest(); + ExtractImageScansRequest b = new ExtractImageScansRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when a field differs") + void differByField() { + ExtractImageScansRequest a = new ExtractImageScansRequest(); + ExtractImageScansRequest b = new ExtractImageScansRequest(); + b.setTolerance(99); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + ExtractImageScansRequest a = new ExtractImageScansRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + ExtractImageScansRequest req = new ExtractImageScansRequest(); + req.setTolerance(20); + assertThat(req.toString()).isNotNull().contains("tolerance=20"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/FlattenRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/FlattenRequestTest.java new file mode 100644 index 0000000000..c155c0422a --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/FlattenRequestTest.java @@ -0,0 +1,91 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("FlattenRequest") +class FlattenRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("flattenOnlyForms and renderDpi default to null") + void defaultsNull() { + FlattenRequest req = new FlattenRequest(); + assertThat(req.getFlattenOnlyForms()).isNull(); + assertThat(req.getRenderDpi()).isNull(); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("all fields round-trip") + void allFieldsRoundTrip() { + FlattenRequest req = new FlattenRequest(); + req.setFlattenOnlyForms(Boolean.TRUE); + req.setRenderDpi(150); + assertThat(req.getFlattenOnlyForms()).isTrue(); + assertThat(req.getRenderDpi()).isEqualTo(150); + } + + @Test + @DisplayName("inherited fileId round-trips") + void inheritedFileIdRoundTrip() { + FlattenRequest req = new FlattenRequest(); + req.setFileId("file-3"); + assertThat(req.getFileId()).isEqualTo("file-3"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + FlattenRequest a = new FlattenRequest(); + FlattenRequest b = new FlattenRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when a subclass field differs") + void differBySubclassField() { + FlattenRequest a = new FlattenRequest(); + FlattenRequest b = new FlattenRequest(); + b.setFlattenOnlyForms(Boolean.TRUE); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + FlattenRequest a = new FlattenRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + FlattenRequest req = new FlattenRequest(); + req.setFlattenOnlyForms(Boolean.TRUE); + assertThat(req.toString()).isNotNull().contains("flattenOnlyForms=true"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ListAttachmentsRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ListAttachmentsRequestTest.java new file mode 100644 index 0000000000..573e4dfc93 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ListAttachmentsRequestTest.java @@ -0,0 +1,67 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +// Zero-field subclass of PDFFile; exercised through inherited state and equality. +@DisplayName("ListAttachmentsRequest") +class ListAttachmentsRequestTest { + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("inherited fileId round-trips") + void inheritedFileIdRoundTrip() { + ListAttachmentsRequest req = new ListAttachmentsRequest(); + req.setFileId("file-66"); + assertThat(req.getFileId()).isEqualTo("file-66"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + ListAttachmentsRequest a = new ListAttachmentsRequest(); + ListAttachmentsRequest b = new ListAttachmentsRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when an inherited field differs") + void differByInheritedField() { + ListAttachmentsRequest a = new ListAttachmentsRequest(); + ListAttachmentsRequest b = new ListAttachmentsRequest(); + b.setFileId("x"); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + ListAttachmentsRequest a = new ListAttachmentsRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null") + void toStringNonNull() { + assertThat(new ListAttachmentsRequest().toString()).isNotNull(); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/MetadataRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/MetadataRequestTest.java new file mode 100644 index 0000000000..d1732e6e7d --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/MetadataRequestTest.java @@ -0,0 +1,123 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("MetadataRequest") +class MetadataRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("all fields default to null on a fresh instance") + void defaultsNull() { + MetadataRequest req = new MetadataRequest(); + assertThat(req.getDeleteAll()).isNull(); + assertThat(req.getAuthor()).isNull(); + assertThat(req.getCreationDate()).isNull(); + assertThat(req.getCreator()).isNull(); + assertThat(req.getKeywords()).isNull(); + assertThat(req.getModificationDate()).isNull(); + assertThat(req.getProducer()).isNull(); + assertThat(req.getSubject()).isNull(); + assertThat(req.getTitle()).isNull(); + assertThat(req.getTrapped()).isNull(); + assertThat(req.getAllRequestParams()).isNull(); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("all fields round-trip") + void allFieldsRoundTrip() { + MetadataRequest req = new MetadataRequest(); + Map params = Map.of("customKey1", "customValue1"); + req.setDeleteAll(Boolean.TRUE); + req.setAuthor("Anthony"); + req.setCreationDate("2023/10/01 12:00:00"); + req.setCreator("creatorApp"); + req.setKeywords("pdf,test"); + req.setModificationDate("2024/01/01 09:30:00"); + req.setProducer("producerApp"); + req.setSubject("subject text"); + req.setTitle("My Title"); + req.setTrapped("True"); + req.setAllRequestParams(params); + + assertThat(req.getDeleteAll()).isTrue(); + assertThat(req.getAuthor()).isEqualTo("Anthony"); + assertThat(req.getCreationDate()).isEqualTo("2023/10/01 12:00:00"); + assertThat(req.getCreator()).isEqualTo("creatorApp"); + assertThat(req.getKeywords()).isEqualTo("pdf,test"); + assertThat(req.getModificationDate()).isEqualTo("2024/01/01 09:30:00"); + assertThat(req.getProducer()).isEqualTo("producerApp"); + assertThat(req.getSubject()).isEqualTo("subject text"); + assertThat(req.getTitle()).isEqualTo("My Title"); + assertThat(req.getTrapped()).isEqualTo("True"); + assertThat(req.getAllRequestParams()) + .containsExactlyEntriesOf(Map.of("customKey1", "customValue1")); + } + + @Test + @DisplayName("inherited fileId round-trips") + void inheritedFileIdRoundTrip() { + MetadataRequest req = new MetadataRequest(); + req.setFileId("file-4"); + assertThat(req.getFileId()).isEqualTo("file-4"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + MetadataRequest a = new MetadataRequest(); + MetadataRequest b = new MetadataRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when a subclass field differs") + void differBySubclassField() { + MetadataRequest a = new MetadataRequest(); + MetadataRequest b = new MetadataRequest(); + b.setAuthor("someone"); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + MetadataRequest a = new MetadataRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + MetadataRequest req = new MetadataRequest(); + req.setTitle("My Title"); + assertThat(req.toString()).isNotNull().contains("title=My Title"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequestTest.java new file mode 100644 index 0000000000..93c2f7b0c4 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/OptimizePdfRequestTest.java @@ -0,0 +1,110 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("OptimizePdfRequest") +class OptimizePdfRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("documented default field values on a fresh instance") + void documentedDefaults() { + OptimizePdfRequest req = new OptimizePdfRequest(); + assertThat(req.getOptimizeLevel()).isEqualTo(5); + assertThat(req.getLinearize()).isFalse(); + assertThat(req.getNormalize()).isFalse(); + assertThat(req.getGrayscale()).isFalse(); + assertThat(req.getLineArt()).isFalse(); + assertThat(req.getLineArtThreshold()).isEqualTo(55d); + assertThat(req.getLineArtEdgeLevel()).isEqualTo(1); + assertThat(req.getExpectedOutputSize()).isNull(); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("all fields round-trip") + void allFieldsRoundTrip() { + OptimizePdfRequest req = new OptimizePdfRequest(); + req.setOptimizeLevel(9); + req.setExpectedOutputSize("100MB"); + req.setLinearize(Boolean.TRUE); + req.setNormalize(Boolean.TRUE); + req.setGrayscale(Boolean.TRUE); + req.setLineArt(Boolean.TRUE); + req.setLineArtThreshold(80d); + req.setLineArtEdgeLevel(3); + + assertThat(req.getOptimizeLevel()).isEqualTo(9); + assertThat(req.getExpectedOutputSize()).isEqualTo("100MB"); + assertThat(req.getLinearize()).isTrue(); + assertThat(req.getNormalize()).isTrue(); + assertThat(req.getGrayscale()).isTrue(); + assertThat(req.getLineArt()).isTrue(); + assertThat(req.getLineArtThreshold()).isEqualTo(80d); + assertThat(req.getLineArtEdgeLevel()).isEqualTo(3); + } + + @Test + @DisplayName("inherited fileId round-trips") + void inheritedFileIdRoundTrip() { + OptimizePdfRequest req = new OptimizePdfRequest(); + req.setFileId("file-5"); + assertThat(req.getFileId()).isEqualTo("file-5"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + OptimizePdfRequest a = new OptimizePdfRequest(); + OptimizePdfRequest b = new OptimizePdfRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when a subclass field differs") + void differBySubclassField() { + OptimizePdfRequest a = new OptimizePdfRequest(); + OptimizePdfRequest b = new OptimizePdfRequest(); + b.setOptimizeLevel(9); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + OptimizePdfRequest a = new OptimizePdfRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + OptimizePdfRequest req = new OptimizePdfRequest(); + req.setExpectedOutputSize("25KB"); + assertThat(req.toString()).isNotNull().contains("expectedOutputSize=25KB"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/OverlayImageRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/OverlayImageRequestTest.java new file mode 100644 index 0000000000..f084a7b8b0 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/OverlayImageRequestTest.java @@ -0,0 +1,102 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@DisplayName("OverlayImageRequest") +class OverlayImageRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("documented default field values on a fresh instance") + void documentedDefaults() { + OverlayImageRequest req = new OverlayImageRequest(); + assertThat(req.getImageFile()).isNull(); + assertThat(req.getX()).isEqualTo(0f); + assertThat(req.getY()).isEqualTo(0f); + assertThat(req.getEveryPage()).isNull(); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("all fields round-trip") + void allFieldsRoundTrip() { + OverlayImageRequest req = new OverlayImageRequest(); + MultipartFile image = + new MockMultipartFile("imageFile", "o.png", "image/png", new byte[] {7}); + req.setImageFile(image); + req.setX(12.5f); + req.setY(34.25f); + req.setEveryPage(Boolean.TRUE); + + assertThat(req.getImageFile()).isSameAs(image); + assertThat(req.getX()).isEqualTo(12.5f); + assertThat(req.getY()).isEqualTo(34.25f); + assertThat(req.getEveryPage()).isTrue(); + } + + @Test + @DisplayName("inherited fileId round-trips") + void inheritedFileIdRoundTrip() { + OverlayImageRequest req = new OverlayImageRequest(); + req.setFileId("file-6"); + assertThat(req.getFileId()).isEqualTo("file-6"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + OverlayImageRequest a = new OverlayImageRequest(); + OverlayImageRequest b = new OverlayImageRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when a subclass field differs") + void differBySubclassField() { + OverlayImageRequest a = new OverlayImageRequest(); + OverlayImageRequest b = new OverlayImageRequest(); + b.setEveryPage(Boolean.TRUE); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + OverlayImageRequest a = new OverlayImageRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + OverlayImageRequest req = new OverlayImageRequest(); + req.setEveryPage(Boolean.TRUE); + assertThat(req.toString()).isNotNull().contains("everyPage=true"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/PrintFileRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/PrintFileRequestTest.java new file mode 100644 index 0000000000..673ebb741a --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/PrintFileRequestTest.java @@ -0,0 +1,87 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("PrintFileRequest") +class PrintFileRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("printerName defaults to null") + void printerNameDefaultsNull() { + assertThat(new PrintFileRequest().getPrinterName()).isNull(); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("printerName round-trips") + void printerNameRoundTrip() { + PrintFileRequest req = new PrintFileRequest(); + req.setPrinterName("HP LaserJet"); + assertThat(req.getPrinterName()).isEqualTo("HP LaserJet"); + } + + @Test + @DisplayName("inherited fileId round-trips") + void inheritedFileIdRoundTrip() { + PrintFileRequest req = new PrintFileRequest(); + req.setFileId("file-7"); + assertThat(req.getFileId()).isEqualTo("file-7"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + PrintFileRequest a = new PrintFileRequest(); + PrintFileRequest b = new PrintFileRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when printerName differs") + void differByPrinterName() { + PrintFileRequest a = new PrintFileRequest(); + PrintFileRequest b = new PrintFileRequest(); + b.setPrinterName("Canon"); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + PrintFileRequest a = new PrintFileRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + PrintFileRequest req = new PrintFileRequest(); + req.setPrinterName("Canon"); + assertThat(req.toString()).isNotNull().contains("printerName=Canon"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequestTest.java new file mode 100644 index 0000000000..5cdbbbda23 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ProcessPdfWithOcrRequestTest.java @@ -0,0 +1,113 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("ProcessPdfWithOcrRequest") +class ProcessPdfWithOcrRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("documented default field values on a fresh instance") + void documentedDefaults() { + ProcessPdfWithOcrRequest req = new ProcessPdfWithOcrRequest(); + assertThat(req.getOcrRenderType()).isEqualTo("hocr"); + assertThat(req.getLanguages()).isNull(); + assertThat(req.isSidecar()).isFalse(); + assertThat(req.isDeskew()).isFalse(); + assertThat(req.isClean()).isFalse(); + assertThat(req.isCleanFinal()).isFalse(); + assertThat(req.getOcrType()).isNull(); + assertThat(req.isRemoveImagesAfter()).isFalse(); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("all fields round-trip") + void allFieldsRoundTrip() { + ProcessPdfWithOcrRequest req = new ProcessPdfWithOcrRequest(); + List langs = List.of("eng", "deu"); + req.setLanguages(langs); + req.setSidecar(true); + req.setDeskew(true); + req.setClean(true); + req.setCleanFinal(true); + req.setOcrType("force-ocr"); + req.setOcrRenderType("sandwich"); + req.setRemoveImagesAfter(true); + + assertThat(req.getLanguages()).containsExactly("eng", "deu"); + assertThat(req.isSidecar()).isTrue(); + assertThat(req.isDeskew()).isTrue(); + assertThat(req.isClean()).isTrue(); + assertThat(req.isCleanFinal()).isTrue(); + assertThat(req.getOcrType()).isEqualTo("force-ocr"); + assertThat(req.getOcrRenderType()).isEqualTo("sandwich"); + assertThat(req.isRemoveImagesAfter()).isTrue(); + } + + @Test + @DisplayName("inherited fileId round-trips") + void inheritedFileIdRoundTrip() { + ProcessPdfWithOcrRequest req = new ProcessPdfWithOcrRequest(); + req.setFileId("file-8"); + assertThat(req.getFileId()).isEqualTo("file-8"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + ProcessPdfWithOcrRequest a = new ProcessPdfWithOcrRequest(); + ProcessPdfWithOcrRequest b = new ProcessPdfWithOcrRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when a subclass field differs") + void differBySubclassField() { + ProcessPdfWithOcrRequest a = new ProcessPdfWithOcrRequest(); + ProcessPdfWithOcrRequest b = new ProcessPdfWithOcrRequest(); + b.setSidecar(true); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + ProcessPdfWithOcrRequest a = new ProcessPdfWithOcrRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + ProcessPdfWithOcrRequest req = new ProcessPdfWithOcrRequest(); + req.setOcrType("Normal"); + assertThat(req.toString()).isNotNull().contains("ocrType=Normal"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/RemoveBlankPagesRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/RemoveBlankPagesRequestTest.java new file mode 100644 index 0000000000..77df31eec7 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/RemoveBlankPagesRequestTest.java @@ -0,0 +1,91 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("RemoveBlankPagesRequest") +class RemoveBlankPagesRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("primitive fields default to zero on a fresh instance") + void defaultsZero() { + RemoveBlankPagesRequest req = new RemoveBlankPagesRequest(); + assertThat(req.getThreshold()).isZero(); + assertThat(req.getWhitePercent()).isEqualTo(0f); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("all fields round-trip") + void allFieldsRoundTrip() { + RemoveBlankPagesRequest req = new RemoveBlankPagesRequest(); + req.setThreshold(10); + req.setWhitePercent(99.9f); + assertThat(req.getThreshold()).isEqualTo(10); + assertThat(req.getWhitePercent()).isEqualTo(99.9f); + } + + @Test + @DisplayName("inherited fileId round-trips") + void inheritedFileIdRoundTrip() { + RemoveBlankPagesRequest req = new RemoveBlankPagesRequest(); + req.setFileId("file-9"); + assertThat(req.getFileId()).isEqualTo("file-9"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + RemoveBlankPagesRequest a = new RemoveBlankPagesRequest(); + RemoveBlankPagesRequest b = new RemoveBlankPagesRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when a subclass field differs") + void differBySubclassField() { + RemoveBlankPagesRequest a = new RemoveBlankPagesRequest(); + RemoveBlankPagesRequest b = new RemoveBlankPagesRequest(); + b.setThreshold(50); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + RemoveBlankPagesRequest a = new RemoveBlankPagesRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + RemoveBlankPagesRequest req = new RemoveBlankPagesRequest(); + req.setThreshold(10); + assertThat(req.toString()).isNotNull().contains("threshold=10"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/RenameAttachmentRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/RenameAttachmentRequestTest.java new file mode 100644 index 0000000000..be6ba66214 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/RenameAttachmentRequestTest.java @@ -0,0 +1,91 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("RenameAttachmentRequest") +class RenameAttachmentRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("attachmentName and newName default to null") + void defaultsNull() { + RenameAttachmentRequest req = new RenameAttachmentRequest(); + assertThat(req.getAttachmentName()).isNull(); + assertThat(req.getNewName()).isNull(); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("all fields round-trip") + void allFieldsRoundTrip() { + RenameAttachmentRequest req = new RenameAttachmentRequest(); + req.setAttachmentName("old.txt"); + req.setNewName("new.txt"); + assertThat(req.getAttachmentName()).isEqualTo("old.txt"); + assertThat(req.getNewName()).isEqualTo("new.txt"); + } + + @Test + @DisplayName("inherited fileId round-trips") + void inheritedFileIdRoundTrip() { + RenameAttachmentRequest req = new RenameAttachmentRequest(); + req.setFileId("file-10"); + assertThat(req.getFileId()).isEqualTo("file-10"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + RenameAttachmentRequest a = new RenameAttachmentRequest(); + RenameAttachmentRequest b = new RenameAttachmentRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when a subclass field differs") + void differBySubclassField() { + RenameAttachmentRequest a = new RenameAttachmentRequest(); + RenameAttachmentRequest b = new RenameAttachmentRequest(); + b.setNewName("changed.txt"); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + RenameAttachmentRequest a = new RenameAttachmentRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + RenameAttachmentRequest req = new RenameAttachmentRequest(); + req.setNewName("renamed.txt"); + assertThat(req.toString()).isNotNull().contains("newName=renamed.txt"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ReplaceAndInvertColorRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ReplaceAndInvertColorRequestTest.java new file mode 100644 index 0000000000..0c19914117 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/misc/ReplaceAndInvertColorRequestTest.java @@ -0,0 +1,152 @@ +package stirling.software.SPDF.model.api.misc; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.api.misc.HighContrastColorCombination; +import stirling.software.common.model.api.misc.ReplaceAndInvert; + +@DisplayName("ReplaceAndInvertColorRequest") +class ReplaceAndInvertColorRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("all fields default to null on a fresh instance") + void defaultsNull() { + ReplaceAndInvertColorRequest req = new ReplaceAndInvertColorRequest(); + assertThat(req.getReplaceAndInvertOption()).isNull(); + assertThat(req.getHighContrastColorCombination()).isNull(); + assertThat(req.getBackGroundColor()).isNull(); + assertThat(req.getTextColor()).isNull(); + } + } + + @Nested + @DisplayName("getters and setters") + class GettersAndSetters { + + @Test + @DisplayName("all fields round-trip") + void allFieldsRoundTrip() { + ReplaceAndInvertColorRequest req = new ReplaceAndInvertColorRequest(); + req.setReplaceAndInvertOption(ReplaceAndInvert.CUSTOM_COLOR); + req.setHighContrastColorCombination(HighContrastColorCombination.WHITE_TEXT_ON_BLACK); + req.setBackGroundColor("16777215"); + req.setTextColor("0"); + + assertThat(req.getReplaceAndInvertOption()).isEqualTo(ReplaceAndInvert.CUSTOM_COLOR); + assertThat(req.getHighContrastColorCombination()) + .isEqualTo(HighContrastColorCombination.WHITE_TEXT_ON_BLACK); + assertThat(req.getBackGroundColor()).isEqualTo("16777215"); + assertThat(req.getTextColor()).isEqualTo("0"); + } + + @Test + @DisplayName("inherited fileId round-trips") + void inheritedFileIdRoundTrip() { + ReplaceAndInvertColorRequest req = new ReplaceAndInvertColorRequest(); + req.setFileId("file-11"); + assertThat(req.getFileId()).isEqualTo("file-11"); + } + } + + @Nested + @DisplayName("equals and hashCode") + class EqualsAndHashCode { + + @Test + @DisplayName("two fresh defaults are equal and share hashCode") + void freshDefaultsEqual() { + ReplaceAndInvertColorRequest a = new ReplaceAndInvertColorRequest(); + ReplaceAndInvertColorRequest b = new ReplaceAndInvertColorRequest(); + assertThat(a).isEqualTo(b); + assertThat(a).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differ when a subclass field differs") + void differBySubclassField() { + ReplaceAndInvertColorRequest a = new ReplaceAndInvertColorRequest(); + ReplaceAndInvertColorRequest b = new ReplaceAndInvertColorRequest(); + b.setReplaceAndInvertOption(ReplaceAndInvert.FULL_INVERSION); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or other type") + void notEqualToNullOrOtherType() { + ReplaceAndInvertColorRequest a = new ReplaceAndInvertColorRequest(); + assertThat(a).isNotEqualTo(null); + assertThat(a).isNotEqualTo("a string"); + } + } + + @Nested + @DisplayName("toString") + class ToString { + + @Test + @DisplayName("is non-null and contains a field value") + void toStringContainsField() { + ReplaceAndInvertColorRequest req = new ReplaceAndInvertColorRequest(); + req.setReplaceAndInvertOption(ReplaceAndInvert.FULL_INVERSION); + assertThat(req.toString()) + .isNotNull() + .contains("replaceAndInvertOption=FULL_INVERSION"); + } + } + + @Nested + @DisplayName("ReplaceAndInvert enum") + class ReplaceAndInvertEnum { + + @Test + @DisplayName("values() exposes all four constants") + void valuesExposesAll() { + assertThat(ReplaceAndInvert.values()) + .containsExactly( + ReplaceAndInvert.HIGH_CONTRAST_COLOR, + ReplaceAndInvert.CUSTOM_COLOR, + ReplaceAndInvert.FULL_INVERSION, + ReplaceAndInvert.COLOR_SPACE_CONVERSION); + } + + @Test + @DisplayName("valueOf round-trips each constant") + void valueOfRoundTrip() { + for (ReplaceAndInvert v : ReplaceAndInvert.values()) { + assertThat(ReplaceAndInvert.valueOf(v.name())).isSameAs(v); + } + } + } + + @Nested + @DisplayName("HighContrastColorCombination enum") + class HighContrastColorCombinationEnum { + + @Test + @DisplayName("values() exposes all four constants") + void valuesExposesAll() { + assertThat(HighContrastColorCombination.values()) + .containsExactly( + HighContrastColorCombination.WHITE_TEXT_ON_BLACK, + HighContrastColorCombination.BLACK_TEXT_ON_WHITE, + HighContrastColorCombination.YELLOW_TEXT_ON_BLACK, + HighContrastColorCombination.GREEN_TEXT_ON_BLACK); + } + + @Test + @DisplayName("valueOf round-trips each constant") + void valueOfRoundTrip() { + for (HighContrastColorCombination v : HighContrastColorCombination.values()) { + assertThat(HighContrastColorCombination.valueOf(v.name())).isSameAs(v); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/security/AddPasswordRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/security/AddPasswordRequestTest.java new file mode 100644 index 0000000000..e730009970 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/security/AddPasswordRequestTest.java @@ -0,0 +1,114 @@ +package stirling.software.SPDF.model.api.security; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +@DisplayName("AddPasswordRequest") +class AddPasswordRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("keyLength defaults to 256 and permission flags are null") + void defaults() { + AddPasswordRequest req = new AddPasswordRequest(); + assertThat(req.getKeyLength()).isEqualTo(256); + assertThat(req.getOwnerPassword()).isNull(); + assertThat(req.getPassword()).isNull(); + assertThat(req.getPreventAssembly()).isNull(); + assertThat(req.getPreventExtractContent()).isNull(); + assertThat(req.getPreventExtractForAccessibility()).isNull(); + assertThat(req.getPreventFillInForm()).isNull(); + assertThat(req.getPreventModify()).isNull(); + assertThat(req.getPreventModifyAnnotations()).isNull(); + assertThat(req.getPreventPrinting()).isNull(); + assertThat(req.getPreventPrintingFaithful()).isNull(); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("all accessors round-trip including inherited fields") + void roundTrip() { + AddPasswordRequest req = new AddPasswordRequest(); + req.setOwnerPassword("owner"); + req.setPassword("user"); + req.setKeyLength(128); + req.setPreventAssembly(true); + req.setPreventExtractContent(true); + req.setPreventExtractForAccessibility(false); + req.setPreventFillInForm(true); + req.setPreventModify(false); + req.setPreventModifyAnnotations(true); + req.setPreventPrinting(false); + req.setPreventPrintingFaithful(true); + req.setFileId("file-1"); + req.setFileInput(new MockMultipartFile("f", new byte[] {1})); + + assertThat(req.getOwnerPassword()).isEqualTo("owner"); + assertThat(req.getPassword()).isEqualTo("user"); + assertThat(req.getKeyLength()).isEqualTo(128); + assertThat(req.getPreventAssembly()).isTrue(); + assertThat(req.getPreventExtractContent()).isTrue(); + assertThat(req.getPreventExtractForAccessibility()).isFalse(); + assertThat(req.getPreventFillInForm()).isTrue(); + assertThat(req.getPreventModify()).isFalse(); + assertThat(req.getPreventModifyAnnotations()).isTrue(); + assertThat(req.getPreventPrinting()).isFalse(); + assertThat(req.getPreventPrintingFaithful()).isTrue(); + assertThat(req.getFileId()).isEqualTo("file-1"); + assertThat(req.getFileInput()).isNotNull(); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class EqualityContract { + + @Test + @DisplayName("equal pair shares hashCode") + void equalPair() { + AddPasswordRequest a = new AddPasswordRequest(); + a.setPassword("pw"); + AddPasswordRequest b = new AddPasswordRequest(); + b.setPassword("pw"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs when a subclass field differs") + void notEqualOnFieldDiff() { + AddPasswordRequest a = new AddPasswordRequest(); + a.setKeyLength(128); + AddPasswordRequest b = new AddPasswordRequest(); + b.setKeyLength(256); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or unrelated type") + void notEqualToOthers() { + AddPasswordRequest a = new AddPasswordRequest(); + assertThat(a).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and a field value") + void toStringContent() { + AddPasswordRequest a = new AddPasswordRequest(); + a.setPassword("secret"); + assertThat(a.toString()).contains("AddPasswordRequest").contains("secret"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/security/ManualRedactPdfRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/security/ManualRedactPdfRequestTest.java new file mode 100644 index 0000000000..f4ab0481a3 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/security/ManualRedactPdfRequestTest.java @@ -0,0 +1,84 @@ +package stirling.software.SPDF.model.api.security; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.api.security.RedactionArea; + +@DisplayName("ManualRedactPdfRequest") +class ManualRedactPdfRequestTest { + + private RedactionArea area(String color) { + RedactionArea a = new RedactionArea(); + a.setColor(color); + return a; + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("all accessors round-trip including inherited pageNumbers") + void roundTrip() { + ManualRedactPdfRequest req = new ManualRedactPdfRequest(); + List areas = List.of(area("#000000"), area("#ffffff")); + req.setRedactions(areas); + req.setConvertPDFToImage(true); + req.setPageRedactionColor("#123456"); + req.setPageNumbers("1,2"); + + assertThat(req.getRedactions()).hasSize(2).isEqualTo(areas); + assertThat(req.getConvertPDFToImage()).isTrue(); + assertThat(req.getPageRedactionColor()).isEqualTo("#123456"); + assertThat(req.getPageNumbers()).isEqualTo("1,2"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class EqualityContract { + + @Test + @DisplayName("equal pair shares hashCode") + void equalPair() { + ManualRedactPdfRequest a = new ManualRedactPdfRequest(); + a.setPageRedactionColor("#000000"); + ManualRedactPdfRequest b = new ManualRedactPdfRequest(); + b.setPageRedactionColor("#000000"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs when a subclass field differs") + void notEqualOnFieldDiff() { + ManualRedactPdfRequest a = new ManualRedactPdfRequest(); + a.setPageRedactionColor("#000000"); + ManualRedactPdfRequest b = new ManualRedactPdfRequest(); + b.setPageRedactionColor("#ffffff"); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or unrelated type") + void notEqualToOthers() { + ManualRedactPdfRequest a = new ManualRedactPdfRequest(); + assertThat(a).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and a field value") + void toStringContent() { + ManualRedactPdfRequest a = new ManualRedactPdfRequest(); + a.setPageRedactionColor("#abcdef"); + assertThat(a.toString()).contains("ManualRedactPdfRequest").contains("#abcdef"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/security/PDFPasswordRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/security/PDFPasswordRequestTest.java new file mode 100644 index 0000000000..f64cc50b5e --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/security/PDFPasswordRequestTest.java @@ -0,0 +1,54 @@ +package stirling.software.SPDF.model.api.security; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +@DisplayName("PDFPasswordRequest") +class PDFPasswordRequestTest { + + @Test + @DisplayName("password and inherited fields round-trip") + void roundTrip() { + PDFPasswordRequest req = new PDFPasswordRequest(); + req.setPassword("pw"); + req.setFileId("file-9"); + req.setFileInput(new MockMultipartFile("f", new byte[] {1})); + + assertThat(req.getPassword()).isEqualTo("pw"); + assertThat(req.getFileId()).isEqualTo("file-9"); + assertThat(req.getFileInput()).isNotNull(); + } + + @Test + @DisplayName("equals/hashCode for equal pair") + void equalPair() { + PDFPasswordRequest a = new PDFPasswordRequest(); + a.setPassword("pw"); + PDFPasswordRequest b = new PDFPasswordRequest(); + b.setPassword("pw"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs when password differs and vs null/other type") + void notEqual() { + PDFPasswordRequest a = new PDFPasswordRequest(); + a.setPassword("pw"); + PDFPasswordRequest b = new PDFPasswordRequest(); + b.setPassword("other"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and value") + void toStringContent() { + PDFPasswordRequest a = new PDFPasswordRequest(); + a.setPassword("secret"); + assertThat(a.toString()).contains("PDFPasswordRequest").contains("secret"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/security/PDFVerificationRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/security/PDFVerificationRequestTest.java new file mode 100644 index 0000000000..ff371e10ea --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/security/PDFVerificationRequestTest.java @@ -0,0 +1,51 @@ +package stirling.software.SPDF.model.api.security; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +// Zero-field subclass of PDFFile - exercised via inherited state. +@DisplayName("PDFVerificationRequest") +class PDFVerificationRequestTest { + + @Test + @DisplayName("inherited fields round-trip") + void roundTrip() { + PDFVerificationRequest req = new PDFVerificationRequest(); + req.setFileId("file-7"); + req.setFileInput(new MockMultipartFile("f", new byte[] {1})); + + assertThat(req.getFileId()).isEqualTo("file-7"); + assertThat(req.getFileInput()).isNotNull(); + } + + @Test + @DisplayName("equals/hashCode for equal pair via inherited field") + void equalPair() { + PDFVerificationRequest a = new PDFVerificationRequest(); + a.setFileId("same"); + PDFVerificationRequest b = new PDFVerificationRequest(); + b.setFileId("same"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs by inherited field and vs null/other type") + void notEqual() { + PDFVerificationRequest a = new PDFVerificationRequest(); + a.setFileId("a"); + PDFVerificationRequest b = new PDFVerificationRequest(); + b.setFileId("b"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name") + void toStringContent() { + assertThat(new PDFVerificationRequest().toString()).contains("PDFVerificationRequest"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/security/PDFVerificationResultTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/security/PDFVerificationResultTest.java new file mode 100644 index 0000000000..8d22b875ce --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/security/PDFVerificationResultTest.java @@ -0,0 +1,140 @@ +package stirling.software.SPDF.model.api.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.SPDF.model.api.security.PDFVerificationResult.ValidationIssue; + +class PDFVerificationResultTest { + + @Nested + @DisplayName("addFailure") + class AddFailure { + + @Test + @DisplayName("appends failure and updates total count") + void appendsAndCounts() { + PDFVerificationResult result = new PDFVerificationResult(); + + result.addFailure(new ValidationIssue("R1", "broken", null, null, null, null)); + result.addFailure(new ValidationIssue("R2", "also broken", null, null, null, null)); + + assertThat(result.getFailures()).hasSize(2); + assertThat(result.getTotalFailures()).isEqualTo(2); + } + } + + @Nested + @DisplayName("addWarning") + class AddWarning { + + @Test + @DisplayName("appends warning and updates total count") + void appendsAndCounts() { + PDFVerificationResult result = new PDFVerificationResult(); + + result.addWarning(new ValidationIssue("W1", "warn", null, null, null, null)); + + assertThat(result.getWarnings()).hasSize(1); + assertThat(result.getTotalWarnings()).isEqualTo(1); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters and getters cover scalar fields") + void scalars() { + PDFVerificationResult result = new PDFVerificationResult(); + result.setStandard("PDF/A-1B"); + result.setStandardName("PDF/A"); + result.setValidationProfile("1b"); + result.setValidationProfileName("Level B"); + result.setComplianceSummary("ok"); + result.setDeclaredPdfa(true); + result.setCompliant(true); + + assertThat(result.getStandard()).isEqualTo("PDF/A-1B"); + assertThat(result.getStandardName()).isEqualTo("PDF/A"); + assertThat(result.getValidationProfile()).isEqualTo("1b"); + assertThat(result.getValidationProfileName()).isEqualTo("Level B"); + assertThat(result.getComplianceSummary()).isEqualTo("ok"); + assertThat(result.isDeclaredPdfa()).isTrue(); + assertThat(result.isCompliant()).isTrue(); + } + } + + @Nested + @DisplayName("all-args constructor and equality") + class ConstructorAndEquality { + + @Test + @DisplayName("all-args constructor populates fields") + void allArgs() { + PDFVerificationResult result = + new PDFVerificationResult( + "std", + "stdName", + "prof", + "profName", + "summary", + true, + false, + 1, + 2, + new java.util.ArrayList<>(), + new java.util.ArrayList<>()); + + assertThat(result.getStandard()).isEqualTo("std"); + assertThat(result.getTotalFailures()).isEqualTo(1); + assertThat(result.getTotalWarnings()).isEqualTo(2); + } + + @Test + @DisplayName("equals/hashCode/toString reflect content") + void equality() { + PDFVerificationResult a = new PDFVerificationResult(); + PDFVerificationResult b = new PDFVerificationResult(); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a).isNotEqualTo(null).isNotEqualTo("x"); + assertThat(a.toString()).contains("PDFVerificationResult"); + } + } + + @Nested + @DisplayName("ValidationIssue nested type") + class ValidationIssueType { + + @Test + @DisplayName("exposes every field via accessors") + void accessors() { + ValidationIssue issue = + new ValidationIssue("rule", "msg", "loc", "spec", "clause", "1.2"); + + assertThat(issue.getRuleId()).isEqualTo("rule"); + assertThat(issue.getMessage()).isEqualTo("msg"); + assertThat(issue.getLocation()).isEqualTo("loc"); + assertThat(issue.getSpecification()).isEqualTo("spec"); + assertThat(issue.getClause()).isEqualTo("clause"); + assertThat(issue.getTestNumber()).isEqualTo("1.2"); + } + + @Test + @DisplayName("no-arg constructor with setters works and equals matches") + void noArgAndEquals() { + ValidationIssue issue = new ValidationIssue(); + issue.setRuleId("r"); + ValidationIssue other = new ValidationIssue(); + other.setRuleId("r"); + + assertThat(issue.getRuleId()).isEqualTo("r"); + assertThat(issue).isEqualTo(other).hasSameHashCodeAs(other); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/security/RedactExecuteRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/security/RedactExecuteRequestTest.java new file mode 100644 index 0000000000..44d403f1ba --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/security/RedactExecuteRequestTest.java @@ -0,0 +1,139 @@ +package stirling.software.SPDF.model.api.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.SPDF.model.api.security.RedactExecuteRequest.ImageBox; +import stirling.software.SPDF.model.api.security.RedactExecuteRequest.RedactStyle; +import stirling.software.SPDF.model.api.security.RedactExecuteRequest.RedactionStrategy; +import stirling.software.SPDF.model.api.security.RedactExecuteRequest.TextRange; + +class RedactExecuteRequestTest { + + @Nested + @DisplayName("defaults") + class Defaults { + + @Test + @DisplayName("collections default to empty lists, not null") + void emptyCollections() { + RedactExecuteRequest req = new RedactExecuteRequest(); + + assertThat(req.getTextValues()).isEmpty(); + assertThat(req.getRegexPatterns()).isEmpty(); + assertThat(req.getWipePages()).isEmpty(); + assertThat(req.getRanges()).isEmpty(); + assertThat(req.getImageBoxes()).isEmpty(); + assertThat(req.getRedactImagePages()).isNull(); + } + + @Test + @DisplayName("style defaults to a fresh RedactStyle") + void styleDefault() { + RedactExecuteRequest req = new RedactExecuteRequest(); + + assertThat(req.getStyle()).isNotNull(); + assertThat(req.getStyle().getColor()).isEqualTo("#000000"); + assertThat(req.getStyle().getPadding()).isZero(); + assertThat(req.getStyle().isConvertToImage()).isFalse(); + assertThat(req.getStyle().getStrategy()).isEqualTo(RedactionStrategy.AUTO); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("setters update collections and style") + void setters() { + RedactExecuteRequest req = new RedactExecuteRequest(); + req.setTextValues(List.of("secret")); + req.setRegexPatterns(List.of("\\d+")); + req.setWipePages(List.of(1, 2)); + req.setRedactImagePages(List.of(3)); + RedactStyle style = new RedactStyle(); + style.setColor("#FF0000"); + style.setPadding(2.5f); + style.setConvertToImage(true); + style.setStrategy(RedactionStrategy.IMAGE_FINALIZE); + req.setStyle(style); + + assertThat(req.getTextValues()).containsExactly("secret"); + assertThat(req.getRegexPatterns()).containsExactly("\\d+"); + assertThat(req.getWipePages()).containsExactly(1, 2); + assertThat(req.getRedactImagePages()).containsExactly(3); + assertThat(req.getStyle().getColor()).isEqualTo("#FF0000"); + assertThat(req.getStyle().getPadding()).isEqualTo(2.5f); + assertThat(req.getStyle().isConvertToImage()).isTrue(); + assertThat(req.getStyle().getStrategy()).isEqualTo(RedactionStrategy.IMAGE_FINALIZE); + } + } + + @Nested + @DisplayName("TextRange record") + class TextRangeRecord { + + @Test + @DisplayName("keeps provided start and end strings") + void keepsValues() { + TextRange range = new TextRange("begin", "end"); + + assertThat(range.startString()).isEqualTo("begin"); + assertThat(range.endString()).isEqualTo("end"); + } + + @Test + @DisplayName("compact constructor coerces null end string to empty") + void nullEndBecomesEmpty() { + TextRange range = new TextRange("begin", null); + + assertThat(range.endString()).isEmpty(); + } + + @Test + @DisplayName("equal records are equal") + void equality() { + assertThat(new TextRange("a", "b")).isEqualTo(new TextRange("a", "b")); + } + } + + @Nested + @DisplayName("ImageBox record") + class ImageBoxRecord { + + @Test + @DisplayName("exposes page index and coordinates") + void accessors() { + ImageBox box = new ImageBox(2, 1.0f, 2.0f, 3.0f, 4.0f); + + assertThat(box.pageIndex()).isEqualTo(2); + assertThat(box.x1()).isEqualTo(1.0f); + assertThat(box.y1()).isEqualTo(2.0f); + assertThat(box.x2()).isEqualTo(3.0f); + assertThat(box.y2()).isEqualTo(4.0f); + } + } + + @Nested + @DisplayName("RedactionStrategy enum") + class StrategyEnum { + + @Test + @DisplayName("exposes the documented constants") + void constants() { + assertThat(RedactionStrategy.values()) + .containsExactly( + RedactionStrategy.AUTO, + RedactionStrategy.OVERLAY_ONLY, + RedactionStrategy.IMAGE_FINALIZE); + assertThat(RedactionStrategy.valueOf("OVERLAY_ONLY")) + .isSameAs(RedactionStrategy.OVERLAY_ONLY); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/security/RedactPdfRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/security/RedactPdfRequestTest.java new file mode 100644 index 0000000000..ed9a54901c --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/security/RedactPdfRequestTest.java @@ -0,0 +1,76 @@ +package stirling.software.SPDF.model.api.security; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("RedactPdfRequest") +class RedactPdfRequestTest { + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("all accessors round-trip") + void roundTrip() { + RedactPdfRequest req = new RedactPdfRequest(); + req.setListOfText("foo,bar"); + req.setUseRegex(true); + req.setWholeWordSearch(false); + req.setRedactColor("#000000"); + req.setCustomPadding(2.5f); + req.setConvertPDFToImage(true); + + assertThat(req.getListOfText()).isEqualTo("foo,bar"); + assertThat(req.getUseRegex()).isTrue(); + assertThat(req.getWholeWordSearch()).isFalse(); + assertThat(req.getRedactColor()).isEqualTo("#000000"); + assertThat(req.getCustomPadding()).isEqualTo(2.5f); + assertThat(req.getConvertPDFToImage()).isTrue(); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class EqualityContract { + + @Test + @DisplayName("equal pair shares hashCode") + void equalPair() { + RedactPdfRequest a = new RedactPdfRequest(); + a.setListOfText("x"); + RedactPdfRequest b = new RedactPdfRequest(); + b.setListOfText("x"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs when a subclass field differs") + void notEqualOnFieldDiff() { + RedactPdfRequest a = new RedactPdfRequest(); + a.setRedactColor("#000000"); + RedactPdfRequest b = new RedactPdfRequest(); + b.setRedactColor("#ffffff"); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or unrelated type") + void notEqualToOthers() { + assertThat(new RedactPdfRequest()).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and a field value") + void toStringContent() { + RedactPdfRequest a = new RedactPdfRequest(); + a.setListOfText("findme"); + assertThat(a.toString()).contains("RedactPdfRequest").contains("findme"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/security/SanitizePdfRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/security/SanitizePdfRequestTest.java new file mode 100644 index 0000000000..af0ff294be --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/security/SanitizePdfRequestTest.java @@ -0,0 +1,74 @@ +package stirling.software.SPDF.model.api.security; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("SanitizePdfRequest") +class SanitizePdfRequestTest { + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("all boolean accessors round-trip") + void roundTrip() { + SanitizePdfRequest req = new SanitizePdfRequest(); + req.setRemoveJavaScript(true); + req.setRemoveEmbeddedFiles(false); + req.setRemoveXMPMetadata(true); + req.setRemoveMetadata(false); + req.setRemoveLinks(true); + req.setRemoveFonts(false); + + assertThat(req.getRemoveJavaScript()).isTrue(); + assertThat(req.getRemoveEmbeddedFiles()).isFalse(); + assertThat(req.getRemoveXMPMetadata()).isTrue(); + assertThat(req.getRemoveMetadata()).isFalse(); + assertThat(req.getRemoveLinks()).isTrue(); + assertThat(req.getRemoveFonts()).isFalse(); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class EqualityContract { + + @Test + @DisplayName("equal pair shares hashCode") + void equalPair() { + SanitizePdfRequest a = new SanitizePdfRequest(); + a.setRemoveJavaScript(true); + SanitizePdfRequest b = new SanitizePdfRequest(); + b.setRemoveJavaScript(true); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs when a subclass field differs") + void notEqualOnFieldDiff() { + SanitizePdfRequest a = new SanitizePdfRequest(); + a.setRemoveFonts(true); + SanitizePdfRequest b = new SanitizePdfRequest(); + b.setRemoveFonts(false); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or unrelated type") + void notEqualToOthers() { + assertThat(new SanitizePdfRequest()).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name") + void toStringContent() { + assertThat(new SanitizePdfRequest().toString()).contains("SanitizePdfRequest"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/security/SecurityRequestsTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/security/SecurityRequestsTest.java new file mode 100644 index 0000000000..e8cc8bccc1 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/security/SecurityRequestsTest.java @@ -0,0 +1,208 @@ +package stirling.software.SPDF.model.api.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +class SecurityRequestsTest { + + @Nested + @DisplayName("AddWatermarkRequest") + class Watermark { + + @Test + @DisplayName("all accessors round-trip") + void roundTrip() { + AddWatermarkRequest req = new AddWatermarkRequest(); + req.setWatermarkType("text"); + req.setWatermarkText("DRAFT"); + req.setWatermarkImage(new MockMultipartFile("img", new byte[] {1})); + req.setAlphabet("roman"); + req.setFontSize(24f); + req.setRotation(45f); + req.setOpacity(0.3f); + req.setWidthSpacer(10); + req.setHeightSpacer(20); + req.setCustomColor("#ffffff"); + req.setConvertPDFToImage(true); + + assertThat(req.getWatermarkType()).isEqualTo("text"); + assertThat(req.getWatermarkText()).isEqualTo("DRAFT"); + assertThat(req.getWatermarkImage()).isNotNull(); + assertThat(req.getAlphabet()).isEqualTo("roman"); + assertThat(req.getFontSize()).isEqualTo(24f); + assertThat(req.getRotation()).isEqualTo(45f); + assertThat(req.getOpacity()).isEqualTo(0.3f); + assertThat(req.getWidthSpacer()).isEqualTo(10); + assertThat(req.getHeightSpacer()).isEqualTo(20); + assertThat(req.getCustomColor()).isEqualTo("#ffffff"); + assertThat(req.getConvertPDFToImage()).isTrue(); + } + + @Test + @DisplayName("equals/hashCode/toString generated") + void equality() { + AddWatermarkRequest a = new AddWatermarkRequest(); + a.setWatermarkText("X"); + AddWatermarkRequest b = new AddWatermarkRequest(); + b.setWatermarkText("X"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a).isNotEqualTo(new AddWatermarkRequest()); + assertThat(a.toString()).contains("AddWatermarkRequest"); + } + } + + @Nested + @DisplayName("SignPDFWithCertRequest") + class SignCert { + + @Test + @DisplayName("all accessors round-trip") + void roundTrip() { + SignPDFWithCertRequest req = new SignPDFWithCertRequest(); + req.setCertType("PKCS12"); + req.setPrivateKeyFile(new MockMultipartFile("k", new byte[] {1})); + req.setCertFile(new MockMultipartFile("c", new byte[] {2})); + req.setP12File(new MockMultipartFile("p", new byte[] {3})); + req.setJksFile(new MockMultipartFile("j", new byte[] {4})); + req.setPassword("pw"); + req.setShowSignature(true); + req.setReason("because"); + req.setLocation("here"); + req.setName("Signer"); + req.setPageNumber(2); + req.setShowLogo(false); + + assertThat(req.getCertType()).isEqualTo("PKCS12"); + assertThat(req.getPrivateKeyFile()).isNotNull(); + assertThat(req.getCertFile()).isNotNull(); + assertThat(req.getP12File()).isNotNull(); + assertThat(req.getJksFile()).isNotNull(); + assertThat(req.getPassword()).isEqualTo("pw"); + assertThat(req.getShowSignature()).isTrue(); + assertThat(req.getReason()).isEqualTo("because"); + assertThat(req.getLocation()).isEqualTo("here"); + assertThat(req.getName()).isEqualTo("Signer"); + assertThat(req.getPageNumber()).isEqualTo(2); + assertThat(req.getShowLogo()).isFalse(); + } + + @Test + @DisplayName("equals/hashCode/toString generated") + void equality() { + SignPDFWithCertRequest a = new SignPDFWithCertRequest(); + a.setCertType("JKS"); + SignPDFWithCertRequest b = new SignPDFWithCertRequest(); + b.setCertType("JKS"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a).isNotEqualTo(new SignPDFWithCertRequest()); + assertThat(a.toString()).contains("SignPDFWithCertRequest"); + } + } + + @Nested + @DisplayName("AddPasswordRequest") + class AddPassword { + + @Test + @DisplayName("key length defaults to 256") + void defaultKeyLength() { + assertThat(new AddPasswordRequest().getKeyLength()).isEqualTo(256); + } + + @Test + @DisplayName("all permission flags and passwords round-trip") + void roundTrip() { + AddPasswordRequest req = new AddPasswordRequest(); + req.setOwnerPassword("owner"); + req.setPassword("user"); + req.setKeyLength(128); + req.setPreventAssembly(true); + req.setPreventExtractContent(true); + req.setPreventExtractForAccessibility(true); + req.setPreventFillInForm(true); + req.setPreventModify(true); + req.setPreventModifyAnnotations(true); + req.setPreventPrinting(true); + req.setPreventPrintingFaithful(true); + + assertThat(req.getOwnerPassword()).isEqualTo("owner"); + assertThat(req.getPassword()).isEqualTo("user"); + assertThat(req.getKeyLength()).isEqualTo(128); + assertThat(req.getPreventAssembly()).isTrue(); + assertThat(req.getPreventExtractContent()).isTrue(); + assertThat(req.getPreventExtractForAccessibility()).isTrue(); + assertThat(req.getPreventFillInForm()).isTrue(); + assertThat(req.getPreventModify()).isTrue(); + assertThat(req.getPreventModifyAnnotations()).isTrue(); + assertThat(req.getPreventPrinting()).isTrue(); + assertThat(req.getPreventPrintingFaithful()).isTrue(); + assertThat(req.toString()).contains("AddPasswordRequest"); + } + } + + @Nested + @DisplayName("SanitizePdfRequest") + class Sanitize { + + @Test + @DisplayName("boolean toggles round-trip") + void roundTrip() { + SanitizePdfRequest req = new SanitizePdfRequest(); + req.setRemoveJavaScript(true); + req.setRemoveEmbeddedFiles(false); + req.setRemoveXMPMetadata(true); + req.setRemoveMetadata(true); + req.setRemoveLinks(false); + req.setRemoveFonts(true); + + assertThat(req.getRemoveJavaScript()).isTrue(); + assertThat(req.getRemoveEmbeddedFiles()).isFalse(); + assertThat(req.getRemoveXMPMetadata()).isTrue(); + assertThat(req.getRemoveMetadata()).isTrue(); + assertThat(req.getRemoveLinks()).isFalse(); + assertThat(req.getRemoveFonts()).isTrue(); + } + + @Test + @DisplayName("equals and hashCode generated") + void equality() { + SanitizePdfRequest a = new SanitizePdfRequest(); + a.setRemoveFonts(true); + SanitizePdfRequest b = new SanitizePdfRequest(); + b.setRemoveFonts(true); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + } + + @Nested + @DisplayName("RedactPdfRequest") + class Redact { + + @Test + @DisplayName("text, flags, color and padding round-trip") + void roundTrip() { + RedactPdfRequest req = new RedactPdfRequest(); + req.setListOfText("a,b"); + req.setUseRegex(true); + req.setWholeWordSearch(true); + req.setRedactColor("#ff0000"); + req.setCustomPadding(2.5f); + req.setConvertPDFToImage(true); + + assertThat(req.getListOfText()).isEqualTo("a,b"); + assertThat(req.getUseRegex()).isTrue(); + assertThat(req.getWholeWordSearch()).isTrue(); + assertThat(req.getRedactColor()).isEqualTo("#ff0000"); + assertThat(req.getCustomPadding()).isEqualTo(2.5f); + assertThat(req.getConvertPDFToImage()).isTrue(); + assertThat(req.toString()).contains("RedactPdfRequest"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/security/SignatureValidationRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/security/SignatureValidationRequestTest.java new file mode 100644 index 0000000000..726c269508 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/security/SignatureValidationRequestTest.java @@ -0,0 +1,53 @@ +package stirling.software.SPDF.model.api.security; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; + +@DisplayName("SignatureValidationRequest") +class SignatureValidationRequestTest { + + @Test + @DisplayName("certFile and inherited fields round-trip") + void roundTrip() { + SignatureValidationRequest req = new SignatureValidationRequest(); + MockMultipartFile cert = new MockMultipartFile("c", new byte[] {1, 2}); + req.setCertFile(cert); + req.setFileId("file-3"); + + assertThat(req.getCertFile()).isSameAs(cert); + assertThat(req.getFileId()).isEqualTo("file-3"); + } + + @Test + @DisplayName("equals/hashCode for equal pair sharing the same certFile") + void equalPair() { + MockMultipartFile cert = new MockMultipartFile("c", new byte[] {1}); + SignatureValidationRequest a = new SignatureValidationRequest(); + a.setCertFile(cert); + SignatureValidationRequest b = new SignatureValidationRequest(); + b.setCertFile(cert); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs by inherited field and vs null/other type") + void notEqual() { + SignatureValidationRequest a = new SignatureValidationRequest(); + a.setFileId("a"); + SignatureValidationRequest b = new SignatureValidationRequest(); + b.setFileId("b"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name") + void toStringContent() { + assertThat(new SignatureValidationRequest().toString()) + .contains("SignatureValidationRequest"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/security/SignatureValidationResultTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/security/SignatureValidationResultTest.java new file mode 100644 index 0000000000..3ed3b2821e --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/security/SignatureValidationResultTest.java @@ -0,0 +1,79 @@ +package stirling.software.SPDF.model.api.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class SignatureValidationResultTest { + + @Test + @DisplayName("all accessors round-trip") + void accessorsRoundTrip() { + SignatureValidationResult r = new SignatureValidationResult(); + r.setValid(true); + r.setChainValid(true); + r.setTrustValid(false); + r.setChainValidationError("none"); + r.setCertPathLength(3); + r.setNotExpired(true); + r.setRevocationChecked(true); + r.setRevocationStatus("good"); + r.setValidationTimeSource("timestamp"); + r.setSignerName("Bob"); + r.setSignatureDate("2026-01-01"); + r.setReason("agree"); + r.setLocation("HQ"); + r.setErrorMessage(null); + r.setIssuerDN("CN=Issuer"); + r.setSubjectDN("CN=Subject"); + r.setSerialNumber("12345"); + r.setValidFrom("2025-01-01"); + r.setValidUntil("2027-01-01"); + r.setSignatureAlgorithm("SHA256withRSA"); + r.setKeySize(2048); + r.setVersion("3"); + r.setKeyUsages(List.of("digitalSignature", "nonRepudiation")); + r.setSelfSigned(true); + + assertThat(r.isValid()).isTrue(); + assertThat(r.isChainValid()).isTrue(); + assertThat(r.isTrustValid()).isFalse(); + assertThat(r.getChainValidationError()).isEqualTo("none"); + assertThat(r.getCertPathLength()).isEqualTo(3); + assertThat(r.isNotExpired()).isTrue(); + assertThat(r.isRevocationChecked()).isTrue(); + assertThat(r.getRevocationStatus()).isEqualTo("good"); + assertThat(r.getValidationTimeSource()).isEqualTo("timestamp"); + assertThat(r.getSignerName()).isEqualTo("Bob"); + assertThat(r.getSignatureDate()).isEqualTo("2026-01-01"); + assertThat(r.getReason()).isEqualTo("agree"); + assertThat(r.getLocation()).isEqualTo("HQ"); + assertThat(r.getErrorMessage()).isNull(); + assertThat(r.getIssuerDN()).isEqualTo("CN=Issuer"); + assertThat(r.getSubjectDN()).isEqualTo("CN=Subject"); + assertThat(r.getSerialNumber()).isEqualTo("12345"); + assertThat(r.getValidFrom()).isEqualTo("2025-01-01"); + assertThat(r.getValidUntil()).isEqualTo("2027-01-01"); + assertThat(r.getSignatureAlgorithm()).isEqualTo("SHA256withRSA"); + assertThat(r.getKeySize()).isEqualTo(2048); + assertThat(r.getVersion()).isEqualTo("3"); + assertThat(r.getKeyUsages()).containsExactly("digitalSignature", "nonRepudiation"); + assertThat(r.isSelfSigned()).isTrue(); + } + + @Test + @DisplayName("equals, hashCode and toString are generated") + void equality() { + SignatureValidationResult a = new SignatureValidationResult(); + a.setSignerName("X"); + SignatureValidationResult b = new SignatureValidationResult(); + b.setSignerName("X"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a).isNotEqualTo(null).isNotEqualTo(new SignatureValidationResult()); + assertThat(a.toString()).contains("SignatureValidationResult"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/security/TimestampPdfRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/security/TimestampPdfRequestTest.java new file mode 100644 index 0000000000..18dda0950c --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/security/TimestampPdfRequestTest.java @@ -0,0 +1,51 @@ +package stirling.software.SPDF.model.api.security; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("TimestampPdfRequest") +class TimestampPdfRequestTest { + + @Test + @DisplayName("tsaUrl and inherited fields round-trip") + void roundTrip() { + TimestampPdfRequest req = new TimestampPdfRequest(); + req.setTsaUrl("http://timestamp.example.com"); + req.setFileId("file-2"); + + assertThat(req.getTsaUrl()).isEqualTo("http://timestamp.example.com"); + assertThat(req.getFileId()).isEqualTo("file-2"); + } + + @Test + @DisplayName("equals/hashCode for equal pair") + void equalPair() { + TimestampPdfRequest a = new TimestampPdfRequest(); + a.setTsaUrl("http://ts"); + TimestampPdfRequest b = new TimestampPdfRequest(); + b.setTsaUrl("http://ts"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs when tsaUrl differs and vs null/other type") + void notEqual() { + TimestampPdfRequest a = new TimestampPdfRequest(); + a.setTsaUrl("http://a"); + TimestampPdfRequest b = new TimestampPdfRequest(); + b.setTsaUrl("http://b"); + + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and value") + void toStringContent() { + TimestampPdfRequest a = new TimestampPdfRequest(); + a.setTsaUrl("http://digicert"); + assertThat(a.toString()).contains("TimestampPdfRequest").contains("http://digicert"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/signature/SavedSignatureRequestTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/signature/SavedSignatureRequestTest.java new file mode 100644 index 0000000000..3ecd24ce12 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/signature/SavedSignatureRequestTest.java @@ -0,0 +1,82 @@ +package stirling.software.SPDF.model.api.signature; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("SavedSignatureRequest") +class SavedSignatureRequestTest { + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("all accessors round-trip") + void roundTrip() { + SavedSignatureRequest req = new SavedSignatureRequest(); + req.setId("id-1"); + req.setLabel("My signature"); + req.setType("text"); + req.setScope("personal"); + req.setDataUrl("data:image/png;base64,AAA"); + req.setSignerName("Alice"); + req.setFontFamily("Helvetica"); + req.setFontSize(14); + req.setTextColor("#000000"); + + assertThat(req.getId()).isEqualTo("id-1"); + assertThat(req.getLabel()).isEqualTo("My signature"); + assertThat(req.getType()).isEqualTo("text"); + assertThat(req.getScope()).isEqualTo("personal"); + assertThat(req.getDataUrl()).isEqualTo("data:image/png;base64,AAA"); + assertThat(req.getSignerName()).isEqualTo("Alice"); + assertThat(req.getFontFamily()).isEqualTo("Helvetica"); + assertThat(req.getFontSize()).isEqualTo(14); + assertThat(req.getTextColor()).isEqualTo("#000000"); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class EqualityContract { + + @Test + @DisplayName("equal pair shares hashCode") + void equalPair() { + SavedSignatureRequest a = new SavedSignatureRequest(); + a.setId("x"); + SavedSignatureRequest b = new SavedSignatureRequest(); + b.setId("x"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs when a field differs") + void notEqualOnFieldDiff() { + SavedSignatureRequest a = new SavedSignatureRequest(); + a.setId("x"); + SavedSignatureRequest b = new SavedSignatureRequest(); + b.setId("y"); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or unrelated type") + void notEqualToOthers() { + assertThat(new SavedSignatureRequest()).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and a field value") + void toStringContent() { + SavedSignatureRequest a = new SavedSignatureRequest(); + a.setLabel("sigLabel"); + assertThat(a.toString()).contains("SavedSignatureRequest").contains("sigLabel"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/api/signature/SavedSignatureResponseTest.java b/app/core/src/test/java/stirling/software/SPDF/model/api/signature/SavedSignatureResponseTest.java new file mode 100644 index 0000000000..5e35229ba0 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/api/signature/SavedSignatureResponseTest.java @@ -0,0 +1,130 @@ +package stirling.software.SPDF.model.api.signature; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("SavedSignatureResponse") +class SavedSignatureResponseTest { + + @Nested + @DisplayName("constructors") + class Constructors { + + @Test + @DisplayName("no-arg constructor yields null fields") + void noArg() { + SavedSignatureResponse r = new SavedSignatureResponse(); + assertThat(r.getId()).isNull(); + assertThat(r.getCreatedAt()).isNull(); + assertThat(r.getUpdatedAt()).isNull(); + } + + @Test + @DisplayName("all-args constructor sets every field") + void allArgs() { + SavedSignatureResponse r = + new SavedSignatureResponse( + "id-1", + "label", + "canvas", + "shared", + "data:url", + "Bob", + "Arial", + 16, + "#ffffff", + 100L, + 200L); + + assertThat(r.getId()).isEqualTo("id-1"); + assertThat(r.getLabel()).isEqualTo("label"); + assertThat(r.getType()).isEqualTo("canvas"); + assertThat(r.getScope()).isEqualTo("shared"); + assertThat(r.getDataUrl()).isEqualTo("data:url"); + assertThat(r.getSignerName()).isEqualTo("Bob"); + assertThat(r.getFontFamily()).isEqualTo("Arial"); + assertThat(r.getFontSize()).isEqualTo(16); + assertThat(r.getTextColor()).isEqualTo("#ffffff"); + assertThat(r.getCreatedAt()).isEqualTo(100L); + assertThat(r.getUpdatedAt()).isEqualTo(200L); + } + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("all accessors round-trip") + void roundTrip() { + SavedSignatureResponse r = new SavedSignatureResponse(); + r.setId("id-2"); + r.setLabel("label2"); + r.setType("image"); + r.setScope("personal"); + r.setDataUrl("http://img"); + r.setSignerName("Carol"); + r.setFontFamily("Times"); + r.setFontSize(12); + r.setTextColor("#123456"); + r.setCreatedAt(1L); + r.setUpdatedAt(2L); + + assertThat(r.getId()).isEqualTo("id-2"); + assertThat(r.getLabel()).isEqualTo("label2"); + assertThat(r.getType()).isEqualTo("image"); + assertThat(r.getScope()).isEqualTo("personal"); + assertThat(r.getDataUrl()).isEqualTo("http://img"); + assertThat(r.getSignerName()).isEqualTo("Carol"); + assertThat(r.getFontFamily()).isEqualTo("Times"); + assertThat(r.getFontSize()).isEqualTo(12); + assertThat(r.getTextColor()).isEqualTo("#123456"); + assertThat(r.getCreatedAt()).isEqualTo(1L); + assertThat(r.getUpdatedAt()).isEqualTo(2L); + } + } + + @Nested + @DisplayName("equals/hashCode/toString") + class EqualityContract { + + @Test + @DisplayName("equal pair shares hashCode") + void equalPair() { + SavedSignatureResponse a = new SavedSignatureResponse(); + a.setId("x"); + SavedSignatureResponse b = new SavedSignatureResponse(); + b.setId("x"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } + + @Test + @DisplayName("differs when a field differs") + void notEqualOnFieldDiff() { + SavedSignatureResponse a = new SavedSignatureResponse(); + a.setCreatedAt(1L); + SavedSignatureResponse b = new SavedSignatureResponse(); + b.setCreatedAt(2L); + + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or unrelated type") + void notEqualToOthers() { + assertThat(new SavedSignatureResponse()).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and a field value") + void toStringContent() { + SavedSignatureResponse a = new SavedSignatureResponse(); + a.setLabel("respLabel"); + assertThat(a.toString()).contains("SavedSignatureResponse").contains("respLabel"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonAnnotationTest.java b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonAnnotationTest.java new file mode 100644 index 0000000000..3dce3f9fa1 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonAnnotationTest.java @@ -0,0 +1,103 @@ +package stirling.software.SPDF.model.json; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("PdfJsonAnnotation") +class PdfJsonAnnotationTest { + + @Nested + @DisplayName("construction") + class Construction { + + @Test + @DisplayName("no-arg constructor yields null fields") + void noArg() { + PdfJsonAnnotation a = new PdfJsonAnnotation(); + assertThat(a.getSubtype()).isNull(); + assertThat(a.getRect()).isNull(); + assertThat(a.getRawData()).isNull(); + } + + @Test + @DisplayName("builder sets scalar and array fields") + void builder() { + PdfJsonAnnotation a = + PdfJsonAnnotation.builder() + .subtype("Highlight") + .contents("note") + .rect(new float[] {0f, 0f, 10f, 10f}) + .color(new float[] {1f, 1f, 0f}) + .flags(4) + .destination("page2") + .iconName("Comment") + .subject("subj") + .author("Alice") + .creationDate("2025-01-01") + .modificationDate("2026-01-01") + .build(); + + assertThat(a.getSubtype()).isEqualTo("Highlight"); + assertThat(a.getContents()).isEqualTo("note"); + assertThat(a.getRect()).containsExactly(0f, 0f, 10f, 10f); + assertThat(a.getColor()).containsExactly(1f, 1f, 0f); + assertThat(a.getFlags()).isEqualTo(4); + assertThat(a.getDestination()).isEqualTo("page2"); + assertThat(a.getIconName()).isEqualTo("Comment"); + assertThat(a.getSubject()).isEqualTo("subj"); + assertThat(a.getAuthor()).isEqualTo("Alice"); + assertThat(a.getCreationDate()).isEqualTo("2025-01-01"); + assertThat(a.getModificationDate()).isEqualTo("2026-01-01"); + assertThat(a.getRawData()).isNull(); + } + + @Test + @DisplayName("setters round-trip") + void setters() { + PdfJsonAnnotation a = new PdfJsonAnnotation(); + a.setSubtype("Text"); + a.setAuthor("Bob"); + assertThat(a.getSubtype()).isEqualTo("Text"); + assertThat(a.getAuthor()).isEqualTo("Bob"); + } + } + + @Nested + @DisplayName("equality") + class Equality { + + // Lombok deep-compares float[] via Arrays.equals. + @Test + @DisplayName("equal content arrays equal; different content not") + void arrayEquality() { + PdfJsonAnnotation a = + PdfJsonAnnotation.builder() + .subtype("Highlight") + .rect(new float[] {1f, 2f, 3f, 4f}) + .build(); + PdfJsonAnnotation b = + PdfJsonAnnotation.builder() + .subtype("Highlight") + .rect(new float[] {1f, 2f, 3f, 4f}) + .build(); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + + PdfJsonAnnotation c = + PdfJsonAnnotation.builder() + .subtype("Highlight") + .rect(new float[] {9f, 9f, 9f, 9f}) + .build(); + assertThat(a).isNotEqualTo(c).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and value") + void toStringContent() { + PdfJsonAnnotation a = PdfJsonAnnotation.builder().subtype("Stamp").build(); + assertThat(a.toString()).contains("PdfJsonAnnotation").contains("Stamp"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonDocumentMetadataTest.java b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonDocumentMetadataTest.java new file mode 100644 index 0000000000..2a4c8c63ab --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonDocumentMetadataTest.java @@ -0,0 +1,95 @@ +package stirling.software.SPDF.model.json; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("PdfJsonDocumentMetadata") +class PdfJsonDocumentMetadataTest { + + @Nested + @DisplayName("construction and defaults") + class Construction { + + @Test + @DisplayName("no-arg constructor initializes empty list fields") + void noArg() { + PdfJsonDocumentMetadata m = new PdfJsonDocumentMetadata(); + assertThat(m.getFonts()).isEmpty(); + assertThat(m.getPageDimensions()).isEmpty(); + assertThat(m.getFormFields()).isEmpty(); + assertThat(m.getMetadata()).isNull(); + assertThat(m.getXmpMetadata()).isNull(); + assertThat(m.getLazyImages()).isNull(); + } + + @Test + @DisplayName("builder defaults produce empty lists") + void builderDefaults() { + PdfJsonDocumentMetadata m = PdfJsonDocumentMetadata.builder().build(); + assertThat(m.getFonts()).isEmpty(); + assertThat(m.getPageDimensions()).isEmpty(); + assertThat(m.getFormFields()).isEmpty(); + } + + @Test + @DisplayName("builder sets scalar and list fields") + void builder() { + PdfJsonMetadata meta = PdfJsonMetadata.builder().title("T").build(); + List dims = List.of(new PdfJsonPageDimension(1, 10f, 20f, 0)); + List fields = List.of(PdfJsonFormField.builder().name("f").build()); + + PdfJsonDocumentMetadata m = + PdfJsonDocumentMetadata.builder() + .metadata(meta) + .xmpMetadata("base64xmp") + .lazyImages(true) + .pageDimensions(dims) + .formFields(fields) + .build(); + + assertThat(m.getMetadata()).isSameAs(meta); + assertThat(m.getXmpMetadata()).isEqualTo("base64xmp"); + assertThat(m.getLazyImages()).isTrue(); + assertThat(m.getPageDimensions()).isEqualTo(dims); + assertThat(m.getFormFields()).isEqualTo(fields); + } + } + + @Nested + @DisplayName("accessors and equality") + class Behavior { + + @Test + @DisplayName("setters round-trip") + void roundTrip() { + PdfJsonDocumentMetadata m = new PdfJsonDocumentMetadata(); + m.setXmpMetadata("xmp"); + m.setLazyImages(false); + assertThat(m.getXmpMetadata()).isEqualTo("xmp"); + assertThat(m.getLazyImages()).isFalse(); + } + + @Test + @DisplayName("equal pair shares hashCode; differs by field") + void equality() { + PdfJsonDocumentMetadata a = PdfJsonDocumentMetadata.builder().xmpMetadata("x").build(); + PdfJsonDocumentMetadata b = PdfJsonDocumentMetadata.builder().xmpMetadata("x").build(); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + + PdfJsonDocumentMetadata c = PdfJsonDocumentMetadata.builder().xmpMetadata("y").build(); + assertThat(a).isNotEqualTo(c).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name") + void toStringContent() { + assertThat(new PdfJsonDocumentMetadata().toString()) + .contains("PdfJsonDocumentMetadata"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonFontCidSystemInfoTest.java b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonFontCidSystemInfoTest.java new file mode 100644 index 0000000000..00ca70e7a8 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonFontCidSystemInfoTest.java @@ -0,0 +1,57 @@ +package stirling.software.SPDF.model.json; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("PdfJsonFontCidSystemInfo") +class PdfJsonFontCidSystemInfoTest { + + @Test + @DisplayName("no-arg constructor yields null fields") + void noArg() { + PdfJsonFontCidSystemInfo info = new PdfJsonFontCidSystemInfo(); + assertThat(info.getRegistry()).isNull(); + assertThat(info.getOrdering()).isNull(); + assertThat(info.getSupplement()).isNull(); + } + + @Test + @DisplayName("all-args constructor sets every field") + void allArgs() { + PdfJsonFontCidSystemInfo info = new PdfJsonFontCidSystemInfo("Adobe", "Japan1", 6); + assertThat(info.getRegistry()).isEqualTo("Adobe"); + assertThat(info.getOrdering()).isEqualTo("Japan1"); + assertThat(info.getSupplement()).isEqualTo(6); + } + + @Test + @DisplayName("builder and setters round-trip") + void builderAndSetters() { + PdfJsonFontCidSystemInfo info = + PdfJsonFontCidSystemInfo.builder() + .registry("Adobe") + .ordering("Identity") + .supplement(0) + .build(); + assertThat(info.getRegistry()).isEqualTo("Adobe"); + assertThat(info.getOrdering()).isEqualTo("Identity"); + assertThat(info.getSupplement()).isZero(); + + info.setSupplement(2); + assertThat(info.getSupplement()).isEqualTo(2); + } + + @Test + @DisplayName("equals/hashCode/toString") + void equality() { + PdfJsonFontCidSystemInfo a = PdfJsonFontCidSystemInfo.builder().registry("Adobe").build(); + PdfJsonFontCidSystemInfo b = PdfJsonFontCidSystemInfo.builder().registry("Adobe").build(); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + + PdfJsonFontCidSystemInfo c = PdfJsonFontCidSystemInfo.builder().registry("MS").build(); + assertThat(a).isNotEqualTo(c).isNotEqualTo(null).isNotEqualTo("string"); + assertThat(a.toString()).contains("PdfJsonFontCidSystemInfo").contains("Adobe"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonFontConversionCandidateTest.java b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonFontConversionCandidateTest.java new file mode 100644 index 0000000000..b7a32b1e1c --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonFontConversionCandidateTest.java @@ -0,0 +1,115 @@ +package stirling.software.SPDF.model.json; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("PdfJsonFontConversionCandidate") +class PdfJsonFontConversionCandidateTest { + + @Nested + @DisplayName("construction") + class Construction { + + @Test + @DisplayName("no-arg constructor yields null fields") + void noArg() { + PdfJsonFontConversionCandidate c = new PdfJsonFontConversionCandidate(); + assertThat(c.getStrategyId()).isNull(); + assertThat(c.getStatus()).isNull(); + assertThat(c.getGlyphCoverage()).isNull(); + } + + @Test + @DisplayName("builder sets scalar, enum and array fields") + void builder() { + PdfJsonFontConversionCandidate c = + PdfJsonFontConversionCandidate.builder() + .strategyId("s1") + .strategyLabel("Strategy 1") + .status(PdfJsonFontConversionStatus.SUCCESS) + .message("ok") + .synthesizedGlyphs(10) + .missingGlyphs(0) + .widthDelta(0.5d) + .bboxDelta(1.0d) + .program("AAA") + .programFormat("ttf") + .webProgram("BBB") + .webProgramFormat("woff") + .pdfProgram("CCC") + .pdfProgramFormat("cff") + .previewImage("PNG") + .diagnostics("{}") + .glyphCoverage(new int[] {65, 66, 67}) + .build(); + + assertThat(c.getStrategyId()).isEqualTo("s1"); + assertThat(c.getStrategyLabel()).isEqualTo("Strategy 1"); + assertThat(c.getStatus()).isEqualTo(PdfJsonFontConversionStatus.SUCCESS); + assertThat(c.getMessage()).isEqualTo("ok"); + assertThat(c.getSynthesizedGlyphs()).isEqualTo(10); + assertThat(c.getMissingGlyphs()).isZero(); + assertThat(c.getWidthDelta()).isEqualTo(0.5d); + assertThat(c.getBboxDelta()).isEqualTo(1.0d); + assertThat(c.getProgram()).isEqualTo("AAA"); + assertThat(c.getProgramFormat()).isEqualTo("ttf"); + assertThat(c.getWebProgram()).isEqualTo("BBB"); + assertThat(c.getWebProgramFormat()).isEqualTo("woff"); + assertThat(c.getPdfProgram()).isEqualTo("CCC"); + assertThat(c.getPdfProgramFormat()).isEqualTo("cff"); + assertThat(c.getPreviewImage()).isEqualTo("PNG"); + assertThat(c.getDiagnostics()).isEqualTo("{}"); + assertThat(c.getGlyphCoverage()).containsExactly(65, 66, 67); + } + + @Test + @DisplayName("setters round-trip") + void setters() { + PdfJsonFontConversionCandidate c = new PdfJsonFontConversionCandidate(); + c.setStrategyId("x"); + c.setStatus(PdfJsonFontConversionStatus.FAILURE); + assertThat(c.getStrategyId()).isEqualTo("x"); + assertThat(c.getStatus()).isEqualTo(PdfJsonFontConversionStatus.FAILURE); + } + } + + @Nested + @DisplayName("equality") + class Equality { + + // Lombok deep-compares int[] via Arrays.equals. + @Test + @DisplayName("equal content arrays equal; different content not") + void arrayEquality() { + PdfJsonFontConversionCandidate a = + PdfJsonFontConversionCandidate.builder() + .strategyId("s") + .glyphCoverage(new int[] {1, 2}) + .build(); + PdfJsonFontConversionCandidate b = + PdfJsonFontConversionCandidate.builder() + .strategyId("s") + .glyphCoverage(new int[] {1, 2}) + .build(); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + + PdfJsonFontConversionCandidate c = + PdfJsonFontConversionCandidate.builder() + .strategyId("s") + .glyphCoverage(new int[] {9}) + .build(); + assertThat(a).isNotEqualTo(c).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and value") + void toStringContent() { + PdfJsonFontConversionCandidate a = + PdfJsonFontConversionCandidate.builder().strategyId("stratId").build(); + assertThat(a.toString()).contains("PdfJsonFontConversionCandidate").contains("stratId"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonFontConversionStatusTest.java b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonFontConversionStatusTest.java new file mode 100644 index 0000000000..22b2223cc0 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonFontConversionStatusTest.java @@ -0,0 +1,36 @@ +package stirling.software.SPDF.model.json; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Arrays; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class PdfJsonFontConversionStatusTest { + + @Test + @DisplayName("contains exactly the expected constants") + void containsExpected() { + assertThat(Arrays.stream(PdfJsonFontConversionStatus.values()).map(Enum::name)) + .containsExactlyInAnyOrder( + "SUCCESS", "WARNING", "FAILURE", "SKIPPED", "UNSUPPORTED"); + } + + @ParameterizedTest + @EnumSource(PdfJsonFontConversionStatus.class) + @DisplayName("valueOf round trips every constant") + void valueOfRoundTrip(PdfJsonFontConversionStatus status) { + assertThat(PdfJsonFontConversionStatus.valueOf(status.name())).isSameAs(status); + } + + @Test + @DisplayName("valueOf throws for unknown name") + void valueOfUnknownThrows() { + assertThatThrownBy(() -> PdfJsonFontConversionStatus.valueOf("BROKEN")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonFontType3GlyphTest.java b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonFontType3GlyphTest.java new file mode 100644 index 0000000000..332abff917 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonFontType3GlyphTest.java @@ -0,0 +1,55 @@ +package stirling.software.SPDF.model.json; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("PdfJsonFontType3Glyph") +class PdfJsonFontType3GlyphTest { + + @Test + @DisplayName("no-arg constructor yields null fields") + void noArg() { + PdfJsonFontType3Glyph g = new PdfJsonFontType3Glyph(); + assertThat(g.getCharCode()).isNull(); + assertThat(g.getGlyphName()).isNull(); + assertThat(g.getUnicode()).isNull(); + assertThat(g.getCharCodeRaw()).isNull(); + } + + @Test + @DisplayName("all-args constructor sets every field") + void allArgs() { + PdfJsonFontType3Glyph g = new PdfJsonFontType3Glyph(65, "A", 0x41, 200); + assertThat(g.getCharCode()).isEqualTo(65); + assertThat(g.getGlyphName()).isEqualTo("A"); + assertThat(g.getUnicode()).isEqualTo(0x41); + assertThat(g.getCharCodeRaw()).isEqualTo(200); + } + + @Test + @DisplayName("builder and setters round-trip") + void builderAndSetters() { + PdfJsonFontType3Glyph g = + PdfJsonFontType3Glyph.builder().charCode(66).glyphName("B").unicode(0x42).build(); + assertThat(g.getCharCode()).isEqualTo(66); + assertThat(g.getGlyphName()).isEqualTo("B"); + assertThat(g.getUnicode()).isEqualTo(0x42); + + g.setCharCodeRaw(10); + assertThat(g.getCharCodeRaw()).isEqualTo(10); + } + + @Test + @DisplayName("equals/hashCode/toString") + void equality() { + PdfJsonFontType3Glyph a = PdfJsonFontType3Glyph.builder().glyphName("A").build(); + PdfJsonFontType3Glyph b = PdfJsonFontType3Glyph.builder().glyphName("A").build(); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + + PdfJsonFontType3Glyph c = PdfJsonFontType3Glyph.builder().glyphName("B").build(); + assertThat(a).isNotEqualTo(c).isNotEqualTo(null).isNotEqualTo("string"); + assertThat(a.toString()).contains("PdfJsonFontType3Glyph"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonFormFieldTest.java b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonFormFieldTest.java new file mode 100644 index 0000000000..ca5aabfd70 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonFormFieldTest.java @@ -0,0 +1,104 @@ +package stirling.software.SPDF.model.json; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("PdfJsonFormField") +class PdfJsonFormFieldTest { + + @Nested + @DisplayName("construction") + class Construction { + + @Test + @DisplayName("no-arg constructor yields null fields") + void noArg() { + PdfJsonFormField f = new PdfJsonFormField(); + assertThat(f.getName()).isNull(); + assertThat(f.getRect()).isNull(); + assertThat(f.getSelectedIndices()).isNull(); + assertThat(f.getOptions()).isNull(); + } + + @Test + @DisplayName("builder sets scalar, list and array fields") + void builder() { + PdfJsonFormField f = + PdfJsonFormField.builder() + .name("form1.text1") + .partialName("text1") + .fieldType("Tx") + .value("hello") + .defaultValue("default") + .flags(2) + .alternateFieldName("alt") + .mappingName("map") + .pageNumber(1) + .rect(new float[] {0f, 0f, 100f, 20f}) + .options(List.of("A", "B")) + .selectedIndices(new int[] {0, 1}) + .checked(true) + .fontName("Helv") + .fontSize(12f) + .build(); + + assertThat(f.getName()).isEqualTo("form1.text1"); + assertThat(f.getPartialName()).isEqualTo("text1"); + assertThat(f.getFieldType()).isEqualTo("Tx"); + assertThat(f.getValue()).isEqualTo("hello"); + assertThat(f.getDefaultValue()).isEqualTo("default"); + assertThat(f.getFlags()).isEqualTo(2); + assertThat(f.getAlternateFieldName()).isEqualTo("alt"); + assertThat(f.getMappingName()).isEqualTo("map"); + assertThat(f.getPageNumber()).isEqualTo(1); + assertThat(f.getRect()).containsExactly(0f, 0f, 100f, 20f); + assertThat(f.getOptions()).containsExactly("A", "B"); + assertThat(f.getSelectedIndices()).containsExactly(0, 1); + assertThat(f.getChecked()).isTrue(); + assertThat(f.getFontName()).isEqualTo("Helv"); + assertThat(f.getFontSize()).isEqualTo(12f); + } + + @Test + @DisplayName("setters round-trip") + void setters() { + PdfJsonFormField f = new PdfJsonFormField(); + f.setName("n"); + f.setChecked(false); + assertThat(f.getName()).isEqualTo("n"); + assertThat(f.getChecked()).isFalse(); + } + } + + @Nested + @DisplayName("equality") + class Equality { + + // Lombok deep-compares int[] via Arrays.equals. + @Test + @DisplayName("equal content arrays equal; different content not") + void arrayEquality() { + PdfJsonFormField a = + PdfJsonFormField.builder().name("f").selectedIndices(new int[] {1, 2}).build(); + PdfJsonFormField b = + PdfJsonFormField.builder().name("f").selectedIndices(new int[] {1, 2}).build(); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + + PdfJsonFormField c = + PdfJsonFormField.builder().name("f").selectedIndices(new int[] {9}).build(); + assertThat(a).isNotEqualTo(c).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and value") + void toStringContent() { + PdfJsonFormField a = PdfJsonFormField.builder().name("fieldName").build(); + assertThat(a.toString()).contains("PdfJsonFormField").contains("fieldName"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonImageElementTest.java b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonImageElementTest.java new file mode 100644 index 0000000000..f6adf39669 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonImageElementTest.java @@ -0,0 +1,104 @@ +package stirling.software.SPDF.model.json; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("PdfJsonImageElement") +class PdfJsonImageElementTest { + + @Nested + @DisplayName("construction") + class Construction { + + @Test + @DisplayName("no-arg constructor yields null fields") + void noArg() { + PdfJsonImageElement e = new PdfJsonImageElement(); + assertThat(e.getId()).isNull(); + assertThat(e.getTransform()).isNull(); + } + + @Test + @DisplayName("builder sets scalar and array fields") + void builder() { + PdfJsonImageElement e = + PdfJsonImageElement.builder() + .id("img1") + .objectName("Im0") + .inlineImage(false) + .nativeWidth(100) + .nativeHeight(200) + .x(1f) + .y(2f) + .width(3f) + .height(4f) + .left(5f) + .right(6f) + .top(7f) + .bottom(8f) + .transform(new float[] {1f, 0f, 0f, 1f, 0f, 0f}) + .zOrder(2) + .imageData("base64") + .imageFormat("png") + .build(); + + assertThat(e.getId()).isEqualTo("img1"); + assertThat(e.getObjectName()).isEqualTo("Im0"); + assertThat(e.getInlineImage()).isFalse(); + assertThat(e.getNativeWidth()).isEqualTo(100); + assertThat(e.getNativeHeight()).isEqualTo(200); + assertThat(e.getX()).isEqualTo(1f); + assertThat(e.getY()).isEqualTo(2f); + assertThat(e.getWidth()).isEqualTo(3f); + assertThat(e.getHeight()).isEqualTo(4f); + assertThat(e.getLeft()).isEqualTo(5f); + assertThat(e.getRight()).isEqualTo(6f); + assertThat(e.getTop()).isEqualTo(7f); + assertThat(e.getBottom()).isEqualTo(8f); + assertThat(e.getTransform()).containsExactly(1f, 0f, 0f, 1f, 0f, 0f); + assertThat(e.getZOrder()).isEqualTo(2); + assertThat(e.getImageData()).isEqualTo("base64"); + assertThat(e.getImageFormat()).isEqualTo("png"); + } + + @Test + @DisplayName("setters round-trip") + void setters() { + PdfJsonImageElement e = new PdfJsonImageElement(); + e.setId("x"); + e.setWidth(9f); + assertThat(e.getId()).isEqualTo("x"); + assertThat(e.getWidth()).isEqualTo(9f); + } + } + + @Nested + @DisplayName("equality") + class Equality { + + // Lombok deep-compares float[] via Arrays.equals. + @Test + @DisplayName("equal content arrays equal; different content not") + void arrayEquality() { + PdfJsonImageElement a = + PdfJsonImageElement.builder().id("i").transform(new float[] {1f, 2f}).build(); + PdfJsonImageElement b = + PdfJsonImageElement.builder().id("i").transform(new float[] {1f, 2f}).build(); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + + PdfJsonImageElement c = + PdfJsonImageElement.builder().id("i").transform(new float[] {9f}).build(); + assertThat(a).isNotEqualTo(c).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and value") + void toStringContent() { + PdfJsonImageElement a = PdfJsonImageElement.builder().id("imgId").build(); + assertThat(a.toString()).contains("PdfJsonImageElement").contains("imgId"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonMetadataTest.java b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonMetadataTest.java new file mode 100644 index 0000000000..b8a661ff9e --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonMetadataTest.java @@ -0,0 +1,100 @@ +package stirling.software.SPDF.model.json; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("PdfJsonMetadata") +class PdfJsonMetadataTest { + + @Nested + @DisplayName("constructors and builder") + class Construction { + + @Test + @DisplayName("no-arg constructor yields null fields") + void noArg() { + PdfJsonMetadata m = new PdfJsonMetadata(); + assertThat(m.getTitle()).isNull(); + assertThat(m.getNumberOfPages()).isNull(); + } + + @Test + @DisplayName("all-args constructor sets every field") + void allArgs() { + PdfJsonMetadata m = + new PdfJsonMetadata( + "Title", + "Author", + "Subject", + "kw", + "Creator", + "Producer", + "2025-01-01", + "2026-01-01", + "False", + 7); + + assertThat(m.getTitle()).isEqualTo("Title"); + assertThat(m.getAuthor()).isEqualTo("Author"); + assertThat(m.getSubject()).isEqualTo("Subject"); + assertThat(m.getKeywords()).isEqualTo("kw"); + assertThat(m.getCreator()).isEqualTo("Creator"); + assertThat(m.getProducer()).isEqualTo("Producer"); + assertThat(m.getCreationDate()).isEqualTo("2025-01-01"); + assertThat(m.getModificationDate()).isEqualTo("2026-01-01"); + assertThat(m.getTrapped()).isEqualTo("False"); + assertThat(m.getNumberOfPages()).isEqualTo(7); + } + + @Test + @DisplayName("builder sets fields") + void builder() { + PdfJsonMetadata m = + PdfJsonMetadata.builder() + .title("BuiltTitle") + .author("BuiltAuthor") + .numberOfPages(3) + .build(); + + assertThat(m.getTitle()).isEqualTo("BuiltTitle"); + assertThat(m.getAuthor()).isEqualTo("BuiltAuthor"); + assertThat(m.getNumberOfPages()).isEqualTo(3); + } + } + + @Nested + @DisplayName("accessors and equality") + class Behavior { + + @Test + @DisplayName("setters round-trip") + void roundTrip() { + PdfJsonMetadata m = new PdfJsonMetadata(); + m.setTitle("T"); + m.setNumberOfPages(2); + assertThat(m.getTitle()).isEqualTo("T"); + assertThat(m.getNumberOfPages()).isEqualTo(2); + } + + @Test + @DisplayName("equal pair shares hashCode; differs by field") + void equality() { + PdfJsonMetadata a = PdfJsonMetadata.builder().title("X").build(); + PdfJsonMetadata b = PdfJsonMetadata.builder().title("X").build(); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + + PdfJsonMetadata c = PdfJsonMetadata.builder().title("Y").build(); + assertThat(a).isNotEqualTo(c).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and value") + void toStringContent() { + PdfJsonMetadata m = PdfJsonMetadata.builder().title("Meta").build(); + assertThat(m.toString()).contains("PdfJsonMetadata").contains("Meta"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonPageDimensionTest.java b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonPageDimensionTest.java new file mode 100644 index 0000000000..fb27541b70 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonPageDimensionTest.java @@ -0,0 +1,61 @@ +package stirling.software.SPDF.model.json; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("PdfJsonPageDimension") +class PdfJsonPageDimensionTest { + + @Test + @DisplayName("no-arg constructor yields primitive defaults") + void noArg() { + PdfJsonPageDimension d = new PdfJsonPageDimension(); + assertThat(d.getPageNumber()).isZero(); + assertThat(d.getWidth()).isZero(); + assertThat(d.getHeight()).isZero(); + assertThat(d.getRotation()).isZero(); + } + + @Test + @DisplayName("all-args constructor sets every field") + void allArgs() { + PdfJsonPageDimension d = new PdfJsonPageDimension(1, 612f, 792f, 90); + assertThat(d.getPageNumber()).isEqualTo(1); + assertThat(d.getWidth()).isEqualTo(612f); + assertThat(d.getHeight()).isEqualTo(792f); + assertThat(d.getRotation()).isEqualTo(90); + } + + @Test + @DisplayName("builder and setters round-trip") + void builderAndSetters() { + PdfJsonPageDimension d = + PdfJsonPageDimension.builder() + .pageNumber(2) + .width(100f) + .height(200f) + .rotation(180) + .build(); + assertThat(d.getPageNumber()).isEqualTo(2); + assertThat(d.getWidth()).isEqualTo(100f); + assertThat(d.getHeight()).isEqualTo(200f); + assertThat(d.getRotation()).isEqualTo(180); + + d.setWidth(300f); + assertThat(d.getWidth()).isEqualTo(300f); + } + + @Test + @DisplayName("equals/hashCode/toString") + void equality() { + PdfJsonPageDimension a = new PdfJsonPageDimension(1, 10f, 20f, 0); + PdfJsonPageDimension b = new PdfJsonPageDimension(1, 10f, 20f, 0); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + + PdfJsonPageDimension c = new PdfJsonPageDimension(2, 10f, 20f, 0); + assertThat(a).isNotEqualTo(c).isNotEqualTo(null).isNotEqualTo("string"); + assertThat(a.toString()).contains("PdfJsonPageDimension"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonTextColorTest.java b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonTextColorTest.java new file mode 100644 index 0000000000..ef759a208b --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonTextColorTest.java @@ -0,0 +1,64 @@ +package stirling.software.SPDF.model.json; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("PdfJsonTextColor") +class PdfJsonTextColorTest { + + @Test + @DisplayName("no-arg constructor yields null fields") + void noArg() { + PdfJsonTextColor c = new PdfJsonTextColor(); + assertThat(c.getColorSpace()).isNull(); + assertThat(c.getComponents()).isNull(); + } + + @Test + @DisplayName("all-args constructor and accessors round-trip") + void allArgs() { + float[] comps = {0.1f, 0.2f, 0.3f}; + PdfJsonTextColor c = new PdfJsonTextColor("DeviceRGB", comps); + assertThat(c.getColorSpace()).isEqualTo("DeviceRGB"); + assertThat(c.getComponents()).containsExactly(0.1f, 0.2f, 0.3f); + } + + @Test + @DisplayName("builder and setters round-trip") + void builderAndSetters() { + PdfJsonTextColor c = + PdfJsonTextColor.builder() + .colorSpace("DeviceGray") + .components(new float[] {0.5f}) + .build(); + assertThat(c.getColorSpace()).isEqualTo("DeviceGray"); + assertThat(c.getComponents()).containsExactly(0.5f); + + c.setColorSpace("DeviceCMYK"); + assertThat(c.getColorSpace()).isEqualTo("DeviceCMYK"); + } + + // Lombok deep-compares float[] via Arrays.equals. + @Test + @DisplayName("equal content arrays equal; different content not") + void arrayEquality() { + PdfJsonTextColor a = + PdfJsonTextColor.builder() + .colorSpace("RGB") + .components(new float[] {1f, 2f}) + .build(); + PdfJsonTextColor b = + PdfJsonTextColor.builder() + .colorSpace("RGB") + .components(new float[] {1f, 2f}) + .build(); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + + PdfJsonTextColor c = + PdfJsonTextColor.builder().colorSpace("RGB").components(new float[] {9f}).build(); + assertThat(a).isNotEqualTo(c).isNotEqualTo(null).isNotEqualTo("string"); + assertThat(a.toString()).contains("PdfJsonTextColor"); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonTextElementTest.java b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonTextElementTest.java new file mode 100644 index 0000000000..edeb0e4cf1 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/model/json/PdfJsonTextElementTest.java @@ -0,0 +1,120 @@ +package stirling.software.SPDF.model.json; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("PdfJsonTextElement") +class PdfJsonTextElementTest { + + @Nested + @DisplayName("construction") + class Construction { + + @Test + @DisplayName("no-arg constructor yields null fields") + void noArg() { + PdfJsonTextElement e = new PdfJsonTextElement(); + assertThat(e.getText()).isNull(); + assertThat(e.getTextMatrix()).isNull(); + assertThat(e.getCharCodes()).isNull(); + assertThat(e.getFillColor()).isNull(); + } + + @Test + @DisplayName("builder sets scalar, nested and array fields") + void builder() { + PdfJsonTextColor fill = + PdfJsonTextColor.builder() + .colorSpace("RGB") + .components(new float[] {1f}) + .build(); + PdfJsonTextElement e = + PdfJsonTextElement.builder() + .text("Hello") + .fontId("F1") + .fontSize(12f) + .fontMatrixSize(1f) + .fontSizeInPt(12f) + .characterSpacing(0.5f) + .wordSpacing(1f) + .spaceWidth(2f) + .zOrder(1) + .horizontalScaling(100f) + .leading(14f) + .rise(0f) + .x(10f) + .y(20f) + .width(30f) + .height(40f) + .textMatrix(new float[] {1f, 0f, 0f, 1f, 0f, 0f}) + .fillColor(fill) + .renderingMode(0) + .fallbackUsed(false) + .charCodes(new int[] {72, 101}) + .build(); + + assertThat(e.getText()).isEqualTo("Hello"); + assertThat(e.getFontId()).isEqualTo("F1"); + assertThat(e.getFontSize()).isEqualTo(12f); + assertThat(e.getFontMatrixSize()).isEqualTo(1f); + assertThat(e.getFontSizeInPt()).isEqualTo(12f); + assertThat(e.getCharacterSpacing()).isEqualTo(0.5f); + assertThat(e.getWordSpacing()).isEqualTo(1f); + assertThat(e.getSpaceWidth()).isEqualTo(2f); + assertThat(e.getZOrder()).isEqualTo(1); + assertThat(e.getHorizontalScaling()).isEqualTo(100f); + assertThat(e.getLeading()).isEqualTo(14f); + assertThat(e.getRise()).isEqualTo(0f); + assertThat(e.getX()).isEqualTo(10f); + assertThat(e.getY()).isEqualTo(20f); + assertThat(e.getWidth()).isEqualTo(30f); + assertThat(e.getHeight()).isEqualTo(40f); + assertThat(e.getTextMatrix()).containsExactly(1f, 0f, 0f, 1f, 0f, 0f); + assertThat(e.getFillColor()).isSameAs(fill); + assertThat(e.getRenderingMode()).isZero(); + assertThat(e.getFallbackUsed()).isFalse(); + assertThat(e.getCharCodes()).containsExactly(72, 101); + } + + @Test + @DisplayName("setters round-trip including stroke color") + void setters() { + PdfJsonTextElement e = new PdfJsonTextElement(); + PdfJsonTextColor stroke = PdfJsonTextColor.builder().colorSpace("Gray").build(); + e.setText("t"); + e.setStrokeColor(stroke); + assertThat(e.getText()).isEqualTo("t"); + assertThat(e.getStrokeColor()).isSameAs(stroke); + } + } + + @Nested + @DisplayName("equality") + class Equality { + + // Lombok deep-compares int[] via Arrays.equals. + @Test + @DisplayName("equal content arrays equal; different content not") + void arrayEquality() { + PdfJsonTextElement a = + PdfJsonTextElement.builder().text("t").charCodes(new int[] {1, 2}).build(); + PdfJsonTextElement b = + PdfJsonTextElement.builder().text("t").charCodes(new int[] {1, 2}).build(); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + + PdfJsonTextElement c = + PdfJsonTextElement.builder().text("t").charCodes(new int[] {9}).build(); + assertThat(a).isNotEqualTo(c).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString contains class name and value") + void toStringContent() { + PdfJsonTextElement a = PdfJsonTextElement.builder().text("TheText").build(); + assertThat(a.toString()).contains("PdfJsonTextElement").contains("TheText"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/pdf/TextFinderMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/pdf/TextFinderMoreTest.java new file mode 100644 index 0000000000..c6579f8e5f --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/pdf/TextFinderMoreTest.java @@ -0,0 +1,194 @@ +package stirling.software.SPDF.pdf; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.AfterEach; +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 stirling.software.SPDF.model.PDFText; + +/** + * Additional branch coverage for {@link TextFinder} over real in-memory PDFs. Focuses on branches + * the primary TextFinderTest leaves untouched: literal escaping of regex metacharacters, the + * search-term trim path, multi-line single-char whole-word, getDebugInfo, and getFoundTexts + * accumulation across repeated runs. + */ +@DisplayName("TextFinder additional branch tests") +class TextFinderMoreTest { + + private PDDocument document; + private PDPage page; + + @BeforeEach + void setUp() { + document = new PDDocument(); + page = new PDPage(PDRectangle.A4); + document.addPage(page); + } + + @AfterEach + void tearDown() throws IOException { + if (document != null) { + document.close(); + } + } + + private void addText(String text) throws IOException { + addText(page, text); + } + + private void addText(PDPage target, String text) throws IOException { + try (PDPageContentStream cs = new PDPageContentStream(document, target)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(50, 750); + cs.showText(text); + cs.endText(); + } + } + + private List find(String term, boolean regex, boolean wholeWord) throws IOException { + TextFinder finder = new TextFinder(term, regex, wholeWord); + finder.getText(document); + return finder.getFoundTexts(); + } + + @Nested + @DisplayName("Literal vs regex metacharacter handling") + class LiteralVsRegex { + + @Test + @DisplayName("literal search treats regex metacharacters as plain text") + void literalMetacharacters() throws IOException { + // "a.c" literally; with \Q..\E it must NOT match "abc". + addText("Match a.c here but not abc here"); + List found = find("a.c", false, false); + assertThat(found).hasSize(1); + assertThat(found.get(0).getText()).isEqualTo("a.c"); + } + + @Test + @DisplayName("regex search interprets the dot as a wildcard") + void regexWildcard() throws IOException { + // As a regex, "a.c" (dot = any char) matches "atc" (from "Match"), "a.c" and "abc". + addText("Match a.c here and abc here"); + List found = find("a.c", true, false); + assertThat(found).extracting(PDFText::getText).containsExactly("atc", "a.c", "abc"); + } + } + + @Nested + @DisplayName("Search term trimming") + class Trimming { + + @Test + @DisplayName("leading and trailing whitespace is trimmed before matching") + void trimsSurroundingWhitespace() throws IOException { + addText("the keyword appears once"); + // Term has padding; the trimmed "keyword" should match. + List found = find(" keyword ", false, false); + assertThat(found).hasSize(1); + assertThat(found.get(0).getText()).isEqualTo("keyword"); + } + } + + @Nested + @DisplayName("Whole-word single-character (non-digit)") + class SingleCharNonDigit { + + @Test + @DisplayName("single non-digit letter matches only as a standalone token") + void standaloneLetterOnly() throws IOException { + // Only the lone "x" should match, not the x inside "box" or "xen". + addText("a x box xen end"); + List found = find("x", false, true); + assertThat(found).hasSize(1); + assertThat(found.get(0).getText()).isEqualTo("x"); + } + } + + @Nested + @DisplayName("Multi-page matching and accumulation") + class MultiPage { + + @Test + @DisplayName("no matches on any page yields an empty result list") + void noMatchAcrossPages() throws IOException { + PDPage second = new PDPage(PDRectangle.A4); + document.addPage(second); + addText("first page text"); + addText(second, "second page text"); + + List found = find("absent", false, false); + assertThat(found).isEmpty(); + } + + @Test + @DisplayName("page index is zero-based for the matched page") + void pageIndexZeroBased() throws IOException { + PDPage second = new PDPage(PDRectangle.A4); + document.addPage(second); + addText("alpha only here"); + addText(second, "beta only here"); + + List found = find("beta", false, false); + assertThat(found).hasSize(1); + assertThat(found.get(0).getPageIndex()).isEqualTo(1); + } + + @Test + @DisplayName("repeated runs accumulate into the same foundTexts list") + void accumulatesAcrossRuns() throws IOException { + addText("repeat repeat repeat"); + TextFinder finder = new TextFinder("repeat", false, false); + finder.getText(document); + finder.getText(document); + // Each pass over the single page finds 3, so two passes give 6. + assertThat(finder.getFoundTexts()).hasSize(6); + } + } + + @Nested + @DisplayName("getDebugInfo") + class DebugInfo { + + @Test + @DisplayName("debug info reports extracted length and position count after extraction") + void reportsCounts() throws IOException { + addText("debuggable content"); + TextFinder finder = new TextFinder("content", false, false); + finder.getText(document); + + String debug = finder.getDebugInfo(); + assertThat(debug) + .contains("Extracted text length") + .contains("Position count") + .contains("Text content"); + } + } + + @Nested + @DisplayName("Case sensitivity") + class CaseSensitivity { + + @Test + @DisplayName("matching is case-insensitive for literal terms") + void caseInsensitiveLiteral() throws IOException { + addText("Mixed Case WORD word WoRd"); + List found = find("word", false, false); + assertThat(found).hasSize(3); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/CertificateValidationServiceMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/service/CertificateValidationServiceMoreTest.java new file mode 100644 index 0000000000..cbf0d30113 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/CertificateValidationServiceMoreTest.java @@ -0,0 +1,765 @@ +package stirling.software.SPDF.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.Security; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaCertStore; +import org.bouncycastle.cms.CMSProcessableByteArray; +import org.bouncycastle.cms.CMSSignedData; +import org.bouncycastle.cms.CMSSignedDataGenerator; +import org.bouncycastle.cms.SignerInformation; +import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; +import org.bouncycastle.util.CollectionStore; +import org.bouncycastle.util.Store; +import org.junit.jupiter.api.BeforeAll; +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.springframework.core.io.ClassPathResource; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.ServerCertificateServiceInterface; + +/** + * Additional coverage for {@link CertificateValidationService} that drives the real X.509 / + * KeyStore machinery with the bundled test fixtures, exercises trust-store initialization, and + * reaches the private trust-list parsers via reflection. Network paths are only hit with file:// + * URLs so no real connection is ever opened. + */ +@DisplayName("CertificateValidationService (more) Tests") +class CertificateValidationServiceMoreTest { + + private static final char[] PASSWORD = "password".toCharArray(); + + private X509Certificate realCert; + private byte[] realCertDer; + + @BeforeAll + static void registerBc() { + if (Security.getProvider("BC") == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + @BeforeEach + void setUp() throws Exception { + realCert = loadPemCert(); + realCertDer = realCert.getEncoded(); + } + + // ---------- helpers ---------- + + private static byte[] readResource(String path) throws Exception { + try (InputStream is = new ClassPathResource(path).getInputStream()) { + return is.readAllBytes(); + } + } + + private static X509Certificate loadPemCert() throws Exception { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + try (InputStream is = new ClassPathResource("certs/test-cert.pem").getInputStream()) { + return (X509Certificate) cf.generateCertificate(is); + } + } + + private static ApplicationProperties defaultProps() { + // Real POJO defaults: trust all off, revocation "none". + ApplicationProperties props = new ApplicationProperties(); + props.getSecurity().getValidation().getTrust().setServerAsAnchor(false); + return props; + } + + private static CertificateValidationService newService(ApplicationProperties props) { + return new CertificateValidationService(null, props); + } + + /** + * Invoke the private @PostConstruct so signingTrustAnchors is created without a Spring context. + */ + private static void initTrustStore(CertificateValidationService svc) throws Exception { + Method m = CertificateValidationService.class.getDeclaredMethod("initializeTrustStore"); + m.setAccessible(true); + m.invoke(svc); + } + + @SuppressWarnings("unchecked") + private static T invokePrivate( + CertificateValidationService svc, String name, Class[] sig, Object... args) + throws Exception { + Method m = CertificateValidationService.class.getDeclaredMethod(name, sig); + m.setAccessible(true); + return (T) m.invoke(svc, args); + } + + private static String certXmlElement(String tagName, byte[] der) { + return "<" + tagName + ">" + Base64.getEncoder().encodeToString(der) + ""; + } + + // ---------- certificate loading from every fixture format ---------- + + @Nested + @DisplayName("Loading certificates from fixture formats") + class CertificateLoadingTests { + + @Test + @DisplayName("PEM, CRT and CER all decode to the same X.509 certificate") + void loadsTextEncodedFormats() throws Exception { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + X509Certificate fromPem = + (X509Certificate) + cf.generateCertificate( + new ByteArrayInputStream(readResource("certs/test-cert.pem"))); + X509Certificate fromCrt = + (X509Certificate) + cf.generateCertificate( + new ByteArrayInputStream(readResource("certs/test-cert.crt"))); + X509Certificate fromCer = + (X509Certificate) + cf.generateCertificate( + new ByteArrayInputStream(readResource("certs/test-cert.cer"))); + + assertThat(fromPem).isEqualTo(fromCrt).isEqualTo(fromCer); + assertThat(fromPem.getSubjectX500Principal().getName()).contains("CN=Test"); + } + + @Test + @DisplayName("DER binary certificate decodes and matches the PEM form") + void loadsDerFormat() throws Exception { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + X509Certificate fromDer = + (X509Certificate) + cf.generateCertificate( + new ByteArrayInputStream(readResource("certs/test-cert.der"))); + assertThat(fromDer).isEqualTo(realCert); + } + + @Test + @DisplayName("PKCS12 (.p12 and .pfx) keystores expose the certificate and private key") + void loadsPkcs12Keystores() throws Exception { + for (String name : new String[] {"certs/test-cert.p12", "certs/test-cert.pfx"}) { + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (InputStream is = new ClassPathResource(name).getInputStream()) { + ks.load(is, PASSWORD); + } + String alias = ks.aliases().nextElement(); + assertThat(ks.getCertificate(alias)).isInstanceOf(X509Certificate.class); + assertThat(ks.getKey(alias, PASSWORD)).isInstanceOf(PrivateKey.class); + } + } + + @Test + @DisplayName("JKS keystore exposes the certificate") + void loadsJksKeystore() throws Exception { + KeyStore ks = KeyStore.getInstance("JKS"); + try (InputStream is = new ClassPathResource("certs/test-cert.jks").getInputStream()) { + ks.load(is, PASSWORD); + } + String alias = ks.aliases().nextElement(); + Certificate cert = ks.getCertificate(alias); + assertThat(cert).isInstanceOf(X509Certificate.class); + } + } + + // ---------- public predicate methods with the REAL certificate ---------- + + @Nested + @DisplayName("Predicate methods on the real test certificate") + class RealCertPredicateTests { + + private final CertificateValidationService svc = newService(defaultProps()); + + @Test + @DisplayName("Test CA certificate reports isCA true") + void realCertIsCa() { + assertThat(svc.isCA(realCert)).isTrue(); + } + + @Test + @DisplayName("Self-signed test certificate reports isSelfSigned true") + void realCertIsSelfSigned() { + assertThat(svc.isSelfSigned(realCert)).isTrue(); + } + + @Test + @DisplayName("Fingerprint of the real certificate is a 64-char uppercase hex string") + void realCertFingerprint() { + String fp = svc.sha256Fingerprint(realCert); + assertThat(fp).hasSize(64).matches("[0-9A-F]+"); + } + + @Test + @DisplayName("Certificate is inside its validity window mid-2026 and outside it in 1990") + void realCertValidityWindow() throws Exception { + Date inWindow = new java.text.SimpleDateFormat("yyyy-MM-dd").parse("2026-01-15"); + Date past = new java.text.SimpleDateFormat("yyyy-MM-dd").parse("1990-01-01"); + assertThat(svc.isOutsideValidityPeriod(realCert, inWindow)).isFalse(); + assertThat(svc.isOutsideValidityPeriod(realCert, past)).isTrue(); + } + } + + // ---------- extractIntermediateCertificates ---------- + + @Nested + @DisplayName("extractIntermediateCertificates") + class ExtractIntermediatesTests { + + private final CertificateValidationService svc = newService(defaultProps()); + + @Test + @DisplayName("Excludes the signer certificate, returns the remaining certificates") + void excludesSignerCert() throws Exception { + X509CertificateHolder holder = new X509CertificateHolder(realCertDer); + Store store = new CollectionStore<>(List.of(holder)); + + // When the only cert is the signer, nothing remains. + Collection none = svc.extractIntermediateCertificates(store, realCert); + assertThat(none).isEmpty(); + + // When the signer is a different cert, the holder cert is returned as an intermediate. + X509Certificate other = mock(X509Certificate.class); + Collection some = svc.extractIntermediateCertificates(store, other); + assertThat(some).hasSize(1); + assertThat(some.iterator().next()).isEqualTo(realCert); + } + } + + // ---------- buildAndValidatePath ---------- + + @Nested + @DisplayName("buildAndValidatePath") + class BuildAndValidatePathTests { + + @Test + @DisplayName("Self-signed cert validates against itself as a custom trust anchor") + void validatesAgainstCustomAnchor() throws Exception { + CertificateValidationService svc = newService(defaultProps()); + var result = svc.buildAndValidatePath(realCert, List.of(), realCert, new Date()); + assertThat(result).isNotNull(); + assertThat(result.getCertPath()).isNotNull(); + } + + @Test + @DisplayName("Throws when there are no trust anchors at all") + void throwsWithoutAnchors() throws Exception { + CertificateValidationService svc = newService(defaultProps()); + initTrustStore(svc); // empty keystore, no anchors + assertThatThrownBy( + () -> svc.buildAndValidatePath(realCert, List.of(), null, new Date())) + .isInstanceOf(GeneralSecurityException.class); + } + + @Test + @DisplayName("Throws when the custom anchor does not match the signer") + void throwsWhenAnchorMismatch() throws Exception { + CertificateValidationService svc = newService(defaultProps()); + // A different self-signed cert as anchor cannot validate the real signer. + X509Certificate stranger = secondSelfSignedCert(); + assertThatThrownBy( + () -> + svc.buildAndValidatePath( + realCert, List.of(), stranger, new Date())) + .isInstanceOf(GeneralSecurityException.class); + } + + @Test + @DisplayName("Revocation mode 'ocsp' configures the checker without throwing") + void revocationOcspModeBuildsPath() throws Exception { + ApplicationProperties props = defaultProps(); + props.getSecurity().getValidation().getRevocation().setMode("ocsp"); + CertificateValidationService svc = newService(props); + // Self-signed anchor with soft-fail (default) -> path still builds. + var result = svc.buildAndValidatePath(realCert, List.of(), realCert, new Date()); + assertThat(result).isNotNull(); + } + + @Test + @DisplayName("Revocation mode 'crl' with hard-fail configures the checker") + void revocationCrlHardFailBuildsPath() throws Exception { + ApplicationProperties props = defaultProps(); + props.getSecurity().getValidation().getRevocation().setMode("crl"); + props.getSecurity().getValidation().getRevocation().setHardFail(true); + CertificateValidationService svc = newService(props); + // Self-signed cert has no CRLDP, so a self-validating path still succeeds. + var result = svc.buildAndValidatePath(realCert, List.of(), realCert, new Date()); + assertThat(result).isNotNull(); + } + + @Test + @DisplayName("Null validation time is accepted (no setDate)") + void nullValidationTimeAccepted() throws Exception { + CertificateValidationService svc = newService(defaultProps()); + var result = svc.buildAndValidatePath(realCert, List.of(), realCert, null); + assertThat(result).isNotNull(); + } + + private X509Certificate secondSelfSignedCert() throws Exception { + // A genuine, unrelated self-signed certificate that cannot anchor the test signer. + java.security.KeyPairGenerator kpg = java.security.KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + java.security.KeyPair kp = kpg.generateKeyPair(); + org.bouncycastle.asn1.x500.X500Name dn = + new org.bouncycastle.asn1.x500.X500Name("CN=Stranger"); + Date from = new Date(System.currentTimeMillis() - 86_400_000L); + Date to = new Date(System.currentTimeMillis() + 86_400_000L * 365); + org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder builder = + new org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder( + dn, java.math.BigInteger.valueOf(1), from, to, dn, kp.getPublic()); + org.bouncycastle.operator.ContentSigner signer = + new JcaContentSignerBuilder("SHA256WithRSA").build(kp.getPrivate()); + return new org.bouncycastle.cert.jcajce.JcaX509CertificateConverter() + .getCertificate(builder.build(signer)); + } + } + + // ---------- trust store initialization ---------- + + @Nested + @DisplayName("Trust store initialization") + class TrustStoreInitTests { + + @Test + @DisplayName("Default initialization creates an empty in-memory trust store") + void defaultInitCreatesEmptyStore() throws Exception { + CertificateValidationService svc = newService(defaultProps()); + initTrustStore(svc); + KeyStore store = svc.getSigningTrustStore(); + assertThat(store).isNotNull(); + assertThat(store.size()).isZero(); + } + + @Test + @DisplayName("allowAIA sets JDK revocation system properties") + void allowAiaSetsSystemProperties() throws Exception { + ApplicationProperties props = defaultProps(); + props.getSecurity().getValidation().setAllowAIA(true); + CertificateValidationService svc = newService(props); + initTrustStore(svc); + assertThat(Security.getProperty("ocsp.enable")).isEqualTo("true"); + assertThat(System.getProperty("com.sun.security.enableCRLDP")).isEqualTo("true"); + } + + @Test + @DisplayName("Java system trust store loading populates trust anchors") + void loadsJavaSystemTrustStore() throws Exception { + ApplicationProperties props = defaultProps(); + props.getSecurity().getValidation().getTrust().setUseSystemTrust(true); + CertificateValidationService svc = newService(props); + initTrustStore(svc); + // The JVM cacerts bundle has many CA certificates. + assertThat(svc.getSigningTrustStore().size()).isGreaterThan(0); + } + + @Test + @DisplayName("Mozilla bundle path is a no-op when the bundle is absent") + void mozillaBundleAbsentIsNoOp() throws Exception { + ApplicationProperties props = defaultProps(); + props.getSecurity().getValidation().getTrust().setUseMozillaBundle(true); + CertificateValidationService svc = newService(props); + initTrustStore(svc); + // No certs/cacert.pem resource exists, so nothing is loaded but init succeeds. + assertThat(svc.getSigningTrustStore()).isNotNull(); + } + + @Test + @DisplayName("Server certificate is added as an anchor when self-signed") + void serverCertAddedAsAnchor() throws Exception { + ServerCertificateServiceInterface serverSvc = + mock(ServerCertificateServiceInterface.class); + when(serverSvc.isEnabled()).thenReturn(true); + when(serverSvc.hasServerCertificate()).thenReturn(true); + when(serverSvc.getServerCertificate()).thenReturn(realCert); + + ApplicationProperties props = defaultProps(); + props.getSecurity().getValidation().getTrust().setServerAsAnchor(true); + CertificateValidationService svc = new CertificateValidationService(serverSvc, props); + initTrustStore(svc); + + assertThat(svc.getSigningTrustStore().size()).isEqualTo(1); + assertThat(svc.getSigningTrustStore().getCertificate("server-anchor")) + .isEqualTo(realCert); + } + + @Test + @DisplayName("Disabled server certificate service contributes no anchor") + void disabledServerCertNotAdded() throws Exception { + ServerCertificateServiceInterface serverSvc = + mock(ServerCertificateServiceInterface.class); + when(serverSvc.isEnabled()).thenReturn(false); + + ApplicationProperties props = defaultProps(); + props.getSecurity().getValidation().getTrust().setServerAsAnchor(true); + CertificateValidationService svc = new CertificateValidationService(serverSvc, props); + initTrustStore(svc); + + assertThat(svc.getSigningTrustStore().size()).isZero(); + } + + @Test + @DisplayName("AATL/EUTL enabled with file:// URLs perform no network call and add nothing") + void aatlEutlWithFileUrlsNoNetwork() throws Exception { + ApplicationProperties props = defaultProps(); + props.getSecurity().getValidation().getTrust().setUseAATL(true); + props.getSecurity().getValidation().getTrust().setUseEUTL(true); + // file:// is not an HttpURLConnection, so the download helpers return null safely. + props.getSecurity().getValidation().getAatl().setUrl("file:///does-not-exist.pdf"); + props.getSecurity().getValidation().getEutl().setLotlUrl("file:///does-not-exist.xml"); + CertificateValidationService svc = newService(props); + initTrustStore(svc); + assertThat(svc.getSigningTrustStore().size()).isZero(); + } + } + + // ---------- private trust-list parsers via reflection ---------- + + @Nested + @DisplayName("Trust-list parsers (reflection)") + class TrustListParserTests { + + private CertificateValidationService svc; + + @BeforeEach + void initService() throws Exception { + svc = newService(defaultProps()); + initTrustStore(svc); // parsers add to signingTrustAnchors + } + + @Test + @DisplayName("parseSecuritySettingsXML imports CA certificate nodes and skips empties") + void parseSecuritySettingsXmlImportsCa() throws Exception { + String xml = + "" + + certXmlElement("Certificate", realCertDer) + + "" + + ""; + int added = + invokePrivate( + svc, + "parseSecuritySettingsXML", + new Class[] {InputStream.class}, + new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + assertThat(added).isEqualTo(1); + assertThat(svc.getSigningTrustStore().size()).isEqualTo(1); + } + + @Test + @DisplayName("parseSecuritySettingsXML returns zero when no Certificate nodes present") + void parseSecuritySettingsXmlNoCerts() throws Exception { + String xml = "x"; + int added = + invokePrivate( + svc, + "parseSecuritySettingsXML", + new Class[] {InputStream.class}, + new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + assertThat(added).isZero(); + } + + @Test + @DisplayName("tryParseSecuritySettingsXML returns null when the file spec is missing") + void tryParseReturnsNullWithoutSpec() throws Exception { + Map empty = Map.of(); + Object result = + invokePrivate( + svc, "tryParseSecuritySettingsXML", new Class[] {Map.class}, empty); + assertThat(result).isNull(); + } + + @Test + @DisplayName("parseLotlForTslLocations extracts every TSLLocation URL") + void parseLotlExtractsLocations() throws Exception { + String ns = "http://uri.etsi.org/02231/v2#"; + String lotl = + "" + + "https://a.test/tsl1.xml" + + "https://b.test/tsl2.xml" + + ""; + List urls = + invokePrivate( + svc, + "parseLotlForTslLocations", + new Class[] {byte[].class}, + (Object) lotl.getBytes(StandardCharsets.UTF_8)); + assertThat(urls).containsExactly("https://a.test/tsl1.xml", "https://b.test/tsl2.xml"); + } + + @Test + @DisplayName("parseLotlForTslLocations returns empty list when no pointers exist") + void parseLotlNoPointers() throws Exception { + String ns = "http://uri.etsi.org/02231/v2#"; + String lotl = + ""; + List urls = + invokePrivate( + svc, + "parseLotlForTslLocations", + new Class[] {byte[].class}, + (Object) lotl.getBytes(StandardCharsets.UTF_8)); + assertThat(urls).isEmpty(); + } + + @Test + @DisplayName("parseTslAndAddCas imports a qualified, active CA certificate") + void parseTslImportsQualifiedActiveCa() throws Exception { + String ns = "http://uri.etsi.org/02231/v2#"; + String tsl = + "" + + "http://uri.etsi.org/TrstSvc/Svctype/CA/QC" + + "http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/undersupervision" + + "" + + certXmlElement("X509Certificate", realCertDer) + + "" + + ""; + int added = + invokePrivate( + svc, + "parseTslAndAddCas", + new Class[] {byte[].class, String.class}, + tsl.getBytes(StandardCharsets.UTF_8), + "https://source.test/tsl.xml"); + assertThat(added).isEqualTo(1); + assertThat(svc.getSigningTrustStore().size()).isEqualTo(1); + } + + @Test + @DisplayName("parseTslAndAddCas skips services whose type is not qualified") + void parseTslSkipsNonQualified() throws Exception { + String ns = "http://uri.etsi.org/02231/v2#"; + String tsl = + "" + + "http://uri.etsi.org/TrstSvc/Svctype/unspecified" + + "http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/undersupervision" + + "" + + certXmlElement("X509Certificate", realCertDer) + + "" + + ""; + int added = + invokePrivate( + svc, + "parseTslAndAddCas", + new Class[] {byte[].class, String.class}, + tsl.getBytes(StandardCharsets.UTF_8), + "https://source.test/tsl.xml"); + assertThat(added).isZero(); + } + + @Test + @DisplayName("parseTslAndAddCas skips qualified services in an inactive status") + void parseTslSkipsInactiveStatus() throws Exception { + String ns = "http://uri.etsi.org/02231/v2#"; + String tsl = + "" + + "http://uri.etsi.org/TrstSvc/Svctype/CA/QC" + + "http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/withdrawn" + + "" + + certXmlElement("X509Certificate", realCertDer) + + "" + + ""; + int added = + invokePrivate( + svc, + "parseTslAndAddCas", + new Class[] {byte[].class, String.class}, + tsl.getBytes(StandardCharsets.UTF_8), + "https://source.test/tsl.xml"); + assertThat(added).isZero(); + } + + @Test + @DisplayName("isActiveStatus accepts supervised/accredited and rejects withdrawn") + void isActiveStatusBranches() throws Exception { + boolean supervised = + invokePrivate( + svc, + "isActiveStatus", + new Class[] {String.class}, + "http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/undersupervision"); + boolean accredited = + invokePrivate( + svc, + "isActiveStatus", + new Class[] {String.class}, + "http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/accredited"); + boolean withdrawn = + invokePrivate( + svc, + "isActiveStatus", + new Class[] {String.class}, + "http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/withdrawn"); + assertThat(supervised).isTrue(); + assertThat(accredited).isTrue(); + assertThat(withdrawn).isFalse(); + } + + @Test + @DisplayName("isActiveStatus honours acceptTransitional for supervision-in-cessation") + void isActiveStatusTransitional() throws Exception { + ApplicationProperties props = defaultProps(); + props.getSecurity().getValidation().getEutl().setAcceptTransitional(true); + CertificateValidationService transitional = newService(props); + initTrustStore(transitional); + boolean cessation = + invokePrivate( + transitional, + "isActiveStatus", + new Class[] {String.class}, + "http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/supervisionincessation"); + assertThat(cessation).isTrue(); + } + + @Test + @DisplayName("parseAATLPdf returns zero for a PDF without embedded files") + void parseAatlPdfNoEmbeddedFiles() throws Exception { + byte[] plainPdf; + try (org.apache.pdfbox.pdmodel.PDDocument doc = + new org.apache.pdfbox.pdmodel.PDDocument()) { + doc.addPage(new org.apache.pdfbox.pdmodel.PDPage()); + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + doc.save(baos); + plainPdf = baos.toByteArray(); + } + int added = + invokePrivate( + svc, "parseAATLPdf", new Class[] {byte[].class}, (Object) plainPdf); + assertThat(added).isZero(); + } + } + + // ---------- extractValidationTime with real CMS ---------- + + @Nested + @DisplayName("extractValidationTime") + class ExtractValidationTimeTests { + + private final CertificateValidationService svc = newService(defaultProps()); + + @Test + @DisplayName("Returns signing-time source when the CMS carries a signingTime attribute") + void returnsSigningTime() throws Exception { + SignerInformation signerInfo = buildSignerWithSignedAttrs(); + CertificateValidationService.ValidationTime vt = svc.extractValidationTime(signerInfo); + assertThat(vt).isNotNull(); + assertThat(vt.source).isEqualTo("signing-time"); + assertThat(vt.date).isNotNull(); + } + + @Test + @DisplayName("Returns null when neither timestamp nor signingTime are present") + void returnsNullWhenNoAttributes() throws Exception { + SignerInformation signerInfo = buildSignerWithoutSignedAttrs(); + assertThat(svc.extractValidationTime(signerInfo)).isNull(); + } + + private SignerInformation buildSignerWithSignedAttrs() throws Exception { + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (InputStream is = new ClassPathResource("certs/test-cert.p12").getInputStream()) { + ks.load(is, PASSWORD); + } + String alias = ks.aliases().nextElement(); + PrivateKey pk = (PrivateKey) ks.getKey(alias, PASSWORD); + X509Certificate cert = (X509Certificate) ks.getCertificate(alias); + + CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); + gen.addSignerInfoGenerator( + new JcaSignerInfoGeneratorBuilder( + new JcaDigestCalculatorProviderBuilder().build()) + .build(new JcaContentSignerBuilder("SHA256WithRSA").build(pk), cert)); + gen.addCertificates( + new JcaCertStore(new ArrayList<>(Arrays.asList(new Certificate[] {cert})))); + // encapsulate=true so signed attributes (incl. signingTime) are generated. + CMSSignedData sd = gen.generate(new CMSProcessableByteArray("data".getBytes()), true); + // Re-parse from DER so the signingTime value deserializes as ASN1UTCTime. + CMSSignedData reparsed = new CMSSignedData(sd.getEncoded()); + return reparsed.getSignerInfos().getSigners().iterator().next(); + } + + private SignerInformation buildSignerWithoutSignedAttrs() throws Exception { + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (InputStream is = new ClassPathResource("certs/test-cert.p12").getInputStream()) { + ks.load(is, PASSWORD); + } + String alias = ks.aliases().nextElement(); + PrivateKey pk = (PrivateKey) ks.getKey(alias, PASSWORD); + X509Certificate cert = (X509Certificate) ks.getCertificate(alias); + + CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); + // No signed-attribute table -> no signingTime, no timestamp. + gen.addSignerInfoGenerator( + new JcaSignerInfoGeneratorBuilder( + new JcaDigestCalculatorProviderBuilder().build()) + .setDirectSignature(true) + .build(new JcaContentSignerBuilder("SHA256WithRSA").build(pk), cert)); + gen.addCertificates( + new JcaCertStore(new ArrayList<>(Arrays.asList(new Certificate[] {cert})))); + CMSSignedData sd = gen.generate(new CMSProcessableByteArray("data".getBytes()), false); + CMSSignedData reparsed = + new CMSSignedData( + new CMSProcessableByteArray("data".getBytes()), sd.getEncoded()); + return reparsed.getSignerInfos().getSigners().iterator().next(); + } + } + + // ---------- small private helpers ---------- + + @Nested + @DisplayName("Private utility helpers (reflection)") + class PrivateHelperTests { + + private final CertificateValidationService svc = newService(defaultProps()); + + @Test + @DisplayName("bytesToHex renders bytes as upper-case two-digit hex") + void bytesToHexFormatsBytes() throws Exception { + String hex = + invokePrivate( + svc, + "bytesToHex", + new Class[] {byte[].class}, + (Object) new byte[] {0x00, 0x0f, (byte) 0xff, 0x10}); + assertThat(hex).isEqualTo("000FFF10"); + } + + @Test + @DisplayName("secureDbfWithNamespaces returns a namespace-aware factory") + void secureDbfIsNamespaceAware() throws Exception { + javax.xml.parsers.DocumentBuilderFactory factory = + invokePrivate(svc, "secureDbfWithNamespaces", new Class[] {}); + assertThat(factory.isNamespaceAware()).isTrue(); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceCoverageTest.java b/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceCoverageTest.java new file mode 100644 index 0000000000..944c66d3de --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceCoverageTest.java @@ -0,0 +1,1110 @@ +package stirling.software.SPDF.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +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.image.LosslessFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText; +import org.junit.jupiter.api.AfterEach; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.quality.Strictness; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.exception.CacheUnavailableException; +import stirling.software.SPDF.model.json.PdfJsonAnnotation; +import stirling.software.SPDF.model.json.PdfJsonDocument; +import stirling.software.SPDF.model.json.PdfJsonDocumentMetadata; +import stirling.software.SPDF.model.json.PdfJsonFont; +import stirling.software.SPDF.model.json.PdfJsonImageElement; +import stirling.software.SPDF.model.json.PdfJsonMetadata; +import stirling.software.SPDF.model.json.PdfJsonPage; +import stirling.software.SPDF.model.json.PdfJsonTextColor; +import stirling.software.SPDF.model.json.PdfJsonTextElement; +import stirling.software.SPDF.service.pdfjson.PdfJsonFontService; +import stirling.software.SPDF.service.pdfjson.type3.Type3FontConversionService; +import stirling.software.SPDF.service.pdfjson.type3.Type3GlyphExtractor; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.TaskManager; +import stirling.software.common.util.JobContext; +import stirling.software.common.util.TempFileManager; + +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * High-volume coverage tests for {@link PdfJsonConversionService}. These drive the large uncovered + * bulk of the class through real in-memory PDF round trips (text in multiple Standard14 fonts, + * embedded raster images, rotated pages, CropBox != MediaBox, annotations, links) plus the + * cache-backed lazy editor API (which is reachable once a {@code jobId} is present on {@link + * JobContext}). + * + *

Complements {@code PdfJsonConversionServiceGapTest} (basic entrypoints) and {@code + * PdfJsonConversionServiceUnicodeParsingTest} (static helpers) without repeating those cases. + */ +@ExtendWith(MockitoExtension.class) +@org.mockito.junit.jupiter.MockitoSettings(strictness = Strictness.LENIENT) +class PdfJsonConversionServiceCoverageTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private EndpointConfiguration endpointConfiguration; + @Mock private TempFileManager tempFileManager; + @Mock private TaskManager taskManager; + @Mock private PdfJsonFallbackFontService fallbackFontService; + @Mock private PdfJsonFontService fontService; + @Mock private Type3FontConversionService type3FontConversionService; + @Mock private Type3GlyphExtractor type3GlyphExtractor; + @Mock private ApplicationProperties applicationProperties; + + // Real COS mapper: serialization is pure and complex, so the real component gives best + // coverage. + private final PdfJsonCosMapper cosMapper = new PdfJsonCosMapper(); + + // Mirror production application.properties so primitive defaults map cleanly on round-trip. + private final ObjectMapper objectMapper = + JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .build(); + + private PdfJsonConversionService service; + + private final List createdTempFiles = new ArrayList<>(); + + @BeforeEach + void setUp() throws IOException { + service = + new PdfJsonConversionService( + pdfDocumentFactory, + objectMapper, + endpointConfiguration, + tempFileManager, + taskManager, + cosMapper, + fallbackFontService, + fontService, + type3FontConversionService, + type3GlyphExtractor, + applicationProperties); + + when(tempFileManager.createTempFile(anyString())) + .thenAnswer( + invocation -> { + String suffix = invocation.getArgument(0); + Path path = Files.createTempFile("pdfjson-cov-test", suffix); + createdTempFiles.add(path); + return path.toFile(); + }); + when(tempFileManager.deleteTempFile(any(File.class))) + .thenAnswer( + invocation -> { + File file = invocation.getArgument(0); + return file != null && file.delete(); + }); + when(taskManager.addNote(anyString(), anyString())).thenReturn(true); + } + + @AfterEach + void tearDown() throws IOException { + JobContext.clear(); + for (Path path : createdTempFiles) { + Files.deleteIfExists(path); + } + createdTempFiles.clear(); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private void stubFallbackFont() throws IOException { + when(fallbackFontService.buildFallbackFontModel()) + .thenAnswer( + invocation -> + PdfJsonFont.builder() + .id(PdfJsonFallbackFontService.FALLBACK_FONT_ID) + .uid(PdfJsonFallbackFontService.FALLBACK_FONT_ID) + .baseName("Fallback") + .subtype("TrueType") + .build()); + when(fallbackFontService.loadFallbackPdfFont(any(PDDocument.class))) + .thenAnswer(invocation -> new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + } + + private MockMultipartFile pdfMultipart(byte[] bytes) { + return new MockMultipartFile("fileInput", "input.pdf", "application/pdf", bytes); + } + + private MockMultipartFile pdfMultipart() { + return pdfMultipart("%PDF-1.4 placeholder".getBytes(StandardCharsets.UTF_8)); + } + + /** Serializes a PDDocument to bytes and closes it. */ + private byte[] toBytes(PDDocument document) throws IOException { + try (document) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } + } + + /** Single page with one line of Helvetica text. */ + private byte[] simpleTextPdf() throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(72, 700); + cs.showText("Hello PDF JSON round trip"); + cs.endText(); + } + return toBytes(document); + } + + /** Multi-font, multi-page PDF with rotation and varied colors to exercise text styling. */ + private byte[] richTextPdf() throws IOException { + PDDocument document = new PDDocument(); + + PDPage page1 = new PDPage(PDRectangle.LETTER); + document.addPage(page1); + try (PDPageContentStream cs = new PDPageContentStream(document, page1)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 14f); + cs.setNonStrokingColor(Color.RED); + cs.newLineAtOffset(72, 720); + cs.showText("Helvetica red line"); + cs.endText(); + + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.TIMES_BOLD), 18f); + cs.setNonStrokingColor(Color.BLUE); + cs.setCharacterSpacing(1.5f); + cs.newLineAtOffset(72, 680); + cs.showText("Times bold blue spaced"); + cs.endText(); + + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.COURIER), 10f); + cs.newLineAtOffset(72, 640); + cs.showText("Courier monospace 0123456789"); + cs.endText(); + } + + PDPage page2 = new PDPage(new PDRectangle(400, 600)); + page2.setRotation(90); + document.addPage(page2); + try (PDPageContentStream cs = new PDPageContentStream(document, page2)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA_OBLIQUE), 12f); + cs.newLineAtOffset(50, 500); + cs.showText("Rotated page text"); + cs.endText(); + } + + PDPage page3 = new PDPage(new PDRectangle(300, 300)); + page3.setRotation(180); + document.addPage(page3); + try (PDPageContentStream cs = new PDPageContentStream(document, page3)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.TIMES_ITALIC), 11f); + cs.newLineAtOffset(40, 150); + cs.showText("Half turn page"); + cs.endText(); + } + return toBytes(document); + } + + private BufferedImage colorTile(int w, int h, Color color) { + BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); + for (int x = 0; x < w; x++) { + for (int y = 0; y < h; y++) { + image.setRGB(x, y, color.getRGB()); + } + } + return image; + } + + /** PDF carrying an embedded lossless raster image plus a line of text. */ + private byte[] imagePdf() throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + PDImageXObject image = + LosslessFactory.createFromImage(document, colorTile(32, 24, Color.GREEN)); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.drawImage(image, 100, 500, 128, 96); + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(72, 400); + cs.showText("Caption under image"); + cs.endText(); + } + return toBytes(document); + } + + /** PDF whose CropBox is strictly smaller than its MediaBox. */ + private byte[] cropBoxPdf() throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + page.setCropBox(new PDRectangle(20, 30, 400, 500)); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(100, 400); + cs.showText("Inside crop box"); + cs.endText(); + } + return toBytes(document); + } + + /** PDF with a text annotation and a link annotation on a single page. */ + private byte[] annotatedPdf() throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + + PDAnnotationText note = new PDAnnotationText(); + note.setContents("Sticky note contents"); + note.setRectangle(new PDRectangle(50, 700, 20, 20)); + note.setSubject("note subject"); + + PDAnnotationLink link = new PDAnnotationLink(); + link.setRectangle(new PDRectangle(100, 600, 200, 20)); + + page.getAnnotations().add(note); + page.getAnnotations().add(link); + + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(72, 500); + cs.showText("Annotated document"); + cs.endText(); + } + return toBytes(document); + } + + /** Converts the given PDF bytes to the in-memory JSON model using the real factory load. */ + private PdfJsonDocument toJsonDocument(byte[] pdfBytes) throws IOException { + when(pdfDocumentFactory.load(any(Path.class), eq(true))) + .thenAnswer( + invocation -> + Loader.loadPDF(invocation.getArgument(0, Path.class).toFile())); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertPdfToJson(pdfMultipart(pdfBytes), out); + return objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class); + } + + private byte[] runJsonToPdf(PdfJsonDocument doc) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertJsonToPdf(doc, out); + return out.toByteArray(); + } + + /** + * Populates the document cache by running a lazy conversion (jobId present on JobContext) so + * the cache-backed page/font/export endpoints can be exercised afterwards. The factory is + * stubbed to load from both Path and raw bytes for the subsequent cache re-loads. + */ + private PdfJsonDocument cacheLazyDocument(String jobId, byte[] pdfBytes) throws IOException { + when(pdfDocumentFactory.load(any(Path.class), eq(true))) + .thenAnswer( + invocation -> + Loader.loadPDF(invocation.getArgument(0, Path.class).toFile())); + when(pdfDocumentFactory.load(any(byte[].class), eq(true))) + .thenAnswer(invocation -> Loader.loadPDF(invocation.getArgument(0, byte[].class))); + JobContext.setJobId(jobId); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertPdfToJson(pdfMultipart(pdfBytes), true, out); + return objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class); + } + + // ================================================================== + // PDF -> JSON deep extraction + // ================================================================== + + @Nested + @DisplayName("PDF to JSON extraction") + class PdfToJsonExtraction { + + @Test + @DisplayName("simple text PDF yields text elements with font references") + void simpleTextProducesElements() throws IOException { + PdfJsonDocument doc = toJsonDocument(simpleTextPdf()); + + assertEquals(1, doc.getPages().size()); + List elements = doc.getPages().get(0).getTextElements(); + assertThat(elements).isNotEmpty(); + String joined = + elements.stream().map(PdfJsonTextElement::getText).reduce("", (a, b) -> a + b); + assertThat(joined).contains("Hello"); + assertThat(doc.getFonts()).isNotEmpty(); + // Every text run should reference a known font id. + assertThat(elements).allSatisfy(e -> assertThat(e.getFontId()).isNotBlank()); + } + + @Test + @DisplayName("multi-font multi-page PDF captures distinct fonts and page geometry") + void richTextCapturesFontsAndGeometry() throws IOException { + PdfJsonDocument doc = toJsonDocument(richTextPdf()); + + assertEquals(3, doc.getPages().size()); + // The first page used three different base fonts. + long distinctBaseNames = + doc.getFonts().stream() + .map(PdfJsonFont::getBaseName) + .filter(java.util.Objects::nonNull) + .distinct() + .count(); + assertThat(distinctBaseNames).isGreaterThanOrEqualTo(3); + assertEquals(90, doc.getPages().get(1).getRotation()); + assertEquals(180, doc.getPages().get(2).getRotation()); + assertEquals(300f, doc.getPages().get(2).getWidth(), 0.5f); + } + + @Test + @DisplayName("text run colors are extracted into fill color components") + void textColorsExtracted() throws IOException { + PdfJsonDocument doc = toJsonDocument(richTextPdf()); + // Color extraction runs during conversion; assert text was extracted and any emitted + // fill color carries well-formed components. + assertThat(doc.getPages().get(0).getTextElements()).isNotEmpty(); + doc.getPages().get(0).getTextElements().stream() + .map(PdfJsonTextElement::getFillColor) + .filter(java.util.Objects::nonNull) + .map(PdfJsonTextColor::getComponents) + .forEach(c -> assertThat(c).isNotNull()); + } + + @Test + @DisplayName("embedded image is extracted as an image element with data") + void imageExtracted() throws IOException { + PdfJsonDocument doc = toJsonDocument(imagePdf()); + List images = doc.getPages().get(0).getImageElements(); + assertThat(images).isNotEmpty(); + PdfJsonImageElement img = images.get(0); + assertThat(img.getImageData()).isNotBlank(); + assertThat(img.getImageFormat()).isNotBlank(); + assertThat(img.getWidth()).isGreaterThan(0f); + } + + @Test + @DisplayName("CropBox-bounded page reports crop dimensions in JSON") + void cropBoxDimensions() throws IOException { + PdfJsonDocument doc = toJsonDocument(cropBoxPdf()); + PdfJsonPage page = doc.getPages().get(0); + // CropBox is 400x500 here, smaller than the Letter MediaBox. + assertEquals(400f, page.getWidth(), 0.5f); + assertEquals(500f, page.getHeight(), 0.5f); + } + + @Test + @DisplayName("annotations and links are collected per page") + void annotationsCollected() throws IOException { + PdfJsonDocument doc = toJsonDocument(annotatedPdf()); + List annotations = doc.getPages().get(0).getAnnotations(); + assertThat(annotations).hasSizeGreaterThanOrEqualTo(2); + assertThat(annotations).anySatisfy(a -> assertThat(a.getSubtype()).isEqualTo("Text")); + assertThat(annotations).anySatisfy(a -> assertThat(a.getSubtype()).isEqualTo("Link")); + } + + @Test + @DisplayName("lightweight extraction still returns parseable pages") + void lightweightExtraction() throws IOException { + when(pdfDocumentFactory.load(any(Path.class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, Path.class).toFile())); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertPdfToJson(pdfMultipart(richTextPdf()), true, out); + PdfJsonDocument doc = objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class); + assertEquals(3, doc.getPages().size()); + } + + @Test + @DisplayName("metadata is fully extracted from the source document information") + void metadataExtracted() throws IOException { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.LETTER)); + PDDocumentInformation info = document.getDocumentInformation(); + info.setTitle("Cov Title"); + info.setAuthor("Cov Author"); + info.setSubject("Cov Subject"); + info.setKeywords("k1,k2"); + info.setCreator("Cov Creator"); + info.setProducer("Cov Producer"); + byte[] bytes = toBytes(document); + + PdfJsonDocument doc = toJsonDocument(bytes); + PdfJsonMetadata md = doc.getMetadata(); + assertEquals("Cov Title", md.getTitle()); + assertEquals("Cov Author", md.getAuthor()); + assertEquals("Cov Subject", md.getSubject()); + assertEquals("k1,k2", md.getKeywords()); + assertEquals(1, md.getNumberOfPages()); + } + + @Test + @DisplayName("progress callback observes increasing percentages through to completion") + void progressMonotonic() throws IOException { + when(pdfDocumentFactory.load(any(Path.class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, Path.class).toFile())); + AtomicInteger maxPercent = new AtomicInteger(-1); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertPdfToJson( + pdfMultipart(richTextPdf()), + progress -> + maxPercent.updateAndGet(prev -> Math.max(prev, progress.getPercent())), + out); + assertEquals(100, maxPercent.get()); + } + } + + // ================================================================== + // Full round trips: PDF -> JSON -> PDF + // ================================================================== + + @Nested + @DisplayName("PDF to JSON to PDF round trip") + class RoundTrip { + + @Test + @DisplayName("simple text round trip yields a single-page PDF") + void simpleTextRoundTrip() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(simpleTextPdf()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("rich multi-page round trip preserves page count and rotation") + void richRoundTripPreservesPages() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(richTextPdf()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(3, loaded.getNumberOfPages()); + assertEquals(90, loaded.getPage(1).getRotation()); + assertEquals(180, loaded.getPage(2).getRotation()); + } + } + + @Test + @DisplayName("image round trip reconstructs an image-bearing page") + void imageRoundTrip() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(imagePdf()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + assertNotNull(loaded.getPage(0).getResources()); + } + } + + @Test + @DisplayName("annotation round trip keeps annotations on the rebuilt page") + void annotationRoundTrip() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(annotatedPdf()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertThat(loaded.getPage(0).getAnnotations()).isNotEmpty(); + } + } + + @Test + @DisplayName("editing text content before rebuild still produces a valid PDF") + void editedTextRoundTrip() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(simpleTextPdf()); + for (PdfJsonTextElement element : doc.getPages().get(0).getTextElements()) { + if (element.getText() != null && !element.getText().isBlank()) { + element.setText("Edited content"); + break; + } + } + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("CropBox document round trips without error") + void cropBoxRoundTrip() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(cropBoxPdf()); + byte[] rebuilt = runJsonToPdf(doc); + assertThat(rebuilt).isNotEmpty(); + } + } + + // ================================================================== + // JSON -> PDF synthesized model paths (no prior extraction) + // ================================================================== + + @Nested + @DisplayName("JSON to PDF from synthesized models") + class SynthesizedJsonToPdf { + + private PdfJsonDocument docWith(PdfJsonPage page) { + PdfJsonDocument doc = new PdfJsonDocument(); + doc.setPages(List.of(page)); + return doc; + } + + @Test + @DisplayName("text element drawn with a Standard14 font reference renders") + void standard14TextRenders() throws IOException { + stubFallbackFont(); + PdfJsonFont font = + PdfJsonFont.builder() + .id("F1") + .uid("F1") + .baseName("Helvetica") + .subtype("Type1") + .standard14Name("Helvetica") + .build(); + + PdfJsonTextElement element = + PdfJsonTextElement.builder() + .text("Synth text") + .fontId("F1") + .fontSize(12f) + .x(72f) + .y(700f) + .build(); + + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .textElements(List.of(element)) + .build(); + + PdfJsonDocument doc = docWith(page); + doc.setFonts(new ArrayList<>(List.of(font))); + + try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(doc))) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("text element with full style attributes renders via regeneration") + void styledTextRenders() throws IOException { + stubFallbackFont(); + PdfJsonFont font = + PdfJsonFont.builder() + .id("F1") + .uid("F1") + .baseName("Times-Roman") + .subtype("Type1") + .standard14Name("Times-Roman") + .build(); + + PdfJsonTextColor fill = + PdfJsonTextColor.builder() + .colorSpace("DeviceRGB") + .components(new float[] {0.2f, 0.4f, 0.6f}) + .build(); + + PdfJsonTextElement element = + PdfJsonTextElement.builder() + .text("Styled") + .fontId("F1") + .fontSize(20f) + .characterSpacing(1.2f) + .wordSpacing(2.0f) + .horizontalScaling(95f) + .rise(1.0f) + .renderingMode(0) + .fillColor(fill) + .textMatrix(new float[] {1f, 0f, 0f, 1f, 100f, 600f}) + .x(100f) + .y(600f) + .build(); + + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .textElements(List.of(element)) + .build(); + PdfJsonDocument doc = docWith(page); + doc.setFonts(new ArrayList<>(List.of(font))); + + assertThat(runJsonToPdf(doc)).isNotEmpty(); + } + + @Test + @DisplayName("image element synthesized from base64 renders onto the page") + void synthesizedImageRenders() throws IOException { + stubFallbackFont(); + // Encode a tiny PNG. + BufferedImage tile = colorTile(8, 8, Color.MAGENTA); + ByteArrayOutputStream pngOut = new ByteArrayOutputStream(); + javax.imageio.ImageIO.write(tile, "png", pngOut); + String base64 = Base64.getEncoder().encodeToString(pngOut.toByteArray()); + + PdfJsonImageElement image = + PdfJsonImageElement.builder() + .id("Im1") + .imageData(base64) + .imageFormat("png") + .x(50f) + .y(500f) + .width(64f) + .height(64f) + .nativeWidth(8) + .nativeHeight(8) + .build(); + + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .imageElements(List.of(image)) + .build(); + + try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(docWith(page)))) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("image element positioned via left/bottom edges still renders") + void imageWithEdgePositioning() throws IOException { + stubFallbackFont(); + BufferedImage tile = colorTile(8, 8, Color.ORANGE); + ByteArrayOutputStream pngOut = new ByteArrayOutputStream(); + javax.imageio.ImageIO.write(tile, "png", pngOut); + String base64 = Base64.getEncoder().encodeToString(pngOut.toByteArray()); + + PdfJsonImageElement image = + PdfJsonImageElement.builder() + .id("Im2") + .imageData(base64) + .imageFormat("png") + .left(30f) + .bottom(40f) + .right(110f) + .top(120f) + .build(); + + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .imageElements(List.of(image)) + .build(); + assertThat(runJsonToPdf(docWith(page))).isNotEmpty(); + } + + @Test + @DisplayName("annotation model is restored onto the rebuilt page") + void synthesizedAnnotationRestored() throws IOException { + stubFallbackFont(); + PdfJsonAnnotation annotation = + PdfJsonAnnotation.builder() + .subtype("Text") + .contents("synthetic note") + .rect(new float[] {50f, 700f, 70f, 720f}) + .color(new float[] {1f, 1f, 0f}) + .build(); + + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .annotations(List.of(annotation)) + .build(); + + try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(docWith(page)))) { + // The rebuild ran the annotation-restore path and produced a valid single-page doc. + assertThat(loaded.getNumberOfPages()).isEqualTo(1); + assertThat(loaded.getPage(0).getAnnotations()).isNotNull(); + } + } + + @Test + @DisplayName("text referencing a missing font falls back without failing") + void missingFontFallsBack() throws IOException { + stubFallbackFont(); + PdfJsonTextElement element = + PdfJsonTextElement.builder() + .text("No font defined") + .fontId("does-not-exist") + .fontSize(12f) + .x(72f) + .y(700f) + .build(); + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .textElements(List.of(element)) + .build(); + assertThat(runJsonToPdf(docWith(page))).isNotEmpty(); + } + + @Test + @DisplayName("page with both text and image regenerates content") + void mixedTextAndImage() throws IOException { + stubFallbackFont(); + BufferedImage tile = colorTile(8, 8, Color.CYAN); + ByteArrayOutputStream pngOut = new ByteArrayOutputStream(); + javax.imageio.ImageIO.write(tile, "png", pngOut); + String base64 = Base64.getEncoder().encodeToString(pngOut.toByteArray()); + + PdfJsonFont font = + PdfJsonFont.builder() + .id("F1") + .uid("F1") + .baseName("Helvetica") + .subtype("Type1") + .standard14Name("Helvetica") + .build(); + PdfJsonTextElement text = + PdfJsonTextElement.builder() + .text("Mixed") + .fontId("F1") + .fontSize(12f) + .x(72f) + .y(700f) + .build(); + PdfJsonImageElement image = + PdfJsonImageElement.builder() + .id("Im1") + .imageData(base64) + .imageFormat("png") + .x(72f) + .y(500f) + .width(48f) + .height(48f) + .build(); + + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .textElements(List.of(text)) + .imageElements(List.of(image)) + .build(); + PdfJsonDocument doc = docWith(page); + doc.setFonts(new ArrayList<>(List.of(font))); + assertThat(runJsonToPdf(doc)).isNotEmpty(); + } + } + + // ================================================================== + // Cache-backed lazy editor API (jobId on JobContext) + // ================================================================== + + @Nested + @DisplayName("cache-backed lazy editor API") + class CacheBackedApi { + + @Test + @DisplayName("lazy conversion caches all pages for later extraction") + void lazyConversionCachesDimensions() throws IOException { + PdfJsonDocument doc = cacheLazyDocument("job-dims", richTextPdf()); + assertThat(doc.getPages()).hasSize(3); + assertTrue(doc.isLazyImages(), "lazy conversion should flag lazyImages"); + + // Every page is now resolvable from the cache, including the last one. + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractSinglePage("job-dims", 3, out); + PdfJsonPage lastPage = objectMapper.readValue(out.toByteArray(), PdfJsonPage.class); + assertEquals(3, lastPage.getPageNumber()); + } + + @Test + @DisplayName("extractSinglePage returns text for a cached page") + void extractSinglePageReturnsText() throws IOException { + cacheLazyDocument("job-page", simpleTextPdf()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractSinglePage("job-page", 1, out); + PdfJsonPage page = objectMapper.readValue(out.toByteArray(), PdfJsonPage.class); + assertEquals(1, page.getPageNumber()); + assertThat(page.getTextElements()).isNotEmpty(); + } + + @Test + @DisplayName("extractSinglePage surfaces image elements on demand") + void extractSinglePageImages() throws IOException { + cacheLazyDocument("job-img", imagePdf()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractSinglePage("job-img", 1, out); + PdfJsonPage page = objectMapper.readValue(out.toByteArray(), PdfJsonPage.class); + assertThat(page.getImageElements()).isNotEmpty(); + } + + @Test + @DisplayName("extractSinglePage surfaces annotations on demand") + void extractSinglePageAnnotations() throws IOException { + cacheLazyDocument("job-ann", annotatedPdf()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractSinglePage("job-ann", 1, out); + PdfJsonPage page = objectMapper.readValue(out.toByteArray(), PdfJsonPage.class); + assertThat(page.getAnnotations()).isNotEmpty(); + } + + @Test + @DisplayName("extractSinglePage rejects an out-of-range page number") + void extractSinglePageOutOfRange() throws IOException { + cacheLazyDocument("job-range", simpleTextPdf()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + assertThrows( + IllegalArgumentException.class, + () -> service.extractSinglePage("job-range", 99, out)); + } + + @Test + @DisplayName("extractPageFonts returns the fonts used on a cached page") + void extractPageFontsReturnsFonts() throws IOException { + cacheLazyDocument("job-fonts", richTextPdf()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractPageFonts("job-fonts", 1, out); + List fonts = objectMapper.readValue(out.toByteArray(), List.class); + assertThat(fonts).isNotEmpty(); + } + + @Test + @DisplayName("extractPageFonts rejects a page number beyond the document") + void extractPageFontsOutOfRange() throws IOException { + cacheLazyDocument("job-fonts-range", simpleTextPdf()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + assertThrows( + IllegalArgumentException.class, + () -> service.extractPageFonts("job-fonts-range", 5, out)); + } + + @Test + @DisplayName("extractDocumentMetadata caches and returns the metadata model") + void extractDocumentMetadataCaches() throws IOException { + when(pdfDocumentFactory.load(any(byte[].class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, byte[].class))); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractDocumentMetadata(pdfMultipart(richTextPdf()), "job-meta", out); + PdfJsonDocumentMetadata md = + objectMapper.readValue(out.toByteArray(), PdfJsonDocumentMetadata.class); + assertThat(md.getPageDimensions()).hasSize(3); + + // The page is now cached, so a single page can be pulled back out. + ByteArrayOutputStream pageOut = new ByteArrayOutputStream(); + service.extractSinglePage("job-meta", 1, pageOut); + assertThat(pageOut.size()).isGreaterThan(0); + } + + @Test + @DisplayName("exportUpdatedPages with no page updates returns the cached PDF unchanged") + void exportUpdatedPagesNoUpdates() throws IOException { + cacheLazyDocument("job-export-none", simpleTextPdf()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.exportUpdatedPages("job-export-none", new PdfJsonDocument(), out); + assertThat(out.toByteArray()).isNotEmpty(); + try (PDDocument loaded = Loader.loadPDF(out.toByteArray())) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("exportUpdatedPages applies an edited page and re-saves the document") + void exportUpdatedPagesAppliesEdit() throws IOException { + stubFallbackFont(); + cacheLazyDocument("job-export-edit", simpleTextPdf()); + + // Pull the real page so we have valid fonts/elements, edit text, then export. + ByteArrayOutputStream pageOut = new ByteArrayOutputStream(); + service.extractSinglePage("job-export-edit", 1, pageOut); + PdfJsonPage page = objectMapper.readValue(pageOut.toByteArray(), PdfJsonPage.class); + page.setPageNumber(1); + for (PdfJsonTextElement element : page.getTextElements()) { + if (element.getText() != null && !element.getText().isBlank()) { + element.setText("Updated via export"); + break; + } + } + + PdfJsonDocument updates = new PdfJsonDocument(); + updates.setPages(List.of(page)); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.exportUpdatedPages("job-export-edit", updates, out); + assertThat(out.toByteArray()).isNotEmpty(); + try (PDDocument loaded = Loader.loadPDF(out.toByteArray())) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("exportUpdatedPages ignores updates for an out-of-range page") + void exportUpdatedPagesSkipsOutOfRange() throws IOException { + cacheLazyDocument("job-export-oor", simpleTextPdf()); + PdfJsonPage page = new PdfJsonPage(); + page.setPageNumber(42); + PdfJsonDocument updates = new PdfJsonDocument(); + updates.setPages(List.of(page)); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.exportUpdatedPages("job-export-oor", updates, out); + // Falls back to returning the cached PDF since no in-range page was updated. + assertThat(out.toByteArray()).isNotEmpty(); + } + + @Test + @DisplayName("clearCachedDocument removes a previously cached job") + void clearCachedDocumentRemovesJob() throws IOException { + cacheLazyDocument("job-clear", simpleTextPdf()); + service.clearCachedDocument("job-clear"); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + assertThrows( + CacheUnavailableException.class, + () -> service.extractSinglePage("job-clear", 1, out)); + } + } + + // ================================================================== + // Edge and error branches + // ================================================================== + + @Nested + @DisplayName("edge and error branches") + class EdgeBranches { + + @Test + @DisplayName("malformed JSON input surfaces as a runtime parsing failure") + void malformedJsonThrows() { + MockMultipartFile file = + new MockMultipartFile( + "fileInput", + "broken.json", + "application/json", + "{ not valid json ]".getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + assertThrows(Exception.class, () -> service.convertJsonToPdf(file, out)); + } + + @Test + @DisplayName("zero-page document produces an empty but valid PDF") + void zeroPageDocument() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = new PdfJsonDocument(); + doc.setPages(new ArrayList<>()); + byte[] bytes = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(bytes)) { + assertEquals(0, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("page with neither text nor images is skipped cleanly") + void emptyContentPageSkipped() throws IOException { + stubFallbackFont(); + PdfJsonPage page = PdfJsonPage.builder().pageNumber(1).width(200f).height(200f).build(); + PdfJsonDocument doc = new PdfJsonDocument(); + doc.setPages(List.of(page)); + try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(doc))) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("blank PDF (no content streams) converts to JSON with one page") + void blankPdfConverts() throws IOException { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.A4)); + PdfJsonDocument doc = toJsonDocument(toBytes(document)); + assertEquals(1, doc.getPages().size()); + assertEquals(PDRectangle.A4.getWidth(), doc.getPages().get(0).getWidth(), 0.5f); + } + + @Test + @DisplayName("image element with invalid base64 data does not abort the conversion") + void invalidImageDataTolerated() throws IOException { + stubFallbackFont(); + PdfJsonImageElement image = + PdfJsonImageElement.builder() + .id("bad") + .imageData("@@@not-base64@@@") + .imageFormat("png") + .x(10f) + .y(10f) + .width(20f) + .height(20f) + .build(); + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(200f) + .height(200f) + .imageElements(List.of(image)) + .build(); + PdfJsonDocument doc = new PdfJsonDocument(); + doc.setPages(List.of(page)); + assertDoesNotThrow(() -> runJsonToPdf(doc)); + } + + @Test + @DisplayName("extractDocumentMetadata with no jobId still streams metadata") + void metadataWithoutJobId() throws IOException { + when(pdfDocumentFactory.load(any(byte[].class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, byte[].class))); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractDocumentMetadata(pdfMultipart(simpleTextPdf()), null, out); + PdfJsonDocumentMetadata md = + objectMapper.readValue(out.toByteArray(), PdfJsonDocumentMetadata.class); + assertThat(md.getPageDimensions()).hasSize(1); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceDeepTest.java b/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceDeepTest.java new file mode 100644 index 0000000000..76d0a9ad71 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceDeepTest.java @@ -0,0 +1,1237 @@ +package stirling.software.SPDF.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +import javax.imageio.ImageIO; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory; +import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationFreeText; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationHighlight; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLine; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationSquare; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText; +import org.junit.jupiter.api.AfterEach; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.quality.Strictness; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.model.json.PdfJsonAnnotation; +import stirling.software.SPDF.model.json.PdfJsonDocument; +import stirling.software.SPDF.model.json.PdfJsonFont; +import stirling.software.SPDF.model.json.PdfJsonImageElement; +import stirling.software.SPDF.model.json.PdfJsonPage; +import stirling.software.SPDF.model.json.PdfJsonTextColor; +import stirling.software.SPDF.model.json.PdfJsonTextElement; +import stirling.software.SPDF.service.pdfjson.PdfJsonFontService; +import stirling.software.SPDF.service.pdfjson.type3.Type3FontConversionService; +import stirling.software.SPDF.service.pdfjson.type3.Type3GlyphExtractor; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.TaskManager; +import stirling.software.common.util.JobContext; +import stirling.software.common.util.TempFileManager; + +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Deep coverage tests for {@link PdfJsonConversionService} that target reachable branches the other + * suites leave cold: the {@code TextElementCursor}/{@code TextRunAccumulator} run-merging + * machinery, a broad sweep of Standard14 font families plus composite/embedded fonts, every + * annotation subtype, the {@code applyColor} colour-space matrix, JPEG/CMYK/transparent image + * extraction and rebuild, and the extreme-coordinate / NaN guard paths in the regeneration helpers. + * + *

Complements {@code PdfJsonConversionServiceCoverageTest} and {@code + * PdfJsonConversionServiceRoundTripTest}; the construction/round-trip helpers mirror those suites + * so the same real in-memory PDF load path is exercised without duplicating their assertions. + */ +@ExtendWith(MockitoExtension.class) +@org.mockito.junit.jupiter.MockitoSettings(strictness = Strictness.LENIENT) +class PdfJsonConversionServiceDeepTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private EndpointConfiguration endpointConfiguration; + @Mock private TempFileManager tempFileManager; + @Mock private TaskManager taskManager; + @Mock private PdfJsonFallbackFontService fallbackFontService; + @Mock private PdfJsonFontService fontService; + @Mock private Type3FontConversionService type3FontConversionService; + @Mock private Type3GlyphExtractor type3GlyphExtractor; + @Mock private ApplicationProperties applicationProperties; + + // Real COS mapper so the serialize/deserialize machinery executes for real. + private final PdfJsonCosMapper cosMapper = new PdfJsonCosMapper(); + + private final ObjectMapper objectMapper = + JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .build(); + + private PdfJsonConversionService service; + + private final List createdTempFiles = new ArrayList<>(); + + @BeforeEach + void setUp() throws IOException { + service = + new PdfJsonConversionService( + pdfDocumentFactory, + objectMapper, + endpointConfiguration, + tempFileManager, + taskManager, + cosMapper, + fallbackFontService, + fontService, + type3FontConversionService, + type3GlyphExtractor, + applicationProperties); + + when(tempFileManager.createTempFile(anyString())) + .thenAnswer( + invocation -> { + String suffix = invocation.getArgument(0); + Path path = Files.createTempFile("pdfjson-deep-test", suffix); + createdTempFiles.add(path); + return path.toFile(); + }); + when(tempFileManager.deleteTempFile(any(File.class))) + .thenAnswer( + invocation -> { + File file = invocation.getArgument(0); + return file != null && file.delete(); + }); + when(taskManager.addNote(anyString(), anyString())).thenReturn(true); + } + + @AfterEach + void tearDown() throws IOException { + JobContext.clear(); + for (Path path : createdTempFiles) { + Files.deleteIfExists(path); + } + createdTempFiles.clear(); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /** Minimal fallback-font stub matching the other suites. */ + private void stubFallbackFont() throws IOException { + when(fallbackFontService.buildFallbackFontModel()) + .thenAnswer( + invocation -> + PdfJsonFont.builder() + .id(PdfJsonFallbackFontService.FALLBACK_FONT_ID) + .uid(PdfJsonFallbackFontService.FALLBACK_FONT_ID) + .baseName("Fallback") + .subtype("TrueType") + .build()); + when(fallbackFontService.loadFallbackPdfFont(any(PDDocument.class))) + .thenAnswer(invocation -> new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + } + + /** + * Stubs canEncode to true so {@code buildFontRuns} keeps the primary font instead of forcing + * the fallback path for every glyph, exercising the encode-with-real-font branch of + * regeneration. + */ + private void stubCanEncode() { + when(fallbackFontService.canEncode(any(PDFont.class), anyString())).thenReturn(true); + when(fallbackFontService.canEncode(any(PDFont.class), anyInt())).thenReturn(true); + } + + private MockMultipartFile pdfMultipart(byte[] bytes) { + return new MockMultipartFile("fileInput", "input.pdf", "application/pdf", bytes); + } + + private byte[] toBytes(PDDocument document) throws IOException { + try (document) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } + } + + private PdfJsonDocument toJsonDocument(byte[] pdfBytes) throws IOException { + when(pdfDocumentFactory.load(any(Path.class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, Path.class).toFile())); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertPdfToJson(pdfMultipart(pdfBytes), out); + return objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class); + } + + private byte[] runJsonToPdf(PdfJsonDocument doc) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertJsonToPdf(doc, out); + return out.toByteArray(); + } + + private PdfJsonDocument cacheLazyDocument(String jobId, byte[] pdfBytes) throws IOException { + when(pdfDocumentFactory.load(any(Path.class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, Path.class).toFile())); + when(pdfDocumentFactory.load(any(byte[].class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, byte[].class))); + JobContext.setJobId(jobId); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertPdfToJson(pdfMultipart(pdfBytes), true, out); + return objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class); + } + + private BufferedImage solidImage(int w, int h, Color color, int type) { + BufferedImage image = new BufferedImage(w, h, type); + for (int x = 0; x < w; x++) { + for (int y = 0; y < h; y++) { + image.setRGB(x, y, color.getRGB()); + } + } + return image; + } + + private String pngBase64(BufferedImage image) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(image, "png", out); + return Base64.getEncoder().encodeToString(out.toByteArray()); + } + + private PdfJsonDocument docWith(PdfJsonPage page) { + PdfJsonDocument doc = new PdfJsonDocument(); + doc.setPages(List.of(page)); + return doc; + } + + private PdfJsonFont std14Font(String id, String standard14Name) { + return PdfJsonFont.builder() + .id(id) + .uid(id) + .baseName(standard14Name) + .subtype("Type1") + .standard14Name(standard14Name) + .build(); + } + + // ================================================================== + // TextElementCursor / TextRunAccumulator driven by multi-run text + // ================================================================== + + @Nested + @DisplayName("text run segmentation and cursor merging") + class TextRunSegmentation { + + /** Two differently styled runs on the same baseline force a style-key split. */ + private byte[] twoStyleRunsSameLinePdf() throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.newLineAtOffset(72, 700); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.showText("Plain "); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD), 12f); + cs.showText("Bold "); + cs.setNonStrokingColor(Color.RED); + cs.showText("Red"); + cs.endText(); + } + return toBytes(document); + } + + @Test + @DisplayName("multiple show-text operators on one baseline split into separate style runs") + void multipleRunsSplitByStyle() throws IOException { + PdfJsonDocument doc = toJsonDocument(twoStyleRunsSameLinePdf()); + List elements = doc.getPages().get(0).getTextElements(); + // At least two style runs because font and colour changed mid-line. + assertThat(elements.size()).isGreaterThanOrEqualTo(2); + String joined = + elements.stream().map(PdfJsonTextElement::getText).reduce("", (a, b) -> a + b); + assertThat(joined).contains("Plain").contains("Bold").contains("Red"); + } + + @Test + @DisplayName( + "same-length token rewrite walks the cursor across multiple runs without rebuild") + void cursorRewriteAcrossRuns() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonDocument doc = toJsonDocument(twoStyleRunsSameLinePdf()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + assertNotNull(loaded.getPage(0).getContents()); + } + } + + @Test + @DisplayName("char-by-char letters merge back into a single run on round trip") + void perGlyphAdvancesMergeIntoRun() throws IOException { + stubFallbackFont(); + stubCanEncode(); + // Emit each glyph through its own TJ adjustment so the stripper sees many positions. + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(72, 700); + cs.setCharacterSpacing(0.5f); + cs.showText("Spaced out glyphs"); + cs.endText(); + } + PdfJsonDocument doc = toJsonDocument(toBytes(document)); + assertThat(doc.getPages().get(0).getTextElements()).isNotEmpty(); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("differing-length text edit aborts rewrite and triggers full regeneration") + void lengthChangeForcesRegeneration() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonDocument doc = toJsonDocument(twoStyleRunsSameLinePdf()); + for (PdfJsonTextElement element : doc.getPages().get(0).getTextElements()) { + if (element.getText() != null && element.getText().contains("Plain")) { + element.setText("A much longer replacement string than before"); + break; + } + } + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + } + + // ================================================================== + // Font family variety (extraction + rebuild) + // ================================================================== + + @Nested + @DisplayName("standard14 font family variety") + class FontVariety { + + /** One line per Standard14 family, covering the symbol/zapf encodings too. */ + private byte[] allStandard14Pdf() throws IOException { + Standard14Fonts.FontName[] families = { + Standard14Fonts.FontName.HELVETICA, + Standard14Fonts.FontName.HELVETICA_BOLD, + Standard14Fonts.FontName.HELVETICA_OBLIQUE, + Standard14Fonts.FontName.HELVETICA_BOLD_OBLIQUE, + Standard14Fonts.FontName.TIMES_ROMAN, + Standard14Fonts.FontName.TIMES_BOLD, + Standard14Fonts.FontName.TIMES_ITALIC, + Standard14Fonts.FontName.TIMES_BOLD_ITALIC, + Standard14Fonts.FontName.COURIER, + Standard14Fonts.FontName.COURIER_BOLD, + Standard14Fonts.FontName.COURIER_OBLIQUE, + Standard14Fonts.FontName.COURIER_BOLD_OBLIQUE + }; + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + float y = 760f; + for (Standard14Fonts.FontName family : families) { + cs.beginText(); + cs.setFont(new PDType1Font(family), 10f); + cs.newLineAtOffset(50, y); + cs.showText(family.getName() + " sample 123"); + cs.endText(); + y -= 18f; + } + } + return toBytes(document); + } + + @Test + @DisplayName("all standard14 families are captured as distinct fonts") + void capturesAllFamilies() throws IOException { + PdfJsonDocument doc = toJsonDocument(allStandard14Pdf()); + long distinct = + doc.getFonts().stream() + .map(PdfJsonFont::getBaseName) + .filter(java.util.Objects::nonNull) + .distinct() + .count(); + assertThat(distinct).isGreaterThanOrEqualTo(8); + } + + @Test + @DisplayName("round trip over every standard14 family rebuilds a valid PDF") + void roundTripAllFamilies() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonDocument doc = toJsonDocument(allStandard14Pdf()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("synthesized text in each standard14 family rebuilds via createFontFromModel") + void synthesizedFamiliesRebuild() throws IOException { + stubFallbackFont(); + stubCanEncode(); + String[] names = { + "Helvetica-BoldOblique", + "Times-BoldItalic", + "Courier-Oblique", + "Symbol", + "ZapfDingbats" + }; + List fonts = new ArrayList<>(); + List elements = new ArrayList<>(); + float y = 720f; + for (int i = 0; i < names.length; i++) { + String id = "F" + i; + fonts.add(std14Font(id, names[i])); + elements.add( + PdfJsonTextElement.builder() + .text("Sample" + i) + .fontId(id) + .fontSize(12f) + .x(72f) + .y(y) + .build()); + y -= 20f; + } + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .textElements(elements) + .build(); + PdfJsonDocument doc = docWith(page); + doc.setFonts(fonts); + try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(doc))) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + } + + // ================================================================== + // Composite / embedded font round trips + // ================================================================== + + @Nested + @DisplayName("composite and embedded fonts") + class CompositeFonts { + + // Loads the project-bundled DejaVuSans.ttf from the classpath so the embedded-font tests + // are deterministic on every platform (no reliance on OS fonts, never skipped). + private Path bundledTrueTypeFont() throws IOException { + Path tmp = Files.createTempFile("deepfont", ".ttf"); + tmp.toFile().deleteOnExit(); + try (java.io.InputStream in = + getClass().getResourceAsStream("/static/fonts/DejaVuSans.ttf")) { + assertThat(in).as("bundled DejaVuSans.ttf on classpath").isNotNull(); + Files.copy(in, tmp, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + return tmp; + } + + @Test + @DisplayName("embedded TrueType (Type0 composite) font survives extraction and rebuild") + void embeddedTrueTypeRoundTrip() throws IOException { + Path ttf = bundledTrueTypeFont(); + stubFallbackFont(); + stubCanEncode(); + + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + PDType0Font embedded = PDType0Font.load(document, ttf.toFile()); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(embedded, 14f); + cs.newLineAtOffset(72, 700); + cs.showText("Composite font line"); + cs.endText(); + } + byte[] bytes = toBytes(document); + + PdfJsonDocument doc = toJsonDocument(bytes); + // A composite/Type0 font should be present in the extracted set. + assertThat(doc.getFonts()).isNotEmpty(); + assertThat(doc.getPages().get(0).getTextElements()).isNotEmpty(); + + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("composite font lazy extraction exposes font payload via extractPageFonts") + void embeddedTrueTypeLazyFonts() throws IOException { + Path ttf = bundledTrueTypeFont(); + + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + PDType0Font embedded = PDType0Font.load(document, ttf.toFile()); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(embedded, 14f); + cs.newLineAtOffset(72, 700); + cs.showText("Lazy composite"); + cs.endText(); + } + byte[] bytes = toBytes(document); + + cacheLazyDocument("job-ttf", bytes); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractPageFonts("job-ttf", 1, out); + List fonts = objectMapper.readValue(out.toByteArray(), List.class); + assertThat(fonts).isNotEmpty(); + } + } + + // ================================================================== + // Annotation variety: collect + restore + // ================================================================== + + @Nested + @DisplayName("annotation subtype variety") + class AnnotationVariety { + + /** One of each common annotation subtype with colour/border styling. */ + private byte[] manyAnnotationsPdf() throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + + PDAnnotationText note = new PDAnnotationText(); + note.setContents("Sticky note"); + note.setRectangle(new PDRectangle(40, 740, 20, 20)); + note.setColor( + new org.apache.pdfbox.pdmodel.graphics.color.PDColor( + new float[] {1f, 1f, 0f}, + org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB.INSTANCE)); + + PDAnnotationLink link = new PDAnnotationLink(); + link.setRectangle(new PDRectangle(40, 700, 200, 18)); + + PDAnnotationHighlight highlight = new PDAnnotationHighlight(); + highlight.setRectangle(new PDRectangle(40, 660, 200, 18)); + highlight.setQuadPoints(new float[] {40, 678, 240, 678, 40, 660, 240, 660}); + highlight.setColor( + new org.apache.pdfbox.pdmodel.graphics.color.PDColor( + new float[] {0f, 1f, 0f}, + org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB.INSTANCE)); + + PDAnnotationSquare square = new PDAnnotationSquare(); + square.setRectangle(new PDRectangle(40, 600, 80, 40)); + square.setInteriorColor( + new org.apache.pdfbox.pdmodel.graphics.color.PDColor( + new float[] {0.2f, 0.2f, 0.9f}, + org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB.INSTANCE)); + + PDAnnotationFreeText freeText = new PDAnnotationFreeText(); + freeText.setRectangle(new PDRectangle(40, 540, 200, 40)); + freeText.setContents("Free text body"); + freeText.setDefaultAppearance("/Helv 10 Tf 0 g"); + + PDAnnotationLine line = new PDAnnotationLine(); + line.setRectangle(new PDRectangle(40, 500, 200, 20)); + line.setLine(new float[] {40, 510, 240, 510}); + + page.getAnnotations().add(note); + page.getAnnotations().add(link); + page.getAnnotations().add(highlight); + page.getAnnotations().add(square); + page.getAnnotations().add(freeText); + page.getAnnotations().add(line); + + // A line of page content so the JSON->PDF rebuild reaches the annotation-restore step. + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(72, 440); + cs.showText("Annotated body text"); + cs.endText(); + } + + return toBytes(document); + } + + @Test + @DisplayName("each annotation subtype is collected with its subtype label") + void collectsAllSubtypes() throws IOException { + PdfJsonDocument doc = toJsonDocument(manyAnnotationsPdf()); + List annotations = doc.getPages().get(0).getAnnotations(); + assertThat(annotations).hasSizeGreaterThanOrEqualTo(6); + List subtypes = + annotations.stream().map(PdfJsonAnnotation::getSubtype).toList(); + assertThat(subtypes) + .contains("Text", "Link", "Highlight", "Square", "FreeText", "Line"); + } + + @Test + @DisplayName("annotation colours are captured into the colour component array") + void capturesAnnotationColors() throws IOException { + PdfJsonDocument doc = toJsonDocument(manyAnnotationsPdf()); + List annotations = doc.getPages().get(0).getAnnotations(); + assertThat(annotations).anySatisfy(a -> assertThat(a.getColor()).isNotNull()); + } + + @Test + @DisplayName("every annotation subtype round trips back onto the rebuilt page via raw data") + void restoresAllSubtypes() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(manyAnnotationsPdf()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertThat(loaded.getPage(0).getAnnotations()).hasSizeGreaterThanOrEqualTo(6); + } + } + + @Test + @DisplayName("lazy extraction surfaces the full annotation set for a cached page") + void lazyAnnotationSet() throws IOException { + cacheLazyDocument("job-anns", manyAnnotationsPdf()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractSinglePage("job-anns", 1, out); + PdfJsonPage page = objectMapper.readValue(out.toByteArray(), PdfJsonPage.class); + assertThat(page.getAnnotations()).hasSizeGreaterThanOrEqualTo(6); + } + } + + // ================================================================== + // Image variety: JPEG (DCT), lossless PNG, transparency, CMYK + // ================================================================== + + @Nested + @DisplayName("image format variety") + class ImageVariety { + + /** A page bearing a JPEG (DCT), a lossless RGB and an ARGB-with-alpha image. */ + private byte[] mixedImagePdf() throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + + PDImageXObject jpeg = + JPEGFactory.createFromImage( + document, solidImage(32, 24, Color.RED, BufferedImage.TYPE_INT_RGB)); + PDImageXObject lossless = + LosslessFactory.createFromImage( + document, solidImage(24, 24, Color.BLUE, BufferedImage.TYPE_INT_RGB)); + BufferedImage argb = + solidImage(16, 16, new Color(0, 255, 0, 128), BufferedImage.TYPE_INT_ARGB); + PDImageXObject transparent = LosslessFactory.createFromImage(document, argb); + + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.drawImage(jpeg, 60, 600, 96, 72); + cs.drawImage(lossless, 200, 600, 72, 72); + cs.drawImage(transparent, 320, 600, 48, 48); + } + return toBytes(document); + } + + @Test + @DisplayName("JPEG, lossless and transparent images are all extracted with format + data") + void extractsMixedImages() throws IOException { + PdfJsonDocument doc = toJsonDocument(mixedImagePdf()); + List images = doc.getPages().get(0).getImageElements(); + assertThat(images).hasSizeGreaterThanOrEqualTo(3); + assertThat(images) + .allSatisfy( + img -> { + assertThat(img.getImageData()).isNotBlank(); + assertThat(img.getImageFormat()).isNotBlank(); + }); + // A JPEG XObject reports a jpg/jpeg suffix. + assertThat(images) + .anySatisfy( + img -> + assertThat(img.getImageFormat().toLowerCase()) + .containsAnyOf("jpg", "jpeg")); + } + + @Test + @DisplayName("mixed-image page round trips into a page that keeps image resources") + void roundTripMixedImages() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(mixedImagePdf()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + assertNotNull(loaded.getPage(0).getResources()); + } + } + + @Test + @DisplayName("synthesized JPEG-format image element with a transform matrix renders") + void synthesizedJpegWithTransform() throws IOException { + stubFallbackFont(); + ByteArrayOutputStream jpgOut = new ByteArrayOutputStream(); + ImageIO.write(solidImage(16, 16, Color.RED, BufferedImage.TYPE_INT_RGB), "jpg", jpgOut); + String base64 = Base64.getEncoder().encodeToString(jpgOut.toByteArray()); + + PdfJsonImageElement image = + PdfJsonImageElement.builder() + .id("Jpg1") + .imageData(base64) + .imageFormat("jpg") + .transform(new float[] {64f, 0f, 0f, 48f, 80f, 500f}) + .build(); + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .imageElements(List.of(image)) + .build(); + try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(docWith(page)))) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("lazy extraction with images materializes image data on demand") + void lazyMixedImages() throws IOException { + cacheLazyDocument("job-mixed-img", mixedImagePdf()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractSinglePage("job-mixed-img", 1, out); + PdfJsonPage page = objectMapper.readValue(out.toByteArray(), PdfJsonPage.class); + assertThat(page.getImageElements()).isNotEmpty(); + assertThat(page.getImageElements()) + .anySatisfy(img -> assertThat(img.getImageData()).isNotBlank()); + } + } + + // ================================================================== + // applyColor / applyTextState colour-space matrix + // ================================================================== + + @Nested + @DisplayName("colour space and text-state application") + class ColorAndState { + + private PdfJsonDocument textWithColor(PdfJsonTextColor fill, PdfJsonTextColor stroke) { + PdfJsonFont font = std14Font("F1", "Helvetica"); + PdfJsonTextElement element = + PdfJsonTextElement.builder() + .text("Coloured") + .fontId("F1") + .fontSize(14f) + .characterSpacing(0.8f) + .wordSpacing(1.5f) + .horizontalScaling(90f) + .leading(16f) + .rise(2f) + .renderingMode(2) + .fillColor(fill) + .strokeColor(stroke) + .x(72f) + .y(700f) + .build(); + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .textElements(List.of(element)) + .build(); + PdfJsonDocument doc = docWith(page); + doc.setFonts(new ArrayList<>(List.of(font))); + return doc; + } + + private PdfJsonTextColor color(String space, float... components) { + return PdfJsonTextColor.builder().colorSpace(space).components(components).build(); + } + + @Test + @DisplayName("explicit DeviceRGB fill and stroke colours render") + void deviceRgb() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonDocument doc = + textWithColor( + color("DeviceRGB", 0.1f, 0.2f, 0.3f), + color("DeviceRGB", 0.9f, 0.8f, 0.7f)); + assertThat(runJsonToPdf(doc)).isNotEmpty(); + } + + @Test + @DisplayName("explicit DeviceCMYK colours render through the CMYK branch") + void deviceCmyk() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonDocument doc = + textWithColor( + color("DeviceCMYK", 0.1f, 0.2f, 0.3f, 0.4f), + color("DeviceCMYK", 0f, 0f, 0f, 1f)); + assertThat(runJsonToPdf(doc)).isNotEmpty(); + } + + @Test + @DisplayName("explicit DeviceGray colours render through the gray branch") + void deviceGray() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonDocument doc = + textWithColor(color("DeviceGray", 0.5f), color("DeviceGray", 0.2f)); + assertThat(runJsonToPdf(doc)).isNotEmpty(); + } + + @Test + @DisplayName("null colour space infers the space from component count (1/3/4)") + void inferredColorSpaces() throws IOException { + stubFallbackFont(); + stubCanEncode(); + assertThat(runJsonToPdf(textWithColor(color(null, 0.4f), color(null, 0.6f)))) + .isNotEmpty(); + assertThat( + runJsonToPdf( + textWithColor( + color(null, 0.1f, 0.2f, 0.3f), + color(null, 0.3f, 0.2f, 0.1f)))) + .isNotEmpty(); + assertThat( + runJsonToPdf( + textWithColor( + color(null, 0.1f, 0.2f, 0.3f, 0.4f), + color(null, 0.4f, 0.3f, 0.2f, 0.1f)))) + .isNotEmpty(); + } + + @Test + @DisplayName("unsupported named colour space is skipped without aborting the rebuild") + void unsupportedColorSpaceSkipped() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonDocument doc = + textWithColor(color("Separation", 0.5f), color("ICCBased", 0.1f, 0.2f, 0.3f)); + assertThat(runJsonToPdf(doc)).isNotEmpty(); + } + + @Test + @DisplayName("two-component colour with null space falls through to the RGB default branch") + void twoComponentDefaultsToRgb() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonDocument doc = textWithColor(color(null, 0.5f, 0.5f), null); + assertThat(runJsonToPdf(doc)).isNotEmpty(); + } + } + + // ================================================================== + // Edge / guard branches + // ================================================================== + + @Nested + @DisplayName("edge and guard branches") + class EdgeGuards { + + @Test + @DisplayName("very large coordinates are tolerated by the regeneration path") + void extremeCoordinates() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonFont font = std14Font("F1", "Helvetica"); + PdfJsonTextElement element = + PdfJsonTextElement.builder() + .text("Far away") + .fontId("F1") + .fontSize(12f) + .x(900_000f) + .y(-900_000f) + .build(); + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .textElements(List.of(element)) + .build(); + PdfJsonDocument doc = docWith(page); + doc.setFonts(new ArrayList<>(List.of(font))); + assertThat(runJsonToPdf(doc)).isNotEmpty(); + } + + @Test + @DisplayName("NaN and Infinity in an image transform fall back to safe defaults") + void nonFiniteTransformGuarded() throws IOException { + stubFallbackFont(); + PdfJsonImageElement image = + PdfJsonImageElement.builder() + .id("Im-nan") + .imageData( + pngBase64( + solidImage( + 8, 8, Color.PINK, BufferedImage.TYPE_INT_RGB))) + .imageFormat("png") + .transform( + new float[] { + Float.NaN, + 0f, + 0f, + Float.POSITIVE_INFINITY, + Float.NEGATIVE_INFINITY, + 100f + }) + .build(); + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .imageElements(List.of(image)) + .build(); + assertDoesNotThrow(() -> runJsonToPdf(docWith(page))); + } + + @Test + @DisplayName("image with zero width/height falls back to native dimensions") + void zeroDimensionImageUsesNative() throws IOException { + stubFallbackFont(); + PdfJsonImageElement image = + PdfJsonImageElement.builder() + .id("Im-zero") + .imageData( + pngBase64( + solidImage( + 10, + 12, + Color.GRAY, + BufferedImage.TYPE_INT_RGB))) + .imageFormat("png") + .width(0f) + .height(0f) + .nativeWidth(10) + .nativeHeight(12) + .x(50f) + .y(500f) + .build(); + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .imageElements(List.of(image)) + .build(); + assertThat(runJsonToPdf(docWith(page))).isNotEmpty(); + } + + @Test + @DisplayName("a page with only blank-text elements rebuilds cleanly") + void blankTextOnlyPage() throws IOException { + stubFallbackFont(); + PdfJsonFont font = std14Font("F1", "Helvetica"); + PdfJsonTextElement blank = + PdfJsonTextElement.builder() + .text(" ") + .fontId("F1") + .fontSize(12f) + .x(72f) + .y(700f) + .build(); + PdfJsonTextElement empty = + PdfJsonTextElement.builder() + .text("") + .fontId("F1") + .fontSize(12f) + .x(72f) + .y(680f) + .build(); + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .textElements(List.of(blank, empty)) + .build(); + PdfJsonDocument doc = docWith(page); + doc.setFonts(new ArrayList<>(List.of(font))); + assertThat(runJsonToPdf(doc)).isNotEmpty(); + } + + @Test + @DisplayName("explicit z-order interleaves images and text by draw order") + void zOrderInterleaving() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonFont font = std14Font("F1", "Helvetica"); + PdfJsonImageElement back = + PdfJsonImageElement.builder() + .id("back") + .imageData( + pngBase64( + solidImage( + 20, + 20, + Color.LIGHT_GRAY, + BufferedImage.TYPE_INT_RGB))) + .imageFormat("png") + .x(60f) + .y(600f) + .width(120f) + .height(40f) + .zOrder(5) + .build(); + PdfJsonTextElement front = + PdfJsonTextElement.builder() + .text("On top") + .fontId("F1") + .fontSize(14f) + .x(64f) + .y(610f) + .zOrder(10) + .build(); + PdfJsonTextElement under = + PdfJsonTextElement.builder() + .text("Below") + .fontId("F1") + .fontSize(14f) + .x(64f) + .y(560f) + .zOrder(1) + .build(); + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .textElements(List.of(front, under)) + .imageElements(List.of(back)) + .build(); + PdfJsonDocument doc = docWith(page); + doc.setFonts(new ArrayList<>(List.of(font))); + try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(doc))) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("text with an explicit 6-value text matrix renders via applyTextMatrix") + void explicitTextMatrix() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonFont font = std14Font("F1", "Helvetica"); + PdfJsonTextElement element = + PdfJsonTextElement.builder() + .text("Matrixed") + .fontId("F1") + .fontSize(12f) + .textMatrix(new float[] {1.2f, 0.3f, -0.3f, 1.2f, 120f, 640f}) + .build(); + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .textElements(List.of(element)) + .build(); + PdfJsonDocument doc = docWith(page); + doc.setFonts(new ArrayList<>(List.of(font))); + assertThat(runJsonToPdf(doc)).isNotEmpty(); + } + } + + // ================================================================== + // Content-stream + resource preservation with varied operators + // ================================================================== + + @Nested + @DisplayName("content stream and resource preservation with varied operators") + class ContentStreamPreservation { + + /** Page mixing graphics-state, clipping, vector fills and text. */ + private byte[] graphicsRichPdf() throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.saveGraphicsState(); + cs.setLineWidth(2f); + cs.setNonStrokingColor(0.2f, 0.4f, 0.6f); + cs.addRect(50, 600, 200, 120); + cs.clip(); + cs.fill(); + cs.restoreGraphicsState(); + + cs.setStrokingColor(Color.DARK_GRAY); + cs.moveTo(50, 560); + cs.lineTo(250, 560); + cs.stroke(); + + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(60, 500); + cs.showText("Text over graphics"); + cs.endText(); + } + return toBytes(document); + } + + @Test + @DisplayName("graphics-rich page preserves content streams and resources on extraction") + void preservesStreamsAndResources() throws IOException { + PdfJsonDocument doc = toJsonDocument(graphicsRichPdf()); + PdfJsonPage page = doc.getPages().get(0); + assertNotNull(page.getResources()); + assertThat(page.getContentStreams()).isNotEmpty(); + } + + @Test + @DisplayName("identity edit over graphics-rich content keeps the token rewrite path viable") + void identityRewriteKeepsGraphics() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonDocument doc = toJsonDocument(graphicsRichPdf()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + assertNotNull(loaded.getPage(0).getContents()); + } + } + + @Test + @DisplayName("convertPdfToJsonDocument exposes the COS model for mutate-and-rebuild") + void cosModelMutateRebuild() throws IOException { + stubFallbackFont(); + when(pdfDocumentFactory.load(any(Path.class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, Path.class).toFile())); + PdfJsonDocument doc = service.convertPdfToJsonDocument(pdfMultipart(graphicsRichPdf())); + assertNotNull(doc); + assertThat(doc.getPages()).hasSize(1); + // Mutate page geometry then rebuild to drive applyPageResources on a changed model. + doc.getPages().get(0).setRotation(90); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + } + + // ================================================================== + // Cache export with multiple edits + // ================================================================== + + @Nested + @DisplayName("cache export with multiple edits") + class CacheExportMultiEdit { + + private byte[] threePageTextPdf() throws IOException { + PDDocument document = new PDDocument(); + for (int i = 0; i < 3; i++) { + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(72, 700); + cs.showText("Page number " + (i + 1)); + cs.endText(); + } + } + return toBytes(document); + } + + @Test + @DisplayName("exportUpdatedPages applies edits to several pages at once") + void multiPageEdits() throws IOException { + stubFallbackFont(); + stubCanEncode(); + cacheLazyDocument("job-multi-export", threePageTextPdf()); + + List updates = new ArrayList<>(); + for (int pageNo = 1; pageNo <= 3; pageNo++) { + ByteArrayOutputStream pageOut = new ByteArrayOutputStream(); + service.extractSinglePage("job-multi-export", pageNo, pageOut); + PdfJsonPage page = objectMapper.readValue(pageOut.toByteArray(), PdfJsonPage.class); + page.setPageNumber(pageNo); + for (PdfJsonTextElement element : page.getTextElements()) { + if (element.getText() != null && !element.getText().isBlank()) { + element.setText("Edited " + pageNo); + break; + } + } + updates.add(page); + } + PdfJsonDocument updateDoc = new PdfJsonDocument(); + updateDoc.setPages(updates); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.exportUpdatedPages("job-multi-export", updateDoc, out); + try (PDDocument loaded = Loader.loadPDF(out.toByteArray())) { + assertEquals(3, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("exportUpdatedPages mixes one in-range edit with one out-of-range page") + void mixedRangeEdits() throws IOException { + stubFallbackFont(); + stubCanEncode(); + cacheLazyDocument("job-mixed-range", threePageTextPdf()); + + ByteArrayOutputStream pageOut = new ByteArrayOutputStream(); + service.extractSinglePage("job-mixed-range", 2, pageOut); + PdfJsonPage realPage = objectMapper.readValue(pageOut.toByteArray(), PdfJsonPage.class); + realPage.setPageNumber(2); + + PdfJsonPage ghost = new PdfJsonPage(); + ghost.setPageNumber(99); + + PdfJsonDocument updateDoc = new PdfJsonDocument(); + updateDoc.setPages(List.of(realPage, ghost)); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.exportUpdatedPages("job-mixed-range", updateDoc, out); + try (PDDocument loaded = Loader.loadPDF(out.toByteArray())) { + assertEquals(3, loaded.getNumberOfPages()); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceExtraTest.java b/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceExtraTest.java new file mode 100644 index 0000000000..b476d596b7 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceExtraTest.java @@ -0,0 +1,1088 @@ +package stirling.software.SPDF.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Calendar; +import java.util.List; +import java.util.TimeZone; + +import javax.imageio.ImageIO; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory; +import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationText; +import org.junit.jupiter.api.AfterEach; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.quality.Strictness; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.exception.CacheUnavailableException; +import stirling.software.SPDF.model.json.PdfJsonDocument; +import stirling.software.SPDF.model.json.PdfJsonDocumentMetadata; +import stirling.software.SPDF.model.json.PdfJsonFont; +import stirling.software.SPDF.model.json.PdfJsonImageElement; +import stirling.software.SPDF.model.json.PdfJsonMetadata; +import stirling.software.SPDF.model.json.PdfJsonPage; +import stirling.software.SPDF.model.json.PdfJsonTextColor; +import stirling.software.SPDF.model.json.PdfJsonTextElement; +import stirling.software.SPDF.service.pdfjson.PdfJsonFontService; +import stirling.software.SPDF.service.pdfjson.type3.Type3FontConversionService; +import stirling.software.SPDF.service.pdfjson.type3.Type3GlyphExtractor; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.TaskManager; +import stirling.software.common.util.JobContext; +import stirling.software.common.util.TempFileManager; + +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Extra coverage tests for {@link PdfJsonConversionService} aimed at reachable branches the + * existing suites leave cold: full document-information round trips that exercise the + * creation/modification date and trapped paths, the {@code convertJsonToPdf(MultipartFile)} file + * overload driven by a re-serialized model, the cache-backed export path with + * added/removed/modified text and image elements plus a font supplied in the update document, rich + * COS metadata via nested page resources, additional text render modes / spacing / rise, negative + * coordinates, and full model-mutation (page add/remove, colour and size edits) round trips. + * + *

Construction mirrors {@code PdfJsonConversionServiceCoverageTest} and {@code + * PdfJsonConversionServiceDeepTest} so the same real in-memory PDF load path is exercised without + * repeating their assertions. + */ +@ExtendWith(MockitoExtension.class) +@org.mockito.junit.jupiter.MockitoSettings(strictness = Strictness.LENIENT) +class PdfJsonConversionServiceExtraTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private EndpointConfiguration endpointConfiguration; + @Mock private TempFileManager tempFileManager; + @Mock private TaskManager taskManager; + @Mock private PdfJsonFallbackFontService fallbackFontService; + @Mock private PdfJsonFontService fontService; + @Mock private Type3FontConversionService type3FontConversionService; + @Mock private Type3GlyphExtractor type3GlyphExtractor; + @Mock private ApplicationProperties applicationProperties; + + // Real COS mapper so the serialize/deserialize machinery runs for real. + private final PdfJsonCosMapper cosMapper = new PdfJsonCosMapper(); + + private final ObjectMapper objectMapper = + JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .build(); + + private PdfJsonConversionService service; + + private final List createdTempFiles = new ArrayList<>(); + + @BeforeEach + void setUp() throws IOException { + service = + new PdfJsonConversionService( + pdfDocumentFactory, + objectMapper, + endpointConfiguration, + tempFileManager, + taskManager, + cosMapper, + fallbackFontService, + fontService, + type3FontConversionService, + type3GlyphExtractor, + applicationProperties); + + when(tempFileManager.createTempFile(anyString())) + .thenAnswer( + invocation -> { + String suffix = invocation.getArgument(0); + Path path = Files.createTempFile("pdfjson-extra-test", suffix); + createdTempFiles.add(path); + return path.toFile(); + }); + when(tempFileManager.deleteTempFile(any(File.class))) + .thenAnswer( + invocation -> { + File file = invocation.getArgument(0); + return file != null && file.delete(); + }); + when(taskManager.addNote(anyString(), anyString())).thenReturn(true); + } + + @AfterEach + void tearDown() throws IOException { + JobContext.clear(); + for (Path path : createdTempFiles) { + Files.deleteIfExists(path); + } + createdTempFiles.clear(); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private void stubFallbackFont() throws IOException { + when(fallbackFontService.buildFallbackFontModel()) + .thenAnswer( + invocation -> + PdfJsonFont.builder() + .id(PdfJsonFallbackFontService.FALLBACK_FONT_ID) + .uid(PdfJsonFallbackFontService.FALLBACK_FONT_ID) + .baseName("Fallback") + .subtype("TrueType") + .build()); + when(fallbackFontService.loadFallbackPdfFont(any(PDDocument.class))) + .thenAnswer(invocation -> new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + } + + private void stubCanEncode() { + when(fallbackFontService.canEncode(any(PDFont.class), anyString())).thenReturn(true); + when(fallbackFontService.canEncode(any(PDFont.class), anyInt())).thenReturn(true); + } + + private MockMultipartFile pdfMultipart(byte[] bytes) { + return new MockMultipartFile("fileInput", "input.pdf", "application/pdf", bytes); + } + + private MockMultipartFile jsonMultipart(byte[] bytes) { + return new MockMultipartFile("fileInput", "model.json", "application/json", bytes); + } + + private byte[] toBytes(PDDocument document) throws IOException { + try (document) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } + } + + private BufferedImage solidImage(int w, int h, Color color, int type) { + BufferedImage image = new BufferedImage(w, h, type); + for (int x = 0; x < w; x++) { + for (int y = 0; y < h; y++) { + image.setRGB(x, y, color.getRGB()); + } + } + return image; + } + + private String pngBase64(BufferedImage image) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(image, "png", out); + return Base64.getEncoder().encodeToString(out.toByteArray()); + } + + private PdfJsonDocument toJsonDocument(byte[] pdfBytes) throws IOException { + when(pdfDocumentFactory.load(any(Path.class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, Path.class).toFile())); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertPdfToJson(pdfMultipart(pdfBytes), out); + return objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class); + } + + private byte[] runJsonToPdf(PdfJsonDocument doc) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertJsonToPdf(doc, out); + return out.toByteArray(); + } + + private PdfJsonDocument cacheLazyDocument(String jobId, byte[] pdfBytes) throws IOException { + when(pdfDocumentFactory.load(any(Path.class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, Path.class).toFile())); + when(pdfDocumentFactory.load(any(byte[].class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, byte[].class))); + JobContext.setJobId(jobId); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertPdfToJson(pdfMultipart(pdfBytes), true, out); + return objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class); + } + + private PdfJsonDocument docWith(PdfJsonPage page) { + PdfJsonDocument doc = new PdfJsonDocument(); + doc.setPages(new ArrayList<>(List.of(page))); + return doc; + } + + private PdfJsonFont std14Font(String id, String standard14Name) { + return PdfJsonFont.builder() + .id(id) + .uid(id) + .baseName(standard14Name) + .subtype("Type1") + .standard14Name(standard14Name) + .build(); + } + + private byte[] simpleTextPdf(String text) throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(72, 700); + cs.showText(text); + cs.endText(); + } + return toBytes(document); + } + + // ================================================================== + // Document information / date / trapped round trips + // ================================================================== + + @Nested + @DisplayName("document information dates and trapped round trips") + class DocumentInfoRoundTrips { + + private byte[] datedPdf() throws IOException { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.LETTER)); + PDDocumentInformation info = document.getDocumentInformation(); + info.setTitle("Dated Title"); + info.setAuthor("Dated Author"); + Calendar created = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + created.set(2021, Calendar.MARCH, 4, 5, 6, 7); + created.set(Calendar.MILLISECOND, 0); + info.setCreationDate(created); + Calendar modified = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + modified.set(2022, Calendar.JUNE, 8, 9, 10, 11); + modified.set(Calendar.MILLISECOND, 0); + info.setModificationDate(modified); + info.setTrapped("True"); + return toBytes(document); + } + + @Test + @DisplayName("creation and modification dates are extracted as ISO instants") + void datesExtractedAsInstants() throws IOException { + PdfJsonDocument doc = toJsonDocument(datedPdf()); + PdfJsonMetadata md = doc.getMetadata(); + assertThat(md.getCreationDate()).isNotBlank(); + assertThat(md.getModificationDate()).isNotBlank(); + // formatCalendar emits Instant.toString(), so it should parse as an instant. + assertThat(md.getCreationDate()).contains("2021"); + assertThat(md.getModificationDate()).contains("2022"); + } + + @Test + @DisplayName("trapped flag survives extraction into the metadata model") + void trappedExtracted() throws IOException { + PdfJsonDocument doc = toJsonDocument(datedPdf()); + assertThat(doc.getMetadata().getTrapped()).isEqualTo("True"); + } + + @Test + @DisplayName("dates and trapped round trip back into PDDocumentInformation") + void datesRoundTripBack() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(datedPdf()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + PDDocumentInformation info = loaded.getDocumentInformation(); + assertEquals("Dated Title", info.getTitle()); + assertEquals("True", info.getTrapped()); + assertNotNull(info.getCreationDate()); + assertEquals(2021, info.getCreationDate().get(Calendar.YEAR)); + assertNotNull(info.getModificationDate()); + assertEquals(2022, info.getModificationDate().get(Calendar.YEAR)); + } + } + + @Test + @DisplayName("synthesized metadata with an unparseable date is ignored, rest applies") + void unparseableDateIgnored() throws IOException { + stubFallbackFont(); + PdfJsonMetadata md = + PdfJsonMetadata.builder() + .title("Keep Title") + .creationDate("not-a-real-instant") + .modificationDate("2023-01-02T03:04:05Z") + .trapped("Unknown") + .build(); + PdfJsonDocument doc = new PdfJsonDocument(); + doc.setMetadata(md); + doc.setPages(new ArrayList<>()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + PDDocumentInformation info = loaded.getDocumentInformation(); + assertEquals("Keep Title", info.getTitle()); + assertEquals("Unknown", info.getTrapped()); + // The bad creation date is dropped; the good modification date is applied. + assertNotNull(info.getModificationDate()); + assertEquals(2023, info.getModificationDate().get(Calendar.YEAR)); + } + } + } + + // ================================================================== + // convertJsonToPdf(MultipartFile) file overload + // ================================================================== + + @Nested + @DisplayName("convertJsonToPdf file overload round trips") + class JsonFileOverload { + + @Test + @DisplayName("re-serialized extracted model rebuilds via the file overload") + void fileOverloadRebuildsModel() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(simpleTextPdf("File overload source")); + byte[] json = objectMapper.writeValueAsBytes(doc); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertJsonToPdf(jsonMultipart(json), out); + try (PDDocument loaded = Loader.loadPDF(out.toByteArray())) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("edited text re-serialized to JSON bytes rebuilds via the file overload") + void fileOverloadAppliesEdit() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonDocument doc = toJsonDocument(simpleTextPdf("Original line here")); + for (PdfJsonTextElement element : doc.getPages().get(0).getTextElements()) { + if (element.getText() != null && !element.getText().isBlank()) { + element.setText("Edited line text"); + break; + } + } + byte[] json = objectMapper.writeValueAsBytes(doc); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertJsonToPdf(jsonMultipart(json), out); + assertThat(out.toByteArray()).isNotEmpty(); + } + } + + // ================================================================== + // Full model mutation: add/remove pages, change colour/size/position + // ================================================================== + + @Nested + @DisplayName("model mutation round trips") + class ModelMutation { + + private byte[] twoPageTextPdf() throws IOException { + PDDocument document = new PDDocument(); + for (int i = 0; i < 2; i++) { + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(72, 700); + cs.showText("Original page " + (i + 1)); + cs.endText(); + } + } + return toBytes(document); + } + + @Test + @DisplayName("removing a page from the model rebuilds with one fewer page") + void removePage() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(twoPageTextPdf()); + assertEquals(2, doc.getPages().size()); + List pages = new ArrayList<>(doc.getPages()); + pages.remove(1); + doc.setPages(pages); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("adding a synthesized page rebuilds with one more page") + void addPage() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(twoPageTextPdf()); + List pages = new ArrayList<>(doc.getPages()); + PdfJsonFont font = std14Font("ExtraF", "Helvetica"); + List fonts = + doc.getFonts() != null ? new ArrayList<>(doc.getFonts()) : new ArrayList<>(); + fonts.add(font); + doc.setFonts(fonts); + PdfJsonTextElement element = + PdfJsonTextElement.builder() + .text("Brand new page") + .fontId("ExtraF") + .fontSize(14f) + .x(72f) + .y(700f) + .build(); + pages.add( + PdfJsonPage.builder() + .pageNumber(3) + .width(612f) + .height(792f) + .textElements(new ArrayList<>(List.of(element))) + .build()); + doc.setPages(pages); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(3, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("changing text colour, font size and position rebuilds a valid PDF") + void changeColorSizePosition() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonDocument doc = toJsonDocument(simpleTextPdf("Recolour me")); + for (PdfJsonTextElement element : doc.getPages().get(0).getTextElements()) { + element.setFontSize(28f); + element.setX(120f); + element.setY(540f); + element.setFillColor( + PdfJsonTextColor.builder() + .colorSpace("DeviceRGB") + .components(new float[] {0.9f, 0.1f, 0.4f}) + .build()); + } + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + } + + // ================================================================== + // Cache export: add / remove / modify text and image elements + // ================================================================== + + @Nested + @DisplayName("cache export with element edits") + class CacheExportElementEdits { + + private byte[] textAndImagePdf() throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + PDImageXObject image = + LosslessFactory.createFromImage( + document, solidImage(20, 16, Color.GREEN, BufferedImage.TYPE_INT_RGB)); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.drawImage(image, 100, 500, 80, 64); + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(72, 400); + cs.showText("Caption text"); + cs.endText(); + } + return toBytes(document); + } + + @Test + @DisplayName("export applies an edit that adds a new image element to a cached page") + void exportAddsImage() throws IOException { + stubFallbackFont(); + cacheLazyDocument("job-add-img", simpleTextPdf("Add an image here")); + + ByteArrayOutputStream pageOut = new ByteArrayOutputStream(); + service.extractSinglePage("job-add-img", 1, pageOut); + PdfJsonPage page = objectMapper.readValue(pageOut.toByteArray(), PdfJsonPage.class); + page.setPageNumber(1); + + PdfJsonImageElement added = + PdfJsonImageElement.builder() + .id("AddedIm") + .imageData( + pngBase64( + solidImage( + 12, 12, Color.RED, BufferedImage.TYPE_INT_RGB))) + .imageFormat("png") + .x(60f) + .y(600f) + .width(48f) + .height(48f) + .build(); + page.setImageElements(new ArrayList<>(List.of(added))); + + PdfJsonDocument updates = new PdfJsonDocument(); + updates.setPages(new ArrayList<>(List.of(page))); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.exportUpdatedPages("job-add-img", updates, out); + try (PDDocument loaded = Loader.loadPDF(out.toByteArray())) { + assertEquals(1, loaded.getNumberOfPages()); + assertNotNull(loaded.getPage(0).getResources()); + } + } + + @Test + @DisplayName("export applies an edit that removes all text from a cached page") + void exportRemovesText() throws IOException { + stubFallbackFont(); + cacheLazyDocument("job-remove-text", textAndImagePdf()); + + ByteArrayOutputStream pageOut = new ByteArrayOutputStream(); + service.extractSinglePage("job-remove-text", 1, pageOut); + PdfJsonPage page = objectMapper.readValue(pageOut.toByteArray(), PdfJsonPage.class); + page.setPageNumber(1); + page.setTextElements(new ArrayList<>()); + + PdfJsonDocument updates = new PdfJsonDocument(); + updates.setPages(new ArrayList<>(List.of(page))); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.exportUpdatedPages("job-remove-text", updates, out); + assertThat(out.toByteArray()).isNotEmpty(); + } + + @Test + @DisplayName("export merges a font supplied in the update document") + void exportMergesUpdateFont() throws IOException { + stubFallbackFont(); + stubCanEncode(); + cacheLazyDocument("job-update-font", simpleTextPdf("Font merge source")); + + ByteArrayOutputStream pageOut = new ByteArrayOutputStream(); + service.extractSinglePage("job-update-font", 1, pageOut); + PdfJsonPage page = objectMapper.readValue(pageOut.toByteArray(), PdfJsonPage.class); + page.setPageNumber(1); + + PdfJsonDocument updates = new PdfJsonDocument(); + updates.setPages(new ArrayList<>(List.of(page))); + // A font carried on the update document drives the updates.getFonts() merge branch. + updates.setFonts(new ArrayList<>(List.of(std14Font("UpdF", "Times-Roman")))); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.exportUpdatedPages("job-update-font", updates, out); + try (PDDocument loaded = Loader.loadPDF(out.toByteArray())) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("export of a cached image-bearing page surfaces image data on re-extract") + void exportThenReextractImage() throws IOException { + stubFallbackFont(); + cacheLazyDocument("job-export-img", textAndImagePdf()); + + ByteArrayOutputStream pageOut = new ByteArrayOutputStream(); + service.extractSinglePage("job-export-img", 1, pageOut); + PdfJsonPage page = objectMapper.readValue(pageOut.toByteArray(), PdfJsonPage.class); + page.setPageNumber(1); + for (PdfJsonTextElement element : page.getTextElements()) { + if (element.getText() != null && !element.getText().isBlank()) { + element.setText("Caption edited"); + break; + } + } + PdfJsonDocument updates = new PdfJsonDocument(); + updates.setPages(new ArrayList<>(List.of(page))); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.exportUpdatedPages("job-export-img", updates, out); + + // The cache is now refreshed; pulling the page back still yields image data. + ByteArrayOutputStream reOut = new ByteArrayOutputStream(); + service.extractSinglePage("job-export-img", 1, reOut); + PdfJsonPage reloaded = objectMapper.readValue(reOut.toByteArray(), PdfJsonPage.class); + assertThat(reloaded.getImageElements()).isNotEmpty(); + } + } + + // ================================================================== + // extractDocumentMetadata variants and downstream cache use + // ================================================================== + + @Nested + @DisplayName("metadata extraction feeding the cache") + class MetadataCacheFlow { + + @Test + @DisplayName("metadata extraction then export with no updates returns the cached PDF") + void metadataThenExportNoUpdates() throws IOException { + when(pdfDocumentFactory.load(any(byte[].class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, byte[].class))); + ByteArrayOutputStream metaOut = new ByteArrayOutputStream(); + service.extractDocumentMetadata( + pdfMultipart(simpleTextPdf("Meta then export")), "job-meta-export", metaOut); + PdfJsonDocumentMetadata md = + objectMapper.readValue(metaOut.toByteArray(), PdfJsonDocumentMetadata.class); + assertThat(md.getPageDimensions()).hasSize(1); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.exportUpdatedPages("job-meta-export", new PdfJsonDocument(), out); + try (PDDocument loaded = Loader.loadPDF(out.toByteArray())) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("metadata extraction caches fonts retrievable via extractPageFonts") + void metadataThenPageFonts() throws IOException { + when(pdfDocumentFactory.load(any(byte[].class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, byte[].class))); + ByteArrayOutputStream metaOut = new ByteArrayOutputStream(); + service.extractDocumentMetadata( + pdfMultipart(simpleTextPdf("Fonts via metadata")), "job-meta-fonts", metaOut); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractPageFonts("job-meta-fonts", 1, out); + List fonts = objectMapper.readValue(out.toByteArray(), List.class); + assertThat(fonts).isNotEmpty(); + } + + @Test + @DisplayName("metadata extraction carries the extracted document title") + void metadataCarriesTitle() throws IOException { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.LETTER)); + document.getDocumentInformation().setTitle("Meta Title Extra"); + byte[] bytes = toBytes(document); + + when(pdfDocumentFactory.load(any(byte[].class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, byte[].class))); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractDocumentMetadata(pdfMultipart(bytes), null, out); + PdfJsonDocumentMetadata md = + objectMapper.readValue(out.toByteArray(), PdfJsonDocumentMetadata.class); + assertThat(md.getMetadata()).isNotNull(); + assertEquals("Meta Title Extra", md.getMetadata().getTitle()); + } + } + + // ================================================================== + // Rich COS metadata via nested page resources + // ================================================================== + + @Nested + @DisplayName("rich COS object mapping via resources") + class RichCosMapping { + + /** A page whose resource dictionary carries nested arrays/dicts/name/number/bool/null. */ + private byte[] richResourcePdf() throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(72, 700); + cs.showText("Resource carrier"); + cs.endText(); + } + + org.apache.pdfbox.cos.COSDictionary custom = new org.apache.pdfbox.cos.COSDictionary(); + custom.setItem( + org.apache.pdfbox.cos.COSName.getPDFName("AName"), + org.apache.pdfbox.cos.COSName.getPDFName("SomeValue")); + custom.setItem( + org.apache.pdfbox.cos.COSName.getPDFName("AnInt"), + org.apache.pdfbox.cos.COSInteger.get(7L)); + custom.setItem( + org.apache.pdfbox.cos.COSName.getPDFName("AFloat"), + new org.apache.pdfbox.cos.COSFloat(1.25f)); + custom.setItem( + org.apache.pdfbox.cos.COSName.getPDFName("ABool"), + org.apache.pdfbox.cos.COSBoolean.TRUE); + custom.setItem( + org.apache.pdfbox.cos.COSName.getPDFName("ANull"), + org.apache.pdfbox.cos.COSNull.NULL); + custom.setItem( + org.apache.pdfbox.cos.COSName.getPDFName("AString"), + new org.apache.pdfbox.cos.COSString("hello string")); + + org.apache.pdfbox.cos.COSArray nested = new org.apache.pdfbox.cos.COSArray(); + nested.add(org.apache.pdfbox.cos.COSInteger.get(1L)); + nested.add(org.apache.pdfbox.cos.COSInteger.get(2L)); + org.apache.pdfbox.cos.COSDictionary innerDict = + new org.apache.pdfbox.cos.COSDictionary(); + innerDict.setItem( + org.apache.pdfbox.cos.COSName.getPDFName("Deep"), + org.apache.pdfbox.cos.COSName.getPDFName("Value")); + nested.add(innerDict); + custom.setItem(org.apache.pdfbox.cos.COSName.getPDFName("AnArray"), nested); + + // Resources dictionary is created once the content stream sets a font above. + page.getResources() + .getCOSObject() + .setItem(org.apache.pdfbox.cos.COSName.getPDFName("StirlingExtra"), custom); + + return toBytes(document); + } + + @Test + @DisplayName("nested custom resource dictionary is preserved through extraction") + void richResourcesPreserved() throws IOException { + PdfJsonDocument doc = toJsonDocument(richResourcePdf()); + PdfJsonPage page = doc.getPages().get(0); + assertNotNull(page.getResources()); + } + + @Test + @DisplayName("nested custom resource dictionary survives a full round trip") + void richResourcesRoundTrip() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(richResourcePdf()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + assertNotNull(loaded.getPage(0).getResources()); + } + } + } + + // ================================================================== + // Text render modes / spacing / rise / negative coords (synthesized) + // ================================================================== + + @Nested + @DisplayName("text render modes, spacing and coordinates") + class TextStateVariants { + + private PdfJsonDocument textWith(PdfJsonTextElement element) { + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .textElements(new ArrayList<>(List.of(element))) + .build(); + PdfJsonDocument doc = docWith(page); + doc.setFonts(new ArrayList<>(List.of(std14Font("F1", "Helvetica")))); + return doc; + } + + @Test + @DisplayName("stroke render mode (1) renders with a stroke colour") + void strokeRenderMode() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonTextElement element = + PdfJsonTextElement.builder() + .text("Stroked") + .fontId("F1") + .fontSize(18f) + .renderingMode(1) + .strokeColor( + PdfJsonTextColor.builder() + .colorSpace("DeviceRGB") + .components(new float[] {0.1f, 0.2f, 0.3f}) + .build()) + .x(72f) + .y(700f) + .build(); + assertThat(runJsonToPdf(textWith(element))).isNotEmpty(); + } + + @Test + @DisplayName("invisible render mode (3) still rebuilds cleanly") + void invisibleRenderMode() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonTextElement element = + PdfJsonTextElement.builder() + .text("Invisible") + .fontId("F1") + .fontSize(12f) + .renderingMode(3) + .x(72f) + .y(680f) + .build(); + assertThat(runJsonToPdf(textWith(element))).isNotEmpty(); + } + + @Test + @DisplayName("fill-stroke-clip render mode (7) rebuilds cleanly") + void fillStrokeClipRenderMode() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonTextElement element = + PdfJsonTextElement.builder() + .text("Clipped") + .fontId("F1") + .fontSize(12f) + .renderingMode(7) + .fillColor( + PdfJsonTextColor.builder() + .colorSpace("DeviceGray") + .components(new float[] {0.3f}) + .build()) + .x(72f) + .y(660f) + .build(); + assertThat(runJsonToPdf(textWith(element))).isNotEmpty(); + } + + @Test + @DisplayName("word spacing, horizontal scaling and rise combine without error") + void spacingScalingRise() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonTextElement element = + PdfJsonTextElement.builder() + .text("spaced words rise") + .fontId("F1") + .fontSize(14f) + .wordSpacing(3.5f) + .characterSpacing(0.7f) + .horizontalScaling(130f) + .rise(4f) + .leading(18f) + .x(72f) + .y(640f) + .build(); + assertThat(runJsonToPdf(textWith(element))).isNotEmpty(); + } + + @Test + @DisplayName("negative coordinates are tolerated by the regeneration path") + void negativeCoordinates() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonTextElement element = + PdfJsonTextElement.builder() + .text("Below origin") + .fontId("F1") + .fontSize(12f) + .x(-50f) + .y(-25f) + .build(); + assertThat(runJsonToPdf(textWith(element))).isNotEmpty(); + } + + @Test + @DisplayName("zero font size falls back without aborting the rebuild") + void zeroFontSize() throws IOException { + stubFallbackFont(); + stubCanEncode(); + PdfJsonTextElement element = + PdfJsonTextElement.builder() + .text("Zero size") + .fontId("F1") + .fontSize(0f) + .x(72f) + .y(620f) + .build(); + assertDoesNotThrow(() -> runJsonToPdf(textWith(element))); + } + } + + // ================================================================== + // Multiple images per page + mixed content (synthesized + extracted) + // ================================================================== + + @Nested + @DisplayName("multiple images and mixed content") + class MultiImageMixed { + + @Test + @DisplayName("two synthesized images (JPEG + lossless PNG) render on one page") + void twoSynthesizedImages() throws IOException { + stubFallbackFont(); + ByteArrayOutputStream jpgOut = new ByteArrayOutputStream(); + ImageIO.write(solidImage(16, 16, Color.RED, BufferedImage.TYPE_INT_RGB), "jpg", jpgOut); + String jpg = Base64.getEncoder().encodeToString(jpgOut.toByteArray()); + + PdfJsonImageElement jpeg = + PdfJsonImageElement.builder() + .id("Jpeg") + .imageData(jpg) + .imageFormat("jpg") + .x(60f) + .y(600f) + .width(64f) + .height(48f) + .build(); + PdfJsonImageElement png = + PdfJsonImageElement.builder() + .id("Png") + .imageData( + pngBase64( + solidImage( + 16, + 16, + Color.BLUE, + BufferedImage.TYPE_INT_RGB))) + .imageFormat("png") + .x(200f) + .y(600f) + .width(48f) + .height(48f) + .build(); + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .imageElements(new ArrayList<>(List.of(jpeg, png))) + .build(); + try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(docWith(page)))) { + assertEquals(1, loaded.getNumberOfPages()); + assertNotNull(loaded.getPage(0).getResources()); + } + } + + @Test + @DisplayName("page mixing text, image and annotation round trips from extraction") + void mixedTextImageAnnotationExtracted() throws IOException { + stubFallbackFont(); + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + + PDImageXObject jpeg = + JPEGFactory.createFromImage( + document, solidImage(24, 18, Color.RED, BufferedImage.TYPE_INT_RGB)); + PDImageXObject png = + LosslessFactory.createFromImage( + document, solidImage(18, 18, Color.BLUE, BufferedImage.TYPE_INT_RGB)); + + PDAnnotationText note = new PDAnnotationText(); + note.setContents("Mixed note"); + note.setRectangle(new PDRectangle(50, 720, 18, 18)); + page.getAnnotations().add(note); + + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.drawImage(jpeg, 60, 600, 96, 72); + cs.drawImage(png, 200, 600, 72, 72); + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(72, 500); + cs.showText("Mixed content body"); + cs.endText(); + } + byte[] bytes = toBytes(document); + + PdfJsonDocument doc = toJsonDocument(bytes); + PdfJsonPage extracted = doc.getPages().get(0); + assertThat(extracted.getImageElements()).hasSizeGreaterThanOrEqualTo(2); + assertThat(extracted.getTextElements()).isNotEmpty(); + assertThat(extracted.getAnnotations()).isNotEmpty(); + + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("lazy single-page extraction surfaces multiple images for a cached page") + void lazyMultipleImages() throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + PDImageXObject a = + LosslessFactory.createFromImage( + document, solidImage(16, 16, Color.RED, BufferedImage.TYPE_INT_RGB)); + PDImageXObject b = + LosslessFactory.createFromImage( + document, solidImage(16, 16, Color.BLUE, BufferedImage.TYPE_INT_RGB)); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.drawImage(a, 60, 600, 48, 48); + cs.drawImage(b, 200, 600, 48, 48); + } + byte[] bytes = toBytes(document); + + cacheLazyDocument("job-multi-img", bytes); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractSinglePage("job-multi-img", 1, out); + PdfJsonPage extracted = objectMapper.readValue(out.toByteArray(), PdfJsonPage.class); + assertThat(extracted.getImageElements()).hasSizeGreaterThanOrEqualTo(2); + } + } + + // ================================================================== + // Cache lifecycle and miss paths + // ================================================================== + + @Nested + @DisplayName("cache lifecycle and miss paths") + class CacheLifecycle { + + @Test + @DisplayName("extractPageFonts on a cleared job throws CacheUnavailableException") + void pageFontsAfterClearThrows() throws IOException { + cacheLazyDocument("job-clear-fonts", simpleTextPdf("Clear fonts")); + service.clearCachedDocument("job-clear-fonts"); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + assertThrows( + CacheUnavailableException.class, + () -> service.extractPageFonts("job-clear-fonts", 1, out)); + } + + @Test + @DisplayName("exportUpdatedPages on a cleared job throws CacheUnavailableException") + void exportAfterClearThrows() throws IOException { + cacheLazyDocument("job-clear-export", simpleTextPdf("Clear export")); + service.clearCachedDocument("job-clear-export"); + PdfJsonPage page = new PdfJsonPage(); + page.setPageNumber(1); + PdfJsonDocument updates = new PdfJsonDocument(); + updates.setPages(new ArrayList<>(List.of(page))); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + assertThrows( + CacheUnavailableException.class, + () -> service.exportUpdatedPages("job-clear-export", updates, out)); + } + + @Test + @DisplayName("extractDocumentMetadata reusing a jobId refreshes the cached document") + void metadataReusedJobIdRefreshes() throws IOException { + when(pdfDocumentFactory.load(any(byte[].class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, byte[].class))); + ByteArrayOutputStream first = new ByteArrayOutputStream(); + service.extractDocumentMetadata( + pdfMultipart(simpleTextPdf("First doc")), "job-reuse", first); + + // Re-run with a two-page doc under the same jobId; the cache should now report two + // pages. + PDDocument twoPager = new PDDocument(); + twoPager.addPage(new PDPage(PDRectangle.LETTER)); + twoPager.addPage(new PDPage(PDRectangle.LETTER)); + byte[] twoBytes = toBytes(twoPager); + + ByteArrayOutputStream second = new ByteArrayOutputStream(); + service.extractDocumentMetadata(pdfMultipart(twoBytes), "job-reuse", second); + + ByteArrayOutputStream pageOut = new ByteArrayOutputStream(); + service.extractSinglePage("job-reuse", 2, pageOut); + PdfJsonPage page = objectMapper.readValue(pageOut.toByteArray(), PdfJsonPage.class); + assertEquals(2, page.getPageNumber()); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceMore2Test.java b/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceMore2Test.java new file mode 100644 index 0000000000..22ade756a8 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceMore2Test.java @@ -0,0 +1,900 @@ +package stirling.software.SPDF.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +import javax.imageio.ImageIO; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory; +import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionGoTo; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageFitDestination; +import org.junit.jupiter.api.AfterEach; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.quality.Strictness; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.exception.CacheUnavailableException; +import stirling.software.SPDF.model.json.PdfJsonAnnotation; +import stirling.software.SPDF.model.json.PdfJsonDocument; +import stirling.software.SPDF.model.json.PdfJsonDocumentMetadata; +import stirling.software.SPDF.model.json.PdfJsonFont; +import stirling.software.SPDF.model.json.PdfJsonImageElement; +import stirling.software.SPDF.model.json.PdfJsonMetadata; +import stirling.software.SPDF.model.json.PdfJsonPage; +import stirling.software.SPDF.model.json.PdfJsonTextColor; +import stirling.software.SPDF.model.json.PdfJsonTextElement; +import stirling.software.SPDF.service.pdfjson.PdfJsonFontService; +import stirling.software.SPDF.service.pdfjson.type3.Type3FontConversionService; +import stirling.software.SPDF.service.pdfjson.type3.Type3GlyphExtractor; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.TaskManager; +import stirling.software.common.util.JobContext; +import stirling.software.common.util.TempFileManager; + +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Additional coverage tests for {@link PdfJsonConversionService} targeting reachable branches the + * other suites leave cold: link annotations carrying URI / GoTo actions plus widget annotations, + * the {@code restoreAnnotations} structured-fallback path when no rawData is present, page rotation + * (90/180/270) combined with a CropBox that differs from the MediaBox, document- and page-level + * metadata edge cases (all-null fields, empty strings, keyword/creator/producer round trips), the + * XMP packet extract-then-apply round trip, multi-image pages mixing JPEG (DCTDecode) and lossless + * PNG with explicit-transform versus default placement, and the cache/editor API around export + * edits and clear-then-miss. + * + *

Construction mirrors {@code PdfJsonConversionServiceDeepTest} and {@code + * PdfJsonConversionServiceExtraTest} so the same real in-memory PDF load path runs without + * repeating their assertions. + */ +@ExtendWith(MockitoExtension.class) +@org.mockito.junit.jupiter.MockitoSettings(strictness = Strictness.LENIENT) +class PdfJsonConversionServiceMore2Test { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private EndpointConfiguration endpointConfiguration; + @Mock private TempFileManager tempFileManager; + @Mock private TaskManager taskManager; + @Mock private PdfJsonFallbackFontService fallbackFontService; + @Mock private PdfJsonFontService fontService; + @Mock private Type3FontConversionService type3FontConversionService; + @Mock private Type3GlyphExtractor type3GlyphExtractor; + @Mock private ApplicationProperties applicationProperties; + + // Real COS mapper so the serialize/deserialize machinery runs for real. + private final PdfJsonCosMapper cosMapper = new PdfJsonCosMapper(); + + private final ObjectMapper objectMapper = + JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .build(); + + private PdfJsonConversionService service; + + private final List createdTempFiles = new ArrayList<>(); + + @BeforeEach + void setUp() throws IOException { + service = + new PdfJsonConversionService( + pdfDocumentFactory, + objectMapper, + endpointConfiguration, + tempFileManager, + taskManager, + cosMapper, + fallbackFontService, + fontService, + type3FontConversionService, + type3GlyphExtractor, + applicationProperties); + + when(tempFileManager.createTempFile(anyString())) + .thenAnswer( + invocation -> { + String suffix = invocation.getArgument(0); + Path path = Files.createTempFile("pdfjson-more2-test", suffix); + createdTempFiles.add(path); + return path.toFile(); + }); + when(tempFileManager.deleteTempFile(any(File.class))) + .thenAnswer( + invocation -> { + File file = invocation.getArgument(0); + return file != null && file.delete(); + }); + when(taskManager.addNote(anyString(), anyString())).thenReturn(true); + } + + @AfterEach + void tearDown() throws IOException { + JobContext.clear(); + for (Path path : createdTempFiles) { + Files.deleteIfExists(path); + } + createdTempFiles.clear(); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private void stubFallbackFont() throws IOException { + when(fallbackFontService.buildFallbackFontModel()) + .thenAnswer( + invocation -> + PdfJsonFont.builder() + .id(PdfJsonFallbackFontService.FALLBACK_FONT_ID) + .uid(PdfJsonFallbackFontService.FALLBACK_FONT_ID) + .baseName("Fallback") + .subtype("TrueType") + .build()); + when(fallbackFontService.loadFallbackPdfFont(any(PDDocument.class))) + .thenAnswer(invocation -> new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + } + + private void stubCanEncode() { + when(fallbackFontService.canEncode(any(PDFont.class), anyString())).thenReturn(true); + when(fallbackFontService.canEncode(any(PDFont.class), anyInt())).thenReturn(true); + } + + private MockMultipartFile pdfMultipart(byte[] bytes) { + return new MockMultipartFile("fileInput", "input.pdf", "application/pdf", bytes); + } + + private byte[] toBytes(PDDocument document) throws IOException { + try (document) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } + } + + private PdfJsonDocument toJsonDocument(byte[] pdfBytes) throws IOException { + when(pdfDocumentFactory.load(any(Path.class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, Path.class).toFile())); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertPdfToJson(pdfMultipart(pdfBytes), out); + return objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class); + } + + private byte[] runJsonToPdf(PdfJsonDocument doc) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertJsonToPdf(doc, out); + return out.toByteArray(); + } + + private PdfJsonDocument cacheLazyDocument(String jobId, byte[] pdfBytes) throws IOException { + when(pdfDocumentFactory.load(any(Path.class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, Path.class).toFile())); + when(pdfDocumentFactory.load(any(byte[].class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, byte[].class))); + JobContext.setJobId(jobId); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertPdfToJson(pdfMultipart(pdfBytes), true, out); + return objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class); + } + + private BufferedImage solidImage(int w, int h, Color color, int type) { + BufferedImage image = new BufferedImage(w, h, type); + for (int x = 0; x < w; x++) { + for (int y = 0; y < h; y++) { + image.setRGB(x, y, color.getRGB()); + } + } + return image; + } + + private String pngBase64(BufferedImage image) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(image, "png", out); + return Base64.getEncoder().encodeToString(out.toByteArray()); + } + + private String jpgBase64(BufferedImage image) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(image, "jpg", out); + return Base64.getEncoder().encodeToString(out.toByteArray()); + } + + private PdfJsonDocument docWith(PdfJsonPage page) { + PdfJsonDocument doc = new PdfJsonDocument(); + doc.setPages(new ArrayList<>(List.of(page))); + return doc; + } + + private PdfJsonFont std14Font(String id, String standard14Name) { + return PdfJsonFont.builder() + .id(id) + .uid(id) + .baseName(standard14Name) + .subtype("Type1") + .standard14Name(standard14Name) + .build(); + } + + private byte[] simpleTextPdf(String text) throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(72, 700); + cs.showText(text); + cs.endText(); + } + return toBytes(document); + } + + // ================================================================== + // Link / action / widget annotations + // ================================================================== + + @Nested + @DisplayName("link, action and widget annotation handling") + class ActionAnnotations { + + /** A page carrying a URI-action link, a GoTo-action link and a widget annotation. */ + private byte[] actionAnnotationPdf() throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + + PDAnnotationLink uriLink = new PDAnnotationLink(); + uriLink.setRectangle(new PDRectangle(40, 720, 200, 18)); + PDActionURI uriAction = new PDActionURI(); + uriAction.setURI("https://example.com/landing"); + uriLink.setAction(uriAction); + + PDAnnotationLink gotoLink = new PDAnnotationLink(); + gotoLink.setRectangle(new PDRectangle(40, 690, 200, 18)); + PDActionGoTo gotoAction = new PDActionGoTo(); + PDPageFitDestination dest = new PDPageFitDestination(); + dest.setPage(page); + gotoAction.setDestination(dest); + gotoLink.setAction(gotoAction); + + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(new PDRectangle(40, 650, 120, 24)); + + page.getAnnotations().add(uriLink); + page.getAnnotations().add(gotoLink); + page.getAnnotations().add(widget); + + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(72, 600); + cs.showText("Body with action links"); + cs.endText(); + } + return toBytes(document); + } + + @Test + @DisplayName("URI, GoTo and widget annotations are collected with their subtypes") + void collectsActionSubtypes() throws IOException { + PdfJsonDocument doc = toJsonDocument(actionAnnotationPdf()); + List annotations = doc.getPages().get(0).getAnnotations(); + assertThat(annotations).hasSizeGreaterThanOrEqualTo(3); + List subtypes = + annotations.stream().map(PdfJsonAnnotation::getSubtype).toList(); + assertThat(subtypes).contains("Link", "Widget"); + } + + @Test + @DisplayName("URI and GoTo action links round trip back onto the rebuilt page") + void actionLinksRoundTrip() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(actionAnnotationPdf()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertThat(loaded.getPage(0).getAnnotations()).hasSizeGreaterThanOrEqualTo(3); + boolean hasUri = + loaded.getPage(0).getAnnotations().stream() + .anyMatch( + a -> + a instanceof PDAnnotationLink + && ((PDAnnotationLink) a).getAction() + instanceof PDActionURI); + assertThat(hasUri).isTrue(); + } + } + + @Test + @DisplayName("structured annotation without rawData hits the fallback restore branch") + void structuredAnnotationFallbackRestore() throws IOException { + stubFallbackFont(); + // No rawData supplied so restoreAnnotations takes the basic-reconstruction warning + // path. + PdfJsonAnnotation noRaw = + PdfJsonAnnotation.builder() + .subtype("Text") + .contents("structured only") + .rect(new float[] {40f, 700f, 60f, 720f}) + .color(new float[] {1f, 0f, 0f}) + .author("Author X") + .subject("Subject Y") + .destination("page-1") + .iconName("Comment") + .build(); + PdfJsonFont font = std14Font("F1", "Helvetica"); + PdfJsonTextElement element = + PdfJsonTextElement.builder() + .text("Body line") + .fontId("F1") + .fontSize(12f) + .x(72f) + .y(640f) + .build(); + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .textElements(new ArrayList<>(List.of(element))) + .annotations(new ArrayList<>(List.of(noRaw))) + .build(); + PdfJsonDocument doc = docWith(page); + doc.setFonts(new ArrayList<>(List.of(font))); + // The annotation has no rawData; rebuild must not abort and the body still renders. + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("lazy extraction surfaces action-link annotations for a cached page") + void lazyActionAnnotations() throws IOException { + cacheLazyDocument("job-action-anns", actionAnnotationPdf()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractSinglePage("job-action-anns", 1, out); + PdfJsonPage page = objectMapper.readValue(out.toByteArray(), PdfJsonPage.class); + assertThat(page.getAnnotations()).hasSizeGreaterThanOrEqualTo(3); + } + } + + // ================================================================== + // Rotation + CropBox geometry + // ================================================================== + + @Nested + @DisplayName("rotation combined with a non-default CropBox") + class RotationCropBox { + + /** Letter MediaBox with a smaller inset CropBox and the supplied rotation. */ + private byte[] rotatedCroppedPdf(int rotation) throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + page.setRotation(rotation); + page.setCropBox(new PDRectangle(36f, 48f, 480f, 600f)); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(100, 300); + cs.showText("Rotated and cropped " + rotation); + cs.endText(); + } + return toBytes(document); + } + + @Test + @DisplayName("rotation 90 reports the CropBox dimensions and survives rebuild") + void rotation90() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(rotatedCroppedPdf(90)); + PdfJsonPage page = doc.getPages().get(0); + assertEquals(90, page.getRotation()); + // Extraction reports CropBox geometry, not the larger MediaBox. + assertEquals(480f, page.getWidth(), 0.5f); + assertEquals(600f, page.getHeight(), 0.5f); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(90, loaded.getPage(0).getRotation()); + } + } + + @Test + @DisplayName("rotation 180 keeps both rotation and crop geometry through a round trip") + void rotation180() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(rotatedCroppedPdf(180)); + assertEquals(180, doc.getPages().get(0).getRotation()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(180, loaded.getPage(0).getRotation()); + PDRectangle box = loaded.getPage(0).getMediaBox(); + assertEquals(480f, box.getWidth(), 0.5f); + assertEquals(600f, box.getHeight(), 0.5f); + } + } + + @Test + @DisplayName("rotation 270 round trips and reports crop geometry on extraction") + void rotation270() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(rotatedCroppedPdf(270)); + assertEquals(270, doc.getPages().get(0).getRotation()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(270, loaded.getPage(0).getRotation()); + } + } + + @Test + @DisplayName("metadata extraction reports rotation per page from the MediaBox path") + void metadataReportsRotation() throws IOException { + when(pdfDocumentFactory.load(any(byte[].class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, byte[].class))); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractDocumentMetadata(pdfMultipart(rotatedCroppedPdf(90)), "job-rot", out); + PdfJsonDocumentMetadata md = + objectMapper.readValue(out.toByteArray(), PdfJsonDocumentMetadata.class); + assertThat(md.getPageDimensions()).hasSize(1); + assertEquals(90, md.getPageDimensions().get(0).getRotation()); + } + } + + // ================================================================== + // Metadata edge cases (document and synthesized) + // ================================================================== + + @Nested + @DisplayName("metadata edge cases") + class MetadataEdgeCases { + + @Test + @DisplayName("a document with no information dictionary fields extracts blank metadata") + void emptyDocumentInfoExtractsBlanks() throws IOException { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.LETTER)); + PdfJsonDocument doc = toJsonDocument(toBytes(document)); + PdfJsonMetadata md = doc.getMetadata(); + assertNotNull(md); + assertThat(md.getTitle()).isNull(); + assertThat(md.getAuthor()).isNull(); + assertEquals(1, md.getNumberOfPages()); + } + + @Test + @DisplayName("all-null synthesized metadata applies cleanly without dates") + void allNullMetadataApplies() throws IOException { + stubFallbackFont(); + PdfJsonMetadata md = PdfJsonMetadata.builder().build(); + PdfJsonDocument doc = new PdfJsonDocument(); + doc.setMetadata(md); + doc.setPages(new ArrayList<>()); + assertDoesNotThrow(() -> runJsonToPdf(doc)); + } + + @Test + @DisplayName("keywords, creator and producer survive a metadata round trip") + void keywordsCreatorProducerRoundTrip() throws IOException { + stubFallbackFont(); + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.LETTER)); + document.getDocumentInformation().setKeywords("alpha, beta, gamma"); + document.getDocumentInformation().setCreator("Creator Tool"); + document.getDocumentInformation().setProducer("Producer Lib"); + document.getDocumentInformation().setSubject("Round trip subject"); + byte[] bytes = toBytes(document); + + PdfJsonDocument doc = toJsonDocument(bytes); + assertThat(doc.getMetadata().getKeywords()).isEqualTo("alpha, beta, gamma"); + + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals("alpha, beta, gamma", loaded.getDocumentInformation().getKeywords()); + assertEquals("Creator Tool", loaded.getDocumentInformation().getCreator()); + assertEquals("Producer Lib", loaded.getDocumentInformation().getProducer()); + assertEquals("Round trip subject", loaded.getDocumentInformation().getSubject()); + } + } + + @Test + @DisplayName("empty-string metadata fields round trip without becoming null") + void emptyStringMetadataFields() throws IOException { + stubFallbackFont(); + PdfJsonMetadata md = + PdfJsonMetadata.builder().title("").author("").keywords("").build(); + PdfJsonDocument doc = new PdfJsonDocument(); + doc.setMetadata(md); + doc.setPages(new ArrayList<>()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals("", loaded.getDocumentInformation().getTitle()); + } + } + } + + // ================================================================== + // XMP metadata extract-then-apply round trip + // ================================================================== + + @Nested + @DisplayName("XMP metadata round trip") + class XmpRoundTrip { + + private byte[] xmpPacket(String title) { + return ("" + + "" + + "" + + "" + + "" + + title + + "" + + "") + .getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + + private byte[] pdfWithXmp(String title) throws IOException { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.LETTER)); + org.apache.pdfbox.pdmodel.common.PDMetadata metadata = + new org.apache.pdfbox.pdmodel.common.PDMetadata( + document, new java.io.ByteArrayInputStream(xmpPacket(title))); + document.getDocumentCatalog().setMetadata(metadata); + return toBytes(document); + } + + @Test + @DisplayName("an XMP packet is extracted as base64 into the document model") + void xmpExtractedAsBase64() throws IOException { + PdfJsonDocument doc = toJsonDocument(pdfWithXmp("XMP Title One")); + assertThat(doc.getXmpMetadata()).isNotBlank(); + byte[] decoded = Base64.getDecoder().decode(doc.getXmpMetadata()); + String xml = new String(decoded, java.nio.charset.StandardCharsets.UTF_8); + assertThat(xml).contains("XMP Title One"); + } + + @Test + @DisplayName("an extracted XMP packet is restored onto the rebuilt document catalog") + void xmpRestoredOnRebuild() throws IOException { + stubFallbackFont(); + PdfJsonDocument doc = toJsonDocument(pdfWithXmp("XMP Title Two")); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + org.apache.pdfbox.pdmodel.common.PDMetadata restored = + loaded.getDocumentCatalog().getMetadata(); + assertNotNull(restored); + try (java.io.InputStream in = restored.createInputStream()) { + String xml = + new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(xml).contains("XMP Title Two"); + } + } + } + + @Test + @DisplayName("metadata extraction surfaces the XMP packet alongside info metadata") + void metadataExtractionIncludesXmp() throws IOException { + when(pdfDocumentFactory.load(any(byte[].class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, byte[].class))); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractDocumentMetadata(pdfMultipart(pdfWithXmp("XMP Meta")), null, out); + PdfJsonDocumentMetadata md = + objectMapper.readValue(out.toByteArray(), PdfJsonDocumentMetadata.class); + assertThat(md.getXmpMetadata()).isNotBlank(); + } + } + + // ================================================================== + // Mixed JPEG + lossless images with transform variety + // ================================================================== + + @Nested + @DisplayName("mixed JPEG and lossless image placement") + class MixedImagePlacement { + + @Test + @DisplayName("an extracted JPEG and PNG pair both carry format and data") + void extractedJpegAndPng() throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + PDImageXObject jpeg = + JPEGFactory.createFromImage( + document, solidImage(40, 30, Color.RED, BufferedImage.TYPE_INT_RGB)); + PDImageXObject png = + LosslessFactory.createFromImage( + document, solidImage(30, 30, Color.BLUE, BufferedImage.TYPE_INT_RGB)); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.drawImage(jpeg, 60, 600, 100, 75); + cs.drawImage(png, 220, 600, 75, 75); + } + PdfJsonDocument doc = toJsonDocument(toBytes(document)); + List images = doc.getPages().get(0).getImageElements(); + assertThat(images).hasSizeGreaterThanOrEqualTo(2); + assertThat(images) + .anySatisfy( + img -> + assertThat(img.getImageFormat().toLowerCase()) + .containsAnyOf("jpg", "jpeg")); + assertThat(images).allSatisfy(img -> assertThat(img.getImageData()).isNotBlank()); + } + + @Test + @DisplayName("explicit-transform JPEG and default-placement PNG render on one page") + void transformVsDefaultPlacement() throws IOException { + stubFallbackFont(); + PdfJsonImageElement transformed = + PdfJsonImageElement.builder() + .id("JpgT") + .imageData( + jpgBase64( + solidImage( + 16, 16, Color.RED, BufferedImage.TYPE_INT_RGB))) + .imageFormat("jpg") + .transform(new float[] {72f, 0f, 0f, 54f, 90f, 560f}) + .build(); + PdfJsonImageElement edgePlaced = + PdfJsonImageElement.builder() + .id("PngE") + .imageData( + pngBase64( + solidImage( + 16, + 16, + Color.BLUE, + BufferedImage.TYPE_INT_RGB))) + .imageFormat("png") + .x(260f) + .y(560f) + .width(64f) + .height(48f) + .build(); + PdfJsonPage page = + PdfJsonPage.builder() + .pageNumber(1) + .width(612f) + .height(792f) + .imageElements(new ArrayList<>(List.of(transformed, edgePlaced))) + .build(); + try (PDDocument loaded = Loader.loadPDF(runJsonToPdf(docWith(page)))) { + assertEquals(1, loaded.getNumberOfPages()); + assertNotNull(loaded.getPage(0).getResources()); + } + } + + @Test + @DisplayName("a JPEG-bearing page round trips and keeps its image resources") + void jpegPageRoundTrip() throws IOException { + stubFallbackFont(); + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + PDImageXObject jpeg = + JPEGFactory.createFromImage( + document, solidImage(48, 36, Color.ORANGE, BufferedImage.TYPE_INT_RGB)); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.drawImage(jpeg, 80, 500, 120, 90); + } + PdfJsonDocument doc = toJsonDocument(toBytes(document)); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + assertNotNull(loaded.getPage(0).getResources()); + } + } + } + + // ================================================================== + // Cache / editor API: export edits, empty edits, out of range, clear + // ================================================================== + + @Nested + @DisplayName("cache export edits and lifecycle") + class CacheExportEditsLifecycle { + + private byte[] twoPageTextPdf() throws IOException { + PDDocument document = new PDDocument(); + for (int i = 0; i < 2; i++) { + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(72, 700); + cs.showText("Cache page " + (i + 1)); + cs.endText(); + } + } + return toBytes(document); + } + + @Test + @DisplayName("export changing font size and colour on a cached page re-saves the document") + void exportChangesFontSizeAndColor() throws IOException { + stubFallbackFont(); + stubCanEncode(); + cacheLazyDocument("job-size-color", simpleTextPdf("Resize and recolour me")); + + ByteArrayOutputStream pageOut = new ByteArrayOutputStream(); + service.extractSinglePage("job-size-color", 1, pageOut); + PdfJsonPage page = objectMapper.readValue(pageOut.toByteArray(), PdfJsonPage.class); + page.setPageNumber(1); + for (PdfJsonTextElement element : page.getTextElements()) { + element.setFontSize(26f); + element.setFillColor( + PdfJsonTextColor.builder() + .colorSpace("DeviceRGB") + .components(new float[] {0.2f, 0.6f, 0.9f}) + .build()); + } + PdfJsonDocument updates = new PdfJsonDocument(); + updates.setPages(new ArrayList<>(List.of(page))); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.exportUpdatedPages("job-size-color", updates, out); + try (PDDocument loaded = Loader.loadPDF(out.toByteArray())) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("export adding a text element to one cached page rebuilds both pages") + void exportAddsTextElement() throws IOException { + stubFallbackFont(); + stubCanEncode(); + cacheLazyDocument("job-add-text", twoPageTextPdf()); + + ByteArrayOutputStream pageOut = new ByteArrayOutputStream(); + service.extractSinglePage("job-add-text", 1, pageOut); + PdfJsonPage page = objectMapper.readValue(pageOut.toByteArray(), PdfJsonPage.class); + page.setPageNumber(1); + List elements = + page.getTextElements() != null + ? new ArrayList<>(page.getTextElements()) + : new ArrayList<>(); + elements.add( + PdfJsonTextElement.builder() + .text("Newly added line") + .fontId(elements.isEmpty() ? null : elements.get(0).getFontId()) + .fontSize(12f) + .x(72f) + .y(640f) + .build()); + page.setTextElements(elements); + + PdfJsonDocument updates = new PdfJsonDocument(); + updates.setPages(new ArrayList<>(List.of(page))); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.exportUpdatedPages("job-add-text", updates, out); + try (PDDocument loaded = Loader.loadPDF(out.toByteArray())) { + assertEquals(2, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("export with an empty update document returns the cached PDF intact") + void exportEmptyUpdatesReturnsCached() throws IOException { + stubFallbackFont(); + cacheLazyDocument("job-empty-updates", twoPageTextPdf()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.exportUpdatedPages("job-empty-updates", new PdfJsonDocument(), out); + try (PDDocument loaded = Loader.loadPDF(out.toByteArray())) { + assertEquals(2, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("export of only an out-of-range page leaves the cached document unchanged") + void exportOnlyOutOfRangePage() throws IOException { + stubFallbackFont(); + cacheLazyDocument("job-only-ghost", twoPageTextPdf()); + PdfJsonPage ghost = new PdfJsonPage(); + ghost.setPageNumber(42); + PdfJsonDocument updates = new PdfJsonDocument(); + updates.setPages(new ArrayList<>(List.of(ghost))); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.exportUpdatedPages("job-only-ghost", updates, out); + try (PDDocument loaded = Loader.loadPDF(out.toByteArray())) { + assertEquals(2, loaded.getNumberOfPages()); + } + } + + @Test + @DisplayName("extractSinglePage after clearCachedDocument throws CacheUnavailableException") + void singlePageAfterClearThrows() throws IOException { + cacheLazyDocument("job-clear-page", simpleTextPdf("Clear single page")); + service.clearCachedDocument("job-clear-page"); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + assertThrows( + CacheUnavailableException.class, + () -> service.extractSinglePage("job-clear-page", 1, out)); + } + + @Test + @DisplayName("clearCachedDocument is idempotent when called twice") + void clearTwiceIsIdempotent() throws IOException { + cacheLazyDocument("job-clear-twice", simpleTextPdf("Clear twice")); + service.clearCachedDocument("job-clear-twice"); + assertDoesNotThrow(() -> service.clearCachedDocument("job-clear-twice")); + } + } + + // ================================================================== + // Metadata extraction with and without a jobId + // ================================================================== + + @Nested + @DisplayName("metadata extraction with and without a jobId") + class MetadataJobIdVariants { + + @Test + @DisplayName("metadata extraction without a jobId does not populate the cache") + void metadataNoJobIdSkipsCache() throws IOException { + when(pdfDocumentFactory.load(any(byte[].class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, byte[].class))); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractDocumentMetadata( + pdfMultipart(simpleTextPdf("No job id meta")), null, out); + PdfJsonDocumentMetadata md = + objectMapper.readValue(out.toByteArray(), PdfJsonDocumentMetadata.class); + assertThat(md.getPageDimensions()).hasSize(1); + assertEquals(Boolean.TRUE, md.getLazyImages()); + } + + @Test + @DisplayName( + "metadata extraction with a jobId caches a page retrievable by extractSinglePage") + void metadataWithJobIdCachesPage() throws IOException { + when(pdfDocumentFactory.load(any(byte[].class), eq(true))) + .thenAnswer(inv -> Loader.loadPDF(inv.getArgument(0, byte[].class))); + ByteArrayOutputStream metaOut = new ByteArrayOutputStream(); + service.extractDocumentMetadata( + pdfMultipart(simpleTextPdf("Job id meta")), "job-meta-cache", metaOut); + + ByteArrayOutputStream pageOut = new ByteArrayOutputStream(); + service.extractSinglePage("job-meta-cache", 1, pageOut); + PdfJsonPage page = objectMapper.readValue(pageOut.toByteArray(), PdfJsonPage.class); + assertEquals(1, page.getPageNumber()); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceRoundTripTest.java b/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceRoundTripTest.java new file mode 100644 index 0000000000..cb4e8e7407 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/PdfJsonConversionServiceRoundTripTest.java @@ -0,0 +1,402 @@ +package stirling.software.SPDF.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDMetadata; +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.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.junit.jupiter.api.AfterEach; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.quality.Strictness; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.SPDF.config.EndpointConfiguration; +import stirling.software.SPDF.model.json.PdfJsonDocument; +import stirling.software.SPDF.model.json.PdfJsonFont; +import stirling.software.SPDF.model.json.PdfJsonFormField; +import stirling.software.SPDF.model.json.PdfJsonPage; +import stirling.software.SPDF.model.json.PdfJsonTextElement; +import stirling.software.SPDF.service.pdfjson.PdfJsonFontService; +import stirling.software.SPDF.service.pdfjson.type3.Type3FontConversionService; +import stirling.software.SPDF.service.pdfjson.type3.Type3GlyphExtractor; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.TaskManager; +import stirling.software.common.util.TempFileManager; + +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Round-trip preservation tests that exercise the resource/content-stream/form-field/XMP machinery + * of {@link PdfJsonConversionService}. Each test builds a real PDF, converts it to the JSON model, + * mutates or inspects it, and rebuilds a PDF, driving the non-lightweight extraction and rebuild + * helpers (resources, content streams, token rewrite, form fields, font metadata). + */ +@ExtendWith(MockitoExtension.class) +@org.mockito.junit.jupiter.MockitoSettings(strictness = Strictness.LENIENT) +class PdfJsonConversionServiceRoundTripTest { + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private EndpointConfiguration endpointConfiguration; + @Mock private TempFileManager tempFileManager; + @Mock private TaskManager taskManager; + @Mock private PdfJsonFallbackFontService fallbackFontService; + @Mock private PdfJsonFontService fontService; + @Mock private Type3FontConversionService type3FontConversionService; + @Mock private Type3GlyphExtractor type3GlyphExtractor; + @Mock private ApplicationProperties applicationProperties; + + private final PdfJsonCosMapper cosMapper = new PdfJsonCosMapper(); + + private final ObjectMapper objectMapper = + JsonMapper.builder() + .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .build(); + + private PdfJsonConversionService service; + + private final List createdTempFiles = new ArrayList<>(); + + @BeforeEach + void setUp() throws IOException { + service = + new PdfJsonConversionService( + pdfDocumentFactory, + objectMapper, + endpointConfiguration, + tempFileManager, + taskManager, + cosMapper, + fallbackFontService, + fontService, + type3FontConversionService, + type3GlyphExtractor, + applicationProperties); + + when(tempFileManager.createTempFile(anyString())) + .thenAnswer( + invocation -> { + String suffix = invocation.getArgument(0); + Path path = Files.createTempFile("pdfjson-rt-test", suffix); + createdTempFiles.add(path); + return path.toFile(); + }); + when(tempFileManager.deleteTempFile(any(File.class))) + .thenAnswer( + invocation -> { + File file = invocation.getArgument(0); + return file != null && file.delete(); + }); + when(fallbackFontService.buildFallbackFontModel()) + .thenAnswer( + invocation -> + PdfJsonFont.builder() + .id(PdfJsonFallbackFontService.FALLBACK_FONT_ID) + .uid(PdfJsonFallbackFontService.FALLBACK_FONT_ID) + .baseName("Fallback") + .subtype("TrueType") + .build()); + when(fallbackFontService.loadFallbackPdfFont(any(PDDocument.class))) + .thenAnswer(invocation -> new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + } + + @AfterEach + void tearDown() throws IOException { + for (Path path : createdTempFiles) { + Files.deleteIfExists(path); + } + createdTempFiles.clear(); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private MockMultipartFile pdfMultipart(byte[] bytes) { + return new MockMultipartFile("fileInput", "input.pdf", "application/pdf", bytes); + } + + private byte[] toBytes(PDDocument document) throws IOException { + try (document) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } + } + + /** Stubs the factory to load from a Path (used by convertPdfToJson). */ + private void stubFactoryFromPath() throws IOException { + when(pdfDocumentFactory.load(any(Path.class), eq(true))) + .thenAnswer( + invocation -> + Loader.loadPDF(invocation.getArgument(0, Path.class).toFile())); + } + + private PdfJsonDocument toJsonDocument(byte[] pdfBytes) throws IOException { + stubFactoryFromPath(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertPdfToJson(pdfMultipart(pdfBytes), out); + return objectMapper.readValue(out.toByteArray(), PdfJsonDocument.class); + } + + private byte[] runJsonToPdf(PdfJsonDocument doc) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.convertJsonToPdf(doc, out); + return out.toByteArray(); + } + + private byte[] twoLineTextPdf() throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12f); + cs.newLineAtOffset(72, 700); + cs.showText("First line of text"); + cs.newLineAtOffset(0, -16); + cs.showText("Second line of text"); + cs.endText(); + } + return toBytes(document); + } + + private byte[] formFieldPdf() throws IOException { + PDDocument document = new PDDocument(); + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + + PDAcroForm acroForm = new PDAcroForm(document); + // A font in default resources plus a /DA string so setValue can build appearances. + PDResources dr = new PDResources(); + dr.put( + org.apache.pdfbox.cos.COSName.getPDFName("Helv"), + new org.apache.pdfbox.pdmodel.font.PDType1Font( + org.apache.pdfbox.pdmodel.font.Standard14Fonts.FontName.HELVETICA)); + acroForm.setDefaultResources(dr); + acroForm.setDefaultAppearance("/Helv 12 Tf 0 g"); + acroForm.setNeedAppearances(true); + document.getDocumentCatalog().setAcroForm(acroForm); + + PDTextField field = new PDTextField(acroForm); + field.setPartialName("firstName"); + field.setDefaultAppearance("/Helv 12 Tf 0 g"); + + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(new PDRectangle(100, 650, 200, 20)); + widget.setPage(page); + List widgets = new ArrayList<>(field.getWidgets()); + widgets.add(widget); + field.setWidgets(widgets); + + acroForm.getFields().add(field); + page.getAnnotations().add(widget); + field.setValue("Jane"); + + return toBytes(document); + } + + // ------------------------------------------------------------------ + // Content stream + resource preservation + // ------------------------------------------------------------------ + + @Nested + @DisplayName("content stream and resource preservation") + class ContentStreamPreservation { + + @Test + @DisplayName("non-lightweight extraction captures content streams and resources") + void capturesStreamsAndResources() throws IOException { + PdfJsonDocument doc = toJsonDocument(twoLineTextPdf()); + PdfJsonPage page = doc.getPages().get(0); + assertNotNull(page.getResources(), "expected serialized resources"); + assertThat(page.getContentStreams()).isNotEmpty(); + } + + @Test + @DisplayName("preserved content streams enable in-place token rewrite on round trip") + void tokenRewriteRoundTrip() throws IOException { + PdfJsonDocument doc = toJsonDocument(twoLineTextPdf()); + // Same-length edit keeps the rewrite path viable. + for (PdfJsonTextElement element : doc.getPages().get(0).getTextElements()) { + if (element.getText() != null && element.getText().contains("First")) { + element.setText(element.getText()); + break; + } + } + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + assertNotNull(loaded.getPage(0).getContents()); + } + } + + @Test + @DisplayName("round trip preserves the two-line page intact") + void preservesTwoLines() throws IOException { + PdfJsonDocument doc = toJsonDocument(twoLineTextPdf()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(1, loaded.getNumberOfPages()); + } + } + } + + // ------------------------------------------------------------------ + // Form fields + // ------------------------------------------------------------------ + + @Nested + @DisplayName("form fields") + class FormFields { + + @Test + @DisplayName("AcroForm text field is extracted with name and value") + void extractsTextField() throws IOException { + PdfJsonDocument doc = toJsonDocument(formFieldPdf()); + List fields = doc.getFormFields(); + assertThat(fields).isNotEmpty(); + PdfJsonFormField field = fields.get(0); + assertThat(field.getPartialName()).isEqualTo("firstName"); + // The service stores the raw COS representation of the field value. + assertThat(field.getValue()).contains("Jane"); + assertThat(field.getRawData()).isNotNull(); + } + + @Test + @DisplayName("form field round trips back into a rebuilt AcroForm") + void formFieldRoundTrip() throws IOException { + PdfJsonDocument doc = toJsonDocument(formFieldPdf()); + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + PDAcroForm acroForm = loaded.getDocumentCatalog().getAcroForm(); + assertNotNull(acroForm, "rebuilt document should carry an AcroForm"); + assertThat(acroForm.getFields()).isNotEmpty(); + } + } + } + + // ------------------------------------------------------------------ + // XMP metadata + // ------------------------------------------------------------------ + + @Nested + @DisplayName("XMP metadata") + class XmpMetadata { + + @Test + @DisplayName("XMP packet survives a PDF to JSON to PDF round trip") + void xmpRoundTrip() throws IOException { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.LETTER)); + String xmp = + "" + + "" + + ""; + PDMetadata metadata = new PDMetadata(document); + metadata.importXMPMetadata(xmp.getBytes(StandardCharsets.UTF_8)); + document.getDocumentCatalog().setMetadata(metadata); + byte[] bytes = toBytes(document); + + PdfJsonDocument doc = toJsonDocument(bytes); + assertThat(doc.getXmpMetadata()).isNotBlank(); + // Round-tripped base64 should decode back to XMP content. + String decoded = + new String( + Base64.getDecoder().decode(doc.getXmpMetadata()), + StandardCharsets.UTF_8); + assertThat(decoded).contains("xmpmeta"); + + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertNotNull(loaded.getDocumentCatalog().getMetadata()); + } + } + } + + // ------------------------------------------------------------------ + // convertPdfToJsonDocument mutate-then-rebuild + // ------------------------------------------------------------------ + + @Nested + @DisplayName("convertPdfToJsonDocument workflow") + class DocumentWorkflow { + + @Test + @DisplayName("in-memory model can be mutated and rebuilt into a PDF") + void mutateAndRebuild() throws IOException { + stubFactoryFromPath(); + PdfJsonDocument doc = service.convertPdfToJsonDocument(pdfMultipart(twoLineTextPdf())); + assertNotNull(doc); + assertEquals(1, doc.getPages().size()); + + // Append a brand new page with synthesized text. + PdfJsonFont font = + PdfJsonFont.builder() + .id("F-new") + .uid("F-new") + .baseName("Helvetica") + .subtype("Type1") + .standard14Name("Helvetica") + .build(); + doc.getFonts().add(font); + + PdfJsonTextElement element = + PdfJsonTextElement.builder() + .text("Appended page") + .fontId("F-new") + .fontSize(12f) + .x(72f) + .y(700f) + .build(); + PdfJsonPage newPage = + PdfJsonPage.builder() + .pageNumber(2) + .width(612f) + .height(792f) + .textElements(List.of(element)) + .build(); + List pages = new ArrayList<>(doc.getPages()); + pages.add(newPage); + doc.setPages(pages); + + byte[] rebuilt = runJsonToPdf(doc); + try (PDDocument loaded = Loader.loadPDF(rebuilt)) { + assertEquals(2, loaded.getNumberOfPages()); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/PdfMetricsServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/PdfMetricsServiceTest.java new file mode 100644 index 0000000000..997e5936c8 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/PdfMetricsServiceTest.java @@ -0,0 +1,79 @@ +package stirling.software.SPDF.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.PostHogService; + +class PdfMetricsServiceTest { + + private PostHogService postHogService; + private ApplicationProperties applicationProperties; + private PdfMetricsService service; + + @BeforeEach + void setUp() { + postHogService = mock(PostHogService.class); + applicationProperties = new ApplicationProperties(); + applicationProperties.getSystem().setEnableAnalytics(true); + service = new PdfMetricsService(postHogService, applicationProperties); + } + + @Test + void flushesOperationAndPdfCounts() { + service.recordOperation(1); + service.recordOperation(2); + + service.flushMetrics(); + + Map event = captureEvent(); + assertEquals("api", event.get("source")); + assertEquals(2L, event.get("operations")); + assertEquals(3L, event.get("pdfs")); + } + + @Test + void sendsOnlyDeltasBetweenFlushes() { + service.recordOperation(1); + service.flushMetrics(); + reset(postHogService); + + service.flushMetrics(); + verify(postHogService, never()).captureEvent(eq("pdf_operation_metrics"), anyMap()); + + service.recordOperation(2); + service.flushMetrics(); + + Map event = captureEvent(); + assertEquals(1L, event.get("operations")); + assertEquals(2L, event.get("pdfs")); + } + + @Test + void doesNothingWhenAnalyticsDisabled() { + applicationProperties.getSystem().setEnableAnalytics(false); + + service.recordOperation(1); + service.flushMetrics(); + + verify(postHogService, never()).captureEvent(eq("pdf_operation_metrics"), anyMap()); + } + + private Map captureEvent() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(postHogService).captureEvent(eq("pdf_operation_metrics"), captor.capture()); + return captor.getValue(); + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/PdfSigningServiceImplTest.java b/app/core/src/test/java/stirling/software/SPDF/service/PdfSigningServiceImplTest.java new file mode 100644 index 0000000000..889c1ee98a --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/PdfSigningServiceImplTest.java @@ -0,0 +1,173 @@ +package stirling.software.SPDF.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; +import java.nio.file.Files; +import java.security.KeyStore; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springframework.core.io.ClassPathResource; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.SPDF.controller.api.security.CertSignController; +import stirling.software.common.service.CustomPDFDocumentFactory; + +class PdfSigningServiceImplTest { + + // Real PKCS12 fixture so CreateSignature can read aliases/key/chain; sign() itself is mocked. + private static KeyStore realKeystore() throws Exception { + KeyStore ks = KeyStore.getInstance("PKCS12"); + try (InputStream is = new ClassPathResource("certs/test-cert.p12").getInputStream()) { + ks.load(is, "password".toCharArray()); + } + return ks; + } + + @Nested + @DisplayName("signWithKeystore") + class SignWithKeystore { + + @Test + @DisplayName("wires arguments through to CertSignController and returns the output bytes") + void wiresArgsAndReturnsBytes() throws Exception { + CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class); + PdfSigningServiceImpl service = new PdfSigningServiceImpl(factory); + KeyStore keystore = realKeystore(); + byte[] pdf = "%PDF-1.4 fake".getBytes(); + + ArgumentCaptor fileCaptor = ArgumentCaptor.forClass(MultipartFile.class); + ArgumentCaptor outCaptor = + ArgumentCaptor.forClass(ByteArrayOutputStream.class); + + try (MockedStatic signer = mockStatic(CertSignController.class)) { + signer.when( + () -> + CertSignController.sign( + any(), + any(), + any(), + any(), + eq(true), + eq(2), + eq("Alice"), + eq("London"), + eq("approval"), + eq(false))) + .thenAnswer( + inv -> { + ByteArrayOutputStream out = inv.getArgument(2); + out.write("signed".getBytes()); + return null; + }); + + byte[] result = + service.signWithKeystore( + pdf, + keystore, + "password".toCharArray(), + true, + 2, + "Alice", + "London", + "approval", + false); + + assertThat(new String(result)).isEqualTo("signed"); + + signer.verify( + () -> + CertSignController.sign( + any(), + fileCaptor.capture(), + outCaptor.capture(), + any(), + eq(true), + eq(2), + eq("Alice"), + eq("London"), + eq("approval"), + eq(false))); + + // Exercise the private ByteArrayMultipartFile wrapper passed to sign(). + MultipartFile wrapper = fileCaptor.getValue(); + assertThat(wrapper.getName()).isEqualTo("file"); + assertThat(wrapper.getOriginalFilename()).isEqualTo("document.pdf"); + assertThat(wrapper.getContentType()).isEqualTo("application/pdf"); + assertThat(wrapper.isEmpty()).isFalse(); + assertThat(wrapper.getSize()).isEqualTo(pdf.length); + assertThat(wrapper.getBytes()).isEqualTo(pdf); + try (InputStream in = wrapper.getInputStream()) { + assertThat(in.readAllBytes()).isEqualTo(pdf); + } + + File dest = File.createTempFile("sign-wrapper", ".pdf"); + dest.deleteOnExit(); + wrapper.transferTo(dest); + assertThat(Files.readAllBytes(dest.toPath())).isEqualTo(pdf); + } + } + + @Test + @DisplayName("empty pdf bytes mark the wrapper as empty") + void emptyWrapper() throws Exception { + CustomPDFDocumentFactory factory = mock(CustomPDFDocumentFactory.class); + PdfSigningServiceImpl service = new PdfSigningServiceImpl(factory); + ArgumentCaptor fileCaptor = ArgumentCaptor.forClass(MultipartFile.class); + + try (MockedStatic signer = mockStatic(CertSignController.class)) { + signer.when( + () -> + CertSignController.sign( + any(), + any(), + any(), + any(), + org.mockito.ArgumentMatchers.anyBoolean(), + any(), + any(), + any(), + any(), + org.mockito.ArgumentMatchers.anyBoolean())) + .thenAnswer(inv -> null); + + service.signWithKeystore( + new byte[0], + realKeystore(), + "password".toCharArray(), + false, + null, + null, + null, + null, + false); + + signer.verify( + () -> + CertSignController.sign( + any(), + fileCaptor.capture(), + any(), + any(), + org.mockito.ArgumentMatchers.anyBoolean(), + any(), + any(), + any(), + any(), + org.mockito.ArgumentMatchers.anyBoolean())); + assertThat(fileCaptor.getValue().isEmpty()).isTrue(); + assertThat(fileCaptor.getValue().getSize()).isZero(); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/VeraPDFServiceMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/service/VeraPDFServiceMoreTest.java new file mode 100644 index 0000000000..349193aa73 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/VeraPDFServiceMoreTest.java @@ -0,0 +1,224 @@ +package stirling.software.SPDF.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.verapdf.pdfa.flavours.PDFAFlavour; +import org.verapdf.pdfa.results.TestAssertion; +import org.verapdf.pdfa.results.ValidationResult; + +import stirling.software.SPDF.model.api.security.PDFVerificationResult; + +/** + * Additional branch coverage for {@link VeraPDFService}: the private result-building helpers across + * PDF/A, PDF/UA and WTPDF flavours. These exercise the pure mapping logic with mocked veraPDF + * results, so no document parsing or validation engine is invoked. + */ +@DisplayName("VeraPDFService additional branch tests") +class VeraPDFServiceMoreTest { + + @SuppressWarnings("unchecked") + private static T invokeStatic(String name, Class[] types, Object... args) + throws Exception { + Method m = VeraPDFService.class.getDeclaredMethod(name, types); + m.setAccessible(true); + try { + return (T) m.invoke(null, args); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof Exception ex) { + throw ex; + } + throw new RuntimeException(cause); + } + } + + @Nested + @DisplayName("getStandardName flavour families") + class StandardName { + + private String name(PDFAFlavour flavour) throws Exception { + return invokeStatic("getStandardName", new Class[] {PDFAFlavour.class}, flavour); + } + + @Test + @DisplayName("PDF/A-4 maps to a PDF/A- name") + void pdfA4() throws Exception { + assertThat(name(PDFAFlavour.PDFA_4)).startsWith("PDF/A-"); + } + + @Test + @DisplayName("PDF/UA-1 maps to a PDF/UA- name") + void pdfUa1() throws Exception { + assertThat(name(PDFAFlavour.PDFUA_1)).startsWith("PDF/UA-"); + } + + @Test + @DisplayName("WTPDF flavour falls through to the raw flavour id") + void wtpdf() throws Exception { + // WTPDF ids ("wt1r") do not contain the "wtpdf" token, so the method returns toString() + assertThat(name(PDFAFlavour.WTPDF_1_0_REUSE)) + .isEqualTo(PDFAFlavour.WTPDF_1_0_REUSE.toString()); + } + } + + @Nested + @DisplayName("isPdfaFlavour") + class IsPdfa { + + private boolean isPdfa(PDFAFlavour flavour) throws Exception { + return invokeStatic("isPdfaFlavour", new Class[] {PDFAFlavour.class}, flavour); + } + + @Test + @DisplayName("true for PDF/A flavours, false for PDF/UA") + void families() throws Exception { + assertThat(isPdfa(PDFAFlavour.PDFA_2_B)).isTrue(); + assertThat(isPdfa(PDFAFlavour.PDFUA_1)).isFalse(); + } + } + + @Nested + @DisplayName("buildErrorResult flavour branches") + class ErrorResult { + + private PDFVerificationResult build( + PDFAFlavour declared, PDFAFlavour validation, String message) throws Exception { + return invokeStatic( + "buildErrorResult", + new Class[] {PDFAFlavour.class, PDFAFlavour.class, String.class}, + declared, + validation, + message); + } + + @Test + @DisplayName("non-PDF/A validation flavour (PDF/UA) keeps that standard id with errors") + void uaValidationFlavour() throws Exception { + PDFVerificationResult result = build(null, PDFAFlavour.PDFUA_1, "broken"); + assertThat(result.isCompliant()).isFalse(); + assertThat(result.getStandardName()).contains("with errors"); + assertThat(result.getValidationProfile()).isEqualTo(PDFAFlavour.PDFUA_1.getId()); + assertThat(result.getFailures()).hasSize(1); + assertThat(result.getFailures().get(0).getMessage()).isEqualTo("broken"); + } + + @Test + @DisplayName("PDF/A validation flavour with no declaration maps to not-pdfa standard") + void pdfaValidationNoDeclaration() throws Exception { + PDFVerificationResult result = build(null, PDFAFlavour.PDFA_2_B, "oops"); + assertThat(result.getStandard()).isEqualTo("not-pdfa"); + assertThat(result.isDeclaredPdfa()).isFalse(); + assertThat(result.getValidationProfile()).isEqualTo(PDFAFlavour.PDFA_2_B.getId()); + } + } + + @Nested + @DisplayName("convertToVerificationResult") + class ConvertResult { + + private PDFVerificationResult convert( + ValidationResult result, PDFAFlavour declared, PDFAFlavour validation) + throws Exception { + return invokeStatic( + "convertToVerificationResult", + new Class[] {ValidationResult.class, PDFAFlavour.class, PDFAFlavour.class}, + result, + declared, + validation); + } + + @Test + @DisplayName("compliant PDF/A result with no failed assertions is marked compliant") + void compliantPdfa() throws Exception { + ValidationResult vr = mock(ValidationResult.class); + lenient().when(vr.isCompliant()).thenReturn(true); + lenient().when(vr.getPDFAFlavour()).thenReturn(PDFAFlavour.PDFA_2_B); + when(vr.getTestAssertions()).thenReturn(Collections.emptyList()); + + PDFVerificationResult result = convert(vr, PDFAFlavour.PDFA_2_B, PDFAFlavour.PDFA_2_B); + + assertThat(result.isCompliant()).isTrue(); + assertThat(result.isDeclaredPdfa()).isTrue(); + assertThat(result.getStandard()).isEqualTo(PDFAFlavour.PDFA_2_B.getId()); + assertThat(result.getStandardName()).contains("compliant"); + assertThat(result.getTotalFailures()).isZero(); + } + + @Test + @DisplayName("failed assertions are collected and the result is non-compliant") + void failedAssertionsCollected() throws Exception { + TestAssertion failing = mock(TestAssertion.class); + when(failing.getStatus()).thenReturn(TestAssertion.Status.FAILED); + lenient().when(failing.getRuleId()).thenReturn(null); + lenient().when(failing.getMessage()).thenReturn("rule violated"); + lenient().when(failing.getLocation()).thenReturn(null); + + ValidationResult vr = mock(ValidationResult.class); + lenient().when(vr.isCompliant()).thenReturn(false); + lenient().when(vr.getPDFAFlavour()).thenReturn(PDFAFlavour.PDFA_2_B); + when(vr.getTestAssertions()).thenReturn(List.of(failing)); + + PDFVerificationResult result = convert(vr, PDFAFlavour.PDFA_2_B, PDFAFlavour.PDFA_2_B); + + assertThat(result.isCompliant()).isFalse(); + assertThat(result.getTotalFailures()).isEqualTo(1); + assertThat(result.getStandardName()).contains("with errors"); + } + + @Test + @DisplayName("PDF/UA validation flavour is reported as the declared standard") + void uaFlavour() throws Exception { + ValidationResult vr = mock(ValidationResult.class); + lenient().when(vr.isCompliant()).thenReturn(true); + lenient().when(vr.getPDFAFlavour()).thenReturn(PDFAFlavour.PDFUA_1); + when(vr.getTestAssertions()).thenReturn(Collections.emptyList()); + + PDFVerificationResult result = convert(vr, PDFAFlavour.PDFUA_1, PDFAFlavour.PDFUA_1); + + assertThat(result.getStandard()).isEqualTo(PDFAFlavour.PDFUA_1.getId()); + assertThat(result.getValidationProfile()).isEqualTo(PDFAFlavour.PDFUA_1.getId()); + assertThat(result.getValidationProfileName()).startsWith("PDF/UA-"); + } + } + + @Nested + @DisplayName("createValidationIssue with a populated rule id") + class ValidationIssue { + + @Test + @DisplayName("rule id, clause, specification and test number are copied") + void populatedRuleId() throws Exception { + org.verapdf.pdfa.validation.profiles.RuleId ruleId = + mock(org.verapdf.pdfa.validation.profiles.RuleId.class); + when(ruleId.getClause()).thenReturn("6.1.2"); + when(ruleId.getTestNumber()).thenReturn(7); + lenient().when(ruleId.getSpecification()).thenReturn(null); + + TestAssertion assertion = mock(TestAssertion.class); + when(assertion.getRuleId()).thenReturn(ruleId); + when(assertion.getMessage()).thenReturn("clause violated"); + lenient().when(assertion.getLocation()).thenReturn(null); + + PDFVerificationResult.ValidationIssue issue = + invokeStatic( + "createValidationIssue", + new Class[] {TestAssertion.class}, + assertion); + + assertThat(issue.getClause()).isEqualTo("6.1.2"); + assertThat(issue.getTestNumber()).isEqualTo("7"); + assertThat(issue.getMessage()).isEqualTo("clause violated"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontServiceMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontServiceMoreTest.java new file mode 100644 index 0000000000..777c855cb6 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontServiceMoreTest.java @@ -0,0 +1,497 @@ +package stirling.software.SPDF.service.pdfjson; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.util.Base64; +import java.util.List; + +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.mockito.MockedStatic; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.ProcessExecutor; +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; +import stirling.software.common.util.TempFileManager; + +/** + * Gap coverage for PdfJsonFontService - exercises loadConfiguration, isCommandAvailable, + * buildPythonCommand, the method-dispatch branches and the Python / FontForge conversion bodies + * (configured / unconfigured / rc!=0 / success) with a mocked ProcessExecutor. + */ +class PdfJsonFontServiceMoreTest { + + private TempFileManager tempFileManager; + private ApplicationProperties applicationProperties; + private PdfJsonFontService service; + + @BeforeEach + void setUp() { + tempFileManager = mock(TempFileManager.class); + applicationProperties = mock(ApplicationProperties.class); + service = new PdfJsonFontService(tempFileManager, applicationProperties); + } + + private void setField(String name, Object value) throws Exception { + Field f = PdfJsonFontService.class.getDeclaredField(name); + f.setAccessible(true); + f.set(service, value); + } + + private Object invoke(String method, Class[] sig, Object... args) throws Exception { + Method m = PdfJsonFontService.class.getDeclaredMethod(method, sig); + m.setAccessible(true); + return m.invoke(service, args); + } + + private void stubRealTempFiles() throws Exception { + when(tempFileManager.createTempFile(anyString())) + .thenAnswer( + inv -> { + String suffix = inv.getArgument(0); + File f = Files.createTempFile("fontsvc-test", suffix).toFile(); + f.deleteOnExit(); + return f; + }); + } + + @Nested + @DisplayName("loadConfiguration / initialise") + class Configuration { + + @Test + @DisplayName("real enabled config populates fields and checks availability") + void initialise_enabledConfig() throws Exception { + ApplicationProperties props = new ApplicationProperties(); + // defaults: cffConverter.enabled = true, method = python + PdfJsonFontService svc = new PdfJsonFontService(tempFileManager, props); + + Method m = + PdfJsonFontService.class.getDeclaredMethod( + "initialiseCffConverterAvailability"); + m.setAccessible(true); + m.invoke(svc); + + assertTrue(svc.isCffConversionEnabled()); + assertEquals("python", svc.getCffConverterMethod()); + } + + @Test + @DisplayName("disabled config short-circuits availability checks") + void initialise_disabledConfig() throws Exception { + ApplicationProperties props = new ApplicationProperties(); + props.getPdfEditor().getCffConverter().setEnabled(false); + PdfJsonFontService svc = new PdfJsonFontService(tempFileManager, props); + + Method m = + PdfJsonFontService.class.getDeclaredMethod( + "initialiseCffConverterAvailability"); + m.setAccessible(true); + m.invoke(svc); + + assertFalse(svc.isCffConversionEnabled()); + } + + @Test + @DisplayName("null pdfEditor config disables CFF conversion") + void loadConfiguration_nullPdfEditor() throws Exception { + when(applicationProperties.getPdfEditor()).thenReturn(null); + invoke("loadConfiguration", new Class[] {}); + assertFalse(service.isCffConversionEnabled()); + } + } + + @Nested + @DisplayName("isCommandAvailable") + class CommandAvailability { + + @Test + @DisplayName("null or blank command returns false") + void nullOrBlank_false() throws Exception { + assertEquals( + false, + invoke("isCommandAvailable", new Class[] {String.class}, (Object) null)); + assertEquals(false, invoke("isCommandAvailable", new Class[] {String.class}, " ")); + } + + @Test + @DisplayName("non-existent command returns false") + void nonExistent_false() throws Exception { + assertEquals( + false, + invoke( + "isCommandAvailable", + new Class[] {String.class}, + "definitely-not-a-real-command-xyz123")); + } + } + + @Nested + @DisplayName("buildPythonCommand") + class BuildPythonCommand { + + @Test + @DisplayName("without toUnicode produces 6-element command") + void withoutToUnicode() throws Exception { + setField("pythonCommand", "python3"); + setField("pythonScript", "/script.py"); + String[] cmd = + (String[]) + invoke( + "buildPythonCommand", + new Class[] {String.class, String.class, String.class}, + "in.cff", + "out.otf", + null); + assertThat(cmd) + .containsExactly( + "python3", "/script.py", "--input", "in.cff", "--output", "out.otf"); + } + + @Test + @DisplayName("with toUnicode appends --to-unicode") + void withToUnicode() throws Exception { + setField("pythonCommand", "python3"); + setField("pythonScript", "/script.py"); + String[] cmd = + (String[]) + invoke( + "buildPythonCommand", + new Class[] {String.class, String.class, String.class}, + "in.cff", + "out.otf", + "uni.txt"); + assertThat(cmd).contains("--to-unicode", "uni.txt"); + assertEquals(8, cmd.length); + } + } + + @Nested + @DisplayName("convertCffProgramToTrueType dispatch") + class Dispatch { + + @Test + @DisplayName("python method, available, rc!=0 returns null") + void pythonMethod_rcFailure() throws Exception { + setField("cffConversionEnabled", true); + setField("cffConverterMethod", "python"); + setField("pythonCffConverterAvailable", true); + setField("pythonCommand", "python3"); + setField("pythonScript", "/script.py"); + stubRealTempFiles(); + + try (MockedStatic mocked = mockStatic(ProcessExecutor.class)) { + ProcessExecutor exec = mock(ProcessExecutor.class); + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + when(result.getRc()).thenReturn(1); + when(result.getMessages()).thenReturn("boom"); + when(exec.runCommandWithOutputHandling(anyList())).thenReturn(result); + mocked.when( + () -> + ProcessExecutor.getInstance( + ProcessExecutor.Processes.CFF_CONVERTER)) + .thenReturn(exec); + + assertNull(service.convertCffProgramToTrueType(new byte[] {1, 2, 3}, null)); + } + } + + @Test + @DisplayName("python method success returns produced bytes") + void pythonMethod_success() throws Exception { + setField("cffConversionEnabled", true); + setField("cffConverterMethod", "python"); + setField("pythonCffConverterAvailable", true); + setField("pythonCommand", "python3"); + setField("pythonScript", "/script.py"); + + // Capture the .otf temp file so the mocked process can populate it. + File[] otfHolder = new File[1]; + when(tempFileManager.createTempFile(anyString())) + .thenAnswer( + inv -> { + String suffix = inv.getArgument(0); + File f = Files.createTempFile("fontsvc-test", suffix).toFile(); + f.deleteOnExit(); + if (".otf".equals(suffix)) { + otfHolder[0] = f; + } + return f; + }); + + try (MockedStatic mocked = mockStatic(ProcessExecutor.class)) { + ProcessExecutor exec = mock(ProcessExecutor.class); + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + when(result.getRc()).thenReturn(0); + when(exec.runCommandWithOutputHandling(anyList())) + .thenAnswer( + inv -> { + Files.write( + otfHolder[0].toPath(), + new byte[] { + (byte) 0x4F, (byte) 0x54, (byte) 0x54, (byte) 0x4F + }); + return result; + }); + mocked.when( + () -> + ProcessExecutor.getInstance( + ProcessExecutor.Processes.CFF_CONVERTER)) + .thenReturn(exec); + + byte[] out = service.convertCffProgramToTrueType(new byte[] {1, 2, 3}, null); + assertNotNull(out); + assertEquals(4, out.length); + } + } + + @Test + @DisplayName("python conversion decodes toUnicode base64; invalid base64 returns null") + void pythonMethod_invalidToUnicode() throws Exception { + setField("cffConversionEnabled", true); + setField("cffConverterMethod", "python"); + setField("pythonCffConverterAvailable", true); + setField("pythonCommand", "python3"); + setField("pythonScript", "/script.py"); + stubRealTempFiles(); + + // No ProcessExecutor mock needed: decode fails before exec is reached. + byte[] out = service.convertCffProgramToTrueType(new byte[] {1, 2, 3}, "@@@notbase64"); + assertNull(out); + } + + @Test + @DisplayName("python conversion succeeds with valid toUnicode payload") + void pythonMethod_validToUnicode() throws Exception { + setField("cffConversionEnabled", true); + setField("cffConverterMethod", "python"); + setField("pythonCffConverterAvailable", true); + setField("pythonCommand", "python3"); + setField("pythonScript", "/script.py"); + + File[] otfHolder = new File[1]; + when(tempFileManager.createTempFile(anyString())) + .thenAnswer( + inv -> { + String suffix = inv.getArgument(0); + File f = Files.createTempFile("fontsvc-test", suffix).toFile(); + f.deleteOnExit(); + if (".otf".equals(suffix)) { + otfHolder[0] = f; + } + return f; + }); + + String toUnicode = Base64.getEncoder().encodeToString(new byte[] {10, 20, 30}); + + try (MockedStatic mocked = mockStatic(ProcessExecutor.class)) { + ProcessExecutor exec = mock(ProcessExecutor.class); + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + when(result.getRc()).thenReturn(0); + when(exec.runCommandWithOutputHandling(anyList())) + .thenAnswer( + inv -> { + Files.write(otfHolder[0].toPath(), new byte[] {1, 2, 3, 4, 5}); + return result; + }); + mocked.when( + () -> + ProcessExecutor.getInstance( + ProcessExecutor.Processes.CFF_CONVERTER)) + .thenReturn(exec); + + byte[] out = service.convertCffProgramToTrueType(new byte[] {9, 9}, toUnicode); + assertNotNull(out); + assertEquals(5, out.length); + } + } + + @Test + @DisplayName("fontforge method, available, rc!=0 returns null") + void fontForgeMethod_rcFailure() throws Exception { + setField("cffConversionEnabled", true); + setField("cffConverterMethod", "fontforge"); + setField("fontForgeCffConverterAvailable", true); + setField("fontforgeCommand", "fontforge"); + stubRealTempFiles(); + + try (MockedStatic mocked = mockStatic(ProcessExecutor.class)) { + ProcessExecutor exec = mock(ProcessExecutor.class); + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + when(result.getRc()).thenReturn(2); + when(exec.runCommandWithOutputHandling(anyList())).thenReturn(result); + mocked.when( + () -> + ProcessExecutor.getInstance( + ProcessExecutor.Processes.CFF_CONVERTER)) + .thenReturn(exec); + + assertNull(service.convertCffProgramToTrueType(new byte[] {1, 2, 3}, null)); + } + } + + @Test + @DisplayName("fontforge method success returns produced bytes") + void fontForgeMethod_success() throws Exception { + setField("cffConversionEnabled", true); + setField("cffConverterMethod", "fontforge"); + setField("fontForgeCffConverterAvailable", true); + setField("fontforgeCommand", "fontforge"); + + File[] ttfHolder = new File[1]; + when(tempFileManager.createTempFile(anyString())) + .thenAnswer( + inv -> { + String suffix = inv.getArgument(0); + File f = Files.createTempFile("fontsvc-test", suffix).toFile(); + f.deleteOnExit(); + if (".ttf".equals(suffix)) { + ttfHolder[0] = f; + } + return f; + }); + + try (MockedStatic mocked = mockStatic(ProcessExecutor.class)) { + ProcessExecutor exec = mock(ProcessExecutor.class); + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + when(result.getRc()).thenReturn(0); + when(exec.runCommandWithOutputHandling(anyList())) + .thenAnswer( + inv -> { + Files.write(ttfHolder[0].toPath(), new byte[] {7, 7, 7}); + return result; + }); + mocked.when( + () -> + ProcessExecutor.getInstance( + ProcessExecutor.Processes.CFF_CONVERTER)) + .thenReturn(exec); + + byte[] out = service.convertCffProgramToTrueType(new byte[] {1, 2, 3}, null); + assertNotNull(out); + assertEquals(3, out.length); + } + } + } + + @Nested + @DisplayName("convertCffUsingPython direct guards") + class PythonGuards { + + @Test + @DisplayName("not available returns null") + void notAvailable() throws Exception { + setField("pythonCffConverterAvailable", false); + assertNull( + invoke( + "convertCffUsingPython", + new Class[] {byte[].class, String.class}, + new byte[] {1}, + null)); + } + + @Test + @DisplayName("blank command/script returns null") + void notConfigured() throws Exception { + setField("pythonCffConverterAvailable", true); + setField("pythonCommand", " "); + setField("pythonScript", ""); + assertNull( + invoke( + "convertCffUsingPython", + new Class[] {byte[].class, String.class}, + new byte[] {1}, + null)); + } + } + + @Nested + @DisplayName("convertCffUsingFontForge direct guard") + class FontForgeGuard { + + @Test + @DisplayName("not available returns null") + void notAvailable() throws Exception { + setField("fontForgeCffConverterAvailable", false); + assertNull(service.convertCffUsingFontForge(new byte[] {1, 2, 3})); + } + + @Test + @DisplayName("rc==0 but no output file returns null") + void noOutputFile() throws Exception { + setField("fontForgeCffConverterAvailable", true); + setField("fontforgeCommand", "fontforge"); + // createTempFile returns a file that we then delete so it does not exist. + when(tempFileManager.createTempFile(anyString())) + .thenAnswer( + inv -> { + File f = + Files.createTempFile("fontsvc-test", inv.getArgument(0)) + .toFile(); + if (".ttf".equals(inv.getArgument(0))) { + Files.deleteIfExists(f.toPath()); + } + return f; + }); + + try (MockedStatic mocked = mockStatic(ProcessExecutor.class)) { + ProcessExecutor exec = mock(ProcessExecutor.class); + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + when(result.getRc()).thenReturn(0); + when(exec.runCommandWithOutputHandling(anyList())).thenReturn(result); + mocked.when( + () -> + ProcessExecutor.getInstance( + ProcessExecutor.Processes.CFF_CONVERTER)) + .thenReturn(exec); + + assertNull(service.convertCffUsingFontForge(new byte[] {1, 2, 3})); + } + } + + @Test + @DisplayName("rc==0 but empty output file returns null") + void emptyOutputFile() throws Exception { + setField("fontForgeCffConverterAvailable", true); + setField("fontforgeCommand", "fontforge"); + stubRealTempFiles(); // empty 0-byte temp files by default + + try (MockedStatic mocked = mockStatic(ProcessExecutor.class)) { + ProcessExecutor exec = mock(ProcessExecutor.class); + ProcessExecutorResult result = mock(ProcessExecutorResult.class); + when(result.getRc()).thenReturn(0); + when(exec.runCommandWithOutputHandling(anyList())).thenReturn(result); + mocked.when( + () -> + ProcessExecutor.getInstance( + ProcessExecutor.Processes.CFF_CONVERTER)) + .thenReturn(exec); + + assertNull(service.convertCffUsingFontForge(new byte[] {1, 2, 3})); + } + } + } + + @Nested + @DisplayName("detect* extra branches") + class DetectExtra { + + @Test + @DisplayName("detectFontFlavor recognises ttcf as cff and otf via OTTO") + void detectFlavorExtra() { + assertEquals("cff", service.detectFontFlavor(new byte[] {0x74, 0x74, 0x63, 0x66})); + List otfVariants = List.of(new byte[] {0x4F, 0x54, 0x54, 0x4F}); + for (byte[] otf : otfVariants) { + assertEquals("otf", service.detectFontFlavor(otf)); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/PdfJsonImageServiceMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/PdfJsonImageServiceMoreTest.java new file mode 100644 index 0000000000..3d84c75275 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/PdfJsonImageServiceMoreTest.java @@ -0,0 +1,477 @@ +package stirling.software.SPDF.service.pdfjson; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyFloat; +import static org.mockito.Mockito.*; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.imageio.ImageIO; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.util.Matrix; +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 stirling.software.SPDF.model.json.PdfJsonImageElement; + +/** + * Gap coverage for PdfJsonImageService - exercises the real draw / encode / extract paths using a + * genuine PNG image, plus transform / fallback dimension resolution and private helpers. + */ +class PdfJsonImageServiceMoreTest { + + private PdfJsonImageService service; + + @BeforeEach + void setUp() { + service = new PdfJsonImageService(); + } + + private byte[] pngBytes(int w, int h) throws IOException { + BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); + for (int x = 0; x < w; x++) { + for (int y = 0; y < h; y++) { + img.setRGB(x, y, Color.RED.getRGB()); + } + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + return baos.toByteArray(); + } + + private String pngBase64(int w, int h) throws IOException { + return Base64.getEncoder().encodeToString(pngBytes(w, h)); + } + + @Nested + @DisplayName("createImageXObject") + class CreateImageXObject { + + @Test + @DisplayName("valid PNG base64 creates a real XObject") + void validPng_returnsXObject() throws IOException { + try (PDDocument doc = new PDDocument()) { + PdfJsonImageElement element = new PdfJsonImageElement(); + element.setImageData(pngBase64(4, 3)); + element.setId("img-1"); + + PDImageXObject xobj = service.createImageXObject(doc, element); + assertNotNull(xobj); + assertEquals(4, xobj.getWidth()); + assertEquals(3, xobj.getHeight()); + } + } + + @Test + @DisplayName("null id falls back to random UUID name") + void nullId_randomName() throws IOException { + try (PDDocument doc = new PDDocument()) { + PdfJsonImageElement element = new PdfJsonImageElement(); + element.setImageData(pngBase64(2, 2)); + element.setId(null); + + PDImageXObject xobj = service.createImageXObject(doc, element); + assertNotNull(xobj); + } + } + } + + @Nested + @DisplayName("drawImageElement") + class DrawImageElement { + + @Test + @DisplayName("with 6-element transform draws via matrix") + void withTransform_drawsMatrix() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPageContentStream cs = mock(PDPageContentStream.class); + PdfJsonImageElement element = new PdfJsonImageElement(); + element.setImageData(pngBase64(5, 5)); + element.setId("t1"); + element.setTransform(new float[] {10f, 0f, 0f, 10f, 20f, 30f}); + Map cache = new HashMap<>(); + + service.drawImageElement(cs, doc, element, cache); + + verify(cs).drawImage(any(PDImageXObject.class), any(Matrix.class)); + assertThat(cache).hasSize(1); + } + } + + @Test + @DisplayName("transform with NaN values falls back to safe defaults") + void withNaNTransform_usesSafeFloat() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPageContentStream cs = mock(PDPageContentStream.class); + PdfJsonImageElement element = new PdfJsonImageElement(); + element.setImageData(pngBase64(5, 5)); + element.setId("t-nan"); + element.setTransform( + new float[] {Float.NaN, 0f, 0f, Float.POSITIVE_INFINITY, 0f, 0f}); + Map cache = new HashMap<>(); + + service.drawImageElement(cs, doc, element, cache); + + verify(cs).drawImage(any(PDImageXObject.class), any(Matrix.class)); + } + } + + @Test + @DisplayName("without transform uses explicit width/height/left/bottom") + void withoutTransform_explicitDims() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPageContentStream cs = mock(PDPageContentStream.class); + PdfJsonImageElement element = new PdfJsonImageElement(); + element.setImageData(pngBase64(5, 5)); + element.setId("d1"); + element.setWidth(50f); + element.setHeight(40f); + element.setLeft(12f); + element.setBottom(13f); + Map cache = new HashMap<>(); + + service.drawImageElement(cs, doc, element, cache); + + verify(cs).drawImage(any(PDImageXObject.class), eq(12f), eq(13f), eq(50f), eq(40f)); + } + } + + @Test + @DisplayName("without transform, zero dims fall back to native size") + void withoutTransform_zeroDimsFallBack() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPageContentStream cs = mock(PDPageContentStream.class); + PdfJsonImageElement element = new PdfJsonImageElement(); + element.setImageData(pngBase64(6, 7)); + element.setId("d2"); + element.setWidth(0f); + element.setHeight(0f); + element.setNativeWidth(6); + element.setNativeHeight(7); + Map cache = new HashMap<>(); + + service.drawImageElement(cs, doc, element, cache); + + verify(cs) + .drawImage( + any(PDImageXObject.class), + anyFloat(), + anyFloat(), + anyFloat(), + anyFloat()); + } + } + + @Test + @DisplayName("cache hit reuses XObject and does not recreate") + void cacheHit_reusesXObject() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPageContentStream cs = mock(PDPageContentStream.class); + PdfJsonImageElement element = new PdfJsonImageElement(); + element.setImageData(pngBase64(3, 3)); + element.setId("reuse"); + element.setTransform(new float[] {1f, 0f, 0f, 1f, 0f, 0f}); + Map cache = new HashMap<>(); + + service.drawImageElement(cs, doc, element, cache); + PDImageXObject first = cache.get("reuse"); + service.drawImageElement(cs, doc, element, cache); + PDImageXObject second = cache.get("reuse"); + + assertSame(first, second); + verify(cs, times(2)).drawImage(any(PDImageXObject.class), any(Matrix.class)); + } + } + + @Test + @DisplayName("undecodable image data short-circuits without drawing") + void badImageData_noDraw() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPageContentStream cs = mock(PDPageContentStream.class); + PdfJsonImageElement element = new PdfJsonImageElement(); + // valid base64 but not an image -> createImageXObject returns/throws -> no draw + element.setImageData("!!!not-base64!!!"); + element.setId("bad"); + Map cache = new HashMap<>(); + + service.drawImageElement(cs, doc, element, cache); + verifyNoInteractions(cs); + } + } + + @Test + @DisplayName("element with no id uses identity-hash cache key") + void noId_identityHashCacheKey() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPageContentStream cs = mock(PDPageContentStream.class); + PdfJsonImageElement element = new PdfJsonImageElement(); + element.setImageData(pngBase64(3, 3)); + element.setId(null); + element.setTransform(new float[] {1f, 0f, 0f, 1f, 0f, 0f}); + Map cache = new HashMap<>(); + + service.drawImageElement(cs, doc, element, cache); + assertThat(cache).hasSize(1); + } + } + } + + @Nested + @DisplayName("collectImages / extractImagesForPage with real image") + class ExtractWithRealImage { + + private void drawImageOnPage(PDDocument doc, PDPage page) throws IOException { + PDImageXObject image = PDImageXObject.createFromByteArray(doc, pngBytes(8, 8), "real"); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.drawImage(image, 50, 50, 80, 80); + } + } + + @Test + @DisplayName("extractImagesForPage returns the embedded image element") + void extractImagesForPage_findsImage() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + drawImageOnPage(doc, page); + + List result = service.extractImagesForPage(doc, page, 1); + assertThat(result).hasSize(1); + PdfJsonImageElement el = result.get(0); + assertNotNull(el.getImageData()); + assertNotNull(el.getImageFormat()); + assertEquals(8, el.getNativeWidth()); + assertEquals(8, el.getNativeHeight()); + assertNotNull(el.getTransform()); + assertEquals(6, el.getTransform().length); + assertFalse(el.getInlineImage()); + } + } + + @Test + @DisplayName("collectImages returns image and fires progress per page") + void collectImages_findsImage() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + drawImageOnPage(doc, page); + + var progress = + new java.util.ArrayList< + stirling.software.SPDF.model.api.PdfJsonConversionProgress>(); + Map> result = + service.collectImages(doc, 1, progress::add); + + assertThat(result).containsKey(1); + assertThat(result.get(1)).hasSize(1); + assertEquals(1, progress.size()); + } + } + + @Test + @DisplayName("same image drawn twice on a page is encoded once (cache reuse)") + void collectImages_cachesRepeatImage() throws IOException { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.LETTER); + doc.addPage(page); + PDImageXObject image = + PDImageXObject.createFromByteArray(doc, pngBytes(8, 8), "shared"); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.drawImage(image, 10, 10, 40, 40); + cs.drawImage(image, 100, 100, 40, 40); + } + + Map> result = + service.collectImages(doc, 1, p -> {}); + assertThat(result.get(1)).hasSize(2); + // both elements share identical base64 payload + assertEquals( + result.get(1).get(0).getImageData(), result.get(1).get(1).getImageData()); + } + } + } + + @Nested + @DisplayName("private helpers via reflection") + class PrivateHelpers { + + private Object invoke(String method, Class[] sig, Object... args) throws Exception { + Method m = PdfJsonImageService.class.getDeclaredMethod(method, sig); + m.setAccessible(true); + return m.invoke(service, args); + } + + @Test + @DisplayName("safeFloat replaces null / NaN / Infinity with default") + void safeFloat() throws Exception { + assertEquals( + 5f, invoke("safeFloat", new Class[] {Float.class, float.class}, null, 5f)); + assertEquals( + 9f, + invoke("safeFloat", new Class[] {Float.class, float.class}, Float.NaN, 9f)); + assertEquals( + 2f, + invoke( + "safeFloat", + new Class[] {Float.class, float.class}, + Float.POSITIVE_INFINITY, + 2f)); + assertEquals( + 7f, invoke("safeFloat", new Class[] {Float.class, float.class}, 7f, 0f)); + } + + @Test + @DisplayName("fallbackWidth/Height prefer bounds, then native, then 1") + void fallbackDims() throws Exception { + PdfJsonImageElement bounds = new PdfJsonImageElement(); + bounds.setLeft(10f); + bounds.setRight(40f); + bounds.setBottom(5f); + bounds.setTop(25f); + assertEquals( + 30f, + invoke("fallbackWidth", new Class[] {PdfJsonImageElement.class}, bounds)); + assertEquals( + 20f, + invoke("fallbackHeight", new Class[] {PdfJsonImageElement.class}, bounds)); + + PdfJsonImageElement nativeOnly = new PdfJsonImageElement(); + nativeOnly.setNativeWidth(123); + nativeOnly.setNativeHeight(456); + assertEquals( + 123f, + invoke( + "fallbackWidth", + new Class[] {PdfJsonImageElement.class}, + nativeOnly)); + assertEquals( + 456f, + invoke( + "fallbackHeight", + new Class[] {PdfJsonImageElement.class}, + nativeOnly)); + + PdfJsonImageElement empty = new PdfJsonImageElement(); + assertEquals( + 1f, invoke("fallbackWidth", new Class[] {PdfJsonImageElement.class}, empty)); + assertEquals( + 1f, + invoke("fallbackHeight", new Class[] {PdfJsonImageElement.class}, empty)); + } + + @Test + @DisplayName("resolveLeft prefers left, then x, then right-width, else 0") + void resolveLeft() throws Exception { + PdfJsonImageElement leftEl = new PdfJsonImageElement(); + leftEl.setLeft(11f); + assertEquals( + 11f, + invoke( + "resolveLeft", + new Class[] {PdfJsonImageElement.class, float.class}, + leftEl, + 10f)); + + PdfJsonImageElement xEl = new PdfJsonImageElement(); + xEl.setX(22f); + assertEquals( + 22f, + invoke( + "resolveLeft", + new Class[] {PdfJsonImageElement.class, float.class}, + xEl, + 10f)); + + PdfJsonImageElement rightEl = new PdfJsonImageElement(); + rightEl.setRight(100f); + assertEquals( + 70f, + invoke( + "resolveLeft", + new Class[] {PdfJsonImageElement.class, float.class}, + rightEl, + 30f)); + + PdfJsonImageElement none = new PdfJsonImageElement(); + assertEquals( + 0f, + invoke( + "resolveLeft", + new Class[] {PdfJsonImageElement.class, float.class}, + none, + 30f)); + } + + @Test + @DisplayName("resolveBottom prefers bottom, then y, then top-height, else 0") + void resolveBottom() throws Exception { + PdfJsonImageElement bottomEl = new PdfJsonImageElement(); + bottomEl.setBottom(11f); + assertEquals( + 11f, + invoke( + "resolveBottom", + new Class[] {PdfJsonImageElement.class, float.class}, + bottomEl, + 10f)); + + PdfJsonImageElement yEl = new PdfJsonImageElement(); + yEl.setY(22f); + assertEquals( + 22f, + invoke( + "resolveBottom", + new Class[] {PdfJsonImageElement.class, float.class}, + yEl, + 10f)); + + PdfJsonImageElement topEl = new PdfJsonImageElement(); + topEl.setTop(100f); + assertEquals( + 60f, + invoke( + "resolveBottom", + new Class[] {PdfJsonImageElement.class, float.class}, + topEl, + 40f)); + + PdfJsonImageElement none = new PdfJsonImageElement(); + assertEquals( + 0f, + invoke( + "resolveBottom", + new Class[] {PdfJsonImageElement.class, float.class}, + none, + 40f)); + } + + @Test + @DisplayName("toMatrixValues returns the six affine components") + void toMatrixValues() throws Exception { + Matrix m = new Matrix(2f, 0f, 0f, 3f, 4f, 5f); + float[] values = (float[]) invoke("toMatrixValues", new Class[] {Matrix.class}, m); + assertEquals(6, values.length); + assertEquals(2f, values[0]); + assertEquals(3f, values[3]); + assertEquals(4f, values[4]); + assertEquals(5f, values[5]); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/PdfLazyLoadingServiceMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/PdfLazyLoadingServiceMoreTest.java new file mode 100644 index 0000000000..a1b144bd6a --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/PdfLazyLoadingServiceMoreTest.java @@ -0,0 +1,223 @@ +package stirling.software.SPDF.service.pdfjson; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +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 stirling.software.SPDF.model.json.PdfJsonDocumentMetadata; +import stirling.software.SPDF.model.json.PdfJsonImageElement; +import stirling.software.SPDF.model.json.PdfJsonPageDimension; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.TaskManager; + +import tools.jackson.databind.ObjectMapper; + +/** + * Additional branch coverage for {@link PdfLazyLoadingService}: the cache-hit page extraction path, + * out-of-range page validation (both ends), and cache removal of an existing entry. Dependencies + * are mocked; a real in-memory PDF is handed back by the factory for the happy path. + */ +@DisplayName("PdfLazyLoadingService additional branch tests") +class PdfLazyLoadingServiceMoreTest { + + private PdfLazyLoadingService service; + private CustomPDFDocumentFactory pdfDocumentFactory; + private ObjectMapper objectMapper; + private TaskManager taskManager; + private PdfJsonMetadataService metadataService; + private PdfJsonImageService imageService; + + @BeforeEach + void setUp() { + pdfDocumentFactory = mock(CustomPDFDocumentFactory.class); + objectMapper = mock(ObjectMapper.class); + taskManager = mock(TaskManager.class); + metadataService = mock(PdfJsonMetadataService.class); + imageService = mock(PdfJsonImageService.class); + service = + new PdfLazyLoadingService( + pdfDocumentFactory, + objectMapper, + taskManager, + metadataService, + imageService); + } + + @SuppressWarnings("unchecked") + private Map cache() throws Exception { + Field f = PdfLazyLoadingService.class.getDeclaredField("documentCache"); + f.setAccessible(true); + return (Map) f.get(service); + } + + /** Reflectively builds a CachedPdfDocument and inserts it into the document cache. */ + private void seedCache(String jobId, byte[] pdfBytes, int pageCount) throws Exception { + PdfJsonDocumentMetadata metadata = new PdfJsonDocumentMetadata(); + List dims = new ArrayList<>(); + for (int i = 0; i < pageCount; i++) { + PdfJsonPageDimension d = new PdfJsonPageDimension(); + d.setPageNumber(i + 1); + d.setWidth(200); + d.setHeight(200); + dims.add(d); + } + metadata.setPageDimensions(dims); + + Class cachedClass = + Class.forName( + "stirling.software.SPDF.service.pdfjson.PdfLazyLoadingService$CachedPdfDocument"); + Constructor ctor = + cachedClass.getDeclaredConstructor(byte[].class, PdfJsonDocumentMetadata.class); + ctor.setAccessible(true); + Object cached = ctor.newInstance(pdfBytes, metadata); + cache().put(jobId, cached); + } + + private static byte[] tinyPdfBytes(int pages) throws Exception { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + for (int i = 0; i < pages; i++) { + doc.addPage(new PDPage(new PDRectangle(200, 200))); + } + doc.save(baos); + return baos.toByteArray(); + } + } + + private static PDDocument tinyDoc(int pages) { + PDDocument doc = new PDDocument(); + for (int i = 0; i < pages; i++) { + doc.addPage(new PDPage(new PDRectangle(200, 200))); + } + return doc; + } + + @Nested + @DisplayName("extractSinglePage cache hit") + class CacheHit { + + @Test + @DisplayName("extracts the requested page and writes JSON to the stream") + void extractsPage() throws Exception { + byte[] pdfBytes = tinyPdfBytes(2); + seedCache("job-hit", pdfBytes, 2); + + when(pdfDocumentFactory.load(eq(pdfBytes), eq(true))).thenReturn(tinyDoc(2)); + List images = new ArrayList<>(); + when(imageService.extractImagesForPage(any(), any(), eq(2))).thenReturn(images); + + doAnswer( + inv -> { + OutputStream os = inv.getArgument(0, OutputStream.class); + os.write(new byte[] {'p', 'g'}); + return null; + }) + .when(objectMapper) + .writeValue(any(OutputStream.class), any()); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + service.extractSinglePage( + "job-hit", + 2, + cos -> null, + page -> new ArrayList<>(), + cos -> cos, + (doc, pageNum) -> new ArrayList<>(), + (doc, pageNum) -> new ArrayList<>(), + out); + + assertThat(out.toByteArray()).hasSize(2); + verify(pdfDocumentFactory).load(eq(pdfBytes), eq(true)); + verify(imageService).extractImagesForPage(any(), any(), eq(2)); + } + } + + @Nested + @DisplayName("extractSinglePage out-of-range") + class OutOfRange { + + @Test + @DisplayName("page number above the page count is rejected") + void aboveRange() throws Exception { + seedCache("job-hi", tinyPdfBytes(1), 1); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + assertThatThrownBy( + () -> + service.extractSinglePage( + "job-hi", + 5, + cos -> null, + page -> new ArrayList<>(), + cos -> cos, + (doc, pageNum) -> new ArrayList<>(), + (doc, pageNum) -> new ArrayList<>(), + out)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("out of range"); + + // The factory is never consulted when validation fails. + verify(pdfDocumentFactory, never()).load(any(byte[].class), anyBoolean()); + } + + @Test + @DisplayName("page number below 1 is rejected") + void belowRange() throws Exception { + seedCache("job-lo", tinyPdfBytes(2), 2); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + assertThatThrownBy( + () -> + service.extractSinglePage( + "job-lo", + 0, + cos -> null, + page -> new ArrayList<>(), + cos -> cos, + (doc, pageNum) -> new ArrayList<>(), + (doc, pageNum) -> new ArrayList<>(), + out)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("out of range"); + } + } + + @Nested + @DisplayName("clearCachedDocument") + class ClearCache { + + @Test + @DisplayName("removes an existing cached entry") + void removesExisting() throws Exception { + seedCache("job-clear", tinyPdfBytes(1), 1); + assertThat(cache()).containsKey("job-clear"); + + service.clearCachedDocument("job-clear"); + + assertThat(cache()).doesNotContainKey("job-clear"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibraryTest.java b/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibraryTest.java new file mode 100644 index 0000000000..8cd556753c --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibraryTest.java @@ -0,0 +1,510 @@ +package stirling.software.SPDF.service.pdfjson.type3.library; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.font.PDType3Font; +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.mockito.MockedStatic; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +import stirling.software.SPDF.service.pdfjson.type3.Type3FontSignatureCalculator; +import stirling.software.common.model.ApplicationProperties; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Gap coverage for Type3FontLibrary - exercises initialise(), match() and the private payload / + * resource / alias helpers via reflection and a real classpath resource loader. + */ +class Type3FontLibraryTest { + + private final ObjectMapper objectMapper = JsonMapper.builder().build(); + private final ResourceLoader resourceLoader = new DefaultResourceLoader(); + + private Type3FontLibrary newLibrary(ResourceLoader loader, ApplicationProperties props) { + return new Type3FontLibrary(objectMapper, loader, props); + } + + private ApplicationProperties propsWithIndex(String indexLocation) { + ApplicationProperties props = new ApplicationProperties(); + props.getPdfEditor().getType3().getLibrary().setIndex(indexLocation); + return props; + } + + private void invokeInitialise(Type3FontLibrary library) throws Exception { + Method m = Type3FontLibrary.class.getDeclaredMethod("initialise"); + m.setAccessible(true); + m.invoke(library); + } + + private Object invoke(Type3FontLibrary library, String method, Class[] sig, Object... args) + throws Exception { + Method m = Type3FontLibrary.class.getDeclaredMethod(method, sig); + m.setAccessible(true); + return m.invoke(library, args); + } + + @Nested + @DisplayName("initialise()") + class Initialise { + + @Test + @DisplayName("loads real classpath index.json and populates entries / indexes") + void initialise_realIndex_loadsEntries() throws Exception { + Type3FontLibrary library = + newLibrary( + new DefaultResourceLoader(), + propsWithIndex("classpath:/type3/library/index.json")); + invokeInitialise(library); + + assertTrue(library.isLoaded()); + } + + @Test + @DisplayName("missing index disables library") + void initialise_missingIndex_disabled() throws Exception { + Type3FontLibrary library = + newLibrary( + new DefaultResourceLoader(), + propsWithIndex("classpath:/type3/library/does-not-exist.json")); + invokeInitialise(library); + + assertFalse(library.isLoaded()); + } + + @Test + @DisplayName("null Type3 config disables library and logs warning") + void initialise_nullConfig_disabled() throws Exception { + ApplicationProperties props = mock(ApplicationProperties.class); + ApplicationProperties.PdfEditor pdfEditor = mock(ApplicationProperties.PdfEditor.class); + when(props.getPdfEditor()).thenReturn(pdfEditor); + when(pdfEditor.getType3()).thenReturn(null); + + Type3FontLibrary library = newLibrary(new DefaultResourceLoader(), props); + invokeInitialise(library); + + assertFalse(library.isLoaded()); + } + + @Test + @DisplayName("null pdfEditor disables library") + void initialise_nullPdfEditor_disabled() throws Exception { + ApplicationProperties props = mock(ApplicationProperties.class); + when(props.getPdfEditor()).thenReturn(null); + + Type3FontLibrary library = newLibrary(new DefaultResourceLoader(), props); + invokeInitialise(library); + + assertFalse(library.isLoaded()); + } + + @Test + @DisplayName("malformed JSON index surfaces a Jackson read exception") + void initialise_malformedJson_throws() throws Exception { + ResourceLoader loader = mock(ResourceLoader.class); + Resource resource = mock(Resource.class); + when(loader.getResource("classpath:/bad.json")).thenReturn(resource); + when(resource.exists()).thenReturn(true); + when(resource.getInputStream()) + .thenReturn( + new ByteArrayInputStream("not json".getBytes(StandardCharsets.UTF_8))); + + Type3FontLibrary library = newLibrary(loader, propsWithIndex("classpath:/bad.json")); + // Jackson 3 throws an unchecked StreamReadException which the IOException-only + // catch in initialise() does not handle, so it propagates. + java.lang.reflect.InvocationTargetException ex = + assertThrows( + java.lang.reflect.InvocationTargetException.class, + () -> invokeInitialise(library)); + assertNotNull(ex.getCause()); + assertFalse(library.isLoaded()); + } + + @Test + @DisplayName("inline base64 program entry is loaded and indexed by signature + alias") + void initialise_inlineBase64_loaded() throws Exception { + String base64 = Base64.getEncoder().encodeToString(new byte[] {1, 2, 3, 4}); + String json = + "[{\"id\":\"e1\",\"label\":\"E1\"," + + "\"signatures\":[\"sha256:ABCDEF\"]," + + "\"aliases\":[\"ABCDEF+MyFont\",\" \",null]," + + "\"program\":{\"base64\":\"" + + base64 + + "\",\"format\":\"TTF\"}," + + "\"glyphCoverage\":[65,null,66]}]"; + ResourceLoader loader = mock(ResourceLoader.class); + Resource resource = mock(Resource.class); + when(loader.getResource("classpath:/inline.json")).thenReturn(resource); + when(resource.exists()).thenReturn(true); + when(resource.getInputStream()) + .thenReturn(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + + Type3FontLibrary library = newLibrary(loader, propsWithIndex("classpath:/inline.json")); + invokeInitialise(library); + + assertTrue(library.isLoaded()); + } + + @Test + @DisplayName("entry with no payload is filtered out") + void initialise_noPayload_filtered() throws Exception { + String json = "[{\"id\":\"empty\",\"label\":\"Empty\"}]"; + ResourceLoader loader = mock(ResourceLoader.class); + Resource resource = mock(Resource.class); + when(loader.getResource("classpath:/empty.json")).thenReturn(resource); + when(resource.exists()).thenReturn(true); + when(resource.getInputStream()) + .thenReturn(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + + Type3FontLibrary library = newLibrary(loader, propsWithIndex("classpath:/empty.json")); + invokeInitialise(library); + + assertFalse(library.isLoaded()); + } + + @Test + @DisplayName("entry with null id is skipped") + void initialise_nullId_skipped() throws Exception { + String base64 = Base64.getEncoder().encodeToString(new byte[] {9, 9, 9, 9}); + String json = "[{\"label\":\"NoId\",\"program\":{\"base64\":\"" + base64 + "\"}}]"; + ResourceLoader loader = mock(ResourceLoader.class); + Resource resource = mock(Resource.class); + when(loader.getResource("classpath:/noid.json")).thenReturn(resource); + when(resource.exists()).thenReturn(true); + when(resource.getInputStream()) + .thenReturn(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + + Type3FontLibrary library = newLibrary(loader, propsWithIndex("classpath:/noid.json")); + invokeInitialise(library); + + assertFalse(library.isLoaded()); + } + + @Test + @DisplayName("resource-based payload is read and re-encoded to base64") + void initialise_resourcePayload_loaded() throws Exception { + String json = + "[{\"id\":\"res\",\"label\":\"Res\"," + + "\"program\":{\"resource\":\"type3/library/fonts/dejavu/DejaVuSans.ttf\"," + + "\"format\":\"ttf\"}}]"; + ResourceLoader loader = new DefaultResourceLoader(); + Resource indexResource = mock(Resource.class); + ResourceLoader spyLoader = spy(loader); + when(spyLoader.getResource("classpath:/res.json")).thenReturn(indexResource); + when(indexResource.exists()).thenReturn(true); + when(indexResource.getInputStream()) + .thenReturn(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + + Type3FontLibrary library = newLibrary(spyLoader, propsWithIndex("classpath:/res.json")); + invokeInitialise(library); + + assertTrue(library.isLoaded()); + } + } + + @Nested + @DisplayName("match()") + class Match { + + @Test + @DisplayName("returns null when font is null") + void match_nullFont_returnsNull() throws Exception { + Type3FontLibrary library = + newLibrary( + new DefaultResourceLoader(), + propsWithIndex("classpath:/type3/library/index.json")); + invokeInitialise(library); + assertNull(library.match(null, "uid")); + } + + @Test + @DisplayName("returns null when no entries loaded") + void match_emptyLibrary_returnsNull() throws Exception { + Type3FontLibrary library = + newLibrary( + new DefaultResourceLoader(), + propsWithIndex("classpath:/type3/library/does-not-exist.json")); + invokeInitialise(library); + PDType3Font font = mock(PDType3Font.class); + assertNull(library.match(font, "uid")); + } + + @Test + @DisplayName("matches by signature using mocked signature calculator") + void match_bySignature_returnsSignatureMatch() throws Exception { + String base64 = Base64.getEncoder().encodeToString(new byte[] {1, 2, 3, 4}); + String json = + "[{\"id\":\"sig-entry\",\"label\":\"SigEntry\"," + + "\"signatures\":[\"sha256:DEADBEEF\"]," + + "\"program\":{\"base64\":\"" + + base64 + + "\"}}]"; + Type3FontLibrary library = libraryFromJson("classpath:/sig.json", json); + + PDType3Font font = mock(PDType3Font.class); + try (MockedStatic mocked = + mockStatic(Type3FontSignatureCalculator.class)) { + mocked.when(() -> Type3FontSignatureCalculator.computeSignature(font)) + .thenReturn("sha256:deadbeef"); + + Type3FontLibraryMatch match = library.match(font, "uid-1"); + assertNotNull(match); + assertEquals("signature", match.getMatchType()); + assertEquals("sig-entry", match.getEntry().getId()); + assertEquals("sha256:deadbeef", match.getSignature()); + } + } + + @Test + @DisplayName("falls back to alias match on BaseFont name") + void match_byAlias_returnsAliasMatch() throws Exception { + String base64 = Base64.getEncoder().encodeToString(new byte[] {1, 2, 3, 4}); + String json = + "[{\"id\":\"alias-entry\",\"label\":\"AliasEntry\"," + + "\"aliases\":[\"ABCDEF+CoolFont\"]," + + "\"program\":{\"base64\":\"" + + base64 + + "\"}}]"; + Type3FontLibrary library = libraryFromJson("classpath:/alias.json", json); + + PDType3Font font = mock(PDType3Font.class); + when(font.getName()).thenReturn("XYZXYZ+CoolFont"); + + try (MockedStatic mocked = + mockStatic(Type3FontSignatureCalculator.class)) { + mocked.when(() -> Type3FontSignatureCalculator.computeSignature(font)) + .thenReturn(null); + + Type3FontLibraryMatch match = library.match(font, "uid-2"); + assertNotNull(match); + assertThat(match.getMatchType()).startsWith("alias:"); + assertEquals("alias-entry", match.getEntry().getId()); + } + } + + @Test + @DisplayName("no signature and no alias match returns null") + void match_noMatch_returnsNull() throws Exception { + String base64 = Base64.getEncoder().encodeToString(new byte[] {1, 2, 3, 4}); + String json = + "[{\"id\":\"only\",\"label\":\"Only\"," + + "\"signatures\":[\"sha256:1111\"]," + + "\"program\":{\"base64\":\"" + + base64 + + "\"}}]"; + Type3FontLibrary library = libraryFromJson("classpath:/nomatch.json", json); + + PDType3Font font = mock(PDType3Font.class); + when(font.getName()).thenReturn("Unrelated"); + + try (MockedStatic mocked = + mockStatic(Type3FontSignatureCalculator.class)) { + mocked.when(() -> Type3FontSignatureCalculator.computeSignature(font)) + .thenReturn("sha256:9999"); + + assertNull(library.match(font, "uid-3")); + } + } + + @Test + @DisplayName("alias resolution falls back to COS BASE_FONT when getName throws") + void match_baseFontFromCos_whenGetNameThrows() throws Exception { + String base64 = Base64.getEncoder().encodeToString(new byte[] {1, 2, 3, 4}); + String json = + "[{\"id\":\"cos-entry\",\"label\":\"CosEntry\"," + + "\"aliases\":[\"CosFont\"]," + + "\"program\":{\"base64\":\"" + + base64 + + "\"}}]"; + Type3FontLibrary library = libraryFromJson("classpath:/cos.json", json); + + PDType3Font font = mock(PDType3Font.class); + when(font.getName()).thenThrow(new RuntimeException("boom")); + COSDictionary cos = new COSDictionary(); + cos.setName(COSName.BASE_FONT, "CosFont"); + when(font.getCOSObject()).thenReturn(cos); + + try (MockedStatic mocked = + mockStatic(Type3FontSignatureCalculator.class)) { + mocked.when(() -> Type3FontSignatureCalculator.computeSignature(font)) + .thenReturn(null); + + Type3FontLibraryMatch match = library.match(font, "uid-4"); + assertNotNull(match); + assertEquals("cos-entry", match.getEntry().getId()); + } + } + } + + @Nested + @DisplayName("private helpers") + class Helpers { + + private Type3FontLibrary library() { + return newLibrary(new DefaultResourceLoader(), new ApplicationProperties()); + } + + @Test + @DisplayName("normalizeAlias strips subset prefix and lowercases") + void normalizeAlias_stripsPrefix() throws Exception { + Type3FontLibrary lib = library(); + assertEquals( + "myfont", + invoke(lib, "normalizeAlias", new Class[] {String.class}, "ABCDEF+MyFont")); + assertEquals( + "plainname", + invoke(lib, "normalizeAlias", new Class[] {String.class}, " PlainName ")); + assertNull(invoke(lib, "normalizeAlias", new Class[] {String.class}, (Object) null)); + assertNull(invoke(lib, "normalizeAlias", new Class[] {String.class}, " ")); + // Trailing plus keeps original since plus is at end + assertEquals( + "name+", invoke(lib, "normalizeAlias", new Class[] {String.class}, "Name+")); + } + + @Test + @DisplayName("normalizeFormat trims and lowercases, null stays null") + void normalizeFormat() throws Exception { + Type3FontLibrary lib = library(); + assertEquals( + "ttf", invoke(lib, "normalizeFormat", new Class[] {String.class}, " TTF ")); + assertNull( + invoke(lib, "normalizeFormat", new Class[] {String.class}, (Object) null)); + } + + @Test + @DisplayName("resolveLocation adds classpath prefix appropriately") + void resolveLocation() throws Exception { + Type3FontLibrary lib = library(); + assertEquals( + "classpath:/a/b.ttf", + invoke(lib, "resolveLocation", new Class[] {String.class}, "a/b.ttf")); + assertEquals( + "classpath:/abs.ttf", + invoke(lib, "resolveLocation", new Class[] {String.class}, "/abs.ttf")); + assertEquals( + "file:/x.ttf", + invoke(lib, "resolveLocation", new Class[] {String.class}, "file:/x.ttf")); + assertNull( + invoke(lib, "resolveLocation", new Class[] {String.class}, (Object) null)); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("normalizeList trims, drops null/blank entries") + void normalizeList() throws Exception { + Type3FontLibrary lib = library(); + List in = java.util.Arrays.asList(" a ", null, "", "b"); + List out = + (List) invoke(lib, "normalizeList", new Class[] {List.class}, in); + assertEquals(List.of("a", "b"), out); + + List empty = + (List) + invoke( + lib, + "normalizeList", + new Class[] {List.class}, + (Object) null); + assertTrue(empty.isEmpty()); + } + + @Test + @DisplayName("loadResourceBytes throws for null / missing resource") + void loadResourceBytes_errors() throws Exception { + Type3FontLibrary lib = library(); + Method m = Type3FontLibrary.class.getDeclaredMethod("loadResourceBytes", String.class); + m.setAccessible(true); + + java.lang.reflect.InvocationTargetException ex1 = + assertThrows( + java.lang.reflect.InvocationTargetException.class, + () -> m.invoke(lib, (Object) null)); + assertInstanceOf(IOException.class, ex1.getCause()); + + java.lang.reflect.InvocationTargetException ex2 = + assertThrows( + java.lang.reflect.InvocationTargetException.class, + () -> m.invoke(lib, "type3/library/missing-font.ttf")); + assertInstanceOf(IOException.class, ex2.getCause()); + } + } + + private Type3FontLibrary libraryFromJson(String location, String json) throws Exception { + ResourceLoader loader = mock(ResourceLoader.class); + Resource resource = mock(Resource.class); + when(loader.getResource(location)).thenReturn(resource); + when(resource.exists()).thenReturn(true); + when(resource.getInputStream()) + .thenReturn(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + Type3FontLibrary library = newLibrary(loader, propsWithIndex(location)); + invokeInitialise(library); + return library; + } + + @BeforeEach + void resetState() { + // no shared state + } + + @Nested + @DisplayName("payload edge cases") + class PayloadEdges { + + @SuppressWarnings("unused") + @Test + @DisplayName("invalid base64 payload yields null payload (entry filtered)") + void invalidBase64_filtered() throws Exception { + // '@' is not valid base64 in the 4-char probe prefix + String json = + "[{\"id\":\"badb64\",\"label\":\"Bad\"," + + "\"program\":{\"base64\":\"@@@@invalid\"}}]"; + Type3FontLibrary library = libraryFromJson("classpath:/badb64.json", json); + assertFalse(library.isLoaded()); + } + + @Test + @DisplayName("internal index maps are populated for loaded entry") + void internalMaps_populated() throws Exception { + String base64 = Base64.getEncoder().encodeToString(new byte[] {1, 2, 3, 4}); + String json = + "[{\"id\":\"mapcheck\",\"label\":\"MapCheck\"," + + "\"signatures\":[\"sha256:CAFE\"]," + + "\"aliases\":[\"MapAlias\"]," + + "\"program\":{\"base64\":\"" + + base64 + + "\"}}]"; + Type3FontLibrary library = libraryFromJson("classpath:/mapcheck.json", json); + + Field sigIndex = Type3FontLibrary.class.getDeclaredField("signatureIndex"); + sigIndex.setAccessible(true); + Field aliasIndex = Type3FontLibrary.class.getDeclaredField("aliasIndex"); + aliasIndex.setAccessible(true); + + @SuppressWarnings("unchecked") + Map sigs = (Map) sigIndex.get(library); + @SuppressWarnings("unchecked") + Map aliases = (Map) aliasIndex.get(library); + assertThat(sigs).containsKey("sha256:cafe"); + assertThat(aliases).containsKey("mapalias"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/type3/tool/Type3SignatureToolMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/type3/tool/Type3SignatureToolMoreTest.java new file mode 100644 index 0000000000..6a534ac18c --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/pdfjson/type3/tool/Type3SignatureToolMoreTest.java @@ -0,0 +1,249 @@ +package stirling.software.SPDF.service.pdfjson.type3.tool; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSStream; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Gap coverage for Type3SignatureTool - exercises the real PDF scanning path (collectType3Fonts / + * scanResources / describeFont / verifyOutput) by feeding it a PDF that embeds a Type3 font. + */ +class Type3SignatureToolMoreTest { + + /** Builds a minimal-but-valid Type3 font dictionary with one glyph CharProc. */ + private COSDictionary buildType3FontDict() { + COSDictionary font = new COSDictionary(); + font.setItem(COSName.TYPE, COSName.FONT); + font.setItem(COSName.SUBTYPE, COSName.TYPE3); + font.setString(COSName.BASE_FONT, "ABCDEF+MyType3"); + + COSArray matrix = new COSArray(); + for (double v : new double[] {0.001, 0, 0, 0.001, 0, 0}) { + matrix.add(new org.apache.pdfbox.cos.COSFloat((float) v)); + } + font.setItem(COSName.FONT_MATRIX, matrix); + + COSArray bbox = new COSArray(); + for (int v : new int[] {0, 0, 750, 750}) { + bbox.add(org.apache.pdfbox.cos.COSInteger.get(v)); + } + font.setItem(COSName.FONT_BBOX, bbox); + + font.setInt(COSName.FIRST_CHAR, 65); + font.setInt(COSName.LAST_CHAR, 65); + COSArray widths = new COSArray(); + widths.add(org.apache.pdfbox.cos.COSInteger.get(600)); + font.setItem(COSName.WIDTHS, widths); + + // Encoding dictionary mapping code 65 -> "A" + COSDictionary encoding = new COSDictionary(); + encoding.setItem(COSName.TYPE, COSName.ENCODING); + COSArray differences = new COSArray(); + differences.add(org.apache.pdfbox.cos.COSInteger.get(65)); + differences.add(COSName.getPDFName("A")); + encoding.setItem(COSName.DIFFERENCES, differences); + font.setItem(COSName.ENCODING, encoding); + + // CharProcs with a tiny content stream for glyph "A" + COSDictionary charProcs = new COSDictionary(); + COSStream glyphStream = new COSStream(); + try (var os = glyphStream.createOutputStream()) { + os.write("600 0 0 0 750 750 d1\n".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + } catch (Exception e) { + throw new RuntimeException(e); + } + charProcs.setItem(COSName.getPDFName("A"), glyphStream); + font.setItem(COSName.CHAR_PROCS, charProcs); + + return font; + } + + private Path writePdfWithType3(Path dir) throws Exception { + Path pdf = dir.resolve("type3.pdf"); + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + PDResources resources = new PDResources(); + resources.getCOSObject().setItem(COSName.FONT, fontResourceDict()); + page.setResources(resources); + document.save(pdf.toFile()); + } + return pdf; + } + + private COSDictionary fontResourceDict() { + COSDictionary fonts = new COSDictionary(); + fonts.setItem(COSName.getPDFName("F1"), buildType3FontDict()); + return fonts; + } + + @Nested + @DisplayName("real PDF scanning") + class RealPdfScan { + + @Test + @DisplayName("writes JSON output file for a PDF containing a Type3 font") + void main_withType3Pdf_writesOutput(@TempDir Path dir) throws Exception { + Path pdf = writePdfWithType3(dir); + Path out = dir.resolve("out.json"); + + PrintStream original = System.out; + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + System.setOut(new PrintStream(captured)); + try { + Type3SignatureTool.main( + new String[] { + "--pdf", pdf.toString(), "--output", out.toString(), "--pretty" + }); + } finally { + System.setOut(original); + } + + assertTrue(Files.exists(out)); + String json = Files.readString(out); + assertThat(json).contains("\"fonts\""); + assertThat(json).contains("F1"); + assertThat(json).contains("signature"); + assertThat(captured.toString()).contains("verified"); + } + + @Test + @DisplayName("writes JSON to stdout when no --output given") + void main_withType3Pdf_stdout(@TempDir Path dir) throws Exception { + Path pdf = writePdfWithType3(dir); + + PrintStream original = System.out; + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + System.setOut(new PrintStream(captured)); + try { + Type3SignatureTool.main(new String[] {"--pdf", pdf.toString()}); + } finally { + System.setOut(original); + } + + String output = captured.toString(); + assertThat(output).contains("fonts"); + assertThat(output).contains("F1"); + } + + @Test + @DisplayName("output path with nested non-existent parent dirs is created") + void main_createsParentDirs(@TempDir Path dir) throws Exception { + Path pdf = writePdfWithType3(dir); + Path out = dir.resolve("nested/sub/out.json"); + + PrintStream original = System.out; + System.setOut(new PrintStream(new ByteArrayOutputStream())); + try { + Type3SignatureTool.main( + new String[] {"--pdf", pdf.toString(), "--output", out.toString()}); + } finally { + System.setOut(original); + } + + assertTrue(Files.exists(out)); + } + + @Test + @DisplayName("PDF without Type3 fonts yields empty fonts array") + void main_noType3Fonts_emptyArray(@TempDir Path dir) throws Exception { + Path pdf = dir.resolve("plain.pdf"); + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + try (PDPageContentStream cs = new PDPageContentStream(document, page)) { + cs.beginText(); + cs.setFont(font, 12); + cs.newLineAtOffset(72, 700); + cs.showText("Hello"); + cs.endText(); + } + document.save(pdf.toFile()); + } + Path out = dir.resolve("plain-out.json"); + + PrintStream original = System.out; + System.setOut(new PrintStream(new ByteArrayOutputStream())); + try { + Type3SignatureTool.main( + new String[] {"--pdf", pdf.toString(), "--output", out.toString()}); + } finally { + System.setOut(original); + } + + String json = Files.readString(out); + // The shared mapper always indents (INDENT_OUTPUT), so the array is "[ ]". + assertThat(json).contains("\"fonts\""); + assertThat(json.replaceAll("\\s", "")).contains("\"fonts\":[]"); + } + + @Test + @DisplayName("Type3 font nested inside a form XObject is discovered") + void main_type3InFormXObject(@TempDir Path dir) throws Exception { + Path pdf = dir.resolve("nested-form.pdf"); + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.LETTER); + document.addPage(page); + + // Build a form XObject whose resources contain the Type3 font. + COSStream formStream = new COSStream(); + try (var os = formStream.createOutputStream()) { + os.write("".getBytes(java.nio.charset.StandardCharsets.US_ASCII)); + } + formStream.setItem(COSName.TYPE, COSName.XOBJECT); + formStream.setItem(COSName.SUBTYPE, COSName.FORM); + COSArray formBBox = new COSArray(); + for (int v : new int[] {0, 0, 100, 100}) { + formBBox.add(org.apache.pdfbox.cos.COSInteger.get(v)); + } + formStream.setItem(COSName.BBOX, formBBox); + COSDictionary formResources = new COSDictionary(); + formResources.setItem(COSName.FONT, fontResourceDict()); + formStream.setItem(COSName.RESOURCES, formResources); + + COSDictionary xobjects = new COSDictionary(); + xobjects.setItem(COSName.getPDFName("Fm0"), formStream); + PDResources pageResources = new PDResources(); + pageResources.getCOSObject().setItem(COSName.XOBJECT, xobjects); + page.setResources(pageResources); + + document.save(pdf.toFile()); + } + Path out = dir.resolve("nested-form-out.json"); + + PrintStream original = System.out; + System.setOut(new PrintStream(new ByteArrayOutputStream())); + try { + Type3SignatureTool.main( + new String[] {"--pdf", pdf.toString(), "--output", out.toString()}); + } finally { + System.setOut(original); + } + + String json = Files.readString(out); + assertThat(json).contains("F1"); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/telegram/TelegramPipelineBotExtraTest.java b/app/core/src/test/java/stirling/software/SPDF/service/telegram/TelegramPipelineBotExtraTest.java new file mode 100644 index 0000000000..9e27d0c188 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/telegram/TelegramPipelineBotExtraTest.java @@ -0,0 +1,301 @@ +package stirling.software.SPDF.service.telegram; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; + +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.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.telegram.telegrambots.meta.TelegramBotsApi; +import org.telegram.telegrambots.meta.api.methods.send.SendMessage; +import org.telegram.telegrambots.meta.exceptions.TelegramApiException; + +import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.model.ApplicationProperties; + +/** + * Network-free coverage for {@link TelegramPipelineBot} private helpers: feedback resolution per + * chat type, inbox folder layout, the download-URL builder, JSON-config detection, pipeline-output + * matching/freshness, and the sendMessage failure swallow. The Telegram client {@code execute(...)} + * boundary is stubbed on a spy. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("TelegramPipelineBot helper coverage") +class TelegramPipelineBotExtraTest { + + @Mock private TelegramBotsApi telegramBotsApi; + @Mock private RuntimePathConfig runtimePathConfig; + + @TempDir Path watchedRoot; + @TempDir Path finishedRoot; + + private ApplicationProperties.Telegram telegramProps; + private TelegramPipelineBot bot; + + @BeforeEach + void setUp() { + ApplicationProperties applicationProperties = new ApplicationProperties(); + telegramProps = new ApplicationProperties.Telegram(); + telegramProps.setBotToken("secret-token"); + telegramProps.setBotUsername("test-bot"); + telegramProps.setPipelineInboxFolder("telegram"); + telegramProps.setCustomFolderSuffix(false); + telegramProps.setProcessingTimeoutSeconds(1); + telegramProps.setPollingIntervalMillis(10); + applicationProperties.setTelegram(telegramProps); + + when(runtimePathConfig.getPipelineWatchedFoldersPath()).thenReturn(watchedRoot.toString()); + when(runtimePathConfig.getPipelineFinishedFoldersPath()) + .thenReturn(finishedRoot.toString()); + + bot = + spy( + new TelegramPipelineBot( + applicationProperties, runtimePathConfig, telegramBotsApi)); + } + + private Object invoke(String name, Class[] sig, Object... args) throws Exception { + Method m = TelegramPipelineBot.class.getDeclaredMethod(name, sig); + m.setAccessible(true); + return m.invoke(bot, args); + } + + private boolean feedback(FeedbackEnum kind, String chatType) throws Exception { + return (boolean) + invoke( + "feedback", + new Class[] {FeedbackEnum.class, String.class}, + kind, + chatType); + } + + @Nested + @DisplayName("feedback resolution") + class Feedback { + + @Test + @DisplayName("group and supergroup chats always receive feedback (default true)") + void groupsDefaultTrue() throws Exception { + assertThat(feedback(FeedbackEnum.NO_VALID_DOCUMENT, "group")).isTrue(); + assertThat(feedback(FeedbackEnum.ERROR_MESSAGE, "supergroup")).isTrue(); + assertThat(feedback(FeedbackEnum.PROCESSING, "group")).isTrue(); + assertThat(feedback(FeedbackEnum.ERROR_PROCESSING, "supergroup")).isTrue(); + } + + @Test + @DisplayName("private chat honours the per-user toggle") + void privateUserToggle() throws Exception { + assertThat(feedback(FeedbackEnum.PROCESSING, "private")).isTrue(); + telegramProps.getFeedback().getUser().setProcessing(false); + assertThat(feedback(FeedbackEnum.PROCESSING, "private")).isFalse(); + } + + @Test + @DisplayName("channel chat honours the per-channel toggle") + void channelToggle() throws Exception { + assertThat(feedback(FeedbackEnum.ERROR_MESSAGE, "channel")).isTrue(); + telegramProps.getFeedback().getChannel().setErrorMessage(false); + assertThat(feedback(FeedbackEnum.ERROR_MESSAGE, "channel")).isFalse(); + } + } + + @Nested + @DisplayName("getInboxFolder") + class GetInboxFolder { + + private Path inbox(Long chatId) throws Exception { + return (Path) invoke("getInboxFolder", new Class[] {Long.class}, chatId); + } + + @Test + @DisplayName("without a custom suffix the base inbox folder is used") + void noSuffix() throws Exception { + Path folder = inbox(42L); + assertThat(folder).isEqualTo(watchedRoot.resolve("telegram")); + assertThat(Files.isDirectory(folder)).isTrue(); + } + + @Test + @DisplayName("with a custom suffix the chat id is appended as a subfolder") + void withSuffix() throws Exception { + telegramProps.setCustomFolderSuffix(true); + Path folder = inbox(99L); + assertThat(folder).isEqualTo(watchedRoot.resolve("telegram").resolve("99")); + assertThat(Files.isDirectory(folder)).isTrue(); + } + } + + @Nested + @DisplayName("buildDownloadUrl") + class BuildDownloadUrl { + + @Test + @DisplayName("builds an https api.telegram.org url embedding the bot token and file path") + void buildsUrl() throws Exception { + URL url = + (URL) + invoke( + "buildDownloadUrl", + new Class[] {String.class}, + "documents/file_1.pdf"); + assertThat(url.getProtocol()).isEqualTo("https"); + assertThat(url.getHost()).isEqualTo("api.telegram.org"); + assertThat(url.getPath()).contains("/file/botsecret-token/"); + assertThat(url.getPath()).contains("documents/file_1.pdf"); + } + } + + @Nested + @DisplayName("hasJsonConfig") + class HasJsonConfig { + + private boolean hasJsonConfig(Long chatId) throws Exception { + return (boolean) invoke("hasJsonConfig", new Class[] {Long.class}, chatId); + } + + @Test + @DisplayName("false when the inbox contains no json file") + void noJson() throws Exception { + assertThat(hasJsonConfig(7L)).isFalse(); + } + + @Test + @DisplayName("true once a json file is present in the inbox") + void withJson() throws Exception { + Path inbox = watchedRoot.resolve("telegram"); + Files.createDirectories(inbox); + Files.write(inbox.resolve("pipeline.json"), "{}".getBytes(StandardCharsets.UTF_8)); + assertThat(hasJsonConfig(7L)).isTrue(); + } + } + + @Nested + @DisplayName("pipeline output matching") + class PipelineOutputMatching { + + private boolean matchesBaseName(String base, Path file) throws Exception { + return (boolean) + invoke( + "matchesBaseName", + new Class[] {String.class, Path.class}, + base, + file); + } + + private boolean isNewerThan(Path path, Instant since) throws Exception { + return (boolean) + invoke("isNewerThan", new Class[] {Path.class, Instant.class}, path, since); + } + + @Test + @DisplayName("matchesBaseName checks the filename contains the unique base") + void baseNameContains() throws Exception { + Path p = finishedRoot.resolve("doc-abc123-out.pdf"); + assertThat(matchesBaseName("abc123", p)).isTrue(); + assertThat(matchesBaseName("zzz", p)).isFalse(); + } + + @Test + @DisplayName("isNewerThan is true for a file modified after the reference instant") + void newerFile() throws Exception { + Path p = finishedRoot.resolve("fresh.pdf"); + Files.write(p, new byte[] {1}); + assertThat(isNewerThan(p, Instant.now().minusSeconds(60))).isTrue(); + } + + @Test + @DisplayName("isNewerThan is false when the file cannot be read") + void missingFile() throws Exception { + Path missing = finishedRoot.resolve("never-existed.pdf"); + assertThat(isNewerThan(missing, Instant.now())).isFalse(); + } + + @Test + @DisplayName("waitForPipelineOutputs returns matching, fresh outputs from the finished dir") + void collectsOutputs() throws Exception { + // savedAt must be within the 1s processing timeout but before the output file mtime. + Instant savedAt = Instant.now().minusMillis(200); + Path out = finishedRoot.resolve("job-unique42-result.pdf"); + Files.write(out, new byte[] {1, 2, 3}); + + Object info = newPipelineFileInfo(finishedRoot.resolve("src.pdf"), "unique42", savedAt); + + @SuppressWarnings("unchecked") + List results = + (List) + invoke( + "waitForPipelineOutputs", + new Class[] {pipelineFileInfoClass()}, + info); + + assertThat(results).contains(out); + } + + private Class pipelineFileInfoClass() throws Exception { + return Class.forName( + "stirling.software.SPDF.service.telegram.TelegramPipelineBot$PipelineFileInfo"); + } + + private Object newPipelineFileInfo(Path file, String base, Instant savedAt) + throws Exception { + Class cls = pipelineFileInfoClass(); + var ctor = cls.getDeclaredConstructor(Path.class, String.class, Instant.class); + ctor.setAccessible(true); + return ctor.newInstance(file, base, savedAt); + } + } + + @Nested + @DisplayName("sendMessage") + class SendMessageBehaviour { + + private void sendMessage(Long chatId, String text) throws Exception { + invoke("sendMessage", new Class[] {Long.class, String.class}, chatId, text); + } + + @Test + @DisplayName("a null chat id is a no-op and never calls execute") + void nullChatIdNoOp() throws Exception { + sendMessage(null, "hi"); + verify(bot, org.mockito.Mockito.never()).execute(any(SendMessage.class)); + } + + @Test + @DisplayName("a TelegramApiException from execute is swallowed") + void swallowsApiException() throws Exception { + doThrow(new TelegramApiException("boom")).when(bot).execute(any(SendMessage.class)); + // must not propagate + sendMessage(123L, "hello"); + verify(bot).execute(any(SendMessage.class)); + } + + @Test + @DisplayName("a successful send invokes execute once") + void successfulSend() throws Exception { + doReturn(null).when(bot).execute(any(SendMessage.class)); + sendMessage(456L, "ok"); + verify(bot).execute(any(SendMessage.class)); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/telegram/TelegramPipelineBotMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/service/telegram/TelegramPipelineBotMoreTest.java new file mode 100644 index 0000000000..5b05d3009e --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/service/telegram/TelegramPipelineBotMoreTest.java @@ -0,0 +1,310 @@ +package stirling.software.SPDF.service.telegram; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +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.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.telegram.telegrambots.meta.TelegramBotsApi; +import org.telegram.telegrambots.meta.api.methods.GetFile; +import org.telegram.telegrambots.meta.api.methods.send.SendMessage; +import org.telegram.telegrambots.meta.api.objects.Chat; +import org.telegram.telegrambots.meta.api.objects.Document; +import org.telegram.telegrambots.meta.api.objects.Message; +import org.telegram.telegrambots.meta.api.objects.Update; +import org.telegram.telegrambots.meta.exceptions.TelegramApiException; + +import stirling.software.common.configuration.RuntimePathConfig; +import stirling.software.common.model.ApplicationProperties; + +/** + * Additional gap tests for {@link TelegramPipelineBot}. The Telegram client boundary (the {@code + * execute(...)} calls) is stubbed on a spy so no network traffic occurs. File handling uses an + * on-disk {@link TempDir} inbox. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class TelegramPipelineBotMoreTest { + + @Mock private TelegramBotsApi telegramBotsApi; + @Mock private RuntimePathConfig runtimePathConfig; + + @TempDir Path watchedRoot; + @TempDir Path finishedRoot; + + private ApplicationProperties applicationProperties; + private ApplicationProperties.Telegram telegramProps; + private TelegramPipelineBot bot; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + telegramProps = new ApplicationProperties.Telegram(); + telegramProps.setBotToken("test-token"); + telegramProps.setBotUsername("test-bot"); + telegramProps.setEnableAllowUserIDs(false); + telegramProps.setEnableAllowChannelIDs(false); + telegramProps.setPipelineInboxFolder("telegram"); + telegramProps.setCustomFolderSuffix(false); + applicationProperties.setTelegram(telegramProps); + + when(runtimePathConfig.getPipelineWatchedFoldersPath()).thenReturn(watchedRoot.toString()); + when(runtimePathConfig.getPipelineFinishedFoldersPath()) + .thenReturn(finishedRoot.toString()); + + bot = + spy( + new TelegramPipelineBot( + applicationProperties, runtimePathConfig, telegramBotsApi)); + } + + private Update textUpdate(String text, String chatType, long chatId) { + Update update = mock(Update.class); + Message message = mock(Message.class); + Chat chat = mock(Chat.class); + + when(update.hasMessage()).thenReturn(true); + when(update.getMessage()).thenReturn(message); + when(message.getChat()).thenReturn(chat); + when(chat.getType()).thenReturn(chatType); + when(chat.getId()).thenReturn(chatId); + when(message.hasText()).thenReturn(true); + when(message.getText()).thenReturn(text); + when(message.hasDocument()).thenReturn(false); + return update; + } + + private Update documentUpdate(String mimeType, String fileName, long chatId) { + Update update = mock(Update.class); + Message message = mock(Message.class); + Chat chat = mock(Chat.class); + Document document = mock(Document.class); + + when(update.hasMessage()).thenReturn(true); + when(update.getMessage()).thenReturn(message); + when(message.getChat()).thenReturn(chat); + when(chat.getType()).thenReturn("private"); + when(chat.getId()).thenReturn(chatId); + when(message.hasText()).thenReturn(false); + when(message.hasDocument()).thenReturn(true); + when(message.getDocument()).thenReturn(document); + when(message.getChatId()).thenReturn(chatId); + when(document.getMimeType()).thenReturn(mimeType); + when(document.getFileName()).thenReturn(fileName); + when(document.getFileId()).thenReturn("file-id-123"); + when(document.getFileUniqueId()).thenReturn("uniq-123"); + return update; + } + + private Path inboxFolder() { + return watchedRoot.resolve("telegram"); + } + + private void writeJsonInInbox() throws Exception { + Path inbox = inboxFolder(); + Files.createDirectories(inbox); + Files.write(inbox.resolve("config.json"), "{}".getBytes(StandardCharsets.UTF_8)); + } + + @Nested + @DisplayName("text command routing") + class TextCommands { + + @Test + @DisplayName("unknown text command falls through to the no-valid-file feedback") + void unknownText_sendsNoValidFile() throws TelegramApiException { + doReturn(null).when(bot).execute(any(SendMessage.class)); + + bot.onUpdateReceived(textUpdate("/help", "private", 100L)); + + verify(bot) + .execute( + (SendMessage) + org.mockito.ArgumentMatchers.argThat( + arg -> + arg instanceof SendMessage sm + && sm.getText() + .contains("No valid file"))); + } + + @Test + @DisplayName("arbitrary text falls through to the no-valid-file feedback") + void arbitraryText_sendsNoValidFile() throws TelegramApiException { + doReturn(null).when(bot).execute(any(SendMessage.class)); + + bot.onUpdateReceived(textUpdate("hello bot", "private", 101L)); + + verify(bot, atLeastOnce()).execute(any(SendMessage.class)); + } + } + + @Nested + @DisplayName("handleIncomingFile - pre-download guards") + class PreDownloadGuards { + + @Test + @DisplayName("missing JSON config sends the contact-administrator message") + void noJsonConfig_sendsAdminMessage() throws TelegramApiException { + doReturn(null).when(bot).execute(any(SendMessage.class)); + // No json written to the inbox folder. + + bot.onUpdateReceived(documentUpdate("application/pdf", "doc.pdf", 200L)); + + verify(bot) + .execute( + (SendMessage) + org.mockito.ArgumentMatchers.argThat( + arg -> + arg instanceof SendMessage sm + && sm.getText() + .contains( + "No JSON" + + " configuration"))); + } + + @Test + @DisplayName("uppercase PDF mime type is accepted and passes the mime guard") + void uppercaseMime_passesGuard() throws TelegramApiException { + doReturn(null).when(bot).execute(any(SendMessage.class)); + // No json config -> still stops at the JSON guard, proving the mime guard passed. + + bot.onUpdateReceived(documentUpdate("APPLICATION/PDF", "doc.pdf", 201L)); + + verify(bot) + .execute( + (SendMessage) + org.mockito.ArgumentMatchers.argThat( + arg -> + arg instanceof SendMessage sm + && sm.getText() + .contains( + "No JSON" + + " configuration"))); + } + + @Test + @DisplayName("null mime type skips the mime guard and reaches the JSON guard") + void nullMime_reachesJsonGuard() throws TelegramApiException { + doReturn(null).when(bot).execute(any(SendMessage.class)); + + bot.onUpdateReceived(documentUpdate(null, "doc.pdf", 202L)); + + verify(bot) + .execute( + (SendMessage) + org.mockito.ArgumentMatchers.argThat( + arg -> + arg instanceof SendMessage sm + && sm.getText() + .contains( + "No JSON" + + " configuration"))); + } + } + + @Nested + @DisplayName("handleIncomingFile - processing and error replies") + class ProcessingAndErrors { + + @Test + @DisplayName("with config present, processing message is sent then GetFile failure errors") + void processingThenTelegramError() throws Exception { + writeJsonInInbox(); + doReturn(null).when(bot).execute(any(SendMessage.class)); + // GetFile execution fails -> caught as TelegramApiException -> error reply. + doThrow(new TelegramApiException("get file failed")) + .when(bot) + .execute(any(GetFile.class)); + + bot.onUpdateReceived(documentUpdate("application/pdf", "doc.pdf", 300L)); + + // "File received. Starting processing..." processing feedback was sent. + verify(bot) + .execute( + (SendMessage) + org.mockito.ArgumentMatchers.argThat( + arg -> + arg instanceof SendMessage sm + && sm.getText() + .contains( + "Starting" + + " processing"))); + // The Telegram API error reply was sent. + verify(bot) + .execute( + (SendMessage) + org.mockito.ArgumentMatchers.argThat( + arg -> + arg instanceof SendMessage sm + && sm.getText() + .contains( + "Telegram API" + + " error"))); + } + + @Test + @DisplayName("processing feedback disabled for user suppresses the processing message") + void processingFeedbackDisabled_noProcessingMessage() throws Exception { + writeJsonInInbox(); + telegramProps.getFeedback().getUser().setProcessing(false); + doReturn(null).when(bot).execute(any(SendMessage.class)); + doThrow(new TelegramApiException("get file failed")) + .when(bot) + .execute(any(GetFile.class)); + + bot.onUpdateReceived(documentUpdate("application/pdf", "doc.pdf", 301L)); + + verify(bot, never()) + .execute( + (SendMessage) + org.mockito.ArgumentMatchers.argThat( + arg -> + arg instanceof SendMessage sm + && sm.getText() + .contains( + "Starting" + + " processing"))); + } + + @Test + @DisplayName("GetFile returning null path raises an IO error reply") + void getFileNullPath_sendsIoError() throws Exception { + writeJsonInInbox(); + doReturn(null).when(bot).execute(any(SendMessage.class)); + // Telegram returns a File with no path -> IOException -> IO error reply. + org.telegram.telegrambots.meta.api.objects.File tgFile = + mock(org.telegram.telegrambots.meta.api.objects.File.class); + when(tgFile.getFilePath()).thenReturn(null); + doReturn(tgFile).when(bot).execute(any(GetFile.class)); + + bot.onUpdateReceived(documentUpdate("application/pdf", "doc.pdf", 302L)); + + verify(bot) + .execute( + (SendMessage) + org.mockito.ArgumentMatchers.argThat( + arg -> + arg instanceof SendMessage sm + && sm.getText().contains("IO error"))); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/utils/SvgOverlayUtilMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/utils/SvgOverlayUtilMoreTest.java new file mode 100644 index 0000000000..4c40fc76a7 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/utils/SvgOverlayUtilMoreTest.java @@ -0,0 +1,91 @@ +package stirling.software.SPDF.utils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** Coverage for {@link SvgOverlayUtil}: the real Batik overlay happy path plus isSvgImage edges. */ +class SvgOverlayUtilMoreTest { + + private static final String TINY_SVG = + "" + + ""; + + @Nested + @DisplayName("overlaySvgOnPage") + class Overlay { + + @Test + @DisplayName("overlays a valid SVG and leaves the document saveable") + void overlaysValidSvg() throws Exception { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + + SvgOverlayUtil.overlaySvgOnPage( + doc, page, TINY_SVG.getBytes(StandardCharsets.UTF_8), 50f, 60f); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + doc.save(out); + assertThat(out.size()).isPositive(); + } + } + + @Test + @DisplayName("invalid SVG bytes raise an IOException") + void invalidSvgThrows() throws Exception { + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + + assertThatThrownBy( + () -> + SvgOverlayUtil.overlaySvgOnPage( + doc, + page, + "not an svg".getBytes(StandardCharsets.UTF_8), + 0f, + 0f)) + .isInstanceOf(IOException.class); + } + } + } + + @Nested + @DisplayName("isSvgImage") + class IsSvg { + + @Test + @DisplayName("recognizes a raw document") + void recognizesSvgTag() { + assertThat(SvgOverlayUtil.isSvgImage(TINY_SVG.getBytes(StandardCharsets.UTF_8))) + .isTrue(); + } + + @Test + @DisplayName("recognizes an xml-declared svg document") + void recognizesXmlSvg() { + String xml = ""; + assertThat(SvgOverlayUtil.isSvgImage(xml.getBytes(StandardCharsets.UTF_8))).isTrue(); + } + + @Test + @DisplayName("rejects null, too-short and non-svg bytes") + void rejectsNonSvg() { + assertThat(SvgOverlayUtil.isSvgImage(null)).isFalse(); + assertThat(SvgOverlayUtil.isSvgImage(new byte[] {1, 2})).isFalse(); + assertThat(SvgOverlayUtil.isSvgImage("%PDF-1.7 hello".getBytes(StandardCharsets.UTF_8))) + .isFalse(); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/utils/text/TextEncodingHelperMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/utils/text/TextEncodingHelperMoreTest.java new file mode 100644 index 0000000000..6319aba2e9 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/utils/text/TextEncodingHelperMoreTest.java @@ -0,0 +1,329 @@ +package stirling.software.SPDF.utils.text; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.lang.reflect.Method; + +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDFontDescriptor; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.PDType3Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +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.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +/** + * Gap coverage for TextEncodingHelper - exercises the array-fallback validation path, surrogate + * handling, simple-character classification and the comprehensive isTextFullyRemovable checks using + * a mix of real Standard14 fonts and precise mocks. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class TextEncodingHelperMoreTest { + + private final PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + + @Nested + @DisplayName("canEncodeCharacters - array fallback") + class CanEncodeArrayFallback { + + @Test + @DisplayName("real Helvetica encodes Latin text -> true") + void realFont_latin_true() { + assertTrue(TextEncodingHelper.canEncodeCharacters(helvetica, "Hello World")); + } + + @Test + @DisplayName("empty full encoding but per-char success -> array fallback allows") + void emptyFullEncoding_arrayFallbackAllows() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("SubFont"); + when(font.encode("AB")).thenReturn(new byte[0]); + when(font.encode("A")).thenReturn(new byte[] {65}); + when(font.encode("B")).thenReturn(new byte[] {66}); + when(font.getStringWidth("A")).thenReturn(500f); + when(font.getStringWidth("B")).thenReturn(500f); + + assertTrue(TextEncodingHelper.canEncodeCharacters(font, "AB")); + } + + @Test + @DisplayName("below 95% success rate -> array fallback rejects") + void lowSuccessRate_rejects() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("SubFont"); + when(font.encode("AB")).thenReturn(new byte[0]); + when(font.encode("A")).thenReturn(new byte[] {65}); + when(font.getStringWidth("A")).thenReturn(500f); + // "B" fails encoding -> 1/2 = 50% < 95% + when(font.encode("B")).thenThrow(new IOException("no B")); + + assertFalse(TextEncodingHelper.canEncodeCharacters(font, "AB")); + } + + @Test + @DisplayName("negative per-char width is not counted as success") + void negativeWidth_notCounted() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("SubFont"); + // full-string encode empty -> array fallback; per-char encodes ok but widths negative + when(font.encode("AB")).thenReturn(new byte[0]); + when(font.encode("A")).thenReturn(new byte[] {65}); + when(font.encode("B")).thenReturn(new byte[] {66}); + when(font.getStringWidth("A")).thenReturn(-1f); + when(font.getStringWidth("B")).thenReturn(-2f); + + assertFalse(TextEncodingHelper.canEncodeCharacters(font, "AB")); + } + + @Test + @DisplayName("exception on full encode + subset name -> array fallback used") + void exceptionWithSubsetName_arrayFallback() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("ABCDEF+Subset"); + when(font.encode("X")).thenThrow(new IOException("boom")); + // array fallback re-invokes encode per-char; still throws -> 0% -> reject + assertFalse(TextEncodingHelper.canEncodeCharacters(font, "X")); + } + + @Test + @DisplayName("exception on full encode, non-subset no-custom -> false without fallback") + void exceptionNonSubset_false() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("PlainFont"); + when(font.encode("X")).thenThrow(new IllegalArgumentException("bad")); + assertFalse(TextEncodingHelper.canEncodeCharacters(font, "X")); + } + + @Test + @DisplayName("surrogate pair code point is iterated as a single unit in fallback") + void surrogatePair_iteratedOnce() throws IOException { + String emoji = "😀"; // U+1F600 (surrogate pair) + String text = emoji + "A"; + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("EmojiSub"); + // full-string encode fails -> array fallback iterates code points + when(font.encode(text)).thenReturn(new byte[0]); + when(font.encode(emoji)).thenReturn(new byte[] {1, 2}); + when(font.encode("A")).thenReturn(new byte[] {65}); + when(font.getStringWidth(emoji)).thenReturn(700f); + when(font.getStringWidth("A")).thenReturn(500f); + + assertTrue(TextEncodingHelper.canEncodeCharacters(font, text)); + } + } + + @Nested + @DisplayName("isTextSegmentRemovable") + class TextSegmentRemovable { + + @Test + @DisplayName("simple char on real font -> removable") + void simpleChar_realFont_true() { + assertTrue(TextEncodingHelper.isTextSegmentRemovable(helvetica, "A")); + } + + @Test + @DisplayName("simple char where encode throws -> not removable") + void simpleChar_encodeThrows_false() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("F"); + when(font.encode("A")).thenThrow(new IOException("fail")); + assertFalse(TextEncodingHelper.isTextSegmentRemovable(font, "A")); + } + + @Test + @DisplayName("complex text delegates to full removable check (real font)") + void complexText_delegates() { + // Contains a non-simple char (emoji) -> goes through isTextFullyRemovable + assertFalse(TextEncodingHelper.isTextSegmentRemovable(helvetica, "Hi 😀 there")); + } + + @Test + @DisplayName("long simple-looking string over 20 chars routes to full check") + void longText_routesToFull() { + String longText = "abcdefghijklmnopqrstuvwxyz"; // 26 chars > 20 + // Helvetica can encode all of these so the full path returns true + assertTrue(TextEncodingHelper.isTextSegmentRemovable(helvetica, longText)); + } + } + + @Nested + @DisplayName("isTextFullyRemovable") + class TextFullyRemovable { + + @Test + @DisplayName("real font, encodable text -> fully removable") + void realFont_true() { + assertTrue(TextEncodingHelper.isTextFullyRemovable(helvetica, "Sample text")); + } + + @Test + @DisplayName("negative width rejects removal") + void negativeWidth_false() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("F"); + when(font.encode("ab")).thenReturn(new byte[] {1, 2}); + when(font.getStringWidth("ab")).thenReturn(-5f); + + assertFalse(TextEncodingHelper.isTextFullyRemovable(font, "ab")); + } + + @Test + @DisplayName("missing font descriptor rejects removal") + void nullDescriptor_false() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("F"); + when(font.encode("ab")).thenReturn(new byte[] {1, 2}); + when(font.getStringWidth("ab")).thenReturn(100f); + when(font.getFontDescriptor()).thenReturn(null); + + assertFalse(TextEncodingHelper.isTextFullyRemovable(font, "ab")); + } + + @Test + @DisplayName("font bounding box throwing rejects removal") + void bboxThrows_false() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("F"); + when(font.encode("ab")).thenReturn(new byte[] {1, 2}); + when(font.getStringWidth("ab")).thenReturn(100f); + PDFontDescriptor descriptor = mock(PDFontDescriptor.class); + when(descriptor.getFontBoundingBox()) + .thenThrow(new IllegalArgumentException("no bbox")); + when(font.getFontDescriptor()).thenReturn(descriptor); + + assertFalse(TextEncodingHelper.isTextFullyRemovable(font, "ab")); + } + + @Test + @DisplayName("IOException during width calc rejects removal") + void widthIOException_false() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("F"); + when(font.encode("ab")).thenReturn(new byte[] {1, 2}); + when(font.getStringWidth("ab")).thenThrow(new IOException("io")); + + assertFalse(TextEncodingHelper.isTextFullyRemovable(font, "ab")); + } + } + + @Nested + @DisplayName("hasCustomEncoding extra branches") + class HasCustomEncoding { + + @Test + @DisplayName("real Type1 standard-encoded font -> not custom") + void realType1_notCustom() { + assertFalse(TextEncodingHelper.hasCustomEncoding(helvetica)); + } + + @Test + @DisplayName("non-simple non-Type0 font assumes standard encoding -> false") + void type3Font_assumesStandard() { + PDType3Font font = mock(PDType3Font.class); + when(font.getName()).thenReturn("T3"); + assertFalse(TextEncodingHelper.hasCustomEncoding(font)); + } + + @Test + @DisplayName("simple font with null encoding -> not custom") + void simpleFontNullEncoding_false() { + org.apache.pdfbox.pdmodel.font.PDSimpleFont font = + mock(org.apache.pdfbox.pdmodel.font.PDSimpleFont.class); + when(font.getEncoding()).thenReturn(null); + when(font.getName()).thenReturn("S"); + assertFalse(TextEncodingHelper.hasCustomEncoding(font)); + } + } + + @Nested + @DisplayName("canCalculateBasicWidths extra branches") + class CanCalculateBasicWidths { + + @Test + @DisplayName("real font calculates widths -> true") + void realFont_true() { + assertTrue(TextEncodingHelper.canCalculateBasicWidths(helvetica)); + } + + @Test + @DisplayName("space ok but all test chars throw -> false") + void testCharsThrow_false() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getStringWidth(" ")).thenReturn(250f); + when(font.getStringWidth("a")).thenThrow(new IOException("x")); + when(font.getStringWidth("A")).thenThrow(new IOException("x")); + when(font.getStringWidth("0")).thenThrow(new IOException("x")); + when(font.getStringWidth(".")).thenThrow(new IOException("x")); + when(font.getStringWidth("e")).thenThrow(new IOException("x")); + when(font.getStringWidth("!")).thenThrow(new IOException("x")); + + assertFalse(TextEncodingHelper.canCalculateBasicWidths(font)); + } + + @Test + @DisplayName("space ok but test chars return zero width -> false") + void testCharsZero_false() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getStringWidth(anyString())).thenReturn(0f); + when(font.getStringWidth(" ")).thenReturn(250f); + + assertFalse(TextEncodingHelper.canCalculateBasicWidths(font)); + } + } + + @Nested + @DisplayName("isSimpleCharacter via reflection") + class IsSimpleCharacter { + + private boolean isSimple(String text) throws Exception { + Method m = + TextEncodingHelper.class.getDeclaredMethod("isSimpleCharacter", String.class); + m.setAccessible(true); + return (boolean) m.invoke(null, text); + } + + @Test + @DisplayName("letters digits whitespace and common punctuation are simple") + void simpleCases() throws Exception { + assertTrue(isSimple("abc 123")); + assertTrue(isSimple("Hello, world!")); + // underscore is NOT in the allow-list, but hyphen is + assertTrue(isSimple("a-b.c")); + } + + @Test + @DisplayName("underscore is not an allowed simple punctuation") + void underscore_false() throws Exception { + assertFalse(isSimple("a_b")); + } + + @Test + @DisplayName("over 20 chars is not simple") + void tooLong_false() throws Exception { + assertFalse(isSimple("aaaaaaaaaaaaaaaaaaaaaaa")); // 23 chars + } + + @Test + @DisplayName("non-letter non-ASCII symbol is not simple") + void nonAscii_false() throws Exception { + // euro sign is not a letter/digit and not in the ASCII punctuation allow-list + assertFalse(isSimple("a€b")); + } + + @Test + @DisplayName("null and empty are not simple") + void nullEmpty_false() throws Exception { + assertFalse(isSimple(null)); + assertFalse(isSimple("")); + } + } +} diff --git a/app/core/src/test/java/stirling/software/SPDF/utils/text/WidthCalculatorMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/utils/text/WidthCalculatorMoreTest.java new file mode 100644 index 0000000000..4151931235 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/utils/text/WidthCalculatorMoreTest.java @@ -0,0 +1,213 @@ +package stirling.software.SPDF.utils.text; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; + +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDFontDescriptor; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +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.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +/** + * Extra coverage for {@link WidthCalculator} focusing on the character-iteration fallback, the + * bounding-box and average-width fallbacks, and the reliability checks against real Standard-14 + * fonts. + */ +@DisplayName("WidthCalculator (more) Tests") +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class WidthCalculatorMoreTest { + + @Nested + @DisplayName("Real Standard-14 font behaviour") + class RealFontTests { + + @Test + @DisplayName("Computes a positive scaled width for an encodable string") + void positiveWidthForEncodableString() { + PDFont helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + + float width = WidthCalculator.calculateAccurateWidth(helvetica, "Hello", 12f); + + assertThat(width).isGreaterThan(0f); + } + + @Test + @DisplayName("Width scales linearly with font size") + void widthScalesWithFontSize() { + PDFont helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + + float at12 = WidthCalculator.calculateAccurateWidth(helvetica, "Width", 12f); + float at24 = WidthCalculator.calculateAccurateWidth(helvetica, "Width", 24f); + + assertThat(at24).isCloseTo(at12 * 2f, within(0.5f)); + } + + @Test + @DisplayName("Standard-14 Helvetica is reported as reliable") + void standard14FontIsReliable() { + PDFont helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + + assertThat(WidthCalculator.isWidthCalculationReliable(helvetica)).isTrue(); + } + } + + @Nested + @DisplayName("Character-iteration fallback") + class CharacterIterationTests { + + @Test + @DisplayName("Uses per-glyph widths when getStringWidth throws but chars encode") + void perGlyphWhenStringWidthThrows() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("IterFont"); + // canEncodeCharacters succeeds for the whole string. + when(font.encode(anyString())).thenReturn(new byte[] {65}); + // Direct width path fails, forcing character iteration. + when(font.getStringWidth(anyString())).thenThrow(new IOException("no string width")); + // Each glyph reports a positive width. + when(font.getWidth(anyInt())).thenReturn(600f); + + float width = WidthCalculator.calculateAccurateWidth(font, "AB", 10f); + + // 600/1000 * 10 = 6 per char, two chars -> 12. + assertThat(width).isCloseTo(12f, within(0.01f)); + } + + @Test + @DisplayName("Falls back to width-from-font when glyph width is zero") + void widthFromFontWhenGlyphWidthZero() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("ZeroGlyphFont"); + when(font.encode(anyString())).thenReturn(new byte[] {65}); + when(font.getStringWidth(anyString())).thenThrow(new IOException("no string width")); + when(font.getWidth(anyInt())).thenReturn(0f); + when(font.getWidthFromFont(anyInt())).thenReturn(500f); + + float width = WidthCalculator.calculateAccurateWidth(font, "A", 10f); + + // 500/1000 * 10 = 5. + assertThat(width).isCloseTo(5f, within(0.01f)); + } + + @Test + @DisplayName("Falls back to average width when both glyph lookups fail") + void averageWidthWhenGlyphLookupsFail() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("AvgFont"); + when(font.encode(anyString())).thenReturn(new byte[] {65}); + when(font.getStringWidth(anyString())).thenThrow(new IOException("no string width")); + when(font.getWidth(anyInt())).thenReturn(0f); + when(font.getWidthFromFont(anyInt())).thenThrow(new IOException("no font width")); + when(font.getAverageFontWidth()).thenReturn(400f); + + float width = WidthCalculator.calculateAccurateWidth(font, "A", 10f); + + // 400/1000 * 10 = 4. + assertThat(width).isCloseTo(4f, within(0.01f)); + } + } + + @Nested + @DisplayName("Bounding-box and conservative fallbacks") + class FallbackTests { + + @Test + @DisplayName("Uses bounding box estimate when characters cannot be encoded") + void boundingBoxEstimateWhenEncodingFails() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("BBoxFont"); + // canEncodeCharacters fails outright. + when(font.encode(anyString())).thenThrow(new IOException("cannot encode")); + PDFontDescriptor descriptor = mock(PDFontDescriptor.class); + when(font.getFontDescriptor()).thenReturn(descriptor); + when(descriptor.getFontBoundingBox()).thenReturn(new PDRectangle(0, 0, 1000, 800)); + + float width = WidthCalculator.calculateAccurateWidth(font, "abc", 10f); + + assertThat(width).isGreaterThan(0f); + } + + @Test + @DisplayName("Uses average-width fallback when no bounding box is present") + void averageWidthFallbackWhenNoBoundingBox() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("NoBBoxFont"); + when(font.encode(anyString())).thenThrow(new IOException("cannot encode")); + when(font.getFontDescriptor()).thenReturn(null); + when(font.getAverageFontWidth()).thenReturn(500f); + + float width = WidthCalculator.calculateAccurateWidth(font, "abcd", 10f); + + // 4 chars * 500/1000 * 10 = 20. + assertThat(width).isCloseTo(20f, within(0.01f)); + } + + @Test + @DisplayName("Uses conservative estimate when every fallback throws") + void conservativeEstimateWhenEverythingThrows() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("BrokenFont"); + when(font.encode(anyString())).thenThrow(new IOException("cannot encode")); + // Bounding box path throws, then average width path also throws. + when(font.getFontDescriptor()).thenThrow(new RuntimeException("descriptor boom")); + when(font.getAverageFontWidth()).thenThrow(new RuntimeException("avg boom")); + + float width = WidthCalculator.calculateAccurateWidth(font, "hello", 10f); + + // Conservative: length * 0.5 * fontSize = 5 * 0.5 * 10 = 25. + assertThat(width).isCloseTo(25f, within(0.01f)); + } + } + + @Nested + @DisplayName("Reliability checks") + class ReliabilityTests { + + @Test + @DisplayName("Returns false for a font flagged with custom encoding") + void falseForCustomEncoding() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("CustomEncFont"); + when(font.isDamaged()).thenReturn(false); + + try (var helper = org.mockito.Mockito.mockStatic(TextEncodingHelper.class)) { + helper.when(() -> TextEncodingHelper.canCalculateBasicWidths(font)) + .thenReturn(true); + helper.when(() -> TextEncodingHelper.hasCustomEncoding(font)).thenReturn(true); + + assertThat(WidthCalculator.isWidthCalculationReliable(font)).isFalse(); + } + } + + @Test + @DisplayName("Returns true when basic widths work and encoding is standard") + void trueForStandardEncoding() throws IOException { + PDFont font = mock(PDFont.class); + when(font.getName()).thenReturn("StdFont"); + when(font.isDamaged()).thenReturn(false); + + try (var helper = org.mockito.Mockito.mockStatic(TextEncodingHelper.class)) { + helper.when(() -> TextEncodingHelper.canCalculateBasicWidths(font)) + .thenReturn(true); + helper.when(() -> TextEncodingHelper.hasCustomEncoding(font)).thenReturn(false); + + assertThat(WidthCalculator.isWidthCalculationReliable(font)).isTrue(); + } + } + } +} diff --git a/app/core/src/test/java/stirling/software/common/controller/JobOwnershipCacheTest.java b/app/core/src/test/java/stirling/software/common/controller/JobOwnershipCacheTest.java new file mode 100644 index 0000000000..7e24e23eea --- /dev/null +++ b/app/core/src/test/java/stirling/software/common/controller/JobOwnershipCacheTest.java @@ -0,0 +1,149 @@ +package stirling.software.common.controller; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentMap; + +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 stirling.software.common.cluster.JobStoreEntry; +import stirling.software.common.cluster.JobStoreEntry.JobState; + +/** + * Unit tests for the package-private process-local TTL cache {@link JobOwnershipCache}. TTL expiry + * is driven deterministically by rewriting the stored timestamp via reflection instead of sleeping. + */ +@DisplayName("JobOwnershipCache") +class JobOwnershipCacheTest { + + private JobOwnershipCache cache; + + @BeforeEach + void setUp() { + cache = new JobOwnershipCache(); + } + + private static JobStoreEntry entry(String jobId) { + return new JobStoreEntry( + jobId, + JobState.COMPLETE, + "node-1", + Instant.now(), + Instant.now(), + null, + List.of("file-1"), + Map.of()); + } + + @SuppressWarnings("unchecked") + private ConcurrentMap internalEntries() throws Exception { + Field f = JobOwnershipCache.class.getDeclaredField("entries"); + f.setAccessible(true); + return (ConcurrentMap) f.get(cache); + } + + /** Replaces the stored Entry's timestamp so the next get() observes a TTL expiry. */ + private void ageEntry(String jobId) throws Exception { + ConcurrentMap entries = internalEntries(); + Object stored = entries.get(jobId); + Field valueField = stored.getClass().getDeclaredField("value"); + valueField.setAccessible(true); + @SuppressWarnings("unchecked") + Optional value = (Optional) valueField.get(stored); + + Constructor ctor = stored.getClass().getDeclaredConstructor(Optional.class, long.class); + ctor.setAccessible(true); + // Timestamp far enough in the past to exceed the 5s TTL. + long ancient = System.nanoTime() - 10L * 1_000_000_000L; + entries.put(jobId, ctor.newInstance(value, ancient)); + } + + @Nested + @DisplayName("get") + class Get { + + @Test + @DisplayName("returns empty for a never-stored job id (cache miss)") + void missReturnsEmpty() { + assertThat(cache.get("absent")).isEmpty(); + } + + @Test + @DisplayName("returns the stored present value on a hit") + void hitReturnsStoredPresentValue() { + JobStoreEntry value = entry("job-1"); + cache.put("job-1", Optional.of(value)); + + Optional> result = cache.get("job-1"); + + assertThat(result).isPresent(); + assertThat(result.get()).contains(value); + } + + @Test + @DisplayName("returns a cached negative (empty) lookup as a hit wrapping empty") + void hitReturnsCachedNegative() { + cache.put("missing-job", Optional.empty()); + + Optional> result = cache.get("missing-job"); + + // Outer present (cache hit), inner empty (the job genuinely does not exist). + assertThat(result).isPresent(); + assertThat(result.get()).isEmpty(); + } + + @Test + @DisplayName("evicts and returns empty once the entry has outlived its TTL") + void expiredEntryIsEvicted() throws Exception { + cache.put("job-ttl", Optional.of(entry("job-ttl"))); + assertThat(cache.get("job-ttl")).isPresent(); + + ageEntry("job-ttl"); + + // Past the TTL the stale entry is dropped and reported as a miss. + assertThat(cache.get("job-ttl")).isEmpty(); + assertThat(internalEntries()).doesNotContainKey("job-ttl"); + } + } + + @Nested + @DisplayName("put") + class Put { + + @Test + @DisplayName("overwriting a job id replaces the cached value") + void overwriteReplacesValue() { + JobStoreEntry first = entry("job-x"); + JobStoreEntry second = entry("job-x"); + cache.put("job-x", Optional.of(first)); + cache.put("job-x", Optional.of(second)); + + assertThat(cache.get("job-x").orElseThrow()).contains(second); + } + + @Test + @DisplayName("clears the map when the max-entries cap is reached, then stores the new key") + void clearsWhenCapacityReached() throws Exception { + // Fill beyond the 2048 cap so the next put triggers a best-effort clear. + for (int i = 0; i < 2048; i++) { + cache.put("job-" + i, Optional.of(entry("job-" + i))); + } + assertThat(internalEntries()).hasSize(2048); + + cache.put("overflow", Optional.of(entry("overflow"))); + + // The cache cleared the full map and kept only the newest entry. + assertThat(internalEntries()).hasSize(1); + assertThat(cache.get("overflow")).isPresent(); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java index af9f8c7283..fe88b40391 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyExecutor.java @@ -116,6 +116,10 @@ public class PolicyExecutor { ToolResult r = callEndpoint(step, inputFiles, supportingFiles); files.addAll(r.files()); report = r.report(); + } else if (inputFiles.isEmpty()) { + ToolResult r = callEndpoint(step, List.of(), supportingFiles); + files.addAll(r.files()); + report = r.report(); } else { for (Resource file : inputFiles) { ToolResult r = callEndpoint(step, List.of(file), supportingFiles); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 3ca3841265..20a9cb2628 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -202,7 +202,8 @@ public class SecurityConfiguration { "Origin", "X-API-KEY", "X-CSRF-TOKEN", - "X-XSRF-TOKEN")); + "X-XSRF-TOKEN", + "X-Browser-Id")); cfg.setExposedHeaders( List.of( diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/audit/ControllerAuditAspectTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/audit/ControllerAuditAspectTest.java new file mode 100644 index 0000000000..e8d8ae7a73 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/audit/ControllerAuditAspectTest.java @@ -0,0 +1,315 @@ +package stirling.software.proprietary.audit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.Signature; +import org.aspectj.lang.reflect.MethodSignature; +import org.junit.jupiter.api.AfterEach; +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.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.service.AuditService; + +@ExtendWith(MockitoExtension.class) +class ControllerAuditAspectTest { + + @Mock private AuditService auditService; + + private AuditConfigurationProperties auditConfig; + private ControllerAuditAspect aspect; + + @BeforeEach + void setUp() { + // Default config: enabled=true, level=STANDARD(2) + auditConfig = new AuditConfigurationProperties(new ApplicationProperties()); + aspect = new ControllerAuditAspect(auditService, auditConfig); + } + + @AfterEach + void tearDown() { + MDC.clear(); + } + + private ProceedingJoinPoint joinPointFor(String methodName) throws Exception { + ProceedingJoinPoint jp = mock(ProceedingJoinPoint.class); + MethodSignature sig = mock(MethodSignature.class); + Method method = SampleController.class.getMethod(methodName); + lenient().when(jp.getSignature()).thenReturn((Signature) sig); + lenient().when(sig.getMethod()).thenReturn(method); + lenient().when(jp.getTarget()).thenReturn(new SampleController()); + lenient().when(jp.getArgs()).thenReturn(new Object[0]); + return jp; + } + + @Nested + @DisplayName("fast path") + class FastPath { + + @Test + @DisplayName("shouldAudit false proceeds without recording") + void skipsWhenShouldAuditFalse() throws Throwable { + ProceedingJoinPoint jp = joinPointFor("getEndpoint"); + when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(false); + when(jp.proceed()).thenReturn("ok"); + + Object result = aspect.auditGetMethod(jp); + + assertThat(result).isEqualTo("ok"); + verify(jp).proceed(); + verify(auditService, never()) + .audit( + any(String.class), + any(String.class), + any(), + any(AuditEventType.class), + anyMap(), + any(AuditLevel.class)); + } + } + + @Nested + @DisplayName("success path") + class SuccessPath { + + @Test + @DisplayName("records success outcome and returns result") + void recordsSuccess() throws Throwable { + ProceedingJoinPoint jp = joinPointFor("postEndpoint"); + when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true); + when(auditService.captureCurrentPrincipal()).thenReturn("alice"); + when(auditService.captureCurrentOrigin()).thenReturn("WEB"); + when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class))) + .thenReturn(new HashMap<>()); + when(auditService.resolveEventType( + any(Method.class), any(Class.class), any(), eq("POST"), isNull())) + .thenReturn(AuditEventType.PDF_PROCESS); + when(jp.proceed()).thenReturn("done"); + + Object result = aspect.auditPostMethod(jp); + + assertThat(result).isEqualTo("done"); + + ArgumentCaptor> dataCaptor = mapCaptor(); + verify(auditService) + .audit( + eq("alice"), + eq("WEB"), + any(), + eq(AuditEventType.PDF_PROCESS), + dataCaptor.capture(), + any(AuditLevel.class)); + assertThat(dataCaptor.getValue()).containsEntry("outcome", "success"); + } + + @Test + @DisplayName("reuses MDC principal/origin when present (no re-capture)") + void reusesMdcContext() throws Throwable { + MDC.put("auditPrincipal", "fromMdc"); + MDC.put("auditOrigin", "API"); + ProceedingJoinPoint jp = joinPointFor("postEndpoint"); + when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true); + when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class))) + .thenReturn(new HashMap<>()); + when(auditService.resolveEventType( + any(Method.class), any(Class.class), any(), eq("POST"), isNull())) + .thenReturn(AuditEventType.PDF_PROCESS); + when(jp.proceed()).thenReturn("done"); + + aspect.auditPostMethod(jp); + + // Principal/origin already in MDC, so service capture must not be called + verify(auditService, never()).captureCurrentPrincipal(); + verify(auditService, never()).captureCurrentOrigin(); + verify(auditService) + .audit( + eq("fromMdc"), + eq("API"), + any(), + eq(AuditEventType.PDF_PROCESS), + anyMap(), + any(AuditLevel.class)); + } + } + + @Nested + @DisplayName("failure path") + class FailurePath { + + @Test + @DisplayName("records failure outcome and rethrows") + void recordsFailureAndRethrows() throws Throwable { + ProceedingJoinPoint jp = joinPointFor("postEndpoint"); + when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true); + when(auditService.captureCurrentPrincipal()).thenReturn("alice"); + when(auditService.captureCurrentOrigin()).thenReturn("WEB"); + when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class))) + .thenReturn(new HashMap<>()); + when(auditService.resolveEventType( + any(Method.class), any(Class.class), any(), eq("POST"), isNull())) + .thenReturn(AuditEventType.PDF_PROCESS); + when(jp.proceed()).thenThrow(new IllegalStateException("boom")); + + assertThatThrownBy(() -> aspect.auditPostMethod(jp)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("boom"); + + ArgumentCaptor> dataCaptor = mapCaptor(); + verify(auditService) + .audit( + eq("alice"), + eq("WEB"), + any(), + eq(AuditEventType.PDF_PROCESS), + dataCaptor.capture(), + any(AuditLevel.class)); + Map data = dataCaptor.getValue(); + assertThat(data).containsEntry("outcome", "failure"); + assertThat(data).containsEntry("errorType", "IllegalStateException"); + assertThat(data).containsEntry("errorMessage", "boom"); + } + } + + @Nested + @DisplayName("@Audited delegation") + class AuditedDelegation { + + @Test + @DisplayName("annotated method proceeds without double-auditing") + void annotatedMethodSkips() throws Throwable { + ProceedingJoinPoint jp = joinPointFor("annotatedEndpoint"); + when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true); + when(auditService.captureCurrentPrincipal()).thenReturn("alice"); + when(auditService.captureCurrentOrigin()).thenReturn("WEB"); + when(jp.proceed()).thenReturn("ok"); + + Object result = aspect.auditPostMethod(jp); + + assertThat(result).isEqualTo("ok"); + // @Audited methods are handled by AuditAspect, so this aspect must not record + verify(auditService, never()) + .audit( + any(String.class), + any(String.class), + any(), + any(AuditEventType.class), + anyMap(), + any(AuditLevel.class)); + } + } + + @Nested + @DisplayName("operation result capture") + class OperationResults { + + @Test + @DisplayName("captures result when enabled and non-UI type") + void capturesResult() throws Throwable { + ProceedingJoinPoint jp = joinPointFor("postEndpoint"); + when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true); + when(auditService.captureCurrentPrincipal()).thenReturn("alice"); + when(auditService.captureCurrentOrigin()).thenReturn("WEB"); + when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class))) + .thenReturn(new HashMap<>()); + when(auditService.resolveEventType( + any(Method.class), any(Class.class), any(), eq("POST"), isNull())) + .thenReturn(AuditEventType.PDF_PROCESS); + when(auditService.shouldCaptureOperationResults()).thenReturn(true); + when(auditService.safeToString(eq("done"), anyInt())).thenReturn("done"); + when(jp.proceed()).thenReturn("done"); + + aspect.auditPostMethod(jp); + + ArgumentCaptor> dataCaptor = mapCaptor(); + verify(auditService) + .audit( + eq("alice"), + eq("WEB"), + any(), + eq(AuditEventType.PDF_PROCESS), + dataCaptor.capture(), + any(AuditLevel.class)); + assertThat(dataCaptor.getValue()).containsEntry("result", "done"); + } + + @Test + @DisplayName("UI_DATA result is not captured") + void uiDataResultSkipped() throws Throwable { + ProceedingJoinPoint jp = joinPointFor("getEndpoint"); + when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true); + when(auditService.captureCurrentPrincipal()).thenReturn("alice"); + when(auditService.captureCurrentOrigin()).thenReturn("WEB"); + when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class))) + .thenReturn(new HashMap<>()); + when(auditService.resolveEventType( + any(Method.class), any(Class.class), any(), eq("GET"), isNull())) + .thenReturn(AuditEventType.UI_DATA); + lenient().when(auditService.shouldCaptureOperationResults()).thenReturn(true); + when(jp.proceed()).thenReturn("payload"); + + aspect.auditGetMethod(jp); + + ArgumentCaptor> dataCaptor = mapCaptor(); + verify(auditService) + .audit( + eq("alice"), + eq("WEB"), + any(), + eq(AuditEventType.UI_DATA), + dataCaptor.capture(), + any(AuditLevel.class)); + assertThat(dataCaptor.getValue()).doesNotContainKey("result"); + } + } + + @SuppressWarnings("unchecked") + private static ArgumentCaptor> mapCaptor() { + return ArgumentCaptor.forClass(Map.class); + } + + /** Sample controller whose methods carry the web-mapping annotations the aspect inspects. */ + public static class SampleController { + + @GetMapping("/api/v1/sample") + public String getEndpoint() { + return "get"; + } + + @PostMapping("/api/v1/sample") + public String postEndpoint() { + return "post"; + } + + @PostMapping("/api/v1/annotated") + @Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC) + public String annotatedEndpoint() { + return "annotated"; + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AuditDashboardControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AuditDashboardControllerTest.java new file mode 100644 index 0000000000..8887c4787b --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AuditDashboardControllerTest.java @@ -0,0 +1,391 @@ +package stirling.software.proprietary.controller.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import stirling.software.proprietary.model.api.audit.AuditDataRequest; +import stirling.software.proprietary.model.api.audit.AuditDataResponse; +import stirling.software.proprietary.model.api.audit.AuditExportRequest; +import stirling.software.proprietary.model.api.audit.AuditStatsResponse; +import stirling.software.proprietary.model.security.PersistentAuditEvent; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +@ExtendWith(MockitoExtension.class) +class AuditDashboardControllerTest { + + @Mock private PersistentAuditEventRepository auditRepository; + + private ObjectMapper objectMapper; + private AuditDashboardController controller; + + @BeforeEach + void setUp() { + objectMapper = JsonMapper.builder().build(); + controller = new AuditDashboardController(auditRepository, objectMapper); + } + + private PersistentAuditEvent event(long id, String principal, String type, String data) { + return PersistentAuditEvent.builder() + .id(id) + .principal(principal) + .type(type) + .data(data) + .timestamp(Instant.parse("2025-01-01T10:15:30Z")) + .build(); + } + + private Page page(List content) { + return new PageImpl<>(content, PageRequest.of(0, 30), content.size()); + } + + @Nested + @DisplayName("getAuditData filter branches") + class GetAuditData { + + @Test + @DisplayName("no filters falls through to findAll") + void noFilters() { + AuditDataRequest req = new AuditDataRequest(); + when(auditRepository.findAll(any(Pageable.class))) + .thenReturn(page(List.of(event(1L, "admin", "USER_LOGIN", null)))); + + AuditDataResponse resp = controller.getAuditData(req); + + assertThat(resp.getContent()).hasSize(1); + assertThat(resp.getTotalElements()).isEqualTo(1L); + assertThat(resp.getCurrentPage()).isZero(); + } + + @Test + @DisplayName("type only routes to findByType") + void typeOnly() { + AuditDataRequest req = new AuditDataRequest(); + req.setType("USER_LOGIN"); + when(auditRepository.findByType(eq("USER_LOGIN"), any(Pageable.class))) + .thenReturn(page(List.of())); + + controller.getAuditData(req); + + verify(auditRepository).findByType(eq("USER_LOGIN"), any(Pageable.class)); + } + + @Test + @DisplayName("principal only routes to findByPrincipal") + void principalOnly() { + AuditDataRequest req = new AuditDataRequest(); + req.setPrincipal("admin"); + when(auditRepository.findByPrincipal(eq("admin"), any(Pageable.class))) + .thenReturn(page(List.of())); + + controller.getAuditData(req); + + verify(auditRepository).findByPrincipal(eq("admin"), any(Pageable.class)); + } + + @Test + @DisplayName("type and principal routes to findByPrincipalAndType") + void typeAndPrincipal() { + AuditDataRequest req = new AuditDataRequest(); + req.setType("USER_LOGIN"); + req.setPrincipal("admin"); + when(auditRepository.findByPrincipalAndType( + eq("admin"), eq("USER_LOGIN"), any(Pageable.class))) + .thenReturn(page(List.of())); + + controller.getAuditData(req); + + verify(auditRepository) + .findByPrincipalAndType(eq("admin"), eq("USER_LOGIN"), any(Pageable.class)); + } + + @Test + @DisplayName("date range only routes to findByTimestampBetween") + void dateRangeOnly() { + AuditDataRequest req = new AuditDataRequest(); + req.setStartDate(LocalDate.of(2025, 1, 1)); + req.setEndDate(LocalDate.of(2025, 1, 31)); + when(auditRepository.findByTimestampBetween( + any(Instant.class), any(Instant.class), any(Pageable.class))) + .thenReturn(page(List.of())); + + controller.getAuditData(req); + + verify(auditRepository) + .findByTimestampBetween( + any(Instant.class), any(Instant.class), any(Pageable.class)); + } + + @Test + @DisplayName("type and date range routes to findByTypeAndTimestampBetween") + void typeAndDateRange() { + AuditDataRequest req = new AuditDataRequest(); + req.setType("PDF_PROCESS"); + req.setStartDate(LocalDate.of(2025, 1, 1)); + req.setEndDate(LocalDate.of(2025, 1, 31)); + when(auditRepository.findByTypeAndTimestampBetween( + eq("PDF_PROCESS"), + any(Instant.class), + any(Instant.class), + any(Pageable.class))) + .thenReturn(page(List.of())); + + controller.getAuditData(req); + + verify(auditRepository) + .findByTypeAndTimestampBetween( + eq("PDF_PROCESS"), + any(Instant.class), + any(Instant.class), + any(Pageable.class)); + } + + @Test + @DisplayName("principal and date range routes to findByPrincipalAndTimestampBetween") + void principalAndDateRange() { + AuditDataRequest req = new AuditDataRequest(); + req.setPrincipal("admin"); + req.setStartDate(LocalDate.of(2025, 1, 1)); + req.setEndDate(LocalDate.of(2025, 1, 31)); + when(auditRepository.findByPrincipalAndTimestampBetween( + eq("admin"), + any(Instant.class), + any(Instant.class), + any(Pageable.class))) + .thenReturn(page(List.of())); + + controller.getAuditData(req); + + verify(auditRepository) + .findByPrincipalAndTimestampBetween( + eq("admin"), + any(Instant.class), + any(Instant.class), + any(Pageable.class)); + } + + @Test + @DisplayName("all filters route to findByPrincipalAndTypeAndTimestampBetween") + void allFilters() { + AuditDataRequest req = new AuditDataRequest(); + req.setType("USER_LOGIN"); + req.setPrincipal("admin"); + req.setStartDate(LocalDate.of(2025, 1, 1)); + req.setEndDate(LocalDate.of(2025, 1, 31)); + when(auditRepository.findByPrincipalAndTypeAndTimestampBetween( + eq("admin"), + eq("USER_LOGIN"), + any(Instant.class), + any(Instant.class), + any(Pageable.class))) + .thenReturn(page(List.of())); + + controller.getAuditData(req); + + verify(auditRepository) + .findByPrincipalAndTypeAndTimestampBetween( + eq("admin"), + eq("USER_LOGIN"), + any(Instant.class), + any(Instant.class), + any(Pageable.class)); + } + } + + @Nested + @DisplayName("getAuditStats aggregation") + class GetAuditStats { + + @Test + @DisplayName("groups events by type, principal and day") + void aggregatesCounts() { + List events = + List.of( + event(1L, "admin", "USER_LOGIN", null), + event(2L, "admin", "USER_LOGIN", null), + event(3L, "bob", "PDF_PROCESS", null)); + when(auditRepository.findByTimestampAfter(any(Instant.class))).thenReturn(events); + + AuditStatsResponse resp = controller.getAuditStats(7); + + assertThat(resp.getTotalEvents()).isEqualTo(3); + assertThat(resp.getEventsByType()).containsEntry("USER_LOGIN", 2L); + assertThat(resp.getEventsByType()).containsEntry("PDF_PROCESS", 1L); + assertThat(resp.getEventsByPrincipal()).containsEntry("admin", 2L); + assertThat(resp.getEventsByDay()).containsEntry("2025-01-01", 3L); + } + + @Test + @DisplayName("empty result yields zero totals") + void emptyResult() { + when(auditRepository.findByTimestampAfter(any(Instant.class))).thenReturn(List.of()); + + AuditStatsResponse resp = controller.getAuditStats(30); + + assertThat(resp.getTotalEvents()).isZero(); + assertThat(resp.getEventsByType()).isEmpty(); + } + } + + @Nested + @DisplayName("getAuditTypes") + class GetAuditTypes { + + @Test + @DisplayName("merges db types with enum types and sorts distinct") + void mergesAndSorts() { + when(auditRepository.findDistinctEventTypes()) + .thenReturn(List.of("CUSTOM_TYPE", "USER_LOGIN")); + + List types = controller.getAuditTypes(); + + assertThat(types).contains("CUSTOM_TYPE", "USER_LOGIN", "PDF_PROCESS"); + assertThat(types).isSorted(); + assertThat(types).doesNotHaveDuplicates(); + } + } + + @Nested + @DisplayName("exportAuditData CSV") + class ExportCsv { + + @Test + @DisplayName("returns CSV with header and escaped rows") + void csvWithRows() { + AuditExportRequest req = new AuditExportRequest(); + when(auditRepository.findAll()) + .thenReturn(List.of(event(1L, "ad\"min", "USER_LOGIN", "{\"a\":1}"))); + + ResponseEntity resp = controller.exportAuditData(req); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + String csv = new String(resp.getBody(), StandardCharsets.UTF_8); + assertThat(csv).startsWith("ID,Principal,Type,Timestamp,Data"); + // Quotes inside fields must be doubled + assertThat(csv).contains("\"ad\"\"min\""); + assertThat(resp.getHeaders().getContentDisposition().getFilename()) + .isEqualTo("audit_export.csv"); + } + + @Test + @DisplayName("type filter feeds findByTypeForExport") + void csvWithTypeFilter() { + AuditExportRequest req = new AuditExportRequest(); + req.setType("USER_LOGIN"); + when(auditRepository.findByTypeForExport("USER_LOGIN")).thenReturn(List.of()); + + controller.exportAuditData(req); + + verify(auditRepository).findByTypeForExport("USER_LOGIN"); + verify(auditRepository, never()).findAll(); + } + } + + @Nested + @DisplayName("exportAuditDataJson") + class ExportJson { + + @Test + @DisplayName("returns JSON body and attachment header") + void jsonExport() { + AuditExportRequest req = new AuditExportRequest(); + when(auditRepository.findAll()) + .thenReturn(List.of(event(1L, "admin", "USER_LOGIN", null))); + + ResponseEntity resp = controller.exportAuditDataJson(req); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + String json = new String(resp.getBody(), StandardCharsets.UTF_8); + assertThat(json).contains("\"principal\":\"admin\""); + assertThat(resp.getHeaders().getContentDisposition().getFilename()) + .isEqualTo("audit_export.json"); + } + + @Test + @DisplayName("all-filter export routes to combined-criteria query") + void jsonExportAllFilters() { + AuditExportRequest req = new AuditExportRequest(); + req.setType("USER_LOGIN"); + req.setPrincipal("admin"); + req.setStartDate(LocalDate.of(2025, 1, 1)); + req.setEndDate(LocalDate.of(2025, 1, 31)); + when(auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport( + eq("admin"), eq("USER_LOGIN"), any(Instant.class), any(Instant.class))) + .thenReturn(List.of()); + + controller.exportAuditDataJson(req); + + verify(auditRepository) + .findAllByPrincipalAndTypeAndTimestampBetweenForExport( + eq("admin"), eq("USER_LOGIN"), any(Instant.class), any(Instant.class)); + } + } + + @Nested + @DisplayName("cleanupBefore") + class CleanupBefore { + + @Test + @DisplayName("past date deletes and returns count") + void pastDateDeletes() { + LocalDate cutoff = LocalDate.now().minusDays(1); + when(auditRepository.deleteByTimestampBefore(any(Instant.class))).thenReturn(5); + + var result = controller.cleanupBefore(cutoff); + + assertThat(result).containsEntry("deleted", 5); + assertThat(result).containsEntry("cutoffDate", cutoff.toString()); + } + + @Test + @DisplayName("future date is rejected without delete") + void futureDateRejected() { + LocalDate future = LocalDate.now().plusDays(1); + + var result = controller.cleanupBefore(future); + + assertThat(result).containsKey("error"); + verify(auditRepository, never()).deleteByTimestampBefore(any(Instant.class)); + } + } + + @Test + @DisplayName("escapeCSV null becomes empty string via default export path") + void csvHandlesNullData() { + AuditExportRequest req = new AuditExportRequest(); + when(auditRepository.findAll()).thenReturn(List.of(event(1L, "admin", "USER_LOGIN", null))); + + ResponseEntity resp = controller.exportAuditData(req); + + String csv = new String(resp.getBody(), StandardCharsets.UTF_8); + // Null data field renders as empty quoted field + assertThat(csv).contains("\"USER_LOGIN\""); + assertThat(csv).contains("admin"); + verify(auditRepository).findAll(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AuditRestControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AuditRestControllerTest.java new file mode 100644 index 0000000000..e3c3a0dd38 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/AuditRestControllerTest.java @@ -0,0 +1,559 @@ +package stirling.software.proprietary.controller.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import stirling.software.proprietary.controller.api.AuditRestController.AuditChartsData; +import stirling.software.proprietary.controller.api.AuditRestController.AuditEventsResponse; +import stirling.software.proprietary.controller.api.AuditRestController.AuditStatsData; +import stirling.software.proprietary.model.security.PersistentAuditEvent; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +@ExtendWith(MockitoExtension.class) +class AuditRestControllerTest { + + @Mock private PersistentAuditEventRepository auditRepository; + + private ObjectMapper objectMapper; + private AuditRestController controller; + + @BeforeEach + void setUp() { + objectMapper = JsonMapper.builder().build(); + controller = new AuditRestController(auditRepository, objectMapper); + } + + private PersistentAuditEvent event(long id, String principal, String type, String data) { + return PersistentAuditEvent.builder() + .id(id) + .principal(principal) + .type(type) + .data(data) + .timestamp(Instant.parse("2025-01-01T10:15:30Z")) + .build(); + } + + private Page page(List content) { + return new PageImpl<>(content, PageRequest.of(0, 30), content.size()); + } + + @Nested + @DisplayName("getAuditEvents filter routing") + class GetAuditEvents { + + @Test + @DisplayName("no filters falls through to findAll and builds paginated response") + void noFilters() { + when(auditRepository.findAll(any(Pageable.class))) + .thenReturn(page(List.of(event(1L, "admin", "USER_LOGIN", "{\"x\":1}")))); + + ResponseEntity resp = + controller.getAuditEvents(0, 30, null, null, null, null); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + AuditEventsResponse body = resp.getBody(); + assertThat(body.getEvents()).hasSize(1); + assertThat(body.getTotalEvents()).isEqualTo(1); + assertThat(body.getPage()).isZero(); + assertThat(body.getPageSize()).isEqualTo(30); + } + + @Test + @DisplayName("empty arrays are treated as no filter") + void emptyArraysIgnored() { + when(auditRepository.findAll(any(Pageable.class))).thenReturn(page(List.of())); + + controller.getAuditEvents(0, 30, new String[0], new String[0], null, null); + + verify(auditRepository).findAll(any(Pageable.class)); + } + + @Test + @DisplayName("eventType only routes to findByTypeIn") + void eventTypeOnly() { + when(auditRepository.findByTypeIn(anyList(), any(Pageable.class))) + .thenReturn(page(List.of())); + + controller.getAuditEvents(0, 30, new String[] {"USER_LOGIN"}, null, null, null); + + verify(auditRepository).findByTypeIn(eq(List.of("USER_LOGIN")), any(Pageable.class)); + } + + @Test + @DisplayName("username only routes to findByPrincipalIn") + void usernameOnly() { + when(auditRepository.findByPrincipalIn(anyList(), any(Pageable.class))) + .thenReturn(page(List.of())); + + controller.getAuditEvents(0, 30, null, new String[] {"admin"}, null, null); + + verify(auditRepository).findByPrincipalIn(eq(List.of("admin")), any(Pageable.class)); + } + + @Test + @DisplayName("type and username routes to findByTypeInAndPrincipalIn") + void typeAndUsername() { + when(auditRepository.findByTypeInAndPrincipalIn( + anyList(), anyList(), any(Pageable.class))) + .thenReturn(page(List.of())); + + controller.getAuditEvents( + 0, 30, new String[] {"USER_LOGIN"}, new String[] {"admin"}, null, null); + + verify(auditRepository) + .findByTypeInAndPrincipalIn(anyList(), anyList(), any(Pageable.class)); + } + + @Test + @DisplayName("date range only routes to findByTimestampBetween") + void dateRangeOnly() { + when(auditRepository.findByTimestampBetween( + any(Instant.class), any(Instant.class), any(Pageable.class))) + .thenReturn(page(List.of())); + + controller.getAuditEvents( + 0, 30, null, null, LocalDate.of(2025, 1, 1), LocalDate.of(2025, 1, 31)); + + verify(auditRepository) + .findByTimestampBetween( + any(Instant.class), any(Instant.class), any(Pageable.class)); + } + + @Test + @DisplayName("type and date range routes to findByTypeInAndTimestampBetween") + void typeAndDateRange() { + when(auditRepository.findByTypeInAndTimestampBetween( + anyList(), any(Instant.class), any(Instant.class), any(Pageable.class))) + .thenReturn(page(List.of())); + + controller.getAuditEvents( + 0, + 30, + new String[] {"PDF_PROCESS"}, + null, + LocalDate.of(2025, 1, 1), + LocalDate.of(2025, 1, 31)); + + verify(auditRepository) + .findByTypeInAndTimestampBetween( + anyList(), any(Instant.class), any(Instant.class), any(Pageable.class)); + } + + @Test + @DisplayName("username and date range routes to findByPrincipalInAndTimestampBetween") + void usernameAndDateRange() { + when(auditRepository.findByPrincipalInAndTimestampBetween( + anyList(), any(Instant.class), any(Instant.class), any(Pageable.class))) + .thenReturn(page(List.of())); + + controller.getAuditEvents( + 0, + 30, + null, + new String[] {"admin"}, + LocalDate.of(2025, 1, 1), + LocalDate.of(2025, 1, 31)); + + verify(auditRepository) + .findByPrincipalInAndTimestampBetween( + anyList(), any(Instant.class), any(Instant.class), any(Pageable.class)); + } + + @Test + @DisplayName("all filters route to combined query") + void allFilters() { + when(auditRepository.findByTypeInAndPrincipalInAndTimestampBetween( + anyList(), + anyList(), + any(Instant.class), + any(Instant.class), + any(Pageable.class))) + .thenReturn(page(List.of())); + + controller.getAuditEvents( + 0, + 30, + new String[] {"USER_LOGIN"}, + new String[] {"admin"}, + LocalDate.of(2025, 1, 1), + LocalDate.of(2025, 1, 31)); + + verify(auditRepository) + .findByTypeInAndPrincipalInAndTimestampBetween( + anyList(), + anyList(), + any(Instant.class), + any(Instant.class), + any(Pageable.class)); + } + + @Test + @DisplayName("invalid json data is captured as rawData in dto details") + void invalidJsonBecomesRawData() { + when(auditRepository.findAll(any(Pageable.class))) + .thenReturn(page(List.of(event(1L, "admin", "USER_LOGIN", "not-json")))); + + ResponseEntity resp = + controller.getAuditEvents(0, 30, null, null, null, null); + + var details = resp.getBody().getEvents().get(0).getDetails(); + assertThat(details).containsEntry("rawData", "not-json"); + } + + @Test + @DisplayName("clientIp is extracted into dto ipAddress") + void clientIpExtracted() { + when(auditRepository.findAll(any(Pageable.class))) + .thenReturn( + page( + List.of( + event( + 1L, + "admin", + "USER_LOGIN", + "{\"clientIp\":\"10.0.0.5\"}")))); + + ResponseEntity resp = + controller.getAuditEvents(0, 30, null, null, null, null); + + assertThat(resp.getBody().getEvents().get(0).getIpAddress()).isEqualTo("10.0.0.5"); + } + + @Test + @DisplayName("__ipAddress fallback is used when clientIp missing") + void ipAddressFallback() { + when(auditRepository.findAll(any(Pageable.class))) + .thenReturn( + page( + List.of( + event( + 1L, + "admin", + "USER_LOGIN", + "{\"__ipAddress\":\"192.168.1.1\"}")))); + + ResponseEntity resp = + controller.getAuditEvents(0, 30, null, null, null, null); + + assertThat(resp.getBody().getEvents().get(0).getIpAddress()).isEqualTo("192.168.1.1"); + } + } + + @Nested + @DisplayName("getAuditCharts period handling") + class GetAuditCharts { + + @Test + @DisplayName("groups events by type, user and day") + void buildsChartData() { + when(auditRepository.findByTimestampAfter(any(Instant.class))) + .thenReturn( + List.of( + event(1L, "admin", "USER_LOGIN", null), + event(2L, "admin", "USER_LOGIN", null), + event(3L, "bob", "PDF_PROCESS", null))); + + ResponseEntity resp = controller.getAuditCharts("week"); + + AuditChartsData data = resp.getBody(); + assertThat(data.getEventsByType().getLabels()).contains("USER_LOGIN", "PDF_PROCESS"); + assertThat(data.getEventsByUser().getLabels()).contains("admin", "bob"); + assertThat(data.getEventsOverTime().getLabels()).contains("2025-01-01"); + } + + @Test + @DisplayName("day and month periods resolve without error") + void dayAndMonthPeriods() { + when(auditRepository.findByTimestampAfter(any(Instant.class))).thenReturn(List.of()); + + assertThat(controller.getAuditCharts("day").getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(controller.getAuditCharts("month").getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(controller.getAuditCharts("unknown").getStatusCode()) + .isEqualTo(HttpStatus.OK); + } + } + + @Nested + @DisplayName("getEventTypes and getUsers") + class TypesAndUsers { + + @Test + @DisplayName("event types merge db and enum values sorted distinct") + void eventTypesMerged() { + when(auditRepository.findDistinctEventTypes()).thenReturn(List.of("CUSTOM")); + + ResponseEntity> resp = controller.getEventTypes(); + + assertThat(resp.getBody()).contains("CUSTOM", "USER_LOGIN"); + assertThat(resp.getBody()).isSorted(); + assertThat(resp.getBody()).doesNotHaveDuplicates(); + } + + @Test + @DisplayName("users extracted and sorted from countByPrincipal rows") + void usersExtracted() { + when(auditRepository.countByPrincipal()) + .thenReturn(List.of(new Object[] {"zoe", 3L}, new Object[] {"amy", 1L})); + + ResponseEntity> resp = controller.getUsers(); + + assertThat(resp.getBody()).containsExactly("amy", "zoe"); + } + } + + @Nested + @DisplayName("getAuditStats metric computation") + class GetAuditStats { + + @Test + @DisplayName("computes success rate, latency, error count and top items") + void computesMetrics() { + List current = + List.of( + event( + 1L, + "admin", + "PDF_PROCESS", + "{\"status\":\"success\",\"latencyMs\":100,\"path\":\"/api/v1/merge\"}"), + event( + 2L, + "admin", + "PDF_PROCESS", + "{\"status\":\"failure\",\"latencyMs\":200,\"path\":\"/api/v1/merge\"}"), + event(3L, "bob", "USER_LOGIN", "{\"statusCode\":500}")); + when(auditRepository.findByTimestampAfter(any(Instant.class))).thenReturn(current); + when(auditRepository.findAllByTimestampBetweenForExport( + any(Instant.class), any(Instant.class))) + .thenReturn(List.of()); + when(auditRepository.histogramByHourBetween(any(Instant.class), any(Instant.class))) + .thenReturn(List.of(new Object[] {10, 2L})); + + ResponseEntity resp = controller.getAuditStats("week"); + + AuditStatsData data = resp.getBody(); + assertThat(data.getTotalEvents()).isEqualTo(3); + assertThat(data.getUniqueUsers()).isEqualTo(2); + // 1 success out of 2 with explicit outcome + assertThat(data.getSuccessRate()).isEqualTo(50.0); + assertThat(data.getAvgLatencyMs()).isEqualTo(150.0); + // 1 explicit failure + 1 statusCode>=400 + assertThat(data.getErrorCount()).isEqualTo(2); + assertThat(data.getTopEventType()).isEqualTo("PDF_PROCESS"); + assertThat(data.getTopUser()).isEqualTo("admin"); + assertThat(data.getTopTools()).containsKey("merge"); + assertThat(data.getHourlyDistribution()).containsEntry("10", 2L); + assertThat(data.getHourlyDistribution()).containsEntry("00", 0L); + } + + @Test + @DisplayName("string latency and statusCode values are parsed safely") + void stringNumericValues() { + when(auditRepository.findByTimestampAfter(any(Instant.class))) + .thenReturn( + List.of( + event( + 1L, + "admin", + "PDF_PROCESS", + "{\"latencyMs\":\"300\",\"statusCode\":\"404\"}"))); + when(auditRepository.findAllByTimestampBetweenForExport( + any(Instant.class), any(Instant.class))) + .thenReturn(List.of()); + when(auditRepository.histogramByHourBetween(any(Instant.class), any(Instant.class))) + .thenReturn(List.of()); + + ResponseEntity resp = controller.getAuditStats("month"); + + assertThat(resp.getBody().getAvgLatencyMs()).isEqualTo(300.0); + assertThat(resp.getBody().getErrorCount()).isEqualTo(1); + } + + @Test + @DisplayName("legacy outcome key counts toward success rate") + void legacyOutcomeKey() { + when(auditRepository.findByTimestampAfter(any(Instant.class))) + .thenReturn( + List.of( + event( + 1L, + "admin", + "PDF_PROCESS", + "{\"outcome\":\"success\"}"))); + when(auditRepository.findAllByTimestampBetweenForExport( + any(Instant.class), any(Instant.class))) + .thenReturn(List.of()); + when(auditRepository.histogramByHourBetween(any(Instant.class), any(Instant.class))) + .thenReturn(List.of()); + + ResponseEntity resp = controller.getAuditStats("day"); + + assertThat(resp.getBody().getSuccessRate()).isEqualTo(100.0); + } + + @Test + @DisplayName("empty period yields default metrics") + void emptyMetrics() { + when(auditRepository.findByTimestampAfter(any(Instant.class))).thenReturn(List.of()); + when(auditRepository.findAllByTimestampBetweenForExport( + any(Instant.class), any(Instant.class))) + .thenReturn(List.of()); + when(auditRepository.histogramByHourBetween(any(Instant.class), any(Instant.class))) + .thenReturn(List.of()); + + ResponseEntity resp = controller.getAuditStats("week"); + + assertThat(resp.getBody().getTotalEvents()).isZero(); + assertThat(resp.getBody().getSuccessRate()).isZero(); + assertThat(resp.getBody().getHourlyDistribution()).hasSize(24); + } + } + + @Nested + @DisplayName("exportAuditData CSV/JSON") + class Export { + + @Test + @DisplayName("default CSV (no fields) uses technical header") + void defaultCsv() { + when(auditRepository.findAll()) + .thenReturn(List.of(event(1L, "admin", "USER_LOGIN", "{\"a\":1}"))); + + ResponseEntity resp = + controller.exportAuditData("csv", null, null, null, null, null); + + String csv = new String(resp.getBody(), StandardCharsets.UTF_8); + assertThat(csv).startsWith("ID,Principal,Type,Timestamp,Data"); + assertThat(resp.getHeaders().getContentDisposition().getFilename()) + .isEqualTo("audit_export.csv"); + } + + @Test + @DisplayName("field-selected CSV builds custom header and extracts nested data") + void fieldSelectedCsv() { + String data = + "{\"path\":\"/api/v1/merge\",\"outcome\":\"success\"," + + "\"clientIp\":\"1.2.3.4\",\"result\":\"ok\"," + + "\"files\":[{\"name\":\"a.pdf\",\"pdfAuthor\":\"jo\",\"fileHash\":\"abc\"}]}"; + when(auditRepository.findAll()) + .thenReturn(List.of(event(1L, "admin", "USER_LOGIN", data))); + + ResponseEntity resp = + controller.exportAuditData( + "csv", + "date,username,ipaddress,tool,documentname,outcome,author,filehash,operationresults,eventtype", + null, + null, + null, + null); + + String csv = new String(resp.getBody(), StandardCharsets.UTF_8); + assertThat(csv).contains("Date,Username,IP Address,Tool,Document Name"); + assertThat(csv).contains("merge"); + assertThat(csv).contains("a.pdf"); + assertThat(csv).contains("jo"); + assertThat(csv).contains("abc"); + assertThat(csv).contains("1.2.3.4"); + assertThat(resp.getHeaders().getContentDisposition().getFilename()) + .startsWith("audit_export_"); + } + + @Test + @DisplayName("json format returns json bytes") + void jsonExport() { + when(auditRepository.findAll()) + .thenReturn(List.of(event(1L, "admin", "USER_LOGIN", null))); + + ResponseEntity resp = + controller.exportAuditData("json", null, null, null, null, null); + + String json = new String(resp.getBody(), StandardCharsets.UTF_8); + assertThat(json).contains("\"principal\":\"admin\""); + assertThat(resp.getHeaders().getContentDisposition().getFilename()) + .isEqualTo("audit_export.json"); + } + + @Test + @DisplayName("type-only export routes to findByTypeInForExport") + void typeOnlyExport() { + when(auditRepository.findByTypeInForExport(anyList())).thenReturn(List.of()); + + controller.exportAuditData("csv", null, new String[] {"USER_LOGIN"}, null, null, null); + + verify(auditRepository).findByTypeInForExport(eq(List.of("USER_LOGIN"))); + verify(auditRepository, never()).findAll(); + } + + @Test + @DisplayName("all-filter export routes to combined export query") + void allFilterExport() { + when(auditRepository.findByTypeInAndPrincipalInAndTimestampBetweenForExport( + anyList(), anyList(), any(Instant.class), any(Instant.class))) + .thenReturn(List.of()); + + controller.exportAuditData( + "csv", + null, + new String[] {"USER_LOGIN"}, + new String[] {"admin"}, + LocalDate.of(2025, 1, 1), + LocalDate.of(2025, 1, 31)); + + verify(auditRepository) + .findByTypeInAndPrincipalInAndTimestampBetweenForExport( + anyList(), anyList(), any(Instant.class), any(Instant.class)); + } + } + + @Nested + @DisplayName("clearAllAuditData") + class ClearAll { + + @Test + @DisplayName("success returns ok with message") + void success() { + ResponseEntity resp = controller.clearAllAuditData(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(auditRepository).deleteAll(); + } + + @Test + @DisplayName("repository failure returns 500") + void failure() { + org.mockito.Mockito.doThrow(new RuntimeException("boom")) + .when(auditRepository) + .deleteAll(); + + ResponseEntity resp = controller.clearAllAuditData(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java new file mode 100644 index 0000000000..3ddd9e9a1e --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java @@ -0,0 +1,360 @@ +package stirling.software.proprietary.controller.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.controller.api.ProprietaryUIDataController.AccountData; +import stirling.software.proprietary.controller.api.ProprietaryUIDataController.AdminSettingsData; +import stirling.software.proprietary.controller.api.ProprietaryUIDataController.AuditDashboardData; +import stirling.software.proprietary.controller.api.ProprietaryUIDataController.LoginData; +import stirling.software.proprietary.controller.api.ProprietaryUIDataController.TeamDetailsData; +import stirling.software.proprietary.controller.api.ProprietaryUIDataController.TeamsData; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.UserLicenseSettings; +import stirling.software.proprietary.model.dto.TeamWithUserCountDTO; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; +import stirling.software.proprietary.security.database.repository.SessionRepository; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; +import stirling.software.proprietary.security.service.DatabaseServiceInterface; +import stirling.software.proprietary.security.service.LoginAttemptService; +import stirling.software.proprietary.security.service.MfaService; +import stirling.software.proprietary.security.session.SessionPersistentRegistry; +import stirling.software.proprietary.service.UserLicenseSettingsService; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ProprietaryUIDataController (additional coverage)") +class ProprietaryUIDataControllerMoreTest { + + @Mock private SessionPersistentRegistry sessionPersistentRegistry; + @Mock private UserRepository userRepository; + @Mock private TeamRepository teamRepository; + @Mock private SessionRepository sessionRepository; + @Mock private DatabaseServiceInterface databaseService; + @Mock private UserLicenseSettingsService licenseSettingsService; + @Mock private PersistentAuditEventRepository auditRepository; + @Mock private MfaService mfaService; + @Mock private LoginAttemptService loginAttemptService; + + private ApplicationProperties applicationProperties; + private AuditConfigurationProperties auditConfig; + private ObjectMapper objectMapper; + + private ProprietaryUIDataController controller; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + applicationProperties.getUi().setLanguages(List.of("en", "de")); + applicationProperties.getSystem().setDefaultLocale("en"); + applicationProperties.getSecurity().setEnableLogin(true); + + auditConfig = new AuditConfigurationProperties(applicationProperties); + objectMapper = JsonMapper.builder().build(); + + controller = + new ProprietaryUIDataController( + applicationProperties, + auditConfig, + sessionPersistentRegistry, + userRepository, + teamRepository, + sessionRepository, + databaseService, + objectMapper, + false, + licenseSettingsService, + auditRepository, + mfaService, + loginAttemptService); + } + + private static User normalUser(Long id, String username) { + User user = new User(); + user.setId(id); + user.setUsername(username); + Authority authority = new Authority(); + authority.setAuthority(Role.USER.getRoleId()); + user.addAuthority(authority); + return user; + } + + @Nested + @DisplayName("getAuditDashboardData") + class AuditDashboard { + + @Test + @DisplayName("returns audit configuration snapshot") + void returnsSnapshot() { + ResponseEntity response = controller.getAuditDashboardData(); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + AuditDashboardData data = response.getBody(); + assertThat(data.getAuditLevels()).isNotEmpty(); + assertThat(data.getAuditEventTypes()).isNotEmpty(); + // pdfMetadataEnabled mirrors file-hash or pdf-author capture flags + assertThat(data.isPdfMetadataEnabled()) + .isEqualTo(auditConfig.isCaptureFileHash() || auditConfig.isCapturePdfAuthor()); + } + } + + @Nested + @DisplayName("getLoginData") + class Login { + + @Test + @DisplayName("flags first-time setup when only the admin exists with first-login") + void singleAdminFirstLogin() { + User admin = normalUser(1L, "admin"); + admin.setFirstLogin(true); + when(userRepository.findAll()).thenReturn(List.of(admin)); + when(userRepository.findByUsernameIgnoreCase("admin")).thenReturn(Optional.of(admin)); + + ResponseEntity response = controller.getLoginData(); + + LoginData data = response.getBody(); + assertThat(data.isFirstTimeSetup()).isTrue(); + assertThat(data.isShowDefaultCredentials()).isTrue(); + } + + @Test + @DisplayName("does not flag setup when a normal user exists") + void normalUserNoSetup() { + when(userRepository.findAll()).thenReturn(List.of(normalUser(1L, "bob"))); + + ResponseEntity response = controller.getLoginData(); + + LoginData data = response.getBody(); + assertThat(data.isFirstTimeSetup()).isFalse(); + assertThat(data.getProviderList()).isEmpty(); + } + } + + @Nested + @DisplayName("getAccountData") + class Account { + + @Test + @DisplayName("returns 401 when authentication is null") + void nullAuth() { + ResponseEntity response = controller.getAccountData(null); + + assertThat(response.getStatusCode().value()).isEqualTo(401); + } + + @Test + @DisplayName("returns 401 when principal type is unrecognized") + void unknownPrincipal() { + Authentication auth = + new UsernamePasswordAuthenticationToken("plain-string", null, List.of()); + + ResponseEntity response = controller.getAccountData(auth); + + assertThat(response.getStatusCode().value()).isEqualTo(401); + } + + @Test + @DisplayName("returns 404 when the user is not found") + void userNotFound() { + User user = normalUser(1L, "ghost@example.com"); + when(userRepository.findByUsernameIgnoreCaseWithSettings("ghost@example.com")) + .thenReturn(Optional.empty()); + Authentication auth = + new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); + + ResponseEntity response = controller.getAccountData(auth); + + assertThat(response.getStatusCode().value()).isEqualTo(404); + } + + @Test + @DisplayName("resolves an OAuth2 principal and flags oauth login") + void oauth2Principal() { + User user = normalUser(2L, "oauthuser"); + user.setSettings(Map.of("k", "v")); + when(userRepository.findByUsernameIgnoreCaseWithSettings("oauthuser")) + .thenReturn(Optional.of(user)); + lenient().when(mfaService.isMfaEnabled(user)).thenReturn(false); + lenient().when(mfaService.isMfaRequired(user)).thenReturn(false); + + OAuth2User oAuth2User = + new DefaultOAuth2User(List.of(), Map.of("sub", "oauthuser"), "sub"); + Authentication auth = + new UsernamePasswordAuthenticationToken(oAuth2User, null, List.of()); + + ResponseEntity response = controller.getAccountData(auth); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody().isOAuth2Login()).isTrue(); + } + + @Test + @DisplayName("resolves a SAML2 principal and flags saml login") + void saml2Principal() { + User user = normalUser(3L, "samluser"); + when(userRepository.findByUsernameIgnoreCaseWithSettings("samluser")) + .thenReturn(Optional.of(user)); + lenient().when(mfaService.isMfaEnabled(user)).thenReturn(false); + lenient().when(mfaService.isMfaRequired(user)).thenReturn(false); + + CustomSaml2AuthenticatedPrincipal principal = + new CustomSaml2AuthenticatedPrincipal( + "samluser", Map.of(), "nameId", List.of()); + Authentication auth = + new UsernamePasswordAuthenticationToken(principal, null, List.of()); + + ResponseEntity response = controller.getAccountData(auth); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody().isSaml2Login()).isTrue(); + } + } + + @Nested + @DisplayName("getAdminSettingsData") + class AdminSettings { + + @Test + @DisplayName("aggregates users, teams and license limits") + void aggregates() { + User user = normalUser(1L, "bob"); + when(userRepository.findAllWithTeam()) + .thenReturn(new java.util.ArrayList<>(List.of(user))); + when(sessionPersistentRegistry.getMaxInactiveInterval()).thenReturn(3600); + when(sessionPersistentRegistry.findLatestSession("bob")).thenReturn(Optional.empty()); + when(userRepository.findByIdWithSettings(1L)).thenReturn(Optional.of(user)); + when(teamRepository.findAll()).thenReturn(List.of()); + + when(licenseSettingsService.calculateMaxAllowedUsers()).thenReturn(10); + when(licenseSettingsService.getAvailableUserSlots()).thenReturn(5L); + when(licenseSettingsService.getDisplayGrandfatheredCount()).thenReturn(0); + UserLicenseSettings settings = org.mockito.Mockito.mock(UserLicenseSettings.class); + when(settings.getLicenseMaxUsers()).thenReturn(10); + when(licenseSettingsService.getSettings()).thenReturn(settings); + when(loginAttemptService.getAllBlockedUsers()).thenReturn(List.of()); + + Authentication auth = new UsernamePasswordAuthenticationToken("bob", null, List.of()); + + ResponseEntity response = controller.getAdminSettingsData(auth); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + AdminSettingsData data = response.getBody(); + assertThat(data.getCurrentUsername()).isEqualTo("bob"); + assertThat(data.getTotalUsers()).isEqualTo(1); + assertThat(data.getUsers()).hasSize(1); + assertThat(data.getMaxAllowedUsers()).isEqualTo(10); + } + } + + @Nested + @DisplayName("getTeamsData") + class Teams { + + @Test + @DisplayName("returns non-internal teams with counts and last activity") + void returnsTeams() { + TeamWithUserCountDTO team = new TeamWithUserCountDTO(1L, "Engineering", 4L); + when(teamRepository.findAllTeamsWithUserCount()).thenReturn(List.of(team)); + when(sessionRepository.findLatestActivityByTeam()).thenReturn(Collections.emptyList()); + + ResponseEntity response = controller.getTeamsData(); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody().getTeamsWithCounts()).hasSize(1); + } + } + + @Nested + @DisplayName("getTeamDetailsData") + class TeamDetails { + + @Test + @DisplayName("returns details for a normal team") + void normalTeam() { + Team team = new Team(); + team.setId(5L); + team.setName("Engineering"); + when(teamRepository.findById(5L)).thenReturn(Optional.of(team)); + when(userRepository.findAllByTeamId(5L)).thenReturn(List.of()); + when(userRepository.findAllWithTeam()).thenReturn(List.of()); + when(sessionRepository.findLatestSessionByTeamId(5L)) + .thenReturn(Collections.emptyList()); + + ResponseEntity response = controller.getTeamDetailsData(5L); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody().getTeam().getName()).isEqualTo("Engineering"); + } + + @Test + @DisplayName("returns 403 for the internal team") + void internalTeamForbidden() { + Team team = new Team(); + team.setId(6L); + team.setName( + stirling.software.proprietary.security.service.TeamService.INTERNAL_TEAM_NAME); + when(teamRepository.findById(6L)).thenReturn(Optional.of(team)); + + ResponseEntity response = controller.getTeamDetailsData(6L); + + assertThat(response.getStatusCode().value()).isEqualTo(403); + } + + @Test + @DisplayName("throws when the team does not exist") + void teamMissing() { + when(teamRepository.findById(99L)).thenReturn(Optional.empty()); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> controller.getTeamDetailsData(99L)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Team not found"); + } + } + + @Nested + @DisplayName("getDatabaseData") + class Database { + + @Test + @DisplayName("reports a known version as not unknown") + void knownVersion() { + when(databaseService.getBackupList()).thenReturn(List.of()); + when(databaseService.getH2Version()).thenReturn("2.2.224"); + + ResponseEntity response = + controller.getDatabaseData(); + + assertThat(response.getBody().getDatabaseVersion()).isEqualTo("2.2.224"); + assertThat(response.getBody().isVersionUnknown()).isFalse(); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java new file mode 100644 index 0000000000..9325c7de51 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -0,0 +1,422 @@ +package stirling.software.proprietary.policy.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.job.JobResponse; +import stirling.software.common.service.JobOwnershipService; +import stirling.software.common.util.TempFileManager; +import stirling.software.proprietary.policy.config.PolicyAccessGuard; +import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.engine.PolicyRunHandle; +import stirling.software.proprietary.policy.engine.PolicyRunRegistry; +import stirling.software.proprietary.policy.engine.PolicyRunner; +import stirling.software.proprietary.policy.engine.PolicyValidator; +import stirling.software.proprietary.policy.model.PipelineDefinition; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.model.PolicyRun; +import stirling.software.proprietary.policy.model.PolicyRunView; +import stirling.software.proprietary.policy.progress.PolicyProgressListener; + +@ExtendWith(MockitoExtension.class) +@DisplayName("PolicyController") +class PolicyControllerTest { + + @Mock private PolicyRunner policyRunner; + @Mock private PolicyRunRegistry runRegistry; + @Mock private stirling.software.proprietary.policy.store.PolicyStore policyStore; + @Mock private PolicyValidator policyValidator; + @Mock private PolicyAccessGuard policyAccessGuard; + @Mock private PolicyManagementAuthority policyManagementAuthority; + @Mock private TempFileManager tempFileManager; + @Mock private JobOwnershipService jobOwnershipService; + + private ApplicationProperties applicationProperties; + private PolicyController controller; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + controller = + new PolicyController( + policyRunner, + runRegistry, + policyStore, + policyValidator, + policyAccessGuard, + policyManagementAuthority, + applicationProperties, + tempFileManager, + jobOwnershipService); + } + + private static PipelineDefinition definitionWithStep() { + return new PipelineDefinition( + "pipe", List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)), null); + } + + private static Policy policy(String id, Long teamId) { + return new Policy(id, "name", "owner", true, null, List.of(), List.of(), null, teamId); + } + + private static PolicyRunHandle handle(String runId) { + PolicyRun run = new PolicyRun(runId, null, definitionWithStep()); + return new PolicyRunHandle(runId, CompletableFuture.completedFuture(run)); + } + + @Nested + @DisplayName("run (ad-hoc)") + class Run { + + @Test + @DisplayName("accepts a runnable pipeline and returns a run id") + void runsAdHoc() throws Exception { + when(policyRunner.runAdHoc(any(), any(), eq(PolicyProgressListener.NOOP))) + .thenReturn(handle("run-1")); + + ResponseEntity> response = + controller.run(definitionWithStep(), new PolicyRunFiles()); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(response.getBody().getJobId()).isEqualTo("run-1"); + } + + @Test + @DisplayName("rejects a pipeline with no steps") + void rejectsEmptyPipeline() { + PipelineDefinition empty = new PipelineDefinition("pipe", List.of(), null); + + assertThatThrownBy(() -> controller.run(empty, new PolicyRunFiles())) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + } + } + + @Nested + @DisplayName("runStream (SSE)") + class RunStream { + + @Test + @DisplayName("returns an emitter for a runnable pipeline") + void returnsEmitter() throws Exception { + when(policyRunner.runAdHoc(any(), any(), any())).thenReturn(handle("run-2")); + + SseEmitter emitter = controller.runStream(definitionWithStep(), new PolicyRunFiles()); + + assertThat(emitter).isNotNull(); + } + + @Test + @DisplayName("rejects a pipeline with no steps") + void rejectsEmpty() { + PipelineDefinition empty = new PipelineDefinition("pipe", List.of(), null); + + assertThatThrownBy(() -> controller.runStream(empty, new PolicyRunFiles())) + .isInstanceOf(ResponseStatusException.class); + } + } + + @Nested + @DisplayName("status") + class Status { + + @Test + @DisplayName("returns the run view when present") + void found() { + PolicyRun run = new PolicyRun("run-3", null, definitionWithStep()); + when(runRegistry.get("run-3")).thenReturn(run); + + ResponseEntity response = controller.status("run-3"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().runId()).isEqualTo("run-3"); + } + + @Test + @DisplayName("returns 404 when run is unknown") + void notFound() { + when(runRegistry.get("missing")).thenReturn(null); + + ResponseEntity response = controller.status("missing"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @Nested + @DisplayName("listRuns") + class ListRuns { + + @Test + @DisplayName("excludes ad-hoc runs and runs owned by others") + void filtersRuns() { + PolicyRun adHoc = new PolicyRun("adhoc", null, definitionWithStep()); + PolicyRun ownedStored = new PolicyRun("owned", "policy-A", definitionWithStep()); + PolicyRun otherStored = new PolicyRun("other", "policy-B", definitionWithStep()); + when(runRegistry.all()).thenReturn(List.of(adHoc, ownedStored, otherStored)); + + // ownedByCurrentUser: strip then re-apply scope reproduces the key only for the owned + // run + when(jobOwnershipService.extractJobId(any())).thenAnswer(i -> i.getArgument(0)); + when(jobOwnershipService.createScopedJobKey("owned")).thenReturn("owned"); + when(jobOwnershipService.createScopedJobKey("other")).thenReturn("scoped-other"); + + List views = controller.listRuns(); + + assertThat(views).hasSize(1); + assertThat(views.get(0).runId()).isEqualTo("owned"); + } + } + + @Nested + @DisplayName("savePolicy") + class SavePolicy { + + @Test + @DisplayName("saves a new policy when editing is allowed") + void savesNew() { + applicationProperties.getSecurity().setEnableLogin(true); + when(policyManagementAuthority.canEditPolicies()).thenReturn(true); + when(policyAccessGuard.ownerForNewPolicy()).thenReturn("alice"); + when(policyAccessGuard.teamForNewPolicy()).thenReturn(7L); + Policy incoming = policy(null, null); + when(policyStore.save(any())).thenAnswer(i -> i.getArgument(0)); + + ResponseEntity response = controller.savePolicy(incoming); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().owner()).isEqualTo("alice"); + assertThat(response.getBody().teamId()).isEqualTo(7L); + verify(policyValidator).validate(any()); + } + + @Test + @DisplayName("forbidden when login enabled and caller cannot edit") + void forbidden() { + applicationProperties.getSecurity().setEnableLogin(true); + when(policyManagementAuthority.canEditPolicies()).thenReturn(false); + + assertThatThrownBy(() -> controller.savePolicy(policy(null, null))) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN)); + verify(policyStore, never()).save(any()); + } + + @Test + @DisplayName("bad request when validation fails") + void validationFails() { + applicationProperties.getSecurity().setEnableLogin(false); + when(policyAccessGuard.ownerForNewPolicy()).thenReturn(null); + when(policyAccessGuard.teamForNewPolicy()).thenReturn(null); + org.mockito.Mockito.doThrow(new IllegalArgumentException("bad output")) + .when(policyValidator) + .validate(any()); + + assertThatThrownBy(() -> controller.savePolicy(policy(null, null))) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + } + + @Test + @DisplayName("not found when updating a policy in another team") + void crossTeamNotFound() { + applicationProperties.getSecurity().setEnableLogin(false); + Policy existing = policy("p1", 99L); + when(policyStore.get("p1")).thenReturn(Optional.of(existing)); + when(policyAccessGuard.canAccess(existing)).thenReturn(false); + + assertThatThrownBy(() -> controller.savePolicy(policy("p1", null))) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND)); + } + + @Test + @DisplayName("update preserves the existing owner and team") + void updatePreservesOwnership() { + applicationProperties.getSecurity().setEnableLogin(false); + Policy existing = + new Policy( + "p2", "name", "origOwner", true, null, List.of(), List.of(), null, 3L); + when(policyStore.get("p2")).thenReturn(Optional.of(existing)); + when(policyAccessGuard.canAccess(existing)).thenReturn(true); + when(policyStore.save(any())).thenAnswer(i -> i.getArgument(0)); + + ResponseEntity response = + controller.savePolicy( + new Policy( + "p2", "name", "forged", true, null, List.of(), List.of(), null, + 77L)); + + assertThat(response.getBody().owner()).isEqualTo("origOwner"); + assertThat(response.getBody().teamId()).isEqualTo(3L); + } + } + + @Nested + @DisplayName("listPolicies / getPolicy") + class ListAndGet { + + @Test + @DisplayName("listPolicies returns team-visible policies") + void listVisible() { + List all = List.of(policy("a", 1L), policy("b", 1L)); + when(policyStore.all()).thenReturn(all); + when(policyAccessGuard.visible(all)).thenReturn(all); + + List result = controller.listPolicies(); + + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("getPolicy returns the policy when accessible") + void getAccessible() { + Policy p = policy("a", 1L); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(true); + + ResponseEntity response = controller.getPolicy("a"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().id()).isEqualTo("a"); + } + + @Test + @DisplayName("getPolicy returns 404 when not accessible") + void getNotAccessible() { + Policy p = policy("a", 1L); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(false); + + ResponseEntity response = controller.getPolicy("a"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("getPolicy returns 404 when missing") + void getMissing() { + when(policyStore.get("z")).thenReturn(Optional.empty()); + + ResponseEntity response = controller.getPolicy("z"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @Nested + @DisplayName("deletePolicy") + class DeletePolicy { + + @Test + @DisplayName("deletes an accessible policy") + void deletes() { + applicationProperties.getSecurity().setEnableLogin(false); + Policy p = policy("a", 1L); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(true); + when(policyStore.delete("a")).thenReturn(true); + + ResponseEntity response = controller.deletePolicy("a"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } + + @Test + @DisplayName("returns 404 when policy is not accessible") + void notAccessible() { + applicationProperties.getSecurity().setEnableLogin(false); + Policy p = policy("a", 1L); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(false); + + ResponseEntity response = controller.deletePolicy("a"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + verify(policyStore, never()).delete(any()); + } + + @Test + @DisplayName("forbidden when login enabled and caller cannot edit") + void forbidden() { + applicationProperties.getSecurity().setEnableLogin(true); + when(policyManagementAuthority.canEditPolicies()).thenReturn(false); + + assertThatThrownBy(() -> controller.deletePolicy("a")) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN)); + } + } + + @Nested + @DisplayName("runStoredPolicy") + class RunStoredPolicy { + + @Test + @DisplayName("runs a stored, accessible policy") + void runsStored() throws Exception { + Policy p = policy("a", 1L); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(true); + when(policyRunner.runWith(eq(p), any(), eq(PolicyProgressListener.NOOP))) + .thenReturn(handle("run-9")); + + ResponseEntity> response = + controller.runStoredPolicy("a", new PolicyRunFiles()); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + assertThat(response.getBody().getJobId()).isEqualTo("run-9"); + } + + @Test + @DisplayName("not found when the stored policy is inaccessible") + void notFound() { + when(policyStore.get("a")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> controller.runStoredPolicy("a", new PolicyRunFiles())) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND)); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java index d682c35cd7..9b10d9090e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyExecutorTest.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.engine; 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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -149,6 +150,40 @@ class PolicyExecutorTest { verify(internalApiClient, times(2)).post(eq(ROTATE), any()); } + @Test + void noInputGeneratorEndpointIsCalledOnceWithNoFile() throws IOException { + // A "create" workflow has no source documents: a generator tool (e.g. + // create-pdf-from-html-agent) produces its output purely from parameters. Per-file + // dispatch would skip it entirely (zero files = zero calls), so it must still run once. + String createPdf = "/api/v1/ai/tools/create-pdf-from-html-agent"; + when(toolMetadataService.isMultiInput(createPdf)).thenReturn(false); + when(toolMetadataService.shouldUnpackZipResponse(createPdf)).thenReturn(false); + stubEndpoint(createPdf, pdf("generated", "purchase-order.pdf")); + + PolicyExecutionResult result = + executor.execute( + definition( + new PipelineStep( + createPdf, + Map.of( + "htmlContent", + "

hi

", + "filename", + "purchase-order.pdf"))), + PolicyInputs.of(List.of()), + PolicyProgressListener.NOOP); + + assertEquals(1, result.files().size()); + assertEquals("purchase-order.pdf", result.files().get(0).getFilename()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> bodyCaptor = + ArgumentCaptor.forClass(MultiValueMap.class); + verify(internalApiClient, times(1)).post(eq(createPdf), bodyCaptor.capture()); + // No document stream: the body carries only the generator's parameters, no fileInput. + assertNull(bodyCaptor.getValue().get("fileInput")); + } + @Test void zipResponseIsUnpackedIntoIndividualFiles() throws IOException { when(toolMetadataService.isMultiInput(SPLIT)).thenReturn(false); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/SecurityConfigurationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/SecurityConfigurationTest.java new file mode 100644 index 0000000000..50d522c056 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/SecurityConfigurationTest.java @@ -0,0 +1,193 @@ +package stirling.software.proprietary.security.configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +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.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.firewall.HttpFirewall; +import org.springframework.security.web.firewall.StrictHttpFirewall; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import stirling.software.common.configuration.AppConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.JwtAuthenticationEntryPoint; +import stirling.software.proprietary.security.database.repository.PersistentLoginRepository; +import stirling.software.proprietary.security.filter.IPRateLimitingFilter; +import stirling.software.proprietary.security.filter.JwtAuthenticationFilter; +import stirling.software.proprietary.security.filter.UserAuthenticationFilter; +import stirling.software.proprietary.security.service.CustomUserDetailsService; +import stirling.software.proprietary.security.service.JwtServiceInterface; +import stirling.software.proprietary.security.service.LoginAttemptService; +import stirling.software.proprietary.security.service.UserService; +import stirling.software.proprietary.security.session.SessionPersistentRegistry; +import stirling.software.proprietary.service.AiUserDataService; +import stirling.software.proprietary.service.UserLicenseSettingsService; + +/** + * Unit tests for {@link SecurityConfiguration}'s standalone {@code @Bean} factory methods and the + * login-disabled filter-chain path. All collaborators are mocked; {@link HttpSecurity} uses deep + * stubs and {@code http.build()} is stubbed to a concrete {@link DefaultSecurityFilterChain}. + */ +@DisplayName("SecurityConfiguration") +class SecurityConfigurationTest { + + private CustomUserDetailsService userDetailsService; + private UserService userService; + private AppConfig appConfig; + private UserAuthenticationFilter userAuthenticationFilter; + private JwtServiceInterface jwtService; + private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private LoginAttemptService loginAttemptService; + private SessionPersistentRegistry sessionRegistry; + private PersistentLoginRepository persistentLoginRepository; + private UserLicenseSettingsService licenseSettingsService; + private AiUserDataService aiUserDataService; + private PasswordEncoder passwordEncoder; + + private ApplicationProperties applicationProperties; + private ApplicationProperties.Security securityProperties; + + private SecurityConfiguration newConfig(boolean loginEnabled) { + return new SecurityConfiguration( + persistentLoginRepository, + userDetailsService, + userService, + loginEnabled, + true, + appConfig, + applicationProperties, + securityProperties, + userAuthenticationFilter, + jwtService, + jwtAuthenticationEntryPoint, + loginAttemptService, + sessionRegistry, + null, + null, + null, + null, + licenseSettingsService, + passwordEncoder, + aiUserDataService); + } + + @BeforeEach + void setUp() { + userDetailsService = mock(CustomUserDetailsService.class); + userService = mock(UserService.class); + appConfig = mock(AppConfig.class); + userAuthenticationFilter = mock(UserAuthenticationFilter.class); + jwtService = mock(JwtServiceInterface.class); + jwtAuthenticationEntryPoint = mock(JwtAuthenticationEntryPoint.class); + loginAttemptService = mock(LoginAttemptService.class); + sessionRegistry = mock(SessionPersistentRegistry.class); + persistentLoginRepository = mock(PersistentLoginRepository.class); + licenseSettingsService = mock(UserLicenseSettingsService.class); + aiUserDataService = mock(AiUserDataService.class); + passwordEncoder = mock(PasswordEncoder.class); + applicationProperties = new ApplicationProperties(); + securityProperties = new ApplicationProperties.Security(); + } + + @Nested + @DisplayName("standalone beans") + class StandaloneBeans { + + @Test + @DisplayName("httpFirewall allows non-ASCII header values but rejects control chars") + void httpFirewall() { + HttpFirewall firewall = newConfig(true).httpFirewall(); + assertThat(firewall).isInstanceOf(StrictHttpFirewall.class); + } + + @Test + @DisplayName("corsConfigurationSource defaults to wildcard when nothing configured") + void corsDefaultsToWildcard() { + CorsConfigurationSource source = newConfig(true).corsConfigurationSource(); + assertThat(source).isInstanceOf(UrlBasedCorsConfigurationSource.class); + + CorsConfiguration cfg = configFor(source); + assertThat(cfg.getAllowedOriginPatterns()).containsExactly("*"); + assertThat(cfg.getAllowCredentials()).isTrue(); + assertThat(cfg.getAllowedMethods()).contains("OPTIONS"); + } + + @Test + @DisplayName("corsConfigurationSource uses configured origin patterns when present") + void corsUsesConfiguredOrigins() { + applicationProperties + .getSystem() + .setCorsAllowedOrigins(List.of("https://app.example.com")); + + CorsConfiguration cfg = configFor(newConfig(true).corsConfigurationSource()); + assertThat(cfg.getAllowedOriginPatterns()).containsExactly("https://app.example.com"); + } + + @Test + @DisplayName("daoAuthenticationProvider is built with the configured password encoder") + void daoAuthenticationProvider() { + DaoAuthenticationProvider provider = newConfig(true).daoAuthenticationProvider(); + assertThat(provider).isNotNull(); + } + + @Test + @DisplayName("rateLimitingFilter is created") + void rateLimitingFilter() { + IPRateLimitingFilter filter = newConfig(true).rateLimitingFilter(); + assertThat(filter).isNotNull(); + } + + @Test + @DisplayName("persistentTokenRepository is created") + void persistentTokenRepository() { + assertThat(newConfig(true).persistentTokenRepository()).isNotNull(); + } + + @Test + @DisplayName("jwtAuthenticationFilter is created") + void jwtAuthenticationFilter() { + JwtAuthenticationFilter filter = newConfig(true).jwtAuthenticationFilter(); + assertThat(filter).isNotNull(); + } + + private CorsConfiguration configFor(CorsConfigurationSource source) { + return ((UrlBasedCorsConfigurationSource) source).getCorsConfigurations().get("/**"); + } + } + + @Nested + @DisplayName("filter chain (login disabled)") + class LoginDisabledChain { + + @Test + @DisplayName("builds a permit-all chain when login is disabled") + void buildsPermitAllChain() throws Exception { + HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); + // http.build() returns DefaultSecurityFilterChain, so stub with that concrete type. + DefaultSecurityFilterChain built = mock(DefaultSecurityFilterChain.class); + when(http.build()).thenReturn(built); + + IPRateLimitingFilter rateLimitingFilter = mock(IPRateLimitingFilter.class); + JwtAuthenticationFilter jwtFilter = mock(JwtAuthenticationFilter.class); + + SecurityFilterChain chain = + newConfig(false).filterChain(http, rateLimitingFilter, jwtFilter); + + assertThat(chain).isSameAs(built); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifierTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifierTest.java new file mode 100644 index 0000000000..45dd017d69 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/KeygenLicenseVerifierTest.java @@ -0,0 +1,484 @@ +package stirling.software.proprietary.security.configuration.ee; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.time.Instant; +import java.util.Base64; + +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 stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Unit tests for {@link KeygenLicenseVerifier}. Standard (online) license paths are NOT exercised + * because they make real HTTP calls to api.keygen.sh through a static HttpClient; only the offline + * certificate/JWT crypto + parsing branches and the private processing helpers are covered. + */ +class KeygenLicenseVerifierTest { + + private static final String CERT_PREFIX = "-----BEGIN LICENSE FILE-----"; + private static final String CERT_SUFFIX = "-----END LICENSE FILE-----"; + private static final String ACCOUNT_ID = "e5430f69-e834-4ae4-befd-b602aae5f372"; + + private ObjectMapper objectMapper; + private ApplicationProperties applicationProperties; + private KeygenLicenseVerifier verifier; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + applicationProperties = new ApplicationProperties(); + verifier = new KeygenLicenseVerifier(objectMapper, applicationProperties); + } + + // Builds a license file string: base64 of {enc, sig, alg}. + private String buildCertificate(String encB64, String sigB64, String alg) { + ObjectNode root = objectMapper.createObjectNode(); + root.put("enc", encB64); + root.put("sig", sigB64); + root.put("alg", alg); + String inner = Base64.getEncoder().encodeToString(root.toString().getBytes()); + return CERT_PREFIX + "\n" + inner + "\n" + CERT_SUFFIX; + } + + // Reflectively builds a private LicenseContext instance. + private Object newContext() throws Exception { + Class ctxClass = + Class.forName( + "stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier$LicenseContext"); + Constructor ctor = ctxClass.getDeclaredConstructor(); + ctor.setAccessible(true); + return ctor.newInstance(); + } + + private boolean readContextBoolean(Object ctx, String field) throws Exception { + Field f = ctx.getClass().getDeclaredField(field); + f.setAccessible(true); + return f.getBoolean(ctx); + } + + private int readContextInt(Object ctx, String field) throws Exception { + Field f = ctx.getClass().getDeclaredField(field); + f.setAccessible(true); + return f.getInt(ctx); + } + + private Object invokePrivate(String name, Class[] types, Object... args) throws Exception { + Method m = KeygenLicenseVerifier.class.getDeclaredMethod(name, types); + m.setAccessible(true); + return m.invoke(verifier, args); + } + + @Nested + @DisplayName("verifyLicense - top-level dispatch") + class VerifyLicenseDispatch { + + @Test + @DisplayName("returns NORMAL when premium is disabled without touching the network") + void premiumDisabled_returnsNormal() { + applicationProperties.getPremium().setEnabled(false); + + License result = verifier.verifyLicense("anything-at-all"); + + assertThat(result).isEqualTo(License.NORMAL); + } + + @Test + @DisplayName("certificate license with invalid signature resolves to NORMAL") + void certificateInvalidSignature_returnsNormal() { + applicationProperties.getPremium().setEnabled(true); + String payload = Base64.getEncoder().encodeToString("{}".getBytes()); + String badSig = Base64.getEncoder().encodeToString("not-a-real-signature".getBytes()); + String cert = buildCertificate(payload, badSig, "base64+ed25519"); + + License result = verifier.verifyLicense(cert); + + assertThat(result).isEqualTo(License.NORMAL); + } + + @Test + @DisplayName("certificate license with unsupported algorithm resolves to NORMAL") + void certificateUnsupportedAlgorithm_returnsNormal() { + applicationProperties.getPremium().setEnabled(true); + String cert = buildCertificate("ZW5j", "c2ln", "rsa-sha256"); + + License result = verifier.verifyLicense(cert); + + assertThat(result).isEqualTo(License.NORMAL); + } + + @Test + @DisplayName("certificate license with non-JSON payload resolves to NORMAL") + void certificateMalformedPayload_returnsNormal() { + applicationProperties.getPremium().setEnabled(true); + String notJson = Base64.getEncoder().encodeToString("this is not json".getBytes()); + String cert = CERT_PREFIX + "\n" + notJson + "\n" + CERT_SUFFIX; + + License result = verifier.verifyLicense(cert); + + assertThat(result).isEqualTo(License.NORMAL); + } + + @Test + @DisplayName("JWT-style license with invalid signature resolves to NORMAL") + void jwtInvalidSignature_returnsNormal() { + applicationProperties.getPremium().setEnabled(true); + String body = Base64.getUrlEncoder().withoutPadding().encodeToString("{}".getBytes()); + String sig = Base64.getUrlEncoder().withoutPadding().encodeToString("bad".getBytes()); + String jwt = "key/" + body + "." + sig; + + License result = verifier.verifyLicense(jwt); + + assertThat(result).isEqualTo(License.NORMAL); + } + + @Test + @DisplayName("JWT-style license with missing signature separator resolves to NORMAL") + void jwtMissingSeparator_returnsNormal() { + applicationProperties.getPremium().setEnabled(true); + String jwt = "key/onlypayloadnodot"; + + License result = verifier.verifyLicense(jwt); + + assertThat(result).isEqualTo(License.NORMAL); + } + } + + @Nested + @DisplayName("verifyEd25519Signature") + class Ed25519Signature { + + @Test + @DisplayName("returns false for a forged signature") + void forgedSignature_returnsFalse() throws Exception { + String sig = Base64.getEncoder().encodeToString(new byte[64]); + Object result = + invokePrivate( + "verifyEd25519Signature", + new Class[] {String.class, String.class}, + "some-encrypted-data", + sig); + assertThat((Boolean) result).isFalse(); + } + + @Test + @DisplayName("returns false when signature is not valid base64") + void invalidBase64Signature_returnsFalse() throws Exception { + Object result = + invokePrivate( + "verifyEd25519Signature", + new Class[] {String.class, String.class}, + "data", + "@@@not-base64@@@"); + assertThat((Boolean) result).isFalse(); + } + } + + @Nested + @DisplayName("verifyJWTSignature") + class JwtSignature { + + @Test + @DisplayName("returns false for a forged signature") + void forgedSignature_returnsFalse() throws Exception { + String sig = Base64.getUrlEncoder().withoutPadding().encodeToString(new byte[64]); + Object result = + invokePrivate( + "verifyJWTSignature", + new Class[] {String.class, String.class}, + "payload", + sig); + assertThat((Boolean) result).isFalse(); + } + } + + @Nested + @DisplayName("processCertificateData") + class ProcessCertificateData { + + private boolean process(String json) throws Exception { + Object ctx = newContext(); + Class ctxClass = ctx.getClass(); + Object result = + invokePrivate( + "processCertificateData", + new Class[] {String.class, ctxClass}, + json, + ctx); + return (Boolean) result; + } + + @Test + @DisplayName("valid SERVER license (no enterprise flag) returns true and sets maxUsers 0") + void serverLicense_returnsTrue() throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode data = root.putObject("data"); + ObjectNode attrs = data.putObject("attributes"); + attrs.put("floating", false); + attrs.put("maxMachines", 1); + ObjectNode metadata = attrs.putObject("metadata"); + metadata.put("isEnterprise", false); + metadata.put("users", 0); + attrs.put("status", "ACTIVE"); + + boolean valid = process(root.toString()); + + assertThat(valid).isTrue(); + assertThat(applicationProperties.getPremium().getMaxUsers()).isZero(); + } + + @Test + @DisplayName("valid ENTERPRISE license sets maxUsers from metadata") + void enterpriseLicense_setsMaxUsers() throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode data = root.putObject("data"); + ObjectNode attrs = data.putObject("attributes"); + ObjectNode metadata = attrs.putObject("metadata"); + metadata.put("isEnterprise", true); + metadata.put("users", 25); + attrs.put("status", "EXPIRING"); + + boolean valid = process(root.toString()); + + assertThat(valid).isTrue(); + assertThat(applicationProperties.getPremium().getMaxUsers()).isEqualTo(25); + } + + @Test + @DisplayName("expired license (expiry in past) returns false") + void expiredLicense_returnsFalse() throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode meta = root.putObject("meta"); + meta.put("issued", Instant.now().minusSeconds(100000).toString()); + meta.put("expiry", Instant.now().minusSeconds(1000).toString()); + root.putObject("data").putObject("attributes"); + + boolean valid = process(root.toString()); + + assertThat(valid).isFalse(); + } + + @Test + @DisplayName("issued date in the future returns false") + void futureIssued_returnsFalse() throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode meta = root.putObject("meta"); + meta.put("issued", Instant.now().plusSeconds(100000).toString()); + meta.put("expiry", Instant.now().plusSeconds(200000).toString()); + root.putObject("data").putObject("attributes"); + + boolean valid = process(root.toString()); + + assertThat(valid).isFalse(); + } + + @Test + @DisplayName("non-active status returns false") + void inactiveStatus_returnsFalse() throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode data = root.putObject("data"); + ObjectNode attrs = data.putObject("attributes"); + attrs.put("status", "SUSPENDED"); + + boolean valid = process(root.toString()); + + assertThat(valid).isFalse(); + } + + @Test + @DisplayName("missing data object returns false") + void missingData_returnsFalse() throws Exception { + boolean valid = process("{}"); + assertThat(valid).isFalse(); + } + + @Test + @DisplayName("valid dates with active status returns true") + void validDates_returnsTrue() throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode meta = root.putObject("meta"); + meta.put("issued", Instant.now().minusSeconds(1000).toString()); + meta.put("expiry", Instant.now().plusSeconds(1000).toString()); + ObjectNode data = root.putObject("data"); + data.putObject("attributes").put("status", "ACTIVE"); + + boolean valid = process(root.toString()); + + assertThat(valid).isTrue(); + } + + @Test + @DisplayName("floating license attribute populates context") + void floatingLicense_populatesContext() throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode attrs = root.putObject("data").putObject("attributes"); + attrs.put("floating", true); + attrs.put("maxMachines", 7); + + Object ctx = newContext(); + Object result = + invokePrivate( + "processCertificateData", + new Class[] {String.class, ctx.getClass()}, + root.toString(), + ctx); + + assertThat((Boolean) result).isTrue(); + assertThat(readContextBoolean(ctx, "isFloatingLicense")).isTrue(); + assertThat(readContextInt(ctx, "maxMachines")).isEqualTo(7); + } + } + + @Nested + @DisplayName("processJWTLicensePayload") + class ProcessJwtPayload { + + private Object processWithContext(String json, Object ctx) throws Exception { + return invokePrivate( + "processJWTLicensePayload", + new Class[] {String.class, ctx.getClass()}, + json, + ctx); + } + + @Test + @DisplayName("payload with nested license object and no expiry returns true") + void nestedLicenseNoExpiry_returnsTrue() throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode license = root.putObject("license"); + license.put("id", "lic-123"); + license.put("floating", false); + + Object ctx = newContext(); + Object result = processWithContext(root.toString(), ctx); + + assertThat((Boolean) result).isTrue(); + } + + @Test + @DisplayName("payload using root object as license (id at root) returns true") + void rootAsLicense_returnsTrue() throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + root.put("id", "root-lic"); + + Object ctx = newContext(); + Object result = processWithContext(root.toString(), ctx); + + assertThat((Boolean) result).isTrue(); + } + + @Test + @DisplayName("payload missing license object and id returns false") + void missingLicenseAndId_returnsFalse() throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + root.putObject("other").put("foo", "bar"); + + Object ctx = newContext(); + Object result = processWithContext(root.toString(), ctx); + + assertThat((Boolean) result).isFalse(); + } + + @Test + @DisplayName("expired JWT license returns false") + void expiredJwt_returnsFalse() throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode license = root.putObject("license"); + license.put("id", "lic-exp"); + license.put("expiry", Instant.now().minusSeconds(1000).toString()); + + Object ctx = newContext(); + Object result = processWithContext(root.toString(), ctx); + + assertThat((Boolean) result).isFalse(); + } + + @Test + @DisplayName("floating license in license object populates context") + void floatingInLicense_populatesContext() throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + ObjectNode license = root.putObject("license"); + license.put("id", "lic-float"); + license.put("floating", true); + license.put("maxMachines", 3); + + Object ctx = newContext(); + Object result = processWithContext(root.toString(), ctx); + + assertThat((Boolean) result).isTrue(); + assertThat(readContextBoolean(ctx, "isFloatingLicense")).isTrue(); + assertThat(readContextInt(ctx, "maxMachines")).isEqualTo(3); + } + + @Test + @DisplayName("account id mismatch still returns true but warns") + void accountMismatch_returnsTrue() throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + root.putObject("license").put("id", "lic-acc"); + root.putObject("account").put("id", "some-other-account"); + + Object ctx = newContext(); + Object result = processWithContext(root.toString(), ctx); + + assertThat((Boolean) result).isTrue(); + } + + @Test + @DisplayName("policy floating + enterprise users set context and maxUsers") + void policyEnterprise_setsContextAndMaxUsers() throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + root.putObject("license").put("id", "lic-policy"); + root.putObject("account").put("id", ACCOUNT_ID); + ObjectNode policy = root.putObject("policy"); + policy.put("id", "pol-1"); + policy.put("floating", true); + policy.put("maxMachines", 9); + policy.put("isEnterprise", true); + policy.put("users", 50); + + Object ctx = newContext(); + Object result = processWithContext(root.toString(), ctx); + + assertThat((Boolean) result).isTrue(); + assertThat(readContextBoolean(ctx, "isFloatingLicense")).isTrue(); + assertThat(readContextInt(ctx, "maxMachines")).isEqualTo(9); + assertThat(applicationProperties.getPremium().getMaxUsers()).isEqualTo(50); + } + + @Test + @DisplayName("policy users from metadata fallback when not at policy level") + void policyUsersFromMetadata_setsMaxUsers() throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + root.putObject("license").put("id", "lic-meta"); + ObjectNode policy = root.putObject("policy"); + policy.put("id", "pol-meta"); + ObjectNode metadata = policy.putObject("metadata"); + metadata.put("isEnterprise", true); + metadata.put("users", 12); + + Object ctx = newContext(); + Object result = processWithContext(root.toString(), ctx); + + assertThat((Boolean) result).isTrue(); + assertThat(applicationProperties.getPremium().getMaxUsers()).isEqualTo(12); + } + + @Test + @DisplayName("malformed JSON payload returns false") + void malformedJson_returnsFalse() throws Exception { + Object ctx = newContext(); + Object result = processWithContext("not-json-at-all", ctx); + assertThat((Boolean) result).isFalse(); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminLicenseControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminLicenseControllerTest.java new file mode 100644 index 0000000000..5dca00ff6d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminLicenseControllerTest.java @@ -0,0 +1,404 @@ +package stirling.software.proprietary.security.controller.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +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.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.GeneralUtils; +import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; +import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class AdminLicenseControllerTest { + + @Mock private LicenseKeyChecker licenseKeyChecker; + + @Mock + private stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier + keygenLicenseVerifier; + + private ApplicationProperties applicationProperties; + + private AdminLicenseController controller; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + controller = new AdminLicenseController(); + org.springframework.test.util.ReflectionTestUtils.setField( + controller, "licenseKeyChecker", licenseKeyChecker); + org.springframework.test.util.ReflectionTestUtils.setField( + controller, "keygenLicenseVerifier", keygenLicenseVerifier); + org.springframework.test.util.ReflectionTestUtils.setField( + controller, "applicationProperties", applicationProperties); + } + + @SuppressWarnings("unchecked") + private Map body(ResponseEntity> response) { + return response.getBody(); + } + + @Nested + @DisplayName("getInstallationId") + class GetInstallationId { + + @Test + @DisplayName("returns 200 with the machine fingerprint") + void returnsFingerprint() { + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + mocked.when(GeneralUtils::generateMachineFingerprint).thenReturn("fingerprint-xyz"); + + ResponseEntity> response = controller.getInstallationId(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("installationId", "fingerprint-xyz"); + } + } + + @Test + @DisplayName("returns 500 when fingerprint generation throws") + void returnsErrorOnException() { + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + mocked.when(GeneralUtils::generateMachineFingerprint) + .thenThrow(new RuntimeException("boom")); + + ResponseEntity> response = controller.getInstallationId(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody()).containsKey("error"); + } + } + } + + @Nested + @DisplayName("saveLicenseKey") + class SaveLicenseKey { + + @Test + @DisplayName("null license key returns 400") + void nullKey_returnsBadRequest() { + ResponseEntity> response = controller.saveLicenseKey(Map.of()); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(body(response)).containsEntry("success", false); + } + + @Test + @DisplayName("valid license key activates and returns licenseType") + void validKey_activates() throws IOException { + applicationProperties.getPremium().setMaxUsers(10); + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE); + + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + ResponseEntity> response = + controller.saveLicenseKey(Map.of("licenseKey", " some-key ")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(body(response)).containsEntry("success", true); + assertThat(body(response)).containsEntry("licenseType", "ENTERPRISE"); + assertThat(applicationProperties.getPremium().isEnabled()).isTrue(); + } + } + + @Test + @DisplayName("NORMAL license disables premium features") + void normalKey_disablesPremium() throws IOException { + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL); + + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + ResponseEntity> response = + controller.saveLicenseKey(Map.of("licenseKey", "free")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(body(response)).containsEntry("licenseType", "NORMAL"); + mocked.verify(() -> GeneralUtils.saveKeyToSettings("premium.enabled", false)); + } + } + + @Test + @DisplayName("returns 500 when license checker is unavailable") + void checkerUnavailable_returnsError() { + org.springframework.test.util.ReflectionTestUtils.setField( + controller, "licenseKeyChecker", null); + + ResponseEntity> response = + controller.saveLicenseKey(Map.of("licenseKey", "x")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(body(response)).containsEntry("success", false); + } + + @Test + @DisplayName("returns 400 when activation throws") + void activationThrows_returnsBadRequest() throws IOException { + doThrow(new IOException("disk full")) + .when(licenseKeyChecker) + .updateLicenseKey(anyString()); + + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + ResponseEntity> response = + controller.saveLicenseKey(Map.of("licenseKey", "x")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(body(response)).containsEntry("success", false); + } + } + } + + @Nested + @DisplayName("resyncLicense") + class ResyncLicense { + + @Test + @DisplayName("returns 500 when checker unavailable") + void checkerUnavailable_returnsError() { + org.springframework.test.util.ReflectionTestUtils.setField( + controller, "licenseKeyChecker", null); + + ResponseEntity> response = controller.resyncLicense(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(body(response)).containsEntry("success", false); + } + + @Test + @DisplayName("returns 400 when no license key is configured") + void noKey_returnsBadRequest() { + applicationProperties.getPremium().setKey(" "); + + ResponseEntity> response = controller.resyncLicense(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(body(response)).containsEntry("success", false); + } + + @Test + @DisplayName("resyncs successfully and returns updated info") + void resync_success() { + applicationProperties.getPremium().setKey("real-key"); + applicationProperties.getPremium().setMaxUsers(3); + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER); + + ResponseEntity> response = controller.resyncLicense(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(body(response)).containsEntry("success", true); + assertThat(body(response)).containsEntry("licenseType", "SERVER"); + assertThat(body(response)).containsEntry("maxUsers", 3); + } + + @Test + @DisplayName("returns 500 when resync throws") + void resyncThrows_returnsError() { + applicationProperties.getPremium().setKey("real-key"); + doThrow(new RuntimeException("api down")).when(licenseKeyChecker).resyncLicense(); + + ResponseEntity> response = controller.resyncLicense(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(body(response)).containsEntry("success", false); + } + } + + @Nested + @DisplayName("getLicenseInfo") + class GetLicenseInfo { + + @Test + @DisplayName("returns license type and key when key present") + void withKey_returnsInfo() { + applicationProperties.getPremium().setEnabled(true); + applicationProperties.getPremium().setKey("my-key"); + applicationProperties.getPremium().setMaxUsers(7); + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE); + + ResponseEntity> response = controller.getLicenseInfo(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(body(response)).containsEntry("licenseType", "ENTERPRISE"); + assertThat(body(response)).containsEntry("hasKey", true); + assertThat(body(response)).containsEntry("licenseKey", "my-key"); + } + + @Test + @DisplayName("returns NORMAL with hasKey false when no checker and no key") + void noCheckerNoKey_returnsNormal() { + org.springframework.test.util.ReflectionTestUtils.setField( + controller, "licenseKeyChecker", null); + + ResponseEntity> response = controller.getLicenseInfo(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(body(response)).containsEntry("licenseType", "NORMAL"); + assertThat(body(response)).containsEntry("hasKey", false); + assertThat(body(response)).doesNotContainKey("licenseKey"); + } + } + + @Nested + @DisplayName("uploadLicenseFile") + class UploadLicenseFile { + + @Test + @DisplayName("empty file returns 400") + void emptyFile_returnsBadRequest() { + MultipartFile file = new MockMultipartFile("file", "license.lic", null, new byte[0]); + + ResponseEntity> response = controller.uploadLicenseFile(file); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(body(response)).containsEntry("success", false); + } + + @Test + @DisplayName("filename with path separators is rejected") + void pathTraversalFilename_rejected() { + MultipartFile file = + new MockMultipartFile( + "file", "../evil.lic", null, "data".getBytes(StandardCharsets.UTF_8)); + + ResponseEntity> response = controller.uploadLicenseFile(file); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(body(response)).containsEntry("success", false); + } + + @Test + @DisplayName("invalid extension is rejected") + void invalidExtension_rejected() { + MultipartFile file = + new MockMultipartFile( + "file", "license.txt", null, "data".getBytes(StandardCharsets.UTF_8)); + + ResponseEntity> response = controller.uploadLicenseFile(file); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(body(response)).containsEntry("success", false); + } + + @Test + @DisplayName("file over 1MB is rejected") + void tooLarge_rejected() { + byte[] large = new byte[1_048_577]; + MultipartFile file = new MockMultipartFile("file", "license.cert", null, large); + + ResponseEntity> response = controller.uploadLicenseFile(file); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(body(response)).containsEntry("success", false); + } + + @Test + @DisplayName("content without certificate header is rejected") + void invalidHeader_rejected() { + MultipartFile file = + new MockMultipartFile( + "file", + "license.lic", + null, + "not a certificate".getBytes(StandardCharsets.UTF_8)); + + ResponseEntity> response = controller.uploadLicenseFile(file); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(body(response)).containsEntry("success", false); + } + + @Test + @DisplayName("valid certificate file is saved and activated") + void validFile_savedAndActivated(@TempDir Path tempDir) throws IOException { + String content = "-----BEGIN LICENSE FILE-----\nABC123\n-----END LICENSE FILE-----"; + MultipartFile file = + new MockMultipartFile( + "file", "license.lic", null, content.getBytes(StandardCharsets.UTF_8)); + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER); + + try (MockedStatic mocked = + mockStatic(InstallationPathConfig.class)) { + mocked.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + + ResponseEntity> response = controller.uploadLicenseFile(file); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(body(response)).containsEntry("success", true); + assertThat(body(response)).containsEntry("licenseType", "SERVER"); + assertThat(body(response)).containsEntry("filename", "license.lic"); + assertThat(Files.exists(tempDir.resolve("license.lic"))).isTrue(); + } + } + + @Test + @DisplayName("existing license file is backed up before overwrite") + void existingFile_backedUp(@TempDir Path tempDir) throws IOException { + Path existing = tempDir.resolve("license.cert"); + Files.writeString(existing, "old-content"); + + String content = "-----BEGIN LICENSE FILE-----\nNEW\n-----END LICENSE FILE-----"; + MultipartFile file = + new MockMultipartFile( + "file", "license.cert", null, content.getBytes(StandardCharsets.UTF_8)); + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER); + + try (MockedStatic mocked = + mockStatic(InstallationPathConfig.class)) { + mocked.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + + ResponseEntity> response = controller.uploadLicenseFile(file); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + Path backupDir = tempDir.resolve("backup"); + assertThat(Files.list(backupDir).count()).isGreaterThan(0); + } + } + + @Test + @DisplayName("activation failure after save returns 400") + void activationThrows_returnsBadRequest(@TempDir Path tempDir) throws IOException { + String content = "-----BEGIN LICENSE FILE-----\nX\n-----END LICENSE FILE-----"; + MultipartFile file = + new MockMultipartFile( + "file", "license.lic", null, content.getBytes(StandardCharsets.UTF_8)); + doThrow(new RuntimeException("bad license")) + .when(licenseKeyChecker) + .updateLicenseKey(any()); + + try (MockedStatic mocked = + mockStatic(InstallationPathConfig.class)) { + mocked.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + + ResponseEntity> response = controller.uploadLicenseFile(file); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(body(response)).containsEntry("success", false); + } + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java new file mode 100644 index 0000000000..5dc0e92963 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java @@ -0,0 +1,533 @@ +package stirling.software.proprietary.security.controller.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mockStatic; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.junit.jupiter.api.AfterEach; +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.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.GeneralUtils; +import stirling.software.proprietary.security.model.api.admin.SettingValueResponse; +import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest; +import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AdminSettingsController") +class AdminSettingsControllerTest { + + private ApplicationProperties applicationProperties; + private ObjectMapper objectMapper; + private ApplicationContext applicationContext; + + private AdminSettingsController controller; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + objectMapper = JsonMapper.builder().build(); + applicationContext = org.mockito.Mockito.mock(ApplicationContext.class); + controller = + new AdminSettingsController( + applicationProperties, objectMapper, applicationContext); + clearPendingChanges(); + } + + @AfterEach + void tearDown() { + clearPendingChanges(); + } + + // pendingChanges is a static map shared across instances; reset between tests. + @SuppressWarnings("unchecked") + private void clearPendingChanges() { + try { + Field field = AdminSettingsController.class.getDeclaredField("pendingChanges"); + field.setAccessible(true); + ((ConcurrentHashMap) field.get(null)).clear(); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } + + @SuppressWarnings("unchecked") + private void putPending(String key, Object value) { + try { + Field field = AdminSettingsController.class.getDeclaredField("pendingChanges"); + field.setAccessible(true); + ((ConcurrentHashMap) field.get(null)).put(key, value); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } + + @Nested + @DisplayName("getSettings") + class GetSettings { + + @Test + @DisplayName("returns full settings map without pending changes") + void returnsSettings() { + ResponseEntity response = controller.getSettings(false); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(response.getBody()).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map body = (Map) response.getBody(); + assertThat(body).containsKey("security"); + assertThat(body).containsKey("system"); + } + + @Test + @DisplayName("merges pending changes when includePending is true") + void mergesPendingChanges() { + putPending("ui.logoStyle", "modern"); + + ResponseEntity response = controller.getSettings(true); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + Map body = (Map) response.getBody(); + Map ui = (Map) body.get("ui"); + assertThat(ui.get("logoStyle")).isEqualTo("modern"); + } + + @Test + @DisplayName("masks sensitive password fields") + void masksSensitiveFields() { + applicationProperties.getMail().setPassword("supersecret"); + + ResponseEntity response = controller.getSettings(false); + + Map body = (Map) response.getBody(); + Map mail = (Map) body.get("mail"); + assertThat(mail.get("password")).isEqualTo("********"); + } + } + + @Nested + @DisplayName("getSettingsDelta") + class GetSettingsDelta { + + @Test + @DisplayName("reports no pending changes when empty") + void emptyDelta() { + ResponseEntity response = controller.getSettingsDelta(); + + Map body = (Map) response.getBody(); + assertThat(body.get("hasPendingChanges")).isEqualTo(false); + assertThat(body.get("count")).isEqualTo(0); + } + + @Test + @DisplayName("reports pending changes with count") + void withPending() { + putPending("ui.appName", "Foo"); + putPending("system.enableAnalytics", false); + + ResponseEntity response = controller.getSettingsDelta(); + + Map body = (Map) response.getBody(); + assertThat(body.get("hasPendingChanges")).isEqualTo(true); + assertThat(body.get("count")).isEqualTo(2); + } + } + + @Nested + @DisplayName("updateSettings") + class UpdateSettings { + + @Test + @DisplayName("rejects null settings map with 400") + void rejectsNull() { + UpdateSettingsRequest request = new UpdateSettingsRequest(); + request.setSettings(null); + + ResponseEntity> response = controller.updateSettings(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).containsKey("error"); + } + + @Test + @DisplayName("rejects empty settings map with 400") + void rejectsEmpty() { + UpdateSettingsRequest request = new UpdateSettingsRequest(); + request.setSettings(Map.of()); + + ResponseEntity> response = controller.updateSettings(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("rejects invalid setting key format with 400") + void rejectsInvalidKey() { + UpdateSettingsRequest request = new UpdateSettingsRequest(); + request.setSettings(Map.of("bad key with spaces", "x")); + + ResponseEntity> response = controller.updateSettings(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().get("error").toString()).contains("Invalid setting key"); + } + + @Test + @DisplayName("rejects unknown section prefix with 400") + void rejectsUnknownSection() { + UpdateSettingsRequest request = new UpdateSettingsRequest(); + request.setSettings(Map.of("nope.value", "x")); + + ResponseEntity> response = controller.updateSettings(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("rejects duplicate watched-folder paths with 400") + void rejectsDuplicatePaths() { + UpdateSettingsRequest request = new UpdateSettingsRequest(); + request.setSettings( + Map.of( + "system.customPaths.pipeline.watchedFoldersDirs", + List.of("/tmp/a", "/tmp/a"))); + + ResponseEntity> response = controller.updateSettings(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().get("error").toString()).contains("Duplicate"); + } + + @Test + @DisplayName("rejects overlapping watched-folder paths with 400") + void rejectsOverlappingPaths() { + UpdateSettingsRequest request = new UpdateSettingsRequest(); + request.setSettings( + Map.of( + "system.customPaths.pipeline.watchedFoldersDirs", + List.of("/tmp/parent", "/tmp/parent/child"))); + + ResponseEntity> response = controller.updateSettings(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().get("error").toString()).contains("Overlapping"); + } + + @Test + @DisplayName("applies valid settings and tracks them as pending") + void appliesValidSettings() { + UpdateSettingsRequest request = new UpdateSettingsRequest(); + request.setSettings(Map.of("ui.appName", "My App")); + + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + ResponseEntity> response = controller.updateSettings(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().get("message").toString()).contains("Successfully"); + mocked.verify( + () -> GeneralUtils.updateSettingsTransactional(request.getSettings())); + } + } + + @Test + @DisplayName("returns 500 when persistence throws IOException") + void persistenceIOException() { + UpdateSettingsRequest request = new UpdateSettingsRequest(); + request.setSettings(Map.of("ui.appName", "My App")); + + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + mocked.when(() -> GeneralUtils.updateSettingsTransactional(request.getSettings())) + .thenThrow(new IOException("disk full")); + + ResponseEntity> response = controller.updateSettings(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Test + @DisplayName("returns 400 when persistence throws IllegalArgumentException") + void persistenceIllegalArgument() { + UpdateSettingsRequest request = new UpdateSettingsRequest(); + request.setSettings(Map.of("ui.appName", "My App")); + + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + mocked.when(() -> GeneralUtils.updateSettingsTransactional(request.getSettings())) + .thenThrow(new IllegalArgumentException("bad")); + + ResponseEntity> response = controller.updateSettings(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + } + + @Nested + @DisplayName("getSettingsSection") + class GetSettingsSection { + + @Test + @DisplayName("returns 400 for invalid section name") + void invalidSection() { + ResponseEntity response = controller.getSettingsSection("nonsense", true); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().toString()).contains("Invalid section name"); + } + + @Test + @DisplayName("returns section data for valid section") + void validSection() { + ResponseEntity response = controller.getSettingsSection("security", false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isInstanceOf(Map.class); + } + + @Test + @DisplayName("adds _pending block when section has pending changes") + void includesPending() { + putPending("ui.appName", "Pending App"); + + ResponseEntity response = controller.getSettingsSection("ui", true); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + @SuppressWarnings("unchecked") + Map body = (Map) response.getBody(); + assertThat(body).containsKey("_pending"); + } + } + + @Nested + @DisplayName("updateSettingsSection") + class UpdateSettingsSection { + + @Test + @DisplayName("rejects null section data with 400") + void rejectsNull() { + ResponseEntity> response = + controller.updateSettingsSection("security", null); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("rejects invalid section name with 400") + void rejectsInvalidSection() { + ResponseEntity> response = + controller.updateSettingsSection("bogus", Map.of("foo", "bar")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().get("error").toString()).contains("Invalid section name"); + } + + @Test + @DisplayName("updates valid section settings and tracks pending") + void updatesValidSection() { + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + ResponseEntity> response = + controller.updateSettingsSection( + "ui", new java.util.HashMap<>(Map.of("appName", "New"))); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().get("message").toString()).contains("Successfully"); + mocked.verify(() -> GeneralUtils.saveKeyToSettings("ui.appName", "New")); + } + } + + @Test + @DisplayName("auto-enables premium when license key provided") + void autoEnablesPremium() { + java.util.Map section = new java.util.HashMap<>(); + section.put("key", "license-123"); + + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + ResponseEntity> response = + controller.updateSettingsSection("premium", section); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + // enabled flag auto-added and persisted + mocked.verify(() -> GeneralUtils.saveKeyToSettings("premium.enabled", true)); + } + } + + @Test + @DisplayName("returns 500 when persistence throws IOException") + void persistenceIOException() { + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + mocked.when(() -> GeneralUtils.saveKeyToSettings("ui.appName", "New")) + .thenThrow(new IOException("io")); + + ResponseEntity> response = + controller.updateSettingsSection( + "ui", new java.util.HashMap<>(Map.of("appName", "New"))); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + } + + @Nested + @DisplayName("getSettingValue") + class GetSettingValue { + + @Test + @DisplayName("returns 400 for invalid key format") + void invalidKey() { + ResponseEntity response = controller.getSettingValue("bad key"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().toString()).contains("Invalid setting key"); + } + + @Test + @DisplayName("returns 400 when key not found") + void keyNotFound() { + ResponseEntity response = controller.getSettingValue("ui.nonExistentProperty"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody().toString()).contains("not found"); + } + + @Test + @DisplayName("returns value for an existing key") + void existingKey() { + applicationProperties.getUi().setLogoStyle("modern"); + + ResponseEntity response = controller.getSettingValue("ui.logoStyle"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + SettingValueResponse body = (SettingValueResponse) response.getBody(); + assertThat(body.getKey()).isEqualTo("ui.logoStyle"); + assertThat(body.getValue()).isEqualTo("modern"); + } + + @Test + @DisplayName("masks sensitive value for an existing secret key") + void masksSecret() { + applicationProperties.getMail().setPassword("secretval"); + + ResponseEntity response = controller.getSettingValue("mail.password"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + SettingValueResponse body = (SettingValueResponse) response.getBody(); + assertThat(body.getValue()).isEqualTo("********"); + } + } + + @Nested + @DisplayName("updateSettingValue") + class UpdateSettingValue { + + @Test + @DisplayName("returns 400 for invalid key format") + void invalidKey() { + UpdateSettingValueRequest request = new UpdateSettingValueRequest(); + request.setValue("x"); + + ResponseEntity response = controller.updateSettingValue("bad key", request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + @DisplayName("blocks saving masked value for sensitive field") + void blocksMaskedSensitive() { + UpdateSettingValueRequest request = new UpdateSettingValueRequest(); + request.setValue("********"); + + ResponseEntity response = + controller.updateSettingValue("mail.password", request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains("Cannot save masked"); + } + + @Test + @DisplayName("saves a valid value and tracks pending") + void savesValue() { + UpdateSettingValueRequest request = new UpdateSettingValueRequest(); + request.setValue("Renamed"); + + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + ResponseEntity response = + controller.updateSettingValue("ui.appName", request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("Successfully updated"); + mocked.verify(() -> GeneralUtils.saveKeyToSettings("ui.appName", "Renamed")); + } + } + + @Test + @DisplayName("returns 500 when persistence throws IOException") + void persistenceIOException() { + UpdateSettingValueRequest request = new UpdateSettingValueRequest(); + request.setValue("Renamed"); + + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + mocked.when(() -> GeneralUtils.saveKeyToSettings("ui.appName", "Renamed")) + .thenThrow(new IOException("io")); + + ResponseEntity response = + controller.updateSettingValue("ui.appName", request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + } + + @Nested + @DisplayName("restartApplication") + class RestartApplication { + + @Test + @DisplayName("returns 503 when not running from a JAR (dev mode)") + void devModeUnavailable() { + try (MockedStatic jar = + mockStatic(stirling.software.common.util.JarPathUtil.class)) { + jar.when(stirling.software.common.util.JarPathUtil::currentJar).thenReturn(null); + + ResponseEntity> response = controller.restartApplication(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(response.getBody().get("error").toString()).contains("development mode"); + } + } + + @Test + @DisplayName("returns 503 when restart helper jar is missing") + void helperMissing() { + try (MockedStatic jar = + mockStatic(stirling.software.common.util.JarPathUtil.class)) { + jar.when(stirling.software.common.util.JarPathUtil::currentJar) + .thenReturn(java.nio.file.Path.of("app.jar")); + jar.when(stirling.software.common.util.JarPathUtil::restartHelperJar) + .thenReturn(null); + + ResponseEntity> response = controller.restartApplication(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(response.getBody().get("error").toString()) + .contains("Restart helper not found"); + } + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java new file mode 100644 index 0000000000..8f5b9e9235 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java @@ -0,0 +1,304 @@ +package stirling.software.proprietary.security.controller.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.security.model.AuthenticationType; +import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa; +import stirling.software.proprietary.security.service.CustomUserDetailsService; +import stirling.software.proprietary.security.service.JwtServiceInterface; +import stirling.software.proprietary.security.service.LoginAttemptService; +import stirling.software.proprietary.security.service.MfaService; +import stirling.software.proprietary.security.service.RefreshRateLimitService; +import stirling.software.proprietary.security.service.TotpService; +import stirling.software.proprietary.security.service.UserService; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AuthController - additional coverage") +class AuthControllerMoreTest { + + private final ObjectMapper objectMapper = JsonMapper.builder().build(); + + private MockMvc mockMvc; + private ApplicationProperties.Security securityProperties; + + @Mock private UserService userService; + @Mock private JwtServiceInterface jwtService; + @Mock private CustomUserDetailsService userDetailsService; + @Mock private LoginAttemptService loginAttemptService; + @Mock private MfaService mfaService; + @Mock private TotpService totpService; + @Mock private RefreshRateLimitService refreshRateLimitService; + + @BeforeEach + void setUp() { + securityProperties = new ApplicationProperties.Security(); + securityProperties.setLoginMethod("all"); + securityProperties.getJwt().setTokenExpiryMinutes(60); + securityProperties.getJwt().setRefreshGraceMinutes(5); + + ApplicationProperties applicationProperties = new ApplicationProperties(); + applicationProperties.setSecurity(securityProperties); + + AuthController controller = + new AuthController( + userService, + jwtService, + userDetailsService, + loginAttemptService, + mfaService, + totpService, + refreshRateLimitService, + securityProperties, + applicationProperties, + new stirling.software.proprietary.service.AiUserDataService(null)); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + private UsernameAndPassMfa payload(String username, String password) { + UsernameAndPassMfa p = new UsernameAndPassMfa(); + p.setUsername(username); + p.setPassword(password); + return p; + } + + private User webUser() { + User user = new User(); + user.setUsername("user@example.com"); + user.setEnabled(true); + user.setAuthenticationType(AuthenticationType.WEB); + Authority authority = new Authority(); + authority.setAuthority(Role.USER.getRoleId()); + user.addAuthorities(Set.of(authority)); + return user; + } + + @Nested + @DisplayName("login validation") + class LoginValidation { + + @Test + @DisplayName("rejects a blank username") + void blankUsername() throws Exception { + mockMvc.perform( + post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(payload(" ", "pw")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Username is required")); + } + + @Test + @DisplayName("rejects a missing password") + void missingPassword() throws Exception { + mockMvc.perform( + post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + payload("user@example.com", "")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Password is required")); + } + + @Test + @DisplayName("returns 401 for a disabled account") + void disabledAccount() throws Exception { + User user = webUser(); + user.setEnabled(false); + when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user); + when(userService.isPasswordCorrect(user, "pw")).thenReturn(true); + + mockMvc.perform( + post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + payload("user@example.com", "pw")))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("User account is disabled")); + } + + @Test + @DisplayName("maps an unknown user to a generic 401 and records the failure") + void unknownUser() throws Exception { + when(userDetailsService.loadUserByUsername("user@example.com")) + .thenThrow(new UsernameNotFoundException("nope")); + + mockMvc.perform( + post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + payload("user@example.com", "pw")))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("Invalid username or password")); + + verify(loginAttemptService).loginFailed("user@example.com"); + } + } + + @Nested + @DisplayName("login MFA") + class LoginMfa { + + @Test + @DisplayName("returns 500 when MFA is enabled but no secret is stored") + void mfaNoSecret() throws Exception { + User user = webUser(); + UsernameAndPassMfa p = payload("user@example.com", "pw"); + p.setMfaCode("123456"); + when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user); + when(userService.isPasswordCorrect(user, "pw")).thenReturn(true); + when(mfaService.isMfaEnabled(user)).thenReturn(true); + when(mfaService.getSecret(user)).thenReturn(""); + + mockMvc.perform( + post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(p))) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.error").value("MFA configuration error")); + } + + @Test + @DisplayName("returns 401 for an invalid MFA code") + void mfaInvalidCode() throws Exception { + User user = webUser(); + UsernameAndPassMfa p = payload("user@example.com", "pw"); + p.setMfaCode("000000"); + when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user); + when(userService.isPasswordCorrect(user, "pw")).thenReturn(true); + when(mfaService.isMfaEnabled(user)).thenReturn(true); + when(mfaService.getSecret(user)).thenReturn("SECRET"); + when(totpService.getValidTimeStep("SECRET", "000000")).thenReturn(null); + + mockMvc.perform( + post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(p))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("invalid_mfa_code")); + + verify(loginAttemptService).loginFailed("user@example.com"); + } + + @Test + @DisplayName("returns 401 when a replayed MFA code is detected") + void mfaReplay() throws Exception { + User user = webUser(); + UsernameAndPassMfa p = payload("user@example.com", "pw"); + p.setMfaCode("123456"); + when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user); + when(userService.isPasswordCorrect(user, "pw")).thenReturn(true); + when(mfaService.isMfaEnabled(user)).thenReturn(true); + when(mfaService.getSecret(user)).thenReturn("SECRET"); + when(totpService.getValidTimeStep("SECRET", "123456")).thenReturn(9L); + when(mfaService.markTotpStepUsed(user, 9L)).thenReturn(false); + + mockMvc.perform( + post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(p))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("invalid_mfa_code")); + } + } + + @Nested + @DisplayName("logout") + class Logout { + + @Test + @DisplayName("clears context and returns a success message") + void logoutSucceeds() throws Exception { + // Null username makes the async purge a no-op, avoiding the stubbed-out engine client. + when(jwtService.extractUsernameFromRequestAllowExpired(any())).thenReturn(null); + + mockMvc.perform(post("/api/v1/auth/logout")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Logged out successfully")); + } + } + + @Nested + @DisplayName("refresh") + class Refresh { + + @Test + @DisplayName("returns 401 when the subject claim is missing") + void missingSubject() throws Exception { + when(jwtService.extractToken(any())).thenReturn("old"); + Map claims = new HashMap<>(); + claims.put("exp", new Date(System.currentTimeMillis() + 60_000)); + when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims); + + mockMvc.perform(post("/api/v1/auth/refresh")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("Token refresh failed")); + + verify(userDetailsService, never()).loadUserByUsername(any()); + } + } + + @Nested + @DisplayName("admin MFA disable") + class AdminMfaDisable { + + @Test + @DisplayName("returns 404 when the target user is unknown") + void userNotFound() throws Exception { + when(userService.findByUsernameIgnoreCaseWithSettings("ghost")) + .thenReturn(Optional.empty()); + + mockMvc.perform(post("/api/v1/auth/mfa/disable/admin/ghost")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error").value("User not found")); + } + + @Test + @DisplayName("disables MFA for an enabled user") + void disablesEnabled() throws Exception { + User user = webUser(); + when(userService.findByUsernameIgnoreCaseWithSettings("user@example.com")) + .thenReturn(Optional.of(user)); + when(mfaService.isMfaEnabled(user)).thenReturn(true); + + mockMvc.perform(post("/api/v1/auth/mfa/disable/admin/user@example.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.enabled").value(false)); + + verify(mfaService).disableMfa(user); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/InviteLinkControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/InviteLinkControllerMoreTest.java new file mode 100644 index 0000000000..beca696e4c --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/InviteLinkControllerMoreTest.java @@ -0,0 +1,307 @@ +package stirling.software.proprietary.security.controller.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.security.model.InviteToken; +import stirling.software.proprietary.security.repository.InviteTokenRepository; +import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.service.EmailService; +import stirling.software.proprietary.security.service.UserService; +import stirling.software.proprietary.service.UserLicenseSettingsService; + +@ExtendWith(MockitoExtension.class) +@DisplayName("InviteLinkController - additional coverage") +class InviteLinkControllerMoreTest { + + @Mock private InviteTokenRepository inviteTokenRepository; + @Mock private TeamRepository teamRepository; + @Mock private UserService userService; + @Mock private EmailService emailService; + @Mock private UserLicenseSettingsService userLicenseSettingsService; + + private ApplicationProperties applicationProperties; + private MockMvc mockMvc; + private Principal adminPrincipal; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + applicationProperties.getMail().setEnableInvites(true); + applicationProperties.getMail().setInviteLinkExpiryHours(24); + applicationProperties.getSystem().setFrontendUrl("https://frontend.example.com"); + + adminPrincipal = () -> "admin"; + + InviteLinkController controller = + new InviteLinkController( + inviteTokenRepository, + teamRepository, + userService, + applicationProperties, + Optional.of(emailService), + userLicenseSettingsService); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + private static InviteToken validInvite(String token) { + InviteToken invite = new InviteToken(); + invite.setToken(token); + invite.setExpiresAt(LocalDateTime.now().plusHours(2)); + invite.setRole(Role.USER.getRoleId()); + invite.setUsed(false); + return invite; + } + + @Nested + @DisplayName("generate") + class Generate { + + @Test + @DisplayName("rejects sendEmail without an email address") + void sendEmailWithoutAddress() throws Exception { + mockMvc.perform( + post("/api/v1/invite/generate") + .principal(adminPrincipal) + .param("sendEmail", "true")) + .andExpect(status().isBadRequest()) + .andExpect( + jsonPath("$.error") + .value("Cannot send email without an email address")); + } + + @Test + @DisplayName("returns conflict when the user already exists") + void userAlreadyExists() throws Exception { + when(userService.usernameExistsIgnoreCase("dup@ex.com")).thenReturn(true); + + mockMvc.perform( + post("/api/v1/invite/generate") + .principal(adminPrincipal) + .param("email", "dup@ex.com")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error").value("User already exists")); + } + + @Test + @DisplayName("returns conflict when an active invite already exists") + void activeInviteExists() throws Exception { + when(userService.usernameExistsIgnoreCase("dup@ex.com")).thenReturn(false); + when(inviteTokenRepository.findByEmail("dup@ex.com")) + .thenReturn(Optional.of(validInvite("existing"))); + + mockMvc.perform( + post("/api/v1/invite/generate") + .principal(adminPrincipal) + .param("email", "dup@ex.com")) + .andExpect(status().isConflict()) + .andExpect( + jsonPath("$.error") + .value( + "An active invite already exists for this email" + + " address")); + } + + @Test + @DisplayName("rejects assigning the INTERNAL_API_USER role") + void rejectsInternalApiRole() throws Exception { + mockMvc.perform( + post("/api/v1/invite/generate") + .principal(adminPrincipal) + .param("role", Role.INTERNAL_API_USER.getRoleId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Cannot assign INTERNAL_API_USER role")); + } + } + + @Nested + @DisplayName("list") + class ListInvites { + + @Test + @DisplayName("returns the active invites with their metadata") + void listsActiveInvites() throws Exception { + InviteToken invite = validInvite("t1"); + invite.setId(11L); + invite.setEmail("a@ex.com"); + invite.setCreatedBy("admin"); + invite.setCreatedAt(LocalDateTime.now()); + when(inviteTokenRepository.findByUsedFalseAndExpiresAtAfter(any(LocalDateTime.class))) + .thenReturn(List.of(invite)); + + mockMvc.perform(get("/api/v1/invite/list")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.invites[0].id").value(11)) + .andExpect(jsonPath("$.invites[0].email").value("a@ex.com")); + } + } + + @Nested + @DisplayName("revoke") + class Revoke { + + @Test + @DisplayName("returns 404 when the invite does not exist") + void notFound() throws Exception { + when(inviteTokenRepository.findById(99L)).thenReturn(Optional.empty()); + + mockMvc.perform(delete("/api/v1/invite/revoke/99")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error").value("Invite not found")); + + verify(inviteTokenRepository, never()).deleteById(any()); + } + + @Test + @DisplayName("deletes the invite when present") + void deletesInvite() throws Exception { + when(inviteTokenRepository.findById(5L)).thenReturn(Optional.of(validInvite("t"))); + + mockMvc.perform(delete("/api/v1/invite/revoke/5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Invite link revoked successfully")); + + verify(inviteTokenRepository).deleteById(5L); + } + } + + @Nested + @DisplayName("cleanup") + class Cleanup { + + @Test + @DisplayName("deletes only the expired or used invites") + void deletesExpiredInvites() throws Exception { + InviteToken expired = new InviteToken(); + expired.setExpiresAt(LocalDateTime.now().minusHours(1)); + InviteToken active = validInvite("active"); + when(inviteTokenRepository.findAll()).thenReturn(List.of(expired, active)); + + mockMvc.perform(post("/api/v1/invite/cleanup")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.deletedCount").value(1)); + + verify(inviteTokenRepository).deleteAll(List.of(expired)); + } + } + + @Nested + @DisplayName("validate") + class Validate { + + @Test + @DisplayName("returns details for a valid token") + void validToken() throws Exception { + InviteToken invite = validInvite("good"); + invite.setEmail("a@ex.com"); + when(inviteTokenRepository.findByToken("good")).thenReturn(Optional.of(invite)); + when(userService.usernameExistsIgnoreCase("a@ex.com")).thenReturn(false); + + mockMvc.perform(get("/api/v1/invite/validate/good")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.email").value("a@ex.com")) + .andExpect(jsonPath("$.emailRequired").value(false)); + } + + @Test + @DisplayName("returns 404 for an already-used token") + void usedToken() throws Exception { + InviteToken invite = validInvite("used"); + invite.setUsed(true); + when(inviteTokenRepository.findByToken("used")).thenReturn(Optional.of(invite)); + + mockMvc.perform(get("/api/v1/invite/validate/used")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error").value("Invalid invite link")); + } + + @Test + @DisplayName("returns 404 when the pre-set email already has an account") + void emailAlreadyExists() throws Exception { + InviteToken invite = validInvite("dup"); + invite.setEmail("taken@ex.com"); + when(inviteTokenRepository.findByToken("dup")).thenReturn(Optional.of(invite)); + when(userService.usernameExistsIgnoreCase("taken@ex.com")).thenReturn(true); + + mockMvc.perform(get("/api/v1/invite/validate/dup")).andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("accept") + class Accept { + + @Test + @DisplayName("rejects a missing password") + void missingPassword() throws Exception { + mockMvc.perform(post("/api/v1/invite/accept/tok").param("password", "")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Password is required")); + } + + @Test + @DisplayName("returns 404 for an expired token") + void expiredToken() throws Exception { + InviteToken invite = validInvite("exp"); + invite.setExpiresAt(LocalDateTime.now().minusHours(1)); + when(inviteTokenRepository.findByToken("exp")).thenReturn(Optional.of(invite)); + + mockMvc.perform(post("/api/v1/invite/accept/exp").param("password", "secret123")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error").value("Invalid invite link")); + } + + @Test + @DisplayName("requires an email when the invite has none") + void emailRequired() throws Exception { + InviteToken invite = validInvite("noemail"); + invite.setEmail(null); + when(inviteTokenRepository.findByToken("noemail")).thenReturn(Optional.of(invite)); + + mockMvc.perform(post("/api/v1/invite/accept/noemail").param("password", "secret123")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Email address is required")); + } + + @Test + @DisplayName("creates the account using the pre-set email") + void createsWithPresetEmail() throws Exception { + InviteToken invite = validInvite("preset"); + invite.setEmail("preset@ex.com"); + invite.setTeamId(3L); + when(inviteTokenRepository.findByToken("preset")).thenReturn(Optional.of(invite)); + when(userService.usernameExistsIgnoreCase("preset@ex.com")).thenReturn(false); + + mockMvc.perform(post("/api/v1/invite/accept/preset").param("password", "secret123")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value("preset@ex.com")); + + verify(userService).saveUserCore(any()); + verify(inviteTokenRepository).save(invite); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerMoreTest.java new file mode 100644 index 0000000000..75c23b4630 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerMoreTest.java @@ -0,0 +1,474 @@ +package stirling.software.proprietary.security.controller.api; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Optional; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.service.EmailService; +import stirling.software.proprietary.security.service.LoginAttemptService; +import stirling.software.proprietary.security.service.TeamService; +import stirling.software.proprietary.security.service.UserService; +import stirling.software.proprietary.security.session.SessionPersistentRegistry; +import stirling.software.proprietary.service.UserLicenseSettingsService; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserController - additional coverage") +class UserControllerMoreTest { + + @Mock private UserService userService; + @Mock private SessionPersistentRegistry sessionRegistry; + @Mock private TeamRepository teamRepository; + @Mock private UserRepository userRepository; + @Mock private EmailService emailService; + @Mock private UserLicenseSettingsService licenseSettingsService; + @Mock private LoginAttemptService loginAttemptService; + + private ApplicationProperties applicationProperties; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + applicationProperties.getMail().setEnabled(true); + + UserController controller = + new UserController( + userService, + sessionRegistry, + applicationProperties, + teamRepository, + userRepository, + Optional.of(emailService), + licenseSettingsService, + loginAttemptService); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + private static Authentication auth(String username) { + return new UsernamePasswordAuthenticationToken(username, "pw"); + } + + private static User user(String username) { + User u = new User(); + u.setUsername(username); + return u; + } + + @Nested + @DisplayName("change-password") + class ChangePassword { + + @Test + @DisplayName("returns 404 when the principal has no user record") + void userNotFound() throws Exception { + when(userService.findByUsernameIgnoreCase("me")).thenReturn(Optional.empty()); + + mockMvc.perform( + post("/api/v1/user/change-password") + .principal(auth("me")) + .param("currentPassword", "old") + .param("newPassword", "new")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error").value("userNotFound")); + } + + @Test + @DisplayName("returns 401 when the current password is wrong") + void incorrectPassword() throws Exception { + User u = user("me"); + when(userService.findByUsernameIgnoreCase("me")).thenReturn(Optional.of(u)); + when(userService.isPasswordCorrect(u, "old")).thenReturn(false); + + mockMvc.perform( + post("/api/v1/user/change-password") + .principal(auth("me")) + .param("currentPassword", "old") + .param("newPassword", "new")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.error").value("incorrectPassword")); + } + + @Test + @DisplayName("changes the password and logs the user out") + void success() throws Exception { + User u = user("me"); + when(userService.findByUsernameIgnoreCase("me")).thenReturn(Optional.of(u)); + when(userService.isPasswordCorrect(u, "old")).thenReturn(true); + + mockMvc.perform( + post("/api/v1/user/change-password") + .principal(auth("me")) + .param("currentPassword", "old") + .param("newPassword", "new")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("credsUpdated")); + + verify(userService).changePassword(u, "new"); + } + } + + @Nested + @DisplayName("change-password-on-login") + class ChangePasswordOnLogin { + + @Test + @DisplayName("rejects mismatched confirmation") + void mismatch() throws Exception { + User u = user("me"); + when(userService.findByUsernameIgnoreCase("me")).thenReturn(Optional.of(u)); + + mockMvc.perform( + post("/api/v1/user/change-password-on-login") + .principal(auth("me")) + .param("currentPassword", "old") + .param("newPassword", "a") + .param("confirmPassword", "b")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("passwordMismatch")); + } + + @Test + @DisplayName("rejects an unchanged password") + void unchanged() throws Exception { + User u = user("me"); + when(userService.findByUsernameIgnoreCase("me")).thenReturn(Optional.of(u)); + + mockMvc.perform( + post("/api/v1/user/change-password-on-login") + .principal(auth("me")) + .param("currentPassword", "same") + .param("newPassword", "same") + .param("confirmPassword", "same")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("passwordUnchanged")); + } + + @Test + @DisplayName("changes the password and clears the force-change flag") + void success() throws Exception { + User u = user("me"); + u.setForcePasswordChange(true); + when(userService.findByUsernameIgnoreCase("me")).thenReturn(Optional.of(u)); + when(userService.isPasswordCorrect(u, "old")).thenReturn(true); + + mockMvc.perform( + post("/api/v1/user/change-password-on-login") + .principal(auth("me")) + .param("currentPassword", "old") + .param("newPassword", "new") + .param("confirmPassword", "new")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("credsUpdated")); + + verify(userService).changePassword(u, "new"); + verify(userService).changeFirstUse(u, false); + } + } + + @Nested + @DisplayName("admin/saveUser") + class SaveUser { + + @Test + @DisplayName("rejects an invalid username format") + void invalidUsername() throws Exception { + when(userService.isUsernameValid("x")).thenReturn(false); + + mockMvc.perform( + post("/api/v1/user/admin/saveUser") + .param("username", "x") + .param("role", "ROLE_USER") + .param("authType", "web")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("rejects an unknown role") + void invalidRole() throws Exception { + when(userService.isUsernameValid("new@ex.com")).thenReturn(true); + when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false); + when(userService.findByUsernameIgnoreCase("new@ex.com")).thenReturn(Optional.empty()); + when(userService.usernameExistsIgnoreCase("new@ex.com")).thenReturn(false); + + mockMvc.perform( + post("/api/v1/user/admin/saveUser") + .param("username", "new@ex.com") + .param("role", "ROLE_BOGUS") + .param("authType", "web")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Invalid role specified.")); + } + + @Test + @DisplayName("requires a password for WEB auth") + void missingPassword() throws Exception { + when(userService.isUsernameValid("new@ex.com")).thenReturn(true); + when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false); + when(userService.findByUsernameIgnoreCase("new@ex.com")).thenReturn(Optional.empty()); + when(userService.usernameExistsIgnoreCase("new@ex.com")).thenReturn(false); + + mockMvc.perform( + post("/api/v1/user/admin/saveUser") + .param("username", "new@ex.com") + .param("role", "ROLE_USER") + .param("authType", "web")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Password is required.")); + } + + @Test + @DisplayName("creates a WEB user with a default team") + void success() throws Exception { + when(userService.isUsernameValid("new@ex.com")).thenReturn(true); + when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false); + when(userService.findByUsernameIgnoreCase("new@ex.com")).thenReturn(Optional.empty()); + when(userService.usernameExistsIgnoreCase("new@ex.com")).thenReturn(false); + Team defaultTeam = new Team(); + defaultTeam.setId(1L); + defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME); + when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME)) + .thenReturn(Optional.of(defaultTeam)); + + mockMvc.perform( + post("/api/v1/user/admin/saveUser") + .param("username", "new@ex.com") + .param("password", "secret1") + .param("role", "ROLE_USER") + .param("authType", "web")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("User created successfully")); + + verify(userService).saveUserCore(any()); + } + } + + @Nested + @DisplayName("admin/changeRole") + class ChangeRole { + + @Test + @DisplayName("returns 404 when the target user is missing") + void userNotFound() throws Exception { + when(userService.findByUsernameIgnoreCase("ghost")).thenReturn(Optional.empty()); + + mockMvc.perform( + post("/api/v1/user/admin/changeRole") + .principal(auth("admin")) + .param("username", "ghost") + .param("role", "ROLE_USER")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("prevents an admin from changing their own role") + void cannotChangeOwnRole() throws Exception { + when(userService.findByUsernameIgnoreCase("admin")) + .thenReturn(Optional.of(user("admin"))); + when(userService.usernameExistsIgnoreCase("admin")).thenReturn(true); + + mockMvc.perform( + post("/api/v1/user/admin/changeRole") + .principal(auth("admin")) + .param("username", "admin") + .param("role", "ROLE_ADMIN")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Cannot change your own role.")); + } + + @Test + @DisplayName("updates the role for another user") + void success() throws Exception { + User target = user("bob"); + when(userService.findByUsernameIgnoreCase("bob")).thenReturn(Optional.of(target)); + when(userService.usernameExistsIgnoreCase("bob")).thenReturn(true); + + mockMvc.perform( + post("/api/v1/user/admin/changeRole") + .principal(auth("admin")) + .param("username", "bob") + .param("role", "ROLE_ADMIN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("User role updated successfully")); + + verify(userService).changeRole(target, "ROLE_ADMIN"); + } + } + + @Nested + @DisplayName("admin/changePasswordForUser") + class ChangePasswordForUser { + + @Test + @DisplayName("prevents changing your own password via the admin route") + void cannotChangeOwn() throws Exception { + when(userService.findByUsernameIgnoreCase("admin")) + .thenReturn(Optional.of(user("admin"))); + + mockMvc.perform( + post("/api/v1/user/admin/changePasswordForUser") + .principal(auth("admin")) + .param("username", "admin") + .param("newPassword", "x")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Cannot change your own password.")); + } + + @Test + @DisplayName("requires a non-blank password") + void requiresPassword() throws Exception { + when(userService.findByUsernameIgnoreCase("bob")).thenReturn(Optional.of(user("bob"))); + + mockMvc.perform( + post("/api/v1/user/admin/changePasswordForUser") + .principal(auth("admin")) + .param("username", "bob")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("New password is required.")); + } + + @Test + @DisplayName("changes the password and invalidates sessions") + void success() throws Exception { + User target = user("bob"); + when(userService.findByUsernameIgnoreCase("bob")).thenReturn(Optional.of(target)); + + mockMvc.perform( + post("/api/v1/user/admin/changePasswordForUser") + .principal(auth("admin")) + .param("username", "bob") + .param("newPassword", "newpass")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("User password updated successfully")); + + verify(userService).changePassword(target, "newpass"); + verify(userService).invalidateUserSessions("bob"); + } + } + + @Nested + @DisplayName("API key endpoints") + class ApiKeyEndpoints { + + @Test + @DisplayName("get-api-key returns 403 without a principal") + void getApiKeyNoPrincipal() throws Exception { + mockMvc.perform(post("/api/v1/user/get-api-key")).andExpect(status().isForbidden()); + } + + @Test + @DisplayName("get-api-key returns the key for the caller") + void getApiKeySuccess() throws Exception { + when(userService.getApiKeyForUser("me")).thenReturn("api-123"); + + mockMvc.perform(post("/api/v1/user/get-api-key").principal(auth("me"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.apiKey").value("api-123")); + } + + @Test + @DisplayName("update-api-key refreshes and returns the new key") + void updateApiKeySuccess() throws Exception { + User refreshed = user("me"); + refreshed.setApiKey("fresh"); + when(userService.refreshApiKeyForUser("me")).thenReturn(refreshed); + + mockMvc.perform(post("/api/v1/user/update-api-key").principal(auth("me"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.apiKey").value("fresh")); + } + } + + @Nested + @DisplayName("admin/deleteUser") + class DeleteUser { + + @Test + @DisplayName("deletes another user and expires their sessions") + void success() throws Exception { + when(userService.usernameExistsIgnoreCase("bob")).thenReturn(true); + when(sessionRegistry.getAllSessions("bob", false)).thenReturn(java.util.List.of()); + + mockMvc.perform(post("/api/v1/user/admin/deleteUser/bob").principal(auth("admin"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("User deleted successfully")); + + verify(userService).deleteUser("bob"); + } + + @Test + @DisplayName("prevents deleting your own account") + void cannotDeleteSelf() throws Exception { + when(userService.usernameExistsIgnoreCase("admin")).thenReturn(true); + + mockMvc.perform(post("/api/v1/user/admin/deleteUser/admin").principal(auth("admin"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Cannot delete your own account.")); + + verify(userService, never()).deleteUser(eq("admin")); + } + } + + @Nested + @DisplayName("admin/inviteUsers") + class InviteUsers { + + @Test + @DisplayName("rejects when invites are disabled") + void invitesDisabled() throws Exception { + applicationProperties.getMail().setEnableInvites(false); + + mockMvc.perform( + post("/api/v1/user/admin/inviteUsers") + .principal(auth("admin")) + .param("emails", "a@ex.com")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Email invites are not enabled")); + } + + @Test + @DisplayName("invites a new user and reports a success count") + void success() throws Exception { + applicationProperties.getMail().setEnableInvites(true); + when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false); + when(userService.usernameExistsIgnoreCase("new@ex.com")).thenReturn(false); + Team defaultTeam = new Team(); + defaultTeam.setId(1L); + defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME); + when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME)) + .thenReturn(Optional.of(defaultTeam)); + + mockMvc.perform( + post("/api/v1/user/admin/inviteUsers") + .principal(auth("admin")) + .param("emails", "new@ex.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.successCount").value(1)); + + verify(userService).saveUserCore(any()); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/UserAuthenticationFilterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/UserAuthenticationFilterTest.java new file mode 100644 index 0000000000..c1900ce908 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/UserAuthenticationFilterTest.java @@ -0,0 +1,258 @@ +package stirling.software.proprietary.security.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.AfterEach; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.session.SessionInformation; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; +import stirling.software.proprietary.security.session.SessionPersistentRegistry; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserAuthenticationFilter") +class UserAuthenticationFilterTest { + + @Mock private UserService userService; + @Mock private SessionPersistentRegistry sessionPersistentRegistry; + + private ApplicationProperties.Security securityProp; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + private MockFilterChain filterChain; + + @BeforeEach + void setUp() { + securityProp = new ApplicationProperties.Security(); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + filterChain = new MockFilterChain(); + SecurityContextHolder.clearContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private UserAuthenticationFilter filter(boolean loginEnabled) { + return new UserAuthenticationFilter( + securityProp, userService, sessionPersistentRegistry, loginEnabled); + } + + private static User enabledUser(String username) { + User user = new User(); + user.setUsername(username); + user.setEnabled(true); + return user; + } + + @Nested + @DisplayName("login disabled") + class LoginDisabled { + + @Test + @DisplayName("passes through without any authentication checks") + void passesThroughWhenLoginDisabled() throws Exception { + request.setRequestURI("/api/v1/anything"); + + filter(false).doFilter(request, response, filterChain); + + assertThat(filterChain.getRequest()).isSameAs(request); + verifyNoInteractions(userService); + verifyNoInteractions(sessionPersistentRegistry); + } + } + + @Nested + @DisplayName("API key authentication") + class ApiKey { + + @Test + @DisplayName("authenticates a request carrying a valid X-API-KEY") + void validApiKeyAuthenticates() throws Exception { + request.setRequestURI("/api/v1/some/protected"); + request.addHeader("X-API-KEY", "good-key"); + User user = enabledUser("api-user"); + user.addAuthority( + new stirling.software.proprietary.security.model.Authority("ROLE_USER", user)); + when(userService.getUserByApiKey("good-key")).thenReturn(Optional.of(user)); + when(userService.usernameExistsIgnoreCase("api-user")).thenReturn(true); + when(userService.isUserDisabled("api-user")).thenReturn(false); + when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean())) + .thenReturn(List.of()); + + filter(true).doFilter(request, response, filterChain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()) + .isInstanceOf(ApiKeyAuthenticationToken.class); + assertThat(filterChain.getRequest()).isSameAs(request); + } + + @Test + @DisplayName("rejects an unknown X-API-KEY with 401") + void invalidApiKeyRejected() throws Exception { + request.setRequestURI("/api/v1/some/protected"); + request.addHeader("X-API-KEY", "bad-key"); + when(userService.getUserByApiKey("bad-key")).thenReturn(Optional.empty()); + + filter(true).doFilter(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentAsString()).contains("Invalid API Key"); + assertThat(filterChain.getRequest()).isNull(); + } + } + + @Nested + @DisplayName("unauthenticated requests") + class Unauthenticated { + + @Test + @DisplayName("allows a public auth endpoint through") + void publicEndpointPassesThrough() throws Exception { + request.setRequestURI("/api/v1/auth/login"); + + filter(true).doFilter(request, response, filterChain); + + assertThat(filterChain.getRequest()).isSameAs(request); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("returns 401 JSON for a protected endpoint with no credentials") + void protectedEndpointReturns401() throws Exception { + request.setRequestURI("/api/v1/some/protected"); + + filter(true).doFilter(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentType()).contains("application/json"); + assertThat(response.getContentAsString()).contains("Authentication required"); + assertThat(filterChain.getRequest()).isNull(); + } + } + + @Nested + @DisplayName("authenticated requests") + class Authenticated { + + @Test + @DisplayName("passes through an enabled, existing user") + void enabledExistingUserPasses() throws Exception { + request.setRequestURI("/api/v1/some/protected"); + User user = enabledUser("alice"); + setAuthentication(user, "alice"); + when(userService.usernameExistsIgnoreCase("alice")).thenReturn(true); + when(userService.isUserDisabled("alice")).thenReturn(false); + when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean())) + .thenReturn(List.of()); + + filter(true).doFilter(request, response, filterChain); + + assertThat(filterChain.getRequest()).isSameAs(request); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("returns 401 and clears context when user no longer exists") + void nonExistentUserReturns401() throws Exception { + request.setRequestURI("/api/v1/some/protected"); + User user = enabledUser("ghost"); + setAuthentication(user, "ghost"); + when(userService.usernameExistsIgnoreCase("ghost")).thenReturn(false); + when(userService.isUserDisabled("ghost")).thenReturn(false); + SessionInformation sessionInfo = + new SessionInformation(user, "sess-1", new java.util.Date()); + when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean())) + .thenReturn(List.of(sessionInfo)); + + filter(true).doFilter(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentAsString()).contains("Invalid credentials"); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(sessionPersistentRegistry).expireSession("sess-1"); + } + + @Test + @DisplayName("returns 403 and clears context when user is disabled") + void disabledUserReturns403() throws Exception { + request.setRequestURI("/api/v1/some/protected"); + User user = enabledUser("blocked"); + setAuthentication(user, "blocked"); + when(userService.usernameExistsIgnoreCase("blocked")).thenReturn(true); + when(userService.isUserDisabled("blocked")).thenReturn(true); + when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean())) + .thenReturn(List.of()); + + filter(true).doFilter(request, response, filterChain); + + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getContentAsString()).contains("User account is disabled"); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + private void setAuthentication(User principal, String name) { + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); + SecurityContextHolder.getContext().setAuthentication(auth); + } + } + + @Nested + @DisplayName("shouldNotFilter") + class ShouldNotFilter { + + @Test + @DisplayName("skips static resources on GET") + void skipsStaticResourceOnGet() { + request.setMethod("GET"); + request.setRequestURI("/favicon.ico"); + + assertThat(filter(true).shouldNotFilter(request)).isTrue(); + } + + @Test + @DisplayName("skips configured public API endpoints") + void skipsPublicApiEndpoint() { + request.setMethod("POST"); + request.setRequestURI("/api/v1/auth/login"); + + assertThat(filter(true).shouldNotFilter(request)).isTrue(); + } + + @Test + @DisplayName("does not skip an arbitrary protected API endpoint") + void doesNotSkipProtectedEndpoint() { + request.setMethod("POST"); + request.setRequestURI("/api/v1/user/admin/saveUser"); + + assertThat(filter(true).shouldNotFilter(request)).isFalse(); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/OAuth2ConfigurationMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/OAuth2ConfigurationMoreTest.java new file mode 100644 index 0000000000..4ce7ba523d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/oauth2/OAuth2ConfigurationMoreTest.java @@ -0,0 +1,170 @@ +package stirling.software.proprietary.security.oauth2; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.core.user.OAuth2UserAuthority; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.Security.OAUTH2; +import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.model.exception.NoProviderFoundException; +import stirling.software.proprietary.security.service.UserService; + +/** + * Behavioural tests for {@link OAuth2Configuration}. Exercises the offline GitHub registration + * path, the no-provider failure, and the granted-authorities mapper. Issuer-discovery providers + * (OIDC / Keycloak / Google) are intentionally left unconfigured so no network discovery is + * attempted. + */ +@DisplayName("OAuth2Configuration") +class OAuth2ConfigurationMoreTest { + + private UserService userService; + + private OAuth2Configuration newConfig(ApplicationProperties props) { + userService = mock(UserService.class); + return new OAuth2Configuration(props, userService); + } + + /** Base props with OAuth2 enabled and a dummy provider name so the OIDC branch never NPEs. */ + private static ApplicationProperties enabledProps() { + ApplicationProperties props = new ApplicationProperties(); + OAUTH2 oauth2 = props.getSecurity().getOauth2(); + oauth2.setEnabled(true); + oauth2.setProvider("custom"); + oauth2.setUseAsUsername("email"); + return props; + } + + @Nested + @DisplayName("clientRegistrationRepository") + class Registrations { + + @Test + @DisplayName("registers a GitHub client from offline static endpoints") + void registersGithub() throws NoProviderFoundException { + ApplicationProperties props = enabledProps(); + OAUTH2 oauth2 = props.getSecurity().getOauth2(); + oauth2.getClient().getGithub().setClientId("gh-id"); + oauth2.getClient().getGithub().setClientSecret("gh-secret"); + + OAuth2Configuration config = newConfig(props); + ClientRegistrationRepository repo = config.clientRegistrationRepository(); + + ClientRegistration github = repo.findByRegistrationId("github"); + assertThat(github).isNotNull(); + assertThat(github.getClientId()).isEqualTo("gh-id"); + assertThat(github.getRedirectUri()).endsWith("github"); + } + + @Test + @DisplayName("throws NoProviderFoundException when no provider is configured") + void noProviderThrows() { + ApplicationProperties props = enabledProps(); + OAuth2Configuration config = newConfig(props); + assertThatThrownBy(config::clientRegistrationRepository) + .isInstanceOf(NoProviderFoundException.class); + } + + @Test + @DisplayName("skips GitHub when OAuth2 is disabled") + void disabledSkipsGithub() { + ApplicationProperties props = new ApplicationProperties(); + props.getSecurity().getOauth2().setEnabled(false); + props.getSecurity().getOauth2().getClient().getGithub().setClientId("gh-id"); + props.getSecurity().getOauth2().getClient().getGithub().setClientSecret("gh-secret"); + + OAuth2Configuration config = newConfig(props); + // Disabled => no registrations => NoProviderFoundException. + assertThatThrownBy(config::clientRegistrationRepository) + .isInstanceOf(NoProviderFoundException.class); + } + + @Test + @DisplayName("skips GitHub when its client id is blank") + void blankGithubIdSkipped() { + ApplicationProperties props = enabledProps(); + props.getSecurity().getOauth2().getClient().getGithub().setClientId(""); + props.getSecurity().getOauth2().getClient().getGithub().setClientSecret(""); + + OAuth2Configuration config = newConfig(props); + assertThatThrownBy(config::clientRegistrationRepository) + .isInstanceOf(NoProviderFoundException.class); + } + } + + @Nested + @DisplayName("userAuthoritiesMapper") + class AuthoritiesMapper { + + @Test + @DisplayName("passes through a plain granted authority") + void mapsSimpleAuthority() { + OAuth2Configuration config = newConfig(enabledProps()); + GrantedAuthoritiesMapper mapper = config.userAuthoritiesMapper(); + + var mapped = mapper.mapAuthorities(List.of(new SimpleGrantedAuthority("ROLE_X"))); + + assertThat(mapped).extracting(GrantedAuthority::getAuthority).contains("ROLE_X"); + } + + @Test + @DisplayName("adds no DB authority when the OAuth2 user is unknown") + void unknownOauthUserAddsOnlyOriginal() { + ApplicationProperties props = enabledProps(); + props.getSecurity().getOauth2().setUseAsUsername("email"); + OAuth2Configuration config = newConfig(props); + when(userService.findByUsernameIgnoreCase("nobody@example.com")) + .thenReturn(Optional.empty()); + + GrantedAuthoritiesMapper mapper = config.userAuthoritiesMapper(); + OAuth2UserAuthority oauthAuthority = + new OAuth2UserAuthority(Map.of("email", "nobody@example.com")); + + var mapped = mapper.mapAuthorities(List.of(oauthAuthority)); + + // Only the original OAUTH2 authority is present; no DB-derived Authority added. + assertThat(mapped).extracting(GrantedAuthority::getAuthority).contains("OAUTH2_USER"); + assertThat(mapped).noneMatch(a -> a instanceof Authority); + } + + @Test + @DisplayName("adds the DB role authority for a known OAuth2 user") + void knownOauthUserAddsDbAuthority() { + ApplicationProperties props = enabledProps(); + props.getSecurity().getOauth2().setUseAsUsername("email"); + OAuth2Configuration config = newConfig(props); + + User user = new User(); + user.setUsername("known@example.com"); + Authority role = new Authority("ROLE_ADMIN", user); + when(userService.findByUsernameIgnoreCase("known@example.com")) + .thenReturn(Optional.of(user)); + when(userService.findRole(user)).thenReturn(role); + + GrantedAuthoritiesMapper mapper = config.userAuthoritiesMapper(); + OAuth2UserAuthority oauthAuthority = + new OAuth2UserAuthority(Map.of("email", "known@example.com")); + + var mapped = mapper.mapAuthorities(List.of(oauthAuthority)); + + assertThat(mapped).extracting(GrantedAuthority::getAuthority).contains("ROLE_ADMIN"); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/DatabaseServiceMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/DatabaseServiceMoreTest.java new file mode 100644 index 0000000000..6bf8422ef7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/DatabaseServiceMoreTest.java @@ -0,0 +1,171 @@ +package stirling.software.proprietary.security.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; + +import javax.sql.DataSource; + +import org.apache.commons.lang3.tuple.Pair; +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.io.TempDir; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.FileInfo; +import stirling.software.proprietary.security.database.DatabaseNotificationServiceInterface; +import stirling.software.proprietary.security.model.exception.BackupNotFoundException; + +@DisplayName("DatabaseService - additional coverage") +class DatabaseServiceMoreTest { + + @TempDir Path tempDir; + + @Mock private DatabaseNotificationServiceInterface notificationService; + + private DatabaseService databaseService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + ApplicationProperties.Datasource datasourceProps = new ApplicationProperties.Datasource(); + datasourceProps.setType(ApplicationProperties.Driver.H2.name()); + + DataSource dataSource = + new DriverManagerDataSource( + "jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""); + + databaseService = new DatabaseService(datasourceProps, dataSource, notificationService); + ReflectionTestUtils.setField(databaseService, "BACKUP_DIR", tempDir); + } + + private Path writeBackup(String fileName) throws IOException { + Path backup = tempDir.resolve(fileName); + Files.writeString(backup, "CREATE TABLE T(ID INT);"); + return backup; + } + + @Nested + @DisplayName("H2 metadata") + class H2Metadata { + + @Test + @DisplayName("getH2Version reports a non-unknown version for a real H2 datasource") + void getH2Version() { + assertThat(databaseService.getH2Version()).isNotEqualTo("Unknown"); + } + } + + @Nested + @DisplayName("backup presence") + class BackupPresence { + + @Test + @DisplayName("hasBackup is true once a backup file is present") + void hasBackupTrue() throws IOException { + writeBackup("backup_202601010101.sql"); + assertThat(databaseService.hasBackup()).isTrue(); + } + } + + @Nested + @DisplayName("import") + class Import { + + @Test + @DisplayName("importDatabase throws when no backups exist") + void importThrowsWhenEmpty() { + assertThatThrownBy(() -> databaseService.importDatabase()) + .isInstanceOf(BackupNotFoundException.class); + } + + @Test + @DisplayName("importDatabaseFromUI by name notifies success") + void importByNameSuccess() throws IOException { + Path backup = writeBackup("backup_user_202601010101.sql"); + + boolean result = databaseService.importDatabaseFromUI(backup.getFileName().toString()); + + assertThat(result).isTrue(); + verify(notificationService) + .notifyImportsSuccess( + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString()); + } + + @Test + @DisplayName("importDatabaseFromUI by name fails validation for a missing file") + void importByNameMissingFile() { + // SQL validation reads the file first; a missing file surfaces as a validation failure. + assertThatThrownBy( + () -> databaseService.importDatabaseFromUI("backup_does_not_exist.sql")) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + @DisplayName("deletion") + class Deletion { + + @Test + @DisplayName("deleteLastBackup removes the final backup in the list") + void deleteLastBackup() throws IOException { + Path first = writeBackup("backup_202601010101.sql"); + Path second = writeBackup("backup_202601020202.sql"); + + List> deleted = databaseService.deleteLastBackup(); + + assertThat(deleted).hasSize(1); + assertThat(deleted.get(0).getRight()).isTrue(); + // Exactly one of the two backups should now be gone. + assertThat(Files.exists(first) && Files.exists(second)).isFalse(); + } + + @Test + @DisplayName("deleteLastBackup is a no-op with no backups") + void deleteLastBackupEmpty() { + assertThat(databaseService.deleteLastBackup()).isEmpty(); + } + + @Test + @DisplayName("deleteBackupFile removes a valid file name") + void deleteBackupFileValid() throws IOException { + writeBackup("backup_202601010101.sql"); + + boolean deleted = databaseService.deleteBackupFile("backup_202601010101.sql"); + + assertThat(deleted).isTrue(); + assertThat(Files.exists(tempDir.resolve("backup_202601010101.sql"))).isFalse(); + } + + @Test + @DisplayName("deleteBackupFile returns false for a non-existent file") + void deleteBackupFileMissing() throws IOException { + assertThat(databaseService.deleteBackupFile("backup_missing.sql")).isFalse(); + } + } + + @Nested + @DisplayName("path resolution") + class PathResolution { + + @Test + @DisplayName("getBackupFilePath resolves a safe name under the backup dir") + void resolvesSafeName() { + Path resolved = databaseService.getBackupFilePath("backup_ok.sql"); + assertThat(resolved.startsWith(tempDir)).isTrue(); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java new file mode 100644 index 0000000000..cfb0f2e885 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java @@ -0,0 +1,460 @@ +package stirling.software.proprietary.security.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.SQLException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +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.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.MessageSource; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.session.SessionInformation; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.enumeration.Role; +import stirling.software.common.model.exception.UnsupportedProviderException; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.database.repository.AuthorityRepository; +import stirling.software.proprietary.security.database.repository.PersistentLoginRepository; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.AuthenticationType; +import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.session.SessionPersistentRegistry; +import stirling.software.proprietary.storage.repository.FileShareAccessRepository; +import stirling.software.proprietary.storage.repository.FileShareRepository; +import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository; +import stirling.software.proprietary.storage.repository.StoredFileRepository; +import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository; +import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository; +import stirling.software.proprietary.workflow.service.UserServerCertificateService; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserService - additional coverage") +class UserServiceMoreTest { + + @Mock private UserRepository userRepository; + @Mock private TeamRepository teamRepository; + @Mock private AuthorityRepository authorityRepository; + @Mock private PasswordEncoder passwordEncoder; + @Mock private MessageSource messageSource; + @Mock private SessionPersistentRegistry sessionRegistry; + @Mock private DatabaseServiceInterface databaseService; + @Mock private ApplicationProperties.Security.OAUTH2 oAuth2; + @Mock private PersistentLoginRepository persistentLoginRepository; + @Mock private UserServerCertificateService userServerCertificateService; + @Mock private WorkflowParticipantRepository workflowParticipantRepository; + @Mock private WorkflowSessionRepository workflowSessionRepository; + @Mock private StoredFileRepository storedFileRepository; + @Mock private StorageCleanupEntryRepository storageCleanupEntryRepository; + @Mock private FileShareRepository fileShareRepository; + @Mock private FileShareAccessRepository fileShareAccessRepository; + + @InjectMocks private UserService userService; + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + private static User user(String username) { + User u = new User(); + u.setUsername(username); + return u; + } + + @Nested + @DisplayName("API key lookups") + class ApiKeyLookups { + + @Test + @DisplayName("getAuthentication returns token for a valid key") + void getAuthenticationValid() { + User u = user("api"); + u.addAuthority(new Authority("ROLE_USER", u)); + when(userRepository.findByApiKey("k")).thenReturn(Optional.of(u)); + + assertThat(userService.getAuthentication("k")).isNotNull(); + } + + @Test + @DisplayName("getAuthentication throws when key is unknown") + void getAuthenticationInvalid() { + when(userRepository.findByApiKey("bad")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.getAuthentication("bad")) + .isInstanceOf(UsernameNotFoundException.class); + } + + @Test + @DisplayName("isValidApiKey reflects repository presence") + void isValidApiKey() { + when(userRepository.findByApiKey("k")).thenReturn(Optional.of(user("x"))); + assertThat(userService.isValidApiKey("k")).isTrue(); + } + + @Test + @DisplayName("loadUserByApiKey returns null when absent") + void loadUserByApiKeyAbsent() { + when(userRepository.findByApiKey("missing")).thenReturn(Optional.empty()); + assertThat(userService.loadUserByApiKey("missing")).isNull(); + } + + @Test + @DisplayName("validateApiKeyForUser matches stored key") + void validateApiKeyForUser() { + User u = user("bob"); + u.setApiKey("secret"); + when(userRepository.findByUsernameIgnoreCase("bob")).thenReturn(Optional.of(u)); + + assertThat(userService.validateApiKeyForUser("bob", "secret")).isTrue(); + assertThat(userService.validateApiKeyForUser("bob", "wrong")).isFalse(); + } + + @Test + @DisplayName("getCurrentUserApiKey throws when no current user") + void getCurrentUserApiKeyNoUser() { + SecurityContextHolder.clearContext(); + assertThatThrownBy(() -> userService.getCurrentUserApiKey()) + .isInstanceOf(IllegalStateException.class); + } + } + + @Nested + @DisplayName("existence and counts") + class ExistenceAndCounts { + + @Test + @DisplayName("usernameExists true when found") + void usernameExists() { + when(userRepository.findByUsername("a")).thenReturn(Optional.of(user("a"))); + assertThat(userService.usernameExists("a")).isTrue(); + } + + @Test + @DisplayName("hasUsers excludes the internal API user from the count") + void hasUsersExcludesInternal() { + when(userRepository.count()).thenReturn(1L); + when(userRepository.findByUsernameIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) + .thenReturn(Optional.of(user("internal"))); + + assertThat(userService.hasUsers()).isFalse(); + } + + @Test + @DisplayName("getTotalUsersCount subtracts the internal API user") + void totalUsersCount() { + when(userRepository.count()).thenReturn(3L); + when(userRepository.findByUsernameIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) + .thenReturn(Optional.empty()); + + assertThat(userService.getTotalUsersCount()).isEqualTo(3L); + } + + @Test + @DisplayName("countOAuthUsers delegates to repository") + void countOAuthUsers() { + when(userRepository.countSsoUsers()).thenReturn(7L); + assertThat(userService.countOAuthUsers()).isEqualTo(7L); + } + } + + @Nested + @DisplayName("attribute mutations export the database") + class AttributeMutations { + + @Test + @DisplayName("changePassword encodes and persists") + void changePassword() throws SQLException, UnsupportedProviderException { + User u = user("p"); + when(passwordEncoder.encode("new")).thenReturn("enc"); + + userService.changePassword(u, "new"); + + assertThat(u.getPassword()).isEqualTo("enc"); + verify(userRepository).save(u); + verify(databaseService).exportDatabase(); + } + + @Test + @DisplayName("changeFirstUse persists the flag") + void changeFirstUse() throws SQLException, UnsupportedProviderException { + User u = user("p"); + + userService.changeFirstUse(u, false); + + assertThat(u.isFirstLogin()).isFalse(); + verify(userRepository).save(u); + } + + @Test + @DisplayName("changeUserEnabled persists the flag") + void changeUserEnabled() throws SQLException, UnsupportedProviderException { + User u = user("p"); + + userService.changeUserEnabled(u, true); + + assertThat(u.isEnabled()).isTrue(); + verify(userRepository).save(u); + } + + @Test + @DisplayName("changeRole updates the authority") + void changeRole() throws SQLException, UnsupportedProviderException { + User u = user("p"); + u.setId(5L); + Authority authority = new Authority("ROLE_USER", u); + when(authorityRepository.findByUserId(5L)).thenReturn(authority); + + userService.changeRole(u, "ROLE_ADMIN"); + + assertThat(authority.getAuthority()).isEqualTo("ROLE_ADMIN"); + verify(authorityRepository).save(authority); + } + + @Test + @DisplayName("changeUsername rejects an invalid new username") + void changeUsernameInvalid() { + when(messageSource.getMessage(any(), any(), any())).thenReturn("bad"); + + assertThatThrownBy(() -> userService.changeUsername(user("p"), "ALL_USERS")) + .isInstanceOf(IllegalArgumentException.class); + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("changeUserTeam falls back to the default team for null") + void changeUserTeamNullUsesDefault() throws SQLException, UnsupportedProviderException { + User u = user("p"); + Team defaultTeam = new Team(); + defaultTeam.setName("Default"); + when(teamRepository.findByName("Default")).thenReturn(Optional.of(defaultTeam)); + + userService.changeUserTeam(u, null); + + assertThat(u.getTeam()).isSameAs(defaultTeam); + } + } + + @Nested + @DisplayName("authentication type and password helpers") + class AuthTypeHelpers { + + @Test + @DisplayName("isPasswordCorrect delegates to the encoder") + void isPasswordCorrect() { + User u = user("p"); + u.setPassword("hash"); + when(passwordEncoder.matches("plain", "hash")).thenReturn(true); + + assertThat(userService.isPasswordCorrect(u, "plain")).isTrue(); + } + + @Test + @DisplayName("isSsoAuthenticationTypeByUsername true for OAUTH2") + void isSsoTrueForOauth() { + User u = user("p"); + u.setAuthenticationType(AuthenticationType.OAUTH2); + when(userRepository.findByUsernameIgnoreCase("p")).thenReturn(Optional.of(u)); + + assertThat(userService.isSsoAuthenticationTypeByUsername("p")).isTrue(); + } + + @Test + @DisplayName("isSsoAuthenticationTypeByUsername false for WEB") + void isSsoFalseForWeb() { + User u = user("p"); + u.setAuthenticationType(AuthenticationType.WEB); + when(userRepository.findByUsernameIgnoreCase("p")).thenReturn(Optional.of(u)); + + assertThat(userService.isSsoAuthenticationTypeByUsername("p")).isFalse(); + } + + @Test + @DisplayName("isAuthenticationTypeByUsername matches the stored type") + void isAuthenticationTypeByUsername() { + User u = user("p"); + u.setAuthenticationType(AuthenticationType.WEB); + when(userRepository.findByUsernameIgnoreCase("p")).thenReturn(Optional.of(u)); + + assertThat(userService.isAuthenticationTypeByUsername("p", AuthenticationType.WEB)) + .isTrue(); + } + + @Test + @DisplayName("isUserDisabled true when user is disabled") + void isUserDisabled() { + User u = user("p"); + u.setEnabled(false); + when(userRepository.findByUsernameIgnoreCase("p")).thenReturn(Optional.of(u)); + + assertThat(userService.isUserDisabled("p")).isTrue(); + } + + @Test + @DisplayName("hasPassword false when user is absent") + void hasPasswordAbsent() { + when(userRepository.findByUsernameIgnoreCase("p")).thenReturn(Optional.empty()); + assertThat(userService.hasPassword("p")).isFalse(); + } + } + + @Nested + @DisplayName("current user resolution") + class CurrentUser { + + @Test + @DisplayName("getCurrentUsername reads the string principal") + void getCurrentUsernameString() { + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken("alice", null, List.of()); + SecurityContextHolder.getContext().setAuthentication(auth); + + assertThat(userService.getCurrentUsername()).isEqualTo("alice"); + } + + @Test + @DisplayName("isCurrentUserAdmin true when the admin authority is present") + void isCurrentUserAdminTrue() { + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken( + "admin", + null, + List.of(new SimpleGrantedAuthority(Role.ADMIN.getRoleId()))); + SecurityContextHolder.getContext().setAuthentication(auth); + + assertThat(userService.isCurrentUserAdmin()).isTrue(); + } + + @Test + @DisplayName("isCurrentUserAdmin false for anonymous principal") + void isCurrentUserAdminAnonymous() { + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken("anonymousUser", null, List.of()); + SecurityContextHolder.getContext().setAuthentication(auth); + + assertThat(userService.isCurrentUserAdmin()).isFalse(); + } + } + + @Nested + @DisplayName("settings and sessions") + class SettingsAndSessions { + + @Test + @DisplayName("updateUserSettings replaces the settings map and exports") + void updateUserSettings() throws SQLException, UnsupportedProviderException { + User u = user("p"); + when(userRepository.findByUsernameIgnoreCaseWithSettings("p")) + .thenReturn(Optional.of(u)); + + userService.updateUserSettings("p", Map.of("theme", "dark")); + + assertThat(u.getSettings()).containsEntry("theme", "dark"); + verify(userRepository).save(u); + verify(databaseService).exportDatabase(); + } + + @Test + @DisplayName("invalidateUserSessions expires sessions for the matching principal") + void invalidateUserSessions() { + when(sessionRegistry.getAllPrincipals()).thenReturn(List.of("p")); + SessionInformation info = new SessionInformation("p", "sess-9", new java.util.Date()); + when(sessionRegistry.getAllSessions("p", false)).thenReturn(List.of(info)); + + userService.invalidateUserSessions("p"); + + verify(sessionRegistry).expireSession("sess-9"); + } + } + + @Nested + @DisplayName("grandfathering and custom API user") + class Grandfathering { + + @Test + @DisplayName("grandfatherAllOAuthUsers marks and saves unflagged users") + void grandfatherAllOAuthUsers() { + User flagged = user("a"); + flagged.setOauthGrandfathered(true); + User unflagged = user("b"); + unflagged.setOauthGrandfathered(false); + when(userRepository.findAllSsoUsers()).thenReturn(List.of(flagged, unflagged)); + + int updated = userService.grandfatherAllOAuthUsers(); + + assertThat(updated).isEqualTo(1); + assertThat(unflagged.isOauthGrandfathered()).isTrue(); + verify(userRepository).saveAll(any()); + } + + @Test + @DisplayName("grandfatherAllOAuthUsers skips persistence when nothing changed") + void grandfatherAllOAuthUsersNoChange() { + User flagged = user("a"); + flagged.setOauthGrandfathered(true); + when(userRepository.findAllSsoUsers()).thenReturn(List.of(flagged)); + + assertThat(userService.grandfatherAllOAuthUsers()).isZero(); + verify(userRepository, never()).saveAll(any()); + } + + @Test + @DisplayName("syncCustomApiUser is a no-op for a blank key") + void syncCustomApiUserBlank() { + userService.syncCustomApiUser(" "); + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("syncCustomApiUser creates the user when missing") + void syncCustomApiUserCreates() { + when(userRepository.findByUsernameIgnoreCase("CUSTOM_API_USER")) + .thenReturn(Optional.empty()); + + userService.syncCustomApiUser("custom-key"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + assertThat(captor.getValue().getApiKey()).isEqualTo("custom-key"); + assertThat(captor.getValue().getUsername()).isEqualTo("CUSTOM_API_USER"); + } + + @Test + @DisplayName("refreshApiKeyForUser regenerates and persists") + void refreshApiKeyForUser() { + User u = user("r"); + u.setApiKey("old"); + when(userRepository.findByUsernameIgnoreCase("r")).thenReturn(Optional.of(u)); + when(userRepository.findByApiKey(any())).thenReturn(Optional.empty()); + when(userRepository.save(any(User.class))) + .thenAnswer(inv -> inv.getArgument(0, User.class)); + + User updated = userService.refreshApiKeyForUser("r"); + + assertThat(updated.getApiKey()).isNotEqualTo("old"); + assertThat(UUID.fromString(updated.getApiKey())).isNotNull(); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceMoreTest.java new file mode 100644 index 0000000000..cc8388b8c2 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceMoreTest.java @@ -0,0 +1,465 @@ +package stirling.software.proprietary.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +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.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.FileStorage; +import stirling.software.common.service.InternalApiClient; +import stirling.software.common.service.ToolMetadataService; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; +import stirling.software.proprietary.model.api.ai.AiWorkflowFileInput; +import stirling.software.proprietary.model.api.ai.AiWorkflowOutcome; +import stirling.software.proprietary.model.api.ai.AiWorkflowRequest; +import stirling.software.proprietary.model.api.ai.AiWorkflowResponse; +import stirling.software.proprietary.policy.engine.PolicyExecutor; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Gap-coverage tests for {@link AiWorkflowService}: terminal outcomes, guard rails (empty/unknown + * files, retry loops), stream-event handling, downstream entitlement (PAYG) mapping, and HTTP error + * detail extraction. Complements {@code AiWorkflowServiceTest}. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("AiWorkflowService (gaps)") +class AiWorkflowServiceMoreTest { + + private static final String ROTATE_ENDPOINT = "/api/v1/general/rotate-pdf"; + private static final String ORCHESTRATOR = "/api/v1/orchestrator"; + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private AiEngineClient aiEngineClient; + @Mock private PdfContentExtractor pdfContentExtractor; + @Mock private InternalApiClient internalApiClient; + @Mock private FileStorage fileStorage; + @Mock private ToolMetadataService toolMetadataService; + @Mock private FileIdStrategy fileIdStrategy; + @Mock private AiEngineEndpointResolver endpointResolver; + + @TempDir Path tempDir; + + private ObjectMapper objectMapper; + private AiWorkflowService service; + + @BeforeEach + void setUp() throws IOException { + ApplicationProperties props = new ApplicationProperties(); + props.getSystem().getTempFileManagement().setBaseTmpDir(tempDir.toString()); + props.getSystem().getTempFileManagement().setPrefix("ai-more-"); + TempFileManager tempFileManager = new TempFileManager(new TempFileRegistry(), props); + objectMapper = JsonMapper.builder().build(); + + lenient() + .when(fileIdStrategy.idFor(any(MultipartFile.class))) + .thenAnswer(inv -> ((MultipartFile) inv.getArgument(0)).getOriginalFilename()); + lenient().when(endpointResolver.getEnabledEndpointUrls()).thenReturn(List.of()); + + PolicyExecutor policyExecutor = + new PolicyExecutor( + internalApiClient, toolMetadataService, tempFileManager, objectMapper); + service = + new AiWorkflowService( + pdfDocumentFactory, + aiEngineClient, + pdfContentExtractor, + objectMapper, + fileStorage, + tempFileManager, + fileIdStrategy, + endpointResolver, + policyExecutor, + null, + new ApplicationProperties()); + } + + @Nested + @DisplayName("request validation") + class Validation { + + @Test + @DisplayName("throws when an uploaded file is empty") + void emptyFileThrows() { + MockMultipartFile empty = + new MockMultipartFile("fileInput", "empty.pdf", "application/pdf", new byte[0]); + assertThatThrownBy(() -> service.orchestrate(requestFor(empty, "do it"))) + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Nested + @DisplayName("terminal outcomes pass straight through") + class TerminalOutcomes { + + @Test + @DisplayName("ANSWER returns the engine response unchanged") + void answerOutcome() throws IOException { + stubOrchestrator("{\"outcome\":\"answer\",\"answer\":\"42\"}"); + AiWorkflowResponse result = + service.orchestrate(requestFor(pdf("a.pdf", "x"), "question")); + assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.ANSWER); + assertThat(result.getAnswer()).isEqualTo("42"); + } + + @Test + @DisplayName("NEED_CLARIFICATION is terminal") + void needClarificationOutcome() throws IOException { + stubOrchestrator("{\"outcome\":\"need_clarification\",\"question\":\"which page?\"}"); + AiWorkflowResponse result = service.orchestrate(requestFor(pdf("a.pdf", "x"), "vague")); + assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.NEED_CLARIFICATION); + } + + @Test + @DisplayName("CANNOT_DO is terminal") + void cannotDoOutcome() throws IOException { + stubOrchestrator("{\"outcome\":\"cannot_do\",\"reason\":\"no\"}"); + AiWorkflowResponse result = + service.orchestrate(requestFor(pdf("a.pdf", "x"), "impossible")); + assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_DO); + } + + @Test + @DisplayName("NOT_FOUND is terminal") + void notFoundOutcome() throws IOException { + stubOrchestrator("{\"outcome\":\"not_found\"}"); + AiWorkflowResponse result = + service.orchestrate(requestFor(pdf("a.pdf", "x"), "missing")); + assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.NOT_FOUND); + } + } + + @Nested + @DisplayName("generate_file guards") + class GenerateFileGuards { + + @Test + @DisplayName("missing content/filename falls back to CANNOT_CONTINUE") + void missingContent() throws IOException { + stubOrchestrator("{\"outcome\":\"generate_file\",\"summary\":\"s\"}"); + AiWorkflowResponse result = service.orchestrate(requestFor(pdf("a.pdf", "x"), "gen")); + assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE); + verify(internalApiClient, never()).post(anyString(), any()); + } + } + + @Nested + @DisplayName("need_content guards") + class NeedContentGuards { + + @Test + @DisplayName("unknown requested file id surfaces a clear CANNOT_CONTINUE") + void unknownFileId() throws IOException { + stubOrchestrator( + """ + {"outcome":"need_content","files":[{"file":{"id":"ghost","name":"ghost.pdf"}}]} + """); + AiWorkflowResponse result = + service.orchestrate(requestFor(pdf("real.pdf", "x"), "extract")); + assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE); + assertThat(result.getReason()).contains("ghost.pdf"); + } + } + + @Nested + @DisplayName("need_ingest guards") + class NeedIngestGuards { + + @Test + @DisplayName("empty filesToIngest yields CANNOT_CONTINUE") + void emptyIngestList() throws IOException { + stubOrchestrator("{\"outcome\":\"need_ingest\",\"reason\":\"r\",\"filesToIngest\":[]}"); + AiWorkflowResponse result = + service.orchestrate(requestFor(pdf("a.pdf", "x"), "ingest")); + assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE); + } + + @Test + @DisplayName("ingest for an unknown file id yields CANNOT_CONTINUE") + void unknownIngestFile() throws IOException { + stubOrchestrator( + """ + {"outcome":"need_ingest","resumeWith":"q", + "filesToIngest":[{"id":"nope","name":"nope.pdf"}]} + """); + AiWorkflowResponse result = + service.orchestrate(requestFor(pdf("a.pdf", "x"), "ingest")); + assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE); + assertThat(result.getReason()).contains("nope.pdf"); + } + } + + @Nested + @DisplayName("convert_markdown guards") + class ConvertMarkdownGuards { + + @Test + @DisplayName("no files listed yields CANNOT_CONTINUE") + void noFiles() throws IOException { + stubOrchestrator("{\"outcome\":\"convert_markdown\",\"filesToIngest\":[]}"); + AiWorkflowResponse result = service.orchestrate(requestFor(pdf("a.pdf", "x"), "to md")); + assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE); + } + + @Test + @DisplayName("unknown file id yields CANNOT_CONTINUE") + void unknownFile() throws IOException { + when(fileIdStrategy.idFor(any())).thenReturn("real-id"); + stubOrchestrator( + """ + {"outcome":"convert_markdown", + "filesToIngest":[{"id":"other-id","name":"other.pdf"}]} + """); + AiWorkflowResponse result = + service.orchestrate(requestFor(pdf("real.pdf", "x"), "to md")); + assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE); + assertThat(result.getReason()).contains("other.pdf"); + } + } + + @Nested + @DisplayName("plan guards and errors") + class PlanGuardsAndErrors { + + @Test + @DisplayName("empty steps list yields CANNOT_CONTINUE") + void emptySteps() throws IOException { + stubOrchestrator("{\"outcome\":\"plan\",\"summary\":\"s\",\"steps\":[]}"); + AiWorkflowResponse result = service.orchestrate(requestFor(pdf("a.pdf", "x"), "plan")); + assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE); + } + + @Test + @DisplayName("a step with no tool endpoint yields CANNOT_CONTINUE") + void stepWithoutTool() throws IOException { + stubOrchestrator( + "{\"outcome\":\"plan\",\"summary\":\"s\",\"steps\":[{\"parameters\":{}}]}"); + AiWorkflowResponse result = service.orchestrate(requestFor(pdf("a.pdf", "x"), "plan")); + assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE); + assertThat(result.getReason()).contains("step 1"); + } + + @Test + @DisplayName("HttpServerErrorException detail is surfaced from the JSON body") + void httpServerErrorDetailSurfaced() throws IOException { + stubOrchestrator( + """ + {"outcome":"plan","summary":"s", + "steps":[{"tool":"%s","parameters":{}}]} + """ + .formatted(ROTATE_ENDPOINT)); + when(toolMetadataService.isMultiInput(ROTATE_ENDPOINT)).thenReturn(false); + HttpServerErrorException boom = + HttpServerErrorException.create( + HttpStatus.INTERNAL_SERVER_ERROR, + "err", + org.springframework.http.HttpHeaders.EMPTY, + "{\"detail\":\"Ghostscript is not installed\"}" + .getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8); + when(internalApiClient.post(eq(ROTATE_ENDPOINT), any())).thenThrow(boom); + + AiWorkflowResponse result = service.orchestrate(requestFor(pdf("a.pdf", "x"), "plan")); + assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE); + assertThat(result.getReason()).isEqualTo("Ghostscript is not installed"); + } + } + + @Nested + @DisplayName("downstream entitlement (PAYG) mapping") + class PaygMapping { + + @Test + @DisplayName("a 402 PAYG_LIMIT_REACHED tool error becomes a structured CANNOT_CONTINUE") + void paygLimitMappedFromToolCall() throws IOException { + stubOrchestrator( + """ + {"outcome":"tool_call","tool":"%s","parameters":{},"rationale":"r"} + """ + .formatted(ROTATE_ENDPOINT)); + when(toolMetadataService.isMultiInput(ROTATE_ENDPOINT)).thenReturn(false); + HttpClientErrorException payg = + HttpClientErrorException.create( + HttpStatus.PAYMENT_REQUIRED, + "Payment Required", + org.springframework.http.HttpHeaders.EMPTY, + "{\"error\":\"PAYG_LIMIT_REACHED\",\"subscribed\":false}" + .getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8); + when(internalApiClient.post(eq(ROTATE_ENDPOINT), any())).thenThrow(payg); + + AiWorkflowResponse result = + service.orchestrate(requestFor(pdf("a.pdf", "x"), "rotate")); + + assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE); + assertThat(result.getErrorCode()).isEqualTo("PAYG_LIMIT_REACHED"); + assertThat(result.getErrorSubscribed()).isFalse(); + } + } + + @Nested + @DisplayName("orchestrator stream handling") + class StreamHandling { + + @Test + @DisplayName("an error event surfaces as an IOException") + void errorEventThrows() throws IOException { + doAnswer( + inv -> { + Consumer consumer = inv.getArgument(3); + consumer.accept( + "{\"event\":\"error\",\"message\":\"engine exploded\"}"); + return null; + }) + .when(aiEngineClient) + .streamPost(eq(ORCHESTRATOR), anyString(), nullable(String.class), any()); + + assertThatThrownBy(() -> service.orchestrate(requestFor(pdf("a.pdf", "x"), "go"))) + .isInstanceOf(IOException.class) + .hasMessageContaining("engine exploded"); + } + + @Test + @DisplayName("a stream that ends without a result throws an IOException") + void noResultThrows() throws IOException { + doAnswer(inv -> null) + .when(aiEngineClient) + .streamPost(eq(ORCHESTRATOR), anyString(), nullable(String.class), any()); + + assertThatThrownBy(() -> service.orchestrate(requestFor(pdf("a.pdf", "x"), "go"))) + .isInstanceOf(IOException.class) + .hasMessageContaining("without a result"); + } + + @Test + @DisplayName("progress and heartbeat events are forwarded then the result is returned") + void progressAndHeartbeatForwarded() throws IOException { + List phases = new ArrayList<>(); + int[] heartbeats = {0}; + doAnswer( + inv -> { + Consumer consumer = inv.getArgument(3); + consumer.accept( + "{\"event\":\"progress\",\"phase\":\"whole_doc_read_started\"," + + "\"question\":\"q\",\"pages\":3,\"slices\":1}"); + consumer.accept("{\"event\":\"heartbeat\"}"); + consumer.accept("{\"event\":\"mystery\"}"); + consumer.accept( + wrapAsResultEvent( + "{\"outcome\":\"answer\",\"answer\":\"ok\"}")); + return null; + }) + .when(aiEngineClient) + .streamPost(eq(ORCHESTRATOR), anyString(), nullable(String.class), any()); + + AiWorkflowService.ProgressListener listener = + new AiWorkflowService.ProgressListener() { + @Override + public void onProgress( + stirling.software.proprietary.model.api.ai.AiWorkflowProgressEvent + event) { + phases.add(String.valueOf(event.getPhase())); + } + + @Override + public void onHeartbeat() { + heartbeats[0]++; + } + }; + + AiWorkflowResponse result = + service.orchestrate(requestFor(pdf("a.pdf", "x"), "go"), listener); + + assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.ANSWER); + assertThat(heartbeats[0]).isEqualTo(1); + assertThat(phases).isNotEmpty(); + } + + @Test + @DisplayName("a malformed (non-JSON) stream line is skipped without aborting") + void malformedLineSkipped() throws IOException { + doAnswer( + inv -> { + Consumer consumer = inv.getArgument(3); + consumer.accept("this is not json {"); + consumer.accept( + wrapAsResultEvent( + "{\"outcome\":\"answer\",\"answer\":\"ok\"}")); + return null; + }) + .when(aiEngineClient) + .streamPost(eq(ORCHESTRATOR), anyString(), nullable(String.class), any()); + + AiWorkflowResponse result = service.orchestrate(requestFor(pdf("a.pdf", "x"), "go")); + assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.ANSWER); + } + } + + // --- helpers (mirrors AiWorkflowServiceTest) --- + + private void stubOrchestrator(String responseJson) throws IOException { + doAnswer( + inv -> { + Consumer consumer = inv.getArgument(3); + consumer.accept(wrapAsResultEvent(responseJson)); + return null; + }) + .when(aiEngineClient) + .streamPost(eq(ORCHESTRATOR), anyString(), nullable(String.class), any()); + } + + private String wrapAsResultEvent(String responseJson) throws IOException { + return objectMapper + .createObjectNode() + .put("event", "result") + .set("response", objectMapper.readTree(responseJson)) + .toString(); + } + + private static MockMultipartFile pdf(String filename, String content) { + return new MockMultipartFile("fileInput", filename, "application/pdf", content.getBytes()); + } + + private static AiWorkflowRequest requestFor(MockMultipartFile file, String message) { + AiWorkflowRequest request = new AiWorkflowRequest(); + List inputs = new ArrayList<>(); + AiWorkflowFileInput fileInput = new AiWorkflowFileInput(); + fileInput.setFileInput(file); + inputs.add(fileInput); + request.setFileInputs(inputs); + request.setUserMessage(message); + return request; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java new file mode 100644 index 0000000000..321d0975d4 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java @@ -0,0 +1,723 @@ +package stirling.software.proprietary.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.Signature; +import org.aspectj.lang.reflect.MethodSignature; +import org.junit.jupiter.api.AfterEach; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.MDC; +import org.springframework.boot.actuate.audit.AuditEvent; +import org.springframework.boot.actuate.audit.AuditEventRepository; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import jakarta.servlet.http.HttpServletResponse; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.audit.AuditLevel; +import stirling.software.proprietary.audit.Audited; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.security.service.JwtServiceInterface; + +@ExtendWith(MockitoExtension.class) +class AuditServiceTest { + + @Mock private AuditEventRepository repository; + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private JwtServiceInterface jwtService; + + private AuditConfigurationProperties auditConfig; + private AuditService service; + + @BeforeEach + void setUp() { + auditConfig = new AuditConfigurationProperties(new ApplicationProperties()); + service = new AuditService(repository, auditConfig, true, pdfDocumentFactory, jwtService); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + RequestContextHolder.resetRequestAttributes(); + MDC.clear(); + } + + private AuditConfigurationProperties config(boolean enabled, int level) { + ApplicationProperties props = new ApplicationProperties(); + var audit = props.getPremium().getEnterpriseFeatures().getAudit(); + audit.setEnabled(enabled); + audit.setLevel(level); + return new AuditConfigurationProperties(props); + } + + private void authenticateAs(String username) { + // 3-arg ctor marks the token authenticated (2-arg leaves it unauthenticated) + Authentication auth = + new UsernamePasswordAuthenticationToken(username, null, java.util.List.of()); + SecurityContextHolder.getContext().setAuthentication(auth); + } + + private void bindRequest(MockHttpServletRequest request) { + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + @Nested + @DisplayName("audit() gating") + class AuditGating { + + @Test + @DisplayName("records event when enabled, level included and EE") + void recordsWhenEnabled() { + authenticateAs("alice"); + + service.audit(AuditEventType.USER_LOGIN, new HashMap<>(), AuditLevel.BASIC); + + verify(repository).add(any(AuditEvent.class)); + } + + @Test + @DisplayName("skips when not running EE") + void skipsWhenNotEE() { + AuditService nonEe = + new AuditService( + repository, auditConfig, false, pdfDocumentFactory, jwtService); + + nonEe.audit(AuditEventType.USER_LOGIN, new HashMap<>(), AuditLevel.BASIC); + + verify(repository, never()).add(any(AuditEvent.class)); + } + + @Test + @DisplayName("skips when audit disabled") + void skipsWhenDisabled() { + AuditService disabled = + new AuditService( + repository, config(false, 2), true, pdfDocumentFactory, jwtService); + + disabled.audit(AuditEventType.USER_LOGIN, new HashMap<>(), AuditLevel.BASIC); + + verify(repository, never()).add(any(AuditEvent.class)); + } + + @Test + @DisplayName("skips when required level exceeds configured level") + void skipsWhenLevelTooHigh() { + // STANDARD config does not include VERBOSE + service.audit(AuditEventType.USER_LOGIN, new HashMap<>(), AuditLevel.VERBOSE); + + verify(repository, never()).add(any(AuditEvent.class)); + } + + @Test + @DisplayName("default-level overload uses STANDARD and enriches origin") + void defaultLevelEnrichesOrigin() { + authenticateAs("alice"); + + service.audit(AuditEventType.USER_LOGIN, new HashMap<>()); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(AuditEvent.class); + verify(repository).add(captor.capture()); + assertThat(captor.getValue().getData()).containsKey("__origin"); + assertThat(captor.getValue().getPrincipal()).isEqualTo("alice"); + } + + @Test + @DisplayName("string-type overload records with provided type name") + void stringTypeOverload() { + authenticateAs("alice"); + + service.audit("CUSTOM_EVENT", new HashMap<>()); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(AuditEvent.class); + verify(repository).add(captor.capture()); + assertThat(captor.getValue().getType()).isEqualTo("CUSTOM_EVENT"); + } + + @Test + @DisplayName("explicit-principal overload bypasses SecurityContext") + void explicitPrincipalOverload() { + service.audit("bob", AuditEventType.USER_LOGIN, new HashMap<>()); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(AuditEvent.class); + verify(repository).add(captor.capture()); + assertThat(captor.getValue().getPrincipal()).isEqualTo("bob"); + } + + @Test + @DisplayName("pre-captured principal/origin/ip overload adds ip to data") + void preCapturedOverloadAddsIp() { + service.audit( + "carol", + "WEB", + "10.0.0.1", + AuditEventType.PDF_PROCESS, + new HashMap<>(), + AuditLevel.BASIC); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(AuditEvent.class); + verify(repository).add(captor.capture()); + assertThat(captor.getValue().getData()).containsEntry("__ipAddress", "10.0.0.1"); + assertThat(captor.getValue().getData()).containsEntry("__origin", "WEB"); + } + + @Test + @DisplayName("pre-captured string-type overload skips ip when null") + void preCapturedStringOverloadNullIp() { + service.audit("carol", "API", null, "CUSTOM", new HashMap<>(), AuditLevel.BASIC); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(AuditEvent.class); + verify(repository).add(captor.capture()); + assertThat(captor.getValue().getData()).doesNotContainKey("__ipAddress"); + assertThat(captor.getValue().getType()).isEqualTo("CUSTOM"); + } + } + + @Nested + @DisplayName("createBaseAuditData") + class CreateBaseAuditData { + + @Test + @DisplayName("prefers MDC principal over SecurityContext") + void prefersMdcPrincipal() { + MDC.put("auditPrincipal", "mdcUser"); + authenticateAs("ctxUser"); + ProceedingJoinPoint jp = joinPoint(); + + Map data = service.createBaseAuditData(jp, AuditLevel.BASIC); + + assertThat(data).containsEntry("principal", "mdcUser"); + assertThat(data).containsKey("timestamp"); + assertThat(data).doesNotContainKey("className"); + } + + @Test + @DisplayName("falls back to system when no principal anywhere") + void fallsBackToSystem() { + ProceedingJoinPoint jp = joinPoint(); + + Map data = service.createBaseAuditData(jp, AuditLevel.BASIC); + + assertThat(data).containsEntry("principal", "system"); + } + + @Test + @DisplayName("VERBOSE level adds class and method names") + void verboseAddsClassAndMethod() { + ProceedingJoinPoint jp = joinPoint(); + + Map data = service.createBaseAuditData(jp, AuditLevel.VERBOSE); + + assertThat(data).containsKey("className"); + assertThat(data).containsEntry("methodName", "sample"); + } + } + + @Nested + @DisplayName("addHttpData") + class AddHttpData { + + @Test + @DisplayName("returns early when method or path null") + void earlyReturnOnNull() { + Map data = new HashMap<>(); + + service.addHttpData(data, null, "/x", AuditLevel.STANDARD); + + assertThat(data).isEmpty(); + } + + @Test + @DisplayName("adds basic http data and stops when no request context") + void noRequestContext() { + Map data = new HashMap<>(); + + service.addHttpData(data, "GET", "/api/v1/x", AuditLevel.STANDARD); + + assertThat(data).containsEntry("httpMethod", "GET"); + assertThat(data).containsEntry("path", "/api/v1/x"); + assertThat(data).doesNotContainKey("clientIp"); + } + + @Test + @DisplayName("STANDARD level captures client IP and form params for POST") + void standardCapturesIpAndForm() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("POST"); + request.setContentType("application/x-www-form-urlencoded"); + request.addParameter("name", "value"); + request.addParameter("_csrf", "secret"); + bindRequest(request); + + Map data = new HashMap<>(); + service.addHttpData(data, "POST", "/api/v1/x", AuditLevel.STANDARD); + + assertThat(data).containsKey("clientIp"); + @SuppressWarnings("unchecked") + Map form = (Map) data.get("formParams"); + assertThat(form).containsKey("name"); + // CSRF token must be stripped from logged params + assertThat(form).doesNotContainKey("_csrf"); + } + } + + @Nested + @DisplayName("safeToString") + class SafeToString { + + @Test + @DisplayName("null becomes literal null") + void nullValue() { + assertThat(service.safeToString(null, 100)).isEqualTo("null"); + } + + @Test + @DisplayName("string passthrough and truncation with ellipsis") + void stringTruncation() { + assertThat(service.safeToString("hello", 100)).isEqualTo("hello"); + String result = service.safeToString("abcdefghij", 6); + assertThat(result).endsWith("..."); + assertThat(result).hasSize(6); + } + + @Test + @DisplayName("byte arrays render as binary length marker") + void byteArray() { + assertThat(service.safeToString(new byte[] {1, 2, 3}, 100)) + .isEqualTo("[binary data length=3]"); + } + + @Test + @DisplayName("numbers and booleans use toString") + void numbersAndBooleans() { + assertThat(service.safeToString(42, 100)).isEqualTo("42"); + assertThat(service.safeToString(true, 100)).isEqualTo("true"); + } + + @Test + @DisplayName("toString failure returns class marker") + void toStringFailure() { + Object boom = + new Object() { + @Override + public String toString() { + throw new IllegalStateException("nope"); + } + }; + + assertThat(service.safeToString(boom, 100)).contains("toString() failed"); + } + } + + @Nested + @DisplayName("shouldAudit / getEffectiveAuditLevel") + class ShouldAudit { + + @Test + @DisplayName("false when not running EE") + void notEe() throws Exception { + AuditService nonEe = + new AuditService( + repository, auditConfig, false, pdfDocumentFactory, jwtService); + + assertThat(nonEe.shouldAudit(sampleMethod(), auditConfig)).isFalse(); + } + + @Test + @DisplayName("false when audit disabled") + void disabled() throws Exception { + assertThat(service.shouldAudit(sampleMethod(), config(false, 2))).isFalse(); + } + + @Test + @DisplayName("true for BASIC method at STANDARD config") + void enabledBasicMethod() throws Exception { + assertThat(service.shouldAudit(sampleMethod(), auditConfig)).isTrue(); + } + + @Test + @DisplayName("effective level defaults to provided when unannotated") + void effectiveLevelDefault() throws Exception { + AuditLevel level = + service.getEffectiveAuditLevel(sampleMethod(), AuditLevel.BASIC, auditConfig); + + assertThat(level).isEqualTo(AuditLevel.BASIC); + } + } + + @Nested + @DisplayName("addTimingData") + class AddTimingData { + + @Test + @DisplayName("non-http call adds latency and status code") + void nonHttpAddsLatency() { + HttpServletResponse response = + new org.springframework.mock.web.MockHttpServletResponse(); + ((org.springframework.mock.web.MockHttpServletResponse) response).setStatus(200); + Map data = new HashMap<>(); + + service.addTimingData( + data, System.currentTimeMillis() - 5, response, AuditLevel.STANDARD, false); + + assertThat(data).containsKey("latencyMs"); + assertThat(data).containsEntry("statusCode", 200); + } + + @Test + @DisplayName("http request skips latency here") + void httpSkipsLatency() { + Map data = new HashMap<>(); + + service.addTimingData( + data, System.currentTimeMillis(), null, AuditLevel.STANDARD, true); + + assertThat(data).doesNotContainKey("latencyMs"); + } + + @Test + @DisplayName("below STANDARD level adds nothing") + void belowStandard() { + Map data = new HashMap<>(); + + service.addTimingData(data, System.currentTimeMillis(), null, AuditLevel.BASIC, false); + + assertThat(data).isEmpty(); + } + } + + @Nested + @DisplayName("resolveEventType") + class ResolveEventType { + + @Test + @DisplayName("explicit annotation type wins") + void annotationWins() throws Exception { + Audited annotation = annotationWith(AuditEventType.USER_LOGIN); + + AuditEventType type = + service.resolveEventType( + sampleMethod(), SampleController.class, "/x", "POST", annotation); + + assertThat(type).isEqualTo(AuditEventType.USER_LOGIN); + } + + @Test + @DisplayName("GET ui-data endpoint resolves to UI_DATA") + void getUiData() throws Exception { + AuditEventType type = + service.resolveEventType( + sampleMethod(), + SampleController.class, + "/api/v1/ui-data/foo", + "GET", + null); + + assertThat(type).isEqualTo(AuditEventType.UI_DATA); + } + + @Test + @DisplayName("GET non-ui endpoint resolves to HTTP_REQUEST") + void getHttpRequest() throws Exception { + AuditEventType type = + service.resolveEventType( + sampleMethod(), SampleController.class, "/api/v1/merge", "GET", null); + + assertThat(type).isEqualTo(AuditEventType.HTTP_REQUEST); + } + + @Test + @DisplayName("settings path resolves to SETTINGS_CHANGED") + void settingsPath() throws Exception { + AuditEventType type = + service.resolveEventType( + sampleMethod(), SampleController.class, "/settings/x", "POST", null); + + assertThat(type).isEqualTo(AuditEventType.SETTINGS_CHANGED); + } + + @Test + @DisplayName("non-http defaults to PDF_PROCESS") + void defaultPdfProcess() throws Exception { + AuditEventType type = + service.resolveEventType( + sampleMethod(), SampleController.class, null, null, null); + + assertThat(type).isEqualTo(AuditEventType.PDF_PROCESS); + } + } + + @Nested + @DisplayName("determineAuditEventType") + class DetermineAuditEventType { + + @Test + @DisplayName("GET resolves to HTTP_REQUEST") + void getRequest() throws Exception { + AuditEventType type = + service.determineAuditEventType( + sampleMethod(), SampleController.class, "/anything", "GET"); + + assertThat(type).isEqualTo(AuditEventType.HTTP_REQUEST); + } + + @Test + @DisplayName("user path resolves to USER_PROFILE_UPDATE") + void userPath() throws Exception { + AuditEventType type = + service.determineAuditEventType( + sampleMethod(), SampleController.class, "/user/edit", "POST"); + + assertThat(type).isEqualTo(AuditEventType.USER_PROFILE_UPDATE); + } + + @Test + @DisplayName("upload path matches file-operation pattern") + void uploadPath() throws Exception { + AuditEventType type = + service.determineAuditEventType( + sampleMethod(), SampleController.class, "/api/upload/file", "POST"); + + assertThat(type).isEqualTo(AuditEventType.FILE_OPERATION); + } + + @Test + @DisplayName("plain POST defaults to PDF_PROCESS") + void defaultProcess() throws Exception { + AuditEventType type = + service.determineAuditEventType( + sampleMethod(), SampleController.class, "/api/v1/merge", "POST"); + + assertThat(type).isEqualTo(AuditEventType.PDF_PROCESS); + } + } + + @Nested + @DisplayName("request helpers") + class RequestHelpers { + + @Test + @DisplayName("getCurrentRequest returns bound request") + void getCurrentRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + bindRequest(request); + + assertThat(service.getCurrentRequest()).isSameAs(request); + } + + @Test + @DisplayName("getCurrentRequest null when no context") + void getCurrentRequestNull() { + assertThat(service.getCurrentRequest()).isNull(); + } + + @Test + @DisplayName("static resource detection") + void staticResource() { + MockHttpServletRequest staticReq = new MockHttpServletRequest(); + staticReq.setRequestURI("/images/logo.png"); + assertThat(service.isStaticResourceRequest(staticReq)).isTrue(); + + MockHttpServletRequest apiReq = new MockHttpServletRequest(); + apiReq.setRequestURI("/api/v1/merge"); + assertThat(service.isStaticResourceRequest(apiReq)).isFalse(); + + assertThat(service.isStaticResourceRequest(null)).isFalse(); + } + + @Test + @DisplayName("polling call detection") + void pollingCall() { + MockHttpServletRequest pollReq = new MockHttpServletRequest(); + pollReq.setMethod("GET"); + pollReq.setRequestURI("/api/v1/auth/me"); + assertThat(service.isPollingCall(pollReq)).isTrue(); + + MockHttpServletRequest healthReq = new MockHttpServletRequest(); + healthReq.setMethod("GET"); + healthReq.setRequestURI("/actuator/health/db"); + assertThat(service.isPollingCall(healthReq)).isTrue(); + + MockHttpServletRequest postReq = new MockHttpServletRequest(); + postReq.setMethod("POST"); + postReq.setRequestURI("/api/v1/auth/me"); + assertThat(service.isPollingCall(postReq)).isFalse(); + + assertThat(service.isPollingCall(null)).isFalse(); + } + + @Test + @DisplayName("shouldCaptureOperationResults reflects config") + void captureOperationResults() { + assertThat(service.shouldCaptureOperationResults()).isFalse(); + } + } + + @Nested + @DisplayName("extractClientIp") + class ExtractClientIp { + + @Test + @DisplayName("null request returns null") + void nullRequest() { + assertThat(service.extractClientIp(null)).isNull(); + } + + @Test + @DisplayName("X-Forwarded-For first IP wins") + void forwardedFor() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Forwarded-For", "203.0.113.1, 10.0.0.1"); + + assertThat(service.extractClientIp(request)).isEqualTo("203.0.113.1"); + } + + @Test + @DisplayName("X-Real-IP used when no forwarded header") + void realIp() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Real-IP", "198.51.100.2"); + + assertThat(service.extractClientIp(request)).isEqualTo("198.51.100.2"); + } + + @Test + @DisplayName("falls back to remote address") + void remoteAddr() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("127.0.0.5"); + + assertThat(service.extractClientIp(request)).isEqualTo("127.0.0.5"); + } + } + + @Nested + @DisplayName("captureCurrentPrincipal / origin") + class Capture { + + @Test + @DisplayName("authenticated user is captured directly") + void authenticatedPrincipal() { + authenticateAs("dave"); + + assertThat(service.captureCurrentPrincipal()).isEqualTo("dave"); + assertThat(service.captureCurrentOrigin()).isEqualTo("WEB"); + } + + @Test + @DisplayName("anonymous with no token resolves to system/SYSTEM") + void anonymousPrincipal() { + assertThat(service.captureCurrentPrincipal()).isEqualTo("system"); + assertThat(service.captureCurrentOrigin()).isEqualTo("SYSTEM"); + } + + @Test + @DisplayName("refresh endpoint derives principal from verified token") + void refreshEndpointPrincipal() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/v1/auth/refresh"); + bindRequest(request); + when(jwtService.extractToken(request)).thenReturn("tok"); + when(jwtService.extractUsernameAllowExpired("tok")).thenReturn("refreshUser"); + + assertThat(service.captureCurrentPrincipal()).isEqualTo("refreshUser"); + } + + @Test + @DisplayName("refresh endpoint with API authType resolves origin API") + void refreshEndpointApiOrigin() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI("/api/v1/auth/refresh"); + bindRequest(request); + when(jwtService.extractToken(request)).thenReturn("tok"); + when(jwtService.extractClaimsAllowExpired("tok")).thenReturn(Map.of("authType", "API")); + + assertThat(service.captureCurrentOrigin()).isEqualTo("API"); + } + } + + // ===== helpers ===== + + private ProceedingJoinPoint joinPoint() { + ProceedingJoinPoint jp = org.mockito.Mockito.mock(ProceedingJoinPoint.class); + MethodSignature sig = org.mockito.Mockito.mock(MethodSignature.class); + try { + // Lenient: BASIC-level tests do not touch signature/target + org.mockito.Mockito.lenient().when(jp.getSignature()).thenReturn((Signature) sig); + org.mockito.Mockito.lenient().when(sig.getMethod()).thenReturn(sampleMethod()); + } catch (Exception e) { + throw new RuntimeException(e); + } + org.mockito.Mockito.lenient().when(jp.getTarget()).thenReturn(new SampleController()); + return jp; + } + + private Method sampleMethod() throws NoSuchMethodException { + return SampleController.class.getMethod("sample"); + } + + private Audited annotationWith(AuditEventType type) { + return new Audited() { + @Override + public Class annotationType() { + return Audited.class; + } + + @Override + public AuditEventType type() { + return type; + } + + @Override + public String typeString() { + return ""; + } + + @Override + public AuditLevel level() { + return AuditLevel.STANDARD; + } + + @Override + public boolean includeArgs() { + return true; + } + + @Override + public boolean includeResult() { + return false; + } + }; + } + + /** Simple controller used as join-point target. */ + public static class SampleController { + public void sample() {} + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/MathAuditorOrchestratorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/MathAuditorOrchestratorTest.java new file mode 100644 index 0000000000..67efe343e7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/MathAuditorOrchestratorTest.java @@ -0,0 +1,296 @@ +package stirling.software.proprietary.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.UserServiceInterface; +import stirling.software.proprietary.model.api.ai.Verdict; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Unit tests for {@link MathAuditorOrchestrator}. The PDFBox factory and the real {@link + * PdfContentExtractor} are wired so page classification/extraction run for real; only the engine + * HTTP boundary ({@link AiEngineClient}) is mocked so no network call is ever made. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("MathAuditorOrchestrator") +class MathAuditorOrchestratorTest { + + private static final String EXAMINE_PATH = "/api/v1/ai/math-auditor-agent/examine"; + private static final String DELIBERATE_PREFIX = "/api/v1/ai/math-auditor-agent/deliberate"; + + @Mock private AiEngineClient aiEngineClient; + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private UserServiceInterface userService; + + private ObjectMapper objectMapper; + private PdfContentExtractor pdfContentExtractor; + private MathAuditorOrchestrator orchestrator; + + @BeforeEach + void setUp() throws IOException { + objectMapper = JsonMapper.builder().build(); + // Real extractor; tabula is never reached for text-only requisitions but stub leniently. + stirling.software.SPDF.pdf.parser.TabulaTableParser tabula = + org.mockito.Mockito.mock(stirling.software.SPDF.pdf.parser.TabulaTableParser.class); + lenient().when(tabula.parse(any(PDDocument.class), anyInt())).thenReturn(List.of()); + pdfContentExtractor = new PdfContentExtractor(tabula); + orchestrator = + new MathAuditorOrchestrator( + aiEngineClient, + pdfDocumentFactory, + pdfContentExtractor, + objectMapper, + userService); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private static MultipartFile pdfFile() throws IOException { + try (PDDocument doc = new PDDocument()) { + for (int i = 0; i < 2; i++) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(72, 700); + cs.showText("Total amount on page " + (i + 1) + " is 100 plus 50 equals 150."); + cs.endText(); + } + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + return new MockMultipartFile( + "file", "ledger.pdf", "application/pdf", baos.toByteArray()); + } + } + + /** A fresh 2-page text document the factory hands back on load(). */ + private static PDDocument loadedDocument() throws IOException { + PDDocument doc = new PDDocument(); + for (int i = 0; i < 2; i++) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(72, 700); + cs.showText("Subtotal " + (i + 1) + ": 100 + 50 = 150 confirmed correct here."); + cs.endText(); + } + } + return doc; + } + + private String requisitionJson(String needText, String needTables, String needOcr) { + return """ + {"type":"requisition","needText":%s,"needTables":%s,"needOcr":%s, + "rationale":"check the totals"} + """ + .formatted(needText, needTables, needOcr); + } + + private String verdictJson(boolean clean) { + return """ + {"type":"verdict","sessionId":"s","discrepancies":[],"pagesExamined":[0,1], + "roundsTaken":1,"summary":"all good","clean":%s,"unauditablePages":[]} + """ + .formatted(clean); + } + + @Nested + @DisplayName("audit happy path") + class HappyPath { + + @Test + @DisplayName("classifies, examines, fulfils text pages, then returns a verdict") + void fullAuditReturnsVerdict() throws IOException { + when(pdfDocumentFactory.load(any(MultipartFile.class))).thenReturn(loadedDocument()); + when(aiEngineClient.post(eq(EXAMINE_PATH), anyString(), nullable(String.class))) + .thenReturn(requisitionJson("[0,1]", "[]", "[]")); + when(aiEngineClient.post(contains("deliberate"), anyString(), nullable(String.class))) + .thenReturn(verdictJson(true)); + + Verdict verdict = orchestrator.audit(pdfFile(), new BigDecimal("0.01")); + + assertThat(verdict).isNotNull(); + assertThat(verdict.clean()).isTrue(); + assertThat(verdict.pagesExamined()).containsExactly(0, 1); + verify(aiEngineClient, times(1)) + .post(eq(EXAMINE_PATH), anyString(), nullable(String.class)); + verify(aiEngineClient, times(1)) + .post(contains("deliberate"), anyString(), nullable(String.class)); + } + + @Test + @DisplayName("passes the tolerance through as a deliberate query parameter") + void toleranceIsForwarded() throws IOException { + when(pdfDocumentFactory.load(any(MultipartFile.class))).thenReturn(loadedDocument()); + when(aiEngineClient.post(eq(EXAMINE_PATH), anyString(), nullable(String.class))) + .thenReturn(requisitionJson("[0]", "[]", "[]")); + when(aiEngineClient.post(contains("deliberate"), anyString(), nullable(String.class))) + .thenReturn(verdictJson(true)); + + orchestrator.audit(pdfFile(), new BigDecimal("0.5")); + + verify(aiEngineClient) + .post( + eq(DELIBERATE_PREFIX + "?tolerance=0.5"), + anyString(), + nullable(String.class)); + } + + @Test + @DisplayName("requesting tables triggers tabula extraction for that page") + void tableRequisitionFulfilled() throws IOException { + when(pdfDocumentFactory.load(any(MultipartFile.class))).thenReturn(loadedDocument()); + when(aiEngineClient.post(eq(EXAMINE_PATH), anyString(), nullable(String.class))) + .thenReturn(requisitionJson("[]", "[0]", "[]")); + when(aiEngineClient.post(contains("deliberate"), anyString(), nullable(String.class))) + .thenReturn(verdictJson(true)); + + Verdict verdict = orchestrator.audit(pdfFile(), new BigDecimal("0.01")); + assertThat(verdict).isNotNull(); + } + + @Test + @DisplayName( + "OCR-only requisition marks the page unauditable and skips deliberation cleanly") + void ocrRequisitionMarksUnauditable() throws IOException { + when(pdfDocumentFactory.load(any(MultipartFile.class))).thenReturn(loadedDocument()); + when(aiEngineClient.post(eq(EXAMINE_PATH), anyString(), nullable(String.class))) + .thenReturn(requisitionJson("[]", "[]", "[0]")); + when(aiEngineClient.post(contains("deliberate"), anyString(), nullable(String.class))) + .thenReturn(verdictJson(true)); + + Verdict verdict = orchestrator.audit(pdfFile(), new BigDecimal("0.01")); + assertThat(verdict).isNotNull(); + } + + @Test + @DisplayName("out-of-bounds requisition pages are filtered before fulfilment") + void outOfBoundsPagesFiltered() throws IOException { + when(pdfDocumentFactory.load(any(MultipartFile.class))).thenReturn(loadedDocument()); + when(aiEngineClient.post(eq(EXAMINE_PATH), anyString(), nullable(String.class))) + .thenReturn(requisitionJson("[5,9]", "[]", "[]")); + when(aiEngineClient.post(contains("deliberate"), anyString(), nullable(String.class))) + .thenReturn(verdictJson(true)); + + Verdict verdict = orchestrator.audit(pdfFile(), new BigDecimal("0.01")); + assertThat(verdict).isNotNull(); + } + } + + @Nested + @DisplayName("error paths") + class ErrorPaths { + + @Test + @DisplayName("throws IllegalStateException when deliberate returns null") + void nullVerdictThrows() throws IOException { + when(pdfDocumentFactory.load(any(MultipartFile.class))).thenReturn(loadedDocument()); + when(aiEngineClient.post(eq(EXAMINE_PATH), anyString(), nullable(String.class))) + .thenReturn(requisitionJson("[0]", "[]", "[]")); + when(aiEngineClient.post(contains("deliberate"), anyString(), nullable(String.class))) + .thenReturn("null"); + + assertThatThrownBy(() -> orchestrator.audit(pdfFile(), new BigDecimal("0.01"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("null Verdict"); + } + + @Test + @DisplayName("propagates an IOException raised by the engine client") + void engineFailurePropagates() throws IOException { + when(pdfDocumentFactory.load(any(MultipartFile.class))).thenReturn(loadedDocument()); + when(aiEngineClient.post(eq(EXAMINE_PATH), anyString(), nullable(String.class))) + .thenThrow(new IOException("engine down")); + + assertThatThrownBy(() -> orchestrator.audit(pdfFile(), new BigDecimal("0.01"))) + .isInstanceOf(IOException.class) + .hasMessageContaining("engine down"); + verify(aiEngineClient, never()) + .post(contains("deliberate"), anyString(), nullable(String.class)); + } + } + + @Nested + @DisplayName("user id propagation") + class UserIdPropagation { + + @Test + @DisplayName("forwards the current username to the engine when security is enabled") + void forwardsUsername() throws IOException { + when(userService.getCurrentUsername()).thenReturn("alice"); + when(pdfDocumentFactory.load(any(MultipartFile.class))).thenReturn(loadedDocument()); + when(aiEngineClient.post(eq(EXAMINE_PATH), anyString(), eq("alice"))) + .thenReturn(requisitionJson("[0]", "[]", "[]")); + when(aiEngineClient.post(contains("deliberate"), anyString(), eq("alice"))) + .thenReturn(verdictJson(true)); + + orchestrator.audit(pdfFile(), new BigDecimal("0.01")); + + verify(aiEngineClient).post(eq(EXAMINE_PATH), anyString(), eq("alice")); + } + + @Test + @DisplayName("sends a null user id when no UserService bean is present") + void nullUserServiceSendsNull() throws IOException { + MathAuditorOrchestrator noUser = + new MathAuditorOrchestrator( + aiEngineClient, + pdfDocumentFactory, + pdfContentExtractor, + objectMapper, + null); + when(pdfDocumentFactory.load(any(MultipartFile.class))).thenReturn(loadedDocument()); + when(aiEngineClient.post(eq(EXAMINE_PATH), anyString(), nullable(String.class))) + .thenReturn(requisitionJson("[0]", "[]", "[]")); + when(aiEngineClient.post(contains("deliberate"), anyString(), nullable(String.class))) + .thenReturn(verdictJson(true)); + + Verdict verdict = noUser.audit(pdfFile(), new BigDecimal("0.01")); + + assertThat(verdict).isNotNull(); + verify(aiEngineClient).post(eq(EXAMINE_PATH), anyString(), eq((String) null)); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/PdfContentExtractorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/PdfContentExtractorTest.java new file mode 100644 index 0000000000..1bb042f882 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/PdfContentExtractorTest.java @@ -0,0 +1,440 @@ +package stirling.software.proprietary.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.when; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +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.image.LosslessFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.SPDF.pdf.parser.PdfModels.Bounds; +import stirling.software.SPDF.pdf.parser.PdfModels.TableFragment; +import stirling.software.SPDF.pdf.parser.TabulaTableParser; +import stirling.software.proprietary.model.api.ai.AiPdfContentType; +import stirling.software.proprietary.model.api.ai.AiWorkflowFileRequest; +import stirling.software.proprietary.model.api.ai.FolioType; +import stirling.software.proprietary.service.PdfContentExtractor.ArtifactKind; +import stirling.software.proprietary.service.PdfContentExtractor.ExtractedFileText; +import stirling.software.proprietary.service.PdfContentExtractor.ImageBlock; +import stirling.software.proprietary.service.PdfContentExtractor.LoadedFile; +import stirling.software.proprietary.service.PdfContentExtractor.PdfContentResult; +import stirling.software.proprietary.service.PdfContentExtractor.TextBlock; +import stirling.software.proprietary.service.PdfContentExtractor.WorkflowArtifact; + +/** + * Unit tests for {@link PdfContentExtractor}. Exercises the low-level extraction methods against + * real in-memory {@link PDDocument}s and the workflow extraction/artifact-building paths with a + * mocked {@link TabulaTableParser}. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("PdfContentExtractor") +class PdfContentExtractorTest { + + @Mock private TabulaTableParser tabulaTableParser; + + private PdfContentExtractor extractor; + + private PdfContentExtractor newExtractor() { + return new PdfContentExtractor(tabulaTableParser); + } + + // ------------------------------------------------------------------ + // PDF builders + // ------------------------------------------------------------------ + + private static PDDocument textDocument(String... pageTexts) throws IOException { + PDDocument doc = new PDDocument(); + for (String text : pageTexts) { + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(72, 700); + cs.showText(text); + cs.endText(); + } + } + return doc; + } + + private static PDDocument blankDocument(int pages) { + PDDocument doc = new PDDocument(); + for (int i = 0; i < pages; i++) { + PDPage page = new PDPage(PDRectangle.A4); + // Give the page an empty resources dict so image detection does not NPE on a bare page. + page.setResources(new PDResources()); + doc.addPage(page); + } + return doc; + } + + /** Page with both a long text block and a small embedded raster image. */ + private static PDDocument textAndImageDocument() throws IOException { + PDDocument doc = new PDDocument(); + PDPage page = new PDPage(PDRectangle.A4); + doc.addPage(page); + BufferedImage bufferedImage = new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB); + PDImageXObject image = LosslessFactory.createFromImage(doc, bufferedImage); + try (PDPageContentStream cs = new PDPageContentStream(doc, page)) { + cs.beginText(); + cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12); + cs.newLineAtOffset(72, 700); + cs.showText("This page has enough text to clear the presence threshold for sure."); + cs.endText(); + cs.drawImage(image, 100, 100, 40, 40); + } + return doc; + } + + private static byte[] toBytes(PDDocument doc) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + return baos.toByteArray(); + } + + @Nested + @DisplayName("classifyPage") + class ClassifyPage { + + @Test + @DisplayName("returns TEXT for a text-only page") + void textOnlyPageIsText() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = + textDocument("This is a fully text page with plenty of selectable words.")) { + assertThat(extractor.classifyPage(doc, 1)).isEqualTo(FolioType.TEXT); + } + } + + @Test + @DisplayName("returns IMAGE for a blank page below the text threshold") + void blankPageIsImage() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = blankDocument(1)) { + assertThat(extractor.classifyPage(doc, 1)).isEqualTo(FolioType.IMAGE); + } + } + + @Test + @DisplayName("returns MIXED when a page has both text and an image") + void textAndImagePageIsMixed() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = textAndImageDocument()) { + assertThat(extractor.classifyPage(doc, 1)).isEqualTo(FolioType.MIXED); + } + } + } + + @Nested + @DisplayName("extractPageTextRaw") + class ExtractPageTextRaw { + + @Test + @DisplayName("returns trimmed page text") + void returnsText() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = textDocument("Hello extraction world")) { + String text = extractor.extractPageTextRaw(doc, 1); + assertThat(text).contains("Hello extraction world"); + } + } + + @Test + @DisplayName("returns empty string for a blank page") + void blankPageReturnsEmpty() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = blankDocument(1)) { + assertThat(extractor.extractPageTextRaw(doc, 1)).isEmpty(); + } + } + + @Test + @DisplayName("reads the requested page only in a multi-page document") + void readsSpecificPage() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = textDocument("First page words", "Second page words")) { + assertThat(extractor.extractPageTextRaw(doc, 2)).contains("Second page words"); + assertThat(extractor.extractPageTextRaw(doc, 2)).doesNotContain("First page"); + } + } + } + + @Nested + @DisplayName("extractTablesAsCsv") + class ExtractTablesAsCsv { + + @Test + @DisplayName("returns empty list when no tables are found") + void noTablesReturnsEmpty() throws IOException { + extractor = newExtractor(); + when(tabulaTableParser.parse(any(PDDocument.class), anyInt())).thenReturn(List.of()); + try (PDDocument doc = textDocument("no tables here")) { + assertThat(extractor.extractTablesAsCsv(doc, 1)).isEmpty(); + } + } + + @Test + @DisplayName("converts each table fragment into a quoted CSV string") + void fragmentsBecomeCsv() throws IOException { + extractor = newExtractor(); + TableFragment fragment = + new TableFragment( + "tbl-1", + 1, + new Bounds(0, 0, 100, 100), + List.of(), + List.of(), + List.of(List.of("a", "b"), List.of("c", "d")), + 2, + 1.0f, + List.of(), + null); + when(tabulaTableParser.parse(any(PDDocument.class), anyInt())) + .thenReturn(List.of(fragment)); + try (PDDocument doc = textDocument("with table")) { + List csv = extractor.extractTablesAsCsv(doc, 1); + assertThat(csv).hasSize(1); + assertThat(csv.get(0)).contains("\"a\"").contains("\"b\"").contains("\"c\""); + } + } + } + + @Nested + @DisplayName("extractImagePositions") + class ExtractImagePositions { + + @Test + @DisplayName("locates an embedded image's bounding box") + void findsImage() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = textAndImageDocument()) { + List images = extractor.extractImagePositions(doc, 0); + assertThat(images).isNotEmpty(); + ImageBlock img = images.get(0); + assertThat(img.x2()).isGreaterThan(img.x1()); + assertThat(img.y2()).isGreaterThan(img.y1()); + } + } + + @Test + @DisplayName("returns empty list for a page with no images") + void noImagesReturnsEmpty() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = textDocument("text only, no images at all")) { + assertThat(extractor.extractImagePositions(doc, 0)).isEmpty(); + } + } + } + + @Nested + @DisplayName("findTextPositions") + class FindTextPositions { + + @Test + @DisplayName("finds a literal substring and returns its page-0 bounding box") + void findsLiteral() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = textDocument("the needle is here")) { + List blocks = extractor.findTextPositions(doc, "needle", false); + assertThat(blocks).isNotEmpty(); + assertThat(blocks.get(0).pageIndex()).isZero(); + assertThat(blocks.get(0).x2()).isGreaterThan(blocks.get(0).x1()); + } + } + + @Test + @DisplayName("supports regex matching") + void findsRegex() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = textDocument("order 12345 confirmed")) { + List blocks = extractor.findTextPositions(doc, "\\d+", true); + assertThat(blocks).isNotEmpty(); + } + } + + @Test + @DisplayName("returns empty list when the term is not present") + void noMatchReturnsEmpty() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = textDocument("nothing matches the query")) { + assertThat(extractor.findTextPositions(doc, "absent-term", false)).isEmpty(); + } + } + + @Test + @DisplayName("blank search term yields no matches") + void blankTermReturnsEmpty() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = textDocument("some content")) { + assertThat(extractor.findTextPositions(doc, " ", false)).isEmpty(); + } + } + } + + @Nested + @DisplayName("extractContent + buildArtifacts") + class WorkflowExtraction { + + @Test + @DisplayName("extracts page text and budgets pages/characters") + void extractsTextWithinBudget() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = textDocument("Alpha page one", "Beta page two")) { + LoadedFile lf = new LoadedFile("id-1", "doc.pdf", doc); + List results = + extractor.extractContent(List.of(lf), Map.of(), 10, 10_000); + + assertThat(results).hasSize(1); + ExtractedFileText fileText = (ExtractedFileText) results.get(0); + assertThat(fileText.getFileName()).isEqualTo("doc.pdf"); + assertThat(fileText.getPages()).hasSize(2); + assertThat(fileText.pagesConsumed()).isEqualTo(2); + assertThat(fileText.charactersConsumed()).isGreaterThan(0); + } + } + + @Test + @DisplayName("honours requested page numbers and content types") + void honoursRequest() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = textDocument("Page one body", "Page two body", "Page three")) { + AiWorkflowFileRequest req = new AiWorkflowFileRequest(); + req.setContentTypes(List.of(AiPdfContentType.PAGE_TEXT)); + req.setPageNumbers(List.of(2)); + + LoadedFile lf = new LoadedFile("id-9", "scan.pdf", doc); + List results = + extractor.extractContent(List.of(lf), Map.of("id-9", req), 10, 10_000); + + ExtractedFileText fileText = (ExtractedFileText) results.get(0); + assertThat(fileText.getPages()).hasSize(1); + assertThat(fileText.getPages().get(0).getPageNumber()).isEqualTo(2); + assertThat(fileText.getPages().get(0).getText()).contains("Page two body"); + } + } + + @Test + @DisplayName("unimplemented content types are skipped and produce no result") + void unimplementedContentTypeSkipped() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = textDocument("some words")) { + AiWorkflowFileRequest req = new AiWorkflowFileRequest(); + req.setContentTypes(List.of(AiPdfContentType.IMAGES)); + + LoadedFile lf = new LoadedFile("id-3", "x.pdf", doc); + List results = + extractor.extractContent(List.of(lf), Map.of("id-3", req), 10, 10_000); + assertThat(results).isEmpty(); + } + } + + @Test + @DisplayName("stops once the page budget is exhausted") + void stopsAtZeroBudget() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = textDocument("only page")) { + LoadedFile lf = new LoadedFile("id-z", "z.pdf", doc); + List results = + extractor.extractContent(List.of(lf), Map.of(), 0, 0); + assertThat(results).isEmpty(); + } + } + + @Test + @DisplayName("buildArtifacts groups extracted text into an ExtractedTextArtifact") + void buildsArtifact() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = textDocument("artifact source text")) { + LoadedFile lf = new LoadedFile("id-a", "a.pdf", doc); + List results = + extractor.extractContent(List.of(lf), Map.of(), 10, 10_000); + + List artifacts = extractor.buildArtifacts(results); + assertThat(artifacts).hasSize(1); + assertThat(artifacts.get(0).getKind()).isEqualTo(ArtifactKind.EXTRACTED_TEXT); + } + } + } + + @Nested + @DisplayName("page selection validation") + class PageSelectionValidation { + + @Test + @DisplayName("throws when a document has no pages") + void noPagesThrows() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = new PDDocument()) { + LoadedFile lf = new LoadedFile("id-empty", "empty.pdf", doc); + assertThatThrownBy( + () -> extractor.extractContent(List.of(lf), Map.of(), 10, 10_000)) + .isInstanceOf(RuntimeException.class); + } + } + + @Test + @DisplayName("throws when a requested page number is out of range") + void outOfRangePageThrows() throws IOException { + extractor = newExtractor(); + try (PDDocument doc = textDocument("single page")) { + AiWorkflowFileRequest req = new AiWorkflowFileRequest(); + req.setContentTypes(List.of(AiPdfContentType.PAGE_TEXT)); + req.setPageNumbers(List.of(99)); + + LoadedFile lf = new LoadedFile("id-oob", "oob.pdf", doc); + assertThatThrownBy( + () -> + extractor.extractContent( + List.of(lf), Map.of("id-oob", req), 10, 10_000)) + .isInstanceOf(IllegalArgumentException.class); + } + } + } + + @Nested + @DisplayName("ArtifactKind enum") + class ArtifactKindEnum { + + @Test + @DisplayName("exposes the python-contract string values") + void values() { + assertThat(ArtifactKind.EXTRACTED_TEXT.getValue()).isEqualTo("extracted_text"); + assertThat(ArtifactKind.TOOL_REPORT.getValue()).isEqualTo("tool_report"); + } + } + + @Test + @DisplayName("classifyPage round-trips through a re-loaded saved document") + void classifyReloadedDocument() throws IOException { + extractor = newExtractor(); + byte[] bytes; + try (PDDocument doc = textDocument("Re-loaded page text with plenty of content here.")) { + bytes = toBytes(doc); + } + try (PDDocument loaded = org.apache.pdfbox.Loader.loadPDF(bytes)) { + assertThat(extractor.classifyPage(loaded, 1)).isEqualTo(FolioType.TEXT); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/ServerCertificateServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/ServerCertificateServiceTest.java new file mode 100644 index 0000000000..4d78ca7471 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/ServerCertificateServiceTest.java @@ -0,0 +1,386 @@ +package stirling.software.proprietary.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.cert.X509Certificate; + +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.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; +import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker; + +/** + * Tests for {@link ServerCertificateService}. Uses a {@link TempDir} for the keystore location + * (mocked via {@link InstallationPathConfig}) and a mocked {@link LicenseKeyChecker} to drive the + * Pro/Enterprise license gating. + */ +@ExtendWith(MockitoExtension.class) +class ServerCertificateServiceTest { + + @Mock private LicenseKeyChecker licenseKeyChecker; + + @TempDir Path tempDir; + + private ServerCertificateService service; + + private static final String KEYSTORE_FILE = "server-certificate.p12"; + private static final String KEYSTORE_ALIAS = "stirling-pdf-server"; + private static final String DEFAULT_PASSWORD = "stirling-pdf-server-cert"; + + @BeforeEach + void setUp() { + service = new ServerCertificateService(licenseKeyChecker); + // default: feature enabled, validity 365, org Stirling-PDF, no regenerate + ReflectionTestUtils.setField(service, "enabled", true); + ReflectionTestUtils.setField(service, "organizationName", "Stirling-PDF"); + ReflectionTestUtils.setField(service, "validityDays", 365); + ReflectionTestUtils.setField(service, "regenerateOnStartup", false); + } + + /** Opens a static mock of InstallationPathConfig returning the temp dir as config path. */ + private MockedStatic mockConfigPath() { + MockedStatic mocked = mockStatic(InstallationPathConfig.class); + mocked.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString() + "/"); + return mocked; + } + + private void grantProLicense() { + lenient() + .when(licenseKeyChecker.getPremiumLicenseEnabledResult()) + .thenReturn(License.SERVER); + } + + private void denyLicense() { + lenient() + .when(licenseKeyChecker.getPremiumLicenseEnabledResult()) + .thenReturn(License.NORMAL); + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("isEnabled") + class IsEnabled { + + @Test + @DisplayName("true when feature flag on and license is SERVER") + void enabledWithServerLicense() { + grantProLicense(); + assertThat(service.isEnabled()).isTrue(); + } + + @Test + @DisplayName("true when feature flag on and license is ENTERPRISE") + void enabledWithEnterpriseLicense() { + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE); + assertThat(service.isEnabled()).isTrue(); + } + + @Test + @DisplayName("false when license is NORMAL") + void disabledWithNormalLicense() { + denyLicense(); + assertThat(service.isEnabled()).isFalse(); + } + + @Test + @DisplayName("false when feature flag off even with a valid license") + void disabledWhenFlagOff() { + ReflectionTestUtils.setField(service, "enabled", false); + assertThat(service.isEnabled()).isFalse(); + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("getServerCertificatePassword") + class Password { + + @Test + @DisplayName("returns the fixed default password") + void returnsDefault() { + assertThat(service.getServerCertificatePassword()).isEqualTo(DEFAULT_PASSWORD); + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("hasServerCertificate") + class HasCertificate { + + @Test + @DisplayName("false when no keystore file exists") + void falseWhenMissing() { + try (MockedStatic ignored = mockConfigPath()) { + assertThat(service.hasServerCertificate()).isFalse(); + } + } + + @Test + @DisplayName("true once a keystore file is present") + void trueWhenPresent() throws Exception { + Files.createFile(tempDir.resolve(KEYSTORE_FILE)); + try (MockedStatic ignored = mockConfigPath()) { + assertThat(service.hasServerCertificate()).isTrue(); + } + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("initializeServerCertificate") + class Initialize { + + @Test + @DisplayName("generates a keystore when none exists and license granted") + void generatesWhenMissing() { + grantProLicense(); + try (MockedStatic ignored = mockConfigPath()) { + service.initializeServerCertificate(); + assertThat(Files.exists(tempDir.resolve(KEYSTORE_FILE))).isTrue(); + } + } + + @Test + @DisplayName("does nothing when the feature flag is off") + void noopWhenDisabled() { + ReflectionTestUtils.setField(service, "enabled", false); + try (MockedStatic ignored = mockConfigPath()) { + service.initializeServerCertificate(); + assertThat(Files.exists(tempDir.resolve(KEYSTORE_FILE))).isFalse(); + } + } + + @Test + @DisplayName("does nothing without a Pro/Enterprise license") + void noopWithoutLicense() { + denyLicense(); + try (MockedStatic ignored = mockConfigPath()) { + service.initializeServerCertificate(); + assertThat(Files.exists(tempDir.resolve(KEYSTORE_FILE))).isFalse(); + } + } + + @Test + @DisplayName("does not regenerate when keystore exists and regenerateOnStartup is false") + void keepsExistingKeystore() throws Exception { + grantProLicense(); + try (MockedStatic ignored = mockConfigPath()) { + // First generation + service.initializeServerCertificate(); + byte[] first = Files.readAllBytes(tempDir.resolve(KEYSTORE_FILE)); + // Second call must not overwrite + service.initializeServerCertificate(); + byte[] second = Files.readAllBytes(tempDir.resolve(KEYSTORE_FILE)); + assertThat(second).isEqualTo(first); + } + } + + @Test + @DisplayName("regenerates when regenerateOnStartup is true") + void regeneratesWhenFlagged() throws Exception { + grantProLicense(); + ReflectionTestUtils.setField(service, "regenerateOnStartup", true); + try (MockedStatic ignored = mockConfigPath()) { + service.initializeServerCertificate(); + assertThat(Files.exists(tempDir.resolve(KEYSTORE_FILE))).isTrue(); + // A regeneration run should still leave a readable keystore + service.initializeServerCertificate(); + assertThat(Files.exists(tempDir.resolve(KEYSTORE_FILE))).isTrue(); + } + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("getServerKeyStore") + class GetKeyStore { + + @Test + @DisplayName("throws when license is missing") + void throwsWithoutLicense() { + denyLicense(); + try (MockedStatic ignored = mockConfigPath()) { + assertThatThrownBy(() -> service.getServerKeyStore()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Pro or Enterprise license"); + } + } + + @Test + @DisplayName("throws when no certificate is available") + void throwsWhenNoCertificate() { + grantProLicense(); + try (MockedStatic ignored = mockConfigPath()) { + assertThatThrownBy(() -> service.getServerKeyStore()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("not available"); + } + } + + @Test + @DisplayName("loads the generated keystore with the default password") + void loadsGeneratedKeystore() throws Exception { + grantProLicense(); + try (MockedStatic ignored = mockConfigPath()) { + service.initializeServerCertificate(); + KeyStore ks = service.getServerKeyStore(); + assertThat(ks).isNotNull(); + assertThat(ks.containsAlias(KEYSTORE_ALIAS)).isTrue(); + } + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("getServerCertificate / publicKey / info") + class CertificateAccessors { + + @Test + @DisplayName("returns the X509 certificate for the standard alias") + void returnsCertificate() throws Exception { + grantProLicense(); + try (MockedStatic ignored = mockConfigPath()) { + service.initializeServerCertificate(); + X509Certificate cert = service.getServerCertificate(); + assertThat(cert).isNotNull(); + assertThat(cert.getSubjectX500Principal().getName()).contains("Stirling-PDF"); + } + } + + @Test + @DisplayName("returns DER-encoded public key bytes") + void returnsPublicKeyBytes() throws Exception { + grantProLicense(); + try (MockedStatic ignored = mockConfigPath()) { + service.initializeServerCertificate(); + byte[] der = service.getServerCertificatePublicKey(); + assertThat(der).isNotEmpty(); + } + } + + @Test + @DisplayName("info reports absent when no certificate exists") + void infoAbsentWhenMissing() throws Exception { + try (MockedStatic ignored = mockConfigPath()) { + var info = service.getServerCertificateInfo(); + assertThat(info.isExists()).isFalse(); + assertThat(info.getSubject()).isNull(); + } + } + + @Test + @DisplayName("info reports subject/issuer/dates when a certificate exists") + void infoPresentWhenAvailable() throws Exception { + grantProLicense(); + try (MockedStatic ignored = mockConfigPath()) { + service.initializeServerCertificate(); + var info = service.getServerCertificateInfo(); + assertThat(info.isExists()).isTrue(); + assertThat(info.getSubject()).contains("Stirling-PDF"); + assertThat(info.getIssuer()).contains("Stirling-PDF"); + assertThat(info.getValidFrom()).isNotNull(); + assertThat(info.getValidTo()).isNotNull(); + } + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("uploadServerCertificate") + class Upload { + + @Test + @DisplayName("imports a private key entry from an uploaded P12 under the standard alias") + void importsUploadedKeystore() throws Exception { + grantProLicense(); + byte[] uploaded = loadCert("valid-test.p12"); + try (MockedStatic ignored = mockConfigPath()) { + service.uploadServerCertificate(new ByteArrayInputStream(uploaded), "testpass"); + + assertThat(Files.exists(tempDir.resolve(KEYSTORE_FILE))).isTrue(); + KeyStore ks = service.getServerKeyStore(); + assertThat(ks.isKeyEntry(KEYSTORE_ALIAS)).isTrue(); + } + } + + @Test + @DisplayName("rejects upload without a Pro/Enterprise license") + void rejectsWithoutLicense() throws Exception { + denyLicense(); + byte[] uploaded = loadCert("valid-test.p12"); + try (MockedStatic ignored = mockConfigPath()) { + InputStream in = new ByteArrayInputStream(uploaded); + assertThatThrownBy(() -> service.uploadServerCertificate(in, "testpass")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Pro or Enterprise license"); + } + } + + @Test + @DisplayName("throws on a wrong upload password") + void rejectsWrongPassword() throws Exception { + grantProLicense(); + byte[] uploaded = loadCert("valid-test.p12"); + try (MockedStatic ignored = mockConfigPath()) { + InputStream in = new ByteArrayInputStream(uploaded); + assertThatThrownBy(() -> service.uploadServerCertificate(in, "wrong")) + .isInstanceOf(Exception.class); + } + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("deleteServerCertificate") + class Delete { + + @Test + @DisplayName("deletes an existing keystore file") + void deletesExisting() throws Exception { + Files.createFile(tempDir.resolve(KEYSTORE_FILE)); + try (MockedStatic ignored = mockConfigPath()) { + service.deleteServerCertificate(); + assertThat(Files.exists(tempDir.resolve(KEYSTORE_FILE))).isFalse(); + } + } + + @Test + @DisplayName("is a no-op when the keystore file is absent") + void noopWhenAbsent() throws Exception { + try (MockedStatic ignored = mockConfigPath()) { + service.deleteServerCertificate(); + assertThat(Files.exists(tempDir.resolve(KEYSTORE_FILE))).isFalse(); + } + } + } + + // ------------------------------------------------------------------------- + private static byte[] loadCert(String filename) throws Exception { + try (InputStream in = + ServerCertificateServiceTest.class.getResourceAsStream("/test-certs/" + filename)) { + if (in == null) { + throw new IllegalStateException("cert not found: " + filename); + } + return in.readAllBytes(); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/SignatureServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/SignatureServiceTest.java new file mode 100644 index 0000000000..8d11700586 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/SignatureServiceTest.java @@ -0,0 +1,384 @@ +package stirling.software.proprietary.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mockStatic; + +import java.io.FileNotFoundException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.List; + +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.io.TempDir; +import org.mockito.MockedStatic; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.proprietary.model.api.signature.SavedSignatureRequest; +import stirling.software.proprietary.model.api.signature.SavedSignatureResponse; + +import tools.jackson.databind.ObjectMapper; + +/** + * Tests for {@link SignatureService}. The service resolves its base directory from {@link + * InstallationPathConfig#getSignaturesPath()} in its constructor, so the constructor is invoked + * inside a static mock pointing at a {@link TempDir}. + */ +class SignatureServiceTest { + + @TempDir Path tempDir; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private SignatureService service; + private MockedStatic pathMock; + + private static final String USER = "alice"; + + @BeforeEach + void setUp() { + pathMock = mockStatic(InstallationPathConfig.class); + pathMock.when(InstallationPathConfig::getSignaturesPath).thenReturn(tempDir.toString()); + service = new SignatureService(objectMapper); + } + + @org.junit.jupiter.api.AfterEach + void tearDown() { + pathMock.close(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** 1x1 PNG data URL. */ + private static String pngDataUrl() { + // Minimal but decodable base64 payload; SignatureService does not parse the image content. + byte[] bytes = "fake-png-bytes".getBytes(StandardCharsets.UTF_8); + return "data:image/png;base64," + Base64.getEncoder().encodeToString(bytes); + } + + private SavedSignatureRequest imageRequest(String id, String scope) { + SavedSignatureRequest req = new SavedSignatureRequest(); + req.setId(id); + req.setLabel("My Signature"); + req.setType("image"); + req.setScope(scope); + req.setDataUrl(pngDataUrl()); + return req; + } + + private Path userFolder(String user) { + return tempDir.resolve(user); + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("saveSignature") + class SaveSignature { + + @Test + @DisplayName("personal scope writes image + metadata and returns a reference URL") + void savesPersonalSignature() throws Exception { + SavedSignatureResponse resp = + service.saveSignature(USER, imageRequest("sig1", "personal")); + + assertThat(resp.getId()).isEqualTo("sig1"); + assertThat(resp.getScope()).isEqualTo("personal"); + assertThat(resp.getDataUrl()).isEqualTo("/api/v1/general/signatures/sig1.png"); + assertThat(Files.exists(userFolder(USER).resolve("sig1.png"))).isTrue(); + assertThat(Files.exists(userFolder(USER).resolve("sig1.json"))).isTrue(); + } + + @Test + @DisplayName("null scope defaults to personal") + void nullScopeDefaultsToPersonal() throws Exception { + SavedSignatureRequest req = imageRequest("sig2", null); + SavedSignatureResponse resp = service.saveSignature(USER, req); + + assertThat(resp.getScope()).isEqualTo("personal"); + assertThat(Files.exists(userFolder(USER).resolve("sig2.png"))).isTrue(); + } + + @Test + @DisplayName("shared scope writes into the ALL_USERS folder") + void savesSharedSignature() throws Exception { + SavedSignatureResponse resp = + service.saveSignature(USER, imageRequest("shared1", "shared")); + + assertThat(resp.getScope()).isEqualTo("shared"); + assertThat(Files.exists(tempDir.resolve("ALL_USERS").resolve("shared1.png"))).isTrue(); + } + + @Test + @DisplayName("text type copies font/colour properties into the response") + void savesTextSignatureProperties() throws Exception { + SavedSignatureRequest req = new SavedSignatureRequest(); + req.setId("text1"); + req.setLabel("Typed"); + req.setType("text"); + req.setScope("personal"); + req.setSignerName("Alice A"); + req.setFontFamily("Arial"); + req.setFontSize(18); + req.setTextColor("#112233"); + // no dataUrl -> only metadata json written + + SavedSignatureResponse resp = service.saveSignature(USER, req); + + assertThat(resp.getSignerName()).isEqualTo("Alice A"); + assertThat(resp.getFontFamily()).isEqualTo("Arial"); + assertThat(resp.getFontSize()).isEqualTo(18); + assertThat(resp.getTextColor()).isEqualTo("#112233"); + assertThat(Files.exists(userFolder(USER).resolve("text1.json"))).isTrue(); + } + + @Test + @DisplayName("rejects an invalid id with path traversal characters") + void rejectsInvalidId() { + SavedSignatureRequest req = imageRequest("../evil", "personal"); + assertThatThrownBy(() -> service.saveSignature(USER, req)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid filename"); + } + + @Test + @DisplayName("rejects a data URL whose decoded image exceeds the per-signature limit") + void rejectsOversizedImage() { + // > 2MB of base64 decodes to > 2MB raw, tripping the decoded-size guard + byte[] big = new byte[2_100_000]; + String dataUrl = "data:image/png;base64," + Base64.getEncoder().encodeToString(big); + SavedSignatureRequest req = new SavedSignatureRequest(); + req.setId("toolarge"); + req.setType("image"); + req.setScope("personal"); + req.setDataUrl(dataUrl); + + assertThatThrownBy(() -> service.saveSignature(USER, req)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("too large"); + } + + @Test + @DisplayName("rejects an unsupported image extension from the data URL mime type") + void rejectsUnsupportedExtension() { + byte[] bytes = "gif-bytes".getBytes(StandardCharsets.UTF_8); + String dataUrl = "data:image/gif;base64," + Base64.getEncoder().encodeToString(bytes); + SavedSignatureRequest req = new SavedSignatureRequest(); + req.setId("gifsig"); + req.setType("image"); + req.setScope("personal"); + req.setDataUrl(dataUrl); + + assertThatThrownBy(() -> service.saveSignature(USER, req)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unsupported image extension"); + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("getPersonalSignatureBytes") + class GetPersonalSignatureBytes { + + @Test + @DisplayName("returns the stored image bytes") + void returnsStoredBytes() throws Exception { + service.saveSignature(USER, imageRequest("sig1", "personal")); + + byte[] bytes = service.getPersonalSignatureBytes(USER, "sig1.png"); + + assertThat(bytes).isNotEmpty(); + } + + @Test + @DisplayName("throws FileNotFoundException when the personal signature is missing") + void throwsWhenMissing() { + assertThatThrownBy(() -> service.getPersonalSignatureBytes(USER, "missing.png")) + .isInstanceOf(FileNotFoundException.class) + .hasMessageContaining("not found"); + } + + @Test + @DisplayName("rejects a filename with invalid characters") + void rejectsInvalidFilename() { + assertThatThrownBy(() -> service.getPersonalSignatureBytes(USER, "bad name!.png")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("invalid characters"); + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("getSavedSignatures") + class GetSavedSignatures { + + @Test + @DisplayName("returns personal and shared signatures combined") + void returnsPersonalAndShared() throws Exception { + service.saveSignature(USER, imageRequest("p1", "personal")); + service.saveSignature(USER, imageRequest("s1", "shared")); + + List all = service.getSavedSignatures(USER); + + assertThat(all).extracting(SavedSignatureResponse::getId).contains("p1", "s1"); + assertThat(all) + .extracting(SavedSignatureResponse::getScope) + .contains("personal", "shared"); + } + + @Test + @DisplayName("returns an empty list when the user has no folder") + void emptyWhenNoFolder() throws Exception { + List all = service.getSavedSignatures("nobody"); + assertThat(all).isEmpty(); + } + + @Test + @DisplayName("falls back to file metadata for legacy images without a json sidecar") + void fallbackForLegacyImage() throws Exception { + Path folder = userFolder(USER); + Files.createDirectories(folder); + Files.write(folder.resolve("legacy.png"), "img".getBytes(StandardCharsets.UTF_8)); + + List all = service.getSavedSignatures(USER); + + assertThat(all).hasSize(1); + SavedSignatureResponse sig = all.get(0); + assertThat(sig.getId()).isEqualTo("legacy"); + assertThat(sig.getType()).isEqualTo("image"); + assertThat(sig.getDataUrl()).isEqualTo("/api/v1/general/signatures/legacy.png"); + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("deleteSignature") + class DeleteSignature { + + @Test + @DisplayName("deletes both the image and its metadata from the personal folder") + void deletesImageAndMetadata() throws Exception { + service.saveSignature(USER, imageRequest("sig1", "personal")); + + service.deleteSignature(USER, "sig1"); + + assertThat(Files.exists(userFolder(USER).resolve("sig1.png"))).isFalse(); + assertThat(Files.exists(userFolder(USER).resolve("sig1.json"))).isFalse(); + } + + @Test + @DisplayName("throws when the signature cannot be found") + void throwsWhenNotFound() throws Exception { + // user folder exists but no matching files + Files.createDirectories(userFolder(USER)); + assertThatThrownBy(() -> service.deleteSignature(USER, "ghost")) + .isInstanceOf(FileNotFoundException.class) + .hasMessageContaining("cannot be deleted"); + } + + @Test + @DisplayName("rejects an invalid signature id") + void rejectsInvalidId() { + assertThatThrownBy(() -> service.deleteSignature(USER, "../x")) + .isInstanceOf(IllegalArgumentException.class); + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("updateSignatureLabel") + class UpdateSignatureLabel { + + @Test + @DisplayName("updates the label of a personal signature") + void updatesPersonalLabel() throws Exception { + service.saveSignature(USER, imageRequest("sig1", "personal")); + + service.updateSignatureLabel(USER, "sig1", "Renamed"); + + String json = + Files.readString(userFolder(USER).resolve("sig1.json"), StandardCharsets.UTF_8); + SavedSignatureResponse updated = + objectMapper.readValue(json, SavedSignatureResponse.class); + assertThat(updated.getLabel()).isEqualTo("Renamed"); + } + + @Test + @DisplayName("updates the label of a shared signature when no personal one exists") + void updatesSharedLabel() throws Exception { + service.saveSignature(USER, imageRequest("sh1", "shared")); + + service.updateSignatureLabel(USER, "sh1", "SharedRenamed"); + + String json = + Files.readString( + tempDir.resolve("ALL_USERS").resolve("sh1.json"), + StandardCharsets.UTF_8); + SavedSignatureResponse updated = + objectMapper.readValue(json, SavedSignatureResponse.class); + assertThat(updated.getLabel()).isEqualTo("SharedRenamed"); + } + + @Test + @DisplayName("throws when no metadata file exists in either folder") + void throwsWhenMetadataMissing() { + assertThatThrownBy(() -> service.updateSignatureLabel(USER, "nope", "x")) + .isInstanceOf(FileNotFoundException.class) + .hasMessageContaining("metadata not found"); + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("isSharedSignature") + class IsSharedSignature { + + @Test + @DisplayName("true when a shared metadata file exists") + void trueWhenShared() throws Exception { + service.saveSignature(USER, imageRequest("sh1", "shared")); + assertThat(service.isSharedSignature("sh1")).isTrue(); + } + + @Test + @DisplayName("false when no shared metadata file exists") + void falseWhenNotShared() { + assertThat(service.isSharedSignature("sig1")).isFalse(); + } + + @Test + @DisplayName("rejects an invalid signature id") + void rejectsInvalidId() { + assertThatThrownBy(() -> service.isSharedSignature("../x")) + .isInstanceOf(IllegalArgumentException.class); + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("storage limits") + class StorageLimits { + + @Test + @DisplayName("rejects saving once the per-user signature count limit is reached") + void rejectsWhenCountLimitReached() throws Exception { + // Pre-create 20 png files to hit MAX_SIGNATURES_PER_USER + Path folder = userFolder(USER); + Files.createDirectories(folder); + for (int i = 0; i < 20; i++) { + Files.write(folder.resolve("s" + i + ".png"), new byte[] {1}); + } + + SavedSignatureRequest req = imageRequest("overflow", "personal"); + assertThatThrownBy(() -> service.saveSignature(USER, req)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Maximum signatures limit reached"); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceMoreTest.java new file mode 100644 index 0000000000..e99b262ff7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceMoreTest.java @@ -0,0 +1,418 @@ +package stirling.software.proprietary.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.beans.factory.ObjectProvider; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.model.UserLicenseSettings; +import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; +import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker; +import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository; +import stirling.software.proprietary.security.service.UserService; + +/** + * Additional coverage for {@link UserLicenseSettingsService} focusing on initialization, integrity + * signing/validation, license-max-user sync, and slot calculations not covered by the primary test. + * Uses a real {@link ApplicationProperties} so the HMAC integrity signing path runs end to end. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class UserLicenseSettingsServiceMoreTest { + + @Mock private UserLicenseSettingsRepository settingsRepository; + @Mock private UserService userService; + @Mock private LicenseKeyChecker licenseKeyChecker; + @Mock private ObjectProvider licenseKeyCheckerProvider; + + private ApplicationProperties applicationProperties; + private UserLicenseSettingsService service; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + applicationProperties.getAutomaticallyGenerated().setKey("auto-key"); + applicationProperties.getAutomaticallyGenerated().setUUID("auto-uuid"); + + when(settingsRepository.save(any(UserLicenseSettings.class))) + .thenAnswer(inv -> inv.getArgument(0)); + when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(licenseKeyChecker); + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL); + + service = + new UserLicenseSettingsService( + settingsRepository, + userService, + applicationProperties, + licenseKeyCheckerProvider); + } + + // Saves a freshly initialized + locked settings row with a valid signature. + private UserLicenseSettings lockedSettings(int count) { + UserLicenseSettings s = new UserLicenseSettings(); + s.setId(UserLicenseSettings.SINGLETON_ID); + s.setGrandfatheredUserCount(count); + s.setGrandfatheringLocked(true); + s.setIntegritySalt("fixed-salt"); + s.setLicenseMaxUsers(0); + when(settingsRepository.findSettings()).thenReturn(Optional.of(s)); + // First validation has a blank signature; align restore-count with the intended value + // so the generated signature matches, then a real signature is persisted on the row. + when(userService.getTotalUsersCount()).thenReturn((long) count); + service.validateSettingsIntegrity(); + return s; + } + + @Nested + @DisplayName("getOrCreateSettings") + class GetOrCreateSettings { + + @Test + @DisplayName("creates and saves a new settings row when none exists") + void createsWhenMissing() { + when(settingsRepository.findSettings()).thenReturn(Optional.empty()); + + UserLicenseSettings result = service.getOrCreateSettings(); + + assertThat(result.getId()).isEqualTo(UserLicenseSettings.SINGLETON_ID); + assertThat(result.getGrandfatheredUserCount()).isZero(); + assertThat(result.isGrandfatheringLocked()).isFalse(); + assertThat(result.getIntegritySalt()).isNotBlank(); + verify(settingsRepository).save(any(UserLicenseSettings.class)); + } + + @Test + @DisplayName("returns the existing row without creating a new one") + void returnsExisting() { + UserLicenseSettings existing = new UserLicenseSettings(); + existing.setGrandfatheredUserCount(42); + when(settingsRepository.findSettings()).thenReturn(Optional.of(existing)); + + UserLicenseSettings result = service.getOrCreateSettings(); + + assertThat(result.getGrandfatheredUserCount()).isEqualTo(42); + verify(settingsRepository, never()).save(any(UserLicenseSettings.class)); + } + } + + @Nested + @DisplayName("initializeGrandfatheredCount") + class InitializeGrandfatheredCount { + + @Test + @DisplayName("fresh installation locks the default limit of 5") + void freshInstall_locksDefault() { + UserLicenseSettings s = new UserLicenseSettings(); + s.setIntegritySalt("salt"); + when(settingsRepository.findSettings()).thenReturn(Optional.of(s)); + when(userService.getTotalUsersCount()).thenReturn(0L); + + service.initializeGrandfatheredCount(); + + assertThat(s.getGrandfatheredUserCount()).isEqualTo(5); + assertThat(s.isGrandfatheringLocked()).isTrue(); + assertThat(s.getGrandfatheredUserSignature()).isNotBlank(); + } + + @Test + @DisplayName("existing installation grandfathers current user count") + void existingInstall_grandfathersUserCount() { + UserLicenseSettings s = new UserLicenseSettings(); + s.setIntegritySalt("salt"); + when(settingsRepository.findSettings()).thenReturn(Optional.of(s)); + when(userService.getTotalUsersCount()).thenReturn(37L); + + service.initializeGrandfatheredCount(); + + assertThat(s.getGrandfatheredUserCount()).isEqualTo(37); + assertThat(s.isGrandfatheringLocked()).isTrue(); + } + + @Test + @DisplayName("already-locked settings are not re-initialized") + void alreadyLocked_skips() { + UserLicenseSettings s = new UserLicenseSettings(); + s.setIntegritySalt("salt"); + s.setGrandfatheringLocked(true); + s.setGrandfatheredUserCount(99); + s.setGrandfatheredUserSignature("99:existing"); + when(settingsRepository.findSettings()).thenReturn(Optional.of(s)); + + service.initializeGrandfatheredCount(); + + assertThat(s.getGrandfatheredUserCount()).isEqualTo(99); + } + + @Test + @DisplayName("locked settings with blank signature get a fresh signature") + void lockedBlankSignature_isResigned() { + UserLicenseSettings s = new UserLicenseSettings(); + s.setIntegritySalt("salt"); + s.setGrandfatheringLocked(true); + s.setGrandfatheredUserCount(10); + s.setGrandfatheredUserSignature(""); + when(settingsRepository.findSettings()).thenReturn(Optional.of(s)); + + service.initializeGrandfatheredCount(); + + assertThat(s.getGrandfatheredUserSignature()).isNotBlank(); + assertThat(s.getGrandfatheredUserCount()).isEqualTo(10); + } + } + + @Nested + @DisplayName("updateLicenseMaxUsers") + class UpdateLicenseMaxUsers { + + @Test + @DisplayName("no paid license keeps licenseMaxUsers at 0") + void noLicense_keepsZero() { + UserLicenseSettings s = new UserLicenseSettings(); + s.setIntegritySalt("salt"); + s.setLicenseMaxUsers(0); + when(settingsRepository.findSettings()).thenReturn(Optional.of(s)); + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL); + + service.updateLicenseMaxUsers(); + + assertThat(s.getLicenseMaxUsers()).isZero(); + } + + @Test + @DisplayName("paid license copies maxUsers from application properties") + void paidLicense_copiesMaxUsers() { + applicationProperties.getPremium().setMaxUsers(15); + UserLicenseSettings s = new UserLicenseSettings(); + s.setIntegritySalt("salt"); + s.setLicenseMaxUsers(0); + when(settingsRepository.findSettings()).thenReturn(Optional.of(s)); + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE); + + service.updateLicenseMaxUsers(); + + assertThat(s.getLicenseMaxUsers()).isEqualTo(15); + } + + @Test + @DisplayName("no change when value already matches") + void noChange_doesNotSaveAgain() { + applicationProperties.getPremium().setMaxUsers(8); + UserLicenseSettings s = new UserLicenseSettings(); + s.setIntegritySalt("salt"); + s.setLicenseMaxUsers(8); + when(settingsRepository.findSettings()).thenReturn(Optional.of(s)); + when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER); + + service.updateLicenseMaxUsers(); + + assertThat(s.getLicenseMaxUsers()).isEqualTo(8); + // save only happens once during getOrCreateSettings path is bypassed here; never saved + verify(settingsRepository, never()).save(any(UserLicenseSettings.class)); + } + } + + @Nested + @DisplayName("validateSettingsIntegrity") + class ValidateSettingsIntegrity { + + @Test + @DisplayName("missing signature is regenerated from restored count") + void missingSignature_restored() { + UserLicenseSettings s = new UserLicenseSettings(); + s.setIntegritySalt("salt"); + s.setGrandfatheredUserCount(20); + s.setGrandfatheredUserSignature(""); + when(settingsRepository.findSettings()).thenReturn(Optional.of(s)); + when(userService.getTotalUsersCount()).thenReturn(20L); + + service.validateSettingsIntegrity(); + + assertThat(s.getGrandfatheredUserSignature()).isNotBlank(); + assertThat(s.getGrandfatheredUserCount()).isGreaterThanOrEqualTo(5); + } + + @Test + @DisplayName("tampered count below minimum is forced back to 5") + void countBelowMinimum_enforced() { + UserLicenseSettings s = new UserLicenseSettings(); + s.setIntegritySalt("salt"); + s.setGrandfatheredUserCount(2); + s.setGrandfatheredUserSignature(""); + when(settingsRepository.findSettings()).thenReturn(Optional.of(s)); + when(userService.getTotalUsersCount()).thenReturn(0L); + + service.validateSettingsIntegrity(); + + assertThat(s.getGrandfatheredUserCount()).isEqualTo(5); + } + + @Test + @DisplayName("a valid signature is preserved across validation") + void validSignature_preserved() { + UserLicenseSettings s = lockedSettings(30); + String signatureAfterFirst = s.getGrandfatheredUserSignature(); + + service.validateSettingsIntegrity(); + + assertThat(s.getGrandfatheredUserCount()).isEqualTo(30); + assertThat(s.getGrandfatheredUserSignature()).isEqualTo(signatureAfterFirst); + } + + @Test + @DisplayName("count modified without signature update is restored to the signed count") + void countModifiedAfterSigning_restored() { + UserLicenseSettings s = lockedSettings(40); + // Tamper: change count but keep the old (now mismatched) signature. + s.setGrandfatheredUserCount(500); + + service.validateSettingsIntegrity(); + + assertThat(s.getGrandfatheredUserCount()).isEqualTo(40); + } + } + + @Nested + @DisplayName("slot calculations") + class SlotCalculations { + + @Test + @DisplayName("wouldExceedLimit true when adding pushes over the cap") + void wouldExceedLimit_true() { + lockedSettings(5); + when(userService.getTotalUsersCount()).thenReturn(5L); + + boolean result = service.wouldExceedLimit(1); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("wouldExceedLimit false when within the cap") + void wouldExceedLimit_false() { + lockedSettings(10); + when(userService.getTotalUsersCount()).thenReturn(5L); + + boolean result = service.wouldExceedLimit(2); + + assertThat(result).isFalse(); + } + + @Test + @DisplayName("getAvailableUserSlots returns remaining capacity") + void availableSlots_remaining() { + lockedSettings(10); + when(userService.getTotalUsersCount()).thenReturn(4L); + + long slots = service.getAvailableUserSlots(); + + assertThat(slots).isEqualTo(6); + } + + @Test + @DisplayName("getAvailableUserSlots never returns negative") + void availableSlots_clampedToZero() { + lockedSettings(5); + when(userService.getTotalUsersCount()).thenReturn(20L); + + long slots = service.getAvailableUserSlots(); + + assertThat(slots).isZero(); + } + } + + @Nested + @DisplayName("display + accessors") + class DisplayAndAccessors { + + @Test + @DisplayName("display count returns excess over the base limit") + void displayCount_excess() { + UserLicenseSettings s = new UserLicenseSettings(); + s.setIntegritySalt("salt"); + s.setGrandfatheredUserCount(15); + when(settingsRepository.findSettings()).thenReturn(Optional.of(s)); + + int display = service.getDisplayGrandfatheredCount(); + + assertThat(display).isEqualTo(10); + } + + @Test + @DisplayName("display count is zero when at the base limit") + void displayCount_zeroAtBase() { + UserLicenseSettings s = new UserLicenseSettings(); + s.setIntegritySalt("salt"); + s.setGrandfatheredUserCount(5); + when(settingsRepository.findSettings()).thenReturn(Optional.of(s)); + + int display = service.getDisplayGrandfatheredCount(); + + assertThat(display).isZero(); + } + + @Test + @DisplayName("getSettings delegates to getOrCreateSettings") + void getSettings_delegates() { + UserLicenseSettings s = new UserLicenseSettings(); + s.setIntegritySalt("salt"); + s.setGrandfatheredUserCount(7); + when(settingsRepository.findSettings()).thenReturn(Optional.of(s)); + + UserLicenseSettings result = service.getSettings(); + + assertThat(result.getGrandfatheredUserCount()).isEqualTo(7); + } + } + + @Nested + @DisplayName("grandfatherExistingOAuthUsers - new-server guard") + class GrandfatherNewServerGuard { + + @Test + @DisplayName("fresh V2 install skips OAuth grandfathering") + void freshV2_skips() { + applicationProperties.getAutomaticallyGenerated().setIsNewServer(true); + + service.grandfatherExistingOAuthUsers(); + + verify(userService, never()).grandfatherAllOAuthUsers(); + verify(userService, never()).grandfatherPendingSsoUsersWithoutSession(); + } + + @Test + @DisplayName("upgrade install runs OAuth grandfathering when none grandfathered yet") + void upgrade_runsGrandfathering() { + applicationProperties.getAutomaticallyGenerated().setIsNewServer(false); + UserLicenseSettings s = new UserLicenseSettings(); + s.setId(UserLicenseSettings.SINGLETON_ID); + s.setIntegritySalt("salt"); + s.setGrandfatheringLocked(true); + when(settingsRepository.findSettings()).thenReturn(Optional.of(s)); + when(userService.countOAuthUsers()).thenReturn(6L); + when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L); + when(userService.grandfatherAllOAuthUsers()).thenReturn(6); + when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(1); + + service.grandfatherExistingOAuthUsers(); + + verify(userService, times(1)).grandfatherAllOAuthUsers(); + verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession(); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/FileStorageControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/FileStorageControllerMoreTest.java new file mode 100644 index 0000000000..1575dfbff5 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/controller/FileStorageControllerMoreTest.java @@ -0,0 +1,417 @@ +package stirling.software.proprietary.storage.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.FileShare; +import stirling.software.proprietary.storage.model.ShareAccessRole; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.model.api.CreateShareLinkRequest; +import stirling.software.proprietary.storage.model.api.ShareLinkMetadataResponse; +import stirling.software.proprietary.storage.model.api.ShareLinkResponse; +import stirling.software.proprietary.storage.model.api.ShareWithUserRequest; +import stirling.software.proprietary.storage.model.api.StoredFileResponse; +import stirling.software.proprietary.storage.provider.StorageProvider; +import stirling.software.proprietary.storage.service.FileStorageService; + +// Drives controller handlers directly (no MockMvc) to cover the non-download endpoints. +@ExtendWith(MockitoExtension.class) +class FileStorageControllerMoreTest { + + @Mock private FileStorageService fileStorageService; + @Mock private StorageProvider storageProvider; + + private FileStorageController controller; + + @BeforeEach + void setUp() { + controller = new FileStorageController(fileStorageService, storageProvider); + } + + private User user() { + User u = new User(); + u.setId(11L); + u.setUsername("alice"); + return u; + } + + private StoredFile storedFile() { + StoredFile f = new StoredFile(); + f.setId(77L); + f.setOwner(user()); + f.setOriginalFilename("doc.pdf"); + f.setContentType("application/pdf"); + f.setSizeBytes(123L); + f.setStorageKey("11/abc-doc.pdf"); + return f; + } + + private Authentication auth(User u) { + return new UsernamePasswordAuthenticationToken(u, "n/a", List.of()); + } + + // ------------------------------------------------------------------------- + // uploadFile / updateFile / list / getFileMetadata + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("CRUD delegation") + class Crud { + + @Test + void uploadFile_delegatesToService() { + User u = user(); + MultipartFile file = mock(MultipartFile.class); + StoredFileResponse resp = StoredFileResponse.builder().id(1L).build(); + when(fileStorageService.requireAuthenticatedUser()).thenReturn(u); + when(fileStorageService.storeFileResponse(u, file, null, null)).thenReturn(resp); + + assertThat(controller.uploadFile(file, null, null)).isSameAs(resp); + } + + @Test + void updateFile_delegatesToService() { + User u = user(); + MultipartFile file = mock(MultipartFile.class); + StoredFileResponse resp = StoredFileResponse.builder().id(2L).build(); + when(fileStorageService.requireAuthenticatedUser()).thenReturn(u); + when(fileStorageService.updateFileResponse(u, 5L, file, null, null)).thenReturn(resp); + + assertThat(controller.updateFile(5L, file, null, null)).isSameAs(resp); + } + + @Test + void listFiles_delegatesToService() { + User u = user(); + StoredFileResponse resp = StoredFileResponse.builder().id(3L).build(); + when(fileStorageService.requireAuthenticatedUser()).thenReturn(u); + when(fileStorageService.listAccessibleFileResponses(u)).thenReturn(List.of(resp)); + + assertThat(controller.listFiles()).containsExactly(resp); + } + + @Test + void getFileMetadata_delegatesToService() { + User u = user(); + StoredFileResponse resp = StoredFileResponse.builder().id(77L).build(); + when(fileStorageService.requireAuthenticatedUser()).thenReturn(u); + when(fileStorageService.getAccessibleFileResponse(u, 77L)).thenReturn(resp); + + assertThat(controller.getFileMetadata(77L)).isSameAs(resp); + } + } + + // ------------------------------------------------------------------------- + // downloadFile streaming fallback (signed URL absent) + // ------------------------------------------------------------------------- + + @Test + @DisplayName("downloadFile streams when no signed URL is available") + void downloadFile_noSignedUrl_streamsContent() throws Exception { + User u = user(); + StoredFile f = storedFile(); + Resource resource = new ByteArrayResource(new byte[] {1, 2, 3}); + when(fileStorageService.requireAuthenticatedUser()).thenReturn(u); + when(fileStorageService.getAccessibleFile(u, 77L)).thenReturn(f); + when(storageProvider.signedDownloadUrl( + eq("11/abc-doc.pdf"), any(Duration.class), anyBoolean(), anyString())) + .thenReturn(Optional.empty()); + when(fileStorageService.loadFile(f)).thenReturn(resource); + + ResponseEntity response = controller.downloadFile(77L, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isSameAs(resource); + verify(fileStorageService).requireReadAccess(u, f); + } + + // ------------------------------------------------------------------------- + // deleteFile + // ------------------------------------------------------------------------- + + @Test + @DisplayName("deleteFile returns 204 and invokes service delete") + void deleteFile_returnsNoContent() { + User u = user(); + StoredFile f = storedFile(); + when(fileStorageService.requireAuthenticatedUser()).thenReturn(u); + when(fileStorageService.getOwnedFile(u, 77L)).thenReturn(f); + + ResponseEntity response = controller.deleteFile(77L); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(fileStorageService).deleteFile(u, f); + } + + // ------------------------------------------------------------------------- + // shareWithUser + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("shareWithUser") + class ShareWithUserHandler { + + @Test + void nullRequest_throwsBadRequest() { + when(fileStorageService.requireAuthenticatedUser()).thenReturn(user()); + + assertThatThrownBy(() -> controller.shareWithUser(77L, null)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400); + } + + @Test + void blankUsername_throwsBadRequest() { + when(fileStorageService.requireAuthenticatedUser()).thenReturn(user()); + ShareWithUserRequest req = new ShareWithUserRequest(); + req.setUsername(" "); + + assertThatThrownBy(() -> controller.shareWithUser(77L, req)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400); + } + + @Test + void validRequest_delegatesToService() { + User u = user(); + ShareWithUserRequest req = new ShareWithUserRequest(); + req.setUsername("bob"); + req.setAccessRole("viewer"); + StoredFileResponse resp = StoredFileResponse.builder().id(77L).build(); + when(fileStorageService.requireAuthenticatedUser()).thenReturn(u); + when(fileStorageService.normalizeShareRole("viewer")) + .thenReturn(ShareAccessRole.VIEWER); + when(fileStorageService.shareWithUserResponse(u, 77L, "bob", ShareAccessRole.VIEWER)) + .thenReturn(resp); + + assertThat(controller.shareWithUser(77L, req)).isSameAs(resp); + } + } + + // ------------------------------------------------------------------------- + // revokeUserShare / leaveUserShare + // ------------------------------------------------------------------------- + + @Test + @DisplayName("revokeUserShare returns 204") + void revokeUserShare_returnsNoContent() { + User u = user(); + StoredFile f = storedFile(); + when(fileStorageService.requireAuthenticatedUser()).thenReturn(u); + when(fileStorageService.getOwnedFile(u, 77L)).thenReturn(f); + + ResponseEntity response = controller.revokeUserShare(77L, "bob"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(fileStorageService).revokeUserShare(u, f, "bob"); + } + + @Test + @DisplayName("leaveUserShare returns 204") + void leaveUserShare_returnsNoContent() { + User u = user(); + StoredFile f = storedFile(); + when(fileStorageService.requireAuthenticatedUser()).thenReturn(u); + when(fileStorageService.getAccessibleFile(u, 77L)).thenReturn(f); + + ResponseEntity response = controller.leaveUserShare(77L); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(fileStorageService).leaveUserShare(u, f); + } + + // ------------------------------------------------------------------------- + // createShareLink / revokeShareLink + // ------------------------------------------------------------------------- + + @Test + @DisplayName("createShareLink maps share to response DTO") + void createShareLink_returnsTokenResponse() { + User u = user(); + StoredFile f = storedFile(); + CreateShareLinkRequest req = new CreateShareLinkRequest(); + req.setAccessRole("viewer"); + FileShare share = new FileShare(); + share.setShareToken("tok-123"); + share.setAccessRole(ShareAccessRole.VIEWER); + when(fileStorageService.requireAuthenticatedUser()).thenReturn(u); + when(fileStorageService.getOwnedFile(u, 77L)).thenReturn(f); + when(fileStorageService.normalizeShareRole("viewer")).thenReturn(ShareAccessRole.VIEWER); + when(fileStorageService.createShareLink(u, f, ShareAccessRole.VIEWER)).thenReturn(share); + + ShareLinkResponse response = controller.createShareLink(77L, req); + + assertThat(response.getToken()).isEqualTo("tok-123"); + assertThat(response.getAccessRole()).isEqualTo("viewer"); + } + + @Test + @DisplayName("revokeShareLink returns 204") + void revokeShareLink_returnsNoContent() { + User u = user(); + StoredFile f = storedFile(); + when(fileStorageService.requireAuthenticatedUser()).thenReturn(u); + when(fileStorageService.getOwnedFile(u, 77L)).thenReturn(f); + + ResponseEntity response = controller.revokeShareLink(77L, "tok"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(fileStorageService).revokeShareLink(u, f, "tok"); + } + + // ------------------------------------------------------------------------- + // downloadShareLink + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("downloadShareLink") + class DownloadShareLink { + + @Test + void accessDenied_authenticated_throwsForbidden() { + FileShare share = new FileShare(); + share.setFile(storedFile()); + Authentication authentication = auth(user()); + when(fileStorageService.getShareByToken("tok")).thenReturn(share); + when(fileStorageService.canAccessShareLink(share, authentication)).thenReturn(false); + + assertThatThrownBy(() -> controller.downloadShareLink("tok", authentication, false)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403); + } + + @Test + void accessDenied_anonymous_throwsUnauthorized() { + FileShare share = new FileShare(); + share.setFile(storedFile()); + when(fileStorageService.getShareByToken("tok")).thenReturn(share); + when(fileStorageService.canAccessShareLink(share, null)).thenReturn(false); + + assertThatThrownBy(() -> controller.downloadShareLink("tok", null, false)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(401); + } + + @Test + void granted_streamsContent() throws Exception { + StoredFile f = storedFile(); + FileShare share = new FileShare(); + share.setFile(f); + Authentication authentication = auth(user()); + Resource resource = new ByteArrayResource(new byte[] {9}); + when(fileStorageService.getShareByToken("tok")).thenReturn(share); + when(fileStorageService.canAccessShareLink(share, authentication)).thenReturn(true); + when(storageProvider.signedDownloadUrl( + eq("11/abc-doc.pdf"), any(Duration.class), anyBoolean(), anyString())) + .thenReturn(Optional.empty()); + when(fileStorageService.loadFile(f)).thenReturn(resource); + + ResponseEntity response = + controller.downloadShareLink("tok", authentication, false); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(fileStorageService).recordShareAccess(share, authentication, false); + verify(fileStorageService).requireReadAccess(share); + } + } + + // ------------------------------------------------------------------------- + // getShareLinkMetadata + // ------------------------------------------------------------------------- + + @Test + @DisplayName("getShareLinkMetadata returns metadata for owner") + void getShareLinkMetadata_returnsMetadata() { + User owner = user(); + StoredFile f = storedFile(); + FileShare share = new FileShare(); + share.setFile(f); + share.setShareToken("tok"); + share.setAccessRole(ShareAccessRole.VIEWER); + Authentication authentication = auth(owner); + when(fileStorageService.getShareByToken("tok")).thenReturn(share); + when(fileStorageService.canAccessShareLink(share, authentication)).thenReturn(true); + when(fileStorageService.requireAuthenticatedUser()).thenReturn(owner); + + ShareLinkMetadataResponse response = controller.getShareLinkMetadata("tok", authentication); + + assertThat(response.getShareToken()).isEqualTo("tok"); + assertThat(response.getFileId()).isEqualTo(77L); + assertThat(response.isOwnedByCurrentUser()).isTrue(); + } + + @Test + @DisplayName("getShareLinkMetadata denied for anonymous throws 401") + void getShareLinkMetadata_anonymousDenied_throwsUnauthorized() { + FileShare share = new FileShare(); + share.setFile(storedFile()); + when(fileStorageService.getShareByToken("tok")).thenReturn(share); + when(fileStorageService.canAccessShareLink(share, null)).thenReturn(false); + + assertThatThrownBy(() -> controller.getShareLinkMetadata("tok", null)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(401); + } + + // ------------------------------------------------------------------------- + // listAccessedShareLinks / listShareAccesses + // ------------------------------------------------------------------------- + + @Test + @DisplayName("listAccessedShareLinks delegates to service") + void listAccessedShareLinks_delegates() { + User u = user(); + ShareLinkMetadataResponse meta = + ShareLinkMetadataResponse.builder().shareToken("t").build(); + when(fileStorageService.requireAuthenticatedUser()).thenReturn(u); + when(fileStorageService.listAccessedShareLinkResponses(u)).thenReturn(List.of(meta)); + + assertThat(controller.listAccessedShareLinks()).containsExactly(meta); + } + + @Test + @DisplayName("listShareAccesses delegates to service") + void listShareAccesses_delegates() { + User u = user(); + StoredFile f = storedFile(); + when(fileStorageService.requireAuthenticatedUser()).thenReturn(u); + when(fileStorageService.getOwnedFile(u, 77L)).thenReturn(f); + when(fileStorageService.listShareAccessResponses(u, f, "tok")).thenReturn(List.of()); + + assertThat(controller.listShareAccesses(77L, "tok")).isEmpty(); + verify(fileStorageService).ensureShareLinksEnabled(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FileStorageServiceMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FileStorageServiceMoreTest.java new file mode 100644 index 0000000000..d254d559ee --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FileStorageServiceMoreTest.java @@ -0,0 +1,773 @@ +package stirling.software.proprietary.storage.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.Resource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.FilePurpose; +import stirling.software.proprietary.storage.model.FileShare; +import stirling.software.proprietary.storage.model.FileShareAccess; +import stirling.software.proprietary.storage.model.ShareAccessRole; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.provider.StorageProvider; +import stirling.software.proprietary.storage.repository.FileShareAccessRepository; +import stirling.software.proprietary.storage.repository.FileShareRepository; +import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository; +import stirling.software.proprietary.storage.repository.StoredFileRepository; +import stirling.software.proprietary.workflow.model.WorkflowSession; + +// Covers gaps not exercised by FileStorageServiceTest: enabled-guards, share-link access, +// workflow helpers, and validation branches. +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class FileStorageServiceMoreTest { + + @Mock private StoredFileRepository storedFileRepository; + @Mock private FileShareRepository fileShareRepository; + @Mock private FileShareAccessRepository fileShareAccessRepository; + @Mock private UserRepository userRepository; + @Mock private ApplicationProperties applicationProperties; + @Mock private StorageProvider storageProvider; + @Mock private StorageCleanupEntryRepository storageCleanupEntryRepository; + + @Mock private ApplicationProperties.Security securityProperties; + @Mock private ApplicationProperties.System systemProperties; + @Mock private ApplicationProperties.Storage storageProperties; + @Mock private ApplicationProperties.Storage.Sharing sharingProperties; + + private FileStorageService service; + + @BeforeEach + void setUp() { + service = + new FileStorageService( + storedFileRepository, + fileShareRepository, + fileShareAccessRepository, + userRepository, + applicationProperties, + storageProvider, + Optional.empty(), + storageCleanupEntryRepository); + + when(applicationProperties.getSecurity()).thenReturn(securityProperties); + when(securityProperties.isEnableLogin()).thenReturn(true); + when(applicationProperties.getStorage()).thenReturn(storageProperties); + when(storageProperties.isEnabled()).thenReturn(true); + when(storageProperties.getSharing()).thenReturn(sharingProperties); + when(sharingProperties.isEnabled()).thenReturn(true); + when(sharingProperties.isLinkEnabled()).thenReturn(true); + when(applicationProperties.getSystem()).thenReturn(systemProperties); + when(systemProperties.getFrontendUrl()).thenReturn("http://localhost:8080"); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private User user(long id) { + User u = new User(); + u.setId(id); + u.setUsername("user" + id); + return u; + } + + private StoredFile ownedFile(User owner) { + StoredFile f = new StoredFile(); + f.setId(100L); + f.setOwner(owner); + f.setOriginalFilename("test.pdf"); + return f; + } + + private FileShare shareFor(StoredFile file, User user, ShareAccessRole role) { + FileShare s = new FileShare(); + s.setFile(file); + s.setSharedWithUser(user); + s.setAccessRole(role); + return s; + } + + private Authentication authFor(User user) { + return new UsernamePasswordAuthenticationToken(user, "n/a", List.of()); + } + + // ------------------------------------------------------------------------- + // ensureStorageEnabled / ensureSharingEnabled / ensureShareLinksEnabled + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("enabled guards") + class EnabledGuards { + + @Test + void ensureStorageEnabled_loginDisabled_throwsForbidden() { + when(securityProperties.isEnableLogin()).thenReturn(false); + + assertThatThrownBy(() -> service.ensureStorageEnabled()) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403); + } + + @Test + void ensureStorageEnabled_storageDisabled_throwsForbidden() { + when(storageProperties.isEnabled()).thenReturn(false); + + assertThatThrownBy(() -> service.ensureStorageEnabled()) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403); + } + + @Test + void ensureSharingEnabled_sharingDisabled_throwsForbidden() { + when(sharingProperties.isEnabled()).thenReturn(false); + + assertThatThrownBy(() -> service.ensureSharingEnabled()) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403); + } + + @Test + void ensureShareLinksEnabled_linksDisabled_throwsForbidden() { + when(sharingProperties.isLinkEnabled()).thenReturn(false); + + assertThatThrownBy(() -> service.ensureShareLinksEnabled()) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403); + } + } + + // ------------------------------------------------------------------------- + // requireAuthenticatedUser + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("requireAuthenticatedUser") + class RequireAuthenticatedUser { + + @Test + void noAuthentication_throwsUnauthorized() { + SecurityContextHolder.clearContext(); + + assertThatThrownBy(() -> service.requireAuthenticatedUser()) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(401); + } + + @Test + void userPrincipal_returnsUser() { + User u = user(5L); + SecurityContextHolder.getContext().setAuthentication(authFor(u)); + try { + assertThat(service.requireAuthenticatedUser()).isSameAs(u); + } finally { + SecurityContextHolder.clearContext(); + } + } + + @Test + void nonUserPrincipal_throwsUnauthorized() { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + "plainString", "n/a", List.of())); + try { + assertThatThrownBy(() -> service.requireAuthenticatedUser()) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(401); + } finally { + SecurityContextHolder.clearContext(); + } + } + } + + // ------------------------------------------------------------------------- + // leaveUserShare + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("leaveUserShare") + class LeaveUserShare { + + @Test + void ownerCannotLeave_throwsForbidden() { + User owner = user(1L); + StoredFile f = ownedFile(owner); + + assertThatThrownBy(() -> service.leaveUserShare(owner, f)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403); + } + + @Test + void shareNotFound_throwsNotFound() { + User owner = user(1L); + User requester = user(2L); + StoredFile f = ownedFile(owner); + when(fileShareRepository.findByFileAndSharedWithUser(f, requester)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.leaveUserShare(requester, f)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(404); + } + + @Test + void existingShare_deleted() { + User owner = user(1L); + User requester = user(2L); + StoredFile f = ownedFile(owner); + FileShare share = shareFor(f, requester, ShareAccessRole.VIEWER); + when(fileShareRepository.findByFileAndSharedWithUser(f, requester)) + .thenReturn(Optional.of(share)); + + service.leaveUserShare(requester, f); + + verify(fileShareRepository).delete(share); + } + } + + // ------------------------------------------------------------------------- + // getShareByToken + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("getShareByToken") + class GetShareByToken { + + @Test + void validToken_returnsShare() { + FileShare share = new FileShare(); + share.setShareToken("t"); + when(fileShareRepository.findByShareTokenWithFile("t")).thenReturn(Optional.of(share)); + + assertThat(service.getShareByToken("t")).isSameAs(share); + } + + @Test + void notFound_throwsNotFound() { + when(fileShareRepository.findByShareTokenWithFile("x")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getShareByToken("x")) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(404); + } + + @Test + void expiredToken_throwsNotFound() { + FileShare share = new FileShare(); + share.setShareToken("t"); + share.setExpiresAt(LocalDateTime.now().minusDays(1)); + when(fileShareRepository.findByShareTokenWithFile("t")).thenReturn(Optional.of(share)); + + assertThatThrownBy(() -> service.getShareByToken("t")) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(404); + } + } + + // ------------------------------------------------------------------------- + // canAccessShareLink (IDOR protection) + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("canAccessShareLink") + class CanAccessShareLink { + + @Test + void linksDisabled_returnsFalse() { + when(sharingProperties.isLinkEnabled()).thenReturn(false); + FileShare share = new FileShare(); + + assertThat(service.canAccessShareLink(share, authFor(user(1L)))).isFalse(); + } + + @Test + void expiredShare_returnsFalse() { + FileShare share = new FileShare(); + share.setExpiresAt(LocalDateTime.now().minusDays(1)); + + assertThat(service.canAccessShareLink(share, authFor(user(1L)))).isFalse(); + } + + @Test + void unauthenticated_returnsFalse() { + FileShare share = new FileShare(); + + assertThat(service.canAccessShareLink(share, null)).isFalse(); + } + + @Test + void anonymousPrincipal_returnsFalse() { + FileShare share = new FileShare(); + Authentication anon = + new UsernamePasswordAuthenticationToken("anonymousUser", "n/a", List.of()); + + assertThat(service.canAccessShareLink(share, anon)).isFalse(); + } + + @Test + void publicShare_authenticatedUser_returnsTrue() { + FileShare share = new FileShare(); + + assertThat(service.canAccessShareLink(share, authFor(user(1L)))).isTrue(); + } + + @Test + void userSpecificShare_otherUser_returnsFalse() { + User owner = user(1L); + User intended = user(2L); + User intruder = user(3L); + StoredFile f = ownedFile(owner); + FileShare share = shareFor(f, intended, ShareAccessRole.VIEWER); + + assertThat(service.canAccessShareLink(share, authFor(intruder))).isFalse(); + } + + @Test + void userSpecificShare_intendedRecipient_returnsTrue() { + User owner = user(1L); + User intended = user(2L); + StoredFile f = ownedFile(owner); + FileShare share = shareFor(f, intended, ShareAccessRole.VIEWER); + + assertThat(service.canAccessShareLink(share, authFor(intended))).isTrue(); + } + + @Test + void userSpecificShare_fileOwner_returnsTrue() { + User owner = user(1L); + User intended = user(2L); + StoredFile f = ownedFile(owner); + FileShare share = shareFor(f, intended, ShareAccessRole.VIEWER); + + assertThat(service.canAccessShareLink(share, authFor(owner))).isTrue(); + } + } + + // ------------------------------------------------------------------------- + // recordShareAccess + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("recordShareAccess") + class RecordShareAccess { + + @Test + void nullShare_noOp() { + service.recordShareAccess(null, authFor(user(1L)), true); + verify(fileShareAccessRepository, never()).save(any()); + } + + @Test + void expiredShare_noOp() { + FileShare share = new FileShare(); + share.setExpiresAt(LocalDateTime.now().minusDays(1)); + + service.recordShareAccess(share, authFor(user(1L)), true); + + verify(fileShareAccessRepository, never()).save(any()); + } + + @Test + void authenticatedUser_savesAccessRecord() { + FileShare share = new FileShare(); + + service.recordShareAccess(share, authFor(user(1L)), false); + + verify(fileShareAccessRepository).save(any(FileShareAccess.class)); + } + } + + // ------------------------------------------------------------------------- + // listShareAccesses / listShareAccessResponses + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("listShareAccesses") + class ListShareAccesses { + + @Test + void nonOwner_throwsForbidden() { + User owner = user(1L); + User intruder = user(2L); + StoredFile f = ownedFile(owner); + + assertThatThrownBy(() -> service.listShareAccesses(intruder, f, "t")) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403); + } + + @Test + void tokenNotFound_throwsNotFound() { + User owner = user(1L); + StoredFile f = ownedFile(owner); + when(fileShareRepository.findByShareToken("t")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.listShareAccesses(owner, f, "t")) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(404); + } + + @Test + void tokenMismatch_throwsForbidden() { + User owner = user(1L); + StoredFile f = ownedFile(owner); + f.setId(1L); + StoredFile other = ownedFile(owner); + other.setId(2L); + FileShare share = shareFor(other, null, ShareAccessRole.VIEWER); + share.setShareToken("t"); + when(fileShareRepository.findByShareToken("t")).thenReturn(Optional.of(share)); + + assertThatThrownBy(() -> service.listShareAccesses(owner, f, "t")) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403); + } + + @Test + void ownerValidToken_returnsAccessResponses() { + User owner = user(1L); + StoredFile f = ownedFile(owner); + FileShare share = shareFor(f, null, ShareAccessRole.VIEWER); + share.setShareToken("t"); + when(fileShareRepository.findByShareToken("t")).thenReturn(Optional.of(share)); + FileShareAccess access = new FileShareAccess(); + access.setUser(user(2L)); + access.setAccessType( + stirling.software.proprietary.storage.model.FileShareAccessType.VIEW); + when(fileShareAccessRepository.findByFileShareWithUserOrderByAccessedAtDesc(share)) + .thenReturn(List.of(access)); + + assertThat(service.listShareAccessResponses(owner, f, "t")).hasSize(1); + } + } + + // ------------------------------------------------------------------------- + // normalizeShareRole + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("normalizeShareRole") + class NormalizeShareRole { + + @Test + void nullRole_defaultsToEditor() { + assertThat(service.normalizeShareRole(null)).isEqualTo(ShareAccessRole.EDITOR); + } + + @Test + void blankRole_defaultsToEditor() { + assertThat(service.normalizeShareRole(" ")).isEqualTo(ShareAccessRole.EDITOR); + } + + @Test + void validLowercaseRole_parsed() { + assertThat(service.normalizeShareRole("viewer")).isEqualTo(ShareAccessRole.VIEWER); + } + + @Test + void invalidRole_throwsBadRequest() { + assertThatThrownBy(() -> service.normalizeShareRole("bogus")) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400); + } + } + + // ------------------------------------------------------------------------- + // requireEditorAccess / requireReadAccess (FileShare overloads) + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("FileShare access overloads") + class FileShareAccessOverloads { + + @Test + void requireEditorAccess_editorShare_passes() { + FileShare share = new FileShare(); + share.setAccessRole(ShareAccessRole.EDITOR); + + assertThatCode(() -> service.requireEditorAccess(share)).doesNotThrowAnyException(); + } + + @Test + void requireEditorAccess_viewerShare_throwsForbidden() { + FileShare share = new FileShare(); + share.setAccessRole(ShareAccessRole.VIEWER); + + assertThatThrownBy(() -> service.requireEditorAccess(share)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403); + } + + @Test + void requireReadAccess_nullRoleShare_defaultsEditorAndPasses() { + // resolveShareRole maps null role to EDITOR which has read access + FileShare share = new FileShare(); + + assertThatCode(() -> service.requireReadAccess(share)).doesNotThrowAnyException(); + } + } + + // ------------------------------------------------------------------------- + // validateMainUpload (blocked content types via storeFile) + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("upload validation") + class UploadValidation { + + @Test + void emptyFile_throwsBadRequest() { + User owner = user(1L); + MockMultipartFile empty = + new MockMultipartFile("file", "f.pdf", "application/pdf", new byte[0]); + + assertThatThrownBy(() -> service.storeFile(owner, empty)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400); + } + + @Test + void blockedContentType_throwsBadRequest() { + User owner = user(1L); + MockMultipartFile jar = + new MockMultipartFile( + "file", "evil.jar", "application/java-archive", new byte[] {1}); + + assertThatThrownBy(() -> service.storeFile(owner, jar)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400); + } + } + + // ------------------------------------------------------------------------- + // getOwnedFile + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("getOwnedFile") + class GetOwnedFile { + + @Test + void found_returnsFile() { + User owner = user(1L); + StoredFile f = ownedFile(owner); + when(storedFileRepository.findByIdAndOwnerWithShares(100L, owner)) + .thenReturn(Optional.of(f)); + + assertThat(service.getOwnedFile(owner, 100L)).isSameAs(f); + } + + @Test + void notFound_throwsNotFound() { + User owner = user(1L); + when(storedFileRepository.findByIdAndOwnerWithShares(99L, owner)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getOwnedFile(owner, 99L)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(404); + } + } + + // ------------------------------------------------------------------------- + // loadFile + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("loadFile") + class LoadFile { + + @Test + void providerReturnsResource_returnsIt() throws IOException { + StoredFile f = new StoredFile(); + f.setStorageKey("k"); + Resource resource = mock(Resource.class); + when(storageProvider.load("k")).thenReturn(resource); + + assertThat(service.loadFile(f)).isSameAs(resource); + } + + @Test + void providerThrowsIo_throwsInternalServerError() throws IOException { + StoredFile f = new StoredFile(); + f.setStorageKey("k"); + when(storageProvider.load("k")).thenThrow(new IOException("boom")); + + assertThatThrownBy(() -> service.loadFile(f)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(500); + } + } + + // ------------------------------------------------------------------------- + // Workflow-aware helpers + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("workflow helpers") + class WorkflowHelpers { + + @Test + void storeWorkflowFile_setsPurposeAndSession() throws IOException { + when(storageProperties.getQuotas()).thenReturn(null); + User owner = user(1L); + MockMultipartFile file = + new MockMultipartFile("file", "f.pdf", "application/pdf", new byte[] {1}); + when(storageProvider.store(any(), any())) + .thenReturn( + stirling.software.proprietary.storage.provider.StoredObject.builder() + .storageKey("k") + .originalFilename("f.pdf") + .contentType("application/pdf") + .sizeBytes(1L) + .build()); + when(storedFileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + WorkflowSession session = new WorkflowSession(); + + StoredFile result = + service.storeWorkflowFile(owner, file, FilePurpose.SIGNING_ORIGINAL, session); + + assertThat(result.getPurpose()).isEqualTo(FilePurpose.SIGNING_ORIGINAL); + assertThat(result.getWorkflowSession()).isSameAs(session); + } + + @Test + void isWorkflowFile_noSession_false() { + StoredFile f = new StoredFile(); + + assertThat(service.isWorkflowFile(f)).isFalse(); + } + + @Test + void isWorkflowFile_activeSession_true() { + StoredFile f = new StoredFile(); + WorkflowSession session = mock(WorkflowSession.class); + when(session.isActive()).thenReturn(true); + f.setWorkflowSession(session); + + assertThat(service.isWorkflowFile(f)).isTrue(); + } + + @Test + void getWorkflowFiles_delegatesToRepository() { + WorkflowSession session = new WorkflowSession(); + StoredFile f = new StoredFile(); + when(storedFileRepository.findByWorkflowSession(session)).thenReturn(List.of(f)); + + assertThat(service.getWorkflowFiles(session)).containsExactly(f); + } + + @Test + void countWorkflowStorageBytes_sumsSizes() { + WorkflowSession session = new WorkflowSession(); + StoredFile a = new StoredFile(); + a.setSizeBytes(10L); + StoredFile b = new StoredFile(); + b.setSizeBytes(15L); + when(storedFileRepository.findByWorkflowSession(session)).thenReturn(List.of(a, b)); + + assertThat(service.countWorkflowStorageBytes(session)).isEqualTo(25L); + } + + @Test + void validateWorkflowDeletion_activeWorkflow_throwsBadRequest() { + StoredFile f = new StoredFile(); + WorkflowSession session = mock(WorkflowSession.class); + when(session.isActive()).thenReturn(true); + f.setWorkflowSession(session); + + assertThatThrownBy(() -> service.validateWorkflowDeletion(f, user(1L))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400); + } + } + + // ------------------------------------------------------------------------- + // listAccessibleFileResponses + // ------------------------------------------------------------------------- + + @Test + @DisplayName("listAccessibleFileResponses sorts newest-first and resolves roles") + void listAccessibleFileResponses_returnsSortedResponses() { + User user = user(2L); + User owner = user(1L); + StoredFile older = ownedFile(owner); + older.setId(10L); + older.setCreatedAt(LocalDateTime.now().minusDays(2)); + StoredFile newer = ownedFile(owner); + newer.setId(11L); + newer.setCreatedAt(LocalDateTime.now().minusDays(1)); + when(storedFileRepository.findAccessibleFiles(user)).thenReturn(List.of(older, newer)); + FileShare share = shareFor(newer, user, ShareAccessRole.VIEWER); + when(fileShareRepository.findBySharedWithUserAndFileIn(user, List.of(older, newer))) + .thenReturn(List.of(share)); + + var responses = service.listAccessibleFileResponses(user); + + assertThat(responses).hasSize(2); + // newest first + assertThat(responses.get(0).getId()).isEqualTo(11L); + assertThat(responses.get(0).getAccessRole()).isEqualTo("viewer"); + } + + @Test + @DisplayName("listAccessibleFiles delegates to repository") + void listAccessibleFiles_delegates() { + User user = user(1L); + StoredFile f = ownedFile(user); + when(storedFileRepository.findAccessibleFiles(user)).thenReturn(List.of(f)); + + assertThat(service.listAccessibleFiles(user)).containsExactly(f); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/SigningSessionControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/SigningSessionControllerTest.java new file mode 100644 index 0000000000..7fa3755371 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/SigningSessionControllerTest.java @@ -0,0 +1,676 @@ +package stirling.software.proprietary.workflow.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.security.Principal; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; +import stirling.software.proprietary.workflow.dto.CertificateInfo; +import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest; +import stirling.software.proprietary.workflow.model.WorkflowSession; +import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator; +import stirling.software.proprietary.workflow.service.SigningFinalizationService; +import stirling.software.proprietary.workflow.service.WorkflowSessionService; + +// Direct handler invocations (no MockMvc) for SigningSessionController; previously 0% covered. +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class SigningSessionControllerTest { + + @Mock private WorkflowSessionService workflowSessionService; + @Mock private UserService userService; + @Mock private SigningFinalizationService signingFinalizationService; + @Mock private CertificateSubmissionValidator certificateSubmissionValidator; + + private SigningSessionController controller; + + @BeforeEach + void setUp() { + controller = + new SigningSessionController( + workflowSessionService, + userService, + signingFinalizationService, + certificateSubmissionValidator); + } + + private Principal principal(String name) { + return () -> name; + } + + private User user(String username) { + User u = new User(); + u.setId(1L); + u.setUsername(username); + return u; + } + + private WorkflowSession ownedSession(String id, User owner) { + WorkflowSession s = new WorkflowSession(); + s.setSessionId(id); + s.setOwner(owner); + s.setDocumentName("doc.pdf"); + s.setParticipants(new ArrayList<>()); + return s; + } + + // ------------------------------------------------------------------------- + // Unauthenticated (null principal) branches + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("null principal returns 401") + class NullPrincipal { + + @Test + void listSessions_unauthenticated() { + assertThat(controller.listSessions(null).getStatusCode()) + .isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void getSession_unauthenticated() { + assertThat(controller.getSession("s1", null).getStatusCode()) + .isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void deleteSession_unauthenticated() { + assertThat(controller.deleteSession("s1", null).getStatusCode()) + .isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void addParticipants_unauthenticated() { + assertThat(controller.addParticipants("s1", List.of(), null).getStatusCode()) + .isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void getSessionPdf_unauthenticated() { + assertThat(controller.getSessionPdf("s1", null).getStatusCode()) + .isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void listSignRequests_unauthenticated() { + assertThat(controller.listSignRequests(null).getStatusCode()) + .isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void validateCertificate_unauthenticated() { + assertThat( + controller + .validateCertificate("P12", "pw", null, null, null) + .getStatusCode()) + .isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + // ------------------------------------------------------------------------- + // listSessions + // ------------------------------------------------------------------------- + + @Test + @DisplayName("listSessions returns mapped responses") + void listSessions_returnsResponses() { + User owner = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner)); + when(workflowSessionService.listUserSessions(owner)) + .thenReturn(List.of(ownedSession("s1", owner))); + + ResponseEntity response = controller.listSessions(principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat((List) response.getBody()).hasSize(1); + } + + @Test + @DisplayName("listSessions wraps service error as 500") + void listSessions_serviceError_returns500() { + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(user("alice"))); + when(workflowSessionService.listUserSessions(any())) + .thenThrow(new RuntimeException("db down")); + + ResponseEntity response = controller.listSessions(principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + + // ------------------------------------------------------------------------- + // createSession + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("createSession") + class CreateSession { + + @Test + void unauthenticated_returns401() throws Exception { + MultipartFile file = mock(MultipartFile.class); + assertThat( + controller + .createSession(file, new WorkflowCreationRequest(), null) + .getStatusCode()) + .isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void success_returnsOk() throws Exception { + User owner = user("alice"); + MultipartFile file = + new MockMultipartFile("file", "d.pdf", "application/pdf", new byte[] {1}); + WorkflowCreationRequest request = new WorkflowCreationRequest(); + WorkflowSession session = ownedSession("s1", owner); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner)); + when(workflowSessionService.createSession(eq(owner), eq(file), eq(request))) + .thenReturn(session); + + ResponseEntity response = + controller.createSession(file, request, principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void serviceError_returns400() throws Exception { + User owner = user("alice"); + MultipartFile file = + new MockMultipartFile("file", "d.pdf", "application/pdf", new byte[] {1}); + WorkflowCreationRequest request = new WorkflowCreationRequest(); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner)); + when(workflowSessionService.createSession(any(), any(), any())) + .thenThrow(new RuntimeException("bad")); + + ResponseEntity response = + controller.createSession(file, request, principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + // ------------------------------------------------------------------------- + // getSession + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("getSession") + class GetSession { + + @Test + void found_returnsOk() { + User owner = user("alice"); + WorkflowSession session = ownedSession("s1", owner); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner)); + when(workflowSessionService.getSessionForOwner("s1", owner)).thenReturn(session); + + ResponseEntity response = controller.getSession("s1", principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void serviceThrows_returnsForbidden() { + User owner = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner)); + when(workflowSessionService.getSessionForOwner("s1", owner)) + .thenThrow(new ResponseStatusException(HttpStatus.FORBIDDEN, "nope")); + + ResponseEntity response = controller.getSession("s1", principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + } + + // ------------------------------------------------------------------------- + // deleteSession + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("deleteSession") + class DeleteSession { + + @Test + void success_returns204() { + User owner = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner)); + + ResponseEntity response = controller.deleteSession("s1", principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(workflowSessionService).deleteSession("s1", owner); + } + + @Test + void serviceThrows_returnsForbidden() { + User owner = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner)); + doThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "finalized")) + .when(workflowSessionService) + .deleteSession("s1", owner); + + ResponseEntity response = controller.deleteSession("s1", principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + } + + // ------------------------------------------------------------------------- + // addParticipants / removeParticipant + // ------------------------------------------------------------------------- + + @Test + @DisplayName("addParticipants returns updated session") + void addParticipants_returnsOk() { + User owner = user("alice"); + WorkflowSession session = ownedSession("s1", owner); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner)); + when(workflowSessionService.getSessionWithParticipantsForOwner("s1", owner)) + .thenReturn(session); + + ResponseEntity response = + controller.addParticipants("s1", List.of(), principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(workflowSessionService).addParticipants(eq("s1"), any(), eq(owner)); + } + + @Test + @DisplayName("removeParticipant returns 204") + void removeParticipant_returns204() { + User owner = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner)); + + ResponseEntity response = controller.removeParticipant("s1", 5L, principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(workflowSessionService).removeParticipant("s1", 5L, owner); + } + + @Test + @DisplayName("removeParticipant unauthenticated returns 401") + void removeParticipant_unauthenticated() { + assertThat(controller.removeParticipant("s1", 5L, null).getStatusCode()) + .isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + @DisplayName("removeParticipant service error returns 403") + void removeParticipant_serviceError() { + User owner = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner)); + doThrow(new ResponseStatusException(HttpStatus.NOT_FOUND, "x")) + .when(workflowSessionService) + .removeParticipant("s1", 5L, owner); + + assertThat(controller.removeParticipant("s1", 5L, principal("alice")).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + // ------------------------------------------------------------------------- + // getSessionPdf + // ------------------------------------------------------------------------- + + @Test + @DisplayName("getSessionPdf returns PDF bytes") + void getSessionPdf_returnsBytes() throws Exception { + User owner = user("alice"); + WorkflowSession session = ownedSession("s1", owner); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner)); + when(workflowSessionService.getSessionForOwner("s1", owner)).thenReturn(session); + when(workflowSessionService.getOriginalFile("s1")).thenReturn(new byte[] {1, 2}); + + ResponseEntity response = controller.getSessionPdf("s1", principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsExactly(1, 2); + } + + @Test + @DisplayName("getSessionPdf service error returns 403") + void getSessionPdf_serviceError() throws Exception { + User owner = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner)); + when(workflowSessionService.getSessionForOwner("s1", owner)) + .thenThrow(new ResponseStatusException(HttpStatus.FORBIDDEN, "nope")); + + assertThat(controller.getSessionPdf("s1", principal("alice")).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + // ------------------------------------------------------------------------- + // finalizeSession + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("finalizeSession") + class FinalizeSession { + + @Test + void unauthenticated_returns401() throws Exception { + assertThat(controller.finalizeSession("s1", null).getStatusCode()) + .isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void success_returnsSignedPdf() throws Exception { + User owner = user("alice"); + WorkflowSession session = ownedSession("s1", owner); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner)); + when(workflowSessionService.getSessionWithParticipantsForOwner("s1", owner)) + .thenReturn(session); + when(workflowSessionService.getOriginalFile("s1")).thenReturn(new byte[] {1}); + when(signingFinalizationService.finalizeDocument(eq(session), any())) + .thenReturn(new byte[] {2, 3}); + + ResponseEntity response = controller.finalizeSession("s1", principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(workflowSessionService).finalizeSession("s1", owner); + verify(workflowSessionService).deleteOriginalFile(session); + } + + @Test + void serviceError_returns500() throws Exception { + User owner = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner)); + when(workflowSessionService.getSessionWithParticipantsForOwner("s1", owner)) + .thenThrow(new RuntimeException("boom")); + + assertThat(controller.finalizeSession("s1", principal("alice")).getStatusCode()) + .isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + // ------------------------------------------------------------------------- + // getSignedPdf + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("getSignedPdf") + class GetSignedPdf { + + @Test + void unauthenticated_returns401() { + assertThat(controller.getSignedPdf("s1", null).getStatusCode()) + .isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + void notFinalized_returns404() throws Exception { + User owner = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner)); + when(workflowSessionService.getProcessedFile("s1", owner)).thenReturn(null); + + assertThat(controller.getSignedPdf("s1", principal("alice")).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void finalized_returnsBytes() throws Exception { + User owner = user("alice"); + WorkflowSession session = ownedSession("s1", owner); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(owner)); + when(workflowSessionService.getProcessedFile("s1", owner)).thenReturn(new byte[] {3}); + when(workflowSessionService.getSessionForOwner("s1", owner)).thenReturn(session); + + ResponseEntity response = controller.getSignedPdf("s1", principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsExactly(3); + } + } + + // ------------------------------------------------------------------------- + // listSignRequests / getSignRequestDetail / getSignRequestDocument + // ------------------------------------------------------------------------- + + @Test + @DisplayName("listSignRequests returns service list") + void listSignRequests_returnsList() { + User user = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(user)); + when(workflowSessionService.listSignRequests(user)).thenReturn(List.of()); + + ResponseEntity response = controller.listSignRequests(principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("getSignRequestDetail returns detail") + void getSignRequestDetail_returnsOk() { + User user = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(user)); + when(workflowSessionService.getSignRequestDetail("s1", user)) + .thenReturn(new stirling.software.proprietary.workflow.dto.SignRequestDetailDTO()); + + ResponseEntity response = controller.getSignRequestDetail("s1", principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + @DisplayName("getSignRequestDetail service error returns 403") + void getSignRequestDetail_serviceError() { + User user = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(user)); + when(workflowSessionService.getSignRequestDetail("s1", user)) + .thenThrow(new ResponseStatusException(HttpStatus.FORBIDDEN, "x")); + + assertThat(controller.getSignRequestDetail("s1", principal("alice")).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("getSignRequestDocument returns bytes") + void getSignRequestDocument_returnsBytes() { + User user = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(user)); + when(workflowSessionService.getSignRequestDocument("s1", user)).thenReturn(new byte[] {9}); + + ResponseEntity response = + controller.getSignRequestDocument("s1", principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + // ------------------------------------------------------------------------- + // signDocument + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("signDocument") + class SignDocument { + + @Test + void success_returns204() { + User user = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(user)); + var request = new stirling.software.proprietary.workflow.dto.SignDocumentRequest(); + + ResponseEntity response = controller.signDocument("s1", request, principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(workflowSessionService).signDocument("s1", user, request); + } + + @Test + void illegalArgument_returns400() { + User user = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(user)); + var request = new stirling.software.proprietary.workflow.dto.SignDocumentRequest(); + doThrow(new IllegalArgumentException("bad cert")) + .when(workflowSessionService) + .signDocument(anyString(), any(), any()); + + ResponseEntity response = controller.signDocument("s1", request, principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void unexpectedError_returns500() { + User user = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(user)); + var request = new stirling.software.proprietary.workflow.dto.SignDocumentRequest(); + doThrow(new RuntimeException("boom")) + .when(workflowSessionService) + .signDocument(anyString(), any(), any()); + + ResponseEntity response = controller.signDocument("s1", request, principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + // ------------------------------------------------------------------------- + // declineSignRequest + // ------------------------------------------------------------------------- + + @Test + @DisplayName("declineSignRequest returns 204") + void declineSignRequest_returns204() { + User user = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(user)); + + ResponseEntity response = controller.declineSignRequest("s1", principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(workflowSessionService).declineSignRequest("s1", user); + } + + @Test + @DisplayName("declineSignRequest service error returns 403") + void declineSignRequest_serviceError() { + User user = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(user)); + doThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "x")) + .when(workflowSessionService) + .declineSignRequest("s1", user); + + assertThat(controller.declineSignRequest("s1", principal("alice")).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + // ------------------------------------------------------------------------- + // getCurrentUser (via any authenticated endpoint) — unknown user maps to 401/handler error + // ------------------------------------------------------------------------- + + @Test + @DisplayName("unknown principal surfaces as handler error response") + void unknownUser_listSignRequests_returns500() { + when(userService.findByUsernameIgnoreCase("ghost")).thenReturn(Optional.empty()); + + // getCurrentUser throws 401 inside the try, caught and remapped to 500 by listSignRequests + ResponseEntity response = controller.listSignRequests(principal("ghost")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + } + + // ------------------------------------------------------------------------- + // validateCertificate + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("validateCertificate") + class ValidateCertificate { + + @Test + void missingFileForP12_throwsBadRequest() { + User user = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(user)); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> + controller.validateCertificate( + "P12", "pw", null, null, principal("alice"))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void serverType_returnsValidTrueWithNullInfo() { + User user = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(user)); + when(certificateSubmissionValidator.validateAndExtractInfo(any(), eq("SERVER"), any())) + .thenReturn(null); + + ResponseEntity response = + controller.validateCertificate("SERVER", null, null, null, principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void validP12_returnsCertInfo() { + User user = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(user)); + MockMultipartFile p12 = + new MockMultipartFile( + "p12File", "c.p12", "application/octet-stream", new byte[] {1}); + CertificateInfo info = + new CertificateInfo( + "Signer", "CA", new java.util.Date(), new java.util.Date(), true); + when(certificateSubmissionValidator.validateAndExtractInfo(any(), eq("P12"), eq("pw"))) + .thenReturn(info); + + ResponseEntity + response = + controller.validateCertificate( + "P12", "pw", p12, null, principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().valid()).isTrue(); + assertThat(response.getBody().subjectName()).isEqualTo("Signer"); + } + + @Test + void validatorThrows_returnsValidFalse() { + User user = user("alice"); + when(userService.findByUsernameIgnoreCase("alice")).thenReturn(Optional.of(user)); + MockMultipartFile p12 = + new MockMultipartFile( + "p12File", "c.p12", "application/octet-stream", new byte[] {1}); + when(certificateSubmissionValidator.validateAndExtractInfo(any(), any(), any())) + .thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST, "bad password")); + + ResponseEntity + response = + controller.validateCertificate( + "P12", "wrong", p12, null, principal("alice")); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().valid()).isFalse(); + assertThat(response.getBody().error()).isEqualTo("bad password"); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantControllerMoreTest.java new file mode 100644 index 0000000000..0b1aedbc05 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantControllerMoreTest.java @@ -0,0 +1,343 @@ +package stirling.software.proprietary.workflow.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Optional; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.ShareAccessRole; +import stirling.software.proprietary.workflow.dto.ParticipantResponse; +import stirling.software.proprietary.workflow.dto.SignatureSubmissionRequest; +import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse; +import stirling.software.proprietary.workflow.model.ParticipantStatus; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; +import stirling.software.proprietary.workflow.model.WorkflowSession; +import stirling.software.proprietary.workflow.model.WorkflowStatus; +import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository; +import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator; +import stirling.software.proprietary.workflow.service.MetadataEncryptionService; +import stirling.software.proprietary.workflow.service.WorkflowSessionService; + +import tools.jackson.databind.ObjectMapper; + +// Covers the participant-facing token endpoints not in WorkflowParticipantValidateCertificateTest. +@ExtendWith(MockitoExtension.class) +class WorkflowParticipantControllerMoreTest { + + @Mock private WorkflowSessionService workflowSessionService; + @Mock private WorkflowParticipantRepository participantRepository; + @Mock private MetadataEncryptionService metadataEncryptionService; + @Mock private CertificateSubmissionValidator certificateSubmissionValidator; + + private WorkflowParticipantController controller; + + private static final String TOKEN = "share-token-abc"; + + @BeforeEach + void setUp() { + controller = + new WorkflowParticipantController( + workflowSessionService, + participantRepository, + new ObjectMapper(), + metadataEncryptionService, + certificateSubmissionValidator); + } + + private WorkflowSession activeSession() { + User owner = new User(); + owner.setId(1L); + owner.setUsername("owner"); + WorkflowSession s = new WorkflowSession(); + s.setSessionId("s1"); + s.setOwner(owner); + s.setDocumentName("doc.pdf"); + s.setStatus(WorkflowStatus.IN_PROGRESS); + s.setParticipants(new ArrayList<>()); + return s; + } + + private WorkflowParticipant participant(ParticipantStatus status) { + WorkflowParticipant p = new WorkflowParticipant(); + p.setId(5L); + p.setEmail("p@example.com"); + p.setStatus(status); + p.setAccessRole(ShareAccessRole.EDITOR); + WorkflowSession s = activeSession(); + s.addParticipant(p); + return p; + } + + // ------------------------------------------------------------------------- + // getSessionByToken + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("getSessionByToken") + class GetSessionByToken { + + @Test + void invalidToken_throwsForbidden() { + when(participantRepository.findByShareToken("bad")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> controller.getSessionByToken("bad")) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void pendingParticipant_marksViewedAndReturnsSession() { + WorkflowParticipant p = participant(ParticipantStatus.PENDING); + when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p)); + + ResponseEntity response = controller.getSessionByToken(TOKEN); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + verify(workflowSessionService).updateParticipantStatus(5L, ParticipantStatus.VIEWED); + } + + @Test + void expiredParticipant_throwsForbidden() { + WorkflowParticipant p = participant(ParticipantStatus.PENDING); + p.setExpiresAt(java.time.LocalDateTime.now().minusDays(1)); + when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p)); + + assertThatThrownBy(() -> controller.getSessionByToken(TOKEN)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void signedParticipant_doesNotUpdateStatus() { + WorkflowParticipant p = participant(ParticipantStatus.SIGNED); + when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p)); + + controller.getSessionByToken(TOKEN); + + verify(workflowSessionService, org.mockito.Mockito.never()) + .updateParticipantStatus( + org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.any()); + } + } + + // ------------------------------------------------------------------------- + // getParticipantDetails + // ------------------------------------------------------------------------- + + @Test + @DisplayName("getParticipantDetails returns participant response") + void getParticipantDetails_returnsResponse() { + WorkflowParticipant p = participant(ParticipantStatus.VIEWED); + when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p)); + + ResponseEntity response = controller.getParticipantDetails(TOKEN); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getEmail()).isEqualTo("p@example.com"); + } + + @Test + @DisplayName("getParticipantDetails invalid token throws 403") + void getParticipantDetails_invalidToken() { + when(participantRepository.findByShareToken("bad")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> controller.getParticipantDetails("bad")) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + // ------------------------------------------------------------------------- + // submitSignature + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("submitSignature") + class SubmitSignature { + + private SignatureSubmissionRequest request(String token) { + SignatureSubmissionRequest r = new SignatureSubmissionRequest(); + r.setParticipantToken(token); + r.setCertType("SERVER"); + return r; + } + + @Test + void blankToken_throwsBadRequest() { + SignatureSubmissionRequest r = new SignatureSubmissionRequest(); + r.setParticipantToken(" "); + + assertThatThrownBy(() -> controller.submitSignature(r)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void invalidToken_throwsForbidden() { + when(participantRepository.findByShareToken("bad")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> controller.submitSignature(request("bad"))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void alreadyCompleted_throwsBadRequest() { + WorkflowParticipant p = participant(ParticipantStatus.SIGNED); + when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p)); + + assertThatThrownBy(() -> controller.submitSignature(request(TOKEN))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void inactiveSession_throwsBadRequest() { + WorkflowParticipant p = participant(ParticipantStatus.PENDING); + p.getWorkflowSession().setFinalized(true); // isActive() false + when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p)); + + assertThatThrownBy(() -> controller.submitSignature(request(TOKEN))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void serverCert_savesParticipantSigned() { + WorkflowParticipant p = participant(ParticipantStatus.PENDING); + when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p)); + when(metadataEncryptionService.encrypt(org.mockito.ArgumentMatchers.any())) + .thenReturn("enc"); + when(participantRepository.save(org.mockito.ArgumentMatchers.any())) + .thenAnswer(i -> i.getArgument(0)); + + ResponseEntity response = + controller.submitSignature(request(TOKEN)); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(p.getStatus()).isEqualTo(ParticipantStatus.SIGNED); + } + } + + // ------------------------------------------------------------------------- + // declineParticipation + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("declineParticipation") + class DeclineParticipation { + + @Test + void invalidToken_throwsForbidden() { + when(participantRepository.findByShareToken("bad")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> controller.declineParticipation("bad", null)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void alreadyCompleted_throwsBadRequest() { + WorkflowParticipant p = participant(ParticipantStatus.DECLINED); + when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p)); + + assertThatThrownBy(() -> controller.declineParticipation(TOKEN, null)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void withReason_setsDeclinedAndNotifies() { + WorkflowParticipant p = participant(ParticipantStatus.PENDING); + when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p)); + when(participantRepository.save(org.mockito.ArgumentMatchers.any())) + .thenAnswer(i -> i.getArgument(0)); + + ResponseEntity response = + controller.declineParticipation(TOKEN, "not me"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(p.getStatus()).isEqualTo(ParticipantStatus.DECLINED); + verify(workflowSessionService).addParticipantNotification(5L, "Declined: not me"); + } + + @Test + void withoutReason_usesDefaultNotification() { + WorkflowParticipant p = participant(ParticipantStatus.PENDING); + when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p)); + when(participantRepository.save(org.mockito.ArgumentMatchers.any())) + .thenAnswer(i -> i.getArgument(0)); + + controller.declineParticipation(TOKEN, null); + + verify(workflowSessionService).addParticipantNotification(5L, "Declined participation"); + } + } + + // ------------------------------------------------------------------------- + // getDocument + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("getDocument") + class GetDocument { + + @Test + void invalidToken_throwsForbidden() { + when(participantRepository.findByShareToken("bad")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> controller.getDocument("bad")) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void expiredParticipant_throwsForbidden() { + WorkflowParticipant p = participant(ParticipantStatus.PENDING); + p.setExpiresAt(java.time.LocalDateTime.now().minusDays(1)); + when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p)); + + assertThatThrownBy(() -> controller.getDocument(TOKEN)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void validParticipant_returnsPdf() throws Exception { + WorkflowParticipant p = participant(ParticipantStatus.PENDING); + when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(p)); + when(workflowSessionService.getOriginalFile("s1")).thenReturn(new byte[] {1, 2, 3}); + + ResponseEntity response = controller.getDocument(TOKEN); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsExactly(1, 2, 3); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/SigningFinalizationServiceMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/SigningFinalizationServiceMoreTest.java new file mode 100644 index 0000000000..2fd34a1e10 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/SigningFinalizationServiceMoreTest.java @@ -0,0 +1,722 @@ +package stirling.software.proprietary.workflow.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import javax.imageio.ImageIO; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfSigningService; +import stirling.software.common.service.ServerCertificateServiceInterface; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.workflow.model.ParticipantStatus; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; +import stirling.software.proprietary.workflow.model.WorkflowSession; +import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository; + +import tools.jackson.databind.ObjectMapper; + +/** + * Gap-filling tests for {@link SigningFinalizationService}, complementing + * SigningFinalizationServiceTest which only covers clearSensitiveMetadata. Exercises the + * finalizeDocument pipeline, keystore building, certificate validation, and metadata extraction + * using real test certificates and an in-memory PDDocument. + */ +@ExtendWith(MockitoExtension.class) +class SigningFinalizationServiceMoreTest { + + @Mock private WorkflowParticipantRepository participantRepository; + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private PdfSigningService pdfSigningService; + @Mock private MetadataEncryptionService metadataEncryptionService; + @Mock private ServerCertificateServiceInterface serverCertificateService; + @Mock private UserServerCertificateService userServerCertificateService; + + // Real Jackson 3 mapper so extractCertificateSubmission actually parses metadata + private final ObjectMapper objectMapper = new ObjectMapper(); + + private SigningFinalizationService service; + + @BeforeEach + void setUp() { + service = + new SigningFinalizationService( + participantRepository, + pdfDocumentFactory, + objectMapper, + pdfSigningService, + metadataEncryptionService, + serverCertificateService, + userServerCertificateService); + } + + // ------------------------------------------------------------------------- + // Test fixtures / helpers + // ------------------------------------------------------------------------- + + private static byte[] loadCert(String filename) throws Exception { + try (InputStream in = + SigningFinalizationServiceMoreTest.class.getResourceAsStream( + "/test-certs/" + filename)) { + if (in == null) { + throw new IllegalStateException("cert not found: " + filename); + } + return in.readAllBytes(); + } + } + + /** Builds a single-page in-memory PDF and returns its bytes. */ + private static byte[] singlePagePdf() throws Exception { + try (PDDocument doc = new PDDocument()) { + doc.addPage(new PDPage(PDRectangle.A4)); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + doc.save(baos); + return baos.toByteArray(); + } + } + + /** Loads the given bytes into a real PDDocument (used to mock the factory). */ + private static PDDocument loadDoc(byte[] bytes) throws Exception { + return org.apache.pdfbox.Loader.loadPDF(bytes); + } + + /** Tiny valid PNG data-URL so PDImageXObject.createFromByteArray succeeds. */ + private static String pngDataUrl() throws Exception { + BufferedImage img = new BufferedImage(8, 8, BufferedImage.TYPE_INT_ARGB); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(img, "png", baos); + return "data:image/png;base64," + Base64.getEncoder().encodeToString(baos.toByteArray()); + } + + private WorkflowParticipant participant(Long id, ParticipantStatus status) { + WorkflowParticipant p = new WorkflowParticipant(); + p.setId(id); + p.setStatus(status); + p.setEmail("p" + id + "@example.com"); + p.setName("Participant " + id); + p.setParticipantMetadata(new HashMap<>()); + return p; + } + + private WorkflowSession sessionOf(WorkflowParticipant... ps) { + WorkflowSession session = new WorkflowSession(); + session.setSessionId("sess-1"); + session.setDocumentName("contract.pdf"); + List list = new ArrayList<>(); + for (WorkflowParticipant p : ps) { + list.add(p); + } + session.setParticipants(list); + User owner = new User(); + owner.setUsername("owner"); + session.setOwner(owner); + return session; + } + + /** Builds participant metadata containing a P12 certificateSubmission with the given cert. */ + private Map p12SubmissionMetadata(byte[] p12Bytes, String password) { + Map submission = new HashMap<>(); + submission.put("certType", "P12"); + submission.put("password", password); + submission.put("p12Keystore", Base64.getEncoder().encodeToString(p12Bytes)); + Map metadata = new HashMap<>(); + metadata.put("certificateSubmission", submission); + return metadata; + } + + private Map wetSignatureMetadata(String dataUrl, int page) { + Map sig = new HashMap<>(); + sig.put("type", "image"); + sig.put("data", dataUrl); + sig.put("page", page); + sig.put("x", 0.1); + sig.put("y", 0.1); + sig.put("width", 0.2); + sig.put("height", 0.1); + Map metadata = new HashMap<>(); + metadata.put("wetSignatures", new ArrayList<>(List.of(sig))); + return metadata; + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("finalizeDocument - digital signature happy path") + class FinalizeHappyPath { + + @Test + @DisplayName("signs each SIGNED participant via P12 keystore and returns signed bytes") + void signsSignedParticipant() throws Exception { + byte[] p12 = loadCert("valid-test.p12"); + WorkflowParticipant p = participant(1L, ParticipantStatus.SIGNED); + p.setParticipantMetadata(p12SubmissionMetadata(p12, "testpass")); + WorkflowSession session = sessionOf(p); + + // No wet signatures -> applyWetSignatures returns input unchanged (factory not used) + when(participantRepository.findById(1L)).thenReturn(Optional.of(p)); + when(metadataEncryptionService.decrypt("testpass")).thenReturn("testpass"); + + byte[] signedOut = "SIGNED-PDF".getBytes(); + when(pdfSigningService.signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + any(), + any(), + any(), + any(), + anyBoolean())) + .thenReturn(signedOut); + + byte[] original = singlePagePdf(); + byte[] result = service.finalizeDocument(session, original); + + assertThat(result).isEqualTo(signedOut); + verify(pdfSigningService, times(1)) + .signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + any(), + any(), + any(), + any(), + anyBoolean()); + } + + @Test + @DisplayName("passes participant reason/location and page-1-converted-to-0-indexed") + void passesReasonLocationAndPageIndex() throws Exception { + byte[] p12 = loadCert("valid-test.p12"); + WorkflowParticipant p = participant(1L, ParticipantStatus.SIGNED); + Map metadata = p12SubmissionMetadata(p12, "testpass"); + @SuppressWarnings("unchecked") + Map sub = (Map) metadata.get("certificateSubmission"); + sub.put("reason", "I approve"); + sub.put("location", "London"); + p.setParticipantMetadata(metadata); + + WorkflowSession session = sessionOf(p); + session.getWorkflowMetadata().put("pageNumber", 3); + session.getWorkflowMetadata().put("showSignature", true); + + when(participantRepository.findById(1L)).thenReturn(Optional.of(p)); + when(metadataEncryptionService.decrypt("testpass")).thenReturn("testpass"); + when(pdfSigningService.signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + any(), + any(), + any(), + any(), + anyBoolean())) + .thenReturn("ok".getBytes()); + + service.finalizeDocument(session, singlePagePdf()); + + // page 3 (1-indexed) -> 2 (0-indexed); reason/location forwarded + verify(pdfSigningService) + .signWithKeystore( + any(), + any(), + any(), + eq(true), + eq(2), + eq("Participant 1"), + eq("London"), + eq("I approve"), + anyBoolean()); + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("finalizeDocument - participant skipping") + class ParticipantSkipping { + + @Test + @DisplayName("skips digital signing for participants whose status is not SIGNED") + void skipsNonSignedParticipant() throws Exception { + WorkflowParticipant pending = participant(1L, ParticipantStatus.PENDING); + WorkflowSession session = sessionOf(pending); + // wet-sig extraction reloads every participant; no wetSignatures key -> skipped + when(participantRepository.findById(1L)).thenReturn(Optional.of(pending)); + + byte[] original = singlePagePdf(); + byte[] result = service.finalizeDocument(session, original); + + // Untouched - no wet sigs, signing skipped because status != SIGNED + assertThat(result).isEqualTo(original); + verify(pdfSigningService, never()) + .signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + any(), + any(), + any(), + any(), + anyBoolean()); + } + + @Test + @DisplayName("skips SIGNED participant with no certificate submission") + void skipsSignedParticipantWithoutSubmission() throws Exception { + WorkflowParticipant p = participant(1L, ParticipantStatus.SIGNED); + // metadata empty -> extractCertificateSubmission returns null + WorkflowSession session = sessionOf(p); + + when(participantRepository.findById(1L)).thenReturn(Optional.of(p)); + + byte[] original = singlePagePdf(); + byte[] result = service.finalizeDocument(session, original); + + assertThat(result).isEqualTo(original); + verify(pdfSigningService, never()) + .signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + any(), + any(), + any(), + any(), + anyBoolean()); + } + + @Test + @DisplayName("throws 500 when a fresh participant lookup fails") + void throwsWhenParticipantNotFound() throws Exception { + WorkflowParticipant p = participant(1L, ParticipantStatus.SIGNED); + WorkflowSession session = sessionOf(p); + when(participantRepository.findById(1L)).thenReturn(Optional.empty()); + + byte[] original = singlePagePdf(); + assertThatThrownBy(() -> service.finalizeDocument(session, original)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("Participant not found"); + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("finalizeDocument - wet signatures") + class WetSignatures { + + @Test + @DisplayName("applies a wet signature overlay then returns the re-rendered PDF") + void appliesWetSignature() throws Exception { + WorkflowParticipant p = participant(1L, ParticipantStatus.SIGNED); + p.setParticipantMetadata(wetSignatureMetadata(pngDataUrl(), 0)); + WorkflowSession session = sessionOf(p); + + byte[] original = singlePagePdf(); + // Factory loads the original bytes once for the wet-signature pass + when(pdfDocumentFactory.load(any(InputStream.class))).thenReturn(loadDoc(original)); + // findById invoked by both extractAllWetSignatures and the signing loop + when(participantRepository.findById(1L)).thenReturn(Optional.of(p)); + + byte[] result = service.finalizeDocument(session, original); + + // wet-sig pass produced a non-empty PDF; signing was skipped (no cert submission) + assertThat(result).isNotNull(); + assertThat(result.length).isGreaterThan(0); + verify(pdfDocumentFactory, times(1)).load(any(InputStream.class)); + } + + @Test + @DisplayName("skips wet signature whose page index exceeds the document") + void skipsOutOfRangeWetSignaturePage() throws Exception { + WorkflowParticipant p = participant(1L, ParticipantStatus.SIGNED); + p.setParticipantMetadata(wetSignatureMetadata(pngDataUrl(), 99)); + WorkflowSession session = sessionOf(p); + + byte[] original = singlePagePdf(); + when(pdfDocumentFactory.load(any(InputStream.class))).thenReturn(loadDoc(original)); + when(participantRepository.findById(1L)).thenReturn(Optional.of(p)); + + byte[] result = service.finalizeDocument(session, original); + + assertThat(result).isNotNull(); + verify(pdfDocumentFactory, times(1)).load(any(InputStream.class)); + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("finalizeDocument - summary page") + class SummaryPage { + + @Test + @DisplayName("appends a summary page when includeSummaryPage is true") + void appendsSummaryPage() throws Exception { + byte[] p12 = loadCert("valid-test.p12"); + WorkflowParticipant signed = participant(1L, ParticipantStatus.SIGNED); + signed.setParticipantMetadata(p12SubmissionMetadata(p12, "testpass")); + signed.setLastUpdated(java.time.LocalDateTime.now()); + WorkflowParticipant declined = participant(2L, ParticipantStatus.DECLINED); + WorkflowSession session = sessionOf(signed, declined); + session.getWorkflowMetadata().put("includeSummaryPage", true); + + byte[] original = singlePagePdf(); + // factory called once for summary-page rendering (no wet sigs present) + when(pdfDocumentFactory.load(any(InputStream.class))).thenReturn(loadDoc(original)); + when(participantRepository.findById(1L)).thenReturn(Optional.of(signed)); + lenient().when(metadataEncryptionService.decrypt("testpass")).thenReturn("testpass"); + when(pdfSigningService.signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + any(), + any(), + any(), + any(), + anyBoolean())) + .thenAnswer(inv -> inv.getArgument(0)); + + byte[] result = service.finalizeDocument(session, original); + + assertThat(result).isNotNull(); + // showVisualSignature forced to false when summary page enabled + verify(pdfSigningService) + .signWithKeystore( + any(), + any(), + any(), + eq(false), + any(), + any(), + any(), + any(), + anyBoolean()); + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("buildKeystore via finalizeDocument - certificate type branches") + class KeystoreTypeBranches { + + private WorkflowParticipant signedWith(Map metadata) { + WorkflowParticipant p = participant(1L, ParticipantStatus.SIGNED); + p.setParticipantMetadata(metadata); + return p; + } + + @Test + @DisplayName("expired P12 certificate is rejected with 400") + void expiredCertificateRejected() throws Exception { + byte[] expired = loadCert("expired-test.p12"); + WorkflowParticipant p = signedWith(p12SubmissionMetadata(expired, "testpass")); + WorkflowSession session = sessionOf(p); + + when(participantRepository.findById(1L)).thenReturn(Optional.of(p)); + when(metadataEncryptionService.decrypt("testpass")).thenReturn("testpass"); + + assertThatThrownBy(() -> service.finalizeDocument(session, singlePagePdf())) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("expired"); + } + + @Test + @DisplayName("not-yet-valid P12 certificate is rejected with 400") + void notYetValidCertificateRejected() throws Exception { + byte[] notYet = loadCert("not-yet-valid-test.p12"); + WorkflowParticipant p = signedWith(p12SubmissionMetadata(notYet, "testpass")); + WorkflowSession session = sessionOf(p); + + when(participantRepository.findById(1L)).thenReturn(Optional.of(p)); + when(metadataEncryptionService.decrypt("testpass")).thenReturn("testpass"); + + assertThatThrownBy(() -> service.finalizeDocument(session, singlePagePdf())) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("not yet valid"); + } + + @Test + @DisplayName("wrong password on P12 keystore is rejected with 400") + void wrongPasswordRejected() throws Exception { + byte[] p12 = loadCert("valid-test.p12"); + WorkflowParticipant p = signedWith(p12SubmissionMetadata(p12, "wrong-pass")); + WorkflowSession session = sessionOf(p); + + when(participantRepository.findById(1L)).thenReturn(Optional.of(p)); + when(metadataEncryptionService.decrypt("wrong-pass")).thenReturn("wrong-pass"); + + assertThatThrownBy(() -> service.finalizeDocument(session, singlePagePdf())) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("Failed to open P12 keystore"); + } + + @Test + @DisplayName("P12 type without keystore bytes is rejected with 400") + void missingP12BytesRejected() throws Exception { + Map submission = new HashMap<>(); + submission.put("certType", "P12"); + submission.put("password", "x"); + Map metadata = new HashMap<>(); + metadata.put("certificateSubmission", submission); + WorkflowParticipant p = signedWith(metadata); + WorkflowSession session = sessionOf(p); + + when(participantRepository.findById(1L)).thenReturn(Optional.of(p)); + lenient().when(metadataEncryptionService.decrypt("x")).thenReturn("x"); + + assertThatThrownBy(() -> service.finalizeDocument(session, singlePagePdf())) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("P12 keystore data is required"); + } + + @Test + @DisplayName("JKS keystore is loaded and signed") + void jksKeystoreLoaded() throws Exception { + byte[] jks = loadCert("valid-test.jks"); + Map submission = new HashMap<>(); + submission.put("certType", "JKS"); + submission.put("password", "jkspass"); + submission.put("jksKeystore", Base64.getEncoder().encodeToString(jks)); + Map metadata = new HashMap<>(); + metadata.put("certificateSubmission", submission); + WorkflowParticipant p = signedWith(metadata); + WorkflowSession session = sessionOf(p); + + when(participantRepository.findById(1L)).thenReturn(Optional.of(p)); + when(metadataEncryptionService.decrypt("jkspass")).thenReturn("jkspass"); + when(pdfSigningService.signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + any(), + any(), + any(), + any(), + anyBoolean())) + .thenReturn("jks-signed".getBytes()); + + byte[] result = service.finalizeDocument(session, singlePagePdf()); + + assertThat(result).isEqualTo("jks-signed".getBytes()); + } + + @Test + @DisplayName("JKS type without keystore bytes is rejected with 400") + void missingJksBytesRejected() throws Exception { + Map submission = new HashMap<>(); + submission.put("certType", "JKS"); + submission.put("password", "x"); + Map metadata = new HashMap<>(); + metadata.put("certificateSubmission", submission); + WorkflowParticipant p = signedWith(metadata); + WorkflowSession session = sessionOf(p); + + when(participantRepository.findById(1L)).thenReturn(Optional.of(p)); + lenient().when(metadataEncryptionService.decrypt("x")).thenReturn("x"); + + assertThatThrownBy(() -> service.finalizeDocument(session, singlePagePdf())) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("JKS keystore data is required"); + } + + @Test + @DisplayName("unknown certificate type is rejected with 400") + void unknownCertTypeRejected() throws Exception { + Map submission = new HashMap<>(); + submission.put("certType", "BOGUS"); + Map metadata = new HashMap<>(); + metadata.put("certificateSubmission", submission); + WorkflowParticipant p = signedWith(metadata); + WorkflowSession session = sessionOf(p); + + when(participantRepository.findById(1L)).thenReturn(Optional.of(p)); + + assertThatThrownBy(() -> service.finalizeDocument(session, singlePagePdf())) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("Invalid certificate type"); + } + + @Test + @DisplayName("SERVER cert type uses the server keystore and password") + void serverCertTypeUsesServerKeystore() throws Exception { + byte[] p12 = loadCert("valid-test.p12"); + java.security.KeyStore serverKs = java.security.KeyStore.getInstance("PKCS12"); + serverKs.load(new ByteArrayInputStream(p12), "testpass".toCharArray()); + + Map submission = new HashMap<>(); + submission.put("certType", "SERVER"); + Map metadata = new HashMap<>(); + metadata.put("certificateSubmission", submission); + WorkflowParticipant p = signedWith(metadata); + WorkflowSession session = sessionOf(p); + + when(participantRepository.findById(1L)).thenReturn(Optional.of(p)); + when(serverCertificateService.isEnabled()).thenReturn(true); + when(serverCertificateService.hasServerCertificate()).thenReturn(true); + when(serverCertificateService.getServerKeyStore()).thenReturn(serverKs); + when(serverCertificateService.getServerCertificatePassword()).thenReturn("testpass"); + when(pdfSigningService.signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + any(), + any(), + any(), + any(), + anyBoolean())) + .thenReturn("server-signed".getBytes()); + + byte[] result = service.finalizeDocument(session, singlePagePdf()); + + assertThat(result).isEqualTo("server-signed".getBytes()); + verify(serverCertificateService).getServerKeyStore(); + } + + @Test + @DisplayName("SERVER cert type without a configured server certificate is rejected") + void serverCertTypeNotConfiguredRejected() throws Exception { + Map submission = new HashMap<>(); + submission.put("certType", "SERVER"); + Map metadata = new HashMap<>(); + metadata.put("certificateSubmission", submission); + WorkflowParticipant p = signedWith(metadata); + WorkflowSession session = sessionOf(p); + + when(participantRepository.findById(1L)).thenReturn(Optional.of(p)); + when(serverCertificateService.isEnabled()).thenReturn(false); + + assertThatThrownBy(() -> service.finalizeDocument(session, singlePagePdf())) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("Server certificate is not available"); + } + + @Test + @DisplayName("USER_CERT type without an authenticated user is rejected") + void userCertWithoutUserRejected() throws Exception { + Map submission = new HashMap<>(); + submission.put("certType", "USER_CERT"); + Map metadata = new HashMap<>(); + metadata.put("certificateSubmission", submission); + WorkflowParticipant p = signedWith(metadata); + p.setUser(null); + WorkflowSession session = sessionOf(p); + + when(participantRepository.findById(1L)).thenReturn(Optional.of(p)); + + assertThatThrownBy(() -> service.finalizeDocument(session, singlePagePdf())) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("User certificate requires authenticated user"); + } + + @Test + @DisplayName("USER_CERT type loads the per-user keystore and password") + void userCertLoadsUserKeystore() throws Exception { + byte[] p12 = loadCert("valid-test.p12"); + java.security.KeyStore userKs = java.security.KeyStore.getInstance("PKCS12"); + userKs.load(new ByteArrayInputStream(p12), "testpass".toCharArray()); + + Map submission = new HashMap<>(); + submission.put("certType", "USER_CERT"); + Map metadata = new HashMap<>(); + metadata.put("certificateSubmission", submission); + WorkflowParticipant p = signedWith(metadata); + User u = new User(); + u.setId(42L); + p.setUser(u); + WorkflowSession session = sessionOf(p); + + when(participantRepository.findById(1L)).thenReturn(Optional.of(p)); + when(userServerCertificateService.getUserKeyStore(42L)).thenReturn(userKs); + when(userServerCertificateService.getUserKeystorePassword(42L)).thenReturn("testpass"); + when(pdfSigningService.signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + any(), + any(), + any(), + any(), + anyBoolean())) + .thenReturn("user-signed".getBytes()); + + byte[] result = service.finalizeDocument(session, singlePagePdf()); + + assertThat(result).isEqualTo("user-signed".getBytes()); + verify(userServerCertificateService).getOrCreateUserCertificate(42L); + verify(userServerCertificateService).getUserKeyStore(42L); + } + } + + // ------------------------------------------------------------------------- + @Nested + @DisplayName("extractCertificateSubmission - password decryption") + class SubmissionDecryption { + + @Test + @DisplayName("decrypts the submission password before signing") + void decryptsPassword() throws Exception { + byte[] p12 = loadCert("valid-test.p12"); + WorkflowParticipant p = participant(1L, ParticipantStatus.SIGNED); + // stored password is an encrypted token; decrypt() resolves it to the real one + p.setParticipantMetadata(p12SubmissionMetadata(p12, "enc:token")); + WorkflowSession session = sessionOf(p); + + when(participantRepository.findById(1L)).thenReturn(Optional.of(p)); + when(metadataEncryptionService.decrypt("enc:token")).thenReturn("testpass"); + when(pdfSigningService.signWithKeystore( + any(), + any(), + any(), + anyBoolean(), + any(), + any(), + any(), + any(), + anyBoolean())) + .thenReturn("ok".getBytes()); + + byte[] result = service.finalizeDocument(session, singlePagePdf()); + + assertThat(result).isEqualTo("ok".getBytes()); + verify(metadataEncryptionService).decrypt("enc:token"); + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceMoreTest.java new file mode 100644 index 0000000000..a997a4a5d0 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceMoreTest.java @@ -0,0 +1,694 @@ +package stirling.software.proprietary.workflow.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.Storage; +import stirling.software.common.model.ApplicationProperties.Storage.Signing; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.provider.StorageProvider; +import stirling.software.proprietary.workflow.dto.ParticipantRequest; +import stirling.software.proprietary.workflow.dto.SignRequestDetailDTO; +import stirling.software.proprietary.workflow.dto.SignRequestSummaryDTO; +import stirling.software.proprietary.workflow.model.ParticipantStatus; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; +import stirling.software.proprietary.workflow.model.WorkflowSession; +import stirling.software.proprietary.workflow.model.WorkflowStatus; +import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository; +import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository; + +import tools.jackson.databind.ObjectMapper; + +// Covers session lifecycle, participant management and sign-request branches not in +// WorkflowSessionServiceTest. +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class WorkflowSessionServiceMoreTest { + + @Mock private WorkflowSessionRepository workflowSessionRepository; + @Mock private WorkflowParticipantRepository workflowParticipantRepository; + + @Mock + private stirling.software.proprietary.storage.repository.StoredFileRepository + storedFileRepository; + + @Mock private UserRepository userRepository; + @Mock private StorageProvider storageProvider; + @Mock private ObjectMapper objectMapper; + @Mock private ApplicationProperties applicationProperties; + @Mock private MetadataEncryptionService metadataEncryptionService; + @Mock private CertificateSubmissionValidator certificateSubmissionValidator; + + @InjectMocks private WorkflowSessionService service; + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private User user(String username, long id) { + User u = new User(); + u.setUsername(username); + u.setId(id); + return u; + } + + private WorkflowSession session(String id, User owner) { + WorkflowSession s = new WorkflowSession(); + s.setSessionId(id); + s.setOwner(owner); + s.setDocumentName("doc.pdf"); + s.setParticipants(new ArrayList<>()); + return s; + } + + private WorkflowParticipant participant(User user, ParticipantStatus status) { + WorkflowParticipant p = new WorkflowParticipant(); + p.setUser(user); + p.setStatus(status); + return p; + } + + // ------------------------------------------------------------------------- + // ensureSigningEnabled + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("ensureSigningEnabled") + class EnsureSigningEnabled { + + @Test + void storageDisabled_throwsForbidden() { + Storage storage = mock(Storage.class); + when(applicationProperties.getStorage()).thenReturn(storage); + when(storage.isEnabled()).thenReturn(false); + + assertThatThrownBy(() -> service.ensureSigningEnabled()) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void signingDisabled_throwsForbidden() { + Storage storage = mock(Storage.class); + Signing signing = mock(Signing.class); + when(applicationProperties.getStorage()).thenReturn(storage); + when(storage.isEnabled()).thenReturn(true); + when(storage.getSigning()).thenReturn(signing); + when(signing.isEnabled()).thenReturn(false); + + assertThatThrownBy(() -> service.ensureSigningEnabled()) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void allEnabled_doesNotThrow() { + Storage storage = mock(Storage.class); + Signing signing = mock(Signing.class); + when(applicationProperties.getStorage()).thenReturn(storage); + when(storage.isEnabled()).thenReturn(true); + when(storage.getSigning()).thenReturn(signing); + when(signing.isEnabled()).thenReturn(true); + + service.ensureSigningEnabled(); + } + } + + // ------------------------------------------------------------------------- + // getSession / getSessionWithParticipants + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("getSession lookups") + class GetSessionLookups { + + @Test + void getSession_found_returnsSession() { + User owner = user("alice", 1L); + WorkflowSession s = session("s1", owner); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + + assertThat(service.getSession("s1")).isSameAs(s); + } + + @Test + void getSession_notFound_throwsNotFound() { + when(workflowSessionRepository.findBySessionId("x")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getSession("x")) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void getSessionWithParticipants_notFound_throwsNotFound() { + when(workflowSessionRepository.findBySessionIdWithParticipants("x")) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getSessionWithParticipants("x")) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void getSessionWithParticipantsForOwner_wrongOwner_throwsForbidden() { + User owner = user("alice", 1L); + User intruder = user("bob", 2L); + WorkflowSession s = session("s2", owner); + when(workflowSessionRepository.findBySessionIdWithParticipants("s2")) + .thenReturn(Optional.of(s)); + + assertThatThrownBy(() -> service.getSessionWithParticipantsForOwner("s2", intruder)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + } + + // ------------------------------------------------------------------------- + // listActiveSessions + // ------------------------------------------------------------------------- + + @Test + @DisplayName("listActiveSessions delegates to repository") + void listActiveSessions_delegates() { + User owner = user("alice", 1L); + WorkflowSession s = session("s1", owner); + when(workflowSessionRepository.findActiveSessionsByOwner(owner)).thenReturn(List.of(s)); + + assertThat(service.listActiveSessions(owner)).containsExactly(s); + } + + // ------------------------------------------------------------------------- + // addParticipants + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("addParticipants") + class AddParticipants { + + @Test + void inactiveSession_throwsBadRequest() { + User owner = user("alice", 1L); + WorkflowSession s = session("s1", owner); + s.setFinalized(true); // makes isActive() false + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + + ParticipantRequest pr = new ParticipantRequest(); + pr.setEmail("p@example.com"); + + assertThatThrownBy(() -> service.addParticipants("s1", List.of(pr), owner)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void activeSession_emailParticipant_savesParticipant() { + User owner = user("alice", 1L); + WorkflowSession s = session("s1", owner); + s.setStatus(WorkflowStatus.IN_PROGRESS); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + when(workflowParticipantRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + ParticipantRequest pr = new ParticipantRequest(); + pr.setEmail("p@example.com"); + pr.setName("Pat"); + + service.addParticipants("s1", List.of(pr), owner); + + verify(workflowParticipantRepository).save(any(WorkflowParticipant.class)); + assertThat(s.getParticipants()).hasSize(1); + assertThat(s.getParticipants().get(0).getEmail()).isEqualTo("p@example.com"); + } + + @Test + void participantWithoutUserIdOrEmail_throwsBadRequest() { + User owner = user("alice", 1L); + WorkflowSession s = session("s1", owner); + s.setStatus(WorkflowStatus.IN_PROGRESS); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + + ParticipantRequest pr = new ParticipantRequest(); // neither userId nor email + + assertThatThrownBy(() -> service.addParticipants("s1", List.of(pr), owner)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void participantWithUnknownUserId_throwsNotFound() { + User owner = user("alice", 1L); + WorkflowSession s = session("s1", owner); + s.setStatus(WorkflowStatus.IN_PROGRESS); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + when(userRepository.findById(99L)).thenReturn(Optional.empty()); + + ParticipantRequest pr = new ParticipantRequest(); + pr.setUserId(99L); + + assertThatThrownBy(() -> service.addParticipants("s1", List.of(pr), owner)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND); + } + } + + // ------------------------------------------------------------------------- + // removeParticipant + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("removeParticipant") + class RemoveParticipant { + + @Test + void participantNotFound_throwsNotFound() { + User owner = user("alice", 1L); + WorkflowSession s = session("s1", owner); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + when(workflowParticipantRepository.findById(5L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.removeParticipant("s1", 5L, owner)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void participantInOtherSession_throwsBadRequest() { + User owner = user("alice", 1L); + WorkflowSession s = session("s1", owner); + WorkflowSession other = session("s2", owner); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + WorkflowParticipant p = participant(user("p", 9L), ParticipantStatus.PENDING); + p.setWorkflowSession(other); + when(workflowParticipantRepository.findById(5L)).thenReturn(Optional.of(p)); + + assertThatThrownBy(() -> service.removeParticipant("s1", 5L, owner)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void validParticipant_removedAndDeleted() { + User owner = user("alice", 1L); + WorkflowSession s = session("s1", owner); + WorkflowParticipant p = participant(user("p", 9L), ParticipantStatus.PENDING); + s.addParticipant(p); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + when(workflowParticipantRepository.findById(5L)).thenReturn(Optional.of(p)); + + service.removeParticipant("s1", 5L, owner); + + verify(workflowParticipantRepository).delete(p); + assertThat(s.getParticipants()).isEmpty(); + } + } + + // ------------------------------------------------------------------------- + // updateParticipantStatus / addParticipantNotification + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("participant status and notifications") + class StatusAndNotifications { + + @Test + void updateParticipantStatus_notFound_throwsNotFound() { + when(workflowParticipantRepository.findById(1L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateParticipantStatus(1L, ParticipantStatus.VIEWED)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void updateParticipantStatus_found_updatesAndSaves() { + WorkflowParticipant p = participant(user("p", 1L), ParticipantStatus.PENDING); + when(workflowParticipantRepository.findById(1L)).thenReturn(Optional.of(p)); + + service.updateParticipantStatus(1L, ParticipantStatus.VIEWED); + + assertThat(p.getStatus()).isEqualTo(ParticipantStatus.VIEWED); + verify(workflowParticipantRepository).save(p); + } + + @Test + void addParticipantNotification_appendsTimestampedMessage() { + WorkflowParticipant p = participant(user("p", 1L), ParticipantStatus.PENDING); + when(workflowParticipantRepository.findById(1L)).thenReturn(Optional.of(p)); + + service.addParticipantNotification(1L, "Hello"); + + assertThat(p.getNotifications()).hasSize(1); + assertThat(p.getNotifications().get(0)).endsWith(": Hello"); + verify(workflowParticipantRepository).save(p); + } + + @Test + void addParticipantNotification_notFound_throwsNotFound() { + when(workflowParticipantRepository.findById(2L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.addParticipantNotification(2L, "x")) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND); + } + } + + // ------------------------------------------------------------------------- + // finalizeSession + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("finalizeSession") + class FinalizeSession { + + @Test + void alreadyFinalized_throwsBadRequest() { + User owner = user("alice", 1L); + WorkflowSession s = session("s1", owner); + s.setFinalized(true); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + + assertThatThrownBy(() -> service.finalizeSession("s1", owner)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void notYetFinalized_marksCompleted() { + User owner = user("alice", 1L); + WorkflowSession s = session("s1", owner); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + + service.finalizeSession("s1", owner); + + assertThat(s.isFinalized()).isTrue(); + assertThat(s.getStatus()).isEqualTo(WorkflowStatus.COMPLETED); + verify(workflowSessionRepository).save(s); + } + } + + // ------------------------------------------------------------------------- + // getProcessedFile / getOriginalFile + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("file retrieval") + class FileRetrieval { + + @Test + void getProcessedFile_noProcessedFile_throwsNotFound() { + User owner = user("alice", 1L); + WorkflowSession s = session("s1", owner); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + + assertThatThrownBy(() -> service.getProcessedFile("s1", owner)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void getProcessedFile_present_returnsBytes() throws IOException { + User owner = user("alice", 1L); + WorkflowSession s = session("s1", owner); + StoredFile pf = new StoredFile(); + pf.setStorageKey("proc-key"); + s.setProcessedFile(pf); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + Resource resource = new ByteArrayResource(new byte[] {1, 2, 3}); + when(storageProvider.load("proc-key")).thenReturn(resource); + + assertThat(service.getProcessedFile("s1", owner)).containsExactly(1, 2, 3); + } + + @Test + void getOriginalFile_noOriginalFile_throwsNotFound() { + User owner = user("alice", 1L); + WorkflowSession s = session("s1", owner); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + + assertThatThrownBy(() -> service.getOriginalFile("s1")) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void getOriginalFile_present_returnsBytes() throws IOException { + User owner = user("alice", 1L); + WorkflowSession s = session("s1", owner); + StoredFile of = new StoredFile(); + of.setStorageKey("orig-key"); + s.setOriginalFile(of); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + Resource resource = new ByteArrayResource(new byte[] {7}); + when(storageProvider.load("orig-key")).thenReturn(resource); + + assertThat(service.getOriginalFile("s1")).containsExactly(7); + } + } + + // ------------------------------------------------------------------------- + // listSignRequests / getSignRequestDetail / getSignRequestDocument + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("sign request views") + class SignRequestViews { + + @Test + void listSignRequests_mapsParticipationsToSummaries() { + User user = user("alice", 1L); + User owner = user("owner", 2L); + WorkflowSession s = session("s1", owner); + s.setCreatedAt(LocalDateTime.now()); + WorkflowParticipant p = participant(user, ParticipantStatus.NOTIFIED); + p.setWorkflowSession(s); + when(workflowParticipantRepository.findByUserOrderByLastUpdatedDesc(user)) + .thenReturn(List.of(p)); + + List result = service.listSignRequests(user); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getSessionId()).isEqualTo("s1"); + assertThat(result.get(0).getOwnerUsername()).isEqualTo("owner"); + assertThat(result.get(0).getMyStatus()).isEqualTo(ParticipantStatus.NOTIFIED); + } + + @Test + void getSignRequestDetail_notifiedParticipant_transitionsToViewed() { + User user = user("alice", 1L); + User owner = user("owner", 2L); + WorkflowSession s = session("s1", owner); + s.setCreatedAt(LocalDateTime.now()); + WorkflowParticipant p = participant(user, ParticipantStatus.NOTIFIED); + s.addParticipant(p); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + + SignRequestDetailDTO dto = service.getSignRequestDetail("s1", user); + + assertThat(dto.getMyStatus()).isEqualTo(ParticipantStatus.NOTIFIED); + assertThat(p.getStatus()).isEqualTo(ParticipantStatus.VIEWED); + verify(workflowParticipantRepository).save(p); + } + + @Test + void getSignRequestDetail_readsAppearanceFromMetadata() { + User user = user("alice", 1L); + User owner = user("owner", 2L); + WorkflowSession s = session("s1", owner); + s.setCreatedAt(LocalDateTime.now()); + Map meta = new HashMap<>(); + meta.put("showSignature", true); + meta.put("pageNumber", 3); + meta.put("reason", "Approval"); + s.setWorkflowMetadata(meta); + WorkflowParticipant p = participant(user, ParticipantStatus.VIEWED); + s.addParticipant(p); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + + SignRequestDetailDTO dto = service.getSignRequestDetail("s1", user); + + assertThat(dto.getShowSignature()).isTrue(); + assertThat(dto.getPageNumber()).isEqualTo(3); + assertThat(dto.getReason()).isEqualTo("Approval"); + } + + @Test + void getSignRequestDetail_userNotParticipant_throwsForbidden() { + User intruder = user("intruder", 3L); + User owner = user("owner", 2L); + WorkflowSession s = session("s1", owner); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + + assertThatThrownBy(() -> service.getSignRequestDetail("s1", intruder)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void getSignRequestDocument_servesOriginalBeforeFinalize() throws IOException { + User user = user("alice", 1L); + User owner = user("owner", 2L); + WorkflowSession s = session("s1", owner); + StoredFile of = new StoredFile(); + of.setStorageKey("orig-key"); + s.setOriginalFile(of); + WorkflowParticipant p = participant(user, ParticipantStatus.PENDING); + s.addParticipant(p); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + when(storageProvider.load("orig-key")) + .thenReturn(new ByteArrayResource(new byte[] {5})); + + assertThat(service.getSignRequestDocument("s1", user)).containsExactly(5); + } + + @Test + void getSignRequestDocument_noFile_throwsNotFound() { + User user = user("alice", 1L); + User owner = user("owner", 2L); + WorkflowSession s = session("s1", owner); + WorkflowParticipant p = participant(user, ParticipantStatus.PENDING); + s.addParticipant(p); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + + assertThatThrownBy(() -> service.getSignRequestDocument("s1", user)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND); + } + } + + // ------------------------------------------------------------------------- + // declineSignRequest + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("declineSignRequest") + class DeclineSignRequest { + + @Test + void alreadySigned_throwsBadRequest() { + User user = user("alice", 1L); + User owner = user("owner", 2L); + WorkflowSession s = session("s1", owner); + WorkflowParticipant p = participant(user, ParticipantStatus.SIGNED); + s.addParticipant(p); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + + assertThatThrownBy(() -> service.declineSignRequest("s1", user)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + + verify(workflowParticipantRepository, never()).save(any()); + } + + @Test + void pendingParticipant_setsDeclined() { + User user = user("alice", 1L); + User owner = user("owner", 2L); + WorkflowSession s = session("s1", owner); + WorkflowParticipant p = participant(user, ParticipantStatus.PENDING); + s.addParticipant(p); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(s)); + + service.declineSignRequest("s1", user); + + assertThat(p.getStatus()).isEqualTo(ParticipantStatus.DECLINED); + verify(workflowParticipantRepository).save(p); + } + } + + // ------------------------------------------------------------------------- + // deleteOriginalFile + // ------------------------------------------------------------------------- + + @Nested + @DisplayName("deleteOriginalFile") + class DeleteOriginalFile { + + @Test + void noOriginalFile_noOp() { + WorkflowSession s = session("s1", user("alice", 1L)); + + service.deleteOriginalFile(s); + + verify(storedFileRepository, never()).delete(any()); + } + + @Test + void withOriginalFile_deletesAndNullsReference() throws IOException { + WorkflowSession s = session("s1", user("alice", 1L)); + StoredFile of = new StoredFile(); + of.setStorageKey("orig-key"); + s.setOriginalFile(of); + + service.deleteOriginalFile(s); + + assertThat(s.getOriginalFile()).isNull(); + verify(storageProvider).delete("orig-key"); + verify(storedFileRepository).delete(of); + verify(workflowSessionRepository).save(s); + } + + @Test + void storageError_nonFatal_keepsReference() throws IOException { + WorkflowSession s = session("s1", user("alice", 1L)); + StoredFile of = new StoredFile(); + of.setStorageKey("orig-key"); + s.setOriginalFile(of); + org.mockito.Mockito.doThrow(new RuntimeException("boom")) + .when(storageProvider) + .delete("orig-key"); + + service.deleteOriginalFile(s); + + // delete threw before nulling — reference still present, no DB delete + verify(storedFileRepository, never()).delete(any()); + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java index c0a78d56e0..967b725b5e 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java @@ -296,7 +296,8 @@ public class SupabaseSecurityConfig { "X-Requested-With", "Accept", "Origin", - "X-API-KEY")); + "X-API-KEY", + "X-Browser-Id")); cfg.setExposedHeaders(List.of("WWW-Authenticate")); cfg.setAllowCredentials(true); cfg.setMaxAge(3600L); diff --git a/app/saas/src/test/java/stirling/software/saas/billing/model/BillingSubscriptionTest.java b/app/saas/src/test/java/stirling/software/saas/billing/model/BillingSubscriptionTest.java new file mode 100644 index 0000000000..08477cfd98 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/billing/model/BillingSubscriptionTest.java @@ -0,0 +1,106 @@ +package stirling.software.saas.billing.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** Accessor and isActive/isValid branch tests for the BillingSubscription Stripe mirror entity. */ +class BillingSubscriptionTest { + + private static BillingSubscription withStatus(String status) { + BillingSubscription sub = new BillingSubscription(); + sub.setStatus(status); + return sub; + } + + @Test + @DisplayName("every setter round-trips through its getter") + void settersRoundTrip() { + UUID userId = UUID.randomUUID(); + LocalDateTime periodEnd = LocalDateTime.of(2026, 7, 1, 0, 0); + LocalDateTime created = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime updated = LocalDateTime.of(2026, 6, 2, 0, 0); + + BillingSubscription sub = new BillingSubscription(); + sub.setId("sub_123"); + sub.setUserId(userId); + sub.setTeamId(7L); + sub.setStatus("active"); + sub.setPriceId("price_abc"); + sub.setCurrentPeriodEnd(periodEnd); + sub.setCreatedAt(created); + sub.setUpdatedAt(updated); + + assertThat(sub.getId()).isEqualTo("sub_123"); + assertThat(sub.getUserId()).isEqualTo(userId); + assertThat(sub.getTeamId()).isEqualTo(7L); + assertThat(sub.getStatus()).isEqualTo("active"); + assertThat(sub.getPriceId()).isEqualTo("price_abc"); + assertThat(sub.getCurrentPeriodEnd()).isEqualTo(periodEnd); + assertThat(sub.getCreatedAt()).isEqualTo(created); + assertThat(sub.getUpdatedAt()).isEqualTo(updated); + } + + @Nested + @DisplayName("isActive") + class IsActive { + + @Test + @DisplayName("true for active, trialing, and past_due (case-insensitive)") + void activeStatuses() { + assertThat(withStatus("active").isActive()).isTrue(); + assertThat(withStatus("ACTIVE").isActive()).isTrue(); + assertThat(withStatus("trialing").isActive()).isTrue(); + assertThat(withStatus("Past_Due").isActive()).isTrue(); + } + + @Test + @DisplayName("false for canceled and unknown statuses") + void inactiveStatuses() { + assertThat(withStatus("canceled").isActive()).isFalse(); + assertThat(withStatus("incomplete_expired").isActive()).isFalse(); + } + } + + @Nested + @DisplayName("isValid") + class IsValid { + + @Test + @DisplayName("active with a null period end is valid (open-ended)") + void activeNullPeriodEnd() { + BillingSubscription sub = withStatus("active"); + assertThat(sub.getCurrentPeriodEnd()).isNull(); + assertThat(sub.isValid()).isTrue(); + } + + @Test + @DisplayName("active with a future period end is valid") + void activeFuturePeriodEnd() { + BillingSubscription sub = withStatus("active"); + sub.setCurrentPeriodEnd(LocalDateTime.now().plusDays(5)); + assertThat(sub.isValid()).isTrue(); + } + + @Test + @DisplayName("active but past the period end is not valid") + void activeExpiredPeriodEnd() { + BillingSubscription sub = withStatus("active"); + sub.setCurrentPeriodEnd(LocalDateTime.now().minusDays(1)); + assertThat(sub.isValid()).isFalse(); + } + + @Test + @DisplayName("inactive status is never valid even with a future period end") + void inactiveNeverValid() { + BillingSubscription sub = withStatus("canceled"); + sub.setCurrentPeriodEnd(LocalDateTime.now().plusDays(5)); + assertThat(sub.isValid()).isFalse(); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/billing/service/StripeUsageReportingServiceMoreTest.java b/app/saas/src/test/java/stirling/software/saas/billing/service/StripeUsageReportingServiceMoreTest.java new file mode 100644 index 0000000000..e52322b1f5 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/billing/service/StripeUsageReportingServiceMoreTest.java @@ -0,0 +1,69 @@ +package stirling.software.saas.billing.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Field; +import java.net.ServerSocket; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.saas.config.SupabaseConfigurationProperties; + +/** + * Branch-gap tests for {@link StripeUsageReportingService} covering the failure paths the + * happy-path suite does not reach: the network {@code IOException} catch (target refuses the + * connection) and the generic {@code Exception} catch (a malformed URL makes {@code URI.create} + * throw). + * + *

Edge-function config is populated so execution gets past the early config guards and actually + * attempts the HTTP send. + */ +class StripeUsageReportingServiceMoreTest { + + private StripeUsageReportingService newService(String supabaseUrl) + throws ReflectiveOperationException { + SupabaseConfigurationProperties props = new SupabaseConfigurationProperties(); + props.setEdgeFunctionUrl(supabaseUrl); + props.setEdgeFunctionSecret("edge-secret"); + + StripeUsageReportingService svc = new StripeUsageReportingService(props); + Field f = StripeUsageReportingService.class.getDeclaredField("supabaseUrl"); + f.setAccessible(true); + f.set(svc, supabaseUrl); + return svc; + } + + @Nested + @DisplayName("reportUsageToStripe - failure paths") + class FailurePaths { + + @Test + @DisplayName("returns false on a network error (connection refused)") + void networkError_returnsFalse() throws Exception { + // Grab a port, then close it so nothing is listening -> ConnectException (IOException). + int closedPort; + try (ServerSocket socket = new ServerSocket(0)) { + closedPort = socket.getLocalPort(); + } + StripeUsageReportingService svc = newService("http://127.0.0.1:" + closedPort); + + boolean ok = svc.reportUsageToStripe(UUID.randomUUID().toString(), 5, "k"); + + assertThat(ok).isFalse(); + } + + @Test + @DisplayName("returns false when the configured URL is malformed (generic catch)") + void malformedUrl_returnsFalse() throws Exception { + // The space makes "http://exa mple.com/functions/v1/meter-usage" fail URI.create. + StripeUsageReportingService svc = newService("http://exa mple.com"); + + boolean ok = svc.reportUsageToStripe(UUID.randomUUID().toString(), 5, "k"); + + assertThat(ok).isFalse(); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/config/SaasDataSourceConfigTest.java b/app/saas/src/test/java/stirling/software/saas/config/SaasDataSourceConfigTest.java new file mode 100644 index 0000000000..625b9712e3 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/config/SaasDataSourceConfigTest.java @@ -0,0 +1,185 @@ +package stirling.software.saas.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; +import org.springframework.boot.jdbc.DatabaseDriver; +import org.springframework.test.util.ReflectionTestUtils; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +/** + * Unit tests for {@link SaasDataSourceConfig}. + * + *

The config's {@code @Value} fields are populated via {@link ReflectionTestUtils} so the {@code + * saasDataSource()} bean method can be exercised without a Spring context. The returned {@link + * HikariDataSource} is created lazily (no real connection until first use), so we can assert its + * wiring and then close it in {@code @AfterEach}. + */ +class SaasDataSourceConfigTest { + + private final SaasDataSourceConfig config = new SaasDataSourceConfig(); + private DataSource created; + + @AfterEach + void closePool() { + if (created instanceof HikariDataSource hikari) { + hikari.close(); + } + } + + private void wireDefaults() { + ReflectionTestUtils.setField(config, "username", "postgres"); + ReflectionTestUtils.setField(config, "password", "secret"); + ReflectionTestUtils.setField(config, "maximumPoolSize", 20); + ReflectionTestUtils.setField(config, "minimumIdle", 5); + ReflectionTestUtils.setField(config, "idleTimeout", 600000L); + ReflectionTestUtils.setField(config, "maxLifetime", 1800000L); + ReflectionTestUtils.setField(config, "keepaliveTime", 300000L); + ReflectionTestUtils.setField(config, "applicationName", "StirlingPDF-SaaS"); + ReflectionTestUtils.setField( + config, "connectionInitSql", "SET search_path TO stirling_pdf, auth, public"); + } + + @Nested + @DisplayName("saasDataSource - missing url guard") + class MissingUrl { + + @Test + @DisplayName("throws IllegalStateException when url is null") + void nullUrl_throws() { + wireDefaults(); + ReflectionTestUtils.setField(config, "url", null); + + assertThatThrownBy(config::saasDataSource) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("spring.datasource.url is required"); + } + + @Test + @DisplayName("throws IllegalStateException when url is blank") + void blankUrl_throws() { + wireDefaults(); + ReflectionTestUtils.setField(config, "url", " "); + + assertThatThrownBy(config::saasDataSource) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("saas profile"); + } + } + + @Nested + @DisplayName("saasDataSource - successful wiring") + class SuccessfulWiring { + + // new HikariDataSource(config) eagerly opens the pool, so intercept construction and assert + // on the HikariConfig the bean built instead of connecting to a real Postgres. + private HikariConfig capture(Runnable build) { + HikariConfig[] holder = new HikariConfig[1]; + try (MockedConstruction mocked = + Mockito.mockConstruction( + HikariDataSource.class, + (mock, ctx) -> holder[0] = (HikariConfig) ctx.arguments().get(0))) { + build.run(); + } + return holder[0]; + } + + @Test + @DisplayName("builds a Hikari pool with the configured properties") + void buildsPool() { + wireDefaults(); + ReflectionTestUtils.setField( + config, "url", "jdbc:postgresql://localhost:5432/stirling"); + + HikariConfig hikari = capture(config::saasDataSource); + + assertThat(hikari.getUsername()).isEqualTo("postgres"); + assertThat(hikari.getPassword()).isEqualTo("secret"); + assertThat(hikari.getMaximumPoolSize()).isEqualTo(20); + assertThat(hikari.getMinimumIdle()).isEqualTo(5); + assertThat(hikari.getIdleTimeout()).isEqualTo(600000L); + assertThat(hikari.getMaxLifetime()).isEqualTo(1800000L); + assertThat(hikari.getKeepaliveTime()).isEqualTo(300000L); + assertThat(hikari.getDriverClassName()) + .isEqualTo(DatabaseDriver.POSTGRESQL.getDriverClassName()); + assertThat(hikari.getConnectionInitSql()) + .isEqualTo("SET search_path TO stirling_pdf, auth, public"); + } + + @Test + @DisplayName("appends ApplicationName to a url that has no query string") + void appendsApplicationName_noQuery() { + wireDefaults(); + ReflectionTestUtils.setField( + config, "url", "jdbc:postgresql://localhost:5432/stirling"); + + HikariConfig hikari = capture(config::saasDataSource); + + assertThat(hikari.getJdbcUrl()).contains("?ApplicationName=StirlingPDF-SaaS"); + } + + @Test + @DisplayName("appends ApplicationName with '&' when url already has a query string") + void appendsApplicationName_existingQuery() { + wireDefaults(); + ReflectionTestUtils.setField( + config, "url", "jdbc:postgresql://localhost:5432/stirling?sslmode=require"); + + HikariConfig hikari = capture(config::saasDataSource); + + assertThat(hikari.getJdbcUrl()).contains("&ApplicationName=StirlingPDF-SaaS"); + } + + @Test + @DisplayName("does not duplicate ApplicationName when already present (case-insensitive)") + void doesNotDuplicateApplicationName() { + wireDefaults(); + ReflectionTestUtils.setField( + config, + "url", + "jdbc:postgresql://localhost:5432/stirling?applicationname=Existing"); + + HikariConfig hikari = capture(config::saasDataSource); + + assertThat(hikari.getJdbcUrl()) + .isEqualTo( + "jdbc:postgresql://localhost:5432/stirling?applicationname=Existing"); + } + + @Test + @DisplayName("skips connection-init-sql when blank") + void blankConnectionInitSql_notSet() { + wireDefaults(); + ReflectionTestUtils.setField( + config, "url", "jdbc:postgresql://localhost:5432/stirling"); + ReflectionTestUtils.setField(config, "connectionInitSql", " "); + + HikariConfig hikari = capture(config::saasDataSource); + + assertThat(hikari.getConnectionInitSql()).isNull(); + } + + @Test + @DisplayName("skips connection-init-sql when null") + void nullConnectionInitSql_notSet() { + wireDefaults(); + ReflectionTestUtils.setField( + config, "url", "jdbc:postgresql://localhost:5432/stirling"); + ReflectionTestUtils.setField(config, "connectionInitSql", null); + + HikariConfig hikari = capture(config::saasDataSource); + + assertThat(hikari.getConnectionInitSql()).isNull(); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/config/SaasLicenseOverrideTest.java b/app/saas/src/test/java/stirling/software/saas/config/SaasLicenseOverrideTest.java new file mode 100644 index 0000000000..ae3373225c --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/config/SaasLicenseOverrideTest.java @@ -0,0 +1,36 @@ +package stirling.software.saas.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link SaasLicenseOverride}. + * + *

Saas mode is unconditionally enterprise. These bean methods are the source of truth for the + * {@code runningProOrHigher}, {@code license}, and {@code runningEE} beans, so a regression would + * silently downgrade every tenant's feature set. + */ +class SaasLicenseOverrideTest { + + private final SaasLicenseOverride override = new SaasLicenseOverride(); + + @Test + @DisplayName("runningProOrHigher bean is true") + void runningProOrHigher() { + assertThat(override.runningProOrHigherSaas()).isTrue(); + } + + @Test + @DisplayName("license bean is ENTERPRISE") + void license() { + assertThat(override.licenseTypeSaas()).isEqualTo("ENTERPRISE"); + } + + @Test + @DisplayName("runningEE bean is true") + void runningEnterprise() { + assertThat(override.runningEnterpriseSaas()).isTrue(); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/config/SaasRestTemplateConfigTest.java b/app/saas/src/test/java/stirling/software/saas/config/SaasRestTemplateConfigTest.java new file mode 100644 index 0000000000..830ee26ac3 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/config/SaasRestTemplateConfigTest.java @@ -0,0 +1,47 @@ +package stirling.software.saas.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestTemplate; + +/** + * Unit tests for {@link SaasRestTemplateConfig}. + * + *

Verifies the {@code saasRestTemplate()} bean is built on a {@link + * SimpleClientHttpRequestFactory} with the bounded connect (10s) and read (30s) timeouts the + * Supabase Edge Function client relies on. + */ +class SaasRestTemplateConfigTest { + + private final SaasRestTemplateConfig config = new SaasRestTemplateConfig(); + + @Test + @DisplayName("returns a non-null RestTemplate backed by SimpleClientHttpRequestFactory") + void returnsRestTemplate() { + RestTemplate template = config.saasRestTemplate(); + + assertThat(template).isNotNull(); + assertThat(template.getRequestFactory()).isInstanceOf(SimpleClientHttpRequestFactory.class); + } + + @Test + @DisplayName("configures the connect and read timeouts on the request factory") + void configuresTimeouts() { + RestTemplate template = config.saasRestTemplate(); + + SimpleClientHttpRequestFactory factory = + (SimpleClientHttpRequestFactory) template.getRequestFactory(); + assertThat(ReflectionTestUtils.getField(factory, "connectTimeout")).isEqualTo(10_000); + assertThat(ReflectionTestUtils.getField(factory, "readTimeout")).isEqualTo(30_000); + } + + @Test + @DisplayName("each invocation builds a fresh instance") + void buildsFreshInstance() { + assertThat(config.saasRestTemplate()).isNotSameAs(config.saasRestTemplate()); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/config/SupabaseConfigurationPropertiesTest.java b/app/saas/src/test/java/stirling/software/saas/config/SupabaseConfigurationPropertiesTest.java new file mode 100644 index 0000000000..7f07f59751 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/config/SupabaseConfigurationPropertiesTest.java @@ -0,0 +1,140 @@ +package stirling.software.saas.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link SupabaseConfigurationProperties}. + * + *

A plain {@code @Data} config bean. Covers the Lombok getters/setters round-trip, the {@code + * clockSkewSeconds} default, and both decision methods ({@code isJwtConfigured}, {@code + * isEdgeFunctionConfigured}) across their null/blank/populated branches. + */ +class SupabaseConfigurationPropertiesTest { + + @Nested + @DisplayName("defaults and accessors") + class DefaultsAndAccessors { + + @Test + @DisplayName("clockSkewSeconds defaults to 120") + void clockSkewDefault() { + assertThat(new SupabaseConfigurationProperties().getClockSkewSeconds()).isEqualTo(120L); + } + + @Test + @DisplayName("all fields round-trip through getters and setters") + void gettersAndSetters() { + SupabaseConfigurationProperties props = new SupabaseConfigurationProperties(); + + props.setIssuer("https://abc.supabase.co/auth/v1"); + props.setExpectedAud("authenticated"); + props.setClockSkewSeconds(300L); + props.setEdgeFunctionUrl("https://abc.supabase.co/functions/v1"); + props.setEdgeFunctionSecret("shhh"); + + assertThat(props.getIssuer()).isEqualTo("https://abc.supabase.co/auth/v1"); + assertThat(props.getExpectedAud()).isEqualTo("authenticated"); + assertThat(props.getClockSkewSeconds()).isEqualTo(300L); + assertThat(props.getEdgeFunctionUrl()) + .isEqualTo("https://abc.supabase.co/functions/v1"); + assertThat(props.getEdgeFunctionSecret()).isEqualTo("shhh"); + } + + @Test + @DisplayName("equals/hashCode/toString are generated by Lombok @Data") + void lombokDataContracts() { + SupabaseConfigurationProperties a = new SupabaseConfigurationProperties(); + a.setIssuer("iss"); + SupabaseConfigurationProperties b = new SupabaseConfigurationProperties(); + b.setIssuer("iss"); + + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a.toString()).contains("iss"); + } + } + + @Nested + @DisplayName("isJwtConfigured") + class IsJwtConfigured { + + @Test + @DisplayName("false when issuer is null") + void nullIssuer() { + assertThat(new SupabaseConfigurationProperties().isJwtConfigured()).isFalse(); + } + + @Test + @DisplayName("false when issuer is blank") + void blankIssuer() { + SupabaseConfigurationProperties props = new SupabaseConfigurationProperties(); + props.setIssuer(" "); + assertThat(props.isJwtConfigured()).isFalse(); + } + + @Test + @DisplayName("true when issuer is set") + void issuerSet() { + SupabaseConfigurationProperties props = new SupabaseConfigurationProperties(); + props.setIssuer("https://abc.supabase.co/auth/v1"); + assertThat(props.isJwtConfigured()).isTrue(); + } + } + + @Nested + @DisplayName("isEdgeFunctionConfigured") + class IsEdgeFunctionConfigured { + + @Test + @DisplayName("false when both url and secret are null") + void bothNull() { + assertThat(new SupabaseConfigurationProperties().isEdgeFunctionConfigured()).isFalse(); + } + + @Test + @DisplayName("false when only the url is set") + void urlOnly() { + SupabaseConfigurationProperties props = new SupabaseConfigurationProperties(); + props.setEdgeFunctionUrl("https://abc.supabase.co/functions/v1"); + assertThat(props.isEdgeFunctionConfigured()).isFalse(); + } + + @Test + @DisplayName("false when only the secret is set") + void secretOnly() { + SupabaseConfigurationProperties props = new SupabaseConfigurationProperties(); + props.setEdgeFunctionSecret("shhh"); + assertThat(props.isEdgeFunctionConfigured()).isFalse(); + } + + @Test + @DisplayName("false when url is set but secret is blank") + void urlSetSecretBlank() { + SupabaseConfigurationProperties props = new SupabaseConfigurationProperties(); + props.setEdgeFunctionUrl("https://abc.supabase.co/functions/v1"); + props.setEdgeFunctionSecret(" "); + assertThat(props.isEdgeFunctionConfigured()).isFalse(); + } + + @Test + @DisplayName("false when url is blank but secret is set") + void urlBlankSecretSet() { + SupabaseConfigurationProperties props = new SupabaseConfigurationProperties(); + props.setEdgeFunctionUrl(" "); + props.setEdgeFunctionSecret("shhh"); + assertThat(props.isEdgeFunctionConfigured()).isFalse(); + } + + @Test + @DisplayName("true when both url and secret are set") + void bothSet() { + SupabaseConfigurationProperties props = new SupabaseConfigurationProperties(); + props.setEdgeFunctionUrl("https://abc.supabase.co/functions/v1"); + props.setEdgeFunctionSecret("shhh"); + assertThat(props.isEdgeFunctionConfigured()).isTrue(); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/model/AmrMethodTest.java b/app/saas/src/test/java/stirling/software/saas/model/AmrMethodTest.java new file mode 100644 index 0000000000..5ea2261e42 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/model/AmrMethodTest.java @@ -0,0 +1,73 @@ +package stirling.software.saas.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Covers the {@link AmrMethod} enum: every constant's wire value, values(), and valueOf(). */ +class AmrMethodTest { + + @Test + @DisplayName("values() lists every Supabase amr method") + void valuesListsAllConstants() { + assertThat(AmrMethod.values()) + .containsExactly( + AmrMethod.OAUTH, + AmrMethod.PASSWORD, + AmrMethod.OTP, + AmrMethod.TOTP, + AmrMethod.RECOVERY, + AmrMethod.INVITE, + AmrMethod.SSO_SAML, + AmrMethod.MAGICLINK, + AmrMethod.EMAIL_SIGNUP, + AmrMethod.EMAIL_CHANGE, + AmrMethod.TOKEN_REFRESH, + AmrMethod.ANONYMOUS); + } + + @Test + @DisplayName("getMethod() returns the JWT amr-claim wire value for each constant") + void getMethodReturnsWireValue() { + assertThat(AmrMethod.OAUTH.getMethod()).isEqualTo("oauth"); + assertThat(AmrMethod.PASSWORD.getMethod()).isEqualTo("password"); + assertThat(AmrMethod.OTP.getMethod()).isEqualTo("otp"); + assertThat(AmrMethod.TOTP.getMethod()).isEqualTo("totp"); + assertThat(AmrMethod.RECOVERY.getMethod()).isEqualTo("recovery"); + assertThat(AmrMethod.INVITE.getMethod()).isEqualTo("invite"); + assertThat(AmrMethod.SSO_SAML.getMethod()).isEqualTo("sso/saml"); + assertThat(AmrMethod.MAGICLINK.getMethod()).isEqualTo("magiclink"); + assertThat(AmrMethod.EMAIL_SIGNUP.getMethod()).isEqualTo("email/signup"); + assertThat(AmrMethod.EMAIL_CHANGE.getMethod()).isEqualTo("email_change"); + assertThat(AmrMethod.TOKEN_REFRESH.getMethod()).isEqualTo("token_refresh"); + assertThat(AmrMethod.ANONYMOUS.getMethod()).isEqualTo("anonymous"); + } + + @Test + @DisplayName("valueOf round-trips the constant name") + void valueOfRoundTrips() { + for (AmrMethod method : AmrMethod.values()) { + assertThat(AmrMethod.valueOf(method.name())).isSameAs(method); + } + } + + @Test + @DisplayName("valueOf rejects an unknown name") + void valueOfRejectsUnknown() { + assertThatThrownBy(() -> AmrMethod.valueOf("not_a_method")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("every wire value is distinct") + void wireValuesAreUnique() { + long distinct = + java.util.Arrays.stream(AmrMethod.values()) + .map(AmrMethod::getMethod) + .distinct() + .count(); + assertThat(distinct).isEqualTo(AmrMethod.values().length); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/model/SaasTeamExtensionsTest.java b/app/saas/src/test/java/stirling/software/saas/model/SaasTeamExtensionsTest.java new file mode 100644 index 0000000000..571c5f4014 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/model/SaasTeamExtensionsTest.java @@ -0,0 +1,137 @@ +package stirling.software.saas.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.model.Team; + +/** Constructor, defaults, accessor, and seat/personal-team branch tests for SaasTeamExtensions. */ +class SaasTeamExtensionsTest { + + @Test + @DisplayName("no-arg constructor carries sensible standard-team defaults") + void defaults() { + SaasTeamExtensions ext = new SaasTeamExtensions(); + assertThat(ext.getTeamType()).isEqualTo(SaasTeamExtensions.TEAM_TYPE_STANDARD); + assertThat(ext.getIsPersonal()).isFalse(); + assertThat(ext.getSeatCount()).isEqualTo(1); + assertThat(ext.getSeatsUsed()).isZero(); + assertThat(ext.getMaxSeats()).isEqualTo(1); + assertThat(ext.isPersonal()).isFalse(); + } + + @Test + @DisplayName("Team constructor derives the team id from the team reference") + void teamConstructor() { + Team team = new Team(); + team.setId(42L); + SaasTeamExtensions ext = new SaasTeamExtensions(team); + assertThat(ext.getTeam()).isSameAs(team); + assertThat(ext.getTeamId()).isEqualTo(42L); + } + + @Test + @DisplayName("every setter round-trips through its getter") + void settersRoundTrip() { + LocalDateTime created = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime updated = LocalDateTime.of(2026, 6, 2, 0, 0); + + SaasTeamExtensions ext = new SaasTeamExtensions(); + ext.setTeamId(5L); + ext.setTeamType(SaasTeamExtensions.TEAM_TYPE_PERSONAL); + ext.setIsPersonal(Boolean.TRUE); + ext.setSeatCount(3); + ext.setSeatsUsed(2); + ext.setMaxSeats(10); + ext.setCreatedByUserId(99L); + ext.setCreatedAt(created); + ext.setUpdatedAt(updated); + ext.setVersion(4L); + + assertThat(ext.getTeamId()).isEqualTo(5L); + assertThat(ext.getTeamType()).isEqualTo(SaasTeamExtensions.TEAM_TYPE_PERSONAL); + assertThat(ext.getIsPersonal()).isTrue(); + assertThat(ext.getSeatCount()).isEqualTo(3); + assertThat(ext.getSeatsUsed()).isEqualTo(2); + assertThat(ext.getMaxSeats()).isEqualTo(10); + assertThat(ext.getCreatedByUserId()).isEqualTo(99L); + assertThat(ext.getCreatedAt()).isEqualTo(created); + assertThat(ext.getUpdatedAt()).isEqualTo(updated); + assertThat(ext.getVersion()).isEqualTo(4L); + } + + @Nested + @DisplayName("isPersonal") + class IsPersonal { + + @Test + @DisplayName("true only when the flag is Boolean.TRUE") + void reflectsFlag() { + SaasTeamExtensions ext = new SaasTeamExtensions(); + assertThat(ext.isPersonal()).isFalse(); + ext.setIsPersonal(Boolean.TRUE); + assertThat(ext.isPersonal()).isTrue(); + ext.setIsPersonal(null); + assertThat(ext.isPersonal()).isFalse(); + } + } + + @Nested + @DisplayName("hasAvailableSeats") + class HasAvailableSeats { + + @Test + @DisplayName("standard teams are always unlimited") + void standardUnlimited() { + SaasTeamExtensions ext = new SaasTeamExtensions(); + ext.setSeatsUsed(100); + ext.setMaxSeats(1); + assertThat(ext.hasAvailableSeats()).isTrue(); + } + + @Test + @DisplayName("personal team has seats only while used < max") + void personalBounded() { + SaasTeamExtensions ext = new SaasTeamExtensions(); + ext.setIsPersonal(Boolean.TRUE); + ext.setMaxSeats(1); + + ext.setSeatsUsed(0); + assertThat(ext.hasAvailableSeats()).isTrue(); + + ext.setSeatsUsed(1); + assertThat(ext.hasAvailableSeats()).isFalse(); + } + + @Test + @DisplayName("personal team with null seat counters has no available seats") + void personalNullCountersFalse() { + SaasTeamExtensions ext = new SaasTeamExtensions(); + ext.setIsPersonal(Boolean.TRUE); + ext.setSeatsUsed(null); + ext.setMaxSeats(null); + assertThat(ext.hasAvailableSeats()).isFalse(); + } + } + + @Nested + @DisplayName("canInviteMembers") + class CanInviteMembers { + + @Test + @DisplayName("standard teams can invite, personal teams cannot") + void byPersonalFlag() { + SaasTeamExtensions standard = new SaasTeamExtensions(); + assertThat(standard.canInviteMembers()).isTrue(); + + SaasTeamExtensions personal = new SaasTeamExtensions(); + personal.setIsPersonal(Boolean.TRUE); + assertThat(personal.canInviteMembers()).isFalse(); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/model/SaasUserExtensionsTest.java b/app/saas/src/test/java/stirling/software/saas/model/SaasUserExtensionsTest.java new file mode 100644 index 0000000000..56872b9333 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/model/SaasUserExtensionsTest.java @@ -0,0 +1,67 @@ +package stirling.software.saas.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.security.model.User; + +/** Constructor, defaults, accessor, and isMeteredBillingEnabled tests for SaasUserExtensions. */ +class SaasUserExtensionsTest { + + @Test + @DisplayName("no-arg constructor defaults metered billing off") + void defaults() { + SaasUserExtensions ext = new SaasUserExtensions(); + assertThat(ext.getHasMeteredBillingEnabled()).isFalse(); + assertThat(ext.isMeteredBillingEnabled()).isFalse(); + assertThat(ext.getApiKeyFirstUsedAt()).isNull(); + } + + @Test + @DisplayName("User constructor derives the user id from the user reference") + void userConstructor() { + User user = new User(); + user.setId(42L); + SaasUserExtensions ext = new SaasUserExtensions(user); + assertThat(ext.getUser()).isSameAs(user); + assertThat(ext.getUserId()).isEqualTo(42L); + } + + @Test + @DisplayName("every setter round-trips through its getter") + void settersRoundTrip() { + LocalDateTime firstUsed = LocalDateTime.of(2026, 6, 1, 12, 0); + LocalDateTime created = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime updated = LocalDateTime.of(2026, 6, 2, 0, 0); + + SaasUserExtensions ext = new SaasUserExtensions(); + ext.setUserId(5L); + ext.setHasMeteredBillingEnabled(Boolean.TRUE); + ext.setApiKeyFirstUsedAt(firstUsed); + ext.setCreatedAt(created); + ext.setUpdatedAt(updated); + + assertThat(ext.getUserId()).isEqualTo(5L); + assertThat(ext.getHasMeteredBillingEnabled()).isTrue(); + assertThat(ext.getApiKeyFirstUsedAt()).isEqualTo(firstUsed); + assertThat(ext.getCreatedAt()).isEqualTo(created); + assertThat(ext.getUpdatedAt()).isEqualTo(updated); + } + + @Test + @DisplayName("isMeteredBillingEnabled is true only for Boolean.TRUE, null-safe otherwise") + void isMeteredBillingEnabled() { + SaasUserExtensions ext = new SaasUserExtensions(); + assertThat(ext.isMeteredBillingEnabled()).isFalse(); + + ext.setHasMeteredBillingEnabled(Boolean.TRUE); + assertThat(ext.isMeteredBillingEnabled()).isTrue(); + + ext.setHasMeteredBillingEnabled(null); + assertThat(ext.isMeteredBillingEnabled()).isFalse(); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/model/SupabaseUserTest.java b/app/saas/src/test/java/stirling/software/saas/model/SupabaseUserTest.java new file mode 100644 index 0000000000..91d2b33b50 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/model/SupabaseUserTest.java @@ -0,0 +1,67 @@ +package stirling.software.saas.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Lombok @Data coverage for the SupabaseUser auth.users mirror: accessors, equals/hashCode. */ +class SupabaseUserTest { + + private static SupabaseUser user(UUID id) { + SupabaseUser u = new SupabaseUser(); + u.setId(id); + u.setEmail("user@example.com"); + u.setSSOUser(true); + u.setAnonymous(false); + u.setCreatedAt(LocalDateTime.of(2026, 6, 1, 0, 0)); + return u; + } + + @Test + @DisplayName("every accessor round-trips its value") + void accessors() { + UUID id = UUID.randomUUID(); + SupabaseUser u = user(id); + assertThat(u.getId()).isEqualTo(id); + assertThat(u.getEmail()).isEqualTo("user@example.com"); + assertThat(u.isSSOUser()).isTrue(); + assertThat(u.isAnonymous()).isFalse(); + assertThat(u.getCreatedAt()).isEqualTo(LocalDateTime.of(2026, 6, 1, 0, 0)); + } + + @Test + @DisplayName("boolean flags default to false on a fresh instance") + void booleanDefaults() { + SupabaseUser u = new SupabaseUser(); + assertThat(u.isSSOUser()).isFalse(); + assertThat(u.isAnonymous()).isFalse(); + } + + @Test + @DisplayName("equal field values produce equal users with matching hash codes") + void equalsAndHashCode() { + UUID id = UUID.randomUUID(); + SupabaseUser a = user(id); + SupabaseUser b = user(id); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a).isEqualTo(a); + } + + @Test + @DisplayName("a different id breaks equality; null and foreign types are unequal") + void notEqual() { + SupabaseUser a = user(UUID.randomUUID()); + SupabaseUser b = user(UUID.randomUUID()); + assertThat(a).isNotEqualTo(b).isNotEqualTo(null).isNotEqualTo("string"); + } + + @Test + @DisplayName("toString is non-null and mentions a field") + void toStringMentionsField() { + assertThat(user(UUID.randomUUID()).toString()).contains("email"); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/model/TeamInvitationTest.java b/app/saas/src/test/java/stirling/software/saas/model/TeamInvitationTest.java new file mode 100644 index 0000000000..cef01564f3 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/model/TeamInvitationTest.java @@ -0,0 +1,158 @@ +package stirling.software.saas.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.enumeration.InvitationStatus; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; + +/** Constructor, accessor, equals/hashCode/toString, and status-helper tests for TeamInvitation. */ +class TeamInvitationTest { + + private static TeamInvitation invitation() { + TeamInvitation inv = new TeamInvitation(); + inv.setInvitationId(1L); + inv.setInviteeEmail("invitee@example.com"); + inv.setInvitationToken("tok-123"); + inv.setStatus(InvitationStatus.PENDING); + inv.setExpiresAt(LocalDateTime.now().plusDays(7)); + return inv; + } + + @Nested + @DisplayName("accessors") + class Accessors { + + @Test + @DisplayName("default status is PENDING before any setter") + void defaultStatus() { + assertThat(new TeamInvitation().getStatus()).isEqualTo(InvitationStatus.PENDING); + } + + @Test + @DisplayName("every setter round-trips through its getter") + void settersRoundTrip() { + Team team = new Team(); + team.setId(7L); + User inviter = new User(); + User invitee = new User(); + LocalDateTime expires = LocalDateTime.of(2026, 7, 1, 0, 0); + LocalDateTime created = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime updated = LocalDateTime.of(2026, 6, 2, 0, 0); + + TeamInvitation inv = new TeamInvitation(); + inv.setInvitationId(42L); + inv.setTeam(team); + inv.setInviter(inviter); + inv.setInviteeEmail("a@b.com"); + inv.setInviteeUser(invitee); + inv.setStatus(InvitationStatus.ACCEPTED); + inv.setInvitationToken("token-xyz"); + inv.setExpiresAt(expires); + inv.setCreatedAt(created); + inv.setUpdatedAt(updated); + + assertThat(inv.getInvitationId()).isEqualTo(42L); + assertThat(inv.getTeam()).isSameAs(team); + assertThat(inv.getInviter()).isSameAs(inviter); + assertThat(inv.getInviteeEmail()).isEqualTo("a@b.com"); + assertThat(inv.getInviteeUser()).isSameAs(invitee); + assertThat(inv.getStatus()).isEqualTo(InvitationStatus.ACCEPTED); + assertThat(inv.getInvitationToken()).isEqualTo("token-xyz"); + assertThat(inv.getExpiresAt()).isEqualTo(expires); + assertThat(inv.getCreatedAt()).isEqualTo(created); + assertThat(inv.getUpdatedAt()).isEqualTo(updated); + } + } + + @Nested + @DisplayName("status helpers") + class StatusHelpers { + + @Test + @DisplayName( + "isExpired is false for a null expiry and a future expiry, true for a past one") + void isExpired() { + TeamInvitation nullExpiry = new TeamInvitation(); + assertThat(nullExpiry.isExpired()).isFalse(); + + TeamInvitation future = invitation(); + assertThat(future.isExpired()).isFalse(); + + TeamInvitation past = invitation(); + past.setExpiresAt(LocalDateTime.now().minusDays(1)); + assertThat(past.isExpired()).isTrue(); + } + + @Test + @DisplayName("isPending requires PENDING status and a non-expired window") + void isPending() { + assertThat(invitation().isPending()).isTrue(); + + TeamInvitation expired = invitation(); + expired.setExpiresAt(LocalDateTime.now().minusDays(1)); + assertThat(expired.isPending()).isFalse(); + + TeamInvitation accepted = invitation(); + accepted.setStatus(InvitationStatus.ACCEPTED); + assertThat(accepted.isPending()).isFalse(); + } + + @Test + @DisplayName("isAccepted and isRejected reflect the status enum") + void isAcceptedAndRejected() { + TeamInvitation accepted = invitation(); + accepted.setStatus(InvitationStatus.ACCEPTED); + assertThat(accepted.isAccepted()).isTrue(); + assertThat(accepted.isRejected()).isFalse(); + + TeamInvitation rejected = invitation(); + rejected.setStatus(InvitationStatus.REJECTED); + assertThat(rejected.isRejected()).isTrue(); + assertThat(rejected.isAccepted()).isFalse(); + } + } + + @Nested + @DisplayName("equals / hashCode / toString") + class Equality { + + @Test + @DisplayName("equal id and token produce equal invitations") + void equalObjects() { + TeamInvitation a = invitation(); + TeamInvitation b = invitation(); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a).isEqualTo(a); + } + + @Test + @DisplayName("a different token breaks equality") + void differentTokenNotEqual() { + TeamInvitation a = invitation(); + TeamInvitation b = invitation(); + b.setInvitationToken("other-token"); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or a foreign type") + void notEqualToNullOrOtherType() { + TeamInvitation a = invitation(); + assertThat(a).isNotEqualTo(null).isNotEqualTo("a string"); + } + + @Test + @DisplayName("toString includes the explicitly-included fields") + void toStringContainsFields() { + String s = invitation().toString(); + assertThat(s).contains("invitee@example.com").contains("tok-123").contains("PENDING"); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/model/TeamMembershipTest.java b/app/saas/src/test/java/stirling/software/saas/model/TeamMembershipTest.java new file mode 100644 index 0000000000..a94e8b9373 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/model/TeamMembershipTest.java @@ -0,0 +1,129 @@ +package stirling.software.saas.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; + +/** Constructor, accessor, equals/hashCode/toString, and role-helper tests for TeamMembership. */ +class TeamMembershipTest { + + private static TeamMembership membership() { + TeamMembership m = new TeamMembership(); + m.setMembershipId(1L); + m.setRole(TeamRole.MEMBER); + m.setInvitedAt(LocalDateTime.now()); + return m; + } + + @Test + @DisplayName("default role is MEMBER before any setter") + void defaultRole() { + assertThat(new TeamMembership().getRole()).isEqualTo(TeamRole.MEMBER); + } + + @Test + @DisplayName("every setter round-trips through its getter") + void settersRoundTrip() { + Team team = new Team(); + team.setId(7L); + User user = new User(); + User invitedBy = new User(); + LocalDateTime invited = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime accepted = LocalDateTime.of(2026, 6, 2, 0, 0); + LocalDateTime created = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime updated = LocalDateTime.of(2026, 6, 3, 0, 0); + + TeamMembership m = new TeamMembership(); + m.setMembershipId(99L); + m.setTeam(team); + m.setUser(user); + m.setRole(TeamRole.LEADER); + m.setInvitedBy(invitedBy); + m.setInvitedAt(invited); + m.setAcceptedAt(accepted); + m.setCreatedAt(created); + m.setUpdatedAt(updated); + m.setCapUnits(500L); + + assertThat(m.getMembershipId()).isEqualTo(99L); + assertThat(m.getTeam()).isSameAs(team); + assertThat(m.getUser()).isSameAs(user); + assertThat(m.getRole()).isEqualTo(TeamRole.LEADER); + assertThat(m.getInvitedBy()).isSameAs(invitedBy); + assertThat(m.getInvitedAt()).isEqualTo(invited); + assertThat(m.getAcceptedAt()).isEqualTo(accepted); + assertThat(m.getCreatedAt()).isEqualTo(created); + assertThat(m.getUpdatedAt()).isEqualTo(updated); + assertThat(m.getCapUnits()).isEqualTo(500L); + } + + @Test + @DisplayName("capUnits defaults to null (bounded only by the team-wide cap)") + void capUnitsDefaultsNull() { + assertThat(new TeamMembership().getCapUnits()).isNull(); + } + + @Nested + @DisplayName("role helpers") + class RoleHelpers { + + @Test + @DisplayName("isLeader / isMember reflect the role enum") + void leaderAndMember() { + TeamMembership leader = membership(); + leader.setRole(TeamRole.LEADER); + assertThat(leader.isLeader()).isTrue(); + assertThat(leader.isMember()).isFalse(); + + TeamMembership member = membership(); + member.setRole(TeamRole.MEMBER); + assertThat(member.isMember()).isTrue(); + assertThat(member.isLeader()).isFalse(); + } + } + + @Nested + @DisplayName("equals / hashCode / toString") + class Equality { + + @Test + @DisplayName("equal membership ids produce equal memberships") + void equalObjects() { + TeamMembership a = membership(); + TeamMembership b = membership(); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a).isEqualTo(a); + } + + @Test + @DisplayName("a different membership id breaks equality") + void differentIdNotEqual() { + TeamMembership a = membership(); + TeamMembership b = membership(); + b.setMembershipId(2L); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("not equal to null or a foreign type") + void notEqualToNullOrOtherType() { + TeamMembership a = membership(); + assertThat(a).isNotEqualTo(null).isNotEqualTo("a string"); + } + + @Test + @DisplayName("toString includes the explicitly-included fields") + void toStringContainsFields() { + String s = membership().toString(); + assertThat(s).contains("1").contains("MEMBER"); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/CapMoneyUnitsTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/CapMoneyUnitsTest.java new file mode 100644 index 0000000000..a4b1fd918f --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/CapMoneyUnitsTest.java @@ -0,0 +1,71 @@ +package stirling.software.saas.payg.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Conversion + validation tests for the {@link CapMoneyUnits} helper. */ +class CapMoneyUnitsTest { + + @Test + @DisplayName("constants expose the V1 rate") + void constants() { + assertThat(CapMoneyUnits.UNITS_PER_USD).isEqualTo(100); + assertThat(CapMoneyUnits.CENTS_PER_USD).isEqualTo(100); + } + + @Test + @DisplayName("usdToUnits multiplies by the unit rate") + void usdToUnits() { + assertThat(CapMoneyUnits.usdToUnits(0)).isZero(); + assertThat(CapMoneyUnits.usdToUnits(25)).isEqualTo(2500L); + } + + @Test + @DisplayName("usdToUnits rejects a negative dollar cap") + void usdToUnits_rejectsNegative() { + assertThatThrownBy(() -> CapMoneyUnits.usdToUnits(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("capUsd"); + } + + @Test + @DisplayName("unitsToUsd floors on the read path") + void unitsToUsd_floors() { + assertThat(CapMoneyUnits.unitsToUsd(2500L)).isEqualTo(25); + // 2450 units → $24 (floor), the only place rounding shows up. + assertThat(CapMoneyUnits.unitsToUsd(2450L)).isEqualTo(24); + assertThat(CapMoneyUnits.unitsToUsd(99L)).isZero(); + } + + @Test + @DisplayName("unitsToUsd clamps a negative balance to zero rather than throwing") + void unitsToUsd_negativeClampsToZero() { + assertThat(CapMoneyUnits.unitsToUsd(-50L)).isZero(); + } + + @Test + @DisplayName("usdToCents multiplies by the cents rate") + void usdToCents() { + assertThat(CapMoneyUnits.usdToCents(0)).isZero(); + assertThat(CapMoneyUnits.usdToCents(25)).isEqualTo(2500L); + } + + @Test + @DisplayName("usdToCents rejects a negative dollar cap") + void usdToCents_rejectsNegative() { + assertThatThrownBy(() -> CapMoneyUnits.usdToCents(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("capUsd"); + } + + @Test + @DisplayName("usd→units→usd round-trips for whole-dollar inputs") + void roundTrips() { + for (int usd : new int[] {0, 1, 10, 25, 999}) { + assertThat(CapMoneyUnits.unitsToUsd(CapMoneyUnits.usdToUnits(usd))).isEqualTo(usd); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java new file mode 100644 index 0000000000..65d55ccb86 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/WalletSnapshotResponseTest.java @@ -0,0 +1,130 @@ +package stirling.software.saas.payg.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.saas.payg.api.WalletSnapshotResponse.ActivityRow; +import stirling.software.saas.payg.api.WalletSnapshotResponse.CategoryBreakdown; +import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow; + +/** Accessor + value-semantics tests for {@link WalletSnapshotResponse} and its nested records. */ +class WalletSnapshotResponseTest { + + private static WalletSnapshotResponse sample() { + return new WalletSnapshotResponse( + 7L, + "subscribed", + "leader", + "2026-06-01", + "2026-07-01", + /* billableUsed= */ 12, + /* billableLimit= */ 100, + /* freeAllowance= */ 500, + /* freeRemaining= */ 488, + new BigDecimal("1.5"), + "usd", + /* estimatedBillMinor= */ 1800L, + /* capUsd= */ 25, + /* noCap= */ false, + "sub_123", + /* spendUnitsThisPeriod= */ 12, + new CategoryBreakdown(5, 4, 3), + List.of(new MemberRow("u1", "Ann", "ann@example.com", 8)), + List.of(new ActivityRow(1L, "api", "API usage", "2026-06-02T10:00", 4))); + } + + @Test + @DisplayName("top-level accessors round-trip every component") + void topLevelAccessors() { + WalletSnapshotResponse r = sample(); + assertThat(r.teamId()).isEqualTo(7L); + assertThat(r.status()).isEqualTo("subscribed"); + assertThat(r.role()).isEqualTo("leader"); + assertThat(r.billingPeriodStart()).isEqualTo("2026-06-01"); + assertThat(r.billingPeriodEnd()).isEqualTo("2026-07-01"); + assertThat(r.billableUsed()).isEqualTo(12); + assertThat(r.billableLimit()).isEqualTo(100); + assertThat(r.freeAllowance()).isEqualTo(500); + assertThat(r.freeRemaining()).isEqualTo(488); + assertThat(r.pricePerDocMinor()).isEqualByComparingTo("1.5"); + assertThat(r.currency()).isEqualTo("usd"); + assertThat(r.estimatedBillMinor()).isEqualTo(1800L); + assertThat(r.capUsd()).isEqualTo(25); + assertThat(r.noCap()).isFalse(); + assertThat(r.stripeSubscriptionId()).isEqualTo("sub_123"); + assertThat(r.spendUnitsThisPeriod()).isEqualTo(12); + } + + @Test + @DisplayName("nested records expose their fields") + void nestedAccessors() { + WalletSnapshotResponse r = sample(); + + CategoryBreakdown cb = r.categoryBreakdown(); + assertThat(cb.api()).isEqualTo(5); + assertThat(cb.ai()).isEqualTo(4); + assertThat(cb.automation()).isEqualTo(3); + + MemberRow member = r.members().get(0); + assertThat(member.userId()).isEqualTo("u1"); + assertThat(member.name()).isEqualTo("Ann"); + assertThat(member.email()).isEqualTo("ann@example.com"); + assertThat(member.spendUnits()).isEqualTo(8); + + ActivityRow activity = r.recent().get(0); + assertThat(activity.id()).isEqualTo(1L); + assertThat(activity.kind()).isEqualTo("api"); + assertThat(activity.label()).isEqualTo("API usage"); + assertThat(activity.ts()).isEqualTo("2026-06-02T10:00"); + assertThat(activity.docUnits()).isEqualTo(4); + } + + @Test + @DisplayName("nullable fields are permitted for the free / unresolved case") + void nullableFields() { + WalletSnapshotResponse free = + new WalletSnapshotResponse( + 7L, + "free", + "member", + "2026-06-01", + "2026-07-01", + 0, + null, + 500, + 500, + null, + null, + null, + null, + false, + null, + 0, + new CategoryBreakdown(0, 0, 0), + List.of(), + List.of()); + + assertThat(free.billableLimit()).isNull(); + assertThat(free.pricePerDocMinor()).isNull(); + assertThat(free.currency()).isNull(); + assertThat(free.estimatedBillMinor()).isNull(); + assertThat(free.capUsd()).isNull(); + assertThat(free.stripeSubscriptionId()).isNull(); + assertThat(free.members()).isEmpty(); + } + + @Test + @DisplayName("equal values produce equal records") + void valueSemantics() { + assertThat(sample()).isEqualTo(sample()).hasSameHashCodeAs(sample()); + assertThat(new CategoryBreakdown(1, 2, 3)).isEqualTo(new CategoryBreakdown(1, 2, 3)); + assertThat(new MemberRow("u", "n", "e", 1)).isEqualTo(new MemberRow("u", "n", "e", 1)); + assertThat(new ActivityRow(1L, "k", "l", "t", 2)) + .isEqualTo(new ActivityRow(1L, "k", "l", "t", 2)); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/billing/TeamBillingServiceMoreTest.java b/app/saas/src/test/java/stirling/software/saas/payg/billing/TeamBillingServiceMoreTest.java new file mode 100644 index 0000000000..fef35f453d --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/billing/TeamBillingServiceMoreTest.java @@ -0,0 +1,363 @@ +package stirling.software.saas.payg.billing; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.Optional; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.saas.payg.policy.PaygTeamExtensions; +import stirling.software.saas.payg.policy.PricingPolicy; +import stirling.software.saas.payg.policy.PricingPolicyService; +import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; +import stirling.software.saas.payg.repository.WalletPolicyRepository; +import stirling.software.saas.payg.stripe.StripeSubscriptionDao; +import stirling.software.saas.payg.stripe.StripeSubscriptionDao.PriceRate; +import stirling.software.saas.payg.stripe.StripeSubscriptionDao.SubscriptionBilling; +import stirling.software.saas.payg.wallet.WalletPolicy; + +/** + * Branch-coverage top-up for {@link TeamBillingService}. The existing {@code + * TeamBillingServiceTest} locks the {@code subscribed} determination; this file exercises the + * money-cap derivation, the un-subscribed rate lookup, the bill/cap estimate helpers, the caching + * path, and the calendar-month window seam. + */ +@ExtendWith(MockitoExtension.class) +class TeamBillingServiceMoreTest { + + private static final long TEAM_ID = 100L; + + @Mock private PaygTeamExtensionsRepository extensionsRepository; + @Mock private WalletPolicyRepository walletPolicyRepository; + @Mock private PricingPolicyService pricingPolicyService; + @Mock private StripeSubscriptionDao subscriptionDao; + + private TeamBillingService service; + + @BeforeEach + void setUp() { + service = + new TeamBillingService( + extensionsRepository, + walletPolicyRepository, + pricingPolicyService, + subscriptionDao); + } + + private PaygTeamExtensions ext(String subscriptionId, long freeRemaining) { + PaygTeamExtensions e = new PaygTeamExtensions(); + e.setTeamId(TEAM_ID); + e.setPaygSubscriptionId(subscriptionId); + e.setFreeUnitsRemaining(freeRemaining); + return e; + } + + private void stubGrant(long grant) { + PricingPolicy policy = org.mockito.Mockito.mock(PricingPolicy.class); + lenient().when(policy.getFreeTierUnits()).thenReturn(grant); + lenient().when(pricingPolicyService.getEffectivePolicy(TEAM_ID)).thenReturn(policy); + } + + @Nested + @DisplayName("compute: subscribed window + cap") + class SubscribedCompute { + + @Test + @DisplayName("uses the Stripe subscription window and derives floor(cap / rate)") + void subscribedWithMoneyCapAndRate() { + stubGrant(500L); + when(extensionsRepository.findById(TEAM_ID)) + .thenReturn(Optional.of(ext("sub_1", 100L))); + + LocalDateTime start = LocalDateTime.of(2026, 6, 10, 0, 0); + LocalDateTime end = LocalDateTime.of(2026, 7, 10, 0, 0); + when(subscriptionDao.findBilling("sub_1")) + .thenReturn( + Optional.of( + new SubscriptionBilling( + start, + end, + "price_1", + "active", + "usd", + new BigDecimal("2")))); + WalletPolicy wp = new WalletPolicy(); + wp.setCapSourceMoney(1000L); // 1000 minor / rate 2 = 500 docs + when(walletPolicyRepository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(wp)); + + TeamBillingContext ctx = service.forTeam(TEAM_ID); + + assertThat(ctx.subscribed()).isTrue(); + assertThat(ctx.periodStart()).isEqualTo(start); + assertThat(ctx.periodEnd()).isEqualTo(end); + assertThat(ctx.perDocMinor()).isEqualByComparingTo("2"); + assertThat(ctx.currency()).isEqualTo("usd"); + assertThat(ctx.capMoneyMinor()).isEqualTo(1000L); + assertThat(ctx.monthlyCapDocUnits()).isEqualTo(500L); + // No un-subscribed rate lookup when subscribed. + verify(subscriptionDao, never()).findRateByLookupKey(any(), any()); + } + + @Test + @DisplayName("money cap but unknown rate falls back to stored cap_units") + void subscribedMoneyCapRateUnknown_fallsBackToLegacyUnits() { + stubGrant(500L); + when(extensionsRepository.findById(TEAM_ID)).thenReturn(Optional.of(ext("sub_1", 0L))); + // Billing present but no usable rate (perDocMinor null). + when(subscriptionDao.findBilling("sub_1")) + .thenReturn( + Optional.of( + new SubscriptionBilling( + LocalDateTime.now(), + LocalDateTime.now().plusDays(30), + "price_1", + "active", + "usd", + null))); + WalletPolicy wp = new WalletPolicy(); + wp.setCapSourceMoney(5000L); + wp.setCapUnits(77L); + when(walletPolicyRepository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(wp)); + + TeamBillingContext ctx = service.forTeam(TEAM_ID); + + assertThat(ctx.monthlyCapDocUnits()).isEqualTo(77L); + } + + @Test + @DisplayName("no money cap but an admin-set cap_units still applies") + void subscribedNoMoneyCap_usesLegacyUnits() { + stubGrant(0L); + when(extensionsRepository.findById(TEAM_ID)).thenReturn(Optional.of(ext("sub_1", 0L))); + when(subscriptionDao.findBilling("sub_1")) + .thenReturn( + Optional.of( + new SubscriptionBilling( + LocalDateTime.now(), + LocalDateTime.now().plusDays(30), + "price_1", + "active", + "usd", + new BigDecimal("3")))); + WalletPolicy wp = new WalletPolicy(); + wp.setCapSourceMoney(null); + wp.setCapUnits(123L); + when(walletPolicyRepository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(wp)); + + TeamBillingContext ctx = service.forTeam(TEAM_ID); + + assertThat(ctx.monthlyCapDocUnits()).isEqualTo(123L); + } + } + + @Nested + @DisplayName("compute: un-subscribed") + class UnsubscribedCompute { + + @Test + @DisplayName("resolves the display rate by lookup key and uses a calendar-month window") + void unsubscribed_resolvesRateByLookupKey() { + stubGrant(500L); + when(extensionsRepository.findById(TEAM_ID)).thenReturn(Optional.of(ext(null, 500L))); + when(walletPolicyRepository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty()); + when(subscriptionDao.findRateByLookupKey("plan:processor", "usd")) + .thenReturn( + Optional.of(new PriceRate("price_disp", "usd", new BigDecimal("4")))); + + TeamBillingContext ctx = service.forTeam(TEAM_ID); + + assertThat(ctx.subscribed()).isFalse(); + assertThat(ctx.perDocMinor()).isEqualByComparingTo("4"); + assertThat(ctx.currency()).isEqualTo("usd"); + // Display-only: still no enforced monthly cap for a free team. + assertThat(ctx.monthlyCapDocUnits()).isNull(); + // Window is the calendar month. + LocalDateTime[] expected = + new LocalDateTime[] { + YearMonth.now().atDay(1).atStartOfDay(), + YearMonth.now().plusMonths(1).atDay(1).atStartOfDay() + }; + assertThat(ctx.periodStart()).isEqualTo(expected[0]); + assertThat(ctx.periodEnd()).isEqualTo(expected[1]); + // Never reads a subscription window for an un-subscribed team. + verify(subscriptionDao, never()).findBilling(any()); + } + + @Test + @DisplayName("leaves rate null when no display price can be resolved") + void unsubscribed_noRate_leavesNull() { + stubGrant(500L); + when(extensionsRepository.findById(TEAM_ID)).thenReturn(Optional.of(ext(null, 500L))); + when(walletPolicyRepository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty()); + when(subscriptionDao.findRateByLookupKey("plan:processor", "usd")) + .thenReturn(Optional.empty()); + + TeamBillingContext ctx = service.forTeam(TEAM_ID); + + assertThat(ctx.perDocMinor()).isNull(); + assertThat(ctx.currency()).isNull(); + } + + @Test + @DisplayName("a failed effective-policy lookup degrades the grant to zero") + void grantLookupFailure_degradesToZero() { + when(extensionsRepository.findById(TEAM_ID)).thenReturn(Optional.of(ext(null, 0L))); + when(walletPolicyRepository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty()); + when(pricingPolicyService.getEffectivePolicy(TEAM_ID)) + .thenThrow(new IllegalStateException("no policy")); + when(subscriptionDao.findRateByLookupKey(any(), any())).thenReturn(Optional.empty()); + + TeamBillingContext ctx = service.forTeam(TEAM_ID); + + assertThat(ctx.freeGrantUnits()).isZero(); + } + + @Test + @DisplayName("missing extension row yields zero free remaining") + void noExtensionRow_zeroFreeRemaining() { + stubGrant(500L); + when(extensionsRepository.findById(TEAM_ID)).thenReturn(Optional.empty()); + when(walletPolicyRepository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty()); + when(subscriptionDao.findRateByLookupKey(any(), any())).thenReturn(Optional.empty()); + + TeamBillingContext ctx = service.forTeam(TEAM_ID); + + assertThat(ctx.freeGrantUnits()).isEqualTo(500L); + assertThat(ctx.freeRemainingUnits()).isZero(); + assertThat(ctx.subscriptionId()).isNull(); + } + } + + @Nested + @DisplayName("caching") + class Caching { + + @Test + @DisplayName("second forTeam call is served from cache (compute runs once)") + void cachesPerTeam() { + stubGrant(500L); + when(extensionsRepository.findById(TEAM_ID)).thenReturn(Optional.of(ext(null, 10L))); + when(walletPolicyRepository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty()); + when(subscriptionDao.findRateByLookupKey(any(), any())).thenReturn(Optional.empty()); + + service.forTeam(TEAM_ID); + service.forTeam(TEAM_ID); + + verify(extensionsRepository, times(1)).findById(TEAM_ID); + } + + @Test + @DisplayName("invalidate forces a recompute on the next call") + void invalidateForcesRecompute() { + stubGrant(500L); + when(extensionsRepository.findById(TEAM_ID)).thenReturn(Optional.of(ext(null, 10L))); + when(walletPolicyRepository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty()); + when(subscriptionDao.findRateByLookupKey(any(), any())).thenReturn(Optional.empty()); + + service.forTeam(TEAM_ID); + service.invalidate(TEAM_ID); + service.forTeam(TEAM_ID); + + verify(extensionsRepository, times(2)).findById(TEAM_ID); + } + + @Test + @DisplayName("invalidate(null) is a no-op") + void invalidateNull_noOp() { + service.invalidate(null); // must not throw + } + } + + @Nested + @DisplayName("estimateBillMinor / docCapForMoney") + class Estimates { + + private TeamBillingContext ctxWithRate(BigDecimal rate) { + return new TeamBillingContext( + true, + "sub", + LocalDateTime.now(), + LocalDateTime.now(), + 0L, + 0L, + rate, + "usd", + null, + null); + } + + @Test + @DisplayName("estimateBillMinor multiplies paid units by the rate, rounding half-up") + void estimateBill_rounds() { + TeamBillingContext ctx = ctxWithRate(new BigDecimal("1.5")); + // 3 paid × 1.5 = 4.5 → HALF_UP → 5 + assertThat(service.estimateBillMinor(ctx, 3)).contains(5L); + } + + @Test + @DisplayName("estimateBillMinor clamps negative paid units to zero") + void estimateBill_clampsNegative() { + TeamBillingContext ctx = ctxWithRate(new BigDecimal("2")); + assertThat(service.estimateBillMinor(ctx, -10)).contains(0L); + } + + @Test + @DisplayName("estimateBillMinor is empty when the rate is unknown") + void estimateBill_emptyWhenRateNull() { + assertThat(service.estimateBillMinor(ctxWithRate(null), 5)).isEmpty(); + } + + @Test + @DisplayName("docCapForMoney is floor(cap / rate)") + void docCap_floors() { + TeamBillingContext ctx = ctxWithRate(new BigDecimal("3")); + // 1000 / 3 = 333.33 → floor 333 + assertThat(service.docCapForMoney(ctx, 1000L)).contains(333L); + } + + @Test + @DisplayName("docCapForMoney is empty when the rate is null or non-positive") + void docCap_emptyWhenRateUnusable() { + assertThat(service.docCapForMoney(ctxWithRate(null), 1000L)).isEmpty(); + assertThat(service.docCapForMoney(ctxWithRate(BigDecimal.ZERO), 1000L)).isEmpty(); + assertThat(service.docCapForMoney(ctxWithRate(new BigDecimal("-1")), 1000L)).isEmpty(); + } + } + + @Nested + @DisplayName("calendarMonthWindow") + class CalendarWindow { + + @Test + @DisplayName("returns inclusive-start / exclusive-end month bounds for a given clock") + void boundsForFixedClock() { + LocalDateTime now = LocalDateTime.of(2026, 2, 14, 9, 30); + LocalDateTime[] window = TeamBillingService.calendarMonthWindow(now); + assertThat(window[0]).isEqualTo(LocalDateTime.of(2026, 2, 1, 0, 0)); + assertThat(window[1]).isEqualTo(LocalDateTime.of(2026, 3, 1, 0, 0)); + } + + @Test + @DisplayName("no-arg overload anchors on the current month") + void noArgOverload() { + LocalDateTime[] window = TeamBillingService.calendarMonthWindow(); + assertThat(window[0]).isEqualTo(YearMonth.now().atDay(1).atStartOfDay()); + assertThat(window[1]).isEqualTo(YearMonth.now().plusMonths(1).atDay(1).atStartOfDay()); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/charge/ChargeRecordsTest.java b/app/saas/src/test/java/stirling/software/saas/payg/charge/ChargeRecordsTest.java new file mode 100644 index 0000000000..d4608e393e --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/charge/ChargeRecordsTest.java @@ -0,0 +1,164 @@ +package stirling.software.saas.payg.charge; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.file.Path; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.saas.payg.charge.ChargeOutcome.Disposition; +import stirling.software.saas.payg.model.BillingCategory; +import stirling.software.saas.payg.model.JobSource; +import stirling.software.saas.payg.model.ProcessType; + +/** Constructor-validation, accessor, and value-semantics tests for the charge-package records. */ +class ChargeRecordsTest { + + @Nested + @DisplayName("ChargeContext") + class ChargeContextTests { + + @Test + @DisplayName("accepts a fully-populated context and exposes its fields") + void validContext() { + ChargeContext ctx = + new ChargeContext( + 1L, 2L, JobSource.API, ProcessType.SINGLE_TOOL, BillingCategory.API); + + assertThat(ctx.ownerUserId()).isEqualTo(1L); + assertThat(ctx.ownerTeamId()).isEqualTo(2L); + assertThat(ctx.source()).isEqualTo(JobSource.API); + assertThat(ctx.processType()).isEqualTo(ProcessType.SINGLE_TOOL); + assertThat(ctx.billingCategory()).isEqualTo(BillingCategory.API); + } + + @Test + @DisplayName("allows a null team id (anonymous-team callers)") + void nullTeamIdAllowed() { + ChargeContext ctx = + new ChargeContext( + 1L, null, JobSource.WEB, ProcessType.CHAIN, BillingCategory.AI); + assertThat(ctx.ownerTeamId()).isNull(); + } + + @Test + @DisplayName("rejects missing required fields") + void rejectsMissingFields() { + assertThatThrownBy( + () -> + new ChargeContext( + null, + 2L, + JobSource.API, + ProcessType.SINGLE_TOOL, + BillingCategory.API)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ownerUserId"); + assertThatThrownBy( + () -> + new ChargeContext( + 1L, + 2L, + null, + ProcessType.SINGLE_TOOL, + BillingCategory.API)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("source"); + assertThatThrownBy( + () -> + new ChargeContext( + 1L, 2L, JobSource.API, null, BillingCategory.API)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("processType"); + assertThatThrownBy( + () -> + new ChargeContext( + 1L, 2L, JobSource.API, ProcessType.SINGLE_TOOL, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("billingCategory"); + } + + @Test + @DisplayName("equal values produce equal records") + void valueSemantics() { + ChargeContext a = + new ChargeContext( + 1L, 2L, JobSource.API, ProcessType.SINGLE_TOOL, BillingCategory.API); + ChargeContext b = + new ChargeContext( + 1L, 2L, JobSource.API, ProcessType.SINGLE_TOOL, BillingCategory.API); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + assertThat(a.toString()).contains("ChargeContext"); + } + } + + @Nested + @DisplayName("ChargeOutcome") + class ChargeOutcomeTests { + + @Test + @DisplayName("OPENED carries the would-be charge units") + void opened() { + UUID id = UUID.randomUUID(); + ChargeOutcome outcome = new ChargeOutcome(id, 5, Disposition.OPENED); + assertThat(outcome.processId()).isEqualTo(id); + assertThat(outcome.units()).isEqualTo(5); + assertThat(outcome.disposition()).isEqualTo(Disposition.OPENED); + } + + @Test + @DisplayName("JOINED carries zero incremental units") + void joined() { + ChargeOutcome outcome = new ChargeOutcome(UUID.randomUUID(), 0, Disposition.JOINED); + assertThat(outcome.units()).isZero(); + assertThat(outcome.disposition()).isEqualTo(Disposition.JOINED); + } + + @Test + @DisplayName("Disposition enum exposes exactly OPENED and JOINED") + void dispositionValues() { + assertThat(Disposition.values()) + .containsExactly(Disposition.OPENED, Disposition.JOINED); + assertThat(Disposition.valueOf("OPENED")).isEqualTo(Disposition.OPENED); + } + } + + @Nested + @DisplayName("JobInput") + class JobInputTests { + + private final MultipartFile file = + new MockMultipartFile("file", "in.pdf", "application/pdf", new byte[] {1, 2, 3}); + private final Path path = Path.of("in.pdf"); + + @Test + @DisplayName("exposes the multipart and path it was built with") + void accessors() { + JobInput input = new JobInput(file, path); + assertThat(input.multipart()).isSameAs(file); + assertThat(input.path()).isEqualTo(path); + } + + @Test + @DisplayName("rejects a null multipart") + void rejectsNullMultipart() { + assertThatThrownBy(() -> new JobInput(null, path)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("multipart"); + } + + @Test + @DisplayName("rejects a null path") + void rejectsNullPath() { + assertThatThrownBy(() -> new JobInput(file, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("path"); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/docs/DefaultDocumentClassifierMoreTest.java b/app/saas/src/test/java/stirling/software/saas/payg/docs/DefaultDocumentClassifierMoreTest.java new file mode 100644 index 0000000000..60d78cc380 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/docs/DefaultDocumentClassifierMoreTest.java @@ -0,0 +1,135 @@ +package stirling.software.saas.payg.docs; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; +import stirling.software.saas.payg.policy.PricingPolicy; + +/** + * Branch top-up for {@link DefaultDocumentClassifier}: the already-materialised-path read paths + * (single + multi) and the {@code materialisedPaths} size-mismatch guard, which the existing {@code + * DefaultDocumentClassifierTest} does not cover. + */ +class DefaultDocumentClassifierMoreTest { + + private static final PricingPolicy DEFAULT_POLICY = + new PricingPolicy(25, 10L * 1024 * 1024, 1, 1000); + + private final DefaultDocumentClassifier classifier = + new DefaultDocumentClassifier( + new TempFileManager(new TempFileRegistry(), new ApplicationProperties())); + + @Test + @DisplayName("single file: reads the page count from the already-materialised path") + void singleFile_readsFromMaterialisedPath(@TempDir Path dir) throws IOException { + byte[] bytes = pdfBytes(60); + Path onDisk = dir.resolve("report.pdf"); + Files.write(onDisk, bytes); + MultipartFile pdf = new MockMultipartFile("file", "report.pdf", "application/pdf", bytes); + + DocumentMetrics metrics = classifier.classify(pdf, onDisk, DEFAULT_POLICY); + + // ceil(60 / 25) = 3 page-units; bytes are tiny so the page axis wins. + assertThat(metrics.pages()).isEqualTo(60); + assertThat(metrics.docUnits()).isEqualTo(3); + assertThat(metrics.contentType()).isEqualTo("application/pdf"); + } + + @Test + @DisplayName("single file: malformed materialised PDF falls back to bytes-only") + void singleFile_malformedMaterialisedPath_bytesOnly(@TempDir Path dir) throws IOException { + byte[] junk = "%PDF-broken".getBytes(); + Path onDisk = dir.resolve("broken.pdf"); + Files.write(onDisk, junk); + MultipartFile pdf = new MockMultipartFile("file", "broken.pdf", "application/pdf", junk); + + DocumentMetrics metrics = classifier.classify(pdf, onDisk, DEFAULT_POLICY); + + assertThat(metrics.pages()).isZero(); + assertThat(metrics.docUnits()).isEqualTo(1); // floor + } + + @Test + @DisplayName("multi-file: reads each page count from the supplied materialised paths") + void multiFile_readsFromMaterialisedPaths(@TempDir Path dir) throws IOException { + byte[] a = pdfBytes(50); + byte[] b = pdfBytes(50); + Path pa = dir.resolve("a.pdf"); + Path pb = dir.resolve("b.pdf"); + Files.write(pa, a); + Files.write(pb, b); + MultipartFile fa = new MockMultipartFile("file", "a.pdf", "application/pdf", a); + MultipartFile fb = new MockMultipartFile("file", "b.pdf", "application/pdf", b); + + DocumentMetrics metrics = + classifier.classify(List.of(fa, fb), List.of(pa, pb), DEFAULT_POLICY); + + // Each ceil(50/25)=2 → sum 4; group cap 1000×2 doesn't bind. + assertThat(metrics.pages()).isEqualTo(100); + assertThat(metrics.docUnits()).isEqualTo(4); + } + + @Test + @DisplayName("multi-file: null materialisedPaths is allowed and reads from the multiparts") + void multiFile_nullMaterialisedPaths_readsFromMultipart() throws IOException { + MultipartFile fa = new MockMultipartFile("file", "a.pdf", "application/pdf", pdfBytes(25)); + MultipartFile fb = new MockMultipartFile("file", "b.pdf", "application/pdf", pdfBytes(25)); + + DocumentMetrics metrics = classifier.classify(List.of(fa, fb), null, DEFAULT_POLICY); + + assertThat(metrics.pages()).isEqualTo(50); + assertThat(metrics.docUnits()).isEqualTo(2); + } + + @Test + @DisplayName("multi-file: a materialisedPaths size mismatch is rejected") + void multiFile_sizeMismatchRejected(@TempDir Path dir) throws IOException { + MultipartFile fa = new MockMultipartFile("file", "a.pdf", "application/pdf", pdfBytes(1)); + MultipartFile fb = new MockMultipartFile("file", "b.pdf", "application/pdf", pdfBytes(1)); + Path pa = dir.resolve("a.pdf"); + Files.write(pa, pdfBytes(1)); + + assertThatThrownBy(() -> classifier.classify(List.of(fa, fb), List.of(pa), DEFAULT_POLICY)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("materialisedPaths size"); + } + + @Test + @DisplayName("classify(file, null, policy) overload falls through to the multipart read") + void singleFile_nullPathOverload() throws IOException { + MultipartFile pdf = new MockMultipartFile("file", "x.pdf", "application/pdf", pdfBytes(30)); + + DocumentMetrics metrics = classifier.classify(pdf, null, DEFAULT_POLICY); + + assertThat(metrics.pages()).isEqualTo(30); + assertThat(metrics.docUnits()).isEqualTo(2); // ceil(30/25) + } + + private static byte[] pdfBytes(int pages) throws IOException { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + for (int i = 0; i < pages; i++) { + doc.addPage(new PDPage()); + } + doc.save(baos); + return baos.toByteArray(); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementSnapshotTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementSnapshotTest.java new file mode 100644 index 0000000000..ebcb81aede --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementSnapshotTest.java @@ -0,0 +1,75 @@ +package stirling.software.saas.payg.entitlement; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.saas.payg.model.EntitlementState; +import stirling.software.saas.payg.model.FeatureGate; +import stirling.software.saas.payg.model.FeatureSet; + +/** Accessor + {@code isDegraded} branch tests for the {@link EntitlementSnapshot} record. */ +class EntitlementSnapshotTest { + + private static EntitlementSnapshot snapshot(EntitlementState state) { + return new EntitlementSnapshot( + state, + FeatureSet.FULL, + List.of(FeatureGate.OFFSITE_PROCESSING, FeatureGate.AI_SUPPORT), + /* periodSpendUnits= */ 42L, + /* periodCapUnits= */ 100L, + LocalDateTime.of(2026, 6, 1, 0, 0), + LocalDateTime.of(2026, 7, 1, 0, 0), + /* subscribed= */ true); + } + + @Test + @DisplayName("accessors round-trip every component") + void accessors() { + EntitlementSnapshot s = snapshot(EntitlementState.WARNED); + assertThat(s.state()).isEqualTo(EntitlementState.WARNED); + assertThat(s.featureSet()).isEqualTo(FeatureSet.FULL); + assertThat(s.enabledGates()) + .containsExactly(FeatureGate.OFFSITE_PROCESSING, FeatureGate.AI_SUPPORT); + assertThat(s.periodSpendUnits()).isEqualTo(42L); + assertThat(s.periodCapUnits()).isEqualTo(100L); + assertThat(s.periodStart()).isEqualTo(LocalDateTime.of(2026, 6, 1, 0, 0)); + assertThat(s.periodEnd()).isEqualTo(LocalDateTime.of(2026, 7, 1, 0, 0)); + assertThat(s.subscribed()).isTrue(); + } + + @Test + @DisplayName("isDegraded is true only in the DEGRADED state") + void isDegraded() { + assertThat(snapshot(EntitlementState.DEGRADED).isDegraded()).isTrue(); + assertThat(snapshot(EntitlementState.FULL).isDegraded()).isFalse(); + assertThat(snapshot(EntitlementState.WARNED).isDegraded()).isFalse(); + } + + @Test + @DisplayName("a null cap means uncapped and is permitted") + void nullCapAllowed() { + EntitlementSnapshot s = + new EntitlementSnapshot( + EntitlementState.FULL, + FeatureSet.FULL, + List.of(), + 0L, + null, + LocalDateTime.now(), + LocalDateTime.now().plusDays(1), + false); + assertThat(s.periodCapUnits()).isNull(); + assertThat(s.enabledGates()).isEmpty(); + } + + @Test + @DisplayName("equal values produce equal records") + void valueSemantics() { + assertThat(snapshot(EntitlementState.FULL)).isEqualTo(snapshot(EntitlementState.FULL)); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/WalletEntitlementSnapshotTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/WalletEntitlementSnapshotTest.java new file mode 100644 index 0000000000..8fa47dc0ef --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/WalletEntitlementSnapshotTest.java @@ -0,0 +1,86 @@ +package stirling.software.saas.payg.entitlement; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.saas.payg.entitlement.WalletEntitlementSnapshot.WalletEntitlementSnapshotId; +import stirling.software.saas.payg.model.EntitlementState; +import stirling.software.saas.payg.model.FeatureGate; +import stirling.software.saas.payg.model.FeatureSet; + +/** + * Field round-trip + composite-id equality tests for the {@link WalletEntitlementSnapshot} entity. + * Complements {@code PaygEntitiesSmokeTest} by covering the remaining setters and the full id + * equality matrix (same ref, null, wrong type, differing components). + */ +class WalletEntitlementSnapshotTest { + + @Test + @DisplayName("defaults are sensible before any setter runs") + void defaults() { + WalletEntitlementSnapshot snap = new WalletEntitlementSnapshot(); + assertThat(snap.getState()).isEqualTo(EntitlementState.FULL); + assertThat(snap.getFeatureSet()).isEqualTo(FeatureSet.FULL); + assertThat(snap.getPeriodSpendUnits()).isEqualTo(0L); + assertThat(snap.getEnabledGates()).isEmpty(); + assertThat(WalletEntitlementSnapshot.TEAM_WIDE_USER_ID).isZero(); + } + + @Test + @DisplayName("all fields round-trip through their setters") + void fieldsRoundTrip() { + WalletEntitlementSnapshot snap = new WalletEntitlementSnapshot(); + WalletEntitlementSnapshotId id = new WalletEntitlementSnapshotId(7L, 42L); + LocalDateTime start = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime end = LocalDateTime.of(2026, 7, 1, 0, 0); + LocalDateTime computed = LocalDateTime.of(2026, 6, 15, 12, 0); + + snap.setId(id); + snap.setPeriodStart(start); + snap.setPeriodEnd(end); + snap.setPeriodSpendUnits(99L); + snap.setPeriodCapUnits(500L); + snap.setState(EntitlementState.DEGRADED); + snap.setFeatureSet(FeatureSet.MINIMAL); + snap.setEnabledGates(List.of(FeatureGate.CLIENT_SIDE)); + snap.setComputedAt(computed); + + assertThat(snap.getId()).isEqualTo(id); + assertThat(snap.getId().getTeamId()).isEqualTo(7L); + assertThat(snap.getId().getUserId()).isEqualTo(42L); + assertThat(snap.getPeriodStart()).isEqualTo(start); + assertThat(snap.getPeriodEnd()).isEqualTo(end); + assertThat(snap.getPeriodSpendUnits()).isEqualTo(99L); + assertThat(snap.getPeriodCapUnits()).isEqualTo(500L); + assertThat(snap.getState()).isEqualTo(EntitlementState.DEGRADED); + assertThat(snap.getFeatureSet()).isEqualTo(FeatureSet.MINIMAL); + assertThat(snap.getEnabledGates()).containsExactly(FeatureGate.CLIENT_SIDE); + assertThat(snap.getComputedAt()).isEqualTo(computed); + } + + @Test + @DisplayName("composite id equality covers ref, null, wrong type, and component diffs") + void compositeIdEquality() { + WalletEntitlementSnapshotId id = new WalletEntitlementSnapshotId(7L, 42L); + assertThat(id).isEqualTo(id); // same reference + assertThat(id).isNotEqualTo(null); + assertThat(id).isNotEqualTo("not-an-id"); + assertThat(id).isEqualTo(new WalletEntitlementSnapshotId(7L, 42L)); + assertThat(id).hasSameHashCodeAs(new WalletEntitlementSnapshotId(7L, 42L)); + assertThat(id).isNotEqualTo(new WalletEntitlementSnapshotId(8L, 42L)); // team differs + assertThat(id).isNotEqualTo(new WalletEntitlementSnapshotId(7L, 43L)); // user differs + } + + @Test + @DisplayName("no-arg id ctor leaves components null") + void noArgIdCtor() { + WalletEntitlementSnapshotId id = new WalletEntitlementSnapshotId(); + assertThat(id.getTeamId()).isNull(); + assertThat(id.getUserId()).isNull(); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygResponseBodyWrapperFilterTest.java b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygResponseBodyWrapperFilterTest.java new file mode 100644 index 0000000000..446dc21cf0 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygResponseBodyWrapperFilterTest.java @@ -0,0 +1,211 @@ +package stirling.software.saas.payg.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockAsyncContext; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import jakarta.servlet.AsyncEvent; +import jakarta.servlet.AsyncListener; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; + +/** + * Tests for {@link PaygResponseBodyWrapperFilter}: the enabled/disabled gate, the synchronous + * close-in-finally path, the fail-open behaviour when wrapper construction throws, and the async + * branch that defers cleanup to a {@code ReleaseOnAsyncComplete} listener. + */ +class PaygResponseBodyWrapperFilterTest { + + private final TempFileManager tempFileManager = + new TempFileManager(new TempFileRegistry(), new ApplicationProperties()); + + private PaygResponseBodyWrapperFilter filter(boolean enabled) { + PaygFilterProperties props = new PaygFilterProperties(); + props.setEnabled(enabled); + return new PaygResponseBodyWrapperFilter(tempFileManager, props); + } + + @Test + @DisplayName("REQUEST_ATTRIBUTE key is derived from the class name") + void requestAttributeKey() { + assertThat(PaygResponseBodyWrapperFilter.REQUEST_ATTRIBUTE) + .isEqualTo(PaygResponseBodyWrapperFilter.class.getName() + ".WRAPPER"); + } + + @Test + @DisplayName("disabled: passes the original response through, no wrapper attribute set") + void disabled_passesThrough() throws ServletException, IOException { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter(false).doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + assertThat(request.getAttribute(PaygResponseBodyWrapperFilter.REQUEST_ATTRIBUTE)).isNull(); + } + + @Nested + @DisplayName("enabled") + class Enabled { + + @Test + @DisplayName( + "wraps the response, exposes it as an attribute, then removes + closes on sync") + void sync_wrapsAndClosesInFinally() throws ServletException, IOException { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // Capture the wrapper visible mid-chain; after the chain returns (sync) it is removed. + Object[] seenMidChain = new Object[1]; + FilterChain chain = + (req, res) -> + seenMidChain[0] = + request.getAttribute( + PaygResponseBodyWrapperFilter.REQUEST_ATTRIBUTE); + + filter(true).doFilter(request, response, chain); + + assertThat(seenMidChain[0]).isInstanceOf(PaygResponseBodyWrapper.class); + // Sync request: not async-started, so the attribute is removed in the finally block. + assertThat(request.getAttribute(PaygResponseBodyWrapperFilter.REQUEST_ATTRIBUTE)) + .isNull(); + } + + @Test + @DisplayName("the chain receives the wrapper, not the raw response") + void chainReceivesWrapper() throws ServletException, IOException { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + Object[] passedResponse = new Object[1]; + FilterChain chain = (req, res) -> passedResponse[0] = res; + + filter(true).doFilter(request, response, chain); + + assertThat(passedResponse[0]).isInstanceOf(PaygResponseBodyWrapper.class); + } + + @Test + @DisplayName("async request: defers cleanup to an AsyncListener, attribute survives chain") + void async_registersListenerAndKeepsAttribute() throws ServletException, IOException { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAsyncSupported(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + + // startAsync() (called inside the chain) creates the live async context the filter then + // registers its listener on; isAsyncStarted() reads true afterwards. + FilterChain chain = (req, res) -> request.startAsync(); + + filter(true).doFilter(request, response, chain); + + // Async branch: the attribute is NOT removed in the finally; a listener owns cleanup. + assertThat(request.getAttribute(PaygResponseBodyWrapperFilter.REQUEST_ATTRIBUTE)) + .isInstanceOf(PaygResponseBodyWrapper.class); + MockAsyncContext ctx = (MockAsyncContext) request.getAsyncContext(); + assertThat(ctx.getListeners()).hasSize(1); + assertThat(ctx.getListeners().get(0).getClass().getSimpleName()) + .isEqualTo("ReleaseOnAsyncComplete"); + } + + @Test + @DisplayName("async listener removes the attribute and closes the wrapper on completion") + void asyncListener_releasesOnComplete() throws ServletException, IOException { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAsyncSupported(true); + MockHttpServletResponse response = new MockHttpServletResponse(); + + FilterChain chain = (req, res) -> request.startAsync(); + filter(true).doFilter(request, response, chain); + + MockAsyncContext ctx = (MockAsyncContext) request.getAsyncContext(); + AsyncListener listener = ctx.getListeners().get(0); + listener.onComplete(new AsyncEvent(ctx)); + + assertThat(request.getAttribute(PaygResponseBodyWrapperFilter.REQUEST_ATTRIBUTE)) + .isNull(); + } + + @Test + @DisplayName("async listener also releases on timeout and on error; startAsync is a no-op") + void asyncListener_releasesOnTimeoutAndError() throws ServletException, IOException { + // onTimeout + MockHttpServletRequest reqTimeout = new MockHttpServletRequest(); + reqTimeout.setAsyncSupported(true); + MockHttpServletResponse resp = new MockHttpServletResponse(); + filter(true).doFilter(reqTimeout, resp, (req, res) -> reqTimeout.startAsync()); + MockAsyncContext ctxTimeout = (MockAsyncContext) reqTimeout.getAsyncContext(); + AsyncListener tl = ctxTimeout.getListeners().get(0); + tl.onStartAsync(new AsyncEvent(ctxTimeout)); // no-op, must not throw + tl.onTimeout(new AsyncEvent(ctxTimeout)); + assertThat(reqTimeout.getAttribute(PaygResponseBodyWrapperFilter.REQUEST_ATTRIBUTE)) + .isNull(); + + // onError + MockHttpServletRequest reqError = new MockHttpServletRequest(); + reqError.setAsyncSupported(true); + MockHttpServletResponse resp2 = new MockHttpServletResponse(); + filter(true).doFilter(reqError, resp2, (req, res) -> reqError.startAsync()); + MockAsyncContext ctxError = (MockAsyncContext) reqError.getAsyncContext(); + AsyncListener el = ctxError.getListeners().get(0); + el.onError(new AsyncEvent(ctxError)); + assertThat(reqError.getAttribute(PaygResponseBodyWrapperFilter.REQUEST_ATTRIBUTE)) + .isNull(); + } + + @Test + @DisplayName("wrapper construction failure: fail-open, chain runs with the raw response") + void constructionFailure_failsOpen() throws ServletException, IOException { + // The wrapper is built inside a try that catches RuntimeException. Make the threshold + // read (properties.getResponse()) throw so the catch's fail-open branch is taken. + PaygFilterProperties props = mock(PaygFilterProperties.class); + when(props.isEnabled()).thenReturn(true); + when(props.getResponse()).thenThrow(new IllegalStateException("boom")); + PaygResponseBodyWrapperFilter filter = + new PaygResponseBodyWrapperFilter(tempFileManager, props); + + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(request, response, chain); + + // Fail-open: chain still ran with the original (unwrapped) response. + verify(chain).doFilter(request, response); + assertThat(request.getAttribute(PaygResponseBodyWrapperFilter.REQUEST_ATTRIBUTE)) + .isNull(); + } + + @Test + @DisplayName("propagates a ServletException thrown by the downstream chain") + void propagatesChainException() throws ServletException, IOException { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + FilterChain chain = mock(FilterChain.class); + doThrow(new ServletException("downstream")).when(chain).doFilter(any(), any()); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> filter(true).doFilter(request, response, chain)) + .isInstanceOf(ServletException.class); + + // Even on exception, the finally block removed the attribute (sync path). + assertThat(request.getAttribute(PaygResponseBodyWrapperFilter.REQUEST_ATTRIBUTE)) + .isNull(); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygWebMvcConfigMoreTest.java b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygWebMvcConfigMoreTest.java new file mode 100644 index 0000000000..5e33e55ba8 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygWebMvcConfigMoreTest.java @@ -0,0 +1,99 @@ +package stirling.software.saas.payg.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.web.servlet.config.annotation.InterceptorRegistration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.util.TempFileManager; +import stirling.software.common.util.TempFileRegistry; +import stirling.software.saas.payg.entitlement.EntitlementGuard; + +/** + * Behavioural tests for {@link PaygWebMvcConfig}: the {@link FilterRegistrationBean} the config + * produces and the interceptor wiring done in {@code addInterceptors}. The registry + registration + * are mocked so the fluent path-pattern / order calls can be asserted without a Spring context. + */ +class PaygWebMvcConfigMoreTest { + + private final PaygChargeInterceptor chargeInterceptor = mock(PaygChargeInterceptor.class); + private final EntitlementGuard entitlementGuard = mock(EntitlementGuard.class); + + private final PaygWebMvcConfig config = + new PaygWebMvcConfig(chargeInterceptor, entitlementGuard); + + @Test + @DisplayName("filter registration wraps the filter and maps it to /api/*") + void filterRegistration_mapsApiPattern() { + PaygResponseBodyWrapperFilter filter = + new PaygResponseBodyWrapperFilter( + new TempFileManager(new TempFileRegistry(), new ApplicationProperties()), + new PaygFilterProperties()); + + FilterRegistrationBean reg = + config.paygResponseBodyWrapperFilterRegistration(filter); + + assertThat(reg.getFilter()).isSameAs(filter); + assertThat(reg.getUrlPatterns()).containsExactly("/api/*"); + } + + @Test + @DisplayName("addInterceptors registers both interceptors with their orders") + void addInterceptors_registersBothWithOrders() { + InterceptorRegistry registry = mock(InterceptorRegistry.class); + InterceptorRegistration chargeRegistration = mock(InterceptorRegistration.class); + InterceptorRegistration guardRegistration = mock(InterceptorRegistration.class); + + when(registry.addInterceptor(chargeInterceptor)).thenReturn(chargeRegistration); + when(registry.addInterceptor(entitlementGuard)).thenReturn(guardRegistration); + // The config chains addPathPatterns(...).excludePathPatterns(...).order(...); each returns + // the same registration so the chain resolves on the mock. + when(chargeRegistration.addPathPatterns(any(String[].class))) + .thenReturn(chargeRegistration); + when(chargeRegistration.excludePathPatterns(any(String[].class))) + .thenReturn(chargeRegistration); + when(guardRegistration.addPathPatterns(any(String[].class))).thenReturn(guardRegistration); + when(guardRegistration.excludePathPatterns(any(String[].class))) + .thenReturn(guardRegistration); + + config.addInterceptors(registry); + + verify(registry).addInterceptor(chargeInterceptor); + verify(registry).addInterceptor(entitlementGuard); + + verify(chargeRegistration).addPathPatterns("/api/**"); + verify(chargeRegistration) + .excludePathPatterns("/api/v1/config/**", "/api/v1/info/**", "/api/v1/admin/**"); + verify(chargeRegistration).order(PaygWebMvcConfig.INTERCEPTOR_ORDER); + + verify(guardRegistration).addPathPatterns("/api/**"); + verify(guardRegistration) + .excludePathPatterns("/api/v1/config/**", "/api/v1/info/**", "/api/v1/admin/**"); + verify(guardRegistration).order(PaygWebMvcConfig.ENTITLEMENT_GUARD_ORDER); + } + + @Test + @DisplayName("charge interceptor is registered before the entitlement guard on the registry") + void chargeInterceptorRegisteredBeforeGuard() { + InterceptorRegistry registry = mock(InterceptorRegistry.class); + InterceptorRegistration reg = mock(InterceptorRegistration.class, inv -> inv.getMock()); + + when(registry.addInterceptor(any())).thenReturn(reg); + + config.addInterceptors(registry); + + InOrder order = inOrder(registry); + order.verify(registry).addInterceptor(chargeInterceptor); + order.verify(registry).addInterceptor(entitlementGuard); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/job/JobContextTest.java b/app/saas/src/test/java/stirling/software/saas/payg/job/JobContextTest.java new file mode 100644 index 0000000000..8ca92a36ba --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/job/JobContextTest.java @@ -0,0 +1,98 @@ +package stirling.software.saas.payg.job; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.saas.payg.model.JobSource; +import stirling.software.saas.payg.model.ProcessType; + +/** Constructor-validation and accessor tests for the {@link JobContext} record. */ +class JobContextTest { + + @Test + @DisplayName("accepts a fully-populated context and exposes its fields") + void valid() { + JobContext ctx = + new JobContext(10L, 20L, JobSource.PIPELINE, ProcessType.AUTOMATION, 99L, 7); + + assertThat(ctx.ownerUserId()).isEqualTo(10L); + assertThat(ctx.ownerTeamId()).isEqualTo(20L); + assertThat(ctx.source()).isEqualTo(JobSource.PIPELINE); + assertThat(ctx.processType()).isEqualTo(ProcessType.AUTOMATION); + assertThat(ctx.policyId()).isEqualTo(99L); + assertThat(ctx.stepLimit()).isEqualTo(7); + } + + @Test + @DisplayName("allows a null owner team id") + void nullTeamAllowed() { + JobContext ctx = new JobContext(10L, null, JobSource.WEB, ProcessType.SINGLE_TOOL, 1L, 1); + assertThat(ctx.ownerTeamId()).isNull(); + } + + @Test + @DisplayName("rejects a null owner user id") + void rejectsNullOwnerUser() { + assertThatThrownBy( + () -> + new JobContext( + null, 20L, JobSource.WEB, ProcessType.SINGLE_TOOL, 1L, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ownerUserId"); + } + + @Test + @DisplayName("rejects a null source") + void rejectsNullSource() { + assertThatThrownBy(() -> new JobContext(1L, 20L, null, ProcessType.SINGLE_TOOL, 1L, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("source"); + } + + @Test + @DisplayName("rejects a null process type") + void rejectsNullProcessType() { + assertThatThrownBy(() -> new JobContext(1L, 20L, JobSource.WEB, null, 1L, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("processType"); + } + + @Test + @DisplayName("rejects a null policy id") + void rejectsNullPolicy() { + assertThatThrownBy( + () -> + new JobContext( + 1L, 20L, JobSource.WEB, ProcessType.SINGLE_TOOL, null, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("policyId"); + } + + @Test + @DisplayName("rejects a non-positive step limit") + void rejectsNonPositiveStepLimit() { + assertThatThrownBy( + () -> + new JobContext( + 1L, 20L, JobSource.WEB, ProcessType.SINGLE_TOOL, 1L, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("stepLimit"); + assertThatThrownBy( + () -> + new JobContext( + 1L, 20L, JobSource.WEB, ProcessType.SINGLE_TOOL, 1L, -3)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("stepLimit"); + } + + @Test + @DisplayName("equal values produce equal records") + void valueSemantics() { + JobContext a = new JobContext(1L, 2L, JobSource.API, ProcessType.CHAIN, 5L, 3); + JobContext b = new JobContext(1L, 2L, JobSource.API, ProcessType.CHAIN, 5L, 3); + assertThat(a).isEqualTo(b).hasSameHashCodeAs(b); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/lineage/JpaJobLineageStoreTest.java b/app/saas/src/test/java/stirling/software/saas/payg/lineage/JpaJobLineageStoreTest.java new file mode 100644 index 0000000000..c8f0f4964b --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/lineage/JpaJobLineageStoreTest.java @@ -0,0 +1,208 @@ +package stirling.software.saas.payg.lineage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +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.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Limit; + +import stirling.software.saas.payg.job.JobArtifactHash; +import stirling.software.saas.payg.job.JobArtifactHash.JobArtifactHashId; +import stirling.software.saas.payg.model.ArtifactKind; +import stirling.software.saas.payg.model.JobStatus; +import stirling.software.saas.payg.repository.JobArtifactHashRepository; + +/** + * Unit tests for {@link JpaJobLineageStore}. The {@link JobArtifactHashRepository} is mocked; the + * rows passed to {@code saveAll} and the query params are captured to assert the storage-key + * encoding and the status/window filtering the store delegates to the DB query. + */ +@ExtendWith(MockitoExtension.class) +class JpaJobLineageStoreTest { + + @Mock private JobArtifactHashRepository hashRepository; + + @InjectMocks private JpaJobLineageStore store; + + @Captor private ArgumentCaptor> rowsCaptor; + + private static final UUID JOB_ID = UUID.fromString("11111111-2222-3333-4444-555555555555"); + + @Nested + @DisplayName("record") + class Record { + + @Test + @DisplayName("rejects null arguments") + void rejectsNulls() { + assertThatThrownBy(() -> store.record(null, Set.of(), ArtifactKind.INPUT)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> store.record(JOB_ID, null, ArtifactKind.INPUT)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> store.record(JOB_ID, Set.of(), null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("empty signature set is a no-op") + void emptySignatures_noOp() { + store.record(JOB_ID, Set.of(), ArtifactKind.INPUT); + verifyNoInteractions(hashRepository); + } + + @Test + @DisplayName("persists one row per signature with the storage-key encoding") + void persistsRowsWithStorageKeys() { + Set sigs = + Set.of( + new LineageSignature("sha256", "aaa"), + new LineageSignature("pdf-id", "bbb")); + + store.record(JOB_ID, sigs, ArtifactKind.OUTPUT); + + verify(hashRepository).saveAll(rowsCaptor.capture()); + List rows = rowsCaptor.getValue(); + assertThat(rows).hasSize(2); + assertThat(rows) + .extracting(JobArtifactHash::getId) + .extracting(JobArtifactHashId::getContentHash) + .containsExactlyInAnyOrder("sha256:aaa", "pdf-id:bbb"); + assertThat(rows) + .allSatisfy( + r -> { + assertThat(r.getId().getJobId()).isEqualTo(JOB_ID); + assertThat(r.getId().getKind()).isEqualTo(ArtifactKind.OUTPUT); + }); + } + } + + @Nested + @DisplayName("findOpenJobForSignatures") + class FindOpenJob { + + @Test + @DisplayName("rejects null arguments") + void rejectsNulls() { + assertThatThrownBy( + () -> + store.findOpenJobForSignatures( + null, Set.of(), Duration.ofMinutes(1))) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy( + () -> store.findOpenJobForSignatures(1L, null, Duration.ofMinutes(1))) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> store.findOpenJobForSignatures(1L, Set.of(), null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("empty candidate set returns empty without querying") + void emptyCandidates_returnsEmpty() { + assertThat(store.findOpenJobForSignatures(1L, Set.of(), Duration.ofMinutes(5))) + .isEmpty(); + verify(hashRepository, never()) + .findOpenJobsForSignatures(any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("delegates to the repository with OPEN status, storage keys, and Limit.of(1)") + void delegatesWithExpectedParams() { + LocalDateTime now = LocalDateTime.now(); + LineageMatch match = new LineageMatch(JOB_ID, ArtifactKind.INPUT, now); + when(hashRepository.findOpenJobsForSignatures( + eq(7L), eq(JobStatus.OPEN), any(), any(), any())) + .thenReturn(List.of(match)); + + Set candidates = + Set.of( + new LineageSignature("sha256", "v1"), + new LineageSignature("pdf-id", "v2")); + + var out = store.findOpenJobForSignatures(7L, candidates, Duration.ofHours(2)); + + assertThat(out).contains(match); + + @SuppressWarnings("unchecked") + ArgumentCaptor> keysCaptor = ArgumentCaptor.forClass(List.class); + ArgumentCaptor sinceCaptor = + ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor limitCaptor = ArgumentCaptor.forClass(Limit.class); + verify(hashRepository) + .findOpenJobsForSignatures( + eq(7L), + eq(JobStatus.OPEN), + sinceCaptor.capture(), + keysCaptor.capture(), + limitCaptor.capture()); + + assertThat(keysCaptor.getValue()).containsExactlyInAnyOrder("sha256:v1", "pdf-id:v2"); + assertThat(limitCaptor.getValue().max()).isEqualTo(1); + // since ≈ now − window; allow a generous slack for test execution. + assertThat(sinceCaptor.getValue()) + .isBefore(LocalDateTime.now().minusHours(1).minusMinutes(50)); + } + + @Test + @DisplayName("empty repository result maps to Optional.empty") + void noMatch_returnsEmpty() { + when(hashRepository.findOpenJobsForSignatures(any(), any(), any(), any(), any())) + .thenReturn(List.of()); + + assertThat( + store.findOpenJobForSignatures( + 7L, + Set.of(new LineageSignature("sha256", "v1")), + Duration.ofHours(1))) + .isEmpty(); + } + } + + @Nested + @DisplayName("pruneOlderThan") + class Prune { + + @Test + @DisplayName("rejects null cutoff") + void rejectsNull() { + assertThatThrownBy(() -> store.pruneOlderThan(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("converts the Instant cutoff to local time and returns the delete count") + void convertsCutoffAndReturnsCount() { + Instant cutoff = Instant.ofEpochSecond(1_700_000_000L); + ArgumentCaptor cutoffCaptor = + ArgumentCaptor.forClass(LocalDateTime.class); + when(hashRepository.deleteOlderThan(cutoffCaptor.capture())).thenReturn(13); + + int deleted = store.pruneOlderThan(cutoff); + + assertThat(deleted).isEqualTo(13); + assertThat(cutoffCaptor.getValue()) + .isEqualTo(LocalDateTime.ofInstant(cutoff, ZoneId.systemDefault())); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterEventLogTest.java b/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterEventLogTest.java new file mode 100644 index 0000000000..940e5f915b --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterEventLogTest.java @@ -0,0 +1,51 @@ +package stirling.software.saas.payg.meter; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Accessor coverage for the PaygMeterEventLog audit row entity. */ +class PaygMeterEventLogTest { + + @Test + @DisplayName("a fresh row is unposted with no Stripe error captured") + void freshRowIsPending() { + PaygMeterEventLog log = new PaygMeterEventLog(); + assertThat(log.getPostedToStripeAt()).isNull(); + assertThat(log.getStripeErrorCode()).isNull(); + assertThat(log.getStripeErrorBody()).isNull(); + } + + @Test + @DisplayName("every setter round-trips through its getter") + void settersRoundTrip() { + UUID jobId = UUID.randomUUID(); + LocalDateTime occurred = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime posted = LocalDateTime.of(2026, 6, 1, 0, 5); + + PaygMeterEventLog log = new PaygMeterEventLog(); + log.setEventId(7L); + log.setTeamId(42L); + log.setJobId(jobId); + log.setIdempotencyKey("process:abc:close"); + log.setUnits(4); + log.setOccurredAt(occurred); + log.setPostedToStripeAt(posted); + log.setStripeErrorCode("rate_limit"); + log.setStripeErrorBody("{\"error\":\"too many requests\"}"); + + assertThat(log.getEventId()).isEqualTo(7L); + assertThat(log.getTeamId()).isEqualTo(42L); + assertThat(log.getJobId()).isEqualTo(jobId); + assertThat(log.getIdempotencyKey()).isEqualTo("process:abc:close"); + assertThat(log.getUnits()).isEqualTo(4); + assertThat(log.getOccurredAt()).isEqualTo(occurred); + assertThat(log.getPostedToStripeAt()).isEqualTo(posted); + assertThat(log.getStripeErrorCode()).isEqualTo("rate_limit"); + assertThat(log.getStripeErrorBody()).contains("too many requests"); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesMoreTest.java b/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesMoreTest.java new file mode 100644 index 0000000000..9b7d345a6c --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesMoreTest.java @@ -0,0 +1,332 @@ +package stirling.software.saas.payg.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import stirling.software.saas.payg.job.JobArtifactHash; +import stirling.software.saas.payg.job.JobArtifactHash.JobArtifactHashId; +import stirling.software.saas.payg.job.ProcessingJob; +import stirling.software.saas.payg.job.ProcessingJobStep; +import stirling.software.saas.payg.policy.PricingPolicy; +import stirling.software.saas.payg.shadow.PaygShadowCharge; +import stirling.software.saas.payg.wallet.WalletLedgerEntry; +import stirling.software.saas.payg.wallet.WalletPolicy; + +/** + * Fills the coverage gaps PaygEntitiesSmokeTest leaves: PricingPolicy ctor validation failures, the + * remaining entity setters/defaults, and the JobArtifactHash composite-id accessors. + */ +class PaygEntitiesMoreTest { + + @Nested + @DisplayName("PricingPolicy convenience-ctor validation") + class PricingPolicyValidation { + + @Test + @DisplayName("rejects a non-positive docPagesPerUnit") + void rejectsDocPages() { + assertThatThrownBy(() -> new PricingPolicy(0, 1, 1, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("docPagesPerUnit"); + } + + @Test + @DisplayName("rejects a non-positive docBytesPerUnit") + void rejectsDocBytes() { + assertThatThrownBy(() -> new PricingPolicy(1, 0, 1, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("docBytesPerUnit"); + } + + @Test + @DisplayName("rejects a minChargeUnits below 1") + void rejectsMinCharge() { + assertThatThrownBy(() -> new PricingPolicy(1, 1, 0, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("minChargeUnits"); + } + + @Test + @DisplayName("rejects a fileUnitCap below 1") + void rejectsFileUnitCap() { + assertThatThrownBy(() -> new PricingPolicy(1, 1, 1, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("fileUnitCap"); + } + + @Test + @DisplayName("no-arg constructor carries the documented defaults") + void noArgDefaults() { + PricingPolicy p = new PricingPolicy(); + assertThat(p.getMinChargeUnits()).isEqualTo(1); + assertThat(p.getFileUnitCap()).isEqualTo(1000); + assertThat(p.getFreeTierUnits()).isZero(); + assertThat(p.getIsDefault()).isFalse(); + assertThat(p.getStepLimits()).isEmpty(); + assertThat(p.getStripePriceIds()).isEmpty(); + } + + @Test + @DisplayName("remaining setters round-trip") + void settersRoundTrip() { + LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime to = LocalDateTime.of(2026, 12, 1, 0, 0); + PricingPolicy p = new PricingPolicy(); + p.setId(3L); + p.setEffectiveFrom(from); + p.setEffectiveTo(to); + p.setFreeTierUnits(50L); + p.setIsDefault(Boolean.TRUE); + p.setNotes("seed policy"); + p.setCreatedBy("admin@example.com"); + + assertThat(p.getId()).isEqualTo(3L); + assertThat(p.getEffectiveFrom()).isEqualTo(from); + assertThat(p.getEffectiveTo()).isEqualTo(to); + assertThat(p.getFreeTierUnits()).isEqualTo(50L); + assertThat(p.getIsDefault()).isTrue(); + assertThat(p.getNotes()).isEqualTo("seed policy"); + assertThat(p.getCreatedBy()).isEqualTo("admin@example.com"); + } + } + + @Nested + @DisplayName("WalletLedgerEntry extra fields") + class WalletLedger { + + @Test + @DisplayName("metadata defaults to an empty mutable map") + void metadataDefault() { + assertThat(new WalletLedgerEntry().getMetadata()).isEmpty(); + } + + @Test + @DisplayName("actor, policy, stripe-event, and metadata setters round-trip") + void settersRoundTrip() { + WalletLedgerEntry entry = new WalletLedgerEntry(); + entry.setId(9L); + entry.setActorUserId(42L); + entry.setPolicyId(3L); + entry.setStripeEventId("evt_123"); + entry.setMetadata(Map.of("source", "grant")); + entry.setOccurredAt(LocalDateTime.of(2026, 6, 1, 0, 0)); + + assertThat(entry.getId()).isEqualTo(9L); + assertThat(entry.getActorUserId()).isEqualTo(42L); + assertThat(entry.getPolicyId()).isEqualTo(3L); + assertThat(entry.getStripeEventId()).isEqualTo("evt_123"); + assertThat(entry.getMetadata()).containsEntry("source", "grant"); + assertThat(entry.getOccurredAt()).isEqualTo(LocalDateTime.of(2026, 6, 1, 0, 0)); + } + } + + @Nested + @DisplayName("WalletPolicy extra fields") + class Wallet { + + @Test + @DisplayName("notificationEmails defaults to an empty mutable list and capUnits is null") + void defaults() { + WalletPolicy policy = new WalletPolicy(); + assertThat(policy.getNotificationEmails()).isEmpty(); + assertThat(policy.getCapUnits()).isNull(); + assertThat(policy.getCapSourceMoney()).isNull(); + } + + @Test + @DisplayName("cap, threshold, and email setters round-trip") + void settersRoundTrip() { + WalletPolicy policy = new WalletPolicy(); + policy.setId(1L); + policy.setTeamId(7L); + policy.setEngine(WalletEngine.PAYG); + policy.setCapPeriod(CapPeriod.BILLING_CYCLE); + policy.setCapUnits(5000L); + policy.setCapSourceMoney(5000L); + policy.setWarnAtPct(75); + policy.setDegradeAtPct(95); + policy.setDegradedFeatureSet(FeatureSet.CLIENT_ONLY); + policy.setAutoGroupStrategy(AutoGroupStrategy.OFF); + policy.setNotificationEmails(List.of("ops@example.com")); + + assertThat(policy.getId()).isEqualTo(1L); + assertThat(policy.getTeamId()).isEqualTo(7L); + assertThat(policy.getEngine()).isEqualTo(WalletEngine.PAYG); + assertThat(policy.getCapPeriod()).isEqualTo(CapPeriod.BILLING_CYCLE); + assertThat(policy.getCapUnits()).isEqualTo(5000L); + assertThat(policy.getCapSourceMoney()).isEqualTo(5000L); + assertThat(policy.getWarnAtPct()).isEqualTo(75); + assertThat(policy.getDegradeAtPct()).isEqualTo(95); + assertThat(policy.getDegradedFeatureSet()).isEqualTo(FeatureSet.CLIENT_ONLY); + assertThat(policy.getAutoGroupStrategy()).isEqualTo(AutoGroupStrategy.OFF); + assertThat(policy.getNotificationEmails()).containsExactly("ops@example.com"); + } + } + + @Nested + @DisplayName("ProcessingJob extra fields") + class Job { + + @Test + @DisplayName("counters default to zero and metadata is an empty map") + void defaults() { + ProcessingJob job = new ProcessingJob(); + assertThat(job.getDocUnits()).isZero(); + assertThat(job.getStepCount()).isZero(); + assertThat(job.getMetadata()).isEmpty(); + assertThat(job.getChargedUnits()).isNull(); + } + + @Test + @DisplayName("remaining setters round-trip") + void settersRoundTrip() { + UUID id = UUID.randomUUID(); + LocalDateTime closed = LocalDateTime.of(2026, 6, 1, 1, 0); + ProcessingJob job = new ProcessingJob(); + job.setId(id); + job.setOwnerTeamId(7L); + job.setDocumentFingerprint("sha256-fp"); + job.setDocUnits(4); + job.setStepCount(3); + job.setClosedAt(closed); + job.setPolicyId(2L); + job.setChargedUnits(4); + job.setChargedCents(400); + job.setIdempotencyKey("open:abc"); + job.setMetadata(Map.of("k", "v")); + + assertThat(job.getId()).isEqualTo(id); + assertThat(job.getOwnerTeamId()).isEqualTo(7L); + assertThat(job.getDocumentFingerprint()).isEqualTo("sha256-fp"); + assertThat(job.getDocUnits()).isEqualTo(4); + assertThat(job.getStepCount()).isEqualTo(3); + assertThat(job.getClosedAt()).isEqualTo(closed); + assertThat(job.getPolicyId()).isEqualTo(2L); + assertThat(job.getChargedUnits()).isEqualTo(4); + assertThat(job.getChargedCents()).isEqualTo(400); + assertThat(job.getIdempotencyKey()).isEqualTo("open:abc"); + assertThat(job.getMetadata()).containsEntry("k", "v"); + } + } + + @Nested + @DisplayName("ProcessingJobStep extra fields") + class Step { + + @Test + @DisplayName("all setters round-trip") + void settersRoundTrip() { + UUID jobId = UUID.randomUUID(); + LocalDateTime started = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime completed = LocalDateTime.of(2026, 6, 1, 0, 1); + ProcessingJobStep step = new ProcessingJobStep(); + step.setId(5L); + step.setJobId(jobId); + step.setToolId("/api/v1/general/merge"); + step.setStatus(JobStepStatus.FAILED); + step.setStartedAt(started); + step.setCompletedAt(completed); + step.setInputPages(12); + step.setInputBytes(2048L); + step.setErrorCode("E_TIMEOUT"); + + assertThat(step.getId()).isEqualTo(5L); + assertThat(step.getJobId()).isEqualTo(jobId); + assertThat(step.getToolId()).isEqualTo("/api/v1/general/merge"); + assertThat(step.getStatus()).isEqualTo(JobStepStatus.FAILED); + assertThat(step.getStartedAt()).isEqualTo(started); + assertThat(step.getCompletedAt()).isEqualTo(completed); + assertThat(step.getInputPages()).isEqualTo(12); + assertThat(step.getInputBytes()).isEqualTo(2048L); + assertThat(step.getErrorCode()).isEqualTo("E_TIMEOUT"); + } + } + + @Nested + @DisplayName("PaygShadowCharge extra fields") + class Shadow { + + @Test + @DisplayName("defaults: CHARGED status and zero free units consumed") + void defaults() { + PaygShadowCharge row = new PaygShadowCharge(); + assertThat(row.getStatus()).isEqualTo(ShadowChargeStatus.CHARGED); + assertThat(row.getFreeUnitsConsumed()).isZero(); + assertThat(row.getRefundedAt()).isNull(); + assertThat(row.getRefundReason()).isNull(); + } + + @Test + @DisplayName("refund and free-unit setters round-trip") + void settersRoundTrip() { + LocalDateTime refundedAt = LocalDateTime.of(2026, 6, 1, 0, 2); + PaygShadowCharge row = new PaygShadowCharge(); + row.setId(9L); + row.setFreeUnitsConsumed(2); + row.setStatus(ShadowChargeStatus.REFUNDED); + row.setRefundedAt(refundedAt); + row.setRefundReason("first-step-5xx:503"); + + assertThat(row.getId()).isEqualTo(9L); + assertThat(row.getFreeUnitsConsumed()).isEqualTo(2); + assertThat(row.getStatus()).isEqualTo(ShadowChargeStatus.REFUNDED); + assertThat(row.getRefundedAt()).isEqualTo(refundedAt); + assertThat(row.getRefundReason()).isEqualTo("first-step-5xx:503"); + } + } + + @Nested + @DisplayName("JobArtifactHash composite id") + class ArtifactHash { + + @Test + @DisplayName("the embedded id exposes its components through getters") + void idAccessors() { + UUID jobId = UUID.randomUUID(); + JobArtifactHashId id = new JobArtifactHashId(jobId, "hash-1", ArtifactKind.OUTPUT); + assertThat(id.getJobId()).isEqualTo(jobId); + assertThat(id.getContentHash()).isEqualTo("hash-1"); + assertThat(id.getKind()).isEqualTo(ArtifactKind.OUTPUT); + } + + @Test + @DisplayName("the no-arg embedded id supports setter round-trips") + void noArgIdSetters() { + UUID jobId = UUID.randomUUID(); + JobArtifactHashId id = new JobArtifactHashId(); + id.setJobId(jobId); + id.setContentHash("hash-2"); + id.setKind(ArtifactKind.INPUT); + assertThat(id.getJobId()).isEqualTo(jobId); + assertThat(id.getContentHash()).isEqualTo("hash-2"); + assertThat(id.getKind()).isEqualTo(ArtifactKind.INPUT); + } + + @Test + @DisplayName("id is unequal to null and to a foreign type") + void idNotEqualNullOrForeign() { + JobArtifactHashId id = + new JobArtifactHashId(UUID.randomUUID(), "h", ArtifactKind.INPUT); + assertThat(id).isNotEqualTo(null).isNotEqualTo("string"); + assertThat(id).isEqualTo(id); + } + + @Test + @DisplayName("the row exposes its createdAt setter") + void rowCreatedAt() { + JobArtifactHash row = new JobArtifactHash(); + LocalDateTime created = LocalDateTime.of(2026, 6, 1, 0, 0); + row.setCreatedAt(created); + assertThat(row.getCreatedAt()).isEqualTo(created); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/policy/PaygTeamExtensionsTest.java b/app/saas/src/test/java/stirling/software/saas/payg/policy/PaygTeamExtensionsTest.java new file mode 100644 index 0000000000..c0badb276a --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/policy/PaygTeamExtensionsTest.java @@ -0,0 +1,60 @@ +package stirling.software.saas.payg.policy; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.model.Team; + +/** Constructor, default, and accessor coverage for the PaygTeamExtensions sidecar entity. */ +class PaygTeamExtensionsTest { + + @Test + @DisplayName("no-arg constructor defaults the free-units counter to zero") + void defaults() { + PaygTeamExtensions ext = new PaygTeamExtensions(); + assertThat(ext.getFreeUnitsRemaining()).isZero(); + assertThat(ext.getPricingPolicyId()).isNull(); + assertThat(ext.getStripeCustomerId()).isNull(); + assertThat(ext.getPaygSubscriptionId()).isNull(); + } + + @Test + @DisplayName("Team constructor derives the team id from the team reference") + void teamConstructor() { + Team team = new Team(); + team.setId(42L); + PaygTeamExtensions ext = new PaygTeamExtensions(team); + assertThat(ext.getTeam()).isSameAs(team); + assertThat(ext.getTeamId()).isEqualTo(42L); + } + + @Test + @DisplayName("every setter round-trips through its getter") + void settersRoundTrip() { + LocalDateTime created = LocalDateTime.of(2026, 6, 1, 0, 0); + LocalDateTime updated = LocalDateTime.of(2026, 6, 2, 0, 0); + + PaygTeamExtensions ext = new PaygTeamExtensions(); + ext.setTeamId(5L); + ext.setPricingPolicyId(11L); + ext.setStripeCustomerId("cus_abc"); + ext.setPaygSubscriptionId("sub_xyz"); + ext.setFreeUnitsRemaining(250L); + ext.setCreatedAt(created); + ext.setUpdatedAt(updated); + ext.setVersion(3L); + + assertThat(ext.getTeamId()).isEqualTo(5L); + assertThat(ext.getPricingPolicyId()).isEqualTo(11L); + assertThat(ext.getStripeCustomerId()).isEqualTo("cus_abc"); + assertThat(ext.getPaygSubscriptionId()).isEqualTo("sub_xyz"); + assertThat(ext.getFreeUnitsRemaining()).isEqualTo(250L); + assertThat(ext.getCreatedAt()).isEqualTo(created); + assertThat(ext.getUpdatedAt()).isEqualTo(updated); + assertThat(ext.getVersion()).isEqualTo(3L); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/stripe/StripeSubscriptionDaoTest.java b/app/saas/src/test/java/stirling/software/saas/payg/stripe/StripeSubscriptionDaoTest.java new file mode 100644 index 0000000000..f1e4c51e45 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/stripe/StripeSubscriptionDaoTest.java @@ -0,0 +1,340 @@ +package stirling.software.saas.payg.stripe; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.sql.ResultSet; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import stirling.software.saas.payg.stripe.StripeSubscriptionDao.PriceRate; +import stirling.software.saas.payg.stripe.StripeSubscriptionDao.SubscriptionBilling; + +/** + * Pure-Mockito tests for {@link StripeSubscriptionDao}. The {@link JdbcTemplate} is mocked; the + * {@link RowMapper} passed to {@code query} is captured and invoked against a mocked {@link + * ResultSet} so the epoch→{@code LocalDateTime} conversion and rate-extraction branches are + * exercised directly without a real Postgres {@code stripe} schema. + */ +@ExtendWith(MockitoExtension.class) +class StripeSubscriptionDaoTest { + + @Mock private JdbcTemplate jdbcTemplate; + + private StripeSubscriptionDao dao() { + return new StripeSubscriptionDao(jdbcTemplate); + } + + @Test + @DisplayName("constructor rejects a null JdbcTemplate") + void constructor_rejectsNull() { + assertThatThrownBy(() -> new StripeSubscriptionDao(null)) + .isInstanceOf(NullPointerException.class); + } + + @Nested + @DisplayName("findBilling") + class FindBilling { + + @Test + @DisplayName("returns empty for null / blank subscription id without touching the DB") + void blankId_shortCircuits() { + assertThat(dao().findBilling(null)).isEmpty(); + assertThat(dao().findBilling("")).isEmpty(); + assertThat(dao().findBilling(" ")).isEmpty(); + verifyNoInteractions(jdbcTemplate); + } + + @Test + @SuppressWarnings("unchecked") + @DisplayName("maps a populated row, converting epoch seconds to local time") + void mapsPopulatedRow() throws Exception { + long start = 1_700_000_000L; + long end = 1_702_000_000L; + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("current_period_start")).thenReturn(start); + when(rs.getLong("current_period_end")).thenReturn(end); + // wasNull() is consulted right after each getLong: start, end → both present. + when(rs.wasNull()).thenReturn(false, false); + when(rs.getString("price_id")).thenReturn("price_1"); + when(rs.getString("status")).thenReturn("active"); + when(rs.getString("currency")).thenReturn("usd"); + // extractRate prefers unit_amount_decimal. + when(rs.getString("unit_amount_decimal")).thenReturn("12.5"); + + when(jdbcTemplate.query(anyString(), any(RowMapper.class), eq("sub_1"))) + .thenAnswer( + inv -> { + RowMapper m = inv.getArgument(1); + return List.of(m.mapRow(rs, 0)); + }); + + Optional out = dao().findBilling("sub_1"); + + assertThat(out).isPresent(); + SubscriptionBilling b = out.get(); + assertThat(b.priceId()).isEqualTo("price_1"); + assertThat(b.status()).isEqualTo("active"); + assertThat(b.currency()).isEqualTo("usd"); + assertThat(b.perDocMinor()).isEqualByComparingTo("12.5"); + assertThat(b.periodStart()) + .isEqualTo( + LocalDateTime.ofInstant( + Instant.ofEpochSecond(start), ZoneId.systemDefault())); + assertThat(b.periodEnd()) + .isEqualTo( + LocalDateTime.ofInstant( + Instant.ofEpochSecond(end), ZoneId.systemDefault())); + } + + @Test + @SuppressWarnings("unchecked") + @DisplayName("row with a null period boundary maps to null and is filtered out") + void nullPeriod_mapsToNull_filteredOut() throws Exception { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("current_period_start")).thenReturn(0L); + when(rs.getLong("current_period_end")).thenReturn(0L); + // start present, end null → returns null from the mapper. + when(rs.wasNull()).thenReturn(false, true); + + when(jdbcTemplate.query(anyString(), any(RowMapper.class), eq("sub_2"))) + .thenAnswer( + inv -> { + RowMapper m = inv.getArgument(1); + java.util.List rows = + new java.util.ArrayList<>(); + rows.add(m.mapRow(rs, 0)); + return rows; + }); + + assertThat(dao().findBilling("sub_2")).isEmpty(); + } + + @Test + @SuppressWarnings("unchecked") + @DisplayName("DataAccessException (missing schema) degrades to empty") + void dataAccessException_degradesToEmpty() { + when(jdbcTemplate.query(anyString(), any(RowMapper.class), eq("sub_3"))) + .thenThrow(new EmptyResultDataAccessException(1)); + + assertThat(dao().findBilling("sub_3")).isEmpty(); + } + + @Test + @SuppressWarnings("unchecked") + @DisplayName("passes the subscription id through as the bind parameter") + void bindsSubscriptionId() { + when(jdbcTemplate.query(anyString(), any(RowMapper.class), eq("sub_bind"))) + .thenReturn(List.of()); + + assertThat(dao().findBilling("sub_bind")).isEmpty(); + + // The subscription id is passed as the (single) bind parameter. + org.mockito.Mockito.verify(jdbcTemplate) + .query(anyString(), any(RowMapper.class), eq("sub_bind")); + } + } + + @Nested + @DisplayName("findRateByLookupKey") + class FindRateByLookupKey { + + @Test + @DisplayName("returns empty for blank lookupKey or currency without touching the DB") + void blankArgs_shortCircuit() { + assertThat(dao().findRateByLookupKey(null, "usd")).isEmpty(); + assertThat(dao().findRateByLookupKey(" ", "usd")).isEmpty(); + assertThat(dao().findRateByLookupKey("plan:processor", null)).isEmpty(); + assertThat(dao().findRateByLookupKey("plan:processor", " ")).isEmpty(); + verifyNoInteractions(jdbcTemplate); + } + + @Test + @SuppressWarnings("unchecked") + @DisplayName("maps a usable rate row and lower-cases the currency bind param") + void mapsRow_lowerCasesCurrency() throws Exception { + ResultSet rs = mock(ResultSet.class); + when(rs.getString("unit_amount_decimal")).thenReturn(null); + when(rs.getLong("unit_amount")).thenReturn(99L); + when(rs.wasNull()).thenReturn(false); + when(rs.getString("price_id")).thenReturn("price_x"); + when(rs.getString("currency")).thenReturn("usd"); + + // Two bind params: lookupKey and the lower-cased currency. Match each vararg element. + when(jdbcTemplate.query( + anyString(), any(RowMapper.class), eq("plan:processor"), eq("usd"))) + .thenAnswer( + inv -> { + RowMapper m = inv.getArgument(1); + return List.of(m.mapRow(rs, 0)); + }); + + Optional out = dao().findRateByLookupKey("plan:processor", "USD"); + + assertThat(out).isPresent(); + assertThat(out.get().priceId()).isEqualTo("price_x"); + assertThat(out.get().currency()).isEqualTo("usd"); + assertThat(out.get().perDocMinor()).isEqualByComparingTo("99"); + + // Verifies the "USD" input was lower-cased to "usd" before binding. + org.mockito.Mockito.verify(jdbcTemplate) + .query(anyString(), any(RowMapper.class), eq("plan:processor"), eq("usd")); + } + + @Test + @SuppressWarnings("unchecked") + @DisplayName("row with no usable amount maps to null and yields empty") + void unusableRate_filteredOut() throws Exception { + ResultSet rs = mock(ResultSet.class); + when(rs.getString("unit_amount_decimal")).thenReturn(null); + when(rs.getLong("unit_amount")).thenReturn(0L); + when(rs.wasNull()).thenReturn(true); // unit_amount is SQL NULL → rate null + + when(jdbcTemplate.query( + anyString(), any(RowMapper.class), eq("plan:processor"), eq("usd"))) + .thenAnswer( + inv -> { + RowMapper m = inv.getArgument(1); + java.util.List rows = new java.util.ArrayList<>(); + rows.add(m.mapRow(rs, 0)); + return rows; + }); + + assertThat(dao().findRateByLookupKey("plan:processor", "usd")).isEmpty(); + } + + @Test + @SuppressWarnings("unchecked") + @DisplayName("DataAccessException degrades to empty") + void dataAccessException_degradesToEmpty() { + when(jdbcTemplate.query( + anyString(), any(RowMapper.class), eq("plan:processor"), eq("usd"))) + .thenThrow(new EmptyResultDataAccessException(1)); + + assertThat(dao().findRateByLookupKey("plan:processor", "usd")).isEmpty(); + } + } + + @Nested + @DisplayName("extractRate branches via the billing mapper") + class ExtractRate { + + @Test + @SuppressWarnings("unchecked") + @DisplayName("blank decimal then valid integer unit_amount yields integer rate") + void blankDecimal_fallsBackToInteger() throws Exception { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("current_period_start")).thenReturn(1L); + when(rs.getLong("current_period_end")).thenReturn(2L); + when(rs.getString("price_id")).thenReturn("p"); + when(rs.getString("status")).thenReturn("active"); + when(rs.getString("currency")).thenReturn("usd"); + when(rs.getString("unit_amount_decimal")).thenReturn(" "); // blank → ignored + when(rs.getLong("unit_amount")).thenReturn(42L); + // wasNull order: start(false), end(false), unit_amount(false). + when(rs.wasNull()).thenReturn(false, false, false); + + when(jdbcTemplate.query(anyString(), any(RowMapper.class), eq("s"))) + .thenAnswer( + inv -> { + RowMapper m = inv.getArgument(1); + return List.of(m.mapRow(rs, 0)); + }); + + assertThat(dao().findBilling("s").orElseThrow().perDocMinor()) + .isEqualByComparingTo("42"); + } + + @Test + @SuppressWarnings("unchecked") + @DisplayName("unparseable decimal falls back to integer unit_amount") + void unparseableDecimal_fallsBackToInteger() throws Exception { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("current_period_start")).thenReturn(1L); + when(rs.getLong("current_period_end")).thenReturn(2L); + when(rs.getString("price_id")).thenReturn("p"); + when(rs.getString("status")).thenReturn("active"); + when(rs.getString("currency")).thenReturn("usd"); + when(rs.getString("unit_amount_decimal")).thenReturn("not-a-number"); + when(rs.getLong("unit_amount")).thenReturn(7L); + when(rs.wasNull()).thenReturn(false, false, false); + + when(jdbcTemplate.query(anyString(), any(RowMapper.class), eq("s"))) + .thenAnswer( + inv -> { + RowMapper m = inv.getArgument(1); + return List.of(m.mapRow(rs, 0)); + }); + + assertThat(dao().findBilling("s").orElseThrow().perDocMinor()) + .isEqualByComparingTo("7"); + } + + @Test + @SuppressWarnings("unchecked") + @DisplayName("zero / negative rate is normalised to null") + void nonPositiveRate_isNull() throws Exception { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("current_period_start")).thenReturn(1L); + when(rs.getLong("current_period_end")).thenReturn(2L); + when(rs.getString("price_id")).thenReturn("p"); + when(rs.getString("status")).thenReturn("active"); + when(rs.getString("currency")).thenReturn("usd"); + when(rs.getString("unit_amount_decimal")).thenReturn("-3"); + when(rs.wasNull()).thenReturn(false, false); + + when(jdbcTemplate.query(anyString(), any(RowMapper.class), eq("s"))) + .thenAnswer( + inv -> { + RowMapper m = inv.getArgument(1); + return List.of(m.mapRow(rs, 0)); + }); + + assertThat(dao().findBilling("s").orElseThrow().perDocMinor()).isNull(); + } + + @Test + @SuppressWarnings("unchecked") + @DisplayName("null decimal and null unit_amount yields a null rate") + void allNull_yieldsNullRate() throws Exception { + ResultSet rs = mock(ResultSet.class); + when(rs.getLong("current_period_start")).thenReturn(1L); + when(rs.getLong("current_period_end")).thenReturn(2L); + when(rs.getString("price_id")).thenReturn("p"); + when(rs.getString("status")).thenReturn("active"); + when(rs.getString("currency")).thenReturn("usd"); + when(rs.getString("unit_amount_decimal")).thenReturn(null); + when(rs.getLong("unit_amount")).thenReturn(0L); + // start(false), end(false), unit_amount(true → SQL NULL). + when(rs.wasNull()).thenReturn(false, false, true); + + when(jdbcTemplate.query(anyString(), any(RowMapper.class), eq("s"))) + .thenAnswer( + inv -> { + RowMapper m = inv.getArgument(1); + return List.of(m.mapRow(rs, 0)); + }); + + assertThat(dao().findBilling("s").orElseThrow().perDocMinor()).isNull(); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/test/PaygCucumberThrowControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/test/PaygCucumberThrowControllerTest.java new file mode 100644 index 0000000000..d629ad911c --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/test/PaygCucumberThrowControllerTest.java @@ -0,0 +1,64 @@ +package stirling.software.saas.payg.test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import io.swagger.v3.oas.annotations.Hidden; + +import stirling.software.common.annotations.AutoJobPostMapping; + +/** + * Tests for the cucumber-only force-500 stub {@link PaygCucumberThrowController}. The endpoint must + * always throw so the PAYG refund path is exercised end-to-end; both the null-file and present-file + * logging branches are driven, and the mapping metadata is locked down. + */ +class PaygCucumberThrowControllerTest { + + private final PaygCucumberThrowController controller = new PaygCucumberThrowController(); + + @Test + @DisplayName("always throws IllegalStateException when given a file") + void throws_withFile() { + MultipartFile file = + new MockMultipartFile("fileInput", "in.pdf", "application/pdf", new byte[] {1, 2}); + assertThatThrownBy(() -> controller.throw500(file)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("PAYG cucumber forced 500"); + } + + @Test + @DisplayName("always throws IllegalStateException when the file is null") + void throws_withNullFile() { + assertThatThrownBy(() -> controller.throw500(null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("PAYG cucumber forced 500"); + } + + @Test + @DisplayName("declares ResponseEntity so the advice's 500 reaches the wire") + void returnTypeIsResponseEntity() throws NoSuchMethodException { + Method m = PaygCucumberThrowController.class.getMethod("throw500", MultipartFile.class); + assertThat(m.getReturnType()).isEqualTo(ResponseEntity.class); + } + + @Test + @DisplayName("is @Hidden and uses AutoJobPostMapping consuming multipart/form-data") + void mappingMetadata() throws NoSuchMethodException { + assertThat(PaygCucumberThrowController.class.isAnnotationPresent(Hidden.class)).isTrue(); + + Method m = PaygCucumberThrowController.class.getMethod("throw500", MultipartFile.class); + AutoJobPostMapping mapping = m.getAnnotation(AutoJobPostMapping.class); + assertThat(mapping).isNotNull(); + assertThat(mapping.value()).containsExactly("/throw-500"); + assertThat(mapping.consumes()).contains(MediaType.MULTIPART_FORM_DATA_VALUE); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/security/EnhancedJwtAuthenticationTokenTest.java b/app/saas/src/test/java/stirling/software/saas/security/EnhancedJwtAuthenticationTokenTest.java new file mode 100644 index 0000000000..e1c8905105 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/security/EnhancedJwtAuthenticationTokenTest.java @@ -0,0 +1,95 @@ +package stirling.software.saas.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +import stirling.software.proprietary.security.model.User; + +/** Unit tests for {@link EnhancedJwtAuthenticationToken}. */ +class EnhancedJwtAuthenticationTokenTest { + + private static Jwt sampleJwt(String subject) { + return new Jwt( + "tok", + Instant.now(), + Instant.now().plusSeconds(60), + Map.of("alg", "HS256"), + Map.of("sub", subject)); + } + + @Test + @DisplayName("four-arg constructor leaves user null so principal falls back to the Jwt") + void fourArgConstructorPrincipalIsJwt() { + Jwt jwt = sampleJwt(UUID.randomUUID().toString()); + EnhancedJwtAuthenticationToken token = + new EnhancedJwtAuthenticationToken( + jwt, + List.of(new SimpleGrantedAuthority("ROLE_USER")), + "alice@example.com", + "sub-123"); + + assertThat(token.getPrincipal()).isSameAs(jwt); + assertThat(token.getEmail()).isEqualTo("alice@example.com"); + assertThat(token.getSupabaseId()).isEqualTo("sub-123"); + } + + @Test + @DisplayName("five-arg constructor with a user exposes that user as principal") + void fiveArgConstructorPrincipalIsUser() { + Jwt jwt = sampleJwt(UUID.randomUUID().toString()); + User user = new User(); + user.setUsername("bob@example.com"); + EnhancedJwtAuthenticationToken token = + new EnhancedJwtAuthenticationToken( + jwt, + List.of(new SimpleGrantedAuthority("ROLE_USER")), + "bob@example.com", + "sub-456", + user); + + assertThat(token.getPrincipal()).isSameAs(user); + assertThat(token.getEmail()).isEqualTo("bob@example.com"); + assertThat(token.getSupabaseId()).isEqualTo("sub-456"); + } + + @Test + @DisplayName("five-arg constructor with null user falls back to the Jwt principal") + void fiveArgConstructorNullUserFallsBackToJwt() { + Jwt jwt = sampleJwt(UUID.randomUUID().toString()); + EnhancedJwtAuthenticationToken token = + new EnhancedJwtAuthenticationToken( + jwt, List.of(new SimpleGrantedAuthority("ROLE_USER")), null, null, null); + + assertThat(token.getPrincipal()).isSameAs(jwt); + assertThat(token.getEmail()).isNull(); + assertThat(token.getSupabaseId()).isNull(); + } + + @Test + @DisplayName("toString includes email, supabaseId, and authorities") + void toStringContainsKeyFields() { + Jwt jwt = sampleJwt(UUID.randomUUID().toString()); + EnhancedJwtAuthenticationToken token = + new EnhancedJwtAuthenticationToken( + jwt, + List.of(new SimpleGrantedAuthority("ROLE_USER")), + "carol@example.com", + "sub-789"); + + String text = token.toString(); + assertThat(text) + .contains("EnhancedJwtAuthenticationToken") + .contains("email=carol@example.com") + .contains("supabaseId=sub-789") + .contains("ROLE_USER"); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java new file mode 100644 index 0000000000..448bafae6b --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseAuthenticationFilterMoreTest.java @@ -0,0 +1,572 @@ +package stirling.software.saas.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; + +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.AuthenticationType; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.TeamService; +import stirling.software.proprietary.security.service.UserService; +import stirling.software.saas.model.SupabaseUser; +import stirling.software.saas.model.exception.UserNotFoundException; +import stirling.software.saas.service.SaasTeamService; +import stirling.software.saas.service.SupabaseUserService; + +/** + * Additional branch coverage for {@link SupabaseAuthenticationFilter}: public-auth and + * already-authenticated short-circuits, shouldNotFilter, anonymous user creation and upgrade, amr + * mapping, and the error paths in getOrCreateUser / createUser. + */ +@ExtendWith(MockitoExtension.class) +class SupabaseAuthenticationFilterMoreTest { + + @Mock private TeamService teamService; + @Mock private UserService userService; + @Mock private SupabaseUserService supabaseUserService; + @Mock private SaasTeamService saasTeamService; + @Mock private JwtDecoder jwtDecoder; + + private SupabaseAuthenticationFilter filter; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + private MockFilterChain chain; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + filter = + new SupabaseAuthenticationFilter( + teamService, userService, supabaseUserService, saasTeamService, jwtDecoder); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + chain = new MockFilterChain(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + // -------- helpers -------- + + private User newUser(String username) { + User u = new User(); + u.setUsername(username); + u.setRoleName("ROLE_USER"); + u.setAuthorities(new HashSet<>()); + return u; + } + + private SupabaseUser supabaseUser(UUID id, String email, boolean anonymous) { + SupabaseUser u = new SupabaseUser(); + u.setId(id); + u.setEmail(email); + u.setAnonymous(anonymous); + return u; + } + + /** Full-claims JWT for a non-anonymous user with the given provider. */ + private Jwt fullJwt(UUID supabaseId, String email, boolean anonymous, String provider) { + Map claims = new HashMap<>(); + claims.put("iss", "https://example.supabase.co/auth/v1"); + claims.put("sub", supabaseId.toString()); + claims.put("aud", List.of("authenticated")); + claims.put("exp", Instant.now().plusSeconds(3600).getEpochSecond()); + claims.put("iat", Instant.now().getEpochSecond()); + claims.put("role", "authenticated"); + claims.put("aal", "aal1"); + claims.put("session_id", "sess-" + supabaseId); + claims.put("is_anonymous", anonymous); + if (!anonymous) { + claims.put("email", email); + if (provider != null) { + claims.put("app_metadata", Map.of("provider", provider)); + } + } + return new Jwt( + "token", + Instant.now(), + Instant.now().plusSeconds(3600), + Map.of("alg", "HS256"), + claims); + } + + private void bearer(String token) { + request.setRequestURI("/api/v1/something"); + request.setMethod("POST"); + request.addHeader("Authorization", "Bearer " + token); + } + + @Nested + @DisplayName("doFilterInternal short-circuits") + class ShortCircuits { + + @Test + @DisplayName("public auth endpoint passes through without decoding") + void publicAuthEndpointPassesThrough() throws Exception { + request.setRequestURI("/api/v1/auth/login"); + request.setMethod("POST"); + request.addHeader("Authorization", "Bearer whatever"); + + filter.doFilter(request, response, chain); + + assertThat(chain.getRequest()).isSameAs(request); + verify(jwtDecoder, never()).decode(any()); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("already-authenticated context passes through without decoding") + void alreadyAuthenticatedPassesThrough() throws Exception { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken("someone", null, List.of())); + request.setRequestURI("/api/v1/something"); + request.setMethod("POST"); + request.addHeader("Authorization", "Bearer whatever"); + + filter.doFilter(request, response, chain); + + assertThat(chain.getRequest()).isSameAs(request); + verify(jwtDecoder, never()).decode(any()); + } + + @Test + @DisplayName("null Authorization header with no api key passes through") + void nullAuthHeaderPassesThrough() throws Exception { + request.setRequestURI("/api/v1/something"); + request.setMethod("POST"); + + filter.doFilter(request, response, chain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(jwtDecoder, never()).decode(any()); + } + + @Test + @DisplayName("non-Bearer Authorization header is ignored by JWT path") + void nonBearerHeaderIgnored() throws Exception { + request.setRequestURI("/api/v1/something"); + request.setMethod("POST"); + request.addHeader("Authorization", "Basic dXNlcjpwYXNz"); + + filter.doFilter(request, response, chain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(jwtDecoder, never()).decode(any()); + } + } + + @Nested + @DisplayName("shouldNotFilter") + class ShouldNotFilter { + + @Test + @DisplayName("GET static resource is skipped") + void getStaticResourceSkipped() { + request.setMethod("GET"); + request.setRequestURI("/css/app.css"); + assertThat(filter.shouldNotFilter(request)).isTrue(); + } + + @Test + @DisplayName("GET frontend route is skipped") + void getFrontendRouteSkipped() { + request.setMethod("GET"); + request.setRequestURI("/dashboard"); + assertThat(filter.shouldNotFilter(request)).isTrue(); + } + + @Test + @DisplayName("public auth endpoint is skipped regardless of method") + void publicAuthEndpointSkipped() { + request.setMethod("POST"); + request.setRequestURI("/api/v1/auth/login"); + assertThat(filter.shouldNotFilter(request)).isTrue(); + } + + @Test + @DisplayName("POST to a protected api endpoint is filtered") + void postProtectedEndpointFiltered() { + request.setMethod("POST"); + request.setRequestURI("/api/v1/something"); + assertThat(filter.shouldNotFilter(request)).isFalse(); + } + } + + @Nested + @DisplayName("apiKeyAuthenticated already-authenticated branch") + class ApiKeyShortCircuit { + + @Test + @DisplayName("returns true and skips lookup when an api key sets an authenticated context") + void apiKeyValidStillAuthenticates() throws Exception { + User user = newUser("alice"); + when(userService.getUserByApiKey("k1")).thenReturn(Optional.of(user)); + + request.setRequestURI("/api/v1/something"); + request.setMethod("POST"); + request.addHeader("X-API-KEY", "k1"); + + filter.doFilter(request, response, chain); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); + verify(userService).trackApiKeyFirstUse(user); + verify(jwtDecoder, never()).decode(any()); + } + } + + @Nested + @DisplayName("anonymous user flows") + class AnonymousFlows { + + @Test + @DisplayName("new anonymous user is created with the anon_ email and LIMITED_API_USER role") + void newAnonymousUserCreated() throws Exception { + UUID supabaseId = UUID.randomUUID(); + Jwt jwt = fullJwt(supabaseId, null, true, null); + when(jwtDecoder.decode("tok")).thenReturn(jwt); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, null, true)); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); + when(userService.saveUser(any(User.class))) + .thenAnswer( + inv -> { + User u = inv.getArgument(0); + assertThat(u.getUsername()) + .startsWith(SupabaseAuthenticationFilter.ANON_PREFIX); + assertThat(u.getAuthenticationType()) + .isEqualToIgnoringCase(AuthenticationType.ANONYMOUS.name()); + return u; + }); + + bearer("tok"); + filter.doFilter(request, response, chain); + + verify(userService, times(1)).saveUser(any(User.class)); + // Anonymous mirror row created with null email and anon flag true. + verify(supabaseUserService).createSupabaseUser(supabaseId, null, true); + assertThat(SecurityContextHolder.getContext().getAuthentication()) + .isInstanceOf(EnhancedJwtAuthenticationToken.class); + // Anonymous sessions keep the raw Jwt principal, not a User. + assertThat(SecurityContextHolder.getContext().getAuthentication().getPrincipal()) + .isSameAs(jwt); + } + + @Test + @DisplayName("anonymous local user is upgraded once the Supabase row is non-anonymous") + void anonymousUserUpgradedToWeb() throws Exception { + UUID supabaseId = UUID.randomUUID(); + // amr password -> WEB upgrade type. + Jwt jwt = withAmr(fullJwt(supabaseId, "real@example.com", false, "email"), "password"); + when(jwtDecoder.decode("tok")).thenReturn(jwt); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, "real@example.com", false)); + + User local = newUser("anon_old"); + local.setSupabaseId(supabaseId); + local.setAuthenticationType(AuthenticationType.ANONYMOUS); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); + when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + when(saasTeamService.ensurePersonalTeam(any(User.class))).thenReturn(new Team()); + + bearer("tok"); + filter.doFilter(request, response, chain); + + verify(userService).saveUser(any(User.class)); + verify(saasTeamService).ensurePersonalTeam(any(User.class)); + assertThat(local.getEmail()).isEqualTo("real@example.com"); + assertThat(local.getUsername()).isEqualTo("real@example.com"); + assertThat(local.getAuthenticationType()) + .isEqualToIgnoringCase(AuthenticationType.WEB.name()); + } + + @Test + @DisplayName("oauth amr upgrades anonymous user to OAUTH2") + void anonymousUpgradeOauthAmr() throws Exception { + UUID supabaseId = UUID.randomUUID(); + Jwt jwt = withAmr(fullJwt(supabaseId, "oauth@example.com", false, "google"), "oauth"); + when(jwtDecoder.decode("tok")).thenReturn(jwt); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, "oauth@example.com", false)); + + User local = newUser("anon_old"); + local.setSupabaseId(supabaseId); + local.setAuthenticationType(AuthenticationType.ANONYMOUS); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); + when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + when(saasTeamService.ensurePersonalTeam(any(User.class))).thenReturn(new Team()); + + bearer("tok"); + filter.doFilter(request, response, chain); + + assertThat(local.getAuthenticationType()) + .isEqualToIgnoringCase(AuthenticationType.OAUTH2.name()); + } + + @Test + @DisplayName("email collision while upgrading anonymous user yields 401") + void anonymousUpgradeEmailCollision() throws Exception { + UUID supabaseId = UUID.randomUUID(); + Jwt jwt = fullJwt(supabaseId, "dupe@example.com", false, "email"); + when(jwtDecoder.decode("tok")).thenReturn(jwt); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, "dupe@example.com", false)); + + User local = newUser("anon_old"); + local.setSupabaseId(supabaseId); + local.setAuthenticationType(AuthenticationType.ANONYMOUS); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(local)); + when(userService.saveUser(any(User.class))) + .thenThrow(new DataIntegrityViolationException("email exists")); + + bearer("tok"); + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + private Jwt withAmr(Jwt base, String method) { + Map claims = new HashMap<>(base.getClaims()); + claims.put("amr", List.of(Map.of("method", method, "timestamp", 1L))); + return new Jwt( + base.getTokenValue(), + base.getIssuedAt(), + base.getExpiresAt(), + base.getHeaders(), + claims); + } + } + + @Nested + @DisplayName("getOrCreateUser error paths") + class GetOrCreateErrors { + + @Test + @DisplayName("UserNotFoundException from the mirror lookup yields 401") + void userNotFoundYields401() throws Exception { + UUID supabaseId = UUID.randomUUID(); + Jwt jwt = fullJwt(supabaseId, "ghost@example.com", false, "email"); + when(jwtDecoder.decode("tok")).thenReturn(jwt); + when(supabaseUserService.getUser(supabaseId)) + .thenThrow(new UserNotFoundException("missing")); + + bearer("tok"); + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("unexpected runtime error is wrapped as an auth failure (401)") + void unexpectedErrorYields401() throws Exception { + UUID supabaseId = UUID.randomUUID(); + Jwt jwt = fullJwt(supabaseId, "boom@example.com", false, "email"); + when(jwtDecoder.decode("tok")).thenReturn(jwt); + when(supabaseUserService.getUser(supabaseId)) + .thenThrow(new IllegalStateException("db down")); + + bearer("tok"); + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("non-UUID subject propagates IllegalArgumentException from UUID.fromString") + void nonUuidSubjectThrows() throws Exception { + Map claims = new HashMap<>(); + claims.put("iss", "https://example.supabase.co/auth/v1"); + claims.put("sub", "not-a-uuid"); + claims.put("aud", List.of("authenticated")); + claims.put("exp", Instant.now().plusSeconds(3600).getEpochSecond()); + claims.put("iat", Instant.now().getEpochSecond()); + claims.put("role", "authenticated"); + claims.put("aal", "aal1"); + claims.put("session_id", "sess"); + claims.put("is_anonymous", false); + claims.put("email", "x@example.com"); + claims.put("app_metadata", Map.of("provider", "email")); + Jwt jwt = + new Jwt( + "tok", + Instant.now(), + Instant.now().plusSeconds(3600), + Map.of("alg", "HS256"), + claims); + when(jwtDecoder.decode("tok")).thenReturn(jwt); + + bearer("tok"); + // UUID.fromString on a malformed subject is not caught by the JwtException handler. + assertThatThrownBy(() -> filter.doFilter(request, response, chain)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid UUID string"); + } + } + + @Nested + @DisplayName("createUser validation and race handling") + class CreateUserPaths { + + @Test + @DisplayName("missing provider for a non-anonymous user yields 401") + void missingProviderYields401() throws Exception { + UUID supabaseId = UUID.randomUUID(); + // provider null -> no app_metadata claim at all. + Jwt jwt = fullJwt(supabaseId, "noprov@example.com", false, null); + when(jwtDecoder.decode("tok")).thenReturn(jwt); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, "noprov@example.com", false)); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); + + bearer("tok"); + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(401); + verify(userService, never()).saveUser(any()); + } + + @Test + @DisplayName("createSupabaseUser DataIntegrityViolation is swallowed; user still created") + void createSupabaseUserConflictIgnored() throws Exception { + UUID supabaseId = UUID.randomUUID(); + Jwt jwt = fullJwt(supabaseId, "race@example.com", false, "email"); + when(jwtDecoder.decode("tok")).thenReturn(jwt); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, "race@example.com", false)); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); + org.mockito.Mockito.doThrow(new DataIntegrityViolationException("dup")) + .when(supabaseUserService) + .createSupabaseUser(eq(supabaseId), any(), eq(false)); + when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + + bearer("tok"); + filter.doFilter(request, response, chain); + + verify(userService, times(1)).saveUser(any(User.class)); + assertThat(SecurityContextHolder.getContext().getAuthentication()) + .isInstanceOf(EnhancedJwtAuthenticationToken.class); + } + + @Test + @DisplayName("createSupabaseUser unexpected error yields 401") + void createSupabaseUserUnexpectedErrorYields401() throws Exception { + UUID supabaseId = UUID.randomUUID(); + Jwt jwt = fullJwt(supabaseId, "fail@example.com", false, "email"); + when(jwtDecoder.decode("tok")).thenReturn(jwt); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, "fail@example.com", false)); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); + org.mockito.Mockito.doThrow(new IllegalStateException("mirror down")) + .when(supabaseUserService) + .createSupabaseUser(eq(supabaseId), any(), eq(false)); + + bearer("tok"); + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(401); + verify(userService, never()).saveUser(any()); + } + + @Test + @DisplayName("saveUser race: loser fetches the winning row instead of creating") + void saveUserRaceFetchesWinner() throws Exception { + UUID supabaseId = UUID.randomUUID(); + Jwt jwt = fullJwt(supabaseId, "winner@example.com", false, "email"); + when(jwtDecoder.decode("tok")).thenReturn(jwt); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, "winner@example.com", false)); + + User winner = newUser("winner@example.com"); + winner.setSupabaseId(supabaseId); + when(userService.findBySupabaseId(supabaseId)) + .thenReturn(Optional.empty()) + .thenReturn(Optional.of(winner)); + when(userService.saveUser(any(User.class))) + .thenThrow(new DataIntegrityViolationException("dup user")); + + bearer("tok"); + filter.doFilter(request, response, chain); + + // Race loser does not run first-time init (ensurePersonalTeam). + verify(saasTeamService, never()).ensurePersonalTeam(any()); + assertThat(SecurityContextHolder.getContext().getAuthentication()) + .isInstanceOf(EnhancedJwtAuthenticationToken.class); + } + + @Test + @DisplayName("saveUser race with no winning row found yields 401") + void saveUserRaceNoWinnerYields401() throws Exception { + UUID supabaseId = UUID.randomUUID(); + Jwt jwt = fullJwt(supabaseId, "lost@example.com", false, "email"); + when(jwtDecoder.decode("tok")).thenReturn(jwt); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, "lost@example.com", false)); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); + when(userService.saveUser(any(User.class))) + .thenThrow(new DataIntegrityViolationException("dup user")); + + bearer("tok"); + filter.doFilter(request, response, chain); + + assertThat(response.getStatus()).isEqualTo(401); + } + + @Test + @DisplayName("personal team creation failure for a new user is swallowed") + void personalTeamFailureSwallowed() throws Exception { + UUID supabaseId = UUID.randomUUID(); + Jwt jwt = fullJwt(supabaseId, "team@example.com", false, "email"); + when(jwtDecoder.decode("tok")).thenReturn(jwt); + when(supabaseUserService.getUser(supabaseId)) + .thenReturn(supabaseUser(supabaseId, "team@example.com", false)); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); + when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + when(saasTeamService.ensurePersonalTeam(any(User.class))) + .thenThrow(new IllegalStateException("team boom")); + + bearer("tok"); + filter.doFilter(request, response, chain); + + // Auth still succeeds even though team creation failed. + assertThat(SecurityContextHolder.getContext().getAuthentication()) + .isInstanceOf(EnhancedJwtAuthenticationToken.class); + verify(userService, times(1)).saveUser(any(User.class)); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java new file mode 100644 index 0000000000..1aa9e783b6 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/security/SupabaseSecurityConfigMoreTest.java @@ -0,0 +1,285 @@ +package stirling.software.saas.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.service.TeamService; +import stirling.software.proprietary.security.service.UserService; +import stirling.software.saas.service.SaasTeamService; +import stirling.software.saas.service.SupabaseUserService; + +/** + * Additional branch coverage for {@link SupabaseSecurityConfig}: validateIssuer, jwtDecoder + * fail-closed and happy paths, corsConfigurationSource defaults vs operator override, and the + * security filter chain bean wiring. + */ +@ExtendWith(MockitoExtension.class) +class SupabaseSecurityConfigMoreTest { + + @Mock private UserService userService; + @Mock private TeamService teamService; + @Mock private SupabaseUserService supabaseUserService; + @Mock private SaasTeamService saasTeamService; + + private SupabaseSecurityConfig config(ApplicationProperties props) { + return new SupabaseSecurityConfig( + userService, teamService, supabaseUserService, saasTeamService, props); + } + + @Nested + @DisplayName("validateIssuer") + class ValidateIssuer { + + @Test + @DisplayName("null issuer reports unset") + void nullIssuer() { + assertThat(SupabaseSecurityConfig.validateIssuer(null)).contains("is not set"); + } + + @Test + @DisplayName("blank issuer reports unset") + void blankIssuer() { + assertThat(SupabaseSecurityConfig.validateIssuer(" ")).contains("is not set"); + } + + @Test + @DisplayName("invalid URI is rejected") + void invalidUri() { + assertThat(SupabaseSecurityConfig.validateIssuer("ht tp://bad uri")) + .contains("not a valid URI"); + } + + @Test + @DisplayName("empty host is rejected (project ref unset)") + void emptyHost() { + // No authority component -> host is null. + assertThat(SupabaseSecurityConfig.validateIssuer("https:///auth/v1")) + .contains("empty host"); + } + + @Test + @DisplayName("host starting with a dot is rejected") + void dottedHost() { + assertThat(SupabaseSecurityConfig.validateIssuer("https://.supabase.co/auth/v1")) + .contains("empty host"); + } + + @Test + @DisplayName("non-http(s) scheme is rejected") + void nonHttpScheme() { + assertThat(SupabaseSecurityConfig.validateIssuer("ftp://host/auth/v1")) + .contains("must be http(s)"); + } + + @Test + @DisplayName("valid https issuer returns null") + void validHttps() { + assertThat(SupabaseSecurityConfig.validateIssuer("https://proj.supabase.co/auth/v1")) + .isNull(); + } + + @Test + @DisplayName("valid http issuer returns null") + void validHttp() { + assertThat(SupabaseSecurityConfig.validateIssuer("http://localhost:9999/auth/v1")) + .isNull(); + } + } + + @Nested + @DisplayName("jwtDecoder bean") + class JwtDecoderBean { + + @Test + @DisplayName("fail-closed decoder rejects every token when issuer is unset") + void failClosedRejectsAllTokens() { + SupabaseSecurityConfig cfg = config(new ApplicationProperties()); + ReflectionTestUtils.setField(cfg, "issuer", ""); + ReflectionTestUtils.setField(cfg, "expectedAud", ""); + ReflectionTestUtils.setField(cfg, "clockSkewSeconds", 120L); + + JwtDecoder decoder = cfg.jwtDecoder(); + + assertThatThrownBy(() -> decoder.decode("anything")) + .isInstanceOf(JwtException.class) + .hasMessageContaining("is not set"); + } + + @Test + @DisplayName("valid issuer builds a real Nimbus decoder (aud disabled branch)") + void validIssuerBuildsDecoderNoAud() { + SupabaseSecurityConfig cfg = config(new ApplicationProperties()); + ReflectionTestUtils.setField(cfg, "issuer", "https://proj.supabase.co/auth/v1"); + ReflectionTestUtils.setField(cfg, "expectedAud", ""); + ReflectionTestUtils.setField(cfg, "clockSkewSeconds", 60L); + + JwtDecoder decoder = cfg.jwtDecoder(); + + // No JWKS fetch happens until a token is decoded, so simply building is enough. + assertThat(decoder).isNotNull(); + } + + @Test + @DisplayName("valid issuer with expected aud builds a decoder (aud enabled branch)") + void validIssuerBuildsDecoderWithAud() { + SupabaseSecurityConfig cfg = config(new ApplicationProperties()); + ReflectionTestUtils.setField(cfg, "issuer", "https://proj.supabase.co/auth/v1"); + ReflectionTestUtils.setField(cfg, "expectedAud", "authenticated"); + ReflectionTestUtils.setField(cfg, "clockSkewSeconds", 90L); + + JwtDecoder decoder = cfg.jwtDecoder(); + + assertThat(decoder).isNotNull(); + } + } + + @Nested + @DisplayName("corsConfigurationSource") + class Cors { + + private CorsConfiguration cors(CorsConfigurationSource source) { + UrlBasedCorsConfigurationSource ub = (UrlBasedCorsConfigurationSource) source; + return ub.getCorsConfigurations().get("/**"); + } + + @Test + @DisplayName( + "default origins include the shipped localhost + stirling hosts and credentials") + void defaultOriginsUsed() { + CorsConfigurationSource source = + config(new ApplicationProperties()).corsConfigurationSource(); + CorsConfiguration cfg = cors(source); + + assertThat(cfg.getAllowedOriginPatterns()) + .contains("https://stirling.com", "http://localhost:3000"); + assertThat(cfg.getAllowCredentials()).isTrue(); + assertThat(cfg.getMaxAge()).isEqualTo(3600L); + assertThat(cfg.getExposedHeaders()).contains("WWW-Authenticate"); + } + + @Test + @DisplayName("desktop tauri origins are always appended exactly once") + void desktopOriginsAppended() { + CorsConfigurationSource source = + config(new ApplicationProperties()).corsConfigurationSource(); + CorsConfiguration cfg = cors(source); + + assertThat(cfg.getAllowedOriginPatterns()) + .contains( + "tauri://localhost", + "http://tauri.localhost", + "https://tauri.localhost"); + assertThat(cfg.getAllowedOriginPatterns().stream().filter("tauri://localhost"::equals)) + .hasSize(1); + } + + @Test + @DisplayName("operator override replaces the default origin list") + void operatorOverrideUsed() { + ApplicationProperties props = new ApplicationProperties(); + props.getSystem().setCorsAllowedOrigins(List.of("https://custom.example.com")); + + CorsConfiguration cfg = cors(config(props).corsConfigurationSource()); + + assertThat(cfg.getAllowedOriginPatterns()) + .contains("https://custom.example.com") + // The shipped default hosts are not present when overridden. + .doesNotContain("https://stirling.com"); + } + + @Test + @DisplayName("operator override already containing a desktop origin is not duplicated") + void operatorOverrideWithDesktopOriginNotDuplicated() { + ApplicationProperties props = new ApplicationProperties(); + props.getSystem() + .setCorsAllowedOrigins( + List.of("https://custom.example.com", "tauri://localhost")); + + CorsConfiguration cfg = cors(config(props).corsConfigurationSource()); + + assertThat(cfg.getAllowedOriginPatterns().stream().filter("tauri://localhost"::equals)) + .hasSize(1); + } + + @Test + @DisplayName("wildcard origin in override still configures (warning branch)") + void wildcardOriginWarns() { + ApplicationProperties props = new ApplicationProperties(); + props.getSystem().setCorsAllowedOrigins(List.of("https://*.example.com")); + + CorsConfiguration cfg = cors(config(props).corsConfigurationSource()); + + assertThat(cfg.getAllowedOriginPatterns()).contains("https://*.example.com"); + } + } + + @Nested + @DisplayName("saasSecurityFilterChain bean") + class FilterChainBean { + + @Mock private JwtDecoder jwtDecoder; + + @Test + @DisplayName("builds and returns the SecurityFilterChain from http.build()") + void buildsFilterChain() throws Exception { + HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); + // http.build() returns DefaultSecurityFilterChain, so stub with that concrete type. + org.springframework.security.web.DefaultSecurityFilterChain built = + mock(org.springframework.security.web.DefaultSecurityFilterChain.class); + when(http.build()).thenReturn(built); + + SecurityFilterChain result = + config(new ApplicationProperties()).saasSecurityFilterChain(http, jwtDecoder); + + assertThat(result).isSameAs(built); + } + } + + @Nested + @DisplayName("toAuthentication anonymous role mapping (interplay with config fields)") + class ToAuthenticationAnon { + + @Test + @DisplayName("anonymous JWT maps to LIMITED_API_USER role") + void anonymousMapsLimited() { + Jwt jwt = + new Jwt( + "tok", + java.time.Instant.now(), + java.time.Instant.now().plusSeconds(60), + java.util.Map.of("alg", "HS256"), + java.util.Map.of( + "sub", + java.util.UUID.randomUUID().toString(), + "is_anonymous", + Boolean.TRUE)); + + var auth = SupabaseSecurityConfig.toAuthentication(jwt); + + assertThat(auth.getAuthorities().stream().map(a -> a.getAuthority()).toList()) + .contains("ROLE_LIMITED_API_USER"); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsMoreTest.java new file mode 100644 index 0000000000..580623c878 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsMoreTest.java @@ -0,0 +1,287 @@ +package stirling.software.saas.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.repository.TeamMembershipRepository; + +/** + * Additional branch coverage for {@link TeamSecurityExpressions}: the JWT resolution path, the + * username-string fallback, isTeamLeader / isTeamMember, and currentUserTeamId via JWT auth. + */ +@ExtendWith(MockitoExtension.class) +class TeamSecurityExpressionsMoreTest { + + @Mock private TeamMembershipRepository membershipRepository; + @Mock private UserService userService; + + private static final long TEAM_ID = 42L; + private static final long USER_ID = 7L; + + private TeamSecurityExpressions expressions() { + return new TeamSecurityExpressions(membershipRepository, userService); + } + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + private User userWithTeam(long userId, Long teamId) { + User user = new User(); + user.setId(userId); + if (teamId != null) { + Team team = new Team(); + team.setId(teamId); + user.setTeam(team); + } + return user; + } + + private TeamMembership membershipWithRole(TeamRole role) { + TeamMembership membership = new TeamMembership(); + membership.setRole(role); + return membership; + } + + /** Authenticate via the JWT path with the given supabase subject. */ + private void authenticateAsJwt(UUID supabaseId) { + Map claims = new HashMap<>(); + claims.put("sub", supabaseId.toString()); + Jwt jwt = + new Jwt( + "tok", + Instant.now(), + Instant.now().plusSeconds(60), + Map.of("alg", "HS256"), + claims); + SecurityContextHolder.getContext() + .setAuthentication( + new EnhancedJwtAuthenticationToken( + jwt, + List.of(new SimpleGrantedAuthority("ROLE_USER")), + "user@example.com", + supabaseId.toString())); + } + + @Nested + @DisplayName("isTeamLeader(teamId)") + class IsTeamLeader { + + @Test + @DisplayName("unauthenticated context returns false") + void unauthenticatedIsFalse() { + assertThat(expressions().isTeamLeader(TEAM_ID)).isFalse(); + } + + @Test + @DisplayName("leader membership returns true") + void leaderReturnsTrue() { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + userWithTeam(USER_ID, TEAM_ID), null, List.of())); + when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID)) + .thenReturn(Optional.of(membershipWithRole(TeamRole.LEADER))); + + assertThat(expressions().isTeamLeader(TEAM_ID)).isTrue(); + } + + @Test + @DisplayName("member (non-leader) membership returns false") + void memberReturnsFalse() { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + userWithTeam(USER_ID, TEAM_ID), null, List.of())); + when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID)) + .thenReturn(Optional.of(membershipWithRole(TeamRole.MEMBER))); + + assertThat(expressions().isTeamLeader(TEAM_ID)).isFalse(); + } + + @Test + @DisplayName("no membership returns false") + void noMembershipReturnsFalse() { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + userWithTeam(USER_ID, TEAM_ID), null, List.of())); + when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID)) + .thenReturn(Optional.empty()); + + assertThat(expressions().isTeamLeader(TEAM_ID)).isFalse(); + } + } + + @Nested + @DisplayName("isTeamMember(teamId)") + class IsTeamMember { + + @Test + @DisplayName("unauthenticated context returns false") + void unauthenticatedIsFalse() { + assertThat(expressions().isTeamMember(TEAM_ID)).isFalse(); + } + + @Test + @DisplayName("existing membership returns true") + void memberReturnsTrue() { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + userWithTeam(USER_ID, TEAM_ID), null, List.of())); + when(membershipRepository.existsByTeamIdAndUserId(TEAM_ID, USER_ID)).thenReturn(true); + + assertThat(expressions().isTeamMember(TEAM_ID)).isTrue(); + } + + @Test + @DisplayName("no membership returns false") + void nonMemberReturnsFalse() { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + userWithTeam(USER_ID, TEAM_ID), null, List.of())); + when(membershipRepository.existsByTeamIdAndUserId(TEAM_ID, USER_ID)).thenReturn(false); + + assertThat(expressions().isTeamMember(TEAM_ID)).isFalse(); + } + } + + @Nested + @DisplayName("getCurrentUser via JWT (EnhancedJwtAuthenticationToken)") + class JwtResolution { + + @Test + @DisplayName("resolves local user by supabase id and reports leadership") + void jwtResolvesUserAndLeads() { + UUID supabaseId = UUID.randomUUID(); + authenticateAsJwt(supabaseId); + User resolved = userWithTeam(USER_ID, TEAM_ID); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.of(resolved)); + when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID)) + .thenReturn(Optional.of(membershipWithRole(TeamRole.LEADER))); + + assertThat(expressions().isCurrentUserTeamLeader()).isTrue(); + assertThat(expressions().currentUserTeamId()).isEqualTo(TEAM_ID); + } + + @Test + @DisplayName("no local user for subject returns null user, so not a leader") + void jwtNoLocalUserIsNotLeader() { + UUID supabaseId = UUID.randomUUID(); + authenticateAsJwt(supabaseId); + when(userService.findBySupabaseId(supabaseId)).thenReturn(Optional.empty()); + + assertThat(expressions().isCurrentUserTeamLeader()).isFalse(); + assertThat(expressions().currentUserTeamId()).isNull(); + } + + @Test + @DisplayName("isTeamMember via JWT-resolved user") + void jwtResolvedUserMembership() { + UUID supabaseId = UUID.randomUUID(); + authenticateAsJwt(supabaseId); + when(userService.findBySupabaseId(supabaseId)) + .thenReturn(Optional.of(userWithTeam(USER_ID, TEAM_ID))); + when(membershipRepository.existsByTeamIdAndUserId(TEAM_ID, USER_ID)).thenReturn(true); + + assertThat(expressions().isTeamMember(TEAM_ID)).isTrue(); + } + } + + @Nested + @DisplayName("getCurrentUser via username-string principal") + class UsernameFallback { + + @Test + @DisplayName("string principal resolves user via findByUsername") + void stringPrincipalResolved() { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + "alice@example.com", + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")))); + when(userService.findByUsername("alice@example.com")) + .thenReturn(Optional.of(userWithTeam(USER_ID, TEAM_ID))); + + assertThat(expressions().currentUserTeamId()).isEqualTo(TEAM_ID); + } + + @Test + @DisplayName("string principal with no matching user returns null") + void stringPrincipalUnresolved() { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + "ghost@example.com", + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")))); + when(userService.findByUsername("ghost@example.com")).thenReturn(Optional.empty()); + + assertThat(expressions().currentUserTeamId()).isNull(); + assertThat(expressions().isTeamLeader(TEAM_ID)).isFalse(); + } + } + + @Nested + @DisplayName("getCurrentUser edge cases") + class EdgeCases { + + @Test + @DisplayName("unsupported principal type returns null user") + void unsupportedPrincipalReturnsNull() { + // Principal that is neither User nor String falls through to null. + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + Integer.valueOf(99), + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")))); + + assertThat(expressions().currentUserTeamId()).isNull(); + assertThat(expressions().isTeamMember(TEAM_ID)).isFalse(); + } + + @Test + @DisplayName("not-authenticated token short-circuits to null user") + void notAuthenticatedTokenReturnsNull() { + UsernamePasswordAuthenticationToken token = + new UsernamePasswordAuthenticationToken("x", "y"); + token.setAuthenticated(false); + SecurityContextHolder.getContext().setAuthentication(token); + lenient() + .when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID)) + .thenReturn(Optional.of(membershipWithRole(TeamRole.LEADER))); + + assertThat(expressions().isTeamLeader(TEAM_ID)).isFalse(); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/service/NoOpDatabaseServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/NoOpDatabaseServiceTest.java new file mode 100644 index 0000000000..795ae7544b --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/service/NoOpDatabaseServiceTest.java @@ -0,0 +1,61 @@ +package stirling.software.saas.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link NoOpDatabaseService}. + * + *

The saas profile manages Postgres externally, so every {@link + * stirling.software.proprietary.security.service.DatabaseServiceInterface} method is a safe no-op. + * These tests pin the no-op return values so callers can rely on them. + */ +class NoOpDatabaseServiceTest { + + private final NoOpDatabaseService service = new NoOpDatabaseService(); + + @Test + @DisplayName("exportDatabase is a no-op that does not throw") + void exportDatabase_noThrow() { + assertThatCode(service::exportDatabase).doesNotThrowAnyException(); + } + + @Test + @DisplayName("importDatabase is a no-op that does not throw") + void importDatabase_noThrow() { + assertThatCode(service::importDatabase).doesNotThrowAnyException(); + } + + @Test + @DisplayName("hasBackup is always false") + void hasBackup_false() { + assertThat(service.hasBackup()).isFalse(); + } + + @Test + @DisplayName("getBackupList returns an empty list") + void getBackupList_empty() { + assertThat(service.getBackupList()).isEmpty(); + } + + @Test + @DisplayName("deleteAllBackups returns an empty list") + void deleteAllBackups_empty() { + assertThat(service.deleteAllBackups()).isEmpty(); + } + + @Test + @DisplayName("deleteLastBackup returns an empty list") + void deleteLastBackup_empty() { + assertThat(service.deleteLastBackup()).isEmpty(); + } + + @Test + @DisplayName("getH2Version reports managed Postgres") + void getH2Version_managedPostgres() { + assertThat(service.getH2Version()).isEqualTo("N/A (managed Postgres)"); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/service/RateLimitServiceMoreTest.java b/app/saas/src/test/java/stirling/software/saas/service/RateLimitServiceMoreTest.java new file mode 100644 index 0000000000..1c755d02a8 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/service/RateLimitServiceMoreTest.java @@ -0,0 +1,106 @@ +package stirling.software.saas.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Branch-gap tests for {@link RateLimitService} that the deterministic-arithmetic suite cannot + * reach without manipulating the clock: the actual eviction of an expired bucket in {@code + * cleanupExpiredBuckets}, and the daily-cap rejection that rolls back the hourly counter. + * + *

These poke the two private {@code ConcurrentHashMap} buckets directly via reflection to seed + * an already-expired or near-cap bucket, since the live windows are a fixed 1h / 1d and never + * expire inside a test run. + */ +class RateLimitServiceMoreTest { + + private RateLimitService service; + + @BeforeEach + void setUp() { + service = new RateLimitService(); + } + + @SuppressWarnings("unchecked") + private Map mapField(String name) throws Exception { + Field f = RateLimitService.class.getDeclaredField(name); + f.setAccessible(true); + return (Map) f.get(service); + } + + private Object newBucket(int count, long resetTime) throws Exception { + Class bucketClass = + Class.forName("stirling.software.saas.service.RateLimitService$RateLimitBucket"); + Constructor ctor = bucketClass.getDeclaredConstructor(int.class, long.class); + ctor.setAccessible(true); + return ctor.newInstance(count, resetTime); + } + + private int bucketCount(Object bucket) throws Exception { + var m = bucket.getClass().getDeclaredMethod("getCount"); + m.setAccessible(true); + return (int) m.invoke(bucket); + } + + @Nested + @DisplayName("cleanupExpiredBuckets - eviction path") + class CleanupEviction { + + @Test + @DisplayName("evicts buckets whose reset time has already passed") + void evictsExpiredBuckets() throws Exception { + long past = System.currentTimeMillis() - 1000L; + mapField("hourlyLimits").put("team:1", newBucket(5, past)); + mapField("dailyLimits").put("team:1", newBucket(5, past)); + + service.cleanupExpiredBuckets(); + + assertThat(mapField("hourlyLimits")).doesNotContainKey("team:1"); + assertThat(mapField("dailyLimits")).doesNotContainKey("team:1"); + } + + @Test + @DisplayName("evicts only expired buckets, leaving fresh ones in place") + void evictsOnlyExpired() throws Exception { + long past = System.currentTimeMillis() - 1000L; + long future = System.currentTimeMillis() + 3_600_000L; + mapField("hourlyLimits").put("team:expired", newBucket(5, past)); + mapField("hourlyLimits").put("team:fresh", newBucket(5, future)); + + service.cleanupExpiredBuckets(); + + assertThat(mapField("hourlyLimits")) + .doesNotContainKey("team:expired") + .containsKey("team:fresh"); + } + } + + @Nested + @DisplayName("allowInvitation - daily cap with hourly rollback") + class DailyCapRollback { + + @Test + @DisplayName("rejects at the daily cap and rolls back the hourly counter it just bumped") + void dailyCapRejection_rollsBackHourly() throws Exception { + long future = System.currentTimeMillis() + 3_600_000L; + // Hourly fresh and well under cap; daily already at its 150 cap so the next bump trips. + mapField("hourlyLimits").put("team:99", newBucket(1, future)); + mapField("dailyLimits").put("team:99", newBucket(150, future)); + + boolean allowed = service.allowInvitation(99L); + + assertThat(allowed).isFalse(); + // Hourly was incremented to 2 by checkAndIncrement, then decremented back to 1. + Object hourly = mapField("hourlyLimits").get("team:99"); + assertThat(bucketCount(hourly)).isEqualTo(1); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java new file mode 100644 index 0000000000..31c4f4be5c --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java @@ -0,0 +1,1386 @@ +package stirling.software.saas.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +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.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.model.enumeration.InvitationStatus; +import stirling.software.common.model.enumeration.Role; +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.saas.billing.repository.BillingSubscriptionRepository; +import stirling.software.saas.config.SupabaseConfigurationProperties; +import stirling.software.saas.model.TeamInvitation; +import stirling.software.saas.model.TeamMembership; +import stirling.software.saas.repository.SaasTeamExtensionsRepository; +import stirling.software.saas.repository.TeamInvitationRepository; +import stirling.software.saas.repository.TeamMembershipRepository; + +/** + * Unit tests for {@link SaasTeamService}. + * + *

The service orchestrates SaaS team lifecycle: personal-team creation, invitations, + * accept/leave flows, seat caps and paid-subscription gating. Every collaborator is mocked, so the + * tests exercise the service's own branching - null guards, early returns, security/permission + * throws, seat-cap enforcement and subscription-gated role grants - with no DB or network. + */ +@ExtendWith(MockitoExtension.class) +class SaasTeamServiceTest { + + @Mock private TeamRepository teamRepository; + @Mock private TeamMembershipRepository membershipRepository; + @Mock private TeamInvitationRepository invitationRepository; + @Mock private UserRepository userRepository; + @Mock private BillingSubscriptionRepository billingSubscriptionRepository; + @Mock private org.springframework.web.client.RestTemplate restTemplate; + @Mock private RateLimitService rateLimitService; + @Mock private SupabaseConfigurationProperties supabaseConfig; + @Mock private UserRoleService userRoleService; + @Mock private SaasTeamExtensionService saasTeamExtensionService; + @Mock private SaasTeamExtensionsRepository saasTeamExtensionsRepository; + @Mock private stirling.software.proprietary.security.service.UserService userService; + + @InjectMocks private SaasTeamService service; + + private static final UUID SUPABASE_ID = UUID.fromString("11111111-2222-3333-4444-555555555555"); + + // ---- fixtures ------------------------------------------------------------------------------- + + private static Team team(Long id, String name) { + Team t = new Team(); + t.setId(id); + t.setName(name); + return t; + } + + private static User user(Long id, String email, String username) { + User u = new User(); + u.setId(id); + u.setEmail(email); + u.setUsername(username); + return u; + } + + private static User proUser(Long id, String email, String username) { + User u = user(id, email, username); + // Authority ctor self-registers on the user, so getRolesAsString() returns ROLE_PRO_USER. + new Authority(Role.PRO_USER.getRoleId(), u); + return u; + } + + private static TeamMembership membership(Team team, User user, TeamRole role) { + TeamMembership m = new TeamMembership(); + m.setTeam(team); + m.setUser(user); + m.setRole(role); + return m; + } + + // ============================================================================================= + @Nested + @DisplayName("ensurePersonalTeam") + class EnsurePersonalTeam { + + @Test + @DisplayName("returns the existing team when the user already has a personal one") + void existingPersonalTeam_returnedAsIs() { + User u = user(1L, "a@x.com", "alice"); + Team existing = team(10L, "My Team"); + u.setTeam(existing); + when(saasTeamExtensionService.isPersonal(existing)).thenReturn(true); + + Team result = service.ensurePersonalTeam(u); + + assertThat(result).isSameAs(existing); + // No new team is created. + verify(teamRepository, never()).save(any()); + } + + @Test + @DisplayName("creates a personal team when the user's team is non-personal") + void nonPersonalTeam_createsNew() { + User u = user(1L, "a@x.com", "alice"); + Team existing = team(10L, "Acme"); + u.setTeam(existing); + when(saasTeamExtensionService.isPersonal(existing)).thenReturn(false); + stubCreatePersonalTeam(u, 99L); + + Team result = service.ensurePersonalTeam(u); + + assertThat(result.getId()).isEqualTo(99L); + verify(teamRepository).save(any(Team.class)); + } + + @Test + @DisplayName("creates a personal team when the user has no team at all") + void noTeam_createsNew() { + User u = user(1L, "a@x.com", "alice"); + stubCreatePersonalTeam(u, 99L); + + Team result = service.ensurePersonalTeam(u); + + assertThat(result.getId()).isEqualTo(99L); + verify(membershipRepository).save(any(TeamMembership.class)); + } + } + + // ============================================================================================= + @Nested + @DisplayName("createPersonalTeam") + class CreatePersonalTeam { + + @Test + @DisplayName("builds a 'My Team', wires extensions, membership and user team-ref") + void happyPath() { + User u = user(1L, "a@x.com", "alice"); + when(userRepository.findById(1L)).thenReturn(Optional.of(u)); + Team saved = team(50L, "My Team"); + when(teamRepository.save(any(Team.class))).thenReturn(saved); + + Team result = service.createPersonalTeam(u); + + assertThat(result).isSameAs(saved); + verify(saasTeamExtensionService).setPersonal(saved, true); + verify(saasTeamExtensionService).setSeats(saved, 1, 1); + verify(saasTeamExtensionService).setCreatedByUserId(saved, 1L); + verify(saasTeamExtensionsRepository).incrementSeatsUsed(50L); + + ArgumentCaptor mcap = ArgumentCaptor.forClass(TeamMembership.class); + verify(membershipRepository).save(mcap.capture()); + assertThat(mcap.getValue().getRole()).isEqualTo(TeamRole.LEADER); + assertThat(mcap.getValue().getAcceptedAt()).isNotNull(); + verify(userRepository).save(u); + } + + @Test + @DisplayName("throws IllegalArgumentException when the user no longer exists") + void userNotFound_throws() { + User u = user(7L, "a@x.com", "alice"); + when(userRepository.findById(7L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.createPersonalTeam(u)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("User not found: 7"); + verify(teamRepository, never()).save(any()); + } + + @Test + @DisplayName("cleans up the old Default team membership when migrating off it") + void migratingOffDefaultTeam_deletesOldMembership() { + User u = user(1L, "a@x.com", "alice"); + Team oldTeam = team(2L, SaasTeamService.DEFAULT_TEAM_NAME); + u.setTeam(oldTeam); + when(userRepository.findById(1L)).thenReturn(Optional.of(u)); + when(teamRepository.save(any(Team.class))).thenReturn(team(50L, "My Team")); + + service.createPersonalTeam(u); + + verify(membershipRepository).deleteByTeamIdAndUserId(2L, 1L); + } + + @Test + @DisplayName("cleans up the old Internal team membership when migrating off it") + void migratingOffInternalTeam_deletesOldMembership() { + User u = user(1L, "a@x.com", "alice"); + Team oldTeam = team(3L, SaasTeamService.INTERNAL_TEAM_NAME); + u.setTeam(oldTeam); + when(userRepository.findById(1L)).thenReturn(Optional.of(u)); + when(teamRepository.save(any(Team.class))).thenReturn(team(50L, "My Team")); + + service.createPersonalTeam(u); + + verify(membershipRepository).deleteByTeamIdAndUserId(3L, 1L); + } + + @Test + @DisplayName("does not delete membership when the old team is a regular (non-system) team") + void migratingOffRegularTeam_keepsOldMembership() { + User u = user(1L, "a@x.com", "alice"); + Team oldTeam = team(4L, "Some Other Team"); + u.setTeam(oldTeam); + when(userRepository.findById(1L)).thenReturn(Optional.of(u)); + when(teamRepository.save(any(Team.class))).thenReturn(team(50L, "My Team")); + + service.createPersonalTeam(u); + + verify(membershipRepository, never()).deleteByTeamIdAndUserId(anyLong(), anyLong()); + } + } + + // ============================================================================================= + @Nested + @DisplayName("inviteUserToTeam") + class InviteUserToTeam { + + private final Long teamId = 100L; + + @Test + @DisplayName("throws when the team does not exist") + void teamNotFound_throws() { + when(teamRepository.findById(teamId)).thenReturn(Optional.empty()); + + assertThatThrownBy( + () -> + service.inviteUserToTeam( + teamId, "b@x.com", user(1L, "a@x.com", "alice"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Team not found"); + } + + @Test + @DisplayName("throws SecurityException when the inviter is not a member of the team") + void inviterNotMember_throws() { + Team t = team(teamId, "Acme"); + User inviter = user(1L, "a@x.com", "alice"); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.inviteUserToTeam(teamId, "b@x.com", inviter)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("not a member"); + } + + @Test + @DisplayName("throws SecurityException when the inviter is a member but not a leader") + void inviterNotLeader_throws() { + Team t = team(teamId, "Acme"); + User inviter = user(1L, "a@x.com", "alice"); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(membership(t, inviter, TeamRole.MEMBER))); + + assertThatThrownBy(() -> service.inviteUserToTeam(teamId, "b@x.com", inviter)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("Only team leaders"); + } + + @Test + @DisplayName("converts a personal team to standard (unlimited seats) on first invitation") + void personalTeam_convertedToStandard() { + Team t = team(teamId, "My Team"); + User inviter = user(1L, "a@x.com", "alice"); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(membership(t, inviter, TeamRole.LEADER))); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(true); + when(saasTeamExtensionService.canInviteMembers(t)).thenReturn(true); + when(rateLimitService.allowInvitation(teamId)).thenReturn(true); + when(invitationRepository.existsPendingInvitationByTeamIdAndEmail(teamId, "b@x.com")) + .thenReturn(false); + when(userRepository.findByEmail("b@x.com")).thenReturn(Optional.empty()); + when(invitationRepository.save(any(TeamInvitation.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + service.inviteUserToTeam(teamId, "b@x.com", inviter); + + verify(saasTeamExtensionService).setPersonal(t, false); + verify(saasTeamExtensionService).setSeats(t, Integer.MAX_VALUE, Integer.MAX_VALUE); + } + + @Test + @DisplayName("throws when the team cannot invite members (no seats / still personal)") + void cannotInvite_throws() { + Team t = team(teamId, "Acme"); + User inviter = user(1L, "a@x.com", "alice"); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(membership(t, inviter, TeamRole.LEADER))); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); + when(saasTeamExtensionService.canInviteMembers(t)).thenReturn(false); + + assertThatThrownBy(() -> service.inviteUserToTeam(teamId, "b@x.com", inviter)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot invite members"); + } + + @Test + @DisplayName("throws IllegalStateException with remaining count when rate-limited") + void rateLimited_throws() { + Team t = team(teamId, "Acme"); + User inviter = user(1L, "a@x.com", "alice"); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(membership(t, inviter, TeamRole.LEADER))); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); + when(saasTeamExtensionService.canInviteMembers(t)).thenReturn(true); + when(rateLimitService.allowInvitation(teamId)).thenReturn(false); + when(rateLimitService.getRemainingInvitations(teamId)).thenReturn(0); + + assertThatThrownBy(() -> service.inviteUserToTeam(teamId, "b@x.com", inviter)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Rate limit exceeded") + .hasMessageContaining("Remaining: 0"); + } + + @Test + @DisplayName("throws when a pending invitation already exists for the email") + void duplicatePendingInvite_throws() { + Team t = team(teamId, "Acme"); + User inviter = user(1L, "a@x.com", "alice"); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(membership(t, inviter, TeamRole.LEADER))); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); + when(saasTeamExtensionService.canInviteMembers(t)).thenReturn(true); + when(rateLimitService.allowInvitation(teamId)).thenReturn(true); + when(invitationRepository.existsPendingInvitationByTeamIdAndEmail(teamId, "b@x.com")) + .thenReturn(true); + + assertThatThrownBy(() -> service.inviteUserToTeam(teamId, "b@x.com", inviter)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Pending invitation already exists"); + } + + @Test + @DisplayName("throws when the invitee already has an active paid subscription") + void inviteePaidSubscriber_throws() { + Team t = team(teamId, "Acme"); + User inviter = user(1L, "a@x.com", "alice"); + User invitee = user(2L, "b@x.com", "bob"); + invitee.setSupabaseId(SUPABASE_ID); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(membership(t, inviter, TeamRole.LEADER))); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); + when(saasTeamExtensionService.canInviteMembers(t)).thenReturn(true); + when(rateLimitService.allowInvitation(teamId)).thenReturn(true); + when(invitationRepository.existsPendingInvitationByTeamIdAndEmail(teamId, "b@x.com")) + .thenReturn(false); + when(userRepository.findByEmail("b@x.com")).thenReturn(Optional.of(invitee)); + when(billingSubscriptionRepository.existsActivePaidSubscriptionForUser(SUPABASE_ID)) + .thenReturn(true); + + assertThatThrownBy(() -> service.inviteUserToTeam(teamId, "b@x.com", inviter)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot invite paid users"); + } + + @Test + @DisplayName("throws when the invitee is already a member of the team") + void inviteeAlreadyMember_throws() { + Team t = team(teamId, "Acme"); + User inviter = user(1L, "a@x.com", "alice"); + User invitee = user(2L, "b@x.com", "bob"); + invitee.setSupabaseId(SUPABASE_ID); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(membership(t, inviter, TeamRole.LEADER))); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); + when(saasTeamExtensionService.canInviteMembers(t)).thenReturn(true); + when(rateLimitService.allowInvitation(teamId)).thenReturn(true); + when(invitationRepository.existsPendingInvitationByTeamIdAndEmail(teamId, "b@x.com")) + .thenReturn(false); + when(userRepository.findByEmail("b@x.com")).thenReturn(Optional.of(invitee)); + when(billingSubscriptionRepository.existsActivePaidSubscriptionForUser(SUPABASE_ID)) + .thenReturn(false); + when(membershipRepository.existsByTeamIdAndUserId(teamId, 2L)).thenReturn(true); + + assertThatThrownBy(() -> service.inviteUserToTeam(teamId, "b@x.com", inviter)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("already a team member"); + } + + @Test + @DisplayName("creates a PENDING invitation with token+expiry and sends the email (success)") + void success_existingFreeInvitee() { + Team t = team(teamId, "Acme"); + User inviter = user(1L, "a@x.com", "alice"); + User invitee = user(2L, "b@x.com", "bob"); + invitee.setSupabaseId(SUPABASE_ID); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(membership(t, inviter, TeamRole.LEADER))); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); + when(saasTeamExtensionService.canInviteMembers(t)).thenReturn(true); + when(rateLimitService.allowInvitation(teamId)).thenReturn(true); + when(invitationRepository.existsPendingInvitationByTeamIdAndEmail(teamId, "b@x.com")) + .thenReturn(false); + when(userRepository.findByEmail("b@x.com")).thenReturn(Optional.of(invitee)); + when(billingSubscriptionRepository.existsActivePaidSubscriptionForUser(SUPABASE_ID)) + .thenReturn(false); + when(membershipRepository.existsByTeamIdAndUserId(teamId, 2L)).thenReturn(false); + when(invitationRepository.save(any(TeamInvitation.class))) + .thenAnswer(inv -> inv.getArgument(0)); + // Edge function unconfigured so sendInvitationEmail short-circuits without + // RestTemplate. + when(supabaseConfig.isEdgeFunctionConfigured()).thenReturn(false); + + TeamInvitation result = service.inviteUserToTeam(teamId, "b@x.com", inviter); + + assertThat(result.getStatus()).isEqualTo(InvitationStatus.PENDING); + assertThat(result.getInvitationToken()).isNotBlank(); + assertThat(result.getExpiresAt()).isAfter(LocalDateTime.now().plusDays(6)); + assertThat(result.getInviteeUser()).isSameAs(invitee); + verify(restTemplate, never()).postForEntity(any(String.class), any(), any()); + } + + @Test + @DisplayName("succeeds for an unknown invitee (no existing user) leaving inviteeUser null") + void success_unknownInvitee() { + Team t = team(teamId, "Acme"); + User inviter = user(1L, "a@x.com", "alice"); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(membership(t, inviter, TeamRole.LEADER))); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); + when(saasTeamExtensionService.canInviteMembers(t)).thenReturn(true); + when(rateLimitService.allowInvitation(teamId)).thenReturn(true); + when(invitationRepository.existsPendingInvitationByTeamIdAndEmail(teamId, "new@x.com")) + .thenReturn(false); + when(userRepository.findByEmail("new@x.com")).thenReturn(Optional.empty()); + when(invitationRepository.save(any(TeamInvitation.class))) + .thenAnswer(inv -> inv.getArgument(0)); + when(supabaseConfig.isEdgeFunctionConfigured()).thenReturn(false); + + TeamInvitation result = service.inviteUserToTeam(teamId, "new@x.com", inviter); + + assertThat(result.getInviteeUser()).isNull(); + assertThat(result.getInviteeEmail()).isEqualTo("new@x.com"); + } + + @Test + @DisplayName("sends the email via RestTemplate when the edge function is configured") + void success_sendsEmailWhenConfigured() { + Team t = team(teamId, "Acme"); + User inviter = user(1L, "a@x.com", "alice"); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(membership(t, inviter, TeamRole.LEADER))); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); + when(saasTeamExtensionService.canInviteMembers(t)).thenReturn(true); + when(rateLimitService.allowInvitation(teamId)).thenReturn(true); + when(invitationRepository.existsPendingInvitationByTeamIdAndEmail(teamId, "new@x.com")) + .thenReturn(false); + when(userRepository.findByEmail("new@x.com")).thenReturn(Optional.empty()); + when(invitationRepository.save(any(TeamInvitation.class))) + .thenAnswer(inv -> inv.getArgument(0)); + when(supabaseConfig.isEdgeFunctionConfigured()).thenReturn(true); + when(supabaseConfig.getEdgeFunctionUrl()).thenReturn("https://edge.example"); + when(supabaseConfig.getEdgeFunctionSecret()).thenReturn("secret"); + + service.inviteUserToTeam(teamId, "new@x.com", inviter); + + ArgumentCaptor urlCap = ArgumentCaptor.forClass(String.class); + verify(restTemplate).postForEntity(urlCap.capture(), any(), eq(String.class)); + assertThat(urlCap.getValue()).isEqualTo("https://edge.example/team-invitation-email"); + } + + @Test + @DisplayName("swallows a RestTemplate failure so the saved invitation is still returned") + void emailFailure_swallowedInvitationReturned() { + Team t = team(teamId, "Acme"); + User inviter = user(1L, "a@x.com", "alice"); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(membership(t, inviter, TeamRole.LEADER))); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); + when(saasTeamExtensionService.canInviteMembers(t)).thenReturn(true); + when(rateLimitService.allowInvitation(teamId)).thenReturn(true); + when(invitationRepository.existsPendingInvitationByTeamIdAndEmail(teamId, "new@x.com")) + .thenReturn(false); + when(userRepository.findByEmail("new@x.com")).thenReturn(Optional.empty()); + when(invitationRepository.save(any(TeamInvitation.class))) + .thenAnswer(inv -> inv.getArgument(0)); + when(supabaseConfig.isEdgeFunctionConfigured()).thenReturn(true); + when(supabaseConfig.getEdgeFunctionUrl()).thenReturn("https://edge.example"); + when(supabaseConfig.getEdgeFunctionSecret()).thenReturn("secret"); + when(restTemplate.postForEntity(any(String.class), any(), eq(String.class))) + .thenThrow(new RuntimeException("network down")); + + TeamInvitation result = service.inviteUserToTeam(teamId, "new@x.com", inviter); + + assertThat(result).isNotNull(); + assertThat(result.getStatus()).isEqualTo(InvitationStatus.PENDING); + } + + @Test + @DisplayName("treats a billing-lookup error as having a subscription (fail-safe block)") + void inviteeBillingLookupError_failsSafeAndBlocks() { + Team t = team(teamId, "Acme"); + User inviter = user(1L, "a@x.com", "alice"); + User invitee = user(2L, "b@x.com", "bob"); + invitee.setSupabaseId(SUPABASE_ID); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(membership(t, inviter, TeamRole.LEADER))); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); + when(saasTeamExtensionService.canInviteMembers(t)).thenReturn(true); + when(rateLimitService.allowInvitation(teamId)).thenReturn(true); + when(invitationRepository.existsPendingInvitationByTeamIdAndEmail(teamId, "b@x.com")) + .thenReturn(false); + when(userRepository.findByEmail("b@x.com")).thenReturn(Optional.of(invitee)); + when(billingSubscriptionRepository.existsActivePaidSubscriptionForUser(SUPABASE_ID)) + .thenThrow(new RuntimeException("db down")); + + // hasPaidSubscription catches the error and returns true -> blocks as a paid user. + assertThatThrownBy(() -> service.inviteUserToTeam(teamId, "b@x.com", inviter)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot invite paid users"); + } + } + + // ============================================================================================= + @Nested + @DisplayName("acceptInvitation") + class AcceptInvitation { + + private TeamInvitation pendingInvitation(Team team, User inviter, String email) { + TeamInvitation inv = new TeamInvitation(); + inv.setTeam(team); + inv.setInviter(inviter); + inv.setInviteeEmail(email); + inv.setStatus(InvitationStatus.PENDING); + inv.setInvitationToken("tok-123"); + inv.setExpiresAt(LocalDateTime.now().plusDays(3)); + inv.setCreatedAt(LocalDateTime.now().minusDays(1)); + return inv; + } + + @Test + @DisplayName("throws when the accepting user no longer exists") + void userNotFound_throws() { + User u = user(5L, "b@x.com", "bob"); + when(userRepository.findById(5L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.acceptInvitation("tok-123", u)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("User not found: 5"); + } + + @Test + @DisplayName("throws when the invitation token is unknown") + void invitationNotFound_throws() { + User u = user(5L, "b@x.com", "bob"); + when(userRepository.findById(5L)).thenReturn(Optional.of(u)); + when(invitationRepository.findByInvitationToken("tok-123")) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.acceptInvitation("tok-123", u)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invitation not found"); + } + + @Test + @DisplayName("throws when the invitation is not PENDING") + void notPending_throws() { + User u = user(5L, "b@x.com", "bob"); + Team t = team(100L, "Acme"); + TeamInvitation inv = pendingInvitation(t, user(1L, "a@x.com", "alice"), "b@x.com"); + inv.setStatus(InvitationStatus.ACCEPTED); + when(userRepository.findById(5L)).thenReturn(Optional.of(u)); + when(invitationRepository.findByInvitationToken("tok-123")) + .thenReturn(Optional.of(inv)); + + assertThatThrownBy(() -> service.acceptInvitation("tok-123", u)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("already processed"); + } + + @Test + @DisplayName("marks the invitation EXPIRED and throws when it has expired") + void expired_marksExpiredAndThrows() { + User u = user(5L, "b@x.com", "bob"); + Team t = team(100L, "Acme"); + TeamInvitation inv = pendingInvitation(t, user(1L, "a@x.com", "alice"), "b@x.com"); + inv.setExpiresAt(LocalDateTime.now().minusDays(1)); + when(userRepository.findById(5L)).thenReturn(Optional.of(u)); + when(invitationRepository.findByInvitationToken("tok-123")) + .thenReturn(Optional.of(inv)); + + assertThatThrownBy(() -> service.acceptInvitation("tok-123", u)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("expired"); + assertThat(inv.getStatus()).isEqualTo(InvitationStatus.EXPIRED); + verify(invitationRepository).save(inv); + } + + @Test + @DisplayName("throws SecurityException when the invitee email does not match the user") + void emailMismatch_throws() { + User u = user(5L, "other@x.com", "bob"); + Team t = team(100L, "Acme"); + TeamInvitation inv = pendingInvitation(t, user(1L, "a@x.com", "alice"), "b@x.com"); + when(userRepository.findById(5L)).thenReturn(Optional.of(u)); + when(invitationRepository.findByInvitationToken("tok-123")) + .thenReturn(Optional.of(inv)); + + assertThatThrownBy(() -> service.acceptInvitation("tok-123", u)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("email mismatch"); + } + + @Test + @DisplayName("throws when the accepting user has an active paid subscription") + void acceptingUserPaid_throws() { + User u = user(5L, "b@x.com", "bob"); + u.setSupabaseId(SUPABASE_ID); + Team t = team(100L, "Acme"); + TeamInvitation inv = pendingInvitation(t, user(1L, "a@x.com", "alice"), "b@x.com"); + when(userRepository.findById(5L)).thenReturn(Optional.of(u)); + when(invitationRepository.findByInvitationToken("tok-123")) + .thenReturn(Optional.of(inv)); + when(billingSubscriptionRepository.existsActivePaidSubscriptionForUser(SUPABASE_ID)) + .thenReturn(true); + + assertThatThrownBy(() -> service.acceptInvitation("tok-123", u)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cancel your subscription"); + } + + @Test + @DisplayName("throws when the inviting team has no available seats") + void teamNoSeats_throws() { + User u = user(5L, "b@x.com", "bob"); + Team t = team(100L, "Acme"); + TeamInvitation inv = pendingInvitation(t, user(1L, "a@x.com", "alice"), "b@x.com"); + when(userRepository.findById(5L)).thenReturn(Optional.of(u)); + when(invitationRepository.findByInvitationToken("tok-123")) + .thenReturn(Optional.of(inv)); + when(saasTeamExtensionService.hasAvailableSeats(t)).thenReturn(false); + + assertThatThrownBy(() -> service.acceptInvitation("tok-123", u)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("no available seats"); + } + + @Test + @DisplayName("blocks accept when the user is the last leader of a paid non-personal team") + void lastLeaderOfPaidTeam_blocksAccept() { + User u = user(5L, "b@x.com", "bob"); + Team newTeam = team(100L, "Acme"); + Team ownTeam = team(200L, "Bob Co"); + TeamInvitation inv = + pendingInvitation(newTeam, user(1L, "a@x.com", "alice"), "b@x.com"); + when(userRepository.findById(5L)).thenReturn(Optional.of(u)); + when(invitationRepository.findByInvitationToken("tok-123")) + .thenReturn(Optional.of(inv)); + when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true); + when(membershipRepository.findByUserId(5L)) + .thenReturn(List.of(membership(ownTeam, u, TeamRole.LEADER))); + when(saasTeamExtensionService.isPersonal(ownTeam)).thenReturn(false); + when(membershipRepository.countByTeamIdAndRole(200L, TeamRole.LEADER)).thenReturn(1L); + when(billingSubscriptionRepository.existsActiveSubscriptionForTeam(200L)) + .thenReturn(true); + + assertThatThrownBy(() -> service.acceptInvitation("tok-123", u)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("active plan"); + } + + @Test + @DisplayName( + "blocks accept when the user is the last leader of an unpaid non-personal team") + void lastLeaderOfUnpaidTeam_blocksAccept() { + User u = user(5L, "b@x.com", "bob"); + Team newTeam = team(100L, "Acme"); + Team ownTeam = team(200L, "Bob Co"); + TeamInvitation inv = + pendingInvitation(newTeam, user(1L, "a@x.com", "alice"), "b@x.com"); + when(userRepository.findById(5L)).thenReturn(Optional.of(u)); + when(invitationRepository.findByInvitationToken("tok-123")) + .thenReturn(Optional.of(inv)); + when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true); + when(membershipRepository.findByUserId(5L)) + .thenReturn(List.of(membership(ownTeam, u, TeamRole.LEADER))); + when(saasTeamExtensionService.isPersonal(ownTeam)).thenReturn(false); + when(membershipRepository.countByTeamIdAndRole(200L, TeamRole.LEADER)).thenReturn(1L); + when(billingSubscriptionRepository.existsActiveSubscriptionForTeam(200L)) + .thenReturn(false); + + assertThatThrownBy(() -> service.acceptInvitation("tok-123", u)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Transfer leadership"); + } + + @Test + @DisplayName( + "happy path: leaves personal team, deletes it, joins new team, increments seats") + void success_migratesFromPersonalTeam() { + User u = user(5L, "b@x.com", "bob"); + Team newTeam = team(100L, "Acme"); + Team personal = team(200L, "My Team"); + User inviter = user(1L, "a@x.com", "alice"); + TeamInvitation inv = pendingInvitation(newTeam, inviter, "b@x.com"); + TeamMembership personalMembership = membership(personal, u, TeamRole.LEADER); + + when(userRepository.findById(5L)).thenReturn(Optional.of(u)); + when(invitationRepository.findByInvitationToken("tok-123")) + .thenReturn(Optional.of(inv)); + when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true); + // assertCanLeave... iterates memberships; personal team is skipped. + when(membershipRepository.findByUserId(5L)) + .thenReturn(List.of(personalMembership)) + .thenReturn(List.of(personalMembership)); + when(saasTeamExtensionService.isPersonal(personal)).thenReturn(true); + when(membershipRepository.countByTeamId(200L)).thenReturn(0L); + when(saasTeamExtensionsRepository.incrementSeatsUsed(100L)).thenReturn(1); + + service.acceptInvitation("tok-123", u); + + verify(membershipRepository).delete(personalMembership); + verify(saasTeamExtensionsRepository).decrementSeatsUsed(200L); + verify(teamRepository).delete(personal); + verify(userRepository).updateUserTeamId(5L, 100L); + assertThat(inv.getStatus()).isEqualTo(InvitationStatus.ACCEPTED); + ArgumentCaptor mcap = ArgumentCaptor.forClass(TeamMembership.class); + verify(membershipRepository).save(mcap.capture()); + assertThat(mcap.getValue().getRole()).isEqualTo(TeamRole.MEMBER); + assertThat(mcap.getValue().getInvitedBy()).isSameAs(inviter); + } + + @Test + @DisplayName("throws if the atomic seat increment loses the race (rowsUpdated == 0)") + void seatIncrementRace_throws() { + User u = user(5L, "b@x.com", "bob"); + Team newTeam = team(100L, "Acme"); + User inviter = user(1L, "a@x.com", "alice"); + TeamInvitation inv = pendingInvitation(newTeam, inviter, "b@x.com"); + + when(userRepository.findById(5L)).thenReturn(Optional.of(u)); + when(invitationRepository.findByInvitationToken("tok-123")) + .thenReturn(Optional.of(inv)); + when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true); + when(membershipRepository.findByUserId(5L)).thenReturn(new ArrayList<>()); + when(saasTeamExtensionsRepository.incrementSeatsUsed(100L)).thenReturn(0); + + assertThatThrownBy(() -> service.acceptInvitation("tok-123", u)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("no available seats"); + } + + @Test + @DisplayName("does not delete a non-personal old team even when it ends up empty") + void nonPersonalOldTeam_notDeleted() { + User u = user(5L, "b@x.com", "bob"); + Team newTeam = team(100L, "Acme"); + Team oldTeam = team(300L, "Old Co"); + User inviter = user(1L, "a@x.com", "alice"); + TeamInvitation inv = pendingInvitation(newTeam, inviter, "b@x.com"); + // Member (not leader) leaving the old team: assertCanLeave skips, accept proceeds. + TeamMembership oldMembership = membership(oldTeam, u, TeamRole.MEMBER); + + when(userRepository.findById(5L)).thenReturn(Optional.of(u)); + when(invitationRepository.findByInvitationToken("tok-123")) + .thenReturn(Optional.of(inv)); + when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true); + when(membershipRepository.findByUserId(5L)) + .thenReturn(List.of(oldMembership)) + .thenReturn(List.of(oldMembership)); + when(saasTeamExtensionService.isPersonal(oldTeam)).thenReturn(false); + when(saasTeamExtensionsRepository.incrementSeatsUsed(100L)).thenReturn(1); + + service.acceptInvitation("tok-123", u); + + verify(teamRepository, never()).delete(oldTeam); + verify(userRepository).updateUserTeamId(5L, 100L); + } + } + + // ============================================================================================= + @Nested + @DisplayName("acceptInvitationAndGrantRole") + class AcceptInvitationAndGrantRole { + + private TeamInvitation pendingInvitation(Team team, User inviter, String email) { + TeamInvitation inv = new TeamInvitation(); + inv.setTeam(team); + inv.setInviter(inviter); + inv.setInviteeEmail(email); + inv.setStatus(InvitationStatus.PENDING); + inv.setInvitationToken("tok-123"); + inv.setExpiresAt(LocalDateTime.now().plusDays(3)); + inv.setCreatedAt(LocalDateTime.now().minusDays(1)); + return inv; + } + + // Stubs a minimal successful acceptInvitation into the given team. + private void stubSuccessfulAccept(User u, Team newTeam) { + TeamInvitation inv = + pendingInvitation(newTeam, user(1L, "a@x.com", "alice"), "b@x.com"); + when(invitationRepository.findByInvitationToken("tok-123")) + .thenReturn(Optional.of(inv)); + when(saasTeamExtensionService.hasAvailableSeats(newTeam)).thenReturn(true); + when(membershipRepository.findByUserId(u.getId())).thenReturn(new ArrayList<>()); + when(saasTeamExtensionsRepository.incrementSeatsUsed(newTeam.getId())).thenReturn(1); + } + + @Test + @DisplayName("grants ROLE_PRO_USER when the joined team has an active subscription") + void grantsProWhenTeamPaid() throws Exception { + User u = user(5L, "b@x.com", "bob"); + Team newTeam = team(100L, "Acme"); + stubSuccessfulAccept(u, newTeam); + // findById is used by acceptInvitation, then again to re-read post-accept. + User reread = user(5L, "b@x.com", "bob"); + reread.setTeam(newTeam); + when(userRepository.findById(5L)) + .thenReturn(Optional.of(u)) + .thenReturn(Optional.of(reread)); + when(billingSubscriptionRepository.existsActiveSubscriptionForTeam(100L)) + .thenReturn(true); + + service.acceptInvitationAndGrantRole("tok-123", u); + + verify(userService).changeRole(reread, Role.PRO_USER.getRoleId()); + } + + @Test + @DisplayName("does not grant PRO when the joined team has no active subscription") + void noGrantWhenTeamUnpaid() throws Exception { + User u = user(5L, "b@x.com", "bob"); + Team newTeam = team(100L, "Acme"); + stubSuccessfulAccept(u, newTeam); + User reread = user(5L, "b@x.com", "bob"); + reread.setTeam(newTeam); + when(userRepository.findById(5L)) + .thenReturn(Optional.of(u)) + .thenReturn(Optional.of(reread)); + when(billingSubscriptionRepository.existsActiveSubscriptionForTeam(100L)) + .thenReturn(false); + + service.acceptInvitationAndGrantRole("tok-123", u); + + verify(userService, never()).changeRole(any(), any()); + } + + @Test + @DisplayName("does not re-grant PRO when the user is already a PRO user") + void noGrantWhenAlreadyPro() throws Exception { + User u = user(5L, "b@x.com", "bob"); + Team newTeam = team(100L, "Acme"); + stubSuccessfulAccept(u, newTeam); + User reread = proUser(5L, "b@x.com", "bob"); + reread.setTeam(newTeam); + when(userRepository.findById(5L)) + .thenReturn(Optional.of(u)) + .thenReturn(Optional.of(reread)); + when(billingSubscriptionRepository.existsActiveSubscriptionForTeam(100L)) + .thenReturn(true); + + service.acceptInvitationAndGrantRole("tok-123", u); + + verify(userService, never()).changeRole(any(), any()); + } + + @Test + @DisplayName("does not grant PRO when the user ends up with a null team") + void noGrantWhenTeamNull() throws Exception { + User u = user(5L, "b@x.com", "bob"); + Team newTeam = team(100L, "Acme"); + stubSuccessfulAccept(u, newTeam); + // Re-read returns a user whose team is null -> early return path. + User reread = user(5L, "b@x.com", "bob"); + when(userRepository.findById(5L)) + .thenReturn(Optional.of(u)) + .thenReturn(Optional.of(reread)); + + service.acceptInvitationAndGrantRole("tok-123", u); + + verify(userService, never()).changeRole(any(), any()); + } + } + + // ============================================================================================= + @Nested + @DisplayName("removeTeamMember") + class RemoveTeamMember { + + private final Long teamId = 100L; + + @Test + @DisplayName("throws SecurityException when the remover is not a member") + void removerNotMember_throws() { + User remover = user(1L, "a@x.com", "alice"); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.removeTeamMember(teamId, 2L, remover)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("not a member"); + } + + @Test + @DisplayName("throws SecurityException when the remover is not a leader") + void removerNotLeader_throws() { + User remover = user(1L, "a@x.com", "alice"); + Team t = team(teamId, "Acme"); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(membership(t, remover, TeamRole.MEMBER))); + + assertThatThrownBy(() -> service.removeTeamMember(teamId, 2L, remover)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("Only team leaders"); + } + + @Test + @DisplayName("throws when a sole leader tries to remove themselves") + void soleLeaderRemovesSelf_throws() { + User remover = user(1L, "a@x.com", "alice"); + Team t = team(teamId, "Acme"); + TeamMembership leaderM = membership(t, remover, TeamRole.LEADER); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(leaderM)); + when(membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER)) + .thenReturn(List.of(leaderM)); + + assertThatThrownBy(() -> service.removeTeamMember(teamId, 1L, remover)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("last team leader"); + } + + @Test + @DisplayName("throws when the member to remove is not in the team") + void memberNotInTeam_throws() { + User remover = user(1L, "a@x.com", "alice"); + Team t = team(teamId, "Acme"); + TeamMembership leaderM = membership(t, remover, TeamRole.LEADER); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(leaderM)); + when(membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER)) + .thenReturn( + List.of( + leaderM, + membership(t, user(9L, "c@x.com", "co"), TeamRole.LEADER))); + when(membershipRepository.findByTeamIdAndUserId(teamId, 2L)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.removeTeamMember(teamId, 2L, remover)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("User not found in team"); + } + + @Test + @DisplayName("removes the member, decrements seats, makes a personal team, downgrades") + void success_removesMemberAndDeletesEmptyTeam() { + User remover = user(1L, "a@x.com", "alice"); + User target = user(2L, "b@x.com", "bob"); + Team t = team(teamId, "Acme"); + TeamMembership leaderM = membership(t, remover, TeamRole.LEADER); + TeamMembership targetM = membership(t, target, TeamRole.MEMBER); + + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(leaderM)); + when(membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER)) + .thenReturn(List.of(leaderM)); + when(membershipRepository.findByTeamIdAndUserId(teamId, 2L)) + .thenReturn(Optional.of(targetM)); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + // createPersonalTeam + downgradeUserToFree both refetch the removed user by id. + // target has no PRO authority, so downgrade hits the early return. + stubCreatePersonalTeam(target, 500L); + // team becomes empty + non-personal -> deleted + when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); + when(membershipRepository.countByTeamId(teamId)).thenReturn(0L); + + service.removeTeamMember(teamId, 2L, remover); + + verify(membershipRepository).delete(targetM); + verify(saasTeamExtensionsRepository).decrementSeatsUsed(teamId); + verify(teamRepository).delete(t); + } + + @Test + @DisplayName("keeps a non-empty team after removing a member") + void success_keepsNonEmptyTeam() { + User remover = user(1L, "a@x.com", "alice"); + User target = user(2L, "b@x.com", "bob"); + Team t = team(teamId, "Acme"); + TeamMembership leaderM = membership(t, remover, TeamRole.LEADER); + TeamMembership targetM = membership(t, target, TeamRole.MEMBER); + + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(leaderM)); + when(membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER)) + .thenReturn(List.of(leaderM)); + when(membershipRepository.findByTeamIdAndUserId(teamId, 2L)) + .thenReturn(Optional.of(targetM)); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + stubCreatePersonalTeam(target, 500L); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); + when(membershipRepository.countByTeamId(teamId)).thenReturn(2L); + + service.removeTeamMember(teamId, 2L, remover); + + verify(teamRepository, never()).delete(t); + } + } + + // ============================================================================================= + @Nested + @DisplayName("leaveTeam") + class LeaveTeam { + + private final Long teamId = 100L; + + @Test + @DisplayName("throws when the user is not a member of the team") + void notMember_throws() { + User u = user(1L, "a@x.com", "alice"); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.leaveTeam(teamId, u)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Not a member of this team"); + } + + @Test + @DisplayName("throws when the sole leader tries to leave") + void soleLeaderLeaves_throws() { + User u = user(1L, "a@x.com", "alice"); + Team t = team(teamId, "Acme"); + TeamMembership leaderM = membership(t, u, TeamRole.LEADER); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(leaderM)); + when(membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER)) + .thenReturn(List.of(leaderM)); + + assertThatThrownBy(() -> service.leaveTeam(teamId, u)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("last team leader"); + } + + @Test + @DisplayName("member leaves: deletes membership, decrements, makes personal team") + void memberLeaves_success() { + User u = user(1L, "a@x.com", "alice"); + Team t = team(teamId, "Acme"); + TeamMembership memberM = membership(t, u, TeamRole.MEMBER); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(memberM)); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + stubCreatePersonalTeam(u, 500L); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(true); + + service.leaveTeam(teamId, u); + + verify(membershipRepository).delete(memberM); + verify(saasTeamExtensionsRepository).decrementSeatsUsed(teamId); + // Personal team is never deleted on leave. + verify(teamRepository, never()).delete(any()); + } + + @Test + @DisplayName("leader leaves when another leader remains: deletes empty non-personal team") + void leaderLeavesWithCoLeader_deletesEmptyTeam() { + User u = user(1L, "a@x.com", "alice"); + Team t = team(teamId, "Acme"); + TeamMembership leaderM = membership(t, u, TeamRole.LEADER); + TeamMembership coLeaderM = membership(t, user(9L, "c@x.com", "co"), TeamRole.LEADER); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(leaderM)); + when(membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER)) + .thenReturn(List.of(leaderM, coLeaderM)); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + stubCreatePersonalTeam(u, 500L); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); + when(membershipRepository.countByTeamId(teamId)).thenReturn(0L); + + service.leaveTeam(teamId, u); + + verify(teamRepository).delete(t); + } + + @Test + @DisplayName("keeps PRO access on leave when the user still has an active subscription") + void leaveKeepsProWhenSubscribed() { + User u = user(1L, "a@x.com", "alice"); + Team t = team(teamId, "Acme"); + TeamMembership memberM = membership(t, u, TeamRole.MEMBER); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(memberM)); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + stubTeamSave(500L); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(true); + // Both createPersonalTeam and downgradeUserToFree refetch by id; return the PRO user + // with an active sub -> keep PRO. + User proRefetch = proUser(1L, "a@x.com", "alice"); + proRefetch.setSupabaseId(SUPABASE_ID); + when(userRepository.findById(1L)).thenReturn(Optional.of(proRefetch)); + when(billingSubscriptionRepository.existsActiveSubscriptionForUser(SUPABASE_ID)) + .thenReturn(true); + + service.leaveTeam(teamId, u); + + verify(userRoleService, never()).downgradeToFree(any()); + } + + @Test + @DisplayName("downgrades a PRO user with no subscription to FREE on leave") + void leaveDowngradesProWithoutSubscription() { + User u = user(1L, "a@x.com", "alice"); + Team t = team(teamId, "Acme"); + TeamMembership memberM = membership(t, u, TeamRole.MEMBER); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(memberM)); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + stubTeamSave(500L); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(true); + User proRefetch = proUser(1L, "a@x.com", "alice"); + proRefetch.setSupabaseId(SUPABASE_ID); + when(userRepository.findById(1L)).thenReturn(Optional.of(proRefetch)); + when(billingSubscriptionRepository.existsActiveSubscriptionForUser(SUPABASE_ID)) + .thenReturn(false); + + service.leaveTeam(teamId, u); + + verify(userRoleService).downgradeToFree(proRefetch); + } + + @Test + @DisplayName("downgrades a PRO user with no supabaseId to FREE on leave") + void leaveDowngradesProWithoutSupabaseId() { + User u = user(1L, "a@x.com", "alice"); + Team t = team(teamId, "Acme"); + TeamMembership memberM = membership(t, u, TeamRole.MEMBER); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(memberM)); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + stubTeamSave(500L); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(true); + // PRO user without supabaseId skips the subscription check and downgrades. + User proRefetch = proUser(1L, "a@x.com", "alice"); + when(userRepository.findById(1L)).thenReturn(Optional.of(proRefetch)); + + service.leaveTeam(teamId, u); + + verify(userRoleService).downgradeToFree(proRefetch); + } + + @Test + @DisplayName("downgrades to FREE when the subscription lookup throws (fail-safe)") + void leaveDowngradesWhenSubscriptionLookupErrors() { + User u = user(1L, "a@x.com", "alice"); + Team t = team(teamId, "Acme"); + TeamMembership memberM = membership(t, u, TeamRole.MEMBER); + when(membershipRepository.findByTeamIdAndUserId(teamId, 1L)) + .thenReturn(Optional.of(memberM)); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + stubTeamSave(500L); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(true); + User proRefetch = proUser(1L, "a@x.com", "alice"); + proRefetch.setSupabaseId(SUPABASE_ID); + when(userRepository.findById(1L)).thenReturn(Optional.of(proRefetch)); + when(billingSubscriptionRepository.existsActiveSubscriptionForUser(SUPABASE_ID)) + .thenThrow(new RuntimeException("db down")); + + service.leaveTeam(teamId, u); + + // On error we proceed with the downgrade to be safe. + verify(userRoleService).downgradeToFree(proRefetch); + } + } + + // ============================================================================================= + @Nested + @DisplayName("hasActivePaidSubscription (team)") + class HasActivePaidSubscription { + + @Test + @DisplayName("returns false for a null team") + void nullTeam_false() { + assertThat(service.hasActivePaidSubscription(null)).isFalse(); + } + + @Test + @DisplayName("returns false for a team without an id") + void teamWithoutId_false() { + assertThat(service.hasActivePaidSubscription(new Team())).isFalse(); + } + + @Test + @DisplayName("returns true when the billing repo reports an active subscription") + void activeSubscription_true() { + Team t = team(100L, "Acme"); + when(billingSubscriptionRepository.existsActiveSubscriptionForTeam(100L)) + .thenReturn(true); + + assertThat(service.hasActivePaidSubscription(t)).isTrue(); + } + + @Test + @DisplayName("returns false when the billing repo reports no subscription") + void noSubscription_false() { + Team t = team(100L, "Acme"); + when(billingSubscriptionRepository.existsActiveSubscriptionForTeam(100L)) + .thenReturn(false); + + assertThat(service.hasActivePaidSubscription(t)).isFalse(); + } + + @Test + @DisplayName("returns false (fail-safe) when the billing lookup throws") + void lookupError_false() { + Team t = team(100L, "Acme"); + when(billingSubscriptionRepository.existsActiveSubscriptionForTeam(100L)) + .thenThrow(new RuntimeException("db down")); + + assertThat(service.hasActivePaidSubscription(t)).isFalse(); + } + } + + // ============================================================================================= + @Nested + @DisplayName("updateTeamSeats") + class UpdateTeamSeats { + + private final Long teamId = 100L; + + @Test + @DisplayName("throws when maxSeats is null") + void nullMaxSeats_throws() { + assertThatThrownBy(() -> service.updateTeamSeats(teamId, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least 1"); + } + + @Test + @DisplayName("throws when maxSeats is below 1") + void zeroMaxSeats_throws() { + assertThatThrownBy(() -> service.updateTeamSeats(teamId, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least 1"); + } + + @Test + @DisplayName("throws when the team does not exist") + void teamNotFound_throws() { + when(teamRepository.findById(teamId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.updateTeamSeats(teamId, 5)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Team not found"); + } + + @Test + @DisplayName("increasing seats on a personal team converts it to standard") + void increaseSeats_personalBecomesStandard() { + Team t = team(teamId, "My Team"); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + when(saasTeamExtensionService.getSeatsUsed(t)).thenReturn(1); + when(saasTeamExtensionService.getMaxSeats(t)).thenReturn(1); + // First isPersonal call (after setSeats) returns true -> convert to standard. + when(saasTeamExtensionService.isPersonal(t)).thenReturn(true, false); + + service.updateTeamSeats(teamId, 5); + + verify(saasTeamExtensionService).setSeats(t, 5, 5); + verify(saasTeamExtensionService).setPersonal(t, false); + verify(teamRepository).save(t); + } + + @Test + @DisplayName("reducing to 1 seat on a standard team converts it back to personal") + void reduceToOne_standardBecomesPersonal() { + Team t = team(teamId, "Acme"); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + when(saasTeamExtensionService.getSeatsUsed(t)).thenReturn(1); + when(saasTeamExtensionService.getMaxSeats(t)).thenReturn(5); + // Was standard (false) so reducing to 1 flips back to personal. + when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); + + service.updateTeamSeats(teamId, 1); + + verify(saasTeamExtensionService).setPersonal(t, true); + } + + @Test + @DisplayName("removes excess members (members before leaders) when reducing below usage") + void reduceBelowUsage_removesExcessMembers() { + Team t = team(teamId, "Acme"); + User leader = user(1L, "a@x.com", "alice"); + User member = user(2L, "b@x.com", "bob"); + // Distinct membership ids so delete() verification can tell the two rows apart + // (TeamMembership equals is by membershipId). + TeamMembership leaderM = membership(t, leader, TeamRole.LEADER); + leaderM.setMembershipId(1L); + TeamMembership memberM = membership(t, member, TeamRole.MEMBER); + memberM.setMembershipId(2L); + memberM.setAcceptedAt(LocalDateTime.now()); + leaderM.setAcceptedAt(LocalDateTime.now().minusDays(10)); + + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + when(saasTeamExtensionService.getSeatsUsed(t)).thenReturn(2); + when(saasTeamExtensionService.getMaxSeats(t)).thenReturn(2); + when(membershipRepository.findByTeamId(teamId)).thenReturn(List.of(leaderM, memberM)); + // Reduce to 1: must remove 1 excess; the MEMBER goes first. + stubCreatePersonalTeam(member, 500L); + when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); + + service.updateTeamSeats(teamId, 1); + + // The MEMBER is removed, the LEADER kept (removal prioritises non-leaders). + verify(membershipRepository).delete(memberM); + verify(membershipRepository, never()).delete(leaderM); + // One decrement for the removed member, then a second seat update is applied via + // setSeats(t, 1, 1). Reducing to 1 seat also flips a standard team back to personal: + // setPersonal(true) is invoked for both the removed member's new personal team and t. + verify(saasTeamExtensionsRepository).decrementSeatsUsed(teamId); + verify(saasTeamExtensionService, org.mockito.Mockito.times(2)) + .setPersonal(any(), eq(true)); + } + + @Test + @DisplayName("plain seat update on a standard team with no conversion needed") + void plainUpdate_noConversion() { + Team t = team(teamId, "Acme"); + when(teamRepository.findById(teamId)).thenReturn(Optional.of(t)); + when(saasTeamExtensionService.getSeatsUsed(t)).thenReturn(2); + when(saasTeamExtensionService.getMaxSeats(t)).thenReturn(5); + // Already standard, raising to 10: neither conversion branch fires. + when(saasTeamExtensionService.isPersonal(t)).thenReturn(false); + + service.updateTeamSeats(teamId, 10); + + verify(saasTeamExtensionService).setSeats(t, 10, 10); + verify(saasTeamExtensionService, never()).setPersonal(any(), eq(true)); + verify(saasTeamExtensionService, never()).setPersonal(any(), eq(false)); + verify(teamRepository).save(t); + } + } + + // ---- shared helpers ------------------------------------------------------------------------- + + // Stubs the collaborators createPersonalTeam touches so callers (ensure/remove/leave/update) + // can drive it without exploding. Returns a saved team with the given id. + private void stubCreatePersonalTeam(User u, long newTeamId) { + when(userRepository.findById(u.getId())).thenReturn(Optional.of(u)); + stubTeamSave(newTeamId); + } + + // Stubs only teamRepository.save (assigns an id), for callers that stub findById themselves. + private void stubTeamSave(long newTeamId) { + when(teamRepository.save(any(Team.class))) + .thenAnswer( + inv -> { + Team saved = inv.getArgument(0); + saved.setId(newTeamId); + return saved; + }); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/service/SaasUserExtensionServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/SaasUserExtensionServiceTest.java new file mode 100644 index 0000000000..4bf9b06baa --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/service/SaasUserExtensionServiceTest.java @@ -0,0 +1,217 @@ +package stirling.software.saas.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; + +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.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.model.SaasUserExtensions; +import stirling.software.saas.repository.SaasUserExtensionsRepository; + +/** + * Unit tests for {@link SaasUserExtensionService}. + * + *

Thin read/write facade over {@link SaasUserExtensionsRepository}. Reads return safe defaults + * when no row exists; writes create the row lazily via {@code getOrCreate}. The repository is fully + * mocked - no DB. + */ +@ExtendWith(MockitoExtension.class) +class SaasUserExtensionServiceTest { + + private static final long USER_ID = 42L; + + @Mock private SaasUserExtensionsRepository repository; + + @InjectMocks private SaasUserExtensionService service; + + private User user; + + @BeforeEach + void setUp() { + user = new User(); + user.setId(USER_ID); + } + + @Nested + @DisplayName("getOrCreate") + class GetOrCreate { + + @Test + @DisplayName("returns the existing row when one exists, without saving") + void existing_returnedWithoutSave() { + SaasUserExtensions existing = new SaasUserExtensions(user); + when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(existing)); + + SaasUserExtensions result = service.getOrCreate(user); + + assertThat(result).isSameAs(existing); + verify(repository, never()).save(any()); + } + + @Test + @DisplayName("creates and saves a new row when none exists") + void missing_createsAndSaves() { + when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + when(repository.save(any(SaasUserExtensions.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + SaasUserExtensions result = service.getOrCreate(user); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(SaasUserExtensions.class); + verify(repository).save(captor.capture()); + assertThat(captor.getValue().getUser()).isSameAs(user); + assertThat(result).isSameAs(captor.getValue()); + } + } + + @Nested + @DisplayName("isMeteredBillingEnabled") + class IsMeteredBillingEnabled { + + @Test + @DisplayName("returns the stored flag when a row exists") + void existing_returnsFlag() { + SaasUserExtensions ext = new SaasUserExtensions(user); + ext.setHasMeteredBillingEnabled(true); + when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(ext)); + + assertThat(service.isMeteredBillingEnabled(user)).isTrue(); + } + + @Test + @DisplayName("returns false when no row exists") + void missing_returnsFalse() { + when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + + assertThat(service.isMeteredBillingEnabled(user)).isFalse(); + verify(repository, never()).save(any()); + } + } + + @Nested + @DisplayName("setMeteredBillingEnabled") + class SetMeteredBillingEnabled { + + @Test + @DisplayName("flips the flag on the existing row and saves it") + void updatesExistingRow() { + SaasUserExtensions ext = new SaasUserExtensions(user); + when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(ext)); + when(repository.save(any(SaasUserExtensions.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + service.setMeteredBillingEnabled(user, true); + + assertThat(ext.isMeteredBillingEnabled()).isTrue(); + // getOrCreate finds the existing row, then the explicit save in the setter persists. + verify(repository).save(ext); + } + + @Test + @DisplayName("creates the row first when none exists, then persists the flag") + void createsRowWhenMissing() { + when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + when(repository.save(any(SaasUserExtensions.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + service.setMeteredBillingEnabled(user, false); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(SaasUserExtensions.class); + // getOrCreate saves once (creation), the setter saves again. + verify(repository, times(2)).save(captor.capture()); + assertThat(captor.getValue().isMeteredBillingEnabled()).isFalse(); + } + } + + @Nested + @DisplayName("getApiKeyFirstUsedAt") + class GetApiKeyFirstUsedAt { + + @Test + @DisplayName("returns the stored timestamp when a row exists") + void existing_returnsTimestamp() { + LocalDateTime ts = LocalDateTime.of(2024, 1, 2, 3, 4, 5); + SaasUserExtensions ext = new SaasUserExtensions(user); + ext.setApiKeyFirstUsedAt(ts); + when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(ext)); + + assertThat(service.getApiKeyFirstUsedAt(user)).isEqualTo(ts); + } + + @Test + @DisplayName("returns null when no row exists") + void missing_returnsNull() { + when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + + assertThat(service.getApiKeyFirstUsedAt(user)).isNull(); + } + } + + @Nested + @DisplayName("trackApiKeyFirstUse") + class TrackApiKeyFirstUse { + + @Test + @DisplayName("records the timestamp the first time and saves") + void firstUse_recordsAndSaves() { + SaasUserExtensions ext = new SaasUserExtensions(user); + when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(ext)); + when(repository.save(any(SaasUserExtensions.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + service.trackApiKeyFirstUse(user); + + assertThat(ext.getApiKeyFirstUsedAt()).isNotNull(); + verify(repository).save(ext); + } + + @Test + @DisplayName("is idempotent - does not overwrite or re-save when already set") + void alreadySet_noOverwriteNoSave() { + LocalDateTime original = LocalDateTime.of(2020, 5, 5, 5, 5, 5); + SaasUserExtensions ext = new SaasUserExtensions(user); + ext.setApiKeyFirstUsedAt(original); + when(repository.findByUserId(USER_ID)).thenReturn(Optional.of(ext)); + + service.trackApiKeyFirstUse(user); + + assertThat(ext.getApiKeyFirstUsedAt()).isEqualTo(original); + // getOrCreate found the row (no save), and the guard skips the second save. + verify(repository, never()).save(any()); + } + + @Test + @DisplayName("creates the row, then records the timestamp when none exists") + void missing_createsThenRecords() { + when(repository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + when(repository.save(any(SaasUserExtensions.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + service.trackApiKeyFirstUse(user); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(SaasUserExtensions.class); + // getOrCreate save (creation) + first-use save. + verify(repository, times(2)).save(captor.capture()); + assertThat(captor.getValue().getApiKeyFirstUsedAt()).isNotNull(); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/service/TeamInvitationCleanupServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/TeamInvitationCleanupServiceTest.java new file mode 100644 index 0000000000..40696f9f68 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/service/TeamInvitationCleanupServiceTest.java @@ -0,0 +1,140 @@ +package stirling.software.saas.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +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.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.model.enumeration.InvitationStatus; +import stirling.software.saas.model.TeamInvitation; +import stirling.software.saas.repository.TeamInvitationRepository; + +/** + * Unit tests for {@link TeamInvitationCleanupService}. + * + *

Two scheduled jobs: {@code markExpiredInvitations} (daily) flips PENDING rows past their + * expiry to EXPIRED via a bulk UPDATE; {@code deleteOldExpiredInvitations} (monthly) purges EXPIRED + * rows older than 30 days. Both swallow exceptions so a failing run never breaks the scheduler. The + * repository is fully mocked. + */ +@ExtendWith(MockitoExtension.class) +class TeamInvitationCleanupServiceTest { + + @Mock private TeamInvitationRepository invitationRepository; + + @InjectMocks private TeamInvitationCleanupService service; + + @Nested + @DisplayName("markExpiredInvitations") + class MarkExpiredInvitations { + + @Test + @DisplayName("calls the bulk update with a current timestamp") + void callsBulkUpdate() { + when(invitationRepository.markExpiredInvitations(any(LocalDateTime.class))) + .thenReturn(3); + + LocalDateTime before = LocalDateTime.now(); + service.markExpiredInvitations(); + LocalDateTime after = LocalDateTime.now(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(invitationRepository).markExpiredInvitations(captor.capture()); + assertThat(captor.getValue()).isAfterOrEqualTo(before).isBeforeOrEqualTo(after); + } + + @Test + @DisplayName("handles the zero-expired branch without error") + void zeroExpired_noError() { + when(invitationRepository.markExpiredInvitations(any(LocalDateTime.class))) + .thenReturn(0); + + assertThatCode(service::markExpiredInvitations).doesNotThrowAnyException(); + verify(invitationRepository).markExpiredInvitations(any(LocalDateTime.class)); + } + + @Test + @DisplayName("swallows repository exceptions so the scheduler keeps running") + void repositoryThrows_swallowed() { + when(invitationRepository.markExpiredInvitations(any(LocalDateTime.class))) + .thenThrow(new RuntimeException("db down")); + + assertThatCode(service::markExpiredInvitations).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("deleteOldExpiredInvitations") + class DeleteOldExpiredInvitations { + + @Test + @DisplayName("deletes the rows returned by the lookup when non-empty") + void nonEmpty_deletesAll() { + List old = List.of(new TeamInvitation(), new TeamInvitation()); + when(invitationRepository.findByStatusAndExpiresAtBefore( + eq(InvitationStatus.EXPIRED), any(LocalDateTime.class))) + .thenReturn(old); + + service.deleteOldExpiredInvitations(); + + verify(invitationRepository).deleteAll(old); + } + + @Test + @DisplayName("looks up EXPIRED rows with a ~30-day cutoff in the past") + void usesThirtyDayCutoff() { + when(invitationRepository.findByStatusAndExpiresAtBefore( + eq(InvitationStatus.EXPIRED), any(LocalDateTime.class))) + .thenReturn(Collections.emptyList()); + + LocalDateTime expectedCutoff = LocalDateTime.now().minusDays(30); + service.deleteOldExpiredInvitations(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(invitationRepository) + .findByStatusAndExpiresAtBefore(eq(InvitationStatus.EXPIRED), captor.capture()); + // Cutoff is ~30 days back; allow a generous window for clock drift during the test. + assertThat(captor.getValue()) + .isBetween(expectedCutoff.minusMinutes(1), expectedCutoff.plusMinutes(1)); + } + + @Test + @DisplayName("does not call deleteAll when there is nothing to purge") + void empty_noDelete() { + when(invitationRepository.findByStatusAndExpiresAtBefore( + eq(InvitationStatus.EXPIRED), any(LocalDateTime.class))) + .thenReturn(Collections.emptyList()); + + service.deleteOldExpiredInvitations(); + + verify(invitationRepository, never()).deleteAll(any()); + } + + @Test + @DisplayName("swallows repository exceptions so the scheduler keeps running") + void repositoryThrows_swallowed() { + when(invitationRepository.findByStatusAndExpiresAtBefore( + any(InvitationStatus.class), any(LocalDateTime.class))) + .thenThrow(new RuntimeException("db down")); + + assertThatCode(service::deleteOldExpiredInvitations).doesNotThrowAnyException(); + verify(invitationRepository, never()).deleteAll(any()); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/util/AuthenticationUtilsTest.java b/app/saas/src/test/java/stirling/software/saas/util/AuthenticationUtilsTest.java new file mode 100644 index 0000000000..0353895af6 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/util/AuthenticationUtilsTest.java @@ -0,0 +1,238 @@ +package stirling.software.saas.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +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.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; +import stirling.software.proprietary.security.model.User; +import stirling.software.saas.security.EnhancedJwtAuthenticationToken; + +/** + * Unit tests for {@link AuthenticationUtils}. + * + *

Covers the three extractors across every authentication shape they branch on: {@link + * EnhancedJwtAuthenticationToken}, {@link ApiKeyAuthenticationToken}, a plain {@link + * UsernamePasswordAuthenticationToken}, a raw {@link Jwt} principal, and the SecurityException + * failure paths in {@code getCurrentUser}. {@link UserRepository} is mocked. + */ +@ExtendWith(MockitoExtension.class) +class AuthenticationUtilsTest { + + private static final UUID SUPABASE_UUID = + UUID.fromString("11111111-2222-3333-4444-555555555555"); + private static final String EMAIL = "user@example.com"; + + @Mock private UserRepository userRepository; + + private static Jwt jwt(Map claims) { + return new Jwt( + "token", + Instant.now(), + Instant.now().plusSeconds(3600), + Map.of("alg", "none"), + claims); + } + + private static EnhancedJwtAuthenticationToken enhancedJwt( + String email, String supabaseId, User user) { + Jwt jwt = jwt(Map.of("sub", supabaseId == null ? "x" : supabaseId)); + return new EnhancedJwtAuthenticationToken( + jwt, List.of(new SimpleGrantedAuthority("ROLE_USER")), email, supabaseId, user); + } + + @Nested + @DisplayName("extractSupabaseId") + class ExtractSupabaseId { + + @Test + @DisplayName("returns the supabase id for an EnhancedJwt token") + void enhancedJwt_returnsSupabaseId() { + Authentication auth = enhancedJwt(EMAIL, SUPABASE_UUID.toString(), null); + + assertThat(AuthenticationUtils.extractSupabaseId(auth)) + .isEqualTo(SUPABASE_UUID.toString()); + } + + @Test + @DisplayName("returns the name (from principal) for an ApiKey token") + void apiKey_returnsName() { + // Authenticated token derives getName() from the principal's toString. + ApiKeyAuthenticationToken auth = + new ApiKeyAuthenticationToken("the-user", "api-key-123", List.of()); + + assertThat(AuthenticationUtils.extractSupabaseId(auth)).isEqualTo("the-user"); + } + + @Test + @DisplayName("falls back to getName() for other authentication types") + void other_returnsName() { + Authentication auth = new UsernamePasswordAuthenticationToken("bob", "pw", List.of()); + + assertThat(AuthenticationUtils.extractSupabaseId(auth)).isEqualTo("bob"); + } + } + + @Nested + @DisplayName("extractEmail") + class ExtractEmail { + + @Test + @DisplayName("returns the email for an EnhancedJwt token") + void enhancedJwt_returnsEmail() { + Authentication auth = enhancedJwt(EMAIL, SUPABASE_UUID.toString(), null); + + assertThat(AuthenticationUtils.extractEmail(auth)).isEqualTo(EMAIL); + } + + @Test + @DisplayName("falls back to getName() for other authentication types") + void other_returnsName() { + Authentication auth = new UsernamePasswordAuthenticationToken("carol", "pw", List.of()); + + assertThat(AuthenticationUtils.extractEmail(auth)).isEqualTo("carol"); + } + } + + @Nested + @DisplayName("getCurrentUser") + class GetCurrentUser { + + @Test + @DisplayName("throws when authentication is null") + void nullAuth_throws() { + assertThatThrownBy(() -> AuthenticationUtils.getCurrentUser(null, userRepository)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("Not authenticated"); + } + + @Test + @DisplayName("returns the principal directly when it is already a User") + void userPrincipal_returnedDirectly() { + User user = new User(); + user.setId(7L); + Authentication auth = new UsernamePasswordAuthenticationToken(user, "pw", List.of()); + + assertThat(AuthenticationUtils.getCurrentUser(auth, userRepository)).isSameAs(user); + } + + @Test + @DisplayName("resolves an EnhancedJwt user via Supabase id lookup") + void enhancedJwt_resolvedBySupabaseId() { + User user = new User(); + user.setId(8L); + // Principal is the raw Jwt (no resolved User) so the Supabase-id branch is exercised. + Authentication auth = enhancedJwt(EMAIL, SUPABASE_UUID.toString(), null); + when(userRepository.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(user)); + + assertThat(AuthenticationUtils.getCurrentUser(auth, userRepository)).isSameAs(user); + } + + @Test + @DisplayName("throws when the EnhancedJwt Supabase id resolves to no user") + void enhancedJwt_userNotFound() { + Authentication auth = enhancedJwt(EMAIL, SUPABASE_UUID.toString(), null); + when(userRepository.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("User not found"); + } + + @Test + @DisplayName("throws when the EnhancedJwt Supabase id is not a valid UUID") + void enhancedJwt_invalidUuid() { + Authentication auth = enhancedJwt(EMAIL, "not-a-uuid", null); + + assertThatThrownBy(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("Invalid Supabase ID format"); + } + + @Test + @DisplayName("resolves a String principal via findByUsername") + void stringPrincipal_resolvedByUsername() { + User user = new User(); + user.setId(9L); + Authentication auth = new UsernamePasswordAuthenticationToken("dave", "pw", List.of()); + when(userRepository.findByUsername("dave")).thenReturn(Optional.of(user)); + + assertThat(AuthenticationUtils.getCurrentUser(auth, userRepository)).isSameAs(user); + } + + @Test + @DisplayName("throws when a String principal resolves to no user") + void stringPrincipal_userNotFound() { + Authentication auth = new UsernamePasswordAuthenticationToken("erin", "pw", List.of()); + when(userRepository.findByUsername("erin")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("User not found"); + } + + @Test + @DisplayName("resolves a raw Jwt principal via the email claim") + void jwtPrincipal_resolvedByEmail() { + User user = new User(); + user.setId(10L); + Jwt rawJwt = jwt(Map.of("sub", "abc", "email", EMAIL)); + Authentication auth = new UsernamePasswordAuthenticationToken(rawJwt, "pw", List.of()); + when(userRepository.findByUsername(EMAIL)).thenReturn(Optional.of(user)); + + assertThat(AuthenticationUtils.getCurrentUser(auth, userRepository)).isSameAs(user); + } + + @Test + @DisplayName("throws when a raw Jwt principal email resolves to no user") + void jwtPrincipal_emailUserNotFound() { + Jwt rawJwt = jwt(Map.of("sub", "abc", "email", EMAIL)); + Authentication auth = new UsernamePasswordAuthenticationToken(rawJwt, "pw", List.of()); + when(userRepository.findByUsername(EMAIL)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("User not found"); + } + + @Test + @DisplayName("throws invalid-principal when a raw Jwt has no email claim") + void jwtPrincipal_noEmail_invalidPrincipal() { + Jwt rawJwt = jwt(Map.of("sub", "abc")); + Authentication auth = new UsernamePasswordAuthenticationToken(rawJwt, "pw", List.of()); + + assertThatThrownBy(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("Invalid authentication principal"); + } + + @Test + @DisplayName("throws invalid-principal for an unrecognised principal type") + void unknownPrincipal_invalidPrincipal() { + Authentication auth = new UsernamePasswordAuthenticationToken(123, "pw", List.of()); + + assertThatThrownBy(() -> AuthenticationUtils.getCurrentUser(auth, userRepository)) + .isInstanceOf(SecurityException.class) + .hasMessageContaining("Invalid authentication principal") + .hasMessageContaining("Integer"); + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/util/LogRedactionUtilsTest.java b/app/saas/src/test/java/stirling/software/saas/util/LogRedactionUtilsTest.java new file mode 100644 index 0000000000..6f74719b68 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/util/LogRedactionUtilsTest.java @@ -0,0 +1,121 @@ +package stirling.software.saas.util; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link LogRedactionUtils}. + * + *

PII masking for log lines. Covers null/blank/edge inputs and the happy path for both email and + * Supabase-id redaction, plus the UUID overload. + */ +class LogRedactionUtilsTest { + + @Nested + @DisplayName("redactEmail") + class RedactEmail { + + @Test + @DisplayName("masks a normal email keeping the first char and the domain") + void normalEmail_masked() { + assertThat(LogRedactionUtils.redactEmail("john@stirling.com")) + .isEqualTo("j***@stirling.com"); + } + + @Test + @DisplayName("returns null unchanged") + void nullEmail_unchanged() { + assertThat(LogRedactionUtils.redactEmail(null)).isNull(); + } + + @Test + @DisplayName("returns blank unchanged") + void blankEmail_unchanged() { + assertThat(LogRedactionUtils.redactEmail(" ")).isEqualTo(" "); + } + + @Test + @DisplayName("returns input with no '@' unchanged") + void noAtSign_unchanged() { + assertThat(LogRedactionUtils.redactEmail("notanemail")).isEqualTo("notanemail"); + } + + @Test + @DisplayName("returns input starting with '@' unchanged (at index 0)") + void atSignAtStart_unchanged() { + assertThat(LogRedactionUtils.redactEmail("@stirling.com")).isEqualTo("@stirling.com"); + } + + @Test + @DisplayName("returns input ending with '@' unchanged (at is last index)") + void atSignAtEnd_unchanged() { + assertThat(LogRedactionUtils.redactEmail("john@")).isEqualTo("john@"); + } + + @Test + @DisplayName("masks a single-char local part to its only char") + void singleCharLocalPart_masked() { + assertThat(LogRedactionUtils.redactEmail("a@b.com")).isEqualTo("a***@b.com"); + } + } + + @Nested + @DisplayName("redactSupabaseId(String)") + class RedactSupabaseIdString { + + @Test + @DisplayName("masks the middle of a full UUID string") + void fullUuid_masked() { + String id = "12345678-90ab-cdef-1234-567890abcdef"; + assertThat(LogRedactionUtils.redactSupabaseId(id)).isEqualTo("12345678-***-cdef"); + } + + @Test + @DisplayName("returns null unchanged") + void nullId_unchanged() { + assertThat(LogRedactionUtils.redactSupabaseId((String) null)).isNull(); + } + + @Test + @DisplayName("returns a string shorter than 12 chars unchanged") + void shortId_unchanged() { + assertThat(LogRedactionUtils.redactSupabaseId("short")).isEqualTo("short"); + } + + @Test + @DisplayName("an 11-char string (one below the threshold) is returned unchanged") + void elevenChars_unchanged() { + assertThat(LogRedactionUtils.redactSupabaseId("12345678901")).isEqualTo("12345678901"); + } + + @Test + @DisplayName("a 12-char string (exactly at the threshold) is masked") + void twelveChars_masked() { + assertThat(LogRedactionUtils.redactSupabaseId("123456789012")) + .isEqualTo("12345678-***-9012"); + } + } + + @Nested + @DisplayName("redactSupabaseId(UUID)") + class RedactSupabaseIdUuid { + + @Test + @DisplayName("masks a UUID by delegating to the string overload") + void uuid_masked() { + UUID id = UUID.fromString("12345678-90ab-cdef-1234-567890abcdef"); + assertThat(LogRedactionUtils.redactSupabaseId(id)).isEqualTo("12345678-***-cdef"); + } + + @Test + @DisplayName("returns null for a null UUID") + void nullUuid_returnsNull() { + assertThat(LogRedactionUtils.redactSupabaseId((UUID) null)).isNull(); + } + } +} diff --git a/build.gradle b/build.gradle index c7413605d1..b8423fc920 100644 --- a/build.gradle +++ b/build.gradle @@ -78,7 +78,7 @@ springBoot { allprojects { group = 'stirling.software' - version = '2.13.0' + version = '2.13.1' configurations.configureEach { exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat" diff --git a/frontend/editor/public/Login/azure.svg b/frontend/editor/public/Login/azure.svg deleted file mode 100644 index fc1130cbb2..0000000000 --- a/frontend/editor/public/Login/azure.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 473f5917c8..a13acd01ba 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -8620,6 +8620,7 @@ disableByAdmin = "Disable MFA" [workspace.people.roleDescriptions] admin = "Can manage settings and invite users, with full administrative access." +currentRole = "Current assigned role." user = "Can view and edit shared files, but cannot manage workspace settings or users." [workspace.people.toggleEnabled] diff --git a/frontend/editor/scripts/tsconfig.json b/frontend/editor/scripts/tsconfig.json index 5f95926cbb..38e4f505a3 100644 --- a/frontend/editor/scripts/tsconfig.json +++ b/frontend/editor/scripts/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "module": "node16", "moduleResolution": "node16", + "types": ["node"], "noEmit": true }, "include": ["./**/*.ts", "./**/*.mts"] diff --git a/frontend/editor/src-tauri/tauri.conf.json b/frontend/editor/src-tauri/tauri.conf.json index 376f1027cd..55dba6de7d 100644 --- a/frontend/editor/src-tauri/tauri.conf.json +++ b/frontend/editor/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Stirling-PDF", - "version": "2.13.0", + "version": "2.13.1", "identifier": "stirling.pdf.dev", "build": { "frontendDist": "../dist", diff --git a/frontend/editor/src/cloud/tsconfig.json b/frontend/editor/src/cloud/tsconfig.json index bddc25d1bf..bf3eedb2e5 100644 --- a/frontend/editor/src/cloud/tsconfig.json +++ b/frontend/editor/src/cloud/tsconfig.json @@ -1,13 +1,16 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "baseUrl": "../../", "paths": { - "@app/*": ["src/cloud/*", "src/proprietary/*", "src/core/*"], - "@cloud/*": ["src/cloud/*"], - "@proprietary/*": ["src/proprietary/*"], - "@core/*": ["src/core/*"], - "@shared/*": ["../shared/*"] + "@app/*": [ + "../../src/cloud/*", + "../../src/proprietary/*", + "../../src/core/*" + ], + "@cloud/*": ["../../src/cloud/*"], + "@proprietary/*": ["../../src/proprietary/*"], + "@core/*": ["../../src/core/*"], + "@shared/*": ["../../../shared/*"] } }, "include": [ diff --git a/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx index 4898c5a825..6f0e0e587c 100644 --- a/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.tsx @@ -31,7 +31,9 @@ function FirstLoginForm({ const [loading, setLoading] = useState(false); const [error, setError] = useState(""); - const handleSubmit = async () => { + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + // Validation if ( (!usingDefaultCredentials && !currentPassword) || @@ -115,102 +117,104 @@ function FirstLoginForm({ return (

- -
- - - {t( - "firstLogin.welcomeMessage", - "For security reasons, you must change your password on your first login.", - )} - -
+
+ +
+ + + {t( + "firstLogin.welcomeMessage", + "For security reasons, you must change your password on your first login.", + )} + +
- - {t("firstLogin.loggedInAs", "Logged in as")}:{" "} - {username} - + + {t("firstLogin.loggedInAs", "Logged in as")}:{" "} + {username} + - {error && ( - - } - color="red" - variant="light" - > - {error} - - )} + {error && ( + + } + color="red" + variant="light" + > + {error} + + )} + + {/* Only show current password field if not using default credentials */} + {!usingDefaultCredentials && ( + setCurrentPassword(e.currentTarget.value)} + required + styles={{ + input: { height: 44 }, + }} + /> + )} - {/* Only show current password field if not using default credentials */} - {!usingDefaultCredentials && ( setCurrentPassword(e.currentTarget.value)} + value={newPassword} + onChange={(e) => setNewPassword(e.currentTarget.value)} + minLength={8} required styles={{ input: { height: 44 }, }} /> - )} - setNewPassword(e.currentTarget.value)} - minLength={8} - required - styles={{ - input: { height: 44 }, - }} - /> + setConfirmPassword(e.currentTarget.value)} + required + minLength={8} + styles={{ + input: { height: 44 }, + }} + /> - setConfirmPassword(e.currentTarget.value)} - required - minLength={8} - styles={{ - input: { height: 44 }, - }} - /> - - -
+ +
+
); diff --git a/frontend/editor/src/core/components/pageEditor/hooks/usePageEditorExport.ts b/frontend/editor/src/core/components/pageEditor/hooks/usePageEditorExport.ts index 2aca4a1d07..35167564f7 100644 --- a/frontend/editor/src/core/components/pageEditor/hooks/usePageEditorExport.ts +++ b/frontend/editor/src/core/components/pageEditor/hooks/usePageEditorExport.ts @@ -306,6 +306,7 @@ export const usePageEditorExport = ({ const newStirlingFiles = await actions.addFiles(renamedFiles, { selectFiles: true, + skipUploadTracking: true, }); if (newStirlingFiles.length > 0) { actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId)); diff --git a/frontend/editor/src/core/components/shared/FirstLoginModal.tsx b/frontend/editor/src/core/components/shared/FirstLoginModal.tsx index 00dc4594b5..4d7764f91d 100644 --- a/frontend/editor/src/core/components/shared/FirstLoginModal.tsx +++ b/frontend/editor/src/core/components/shared/FirstLoginModal.tsx @@ -37,7 +37,9 @@ export default function FirstLoginModal({ const [loading, setLoading] = useState(false); const [error, setError] = useState(""); - const handleSubmit = async () => { + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + // Validation if (!currentPassword || !newPassword || !confirmPassword) { setError(t("firstLogin.allFieldsRequired", "All fields are required")); @@ -125,86 +127,90 @@ export default function FirstLoginModal({ size="md" zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} > - - } - title={t("firstLogin.welcomeTitle", "Welcome!")} - color="blue" - > - - {t( - "firstLogin.welcomeMessage", - "For security reasons, you must change your password on your first login.", - )} - - - - - {t("firstLogin.loggedInAs", "Logged in as")}:{" "} - {username} - - - {error && ( +
+ } - title={t("firstLogin.error", "Error")} - color="red" + icon={} + title={t("firstLogin.welcomeTitle", "Welcome!")} + color="blue" > - {error} + + {t( + "firstLogin.welcomeMessage", + "For security reasons, you must change your password on your first login.", + )} + - )} - + {t("firstLogin.loggedInAs", "Logged in as")}:{" "} + {username} + + + {error && ( + + } + title={t("firstLogin.error", "Error")} + color="red" + > + {error} + )} - value={currentPassword} - onChange={(e) => setCurrentPassword(e.currentTarget.value)} - required - /> - setNewPassword(e.currentTarget.value)} - minLength={8} - required - /> + setCurrentPassword(e.currentTarget.value)} + required + /> - setConfirmPassword(e.currentTarget.value)} - minLength={8} - required - /> + setNewPassword(e.currentTarget.value)} + minLength={8} + required + /> - - + setConfirmPassword(e.currentTarget.value)} + minLength={8} + required + /> + + +
+ ); } diff --git a/frontend/editor/src/core/components/shared/HoverActionMenu.tsx b/frontend/editor/src/core/components/shared/HoverActionMenu.tsx index f4cfb247a1..fafebdf8f2 100644 --- a/frontend/editor/src/core/components/shared/HoverActionMenu.tsx +++ b/frontend/editor/src/core/components/shared/HoverActionMenu.tsx @@ -64,6 +64,7 @@ const HoverActionMenu: React.FC = ({ disabled={action.disabled} onClick={action.onClick} c={action.color} + aria-label={action.label} style={{ color: action.color || "var(--text-secondary)" }} data-tour={action.dataTour} > diff --git a/frontend/editor/src/core/components/shared/UpdateModal.tsx b/frontend/editor/src/core/components/shared/UpdateModal.tsx index ca052ae523..1399325b03 100644 --- a/frontend/editor/src/core/components/shared/UpdateModal.tsx +++ b/frontend/editor/src/core/components/shared/UpdateModal.tsx @@ -24,7 +24,7 @@ import { MachineInfo, } from "@app/services/updateService"; import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; -import { openExternal } from "@app/platform/openExternal"; +import { handleExternalLinkClick } from "@app/platform/externalLinkClick"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import DownloadIcon from "@mui/icons-material/Download"; @@ -37,19 +37,6 @@ import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandLessIcon from "@mui/icons-material/ExpandLess"; -/** - * Best-effort Tauri detection without importing `@tauri-apps/api` into the - * core bundle (which must stay runnable on plain web). Tauri v2 injects - * `__TAURI_INTERNALS__` before any user code runs. Mirrors UpdateStartupPopup. - */ -function isRunningInTauri(): boolean { - if (typeof window === "undefined") return false; - return ( - typeof (window as unknown as { __TAURI_INTERNALS__?: unknown }) - .__TAURI_INTERNALS__ !== "undefined" - ); -} - export type DesktopInstallState = | "idle" | "downloading" @@ -211,18 +198,10 @@ const UpdateModal: React.FC = ({ onClose(); }; - // External links (release notes, migration guides, download fallback) use - // real anchors so they open a new tab on web. Inside Tauri the webview traps - // target="_blank", so on desktop we intercept and hand the URL to the OS - // browser via the platform seam. stopPropagation keeps links nested in the - // clickable version-history rows from toggling the row. const handleExternalLink = (url: string) => (e: React.MouseEvent) => { e.stopPropagation(); - if (isRunningInTauri()) { - e.preventDefault(); - void openExternal(url); - } + handleExternalLinkClick(url, e); }; // Sort versions newest first, skip the latest (already shown in header) diff --git a/frontend/editor/src/core/components/shared/UpdateStartupPopup.tsx b/frontend/editor/src/core/components/shared/UpdateStartupPopup.tsx index f3e2a446b9..62f6dbb999 100644 --- a/frontend/editor/src/core/components/shared/UpdateStartupPopup.tsx +++ b/frontend/editor/src/core/components/shared/UpdateStartupPopup.tsx @@ -20,29 +20,9 @@ const STARTUP_DELAY_MS = 15_000; const SNOOZE_KEY = "stirling-pdf-updater:snoozedUntil"; const SNOOZE_DURATION_MS = 24 * 60 * 60 * 1000; -/** - * Best-effort Tauri detection without importing `@tauri-apps/api` into the - * core bundle (which must remain runnable on plain web). Tauri v2 injects - * `__TAURI_INTERNALS__` before any user code runs. - */ -function isRunningInTauri(): boolean { - if (typeof window === "undefined") return false; - return ( - typeof (window as unknown as { __TAURI_INTERNALS__?: unknown }) - .__TAURI_INTERNALS__ !== "undefined" - ); -} - /** * Web/server-side auto-popup that shows the UpdateModal on startup when a - * newer Stirling-PDF version is available. Previously this check only ran - * from the Settings → General "Check for Updates" button, so non-desktop - * users could sit on stale versions indefinitely without any prompt. - * - * On desktop (Tauri) this component is a no-op — `useDesktopUpdatePopup` - * drives the desktop flow because it also has to honour the headless - * `updateMode` provisioning flag and wire up the silent/auto installer. - * Running both would double-popup. + * newer Stirling-PDF version is available. */ export function UpdateStartupPopup() { const { config } = useAppConfig(); @@ -60,8 +40,6 @@ export function UpdateStartupPopup() { const hasChecked = useRef(false); useEffect(() => { - // Skip on desktop — the Tauri popup owns that flow end-to-end. - if (isRunningInTauri()) return; if (hasChecked.current) return; if (!currentVersion) return; // Don't even schedule the timer until we have a version to compare. diff --git a/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx index ca6744ccc9..bb157558ec 100644 --- a/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx +++ b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx @@ -191,8 +191,9 @@ export default function ProviderCard({ }; const renderProviderIcon = () => { - // If icon starts with '/', it's a path to an SVG file - if (provider.icon.startsWith("/")) { + // Image source: an absolute/relative path, a data: URI (small bundled SVGs + // are inlined), or a full URL. Iconify names ("key-rounded") use LocalIcon. + if (/^(\/|\.\.?\/|data:|blob:|https?:)/.test(provider.icon)) { return ( { return { id: "google", name: "Google", - icon: "/Login/google.svg", + icon: oauthIconUrl("google.svg"), type: "oauth2", scope: t("provider.oauth2.google.scope", "Sign-in authentication"), documentationUrl: @@ -86,7 +87,7 @@ const useGitHubProvider = (): Provider => { return { id: "github", name: "GitHub", - icon: "/Login/github.svg", + icon: oauthIconUrl("github.svg"), type: "oauth2", scope: t("provider.oauth2.github.scope", "Sign-in authentication"), documentationUrl: diff --git a/frontend/editor/src/core/components/shared/signing/SignPopout.tsx b/frontend/editor/src/core/components/shared/signing/SignPopout.tsx index 3e7eb0c9bd..aece0cb2da 100644 --- a/frontend/editor/src/core/components/shared/signing/SignPopout.tsx +++ b/frontend/editor/src/core/components/shared/signing/SignPopout.tsx @@ -669,7 +669,7 @@ const SignPopout = ({ const signedFile = new File([response.data], filename, { type: "application/pdf", }); - await fileActions.addFiles([signedFile]); + await fileActions.addFiles([signedFile], { skipUploadTracking: true }); alert({ alertType: "success", title: t("success"), @@ -700,7 +700,7 @@ const SignPopout = ({ const signedFile = new File([response.data], filename, { type: "application/pdf", }); - await fileActions.addFiles([signedFile]); + await fileActions.addFiles([signedFile], { skipUploadTracking: true }); alert({ alertType: "success", title: t("success"), diff --git a/frontend/editor/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx b/frontend/editor/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx index 04756abdfe..fa2512fd04 100644 --- a/frontend/editor/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx +++ b/frontend/editor/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx @@ -258,7 +258,7 @@ const SignRequestWorkbenchView = ({ data }: SignRequestWorkbenchViewProps) => { }; const handleAddToActiveFiles = async () => { - await fileActions.addFiles([pdfFile]); + await fileActions.addFiles([pdfFile], { skipUploadTracking: true }); alert({ alertType: "success", title: t("success"), diff --git a/frontend/editor/src/core/contexts/FileContext.tsx b/frontend/editor/src/core/contexts/FileContext.tsx index 6621bf0efd..0840d49a72 100644 --- a/frontend/editor/src/core/contexts/FileContext.tsx +++ b/frontend/editor/src/core/contexts/FileContext.tsx @@ -243,6 +243,7 @@ function FileContextInner({ skipAutoUnzip?: boolean; /** Persist to IDB without dispatching to workspace state. */ skipWorkspaceDispatch?: boolean; + skipUploadTracking?: boolean; }, ): Promise => { const stirlingFiles = await addFiles( @@ -286,6 +287,7 @@ function FileContextInner({ fileName: string, ) => Promise; allowDuplicates?: boolean; + skipUploadTracking?: boolean; }, ): Promise => { const stirlingFiles = await addFiles( diff --git a/frontend/editor/src/core/contexts/file/fileActions.ts b/frontend/editor/src/core/contexts/file/fileActions.ts index c4d6ff1f88..749e820e41 100644 --- a/frontend/editor/src/core/contexts/file/fileActions.ts +++ b/frontend/editor/src/core/contexts/file/fileActions.ts @@ -20,6 +20,7 @@ import { StirlingFile } from "@app/types/fileContext"; import { fileStorage } from "@app/services/fileStorage"; import { zipFileService } from "@app/services/zipFileService"; import { FileAnalyzer } from "@app/services/fileAnalyzer"; +import { trackPdfUploaded } from "@app/services/analytics"; const DEBUG = process.env.NODE_ENV === "development"; const HYDRATION_CONCURRENCY = 2; let activeHydrations = 0; @@ -252,6 +253,7 @@ interface AddFileOptions { fileName: string, ) => Promise; // Optional callback to confirm extraction of large ZIP files allowDuplicates?: boolean; + skipUploadTracking?: boolean; } /** @@ -538,6 +540,10 @@ export async function addFiles( ); } + if (!options.skipUploadTracking && stirlingFiles.length > 0) { + trackPdfUploaded(stirlingFiles); + } + return stirlingFiles; } finally { // Always release mutex even if error occurs diff --git a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts index 52b4ce379d..5629dfe54b 100644 --- a/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/editor/src/core/hooks/tools/shared/useToolOperation.ts @@ -30,6 +30,7 @@ import { import { createNewStirlingFileStub } from "@app/types/fileContext"; import { ToolOperation } from "@app/types/file"; import { ensureBackendReady } from "@app/services/backendReadinessGuard"; +import { trackEditorOperation } from "@app/services/analytics"; import { useWillUseCloud } from "@app/hooks/useWillUseCloud"; import { useCreditCheck } from "@app/hooks/useCreditCheck"; import { notifyPdfProcessingComplete } from "@app/services/desktopNotificationService"; @@ -384,6 +385,11 @@ export const useToolOperation = ( } if (processedFiles.length > 0) { + trackEditorOperation( + config.operationType, + successSourceIds.length || validFiles.length, + ); + actions.setFiles(processedFiles); // Generate thumbnails and download URL concurrently diff --git a/frontend/editor/src/core/i18n.ts b/frontend/editor/src/core/i18n.ts index cf0fcf7b5f..82b860b467 100644 --- a/frontend/editor/src/core/i18n.ts +++ b/frontend/editor/src/core/i18n.ts @@ -1,73 +1,29 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import LanguageDetector from "i18next-browser-languagedetector"; -import TomlBackend from "@app/i18n/tomlBackend"; +import TomlBackend from "@shared/i18n/tomlBackend"; +import { + supportedLanguages, + rtlLanguages, + I18N_STORAGE_KEYS, + LanguageSource, + normalizeLanguageCode, + toUnderscoreFormat, + toUnderscoreLanguages, +} from "@shared/i18n/languages"; -// Define supported languages (based on your existing translations) -export const supportedLanguages = { - "en-US": "English (US)", - "en-GB": "English (UK)", - "ar-AR": "العربية", - "az-AZ": "Azərbaycan Dili", - "bg-BG": "Български", - "ca-CA": "Català", - "cs-CZ": "Česky", - "da-DK": "Dansk", - "de-DE": "Deutsch", - "el-GR": "Ελληνικά", - "es-ES": "Español", - "eu-ES": "Euskara", - "fa-IR": "فارسی", - "fr-FR": "Français", - "ga-IE": "Gaeilge", - "hi-IN": "हिंदी", - "hr-HR": "Hrvatski", - "hu-HU": "Magyar", - "id-ID": "Bahasa Indonesia", - "it-IT": "Italiano", - "ja-JP": "日本語", - "ko-KR": "한국어", - "ml-ML": "മലയാളം", - "nl-NL": "Nederlands", - "no-NB": "Norsk", - "pl-PL": "Polski", - "pt-BR": "Português (Brasil)", - "pt-PT": "Português", - "ro-RO": "Română", - "ru-RU": "Русский", - "sk-SK": "Slovensky", - "sl-SI": "Slovenščina", - "sr-LATN-RS": "Srpski", - "sv-SE": "Svenska", - "th-TH": "ไทย", - "tr-TR": "Türkçe", - "uk-UA": "Українська", - "vi-VN": "Tiếng Việt", - "zh-BO": "བོད་ཡིག", - "zh-CN": "简体中文", - "zh-TW": "繁體中文", +// Language metadata and code helpers are shared with the portal via +// @shared/i18n. Re-export them so existing `@app/i18n` consumers are unchanged. +export { + supportedLanguages, + rtlLanguages, + I18N_STORAGE_KEYS, + LanguageSource, + normalizeLanguageCode, + toUnderscoreFormat, + toUnderscoreLanguages, }; -// RTL languages (based on your existing language.direction property) -export const rtlLanguages = ["ar-AR", "fa-IR"]; - -// LocalStorage keys for i18next -export const I18N_STORAGE_KEYS = { - LANGUAGE: "i18nextLng", - LANGUAGE_SOURCE: "i18nextLng-source", -} as const; - -/** - * Language selection priority levels - * Higher number = higher priority (cannot be overridden by lower priority) - */ -export enum LanguageSource { - Fallback = 0, - Browser = 1, - ServerDefault = 2, - User = 3, -} - i18n .use(TomlBackend) .use(LanguageDetector) @@ -139,36 +95,6 @@ i18n.on("initialized", () => { } }); -export function normalizeLanguageCode(languageCode: string): string { - // Replace underscores with hyphens to align with i18next/translation file naming - const hyphenated = languageCode.replace(/_/g, "-"); - const [base, ...rest] = hyphenated.split("-"); - - if (rest.length === 0) { - return base.toLowerCase(); - } - - const normalizedParts = rest.map((part) => - part.length <= 3 ? part.toUpperCase() : part, - ); - return [base.toLowerCase(), ...normalizedParts].join("-"); -} - -/** - * Convert language codes to underscore format (e.g., en-US → en_US) - * Used for backend API communication which expects underscore format - */ -export function toUnderscoreFormat(languageCode: string): string { - return languageCode.replace(/-/g, "_"); -} - -/** - * Convert array of language codes to underscore format - */ -export function toUnderscoreLanguages(languages: string[]): string[] { - return languages.map(toUnderscoreFormat); -} - /** * Get the current language source priority */ diff --git a/frontend/editor/src/core/i18n/config.ts b/frontend/editor/src/core/i18n/config.ts index 02b79f06b1..47e028c1f4 100644 --- a/frontend/editor/src/core/i18n/config.ts +++ b/frontend/editor/src/core/i18n/config.ts @@ -1,6 +1,6 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; -import TomlBackend from "@app/i18n/tomlBackend"; +import TomlBackend from "@shared/i18n/tomlBackend"; i18n .use(TomlBackend) diff --git a/frontend/editor/src/core/pages/MobileScannerPage.tsx b/frontend/editor/src/core/pages/MobileScannerPage.tsx index 0424125aa8..0d14ce7ee8 100644 --- a/frontend/editor/src/core/pages/MobileScannerPage.tsx +++ b/frontend/editor/src/core/pages/MobileScannerPage.tsx @@ -20,12 +20,32 @@ import PhotoCameraRoundedIcon from "@mui/icons-material/PhotoCameraRounded"; import UploadRoundedIcon from "@mui/icons-material/UploadRounded"; import AddPhotoAlternateRoundedIcon from "@mui/icons-material/AddPhotoAlternateRounded"; import CheckCircleRoundedIcon from "@mui/icons-material/CheckCircleRounded"; -import { loadJscanify } from "@app/utils/loadJscanify"; +import { + loadJscanify, + type JscanifyCornerPoints, + type JscanifyScanner, +} from "@app/utils/loadJscanify"; import apiClient from "@app/services/apiClient"; // Use the configured API base (e.g. api.stirling.com), not the page origin. const API_BASE = (apiClient.defaults.baseURL ?? "").replace(/\/+$/, ""); +// Experimental camera controls (W3C Image Capture / MediaStream extensions) that +// are not yet part of the standard DOM lib typings but are widely shipped on +// mobile browsers and required for document scanning. +declare global { + interface MediaTrackCapabilities { + focusMode?: string[]; + exposureMode?: string[]; + torch?: boolean; + } + interface MediaTrackConstraintSet { + focusMode?: ConstrainDOMString; + exposureMode?: ConstrainDOMString; + torch?: ConstrainBoolean; + } +} + /** * MobileScannerPage * @@ -63,7 +83,7 @@ export default function MobileScannerPage() { const highlightCanvasRef = useRef(null); const streamRef = useRef(null); const fileInputRef = useRef(null); - const scannerRef = useRef(null); + const scannerRef = useRef(null); const highlightIntervalRef = useRef(null); // Detection resolution - extremely low for mobile performance @@ -254,15 +274,15 @@ export default function MobileScannerPage() { // Configure camera capabilities for document scanning try { - const capabilities = videoTrack.getCapabilities() as any; // Cast to any for experimental camera APIs - const constraints: any = { advanced: [] }; + const capabilities = videoTrack.getCapabilities(); + const advanced: MediaTrackConstraintSet[] = []; // 1. Enable continuous autofocus if ( capabilities.focusMode && capabilities.focusMode.includes("continuous") ) { - constraints.advanced.push({ focusMode: "continuous" }); + advanced.push({ focusMode: "continuous" }); console.log("✓ Continuous autofocus enabled"); } @@ -271,7 +291,7 @@ export default function MobileScannerPage() { capabilities.exposureMode && capabilities.exposureMode.includes("continuous") ) { - constraints.advanced.push({ exposureMode: "continuous" }); + advanced.push({ exposureMode: "continuous" }); console.log("✓ Auto-exposure enabled"); } @@ -282,8 +302,8 @@ export default function MobileScannerPage() { } // Apply all constraints - if (constraints.advanced.length > 0) { - await videoTrack.applyConstraints(constraints); + if (advanced.length > 0) { + await videoTrack.applyConstraints({ advanced }); } } catch (err) { console.log("Could not configure camera features:", err); @@ -444,15 +464,19 @@ export default function MobileScannerPage() { // Step 2: Simple jscanify detection const detectionStart = performance.now(); - let corners = null; + let corners: JscanifyCornerPoints | null = null; // Run jscanify detection directly - convert canvas to Mat first - const mat = (window as any).cv.imread(detectionCanvas); - const contour = scannerRef.current.findPaperContour(mat); - mat.delete(); + const cv = window.cv; + const scanner = scannerRef.current; + if (cv && scanner) { + const mat = cv.imread(detectionCanvas); + const contour = scanner.findPaperContour(mat); + mat.delete(); - if (contour) { - corners = scannerRef.current.getCornerPoints(contour); + if (contour) { + corners = scanner.getCornerPoints(contour); + } } const detectionTime = performance.now() - detectionStart; @@ -660,7 +684,9 @@ export default function MobileScannerPage() { let finalDataUrl: string; // Apply jscanify processing if enabled and available - if (autoEnhance && scannerRef.current && openCvReady) { + const cv = window.cv; + const scanner = scannerRef.current; + if (autoEnhance && scanner && openCvReady && cv) { try { // Create low-res canvas for detection (faster processing) const detectionCanvas = document.createElement("canvas"); @@ -683,11 +709,11 @@ export default function MobileScannerPage() { ); // Run detection on low-res image - const mat = (window as any).cv.imread(detectionCanvas); - const contour = scannerRef.current.findPaperContour(mat); + const mat = cv.imread(detectionCanvas); + const contour = scanner.findPaperContour(mat); if (contour) { - const cornerPoints = scannerRef.current.getCornerPoints(contour); + const cornerPoints = scanner.getCornerPoints(contour); // Scale corner points back to full resolution if (cornerPoints) { @@ -746,7 +772,7 @@ export default function MobileScannerPage() { const docHeight = Math.round((leftHeight + rightHeight) / 2); // Extract paper from full-resolution canvas with scaled corner points - const resultCanvas = scannerRef.current.extractPaper( + const resultCanvas = scanner.extractPaper( canvas, docWidth, docHeight, @@ -891,8 +917,8 @@ export default function MobileScannerPage() { try { const videoTrack = streamRef.current.getVideoTracks()[0]; await videoTrack.applyConstraints({ - advanced: [{ torch: !torchEnabled } as any], // Cast to any for experimental torch API - } as any); + advanced: [{ torch: !torchEnabled }], + }); setTorchEnabled(!torchEnabled); console.log("Torch:", !torchEnabled ? "ON" : "OFF"); } catch (err) { diff --git a/frontend/editor/src/core/platform/externalLinkClick.ts b/frontend/editor/src/core/platform/externalLinkClick.ts new file mode 100644 index 0000000000..9be70d062f --- /dev/null +++ b/frontend/editor/src/core/platform/externalLinkClick.ts @@ -0,0 +1,15 @@ +import type { MouseEvent } from "react"; + +/** + * Click handler for external `` links rendered by shared + * components (e.g. UpdateModal release notes / migration guides). + * + * In a normal browser a `target="_blank"` anchor already opens the URL in a new + * tab, so this default does nothing and lets the native navigation proceed. + * Builds whose webview traps `target="_blank"` inside the app window shadow this + * module to intercept the click and route the URL to the OS browser instead. + */ +export function handleExternalLinkClick( + _url: string, + _event: MouseEvent, +): void {} diff --git a/frontend/editor/src/core/services/analytics.test.ts b/frontend/editor/src/core/services/analytics.test.ts new file mode 100644 index 0000000000..3334066cb9 --- /dev/null +++ b/frontend/editor/src/core/services/analytics.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const capture = vi.fn(); +let optedIn = true; + +vi.mock("posthog-js", () => ({ + default: { + __loaded: true, + has_opted_in_capturing: () => optedIn, + capture: (...args: unknown[]) => capture(...args), + }, +})); + +import { + trackPdfUploaded, + trackEditorOperation, +} from "@app/services/analytics"; + +function pdf(name: string, size = 100): File { + return new File([new Uint8Array(size)], name, { type: "application/pdf" }); +} + +describe("analytics", () => { + beforeEach(() => { + capture.mockClear(); + optedIn = true; + }); + + it("captures one event per uploaded PDF (no dedup)", () => { + trackPdfUploaded([pdf("a.pdf"), pdf("a.pdf"), pdf("b.pdf")]); + expect(capture).toHaveBeenCalledTimes(3); + expect(capture).toHaveBeenCalledWith("editor_pdf_uploaded", { + source: "editor", + }); + }); + + it("counts every uploaded file regardless of type", () => { + trackPdfUploaded([ + new File(["x"], "a.png", { type: "image/png" }), + pdf("b.pdf"), + ]); + expect(capture).toHaveBeenCalledTimes(2); + }); + + it("captures one event per editor operation run", () => { + trackEditorOperation("compress", 3); + expect(capture).toHaveBeenCalledWith("editor_operation", { + source: "editor", + tool: "compress", + file_count: 3, + }); + }); + + it("does not capture when opted out", () => { + optedIn = false; + trackPdfUploaded([pdf("a.pdf")]); + trackEditorOperation("compress", 1); + expect(capture).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/core/services/analytics.ts b/frontend/editor/src/core/services/analytics.ts new file mode 100644 index 0000000000..f4ea317eab --- /dev/null +++ b/frontend/editor/src/core/services/analytics.ts @@ -0,0 +1,40 @@ +import posthog from "posthog-js"; + +const DEV = process.env.NODE_ENV === "development"; + +function canCapture(): boolean { + if (typeof window === "undefined") return false; + const ph = posthog as unknown as { + __loaded?: boolean; + has_opted_in_capturing?: () => boolean; + }; + if (!ph.__loaded) return false; + return ( + typeof ph.has_opted_in_capturing !== "function" || + ph.has_opted_in_capturing() + ); +} + +export function trackPdfUploaded(files: File[]): void { + try { + if (!canCapture() || !files) return; + for (let i = 0; i < files.length; i++) { + posthog.capture("editor_pdf_uploaded", { source: "editor" }); + } + } catch (error) { + if (DEV) console.warn("[analytics] trackPdfUploaded failed", error); + } +} + +export function trackEditorOperation(toolId: string, fileCount: number): void { + try { + if (!canCapture()) return; + posthog.capture("editor_operation", { + source: "editor", + tool: toolId, + file_count: fileCount, + }); + } catch (error) { + if (DEV) console.warn("[analytics] trackEditorOperation failed", error); + } +} diff --git a/frontend/editor/src/core/services/fileSyncService.ts b/frontend/editor/src/core/services/fileSyncService.ts index 8d80563f8e..5e948f8ba7 100644 --- a/frontend/editor/src/core/services/fileSyncService.ts +++ b/frontend/editor/src/core/services/fileSyncService.ts @@ -394,6 +394,7 @@ export async function materializeServerStubs( autoUnzip: boolean; skipAutoUnzip: boolean; allowDuplicates: boolean; + skipUploadTracking?: boolean; }, ) => Promise; updateStub: (id: FileId, updates: Partial) => void; @@ -463,6 +464,7 @@ export async function materializeServerStubs( autoUnzip: false, skipAutoUnzip: true, allowDuplicates: true, + skipUploadTracking: true, }); if (ingested.length === 0) continue; const primary = ingested[ingested.length - 1]!; diff --git a/frontend/editor/src/core/services/pdfExportService.ts b/frontend/editor/src/core/services/pdfExportService.ts index da7bbc9a0b..1883d9efc9 100644 --- a/frontend/editor/src/core/services/pdfExportService.ts +++ b/frontend/editor/src/core/services/pdfExportService.ts @@ -135,11 +135,12 @@ export class PDFExportService { if (page.isBlankPage || page.originalPageNumber === -1) { // Insert a blank A4 page await addNewPage(destDocPtr, insertIdx, A4_WIDTH, A4_HEIGHT); - // Apply rotation - const pdfiumRotation = degreesToPdfiumRotation(page.rotation); - if (pdfiumRotation !== 0) { - await setPageRotation(destDocPtr, insertIdx, pdfiumRotation); - } + // Set absolute rotation (incl. 0) so editor rotation wins over source. + await setPageRotation( + destDocPtr, + insertIdx, + degreesToPdfiumRotation(page.rotation), + ); insertIdx++; } else if (page.originalFileId && loadedDocs.has(page.originalFileId)) { const srcDocPtr = loadedDocs.get(page.originalFileId)!; @@ -155,17 +156,18 @@ export class PDFExportService { pageRange, insertIdx, ); - if (!imported) { + if (imported) { + // Set absolute rotation (incl. 0) so editor rotation wins over source. + await setPageRotation( + destDocPtr, + insertIdx, + degreesToPdfiumRotation(page.rotation), + ); + } else { console.warn( `[PDFExport] importPages failed for fileId=${page.originalFileId} pageRange=${pageRange} — page will be missing from output.`, ); } - - // Apply rotation - const pdfiumRotation = degreesToPdfiumRotation(page.rotation); - if (pdfiumRotation !== 0) { - await setPageRotation(destDocPtr, insertIdx, pdfiumRotation); - } insertIdx++; } } else { @@ -211,10 +213,12 @@ export class PDFExportService { for (const page of pages) { if (page.isBlankPage || page.originalPageNumber === -1) { await addNewPage(destDocPtr, insertIdx, A4_WIDTH, A4_HEIGHT); - const pdfiumRotation = degreesToPdfiumRotation(page.rotation); - if (pdfiumRotation !== 0) { - await setPageRotation(destDocPtr, insertIdx, pdfiumRotation); - } + // Set absolute rotation (incl. 0) so editor rotation wins over source. + await setPageRotation( + destDocPtr, + insertIdx, + degreesToPdfiumRotation(page.rotation), + ); insertIdx++; } else { const sourcePageIndex = page.originalPageNumber - 1; @@ -227,16 +231,18 @@ export class PDFExportService { pageRange, insertIdx, ); - if (!imported) { + if (imported) { + // Set absolute rotation (incl. 0) so editor rotation wins over source. + await setPageRotation( + destDocPtr, + insertIdx, + degreesToPdfiumRotation(page.rotation), + ); + } else { console.warn( `[PDFExport] importPages failed for page ${page.originalPageNumber} pageRange=${pageRange} — page will be missing from output.`, ); } - - const pdfiumRotation = degreesToPdfiumRotation(page.rotation); - if (pdfiumRotation !== 0) { - await setPageRotation(destDocPtr, insertIdx, pdfiumRotation); - } insertIdx++; } } diff --git a/frontend/editor/src/core/testing/serverExperienceSimulations.ts b/frontend/editor/src/core/testing/serverExperienceSimulations.ts index eee4f85851..8338efb9e9 100644 --- a/frontend/editor/src/core/testing/serverExperienceSimulations.ts +++ b/frontend/editor/src/core/testing/serverExperienceSimulations.ts @@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: "2.13.0", + appVersion: "2.13.1", serverCertificateEnabled: false, enableAlphaFunctionality: false, serverPort: 8080, diff --git a/frontend/editor/src/core/tests/convert/ConvertIntegration.test.tsx b/frontend/editor/src/core/tests/convert/ConvertIntegration.test.tsx index e0555d25f8..06f9198f05 100644 --- a/frontend/editor/src/core/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/editor/src/core/tests/convert/ConvertIntegration.test.tsx @@ -123,7 +123,7 @@ describe("Convert Tool Integration Tests", () => { beforeEach(() => { vi.clearAllMocks(); // Setup default apiClient mock - mockedApiClient.post = vi.fn() as any; + mockedApiClient.post = vi.fn() as typeof mockedApiClient.post; }); afterEach(() => { diff --git a/frontend/editor/src/core/tests/live/watched-folders.spec.ts b/frontend/editor/src/core/tests/live/watched-folders.spec.ts index 8f6e09d4b6..6ee7dddd7c 100644 --- a/frontend/editor/src/core/tests/live/watched-folders.spec.ts +++ b/frontend/editor/src/core/tests/live/watched-folders.spec.ts @@ -83,7 +83,10 @@ async function getIDBFolders( const all = tx.objectStore(storeName).getAll(); all.onsuccess = () => resolve( - (all.result || []).map((f: any) => ({ id: f.id, name: f.name })), + (all.result || []).map((f: { id: string; name: string }) => ({ + id: f.id, + name: f.name, + })), ); all.onerror = () => resolve([]); }; @@ -275,7 +278,7 @@ test.describe("Watched Folders — Create / Edit / Delete", () => { dbName: string, storeName: string, key: string, - value: any, + value: unknown, ) => new Promise((resolve) => { const req = indexedDB.open(dbName); diff --git a/frontend/editor/src/core/tests/missingTranslations.test.ts b/frontend/editor/src/core/tests/missingTranslations.test.ts index 6448d3cb63..f98022559b 100644 --- a/frontend/editor/src/core/tests/missingTranslations.test.ts +++ b/frontend/editor/src/core/tests/missingTranslations.test.ts @@ -1,241 +1,53 @@ import fs from "fs"; import path from "path"; -import ts from "typescript"; import { describe, expect, test } from "vitest"; -import { parse } from "smol-toml"; +import { + I18N_PROJECTS, + REPO_ROOT, + findMissingKeys, +} from "@shared/i18n/translationAudit"; -const REPO_ROOT = path.join(__dirname, "../../../.."); -const SRC_ROOT = path.join(__dirname, "../.."); -const EN_US_FILE = path.join( - __dirname, - "../../../public/locales/en-US/translation.toml", -); +// One suite per frontend app (editor + portal). The scan logic lives in +// @shared/i18n/translationAudit so both apps share one implementation. +describe.each(I18N_PROJECTS)( + "Missing translation coverage — $name", + (project) => { + test( + "fails if any en-US key used in source is missing from the locale", + { timeout: 10000 }, + () => { + expect(fs.existsSync(project.localeFile)).toBe(true); -const IGNORED_DIRS = new Set(["tests", "__mocks__"]); -const IGNORED_FILE_PATTERNS = [ - /\.d\.ts$/, - /\.test\./, - /\.spec\./, - /\.stories\./, -]; -const IGNORED_KEYS = new Set([ - // If the script has found a false-positive that shouldn't be in the translations, include it here -]); -const LIKELY_TRANSLATION_USAGE_RE = /(?:^|[^\w$])t\s*\(|\.t\s*\(|\bi18nKey\b/; -const PLURAL_SUFFIX_RE = /_(zero|one|two|few|many|other)$/; + const { missing, usedCount } = findMissingKeys(project); + expect(usedCount).toBeGreaterThan(project.minUsedKeys ?? 1); // scan sanity -type FoundKey = { - key: string; - fallback: string; - file: string; - line: number; - column: number; -}; - -const flattenKeys = ( - node: unknown, - prefix = "", - acc = new Set(), -): Set => { - if (!node || typeof node !== "object" || Array.isArray(node)) { - if (prefix) { - acc.add(prefix); - } - return acc; - } - - for (const [childKey, value] of Object.entries( - node as Record, - )) { - const next = prefix ? `${prefix}.${childKey}` : childKey; - flattenKeys(value, next, acc); - } - - return acc; -}; - -const hasPluralCoverage = (key: string, availableKeys: Set): boolean => - [...availableKeys].some( - (availableKey) => - availableKey.startsWith(`${key}_`) && PLURAL_SUFFIX_RE.test(availableKey), - ); - -const listSourceFiles = (): string[] => { - const files = ts.sys.readDirectory( - SRC_ROOT, - [".ts", ".tsx", ".js", ".jsx"], - undefined, - ["**/*"], - ); - - return files - .filter( - (file) => - !file.split(path.sep).some((segment) => IGNORED_DIRS.has(segment)), - ) - .filter((file) => !IGNORED_FILE_PATTERNS.some((re) => re.test(file))); -}; - -const getScriptKind = (file: string): ts.ScriptKind => { - if (file.endsWith(".tsx")) { - return ts.ScriptKind.TSX; - } - - if (file.endsWith(".ts")) { - return ts.ScriptKind.TS; - } - - if (file.endsWith(".jsx")) { - return ts.ScriptKind.JSX; - } - - return ts.ScriptKind.JS; -}; - -/** - * Find all of the static first keys for translation functions that we can. - * Ignores dynamic strings because we can't know what the actual translation key will be. - */ -const extractKeys = (file: string): FoundKey[] => { - const code = fs.readFileSync(file, "utf8"); - if (!LIKELY_TRANSLATION_USAGE_RE.test(code)) { - return []; - } - - const sourceFile = ts.createSourceFile( - file, - code, - ts.ScriptTarget.Latest, - true, - getScriptKind(file), - ); - - const found: FoundKey[] = []; - - const record = (node: ts.Node, key: string, fallback: string = "") => { - const { line, character } = sourceFile.getLineAndCharacterOfPosition( - node.getStart(), - ); - found.push({ key, fallback, file, line: line + 1, column: character + 1 }); - }; - - const visit = (node: ts.Node) => { - if (ts.isCallExpression(node)) { - const callee = node.expression; - const arg0 = node.arguments.at(0); - const arg1 = node.arguments.at(1); - - const isT = - (ts.isIdentifier(callee) && callee.text === "t") || - (ts.isPropertyAccessExpression(callee) && callee.name.text === "t"); - - if ( - isT && - arg0 && - (ts.isStringLiteral(arg0) || ts.isNoSubstitutionTemplateLiteral(arg0)) - ) { - let arg1Text: string = ""; - if ( - arg1 && - (ts.isStringLiteral(arg1) || ts.isNoSubstitutionTemplateLiteral(arg1)) - ) { - arg1Text = arg1.text; - } - record(arg0, arg0.text, arg1Text); - } - } - - if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { - for (const attr of node.attributes.properties) { - if ( - !ts.isJsxAttribute(attr) || - attr.name.getText(sourceFile) !== "i18nKey" || - !attr.initializer - ) { - continue; - } - - const init = attr.initializer; - - if (ts.isStringLiteral(init)) { - record(init, init.text); - continue; - } - - if ( - ts.isJsxExpression(init) && - init.expression && - ts.isStringLiteral(init.expression) - ) { - record(init.expression, init.expression.text); - } - } - } - - ts.forEachChild(node, visit); - }; - - ts.forEachChild(sourceFile, visit); - return found; -}; - -describe("Missing translation coverage", () => { - test( - "fails if any en-US translation key used in source is missing", - { timeout: 10000 }, - () => { - expect(fs.existsSync(EN_US_FILE)).toBe(true); - - const localeContent = fs.readFileSync(EN_US_FILE, "utf8"); - const enUs = parse(localeContent); - const availableKeys = flattenKeys(enUs); - - const usedKeys = listSourceFiles() - .flatMap(extractKeys) - .filter(({ key }) => !IGNORED_KEYS.has(key)); - expect(usedKeys.length).toBeGreaterThan(100); // Sanity check - - const missingKeys = usedKeys.filter( - ({ key }) => - !availableKeys.has(key) && !hasPluralCoverage(key, availableKeys), - ); - - const annotations = missingKeys.map( - ({ key, fallback, file, line, column }) => { - const workspaceRelativeRaw = path.relative(REPO_ROOT, file); - const workspaceRelativeFile = workspaceRelativeRaw.replace( - /\\/g, - "/", - ); - - return { + const annotations = missing.map( + ({ key, fallback, file, line, column }) => ({ key, fallback, - file: workspaceRelativeFile, + file: path.relative(REPO_ROOT, file).replace(/\\/g, "/"), line, column, - }; - }, - ); - - // Output errors in GitHub Annotations format so they appear tagged in the code in CI - for (const { key, fallback, file, line, column } of annotations) { - process.stderr.write( - `::error file=${file},line=${line},col=${column}::Missing en-US translation for ${key} (${fallback})\n`, + }), ); - } - const neatened = annotations.map( - ({ key, fallback, file, line, column }) => { - return { + // GitHub Annotations format so misses show up tagged on the code in CI. + for (const { key, fallback, file, line, column } of annotations) { + process.stderr.write( + `::error file=${file},line=${line},col=${column}::Missing en-US translation for ${key} (${fallback})\n`, + ); + } + + const located = annotations.map( + ({ key, fallback, file, line, column }) => ({ key, fallback, location: `${file}:${line}:${column}`, - }; - }, - ); + }), + ); - expect(neatened).toEqual([]); - }, - ); -}); + expect(located).toEqual([]); + }, + ); + }, +); diff --git a/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts b/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts new file mode 100644 index 0000000000..5912c89211 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/page-editor-rotation.spec.ts @@ -0,0 +1,97 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { PDFDocument } from "@cantoo/pdf-lib"; + +import { test, expect } from "@app/tests/helpers/stub-test-base"; +import { uploadFiles, dismissTourTooltip } from "@app/tests/helpers/ui-helpers"; + +// Fixture: 4 portrait pages whose intrinsic /Rotate is 0, 90, 270, 180. +// Page 3 (index 2) is 270 so a single rotate-right lands on a net-0 target - +// the exact case the export used to drop, leaving the source rotation behind. +const ROTATED_PDF = path.join(__dirname, "../test-fixtures/rotated-pages.pdf"); +const SOURCE_ROTATIONS = [0, 90, 270, 180]; + +/** Read the rotation each thumbnail is currently displaying (= page.rotation). */ +async function readEditorRotations(page: import("@playwright/test").Page) { + const imgs = page.locator("[data-page-id] img[data-original-rotation]"); + await expect(imgs).toHaveCount(SOURCE_ROTATIONS.length, { timeout: 30_000 }); + const count = await imgs.count(); + const rots: number[] = []; + for (let i = 0; i < count; i++) { + rots.push( + parseInt( + (await imgs.nth(i).getAttribute("data-original-rotation")) || "NaN", + 10, + ), + ); + } + return rots; +} + +// Skip the fixture's 30s auto-goto; vite's cold on-demand compile can exceed it. +test.use({ autoGoto: false }); + +test.describe("PageEditor (multitool) rotation save", () => { + test("rotating a page persists the correct absolute rotation on export", async ({ + page, + }) => { + await page.goto("/", { waitUntil: "domcontentloaded", timeout: 120_000 }); + await uploadFiles(page, ROTATED_PDF); + // Enter the multitool via in-app navigation, NOT page.goto: a full reload + // wipes the in-memory workbench before PageEditorContext's "entering page + // editor" effect can auto-select the file, leaving the editor empty. + await dismissTourTooltip(page); + await page.getByText("PDF Multi Tool", { exact: true }).first().click(); + + // 1. Baseline: the multitool must seed page.rotation from the source /Rotate, + // otherwise rotated pages render upright and every rotate is off-baseline. + const baseline = await readEditorRotations(page); + expect( + baseline, + "editor must show pages at their true source rotation", + ).toEqual(SOURCE_ROTATIONS); + + // 2. Rotate page 3 (index 2, source /Rotate 270) right once via its + // per-page hover menu. Target rotation is (270 + 90) % 360 = 0. + const page3 = page.locator("[data-page-id]").nth(2); + await page3.scrollIntoViewIfNeeded(); + await page3.hover(); + const rotateRight = page3.getByRole("button", { name: "Rotate Right" }); + await expect(rotateRight).toBeVisible({ timeout: 5_000 }); + await rotateRight.click(); + + // Only page 3 changes (270 -> 0); the others keep their source rotation. + await expect(page3.locator("img[data-original-rotation]")).toHaveAttribute( + "data-original-rotation", + "0", + { timeout: 10_000 }, + ); + expect(await readEditorRotations(page)).toEqual([0, 90, 0, 180]); + + // 3. Ensure all pages are selected, then export, capturing the PDF. + // Pages load all-selected, so "Select All" is disabled - only click it + // if some pages got deselected. + const selectAll = page.getByRole("button", { + name: "Select All", + exact: true, + }); + if (await selectAll.isEnabled()) { + await selectAll.click(); + } + const tmpOut = path.join(os.tmpdir(), `rot-export-${process.pid}.pdf`); + const [download] = await Promise.all([ + page.waitForEvent("download", { timeout: 30_000 }), + page.getByRole("button", { name: "Export Selected Pages" }).click(), + ]); + await download.saveAs(tmpOut); + + // 4. The exported /Rotate must match what the editor showed: page 3 upright + // (0), the untouched pages keeping their source rotation. + const outDoc = await PDFDocument.load(fs.readFileSync(tmpOut)); + const outRotations = outDoc.getPages().map((p) => p.getRotation().angle); + fs.rmSync(tmpOut, { force: true }); + expect(outRotations).toEqual([0, 90, 0, 180]); + }); +}); diff --git a/frontend/editor/src/core/tests/test-fixtures/rotated-pages.pdf b/frontend/editor/src/core/tests/test-fixtures/rotated-pages.pdf new file mode 100644 index 0000000000..53f7a3ac5a Binary files /dev/null and b/frontend/editor/src/core/tests/test-fixtures/rotated-pages.pdf differ diff --git a/frontend/editor/src/core/tests/unusedTranslations.test.ts b/frontend/editor/src/core/tests/unusedTranslations.test.ts index 2e3d408ce0..1193c737bf 100644 --- a/frontend/editor/src/core/tests/unusedTranslations.test.ts +++ b/frontend/editor/src/core/tests/unusedTranslations.test.ts @@ -1,224 +1,46 @@ import fs from "fs"; import path from "path"; -import ts from "typescript"; import { describe, expect, test } from "vitest"; -import { parse } from "smol-toml"; +import { + I18N_PROJECTS, + REPO_ROOT, + findUnusedKeys, +} from "@shared/i18n/translationAudit"; -const REPO_ROOT = path.join(__dirname, "../../../.."); -const SRC_ROOT = path.join(__dirname, "../.."); -const EN_US_FILE = path.join( - __dirname, - "../../../public/locales/en-US/translation.toml", -); +// One suite per frontend app (editor + portal). The scan logic lives in +// @shared/i18n/translationAudit so both apps share one implementation; each +// project carries its own ignoredKeyPatterns for runtime-assembled keys. +describe.each(I18N_PROJECTS)( + "Unused translation coverage — $name", + (project) => { + test( + "fails if any en-US key has no source references", + { timeout: 30_000 }, + () => { + expect(fs.existsSync(project.localeFile)).toBe(true); -const IGNORED_DIRS = new Set(["tests", "__mocks__"]); -const IGNORED_FILE_PATTERNS = [ - /\.d\.ts$/, - /\.test\./, - /\.spec\./, - /\.stories\./, -]; -const PLURAL_SUFFIX_PATTERN = /_(zero|one|two|few|many|other)$/; + const { unused, localeCount } = findUnusedKeys(project); + expect(localeCount).toBeGreaterThan(project.minLocaleKeys ?? 1); // sanity -/** - * Keys that look unused to the heuristic but are genuinely used: keep them. - * These are families assembled at runtime, so no static fragment ever reaches - * source code for the literal/template matching to catch. Add a regex here - * (with a comment naming the runtime usage) rather than teaching the test - * about specific component internals. For a single key, anchor it: /^a\.b$/. - */ -const IGNORED_KEY_PATTERNS: RegExp[] = [ - // SignSettings / SavedSignaturesSection look up every key as - // t(`${translationScope}.${key}`); the scope ("sign" | "addText" | - // "addImage") and the relative key only ever exist as separate literals. - /^(sign|addText|addImage)\./, - // SettingsSearchBar builds its search index by loading whole subtrees via - // t(prefix, { returnObjects: true }); the leaf keys never appear in source. - /^admin\.settings\./, - /^settings\./, - /^account\./, -]; + const localeRelative = path + .relative(REPO_ROOT, project.localeFile) + .replace(/\\/g, "/"); -const flattenKeys = ( - node: unknown, - prefix = "", - acc = new Set(), -): Set => { - if (!node || typeof node !== "object" || Array.isArray(node)) { - if (prefix) { - acc.add(prefix); - } - return acc; - } - - for (const [childKey, value] of Object.entries( - node as Record, - )) { - const next = prefix ? `${prefix}.${childKey}` : childKey; - flattenKeys(value, next, acc); - } - - return acc; -}; - -const listSourceFiles = (): string[] => { - const files = ts.sys.readDirectory( - SRC_ROOT, - [".ts", ".tsx", ".js", ".jsx"], - undefined, - ["**/*"], - ); - - return files - .filter( - (file) => - !file.split(path.sep).some((segment) => IGNORED_DIRS.has(segment)), - ) - .filter((file) => !IGNORED_FILE_PATTERNS.some((re) => re.test(file))); -}; - -const getScriptKind = (file: string): ts.ScriptKind => { - if (file.endsWith(".tsx")) return ts.ScriptKind.TSX; - if (file.endsWith(".ts")) return ts.ScriptKind.TS; - if (file.endsWith(".jsx")) return ts.ScriptKind.JSX; - return ts.ScriptKind.JS; -}; - -/** - * Walk each file's AST and collect every template literal whose static parts - * could plausibly form a dotted translation key. Each shape replaces ${...} - * interpolations with `*`, e.g. `tools.${id}.title` becomes `tools.*.title`. - * - * We deliberately collect *all* template literals (not just those at t() - * call sites), because keys are often built up in helpers, constants or - * config objects and only passed to t() somewhere far away. A shape only - * counts if it carries at least one identifier-like static fragment though, - * so generic templates like `${name}.${ext}` (shape `*.*`) are discarded. - * - * Using the AST (rather than a backtick-pair regex) is important: source - * files contain large multi-line templates with embedded CSS/HTML and - * nested interpolations that confuse regex-based pairing. - */ -const extractTemplateShapesFromFile = ( - file: string, - acc: Set, -): void => { - const code = fs.readFileSync(file, "utf8"); - if (!code.includes("${")) return; - - const sourceFile = ts.createSourceFile( - file, - code, - ts.ScriptTarget.Latest, - false, - getScriptKind(file), - ); - - const visit = (node: ts.Node): void => { - if (ts.isTemplateExpression(node)) { - let shape = node.head.text; - for (const span of node.templateSpans) { - shape += "*"; - shape += span.literal.text; - } - if ( - shape.includes(".") && - /[A-Za-z0-9_-]/.test(shape.replace(/\*/g, "")) - ) { - acc.add(shape); - } - } - ts.forEachChild(node, visit); - }; - - ts.forEachChild(sourceFile, visit); -}; - -const shapeToMatcher = (shape: string): RegExp => { - // Each * stands in for one runtime-supplied path segment. We use `[^.]+` - // (not `.+`) so a one-variable interpolation doesn't accidentally span - // multiple key levels. If a real interpolation does carry a multi-segment - // string, the IGNORED_KEY_PATTERNS list is the escape hatch. - const escaped = shape - .split("*") - .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")) - .join("[^.]+"); - return new RegExp(`^${escaped}$`); -}; - -const isIgnored = (key: string): boolean => { - return IGNORED_KEY_PATTERNS.some((re) => re.test(key)); -}; - -const getTranslationLookupKeys = (key: string): string[] => { - const pluralBaseKey = key.replace(PLURAL_SUFFIX_PATTERN, ""); - if (pluralBaseKey === key) { - return [key]; - } - - return [key, pluralBaseKey]; -}; - -describe("Unused translation coverage", () => { - test( - "fails if any en-US translation key has no source references", - { timeout: 30_000 }, - () => { - expect(fs.existsSync(EN_US_FILE)).toBe(true); - - const enUs = parse(fs.readFileSync(EN_US_FILE, "utf8")); - const availableKeys = Array.from(flattenKeys(enUs)); - expect(availableKeys.length).toBeGreaterThan(100); // sanity check - - const sourceFiles = listSourceFiles(); - expect(sourceFiles.length).toBeGreaterThan(0); - - const source = sourceFiles - .map((file) => fs.readFileSync(file, "utf8")) - .join("\n"); - - const shapes = new Set(); - for (const file of sourceFiles) { - extractTemplateShapesFromFile(file, shapes); - } - const shapeMatchers = Array.from(shapes).map(shapeToMatcher); - - const unused = availableKeys.filter((key) => { - if (isIgnored(key)) return false; - const lookupKeys = getTranslationLookupKeys(key); - // Direct: the full key text appears anywhere in source (catches - // static t() calls, i18nKey props, constants, and any other place - // the literal string sits in code or comments). Plural variants also - // count as used when their base key is referenced because i18next - // resolves suffixes like _one/_other from a single base lookup. - if (lookupKeys.some((lookupKey) => source.includes(lookupKey))) { - return false; + // GitHub Annotations format so unused keys show up tagged on the locale. + for (const key of unused) { + process.stderr.write( + `::error file=${localeRelative}::Unused en-US translation: ${key}\n`, + ); } - // Dynamic: the key matches a template-literal shape from source. - return !lookupKeys.some((lookupKey) => - shapeMatchers.some((re) => re.test(lookupKey)), - ); - }); - const localeRelative = path - .relative(REPO_ROOT, EN_US_FILE) - .replace(/\\/g, "/"); - - // GitHub Annotations format so unused keys show up tagged on the - // translation file in CI. - for (const key of unused) { - process.stderr.write( - `::error file=${localeRelative}::Unused en-US translation: ${key}\n`, - ); - } - - expect( - unused, - `Found ${unused.length} unused en-US translation key(s). ` + - `Remove them from ${localeRelative}, or (if the usage is too ` + - `dynamic for the heuristic to spot) add to IGNORED_KEY_PATTERNS ` + - `in this test.`, - ).toEqual([]); - }, - ); -}); + expect( + unused, + `Found ${unused.length} unused en-US translation key(s). ` + + `Remove them from ${localeRelative}, or (if the usage is too ` + + `dynamic for the heuristic to spot) add a pattern to this ` + + `project's ignoredKeyPatterns in @shared/i18n/translationAudit.`, + ).toEqual([]); + }, + ); + }, +); diff --git a/frontend/editor/src/core/tools/AddAttachments.tsx b/frontend/editor/src/core/tools/AddAttachments.tsx index 22c9713b68..17e5d7b0b1 100644 --- a/frontend/editor/src/core/tools/AddAttachments.tsx +++ b/frontend/editor/src/core/tools/AddAttachments.tsx @@ -1,7 +1,10 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; -import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; +import { + createToolFlow, + type MiddleStepConfig, +} from "@app/components/tools/shared/createToolFlow"; import { BaseToolProps, ToolComponent } from "@app/types/tool"; import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; import { useAddAttachmentsParameters } from "@app/hooks/tools/addAttachments/useAddAttachmentsParameters"; @@ -36,9 +39,9 @@ const AddAttachments = ({ if (operation.files && onComplete) { onComplete(operation.files); } - } catch (error: any) { + } catch (error) { onError?.( - error?.message || + (error instanceof Error ? error.message : undefined) || t( "AddAttachmentsRequest.error.failed", "Add attachments operation failed", @@ -70,7 +73,7 @@ const AddAttachments = ({ }); const getSteps = () => { - const steps: any[] = []; + const steps: MiddleStepConfig[] = []; // Step 1: Attachments Selection steps.push({ diff --git a/frontend/editor/src/core/tools/AddPageNumbers.tsx b/frontend/editor/src/core/tools/AddPageNumbers.tsx index db6e5404e5..7001dc2894 100644 --- a/frontend/editor/src/core/tools/AddPageNumbers.tsx +++ b/frontend/editor/src/core/tools/AddPageNumbers.tsx @@ -1,7 +1,10 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; -import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; +import { + createToolFlow, + type MiddleStepConfig, +} from "@app/components/tools/shared/createToolFlow"; import { BaseToolProps, ToolComponent } from "@app/types/tool"; import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; import { useAddPageNumbersParameters } from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters"; @@ -35,9 +38,9 @@ const AddPageNumbers = ({ if (operation.files && onComplete) { onComplete(operation.files); } - } catch (error: any) { + } catch (error) { onError?.( - error?.message || + (error instanceof Error ? error.message : undefined) || t("addPageNumbers.error.failed", "Add page numbers operation failed"), ); } @@ -67,7 +70,7 @@ const AddPageNumbers = ({ }); const getSteps = () => { - const steps: any[] = []; + const steps: MiddleStepConfig[] = []; // Step 1: Position Selection & Pages/Starting Number steps.push({ diff --git a/frontend/editor/src/core/tools/AddStamp.tsx b/frontend/editor/src/core/tools/AddStamp.tsx index b5c5d913fc..9a7422188e 100644 --- a/frontend/editor/src/core/tools/AddStamp.tsx +++ b/frontend/editor/src/core/tools/AddStamp.tsx @@ -1,6 +1,9 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { createToolFlow } from "@app/components/tools/shared/createToolFlow"; +import { + createToolFlow, + type MiddleStepConfig, +} from "@app/components/tools/shared/createToolFlow"; import { BaseToolProps, ToolComponent } from "@app/types/tool"; import { useEndpointEnabled } from "@app/hooks/useEndpointConfig"; import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; @@ -41,9 +44,9 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { if (operation.files && onComplete) { onComplete(operation.files); } - } catch (error: any) { + } catch (error) { onError?.( - error?.message || + (error instanceof Error ? error.message : undefined) || t("AddStampRequest.error.failed", "Add stamp operation failed"), ); } @@ -73,7 +76,7 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { }); const getSteps = () => { - const steps: any[] = []; + const steps: MiddleStepConfig[] = []; // Step 1: Stamp Setup steps.push({ diff --git a/frontend/editor/src/core/tools/EditTableOfContents.tsx b/frontend/editor/src/core/tools/EditTableOfContents.tsx index 725c64124c..85769e0649 100644 --- a/frontend/editor/src/core/tools/EditTableOfContents.tsx +++ b/frontend/editor/src/core/tools/EditTableOfContents.tsx @@ -26,6 +26,7 @@ import { useNavigationState, } from "@app/contexts/NavigationContext"; import { useFileSelection } from "@app/contexts/FileContext"; +import { isStirlingFile } from "@app/types/fileContext"; const extractBookmarks = async (file: File): Promise => { const formData = new FormData(); @@ -39,7 +40,7 @@ const extractBookmarks = async (file: File): Promise => { return response.data as BookmarkPayload[]; }; -const useStableCallback = any>( +const useStableCallback = unknown>( callback: T, ): T => { const callbackRef = useRef(callback); @@ -112,7 +113,7 @@ const EditTableOfContents = (props: BaseToolProps) => { const payload = await extractBookmarks(file); const bookmarks = hydrateBookmarkPayload(payload); setBookmarks(bookmarks); - setLastLoadedFileId((file as any)?.fileId ?? file.name); + setLastLoadedFileId(isStirlingFile(file) ? file.fileId : file.name); if (showToast) { alert({ @@ -164,7 +165,7 @@ const EditTableOfContents = (props: BaseToolProps) => { return; } - const fileId = (selectedFile as any)?.fileId ?? selectedFile.name; + const fileId = selectedFile.fileId; if (fileId === lastLoadedFileId) { return; } @@ -466,6 +467,7 @@ const EditTableOfContents = (props: BaseToolProps) => { }); }; -(EditTableOfContents as any).tool = () => useEditTableOfContentsOperation; +(EditTableOfContents as ToolComponent).tool = () => + useEditTableOfContentsOperation; export default EditTableOfContents as ToolComponent; diff --git a/frontend/editor/src/core/tools/ReorganizePages.tsx b/frontend/editor/src/core/tools/ReorganizePages.tsx index dcf5c1ae9c..024469bee4 100644 --- a/frontend/editor/src/core/tools/ReorganizePages.tsx +++ b/frontend/editor/src/core/tools/ReorganizePages.tsx @@ -34,9 +34,9 @@ const ReorganizePages = ({ if (operation.files && onComplete) { onComplete(operation.files); } - } catch (error: any) { + } catch (error) { onError?.( - error?.message || + (error instanceof Error ? error.message : undefined) || t("reorganizePages.error.failed", "Failed to reorganize pages"), ); } @@ -107,6 +107,6 @@ const ReorganizePages = ({ }); }; -(ReorganizePages as any).tool = () => useReorganizePagesOperation; +(ReorganizePages as ToolComponent).tool = () => useReorganizePagesOperation; export default ReorganizePages as ToolComponent; diff --git a/frontend/editor/src/core/tools/formFill/FormFill.tsx b/frontend/editor/src/core/tools/formFill/FormFill.tsx index 846c1dcc83..0e4829df69 100644 --- a/frontend/editor/src/core/tools/formFill/FormFill.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFill.tsx @@ -28,6 +28,7 @@ import { ActionIcon, } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import { isAxiosError } from "axios"; import { useFormFill, useAllFormValues, @@ -267,13 +268,15 @@ const FormFill = (_props: BaseToolProps) => { detail: { blob: filledBlob }, }); window.dispatchEvent(event); - } catch (err: any) { + } catch (err) { + const status = isAxiosError(err) ? err.response?.status : undefined; const message = - err?.response?.status === 413 + status === 413 ? "File too large. Try reducing the PDF size first." - : err?.response?.status === 400 + : status === 400 ? "Invalid form data. Please check all fields." - : err?.message || "Failed to save filled form"; + : (err instanceof Error ? err.message : undefined) || + "Failed to save filled form"; setSaveError(message); console.error("[FormFill] Save failed:", err); } finally { diff --git a/frontend/editor/src/core/tools/formFill/FormFillContext.tsx b/frontend/editor/src/core/tools/formFill/FormFillContext.tsx index 217b568bbc..4377984111 100644 --- a/frontend/editor/src/core/tools/formFill/FormFillContext.tsx +++ b/frontend/editor/src/core/tools/formFill/FormFillContext.tsx @@ -30,6 +30,7 @@ import React, { useSyncExternalStore, } from "react"; import { useDebouncedCallback } from "@mantine/hooks"; +import { isAxiosError } from "axios"; import type { FormField, FormFillState, @@ -361,11 +362,13 @@ export function FormFillProvider({ forFileIdRef.current = fileId ?? null; setForFileId(fileId ?? null); dispatch({ type: "FETCH_SUCCESS", fields }); - } catch (err: any) { + } catch (err) { if (fetchVersionRef.current !== version) return; // stale const msg = - err?.response?.data?.message || - err?.message || + (isAxiosError<{ message?: string }>(err) + ? err.response?.data?.message + : undefined) || + (err instanceof Error ? err.message : undefined) || "Failed to fetch form fields"; dispatch({ type: "FETCH_ERROR", error: msg }); } diff --git a/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx b/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx index bf9628f5b3..fee07ac6ea 100644 --- a/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx +++ b/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useTranslation } from "react-i18next"; +import { isAxiosError } from "axios"; import DescriptionIcon from "@mui/icons-material/DescriptionOutlined"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; @@ -16,6 +17,7 @@ import { import { useViewer } from "@app/contexts/ViewerContext"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import { BaseToolProps, ToolComponent } from "@app/types/tool"; +import type { FileId } from "@app/types/file"; import { getDefaultWorkbench } from "@app/types/workbench"; import { CONVERSION_ENDPOINTS } from "@app/constants/convertConstants"; import apiClient from "@app/services/apiClient"; @@ -294,7 +296,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { const imagesByPageRef = useRef([]); const lastLoadedFileRef = useRef(null); const autoLoadKeyRef = useRef(null); - const sourceFileIdRef = useRef(null); + const sourceFileIdRef = useRef(null); const loadRequestIdRef = useRef(0); const latestPdfRequestIdRef = useRef(null); const loadedDocumentRef = useRef(null); @@ -339,8 +341,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { }; }, []); - const isCacheUnavailableError = useCallback((error: any): boolean => { - const status = error?.response?.status; + const isCacheUnavailableError = useCallback((error: unknown): boolean => { + const status = isAxiosError(error) ? error.response?.status : undefined; // Treat any 410 as cache unavailable, since responseType: 'blob' makes // it impossible to reliably check the JSON body return status === 410; @@ -804,14 +806,20 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { } else { console.log("Job not complete yet, continuing to poll..."); } - } catch (pollError: any) { + } catch (pollError) { console.error("Error polling job status:", pollError); + const status = isAxiosError(pollError) + ? pollError.response?.status + : undefined; console.error("Poll error details:", { - status: pollError?.response?.status, - data: pollError?.response?.data, - message: pollError?.message, + status, + data: isAxiosError(pollError) + ? pollError.response?.data + : undefined, + message: + pollError instanceof Error ? pollError.message : undefined, }); - if (pollError?.response?.status === 404) { + if (status === 404) { throw new Error("Job not found on server", { cause: pollError, }); @@ -864,12 +872,12 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { cachedJobIdRef.current = newJobId; setFileName(file.name); setErrorMessage(null); - } catch (error: any) { + } catch (error) { console.error("Failed to load file", error); console.error("Error details:", { - message: error?.message, - response: error?.response?.data, - stack: error?.stack, + message: error instanceof Error ? error.message : undefined, + response: isAxiosError(error) ? error.response?.data : undefined, + stack: error instanceof Error ? error.stack : undefined, }); if (loadRequestIdRef.current !== requestId) { @@ -885,7 +893,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { if (isPdf) { const errorMsg = - error?.message || + (error instanceof Error ? error.message : undefined) || t( "pdfTextEditor.conversionFailed", "Failed to convert PDF. Please try again.", @@ -1406,11 +1414,11 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { onComplete([pdfFile]); } setErrorMessage(null); - } catch (error: any) { + } catch (error) { console.error("Failed to convert JSON back to PDF", error); const message = - error?.response?.data || - error?.message || + (isAxiosError(error) ? error.response?.data : undefined) || + (error instanceof Error ? error.message : undefined) || t( "pdfTextEditor.errors.pdfConversion", "Unable to convert the edited JSON back into a PDF.", @@ -1451,9 +1459,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { return; } - const parentStub = selectors.getStirlingFileStub( - sourceFileIdRef.current as any, - ); + const sourceFileId = sourceFileIdRef.current; + const parentStub = selectors.getStirlingFileStub(sourceFileId); if (!parentStub) { console.warn( "[PdfTextEditor] Could not find parent stub for save to workbench", @@ -1660,11 +1667,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { ); // Replace the original file with the edited version - await consumeFiles( - [sourceFileIdRef.current as any], - stirlingFiles, - stubs, - ); + await consumeFiles([sourceFileId], stirlingFiles, stubs); // Update the source file ID to point to the new file sourceFileIdRef.current = stubs[0].id; @@ -1676,11 +1679,11 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { // Set flag to trigger navigation after state update is processed setShouldNavigateAfterSave(true); - } catch (error: any) { + } catch (error) { console.error("Failed to save to workbench", error); const message = - error?.response?.data || - error?.message || + (isAxiosError(error) ? error.response?.data : undefined) || + (error instanceof Error ? error.message : undefined) || t( "pdfTextEditor.errors.pdfConversion", "Unable to save changes to workbench.", @@ -1955,7 +1958,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { autoLoadKeyRef.current = fileKey; // Capture the source file ID for save-to-workbench functionality - sourceFileIdRef.current = (autoLoadFile as any).fileId ?? null; + sourceFileIdRef.current = autoLoadFile.fileId ?? null; void handleLoadFile(autoLoadFile); }, [autoLoadFile, navigationState.selectedTool, handleLoadFile]); diff --git a/frontend/editor/src/core/tsconfig.json b/frontend/editor/src/core/tsconfig.json index 655b1900f5..781d894b6d 100644 --- a/frontend/editor/src/core/tsconfig.json +++ b/frontend/editor/src/core/tsconfig.json @@ -1,10 +1,9 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "baseUrl": "../../", "paths": { - "@app/*": ["src/core/*"], - "@shared/*": ["../shared/*"] + "@app/*": ["../../src/core/*"], + "@shared/*": ["../../../shared/*"] } }, "include": ["../global.d.ts", "../*.js", "../*.ts", "../*.tsx", "."] diff --git a/frontend/editor/src/core/types/fileContext.ts b/frontend/editor/src/core/types/fileContext.ts index 3e9ba4283d..120e39b751 100644 --- a/frontend/editor/src/core/types/fileContext.ts +++ b/frontend/editor/src/core/types/fileContext.ts @@ -306,7 +306,11 @@ export interface FileContextActions { // File management - lightweight actions only addFiles: ( files: File[], - options?: { insertAfterPageId?: string; selectFiles?: boolean }, + options?: { + insertAfterPageId?: string; + selectFiles?: boolean; + skipUploadTracking?: boolean; + }, ) => Promise; addFilesWithOptions: ( files: File[], @@ -321,6 +325,7 @@ export interface FileContextActions { fileName: string, ) => Promise; allowDuplicates?: boolean; + skipUploadTracking?: boolean; }, ) => Promise; addStirlingFileStubs: ( diff --git a/frontend/editor/src/core/utils/loadJscanify.ts b/frontend/editor/src/core/utils/loadJscanify.ts index 93dec00394..94b141bba2 100644 --- a/frontend/editor/src/core/utils/loadJscanify.ts +++ b/frontend/editor/src/core/utils/loadJscanify.ts @@ -1,9 +1,52 @@ import { withBasePath } from "@app/constants/app"; +/** A single point in image space, as returned by jscanify corner detection. */ +export interface JscanifyPoint { + x: number; + y: number; +} + +/** The four detected document corners returned by {@link JscanifyScanner.getCornerPoints}. */ +export interface JscanifyCornerPoints { + topLeftCorner: JscanifyPoint; + topRightCorner: JscanifyPoint; + bottomLeftCorner: JscanifyPoint; + bottomRightCorner: JscanifyPoint; +} + +/** Minimal subset of an OpenCV.js `Mat` that this app interacts with directly. */ +export interface OpenCVMat { + delete(): void; +} + +/** Minimal subset of the OpenCV.js runtime exposed on `window.cv`. */ +export interface OpenCV { + /** Defined only once the WASM runtime has finished initializing. */ + readonly Mat: unknown; + imread(source: HTMLImageElement | HTMLCanvasElement | string): OpenCVMat; +} + +/** The jscanify scanner instance API used by the mobile scanner. */ +export interface JscanifyScanner { + findPaperContour(image: OpenCVMat): OpenCVMat | undefined; + getCornerPoints(contour: OpenCVMat): JscanifyCornerPoints; + extractPaper( + image: HTMLCanvasElement, + resultWidth: number, + resultHeight: number, + cornerPoints?: JscanifyCornerPoints, + ): HTMLCanvasElement; +} + +/** Constructor for jscanify, exposed on `window.jscanify`. */ +export interface JscanifyConstructor { + new (): JscanifyScanner; +} + declare global { interface Window { - cv?: any; - jscanify?: any; + cv?: OpenCV; + jscanify?: JscanifyConstructor; } } diff --git a/frontend/editor/src/core/utils/thumbnailUtils.ts b/frontend/editor/src/core/utils/thumbnailUtils.ts index a4efafbff5..838992e38b 100644 --- a/frontend/editor/src/core/utils/thumbnailUtils.ts +++ b/frontend/editor/src/core/utils/thumbnailUtils.ts @@ -192,15 +192,16 @@ export async function generateThumbnailWithMetadata( } const scale = calculateScaleFromFileSize(file.size); - const isVeryLarge = file.size >= 100 * 1024 * 1024; // 100MB threshold try { const arrayBuffer = await file.arrayBuffer(); + // Always read per-page rotation: PageEditor renders thumbnails upright and + // uses this as the rotation baseline, so skipping it corrupts saves. const result = await renderPdfThumbnailPdfium( arrayBuffer, scale, applyRotation, - !isVeryLarge, + true, ); if (result.isEncrypted) { diff --git a/frontend/editor/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx b/frontend/editor/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx index 70876ca3ef..ad783e9f76 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import LoginRightCarousel from "@app/components/shared/LoginRightCarousel"; +import LoginRightCarousel from "@shared/auth/ui/LoginRightCarousel"; import buildLoginSlides from "@app/components/shared/loginSlides"; -import styles from "@app/routes/authShared/AuthLayout.module.css"; +import styles from "@shared/auth/ui/AuthShell.module.css"; import { useLogoVariant } from "@app/hooks/useLogoVariant"; interface DesktopAuthLayoutProps { diff --git a/frontend/editor/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx b/frontend/editor/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx index 02d4d2008d..c2da65845b 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { authService, UserInfo } from "@app/services/authService"; import { buildOAuthCallbackHtml } from "@app/utils/oauthCallbackHtml"; -import { BASE_PATH } from "@app/constants/app"; +import { oauthIconUrl } from "@shared/auth/ui/oauthIcons"; import { STIRLING_SAAS_URL } from "@app/constants/connection"; import "@app/components/SetupWizard/desktopOAuth.css"; @@ -159,7 +159,9 @@ export const DesktopOAuthButtons: React.FC = ({ {label} diff --git a/frontend/editor/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx b/frontend/editor/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx index 3c02362b76..7f679052a3 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx @@ -1,13 +1,13 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import LoginHeader from "@app/routes/login/LoginHeader"; -import ErrorMessage from "@app/routes/login/ErrorMessage"; -import EmailPasswordForm from "@app/routes/login/EmailPasswordForm"; +import ErrorMessage from "@shared/auth/ui/ErrorMessage"; +import EmailPasswordForm from "@shared/auth/ui/EmailPasswordForm"; import DividerWithText from "@app/components/shared/DividerWithText"; import { DesktopOAuthButtons } from "@app/components/SetupWizard/DesktopOAuthButtons"; import { SelfHostedLink } from "@app/components/SetupWizard/SelfHostedLink"; import { UserInfo } from "@app/services/authService"; -import "@app/routes/authShared/auth.css"; +import "@shared/auth/ui/auth.css"; interface SaaSLoginScreenProps { serverUrl: string; diff --git a/frontend/editor/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx b/frontend/editor/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx index e13406f40c..bcc7bf88c7 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx @@ -1,14 +1,14 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import LoginHeader from "@app/routes/login/LoginHeader"; -import ErrorMessage from "@app/routes/login/ErrorMessage"; +import ErrorMessage from "@shared/auth/ui/ErrorMessage"; import SignupForm from "@app/routes/signup/SignupForm"; import { useSignupFormValidation, SignupFieldErrors, } from "@app/routes/signup/SignupFormValidation"; import { authService } from "@app/services/authService"; -import "@app/routes/authShared/auth.css"; +import "@shared/auth/ui/auth.css"; interface SaaSSignupScreenProps { loading: boolean; diff --git a/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLink.tsx b/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLink.tsx index 1ef652a310..739c02516d 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLink.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLink.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import "@app/routes/authShared/auth.css"; +import "@shared/auth/ui/auth.css"; interface SelfHostedLinkProps { onClick: () => void; diff --git a/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx b/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx index db4ca7cc51..ff2d743809 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx @@ -2,13 +2,13 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { Text } from "@mantine/core"; import LoginHeader from "@app/routes/login/LoginHeader"; -import ErrorMessage from "@app/routes/login/ErrorMessage"; -import EmailPasswordForm from "@app/routes/login/EmailPasswordForm"; +import ErrorMessage from "@shared/auth/ui/ErrorMessage"; +import EmailPasswordForm from "@shared/auth/ui/EmailPasswordForm"; import DividerWithText from "@app/components/shared/DividerWithText"; import { DesktopOAuthButtons } from "@app/components/SetupWizard/DesktopOAuthButtons"; import { UserInfo } from "@app/services/authService"; import { SSOProviderConfig } from "@app/services/connectionModeService"; -import "@app/routes/authShared/auth.css"; +import "@shared/auth/ui/auth.css"; interface SelfHostedLoginScreenProps { serverUrl: string; diff --git a/frontend/editor/src/desktop/components/SetupWizard/ServerSelectionScreen.tsx b/frontend/editor/src/desktop/components/SetupWizard/ServerSelectionScreen.tsx index 289fbf5c32..7ddf7b3cb2 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/ServerSelectionScreen.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/ServerSelectionScreen.tsx @@ -1,10 +1,10 @@ import React from "react"; import { useTranslation } from "react-i18next"; import LoginHeader from "@app/routes/login/LoginHeader"; -import ErrorMessage from "@app/routes/login/ErrorMessage"; +import ErrorMessage from "@shared/auth/ui/ErrorMessage"; import { ServerSelection } from "@app/components/SetupWizard/ServerSelection"; import { ServerConfig } from "@app/services/connectionModeService"; -import "@app/routes/authShared/auth.css"; +import "@shared/auth/ui/auth.css"; interface ServerSelectionScreenProps { onSelect: (config: ServerConfig) => void; diff --git a/frontend/editor/src/desktop/components/SetupWizard/index.tsx b/frontend/editor/src/desktop/components/SetupWizard/index.tsx index a3a2595131..e218f06e0c 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/index.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/index.tsx @@ -19,7 +19,7 @@ import { import { tauriBackendService } from "@app/services/tauriBackendService"; import { STIRLING_SAAS_URL } from "@app/constants/connection"; import { listen } from "@tauri-apps/api/event"; -import "@app/routes/authShared/auth.css"; +import "@shared/auth/ui/auth.css"; import { DisabledButtonWithTooltip } from "@app/components/shared/DisabledButtonWithTooltip"; enum SetupStep { diff --git a/frontend/editor/src/desktop/components/shared/UpdateStartupPopup.tsx b/frontend/editor/src/desktop/components/shared/UpdateStartupPopup.tsx new file mode 100644 index 0000000000..07ca5ad2b6 --- /dev/null +++ b/frontend/editor/src/desktop/components/shared/UpdateStartupPopup.tsx @@ -0,0 +1,13 @@ +/** + * Desktop (Tauri) override of @app/components/shared/UpdateStartupPopup. + * + * On desktop the update flow is owned end-to-end by `useDesktopUpdatePopup`, + * which also honours the headless `updateMode` provisioning flag and wires up + * the silent/auto installer. The web startup popup must therefore be a no-op + * here, otherwise both would run and double-popup. + */ +export function UpdateStartupPopup() { + return null; +} + +export default UpdateStartupPopup; diff --git a/frontend/editor/src/desktop/platform/externalLinkClick.ts b/frontend/editor/src/desktop/platform/externalLinkClick.ts new file mode 100644 index 0000000000..23c5722e2b --- /dev/null +++ b/frontend/editor/src/desktop/platform/externalLinkClick.ts @@ -0,0 +1,18 @@ +import type { MouseEvent } from "react"; +import { openExternal } from "@app/platform/openExternal"; + +/** + * Desktop (Tauri) override of the @app/platform/externalLinkClick seam. + * + * The app runs inside a Tauri webview, which traps a `target="_blank"` anchor + * inside our own window. Intercept the click and hand the URL to the OS browser + * via the openExternal seam (Tauri shell open) so the link lands in the user's + * real browser. + */ +export function handleExternalLinkClick( + url: string, + event: MouseEvent, +): void { + event.preventDefault(); + void openExternal(url); +} diff --git a/frontend/editor/src/desktop/tsconfig.json b/frontend/editor/src/desktop/tsconfig.json index b210c8a154..27f094be66 100644 --- a/frontend/editor/src/desktop/tsconfig.json +++ b/frontend/editor/src/desktop/tsconfig.json @@ -1,18 +1,17 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "baseUrl": "../../", "paths": { "@app/*": [ - "src/desktop/*", - "src/cloud/*", - "src/proprietary/*", - "src/core/*" + "../../src/desktop/*", + "../../src/cloud/*", + "../../src/proprietary/*", + "../../src/core/*" ], - "@cloud/*": ["src/cloud/*"], - "@proprietary/*": ["src/proprietary/*"], - "@core/*": ["src/core/*"], - "@shared/*": ["../shared/*"] + "@cloud/*": ["../../src/cloud/*"], + "@proprietary/*": ["../../src/proprietary/*"], + "@core/*": ["../../src/core/*"], + "@shared/*": ["../../../shared/*"] } }, "include": [ diff --git a/frontend/editor/src/global.d.ts b/frontend/editor/src/global.d.ts index b31330902d..cb6275c471 100644 --- a/frontend/editor/src/global.d.ts +++ b/frontend/editor/src/global.d.ts @@ -1,3 +1,8 @@ +/// +/// +/// +/// + declare module "*.js"; declare module "*.module.css"; diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx index 8373391b5a..4f231e81c6 100644 --- a/frontend/editor/src/proprietary/App.tsx +++ b/frontend/editor/src/proprietary/App.tsx @@ -21,7 +21,7 @@ import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import "@app/styles/tailwind.css"; import "@app/styles/cookieconsent.css"; import "@app/styles/index.css"; -import "@app/styles/auth-theme.css"; +import "@shared/auth/ui/auth-theme.css"; // Import file ID debugging helpers (development only) import "@app/utils/fileIdSafety"; diff --git a/frontend/editor/src/proprietary/auth/UseSession.test.ts b/frontend/editor/src/proprietary/auth/UseSession.test.ts index 86285dd6ac..eb4dc4bba0 100644 --- a/frontend/editor/src/proprietary/auth/UseSession.test.ts +++ b/frontend/editor/src/proprietary/auth/UseSession.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import type { TFunction } from "i18next"; -import type { User } from "@app/auth/springAuthClient"; +import type { User } from "@shared/auth/spring/springAuthClient"; import { deriveDisplayName } from "@app/auth/UseSession"; // Stub t() that returns the fallback string. The real i18next instance diff --git a/frontend/editor/src/proprietary/auth/UseSession.tsx b/frontend/editor/src/proprietary/auth/UseSession.tsx index 00532ff629..4908ec61c5 100644 --- a/frontend/editor/src/proprietary/auth/UseSession.tsx +++ b/frontend/editor/src/proprietary/auth/UseSession.tsx @@ -1,341 +1,50 @@ -import { - createContext, - useContext, - useEffect, - useState, - ReactNode, - useCallback, -} from "react"; +import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; -import { springAuth } from "@app/auth/springAuthClient"; -import { clearPlatformAuthOnLoginInit } from "@app/extensions/authSessionCleanup"; -import { stripBasePath } from "@app/constants/app"; -import type { - Session, - User, - AuthError, - AuthChangeEvent, -} from "@app/auth/springAuthClient"; +import { + SpringAuthProvider, + deriveDisplayName as deriveDisplayNameShared, +} from "@shared/auth/spring/UseSession"; +import { useAuth as useSharedAuth } from "@shared/auth/context"; +import type { AuthUser } from "@shared/auth/types"; +// Side-effect import: wires the editor's transport + platform seams into the +// shared Spring engine before AppProviders mounts the provider below. +import "@app/auth/configureSpringAuth"; + +export type { AuthUser as User } from "@shared/auth/types"; /** - * Auth Context Type - * Simplified version without SaaS-specific features (credits, subscriptions) - */ -interface AuthContextType { - session: Session | null; - user: User | null; - /** - * Human-readable name to show in the UI for the current session. - * - A real identity (username/email) when the user is signed in. - * - The localised "User" placeholder for anonymous sessions - * (proprietary's chosen label - see deriveDisplayName). - * - null only when there is no user object at all (signed-out), so - * consumers can fall back to whatever makes sense. - */ - displayName: string | null; - /** Whether the current session is an anonymous (login-disabled) one. */ - isAnonymous: boolean; - loading: boolean; - error: AuthError | null; - signOut: () => Promise; - refreshSession: () => Promise; -} - -/** - * Derive a display name from the Spring user. Anonymous users get the - * localised "User" placeholder (proprietary's chosen label for unsigned-in - * sessions); returns null only when there is no user object at all so - * consumers can pick their own fallback. - * - * Exported for unit testing. + * Editor display-name helper. Keeps the i18next `TFunction` signature the + * editor's components and tests rely on, delegating to the shared + * implementation for the actual logic (anonymous placeholder vs username/email). */ export function deriveDisplayName( - user: User | null | undefined, + user: AuthUser | null | undefined, t: TFunction, ): string | null { - if (!user) return null; - if (user.is_anonymous) return t("auth.displayName.user", "User"); - return user.username || user.email || null; + return deriveDisplayNameShared(user, (key, fallback) => t(key, fallback)); } -const AuthContext = createContext({ - session: null, - user: null, - displayName: null, - isAnonymous: false, - loading: true, - error: null, - signOut: async () => {}, - refreshSession: async () => {}, -}); - /** - * Auth Provider Component - * - * Manages authentication state and provides it to the entire app. - * Integrates with Spring Security + JWT backend. + * Auth Provider for the editor. Wraps the shared Spring provider and feeds it an + * i18next-backed translate function so the localised "User" placeholder still + * works for anonymous sessions. */ export function AuthProvider({ children }: { children: ReactNode }) { - const [session, setSession] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - // Debug: Track state transitions - useEffect(() => { - console.log("[Auth] State changed:", { - loading, - hasSession: !!session, - hasError: !!error, - userId: session?.user?.id, - timestamp: new Date().toISOString(), - }); - }, [loading, session, error]); - - /** - * Refresh current session - */ - const refreshSession = useCallback(async () => { - try { - setLoading(true); - setError(null); - console.debug("[Auth] refreshSession: start", { - path: window.location.pathname, - }); - console.debug("[Auth] Refreshing session..."); - - const { data, error } = await springAuth.refreshSession(); - - if (error) { - console.error("[Auth] Session refresh error:", error); - setError(error); - setSession(null); - } else { - console.debug("[Auth] Session refreshed successfully"); - setSession(data.session); - } - } catch (err) { - console.error("[Auth] Unexpected error during session refresh:", err); - setError(err as AuthError); - } finally { - console.debug("[Auth] refreshSession: done", { hasSession: !!session }); - setLoading(false); - } - }, []); - - /** - * Sign out user - */ - const signOut = useCallback(async () => { - try { - setError(null); - console.debug("[Auth] Signing out..."); - - const { error } = await springAuth.signOut(); - - // Always clear the in-memory session: springAuth.signOut() removes the - // local token and platform user_info even when the backend POST fails, - // so the user is effectively signed out either way. Leaving session - // populated on error would mean the UI keeps the old user's badge until - // a manual reload (the SIGNED_OUT notifyListeners call also covers this - // path now, but clearing here is defence in depth). - setSession(null); - - if (error) { - console.error("[Auth] Sign out error:", error); - setError(error); - } else { - console.debug("[Auth] Signed out successfully"); - } - } catch (err) { - console.error("[Auth] Unexpected error during sign out:", err); - setSession(null); - setError(err as AuthError); - } - }, []); - - /** - * Initialize auth on mount - */ - useEffect(() => { - let mounted = true; - const mountId = Math.random().toString(36).substring(7); - console.log(`[Auth:${mountId}] 🔵 AuthProvider mounted`); - - const initializeAuth = async () => { - try { - console.debug(`[Auth:${mountId}] Initializing auth...`); - console.debug( - `[Auth:${mountId}] Path: ${window.location.pathname} Search: ${window.location.search}`, - ); - // Clear any platform-specific cached auth on login page init. - if ( - typeof window !== "undefined" && - stripBasePath(window.location.pathname).startsWith("/login") - ) { - await clearPlatformAuthOnLoginInit(); - } - - // Skip config check entirely - let the app handle login state - // The config will be fetched by useAppConfig when needed - const { data, error } = await springAuth.getSession(); - - if (!mounted) return; - - if (error) { - console.error("[Auth] Initial session error:", error); - setError(error); - } else { - console.debug("[Auth] Initial session loaded:", { - hasSession: !!data.session, - userId: data.session?.user?.id, - email: data.session?.user?.email, - }); - setSession(data.session); - } - } catch (err) { - console.error( - "[Auth] Unexpected error during auth initialization:", - err, - ); - if (mounted) { - setError(err as AuthError); - } - } finally { - console.debug( - `[Auth:${mountId}] Initialize auth complete. mounted=${mounted}`, - ); - if (mounted) { - setLoading(false); - } - } - }; - - initializeAuth(); - - // Listen for jwt-available event (triggered by desktop auth or other sources) - const handleJwtAvailable = () => { - console.log(`[Auth:${mountId}] ════════════════════════════════════`); - console.log(`[Auth:${mountId}] 🔄 JWT available event received`); - console.log( - `[Auth:${mountId}] Current state: loading=${loading}, hasSession=${!!session}`, - ); - console.log( - `[Auth:${mountId}] Setting loading=true to stabilize auth state`, - ); - setLoading(true); // Prevent unstable renders during auth state transition - setError(null); - console.log(`[Auth:${mountId}] Refreshing session...`); - void initializeAuth(); - }; - - window.addEventListener("jwt-available", handleJwtAvailable); - - // Subscribe to auth state changes - const { - data: { subscription }, - } = springAuth.onAuthStateChange( - async (event: AuthChangeEvent, newSession: Session | null) => { - if (!mounted) { - console.log( - `[Auth:${mountId}] ⚠️ Auth state change ignored (unmounted): ${event}`, - ); - return; - } - - console.log(`[Auth:${mountId}] ════════════════════════════════════`); - console.log(`[Auth:${mountId}] 📢 Auth state change event: ${event}`); - console.log(`[Auth:${mountId}] Has session: ${!!newSession}`); - console.log( - `[Auth:${mountId}] User: ${newSession?.user?.email || "none"}`, - ); - console.log(`[Auth:${mountId}] Timestamp: ${new Date().toISOString()}`); - - // Schedule state update - setTimeout(() => { - if (mounted) { - console.log( - `[Auth:${mountId}] Applying session update (event: ${event})`, - ); - setSession(newSession); - setError(null); - - // Handle specific events - if (event === "SIGNED_OUT") { - console.log( - `[Auth:${mountId}] ✓ User signed out, session cleared`, - ); - } else if (event === "SIGNED_IN") { - console.log(`[Auth:${mountId}] ✓ User signed in successfully`); - } else if (event === "TOKEN_REFRESHED") { - console.log(`[Auth:${mountId}] ✓ Token refreshed`); - } else if (event === "USER_UPDATED") { - console.log(`[Auth:${mountId}] ✓ User updated`); - } - } else { - console.log( - `[Auth:${mountId}] ⚠️ Session update skipped (unmounted during timeout)`, - ); - } - }, 0); - }, - ); - - return () => { - console.log(`[Auth:${mountId}] 🔴 AuthProvider unmounting`); - mounted = false; - window.removeEventListener("jwt-available", handleJwtAvailable); - subscription.unsubscribe(); - }; - }, []); - const { t } = useTranslation(); - const user = session?.user ?? null; - const value: AuthContextType = { - session, - user, - displayName: deriveDisplayName(user, t), - isAnonymous: user?.is_anonymous === true, - loading, - error, - signOut, - refreshSession, - }; - - return {children}; + return ( + t(key, fallback)}> + {children} + + ); } -/** - * Hook to access auth context - * Must be used within AuthProvider - */ +/** Hook to access auth context. Must be used within AuthProvider. */ export function useAuth() { - const context = useContext(AuthContext); - - if (context === undefined) { - throw new Error("useAuth must be used within an AuthProvider"); - } - - return context; + return useSharedAuth(); } -/** - * Debug hook to expose auth state for debugging - * Can be used in development to monitor auth state - */ +/** Debug alias kept for backwards compatibility with existing callers. */ export function useAuthDebug() { - const auth = useAuth(); - - useEffect(() => { - console.debug("[Auth Debug] Current auth state:", { - hasSession: !!auth.session, - hasUser: !!auth.user, - loading: auth.loading, - hasError: !!auth.error, - userId: auth.user?.id, - email: auth.user?.email, - }); - }, [auth.session, auth.user, auth.loading, auth.error]); - - return auth; + return useSharedAuth(); } diff --git a/frontend/editor/src/proprietary/auth/configureSpringAuth.ts b/frontend/editor/src/proprietary/auth/configureSpringAuth.ts new file mode 100644 index 0000000000..7998d58921 --- /dev/null +++ b/frontend/editor/src/proprietary/auth/configureSpringAuth.ts @@ -0,0 +1,44 @@ +/** + * Wires the editor's transport + platform seams into the shared Spring auth + * engine. Import this module for its side effect (it configures the engine on + * load) before any auth call runs - AppProviders does so via UseSession. + * + * The `@app/*` imports resolve per build flavor: proprietary/web gets the no-op + * web defaults, the desktop build gets the Tauri-backed implementations. So the + * desktop and web auth behaviour is unchanged by the move to the shared engine. + */ +import type { AxiosInstance } from "axios"; +import apiClient from "@app/services/apiClient"; +import { BASE_PATH } from "@app/constants/app"; +import { configureSpringAuth } from "@shared/auth/config"; +import { + clearPlatformAuthAfterSignOut, + clearPlatformAuthOnLoginInit, +} from "@app/extensions/authSessionCleanup"; +import { + getPlatformSessionUser, + isDesktopSaaSAuthMode, + refreshPlatformSession, + savePlatformToken, + shouldCallBackendLogout, +} from "@app/extensions/platformSessionBridge"; +import { startOAuthNavigation } from "@app/extensions/oauthNavigation"; + +configureSpringAuth({ + // The desktop build resolves @app/services/apiClient to a TauriHttpClient, + // which is API-compatible with axios but not nominally an AxiosInstance - + // matches the existing `as unknown as AxiosInstance` bridge in + // desktop/services/apiClient.ts. Harmless no-op for the web (axios) build. + http: apiClient as unknown as AxiosInstance, + basePath: BASE_PATH, + platform: { + clearPlatformAuthAfterSignOut, + clearPlatformAuthOnLoginInit, + isDesktopSaaSAuthMode, + shouldCallBackendLogout, + getPlatformSessionUser, + refreshPlatformSession, + savePlatformToken, + startOAuthNavigation, + }, +}); diff --git a/frontend/editor/src/proprietary/auth/springAuthClient.test.ts b/frontend/editor/src/proprietary/auth/springAuthClient.test.ts index d64ac49476..5eab014103 100644 --- a/frontend/editor/src/proprietary/auth/springAuthClient.test.ts +++ b/frontend/editor/src/proprietary/auth/springAuthClient.test.ts @@ -5,9 +5,12 @@ import { POST_LOGIN_REDIRECT_STORAGE_KEY, setPostLoginRedirectPath, springAuth, -} from "@app/auth/springAuthClient"; +} from "@shared/auth/spring/springAuthClient"; import { startOAuthNavigation } from "@app/extensions/oauthNavigation"; import apiClient from "@app/services/apiClient"; +// Side-effect: configures the shared Spring engine with the (mocked) apiClient +// + oauthNavigation seam, so springAuth routes through the mocks below. +import "@app/auth/configureSpringAuth"; import { allowConsole, expectConsole } from "@app/tests/failOnConsole"; import { AxiosError, diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index a0b2a02bae..8440d53b5b 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -299,7 +299,10 @@ export function usePolicyAutoRun(): void { } interface ImportContext { - addFiles: (files: File[]) => Promise; + addFiles: ( + files: File[], + options?: { skipUploadTracking?: boolean }, + ) => Promise; consumeFiles: ( inputFileIds: FileId[], outputs: StirlingFile[], @@ -462,7 +465,7 @@ async function importOutputs( ctx.bumpRevision(); } } else { - const added = await ctx.addFiles(files); + const added = await ctx.addFiles(files, { skipUploadTracking: true }); // Same loop-guard for new-file output: the produced file is a new workspace // file the auto-run would otherwise re-enforce indefinitely. for (const f of added) markDispatched(run.categoryId, f.fileId); diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index 151d0c5dff..454b9e138c 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -94,6 +94,21 @@ export default function PeopleSection() { const isCurrentUser = (user: User) => currentUser?.username === user.username; const isLockedUser = (user: User) => lockedUsers.includes(user.username); + const getUserRoleId = (user: User) => + user.rolesAsString || + (user.roleName?.startsWith("ROLE_") ? user.roleName : undefined) || + "ROLE_USER"; + + const getRoleLabel = (roleId: string) => { + switch (roleId) { + case "ROLE_ADMIN": + return t("workspace.people.admin", "Admin"); + case "ROLE_USER": + return t("workspace.people.user", "User"); + default: + return roleId; + } + }; // Form state for edit user modal const [editForm, setEditForm] = useState({ @@ -353,7 +368,7 @@ export default function PeopleSection() { const openEditModal = (user: User) => { setSelectedUser(user); setEditForm({ - role: user.roleName, + role: getUserRoleId(user), teamId: user.team?.id, }); setEditUserModalOpened(true); @@ -385,7 +400,7 @@ export default function PeopleSection() { const roleOptions = [ { value: "ROLE_ADMIN", - label: t("workspace.people.admin"), + label: getRoleLabel("ROLE_ADMIN"), description: t( "workspace.people.roleDescriptions.admin", "Can manage settings and invite members, with full administrative access.", @@ -394,7 +409,7 @@ export default function PeopleSection() { }, { value: "ROLE_USER", - label: t("workspace.people.user"), + label: getRoleLabel("ROLE_USER"), description: t( "workspace.people.roleDescriptions.user", "Can view and edit shared files, but cannot manage workspace settings or members.", @@ -474,7 +489,9 @@ export default function PeopleSection() { {" "} - {t("workspace.people.license.users", "users")} + {t("workspace.people.license.users", "users", { + count: licenseInfo.totalUsers, + })} @@ -704,18 +721,18 @@ export default function PeopleSection() { size="sm" variant="light" color={ - (user.rolesAsString || "").includes("ROLE_ADMIN") + getUserRoleId(user) === "ROLE_ADMIN" ? "blue" - : "cyan" + : getUserRoleId(user) === "ROLE_PRO_USER" + ? "grape" + : "cyan" } styles={{ root: { maxWidth: "none" }, label: { overflow: "visible" }, }} > - {(user.rolesAsString || "").includes("ROLE_ADMIN") - ? t("workspace.people.admin", "Admin") - : t("workspace.people.user", "User")} + {getRoleLabel(getUserRoleId(user))} @@ -1009,7 +1026,25 @@ export default function PeopleSection() { onName(e.target.value)} - placeholder="Your name" + placeholder={t("settings.profile.namePlaceholder")} /> onEmail(e.target.value)} - placeholder="you@company.com" + placeholder={t("settings.profile.emailPlaceholder")} /> @@ -418,19 +403,22 @@ function AppearancePanel({ theme: Theme; onTheme: (theme: Theme) => void; }) { + const { t } = useTranslation(); return (
-

Theme

+

+ {t("settings.appearance.themeTitle")} +

- Choose how the portal looks on this device. + {t("settings.appearance.themeSub")}

{THEME_OPTIONS.map((opt) => ( ))} @@ -478,13 +466,16 @@ function NotificationsPanel({ order: string[]; onToggle: (id: string, value: boolean) => void; }) { + const { t } = useTranslation(); return (
-

Email notifications

+

+ {t("settings.notifications.title")} +

- Pick which events reach your inbox. + {t("settings.notifications.sub")}

@@ -505,13 +496,14 @@ function NotificationsPanel({ {!loading && (
{order.map((id) => { - const copy = NOTIFICATION_COPY[id]; - if (!copy) return null; + if (!(NOTIFICATION_IDS as readonly string[]).includes(id)) { + return null; + } return (
- {copy.label} - {copy.description} + {t(`settings.notifications.${id}.label`)} + {t(`settings.notifications.${id}.description`)}
@@ -562,17 +555,17 @@ function WorkspacePanel({ return (
- + onWorkspaceName(e.target.value)} - placeholder="Workspace name" + placeholder={t("settings.workspace.namePlaceholder")} /> onSecurity({ sessionTimeoutMins: Number(e.target.value) }) } - options={SESSION_TIMEOUT_OPTIONS} + options={SESSION_TIMEOUT_VALUES.map((value) => ({ + value, + label: t(`settings.authentication.timeout.${value}`), + }))} />
@@ -722,6 +728,7 @@ function SessionsPanel({ loading: boolean; sessions: ActiveSession[]; }) { + const { t } = useTranslation(); if (loading) { return (
@@ -735,9 +742,11 @@ function SessionsPanel({
-

Active sessions

+

+ {t("settings.sessions.title")} +

- Devices currently signed in to this account. + {t("settings.sessions.sub")}

@@ -751,12 +760,12 @@ function SessionsPanel({
{s.current ? ( - This device + {t("settings.sessions.thisDevice")} ) : ( // TODO(backend): DELETE /v1/settings/sessions/{id} )}
@@ -784,6 +793,7 @@ function EarlyAccessPanel({ betaToggles: Record; onBeta: (id: string, value: boolean) => void; }) { + const { t } = useTranslation(); if (loading) { return (
@@ -799,9 +809,11 @@ function EarlyAccessPanel({
-

Preview features

+

+ {t("settings.earlyAccess.title")} +

- Opt into features still in preview. + {t("settings.earlyAccess.sub")}

@@ -814,7 +826,7 @@ function EarlyAccessPanel({ {f.label} {locked && ( - Enterprise + {t("settings.enterpriseBadge")} )} diff --git a/frontend/portal/src/components/Sidebar.tsx b/frontend/portal/src/components/Sidebar.tsx index a31f59d933..ac5b042b92 100644 --- a/frontend/portal/src/components/Sidebar.tsx +++ b/frontend/portal/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Dropdown, NavItem } from "@shared/components"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useTier } from "@portal/contexts/TierContext"; @@ -5,6 +6,7 @@ import { useTheme } from "@portal/contexts/ThemeContext"; import { useUI } from "@portal/contexts/UIContext"; import { useAsync } from "@portal/hooks/useAsync"; import { fetchHomeKpis, type KpiEntry } from "@portal/api/home"; +import { EDITOR_URL } from "@portal/auth/editorUrl"; import markLight from "@shared/assets/stirling-mark-light.svg"; import markDark from "@shared/assets/stirling-mark-dark.svg"; import { @@ -23,42 +25,31 @@ import { } from "@portal/components/icons"; import "@portal/components/Sidebar.css"; -// The editor is a separate Vite app with no shared shell, so switching apps is -// a hard navigation — the editor's dev server in dev, the site root in prod. -// A standalone portal deploy can gate this behind a configured editor URL. -const EDITOR_URL = import.meta.env.DEV ? "http://localhost:5180/" : "/"; - interface NavEntry { id: ViewId; - label: string; icon: React.ReactNode; } -const GROUP_PRIMARY: NavEntry[] = [ - { id: "home", label: "Home", icon: }, -]; +const GROUP_PRIMARY: NavEntry[] = [{ id: "home", icon: }]; const GROUP_OPERATIONAL: NavEntry[] = [ - { id: "users", label: "Users", icon: }, - { id: "sources", label: "Sources", icon: }, - { id: "policies", label: "Policies", icon: }, - { id: "pipelines", label: "Pipelines", icon: }, - { id: "documents", label: "Documents", icon: }, - { id: "components", label: "Components", icon: }, + { id: "users", icon: }, + { id: "sources", icon: }, + { id: "policies", icon: }, + { id: "pipelines", icon: }, + { id: "documents", icon: }, + { id: "components", icon: }, ]; const GROUP_PLATFORM: NavEntry[] = [ - { - id: "infrastructure", - label: "Infrastructure", - icon: , - }, - { id: "usage", label: "Usage & Billing", icon: }, - { id: "docs", label: "Developer Docs", icon: }, + { id: "infrastructure", icon: }, + { id: "usage", icon: }, + { id: "docs", icon: }, ]; function UsageFooter() { const { tier } = useTier(); + const { t } = useTranslation(); // Read the same endpoint Home's KPI strip uses so the doc count here can't // drift from the headline figure. The first KPI is always the doc total. const { data: kpis, loading } = useAsync( @@ -77,7 +68,9 @@ function UsageFooter() { return (
- Docs processed + + {t("shell.sidebar.docsProcessed")} + {docs ?? "—"}
@@ -105,7 +101,7 @@ function UsageFooter() { {planLabel} - {docs != null ? `${docs} docs` : "—"} + {docs != null ? t("shell.sidebar.docsCount", { docs }) : "—"}
@@ -116,13 +112,14 @@ export function Sidebar() { const { activeView, setActiveView } = useView(); const { theme } = useTheme(); const { openSettings } = useUI(); + const { t } = useTranslation(); function renderGroup(entries: NavEntry[]) { return entries.map((entry) => ( setActiveView(id as ViewId)} @@ -131,7 +128,10 @@ export function Sidebar() { } return ( -
)}
{hovered - ? `${new Date(hovered.raw.date).toLocaleDateString(undefined, { - weekday: "short", - month: "short", - day: "numeric", - })}: ${hovered.raw.value.toLocaleString()} docs` + ? t("usageChart.srAnnounce", { + date: new Date(hovered.raw.date).toLocaleDateString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + }), + value: hovered.raw.value.toLocaleString(), + }) : ""}
diff --git a/frontend/portal/src/components/WelcomeCarousel.tsx b/frontend/portal/src/components/WelcomeCarousel.tsx index e623200856..b485eee1a4 100644 --- a/frontend/portal/src/components/WelcomeCarousel.tsx +++ b/frontend/portal/src/components/WelcomeCarousel.tsx @@ -1,17 +1,15 @@ import { useEffect, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { Button, StatusBadge } from "@shared/components"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import "@portal/components/WelcomeCarousel.css"; type SlideAction = - | { label: string; target: ViewId } - | { label: string; action: "try-op" }; + | { labelKey: string; target: ViewId } + | { labelKey: string; action: "try-op" }; interface Slide { id: string; - eyebrow: string; - title: string; - sub: string; durationMs: number; primary: SlideAction; secondary: SlideAction; @@ -19,21 +17,22 @@ interface Slide { } function EditorOrnament() { + const { t } = useTranslation(); return (
- Critical + {t("welcome.ornament.editor.critical")}
Vulnerability Assessment Report
CVE-2026-1847 · 12 pages
- signed + {t("welcome.ornament.editor.signed")} · - OCR-clean + {t("welcome.ornament.editor.ocrClean")} · - schema match 0.97 + {t("welcome.ornament.editor.schemaMatch")}
); @@ -88,32 +87,29 @@ function AgentOrnament() { const SLIDES: Slide[] = [ { id: "editor", - eyebrow: "PDF Editor", - title: "The #1 PDF Editor on GitHub", - sub: "Annotate, sign, redact, and review locally or in the cloud. Brought to the platform as the credibility anchor of the Stirling control plane.", durationMs: 12000, - primary: { label: "Install PDF Editor", target: "editor" }, - secondary: { label: "Connect an instance", target: "editor" }, + primary: { labelKey: "welcome.slides.editor.primary", target: "editor" }, + secondary: { + labelKey: "welcome.slides.editor.secondary", + target: "editor", + }, ornament: , }, { id: "platform", - eyebrow: "Platform", - title: "PDF Infrastructure for Developers", - sub: "Ingest from agents, APIs and connectors. Run composable pipelines with evals and golden sets. Land in a vault with zero-standing-access controls.", durationMs: 8000, - primary: { label: "Try a PDF operation", action: "try-op" }, - secondary: { label: "Get an API key", target: "infrastructure" }, + primary: { labelKey: "welcome.slides.platform.primary", action: "try-op" }, + secondary: { + labelKey: "welcome.slides.platform.secondary", + target: "infrastructure", + }, ornament: , }, { id: "agents", - eyebrow: "AI Agents", - title: "PDF Processor for AI Agents", - sub: "Wire your agent via MCP, REST or tool definitions. Deterministic operations and guardrails — test with scenarios and evals before you ship.", durationMs: 8000, - primary: { label: "Try PDF Processor", target: "sources" }, - secondary: { label: "View MCP docs", target: "docs" }, + primary: { labelKey: "welcome.slides.agents.primary", target: "sources" }, + secondary: { labelKey: "welcome.slides.agents.secondary", target: "docs" }, ornament: , }, ]; @@ -124,6 +120,7 @@ interface WelcomeCarouselProps { } export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) { + const { t } = useTranslation(); const [index, setIndex] = useState(0); const [paused, setPaused] = useState(false); const { setActiveView } = useView(); @@ -160,27 +157,33 @@ export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) { setPaused(false); } }} - aria-label="Stirling product highlights" + aria-label={t("welcome.ariaLabel")} aria-roledescription="carousel" >
-
{slide.eyebrow}
-

{slide.title}

-

{slide.sub}

+
+ {t(`welcome.slides.${slide.id}.eyebrow`)} +
+

+ {t(`welcome.slides.${slide.id}.title`)} +

+

+ {t(`welcome.slides.${slide.id}.sub`)} +

@@ -195,14 +198,17 @@ export function WelcomeCarousel({ onTryOp }: WelcomeCarouselProps) {
{SLIDES.map((s, i) => (
} >

- Drop a sample document and we'll propose scenarios and an - extraction schema you can refine. Nothing is published until you - review it. + {t("agentBuilder.bootstrap.lead")}

diff --git a/frontend/portal/src/components/agent-builder/EvalsPanel.tsx b/frontend/portal/src/components/agent-builder/EvalsPanel.tsx index 58c601accd..651f6c1d69 100644 --- a/frontend/portal/src/components/agent-builder/EvalsPanel.tsx +++ b/frontend/portal/src/components/agent-builder/EvalsPanel.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Button, EmptyState, @@ -14,38 +15,50 @@ interface EvalsPanelProps { agent: Agent; } -const COLUMNS: TableColumn[] = [ - { key: "name", header: "Eval case", render: (c) => c.name }, - { - key: "result", - header: "Result", - render: (c) => - c.passing === null ? ( - not run - ) : ( - - {c.passing ? "pass" : "fail"} - - ), - }, - { - key: "latency", - header: "Latency", - align: "right", - render: (c) => ( - {c.latencyMs} ms - ), - }, -]; - /** Golden-set pass-rate, the per-case results table, and a run affordance. */ export function EvalsPanel({ agent }: EvalsPanelProps) { + const { t } = useTranslation(); + + const columns: TableColumn[] = [ + { + key: "name", + header: t("agentBuilder.evals.columnCase"), + render: (c) => c.name, + }, + { + key: "result", + header: t("agentBuilder.evals.columnResult"), + render: (c) => + c.passing === null ? ( + + {t("agentBuilder.evals.notRun")} + + ) : ( + + {c.passing + ? t("agentBuilder.evals.pass") + : t("agentBuilder.evals.fail")} + + ), + }, + { + key: "latency", + header: t("agentBuilder.evals.columnLatency"), + align: "right", + render: (c) => ( + + {t("agentBuilder.evals.latencyMs", { ms: c.latencyMs })} + + ), + }, + ]; + if (agent.evalsTotal === 0) { return (
@@ -64,34 +77,34 @@ export function EvalsPanel({ agent }: EvalsPanelProps) {
= 0.95 ? "success" : rate >= 0.8 ? "warning" : "danger"} />
- Golden-set pass rate + {t("agentBuilder.evals.goldenSetPassRate")} {Math.round(rate * 100)}%
= 0.95 ? "var(--color-green)" : "var(--color-amber)"} - label="Golden-set pass rate" + label={t("agentBuilder.evals.goldenSetPassRate")} />
- columns={COLUMNS} + columns={columns} rows={agent.evalCases} rowKey={(c) => c.id} /> diff --git a/frontend/portal/src/components/agent-builder/ScenariosPanel.tsx b/frontend/portal/src/components/agent-builder/ScenariosPanel.tsx index 84ef11ecb2..f355dfa4d0 100644 --- a/frontend/portal/src/components/agent-builder/ScenariosPanel.tsx +++ b/frontend/portal/src/components/agent-builder/ScenariosPanel.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button, Chip, @@ -19,6 +20,7 @@ interface ScenariosPanelProps { * the submit endpoint exists. */ export function ScenariosPanel({ agent }: ScenariosPanelProps) { + const { t } = useTranslation(); // Seed from the agent and re-seed when the selection changes (key prop on the // builder forces a remount, so a plain useState initialiser is enough). const [scenarios, setScenarios] = useState(agent.scenarios); @@ -61,7 +63,9 @@ export function ScenariosPanel({ agent }: ScenariosPanelProps) { size="sm" showDot={false} > - {s.enabled ? "in eval" : "muted"} + {s.enabled + ? t("agentBuilder.scenarios.inEval") + : t("agentBuilder.scenarios.muted")}
@@ -73,7 +77,9 @@ export function ScenariosPanel({ agent }: ScenariosPanelProps) { variant="ghost" onClick={() => toggleEnabled(s.id)} > - {s.enabled ? "Mute" : "Enable"} + {s.enabled + ? t("agentBuilder.scenarios.mute") + : t("agentBuilder.scenarios.enable")} ))} @@ -81,25 +87,25 @@ export function ScenariosPanel({ agent }: ScenariosPanelProps) {
- Add scenario + {t("agentBuilder.scenarios.addScenario")}
- + setName(e.target.value)} - placeholder="e.g. Compliance escalation" + placeholder={t("agentBuilder.scenarios.namePlaceholder")} /> - + setExpectation(e.target.value)} - placeholder="What the agent should do" + placeholder={t("agentBuilder.scenarios.expectationPlaceholder")} />
diff --git a/frontend/portal/src/components/agent-builder/ToolsPanel.tsx b/frontend/portal/src/components/agent-builder/ToolsPanel.tsx index c53faa2a60..81e0f57af4 100644 --- a/frontend/portal/src/components/agent-builder/ToolsPanel.tsx +++ b/frontend/portal/src/components/agent-builder/ToolsPanel.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Chip, ToggleSwitch } from "@shared/components"; import { type Agent, type ToolMode, TOOL_CATALOGUE } from "@portal/api/agents"; import "@portal/views/AgentBuilder.css"; @@ -14,6 +15,7 @@ interface ToolsPanelProps { * default minus an explicit deny list, picked from the known tool catalogue. */ export function ToolsPanel({ agent, governanceUnlocked }: ToolsPanelProps) { + const { t } = useTranslation(); const [mode, setMode] = useState(agent.toolMode); const [denied, setDenied] = useState(agent.deniedTools); @@ -38,23 +40,27 @@ export function ToolsPanel({ agent, governanceUnlocked }: ToolsPanelProps) { checked={restricted} onChange={setRestricted} disabled={!governanceUnlocked} - label="Restricted tool access" + label={t("agentBuilder.tools.restrictedAccess")} description={ governanceUnlocked - ? "Allow every tool except the ones you deny below." - : "Tool governance is available on the Enterprise plan." + ? t("agentBuilder.tools.restrictedDescription") + : t("agentBuilder.tools.governanceGate") } /> - {restricted ? "Restricted" : "Broad access"} + {restricted + ? t("agentBuilder.tools.restricted") + : t("agentBuilder.tools.broadAccess")}
{restricted && (
- Denied tools + + {t("agentBuilder.tools.deniedTools")} +

- Selected tools are blocked. Everything else stays callable. + {t("agentBuilder.tools.deniedHint")}

{TOOL_CATALOGUE.map((tool) => { diff --git a/frontend/portal/src/components/agent-builder/VersionsPanel.tsx b/frontend/portal/src/components/agent-builder/VersionsPanel.tsx index 4a9bc5377a..b05e2fa7fc 100644 --- a/frontend/portal/src/components/agent-builder/VersionsPanel.tsx +++ b/frontend/portal/src/components/agent-builder/VersionsPanel.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Button, StatusBadge } from "@shared/components"; import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents"; import "@portal/views/AgentBuilder.css"; @@ -19,6 +20,7 @@ function formatDate(iso: string): string { /** Version history with publish / rollback actions per row. */ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { + const { t } = useTranslation(); // Without governance, only the current version is meaningful to show. const versions = historyUnlocked ? agent.versions @@ -54,7 +56,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { {isCurrent && ( - current + {t("agentBuilder.versions.current")} )}
@@ -70,7 +72,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { variant="outline" onClick={() => publish(v.version)} > - Publish + {t("agentBuilder.versions.publish")} )} {v.status === "published" && !isCurrent && ( @@ -79,7 +81,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { variant="ghost" onClick={() => rollback(v.version)} > - Roll back + {t("agentBuilder.versions.rollBack")} )}
@@ -90,8 +92,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { {!historyUnlocked && publishedExists && (

- Full version history and rollback are available on the Enterprise - plan. + {t("agentBuilder.versions.historyGate")}

)}
diff --git a/frontend/portal/src/components/catalogue/ComponentCard.tsx b/frontend/portal/src/components/catalogue/ComponentCard.tsx index 6b2c46d8ab..9c662d5b9b 100644 --- a/frontend/portal/src/components/catalogue/ComponentCard.tsx +++ b/frontend/portal/src/components/catalogue/ComponentCard.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Card, Chip, StatusBadge } from "@shared/components"; import { type SdkComponent, @@ -19,6 +20,7 @@ export function ComponentCard({ unlocked, onOpen, }: ComponentCardProps) { + const { t } = useTranslation(); const maturity = MATURITY_META[component.maturity]; return ( @@ -28,7 +30,7 @@ export function ComponentCard({ className={"portal-components__card" + (unlocked ? "" : " is-locked")} role="button" tabIndex={0} - aria-label={`Open ${component.name} component`} + aria-label={t("catalogue.card.openAriaLabel", { name: component.name })} onClick={() => onOpen(component)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { @@ -43,7 +45,10 @@ export function ComponentCard({ {maturity.label} {!unlocked && ( - + 🔒 )} diff --git a/frontend/portal/src/components/catalogue/ComponentDetailModal.tsx b/frontend/portal/src/components/catalogue/ComponentDetailModal.tsx index d9f8627a38..970d2fe392 100644 --- a/frontend/portal/src/components/catalogue/ComponentDetailModal.tsx +++ b/frontend/portal/src/components/catalogue/ComponentDetailModal.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Banner, Button, @@ -19,12 +20,7 @@ import "@portal/views/Components.css"; type DetailTab = "overview" | "code" | "props" | "pricing"; -const TABS: { key: DetailTab; label: string }[] = [ - { key: "overview", label: "Overview" }, - { key: "code", label: "Code" }, - { key: "props", label: "Props / API" }, - { key: "pricing", label: "Pricing" }, -]; +const TAB_KEYS: DetailTab[] = ["overview", "code", "props", "pricing"]; interface ComponentDetailModalProps { component: SdkComponent | null; @@ -43,16 +39,26 @@ export function ComponentDetailModal({ unlocked, onClose, }: ComponentDetailModalProps) { + const { t } = useTranslation(); const [tab, setTab] = useState("overview"); // Reset to the first tab whenever a new component is opened. const open = component !== null; if (!component) { return ( - + ); } + const tabs = TAB_KEYS.map((key) => ({ + key, + label: t(`catalogue.detail.tabs.${key}`), + })); + const maturity = MATURITY_META[component.maturity]; const npm = `@stirling/${component.package}`; @@ -86,7 +92,7 @@ export function ComponentDetailModal({ // publishable key scoped to this component. onClick={() => onClose()} > - Add to project + {t("catalogue.detail.addToProject")}
) : ( @@ -96,7 +102,7 @@ export function ComponentDetailModal({ // TODO(backend): route to the upgrade / contact-sales flow. onClick={() => onClose()} > - Upgrade to unlock + {t("catalogue.detail.upgradeToUnlock")} ) } @@ -104,8 +110,11 @@ export function ComponentDetailModal({ {!unlocked && ( )} @@ -113,19 +122,21 @@ export function ComponentDetailModal({
{/* TODO(backend)/host: mount the live here, booting the component against a demo document and the dev's publishable key. */} - Live preview + + {t("catalogue.detail.preview.badge")} + - Interactive sandbox renders here + {t("catalogue.detail.preview.note")}
className="portal-components__tabs" - items={TABS} + items={tabs} activeKey={tab} onChange={setTab} variant="underline" - ariaLabel="Component detail sections" + ariaLabel={t("catalogue.detail.tabsAriaLabel")} />
@@ -142,18 +153,26 @@ export function ComponentDetailModal({ ))}
- - + + 0 - ? `${component.pricing.freeQuota.toLocaleString()} / mo` - : "None" + ? t("catalogue.detail.stats.freeQuotaValue", { + amount: component.pricing.freeQuota.toLocaleString(), + }) + : t("catalogue.detail.stats.none") } />
@@ -162,11 +181,15 @@ export function ComponentDetailModal({ {tab === "code" && (
- +
)} @@ -177,23 +200,28 @@ export function ComponentDetailModal({
- + 0 - ? `${component.pricing.freeQuota.toLocaleString()} / mo` - : "None" + ? t("catalogue.detail.stats.freeQuotaValue", { + amount: component.pricing.freeQuota.toLocaleString(), + }) + : t("catalogue.detail.stats.none") } />

- Metered per {component.pricing.unit}. Usage beyond the monthly - free quota is billed to your account and itemised under Usage - & Billing. + {t("catalogue.detail.pricing.note", { + unit: component.pricing.unit, + })}

)} diff --git a/frontend/portal/src/components/catalogue/ComponentPropsTable.tsx b/frontend/portal/src/components/catalogue/ComponentPropsTable.tsx index 03f8e46925..0bb77cb47a 100644 --- a/frontend/portal/src/components/catalogue/ComponentPropsTable.tsx +++ b/frontend/portal/src/components/catalogue/ComponentPropsTable.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Chip, Table, type TableColumn } from "@shared/components"; import type { ComponentProp } from "@portal/api/sdkComponents"; import "@portal/views/Components.css"; @@ -9,43 +10,46 @@ interface ComponentPropsTableProps { /** Small Props/API reference shown under the detail modal's Props tab. */ export function ComponentPropsTable({ props: rows }: ComponentPropsTableProps) { + const { t } = useTranslation(); const columns = useMemo[]>( () => [ { key: "name", - header: "Prop", + header: t("catalogue.props.columns.name"), render: (p) => ( {p.name} ), }, { key: "type", - header: "Type", + header: t("catalogue.props.columns.type"), render: (p) => ( {p.type} ), }, { key: "required", - header: "Required", + header: t("catalogue.props.columns.required"), render: (p) => p.required ? ( - required + {t("catalogue.props.required")} ) : ( - optional + + {t("catalogue.props.optional")} + ), }, { key: "description", - header: "Description", + header: t("catalogue.props.columns.description"), render: (p) => ( {p.description} ), }, ], - [], + [t], ); return ( diff --git a/frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx b/frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx index 8cad2c51ae..ec46ad855f 100644 --- a/frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx +++ b/frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { MetricCard, MetricStrip } from "@shared/components"; import type { ComponentsResponse } from "@portal/api/sdkComponents"; @@ -6,11 +7,11 @@ import type { ComponentsResponse } from "@portal/api/sdkComponents"; * so the strip's structure stays stable across loading / empty / ready states. * Only values flow from the API. */ -const KPI_LABELS = [ - "Components GA", - "In beta", - "Embeds this month", - "Component spend (MTD)", +const KPI_LABEL_KEYS = [ + "catalogue.summary.componentsGa", + "catalogue.summary.inBeta", + "catalogue.summary.embedsThisMonth", + "catalogue.summary.componentSpendMtd", ] as const; interface ComponentsSummaryStripProps { @@ -22,6 +23,7 @@ export function ComponentsSummaryStrip({ data, loading, }: ComponentsSummaryStripProps) { + const { t } = useTranslation(); const s = loading ? undefined : data?.summary; const values: (string | number)[] = [ s?.gaCount ?? "—", @@ -32,8 +34,8 @@ export function ComponentsSummaryStrip({ return ( - {KPI_LABELS.map((label, i) => ( - + {KPI_LABEL_KEYS.map((labelKey, i) => ( + ))} ); diff --git a/frontend/portal/src/components/docs/AuthenticationSection.tsx b/frontend/portal/src/components/docs/AuthenticationSection.tsx index b6f782305c..65b1d89599 100644 --- a/frontend/portal/src/components/docs/AuthenticationSection.tsx +++ b/frontend/portal/src/components/docs/AuthenticationSection.tsx @@ -1,17 +1,19 @@ +import { useTranslation } from "react-i18next"; import { Chip, CodeBlock } from "@shared/components"; import { DocsSection } from "@portal/components/docs/DocsSection"; export function AuthenticationSection() { + const { t } = useTranslation(); return (
@@ -19,13 +21,13 @@ export function AuthenticationSection() { sk_live_ - Production keys — billed, rate-limited per your plan. + {t("docs.authentication.liveKey")}
sk_test_ - Sandbox keys — free, return synthetic fixtures. + {t("docs.authentication.testKey")}
diff --git a/frontend/portal/src/components/docs/ComponentsSection.tsx b/frontend/portal/src/components/docs/ComponentsSection.tsx index b25f5e5164..10a2349154 100644 --- a/frontend/portal/src/components/docs/ComponentsSection.tsx +++ b/frontend/portal/src/components/docs/ComponentsSection.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Card, Chip, CodeBlock } from "@shared/components"; import type { EmbedComponent } from "@portal/api/docs"; import { DocsSection } from "@portal/components/docs/DocsSection"; @@ -7,12 +8,13 @@ export function ComponentsSection({ }: { components: EmbedComponent[]; }) { + const { t } = useTranslation(); return (
{components.map((c) => ( @@ -29,7 +31,7 @@ export function ComponentsSection({
void; }) { + const { t } = useTranslation(); return ( -

i.id} /> diff --git a/frontend/portal/src/components/editor-admin/OfflineActivationCard.tsx b/frontend/portal/src/components/editor-admin/OfflineActivationCard.tsx index cd7370cbe2..3e9fef1998 100644 --- a/frontend/portal/src/components/editor-admin/OfflineActivationCard.tsx +++ b/frontend/portal/src/components/editor-admin/OfflineActivationCard.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Banner, Button, Card } from "@shared/components"; interface Props { @@ -14,6 +15,7 @@ interface Props { * shell with no submit endpoint yet. */ export function OfflineActivationCard({ available, onUpgrade }: Props) { + const { t } = useTranslation(); const [generating, setGenerating] = useState(false); const [generated, setGenerated] = useState(false); @@ -32,13 +34,13 @@ export function OfflineActivationCard({ available, onUpgrade }: Props) {

- Air-gapped activation - Enterprise + {t("editorAdmin.offlineActivation.title")} + + {t("editorAdmin.offlineActivation.enterpriseTag")} +

- Generate a signed activation bundle for an offline or on-prem - install with no outbound network path. Transfer it to the instance - and apply it during first-run setup. + {t("editorAdmin.offlineActivation.subtitle")}

@@ -46,7 +48,7 @@ export function OfflineActivationCard({ available, onUpgrade }: Props) { {!available ? (

- Offline and on-prem activation is part of Enterprise. + {t("editorAdmin.offlineActivation.lockCopy")}

) : ( @@ -62,8 +64,11 @@ export function OfflineActivationCard({ available, onUpgrade }: Props) { {generated && ( )}
@@ -73,7 +78,7 @@ export function OfflineActivationCard({ available, onUpgrade }: Props) { loading={generating} onClick={generate} > - Generate offline bundle + {t("editorAdmin.offlineActivation.generateButton")}
diff --git a/frontend/portal/src/components/editor-admin/PairingPanel.tsx b/frontend/portal/src/components/editor-admin/PairingPanel.tsx index 0ddcc31050..0b5b84f919 100644 --- a/frontend/portal/src/components/editor-admin/PairingPanel.tsx +++ b/frontend/portal/src/components/editor-admin/PairingPanel.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button, Card, Chip, CodeBlock } from "@shared/components"; import type { PairingMethod, PairingOption } from "@portal/api/editorDeploy"; @@ -20,6 +21,7 @@ interface Props { * has no submit endpoint yet. */ export function PairingPanel({ pairings, onUpgrade }: Props) { + const { t } = useTranslation(); // Tracks which option just got a (mock) rotate so we can flash confirmation. const [rotated, setRotated] = useState(null); @@ -53,7 +55,7 @@ export function PairingPanel({ pairings, onUpgrade }: Props) { {p.locked ? (

- IaC provisioning is part of Enterprise. + {t("editorAdmin.pairing.lockCopy")}

) : ( @@ -85,10 +87,10 @@ export function PairingPanel({ pairings, onUpgrade }: Props) { onClick={() => rotate(p.method)} > {rotated === p.method - ? "Generated ✓" + ? t("editorAdmin.pairing.generated") : p.method === "shortcode" - ? "Generate new code" - : "Rotate"} + ? t("editorAdmin.pairing.generateNewCode") + : t("editorAdmin.pairing.rotate")} diff --git a/frontend/portal/src/components/infrastructure/ApiKeyCard.tsx b/frontend/portal/src/components/infrastructure/ApiKeyCard.tsx index d0a7e3fe3c..9a08f8e769 100644 --- a/frontend/portal/src/components/infrastructure/ApiKeyCard.tsx +++ b/frontend/portal/src/components/infrastructure/ApiKeyCard.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Card, Chip, StatusBadge } from "@shared/components"; import type { ApiKey } from "@portal/api/infrastructure"; import { @@ -8,6 +9,7 @@ import { /** Collapsible row for a single API key: header summary + expandable detail grid. */ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); return ( @@ -38,33 +40,35 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
-
Created
+
{t("infrastructure.apiKeys.card.created")}
{apiKey.created}
-
Last used
+
{t("infrastructure.apiKeys.card.lastUsed")}
{apiKey.lastUsed}
-
Rate limit
+
{t("infrastructure.apiKeys.card.rateLimit")}
- {apiKey.rateLimit.toLocaleString()} req/min + {t("infrastructure.apiKeys.card.rateLimitValue", { + value: apiKey.rateLimit.toLocaleString(), + })}
-
Usage today
+
{t("infrastructure.apiKeys.card.usageToday")}
{apiKey.usageToday.toLocaleString()}
-
Usage this month
+
{t("infrastructure.apiKeys.card.usageMonth")}
{apiKey.usageMonth.toLocaleString()}
-
Permissions
+
{t("infrastructure.apiKeys.card.permissions")}
{apiKey.permissions.map((p) => ( @@ -74,11 +78,11 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
-
Allowed IPs
+
{t("infrastructure.apiKeys.card.allowedIps")}
{apiKey.allowedIps.length === 0 ? ( - Any IP (no allowlist) + {t("infrastructure.apiKeys.card.anyIp")} ) : ( apiKey.allowedIps.map((ip) => ( diff --git a/frontend/portal/src/components/infrastructure/ApiKeysTab.tsx b/frontend/portal/src/components/infrastructure/ApiKeysTab.tsx index f81ba468cc..8cb48a4128 100644 --- a/frontend/portal/src/components/infrastructure/ApiKeysTab.tsx +++ b/frontend/portal/src/components/infrastructure/ApiKeysTab.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button, EmptyState, Skeleton } from "@shared/components"; import { useTier } from "@portal/contexts/TierContext"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; @@ -8,6 +9,7 @@ import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal import { SectionHeader } from "@portal/components/infrastructure/SectionHeader"; export function ApiKeysTab() { + const { t } = useTranslation(); const { tier } = useTier(); const [modalOpen, setModalOpen] = useState(false); const state = useAsync(() => fetchApiKeys(tier), [tier]); @@ -18,8 +20,8 @@ export function ApiKeysTab() {
@@ -42,8 +44,8 @@ export function ApiKeysTab() { {isEmpty && ( )} diff --git a/frontend/portal/src/components/infrastructure/AuditTab.tsx b/frontend/portal/src/components/infrastructure/AuditTab.tsx index 7bedf71cc6..729d92c81b 100644 --- a/frontend/portal/src/components/infrastructure/AuditTab.tsx +++ b/frontend/portal/src/components/infrastructure/AuditTab.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Card, EmptyState, @@ -28,59 +29,69 @@ import { type AuditFilter = "all" | AuditCategory; -const AUDIT_FILTERS: TabItem[] = [ - { key: "all", label: "All" }, - { key: "auth", label: "Auth" }, - { key: "config", label: "Config" }, - { key: "elevation", label: "Elevation" }, - { key: "processing", label: "Processing" }, - { key: "security", label: "Security" }, -]; - -const cols: TableColumn[] = [ - { - key: "timestamp", - header: "Timestamp", - render: (e) => {e.timestamp}, - }, - { - key: "event", - header: "Event", - render: (e) => ( -
- - {AUDIT_CAT_LABEL[e.category]} - - {e.action} -
- ), - }, - { - key: "actor", - header: "Actor", - render: (e) => {e.actor}, - }, - { key: "target", header: "Target", render: (e) => e.target }, - { - key: "status", - header: "Status", - render: (e) => ( - - {titleCase(e.status)} - - ), - }, - { - key: "latency", - header: "Latency", - align: "right", - render: (e) => {e.latencyMs} ms, - }, -]; - export function AuditTab() { + const { t } = useTranslation(); const { tier } = useTier(); const [filter, setFilter] = useState("all"); + + const auditFilters: TabItem[] = [ + { key: "all", label: t("infrastructure.audit.filters.all") }, + { key: "auth", label: t("infrastructure.audit.filters.auth") }, + { key: "config", label: t("infrastructure.audit.filters.config") }, + { key: "elevation", label: t("infrastructure.audit.filters.elevation") }, + { key: "processing", label: t("infrastructure.audit.filters.processing") }, + { key: "security", label: t("infrastructure.audit.filters.security") }, + ]; + + const cols: TableColumn[] = [ + { + key: "timestamp", + header: t("infrastructure.audit.columns.timestamp"), + render: (e) => {e.timestamp}, + }, + { + key: "event", + header: t("infrastructure.audit.columns.event"), + render: (e) => ( +
+ + {AUDIT_CAT_LABEL[e.category]} + + {e.action} +
+ ), + }, + { + key: "actor", + header: t("infrastructure.audit.columns.actor"), + render: (e) => {e.actor}, + }, + { + key: "target", + header: t("infrastructure.audit.columns.target"), + render: (e) => e.target, + }, + { + key: "status", + header: t("infrastructure.audit.columns.status"), + render: (e) => ( + + {titleCase(e.status)} + + ), + }, + { + key: "latency", + header: t("infrastructure.audit.columns.latency"), + align: "right", + render: (e) => ( + + {t("infrastructure.audit.latencyValue", { value: e.latencyMs })} + + ), + }, + ]; + const state = useAsync(() => fetchAuditLog(tier), [tier]); const { data } = state; const { isLoading, isEmpty } = useSectionFlags(state); @@ -94,37 +105,37 @@ export function AuditTab() { return (
{data && (
)} - items={AUDIT_FILTERS} + items={auditFilters} activeKey={filter} onChange={setFilter} variant="pill" - ariaLabel="Filter audit events by category" + ariaLabel={t("infrastructure.audit.filterAriaLabel")} /> @@ -132,8 +143,8 @@ export function AuditTab() { {isEmpty && ( )} {!isEmpty && data && ( @@ -141,7 +152,7 @@ export function AuditTab() { columns={cols} rows={rows} rowKey={(e) => e.id} - empty="No events in this category." + empty={t("infrastructure.audit.noEventsInCategory")} /> )} diff --git a/frontend/portal/src/components/infrastructure/CreateKeyModal.tsx b/frontend/portal/src/components/infrastructure/CreateKeyModal.tsx index 3c3c8aa528..f2100594ad 100644 --- a/frontend/portal/src/components/infrastructure/CreateKeyModal.tsx +++ b/frontend/portal/src/components/infrastructure/CreateKeyModal.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Banner, Button, @@ -23,6 +24,7 @@ export function CreateKeyModal({ open: boolean; onClose: () => void; }) { + const { t } = useTranslation(); const [name, setName] = useState(""); const [perms, setPerms] = useState(["Read"]); const [ips, setIps] = useState(""); @@ -58,28 +60,32 @@ export function CreateKeyModal({ open={open} onClose={close} width="md" - title={created ? "Key created" : "Create API key"} + title={ + created + ? t("infrastructure.createKey.titleCreated") + : t("infrastructure.createKey.title") + } subtitle={ created - ? "Copy this secret now — it won't be shown again." - : "Scope the key to the minimum it needs. You can rotate or revoke at any time." + ? t("infrastructure.createKey.subtitleCreated") + : t("infrastructure.createKey.subtitle") } footer={ created ? ( ) : (
) @@ -90,24 +96,27 @@ export function CreateKeyModal({
) : (
- + setName(e.target.value)} - placeholder="e.g. Production · ingest" + placeholder={t("infrastructure.createKey.keyNamePlaceholder")} /> - +
{PERMISSION_OPTS.map((p) => ( [] = [ - { - key: "name", - header: "Region", - render: (r) => ( -
- {r.name} - {r.code} -
- ), - }, - { - key: "latency", - header: "Latency", - align: "right", - render: (r) => {r.latencyMs} ms, - }, - { - key: "load", - header: "Load", - width: "9rem", - render: (r) => ( -
- - {pct(r.load)} -
- ), - }, - { - key: "status", - header: "Status", - render: (r) => ( - - {titleCase(r.status)} - - ), - }, - { - key: "version", - header: "Version", - render: (r) => {r.version}, - }, - { - key: "uptime", - header: "Uptime", - align: "right", - render: (r) => ( - {pct(r.uptime, 3)} - ), - }, - { - key: "instances", - header: "Instances", - align: "right", - render: (r) => {r.instances}, - }, - { - key: "throughput", - header: "Throughput", - align: "right", - render: (r) => ( - - {r.throughput.toLocaleString()}/min - - ), - }, - { - key: "p99", - header: "P99", - align: "right", - render: (r) => {r.p99Ms} ms, - }, -]; - -const deployCols: TableColumn[] = [ - { - key: "version", - header: "Version", - render: (d) => {d.version}, - }, - { - key: "environment", - header: "Environment", - render: (d) => ( - - {d.environment} - - ), - }, - { key: "product", header: "Product", render: (d) => d.product }, - { - key: "status", - header: "Status", - render: (d) => ( - - {DEPLOY_LABEL[d.status]} - - ), - }, - { - key: "deployedBy", - header: "Deployed by", - render: (d) => {d.deployedBy}, - }, - { - key: "timestamp", - header: "When", - align: "right", - render: (d) => {d.timestamp}, - }, -]; - export function DeploymentsTab() { + const { t } = useTranslation(); const { tier } = useTier(); const state = useAsync( () => fetchDeployments(tier), @@ -159,20 +36,165 @@ export function DeploymentsTab() { const { data } = state; const { isLoading, isEmpty } = useSectionFlags(state); + const regionCols: TableColumn[] = [ + { + key: "name", + header: t("infrastructure.deployments.regionColumns.region"), + render: (r) => ( +
+ {r.name} + {r.code} +
+ ), + }, + { + key: "latency", + header: t("infrastructure.deployments.regionColumns.latency"), + align: "right", + render: (r) => ( + + {t("infrastructure.deployments.msValue", { value: r.latencyMs })} + + ), + }, + { + key: "load", + header: t("infrastructure.deployments.regionColumns.load"), + width: "9rem", + render: (r) => ( +
+ + {pct(r.load)} +
+ ), + }, + { + key: "status", + header: t("infrastructure.deployments.regionColumns.status"), + render: (r) => ( + + {titleCase(r.status)} + + ), + }, + { + key: "version", + header: t("infrastructure.deployments.regionColumns.version"), + render: (r) => ( + {r.version} + ), + }, + { + key: "uptime", + header: t("infrastructure.deployments.regionColumns.uptime"), + align: "right", + render: (r) => ( + {pct(r.uptime, 3)} + ), + }, + { + key: "instances", + header: t("infrastructure.deployments.regionColumns.instances"), + align: "right", + render: (r) => {r.instances}, + }, + { + key: "throughput", + header: t("infrastructure.deployments.regionColumns.throughput"), + align: "right", + render: (r) => ( + + {t("infrastructure.deployments.throughputValue", { + value: r.throughput.toLocaleString(), + })} + + ), + }, + { + key: "p99", + header: t("infrastructure.deployments.regionColumns.p99"), + align: "right", + render: (r) => ( + + {t("infrastructure.deployments.msValue", { value: r.p99Ms })} + + ), + }, + ]; + + const deployCols: TableColumn[] = [ + { + key: "version", + header: t("infrastructure.deployments.deployColumns.version"), + render: (d) => ( + {d.version} + ), + }, + { + key: "environment", + header: t("infrastructure.deployments.deployColumns.environment"), + render: (d) => ( + + {d.environment} + + ), + }, + { + key: "product", + header: t("infrastructure.deployments.deployColumns.product"), + render: (d) => d.product, + }, + { + key: "status", + header: t("infrastructure.deployments.deployColumns.status"), + render: (d) => ( + + {DEPLOY_LABEL[d.status]} + + ), + }, + { + key: "deployedBy", + header: t("infrastructure.deployments.deployColumns.deployedBy"), + render: (d) => {d.deployedBy}, + }, + { + key: "timestamp", + header: t("infrastructure.deployments.deployColumns.when"), + align: "right", + render: (d) => {d.timestamp}, + }, + ]; + return (
{isLoading && } {isEmpty && ( )} {!isEmpty && data && data.regions.length > 0 && ( @@ -187,8 +209,8 @@ export function DeploymentsTab() {
{isLoading && } diff --git a/frontend/portal/src/components/infrastructure/ModelsTab.tsx b/frontend/portal/src/components/infrastructure/ModelsTab.tsx index f807941a6b..26d586a747 100644 --- a/frontend/portal/src/components/infrastructure/ModelsTab.tsx +++ b/frontend/portal/src/components/infrastructure/ModelsTab.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Banner, Card, @@ -31,81 +32,88 @@ import { pct, } from "@portal/components/infrastructure/infraFormat"; -const modelCols: TableColumn[] = [ - { - key: "name", - header: "Model", - render: (m) => ( -
- {m.name} - - {MODEL_PROVIDER_LABEL[m.provider]} - -
- ), - }, - { - key: "type", - header: "Type", - render: (m) => ( - - {MODEL_TYPE_LABEL[m.type]} - - ), - }, - { - key: "status", - header: "Status", - render: (m) => ( - - {MODEL_LABEL[m.status]} - - ), - }, - { - key: "load", - header: "Load", - width: "9rem", - render: (m) => ( -
- - {pct(m.load)} -
- ), - }, - { - key: "latency", - header: "Latency", - align: "right", - render: (m) => {m.latencyMs} ms, - }, - { - key: "cost", - header: "Cost", - align: "right", - render: (m) => ( - - {modelCost(m.cost, m.costUnit)} - - ), - }, - { - key: "version", - header: "Version", - render: (m) => {m.version}, - }, -]; - export function ModelsTab() { + const { t } = useTranslation(); const { tier } = useTier(); const state = useAsync(() => fetchModels(tier), [tier]); const { data } = state; const { isLoading, isEmpty } = useSectionFlags(state); + const modelCols: TableColumn[] = [ + { + key: "name", + header: t("infrastructure.models.columns.model"), + render: (m) => ( +
+ {m.name} + + {MODEL_PROVIDER_LABEL[m.provider]} + +
+ ), + }, + { + key: "type", + header: t("infrastructure.models.columns.type"), + render: (m) => ( + + {MODEL_TYPE_LABEL[m.type]} + + ), + }, + { + key: "status", + header: t("infrastructure.models.columns.status"), + render: (m) => ( + + {MODEL_LABEL[m.status]} + + ), + }, + { + key: "load", + header: t("infrastructure.models.columns.load"), + width: "9rem", + render: (m) => ( +
+ + {pct(m.load)} +
+ ), + }, + { + key: "latency", + header: t("infrastructure.models.columns.latency"), + align: "right", + render: (m) => ( + + {t("infrastructure.models.msValue", { value: m.latencyMs })} + + ), + }, + { + key: "cost", + header: t("infrastructure.models.columns.cost"), + align: "right", + render: (m) => ( + + {modelCost(m.cost, m.costUnit)} + + ), + }, + { + key: "version", + header: t("infrastructure.models.columns.version"), + render: (m) => ( + {m.version} + ), + }, + ]; + // Free has no routing control: the catalogue is read-only and the routing // table is replaced by an upgrade nudge. const canRoute = tier !== "free"; @@ -121,29 +129,35 @@ export function ModelsTab() { const routingCols: TableColumn[] = [ { key: "operation", - header: "Operation", + header: t("infrastructure.models.routingColumns.operation"), render: (r) => (
{r.operation} {r.isDefault && ( - Default + {t("infrastructure.models.routingColumns.default")} )}
), }, - { key: "docType", header: "Document type", render: (r) => r.docType }, + { + key: "docType", + header: t("infrastructure.models.routingColumns.docType"), + render: (r) => r.docType, + }, { key: "modelId", - header: "Routed to", + header: t("infrastructure.models.routingColumns.routedTo"), width: "16rem", render: (r) => (
- Active + + {t("infrastructure.storage.lifecycle.active")} + - 0–{retentionValue === "never" ? "∞" : retentionValue}d + {t("infrastructure.storage.lifecycle.activeRange", { + value: retentionValue === "never" ? "∞" : retentionValue, + })}
@@ -165,17 +195,25 @@ export function StorageTab() {
- Archived - cold storage + + {t("infrastructure.storage.lifecycle.archived")} + + + {t("infrastructure.storage.lifecycle.coldStorage")} +
- Deleted + + {t("infrastructure.storage.lifecycle.deleted")} + - {retentionValue === "never" ? "never" : "purged"} + {retentionValue === "never" + ? t("infrastructure.storage.lifecycle.never") + : t("infrastructure.storage.lifecycle.purged")}
diff --git a/frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx b/frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx index 0ad463c3a1..03d2e6f015 100644 --- a/frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx +++ b/frontend/portal/src/components/pipelines/DeployedPipelinesTable.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { StatusBadge, Table, type TableColumn } from "@shared/components"; import type { Pipeline } from "@portal/api/pipelines"; import { compact, goldenTone, pct } from "@portal/components/pipelines/format"; @@ -17,11 +18,12 @@ export function DeployedPipelinesTable({ pipelines, onRowClick, }: DeployedPipelinesTableProps) { + const { t } = useTranslation(); const columns = useMemo[]>( () => [ { key: "name", - header: "Pipeline", + header: t("pipelines.table.header.name"), render: (p) => (
{p.name} @@ -33,20 +35,22 @@ export function DeployedPipelinesTable({ }, { key: "status", - header: "Health", + header: t("pipelines.table.header.health"), render: (p) => ( - {p.status === "degraded" ? "Degraded" : "Healthy"} + {p.status === "degraded" + ? t("pipelines.status.degraded") + : t("pipelines.status.healthy")} ), }, { key: "golden", - header: "Golden set", + header: t("pipelines.table.header.goldenSet"), width: "11rem", render: (p) => { const tone = goldenTone(p.golden); @@ -58,7 +62,9 @@ export function DeployedPipelinesTable({ {pct(rate, 1)} @@ -68,7 +74,7 @@ export function DeployedPipelinesTable({ }, { key: "docs", - header: "Docs / 24h", + header: t("pipelines.table.header.docs24h"), align: "right", render: (p) => ( @@ -78,14 +84,14 @@ export function DeployedPipelinesTable({ }, { key: "version", - header: "Version", + header: t("pipelines.table.header.version"), align: "right", render: (p) => ( {p.version} ), }, ], - [], + [t], ); return ( diff --git a/frontend/portal/src/components/pipelines/PipelineCard.tsx b/frontend/portal/src/components/pipelines/PipelineCard.tsx index 77fa31dc6d..596ec8cd86 100644 --- a/frontend/portal/src/components/pipelines/PipelineCard.tsx +++ b/frontend/portal/src/components/pipelines/PipelineCard.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Card, StatTile, StatusBadge } from "@shared/components"; import type { Pipeline, StageSummary } from "@portal/api/pipelines"; import { @@ -8,6 +9,7 @@ import { compact, pct } from "@portal/components/pipelines/format"; /** Compact five-dot stage indicator: a lit dot per stage that has ops. */ function StageDots({ stages }: { stages: StageSummary[] }) { + const { t } = useTranslation(); return ( {stages.map((s) => ( @@ -19,7 +21,10 @@ function StageDots({ stages }: { stages: StageSummary[] }) { ? STAGE_COLOR_VAR[STAGE_ACCENT[s.key]] : "var(--color-border)", }} - title={`${s.label}: ${s.ops.length} op${s.ops.length === 1 ? "" : "s"}`} + title={t("pipelines.card.stageTooltip", { + label: s.label, + count: s.ops.length, + })} /> ))} @@ -33,6 +38,7 @@ export interface PipelineCardProps { /** Row in the deployed fleet: health, source→stages→destination rail, 24h metrics. */ export function PipelineCard({ pipeline, onOpen }: PipelineCardProps) { + const { t } = useTranslation(); const m = pipeline.metrics; const degraded = pipeline.status === "degraded"; const errorTone = @@ -60,7 +66,9 @@ export function PipelineCard({ pipeline, onOpen }: PipelineCardProps) { size="sm" pulse={degraded} > - {degraded ? "Degraded" : "Healthy"} + {degraded + ? t("pipelines.status.degraded") + : t("pipelines.status.healthy")}
@@ -79,15 +87,27 @@ export function PipelineCard({ pipeline, onOpen }: PipelineCardProps) {
- - + + - - + +
@@ -95,11 +115,14 @@ export function PipelineCard({ pipeline, onOpen }: PipelineCardProps) { {pipeline.version} · {pipeline.regions.join(", ")} - Golden {pipeline.golden.passing}/{pipeline.golden.total} + {t("pipelines.card.golden", { + passing: pipeline.golden.passing, + total: pipeline.golden.total, + })} {driftCount > 0 && ( {" · "} - {driftCount} drift{driftCount > 1 ? "s" : ""} + {t("pipelines.card.drift", { count: driftCount })} )} diff --git a/frontend/portal/src/components/pipelines/PipelineComposer.tsx b/frontend/portal/src/components/pipelines/PipelineComposer.tsx index a2934a88fd..b57c9eaa87 100644 --- a/frontend/portal/src/components/pipelines/PipelineComposer.tsx +++ b/frontend/portal/src/components/pipelines/PipelineComposer.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button, Chip, Modal } from "@shared/components"; import { DESTINATION_OPTIONS, @@ -13,15 +14,16 @@ import { STAGE_COLOR_VAR, } from "@portal/components/pipelines/stageAccent"; -const COMPOSER_STEPS = ["Source", "Operations", "Routing"] as const; +const COMPOSER_STEPS = ["source", "operations", "routing"] as const; -const OP_KIND_LABEL: Record = { - ingest: "Ingest", - validate: "Validate", - modify: "Modify", - secure: "Secure", - store: "Route / Store", - alert: "Alerts", +/** Translation key suffixes for each op-kind group heading in the picker. */ +const OP_KIND_LABEL_KEY: Record = { + ingest: "ingest", + validate: "validate", + modify: "modify", + secure: "secure", + store: "store", + alert: "alert", }; /** Selectable ops in the picker — excludes pipeline-only structural ops. */ @@ -48,6 +50,7 @@ export interface PipelineComposerProps { /** Three-step wizard: pick a source, compose the op chain, route the output. */ export function PipelineComposer({ open, onClose }: PipelineComposerProps) { + const { t } = useTranslation(); const [step, setStep] = useState(0); const [source, setSource] = useState("upload"); const [selectedOps, setSelectedOps] = useState([ @@ -103,29 +106,29 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) { open={open} onClose={close} width="xl" - title="New pipeline" - subtitle="Pick a source, compose the operation chain, then route the output." + title={t("pipelines.composer.title")} + subtitle={t("pipelines.composer.subtitle")} footer={ <>
- {COMPOSER_STEPS.map((label, i) => ( + {COMPOSER_STEPS.map((stepId, i) => ( - {i + 1}. {label} + {i + 1}. {t(`pipelines.composer.steps.${stepId}`)} ))}
{step > 0 && ( )} {isLast ? ( @@ -134,7 +137,7 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) { onClick={deploy} trailingIcon={} > - Deploy pipeline + {t("pipelines.composer.deploy")} ) : ( )} @@ -162,10 +165,10 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) { onClick={() => setSource("any")} > - Any source + {t("pipelines.composer.anySource.label")} - Accept documents from every connected channel + {t("pipelines.composer.anySource.desc")} {SOURCE_OPTIONS.map((opt) => ( @@ -194,12 +197,14 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
- Operation chain ({selectedOps.length}) + {t("pipelines.composer.operationChain", { + count: selectedOps.length, + })}
{selectedOps.length === 0 ? ( - Add operations from the library below. + {t("pipelines.composer.chainEmpty")} ) : ( selectedOps.map((id) => { @@ -222,7 +227,7 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) {
- Quick-add bundles + {t("pipelines.composer.quickAddBundles")}
{PIPELINE_AGENTS.map((agent) => ( @@ -249,7 +254,7 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) { }} aria-hidden /> - {OP_KIND_LABEL[kind]} + {t(`pipelines.composer.opKind.${OP_KIND_LABEL_KEY[kind]}`)}
{PICKER_OPS[kind].map((op) => { @@ -275,7 +280,9 @@ export function PipelineComposer({ open, onClose }: PipelineComposerProps) { {step === 2 && (
- Destination + + {t("pipelines.composer.destination")} +
{DESTINATION_OPTIONS.map((opt) => ( ); }, }, ], - [promoteState], + [promoteState, t], ); return ( diff --git a/frontend/portal/src/components/policies/CatalogueSummary.tsx b/frontend/portal/src/components/policies/CatalogueSummary.tsx index 005696da91..f4907ca091 100644 --- a/frontend/portal/src/components/policies/CatalogueSummary.tsx +++ b/frontend/portal/src/components/policies/CatalogueSummary.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { MetricCard, MetricStrip } from "@shared/components"; import type { PoliciesResponse } from "@portal/api/policies"; @@ -12,28 +13,29 @@ interface CatalogueSummaryProps { * across loading / ready states; only the values flow from the API. */ export function CatalogueSummary({ data, loading }: CatalogueSummaryProps) { + const { t } = useTranslation(); const s = loading ? undefined : data?.summary; return ( ); diff --git a/frontend/portal/src/components/policies/PolicyCategoryCard.tsx b/frontend/portal/src/components/policies/PolicyCategoryCard.tsx index 38e06a357a..fe999a1dfb 100644 --- a/frontend/portal/src/components/policies/PolicyCategoryCard.tsx +++ b/frontend/portal/src/components/policies/PolicyCategoryCard.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Card, Chip, StatusBadge, StatTile } from "@shared/components"; import type { CatalogueEntry } from "@portal/api/policies"; import { policyIcon } from "@portal/components/policies/policyIcons"; @@ -14,6 +15,7 @@ interface PolicyCategoryCardProps { * "Set up" affordance; coming-soon categories render locked and inert. */ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) { + const { t } = useTranslation(); const { category, config, policy } = entry; const comingSoon = category.comingSoon === true; const openable = !comingSoon; @@ -53,7 +55,7 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
{comingSoon ? ( - Coming soon + {t("policies.card.comingSoon")} ) : policy ? ( - {status === "paused" ? "Paused" : "Active"} + {status === "paused" + ? t("policies.status.paused") + : t("policies.status.active")} ) : ( - Not set up + {t("policies.card.notSetUp")} )} @@ -75,11 +79,17 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) { {policy ? (
- - + +
) : (
@@ -91,7 +101,9 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) { ))}
{!comingSoon && ( - Set up → + + {t("policies.card.setUp")} + )} )} diff --git a/frontend/portal/src/components/policies/PolicyDetailPanel.tsx b/frontend/portal/src/components/policies/PolicyDetailPanel.tsx index da0fa06e39..cdf4399bd9 100644 --- a/frontend/portal/src/components/policies/PolicyDetailPanel.tsx +++ b/frontend/portal/src/components/policies/PolicyDetailPanel.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Banner, Button, @@ -45,6 +46,7 @@ export function PolicyDetailPanel({ onTogglePause, onDelete, }: PolicyDetailPanelProps) { + const { t } = useTranslation(); if (!policy) return null; const { category, config, state, steps, stats, activity } = policy; const isPaused = state.status === "paused"; @@ -64,7 +66,7 @@ export function PolicyDetailPanel({ > {policyIcon(category.icon)} - {category.label} policy + {t("policies.detail.title", { category: category.label })} } subtitle={config.summary} @@ -79,7 +81,7 @@ export function PolicyDetailPanel({ disabled={busy} style={{ marginRight: "auto" }} > - Delete + {t("policies.detail.actions.delete")} )}
} >
- {isPaused ? "Paused" : "Active"} + {isPaused ? t("policies.status.paused") : t("policies.status.active")} - Runs on {state.runOn ?? "upload"} · output{" "} - {state.outputMode === "new_file" - ? "as a new file" - : "as a new version"} + {t("policies.detail.meta", { + event: state.runOn ?? "upload", + output: + state.outputMode === "new_file" + ? t("policies.detail.outputAsNewFile") + : t("policies.detail.outputAsNewVersion"), + })}
-

Enforces

+

+ {t("policies.detail.enforces")} +

{enforceItems.length > 0 ? (
@@ -144,12 +153,13 @@ export function PolicyDetailPanel({
)}

- {config.scopeLabel} · originals stay untouched, the enforced version - is saved alongside. + {t("policies.detail.enforceNote", { scope: config.scopeLabel })}

-

Recent activity

+

+ {t("policies.detail.recentActivity")} +

{activity.length > 0 ? ( {activity.map((item, i) => ( @@ -179,26 +189,34 @@ export function PolicyDetailPanel({ )} - - + + {state.scopeTypes.length > 0 && ( )} diff --git a/frontend/portal/src/components/policies/PolicySetupWizard.tsx b/frontend/portal/src/components/policies/PolicySetupWizard.tsx index 35df6b8a3d..133100f9a3 100644 --- a/frontend/portal/src/components/policies/PolicySetupWizard.tsx +++ b/frontend/portal/src/components/policies/PolicySetupWizard.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Banner, Button, @@ -101,6 +102,7 @@ function PolicySetupWizardBody({ onClose: () => void; onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise; }) { + const { t } = useTranslation(); const { category, config, policy } = entry; const isEdit = policy != null; @@ -158,7 +160,7 @@ function PolicySetupWizardBody({ async function submit() { if (submitting) return; if (enabledTools.length === 0) { - setError("Enable at least one tool in the workflow first."); + setError(t("policies.wizard.errors.noTools")); setStep("workflow"); return; } @@ -182,7 +184,7 @@ function PolicySetupWizardBody({ }); } catch { setSubmitting(false); - setError("Couldn't save the policy. Please try again."); + setError(t("policies.wizard.errors.saveFailed")); } } @@ -202,15 +204,15 @@ function PolicySetupWizardBody({ {policyIcon(category.icon)} {isEdit - ? `Edit ${category.label} policy` - : `Set up ${category.label} policy`} + ? t("policies.wizard.title.edit", { category: category.label }) + : t("policies.wizard.title.setUp", { category: category.label })} } subtitle={config.summary} footer={
{step === "workflow" ? ( ) : ( <> @@ -228,10 +230,12 @@ function PolicySetupWizardBody({ style={{ marginLeft: "auto" }} onClick={() => setStep("workflow")} > - Back + {t("policies.wizard.actions.back")} )} @@ -240,12 +244,12 @@ function PolicySetupWizardBody({ > setStep(k as Step)} items={[ - { key: "workflow", label: "Workflow" }, - { key: "settings", label: "Settings" }, + { key: "workflow", label: t("policies.wizard.tabs.workflow") }, + { key: "settings", label: t("policies.wizard.tabs.settings") }, ]} /> @@ -260,8 +264,7 @@ function PolicySetupWizardBody({ {step === "workflow" && (

- The sequence of tools this policy runs on each document. Each tool - is a Stirling endpoint; toggle the ones this policy should enforce. + {t("policies.wizard.workflow.description")}

{tools.map((tl) => ( @@ -290,7 +293,9 @@ function PolicySetupWizardBody({
{config.fields.length > 0 && ( <> -

Settings

+

+ {t("policies.wizard.settings.heading")} +

{config.fields.map((field) => ( )} -

Sources

+

+ {t("policies.wizard.sources.heading")} +

{POLICY_SOURCES.map((src) => (
{scopeNarrow && ( @@ -375,11 +388,13 @@ function PolicySetupWizardBody({ )} -

Output & run

+

+ {t("policies.wizard.output.heading")} +

- +
setOutputName(e.target.value)} /> )}
= 0.05 ? "danger" @@ -19,22 +21,31 @@ export function AgentPanel({ d }: { d: AgentDetail }) { return (
- {d.model}} /> - {d.model}} + /> + + {pct(d.errorRate)} } /> - +
- Mean confidence + {t("sources.agent.meanConfidence")} {pct(d.confidence)}
= 0.93 ? "var(--color-green)" : "var(--color-amber)" } - label="Mean output confidence" + label={t("sources.agent.meanOutputConfidence")} />
- Assigned pipelines + {t("sources.agent.assignedPipelines")}
{d.assignedPipelines.map((p) => ( @@ -60,7 +71,9 @@ export function AgentPanel({ d }: { d: AgentDetail }) {
- Scopes + + {t("sources.agent.scopes")} +
{d.scopes.map((s) => ( @@ -74,10 +87,10 @@ export function AgentPanel({ d }: { d: AgentDetail }) { POST /v1/sources/{id}/pause — currently inert demo controls. */}
diff --git a/frontend/portal/src/components/sources/ApiClientPanel.tsx b/frontend/portal/src/components/sources/ApiClientPanel.tsx index 2a2a52caac..332b18f949 100644 --- a/frontend/portal/src/components/sources/ApiClientPanel.tsx +++ b/frontend/portal/src/components/sources/ApiClientPanel.tsx @@ -1,32 +1,50 @@ +import { useTranslation } from "react-i18next"; import { Button, Chip, ProgressBar, StatTile } from "@shared/components"; import type { ApiClientDetail } from "@portal/api/sources"; import { pct } from "@portal/components/sources/format"; import "@portal/views/Sources.css"; export function ApiClientPanel({ d }: { d: ApiClientDetail }) { + const { t } = useTranslation(); return (
- {d.maskedKey}} /> - - - + {d.maskedKey}} + /> + + +
- Rate-limit window - {pct(d.rateUsedPct)} used + {t("sources.apiClient.rateLimitWindow")} + + {t("sources.apiClient.usedPct", { pct: pct(d.rateUsedPct) })} +
- Top endpoints + + {t("sources.apiClient.topEndpoints")} +
{d.endpoints.map((e) => (
@@ -39,7 +57,9 @@ export function ApiClientPanel({ d }: { d: ApiClientDetail }) { {e.path} - {e.calls24h.toLocaleString()} / 24h + {t("sources.apiClient.callsPer24h", { + count: e.calls24h.toLocaleString(), + })}
))} @@ -50,10 +70,10 @@ export function ApiClientPanel({ d }: { d: ApiClientDetail }) { DELETE /v1/sources/{id} — currently inert demo controls. */}
diff --git a/frontend/portal/src/components/sources/ConnectWizard.tsx b/frontend/portal/src/components/sources/ConnectWizard.tsx index 1c6471cc83..8bf677f27d 100644 --- a/frontend/portal/src/components/sources/ConnectWizard.tsx +++ b/frontend/portal/src/components/sources/ConnectWizard.tsx @@ -1,9 +1,10 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button, CodeBlock, Modal, StatTile } from "@shared/components"; import { type Source, SOURCE_TYPE_META } from "@portal/api/sources"; import "@portal/views/Sources.css"; -const WIZARD_STEPS = ["Choose type", "Configure", "Review & connect"] as const; +const WIZARD_STEP_COUNT = 3; const CONNECT_SNIPPET = `curl https://api.stirlingpdf.com/v1/extract \\ -H "Authorization: Bearer sk_live_••••" \\ @@ -20,9 +21,16 @@ interface ConnectWizardProps { * closes without provisioning — wiring it to the backend creates the source. */ export function ConnectWizard({ open, onClose }: ConnectWizardProps) { + const { t } = useTranslation(); const [step, setStep] = useState(0); const [type, setType] = useState("agent"); + const wizardSteps = [ + t("sources.wizard.steps.chooseType"), + t("sources.wizard.steps.configure"), + t("sources.wizard.steps.review"), + ]; + function close() { onClose(); // Reset for the next open, after the close transition has finished. @@ -32,7 +40,7 @@ export function ConnectWizard({ open, onClose }: ConnectWizardProps) { }, 200); } - const isLast = step === WIZARD_STEPS.length - 1; + const isLast = step === WIZARD_STEP_COUNT - 1; function advance() { if (isLast) { @@ -49,8 +57,12 @@ export function ConnectWizard({ open, onClose }: ConnectWizardProps) { open={open} onClose={close} width="lg" - title="Connect a source" - subtitle={`Step ${step + 1} of ${WIZARD_STEPS.length} · ${WIZARD_STEPS[step]}`} + title={t("sources.wizard.title")} + subtitle={t("sources.wizard.subtitle", { + current: step + 1, + total: WIZARD_STEP_COUNT, + label: wizardSteps[step], + })} footer={
} >
    - {WIZARD_STEPS.map((label, i) => ( + {wizardSteps.map((label, i) => (
  1. - Configure your {SOURCE_TYPE_META[type].label}. - Point it at Stirling and attach a default pipeline — every document - this source ingests runs through it automatically. + {t("sources.wizard.configureLead.before")}{" "} + {SOURCE_TYPE_META[type].label} + {t("sources.wizard.configureLead.after")}

    - Scopes, rate limits and IP allowlists can be tuned after the source - is connected. + {t("sources.wizard.configureNote")}

)} @@ -129,15 +142,24 @@ export function ConnectWizard({ open, onClose }: ConnectWizardProps) { {step === 2 && (

- Ready to connect a new{" "} - {SOURCE_TYPE_META[type].label}. It starts paused so - you can verify the first few documents before going live. + {t("sources.wizard.reviewLead.before")}{" "} + {SOURCE_TYPE_META[type].label} + {t("sources.wizard.reviewLead.after")}

- - - - + + + +
)} diff --git a/frontend/portal/src/components/sources/KpiStrip.tsx b/frontend/portal/src/components/sources/KpiStrip.tsx index de319d05ac..12f242bcad 100644 --- a/frontend/portal/src/components/sources/KpiStrip.tsx +++ b/frontend/portal/src/components/sources/KpiStrip.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { MetricCard, MetricStrip } from "@shared/components"; import type { SourcesResponse } from "@portal/api/sources"; @@ -6,11 +7,11 @@ import type { SourcesResponse } from "@portal/api/sources"; * current value. They stay client-side so the strip's structure is stable * across loading / empty / ready states; only values + deltas flow from the API. */ -const KPI_LABELS = [ - "Agents active", - "Scenarios", - "Eval pass rate (7d)", - "Docs / 24h", +const KPI_LABEL_KEYS = [ + "sources.kpi.agentsActive", + "sources.kpi.scenarios", + "sources.kpi.evalPassRate", + "sources.kpi.docs24h", ] as const; interface KpiStripProps { @@ -19,14 +20,15 @@ interface KpiStripProps { } export function KpiStrip({ data, loading }: KpiStripProps) { + const { t } = useTranslation(); return ( - {KPI_LABELS.map((label, i) => { + {KPI_LABEL_KEYS.map((labelKey, i) => { const k = loading ? undefined : data?.kpis[i]; return ( @@ -22,14 +24,17 @@ export function SourceDetailCard({ source, onClose }: SourceDetailCardProps) {

{source.name}

- {meta.label} · owned by {source.owner} + {t("sources.detail.ownedBy", { + type: meta.label, + owner: source.owner, + })}
diff --git a/frontend/portal/src/components/sources/SourcesTable.tsx b/frontend/portal/src/components/sources/SourcesTable.tsx index 97eb687d1e..a0cb15f520 100644 --- a/frontend/portal/src/components/sources/SourcesTable.tsx +++ b/frontend/portal/src/components/sources/SourcesTable.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { Chip, StatusBadge, Table, type TableColumn } from "@shared/components"; import { type Source, @@ -19,11 +20,12 @@ export function SourcesTable({ expandedId, onRowClick, }: SourcesTableProps) { + const { t } = useTranslation(); const columns = useMemo[]>( () => [ { key: "name", - header: "Source", + header: t("sources.table.source"), render: (s) => { const meta = SOURCE_TYPE_META[s.type]; return ( @@ -46,7 +48,7 @@ export function SourcesTable({ }, { key: "status", - header: "Status", + header: t("sources.table.status"), render: (s) => ( s.docs24h.toLocaleString(), }, { key: "docs30d", - header: "Docs / 30d", + header: t("sources.table.docs30d"), align: "right", render: (s) => s.docs30d.toLocaleString(), }, { key: "lastEvent", - header: "Last event", + header: t("sources.table.lastEvent"), render: (s) => ( {s.lastEvent} ), }, { key: "owner", - header: "Owner", + header: t("sources.table.owner"), render: (s) => {s.owner}, }, { @@ -98,7 +100,7 @@ export function SourcesTable({ ), }, ], - [expandedId], + [expandedId, t], ); return ( diff --git a/frontend/portal/src/components/sources/WebhookPanel.tsx b/frontend/portal/src/components/sources/WebhookPanel.tsx index 7dc6e0987f..0edd58fd20 100644 --- a/frontend/portal/src/components/sources/WebhookPanel.tsx +++ b/frontend/portal/src/components/sources/WebhookPanel.tsx @@ -1,9 +1,11 @@ +import { useTranslation } from "react-i18next"; import { Button, StatTile, StatusBadge } from "@shared/components"; import type { WebhookDetail } from "@portal/api/sources"; import { pct } from "@portal/components/sources/format"; import "@portal/views/Sources.css"; export function WebhookPanel({ d }: { d: WebhookDetail }) { + const { t } = useTranslation(); const rateTone = d.successRate >= 0.99 ? "success" @@ -14,24 +16,27 @@ export function WebhookPanel({ d }: { d: WebhookDetail }) {
{d.url}} /> - + {pct(d.successRate)} } /> - +
- Recent deliveries + {t("sources.webhook.recentDeliveries")}
{d.recentDeliveries.map((r, i) => ( @@ -54,10 +59,10 @@ export function WebhookPanel({ d }: { d: WebhookDetail }) { GET /v1/sources/{id}/signing-secret — currently inert demo controls. */}
diff --git a/frontend/portal/src/components/usage/AvailablePlans.tsx b/frontend/portal/src/components/usage/AvailablePlans.tsx index adc8c18ad8..f82285f2ee 100644 --- a/frontend/portal/src/components/usage/AvailablePlans.tsx +++ b/frontend/portal/src/components/usage/AvailablePlans.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import type { Tier } from "@portal/contexts/TierContext"; import type { PlanOption } from "@portal/api/usage"; import { PlanCard } from "@portal/components/usage/PlanCard"; @@ -13,13 +14,14 @@ export function AvailablePlans({ current: Tier; onSelect: (plan: PlanOption) => void; }) { + const { t } = useTranslation(); return (
-

Plans

-

- Move up or down at any time — changes take effect next cycle. -

+

+ {t("usage.plans.title")} +

+

{t("usage.plans.subtitle")}

{plans.map((plan) => ( diff --git a/frontend/portal/src/components/usage/BillingHistoryTable.tsx b/frontend/portal/src/components/usage/BillingHistoryTable.tsx index 765fe7b545..eb5ec8a04b 100644 --- a/frontend/portal/src/components/usage/BillingHistoryTable.tsx +++ b/frontend/portal/src/components/usage/BillingHistoryTable.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Card, EmptyState, @@ -24,16 +25,17 @@ const STATUS_TONE: Record = { refunded: "neutral", }; -const STATUS_LABEL: Record = { - paid: "Paid", - due: "Due", - pending: "Pending", - refunded: "Refunded", -}; - /** Invoice / line-item history for the current and prior billing cycles. */ export function BillingHistoryTable() { + const { t } = useTranslation(); const { tier } = useTier(); + + const statusLabel: Record = { + paid: t("usage.history.status.paid"), + due: t("usage.history.status.due"), + pending: t("usage.history.status.pending"), + refunded: t("usage.history.status.refunded"), + }; const state = useAsync( () => fetchBillingHistory(tier), [tier], @@ -44,7 +46,7 @@ export function BillingHistoryTable() { const columns: TableColumn[] = [ { key: "date", - header: "Date", + header: t("usage.history.columns.date"), render: (r) => ( {formatBillingDate(r.date)} @@ -54,19 +56,19 @@ export function BillingHistoryTable() { }, { key: "description", - header: "Description", + header: t("usage.history.columns.description"), render: (r) => r.description, }, { key: "docs", - header: "Docs", + header: t("usage.history.columns.docs"), align: "right", render: (r) => (r.docs > 0 ? r.docs.toLocaleString() : "—"), width: "8rem", }, { key: "amount", - header: "Amount", + header: t("usage.history.columns.amount"), align: "right", render: (r) => ( ( - {STATUS_LABEL[r.status]} + {statusLabel[r.status]} ), width: "8rem", @@ -99,9 +101,11 @@ export function BillingHistoryTable() { return (
-

Billing history

+

+ {t("usage.history.title")} +

- Line items from the current and prior billing cycles. + {t("usage.history.subtitle")}

@@ -116,8 +120,8 @@ export function BillingHistoryTable() { {isEmpty && ( )} @@ -127,7 +131,7 @@ export function BillingHistoryTable() { columns={columns} rows={rows} rowKey={(r) => r.id} - empty="No line items" + empty={t("usage.history.emptyRows")} /> )} diff --git a/frontend/portal/src/components/usage/BillingKpiStrip.tsx b/frontend/portal/src/components/usage/BillingKpiStrip.tsx index 58dd7258e2..9ed9e6024a 100644 --- a/frontend/portal/src/components/usage/BillingKpiStrip.tsx +++ b/frontend/portal/src/components/usage/BillingKpiStrip.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { MetricCard, MetricStrip } from "@shared/components"; import { useTier } from "@portal/contexts/TierContext"; import { OVERAGE_RATE, type BillingSummary } from "@portal/api/usage"; @@ -10,6 +11,7 @@ export function BillingKpiStrip({ }: { summary: BillingSummary | null; }) { + const { t } = useTranslation(); const { tier } = useTier(); // Overage is meaningless on free (gated) / enterprise (committed) — surface @@ -17,47 +19,56 @@ export function BillingKpiStrip({ const overageCard = tier === "free" ? { - label: "Remaining in plan", + label: t("usage.kpi.remainingInPlan.label"), value: summary ? `${(summary.includedDocs - summary.docsThisPeriod).toLocaleString()}` : "—", - description: "docs before cap", + description: t("usage.kpi.remainingInPlan.description"), } : tier === "enterprise" ? { - label: "Commit utilisation", + label: t("usage.kpi.commitUtilisation.label"), value: summary ? `${Math.round((summary.docsThisPeriod / summary.includedDocs) * 100)}%` : "—", - description: "of committed volume", + description: t("usage.kpi.commitUtilisation.description"), } : { - label: `Overage ($${OVERAGE_RATE.toFixed(2)}/doc)`, + label: t("usage.kpi.overage.label", { + rate: OVERAGE_RATE.toFixed(2), + }), value: summary ? USD.format(summary.overageCost) : "—", description: summary - ? `${summary.overageDocs.toLocaleString()} docs past cap` + ? t("usage.kpi.overage.description", { + count: summary.overageDocs, + docs: summary.overageDocs.toLocaleString(), + }) : undefined, }; return ( 0 - ? `incl. ${USD.format(summary.monthlyFee)} platform` + ? t("usage.kpi.costThisMonth.description", { + fee: USD.format(summary.monthlyFee), + }) : tier === "free" - ? "free plan" + ? t("usage.kpi.costThisMonth.freePlan") : undefined } /> @@ -67,9 +78,13 @@ export function BillingKpiStrip({ description={overageCard.description} /> ); diff --git a/frontend/portal/src/components/usage/CurrentPlanCard.tsx b/frontend/portal/src/components/usage/CurrentPlanCard.tsx index 23a3fa72e6..4dde3a06c1 100644 --- a/frontend/portal/src/components/usage/CurrentPlanCard.tsx +++ b/frontend/portal/src/components/usage/CurrentPlanCard.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Banner, Button, @@ -44,6 +45,7 @@ export function CurrentPlanCard({ summary: BillingSummary; onUpgrade: () => void; }) { + const { t } = useTranslation(); const { tier } = useTier(); const usedRatio = summary.docsThisPeriod / summary.includedDocs; @@ -51,7 +53,9 @@ export function CurrentPlanCard({
- Current plan + + {t("usage.currentPlan.eyebrow")} +

{summary.planName}

{tier === "free" - ? "Free" + ? t("usage.currentPlan.badge.free") : tier === "pro" - ? "Pay-as-you-go" - : "Committed"} + ? t("usage.currentPlan.badge.pro") + : t("usage.currentPlan.badge.enterprise")}
@@ -88,18 +92,24 @@ export function CurrentPlanCard({ value={usedRatio} thresholded height={8} - label="Free plan usage" + label={t("usage.currentPlan.free.progressLabel")} />
{summary.capReached ? ( - - New documents are paused until next cycle. Upgrade to keep - processing without interruption. + + {t("usage.currentPlan.free.capReached.body")} ) : ( - - You're at {Math.round(usedRatio * 100)}% of 500 docs/month. - Upgrade to pay-as-you-go to avoid a pause. + + {t("usage.currentPlan.free.approaching.body", { + pct: Math.round(usedRatio * 100), + })} )} @@ -108,19 +118,22 @@ export function CurrentPlanCard({ {tier === "pro" && (
@@ -130,19 +143,25 @@ export function CurrentPlanCard({ {tier === "enterprise" && (
@@ -156,16 +175,18 @@ export function CurrentPlanCard({ accent={tier === "free" ? "blue" : "purple"} onClick={onUpgrade} > - {tier === "free" ? "Upgrade plan" : "Talk to sales"} + {tier === "free" + ? t("usage.currentPlan.actions.upgrade") + : t("usage.currentPlan.actions.talkToSales")} ) : ( )} {/* TODO(backend): GET /v1/billing/invoices?format=pdf — bundle + download invoice PDFs. */}
diff --git a/frontend/portal/src/components/usage/PlanCard.tsx b/frontend/portal/src/components/usage/PlanCard.tsx index 4c745e8a8f..2e5d182a12 100644 --- a/frontend/portal/src/components/usage/PlanCard.tsx +++ b/frontend/portal/src/components/usage/PlanCard.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Button, Card, StatusBadge } from "@shared/components"; import type { PlanOption } from "@portal/api/usage"; import "@portal/views/Usage.css"; @@ -12,6 +13,7 @@ export function PlanCard({ isCurrent: boolean; onSelect: () => void; }) { + const { t } = useTranslation(); const accent = plan.tier === "enterprise" ? "purple" : "blue"; return ( {plan.name} {isCurrent && ( - Current + {t("usage.planCard.current")} )}
@@ -56,10 +58,10 @@ export function PlanCard({ onClick={onSelect} > {isCurrent - ? "Your plan" + ? t("usage.planCard.yourPlan") : plan.tier === "enterprise" - ? "Contact sales" - : "Choose plan"} + ? t("usage.planCard.contactSales") + : t("usage.planCard.choosePlan")} ); diff --git a/frontend/portal/src/components/usage/SpendCapControl.tsx b/frontend/portal/src/components/usage/SpendCapControl.tsx index 1e5bf32ebe..c82992d383 100644 --- a/frontend/portal/src/components/usage/SpendCapControl.tsx +++ b/frontend/portal/src/components/usage/SpendCapControl.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button, Card, @@ -16,6 +17,7 @@ import "@portal/views/Usage.css"; * enterprise render explanatory cards instead of the interactive slider. */ export function SpendCapControl({ summary }: { summary: BillingSummary }) { + const { t } = useTranslation(); const { tier } = useTier(); const [enabled, setEnabled] = useState(summary.spendCap !== null); const [cap, setCap] = useState(summary.spendCap ?? 1_000); @@ -23,10 +25,11 @@ export function SpendCapControl({ summary }: { summary: BillingSummary }) { if (tier === "free") { return ( -

Spend cap

+

+ {t("usage.spendCap.free.title")} +

- The free plan can't accrue spend — your usage is hard-capped at 500 - docs/month. Upgrade to pay-as-you-go to set a monthly spend cap. + {t("usage.spendCap.free.description")}

); @@ -35,16 +38,21 @@ export function SpendCapControl({ summary }: { summary: BillingSummary }) { if (tier === "enterprise") { return ( -

Spend controls

+

+ {t("usage.spendCap.enterprise.title")} +

- Spend is governed by your committed-volume contract. Overage terms and - alert thresholds are managed with your account team. + {t("usage.spendCap.enterprise.description")}

- Committed contract + {t("usage.spendCap.enterprise.badge")} - Overage billed at ${summary.overageRate.toFixed(3)}/doc + + {t("usage.spendCap.enterprise.overage", { + rate: summary.overageRate.toFixed(3), + })} +
); @@ -59,9 +67,11 @@ export function SpendCapControl({ summary }: { summary: BillingSummary }) {
-

Monthly spend cap

+

+ {t("usage.spendCap.pro.title")} +

- Pause processing automatically when spend reaches your limit. + {t("usage.spendCap.pro.subtitle")}

@@ -87,7 +99,10 @@ export function SpendCapControl({ summary }: { summary: BillingSummary }) {
- Projected {USD.format(projected)} of {USD.format(cap)} cap + {t("usage.spendCap.pro.projected", { + projected: USD.format(projected), + cap: USD.format(cap), + })} {Math.round(capRatio * 100)}% @@ -97,7 +112,7 @@ export function SpendCapControl({ summary }: { summary: BillingSummary }) { value={capRatio} thresholded height={8} - label="Spend against cap" + label={t("usage.spendCap.pro.progressLabel")} /> )} diff --git a/frontend/portal/src/components/usage/UpgradeModal.tsx b/frontend/portal/src/components/usage/UpgradeModal.tsx index 0146a2aef7..2dc989d6c7 100644 --- a/frontend/portal/src/components/usage/UpgradeModal.tsx +++ b/frontend/portal/src/components/usage/UpgradeModal.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { Button, Modal } from "@shared/components"; import type { Tier } from "@portal/contexts/TierContext"; import type { PlanOption } from "@portal/api/usage"; @@ -18,22 +20,23 @@ interface UpgradeCopy { * enterprise user is routed to their account team for bespoke terms. */ function upgradeCopy( + t: TFunction, currentTier: Tier, target: PlanOption | null, ): UpgradeCopy { // Cap-reached: free user pushed to pay-as-you-go. if (currentTier === "free") { return { - title: "Upgrade to keep processing", - subtitle: "Pay-as-you-go · $0.05 / doc", - body: "You're at the edge of the 500 doc/month free cap. Pay-as-you-go lifts the cap instantly — you only pay for what you process beyond the included 25,000 docs.", + title: t("usage.upgrade.free.title"), + subtitle: t("usage.upgrade.free.subtitle"), + body: t("usage.upgrade.free.body"), bullets: [ - "Lift the 500 doc/month cap immediately", - "25,000 docs included, then $0.05/doc", - "Unlimited pipelines, agents, and sources", - "Set a monthly spend cap to stay in control", + t("usage.upgrade.free.bullets.0"), + t("usage.upgrade.free.bullets.1"), + t("usage.upgrade.free.bullets.2"), + t("usage.upgrade.free.bullets.3"), ], - cta: "Switch to pay-as-you-go", + cta: t("usage.upgrade.free.cta"), ctaAccent: "blue", }; } @@ -42,44 +45,44 @@ function upgradeCopy( if (currentTier === "pro") { if (target?.tier === "enterprise") { return { - title: "Move to a committed plan", - subtitle: "Enterprise · committed annual volume", - body: "Your overage is consistent month over month. A committed-volume contract lowers your effective per-doc rate and unlocks dedicated regions, SSO, and a named CSM.", + title: t("usage.upgrade.proToEnterprise.title"), + subtitle: t("usage.upgrade.proToEnterprise.subtitle"), + body: t("usage.upgrade.proToEnterprise.body"), bullets: [ - "Lower effective rate vs metered overage", - "Dedicated & on-prem region options", - "SSO, audit-log export, signed DPA", - "Named CSM and 99.99% SLA", + t("usage.upgrade.proToEnterprise.bullets.0"), + t("usage.upgrade.proToEnterprise.bullets.1"), + t("usage.upgrade.proToEnterprise.bullets.2"), + t("usage.upgrade.proToEnterprise.bullets.3"), ], - cta: "Talk to sales", + cta: t("usage.upgrade.proToEnterprise.cta"), ctaAccent: "purple", }; } return { - title: "You're already on pay-as-you-go", - subtitle: "Considering a committed plan?", - body: "Pay-as-you-go scales with usage. If your volume is steady, a committed-volume contract typically lowers your effective per-doc rate.", + title: t("usage.upgrade.pro.title"), + subtitle: t("usage.upgrade.pro.subtitle"), + body: t("usage.upgrade.pro.body"), bullets: [ - "Predictable monthly spend", - "Lower effective per-doc rate at volume", - "Volume discounts kick in past 1M docs/mo", + t("usage.upgrade.pro.bullets.0"), + t("usage.upgrade.pro.bullets.1"), + t("usage.upgrade.pro.bullets.2"), ], - cta: "Explore committed pricing", + cta: t("usage.upgrade.pro.cta"), ctaAccent: "purple", }; } // Bespoke-enterprise: route to account team. return { - title: "Adjust your commitment", - subtitle: "Enterprise · bespoke terms", - body: "Your plan is governed by a committed-volume contract. Changes to committed volume, regions, or terms are handled with your account team — they'll model the right shape with you.", + title: t("usage.upgrade.enterprise.title"), + subtitle: t("usage.upgrade.enterprise.subtitle"), + body: t("usage.upgrade.enterprise.body"), bullets: [ - "Re-model committed volume up or down", - "Add dedicated or on-prem regions", - "Adjust SLA, DPA, and overage terms", + t("usage.upgrade.enterprise.bullets.0"), + t("usage.upgrade.enterprise.bullets.1"), + t("usage.upgrade.enterprise.bullets.2"), ], - cta: "Contact your CSM", + cta: t("usage.upgrade.enterprise.cta"), ctaAccent: "purple", }; } @@ -96,7 +99,8 @@ export function UpgradeModal({ currentTier: Tier; target: PlanOption | null; }) { - const copy = upgradeCopy(currentTier, target); + const { t } = useTranslation(); + const copy = upgradeCopy(t, currentTier, target); return ( {/* TODO(backend): POST /v1/billing/plan-change { tier } (or hand off to sales) — for now the CTA just dismisses the modal. */} diff --git a/frontend/portal/src/components/usage/UsageChart.tsx b/frontend/portal/src/components/usage/UsageChart.tsx index 23838595ce..a705f08132 100644 --- a/frontend/portal/src/components/usage/UsageChart.tsx +++ b/frontend/portal/src/components/usage/UsageChart.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { EmptyState, Skeleton } from "@shared/components"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; import { fetchBillingUsage, type UsageSeriesResponse } from "@portal/api/usage"; @@ -7,6 +8,7 @@ import "@portal/components/UsageAreaChart.css"; /** 30-day docs-processed area chart, with the period total and prior-period delta. */ export function UsageChart() { + const { t } = useTranslation(); const state = useAsync(() => fetchBillingUsage(), []); const { data: usage } = state; const { isLoading } = useSectionFlags(state); @@ -33,8 +35,8 @@ export function UsageChart() { if (!usage || usage.points.length === 0) { return ( ); } diff --git a/frontend/portal/src/components/users/AccessControls.tsx b/frontend/portal/src/components/users/AccessControls.tsx index ede0720e94..417036456c 100644 --- a/frontend/portal/src/components/users/AccessControls.tsx +++ b/frontend/portal/src/components/users/AccessControls.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Banner, Button, @@ -25,6 +26,7 @@ interface AccessControlsProps { * Toggles hold local state only; persisting them is a backend wiring task. */ export function AccessControls({ access }: AccessControlsProps) { + const { t } = useTranslation(); const [mfaEnforced, setMfaEnforced] = useState(access.mfaEnforced ?? false); const [shortSessions, setShortSessions] = useState(false); @@ -36,9 +38,11 @@ export function AccessControls({ access }: AccessControlsProps) { return (
-

Access & security

+

+ {t("users.access.title")} +

- Seats, authentication and provisioning for your organization. + {t("users.access.subtitle")}

@@ -46,20 +50,25 @@ export function AccessControls({ access }: AccessControlsProps) { {/* Seats — shown on every tier. */}
-

Seats

+

+ {t("users.access.seats.title")} +

{seatsLabel(access.seatsUsed, access.seatLimit)}
{access.seatLimit === null ? (

- Your plan includes unlimited seats. + {t("users.access.seats.unlimited")}

) : ( )}
@@ -67,7 +76,9 @@ export function AccessControls({ access }: AccessControlsProps) { {/* Pro+: MFA + sessions self-service. */} {access.mfaAvailable && ( -

Authentication

+

+ {t("users.access.auth.title")} +

@@ -92,8 +103,13 @@ export function AccessControls({ access }: AccessControlsProps) { setShortSessions(v); // TODO(backend): PATCH /v1/users/access { sessionTimeout } }} - label="Short-lived sessions" - description={`Sign members out after inactivity (currently ${access.sessionTimeout}).`} + label={t("users.access.auth.shortSessions.label")} + description={t( + "users.access.auth.shortSessions.description", + { + timeout: access.sessionTimeout, + }, + )} />
@@ -104,25 +120,30 @@ export function AccessControls({ access }: AccessControlsProps) { {access.sso && (
-

SSO / SAML

+

+ {t("users.access.sso.title")} +

{access.sso.status === "connected" - ? "Connected" - : "Not configured"} + ? t("users.access.sso.connected") + : t("users.access.sso.notConfigured")}
- +
)} @@ -132,22 +153,29 @@ export function AccessControls({ access }: AccessControlsProps) {

- SCIM provisioning + {t("users.access.scim.title")}

- {access.scim.enabled ? "Active" : "Off"} + {access.scim.enabled + ? t("users.access.scim.active") + : t("users.access.scim.off")}
- - + +

- Members are created, updated and deactivated automatically from - your identity provider. + {t("users.access.scim.note")}

)} @@ -157,11 +185,11 @@ export function AccessControls({ access }: AccessControlsProps) { {access.upgradeHint && ( - Upgrade plan + {t("users.access.upgrade.action")} } /> diff --git a/frontend/portal/src/components/users/InviteMemberModal.tsx b/frontend/portal/src/components/users/InviteMemberModal.tsx index 78f0b7590e..9cc8394143 100644 --- a/frontend/portal/src/components/users/InviteMemberModal.tsx +++ b/frontend/portal/src/components/users/InviteMemberModal.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Button, FormField, Input, Modal, Select } from "@shared/components"; import { type RoleId, ROLES } from "@portal/api/users"; import "@portal/views/Users.css"; @@ -23,13 +24,14 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; * sending — wiring the submit to the backend dispatches the invitation. */ export function InviteMemberModal({ open, onClose }: InviteMemberModalProps) { + const { t } = useTranslation(); const [email, setEmail] = useState(""); const [role, setRole] = useState(DEFAULT_ROLE); const [touched, setTouched] = useState(false); const emailValid = EMAIL_RE.test(email.trim()); const error = - touched && !emailValid ? "Enter a valid email address" : undefined; + touched && !emailValid ? t("users.invite.emailError") : undefined; function close() { onClose(); @@ -54,24 +56,24 @@ export function InviteMemberModal({ open, onClose }: InviteMemberModalProps) { open={open} onClose={close} width="sm" - title="Invite member" - subtitle="They'll receive an email to join your organization." + title={t("common.inviteMember")} + subtitle={t("users.invite.subtitle")} footer={
} >
- + setEmail(e.target.value)} @@ -79,8 +81,8 @@ export function InviteMemberModal({ open, onClose }: InviteMemberModalProps) { />