Compare commits

..
Author SHA1 Message Date
EthanHealy01 3869072cf7 Merge branch 'main' of https://github.com/Stirling-Tools/Stirling-PDF into feature/documentClassifierAgent 2026-07-02 15:33:53 +01:00
EthanHealy01 3817779b9e translation 2026-07-02 14:42:07 +01:00
EthanHealy01 c4e66f2c2d Merge branch 'feature/documentClassifierAgent' of https://github.com/Stirling-Tools/Stirling-PDF into feature/documentClassifierAgent 2026-07-02 14:28:07 +01:00
EthanHealy01 c27fd4db69 added the classification taxonomy table in the db, team scoped. Allow admins to edit the taxonomy and set up the backend to query it and send it to python each time it's requested. No longer requires a backend restart to make it get the latest taxonomy. Also fixed up how the ordeering of policies is selected and how they chain together in that order. Also made a tiny fix to the my files section to keep it up to date with the indexdb 2026-07-02 14:28:02 +01:00
Anthony Stirling 6c85200eb9 Add portal access control and S3/MCP/API integration configs (#6795) 2026-07-01 13:49:02 +01:00
Reece Browne 467f3a86c4 Portal policies (#6852) 2026-07-01 13:42:26 +01:00
Anthony Stirling 9d3701a585 Fix rearrange-pages DUPLICATE producing shared page nodes (pypdf cyclic-references CI break) (#6851) 2026-07-01 13:40:27 +01:00
James Brunton c22ecc6c09 Add counts to sources page (#6819) 2026-07-01 11:42:35 +01:00
b38c849726 Portal: Procurement surface — layout rework + stateful mock backend (#6785)
Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com>
Co-authored-by: Connor Yoh <con.yoh13@gmail.com>
2026-07-01 11:38:38 +01:00
EthanHealy01 0ae7052dcb Merge branch 'main' of https://github.com/Stirling-Tools/Stirling-PDF into feature/documentClassifierAgent 2026-07-01 10:55:36 +01:00
dependabot[bot]andAnthony Stirling 41f1cb2c22 build(deps): bump test pypdf + add translations (#6831)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 23:40:26 +01:00
Anthony Stirling ff3e3bd0fc Add desktop hardware token signing and trust-aware signature validation (#6765)
# Description of Changes

<img width="432" height="800" alt="image"
src="https://github.com/user-attachments/assets/a01ed9ac-220c-4911-9134-b51e0f321be8"
/>

<img width="408" height="859" alt="image"
src="https://github.com/user-attachments/assets/a9c285b6-5b75-493a-95ec-09e08d0f58f1"
/>

<img width="426" height="874" alt="image"
src="https://github.com/user-attachments/assets/a60db96e-be93-4cc5-ba0a-63512c2857ba"
/>

<img width="356" height="1076" alt="image"
src="https://github.com/user-attachments/assets/24d03674-94d3-40ed-99ee-73395bafae6a"
/>


---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-30 23:23:58 +01:00
EthanHealy01 54042c8e5e Signing UI edge-case cleanup (#6849) 2026-06-30 23:04:10 +01:00
stirlingbot[bot]andAnthony Stirling bb92ecc143 Update Backend 3rd Party Licenses + Translations and bump versio (#6794)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
2026-06-30 22:41:40 +01:00
EthanHealy01 7ab30d2629 add file share to the top workbench bar and add shared signing (#6715) 2026-06-30 22:14:49 +01:00
EthanHealy01 69fc4d5bc1 Merge branch 'main' into feature/documentClassifierAgent 2026-06-30 19:42:24 +01:00
James Brunton 276eb8f2a7 Add pipelines page to portal (#6818)
# Description of Changes

Connect pipelines page to the backend. Note that this is really half an
implementation because the portal doesn't have access to the tools list
and their settings, but I can't fix that without re-architecture work,
which I'll do in another PR, then come back to finish this off in a new
PR.

<img width="786" height="579" alt="image"
src="https://github.com/user-attachments/assets/d3f06110-a35d-4d48-a2f9-1edb900c5c35"
/>

<img width="1232" height="519" alt="image"
src="https://github.com/user-attachments/assets/9f344648-ea45-498d-9e84-9558a3999838"
/>
2026-06-30 16:11:48 +00:00
James Brunton e44da5c410 Fix missing refresh token on desktop (#6838)
# Description of Changes
Fix #6801, along with fixing policies on desktop, which would attempt to
download policy outputs from the local backend instead of the server,
where they actually live. I've changed the policies logic to maintain
the same backend for the file retrieval as it used for the policy
running, so when we support running policies locally, it should still
work correctly.
2026-06-30 14:07:12 +00:00
Reece Browne 0beff1a92b feat(shared): make @shared the single home for brand logo assets (#6714)
## What

Makes `@shared` the single home for the Stirling brand logo assets.
Moves the editor's two logo sets — `classic-logo` + `modern-logo` (22
files: marks, wordmarks, favicons, login headers, PNGs) — out of
`editor/public/` into `shared/assets/brand/`, and adds a Storybook
**Brand/Logos** gallery.

## Why this shape (not a plain move)

The editor serves logos by **URL** from `public/` and switches
`classic`/`modern` by a **user preference** (`useLogoAssets`,
`manifest.json` / `manifest-classic.json`, `index.html` favicon links).
Rewiring all that to module imports would be a large, risky change to
the variant system.

Instead the editor keeps its variant system **unchanged** and just
sources the files from shared: `vite-plugin-static-copy` copies
`shared/assets/brand/{classic,modern}-logo/*` back to the served
`/{classic,modern}-logo` paths (the editor already uses this plugin for
pdfium/pdfjs assets). Single source of truth in shared, zero editor
code/manifest/markup changes.

## Verified

- **Build:** editor builds with both sets present at
`dist/{modern,classic}-logo/`; `manifest.json` + favicon refs resolve.
- **Dev:** the vite dev server serves the bridged paths —
`/modern-logo/logo512.png`,
`/modern-logo/StirlingPDFLogoNoTextDark.svg`,
`/classic-logo/favicon.ico` all return **HTTP 200** (the plugin's dev
middleware).
- Typecheck clean on core/proprietary/saas; prettier clean; `storybook
build` succeeds with the `Brand/Logos` gallery bundled.
- The portal's existing `@shared/assets` brand imports are untouched.

## Follow-ups (not in this PR)

- **Dedup:** `shared/assets/stirling-mark-*.svg` is byte-identical to
`brand/modern-logo/StirlingPDFLogoNoTextDark.svg`, and
`stirling-pdf-logo-*` is a near-twin of the modern wordmark. Reconciling
these (and re-pointing the portal) needs a designer eye on which
wordmark is canonical, so it's left out here to avoid changing the
portal's rendered logo.
- `editor/src/logo.svg` appears unused (no references) — candidate for
deletion separately.
2026-06-30 11:24:14 +00:00
EthanHealy01 2e023a6e78 Merge branch 'main' into feature/documentClassifierAgent 2026-06-30 10:45:27 +01:00
ConnorYoh 425b76e9a7 fix(portal/i18n): add inline default values to account-link + billing t() calls (#6842)
## Problem

The account-link / billing / Usage strings migrated to i18next in #6738
call `t("key")` with **no inline default**. When no i18next instance is
initialized — which is the case in **Storybook** (the preview doesn't
load the portal i18n config) — or whenever a key is missing,
react-i18next renders the **raw key** (e.g. `billing.walletMeter.title`)
instead of English. That's why the billing stories regressed to showing
keys.

## Fix

Add the English string as the `t()` default value, matching the
**existing portal convention** (`AuthGate`, `Header`, `Sidebar`) and the
editor:

- plain → `t("key", "English")`
- interpolation → `t("key", "English {{var}}", { var })`
- plural → `t("key", "{{count}} …", { count })`

Dynamic keys resolved via data fields carry a sibling `*Default` string
passed as the default:
- `LINK_INFO` badge labels → `labelDefault` (`t(info.labelKey,
info.labelDefault)`)
- `PdfsProcessedCard` segment legend → `labelDefault` / `descDefault`

Defaults were sourced **verbatim from the merged
`en-US/translation.toml`**, so the TOML stays the source of truth — the
inline default only fills in when the catalogue isn't loaded or lacks
the key.

## Scope

All strings added in #6738: 5 account-link + 12 billing components + the
Usage view (157 static call sites + the `LINK_INFO` / segment dynamic
ones). No new keys; no copy changes.

## Verification

- `tsc -p portal/tsconfig.json` → 0
- `eslint --max-warnings=0` (changed files) → 0
- `prettier --check` → clean
- portal `vitest` → **62/62 pass**

No behaviour change when i18n is initialized; Storybook and any
missing-key fallback now render English.
2026-06-30 09:23:13 +00:00
EthanHealy01 7e523d48d7 rename script and remove retry test 2026-06-29 22:27:26 +01:00
EthanHealy01 b8cb020e59 Merge branch 'main' into feature/documentClassifierAgent 2026-06-29 22:10:52 +01:00
EthanHealy01 a92722ff13 remove classify as a user facing tool 2026-06-29 22:10:09 +01:00
EthanHealy01 fed7ad300f document classifier agent that runs as a policy and assigns a category and sub-category docType 2026-06-29 21:58:03 +01:00
Reece Browne c8af6e3b7e feat(policies): enforce run-on-export policies on all PDF exit paths (#6788)
> **Draft / WIP** — print enforcement is still to come (see below).

## Goal

A "run on export" policy must enforce on **every** path where a PDF
leaves the editor, not just the main Download/Export button. This routes
the remaining exits through the existing export-policy gateway
(`downloadFileWithPolicy`), which runs `enforceExportPolicies` before
the file leaves and is a no-op when no export policy is active.

## Audit of exit paths

| Path | Status |
|---|---|
| Web download / export, page-editor, file-editor, thumbnails | 
already covered (gateway) |
| **Form-fill download** (`FormSaveBar`) |  fixed here — was a raw
`createObjectURL` download |
| **Desktop Ctrl+S save** (`useSaveShortcut`) |  fixed here — was raw
`downloadService` |
| **Desktop save-operation-results** (`operationResultsSaveService`) | 
fixed here — was raw `downloadService` |
| Viewer `saveAsCopy` (annotations/redactions) | n/a — in-memory version
saves, not exits |
| **Print** (`printActions.print`) |  pending — enforce-then-print
(below) |
| Web operation-results (`downloadFromUrl`) |  pending — URL-stream,
needs a fetch→enforce wrapper |
| Share link | excluded by design (enforce at share-creation, not
recipient download) |

## In this PR

All three fixes are the same pattern — route the raw download through
`downloadFileWithPolicy` instead of `URL.createObjectURL` / the raw
download service.

## Still to come (why it's a draft)

- **Print** — enforce-then-print: on print, run the same
`enforceExportPolicies`; if it changed the doc, swap the viewer to the
enforced version (new version in history) and toast *"PDF updated by
policy enforcement — review, then print again"* rather than silently
printing a different doc; if unchanged, print. Covers Ctrl+P, the
toolbar button, and embedded PDF-JS print.
- **Web operation-results** (`downloadFromUrl`) — fetch the result to a
blob, enforce, then download.

## Verification

Typecheck (core/proprietary) + prettier clean for the changes here;
desktop tsc clean for the touched files. The print UX, once added, needs
a manual run with an active export policy — there's no automated path
for it.
2026-06-29 18:01:12 +00:00
James Brunton 82ec2acaba Make explicit signed and unsigned desktop CI jobs (#6840)
# Description of Changes
Makes it easier to skip signing on nightlies, which we don't need to do
since we're just warming the Rust cache.
2026-06-29 16:14:40 +00:00
Anthony Stirling 5e97746721 UX improvement for side menu bookmark, comments and attachments (#6552)
- Inline "Add bookmark" form in the bookmark sidebar (title + page,
defaults to current page) - saves via
/api/v1/general/edit-table-of-contents without leaving the viewer
- Persistent "+ Add" rows above the list in Bookmarks, Attachments,
Comments and Files sidebars (was only in empty state)
- Close (X) button in every viewer sidebar header (Bookmarks,
Attachments, Comments, Layers, Thumbnails)
- "Add comment" button morphs into "Click a page to place… (cancel)"
while textComment is armed, ESC to cancel
- "Add attachment" auto-closes the attachment sidebar so you don't end
up with two stacked panels
- Footer link in bookmark sidebar to the full Edit Table of Contents
tool for nesting/reordering
- Fix: bookmark/attachment sidebars getting stuck on "Loading…" after a
file swap (cache no longer caches `loading`, retry treats null bridge as
not-ready)
- Fix: Save silently routing to the editor tool on a fresh /read upload
when `activeFileId` is still null
- New Playwright tests (stubbed + live) covering Add buttons, Save flow
with PDF round-trip, and close buttons
<img width="720" height="1032" alt="06-thumbnails"
src="https://github.com/user-attachments/assets/62298d0d-8eba-4397-9bc2-96871be29b3c"
/>
<img width="790" height="1062" alt="01-bookmarks"
src="https://github.com/user-attachments/assets/1eb33667-c038-4b78-8711-97f354344fae"
/>
<img width="720" height="1032" alt="02-bookmarks-empty"
src="https://github.com/user-attachments/assets/3db263ef-9550-4bac-9ffa-c729263f42c3"
/>
<img width="1032" height="1032" alt="03-attachments"
src="https://github.com/user-attachments/assets/33580e64-020a-4e07-bf9a-595faf695fd8"
/>
<img width="919" height="1062" alt="04-comments"
src="https://github.com/user-attachments/assets/89ef01a8-35a6-406b-825a-f04beec02f29"
/>
<img width="720" height="1032" alt="05-layers"
src="https://github.com/user-attachments/assets/57d3cfe9-0a4c-468d-b497-ed855ddd69e5"
/>

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-29 14:05:58 +00:00
ConnorYohandJames Brunton 14245d33d1 feat(saas): account-link — connected self-hosted billing (Mode A) [WIP, flag-gated] (#6738)
> **Draft / WIP.** Combined-billing **Mode A** (connected self-hosted).
Entirely behind `stirling.billing.account-link.enabled` (default **off**
→ beans absent → 404). Pairs with Stirling-PDF-SaaS PR #313 (twin
migration → `v3`).

## What this does

A self-hosted instance links a SaaS account in the **Portal**, gets a
**device credential**, and authenticates unattended metering/entitlement
with it — no long-lived user JWT on the server. The Portal then surfaces
the team's **billing** (free trial → metered Processor plan) driven by
the live wallet.

```mermaid
sequenceDiagram
  participant Portal as Portal (browser)
  participant Supa as SaaS Supabase Auth
  participant Local as Self-hosted backend
  participant SaaS as SaaS Java (app/saas)
  Portal->>Supa: signIn / signUp (Supabase JS, short-lived JWT)
  Supa-->>Portal: JWT (SDK-refreshed, stays in browser)
  Portal->>Local: hand JWT (same-origin)
  Local->>SaaS: POST /account-link/register (Bearer JWT, leader)
  SaaS-->>Local: { device_id, device_secret }  (secret once)
  Note over Local: store device_secret server-side
  loop unattended
    Local->>SaaS: /api/v1/instance/** (X-Device-Id + X-Device-Secret)
    SaaS-->>Local: entitlement / gate decision
  end
```

**Auth model:** human auth = Supabase JS (ephemeral JWT, kept for
attended portal features). Durable instance auth = a team-bound
**device_id + secret** (SHA-256 stored, shown once), non-user
`ROLE_LINKED_INSTANCE`, path-scoped to `/api/v1/instance/**`. Instance
binds to a **team**, never a user.

## Billing surface (Portal · Mode A states)

`Usage & billing` is state-driven by the link/subscription dimension and
built to the marketing designs, sharing one component layer across
states:

- **Unlinked** → link-account prompt.
- **Linked · Free** — the *Processor trial*: a one-time 500-PDF free
grant ("Process 500 PDFs free, then $X/PDF"), the team's free-editor
fleet, and a leader-only **Switch on the Processor →** (embedded Stripe
Checkout).
- **Linked · Subscribed** — the *Processor plan* dashboard:
PDFs-processed split (API / Agents / Automation), **spend this month**
vs. a **spend limit** meter with a run-rate projection and an **in-place
cap editor** (preset buckets + suggested value + guardrail), Stripe
**invoices** (with billed PDFs per invoice), and the default **payment
method**. Card / subscription changes deep-link to Stripe's hosted
portal.

Manual PDF editing is always free — only Automation / AI / API is
metered; a `$0` cap blocks all metered work (≠ "no cap").

**Shared, not duplicated:** the editor-fleet card, the Enterprise
upsell, and the meter (`@shared/billing` `MeterBar`) render in both the
free and subscribed views; money/cap math lives once in
`@shared/billing`. The page header is a sticky, full-bleed bar.

**New SaaS reads** (defensive — degrade to empty/"—" when the Stripe
mirror lacks a table, never 500):
- `GET /api/v1/payg/payment-method` — default card (brand / last4 /
expiry) from `stripe.payment_methods`.
- Invoice **PDFs processed** — billed line-item quantity from
`stripe.invoice_line_items`.

## Progress

- [x] Schema: `V22 linked_instance` (+ Supabase twin in #313)
- [x] `AccountLinkController` register / list / revoke (leader-only,
team from caller)
- [x] Device-credential filter (path-scoped, constant-time,
revocation-aware) + `SupabaseSecurityConfig` wiring (conditional)
- [x] `GET /api/v1/instance/whoami` + **`/entitlement`** (reuses
`EntitlementService`/`TeamBillingService`) + tests
- [x] Self-hosted backend (`app/proprietary`): orchestrator + instance
gate (dark + **fail-open**) + tests
- [x] Portal: in-app Supabase login modal + register hand-off +
`LinkContext` (unlinked default) + "Linked instances" view — all
`@shared` Storybook components
- [x] **Portal billing surface** — free (Processor trial) + subscribed
(Processor plan) Usage views to marketing spec; link-state derived from
the **live wallet**; in-place cap editor; over-cap banner
- [x] **SaaS reads** — payment-method endpoint + invoice billed-units
(defensive `stripe.*` mirror DAOs) + tests
- [x] Orphan guard: block leaving/accepting away from a team whose
departure orphans its linked instances
- [ ] Metering Step 2 (lease + reconcile loop) + bounded fail-open
cutoff
- [ ] Proprietary hardening (SaaS base-url config, secret-at-rest, finer
billable classification) + HTTP integration test
- [ ] Cross-repo Stripe lifecycle certified end-to-end (subscribe →
meter → cancel → 402)
- [ ] Admin ⟺ SaaS-leader enforcement (separate portal-team-mgmt
workstream)

## Verification — all green
| Gate | Result |
|---|---|
| `STIRLING_FLAVOR=saas :saas:test` | BUILD SUCCESSFUL (account-link +
payg, incl. `PaygPaymentMethodControllerTest`,
`PaygInvoicesControllerTest`) |
| `:proprietary:test` | BUILD SUCCESSFUL (account-link + entitlement
cache/interceptor) |
| portal | tsc 0 · eslint 0 · **vitest 55** · storybook build (all
billing stories) |
| frontend post-sync | typecheck shared + portal + editor (saas +
desktop): 0 |

## Screenshots — billing UI
_Latest Storybook renders (Portal/Billing). Drag each capture below its
caption — kept out of the repo._

**Linked · Free — Processor trial**


<img width="1648" height="503" alt="01-free-processor-trial"
src="https://github.com/user-attachments/assets/afe6238a-d3b4-47fd-8ea2-cbaed8b0a653"
/>

**Linked · Subscribed — Processor plan dashboard**

<img width="1648" height="930" alt="02-subscribed-processor-plan"
src="https://github.com/user-attachments/assets/329e6808-a9a9-4e65-99af-5a8a5e6bf4ab"
/>

**Spend limit — in-place cap editor**

<img width="1648" height="411" alt="03-spend-limit-editor"
src="https://github.com/user-attachments/assets/acc95096-bf8e-4ab0-a32c-3c20dc94f816"
/>


## Review feedback applied
Reworked the portal after first-pass feedback: linking signs in via the
**shared Supabase login** (SSO + email/password) — no bespoke form; the
**device secret is never shown in or sent to the FE** (the local backend
registers + stores it server-side); billing copy reads **PDFs**, not
"units"; the wallet surface uses **`@shared` components** matching the
SaaS Plan page. Re-verified including an assertion the link response
carries no `deviceSecret`/`deviceId`.

**Synced onto unified auth + in-app login (2026-06-23).** Merged `main`
incl. **#6725 unified auth** (`frontend/shared/auth`); the link flow
uses a shared `useSupabaseLogin` hook + `SupabaseLoginForm`, a portal
`LinkAccountModal`, and `useAccountLink.completeLink(session)` (+
on-mount SSO redirect-return). Config: `VITE_SAAS_SUPABASE_URL` +
`VITE_SAAS_SUPABASE_ANON_KEY`. The local `/account-link/link` call
carries the Spring admin bearer with the SaaS JWT in the body. **SSO**
needs the SaaS Supabase project to allow-list the portal redirect URL
(email/password works without it).

## Assumptions / open
- **Proprietary remains a scaffold** (placeholder SaaS base-url,
plaintext device secret at rest, coarse billable classification).
- Payment-method + invoice-quantity render only when
`stripe.payment_methods` / `stripe.invoice_line_items` are in the
Sync-Engine target (confirm in the Supabase/Sync-Engine config);
otherwise they degrade gracefully.
- A self-contained local HTML report + manual E2E runbook live in
`notes/account-link-report/` (dev artifacts, outside the repo).

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-06-29 13:35:07 +00:00
Anthony Stirling 84739e8b0e Align settings.yml defaults and fix dead/mismapped settings (#6816)
# Description of Changes

Align settings.yml defaults and fix dead/mismapped settings

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-29 11:33:55 +00:00
Anthony Stirling 0996277c41 Brand MSI installer and rename display name to Stirling PDF (#6764)
# Description of Changes

Add icons to stirling PDF installer and changed app name from
Stirling-PDF to Stirling PDF

<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/6f23b501-d765-43a6-a713-b330ea199a04"
/>
<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/83d50ac9-2220-474b-8269-bfcfad01166c"
/>
<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/f0113ef5-9567-46d0-820c-0891d33b2355"
/>

vs old

<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/d50fa652-cb42-4668-b951-4f2ce52eba14"
/>
<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/b113890b-f06d-4dea-9738-1b885a9ba125"
/>
<img width="495" height="387" alt="image"
src="https://github.com/user-attachments/assets/3b6792aa-48a5-425f-9ae2-13938fd297a5"
/>


---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-29 11:17:30 +00:00
Anthony StirlingandJames Brunton d508bc41bf Fix PR docker CI when the base image changes (#6809)
# Description of Changes

- Fix PR CI for base-image changes: the embedded build's buildx
container builder could not resolve the locally-built
`stirling-pdf-base:pr-test` and tried to pull it from a registry,
failing the build
- `test-build-docker.yml`: when the base changed, build the embedded
image with the docker driver (`docker build`) so the locally-built base
resolves from the daemon image store
- `docker-compose-tests.yml`: when the base changed, skip the buildx
container builder + gha cache so `test.sh`'s local base build resolves
via the default docker driver

---

## Checklist

### General

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

### Documentation

- [ ] I have updated relevant docs (if functionality has heavily
changed)
- [ ] I have read the section Add New Translation Tags (for new
translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-06-29 09:59:17 +00:00
James Brunton 6ff910f26c Expand any type linting in frontend (#6808)
# Description of Changes
Continued effort to expand linting scope to ban the `any` type in our
codebase. This PR pulls in a lot of subfolders into the linting scope,
because the excluded list was getting short enough that it was feasible
to move a layer down. I then fixed all the trivially fixable `any` type
violations in the subfolders, which just required local changes to the
one file. The aim of this PR is more to expand the scope to all the
folders we can that already avoid `any` types, rather than actually fix
violations.
2026-06-29 08:32:30 +00:00
James Brunton 013f145462 Upgrade to TS7 for local type-checking (#6815)
# Description of Changes
We can't convert to TS7 completely yet because it lacks the TS API, so
ESLint and some of our scripts don't work, but we can do [what the TS
team suggest and run TS6 and TS7
side-by-side](https://devblogs.microsoft.com/typescript/progress-on-typescript-7-december-2025/#compiler).
When we do that, we take the `task frontend:typecheck:all` job from ~76s
to ~13s, and everything else continues to work as it did before.

I've set it so that CI will still use TS6 for the time being and locally
we use TS7 out of an abundance of caution because CI time doesn't really
matter but local time does. I do think it was a bit pointless doing that
since the TS team claim the type checking performs identically, but we
might as well have it like that for now. If it happens to go badly
locally for any devs, they can use `CI=true task frontend:typecheck` to
revert to use TS6 trivially.
2026-06-26 15:05:07 +00:00
James Brunton eea9696bd4 Actually build the frontend for Playwright nightlies (#6817)
# Description of Changes
[Our nightlies have literally never passed
before](https://github.com/Stirling-Tools/Stirling-PDF/actions/workflows/nightly.yml).
As far as I can tell, that's because the frontend was never being built,
so the Playwright tests would just never start up.

I've forced a nightly run from this branch, and the Playwright tests
still fail, but for legitimate failures now. It's a separate job to
track down why they're actually failing, so I'm leaving that for
followup work.
2026-06-26 14:53:51 +00:00
James Brunton 3f7e898c69 Add sources service and frontend (#6774)
# Description of Changes
Redesign policies backend to treat sources a lot closer to how the
frontend imagined them working (they're persistent now and have an API).
Then connect the portal to the sources when mocks are off to allow for
source creation in the UI. It's not particularly useful to do that right
now because there's no policies UI, but I've tested manually that
sources set up in the UI are usable by policies created via the API.

I had to change the portal so that when mocks are off, it doesn't just
hard crash when attempting to connect to all the backend APIs that don't
exist yet. It'll still log the errors, but just continues on rendering
the UI now.

I also changed all the policies backend APIs to be gated behind a flag
instead of behind the SaaS profile. This is because we haven't yet got
the payment model sorted, but we're going to need this stuff running
self-hosted to be able to test it locally.
2026-06-26 13:21:53 +00:00
James Brunton def3cf79f6 More desktop CI optimisations (#6786)
# Description of Changes
- Change the nightly build to not sign any of the desktop builds, since
we just care about the compiled code. The restored code will still be
signed dependent on the OS in the PR builds.
- Change RPM Linux to use zstd for compression because the one it was
using runs really slowly, and the Jar is already compressed so it makes
basically no difference (arguably we shouldn't compress at all)
- ~Switch to consistently use Depot for Docker caching to stop filling
up the GHA cache and evicting the Rust cache~ Decided against switching
to Depot because we're probably doing another PR to remove Depot
altogether in the near future
2026-06-26 11:08:08 +00:00
Matheus Saito 501a7199e0 Add bulk comment and annotation clearing to editor (#6792)
# Description of Changes
Closes #6695 

This PR adds bulk cleanup actions for comments and annotations in the
PDF editor, while tightening the save and navigation behavior around
annotation edits.

### Comments sidebar

Adds a “Clear all comments” action to the comments sidebar overflow
menu. The action opens a confirmation modal before clearing sidebar
comments and replies.

The implementation distinguishes between standalone comment annotations
and comments attached to existing visual annotations. Standalone
comments and replies are removed from the document, while comments
attached to markup, shapes, ink, or other visual annotations are cleared
from the sidebar without deleting the underlying annotation itself. This
preserves the visible document markup while removing the comment
metadata and persisted comment contents.

The comments sidebar state is also reset after clearing, including draft
comments, reply drafts, edit state, and open confirmation/delete modal
state.

### Annotate tool

Adds a document-level “Clear all annotations” action to the Annotate
tool. The action is exposed through the annotation panel’s overflow menu
and uses a confirmation modal before removing annotations.

The clear operation is routed through the existing annotation API bridge
and delegates to EmbedPDF’s document-level annotation clearing API. The
UI handles unavailable annotation state, successful clears, and
failures.

After annotations are cleared, the editor resets annotation interaction
state, exits placement/selection-specific state, returns to select mode,
and marks the document as having unsaved changes only when annotations
were actually removed. The user can then persist the removal through the
normal Save Changes flow.

### Save and navigation hardening

Improves the viewer save/apply flow used by annotations and manual
redactions.

Save operations are now deduplicated while an apply operation is already
in flight, preventing duplicate exports or duplicate file consumption
when users trigger save/navigation repeatedly.

The global unsaved-changes navigation modal now waits for “Apply &
Leave” to complete successfully before navigating. If saving fails, the
modal keeps the user in place instead of leaving with unsaved edits
still present.

The Annotate panel also prevents “Save Changes” and “Clear all
annotations” from running concurrently.

<!--
Please provide a summary of the changes, including:

- What was changed:
- Why the change was made
- Any challenges encountered

-->

---


## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

Clear all comments : 
<img width="310" height="397" alt="image"
src="https://github.com/user-attachments/assets/d1682611-13f8-4f40-aa77-44b37450e56e"
/>

Clear all annotations: 
<img width="284" height="549" alt="image"
src="https://github.com/user-attachments/assets/e4049bc1-f07b-4b36-b08e-ad6d6b86fe62"
/>



### Testing (if applicable)

- [X] I have run `task check` to verify linters, typechecks, and tests
pass
- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-25 08:55:11 +00:00
Anthony Stirling bc6f1a1ff5 Add login agreement disclaimer feature (#6766) 2026-06-24 22:07:19 +01:00
Anthony Stirling d06d3cabaf chore: update svg conversion and database import handling (#6796) 2026-06-24 22:01:32 +01:00
EthanHealy01 b040277220 fast-path local PDF transport and reduce chat re-renders (#6798) 2026-06-24 21:44:13 +01:00
dependabot[bot]andAnthony Stirling f715a73f1b build(deps): bump astral-sh/setup-uv from 8.1.0 to 8.2.0 + translation files (#6748)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-24 21:42:56 +01:00
Anthony Stirling 5be9a0e1df fix desktop bundles (#6773)
# Description of Changes

Changes
- Use 127.0.0.1 instead of localhost for the local backend. The bundled
backend starts on a random port and binds the IPv4 wildcard, but the
frontend health-checked http://localhost:{port}. On macOS (and some
Linux) localhost resolves to IPv6 ::1 first, so the connection is
refused and every backend-dependent tool shows "backend offline" even
though the backend started fine. Switched getBackendUrl() and the
health-check URL to the 127.0.0.1 loopback literal (already in the Tauri
HTTP capability allowlist, and what the OAuth loopback server already
uses). Client-side tools were unaffected, which matches the reports.
- Fail the desktop build when the bundled JRE is older than the app JAR.
The app JAR is compiled for Java 25, but the bundle could ship an older
runtime/jre (jlink:runtime short-circuits on an existing runtime, and
nothing checked its version), producing UnsupportedClassVersionError at
launch so the backend never starts. Added a jlink:verify task that reads
the jlink release file and fails the build if the bundled JRE major is
below REQUIRED_JAVA (25, kept in sync with build.gradle
modernJavaVersion). It runs after the runtime is staged - including the
short-circuit reuse path that lets a stale JRE slip through.
Cross-platform Node script, no new dependencies.

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-24 15:12:48 +00:00
Anthony Stirling f7f7b8790e fix update notification visibility and install flicker (#6776)
# Description of Changes

- Closes #6754
- Update popup now hidden on mobile, for non-admins, and never on SaaS
- Respects admin "Show Update Notifications" setting (`showUpdate` /
`showUpdateOnlyAdmin`, now default on)
- Fixes update modal flickering during desktop install
---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-24 14:00:43 +00:00
Ludy 26021425e3 chore(ci): upgrade Gradle to 9.6.0 across workflows, Docker builds, and wrapper (#6790)
# Description of Changes

## What was changed

- Updated all GitHub Actions workflows using Gradle from older versions
(9.3.1 and 9.5.1) to Gradle 9.6.0.
- Updated the Gradle Wrapper distribution URL to use Gradle 9.6.0.
- Updated all Gradle-based Docker build stages to use the
`gradle:9.6.0-jdk25` image and corresponding image digest.
- Aligned CI, Docker, and local development environments on the same
Gradle version.
- Included the regenerated `gradlew` script changes produced by the
Gradle wrapper update process.

## Why the change was made

- Ensures consistent Gradle versions across local development, CI
workflows, and Docker builds.
- Takes advantage of the latest Gradle 9.6.0 improvements, fixes, and
compatibility updates.
- Reduces the risk of version mismatches causing build or deployment
inconsistencies.
- Simplifies maintenance by standardizing the build toolchain throughout
the repository.

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-24 08:36:05 +00:00
Ludy e35594f946 chore(build): centralize Gradle dependency version management (#6499)
# Description of Changes

This change centralizes several dependency version declarations into
shared Gradle version properties and updates module build files to
reference those properties instead of hardcoded version strings.

### What was changed

- Added centralized version properties in the root `build.gradle` for:
  - commons-io
  - commons-lang3
  - rhino
  - okhttp BOM
  - gson
  - guava
  - bucket4j
  - archunit
  - batik
  - jpdfium
  - JWT
  - AWS SDK
  - Testcontainers

- Replaced hardcoded dependency versions across multiple modules with
shared version variables.
- Updated `resolutionStrategy.force` declarations to use centralized
version properties.
- Updated dependency constraints and BOM references to use shared
version variables.
- Removed module-specific duplicate version declarations from
`app/proprietary/build.gradle`.
- Standardized dependency declarations across `common`, `core`,
`proprietary`, and `saas` modules.

## Why the change was made

- Reduce duplication of dependency version definitions.
- Simplify future dependency upgrades and maintenance.
- Ensure consistent dependency versions across all modules.
- Improve readability and reduce the risk of version drift between
subprojects.
- Make security-related dependency overrides easier to maintain from a
single location.

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-24 08:34:59 +00:00
Anthony Stirling 8a0b12b5ab Remove ffmpeg from published Docker images (#6791)
## Summary

Published Docker images (`stirling-pdf:latest`, `:2.13.1`) still shipped
the full `ffmpeg` package even though it was disabled in source back in
#6053.

**Root cause:** `push-docker.yml` passed a hardcoded
`BASE_VERSION=1.0.0` build-arg for the regular image, overriding the
Dockerfile's `ARG BASE_VERSION=1.0.2` default. Base `1.0.0` is the
original base that still does the explicit `ffmpeg` apt install, so the
published image never picked up the removal.
2026-06-24 08:21:28 +00:00
Ludy 60fff188a6 fix(team): hide users already assigned to the selected team (#6760)
# Description of Changes

# Description of Changes

- What was changed
- Filtered the "Add Member to Team" user picker so users who are already
members of the selected target team are no longer shown.
  - Applied the same filtering in both team management entry points:
    - `TeamsSection`
    - `TeamDetailsSection`
- Kept users in other teams visible, so they can still be moved into the
selected team.

- Why the change was made
- The modal was showing users who were already part of the target team,
which made the action misleading and allowed redundant selection.
- Hiding already-assigned users keeps the UI aligned with the actual
action: adding new members to the team

before:

<img width="442" height="425" alt="image"
src="https://github.com/user-attachments/assets/9faf991f-a7d3-4a48-91cd-f47730decde8"
/>

after:

<img width="430" height="382" alt="image"
src="https://github.com/user-attachments/assets/f8291172-feb6-4d8d-b536-eebf752b764b"
/>



---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-24 08:15:08 +00:00
James Brunton 41181c9da1 Redesign tool config types to avoid any typing (#6582)
# Description of Changes
Fixes one of the main causes of `any` typing left in tools, the way that
we register tool parameters in the registry. Currently, it just accepts
tool params via `any`, but instead we can explicitly change them to
`Record<string, unknown)`, so on the way back out they can more safely
be cast back to their correct type when known.

One consequence of this is that I had to redesign the way we
special-case the Convert tool, which previously was a different shape
than all the other param types. Now it's just got optional parameters on
it, which isn't quite as type-safe as before, but it does mean all tools
are a consistent shape now, which I think is worth the tradeoff.
2026-06-23 15:44:52 +00:00
EthanHealy01 8e485801c9 change policies ui (#6683)
• Removed colors from policies to make them look more professional.
• upgraded to enterprise link to contact us.
• Hid inactive policies from users (Kept for admin and team lead).
• Closing policies had wrong arrow, made a standard component for chat,
tools and policies header.
2026-06-23 13:57:17 +00:00
EthanHealy01 436afa51d7 Always use the modern logo in the SaaS build (#6775)
## What

Make the **SaaS** build always use the modern logo, so the classic logo
can no longer appear anywhere in the SaaS app.

This is a minimal, SaaS-only alternative to the full classic-logo
removal PR (~80 files). **OSS (`core`) and proprietary builds are
untouched** — they keep the full modern/classic variant system,
including the admin _Logo Style_ picker.

## How

A single SaaS-layer override shadows the core hook:

- `frontend/editor/src/saas/hooks/useLogoVariant.ts` → returns
`"modern"` unconditionally.

In the SaaS build the `@app/*` alias cascade resolves
`@app/hooks/useLogoVariant` to `src/saas/*` before `src/core/*`, so this
shadows the core implementation (which otherwise resolves the variant
from the stored user preference or the server `logoStyle`).

## Why one file is enough

All logo rendering funnels through `useLogoVariant()`:

- `useLogoAssets()` → favicon, web manifest, apple-touch icon, wordmark,
`logo512`, tooltip logo — consumed by `BrandingAssetManager` (which sets
`<link rel="icon|manifest|apple-touch-icon">`), `Wordmark`, `LogoIcon`,
`Tooltip`.
- `useLogoPath()` → the no-text logo SVGs.
- The login-carousel slides (`buildLoginSlides`) receive the variant
from `AuthLayout`, which calls the hook.

Everything else that references a logo in SaaS already hardcodes
`modern-logo` (`index.html`, the SaaS
`Login`/`Signup`/`AuthCallback`/`OAuthConsent` routes, cloud onboarding,
account/MFA QR logos).

The only hardcoded `classic-logo` reference — the admin _Logo Style_
picker in `AdminGeneralSection` — is **not shipped in SaaS**:
`createSaasConfigNavSections` builds from the core nav sections and
never includes the proprietary admin sections.

`manifest-classic.json` and the classic assets remain in the shared
`public/` folder (served by all builds) but are never referenced in the
SaaS bundle.

## Test plan

- [x] `task frontend:typecheck:saas` — clean
- [x] `eslint` on the new file — clean
2026-06-23 13:03:17 +00:00
Anthony Stirling 0a29186ed6 Add JUnit tests to raise coverage across all modules (#6782)
# Description of Changes

AI generated junit tests

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-23 09:27:03 +00:00
James Brunton 101502cf4f Upgrade to TypeScript 6 (#6772)
# Description of Changes
TS6 introduced backwards-incompatible changes which affected us a little
bit. Other than that, I don't think it significantly changes things for
us, but we will need to deal with these breaking changes to be able to
upgrade to TS7 (the version written in Go, so dramatically faster), so
I'd rather do the work now before TS7 actually releases.

Main things I've done:
- Removed the use of `baseUrl` in the `tsconfig.json` files 
- Explicitly provide the `node` types where needed
- Explicitly reference the un-referenced but required Google API types
- Dropped the installation of `madge` which we weren't using and wasn't
directly compatible with TS6
- Updated the `i18next` packages for explicit TS6 compatibility
- Explicitly override `tsconfck` to force TS6 compatibility since we
can't upgrade it. We're only using that for `vite-tsconfig-paths` and it
all still seems to work fine, so I don't think this is an issue. I think
we can theoretically drop `vite-tsconfig-paths` when we upgrade to Vite
8 ([because it supports
`paths`](https://v8.vite.dev/guide/features#paths)), but that's a bigger
job than I want to do in this PR
2026-06-23 08:52:32 +00:00
James Brunton f2b65f4a77 Make pre-commit scripts more OS-agnostic (#6724)
# Description of Changes
Fix #6723
2026-06-23 08:42:18 +00:00
James Brunton 1816bad1ba Unified auth for portal and editor (#6725)
# Description of Changes
Refactor frontend auth to the shared folder and hook it up to both the
portal and editor so they share the same system. Also adds various tasks
to help run the portal, including `task dev:portal` to spawn the portal
with the backend, and `task dev:portal:proxy` to spawn the editor,
portal and backend, and a reverse proxy (at localhost:3000) to allow you
to use both at once to simulate how this will actually be deployed,
allowing you to check whether the seamless transition between the two
actually works.
2026-06-22 16:22:36 +00:00
James Brunton 9aee85d55e Wrap first login popup in a form so enter works to change password (#6769)
# Description of Changes

> [!note]
> GitHub absolutely mangles the diff unless you change to ignore
whitespace changes

This page has never had the Enter key bound to the Change Password
button:

<img width="673" height="745" alt="image"
src="https://github.com/user-attachments/assets/7a1b06f0-2945-4270-a795-f799ec556c12"
/>

This PR changes the modal to be properly wrapped in a form so key
commands work correctly on it.
2026-06-22 13:59:28 +00:00
James Brunton 72f8705460 Build Rust cache on nightlies (#6768)
# Description of Changes
Rust cache added in #6732 never fired because `main` builds don't
include building the desktop apps. We could build them on `main` builds,
but that's fairly expensive, so just build them on nightlies instead to
warm the cache for any desktop PRs the next day
2026-06-22 13:59:13 +00:00
Reece Browne dffc292888 I18n on portal (#6761)
## Overview

Internationalizes the **developer portal**, which previously had **zero
i18n** — every string was hardcoded across ~118 components. Rather than
stand up a parallel system, this shares the **editor's** existing i18n
setup (same TOML locale format, same Crowdin pipeline), then converts
every portal surface to `react-i18next` and adds a CI guard so coverage
can't regress.

## What's included

### 🔗 Shared i18n core (`@shared/i18n`)
- Extracts the editor's `TomlBackend` (HTTP loader for
`public/locales/{lng}/translation.toml`) and language metadata/helpers
(the 42-language list, RTL set, `LanguageSource` priority, code
normalizers) into `frontend/shared/i18n/`.
- The **editor** now imports and re-exports these from `@shared/i18n` —
its 20+ consumers are unchanged. Its local `tomlBackend.ts` is deleted.
- The **portal** builds its own i18next instance from the shared core,
with **en-US as the source of truth** and the same
`/locales/{lng}/translation.toml` layout.

### 🌍 Full portal coverage
- Every view and component converted to `t()` — all feature areas (home,
pipelines, sources, infrastructure, usage, documents, agent-builder,
editor-admin, policies, users, docs, catalogue, components view) plus
app shell, nav, modals, and the home/domain widgets.
- **1108 keys across ~30 namespaces** in
`portal/public/locales/en-US/translation.toml`, grouped by feature;
shared strings under `[common]`. Plurals use i18next count forms;
dynamic labels (nav, settings sections, status badges) use template keys
against populated tables.
- Data-driven strings (values from `@portal/api/*` mocks, enum/id
values, code samples) are intentionally left untranslated — they're
data, not UI chrome.

###  CI coverage guard
- `portal/scripts/check-i18n.mjs` fails if any static `t("key")` in
portal source is missing from the en-US locale. Wired into
`frontend:check` and `frontend:check:all`, so missed keys break CI. This
mirrors the editor's `missingTranslations` test for the portal, which
has no vitest harness of its own.

## Testing
- `task frontend:check:all` passes locally (typecheck all variants,
lint, format, **portal i18n guard**, builds, tests, storybook).
- Every static `t()` key verified to resolve in the locale (1108 keys /
186 source files); all dynamic key prefixes map to populated tables.
- Runtime sweep of all 12 portal routes shows **no unresolved keys** on
screen; nav labels, plurals, and array-backed copy all render real text.

## Follow-ups (not in this PR)
- **Crowdin** — register `frontend/portal/public/locales/` as a source
so portal strings flow through the same translation pipeline as the
editor (an ops step on the Crowdin side; there's no Crowdin config in
the repo).
- Only `en-US` is populated; other languages will arrive via the
pipeline.
2026-06-22 12:57:12 +00:00
Ludy c95fb89c63 fix(frontend): correctly display the current user role in the edit dialog (#6758)
# Description of Changes

This PR fixes the role field in the People settings "Edit User" dialog
so the currently assigned role is displayed correctly.

- What was changed
- The edit dialog now uses the actual role identifier from the user data
when preselecting the role.
- The role selection is made more robust by falling back to a valid
default when the backend response does not provide a usable role value.
- The role label shown in the UI is derived consistently from the role
identifier.

- Why the change was made
- The dialog could open with an empty role field even though the user
already had a role assigned.
- This made role editing confusing and could lead to accidental changes.
- The fix keeps the People UI aligned with the backend role data model.

before:

<img width="438" height="465" alt="image"
src="https://github.com/user-attachments/assets/929d8c63-8ae9-436a-b895-e6a746f22458"
/>

after:

<img width="437" height="461" alt="image"
src="https://github.com/user-attachments/assets/942f6787-d678-4555-90a9-bf5c7c5c363d"
/>


---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-21 10:25:30 +01:00
dependabot[bot]andAnthony Stirling 956b8000e4 build(deps): bump ws from 8.20.1 to 8.21.0 in /frontend (#6679)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 19:25:27 +01:00
Anthony Stirling a3fe15bfd0 Add metrics for numerical count of total PDFs (#6737) 2026-06-19 18:57:03 +01:00
EthanHealy01andJames Brunton 1a770af47c fix create tool in the AI chat (#6673)
AI PDF creation ("create a PDF for me") has been broken since the
Policies backend (#6527) introduced PolicyExecutor as the tool execution
pipeline. PolicyExecutor runs normal single-input tools with a per-file
loop, but generator tools like `create-pdf-from-html-agent` take no
input file and build their output purely from parameters. With zero
input files the loop ran zero times, so the endpoint was never called
and the step silently produced nothing. The chat reported success
("Created Purchase Order") while no document ever appeared.

This adds an `else if (inputFiles.isEmpty())` branch so a generator tool
is called once with an empty file list, matching what the multi-input
branch already does for an empty input. Two files changed: the
one-line-ish fix in `PolicyExecutor`, and a regression test covering the
no-input case.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-06-19 17:06:28 +00:00
Anthony Stirling 3870ac3d7d Add desktop mobile-upload page and fix LAN QR URL (#6736)
# Description of Changes

Desktop can not use QR code upload due to API backend not having UI for
it...
Because of this we add UI, has to be custom because can not support
OpenCV and in app camera due to its https requirement

<img width="919" height="2048" alt="image"
src="https://github.com/user-attachments/assets/d84c3903-fd23-421a-8919-24d89ba6c753"
/>
<img width="919" height="2048" alt="image"
src="https://github.com/user-attachments/assets/d6c8fdf3-837b-4dc3-92ea-37f7502f8462"
/>
<img width="919" height="2048" alt="image"
src="https://github.com/user-attachments/assets/fd4ce3b4-69ad-45dd-b066-bff2d082c583"
/>

<img width="1550" height="790" alt="image"
src="https://github.com/user-attachments/assets/701d4dcc-ebd6-4e03-aa7b-2d8623525fc9"
/>

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-06-19 15:33:49 +00:00
James Brunton b9ea9064c7 Cache Rust build to improve Tauri build job times (#6732)
# Description of Changes
The Tauri jobs are very slow, especially the Linux ones, which can take
>1hr to build all the necessary code. A lot of that is because of the
actual Rust compilation, which isn't cached at all as far as I can tell.
This introduces a cache step for the Rust dependencies, so PRs will just
reuse the compiled Rust from the last build of main (if it's safe to do
so).
2026-06-19 15:27:14 +00:00
1257 changed files with 121571 additions and 25084 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.13.1
pkgver=2.14.0
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.13.1
pkgver=2.14.0
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
@@ -222,7 +222,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
@@ -43,7 +43,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
- name: Install Task
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
- name: Install Task
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
- name: Install Task
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.3.1
gradle-version: 9.6.0
cache-disabled: true
- name: Set up Python
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
# No `-PnoSpotless` here yet because the upstream cache layer matches the
+9 -1
View File
@@ -61,14 +61,22 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
# When the PR changes the base image, test.sh builds it locally
# (stirling-pdf-base:local) into the daemon image store. A buildx
# container builder can't see that store, so skip it here and let
# `docker buildx build` fall back to the default docker driver, which
# resolves the local base. The gha cache backend is also skipped (its
# runtime token isn't exposed) since the docker driver can't use it.
- name: Set up Docker Buildx
if: inputs.docker-base-changed != 'true'
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
- name: Expose GitHub runtime for Buildx cache
if: inputs.docker-base-changed != 'true'
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
- name: Install Docker Compose
@@ -349,7 +349,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+3 -3
View File
@@ -73,7 +73,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -148,7 +148,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Setup Node.js
if: matrix.variant.build_frontend == true
@@ -252,7 +252,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
+18
View File
@@ -41,6 +41,11 @@ jobs:
- name: Install all Playwright browsers
run: task e2e:install
- name: Build frontend (production bundle for vite preview)
env:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run E2E tests (all browsers)
run: task e2e:cross-browser
@@ -51,3 +56,16 @@ 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
sign: false
secrets: inherit
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
+2 -2
View File
@@ -78,7 +78,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Set up Docker Buildx
id: buildx
@@ -155,9 +155,9 @@ jobs:
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# No BASE_VERSION pin: inherit the Dockerfile ARG default (single source of truth).
build-args: |
VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
BASE_VERSION=1.0.0
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Generate Swagger documentation
run: ./gradlew :stirling-pdf:generateOpenApiDocs
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
+59 -14
View File
@@ -16,6 +16,11 @@ on:
required: false
type: string
default: "all"
sign:
description: "Sign and notarize the bundles."
required: false
type: boolean
default: true
workflow_dispatch:
inputs:
platform:
@@ -28,6 +33,11 @@ on:
- windows
- macos
- linux
sign:
description: "Sign and notarize the bundles."
required: false
default: true
type: boolean
permissions:
contents: read
@@ -115,6 +125,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
@@ -136,7 +160,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Setup Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
@@ -163,7 +187,7 @@ jobs:
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
uses: digicert/ssm-code-signing@1d820463733701cf1484c7eb5d7d24a15ca2c454 # v1.2.1
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
@@ -173,7 +197,7 @@ jobs:
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
@@ -208,7 +232,7 @@ jobs:
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY == '' && github.ref == 'refs/heads/main' }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
@@ -239,7 +263,7 @@ jobs:
}
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -260,7 +284,7 @@ jobs:
rm certificate.p12
- name: Verify Certificate
if: matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
if: inputs.sign && matrix.platform == 'macos-15' && env.APPLE_CERTIFICATE != ''
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
@@ -283,7 +307,7 @@ jobs:
ls -la /usr/bin/hd* || echo "No hd* tools found"
- name: Preflight smctl
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: pwsh
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -296,7 +320,7 @@ jobs:
if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] smctl windows certsync returned non-zero - continuing" }
- name: Configure Windows code signing
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
if: ${{ inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
shell: bash
env:
KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
@@ -315,7 +339,7 @@ jobs:
EOF
- name: Import release GPG signing key (Linux)
if: matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
if: inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
run: |
echo "$RELEASE_GPG_PRIVATE_KEY" | gpg --batch --import
gpg --list-secret-keys --keyid-format=long
@@ -332,7 +356,8 @@ jobs:
exit 1
fi
- name: Build Tauri app
- name: Build Tauri app (signed)
if: inputs.sign
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -366,6 +391,26 @@ jobs:
# failure (#6127 onwards) does not tank deb/rpm uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
- name: Build Tauri app (unsigned)
if: ${{ !inputs.sign }}
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: "0"
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
CI: true
with:
projectPath: ./frontend/editor
tauriScript: npx tauri
# Linux: build deb+rpm only here. AppImage runs in its own
# continue-on-error step below so its persistent linuxdeploy
# failure (#6127 onwards) does not tank deb/rpm uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
# AppImage is decoupled so its linuxdeploy run gets a fresh process
# (rpm scratch state torn down) and its failure can't tank deb/rpm.
- name: Build Tauri app (Linux AppImage)
@@ -374,7 +419,7 @@ jobs:
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -389,7 +434,7 @@ jobs:
args: --bundles appimage
- name: Clear release GPG key from runner keyring (Linux)
if: always() && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
if: always() && inputs.sign && matrix.platform == 'ubuntu-22.04' && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main'
env:
RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
run: |
@@ -399,7 +444,7 @@ jobs:
fi
- name: Verify notarization (macOS only)
if: matrix.platform == 'macos-15'
if: inputs.sign && matrix.platform == 'macos-15'
run: |
echo "🔍 Verifying notarization status..."
cd ./frontend/editor/src-tauri/target
@@ -437,7 +482,7 @@ jobs:
# Verify the MSI AND the inner exe extracted from it are signed.
# The inner exe is what gets installed on users' machines and what AV scans.
- name: Verify Windows Code Signature
if: matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
if: inputs.sign && matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main'
shell: pwsh
run: |
$allSigned = $true
+17 -2
View File
@@ -106,7 +106,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
cache-disabled: true
- name: Install Task
@@ -155,6 +155,19 @@ jobs:
echo "platforms=linux/amd64,linux/arm64/v8" >> "$GITHUB_OUTPUT"
fi
# Base-changed PRs build the embedded image with the local docker driver
# so the locally-built stirling-pdf-base:pr-test (in the daemon image
# store) resolves. A buildx container builder cannot see it and would try
# to pull it from a registry, which fails. Single-platform, no gha cache.
- name: Build ${{ matrix.docker-rev }} against local base (PR base change)
if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true'
run: |
DOCKER_BUILDKIT=1 docker build \
--build-arg BASE_IMAGE=${{ steps.build-params.outputs.base_image }} \
--file ./${{ matrix.docker-rev }} \
--tag stirling-pdf-embedded:pr-test \
.
- name: Build ${{ matrix.docker-rev }} (Depot)
if: env.USE_DEPOT == 'true'
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
@@ -169,8 +182,10 @@ jobs:
provenance: true
sbom: true
# Fork PRs that did NOT change the base use the buildx container builder
# (multi-platform + gha cache) against the published base image.
- name: Build ${{ matrix.docker-rev }} (Docker fork fallback)
if: env.USE_DEPOT != 'true'
if: env.USE_DEPOT != 'true' && inputs.docker-base-changed != 'true'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
builder: ${{ steps.buildx.outputs.name }}
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
with:
gradle-version: 9.5.1
gradle-version: 9.6.0
- name: Build with Gradle
run: ./gradlew build
+1
View File
@@ -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/
+8
View File
@@ -19,3 +19,11 @@ frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
# False positive: generic-api-key matches the Java type name "X509Certificate"
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java:generic-api-key:224
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
.github/workflows/tauri-build.yml:generic-api-key:402
+10 -2
View File
@@ -25,21 +25,29 @@ tasks:
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
POLICIES_ENABLED: '{{.POLICIES_ENABLED}}'
dev:proprietary:
desc: "Start backend dev server in proprietary mode"
# `dotenv:` reads from the root Taskfile's directory (".") because this
# subtaskfile is included with `dir: .`. Local overrides in
# .env.proprietary.local win over the committed .env.proprietary defaults.
dotenv: ['app/.env.proprietary.local', 'app/.env.proprietary']
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
POLICIES_ENABLED: '{{.POLICIES_ENABLED | 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}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{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}}{{if .POLICIES_ENABLED}}POLICIES_ENABLED={{.POLICIES_ENABLED}} {{end}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
+48 -9
View File
@@ -5,6 +5,11 @@ vars:
# NoClassDefFoundError: jdk/dynalink/Namespace at runtime in get-info-on-pdf and verify-pdf
JLINK_MODULES: "java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.unsupported,jdk.dynalink"
# Minimum Java major the bundled JRE must be. Keep in sync with build.gradle
# `modernJavaVersion` - the app JAR is compiled for this, so an older runtime
# fails at launch with UnsupportedClassVersionError. Enforced by jlink:verify.
REQUIRED_JAVA: "25"
# Override via JPDFIUM_PLATFORMS env (csv of platform keys, or 'all').
JPDFIUM_PLATFORMS:
sh: |
@@ -102,6 +107,20 @@ tasks:
jlink:
desc: "Build backend JAR and create JLink runtime for Tauri"
deps: [jlink:jar, jlink:runtime]
# Runs after the runtime is in place. Lives here (not in jlink:runtime's
# cmds) so it still fires when jlink:runtime short-circuits on its `status:`
# check and reuses an existing runtime/jre - that reuse path is exactly how
# a stale, too-old JRE slips through.
cmds:
- task: jlink:verify
jlink:verify:
desc: "Fail the build if the bundled JRE is older than the app JAR requires"
dir: editor
env:
REQUIRED_JAVA: "{{.REQUIRED_JAVA}}"
cmds:
- node scripts/verify-bundled-jre.mjs src-tauri/runtime/jre/release
jlink:jar:
desc: "Build backend JAR for Tauri bundling (host-OS natives only by default)"
@@ -127,15 +146,35 @@ tasks:
cmds:
- rm -rf runtime/jre
- mkdir -p runtime
- |
JLINK_COMPRESS="$(jlink --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
jlink \
--add-modules {{.JLINK_MODULES}} \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
# Pin jlink to JAVA_HOME so the bundled JRE matches the JDK the build
# uses. Bare `jlink` on PATH can resolve to an older system Java (the
# ubuntu runner ships Java 11), producing a runtime jlink:verify rejects.
#
# jdk.crypto.mscapi (the Windows certificate store / SunMSCAPI provider, used by
# hardware-backed cert signing) is a Windows-only module - it only exists in a Windows
# JDK's jmods, so it is added on Windows only or jlink fails to resolve it elsewhere.
- cmd: |
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$JLINK" \
--add-modules {{.JLINK_MODULES}},jdk.crypto.mscapi \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
platforms: [windows]
- cmd: |
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$JLINK" \
--add-modules {{.JLINK_MODULES}} \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
platforms: [linux, darwin]
# jlink emits its files mode 444 (read-only). Tauri's build-script
# resource copier preserves source permissions when staging
# `runtime/jre/**/*` into `target/<profile>/runtime/jre/...`, so the
+108 -12
View File
@@ -55,6 +55,15 @@ tasks:
- editor/src/core/data/ogImageMap.json
- editor/public/og-metadata.json
prepare:classifier-categories:
internal: true
run: when_changed
desc: "Regenerate the engine classifier categories JSON from the TS source of truth"
cmds:
- npx tsx editor/scripts/generate-classification-taxonomy.mts
sources:
- editor/src/proprietary/data/classificationTaxonomy.ts
prepare:
desc: "Set up dev environment"
run: when_changed
@@ -65,6 +74,7 @@ tasks:
vars: { MODE: '{{.MODE}}' }
- prepare:icons
- prepare:og
- prepare:classifier-categories
# ============================================================
# Development
@@ -130,9 +140,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 +188,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 +218,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"
@@ -250,17 +305,26 @@ tasks:
cmds:
- task: typecheck:proprietary
typecheck:_run:
internal: true
env:
CI: '{{ .CI | default "false" }}'
cmds:
- '{{ if eq .CI "true" }}npx tsc{{ else }}npx tsgo{{ end }} --noEmit --project {{.PROJECT}}'
typecheck:core:
desc: "Typecheck core build variant"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/core/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/core/tsconfig.json }
typecheck:proprietary:
desc: "Typecheck proprietary build variant"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/proprietary/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/proprietary/tsconfig.json }
typecheck:saas:
desc: "Typecheck SaaS build variant"
@@ -268,7 +332,8 @@ tasks:
- task: prepare
vars: { MODE: saas }
cmds:
- npx tsc --noEmit --project editor/src/saas/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/saas/tsconfig.json }
typecheck:desktop:
desc: "Typecheck desktop build variant"
@@ -276,37 +341,45 @@ tasks:
- task: prepare
vars: { MODE: desktop }
cmds:
- npx tsc --noEmit --project editor/src/desktop/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/desktop/tsconfig.json }
typecheck:cloud:
desc: "Typecheck cloud shared layer (standalone)"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/cloud/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/cloud/tsconfig.json }
typecheck:scripts:
desc: "Typecheck scripts"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/scripts/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: scripts/tsconfig.json }
- task: typecheck:_run
vars: { PROJECT: editor/scripts/tsconfig.json }
typecheck:prototypes:
desc: "Typecheck prototypes build variant"
deps: [prepare]
cmds:
- npx tsc --noEmit --project editor/src/prototypes/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: editor/src/prototypes/tsconfig.json }
typecheck:portal:
desc: "Typecheck developer portal build variant"
deps: [install]
cmds:
- npx tsc --noEmit --project portal/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: portal/tsconfig.json }
typecheck:shared:
desc: "Typecheck the shared design system"
deps: [install]
cmds:
- npx tsc --noEmit --project shared/tsconfig.json
- task: typecheck:_run
vars: { PROJECT: shared/tsconfig.json }
typecheck:all:
desc: "Typecheck all build variants"
@@ -338,12 +411,23 @@ tasks:
cmds:
- node editor/scripts/generate-og-metadata.mjs --check
classifier-categories:
desc: "Regenerate the engine classifier categories JSON from the TS source"
cmds:
- npx tsx editor/scripts/generate-classification-taxonomy.mts
classifier-categories:check:
desc: "Fail if the committed classifier categories JSON is out of date"
cmds:
- npx tsx editor/scripts/generate-classification-taxonomy.mts --check
check:all:
desc: "Full CI quality gate"
cmds:
# Runs first, before prepare regenerates: guards the committed og-metadata.json /
# ogImageMap.json that the Cloudflare Pages (plain `vite build`) deploy relies on.
- task: og:check
- task: classifier-categories:check
- task: typecheck:all
- task: lint
- task: format:check
@@ -358,10 +442,22 @@ tasks:
test:
desc: "Run tests"
cmds:
- task: test:editor
- task: test:portal
test:editor:
desc: "Run editor tests"
deps: [prepare]
cmds:
- npx vitest run --root editor
test:portal:
desc: "Run portal tests"
deps: [prepare]
cmds:
- npx vitest run --root portal
test:watch:
desc: "Run tests in watch mode"
deps: [prepare]
+12 -38
View File
@@ -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
+122
View File
@@ -78,6 +78,110 @@ 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"
POLICIES_ENABLED: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
MOCKS: 'false'
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"
POLICIES_ENABLED: "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}}/'
MOCKS: 'false'
OPEN: "true"
- task: frontend:dev
vars:
PORT: '{{.EDITOR_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
dev:portal:all:saas:
desc: "Start SaaS 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:saas
vars:
PORT: '{{.BACKEND_PORT}}'
POLICIES_ENABLED: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
MOCKS: 'false'
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"
POLICIES_ENABLED: "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 +228,24 @@ 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"
POLICIES_ENABLED: "true"
- task: frontend:preview:portal:proxy
vars:
PORT: '{{.PROXY_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
# ============================================================
# Test
# ============================================================
+8
View File
@@ -0,0 +1,8 @@
# Committed defaults for `task backend:dev:proprietary` (self-hosted / proprietary
# flavor). Local overrides + secrets live in app/.env.proprietary.local (ignored).
# Combined-billing account link (Mode A). Feature-flagged: OFF until release.
# Flip to true in app/.env.proprietary.local to test linking locally.
STIRLING_BILLING_ACCOUNT_LINK_ENABLED=false
# SaaS base URL the linked instance calls (register + entitlement).
STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL=https://stirling.com/app
+1
View File
@@ -1,3 +1,4 @@
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
# stays ignored via the root .gitignore.
!.env.saas
!.env.proprietary
+6 -6
View File
@@ -29,13 +29,13 @@ spotless {
}
}
dependencies {
api 'com.google.guava:guava:33.6.0-jre'
api "com.google.guava:guava:${guavaVersion}"
api 'org.springframework.boot:spring-boot-starter-webmvc'
api 'org.springframework.boot:spring-boot-starter-aspectj'
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260313.1'
api 'com.fathzer:javaluator:3.0.6'
api 'com.posthog.java:posthog:1.2.0'
api 'org.apache.commons:commons-lang3:3.20.0'
api "org.apache.commons:commons-lang3:${commonsLang3}"
api 'com.drewnoakes:metadata-extractor:2.20.0' // Image metadata extractor
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
@@ -60,7 +60,7 @@ dependencies {
exclude group: 'com.google.code.gson', module: 'gson'
}
api 'com.stirling:jpdfium:1.0.2'
api "com.stirling:jpdfium:${jpdfiumVersion}"
// -PjpdfiumPlatforms=all|<csv of linux-x64,linux-arm64,darwin-x64,darwin-arm64,windows-x64>
def jpdfiumPlatformsProp = (project.findProperty('jpdfiumPlatforms') ?: 'all').toString().trim()
@@ -75,12 +75,12 @@ dependencies {
}
logger.lifecycle("JPDFium native platforms: ${jpdfiumPlatforms.join(', ')}")
jpdfiumPlatforms.each { platform ->
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.2"
runtimeOnly "com.stirling:jpdfium-natives-${platform}:${jpdfiumVersion}"
}
// Bucket4j (local in-process token bucket for RateLimitStore default impl)
implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0'
implementation "com.bucket4j:bucket4j_jdk17-core:${bucket4jVersion}"
// ArchUnit: enforces module dependency direction (see ArchitectureTest)
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
testImplementation "com.tngtech.archunit:archunit-junit5:${archunitVersion}"
}
@@ -206,6 +206,11 @@ public class ApplicationProperties {
@Data
public static class Policies {
/**
* Master switch for the policy + sources subsystem (the PAYG-metered automation surface).
*/
private boolean enabled = false;
/**
* Absolute directories that policy folder input sources and output sinks may read from or
* write to. Empty (the default) disables folder access entirely, so a policy can never be
@@ -514,6 +519,14 @@ public class ApplicationProperties {
private String accessibilityStatement;
private String cookiePolicy;
private String impressum;
private LoginAgreement loginAgreement = new LoginAgreement();
@Data
public static class LoginAgreement {
private boolean enabled = false;
private boolean showInAnonymousMode = true;
private String fallbackText = "";
}
}
@Data
@@ -582,7 +595,7 @@ public class ApplicationProperties {
public static class SAML2 {
private String provider;
private Boolean enabled = false;
private Boolean autoCreateUser = false;
private Boolean autoCreateUser = true;
private Boolean blockRegistration = false;
private String registrationId = "stirling";
@@ -659,7 +672,7 @@ public class ApplicationProperties {
private String issuer;
private String clientId;
@ToString.Exclude private String clientSecret;
private Boolean autoCreateUser = false;
private Boolean autoCreateUser = true;
private Boolean blockRegistration = false;
private String useAsUsername;
private Collection<String> scopes = new ArrayList<>();
@@ -730,7 +743,6 @@ public class ApplicationProperties {
@Data
public static class Jwt {
private boolean enableKeystore = true;
private boolean enableKeyRotation = false;
private boolean enableKeyCleanup = true;
/**
@@ -834,8 +846,8 @@ public class ApplicationProperties {
@Data
public static class Trust {
private boolean serverAsAnchor = true;
private boolean useSystemTrust = false;
private boolean useMozillaBundle = false;
private boolean useSystemTrust = true;
private boolean useMozillaBundle = true;
private boolean useAATL = false;
private boolean useEUTL = false;
}
@@ -869,8 +881,8 @@ public class ApplicationProperties {
public static class System {
private String defaultLocale;
private boolean googlevisibility;
private boolean showUpdate;
private boolean showUpdateOnlyAdmin;
private boolean showUpdate = true;
private boolean showUpdateOnlyAdmin = true;
private boolean showSettingsWhenNoLogin = true;
private boolean customHTMLFiles;
private String tessdataDir;
@@ -878,10 +890,10 @@ public class ApplicationProperties {
private Boolean enableAnalytics;
private Boolean enablePosthog;
private Boolean enableScarf;
private Boolean enableDesktopInstallSlide;
private Boolean enableDesktopInstallSlide = true;
private Datasource datasource;
private boolean disableSanitize;
private int maxDPI;
private int maxDPI = 500;
private boolean enableUrlToPDF;
private Html html = new Html();
private CustomPaths customPaths = new CustomPaths();
@@ -895,8 +907,9 @@ public class ApplicationProperties {
private String frontendUrl; // Frontend URL for invite email links (e.g.
// 'https://app.example.com'). If not set, falls back to backendUrl.
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
private boolean enableMobileScanner = true; // Enable mobile phone QR code upload feature
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
private ServerCertificate serverCertificate = new ServerCertificate();
@Data
public static class MobileScannerSettings {
@@ -906,6 +919,16 @@ public class ApplicationProperties {
private boolean stretchToFit = false; // Whether to stretch image to fill page
}
@Data
public static class ServerCertificate {
private boolean enabled =
true; // Enable server-side "Sign with Stirling-PDF" certificate
private String organizationName = "Stirling PDF Inc";
private int validity = 365; // Certificate validity in days
private boolean regenerateOnStartup =
false; // Generate a new certificate on each startup
}
public boolean isAnalyticsEnabled() {
return this.enableAnalytics != null && this.enableAnalytics;
}
@@ -990,7 +1013,7 @@ public class ApplicationProperties {
@Data
public static class Sharing {
private boolean enabled = false;
private boolean linkEnabled = false;
private boolean linkEnabled = true;
private boolean emailEnabled = false;
private int linkExpirationDays = 3;
}
@@ -1164,7 +1187,7 @@ public class ApplicationProperties {
@Data
public static class Metrics {
private boolean enabled;
private boolean enabled = true;
}
@Data
@@ -1216,7 +1239,7 @@ public class ApplicationProperties {
private boolean enableInvites = false;
private int inviteLinkExpiryHours = 72; // Default: 72 hours (3 days)
private String host;
private int port;
private int port = 587;
private String username;
@ToString.Exclude private String password;
private String from;
@@ -1243,10 +1266,10 @@ public class ApplicationProperties {
@ToString.Exclude private String botToken;
private String botUsername;
private String pipelineInboxFolder = "telegram";
private Boolean customFolderSuffix = false;
private Boolean enableAllowUserIDs = false;
private Boolean customFolderSuffix = true;
private Boolean enableAllowUserIDs = true;
private List<Long> allowUserIDs = new ArrayList<>();
private Boolean enableAllowChannelIDs = false;
private Boolean enableAllowChannelIDs = true;
private List<Long> allowChannelIDs = new ArrayList<>();
private long processingTimeoutSeconds = 180;
private long pollingIntervalMillis = 2000;
@@ -0,0 +1,204 @@
package stirling.software.common.service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
// Resolves login agreement text from customFiles/disclaimer/<locale>.md (read live);
// enable/visibility come from the legal.loginAgreement settings.
@Service
@Slf4j
public class LoginAgreementService {
// Locale codes only: rejects path separators and dots so the value can never escape the
// disclaimer directory. Matches e.g. en, en-GB, fr-FR, zh-Hant, pt-BR.
private static final Pattern LOCALE_PATTERN =
Pattern.compile("^[A-Za-z]{2,3}([_-][A-Za-z0-9]{2,8})*$");
// BCP-47 tags are well under this; the cap also prevents the regex's repetition group
// from recursing far enough to overflow the stack on a hostile over-length input.
private static final int MAX_LOCALE_LENGTH = 35;
// Disclaimers are short markdown; cap the read so an oversized file can't be loaded
// wholesale into heap on every public request.
private static final long MAX_FILE_BYTES = 256 * 1024;
private final ApplicationProperties applicationProperties;
public LoginAgreementService(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
public boolean isEnabled() {
return config().isEnabled();
}
public boolean isShowInAnonymousMode() {
return config().isShowInAnonymousMode();
}
/**
* Resolve the markdown to show for the requested language, falling back through the base
* language, the configured default locale (and its base), then the configured fallbackText.
* Returns an empty string when nothing is configured.
*/
public String resolveContent(String requestedLang) {
List<String> candidates = new ArrayList<>();
addLocaleCandidates(candidates, requestedLang);
addLocaleCandidates(candidates, applicationProperties.getSystem().getDefaultLocale());
for (String candidate : candidates) {
String content = readFileIfExists(candidate);
if (content != null && !content.isBlank()) {
return content;
}
}
String fallback = config().getFallbackText();
return fallback == null ? "" : fallback;
}
/**
* Admin read of a single locale's raw file. Returns null for an invalid locale, "" if absent.
*/
public String readRawForLocale(String locale) {
if (!isValidLocale(locale)) {
return null;
}
String content = readFileIfExists(locale);
return content == null ? "" : content;
}
/** Admin write. Blank content deletes the file so it falls back cleanly. */
public void writeForLocale(String locale, String content) throws IOException {
Path file = resolveLocaleFile(locale);
if (file == null) {
throw new IllegalArgumentException("Invalid locale: " + locale);
}
if (content == null || content.isBlank()) {
Files.deleteIfExists(file);
return;
}
Files.createDirectories(file.getParent());
// Write to a sibling temp file then atomically swap, so a concurrent reader (the public
// /login-disclaimer fetch is lockless) never observes a truncated/partial file.
Path tmp = Files.createTempFile(file.getParent(), "disclaimer", ".md.tmp");
try {
Files.writeString(tmp, content, StandardCharsets.UTF_8);
try {
Files.move(
tmp,
file,
StandardCopyOption.ATOMIC_MOVE,
StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(tmp);
}
}
/** Locales that currently have a markdown file, for the admin editor. */
public Set<String> listLocalesWithContent() {
Set<String> result = new TreeSet<>();
Path dir = disclaimerDir();
if (!Files.isDirectory(dir)) {
return result;
}
try (Stream<Path> files = Files.list(dir)) {
files.filter(Files::isRegularFile)
.map(path -> path.getFileName().toString())
.filter(name -> name.endsWith(".md"))
.map(name -> name.substring(0, name.length() - ".md".length()))
.filter(this::isValidLocale)
.forEach(result::add);
} catch (IOException e) {
log.warn("Failed listing login agreement files", e);
}
return result;
}
private ApplicationProperties.Legal.LoginAgreement config() {
return applicationProperties.getLegal().getLoginAgreement();
}
private Path disclaimerDir() {
return Path.of(InstallationPathConfig.getCustomFilesPath(), "disclaimer").normalize();
}
private void addLocaleCandidates(List<String> out, String locale) {
if (!isValidLocale(locale)) {
return;
}
if (!out.contains(locale)) {
out.add(locale);
}
String base = locale.split("[_-]", 2)[0];
if (!base.equals(locale) && !out.contains(base)) {
out.add(base);
}
}
private String readFileIfExists(String locale) {
Path file = resolveLocaleFile(locale);
if (file == null) {
return null;
}
try {
// NOFOLLOW_LINKS: a symlinked entry is treated as non-regular and skipped, so a
// planted symlink can't expose files outside the disclaimer dir via the public read.
if (Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
if (Files.size(file) > MAX_FILE_BYTES) {
log.warn(
"Login agreement file for locale {} exceeds {} bytes; ignoring",
locale,
MAX_FILE_BYTES);
return null;
}
return Files.readString(file, StandardCharsets.UTF_8);
}
} catch (IOException e) {
log.warn("Failed reading login agreement file for locale {}", locale, e);
}
return null;
}
private Path resolveLocaleFile(String locale) {
if (!isValidLocale(locale)) {
return null;
}
Path dir = disclaimerDir();
Path file = dir.resolve(locale + ".md").normalize();
// Defence in depth: the regex already blocks separators, but confirm containment.
if (!file.startsWith(dir)) {
return null;
}
return file;
}
private boolean isValidLocale(String locale) {
// Length check BEFORE the regex: LOCALE_PATTERN's repetition group recurses one stack
// frame per repeat in java.util.regex, so an unbounded input could overflow the stack.
return locale != null
&& locale.length() <= MAX_LOCALE_LENGTH
&& LOCALE_PATTERN.matcher(locale).matches();
}
}
@@ -7,6 +7,7 @@ import java.time.format.DateTimeFormatter;
import java.util.Calendar;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@@ -17,6 +18,11 @@ import stirling.software.common.model.PdfMetadata;
@Service
public class PdfMetadataService {
/**
* ({@code {category, docType, typeConfidence, tags}}). Written by the classify-and-tag tool.
*/
public static final String CLASSIFICATION_KEY = "StirlingPDFClassification";
private final ApplicationProperties applicationProperties;
private final String stirlingPDFLabel;
private final UserServiceInterface userService;
@@ -177,4 +183,14 @@ public class PdfMetadataService {
}
pdf.getDocumentInformation().setAuthor(author);
}
/**
* Write the document classifier's JSON result into the custom Info-dictionary field {@link
* #CLASSIFICATION_KEY}, leaving all other metadata untouched.
*/
public void setClassificationMetadata(PDDocument pdf, String classificationJson) {
PDDocumentInformation info = pdf.getDocumentInformation();
info.setCustomMetadataValue(CLASSIFICATION_KEY, classificationJson);
pdf.setDocumentInformation(info);
}
}
@@ -1185,23 +1185,181 @@ public class GeneralUtils {
}
public String getLocalNetworkIp() {
String routed = detectLocalIpViaDefaultRoute();
if (routed != null) {
return routed;
}
try {
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
if (interfaces == null) return null;
while (interfaces.hasMoreElements()) {
NetworkInterface iface = interfaces.nextElement();
if (!iface.isUp() || iface.isLoopback() || iface.isVirtual()) continue;
Enumeration<InetAddress> 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<NetworkInterfaceInfo> collectInterfaceInfo() throws SocketException {
List<NetworkInterfaceInfo> infos = new ArrayList<>();
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
if (interfaces == null) {
return infos;
}
while (interfaces.hasMoreElements()) {
NetworkInterface iface = interfaces.nextElement();
List<String> siteLocalIpv4s = new ArrayList<>();
Enumeration<InetAddress> 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<NetworkInterfaceInfo> 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<String> siteLocalIpv4s) {}
private record ScoredAddress(String ip, int score, int interfaceIndex) {}
}
@@ -244,10 +244,7 @@ public class SvgSanitizer {
return false;
}
return normalized.startsWith("http://")
|| normalized.startsWith("https://")
|| normalized.startsWith("//")
|| normalized.startsWith("file:");
return true;
}
private boolean isUrlAllowed(String url) {
@@ -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<ImageBox> 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();
}
}
}
@@ -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<Path> 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-");
}
}
}
@@ -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<InstallationPathConfig> 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<InstallationPathConfig> 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<Path> files = Files.list(configDir)) {
assertThat(files.anyMatch(p -> p.getFileName().toString().contains(".bak")))
.isTrue();
}
assertThat(Files.exists(settings)).isFalse();
}
}
}
@@ -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<TextChar> 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<TextLine> 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<float[]> 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<TextLine> 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<float[]> cols = PdfMarkdownConverter.findColumnRangesFromLines(rows);
assertThat(cols).hasSize(1);
}
@Test
@DisplayName("a single occupied band yields one column (trailing-band flush)")
void singleColumn() {
List<TextLine> rows = new ArrayList<>();
for (int r = 0; r < 3; r++) {
rows.add(row(400f - r * 12f, word("word", 50f, 60f)));
}
List<float[]> 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<TextLine> 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<TextLine> 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<float[]> 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<PageText> 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<Table> 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<Table> tables = converter.extractTables(doc, 0);
List<String> 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);
}
});
}
}
}
@@ -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<List<String>> 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);
}
}
}
}
@@ -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();
}
}
}
@@ -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();
}
}
}
}
@@ -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<Object> 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<Object> 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<Object> 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<String> 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<Object> 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<Object> 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<byte[]> 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<Object> 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<StreamingResponseBody> 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<Object> 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<Object> 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<Object> 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<Supplier<Object>> 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<Supplier<Object>> workCaptor = ArgumentCaptor.forClass(Supplier.class);
when(jobQueue.queueJob(anyString(), eq(80), workCaptor.capture(), anyLong()))
.thenReturn(new CompletableFuture<>());
Supplier<Object> 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<Object> 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<String, String> body = (Map<String, String>) response.getBody();
assertThat(body.get("error")).contains("timed out");
}
}
}
@@ -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<ResourceStatus> 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<ResponseEntity<?>> 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<Object> 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<ResponseEntity<?>> 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<ResponseEntity<?>> 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<ResponseEntity<?>> 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<String, Object>)
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<ResponseEntity<?>> 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<String, Object>)
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<ResponseEntity<?>> 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<String> inner = ResponseEntity.ok("inner");
CompletableFuture<ResponseEntity<?>> 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<Object> failing =
() -> {
throw new RuntimeException("exec-boom");
};
CompletableFuture<ResponseEntity<?>> 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<Object>) () -> "joined", 0L);
assertThat(result).isEqualTo("joined");
}
@Test
@DisplayName("an execution failure is unwrapped to its cause")
void executionFailureUnwrapped() {
Supplier<Object> 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<Object> 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");
}
}
}
@@ -0,0 +1,201 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mockStatic;
import java.io.IOException;
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.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.MockedStatic;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
/**
* Unit tests for {@link LoginAgreementService}. The service resolves per-language markdown from
* {@code <customFiles>/disclaimer/<locale>.md}; here {@link
* InstallationPathConfig#getCustomFilesPath()} is mocked to a {@link TempDir} so file IO is
* isolated.
*/
class LoginAgreementServiceTest {
@TempDir Path customFilesDir;
private ApplicationProperties properties;
private ApplicationProperties.Legal.LoginAgreement config;
private LoginAgreementService service;
private Path disclaimerDir;
@BeforeEach
void setUp() {
properties = new ApplicationProperties();
config = properties.getLegal().getLoginAgreement();
service = new LoginAgreementService(properties);
disclaimerDir = customFilesDir.resolve("disclaimer");
}
/**
* Run {@code action} with InstallationPathConfig.getCustomFilesPath() pointing at the temp dir.
*/
private void withMockedPath(Runnable action) {
try (MockedStatic<InstallationPathConfig> mocked =
mockStatic(InstallationPathConfig.class)) {
mocked.when(InstallationPathConfig::getCustomFilesPath)
.thenReturn(customFilesDir.toString());
action.run();
}
}
private void writeFile(String locale, String content) throws IOException {
Files.createDirectories(disclaimerDir);
Files.writeString(disclaimerDir.resolve(locale + ".md"), content, StandardCharsets.UTF_8);
}
@Test
void flagsReflectConfig() {
config.setEnabled(true);
config.setShowInAnonymousMode(false);
assertTrue(service.isEnabled());
assertFalse(service.isShowInAnonymousMode());
}
@Test
void resolveContentReturnsExactLocaleFile() throws IOException {
writeFile("fr-FR", "# Avis");
withMockedPath(() -> assertEquals("# Avis", service.resolveContent("fr-FR")));
}
@Test
void resolveContentFallsBackToBaseLanguage() throws IOException {
// Only a language-only file exists; a region-specific request should fall back to it.
writeFile("de", "# Hinweis");
withMockedPath(() -> assertEquals("# Hinweis", service.resolveContent("de-DE")));
}
@Test
void resolveContentFallsBackToDefaultLocale() throws IOException {
properties.getSystem().setDefaultLocale("en-GB");
writeFile("en-GB", "# Notice");
// No file for the requested locale -> falls through to the configured default locale.
withMockedPath(() -> assertEquals("# Notice", service.resolveContent("es-ES")));
}
@Test
void resolveContentFallsBackToFallbackTextWhenNoFile() {
config.setFallbackText("# Fallback");
withMockedPath(() -> assertEquals("# Fallback", service.resolveContent("ja-JP")));
}
@Test
void resolveContentReturnsEmptyWhenNothingConfigured() {
withMockedPath(() -> assertEquals("", service.resolveContent("ja-JP")));
}
@Test
void resolveContentDoesNotEscapeDisclaimerDirectory() throws IOException {
// Plant a file outside the disclaimer dir; a traversal-style locale must not read it.
Files.writeString(
customFilesDir.resolve("secret.md"), "TOP SECRET", StandardCharsets.UTF_8);
config.setFallbackText("safe");
withMockedPath(
() -> {
assertEquals("safe", service.resolveContent("../secret"));
assertEquals("safe", service.resolveContent("..%2Fsecret"));
assertEquals("safe", service.resolveContent("/etc/passwd"));
});
}
@Test
void readRawRejectsInvalidLocale() {
withMockedPath(
() -> {
assertNull(service.readRawForLocale("../secret"));
assertNull(service.readRawForLocale("en/GB"));
assertNull(service.readRawForLocale("C:\\x"));
assertNull(service.readRawForLocale(null));
});
}
@Test
void readRawReturnsEmptyForValidButAbsentLocale() {
withMockedPath(() -> assertEquals("", service.readRawForLocale("pt-BR")));
}
@Test
void overlongLocaleIsRejectedWithoutStackOverflow() {
// Guards against the regex-recursion stack overflow on unbounded input.
String hostile = "en" + "-ab".repeat(4000);
withMockedPath(
() -> {
assertDoesNotThrow(() -> service.readRawForLocale(hostile));
assertNull(service.readRawForLocale(hostile));
assertDoesNotThrow(() -> service.resolveContent(hostile));
});
}
@Test
void writeThenReadRoundTrips() throws IOException {
withMockedPath(
() -> {
assertDoesNotThrow(() -> service.writeForLocale("fr-FR", "# Bonjour"));
assertEquals("# Bonjour", service.readRawForLocale("fr-FR"));
});
assertTrue(Files.isRegularFile(disclaimerDir.resolve("fr-FR.md")));
}
@Test
void writeBlankDeletesFile() throws IOException {
writeFile("fr-FR", "# Bonjour");
withMockedPath(
() -> {
assertDoesNotThrow(() -> service.writeForLocale("fr-FR", " "));
assertEquals("", service.readRawForLocale("fr-FR"));
});
assertFalse(Files.exists(disclaimerDir.resolve("fr-FR.md")));
}
@Test
void writeRejectsInvalidLocale() {
withMockedPath(
() ->
assertThrows(
IllegalArgumentException.class,
() -> service.writeForLocale("../escape", "x")));
}
@Test
void listLocalesWithContentReturnsOnlyValidMarkdownFiles() throws IOException {
writeFile("en-GB", "a");
writeFile("fr-FR", "b");
Files.writeString(disclaimerDir.resolve("notes.txt"), "x", StandardCharsets.UTF_8);
withMockedPath(
() -> {
var locales = service.listLocalesWithContent();
assertTrue(locales.contains("en-GB"));
assertTrue(locales.contains("fr-FR"));
assertEquals(2, locales.size());
});
}
@Test
void oversizedFileIsIgnored() throws IOException {
// Files beyond the read cap are skipped rather than loaded into heap.
byte[] big = new byte[300 * 1024];
java.util.Arrays.fill(big, (byte) 'x');
Files.createDirectories(disclaimerDir);
Files.write(disclaimerDir.resolve("en-GB.md"), big);
config.setFallbackText("small-fallback");
properties.getSystem().setDefaultLocale("en-GB");
withMockedPath(() -> assertEquals("small-fallback", service.resolveContent("en-GB")));
}
}
@@ -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<ResourceStatus> currentStatus =
new AtomicReference<>(ResourceStatus.OK);
private final AtomicReference<ResourceMetrics> 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;
}
}
}
@@ -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();
}
}
}
@@ -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<ResultFile> 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<String, JobResult> map =
(Map<String, JobResult>)
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"));
}
}
}
@@ -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<Path> 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<Path> 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<Path> 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<Path> 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<Path> 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);
}
}
}
@@ -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();
}
}
}
@@ -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();
}
}
}
@@ -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"
+ "<html><body><p>Rich</p></body></html>",
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();
}
}
}
@@ -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("<!DOCTYPE html>")
.contains("My Subject")
.contains("from@x.com")
.contains("to@x.com")
.contains("plain body text")
.contains("</body></html>");
}
@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("<p>html version</p>");
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<EmailAttachment> 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<EmailAttachment> 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 =
"<html><head><style>.a{}</style></head>"
+ "<body><script>alert(1)</script><p>keep</p></body></html>";
String result = EmlProcessingUtils.simplifyHtmlContent(html);
assertThat(result).doesNotContain("<script").doesNotContain("<style").contains("keep");
}
}
@Nested
@DisplayName("decodeMimeHeader - quoted-printable charset handling")
class DecodeMimeHeaderTests {
@Test
@DisplayName("decodes a quoted-printable hex sequence into the right characters")
void decodesQpHex() {
// =E9 in ISO-8859-1 is 'é'.
String result = EmlProcessingUtils.decodeMimeHeader("=?ISO-8859-1?Q?caf=E9?=");
assertThat(result).isEqualTo("café");
}
@Test
@DisplayName("an unknown charset falls back without throwing")
void unknownCharsetFallback() {
String result = EmlProcessingUtils.decodeMimeHeader("=?MADE-UP-CHARSET?B?SGVsbG8=?=");
assertThat(result).isNotNull();
}
}
@Nested
@DisplayName("convertTextToHtml - sanitizer-less escaping")
class ConvertTextToHtmlTests {
@Test
@DisplayName("escapes HTML special characters when no sanitizer is supplied")
void escapesSpecialChars() {
String result = EmlProcessingUtils.convertTextToHtml("a <b> & c", null);
assertThat(result).contains("&lt;b&gt;").contains("&amp;");
}
}
@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");
}
}
}
@@ -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());
}
}
}
@@ -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<Path> 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);
}
}
}
@@ -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<List<String>> commandCaptor = ArgumentCaptor.forClass(List.class);
Mockito.doReturn(successResult())
.when(executor)
.runCommandWithOutputHandling(commandCaptor.capture());
try (MockedStatic<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
.thenReturn(executor);
byte[] result =
FileToPdf.convertHtmlToPdf(
"/usr/bin/weasyprint",
new HTMLToPdfRequest(),
"<html><body>hi</body></html>".getBytes(StandardCharsets.UTF_8),
"page.html",
tempFileManager,
sanitizer);
assertThat(result).isNotNull();
List<String> 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<ProcessExecutor> mocked = Mockito.mockStatic(ProcessExecutor.class)) {
mocked.when(() -> ProcessExecutor.getInstance(ProcessExecutor.Processes.WEASYPRINT))
.thenReturn(executor);
FileToPdf.convertHtmlToPdf(
"weasyprint",
new HTMLToPdfRequest(),
"<b>x</b>".getBytes(StandardCharsets.UTF_8),
"doc.HTML",
tempFileManager,
sanitizer);
Mockito.verify(sanitizer).sanitize("<b>x</b>");
}
}
}
@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[] {"<p>body</p>", "p{color:red}"});
ProcessExecutor executor = mock(ProcessExecutor.class);
Mockito.doReturn(successResult())
.when(executor)
.runCommandWithOutputHandling(anyList());
try (MockedStatic<ProcessExecutor> 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("<p>body</p>");
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<ProcessExecutor> 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[] {"<i>hi</i>"});
// 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<String> entryNames = new java.util.ArrayList<>();
ProcessExecutor executor = mock(ProcessExecutor.class);
Mockito.doAnswer(
invocation -> {
List<String> 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<ProcessExecutor> 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"));
}
}
}
}
@@ -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<PDAnnotationWidget> 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<FormUtils.FormFieldInfo> 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<FormUtils.FormFieldInfo> 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<FormUtils.FormFieldInfo> 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<FormUtils.FormFieldInfo> 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<FormUtils.FormFieldInfo> 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<FormUtils.FormFieldInfo> 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<FormUtils.FormFieldInfo> 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<FormUtils.FormFieldInfo> 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<FormUtils.FormFieldInfo> 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<FormFieldWithCoordinates> 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<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(doc);
assertEquals(1, fields.size());
List<String> 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<FormFieldWithCoordinates> 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<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(doc);
assertEquals(1, fields.size());
List<FormFieldWithCoordinates.WidgetCoordinates> 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<PDAnnotationWidget> widgets = new ArrayList<>();
widgets.add(widget);
text.setWidgets(widgets);
setup.acroForm().getFields().add(text);
setup.page().getAnnotations().add(widget);
List<FormFieldWithCoordinates> 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<String, Object> 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<String, Object> 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<FormUtils.FormFieldInfo> 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<FormUtils.FormFieldInfo> 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<String> 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<String, Object> result = FormUtils.buildFillTemplateRecord(List.of(info));
assertEquals("Yes", result.get("choice"));
}
@Test
void buildFillTemplateNullEntriesAreSkipped() {
List<FormUtils.FormFieldInfo> list = new ArrayList<>();
list.add(null);
list.add(
new FormUtils.FormFieldInfo(
"kept", "Kept", "text", "v", null, false, 0, false, null, 0));
Map<String, Object> 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<String> 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));
}
}
}
@@ -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<PDAnnotationWidget> 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<String> 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<String> 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());
}
}
}
@@ -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"));
}
}
@@ -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<Integer> 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<Integer> 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<InstallationPathConfig> 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<ProcessExecutor> 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<ProcessExecutor> 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)));
}
}
}
@@ -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<ProcessExecutor> 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<ProcessExecutor> 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<ProcessExecutor> 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();
}
}
}
@@ -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<ApplicationContextProvider> 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<ApplicationContextProvider> 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<ApplicationContextProvider> 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);
}
}
}
}
@@ -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<ProcessBuilder> 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<ProcessBuilder> 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<ProcessBuilder> 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<ProcessBuilder> 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<ProcessBuilder> 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<ProcessBuilder> 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<ProcessBuilder> 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<ProcessBuilder> 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));
}
}
}
}
@@ -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<String> 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("a<b>c").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("<script>x()</script>").find());
assertTrue(utils.getStyleTagPattern().matcher("<style>a{}</style>").find());
assertTrue(utils.getFixedPositionCssPattern().matcher("position: fixed;").find());
assertTrue(utils.getAbsolutePositionCssPattern().matcher("position: absolute;").find());
}
@Test
void inlineCidAndImagePatterns() {
assertTrue(utils.getInlineCidImagePattern().matcher("<img src=\"cid:abc\">").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());
}
}
@@ -97,4 +97,51 @@ class SvgSanitizerTest {
byte[] invalid = "not xml at all".getBytes(StandardCharsets.UTF_8);
assertThrows(IOException.class, () -> sanitizer.sanitize(invalid));
}
@Test
void testSanitize_removesRootRelativeLocalPath() throws IOException {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
+ "<image href=\"/tmp/image.png\" width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("/tmp/image.png"), "Root-relative local path must be stripped");
}
@Test
void testSanitize_removesRelativeLocalPath() throws IOException {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
+ "<image href=\"../../assets/image.png\" width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(output.contains("assets/image.png"), "Relative local path must be stripped");
}
@Test
void testSanitize_removesRootRelativeWindowsDrivePath() throws IOException {
when(ssrfProtectionService.isUrlAllowed(anyString())).thenReturn(false);
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\" "
+ "xmlns:xlink=\"http://www.w3.org/1999/xlink\">"
+ "<image xlink:href=\"/C:/Users/x/external-image.svg\""
+ " width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertFalse(
output.contains("external-image"), "Root-relative Windows path must be stripped");
}
@Test
void testSanitize_keepsInDocumentFragmentReference() throws IOException {
String svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\">"
+ "<use href=\"#gradient\"/><rect width=\"10\" height=\"10\"/></svg>";
byte[] result = sanitizer.sanitize(svg.getBytes(StandardCharsets.UTF_8));
String output = new String(result, StandardCharsets.UTF_8);
assertTrue(
output.contains("#gradient"), "In-document fragment references must be preserved");
}
}
@@ -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<String, Object> 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();
}
}
}
@@ -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<TempFile> 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<Resource> 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<Resource> 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<Resource> 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<Resource> 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<TempFile> seen = new ArrayList<>();
List<Resource> 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<Resource> 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);
}
}
}
@@ -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);
}
}
}
+8 -9
View File
@@ -67,7 +67,7 @@ dependencies {
exclude group: 'com.fasterxml.jackson.jaxrs'
exclude group: 'com.fasterxml.jackson.module', module: 'jackson-module-jaxb-annotations'
}
implementation 'commons-io:commons-io:2.22.0'
implementation "commons-io:commons-io:$commonsIoVersion"
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
implementation 'io.micrometer:micrometer-core'
@@ -81,25 +81,24 @@ dependencies {
implementation 'org.verapdf:validation-model:1.28.2'
// CVE-2025-66453: Explicit rhino 1.7.15 to override verapdf's 1.7.13
implementation 'org.mozilla:rhino:1.9.1'
implementation "org.mozilla:rhino:${rhinoVersion}"
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
implementation 'com.sun.xml.bind:jaxb-core:4.0.7'
implementation 'org.apache.poi:poi-ooxml:5.5.1'
// CVE-2022-25647: Explicit gson 2.13.2 to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation 'com.google.code.gson:gson:2.13.2'
// CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7)
implementation "com.google.code.gson:gson:${gsonVersion}"
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
implementation 'org.apache.poi:poi-ooxml:5.5.1'
// Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
// Replaces batik-all which included unused codec, svggen, transcoder, script modules
implementation 'org.apache.xmlgraphics:batik-bridge:1.19'
implementation "org.apache.xmlgraphics:batik-bridge:${batikVersion}"
// Required by TwelveMonkeys imageio-batik SPI (SVGImageReaderSpi) during ImageIO init
runtimeOnly 'org.apache.xmlgraphics:batik-transcoder:1.19'
runtimeOnly "org.apache.xmlgraphics:batik-transcoder:${batikVersion}"
// PDFBox Graphics2D bridge for Batik SVG to PDF conversion
implementation 'de.rototor.pdfbox:graphics2d:3.0.5'
@@ -332,8 +331,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')
}
@@ -24,12 +24,14 @@ import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.Locale;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.bouncycastle.cert.jcajce.JcaCertStore;
@@ -50,6 +52,13 @@ public abstract class CreateSignatureBase implements SignatureInterface {
@Getter private Certificate[] certificateChain;
@Setter private String tsaUrl;
/**
* Provider that must service the signing operation. Set for hardware-held keys (SunPKCS11 for
* USB tokens, SunMSCAPI for the Windows store) so the {@link java.security.Signature} runs on
* the token. Left {@code null} for software keystores, which use the default provider.
*/
@Setter private Provider signingProvider;
/**
* Specifies whether the external signing scenario should be used. If set to {@code true},
* external signing will be performed and {@link SignatureInterface} will be used for signing.
@@ -80,25 +89,48 @@ public abstract class CreateSignatureBase implements SignatureInterface {
NoSuchAlgorithmException,
IOException,
CertificateException {
// grabs the first alias from the keystore and get the private key. An
// alternative method or constructor could be used for setting a specific
// alias that should be used.
this(keystore, pin, null);
}
/**
* Initialize the signature creator, optionally selecting a specific certificate by alias. A
* hardware token / the Windows store can hold several certificates, so the caller picks one;
* when {@code requestedAlias} is null the first usable entry is used (software keystore
* behaviour).
*
* @param keystore the keystore (software, PKCS#11 or Windows-MY)
* @param pin the keystore / token PIN, may be null for the Windows store
* @param requestedAlias the alias to sign with, or null to pick the first usable entry
*/
public CreateSignatureBase(KeyStore keystore, char[] pin, String requestedAlias)
throws KeyStoreException,
UnrecoverableKeyException,
NoSuchAlgorithmException,
IOException,
CertificateException {
if (requestedAlias != null
&& !requestedAlias.isBlank()
&& keystore.containsAlias(requestedAlias)) {
privateKey = (PrivateKey) keystore.getKey(requestedAlias, pin);
certificateChain = resolveChain(keystore, requestedAlias);
if (certificateChain == null) {
throw new IOException("Could not find certificate for alias " + requestedAlias);
}
checkValidity(certificateChain[0]);
return;
}
// grabs the first alias from the keystore and gets the private key.
Enumeration<String> aliases = keystore.aliases();
String alias;
Certificate cert = null;
while (cert == null && aliases.hasMoreElements()) {
alias = aliases.nextElement();
String alias = aliases.nextElement();
privateKey = (PrivateKey) keystore.getKey(alias, pin);
Certificate[] certChain = keystore.getCertificateChain(alias);
Certificate[] certChain = resolveChain(keystore, alias);
if (certChain != null) {
certificateChain = certChain;
cert = certChain[0];
if (cert instanceof X509Certificate) {
// avoid expired certificate
((X509Certificate) cert).checkValidity();
//// SigUtils.checkCertificateUsage((X509Certificate) cert);
}
checkValidity(cert);
}
}
@@ -107,6 +139,27 @@ public abstract class CreateSignatureBase implements SignatureInterface {
}
}
/**
* Resolve the certificate chain for an alias. PKCS#11 tokens and the Windows store frequently
* expose only the leaf certificate (a null chain), so fall back to the single certificate.
*/
private static Certificate[] resolveChain(KeyStore keystore, String alias)
throws KeyStoreException {
Certificate[] chain = keystore.getCertificateChain(alias);
if (chain != null && chain.length > 0) {
return chain;
}
Certificate single = keystore.getCertificate(alias);
return single != null ? new Certificate[] {single} : null;
}
private static void checkValidity(Certificate cert) throws CertificateException {
if (cert instanceof X509Certificate x509Cert) {
// avoid expired certificate
x509Cert.checkValidity();
}
}
public final void setPrivateKey(PrivateKey privateKey) {
this.privateKey = privateKey;
}
@@ -136,12 +189,18 @@ public abstract class CreateSignatureBase implements SignatureInterface {
try {
CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
X509Certificate cert = (X509Certificate) certificateChain[0];
ContentSigner sha1Signer =
new JcaContentSignerBuilder("SHA256WithRSA").build(privateKey);
JcaContentSignerBuilder signerBuilder =
new JcaContentSignerBuilder(resolveSignatureAlgorithm(privateKey, cert));
// Hardware keys (PKCS#11 / Windows store) must sign on their own provider so the
// operation runs on the token; software keys use the default provider.
if (signingProvider != null) {
signerBuilder.setProvider(signingProvider);
}
ContentSigner signer = signerBuilder.build(privateKey);
gen.addSignerInfoGenerator(
new JcaSignerInfoGeneratorBuilder(
new JcaDigestCalculatorProviderBuilder().build())
.build(sha1Signer, cert));
.build(signer, cert));
gen.addCertificates(new JcaCertStore(Arrays.asList(certificateChain)));
CMSProcessableInputStream msg = new CMSProcessableInputStream(content);
CMSSignedData signedData = gen.generate(msg, false);
@@ -157,4 +216,26 @@ public abstract class CreateSignatureBase implements SignatureInterface {
throw new IOException(e);
}
}
/**
* Pick a SHA-256 signature algorithm that matches the key type. RSA keeps the historical
* default; EC / EdDSA tokens are common, so they are handled too.
*/
private static String resolveSignatureAlgorithm(PrivateKey key, X509Certificate cert) {
String alg = key.getAlgorithm();
if (alg == null || alg.isBlank()) {
alg = cert.getPublicKey().getAlgorithm();
}
alg = alg == null ? "" : alg.toUpperCase(Locale.ROOT);
if (alg.contains("ED25519") || alg.contains("EDDSA")) {
return "Ed25519";
}
if (alg.contains("EC")) { // EC, ECDSA
return "SHA256withECDSA";
}
if (alg.contains("DSA")) {
return "SHA256withDSA";
}
return "SHA256withRSA";
}
}
@@ -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<MultipartFile> 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;
}
}
@@ -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
@@ -83,7 +85,6 @@ public class WebMvcConfig implements WebMvcConfigurer {
"/icons/**",
"/modern-logo/**",
"/classic-logo/**",
"/robots.txt",
"/3rdPartyLicenses.json",
"/pdfjs/**",
"/pdfjs-legacy/**",
@@ -3,9 +3,12 @@ package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
@@ -261,10 +264,19 @@ public class RearrangePagesPDFController {
log.info("newPageOrder = {}", newPageOrder);
log.info("totalPages = {}", totalPages);
// Snapshot the desired pages before mutating the source document's page tree.
// Snapshot desired pages before mutating the tree; clone repeats (e.g. DUPLICATE)
// so each slot is a distinct node, not one PDPage under multiple /Kids.
List<PDPage> newPages = new ArrayList<>(newPageOrder.size());
Set<Integer> seenIndices = new HashSet<>();
for (Integer idx : newPageOrder) {
newPages.add(document.getPage(idx));
PDPage page = document.getPage(idx);
if (!seenIndices.add(idx)) {
// Duplicate index: distinct page node sharing content/resources.
COSDictionary clonedDict = new COSDictionary();
clonedDict.addAll(page.getCOSObject());
page = new PDPage(clonedDict);
}
newPages.add(page);
}
// Rearrange in-place on the source document rather than copying pages into a
@@ -22,6 +22,7 @@ import stirling.software.SPDF.config.InitialSetup;
import stirling.software.SPDF.controller.api.security.TimestampController;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.configuration.interfaces.ShowAdminInterface;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.common.service.UserServiceInterface;
@@ -37,6 +38,7 @@ public class ConfigController {
private final EndpointConfiguration endpointConfiguration;
private final ServerCertificateServiceInterface serverCertificateService;
private final UserServiceInterface userService;
private final ShowAdminInterface showAdmin;
private final stirling.software.common.service.LicenseServiceInterface licenseService;
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
@@ -48,6 +50,8 @@ public class ConfigController {
ServerCertificateServiceInterface serverCertificateService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
UserServiceInterface userService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
ShowAdminInterface showAdmin,
@org.springframework.beans.factory.annotation.Autowired(required = false)
stirling.software.common.service.LicenseServiceInterface licenseService,
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
@@ -56,6 +60,7 @@ public class ConfigController {
this.endpointConfiguration = endpointConfiguration;
this.serverCertificateService = serverCertificateService;
this.userService = userService;
this.showAdmin = showAdmin;
this.licenseService = licenseService;
this.externalAppDepConfig = externalAppDepConfig;
}
@@ -315,6 +320,10 @@ public class ConfigController {
configData.put(
"enableAlphaFunctionality",
applicationProperties.getSystem().isEnableAlphaFunctionality());
boolean shouldShowUpdate =
applicationProperties.getSystem().isShowUpdate()
&& (showAdmin == null || showAdmin.getShowUpdateOnlyAdmins());
configData.put("shouldShowUpdate", shouldShowUpdate);
configData.put(
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog());
@@ -341,6 +350,18 @@ public class ConfigController {
"serverCertificateEnabled",
serverCertificateService != null && serverCertificateService.isEnabled());
// Hardware-backed signing (Windows store / USB PKCS#11 tokens) is only viable on the
// desktop bundle, where the backend runs locally in the user's session. The Tauri
// bundle signals this via STIRLING_PDF_TAURI_MODE (machineType is Server-jar there);
// the bare-jar desktop launcher signals it via a Client-* machineType.
boolean hardwareSigningAvailable =
Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"));
if (!hardwareSigningAvailable && applicationContext.containsBean("machineType")) {
String mt = applicationContext.getBean("machineType", String.class);
hardwareSigningAvailable = mt != null && mt.startsWith("Client-");
}
configData.put("hardwareSigningAvailable", hardwareSigningAvailable);
// Legal settings
configData.put(
"termsAndConditions", applicationProperties.getLegal().getTermsAndConditions());
@@ -0,0 +1,49 @@
package stirling.software.SPDF.controller.api.misc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.service.LoginAgreementService;
/**
* Serves the login agreement / disclaimer for the frontend. Shares the /api/v1/config access rules:
* it requires authentication when login is enabled (the modal is shown after login, never on the
* login screen) and is permit-all in anonymous/no-login mode and in SaaS. The text is read live
* from disk, so admin edits take effect on the next login without a restart.
*/
@ConfigApi
@Hidden
@RequiredArgsConstructor
public class LoginDisclaimerController {
private final LoginAgreementService loginAgreementService;
@GetMapping("/login-disclaimer")
@Operation(
summary = "Get the login agreement/disclaimer",
description =
"Returns whether the login agreement is enabled and, if so, the markdown to"
+ " display for the requested language.")
public LoginDisclaimerResponse getLoginDisclaimer(
@RequestParam(name = "lang", required = false) String lang) {
boolean showInAnonymousMode = loginAgreementService.isShowInAnonymousMode();
if (!loginAgreementService.isEnabled()) {
return new LoginDisclaimerResponse(false, showInAnonymousMode, "", "markdown");
}
String content = loginAgreementService.resolveContent(lang);
// Enabled but no resolvable text (no file for any candidate locale and no fallbackText):
// report disabled so clients don't try to render an empty agreement.
boolean hasContent = content != null && !content.isBlank();
return new LoginDisclaimerResponse(
hasContent, showInAnonymousMode, hasContent ? content : "", "markdown");
}
public record LoginDisclaimerResponse(
boolean enabled, boolean showInAnonymousMode, String content, String format) {}
}
@@ -70,10 +70,13 @@ import io.micrometer.common.util.StringUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
import stirling.software.SPDF.service.HardwareKeyStoreService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -109,14 +112,17 @@ public class CertSignController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ServerCertificateServiceInterface serverCertificateService;
private final TempFileManager tempFileManager;
private final HardwareKeyStoreService hardwareKeyStoreService;
public CertSignController(
CustomPDFDocumentFactory pdfDocumentFactory,
@Autowired(required = false) ServerCertificateServiceInterface serverCertificateService,
TempFileManager tempFileManager) {
TempFileManager tempFileManager,
HardwareKeyStoreService hardwareKeyStoreService) {
this.pdfDocumentFactory = pdfDocumentFactory;
this.serverCertificateService = serverCertificateService;
this.tempFileManager = tempFileManager;
this.hardwareKeyStoreService = hardwareKeyStoreService;
}
public static void sign(
@@ -170,7 +176,8 @@ public class CertSignController {
"This endpoint accepts a PDF file, a digital certificate and related"
+ " information to sign the PDF. It then returns the digitally signed PDF"
+ " file. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<Resource> signPDFWithCert(@ModelAttribute SignPDFWithCertRequest request)
public ResponseEntity<Resource> signPDFWithCert(
@ModelAttribute SignPDFWithCertRequest request, HttpServletRequest httpRequest)
throws Exception {
MultipartFile pdf = request.getFileInput();
String certType = request.getCertType();
@@ -196,6 +203,8 @@ public class CertSignController {
KeyStore ks = null;
String keystorePassword = password;
Provider signingProvider = null;
HardwareKeyStoreService.Pkcs11Session pkcs11Session = null;
switch (certType) {
case "PEM":
@@ -245,6 +254,31 @@ public class CertSignController {
ks = serverCertificateService.getServerKeyStore();
keystorePassword = serverCertificateService.getServerCertificatePassword();
break;
case "WINDOWS_STORE":
hardwareKeyStoreService.assertLocalDesktop(httpRequest);
ks = hardwareKeyStoreService.loadWindowsKeyStore();
signingProvider = hardwareKeyStoreService.windowsProvider();
// PIN is prompted by the Windows CSP / token middleware, not passed here.
keystorePassword = password;
break;
case "PKCS11":
hardwareKeyStoreService.assertLocalDesktop(httpRequest);
char[] pkcs11Pin = password != null ? password.toCharArray() : null;
try {
pkcs11Session =
hardwareKeyStoreService.openPkcs11(
request.getPkcs11LibraryPath(),
request.getPkcs11Slot(),
pkcs11Pin);
} finally {
if (pkcs11Pin != null) {
java.util.Arrays.fill(pkcs11Pin, '\0');
}
}
ks = pkcs11Session.keyStore();
signingProvider = pkcs11Session.provider();
keystorePassword = password;
break;
default:
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
@@ -252,7 +286,9 @@ public class CertSignController {
"certificate type: " + certType);
}
CreateSignature createSignature = new CreateSignature(ks, keystorePassword.toCharArray());
char[] pin = keystorePassword != null ? keystorePassword.toCharArray() : null;
CreateSignature createSignature =
new CreateSignature(ks, pin, request.getAlias(), signingProvider);
TempFile signedOut = tempFileManager.createManagedTempFile(".pdf");
try (OutputStream os = new FileOutputStream(signedOut.getFile())) {
sign(
@@ -269,6 +305,14 @@ public class CertSignController {
} catch (IOException e) {
signedOut.close();
throw e;
} finally {
// Clear the PIN copy and log out the token session once signing is done.
if (pin != null) {
java.util.Arrays.fill(pin, '\0');
}
if (pkcs11Session != null) {
pkcs11Session.close();
}
}
// Return the signed PDF
return WebResponseUtils.pdfFileToWebResponse(
@@ -324,7 +368,22 @@ public class CertSignController {
NoSuchAlgorithmException,
IOException,
CertificateException {
super(keystore, pin);
this(keystore, pin, null, null);
}
public CreateSignature(
KeyStore keystore, char[] pin, String alias, Provider signingProvider)
throws KeyStoreException,
UnrecoverableKeyException,
NoSuchAlgorithmException,
IOException,
CertificateException {
super(keystore, pin, alias);
setSigningProvider(signingProvider);
loadLogo();
}
private void loadLogo() throws IOException {
ClassPathResource resource = new ClassPathResource("static/images/signature.png");
try (InputStream is = resource.getInputStream()) {
logoFile = Files.createTempFile("signature", ".png").toFile();
@@ -305,6 +305,23 @@ public class GetInfoOnPDF {
}
}
/**
* Info-dictionary keys exposed above via typed getters; any other key in the dictionary is
* surfaced as custom metadata (e.g. the classification policy's StirlingPDFClassification
* entry).
*/
private static final java.util.Set<String> STANDARD_INFO_KEYS =
java.util.Set.of(
"Title",
"Author",
"Subject",
"Keywords",
"Producer",
"Creator",
"CreationDate",
"ModDate",
"Trapped");
private static ObjectNode extractMetadata(PDDocument document) {
ObjectNode metadata = objectMapper.createObjectNode();
@@ -335,6 +352,18 @@ public class GetInfoOnPDF {
if (modificationDate != null) {
metadata.put("ModificationDate", modificationDate);
}
// Surface custom Info-dictionary entries (anything beyond the
// standard fields above) — e.g. StirlingPDFClassification
for (String key : info.getMetadataKeys()) {
if (STANDARD_INFO_KEYS.contains(key)) {
continue;
}
String value = info.getCustomMetadataValue(key);
if (value != null && !value.isBlank()) {
metadata.put(key, value);
}
}
}
} catch (Exception e) {
log.error("Error extracting metadata: {}", e.getMessage());
@@ -0,0 +1,85 @@
package stirling.software.SPDF.controller.api.security;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.HardwareCertificateInfo;
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
import stirling.software.SPDF.model.api.security.Pkcs11CertificatesRequest;
import stirling.software.SPDF.service.HardwareKeyStoreService;
/**
* Lets the desktop frontend discover which hardware-backed signing options the local backend can
* reach (Windows certificate store, plugged-in USB / PKCS#11 tokens) and enumerate the certificates
* available to sign with. Enumeration endpoints are restricted to the desktop bundle, reached over
* loopback - see {@link HardwareKeyStoreService#assertLocalDesktop}.
*/
@RestController
@RequestMapping("/api/v1/security/cert-sign/hardware")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Security", description = "Security APIs")
public class HardwareSigningController {
private final HardwareKeyStoreService hardwareKeyStoreService;
@GetMapping("/capabilities")
@Operation(
summary = "Hardware signing capabilities",
description =
"Reports whether hardware-backed signing is available on this device and which"
+ " PKCS#11 driver libraries were detected. Returns desktop=false when"
+ " not running as the desktop app.")
public ResponseEntity<HardwareSigningCapabilities> getCapabilities() {
return ResponseEntity.ok(hardwareKeyStoreService.capabilities());
}
@GetMapping("/windows-certificates")
@Operation(
summary = "List Windows certificate store signing certificates",
description =
"Enumerates certificates with a usable private key from the current user's"
+ " Windows certificate store. Desktop-only, loopback-only.")
public ResponseEntity<List<HardwareCertificateInfo>> getWindowsCertificates(
HttpServletRequest request) throws Exception {
hardwareKeyStoreService.assertLocalDesktop(request);
return ResponseEntity.ok(hardwareKeyStoreService.listWindowsCertificates());
}
@PostMapping("/pkcs11-certificates")
@Operation(
summary = "List PKCS#11 token signing certificates",
description =
"Logs into a PKCS#11 token with the supplied PIN and enumerates its signing"
+ " certificates. The PIN is used only for this call. Desktop-only,"
+ " loopback-only.")
public ResponseEntity<List<HardwareCertificateInfo>> getPkcs11Certificates(
HttpServletRequest request, @RequestBody Pkcs11CertificatesRequest body)
throws Exception {
hardwareKeyStoreService.assertLocalDesktop(request);
char[] pin = body.pin() != null ? body.pin().toCharArray() : null;
try {
return ResponseEntity.ok(
hardwareKeyStoreService.listPkcs11Certificates(
body.libraryPath(), body.slot(), pin));
} finally {
if (pin != null) {
java.util.Arrays.fill(pin, '\0');
}
}
}
}
@@ -102,8 +102,27 @@ public class ValidateSignatureController {
try (PDDocument document = pdfDocumentFactory.load(file.getInputStream())) {
List<PDSignature> signatures = document.getSignatureDictionaries();
// Detect content appended outside every signature's ByteRange (added after signing). A
// properly signed document has its last signature cover all the way to EOF; if the
// furthest any signature reaches stops short of the file length, the tail is unsigned.
// Taking the max across all signatures avoids false positives on legitimately
// multi-signed PDFs, where an earlier signature intentionally omits later revisions.
long fileLength = file.getSize();
long maxCovered = 0;
for (PDSignature sig : signatures) {
int[] byteRange = sig.getByteRange();
if (byteRange != null && byteRange.length == 4) {
long end = (long) byteRange[2] + byteRange[3];
if (end > maxCovered) {
maxCovered = end;
}
}
}
boolean documentCovered = maxCovered <= 0 || maxCovered >= fileLength;
for (PDSignature sig : signatures) {
SignatureValidationResult result = new SignatureValidationResult();
result.setCoversEntireDocument(documentCovered);
try {
byte[] signedContent = sig.getSignedContent(file.getInputStream());
@@ -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<String> 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<String> serveTauriAuthCallback(HttpServletRequest request) {
// cachedCallbackHtml is always initialized in @PostConstruct
@@ -0,0 +1,30 @@
package stirling.software.SPDF.controller.web;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import stirling.software.common.model.ApplicationProperties;
/**
* Serves /robots.txt dynamically so the system.googlevisibility flag actually controls
* search-engine indexing. 'true' returns an allow-all policy; 'false' returns a disallow-all policy
* to keep the instance out of search engines (useful for embedded/internal deployments).
*/
@RestController
public class RobotsController {
private final ApplicationProperties applicationProperties;
public RobotsController(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
@GetMapping(value = "/robots.txt", produces = MediaType.TEXT_PLAIN_VALUE)
@ResponseBody
public String robotsTxt() {
boolean allowIndexing = applicationProperties.getSystem().isGooglevisibility();
return "User-agent: *\n" + (allowIndexing ? "Allow: /\n" : "Disallow: /\n");
}
}
@@ -0,0 +1,21 @@
package stirling.software.SPDF.model.api.security;
/**
* Metadata for a single signing certificate discovered on a hardware source (Windows certificate
* store or a PKCS#11 token). Returned to the desktop frontend so the user can pick which
* certificate to sign with. Never carries private key material - signing always happens on the
* token / OS.
*/
public record HardwareCertificateInfo(
String alias,
String source,
String subject,
String issuer,
String subjectCommonName,
String issuerCommonName,
String serialNumber,
String keyAlgorithm,
String notBefore,
String notAfter,
boolean expired,
boolean notYetValid) {}
@@ -0,0 +1,19 @@
package stirling.software.SPDF.model.api.security;
import java.util.List;
/**
* Describes what hardware-backed signing the local backend can offer. Only meaningful on the
* desktop bundle, where the backend runs as a local sidecar in the signed-in user's session and can
* reach the Windows certificate store / a plugged-in USB PKCS#11 token.
*/
public record HardwareSigningCapabilities(
boolean desktop,
String osName,
boolean windowsStoreSupported,
boolean pkcs11Supported,
List<Pkcs11LibraryInfo> detectedLibraries) {
/** A PKCS#11 driver library detected on disk (or supplied via configuration). */
public record Pkcs11LibraryInfo(String name, String path) {}
}
@@ -0,0 +1,7 @@
package stirling.software.SPDF.model.api.security;
/**
* Request body for enumerating the certificates on a PKCS#11 token. The PIN is required to log into
* the token; it is used only for the duration of the call and never stored.
*/
public record Pkcs11CertificatesRequest(String libraryPath, Integer slot, String pin) {}
@@ -14,8 +14,10 @@ import stirling.software.common.model.api.PDFFile;
public class SignPDFWithCertRequest extends PDFFile {
@Schema(
description = "The type of the digital certificate",
allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER"},
description =
"The type of the digital certificate. WINDOWS_STORE and PKCS11 are"
+ " hardware-backed and only available in the desktop app.",
allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER", "WINDOWS_STORE", "PKCS11"},
requiredMode = Schema.RequiredMode.REQUIRED)
private String certType;
@@ -39,9 +41,31 @@ public class SignPDFWithCertRequest extends PDFFile {
@Schema(description = "The JKS keystore file (Java Key Store)")
private MultipartFile jksFile;
@Schema(description = "The password for the keystore or the private key", format = "password")
@Schema(
description =
"The password for the keystore / private key, or the token PIN for PKCS11",
format = "password")
private String password;
@Schema(
description =
"The alias of the certificate to sign with. Required for WINDOWS_STORE and"
+ " recommended for PKCS11 tokens holding multiple certificates.")
private String alias;
@Schema(
description =
"Absolute path to the PKCS#11 driver library (required for PKCS11 type). Must"
+ " be an allowed driver - a detected one or configured via"
+ " STIRLING_PKCS11_LIBRARIES.")
private String pkcs11LibraryPath;
@Schema(
description =
"Optional PKCS#11 slot index. When omitted the first slot with a token is"
+ " used.")
private Integer pkcs11Slot;
@Schema(
description = "Whether to visually show the signature in the PDF file",
defaultValue = "false",
@@ -18,6 +18,11 @@ public class SignatureValidationResult {
// Time validation
private boolean notExpired;
// Whether the document's signatures cover all of its bytes. False when content was appended
// outside every signature's ByteRange (i.e. added after signing), which the signature can't
// attest to even though the signed bytes themselves remain cryptographically intact.
private boolean coversEntireDocument = true;
// Revocation validation
private boolean revocationChecked; // true if PKIX revocation was enabled
private String revocationStatus; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
@@ -115,7 +115,8 @@ public class CertificateValidationService {
log.info("Enabled AIA certificate fetching and revocation checking");
}
// Trust only what we explicitly opt into:
// Trust only what we explicitly opt into. Desktop follows the same flags as the server -
// our own signing cert is trusted via serverAsAnchor, not by force-loading every system CA.
if (validation.getTrust().isServerAsAnchor()) loadServerCertAsAnchor();
if (validation.getTrust().isUseSystemTrust()) loadJavaSystemTrustStore();
if (validation.getTrust().isUseMozillaBundle()) loadBundledMozillaCACerts();
@@ -0,0 +1,483 @@
package stirling.software.SPDF.service;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.Provider;
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.Enumeration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import javax.security.auth.x500.X500Principal;
import org.bouncycastle.asn1.x500.RDN;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x500.style.BCStyle;
import org.bouncycastle.asn1.x500.style.IETFUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.HardwareCertificateInfo;
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities.Pkcs11LibraryInfo;
import stirling.software.common.util.ExceptionUtils;
/**
* Bridges PDF signing to hardware-held keys: the Windows certificate store (via the JDK SunMSCAPI
* provider) and USB / smart-card PKCS#11 tokens (via SunPKCS11). The private key never leaves the
* token - the JCA routes the actual signing operation onto the hardware.
*
* <p>These code paths are gated to the desktop bundle. On a hosted server the backend cannot reach
* a remote user's USB token anyway, and loading an arbitrary PKCS#11 driver library is effectively
* native code execution, so PKCS#11 libraries are additionally restricted to an allowlist of
* detected / configured driver paths.
*/
@Service
@Slf4j
public class HardwareKeyStoreService {
public static final String SOURCE_WINDOWS_STORE = "WINDOWS_STORE";
public static final String SOURCE_PKCS11 = "PKCS11";
private static final String WINDOWS_KEYSTORE_TYPE = "Windows-MY";
private static final String MSCAPI_PROVIDER = "SunMSCAPI";
private static final String PKCS11_BASE_PROVIDER = "SunPKCS11";
/** Extra PKCS#11 driver libraries, absolute paths, comma/`File.pathSeparator` separated. */
private static final String PKCS11_LIBRARIES_ENV = "STIRLING_PKCS11_LIBRARIES";
/** Same as {@link #PKCS11_LIBRARIES_ENV} but as a JVM system property. */
private static final String PKCS11_LIBRARIES_PROP = "stirling.pkcs11.libraries";
private final String machineType;
public HardwareKeyStoreService(
@Autowired(required = false) @Qualifier("machineType") String machineType) {
this.machineType = machineType;
}
// ---------------------------------------------------------------------
// Gating
// ---------------------------------------------------------------------
/**
* True when running as the desktop bundle (local sidecar in the user's session). The Tauri
* bundle sets {@code STIRLING_PDF_TAURI_MODE=true} (with {@code BROWSER_OPEN=false}, so
* machineType is {@code Server-jar} there); the bare-jar desktop launcher instead yields a
* {@code Client-*} machineType. Accept either.
*/
public boolean isDesktop() {
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
return true;
}
return machineType != null && machineType.startsWith("Client-");
}
public boolean isWindows() {
return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win");
}
private boolean windowsStoreSupported() {
return isWindows() && Security.getProvider(MSCAPI_PROVIDER) != null;
}
private boolean pkcs11Supported() {
return Security.getProvider(PKCS11_BASE_PROVIDER) != null;
}
/** Reject anything that is not the desktop bundle reached over loopback. */
public void assertLocalDesktop(HttpServletRequest request) {
if (!isDesktop()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.hardwareSigningDesktopOnly",
"Hardware-backed signing is only available in the Stirling PDF desktop app");
}
if (request != null && !isLocalRequest(request.getRemoteAddr())) {
throw ExceptionUtils.createIllegalArgumentException(
"error.hardwareSigningLocalOnly",
"Hardware-backed signing can only be used from this device");
}
}
/**
* True when the request originates from this machine. Loopback (incl. IPv4-mapped IPv6 like
* {@code ::ffff:127.0.0.1}) counts, as does any address bound to a local interface - so it
* works whether the desktop app reaches the sidecar over {@code localhost} or a LAN IP, while
* still rejecting other machines on the network.
*/
static boolean isLocalRequest(String remoteAddr) {
if (remoteAddr == null || remoteAddr.isBlank()) {
return false;
}
try {
InetAddress addr = InetAddress.getByName(remoteAddr);
if (addr.isLoopbackAddress() || addr.isAnyLocalAddress()) {
return true;
}
return NetworkInterface.networkInterfaces()
.anyMatch(nif -> nif.inetAddresses().anyMatch(local -> local.equals(addr)));
} catch (Exception e) {
return false;
}
}
// ---------------------------------------------------------------------
// Capabilities
// ---------------------------------------------------------------------
public HardwareSigningCapabilities capabilities() {
boolean desktop = isDesktop();
if (!desktop) {
return new HardwareSigningCapabilities(false, "", false, false, List.of());
}
return new HardwareSigningCapabilities(
true,
System.getProperty("os.name", ""),
windowsStoreSupported(),
pkcs11Supported(),
detectPkcs11Libraries());
}
/**
* Known driver install locations plus any paths configured via {@code
* STIRLING_PKCS11_LIBRARIES}.
*/
public List<Pkcs11LibraryInfo> detectPkcs11Libraries() {
Map<String, List<String>> candidates = new LinkedHashMap<>();
String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
if (os.contains("win")) {
candidates.put(
"OpenSC",
List.of(
"C:\\Program Files\\OpenSC Project\\OpenSC\\pkcs11\\opensc-pkcs11.dll"));
candidates.put(
"YubiKey (ykcs11)",
List.of("C:\\Program Files\\Yubico\\Yubico PIV Tool\\bin\\libykcs11.dll"));
candidates.put("SafeNet eToken", List.of("C:\\Windows\\System32\\eTPKCS11.dll"));
candidates.put(
"Thales/Gemalto IDPrime", List.of("C:\\Windows\\System32\\IDPrimePKCS11.dll"));
candidates.put(
"SoftHSM2",
List.of(
"C:\\Program Files\\SoftHSM2\\lib\\softhsm2-x64.dll",
"C:\\SoftHSM2\\lib\\softhsm2-x64.dll"));
} else if (os.contains("mac")) {
candidates.put(
"OpenSC",
List.of(
"/Library/OpenSC/lib/opensc-pkcs11.so",
"/usr/local/lib/opensc-pkcs11.so"));
candidates.put(
"YubiKey (ykcs11)",
List.of("/usr/local/lib/libykcs11.dylib", "/opt/homebrew/lib/libykcs11.dylib"));
candidates.put(
"SoftHSM2",
List.of(
"/usr/local/lib/softhsm/libsofthsm2.so",
"/opt/homebrew/lib/softhsm/libsofthsm2.so"));
} else {
candidates.put(
"OpenSC",
List.of(
"/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so",
"/usr/lib/opensc-pkcs11.so",
"/usr/lib64/opensc-pkcs11.so"));
candidates.put(
"YubiKey (ykcs11)",
List.of(
"/usr/lib/x86_64-linux-gnu/libykcs11.so",
"/usr/local/lib/libykcs11.so"));
candidates.put(
"SoftHSM2",
List.of(
"/usr/lib/softhsm/libsofthsm2.so",
"/usr/lib64/softhsm/libsofthsm2.so",
"/usr/local/lib/softhsm/libsofthsm2.so"));
}
List<Pkcs11LibraryInfo> result = new ArrayList<>();
candidates.forEach(
(name, paths) ->
paths.stream()
.filter(p -> Files.exists(Path.of(p)))
.findFirst()
.ifPresent(p -> result.add(new Pkcs11LibraryInfo(name, p))));
for (String configured : configuredLibraries()) {
if (Files.exists(Path.of(configured))
&& result.stream().noneMatch(l -> sameFile(l.path(), configured))) {
result.add(new Pkcs11LibraryInfo(fileName(configured), configured));
}
}
return result;
}
private static List<String> configuredLibraries() {
String env = System.getenv(PKCS11_LIBRARIES_ENV);
String prop = System.getProperty(PKCS11_LIBRARIES_PROP);
StringBuilder combined = new StringBuilder();
if (env != null && !env.isBlank()) {
combined.append(env);
}
if (prop != null && !prop.isBlank()) {
if (combined.length() > 0) {
combined.append(java.io.File.pathSeparator);
}
combined.append(prop);
}
if (combined.length() == 0) {
return List.of();
}
return Arrays.stream(combined.toString().split("[,;" + java.io.File.pathSeparator + "]"))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
// ---------------------------------------------------------------------
// Windows certificate store
// ---------------------------------------------------------------------
public KeyStore loadWindowsKeyStore() throws Exception {
if (!windowsStoreSupported()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.windowsStoreUnavailable",
"The Windows certificate store is not available on this platform");
}
KeyStore ks = KeyStore.getInstance(WINDOWS_KEYSTORE_TYPE, MSCAPI_PROVIDER);
ks.load(null, null);
return ks;
}
public Provider windowsProvider() {
return Security.getProvider(MSCAPI_PROVIDER);
}
public List<HardwareCertificateInfo> listWindowsCertificates() throws Exception {
return listSigningCertificates(loadWindowsKeyStore(), SOURCE_WINDOWS_STORE);
}
// ---------------------------------------------------------------------
// PKCS#11 tokens
// ---------------------------------------------------------------------
/**
* A configured, logged-in PKCS#11 keystore plus the provider that must service signing. Closing
* logs the session out so the PIN-authenticated session does not outlive the request. The
* provider stays cached (logout is C_Logout, not C_Finalize) so the next call reuses the same
* C_Initialize. Single-user desktop model - logout is best-effort.
*/
public record Pkcs11Session(KeyStore keyStore, Provider provider) implements AutoCloseable {
@Override
public void close() {
if (provider instanceof java.security.AuthProvider authProvider) {
try {
authProvider.logout();
} catch (Exception e) {
// Not logged in / already logged out - nothing to clear.
}
}
}
}
// One SunPKCS11 provider per driver+slot, reused across enumerate + sign. A PKCS#11 module
// typically allows C_Initialize only once per process, so configuring a fresh provider on every
// call races with the previous (not-yet-GC'd) one - the cause of "first sign fails, second
// works". Reusing the provider keeps a single C_Initialize alive for the session.
private final java.util.concurrent.ConcurrentHashMap<String, Provider> pkcs11Providers =
new java.util.concurrent.ConcurrentHashMap<>();
public Pkcs11Session openPkcs11(String libraryPath, Integer slot, char[] pin) throws Exception {
validateLibraryAllowed(libraryPath);
if (!pkcs11Supported()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pkcs11Unavailable", "PKCS#11 support is not available in this runtime");
}
String cacheKey = libraryPath + "|" + slot;
Provider provider =
pkcs11Providers.computeIfAbsent(
cacheKey, k -> buildPkcs11Provider(libraryPath, slot));
try {
KeyStore ks = KeyStore.getInstance("PKCS11", provider);
ks.load(null, pin);
return new Pkcs11Session(ks, provider);
} catch (Exception e) {
// A wrong PIN must not be retried: a second C_Login would burn the token's retry
// counter twice per attempt and can lock the token. Only rebuild on provider/init
// failures (e.g. token removed/re-inserted leaving a stale provider).
if (isAuthFailure(e)) {
throw e;
}
pkcs11Providers.remove(cacheKey, provider);
Provider fresh =
pkcs11Providers.computeIfAbsent(
cacheKey, k -> buildPkcs11Provider(libraryPath, slot));
KeyStore ks = KeyStore.getInstance("PKCS11", fresh);
ks.load(null, pin);
return new Pkcs11Session(ks, fresh);
}
}
/** True when the failure is a bad/locked PIN rather than a provider/init/device problem. */
private static boolean isAuthFailure(Throwable t) {
while (t != null) {
if (t instanceof javax.security.auth.login.FailedLoginException) {
return true;
}
String msg = t.getMessage();
if (msg != null && msg.toUpperCase(Locale.ROOT).contains("CKR_PIN")) {
return true; // CKR_PIN_INCORRECT / CKR_PIN_LOCKED / CKR_PIN_INVALID / ...
}
t = t.getCause();
}
return false;
}
private Provider buildPkcs11Provider(String libraryPath, Integer slot) {
StringBuilder config = new StringBuilder();
config.append("--name=").append(providerName(libraryPath)).append('\n');
config.append("library=").append(libraryPath).append('\n');
if (slot != null) {
config.append("slot=").append(slot).append('\n');
}
try {
return Security.getProvider(PKCS11_BASE_PROVIDER).configure(config.toString());
} catch (Exception e) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pkcs11ConfigFailed",
"Failed to initialise the PKCS#11 driver: {0}",
e.getMessage());
}
}
public List<HardwareCertificateInfo> listPkcs11Certificates(
String libraryPath, Integer slot, char[] pin) throws Exception {
try (Pkcs11Session session = openPkcs11(libraryPath, slot, pin)) {
return listSigningCertificates(session.keyStore(), SOURCE_PKCS11);
}
}
/**
* Reject driver paths that are not detected on disk / configured - blocks arbitrary DLL loads.
*/
public void validateLibraryAllowed(String libraryPath) {
if (libraryPath == null || libraryPath.isBlank()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pkcs11LibraryRequired", "A PKCS#11 driver library path is required");
}
Set<String> allowed =
detectPkcs11Libraries().stream()
.map(Pkcs11LibraryInfo::path)
.collect(Collectors.toSet());
boolean ok = allowed.stream().anyMatch(p -> sameFile(p, libraryPath));
if (!ok) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pkcs11LibraryNotAllowed",
"PKCS#11 driver is not in the allowed list. Add it via the"
+ " STIRLING_PKCS11_LIBRARIES setting: {0}",
libraryPath);
}
}
// ---------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------
private List<HardwareCertificateInfo> listSigningCertificates(KeyStore ks, String source)
throws Exception {
List<HardwareCertificateInfo> certs = new ArrayList<>();
Enumeration<String> aliases = ks.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
if (!ks.isKeyEntry(alias)) {
continue; // only entries we can sign with
}
Certificate cert = ks.getCertificate(alias);
if (cert instanceof X509Certificate x509) {
certs.add(toInfo(alias, x509, source));
}
}
return certs;
}
private static HardwareCertificateInfo toInfo(
String alias, X509Certificate cert, String source) {
java.util.Date now = new java.util.Date();
return new HardwareCertificateInfo(
alias,
source,
cert.getSubjectX500Principal().getName(),
cert.getIssuerX500Principal().getName(),
commonName(cert.getSubjectX500Principal()),
commonName(cert.getIssuerX500Principal()),
cert.getSerialNumber().toString(16),
cert.getPublicKey().getAlgorithm(),
cert.getNotBefore().toInstant().toString(),
cert.getNotAfter().toInstant().toString(),
now.after(cert.getNotAfter()),
now.before(cert.getNotBefore()));
}
private static String commonName(X500Principal principal) {
try {
X500Name x500Name = new X500Name(principal.getName());
RDN[] rdns = x500Name.getRDNs(BCStyle.CN);
if (rdns.length > 0) {
return IETFUtils.valueToString(rdns[0].getFirst().getValue());
}
} catch (Exception e) {
log.debug("Could not parse common name from {}", principal.getName());
}
return principal.getName();
}
private static String providerName(String libraryPath) {
String base = fileName(libraryPath).replaceAll("[^a-zA-Z0-9]", "");
if (base.isEmpty()) {
base = "token";
}
return "StirlingHW" + base;
}
private static String fileName(String path) {
try {
return Path.of(path).getFileName().toString();
} catch (Exception e) {
return path;
}
}
private static boolean sameFile(String a, String b) {
if (a == null || b == null) {
return false;
}
try {
Path pa = Path.of(a);
Path pb = Path.of(b);
if (Files.exists(pa) && Files.exists(pb)) {
return Files.isSameFile(pa, pb);
}
return pa.toAbsolutePath().normalize().equals(pb.toAbsolutePath().normalize());
} catch (Exception e) {
return a.equalsIgnoreCase(b);
}
}
}
@@ -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<String, Object> 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;
}
}
@@ -45,6 +45,10 @@ public class SvgOverlayUtil {
@Override
public void checkLoadExternalResource(
ParsedURL resourceURL, ParsedURL docURL) {
// Inline data: URIs are self-contained (no network/file fetch).
if (resourceURL != null && "data".equals(resourceURL.getProtocol())) {
return;
}
throw new SecurityException(
"External resource loading is disabled for SVG overlays: "
+ resourceURL);
@@ -19,6 +19,7 @@ import org.apache.batik.bridge.GVTBuilder;
import org.apache.batik.bridge.UserAgent;
import org.apache.batik.bridge.UserAgentAdapter;
import org.apache.batik.gvt.GraphicsNode;
import org.apache.batik.util.ParsedURL;
import org.apache.batik.util.XMLResourceDescriptor;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
@@ -63,7 +64,7 @@ public class SvgToPdf {
}
// 2. Build the GVT (Graphics Vector Tree) with timeout protection
UserAgent userAgent = new UserAgentAdapter();
UserAgent userAgent = createSecureUserAgent();
DocumentLoader loader = new DocumentLoader(userAgent);
BridgeContext ctx = new BridgeContext(userAgent, loader);
ctx.setDynamicState(BridgeContext.DYNAMIC);
@@ -94,6 +95,21 @@ public class SvgToPdf {
}
}
private UserAgent createSecureUserAgent() {
return new UserAgentAdapter() {
@Override
public void checkLoadExternalResource(ParsedURL resourceURL, ParsedURL docURL) {
// Inline data: URIs are self-contained (no network/file fetch) - allow them.
if (resourceURL != null && "data".equals(resourceURL.getProtocol())) {
return;
}
throw new SecurityException(
"External resource loading is disabled for SVG to PDF conversion: "
+ resourceURL);
}
};
}
private GraphicsNode buildGvtWithTimeout(BridgeContext ctx, SVGDocument svgDoc)
throws IOException {
GVTBuilder builder = new GVTBuilder();
@@ -202,7 +218,7 @@ public class SvgToPdf {
svgDoc = factory.createSVGDocument("file:///input.svg", inputStream);
}
UserAgent userAgent = new UserAgentAdapter();
UserAgent userAgent = createSecureUserAgent();
DocumentLoader loader = new DocumentLoader(userAgent);
BridgeContext ctx = new BridgeContext(userAgent, loader);
ctx.setDynamicState(BridgeContext.DYNAMIC);
@@ -62,8 +62,6 @@ security:
# IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId}
# Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration.
jwt: # This feature is currently under development and not yet fully supported. Do not use in production.
persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes for web clients (1 day).
desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days).
@@ -141,10 +139,10 @@ telegram:
botUsername: "" # Telegram bot username (without @)
pipelineInboxFolder: telegram # Name of the pipeline inbox folder for Telegram uploads
customFolderSuffix: true # set to 'true' to allow users to specify custom target folders via UserID
enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs
allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users.
enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs
allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels.
enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs. NOTE: only takes effect when allowUserIDs is non-empty; with an empty list every user is still allowed even when this is 'true'
allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users (the enableAllowUserIDs toggle has no effect until this list is populated).
enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs. NOTE: only takes effect when allowChannelIDs is non-empty; with an empty list every channel is still allowed even when this is 'true'
allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels (the enableAllowChannelIDs toggle has no effect until this list is populated).
processingTimeoutSeconds: 180 # Maximum time in seconds to wait for processing a Telegram request
pollingIntervalMillis: 2000 # Interval in milliseconds between polling for new messages
feedback:
@@ -165,13 +163,17 @@ legal:
accessibilityStatement: "" # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
cookiePolicy: "" # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
impressum: "" # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
loginAgreement:
enabled: false # set to 'true' to show a login agreement/disclaimer popup after login (and on app launch when login is disabled). Per-language text is read from customFiles/disclaimer/<locale>.md (e.g. en-GB.md, fr-FR.md)
showInAnonymousMode: true # when login is disabled, set to 'false' to suppress the agreement in anonymous (no-login) mode
fallbackText: "" # optional markdown used for any language that has no customFiles/disclaimer/<locale>.md file (also settable via the LEGAL_LOGINAGREEMENT_FALLBACKTEXT env var for single-language headless installs)
system:
defaultLocale: "" # force a default language for new users (e.g. 'en-US', 'de-DE'). Empty string auto-detects from the browser, falling back to en-US
googlevisibility: false # 'true' to allow Google visibility (via robots.txt), 'false' to disallow
googlevisibility: false # 'true' serves an allow-all /robots.txt; 'false' serves a disallow-all /robots.txt to keep the instance out of search engines
enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes)
showUpdate: false # see when a new update is available
showUpdateOnlyAdmin: false # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
showUpdate: true # see when a new update is available
showUpdateOnlyAdmin: true # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
showSettingsWhenNoLogin: true # set to 'false' to hide settings button when login is disabled (enableLogin: false). Only applies when login is disabled.
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
tessdataDir: "" # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored.
@@ -182,7 +184,7 @@ system:
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173'
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). WARNING: leaving this empty falls back to allowing ALL origins (with credentials), it does NOT disable CORS. Set explicit origins to lock it down.
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
@@ -193,7 +195,7 @@ system:
stretchToFit: false # Whether to stretch images to fill the entire page (may distort aspect ratio). If false, images are centered with preserved aspect ratio. Only applies when convertToPdf is true.
serverCertificate:
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
organizationName: Stirling-PDF # Organization name for generated certificates
organizationName: Stirling PDF Inc # Organization name for generated certificates
validity: 365 # Certificate validity in days
regenerateOnStartup: false # Generate new certificate on each startup
html:
@@ -300,7 +302,7 @@ autoPipeline:
allowedExtensions: [] # Optional extension allow-list (case-insensitive, without the leading dot). Empty list = accept all extensions. Example: ["pdf", "tiff"]
ui:
appNameNavbar: "" # name displayed on the navigation bar
appNameNavbar: "" # custom app/brand name. NOTE: no longer shown in the navbar (the navbar renders the logo). It IS used as the browser tab title and as the TOTP/2FA issuer label in authenticator apps. Empty falls back to "Stirling PDF"
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
languages: [] # If empty, all languages are enabled. To restrict to specific languages, use a whitelist like ["de_DE", "pl_PL", "sv_SE"]. Empty list or not restricting any languages will enable all available languages.
defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out
@@ -179,6 +179,13 @@
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.code.gson:gson",
"moduleUrl": "https://github.com/google/gson",
"moduleVersion": "2.14.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.errorprone:error_prone_annotations",
"moduleUrl": "https://errorprone.info/error_prone_annotations",
@@ -186,6 +193,13 @@
"moduleLicense": "Apache 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.errorprone:error_prone_annotations",
"moduleUrl": "https://errorprone.info/error_prone_annotations",
"moduleVersion": "2.48.0",
"moduleLicense": "Apache 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.guava:failureaccess",
"moduleUrl": "https://github.com/google/guava/",
@@ -629,13 +643,6 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "commons-io:commons-io",
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
"moduleVersion": "2.21.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "commons-io:commons-io",
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
File diff suppressed because one or more lines are too long
@@ -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<String> 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);
}
}
}
@@ -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);
}
}
@@ -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<SystemCommand> 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<SystemCommand> sys = Mockito.mockStatic(SystemCommand.class);
MockedConstruction<Socket> 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<SystemCommand> sys = Mockito.mockStatic(SystemCommand.class);
MockedConstruction<Socket> 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();
}
}
}
@@ -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");
}
}
@@ -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("/");
}
}
}
@@ -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<String> validEndpoints() throws Exception {
Field field = EndpointInspector.class.getDeclaredField("validGetEndpoints");
field.setAccessible(true);
return (Set<String>) field.get(inspector);
}
private void stubMapping(Map<RequestMappingInfo, HandlerMethod> handlerMethods) {
RequestMappingHandlerMapping mapping = mock(RequestMappingHandlerMapping.class);
when(mapping.getHandlerMethods()).thenReturn(handlerMethods);
Map<String, RequestMappingHandlerMapping> 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<RequestMappingInfo, HandlerMethod> methods = new LinkedHashMap<>();
RequestMappingInfo getInfo =
RequestMappingInfo.paths("/dashboard").methods(RequestMethod.GET).build();
methods.put(getInfo, handlerMethod());
stubMapping(methods);
Set<String> 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<RequestMappingInfo, HandlerMethod> 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<RequestMappingInfo, HandlerMethod> 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<RequestMappingInfo, HandlerMethod> 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<String> 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<String> 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);
}
}
}

Some files were not shown because too many files have changed in this diff Show More