Compare commits

...
Author SHA1 Message Date
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
Anthony Stirling fe7a2a5ac7 Fix Multi Tool page rotation lost on save (#6733)
# Description of Changes

Rotating a page in the Multi Tool and saving could leave the page at its
original rotation (the change appeared lost), with inconsistent results
across pages.

- Page rotation is now always written on export, including 0°, so
rotating a page that already had a non-zero rotation in the source PDF
(e.g. a 270° page rotated back to upright) is no longer dropped.
- Per-page rotation is always read when building the Multi Tool
document, so pages keep their true orientation regardless of file size.
- Rotation is only applied after a page imports successfully, avoiding a
misaligned or failed export when an import fails.

---

## 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)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

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

### 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 14:19:51 +00:00
James Brunton 6a9876a067 Fix more any typing usage in the frontend (#6664)
# Description of Changes
Continued effort to remove the remaining uses of the `any` type from our
TS code. The vast majority of these uses that it cleans up was just
catching errors as `any`, which are pretty simple to fix. I couldn't
completely remove the `any` type usage from `core/tools` because there
were cascading issues from a couple of the files in there (most notably
Automate) but still, moving in the right direction.
2026-06-19 13:37:53 +00:00
James Brunton 3793a6df52 Fix bad frontend architecture (#6730)
# Description of Changes
#6727 introduced frontend code which goes against the architecture, so
this PR re-implements it in the architecture properly, along with
another bad Tauri check that I found in the source. I also updated the
`AGENTS.md` file to use Claude's "read this file" syntax to try and
force AI to actually read the file instead of just suggesting that it
does it.
2026-06-19 12:34:13 +00:00
dependabot[bot]andAnthony Stirling 66841db2b7 build(deps): bump js-yaml from 4.1.1 to 4.2.0 in /devTools (#6680)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md">js-yaml's
changelog</a>.</em></p>
<blockquote>
<h2>[4.2.0] - 2026-06-01</h2>
<h3>Added</h3>
<ul>
<li>Added <code>docs/safety.md</code> with notes about processing
untrusted YAML.</li>
<li>Added <code>maxDepth</code> (100) loader option. Not a problem, but
gives a better
exception instead of RangeError on stack overflow.</li>
<li>Added <code>maxMergeSeqLength</code> (20) loader option. Not a
problem after <code>merge</code> fix,
but an additional restriction for safety.</li>
<li>Added sourcemaps to <code>dist/</code> builds.</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Stop resolving numbers with underscores as numeric scalars, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/627">#627</a>.</li>
<li>Switched dev toolchains to Vite / neostandard.</li>
<li>Updated demo.</li>
<li>Reorganized tests.</li>
<li><code>dist/</code> files are no longer kept in the repository.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Fix parsing of properties on the first implicit block mapping key,
<a
href="https://redirect.github.com/nodeca/js-yaml/issues/62">#62</a>.</li>
<li>Fix trailing whitespace handling when folding flow scalar lines, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Reject top-level block scalars without content indentation, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/280">#280</a>.</li>
<li>Ensure numbers survive round-trip, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/737">#737</a>.</li>
<li>Fix test coverage for issue <a
href="https://redirect.github.com/nodeca/js-yaml/issues/221">#221</a>.</li>
<li>Fix flow scalar trailing whitespace folding, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Fix digits in YAML named tag handles.</li>
</ul>
<h3>Security</h3>
<ul>
<li>Fix potential DoS via quadratic complexity in merge - deduplicate
repeated
elements (makes sense for malformed files &gt; 10K).</li>
</ul>
<h2>[3.14.2] - 2025-11-15</h2>
<h3>Security</h3>
<ul>
<li>Backported v4.1.1 fix to v3</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/nodeca/js-yaml/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=js-yaml&package-manager=npm_and_yarn&previous-version=4.1.1&new-version=4.2.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-19 12:15:23 +00:00
dependabot[bot]andAnthony Stirling 377677c182 build(deps-dev): bump js-yaml from 4.1.1 to 4.2.0 in /frontend (#6677)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0.
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md">js-yaml's
changelog</a>.</em></p>
<blockquote>
<h2>[4.2.0] - 2026-06-01</h2>
<h3>Added</h3>
<ul>
<li>Added <code>docs/safety.md</code> with notes about processing
untrusted YAML.</li>
<li>Added <code>maxDepth</code> (100) loader option. Not a problem, but
gives a better
exception instead of RangeError on stack overflow.</li>
<li>Added <code>maxMergeSeqLength</code> (20) loader option. Not a
problem after <code>merge</code> fix,
but an additional restriction for safety.</li>
<li>Added sourcemaps to <code>dist/</code> builds.</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Stop resolving numbers with underscores as numeric scalars, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/627">#627</a>.</li>
<li>Switched dev toolchains to Vite / neostandard.</li>
<li>Updated demo.</li>
<li>Reorganized tests.</li>
<li><code>dist/</code> files are no longer kept in the repository.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Fix parsing of properties on the first implicit block mapping key,
<a
href="https://redirect.github.com/nodeca/js-yaml/issues/62">#62</a>.</li>
<li>Fix trailing whitespace handling when folding flow scalar lines, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Reject top-level block scalars without content indentation, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/280">#280</a>.</li>
<li>Ensure numbers survive round-trip, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/737">#737</a>.</li>
<li>Fix test coverage for issue <a
href="https://redirect.github.com/nodeca/js-yaml/issues/221">#221</a>.</li>
<li>Fix flow scalar trailing whitespace folding, <a
href="https://redirect.github.com/nodeca/js-yaml/issues/307">#307</a>.</li>
<li>Fix digits in YAML named tag handles.</li>
</ul>
<h3>Security</h3>
<ul>
<li>Fix potential DoS via quadratic complexity in merge - deduplicate
repeated
elements (makes sense for malformed files &gt; 10K).</li>
</ul>
<h2>[3.14.2] - 2025-11-15</h2>
<h3>Security</h3>
<ul>
<li>Backported v4.1.1 fix to v3</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li>See full diff in <a
href="https://github.com/nodeca/js-yaml/commits">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=js-yaml&package-manager=npm_and_yarn&previous-version=4.1.1&new-version=4.2.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-19 12:15:13 +00:00
dependabot[bot]andAnthony Stirling f25f7e5fc9 build(deps): bump dompurify from 3.4.1 to 3.4.11 in /frontend (#6722)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.1 to
3.4.11.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/cure53/DOMPurify/releases">dompurify's
releases</a>.</em></p>
<blockquote>
<h2>DOMPurify 3.4.11</h2>
<ul>
<li>Fixed an issue with a leaky config for hooks via
<code>setConfig</code>, thanks <a
href="https://github.com/trace37labs"><code>@​trace37labs</code></a></li>
<li>Bumped vulnerable development dependencies to arrive at plain 0 with
<code>npm audit</code></li>
<li>Updated the <code>osv-scanner</code> suppression list as no
vulnerable dependencies are left for now</li>
<li>Updated up the linting tool-chain and removed now-redundant lint
directives</li>
<li>Updated the documentation is several spots, README, wiki, etc.</li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.10</h2>
<ul>
<li>Refactored codebase for clarity: extracted the public type
declarations into <code>types.ts</code></li>
<li>Decomposed the three largest sanitizer functions into focused
helpers</li>
<li>Removed duplicated defaults and dead branches, consolidated
<code>SAFE_FOR_TEMPLATES</code> scrubbing into single shared path</li>
<li>Improved per-node performance by hoisting the mXSS probe regexes and
testing <code>textContent</code> before <code>innerHTML</code></li>
<li>Added a deterministic micro-benchmark harness (<code>npm run
bench</code>) with a <code>--compare</code> mode</li>
<li>Reduced CI cost by running the full three-engine browser suite once
per PR</li>
<li>Refreshed the <code>demos/</code> folder so every demo runs again,
and added a SVG-via-<code>&lt;img&gt;</code> demo</li>
<li>Documented the bench and <code>test:happydom</code> scripts in the
README</li>
<li>Completed the Attack Classes &amp; Bypass History wiki page</li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.9</h2>
<ul>
<li>Further improved the handling of Trusted Types config options,
thanks <a
href="https://github.com/offset"><code>@​offset</code></a></li>
<li>Further improved the handling of <code>IN_PLACE</code> sanitization,
thanks <a
href="https://github.com/mozfreddyb"><code>@​mozfreddyb</code></a></li>
<li>Added more test coverage for <code>IN_PLACE</code> and Trusted Types
related usage</li>
<li>Bumped several dependencies where possible</li>
<li>Updated README and wiki with more accurate documentation &amp;
attack samples</li>
</ul>
<h2>DOMPurify 3.4.8</h2>
<ul>
<li>Cleaned up the repository root, renamed some and removed unneeded
files</li>
<li>Fixed an issue with handling of Trusted Types policies, thanks <a
href="https://github.com/fulstadev"><code>@​fulstadev</code></a></li>
<li>Fixed the node iterator for better template scrubbing, thanks <a
href="https://github.com/IamLeandrooooo"><code>@​IamLeandrooooo</code></a></li>
<li>Included formerly missing LICENSE-MPL in published npm package,
thanks <a
href="https://github.com/asamuzaK"><code>@​asamuzaK</code></a></li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.7</h2>
<ul>
<li>Hardened the handling of Shadow Roots when using
<code>IN_PLACE</code>, thanks <a
href="https://github.com/GameZoneHacker"><code>@​GameZoneHacker</code></a></li>
<li>Removed a problem leading to permanent hook pollution, thanks <a
href="https://github.com/offset"><code>@​offset</code></a></li>
<li>Refactored the test suite and expanded test coverage
significantly</li>
</ul>
<h2>DOMPurify 3.4.6</h2>
<ul>
<li>Fixed several issues with DOM Clobbering in <code>IN_PLACE</code>
mode, thanks <a
href="https://github.com/offset"><code>@​offset</code></a> &amp; <a
href="https://github.com/Bankde"><code>@​Bankde</code></a></li>
<li>Hardened the checks for cross-realm <code>IN_PLACE</code> and Shadow
DOM sanitization, thanks <a
href="https://github.com/offset"><code>@​offset</code></a> &amp; <a
href="https://github.com/Bankde"><code>@​Bankde</code></a></li>
<li>Added more test coverage for <code>IN_PLACE</code> and general DOM
Clobbering attacks</li>
<li>Bumped several dependencies where possible</li>
</ul>
<h2>DOMPurify 3.4.5</h2>
<ul>
<li>Fixed a bypass caused by the new HTML element
<code>selectedcontent</code> added in 3.4.4, thanks <a
href="https://github.com/KabirAcharya"><code>@​KabirAcharya</code></a></li>
</ul>
<p><strong>Note that this is a security release for an issue introduced
in 3.4.4 and should be upgraded to immediately.</strong></p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/cure53/DOMPurify/commit/0cae5187403132f96a6d357649e4b15633fc210a"><code>0cae518</code></a>
release: 3.4.11 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1494">#1494</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/6ee5716f8336989753611beeca364957c0eb0c3e"><code>6ee5716</code></a>
release: 3.4.10 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1478">#1478</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/52102472d46035857c52df19e44285f8a1e102fc"><code>5210247</code></a>
release: 3.4.9 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1459">#1459</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/bcdd8285412dc9c4c149652aed2d712e790d6ccf"><code>bcdd828</code></a>
release: 3.4.8 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1439">#1439</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/ca30f070c360df162a3e3848e80e6fd3c9e74bff"><code>ca30f07</code></a>
release: 3.4.7 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1414">#1414</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/bb7739e5bccec7e1ab3dae3f3e42d02db3acaaae"><code>bb7739e</code></a>
release: 3.4.6 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1394">#1394</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/011b0c78f2a0f57ee54f5fcccb697a46ca6e63ea"><code>011b0c7</code></a>
release: 3.4.5 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1382">#1382</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/5817ad969c15e67dfcd6cb37248d6e9c1553e7c3"><code>5817ad9</code></a>
release: 3.4.4 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1374">#1374</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/520edb0371a9638f9b51f1798051299a250c686b"><code>520edb0</code></a>
release: 3.4.3 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1352">#1352</a>)</li>
<li><a
href="https://github.com/cure53/DOMPurify/commit/6f67fd396a7b8c64294343999fe607ca1f5299c0"><code>6f67fd3</code></a>
Sync/3.4.2 (<a
href="https://redirect.github.com/cure53/DOMPurify/issues/1322">#1322</a>)</li>
<li>See full diff in <a
href="https://github.com/cure53/DOMPurify/compare/3.4.1...3.4.11">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=dompurify&package-manager=npm_and_yarn&previous-version=3.4.1&new-version=3.4.11)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-19 12:14:27 +00:00
James Brunton b57958531d Add message when running task with no arguments (#6731)
# Description of Changes
Add message describing the most common tasks when running `task` with no
arguments. I think this should help newcomers because `task --list` is
massive at this point and nobody's going to read through it all. Let me
know if you think any other commands should be in the default message.
2026-06-19 10:44:30 +00:00
Ludy f8ceca0c3f feat(i18n): sync editor translations with pluralization support and new UI strings (#6565)
# Description of Changes

## What was changed

- Updated editor translation files across multiple locales.
- Migrated numerous count-based translation keys from legacy
`{{plural}}` handling to ICU-style plural forms using `_one`, `_other`,
and where applicable `_zero` variants.
- Added translations and localization keys for newly introduced features
and UI areas, including:
  - Stirling Agents
  - Chat interface and quick actions
  - Files management and folder organization
  - Desktop update workflow
  - Folder scanning warnings
  - Team and workspace management
  - Sharing and upload dialogs
  - Comparison status messages
  - Relative time formatting
  - Additional tool panel and update UI strings
- Added missing translation entries required by recently introduced
frontend functionality.
- Reorganized some translation sections to maintain consistency and key
ordering.

## Why the change was made

- To align locale files with the current frontend feature set.
- To support proper pluralization behavior across languages.
- To prevent missing translation keys and fallback text in newly added
UI components.
- To improve localization consistency and maintainability as the
application grows.

---

## 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 10:34:55 +00:00
Anthony Stirling e6d476297d Clean up update dialog UI and fix desktop external links (#6727) 2026-06-18 22:20:02 +01:00
stirlingbot[bot] 3456316569 Update Backend 3rd Party Licenses (#6719)
Auto-generated by stirlingbot[bot]

This PR updates the backend license report based on dependency changes.

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2026-06-18 19:54:02 +00:00
Anthony StirlingandEthanHealy01 215bba39bc Give SaaS users their own team and harden the user list endpoint (#6717)
# Description of Changes

Previously, new SaaS users were placed on a shared Default team and then
migrated to their own. A race (or a failed migration, or an
anonymous→registered upgrade) could leave them stuck on that shared
team, where unrelated users could see each other
Instead they now get their own personal team during creation so
unrelated users no longer collide on one team. SaaS-only
(@Profile("saas")); self-host's Default behaviour is untouched.
Also happens during call to avoid uncaught users

Scope GET /api/v1/user/users. Anonymous callers get 403; a caller on a
system team (Default/Internal) gets only themselves, not the team's
members.

---

## 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.

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-06-18 16:27:22 +00:00
ConnorYohandEthanHealy01 900b66b030 chore(saas): remove dead ErrorTrackingService island + credits path exclusion (#6718)
## What

Residual dead-code cleanup following the credits engine teardown
(#6687).

- **Delete the `ErrorTrackingService` dead island** (6 files): the
service, `UserErrorTrackerRepository`, `UserErrorTracker`,
`ProcessingErrorType`, `CreditsProperties`, and
`ErrorTrackingServiceTest`. These formed a self-referential cluster with
**zero external callers** once the credit machinery was removed.
- **Remove `/api/v1/credits/**`** from both `excludePathPatterns` blocks
in `PaygWebMvcConfig` — the credits controller no longer exists, so the
exclusion is defunct. (spotless collapsed the lists to one line.)

## Verification

- `./gradlew :saas:compileJava :saas:compileTestJava` → **BUILD
SUCCESSFUL**
- grep confirms zero dangling references to the deleted types

## Not in scope (deliberately deferred)

Destructive DB drops
(`user_credits`/`team_credits`/`user_subscription_plans` tables, dead
`payg_shadow_charge` columns, `user_error_tracker` table) are gated
behind the post-release soak (`live_ratio==1.0 ≥7d`) and tracked in a
separate bundle.

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-06-18 14:32:54 +00:00
c3795c1a3c fix(viewer): wire Ctrl+A to select all text in the PDF (#6517)
# Description of Changes

Allow Ctrl A support in viewer and fix select text to copy issues via a
hovering copy button

---

## 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.

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-06-18 13:50:08 +00:00
Anthony StirlingandEthanHealy01 c8925acee7 add prerendered Open Graph previews and OG card generator (#6661)
# Description of Changes

add prerendered Open Graph previews and OG card generator
so that /compresss etc shows a pre generated static html file (Since
google etc doenst render javascript)


---

## 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.

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-06-18 13:49:16 +00:00
James Brunton eb08e60d67 Redesign pre-commit commands to run through Task (#6670)
# Description of Changes
The `pre-commit` commands in this repo are inconsistent with the rest of
the dev workflow, as they are impossible to run through Task and they
can cause CI to fail with no way for a developer to run the `pre-commit`
scripts after they've failed. This PR adds `task pre-commit` (and `task
pre-commit:fix`) and then hooks up the existing `pre-commit` hooks and
CI to call the Task rule, so if developers are using pre-commit hooks
then they should still work, but they're also runnable without using
pre-commit at all.

I think it'd be worth reviewing what we're actually running at
pre-commit in the future because I'm not entirely convinced by all of
the scripts that we are running, but this should at least make what we
have properly enforced and usable by all devs.
2026-06-18 12:55:53 +00:00
EthanHealy01 18da914bf9 fix theme issues, remove dead rainbow mode code, standardize theme us… (#6668)
Fix issues with the theme of the app that caused some things to persist
in light mode/dark mode whilst the rest of the app was the opposite
theme.

Removed dead rainbow mode code.

Added system theme option to settings.
2026-06-18 12:42:19 +00:00
Reece Browne 8f46ca0d92 feat(policies): lock policies to the SaaS build + profile (#6702)
## What & why

Policies (automation-backed enforcement) execute and bill through the
cloud backend, so the feature should only be available in the hosted
**SaaS** product — not in self-hosted proprietary or core builds. Today
it's enabled in the proprietary build (and the API is exposed in any
proprietary backend), so this locks it to SaaS on both layers.

## Frontend (build-flavor gate)

`POLICIES_ENABLED` is the single gate `usePoliciesEnabled` uses (rail +
auto-run controller).

- `proprietary` flag → **`false`** (self-hosted web no longer shows
policies)
- new `src/saas/constants/featureFlags.ts` → re-exports proprietary
flags, overrides `POLICIES_ENABLED = true`
- new `src/desktop/constants/featureFlags.ts` → same `true` override —
**required**: desktop's `@app` alias has no saas layer, and desktop
already gates policies on `POLICIES_ENABLED && useConfirmedSaaSMode()`,
so without a `true` here that runtime gate could never be satisfied.
Behaviour unchanged: desktop shows policies only when connected to SaaS.
- `PoliciesSidebar.test` mocks the flag on (it tests the component, not
the build gate — same pattern the existing `usePolicyAutoRun.retry.test`
uses).

## Backend (`@Profile("saas")` gate)

The saas backend runs under the `saas` Spring profile (as
`EntitlementGuard`, the AI controllers, etc. already do). The policy
beans are now `@Profile("saas")`, so `/api/v1/policies/*` and the
auto-run triggers exist **only** in the saas backend:

`PolicyController`, `PolicyEngine`, `PolicyRunner`, `PolicyRunRegistry`,
`PolicyValidator`, `JpaPolicyStore`, `FolderInputSource`,
`FolderOutputSink`, `InlineOutputSink`, `PolicyAccessGuard`,
`FolderAccessGuard`, `FolderWatchTrigger`, `ScheduleTrigger`,
`PolicyTriggerManager`.

**Deliberately *not* gated:** `PolicyExecutor` — `AiWorkflowService`
(always-on) injects it to run ad-hoc pipelines, so it stays
profile-free. It only depends on shared infra (`InternalApiClient`,
`ToolMetadataService`, `TempFileManager`, `ObjectMapper`), so leaving it
on is safe. Gating the engine/store/triggers as a set keeps wiring
consistent (nothing un-gated depends on a gated bean).

The saas `PolicyManagementAuthority` impl
(`TeamLeaderPolicyManagementAuthority`, `@Profile("saas")`) satisfies
`PolicyAccessGuard` in the saas context.
`AdminPolicyManagementAuthority` (`@Profile("!saas")`) becomes an unused
orphan in non-saas builds — harmless; left as-is rather than expanding
this PR's scope.

## Testing
- Frontend: full suite **869 pass**; typecheck clean on
proprietary/saas/core.
- Backend: `:proprietary` compiles, spotless clean, policy tests pass,
and the proprietary (non-saas) Spring context still boots with the
policy beans gated out (verified via the MCP `@SpringBootTest`
integration tests — no missing-bean failures).

Net: SaaS web build + desktop-in-SaaS-mode get policies (UI + API);
self-hosted proprietary and core get neither the UI nor the
`/api/v1/policies` endpoints.
2026-06-18 11:05:55 +00:00
Anthony Stirling 9a3bc6b47f Add cloud-aware delete and version history to My Files (#6704)
## /files
- Cloud-aware delete: choose device / cloud / both
- `/files` left rail always collapsed
- Details panel: pinned buttons, collapsible info, smaller preview
- Removed redundant Quick view
- New i18n keys added to en-US/en-GB

## Sidebar 
- Sidebar kebab: upload to server, delete, version history (+ cloud
badge)
- Version history modal everywhere files appear
2026-06-18 10:33:18 +00:00
Reece Browne 2b05865a84 Portal: unified-design surfaces (Policies, Users, Components, Agent Builder, Editor deploy) + Settings rebuild (#6696)
Builds the remaining developer-portal surfaces from the unified design
and rebuilds Settings, on top of the portal scaffold merged in #6686.
All tier-aware, mock-driven (MSW), componentised with Storybook
coverage. Touches only `frontend/portal` + `frontend/shared` — the
editor is untouched.

## New surfaces
- **Policies** — org-wide governance across the five categories
(Ingestion / Security / Compliance / Routing / Retention) with a
designer + per-doc-type overrides
- **Users** — members, roles, invite, tier-scaled SSO/SCIM access
- **Components** — embeddable `@stirling/*` SDK catalogue with
per-action pricing
- **Getting Started** — three-step funnel (use case → analyse a document
→ API key + snippets)
- **Agent Builder** — agent lifecycle (scenarios, tool modes,
evals/golden-sets, versions), reached from Sources
- **Editor deployment** — deploy/pair/operate the editor (targets,
pairing, health, credential rotation, air-gapped bundle), reached from
Infrastructure

## Reworks
- **Documents** → review/approval queue (confidence, extractions, audit
drawer, zero-standing-access elevation); the doc-type catalogue is
retained as a second tab
- **Pipelines** → golden-set pass column + "Promoted from the Editor"
section
- **Infrastructure** → new **Models** tab; deeper **Security** (managed
/ BYOK / HYOK + SOC 2 / ISO 27001 / HIPAA / GDPR / PCI attestations)
- **Home** → "What runs on your PDFs" policy summary + tier-aware
processing-status strip + pipeline-fork wizard

## Settings & shared
- New shared **`SettingsShell`** (grouped left-nav + content pane),
modelled on the editor's account-settings modal so both apps can
converge on one layout
- Portal **Settings** rebuilt on it as scoped sections — Account /
Workspace / Admin (Authentication, Active sessions, Early access)

## Brand
- Adopt the editor's brand mark + favicon; sidebar reads **Stirling
Processor**; app-switcher labels the active app "Processor"

## Mock contract
- Every surface follows the 3-layer pattern (typed `api/*` → MSW handler
→ fixtures); new endpoints documented in `MOCKS.md`. The read contract
is backend-ready; writes are marked `// TODO(backend): <METHOD> <path>`.

## Verification
- tsc (portal + shared) ✓ · eslint ✓ · dpdm (no circular) ✓ ·
`build:portal` ✓ · `storybook:build` ✓ · Prettier ✓

## Deferred (noted, not in scope)
- Unified shell / auth / role→surface routing / Workspace=Plan
(architectural epic)
- Tier rename (Editor / Processor / Bespoke) and the Usage flat-pricing
+ PAYG quick-amounts + Bespoke modal
- Editor adopting the shared `SettingsShell`; converting marked
write-stubs into live `api/` seams
2026-06-18 10:28:03 +00:00
James Brunton 0c503cc41d Fix all top-level dev tasks treating engine as enabled (#6705)
# Description of Changes
Currently, `task dev` explicitly calls the backend with
`AIENGINE_ENABLED=true` even though it isn't being spawned, so you just
get a dead FAB in the UI. This PR fixes it so that the engine will only
be enabled for tasks that will actually spawn the engine.

It also fixes a bug with the chat which makes it unusable locally. The
API path was not going through `apiClient` so for local dev you end up
with `//api/v1/...` which is not a valid path, so you get CORS errors
when trying to connect to the AI engine.
2026-06-18 10:08:50 +00:00
albanobattistella b1fef4c647 Update Italian translations (#6713) 2026-06-18 08:35:31 +00:00
EthanHealy01 06254853af allow drag and drop onto left files section and make top bar slightly smaller (#6711)
<img width="1261" height="984" alt="Screenshot 2026-06-17 at 5 59 30 PM"
src="https://github.com/user-attachments/assets/849dee17-1927-4336-81fc-dff7e91e55e7"
/>
2026-06-18 08:28:11 +00:00
Anthony Stirling d9e6041a75 set z-index on config dropdowns so they render above the modal (#6674) 2026-06-18 09:08:50 +01:00
Anthony Stirling 8f81fdc762 Use glibc base for ultra-lite and bundle per-arch JPDFium natives (#6706) 2026-06-18 08:32:55 +01:00
James Brunton 13af10a6d1 Redesign policy running (#6609)
# Description of Changes
Redesign policy running so the server is in charge of policy IDs and
running, to make it impossible to have the frontend miss the results.
This solves a minor bug that we currently have in policies, where if you
load a file and then refresh while the policy is running, you'll never
receive the outputted file.
2026-06-17 16:18:50 +00:00
EthanHealy01 3750111ffc fix agent overlay chat position when workbench size changes (#6682)
<img width="2056" height="1047" alt="Screenshot 2026-06-16 at 12 42
05 AM"
src="https://github.com/user-attachments/assets/74a38b93-f31f-4263-bb62-24c2334a22e8"
/>
<img width="1443" height="1051" alt="Screenshot 2026-06-16 at 12 42
33 AM"
src="https://github.com/user-attachments/assets/adb3ba47-f3e6-44a7-bbc3-2097e15843b6"
/>
2026-06-17 15:54:09 +00:00
ConnorYoh 20c88feabb refactor(saas): remove the legacy credits engine (FE + Java) (#6687)
Complete legacy-credits teardown ("Group 3"). The per-user/per-team
credit model is fully superseded by PAYG (`wallet_ledger`) — confirmed
no PAYG code references it. Authorized to also remove the `TeamCredit`
pool + its monthly reset.

## Frontend (saas)
- Deleted `saas/hooks/useCredits.ts`, `apiKeys/hooks/useCredits.ts`,
`types/credits.ts`, `apiKeys/UsageSection.tsx`.
- `UseSession.tsx`: removed credit members (`creditBalance`,
`creditSummary`, `hasSufficientCredits`, `updateCredits`,
`refreshCredits`, `fetchCredits`) + the credit types + global
credit-update callback. **Kept** `isPro`/`refreshProStatus` and the
Supabase auth subscription listener.
- `services/apiClient.ts`: removed the dead `x-credits-remaining`
handler + low-credit plumbing (token-refresh / PAYG / 401 logic
untouched).
- Credit refs removed from `ApiKeys.tsx`, `AppConfigModal.tsx`,
`auth/teamSession.ts`.

## Java (:saas)
**Deleted (15):** `UserCredit`(+repo),
`TeamCredit`(+repo)+`TeamCreditService`, `CreditService`,
`CreditHeaderUtils`, `CreditResetScheduler`, `CreditController`,
`CreditInterceptorConfig`, `UnifiedCreditInterceptor`,
`CreditSuccessAdvice`, `CreditErrorAdvice`, `CreditConsumptionResult` (+
the CreditController test).

**Edited — stripped legacy credit side-effects, preserved
auth/role/AI/PAYG logic:**
- `AiCreate`/`AiProxyController`: dropped the
`X-Credits-Remaining`/`X-Credit-Source` response header (its only
consumer, the desktop credit system, was already removed).
- `SaasTeamService`: dropped UserCredit/TeamCredit init on team-create +
seat-update.
- `SupabaseAuthenticationFilter` / `SupabaseSecurityConfig`: dropped
`getOrCreateUserCredits` on signup + the credit field/CORS header.
- `UserRoleService`: dropped `resetCycleAllocationForRoleChange`;
`ROLE_PRO_USER` grant/revoke preserved.
- proprietary `UserRepository`: dropped
`findUsersWithApiKeyButNoCredits()`.
- Tests updated to drop credit mocks/refs.

## Kept / scope
- `isPro` / `is_pro` RPC / `ROLE_PRO_USER` (that's the separate Group-4
/ EE effort) and **all PAYG** are untouched.
- **No DB tables dropped.** `user_credits`/`team_credits` stay until a
later **gated** migration — which this PR unblocks (the JPA entities
that pinned them are gone).

## Verify
`:saas:compileJava` + `:saas:compileTestJava` pass; FE `tsc --noEmit`
(saas) + eslint clean; 0 stray artifacts; no residual source refs to the
deleted classes.

## Follow-up (not in this PR)
`ErrorTrackingService` (+
`UserErrorTracker`/`ProcessingErrorType`/`CreditsProperties`) is now a
dead island — its only callers were the deleted interceptors. Safe to
delete, but it cascades beyond the credit scope, so it's a separate
tidy-up.

Targets `feat/desktop-cloud-saas-reuse`.
2026-06-17 14:11:06 +00:00
ConnorYoh 4f26fdeb5c feat(desktop): show the AI assistant in SaaS mode via the cloud kill switch (#6666)
## What & why

Chained on top of #6649 (the `cloud/` refactor). The AI assistant was
effectively dead on desktop:

1. **Hidden** — `ChatFAB` gates on `aiEngineEnabled`, which desktop
reads from the **local** bundled backend's `/api/v1/config/app-config`.
The local backend has no AI engine, so the flag is always `false` and
the FAB never renders.
2. **Mis-routed** — even if shown, AI calls used `getApiBaseUrl()`,
which is empty/local on desktop, so the orchestrate stream and AI
result-file download missed the engine (which only runs in the cloud).

This PR wires AI properly **without hardcoding it on**, so the cloud
keeps the kill switch: flip `aiEngineEnabled` server-side and the
desktop FAB disappears on the next load — no desktop release required.
(Deliberately *not* assume-on, so a future "turn AI off" doesn't strand
shipped versions.)

## Changes

**General SaaS app-config service** (reusable for any cloud flag, not
just AI):
- `desktop/services/saasAppConfigService.ts` — SaaS-mode-only fetch +
5-min cache of the **public** `/api/v1/config/app-config` from the
**SaaS** backend over native HTTP (`@tauri-apps/plugin-http`, no CORS).
Returns `null` outside SaaS mode.
- `desktop/hooks/useSaasAppConfig.ts` — hook over it; reloads on
connection-mode change.

**AI gating + routing seams:**
- `useAiEngineEnabled()` — core reads `useAppConfig()` (web), desktop
reads `useSaasAppConfig()`. `ChatFAB` consumes it.
- `getAiBaseUrl()` — core uses the normal API base (web), desktop points
AI calls at the SaaS backend. `ChatContext` uses it for the orchestrate
stream + result-file download.
- `operationRouter` — route `/api/v1/ai/*` to the SaaS backend
(cloud-only prefix).

**Docs:** AGENTS.md gains a short "cloud feature flags on desktop" note
so the pattern is maintained.

## Verification
- `tsc --noEmit` green for saas / desktop / cloud flavors
- `eslint --max-warnings=0` clean (cloud-layer guardrail respected — the
platform-coupled bits live in `desktop/`)
- New `saasAppConfigService.test.ts` (3 tests) + existing
`operationRouter` / `tauriHttpClient` / `httpErrorHandler` suites green
- 0 stray compiled artifacts

## Not headlessly verifiable — needs a live Tauri smoke
The orchestrate **SSE stream** uses the webview's global `fetch` (native
HTTP can't stream the body the same way), so it's subject to browser
CORS to the SaaS backend. The `SupabaseSecurityConfig` tauri-origin
allowance (from #6649) covers it, but please confirm on a real build:
open the FAB in SaaS mode, run an agent task, watch the stream + a
result-file download succeed.
2026-06-17 14:10:35 +00:00
Anthony Stirling df9dbc5179 MCP token rejection reason and stop logging the raw tokens (#6700)
- Surface the real reason an MCP token is rejected: the 401's
WWW-Authenticate header now includes error_description
(audience/issuer/expiry), and a present-but-rejected token logs the
concrete OAuth2 reason. Tokenless 401s (the normal discovery handshake)
stay at debug.
- Add McpConfigValidator that sanity-checks MCP config at startup and
logs actionable warnings (missing issuer-uri/resource-id, unrecognized
auth mode, sub + require-existing-account, open access, scopes,
allow/block overlap) so misconfig shows up in the logs before a client
ever connects.
- Align the audience-rejection message to mention both resource-id and
accepted-audiences.
- Harden audit writes: hash JWT-shaped or over-long principals
(token:<sha256-prefix>) so the insert fits the column and never stores a
raw bearer token, and stop logging the raw principal on persist failure.
---

## 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-17 11:06:05 +00:00
Anthony Stirling de9242c4f7 Add JUnit tests for saas module coverage (#6699)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## 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-17 11:04:53 +00:00
Anthony Stirling 460c037bbb Prefer JBoss mirror over shibboleth repo for opensaml (#6701)
# Description of Changes

Jboss not shibboleth first

---

## 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-17 10:55:59 +00:00
ConnorYohandJames Brunton cd7264a76a refactor(fe): share the SaaS PAYG experience with desktop via a cloud/ layer (#6649)
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-06-17 11:12:05 +01:00
James Brunton ef0deef4f2 Skip flaky Playwright test (#6698)
# Description of Changes
One of the Playwright tests is flaky, despite several attempts to fix it
before it made it into main. This disables the test for now so a
followup PR can try to fix it again.
2026-06-17 09:12:09 +00:00
65fcc036fe Fix inverted link toolbar in rotated PDFs (#6518) (#6684)
Closes #6518 

# Cause of the bug 
This is a fix to the #6518 issue. The bug happened because the link
toolbar was rendered inside the PDF page layer. That layer can be
affected by the viewer/page rotation transform, so the toolbar was laid
out using local page coordinates and then visually transformed together
with the page.

As a result, the placement logic could calculate a position that was
correct in the page’s local coordinate space, such as above or below the
link, but the parent transform would rotate or shift that result after
layout. On rotated pages, this could make the toolbar appear on the
wrong side, inverted, or misaligned relative to the link.

More specifically, in the PDF that exposed the bug, the page content
appears to have been authored upside down and then corrected with a
180-degree page/viewer rotation so it looks normal to the user.

Because the toolbar was rendered inside the same transformed page layer,
it inherited that 180-degree rotation as well. The PDF content looked
upright because the rotation was part of how the page was displayed, but
the toolbar is viewer UI and should not be rotated with the page. As a
result, the tooltip appeared upside down even though the PDF itself
looked correct.


# Description of Changes

Fixes the inverted link tooltip/toolbar positioning in rotated PDF
viewer pages.

The link toolbar is now rendered through a body portal and positioned
from the link element’s real viewport bounds, so page rotation
transforms no longer flip or misalign it.

The update also keeps the toolbar within the viewport during scroll,
resize, zoom, and rotation changes, preserves the hover delay between
the link and toolbar, centralizes the z-index in a shared constant, and
improves label sizing to avoid clipped text.

Note: The link hover styling was also changed from an underline to a
subtle rectangular highlight based on the PDF link annotation bounds.

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

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

Closes #(issue_number)
-->

---

## 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)
- [ ] 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)

UI behaviour before the changes :

<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-12-10"
src="https://github.com/user-attachments/assets/321edbb3-42a2-4bc3-96ad-3ccc70a355b8"
/>

<img width="762" height="496" alt="Captura de tela de 2026-06-16
00-11-46"
src="https://github.com/user-attachments/assets/be4c1af4-5488-4a54-9b6f-675e3bea73b8"
/>

<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-12-57"
src="https://github.com/user-attachments/assets/60f44cd5-c772-44a8-97c8-bde135764e53"
/>


UI behaviour after the changes :
 
<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-22-02"
src="https://github.com/user-attachments/assets/dda77bda-0780-4807-a70d-3bbc60683e5a"
/>

<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-23-07"
src="https://github.com/user-attachments/assets/5745c37e-438a-4bbe-ba1e-c6f2098421de"
/>

<img width="1256" height="868" alt="Captura de tela de 2026-06-16
00-23-24"
src="https://github.com/user-attachments/assets/85932541-4a6f-48e4-879f-41f34a6d79e6"
/>


### 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.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-06-17 08:15:31 +00:00
660 changed files with 69255 additions and 76093 deletions
+4 -4
View File
@@ -20,8 +20,8 @@ set -e
# - To build the project, use:
# ./gradlew build
#
# - For running pre-commit hooks (if configured), use:
# pre-commit run --all-files
# - To run the lint/format/secret checks, use:
# task pre-commit
#
# Make sure you are in the project root directory after this script executes.
# =============================================================================
@@ -70,6 +70,6 @@ echo ""
echo " To build the project: "
echo -e "\e[34m gradle build\e[0m"
echo ""
echo " To run pre-commit hooks (if configured):"
echo -e "\e[34m pre-commit run --all-files -c .pre-commit-config.yaml\e[0m"
echo " To run the lint/format/secret checks:"
echo -e "\e[34m task pre-commit\e[0m"
echo "=================================================================="
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.12.0
pkgver=2.13.1
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.12.0
pkgver=2.13.1
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
@@ -1 +0,0 @@
pre-commit
-121
View File
@@ -1,121 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in'
#
cfgv==3.5.0 \
--hash=sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0 \
--hash=sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132
# via pre-commit
distlib==0.4.0 \
--hash=sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16 \
--hash=sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d
# via virtualenv
filelock==3.29.0 \
--hash=sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90 \
--hash=sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258
# via
# python-discovery
# virtualenv
identify==2.6.19 \
--hash=sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a \
--hash=sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842
# via pre-commit
nodeenv==1.10.0 \
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
# via pre-commit
platformdirs==4.9.6 \
--hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \
--hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917
# via
# python-discovery
# virtualenv
pre-commit==4.6.0 \
--hash=sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9 \
--hash=sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b
# via -r .github/scripts/requirements_pre_commit.in
python-discovery==1.2.2 \
--hash=sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb \
--hash=sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a
# via virtualenv
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via pre-commit
virtualenv==21.2.4 \
--hash=sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac \
--hash=sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada
# via pre-commit
+9 -24
View File
@@ -1,8 +1,7 @@
name: Pre-commit
# Runs `pre-commit run` for ruff / codespell / gitleaks / EOF / trailing-ws.
# Called from build.yml on PRs and merge_group; also runnable on demand via
# workflow_dispatch for manual local-equivalent linting.
# Runs the repo-wide lint/format/secret checks via `task pre-commit`.
# Called from build.yml on PRs and merge_group; also runnable on demand via workflow_dispatch.
on:
workflow_call:
workflow_dispatch:
@@ -13,10 +12,6 @@ permissions:
jobs:
pre-commit:
runs-on: ubuntu-latest
env:
# Prevents sdist builds → no tar extraction
PIP_ONLY_BINARY: ":all:"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
@@ -29,23 +24,13 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
python-version: 3.12
cache: "pip" # caching pip dependencies
cache-dependency-path: ./.github/scripts/requirements_pre_commit.txt
enable-cache: true
- name: Run Pre-Commit Hooks
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_pre_commit.txt
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
- name: Run Pre-Commit
run: |
pre-commit run ruff --all-files -c .pre-commit-config.yaml
pre-commit run ruff-format --all-files -c .pre-commit-config.yaml
pre-commit run codespell --all-files -c .pre-commit-config.yaml
pre-commit run gitleaks --all-files -c .pre-commit-config.yaml
pre-commit run end-of-file-fixer --all-files -c .pre-commit-config.yaml
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
git diff --exit-code
- name: Run pre-commit checks
run: task pre-commit
+11 -3
View File
@@ -58,15 +58,23 @@ jobs:
- name: Install Python dependencies
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt -r ./.github/scripts/requirements_pre_commit.txt
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
with:
enable-cache: true
- name: Install Task
uses: go-task/setup-task@3be4020d41929789a01026e0e427a4321ce0ad44 # v2.0.0
- name: Sync translation TOML files
run: |
python .github/scripts/check_language_toml.py --reference-file "frontend/editor/public/locales/en-US/translation.toml" --branch main
- name: pre-commit run
- name: Sort translation TOML files
run: |
pre-commit run toml-sort-fix --all-files
task pre-commit:toml-sort FIX=1
- name: Commit translation files
run: |
+9
View File
@@ -115,6 +115,15 @@ 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
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Set up x86_64 JDK 25 (macOS universal JRE)
if: matrix.platform == 'macos-15'
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
+8
View File
@@ -46,6 +46,12 @@ app/core/storage/
# These are generated by npm build and should not be committed
app/core/src/main/resources/static/assets/
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/
app/core/src/main/resources/static/Login/
app/core/src/main/resources/static/classic-logo/
@@ -53,6 +59,8 @@ app/core/src/main/resources/static/modern-logo/
app/core/src/main/resources/static/og_images/
app/core/src/main/resources/static/samples/
app/core/src/main/resources/static/manifest-classic.json
app/core/src/main/resources/static/og-metadata.json
app/core/src/main/resources/static/sw-folder-retry.js
app/core/src/main/resources/static/robots.txt
app/core/src/main/resources/static/pdfium/
app/core/src/main/resources/static/pdfjs/
+8 -1
View File
@@ -1,4 +1,4 @@
# PostHog project-level key phc_ prefix keys are public/client-side by design
# PostHog project-level key - phc_ prefix keys are public/client-side by design
# (PostHog client-side tracking embeds them in the browser bundle). Committed
# intentionally in #6150 so engine/.env has a working default, with real
# credentials overridden via engine/.env.local.
@@ -12,3 +12,10 @@ app/proprietary/src/test/java/stirling/software/proprietary/mcp/security/McpApiK
testing/compose/docker-compose-keycloak-mcp.yml:generic-api-key:25
testing/compose/validate-mcp-apikey.sh:curl-auth-header:73
testing/compose/validate-mcp-test.sh:curl-auth-header:92
testing/compose/validate-mcp-test.sh:curl-auth-header:116
# Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...).
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
+12 -50
View File
@@ -1,52 +1,14 @@
# The actual checks live in .taskfiles/pre-commit.yml (with helper scripts under
# scripts/pre-commit/) and are driven by Task. This hook just delegates to `task
# pre-commit` so the git pre-commit hook, CI and a manual `task pre-commit` all
# run the exact same thing. Requires `task` and `uv` on PATH. To auto-fix instead
# of only checking, run `task pre-commit:fix`.
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.14
- repo: local
hooks:
- id: ruff
args:
- --fix
- --line-length=127
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
exclude: (split_photos.py)
- id: ruff-format
files: ^((\.github/scripts|scripts|app/core/src/main/resources/static/python)/.+)?[^/]+\.py$
exclude: (split_photos.py)
- repo: https://github.com/codespell-project/codespell
rev: v2.4.2
hooks:
- id: codespell
args:
- --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment
- --skip="./.*,*.csv,*.json,*.ambr"
- --quiet-level=2
files: \.(html|css|js|py|md)$
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|frontend/editor/public/vendor|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.0
hooks:
- id: gitleaks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: end-of-file-fixer
files: ^.*(\.js|\.java|\.py|\.yml)$
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
- id: trailing-whitespace
files: ^.*(\.js|\.java|\.py|\.yml)$
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
- repo: https://github.com/pappasam/toml-sort
rev: v0.24.4
hooks:
- id: toml-sort-fix
files: frontend/editor/public/locales/.*\.toml$
args: ['--in-place', '--all', '--ignore-case']
# - repo: https://github.com/thibaudcolas/pre-commit-stylelint
# rev: v16.21.1
# hooks:
# - id: stylelint
# additional_dependencies:
# - stylelint@16.21.1
# - stylelint-config-standard@38.0.0
# - "@stylistic/stylelint-plugin@3.1.3"
# files: \.(css)$
# args: [--fix]
- id: task-pre-commit
name: task pre-commit
entry: task pre-commit
language: system
pass_filenames: false
always_run: true
+6 -3
View File
@@ -23,6 +23,7 @@ tasks:
vars:
PORT: '{{.PORT}}'
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
dev:proprietary:
@@ -31,13 +32,14 @@ tasks:
vars:
PORT: '{{.PORT | default "8080"}}'
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
env:
SERVER_PORT: '{{.PORT}}'
cmds:
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true 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}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
platforms: [windows]
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true 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}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
@@ -60,12 +62,13 @@ tasks:
# Override to "" to run the pure `saas` profile against your own SAAS_DB_*.
PROFILES: '{{.PROFILES | default "dev"}}'
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
env:
SERVER_PORT: '{{.PORT}}'
STIRLING_FLAVOR: saas
AIENGINE_URL: '{{.AIENGINE_URL}}'
AIENGINE_ENABLED: '{{if .AIENGINE_URL}}true{{else}}false{{end}}'
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
cmds:
- cmd: cmd /c ".\gradlew.bat :stirling-pdf:bootRun {{if .PROFILES}}--args=\"--spring.profiles.include={{.PROFILES}}\"{{end}}"
+43
View File
@@ -40,6 +40,21 @@ tasks:
cmds:
- node editor/scripts/generate-icons.js
prepare:og:
internal: true
run: when_changed
desc: "Regenerate OG/social-preview metadata from the tool registry"
cmds:
- node editor/scripts/generate-og-metadata.mjs
sources:
- editor/src/core/types/toolId.ts
- editor/src/core/utils/urlMapping.ts
- editor/src/core/data/useTranslatedToolRegistry.tsx
- editor/public/og_images/*.png
generates:
- editor/src/core/data/ogImageMap.json
- editor/public/og-metadata.json
prepare:
desc: "Set up dev environment"
run: when_changed
@@ -49,6 +64,7 @@ tasks:
- task: prepare:env
vars: { MODE: '{{.MODE}}' }
- prepare:icons
- prepare:og
# ============================================================
# Development
@@ -262,6 +278,12 @@ tasks:
cmds:
- npx tsc --noEmit --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
typecheck:scripts:
desc: "Typecheck scripts"
deps: [prepare]
@@ -293,6 +315,7 @@ tasks:
- task: typecheck:proprietary
- task: typecheck:saas
- task: typecheck:desktop
- task: typecheck:cloud
- task: typecheck:scripts
- task: typecheck:prototypes
- task: typecheck:portal
@@ -310,9 +333,17 @@ tasks:
- task: format:check
- task: test
og:check:
desc: "Fail if committed OG/social-preview metadata is out of date"
cmds:
- node editor/scripts/generate-og-metadata.mjs --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: typecheck:all
- task: lint
- task: format:check
@@ -367,3 +398,15 @@ tasks:
deps: [install]
cmds:
- node editor/scripts/generate-licenses.js
# ============================================================
# Clean
# ============================================================
clean:
desc: "Clean build artifacts and caches"
cmds:
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist, dist-portal
platforms: [windows]
- cmd: rm -rf node_modules/.vite editor/dist dist dist-portal
platforms: [linux, darwin]
+159
View File
@@ -0,0 +1,159 @@
version: '3'
# Repo-wide lint/format/secret checks - the single source of truth that the git
# 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: >-
'scripts/*.py'
'.github/scripts/*.py'
'app/core/src/main/resources/static/python/*.py'
':(exclude)*split_photos.py'
SPELL_FILES: >-
'*.html'
'*.css'
'*.js'
'*.py'
'*.md'
':(exclude).vscode/*'
':(exclude).devcontainer/*'
':(exclude)app/core/src/main/resources/*'
':(exclude)app/proprietary/src/main/resources/*'
':(exclude)frontend/editor/public/vendor/*'
':(exclude)*Dockerfile*'
':(exclude)*pdfjs*'
':(exclude)*thirdParty*'
':(exclude)*bootstrap*'
':(exclude)*.min.*'
':(exclude)*diff.js'
WS_FILES: >-
'*.js'
'*.java'
'*.py'
'*.yml'
':(exclude)*pdfjs*'
':(exclude)*thirdParty*'
':(exclude)*bootstrap*'
':(exclude)*.min.*'
':(exclude)*diff.js'
':(exclude).github/workflows/*'
LOCALE_TOML: 'frontend/editor/public/locales/*/translation.toml'
GITLEAKS_BIN: '.task/bin/gitleaks-{{.GITLEAKS}}{{if eq OS "windows"}}.exe{{end}}'
tasks:
default:
desc: "Check formatting, spelling, and secrets across the repo"
cmds:
- task: ruff
- task: ruff-format
- task: codespell
- task: gitleaks
- task: whitespace
- task: toml-sort
fix:
desc: "Auto-fix formatting, spelling, and secrets issues across the repo"
cmds:
# Auto-fixers first, then the report-only tools (codespell, gitleaks) so a
# finding there does not stop the fixers from running.
- task: ruff
vars: { FIX: '1' }
- task: ruff-format
vars: { FIX: '1' }
- task: whitespace
vars: { FIX: '1' }
- task: toml-sort
vars: { FIX: '1' }
- task: codespell
- task: gitleaks
install:
desc: "Install the pinned pre-commit Python tools (ruff, codespell, toml-sort)"
run: once
cmds:
- uv sync --project scripts/pre-commit --locked
sources:
- scripts/pre-commit/uv.lock
- scripts/pre-commit/pyproject.toml
status:
- test -d scripts/pre-commit/.venv
clean:
desc: "Remove the cache/build artifacts"
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-*"
# 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.
ruff:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync ruff check --line-length=127 {{if .FIX}}--fix {{end}}$(git ls-files {{.PY_FILES}})
ruff-format:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync ruff format {{if .FIX}}{{else}}--check {{end}}$(git ls-files {{.PY_FILES}})
codespell:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync codespell --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist,fulfilment --quiet-level=2 $(git ls-files {{.SPELL_FILES}})
toml-sort:
deps: [install]
cmds:
- uv run --project scripts/pre-commit --no-sync toml-sort --all --ignore-case {{if .FIX}}--in-place{{else}}--check{{end}} {{.LOCALE_TOML}}
whitespace:
cmds:
- uv run --no-project python scripts/pre-commit/whitespace.py {{if .FIX}}--fix {{end}}$(git ls-files {{.WS_FILES}})
gitleaks:
deps: [gitleaks-bin]
# Scan staged changes only, matching the old hook: the git-mode fingerprints
# in .gitleaksignore (file:rule:line) still apply, and with nothing staged
# this is a no-op. Secrets are never auto-fixed, so FIX has no effect.
cmds:
- "{{.GITLEAKS_BIN}} git --pre-commit --redact --staged --verbose"
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}}
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]
+26 -2
View File
@@ -152,7 +152,7 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
#### Import Paths - CRITICAL
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
For a broader explanation of the frontend layering and override architecture, see [frontend/editor/DeveloperGuide.md](frontend/editor/DeveloperGuide.md).
For a broader explanation of the frontend layering and override architecture, read @frontend/editor/DeveloperGuide.md
```typescript
// ✅ CORRECT - Use @app/* for all imports
@@ -169,7 +169,31 @@ import { useFileContext } from "@proprietary/contexts/FileContext";
- Building layer-specific override that wraps a lower layer's component
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/saas/desktop/cloud) and handles the fallback cascade — see "Frontend `cloud/` Layer" below for the full per-flavor order.
#### Frontend `cloud/` Layer
`@app/*` resolves through a per-flavor cascade — first existing file wins (shadow/override):
- **core** → core
- **proprietary** → proprietary → core
- **saas** → saas → cloud → proprietary → core
- **desktop** → desktop → cloud → proprietary → core
- **cloud** → cloud → proprietary → core
What goes where:
- **core** — OSS base.
- **proprietary** — licensed / offline features.
- **cloud** — the SHARED hosted/SaaS experience used by BOTH saas + desktop: PAYG, wallet, plan, billing, usage meters, cloud config/team/onboarding.
- **saas** — web-only: Supabase web auth, AuthCallback, avatar canvas, `window.location`.
- **desktop** — Tauri-only: keyring authService, tauriHttpClient, native files/windows, backend routing.
`cloud/` MUST NOT import `@supabase/*`, `@tauri-apps/*`, raw `fetch`, `window.location`, `localStorage`, `sessionStorage`, or `import.meta.env.VITE_*` (enforced by ESLint). It reaches platform-specific things only via `@app/*` seams: `services/apiClient`, `auth/session.getAccessToken`, `auth/supabase`, `platform/openExternal`, `services/billing`, `hooks/useSaaSMode` — each provided per-platform in `saas/` and `desktop/`.
Rule of thumb — **move, don't copy**: share via `cloud/`, override by shadowing the same `@app/*` path in a leaf (`saas/` or `desktop/`).
**Cloud feature flags on desktop.** The local `AppConfigContext` reads `/api/v1/config/app-config` from the LOCAL bundled backend, so cloud-only flags (`aiEngineEnabled`, `premiumEnabled`, …) are never seen on desktop. To read the cloud's view, use `useSaasAppConfig()` (`desktop/hooks/useSaasAppConfig.ts`, backed by the general `saasAppConfigService` — SaaS-mode-only, public endpoint, native HTTP, 5-min cache). It returns `null` outside SaaS mode, so cloud features stay off in local/self-hosted and the server keeps the on/off switch (no desktop release needed to flip a flag). Gate a feature behind a per-platform seam — e.g. `useAiEngineEnabled()` (core reads `useAppConfig()`, desktop reads `useSaasAppConfig()`) — rather than hardcoding the flag on.
#### Component Override Pattern (Stub/Shadow)
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
+2
View File
@@ -16,6 +16,8 @@ if that directory exists, is licensed under the license defined in "frontend/edi
if that directory exists, is licensed under the license defined in "frontend/editor/src/desktop/LICENSE".
* All content that resides under the "frontend/editor/src/saas/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/saas/LICENSE".
* All content that resides under the "frontend/editor/src/cloud/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE".
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
* All content that resides under the "frontend/portal/" directory of this repository,
+1 -1
View File
@@ -60,7 +60,7 @@ For full installation options (including desktop and Kubernetes), see our [Docum
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
This project uses [Task](https://taskfile.dev/) as a unified command runner for all build, dev, and test commands. Run `task install` to get started, or see the [Developer Guide](DeveloperGuide.md) for full details.
This project uses [Task](https://taskfile.dev/) as a unified command runner for all build, dev, and test commands. Run `task dev` to get started running the editor, run `task` to see the most common commands, or see the [Developer Guide](DeveloperGuide.md) for full details.
For adding translations, see the [Translation Guide](devGuide/HowToAddNewLanguage.md).
+23
View File
@@ -25,8 +25,28 @@ includes:
e2e:
taskfile: .taskfiles/e2e.yml
dir: .
pre-commit:
taskfile: .taskfiles/pre-commit.yml
dir: .
tasks:
# ============================================================
# Help (shown when you run `task` with no arguments)
# ============================================================
default:
desc: "List the most common commands"
silent: true
cmds:
- |
echo "Common commands (run 'task --list' to see all):"
echo ""
echo " task dev Start backend & frontend on free ports"
echo " task backend:dev Start backend on default port"
echo " task frontend:dev Start frontend on default port"
echo " task desktop:dev Start desktop app"
echo " task check Quality gate (lint, typecheck, test, etc.)"
# ============================================================
# Setup & Prerequisites
# ============================================================
@@ -87,6 +107,7 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
AIENGINE_ENABLED: "true"
- task: 'frontend:dev:{{.FRONTEND}}'
vars:
PORT: '{{.FRONTEND_PORT}}'
@@ -171,4 +192,6 @@ tasks:
desc: "Clean all build artifacts"
cmds:
- task: backend:clean
- task: frontend:clean
- task: engine:clean
- task: pre-commit:clean
@@ -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) {}
}
@@ -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"));
}
}
+6
View File
@@ -330,6 +330,12 @@ tasks.register('cleanFrontendAssets', Delete) {
group = 'frontend'
description = 'Remove previously generated frontend assets from static resources'
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 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')
}
tasks.register('copyApiLandingPage', Copy) {
@@ -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
@@ -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,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;
}
}
@@ -2215,6 +2215,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-security-oauth2-resource-server",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "4.0.6",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-servlet",
"moduleUrl": "https://spring.io/projects/spring-boot",
@@ -2320,6 +2327,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-oauth2-resource-server",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "4.0.6",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-security",
"moduleUrl": "https://spring.io/projects/spring-boot",
@@ -2438,6 +2452,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-oauth2-resource-server",
"moduleUrl": "https://spring.io/projects/spring-security",
"moduleVersion": "7.0.5",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.security:spring-security-saml2-service-provider",
"moduleUrl": "https://spring.io/projects/spring-security",
File diff suppressed because one or more lines are too long
@@ -0,0 +1,107 @@
package stirling.software.SPDF.config;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import stirling.software.SPDF.service.PdfMetricsService;
class PdfMetricsInterceptorTest {
private PdfMetricsService service;
private PdfMetricsInterceptor interceptor;
@BeforeEach
void setUp() {
service = mock(PdfMetricsService.class);
when(service.isEnabled()).thenReturn(true);
interceptor = new PdfMetricsInterceptor(service);
}
private MultipartHttpServletRequest editRequest(int fileParts, String... headers) {
MultipartHttpServletRequest request = mock(MultipartHttpServletRequest.class);
when(request.getMethod()).thenReturn("POST");
when(request.getServletPath()).thenReturn("/api/v1/general/rotate-pdf");
for (int i = 0; i + 1 < headers.length; i += 2) {
when(request.getHeader(headers[i])).thenReturn(headers[i + 1]);
}
MultiValueMap<String, MultipartFile> files = new LinkedMultiValueMap<>();
for (int i = 0; i < fileParts; i++) {
files.add("fileInput", mock(MultipartFile.class));
}
when(request.getMultiFileMap()).thenReturn(files);
return request;
}
private HttpServletResponse response(int status, String contentType) {
HttpServletResponse response = mock(HttpServletResponse.class);
when(response.getStatus()).thenReturn(status);
when(response.getContentType()).thenReturn(contentType);
return response;
}
@Test
void apiRequestIsCounted() {
interceptor.afterCompletion(editRequest(1), response(200, "application/pdf"), null, null);
verify(service).recordOperation(1);
}
@Test
void countsEveryFilePartUnderOneFieldName() {
interceptor.afterCompletion(editRequest(3), response(200, "application/pdf"), null, null);
verify(service).recordOperation(3);
}
@Test
void countsRegardlessOfResponseType() {
interceptor.afterCompletion(editRequest(1), response(200, "application/json"), null, null);
verify(service).recordOperation(1);
}
@Test
void editorRequestWithBrowserIdIsNotCounted() {
interceptor.afterCompletion(
editRequest(1, "X-Browser-Id", "abc-123"),
response(200, "application/pdf"),
null,
null);
verify(service, never()).recordOperation(anyInt());
}
@Test
void editorJwtWithoutBrowserIdIsNotCounted() {
interceptor.afterCompletion(
editRequest(1, "Authorization", "Bearer eyJhbG.eyJzdWI.sig"),
response(200, "application/pdf"),
null,
null);
verify(service, never()).recordOperation(anyInt());
}
@Test
void bearerApiKeyIsCounted() {
interceptor.afterCompletion(
editRequest(1, "Authorization", "Bearer sk-not-a-jwt-key"),
response(200, "application/pdf"),
null,
null);
verify(service).recordOperation(1);
}
@Test
void errorResponseIsNotCounted() {
interceptor.afterCompletion(editRequest(1), response(500, "application/pdf"), null, null);
verify(service, never()).recordOperation(anyInt());
}
}
@@ -95,6 +95,38 @@ class ReactRoutingControllerTest {
assertTrue(body.contains("Stirling PDF"));
}
// --- mobile scanner route ---
@Test
void serveMobileScanner_webMode_servesSpaNotUploadPage() {
controller.init();
ResponseEntity<String> response = controller.serveMobileScanner(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
String body = response.getBody();
assertNotNull(body);
assertFalse(body.contains("Take Photo"));
}
@Test
void serveMobileScanner_desktopMode_servesStaticUploadPage() {
controller.init();
System.setProperty("STIRLING_PDF_TAURI_MODE", "true");
try {
ResponseEntity<String> response = controller.serveMobileScanner(request);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(MediaType.TEXT_HTML, response.getHeaders().getContentType());
String body = response.getBody();
assertNotNull(body);
assertTrue(body.contains("Mobile Upload"));
assertTrue(body.contains("Take Photo"));
} finally {
System.clearProperty("STIRLING_PDF_TAURI_MODE");
}
}
// --- tauri auth callback ---
@Test
@@ -0,0 +1,79 @@
package stirling.software.SPDF.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.verify;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.PostHogService;
class PdfMetricsServiceTest {
private PostHogService postHogService;
private ApplicationProperties applicationProperties;
private PdfMetricsService service;
@BeforeEach
void setUp() {
postHogService = mock(PostHogService.class);
applicationProperties = new ApplicationProperties();
applicationProperties.getSystem().setEnableAnalytics(true);
service = new PdfMetricsService(postHogService, applicationProperties);
}
@Test
void flushesOperationAndPdfCounts() {
service.recordOperation(1);
service.recordOperation(2);
service.flushMetrics();
Map<String, Object> event = captureEvent();
assertEquals("api", event.get("source"));
assertEquals(2L, event.get("operations"));
assertEquals(3L, event.get("pdfs"));
}
@Test
void sendsOnlyDeltasBetweenFlushes() {
service.recordOperation(1);
service.flushMetrics();
reset(postHogService);
service.flushMetrics();
verify(postHogService, never()).captureEvent(eq("pdf_operation_metrics"), anyMap());
service.recordOperation(2);
service.flushMetrics();
Map<String, Object> event = captureEvent();
assertEquals(1L, event.get("operations"));
assertEquals(2L, event.get("pdfs"));
}
@Test
void doesNothingWhenAnalyticsDisabled() {
applicationProperties.getSystem().setEnableAnalytics(false);
service.recordOperation(1);
service.flushMetrics();
verify(postHogService, never()).captureEvent(eq("pdf_operation_metrics"), anyMap());
}
private Map<String, Object> captureEvent() {
ArgumentCaptor<Map<String, Object>> captor = ArgumentCaptor.forClass(Map.class);
verify(postHogService).captureEvent(eq("pdf_operation_metrics"), captor.capture());
return captor.getValue();
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
repositories {
maven { url = "https://build.shibboleth.net/maven/releases" }
maven { url = "https://repository.jboss.org/" }
maven { url = "https://build.shibboleth.net/maven/releases" }
}
ext {
@@ -1,6 +1,10 @@
package stirling.software.proprietary.config;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
@@ -61,17 +65,43 @@ public class CustomAuditEventRepository implements AuditEventRepository {
PersistentAuditEvent ent =
PersistentAuditEvent.builder()
.principal(ev.getPrincipal())
.principal(safePrincipal(ev.getPrincipal()))
.type(ev.getType())
.data(auditEventData)
.timestamp(ev.getTimestamp())
.build();
repo.save(ent);
} catch (Exception e) {
log.error(
"Failed to persist audit event (fail-open); principal={}",
ev.getPrincipal(),
e);
log.error("Failed to persist audit event (fail-open); type={}", ev.getType(), e);
}
}
/** Width of the {@code principal} column; longer values are hashed so the insert can't fail. */
private static final int PRINCIPAL_MAX_LENGTH = 255;
/**
* Hash JWT-shaped or over-long principals so the insert fits the column and stores no secret.
*/
static String safePrincipal(String principal) {
if (principal == null || principal.isBlank()) {
return "anonymous";
}
// Hash JWTs ("eyJ...") and any over-long value rather than store verbatim.
if (principal.startsWith("eyJ") || principal.length() > PRINCIPAL_MAX_LENGTH) {
return "token:" + sha256Prefix(principal);
}
return principal;
}
/** First 8 bytes of SHA-256 as hex: stable, one-way, collision-safe enough. */
private static String sha256Prefix(String value) {
try {
byte[] digest =
MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest, 0, 8);
} catch (NoSuchAlgorithmException e) {
return "unhashable";
}
}
}
@@ -43,8 +43,8 @@ public class McpAudienceValidator implements OAuth2TokenValidator<Jwt> {
return OAuth2TokenValidatorResult.failure(
new OAuth2Error(
"invalid_token",
"MCP server has no resource id configured; rejecting all tokens"
+ " until mcp.auth.resource-id is set.",
"MCP audience binding is not configured; rejecting all tokens until"
+ " mcp.auth.resource-id or mcp.auth.accepted-audiences is set.",
null));
}
List<String> aud = token.getAudience();
@@ -4,15 +4,21 @@ import java.io.IOException;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.web.AuthenticationEntryPoint;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
/**
* Emits 401 + {@code WWW-Authenticate: Bearer resource_metadata="..."} (RFC 9728), preferring
* X-Forwarded-* headers to build the public-facing metadata URL.
* Emits 401 + {@code WWW-Authenticate: Bearer resource_metadata="..."} (RFC 9728) from
* X-Forwarded-* headers. A rejected token also logs the OAuth2 reason and echoes it as {@code
* error_description}.
*/
@Slf4j
public class McpAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final String metadataPath;
@@ -28,15 +34,47 @@ public class McpAuthenticationEntryPoint implements AuthenticationEntryPoint {
HttpServletResponse response,
AuthenticationException authException)
throws IOException {
// Tokenless 401 is the normal discovery handshake; only a rejected token is a real failure.
boolean tokenPresented = request.getHeader("Authorization") != null;
String reason = rejectionReason(authException);
if (tokenPresented) {
log.warn("MCP rejected bearer token: {}", reason != null ? reason : "invalid_token");
} else {
log.debug("MCP 401: no bearer token; returning protected-resource metadata pointer");
}
String scheme = firstForwarded(request, "X-Forwarded-Proto", request.getScheme());
String authority = forwardedHost(request, scheme);
String metadataUrl = scheme + "://" + authority + metadataPath;
response.setHeader(
"WWW-Authenticate",
"Bearer error=\"invalid_token\", resource_metadata=\"" + metadataUrl + "\"");
StringBuilder header = new StringBuilder("Bearer error=\"invalid_token\"");
if (tokenPresented && reason != null) {
header.append(", error_description=\"").append(reason).append('"');
}
header.append(", resource_metadata=\"").append(metadataUrl).append('"');
response.setHeader("WWW-Authenticate", header.toString());
response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized");
}
/**
* OAuth2 error as {@code "code - description"}, sanitized for a header/log line; null if none.
*/
private static String rejectionReason(AuthenticationException ex) {
if (!(ex instanceof OAuth2AuthenticationException oae)) {
return null;
}
OAuth2Error error = oae.getError();
if (error == null) {
return null;
}
String description = error.getDescription();
String combined =
(description == null || description.isBlank())
? error.getErrorCode()
: error.getErrorCode() + " - " + description;
return combined == null ? null : combined.replaceAll("[\\r\\n\"]", " ").trim();
}
/** host[:port] from forwarded headers when present, else the servlet host/port. */
private static String forwardedHost(HttpServletRequest request, String scheme) {
String host = firstForwarded(request, "X-Forwarded-Host", null);
@@ -0,0 +1,195 @@
package stirling.software.proprietary.mcp.security;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import stirling.software.common.model.ApplicationProperties;
/**
* Startup sanity-checks for MCP config; {@link McpSecurityConfig} logs the findings at boot so a
* misconfigured /mcp endpoint shows up in the logs instead of as a later rejected-token 401.
*/
public final class McpConfigValidator {
public enum Severity {
WARN,
INFO
}
public record Finding(Severity severity, String message) {}
private McpConfigValidator() {}
/** Inspect the resolved MCP config and return ordered findings (most actionable first). */
public static List<Finding> validate(ApplicationProperties.Mcp mcp) {
List<Finding> findings = new ArrayList<>();
ApplicationProperties.Mcp.Auth auth = mcp.getAuth();
if ("apikey".equalsIgnoreCase(auth.getMode())) {
findings.add(
info(
"auth mode = apikey - clients send a Stirling API key via X-API-KEY (or"
+ " Authorization: Bearer <key>); no external IdP needed. The key"
+ " must belong to a provisioned, enabled account (Account -> API"
+ " Keys)."));
return findings;
}
// Anything that isn't exactly "apikey" runs the OAuth chain (mirrors isApiKeyMode()).
String mode = auth.getMode();
if (mode != null && !mode.isBlank() && !"oauth".equalsIgnoreCase(mode.trim())) {
findings.add(
warn(
"mcp.auth.mode='"
+ mode
+ "' is not recognized (expected 'oauth' or 'apikey'); it falls"
+ " back to the OAuth chain, which rejects every token unless"
+ " issuer-uri and resource-id are set. A near-miss like"
+ " 'api-key' is NOT treated as API-key mode."));
}
findings.add(info("auth mode = oauth - running as an OAuth2 resource server for /mcp."));
if (isBlank(auth.getIssuerUri())) {
findings.add(
warn(
"mcp.auth.issuer-uri is not set: the JWT decoder fails closed and rejects"
+ " every token. Set it to your IdP issuer that publishes"
+ " /.well-known/openid-configuration (e.g."
+ " https://login.microsoftonline.com/<tenant>/v2.0)."));
} else if (!looksLikeUrl(auth.getIssuerUri())) {
findings.add(
warn(
"mcp.auth.issuer-uri='"
+ auth.getIssuerUri()
+ "' does not look like an http(s) URL."));
}
boolean hasResourceId = !isBlank(auth.getResourceId());
boolean hasAcceptedAudiences =
auth.getAcceptedAudiences().stream().anyMatch(a -> !isBlank(a));
if (!hasResourceId && !hasAcceptedAudiences) {
findings.add(
warn(
"neither mcp.auth.resource-id nor mcp.auth.accepted-audiences is set: the"
+ " audience validator fails closed and rejects every token (RFC"
+ " 8707). Set resource-id to this server's public /mcp URL, or"
+ " accepted-audiences to the audience your IdP actually mints."));
} else {
if (hasResourceId && !looksLikeUrl(auth.getResourceId())) {
findings.add(
warn(
"mcp.auth.resource-id='"
+ auth.getResourceId()
+ "' is not an http(s) URL: the token aud must match it"
+ " exactly (scheme, host and port included)."));
} else if (hasResourceId && !auth.getResourceId().endsWith("/mcp")) {
findings.add(
warn(
"mcp.auth.resource-id='"
+ auth.getResourceId()
+ "' does not end in /mcp: it must match the public URL"
+ " clients call and the audience your IdP puts in the"
+ " token."));
}
if (hasAcceptedAudiences) {
findings.add(
info(
"mcp.auth.accepted-audiences="
+ auth.getAcceptedAudiences()
+ " - tokens whose aud matches any of these are accepted, the"
+ " escape hatch for IdPs that can't mint a resource-specific"
+ " audience (e.g. an Entra ID app id, or Supabase's"
+ " aud=authenticated)."));
} else {
findings.add(
info(
"audience binding is strict (token aud must equal"
+ " mcp.auth.resource-id). If your IdP can't mint that - e.g."
+ " Entra ID issues aud=<client-id> - set"
+ " mcp.auth.accepted-audiences to the audience it actually"
+ " emits."));
}
}
if (isBlank(auth.getJwksUri())) {
findings.add(
info(
"mcp.auth.jwks-uri not set - signing keys are auto-discovered from the"
+ " issuer's OpenID configuration."));
}
if ("sub".equalsIgnoreCase(auth.getUsernameClaim()) && auth.isRequireExistingAccount()) {
findings.add(
warn(
"mcp.auth.username-claim='sub' with require-existing-account=true: many"
+ " IdPs (e.g. Entra ID, Google) set 'sub' to an opaque id that won't"
+ " match a Stirling username. Set mcp.auth.username-claim to 'email'"
+ " or 'preferred_username', or provision accounts keyed by sub."));
}
if (!auth.isRequireExistingAccount()) {
findings.add(
warn(
"mcp.auth.require-existing-account=false: any token your IdP signs can"
+ " invoke MCP tools even if its subject has no Stirling account. Set"
+ " it true unless you intend open access for every IdP-valid"
+ " token."));
}
if (mcp.isScopesEnabled()) {
findings.add(
info(
"mcp.scopes-enabled=true - the IdP must mint 'mcp.tools.read' and"
+ " 'mcp.tools.write' scopes or clients are rejected; set"
+ " mcp.scopes-enabled=false if it can only issue coarse tokens."));
}
List<String> allowed = mcp.getAllowedOperations();
List<String> blocked = mcp.getBlockedOperations();
if (allowed != null && !allowed.isEmpty()) {
findings.add(
info(
"mcp.allowed-operations is a strict allow-list of "
+ allowed.size()
+ " operation(s); every other tool is hidden, so a wrong or"
+ " typo'd id silently exposes nothing."));
List<String> shadowed =
blocked == null
? List.of()
: allowed.stream().filter(blocked::contains).toList();
if (!shadowed.isEmpty()) {
findings.add(
warn(
"mcp operation(s) "
+ shadowed
+ " are in both allowed-operations and blocked-operations;"
+ " blocked wins, so they are hidden."));
}
}
if (findings.stream().noneMatch(f -> f.severity() == Severity.WARN)) {
findings.add(info("OAuth settings look complete."));
}
return findings;
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
private static boolean looksLikeUrl(String value) {
String lower = value.toLowerCase(Locale.ROOT);
return lower.startsWith("http://") || lower.startsWith("https://");
}
private static Finding warn(String message) {
return new Finding(Severity.WARN, message);
}
private static Finding info(String message) {
return new Finding(Severity.INFO, message);
}
}
@@ -77,24 +77,14 @@ public class McpSecurityConfig {
}
@PostConstruct
void warnIfMisconfigured() {
ApplicationProperties.Mcp mcp = applicationProperties.getMcp();
if (isApiKeyMode()) {
log.info(
"MCP auth mode = apikey: clients authenticate with a Stirling per-user API key"
+ " (X-API-KEY header). No OAuth issuer required.");
} else {
if (mcp.getAuth().getIssuerUri().isBlank()) {
log.warn(
"MCP enabled but mcp.auth.issuer-uri is blank - JWT decoder will reject"
+ " every token (fail-closed). Set mcp.auth.issuer-uri and"
+ " mcp.auth.resource-id before exposing /mcp to clients.");
}
if (mcp.getAuth().getResourceId().isBlank()) {
log.warn(
"MCP enabled but mcp.auth.resource-id is blank - audience validator will"
+ " reject every token. Set this to the public URL of the MCP"
+ " endpoint (RFC 8707).");
void validateConfigOnStartup() {
log.info("MCP server enabled - validating configuration:");
for (McpConfigValidator.Finding finding :
McpConfigValidator.validate(applicationProperties.getMcp())) {
if (finding.severity() == McpConfigValidator.Severity.WARN) {
log.warn("MCP config: {}", finding.message());
} else {
log.info("MCP config: {}", finding.message());
}
}
}
@@ -5,6 +5,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
@@ -27,6 +28,7 @@ import stirling.software.proprietary.policy.model.Policy;
* defended: an operator who roots an allowlist on a symlink to a sensitive location is trusted.
*/
@Component
@Profile("saas")
public class FolderAccessGuard {
public static final String FOLDER_TYPE = "folder";
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.config;
import java.util.List;
import java.util.Objects;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
@@ -21,6 +22,7 @@ import stirling.software.proprietary.policy.model.Policy;
*/
@Component
@RequiredArgsConstructor
@Profile("saas")
public class PolicyAccessGuard {
private final UserServiceInterface userService;
@@ -6,6 +6,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
@@ -36,6 +37,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.job.JobResponse;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
@@ -63,6 +65,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
@Hidden
@RequiredArgsConstructor
@Tag(name = "Policies", description = "Run tool pipelines on the backend")
@Profile("saas")
public class PolicyController {
private final PolicyRunner policyRunner;
@@ -73,6 +76,7 @@ public class PolicyController {
private final PolicyManagementAuthority policyManagementAuthority;
private final ApplicationProperties applicationProperties;
private final TempFileManager tempFileManager;
private final JobOwnershipService jobOwnershipService;
@PostMapping(value = "/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
@@ -143,6 +147,35 @@ public class PolicyController {
return ResponseEntity.ok(PolicyRunView.of(run));
}
@GetMapping("/runs")
@Operation(
summary = "List the caller's stored-policy runs",
description =
"Returns the caller's in-flight and recently-finished stored-policy runs (within"
+ " the run-retention window). The frontend reconciles these on load so a"
+ " run started before a refresh/crash is rediscovered and its outputs"
+ " collected, rather than orphaned on the backend. Ad-hoc runs (no"
+ " policy id) are excluded.")
public List<PolicyRunView> listRuns() {
return runRegistry.all().stream()
.filter(run -> run.getPolicyId() != null)
.filter(run -> ownedByCurrentUser(run.getRunId()))
.map(PolicyRunView::of)
.toList();
}
/**
* Whether the run is owned by the current user, derived purely from the existing scoping
* methods: stripping then re-applying the scope reproduces the run's key only when its owner
* prefix matches the caller's. No auth (single-user) owns everything. Avoids duplicating the
* scoped-key format here.
*/
private boolean ownedByCurrentUser(String runId) {
return jobOwnershipService
.createScopedJobKey(jobOwnershipService.extractJobId(runId))
.equals(runId);
}
// --- Policy management ---
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@@ -9,6 +9,7 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import org.slf4j.MDC;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
@@ -53,6 +54,7 @@ import stirling.software.proprietary.service.DownstreamEntitlementError;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
public class PolicyEngine {
// Admission weight for one run. Weighted heavy: a run chains many tools and holds intermediate
@@ -81,13 +83,27 @@ public class PolicyEngine {
*/
public PolicyRunHandle submit(
PipelineDefinition definition, PolicyInputs inputs, PolicyProgressListener listener) {
return submit(definition, inputs, listener, null);
}
/**
* As {@link #submit(PipelineDefinition, PolicyInputs, PolicyProgressListener)}, recording the
* originating stored policy's id on the run ({@code null} for ad-hoc pipelines). The id lets a
* client attribute a run it rediscovers via {@code GET /policies/runs} after losing local state
* (e.g. a refresh before it recorded the run), so a finished run is never orphaned server-side.
*/
public PolicyRunHandle submit(
PipelineDefinition definition,
PolicyInputs inputs,
PolicyProgressListener listener,
String policyId) {
// Ad-hoc run (no stored policy): bill whoever kicked it off and own the outputs as them
// too.
// Capture the principal on this (request) thread — it does not survive the hop onto the
// async
// worker.
String principal = currentActingPrincipal();
return submitForPrincipal(principal, principal, definition, inputs, listener);
return submitForPrincipal(principal, principal, policyId, definition, inputs, listener);
}
/** Run a stored policy on demand. {@code enabled} gates triggers, not explicit runs. */
@@ -103,12 +119,13 @@ public class PolicyEngine {
String triggeringUser = currentActingPrincipal();
String fileOwner = triggeringUser != null ? triggeringUser : policy.owner();
return submitForPrincipal(
policy.owner(), fileOwner, policy.toDefinition(), inputs, listener);
policy.owner(), fileOwner, policy.id(), policy.toDefinition(), inputs, listener);
}
private PolicyRunHandle submitForPrincipal(
String billingPrincipal,
String fileOwner,
String policyId,
PipelineDefinition definition,
PolicyInputs inputs,
PolicyProgressListener listener) {
@@ -116,7 +133,7 @@ public class PolicyEngine {
// ownership check passes. No-op when security is off.
String runId = jobOwnershipService.createScopedJobKey(UUID.randomUUID().toString());
taskManager.createTask(runId);
PolicyRun run = new PolicyRun(runId, definition);
PolicyRun run = new PolicyRun(runId, policyId, definition);
registry.register(run);
CompletableFuture<PolicyRun> completion = new CompletableFuture<>();
PolicyProgressListener tracking = trackingListener(runId, run, listener);
@@ -116,6 +116,10 @@ public class PolicyExecutor {
ToolResult r = callEndpoint(step, inputFiles, supportingFiles);
files.addAll(r.files());
report = r.report();
} else if (inputFiles.isEmpty()) {
ToolResult r = callEndpoint(step, List.of(), supportingFiles);
files.addAll(r.files());
report = r.report();
} else {
for (Resource file : inputFiles) {
ToolResult r = callEndpoint(step, List.of(file), supportingFiles);
@@ -9,6 +9,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import jakarta.annotation.PreDestroy;
@@ -28,6 +29,7 @@ import stirling.software.proprietary.policy.model.PolicyRun;
*/
@Slf4j
@Service
@Profile("saas")
public class PolicyRunRegistry {
private final Map<String, PolicyRun> runs = new ConcurrentHashMap<>();
@@ -4,6 +4,7 @@ import java.io.IOException;
import java.util.List;
import java.util.function.Consumer;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -26,6 +27,7 @@ import stirling.software.proprietary.policy.progress.PolicyProgressListener;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
public class PolicyRunner {
private final PolicyEngine policyEngine;
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.engine;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -21,6 +22,7 @@ import stirling.software.proprietary.policy.trigger.PolicyTrigger;
*/
@Service
@RequiredArgsConstructor
@Profile("saas")
public class PolicyValidator {
private final List<PolicyTrigger> triggers;
@@ -9,6 +9,7 @@ import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
@@ -33,6 +34,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
public class FolderInputSource implements InputSource {
private static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
@@ -17,6 +17,10 @@ import stirling.software.common.model.job.ResultFile;
public class PolicyRun {
private final String runId;
/** ID of the stored policy that produced this run; null for ad-hoc pipelines. */
private final String policyId;
private final PipelineDefinition definition;
private final Instant createdAt = Instant.now();
@@ -45,8 +49,9 @@ public class PolicyRun {
private volatile List<ResultFile> outputs = List.of();
private volatile Instant updatedAt = Instant.now();
public PolicyRun(String runId, PipelineDefinition definition) {
public PolicyRun(String runId, String policyId, PipelineDefinition definition) {
this.runId = runId;
this.policyId = policyId;
this.definition = definition;
}
@@ -10,23 +10,28 @@ import stirling.software.common.model.job.ResultFile;
*/
public record PolicyRunView(
String runId,
String policyId,
PolicyRunStatus status,
int currentStep,
int stepCount,
String error,
String errorCode,
Boolean errorSubscribed,
List<ResultFile> outputs) {
List<ResultFile> outputs,
/** When the run was created, epoch millis, so a rediscovered run shows its real age. */
long createdAt) {
public static PolicyRunView of(PolicyRun run) {
return new PolicyRunView(
run.getRunId(),
run.getPolicyId(),
run.getStatus(),
run.getCurrentStep(),
run.stepCount(),
run.getError(),
run.getErrorCode(),
run.getErrorSubscribed(),
run.getOutputs());
run.getOutputs(),
run.getCreatedAt().toEpochMilli());
}
}
@@ -9,6 +9,7 @@ import java.util.List;
import java.util.UUID;
import org.apache.commons.io.FilenameUtils;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -30,6 +31,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
public class FolderOutputSink implements PolicyOutputSink {
static final String TYPE = FolderAccessGuard.FOLDER_TYPE;
@@ -5,6 +5,7 @@ import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
@@ -22,6 +23,7 @@ import stirling.software.proprietary.policy.model.OutputSpec;
*/
@Service
@RequiredArgsConstructor
@Profile("saas")
public class InlineOutputSink implements PolicyOutputSink {
private static final String TYPE = "inline";
@@ -4,6 +4,7 @@ import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -18,6 +19,7 @@ import tools.jackson.databind.ObjectMapper;
*/
@Service
@RequiredArgsConstructor
@Profile("saas")
public class JpaPolicyStore implements PolicyStore {
private final PolicyRepository repository;
@@ -20,6 +20,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -45,6 +46,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
public class FolderWatchTrigger implements PolicyTrigger {
private static final String TYPE = "folder-watch";
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.trigger;
import java.util.List;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -12,6 +13,7 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
public class PolicyTriggerManager implements SmartLifecycle {
private final List<PolicyTrigger> triggers;
@@ -10,6 +10,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
@@ -31,6 +32,7 @@ import tools.jackson.databind.ObjectMapper;
@Slf4j
@Service
@RequiredArgsConstructor
@Profile("saas")
public class ScheduleTrigger implements PolicyTrigger {
private static final String TYPE = "schedule";
@@ -202,7 +202,8 @@ public class SecurityConfiguration {
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN"));
"X-XSRF-TOKEN",
"X-Browser-Id"));
cfg.setExposedHeaders(
List.of(
@@ -978,28 +978,33 @@ public class UserController {
}
}
// Lists enabled users for the signing user picker, scoped by storage.signing.userListScope:
// 'org' (default) = whole instance, anything else = caller's team only (fail-closed).
// Lists enabled users for the signing picker; 'org' scope = instance-wide, else caller's team.
@GetMapping("/users")
public ResponseEntity<List<UserSummaryDTO>> listUsers(Principal principal) {
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Optional<User> callerOpt = userService.findByUsernameIgnoreCase(principal.getName());
// Anonymous (SaaS) accounts must never enumerate users, in any scope or team.
if (callerOpt.map(UserController::isAnonymousUser).orElse(false)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
// Fail-closed: only literal "org" opens the whole instance; anything else scopes to team.
String scope = applicationProperties.getStorage().getSigning().getUserListScope();
boolean teamScoped = !"org".equalsIgnoreCase(scope == null ? "" : scope.trim());
List<User> source;
if (teamScoped) {
Optional<User> callerOpt = userService.findByUsernameIgnoreCase(principal.getName());
if (callerOpt.isEmpty() || callerOpt.get().getTeam() == null) {
// No team: return only the caller rather than leak the org.
Team callerTeam = callerOpt.map(User::getTeam).orElse(null);
if (callerTeam == null || isSystemTeam(callerTeam)) {
// No team or a shared system team: return only the caller, not the team's members.
source = callerOpt.map(List::of).orElse(List.of());
} else {
// KNOWN LIMITATION: scopes the team via the single User.team FK - correct while
// acceptInvitation() collapses users to one team; revisit if multi-team enabled.
source = userRepository.findAllByTeamId(callerOpt.get().getTeam().getId());
// Scopes via the single User.team FK; revisit if multi-team membership is added.
source = userRepository.findAllByTeamId(callerTeam.getId());
}
} else {
source = userRepository.findAll();
@@ -1011,6 +1016,18 @@ public class UserController {
return ResponseEntity.ok(users);
}
// SaaS anonymous accounts, which must not enumerate users.
private static boolean isAnonymousUser(User user) {
return AuthenticationType.ANONYMOUS.name().equalsIgnoreCase(user.getAuthenticationType());
}
// System teams (Default/Internal) are not enumerable through the signing picker.
private static boolean isSystemTeam(Team team) {
String name = team.getName();
return TeamService.DEFAULT_TEAM_NAME.equalsIgnoreCase(name)
|| TeamService.INTERNAL_TEAM_NAME.equalsIgnoreCase(name);
}
private UserSummaryDTO toUserSummaryDTO(User user) {
return new UserSummaryDTO(
user.getId(),
@@ -105,15 +105,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
Stream<Long> findByUsernameIsNullAndCreatedAtBefore(
@Param("cutoffDate") LocalDateTime cutoffDate);
/** Users with an API key but no row in {@code user_credits}. */
@Query(
value =
"SELECT u.* FROM users u "
+ "LEFT JOIN user_credits uc ON uc.user_id = u.user_id "
+ "WHERE u.api_key IS NOT NULL AND uc.user_id IS NULL",
nativeQuery = true)
List<User> findUsersWithApiKeyButNoCredits();
/** Single-shot UPDATE that reassigns a user to a different team. */
@Modifying
@Query("UPDATE User u SET u.team.id = :teamId WHERE u.id = :userId")
@@ -0,0 +1,52 @@
package stirling.software.proprietary.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class CustomAuditEventRepositoryTest {
@Test
void shortPrincipalPassesThroughUnchanged() {
assertEquals(
"alice@example.com", CustomAuditEventRepository.safePrincipal("alice@example.com"));
}
@Test
void blankOrNullPrincipalBecomesAnonymous() {
assertEquals("anonymous", CustomAuditEventRepository.safePrincipal(null));
assertEquals("anonymous", CustomAuditEventRepository.safePrincipal(" "));
}
@Test
void tokenShapedPrincipalIsHashedNotStoredVerbatim() {
String jwt = "eyJhbGciOiJSUzI1NiJ9." + "x".repeat(1400);
String safe = CustomAuditEventRepository.safePrincipal(jwt);
assertNotEquals(jwt, safe);
assertFalse(safe.contains(jwt), "raw token must not be stored");
assertTrue(safe.startsWith("token:"));
assertTrue(safe.length() <= 255, "must fit the principal column");
}
@Test
void distinctTokensStayDistinguishable() {
String a = CustomAuditEventRepository.safePrincipal("eyJ" + "a".repeat(400));
String b = CustomAuditEventRepository.safePrincipal("eyJ" + "b".repeat(400));
assertNotEquals(a, b, "different tokens must map to different audit principals");
}
@Test
void sameTokenHashesStably() {
String token = "eyJ" + "c".repeat(400);
assertEquals(
CustomAuditEventRepository.safePrincipal(token),
CustomAuditEventRepository.safePrincipal(token));
}
}
@@ -11,6 +11,8 @@ import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -58,4 +60,30 @@ class McpAuthenticationEntryPointTest {
verify(resp).setHeader(eq("WWW-Authenticate"), header.capture());
assertTrue(header.getValue().contains("http://localhost:8080" + META), header.getValue());
}
@Test
void surfacesRejectionReasonWhenTokenPresented() throws Exception {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getScheme()).thenReturn("https");
when(req.getServerName()).thenReturn("mcp.example.com");
when(req.getServerPort()).thenReturn(443);
when(req.getHeader("Authorization")).thenReturn("Bearer bad.token");
HttpServletResponse resp = mock(HttpServletResponse.class);
OAuth2Error error =
new OAuth2Error(
"invalid_token",
"Token audience does not include this server's resource id"
+ " (https://mcp.example.com/mcp).",
null);
entryPoint.commence(req, resp, new OAuth2AuthenticationException(error));
ArgumentCaptor<String> header = ArgumentCaptor.forClass(String.class);
verify(resp).setHeader(eq("WWW-Authenticate"), header.capture());
String www = header.getValue();
assertTrue(
www.contains("error_description=\"invalid_token - Token audience does not include"),
"must surface the rejection reason, got: " + www);
}
}
@@ -0,0 +1,151 @@
package stirling.software.proprietary.mcp.security;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
class McpConfigValidatorTest {
private static ApplicationProperties.Mcp newMcp() {
return new ApplicationProperties.Mcp();
}
private static boolean hasWarn(List<McpConfigValidator.Finding> findings, String needle) {
return findings.stream()
.anyMatch(
f ->
f.severity() == McpConfigValidator.Severity.WARN
&& f.message().contains(needle));
}
@Test
void apiKeyModeSkipsOAuthChecks() {
ApplicationProperties.Mcp mcp = newMcp();
mcp.getAuth().setMode("apikey");
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(mcp);
assertEquals(1, findings.size());
assertEquals(McpConfigValidator.Severity.INFO, findings.get(0).severity());
assertTrue(findings.get(0).message().contains("apikey"));
}
@Test
void blankIssuerAndResourceProduceWarnings() {
// Defaults: oauth mode, blank issuer-uri and resource-id.
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(newMcp());
assertTrue(hasWarn(findings, "issuer-uri"), "blank issuer must warn");
assertTrue(hasWarn(findings, "resource-id"), "blank resource-id must warn");
}
@Test
void subClaimWithRequireAccountWarns() {
ApplicationProperties.Mcp mcp = newMcp();
mcp.getAuth().setIssuerUri("https://issuer.example.com");
mcp.getAuth().setResourceId("https://host.example.com/mcp");
// Defaults username-claim=sub, require-existing-account=true.
assertTrue(hasWarn(McpConfigValidator.validate(mcp), "username-claim='sub'"));
}
@Test
void completeConfigReportsReadyWithNoWarnings() {
ApplicationProperties.Mcp mcp = newMcp();
mcp.getAuth().setIssuerUri("https://issuer.example.com");
mcp.getAuth().setResourceId("https://host.example.com/mcp");
mcp.getAuth().setUsernameClaim("email");
mcp.setScopesEnabled(false);
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(mcp);
assertTrue(
findings.stream().noneMatch(f -> f.severity() == McpConfigValidator.Severity.WARN),
"complete config must have no warnings");
assertTrue(findings.stream().anyMatch(f -> f.message().contains("look complete")));
}
@Test
void acceptedAudiencesCoverBlankResourceId() {
ApplicationProperties.Mcp mcp = newMcp();
mcp.getAuth().setIssuerUri("https://issuer.example.com");
mcp.getAuth().setResourceId("");
mcp.getAuth().setAcceptedAudiences(List.of("authenticated"));
mcp.getAuth().setUsernameClaim("email");
mcp.setScopesEnabled(false);
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(mcp);
assertFalse(
hasWarn(findings, "fails closed"),
"accepted-audiences must satisfy audience binding without a resource id");
assertTrue(
findings.stream().anyMatch(f -> f.message().contains("accepted-audiences=")),
"configured accepted-audiences should be surfaced");
}
@Test
void strictAudienceHintsAtAcceptedAudiencesEscapeHatch() {
ApplicationProperties.Mcp mcp = newMcp();
mcp.getAuth().setIssuerUri("https://issuer.example.com");
mcp.getAuth().setResourceId("https://host.example.com/mcp");
mcp.getAuth().setUsernameClaim("email");
mcp.setScopesEnabled(false);
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(mcp);
assertTrue(
findings.stream().anyMatch(f -> f.message().contains("accepted-audiences")),
"should point coarse-audience IdPs at accepted-audiences");
}
@Test
void unrecognizedModeWarnsAboutOAuthFallback() {
ApplicationProperties.Mcp mcp = newMcp();
mcp.getAuth().setMode("api-key"); // near-miss typo that silently runs the OAuth chain
assertTrue(hasWarn(McpConfigValidator.validate(mcp), "is not recognized"));
}
@Test
void requireExistingAccountFalseWarnsAboutOpenAccess() {
ApplicationProperties.Mcp mcp = newMcp();
mcp.getAuth().setIssuerUri("https://issuer.example.com");
mcp.getAuth().setResourceId("https://host.example.com/mcp");
mcp.getAuth().setUsernameClaim("email");
mcp.getAuth().setRequireExistingAccount(false);
assertTrue(hasWarn(McpConfigValidator.validate(mcp), "require-existing-account=false"));
}
@Test
void nonUrlResourceIdWarns() {
ApplicationProperties.Mcp mcp = newMcp();
mcp.getAuth().setIssuerUri("https://issuer.example.com");
mcp.getAuth().setResourceId("localhost:8080/mcp"); // missing scheme
assertTrue(hasWarn(McpConfigValidator.validate(mcp), "is not an http(s) URL"));
}
@Test
void allowListIsFlaggedAndOverlapWithBlockListWarns() {
ApplicationProperties.Mcp mcp = newMcp();
mcp.getAuth().setIssuerUri("https://issuer.example.com");
mcp.getAuth().setResourceId("https://host.example.com/mcp");
mcp.setAllowedOperations(List.of("merge-pdfs", "split-pdf"));
mcp.setBlockedOperations(List.of("split-pdf"));
List<McpConfigValidator.Finding> findings = McpConfigValidator.validate(mcp);
assertTrue(
findings.stream().anyMatch(f -> f.message().contains("strict allow-list")),
"an allow-list should be surfaced");
assertTrue(hasWarn(findings, "blocked wins"), "allowed+blocked overlap should warn");
}
}
@@ -2,6 +2,7 @@ package stirling.software.proprietary.policy.engine;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -149,6 +150,40 @@ class PolicyExecutorTest {
verify(internalApiClient, times(2)).post(eq(ROTATE), any());
}
@Test
void noInputGeneratorEndpointIsCalledOnceWithNoFile() throws IOException {
// A "create" workflow has no source documents: a generator tool (e.g.
// create-pdf-from-html-agent) produces its output purely from parameters. Per-file
// dispatch would skip it entirely (zero files = zero calls), so it must still run once.
String createPdf = "/api/v1/ai/tools/create-pdf-from-html-agent";
when(toolMetadataService.isMultiInput(createPdf)).thenReturn(false);
when(toolMetadataService.shouldUnpackZipResponse(createPdf)).thenReturn(false);
stubEndpoint(createPdf, pdf("generated", "purchase-order.pdf"));
PolicyExecutionResult result =
executor.execute(
definition(
new PipelineStep(
createPdf,
Map.of(
"htmlContent",
"<p>hi</p>",
"filename",
"purchase-order.pdf"))),
PolicyInputs.of(List.of()),
PolicyProgressListener.NOOP);
assertEquals(1, result.files().size());
assertEquals("purchase-order.pdf", result.files().get(0).getFilename());
@SuppressWarnings("unchecked")
ArgumentCaptor<MultiValueMap<String, Object>> bodyCaptor =
ArgumentCaptor.forClass(MultiValueMap.class);
verify(internalApiClient, times(1)).post(eq(createPdf), bodyCaptor.capture());
// No document stream: the body carries only the generator's parameters, no fileInput.
assertNull(bodyCaptor.getValue().get("fileInput"));
}
@Test
void zipResponseIsUnpackedIntoIndividualFiles() throws IOException {
when(toolMetadataService.isMultiInput(SPLIT)).thenReturn(false);
@@ -95,7 +95,7 @@ class PolicyRunRegistryTest {
}
private PolicyRun register(String runId) {
PolicyRun run = new PolicyRun(runId, new PipelineDefinition(runId, List.of(), null));
PolicyRun run = new PolicyRun(runId, null, new PipelineDefinition(runId, List.of(), null));
registry.register(run);
return run;
}
@@ -2,7 +2,6 @@ package stirling.software.proprietary.security.controller.api;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -28,6 +27,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.repository.TeamRepository;
@@ -195,8 +195,22 @@ class UserControllerTest {
.andExpect(jsonPath("$[0].username").value("a@alpha.com"))
.andExpect(jsonPath("$[1].username").value("b@alpha.com"));
// Caller is resolved (for the anonymous-gate) but org scope still uses findAll, not team.
verify(userRepository, never()).findAllByTeamId(any());
}
@Test
void listUsersForbiddenForAnonymousCaller() throws Exception {
// Anonymous SaaS accounts must never enumerate users, regardless of scope.
User anon = user(1L, "anon_abc", true, team(1L, TeamService.DEFAULT_TEAM_NAME));
anon.setAuthenticationType(AuthenticationType.ANONYMOUS);
when(userService.findByUsernameIgnoreCase("anon_abc")).thenReturn(Optional.of(anon));
mockMvc.perform(get("/api/v1/user/users").principal(auth("anon_abc")))
.andExpect(status().isForbidden());
verify(userRepository, never()).findAll();
verify(userRepository, never()).findAllByTeamId(any());
verify(userService, never()).findByUsernameIgnoreCase(anyString());
}
@Test
@@ -262,6 +276,39 @@ class UserControllerTest {
verify(userRepository, never()).findAll();
}
@Test
void listUsersTeamScopeOnDefaultTeamReturnsSelfOnly() throws Exception {
// A caller on a shared system team must not enumerate its members.
applicationProperties.getStorage().getSigning().setUserListScope("team");
Team defaultTeam = team(1L, TeamService.DEFAULT_TEAM_NAME);
User caller = user(1L, "new@saas.com", true, defaultTeam);
when(userService.findByUsernameIgnoreCase("new@saas.com")).thenReturn(Optional.of(caller));
mockMvc.perform(get("/api/v1/user/users").principal(auth("new@saas.com")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].username").value("new@saas.com"));
verify(userRepository, never()).findAllByTeamId(any());
verify(userRepository, never()).findAll();
}
@Test
void listUsersTeamScopeOnInternalTeamReturnsSelfOnly() throws Exception {
applicationProperties.getStorage().getSigning().setUserListScope("team");
Team internalTeam = team(2L, TeamService.INTERNAL_TEAM_NAME);
User caller = user(1L, "svc@saas.com", true, internalTeam);
when(userService.findByUsernameIgnoreCase("svc@saas.com")).thenReturn(Optional.of(caller));
mockMvc.perform(get("/api/v1/user/users").principal(auth("svc@saas.com")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].username").value("svc@saas.com"));
verify(userRepository, never()).findAllByTeamId(any());
verify(userRepository, never()).findAll();
}
@Test
void listUsersFailsClosedOnUnrecognisedScope() throws Exception {
// Any non-"org" value must restrict to the caller's team, not leak the instance.
@@ -51,10 +51,7 @@ import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.ProcessType;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
import stirling.software.saas.util.CreditHeaderUtils;
@RestController
@Profile("saas")
@@ -69,10 +66,7 @@ public class AiCreateController {
private final AiCreateSessionService sessionService;
private final AiCreateProxyService proxyService;
private final ObjectMapper objectMapper = new ObjectMapper();
private final CreditService creditService;
private final TeamCreditService teamCreditService;
private final UserRepository userRepository;
private final CreditHeaderUtils creditHeaderUtils;
private final JobChargeService jobChargeService;
@PostMapping("/sessions")
@@ -233,8 +227,7 @@ public class AiCreateController {
@PathVariable String sessionId, HttpServletRequest request) {
sessionService.getSessionForCurrentUser(sessionId);
log.info("AI create fillFields sessionId={}", sessionId);
return proxy(
"POST", "/api/create/sessions/" + sessionId + "/fields", request, false, false);
return proxy("POST", "/api/create/sessions/" + sessionId + "/fields", request, false);
}
@GetMapping(
@@ -243,20 +236,11 @@ public class AiCreateController {
public ResponseEntity<StreamingResponseBody> stream(
@PathVariable String sessionId, HttpServletRequest request) {
sessionService.getSessionForCurrentUser(sessionId);
return proxy(
"GET",
"/api/create/sessions/" + sessionId + "/stream",
request,
true,
true); // Add credits header: frontend endpoint that triggers AI
return proxy("GET", "/api/create/sessions/" + sessionId + "/stream", request, true);
}
private ResponseEntity<StreamingResponseBody> proxy(
String method,
String path,
HttpServletRequest request,
boolean acceptEventStream,
boolean includeCreditsHeader) {
String method, String path, HttpServletRequest request, boolean acceptEventStream) {
try {
HttpResponse<InputStream> response =
proxyService.forward(method, path, request, acceptEventStream);
@@ -270,11 +254,6 @@ public class AiCreateController {
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE);
}
// Add credit headers if requested
if (includeCreditsHeader) {
addCreditHeaders(headers);
}
StreamingResponseBody body =
outputStream -> {
try (InputStream inputStream = response.body()) {
@@ -302,31 +281,6 @@ public class AiCreateController {
.ifPresent(value -> headers.set(headerName, value));
}
/**
* Add credit headers to the response headers.
*
* @param headers The headers to add credit information to
*/
private void addCreditHeaders(HttpHeaders headers) {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
log.debug("[AI-CREATE] No authentication found, skipping credit header");
return;
}
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
int remainingCredits =
creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService);
if (remainingCredits >= 0) {
headers.set("X-Credits-Remaining", Integer.toString(remainingCredits));
log.warn("[AI-CREATE] Added X-Credits-Remaining header: {}", remainingCredits);
}
} catch (Exception e) {
log.error("[AI-CREATE] Failed to add credit header: {}", e.getMessage(), e);
}
}
public record CreateSessionRequest(
String prompt,
String docType,
@@ -9,8 +9,6 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -25,15 +23,9 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.ai.service.AiProxyService;
import stirling.software.saas.payg.cap.RequiresFeature;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
import stirling.software.saas.util.CreditHeaderUtils;
@RestController
@Profile("saas")
@@ -45,103 +37,89 @@ import stirling.software.saas.util.CreditHeaderUtils;
public class AiProxyController {
private final AiProxyService aiProxyService;
private final CreditService creditService;
private final TeamCreditService teamCreditService;
private final UserRepository userRepository;
private final CreditHeaderUtils creditHeaderUtils;
public AiProxyController(
AiProxyService aiProxyService,
CreditService creditService,
TeamCreditService teamCreditService,
UserRepository userRepository,
CreditHeaderUtils creditHeaderUtils) {
public AiProxyController(AiProxyService aiProxyService) {
this.aiProxyService = aiProxyService;
this.creditService = creditService;
this.teamCreditService = teamCreditService;
this.userRepository = userRepository;
this.creditHeaderUtils = creditHeaderUtils;
}
@PostMapping("/generate_section")
public ResponseEntity<StreamingResponseBody> generateSection(HttpServletRequest request) {
return proxy("POST", "/api/generate_section", request, false, false);
return proxy("POST", "/api/generate_section", request, false);
}
@PostMapping("/generate_all_sections")
public ResponseEntity<StreamingResponseBody> generateAllSections(HttpServletRequest request) {
return proxy("POST", "/api/generate_all_sections", request, false, false);
return proxy("POST", "/api/generate_all_sections", request, false);
}
@PostMapping("/intent/check")
public ResponseEntity<StreamingResponseBody> intentCheck(HttpServletRequest request) {
return proxy("POST", "/api/intent/check", request, false, false);
return proxy("POST", "/api/intent/check", request, false);
}
@PostMapping("/chat/route")
public ResponseEntity<StreamingResponseBody> chatRoute(HttpServletRequest request) {
return proxy("POST", "/api/chat/route", request, false, true);
return proxy("POST", "/api/chat/route", request, false);
}
@PostMapping("/chat/create-smart-folder")
public ResponseEntity<StreamingResponseBody> createSmartFolder(HttpServletRequest request) {
return proxy("POST", "/api/chat/create-smart-folder", request, false, true);
return proxy("POST", "/api/chat/create-smart-folder", request, false);
}
@PostMapping("/chat/info")
public ResponseEntity<StreamingResponseBody> chatInfo(HttpServletRequest request) {
return proxy("POST", "/api/chat/info", request, false, true);
return proxy("POST", "/api/chat/info", request, false);
}
@PostMapping("/pdf/answer")
public ResponseEntity<StreamingResponseBody> pdfAnswer(HttpServletRequest request) {
return proxy("POST", "/api/pdf/answer", request, false, false);
return proxy("POST", "/api/pdf/answer", request, false);
}
@PostMapping("/progressive_render")
public ResponseEntity<StreamingResponseBody> progressiveRender(HttpServletRequest request) {
return proxy("POST", "/api/progressive_render", request, false, false);
return proxy("POST", "/api/progressive_render", request, false);
}
@GetMapping("/versions/{userId}")
public ResponseEntity<StreamingResponseBody> versions(
@PathVariable("userId") String userId, HttpServletRequest request) {
return proxy("GET", "/api/versions/" + userId, request, false, false);
return proxy("GET", "/api/versions/" + userId, request, false);
}
@GetMapping("/style/{userId}")
public ResponseEntity<StreamingResponseBody> style(
@PathVariable("userId") String userId, HttpServletRequest request) {
return proxy("GET", "/api/style/" + userId, request, false, false);
return proxy("GET", "/api/style/" + userId, request, false);
}
@PostMapping("/style/{userId}")
public ResponseEntity<StreamingResponseBody> updateStyle(
@PathVariable("userId") String userId, HttpServletRequest request) {
return proxy("POST", "/api/style/" + userId, request, false, false);
return proxy("POST", "/api/style/" + userId, request, false);
}
@PostMapping("/import_template")
public ResponseEntity<StreamingResponseBody> importTemplate(HttpServletRequest request) {
return proxy("POST", "/api/import_template", request, false, false);
return proxy("POST", "/api/import_template", request, false);
}
@PostMapping("/edit/sessions")
public ResponseEntity<StreamingResponseBody> createEditSession(HttpServletRequest request) {
return proxy("POST", "/api/edit/sessions", request, false, false);
return proxy("POST", "/api/edit/sessions", request, false);
}
@PostMapping("/edit/sessions/{sessionId}/messages")
public ResponseEntity<StreamingResponseBody> editSessionMessage(
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
return proxy("POST", "/api/edit/sessions/" + sessionId + "/messages", request, false, true);
return proxy("POST", "/api/edit/sessions/" + sessionId + "/messages", request, false);
}
@PostMapping("/edit/sessions/{sessionId}/attachments")
public ResponseEntity<StreamingResponseBody> editSessionAttachment(
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
return proxy(
"POST", "/api/edit/sessions/" + sessionId + "/attachments", request, false, false);
return proxy("POST", "/api/edit/sessions/" + sessionId + "/attachments", request, false);
}
@PostMapping(
@@ -149,17 +127,17 @@ public class AiProxyController {
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity<StreamingResponseBody> runEditSession(
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
return proxy("POST", "/api/edit/sessions/" + sessionId + "/run", request, true, false);
return proxy("POST", "/api/edit/sessions/" + sessionId + "/run", request, true);
}
@GetMapping("/pdf-editor/document")
public ResponseEntity<StreamingResponseBody> pdfEditorDocument(HttpServletRequest request) {
return proxy("GET", "/api/pdf-editor/document", request, false, false);
return proxy("GET", "/api/pdf-editor/document", request, false);
}
@PostMapping("/pdf-editor/upload")
public ResponseEntity<StreamingResponseBody> pdfEditorUpload(HttpServletRequest request) {
return proxy("POST", "/api/pdf-editor/upload", request, false, false);
return proxy("POST", "/api/pdf-editor/upload", request, false);
}
@GetMapping("/output/**")
@@ -167,27 +145,22 @@ public class AiProxyController {
String requestUri = request.getRequestURI();
String prefix = request.getContextPath() + "/api/v1/ai/output/";
String path = requestUri.startsWith(prefix) ? requestUri.substring(prefix.length()) : "";
return proxy("GET", "/output/" + path, request, false, false);
return proxy("GET", "/output/" + path, request, false);
}
// Health endpoint at /api/v1/ai/health is owned by the proprietary AiEngineController; both
// proxy to the same backing AI engine. No need for credit-aware wrapping on a health probe.
/**
* Proxy method that optionally adds credit headers.
* Proxy method.
*
* @param method HTTP method
* @param path API path
* @param request The incoming request
* @param acceptEventStream Whether to accept event stream responses
* @param includeCreditsHeader Whether to add credit balance header
*/
private ResponseEntity<StreamingResponseBody> proxy(
String method,
String path,
HttpServletRequest request,
boolean acceptEventStream,
boolean includeCreditsHeader) {
String method, String path, HttpServletRequest request, boolean acceptEventStream) {
try {
// Forward to AI backend
HttpResponse<InputStream> aiResponse =
@@ -204,11 +177,6 @@ public class AiProxyController {
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE);
}
// Add credit headers if requested (after AI processing completes)
if (includeCreditsHeader) {
addCreditHeaders(headers);
}
StreamingResponseBody body =
outputStream -> {
try (InputStream inputStream = aiResponse.body()) {
@@ -247,30 +215,4 @@ public class AiProxyController {
}
});
}
/**
* Add credit headers to the response headers.
*
* @param headers The headers to add credit information to
*/
private void addCreditHeaders(HttpHeaders headers) {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
log.debug("[AI-PROXY] No authentication found, skipping credit header");
return;
}
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
int remainingCredits =
creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService);
if (remainingCredits >= 0) {
headers.set("X-Credits-Remaining", Integer.toString(remainingCredits));
log.warn("[AI-PROXY] Added X-Credits-Remaining header: {}", remainingCredits);
}
headers.set("X-Credit-Source", "AI_TOOL_CALL");
} catch (Exception e) {
log.error("[AI-PROXY] Failed to add credit header: {}", e.getMessage(), e);
}
}
}
@@ -1,31 +0,0 @@
package stirling.software.saas.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
import stirling.software.saas.interceptor.UnifiedCreditInterceptor;
// Legacy credit-billing path. Disabled in saas-PAYG by default — activate the legacy-credits
// profile explicitly (`--spring.profiles.active=saas,dev,legacy-credits`) if you need it back.
@Configuration
@Profile("saas & legacy-credits")
@RequiredArgsConstructor
public class CreditInterceptorConfig implements WebMvcConfigurer {
private final UnifiedCreditInterceptor unifiedCreditInterceptor;
private final CreditsProperties creditsProperties;
@Override
public void addInterceptors(InterceptorRegistry registry) {
if (creditsProperties.isEnabled()) {
registry.addInterceptor(unifiedCreditInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns(
"/api/v1/credits/**", "/api/v1/config/**", "/api/v1/info/**");
}
}
}
@@ -1,75 +0,0 @@
package stirling.software.saas.config;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import lombok.Data;
@Data
@Component
@Profile("saas")
@ConfigurationProperties(prefix = "credits")
public class CreditsProperties {
/** Whether the credits system is enabled */
private boolean enabled = true;
/** Credit allocations per billing cycle (monthly) */
private CycleAllocations cycle = new CycleAllocations();
/** Reset configuration */
private Reset reset = new Reset();
/** Error tracking configuration */
private Errors errors = new Errors();
/** Cache configuration */
private Cache cache = new Cache();
@Data
public static class CycleAllocations {
/** Whether admin role has unlimited credits */
private boolean adminUnlimited = true;
/** Credit allocations per billing cycle (monthly) per role */
private Map<String, Integer> allocations =
Map.of(
"ROLE_ADMIN", 1000,
"ROLE_PRO_USER", 500,
"ROLE_USER", 50,
"ROLE_LIMITED_API_USER", 10,
"ROLE_EXTRA_LIMITED_API_USER", 20,
"ROLE_WEB_ONLY_USER", 0,
"ROLE_DEMO_USER", 100);
}
@Data
public static class Reset {
/** Cron expression for monthly reset (default: 1st of month 02:00 UTC) */
private String cron = "0 0 2 1 * *";
/** Time zone for the reset schedule */
private String zone = "UTC";
}
@Data
public static class Errors {
/** How long error counts are tracked (in minutes) */
private int ttlMinutes = 60;
/** Number of free processing errors before charging */
private int freeProcessingErrors = 2;
}
@Data
public static class Cache {
/** Enable local Caffeine cache for error counts */
private boolean localEnabled = true;
/** Enable Redis cache for multi-instance deployments */
private boolean redisEnabled = false;
}
}
@@ -1,315 +0,0 @@
package stirling.software.saas.controller;
import java.util.Map;
import org.springframework.context.annotation.Profile;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.security.EnhancedJwtAuthenticationToken;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.CreditService.CreditSummary;
import stirling.software.saas.util.LogRedactionUtils;
// Legacy credit-billing endpoints. PAYG replaces this — gated behind legacy-credits profile.
@RestController
@Profile("saas & legacy-credits")
@RequestMapping("/api/v1/credits")
@Tag(name = "Credit Management", description = "Endpoints for managing user API credits")
@RequiredArgsConstructor
@Slf4j
public class CreditController {
private final CreditService creditService;
@GetMapping
@Hidden
@Operation(
summary = "Get user credit information",
description =
"Retrieve current credit balance and usage statistics for the authenticated user")
@ApiResponse(
responseCode = "200",
description = "Credit information retrieved successfully",
content = @Content(schema = @Schema(implementation = CreditSummary.class)))
public ResponseEntity<CreditSummary> getUserCredits(Authentication authentication) {
return ResponseEntity.ok(getCreditSummaryForAuthentication(authentication));
}
@PostMapping("/purchase")
@Hidden
@Operation(
summary = "Purchase additional credits",
description = "Add bought credits to user account (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Credits purchased successfully")
public ResponseEntity<Map<String, Object>> purchaseCredits(
@RequestParam("username") String username, @RequestParam("credits") int credits) {
if (credits <= 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits must be positive"));
}
try {
creditService.addBoughtCredits(username, credits);
log.info("Admin added {} credits to user: {}", credits, username);
return ResponseEntity.ok(Map.of("success", true, "creditsAdded", credits));
} catch (IllegalArgumentException e) {
log.warn("purchaseCredits rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@PostMapping("/purchase-by-supabase-id")
@Hidden
@Operation(
summary = "Purchase additional credits by Supabase ID",
description = "Add bought credits to user account using Supabase ID (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Credits purchased successfully")
public ResponseEntity<Map<String, Object>> purchaseCreditsBySupabaseId(
@RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) {
if (credits <= 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits must be positive"));
}
try {
creditService.addBoughtCreditsBySupabaseId(supabaseId, credits);
log.info(
"Admin added {} credits to user with Supabase ID: {}",
credits,
LogRedactionUtils.redactSupabaseId(supabaseId));
return ResponseEntity.ok(Map.of("success", true, "creditsAdded", credits));
} catch (IllegalArgumentException e) {
log.warn("purchaseCreditsBySupabaseId rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@GetMapping("/user/{username}")
@Hidden
@Operation(
summary = "Get credit information for specific user",
description = "Retrieve credit information for a specific user (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(
responseCode = "200",
description = "User credit information retrieved successfully",
content = @Content(schema = @Schema(implementation = CreditSummary.class)))
public ResponseEntity<CreditSummary> getUserCreditsAdmin(
@PathVariable("username") String username) {
CreditSummary summary = creditService.getCreditSummary(username);
return ResponseEntity.ok(summary);
}
@GetMapping("/user-by-supabase-id/{supabaseId}")
@Hidden
@Operation(
summary = "Get credit information for specific user by Supabase ID",
description =
"Retrieve credit information for a specific user using Supabase ID (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(
responseCode = "200",
description = "User credit information retrieved successfully",
content = @Content(schema = @Schema(implementation = CreditSummary.class)))
public ResponseEntity<CreditSummary> getUserCreditsAdminBySupabaseId(
@PathVariable("supabaseId") String supabaseId) {
CreditSummary summary = creditService.getCreditSummaryBySupabaseId(supabaseId);
return ResponseEntity.ok(summary);
}
@PostMapping("/reset-cycle")
@Hidden
@Operation(
summary = "Reset cycle credits for all users",
description = "Manually trigger cycle credit reset for all users (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Cycle credits reset successfully")
public ResponseEntity<String> resetCycleCredits() {
creditService.resetCycleCreditsForAllUsers();
log.info("Manual cycle credit reset triggered by admin");
return ResponseEntity.ok("Cycle credits reset successfully for all users");
}
@PostMapping("/set-bought-credits")
@Hidden
@Operation(
summary = "Set user's bought credits to a specific amount",
description =
"Hard set the bought credits balance for a specific user to an exact amount (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Bought credits set successfully")
public ResponseEntity<Map<String, Object>> setBoughtCredits(
@RequestParam("username") String username, @RequestParam("credits") int credits) {
if (credits < 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
}
try {
creditService.setBoughtCredits(username, credits);
log.info("Admin set bought credits to {} for user: {}", credits, username);
return ResponseEntity.ok(Map.of("success", true, "boughtCredits", credits));
} catch (IllegalArgumentException e) {
log.warn("setBoughtCredits rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@PostMapping("/set-bought-credits-by-supabase-id")
@Hidden
@Operation(
summary = "Set user's bought credits to a specific amount by Supabase ID",
description =
"Hard set the bought credits balance for a specific user using Supabase ID to an exact amount (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Bought credits set successfully")
public ResponseEntity<Map<String, Object>> setBoughtCreditsBySupabaseId(
@RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) {
if (credits < 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
}
try {
creditService.setBoughtCreditsBySupabaseId(supabaseId, credits);
log.info(
"Admin set bought credits to {} for user with Supabase ID: {}",
credits,
LogRedactionUtils.redactSupabaseId(supabaseId));
return ResponseEntity.ok(Map.of("success", true, "boughtCredits", credits));
} catch (IllegalArgumentException e) {
log.warn("setBoughtCreditsBySupabaseId rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@PostMapping("/set-cycle-credits")
@Hidden
@Operation(
summary = "Set user's cycle credits remaining to a specific amount",
description =
"Hard set the cycle credits remaining balance for a specific user to an exact amount (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Cycle credits set successfully")
public ResponseEntity<Map<String, Object>> setCycleCredits(
@RequestParam("username") String username, @RequestParam("credits") int credits) {
if (credits < 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
}
try {
creditService.setCycleCredits(username, credits);
log.info("Admin set cycle credits to {} for user: {}", credits, username);
return ResponseEntity.ok(Map.of("success", true, "cycleCredits", credits));
} catch (IllegalArgumentException e) {
log.warn("setCycleCredits rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@PostMapping("/set-cycle-credits-by-supabase-id")
@Hidden
@Operation(
summary = "Set user's cycle credits remaining to a specific amount by Supabase ID",
description =
"Hard set the cycle credits remaining balance for a specific user using Supabase ID to an exact amount (admin only)")
@PreAuthorize("hasRole('ADMIN')")
@ApiResponse(responseCode = "200", description = "Cycle credits set successfully")
public ResponseEntity<Map<String, Object>> setCycleCreditsBySupabaseId(
@RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) {
if (credits < 0) {
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
}
try {
creditService.setCycleCreditsBySupabaseId(supabaseId, credits);
log.info(
"Admin set cycle credits to {} for user with Supabase ID: {}",
credits,
LogRedactionUtils.redactSupabaseId(supabaseId));
return ResponseEntity.ok(Map.of("success", true, "cycleCredits", credits));
} catch (IllegalArgumentException e) {
log.warn("setCycleCreditsBySupabaseId rejected: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
}
}
@GetMapping("/usage")
@Hidden
@Operation(
summary = "Get credit usage summary",
description = "Get overview of credit usage (for authenticated user or admin view)")
public ResponseEntity<UsageSummary> getCreditUsage(Authentication authentication) {
CreditSummary summary = getCreditSummaryForAuthentication(authentication);
// For unlimited users, don't show meaningless huge usage numbers
int cycleCreditsUsed =
summary.unlimited
? 0
: (summary.cycleCreditsAllocated - summary.cycleCreditsRemaining);
UsageSummary usage =
new UsageSummary(
cycleCreditsUsed,
summary.totalBoughtCredits - summary.boughtCreditsRemaining,
summary.totalAvailableCredits,
summary.unlimited);
return ResponseEntity.ok(usage);
}
/** Resolves the current authentication to a credit summary, handling JWT and API-key auth. */
private CreditSummary getCreditSummaryForAuthentication(Authentication authentication) {
if (authentication instanceof EnhancedJwtAuthenticationToken enhancedJwt) {
return creditService.getCreditSummaryBySupabaseId(enhancedJwt.getSupabaseId());
}
if (authentication instanceof ApiKeyAuthenticationToken apiKeyToken) {
String apiKey = (String) apiKeyToken.getCredentials();
// Principal is the resolved User entity (per SupabaseAuthenticationFilter). Prefer the
// linked Supabase ID; fall back to API-key-keyed credits if there's no supabase link
// or no User row (e.g. legacy API-key-only deployments).
if (apiKeyToken.getPrincipal() instanceof User user && user.getSupabaseId() != null) {
return creditService.getCreditSummaryBySupabaseId(user.getSupabaseId().toString());
}
return creditService.getCreditSummaryByApiKey(apiKey);
}
return creditService.getCreditSummaryBySupabaseId(authentication.getName());
}
public static class UsageSummary {
public final int cycleCreditsUsed;
public final int boughtCreditsUsed;
public final int creditsRemaining;
public final boolean unlimited;
public UsageSummary(
int cycleCreditsUsed,
int boughtCreditsUsed,
int creditsRemaining,
boolean unlimited) {
this.cycleCreditsUsed = cycleCreditsUsed;
this.boughtCreditsUsed = boughtCreditsUsed;
this.creditsRemaining = creditsRemaining;
this.unlimited = unlimited;
}
}
}
@@ -1,307 +0,0 @@
package stirling.software.saas.interceptor;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.CreditConsumptionResult;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.ErrorTrackingService;
import stirling.software.saas.service.SaasTeamExtensionService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
import stirling.software.saas.util.CreditHeaderUtils;
/**
* Scoped to controllers annotated with {@link AutoJobPostMapping} so it doesn't hijack the global
* exception flow.
*/
// Legacy credit-billing error advice. PAYG handles its own error semantics via
// PaygChargeInterceptor — disabled by default in saas, activate legacy-credits profile if needed.
@RestControllerAdvice(annotations = AutoJobPostMapping.class)
@Profile("saas & legacy-credits")
@Slf4j
@Order(1)
public class CreditErrorAdvice {
private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE";
private static final String ATTR_APIKEY = "CREDIT_API_KEY";
private static final String ATTR_CHARGED = "CREDIT_CHARGED";
private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
private final CreditService creditService;
private final TeamCreditService teamCreditService;
private final UserRepository userRepository;
private final ErrorTrackingService errorTrackingService;
private final SaasTeamExtensionService saasTeamExtensionService;
private final CreditHeaderUtils creditHeaderUtils;
private final Counter creditsConsumedCounter;
// Inlined: Stirling's parent build uses Jackson 3 (tools.jackson), no Jackson 2 ObjectMapper
// bean in the context. Stateless usage, so a fresh instance is fine.
private final ObjectMapper objectMapper = new ObjectMapper();
public CreditErrorAdvice(
CreditService creditService,
TeamCreditService teamCreditService,
UserRepository userRepository,
ErrorTrackingService errorTrackingService,
SaasTeamExtensionService saasTeamExtensionService,
CreditHeaderUtils creditHeaderUtils,
MeterRegistry meterRegistry) {
this.creditService = creditService;
this.teamCreditService = teamCreditService;
this.userRepository = userRepository;
this.errorTrackingService = errorTrackingService;
this.saasTeamExtensionService = saasTeamExtensionService;
this.creditHeaderUtils = creditHeaderUtils;
this.creditsConsumedCounter =
Counter.builder("credits.consumed")
.description("Number of credits actually consumed")
.tag("source", "error")
.register(meterRegistry);
}
@ExceptionHandler(Throwable.class)
public ResponseEntity<Object> handleThrowable(HttpServletRequest request, Throwable ex) {
HttpStatus status = determineHttpStatus(ex);
log.debug(
"[CREDIT-DEBUG] CreditErrorAdvice: Handling exception: {} -> {}",
ex.getClass().getSimpleName(),
status);
String message = Optional.ofNullable(ex.getMessage()).orElse("An error occurred");
// Build error body
Map<String, Object> body = new HashMap<>();
body.put("error", ex.getClass().getSimpleName());
body.put("message", message);
body.put("status", status.value());
var builder = ResponseEntity.status(status);
// Handle credit consumption for errors
if (Boolean.TRUE.equals(request.getAttribute(ATTR_ELIGIBLE))
&& request.getAttribute(ATTR_CHARGED) == null) {
var apiKey = (String) request.getAttribute(ATTR_APIKEY);
var resourceWeight = (Integer) request.getAttribute(ATTR_RESOURCE_WEIGHT);
var isApiRequest = (Boolean) request.getAttribute("IS_API_REQUEST");
int creditAmount = resourceWeight != null ? resourceWeight : 1;
String identifierForErrorTracking =
apiKey; // Keep using apiKey/username for error tracking
if (apiKey != null
&& errorTrackingService.recordErrorAndShouldConsumeCredit(
identifierForErrorTracking,
request.getRequestURI(),
ex,
status.value())) {
// Get current user
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
User user = null;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (Exception e) {
log.warn(
"[CREDIT-DEBUG] CreditErrorAdvice: Could not get user for team check: {}",
e.getMessage());
}
if (user == null) {
log.error(
"[CREDIT-DEBUG] CreditErrorAdvice: Unable to resolve user - skipping credit consumption");
} else {
// Check if user is in a non-personal team (must match UnifiedCreditInterceptor
// logic)
Long targetTeamId = null;
if (user.getTeam() != null
&& !saasTeamExtensionService.isPersonal(user.getTeam())) {
targetTeamId = user.getTeam().getId();
}
boolean consumed = false;
String creditSource = null;
if (targetTeamId != null) {
// User is in a non-personal team - consume from team credit pool
consumed = teamCreditService.consumeCredit(targetTeamId, creditAmount);
creditSource = "TEAM_CREDITS";
log.debug(
"[CREDIT-DEBUG] CreditErrorAdvice: Consumed {} credits from team {}",
creditAmount,
targetTeamId);
} else {
// No team - use waterfall logic for individual credits
boolean isApiRequestFlag = Boolean.TRUE.equals(isApiRequest);
CreditConsumptionResult result =
creditService.consumeCreditWithWaterfall(
user, creditAmount, isApiRequestFlag);
consumed = result.isSuccess();
creditSource = result.getSource();
if (!consumed) {
log.error(
"[CREDIT-DEBUG] CreditErrorAdvice: Credit consumption failed for user: {} - {}",
user.getUsername(),
result.getMessage());
}
}
if (consumed) {
request.setAttribute(ATTR_CHARGED, Boolean.TRUE);
creditsConsumedCounter.increment();
// Set remaining credits header
int remainingCredits =
creditHeaderUtils.getRemainingCredits(
user, creditService, teamCreditService);
if (remainingCredits >= 0) {
builder.header(
"X-Credits-Remaining", Integer.toString(remainingCredits));
log.warn(
"[CREDIT-HEADER] Added X-Credits-Remaining header: {}",
remainingCredits);
}
if (creditSource != null) {
builder.header("X-Credit-Source", creditSource);
}
log.info(
"[CREDIT-DEBUG] CreditErrorAdvice: {} credits consumed from {} for user: {} (error case)",
creditAmount,
creditSource,
user.getUsername());
}
}
} else {
log.debug(
"[CREDIT-DEBUG] CreditErrorAdvice: ErrorTrackingService says do NOT consume credit for this error");
}
} else if (request.getAttribute(ATTR_CHARGED) != null) {
// Already charged, set header if user is authenticated
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated()) {
try {
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
int remainingCredits =
creditHeaderUtils.getRemainingCredits(
user, creditService, teamCreditService);
if (remainingCredits >= 0) {
builder.header("X-Credits-Remaining", Integer.toString(remainingCredits));
log.warn(
"[CREDIT-HEADER] Added X-Credits-Remaining header: {}",
remainingCredits);
}
} catch (Exception e) {
log.debug(
"[CREDIT-HEADER] Could not add credits header for already charged error: {}",
e.getMessage());
}
}
log.debug("[CREDIT-DEBUG] CreditErrorAdvice: Header set for already charged error");
}
if (isSseRequest(request)) {
String payload = toJsonPayload(body);
String sseBody = "event: error\ndata: " + payload + "\n\n";
return builder.contentType(MediaType.TEXT_EVENT_STREAM).body(sseBody);
}
return builder.body(body);
}
private String maskApiKey(String apiKey) {
if (apiKey == null || apiKey.length() < 8) {
return "***";
}
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
}
private HttpStatus determineHttpStatus(Throwable throwable) {
// Map common exceptions to HTTP status codes
String exceptionClass = throwable.getClass().getSimpleName();
switch (exceptionClass) {
case "IllegalArgumentException":
case "ValidationException":
case "MethodArgumentNotValidException":
return HttpStatus.BAD_REQUEST;
case "AccessDeniedException":
return HttpStatus.FORBIDDEN;
case "UsernameNotFoundException":
return HttpStatus.UNAUTHORIZED;
case "HttpMessageNotReadableException":
return HttpStatus.BAD_REQUEST;
case "MaxUploadSizeExceededException":
return HttpStatus.PAYLOAD_TOO_LARGE;
case "UnsupportedOperationException":
return HttpStatus.NOT_IMPLEMENTED;
default:
// Check error message for clues
String message = throwable.getMessage();
if (message != null) {
if (message.toLowerCase().contains("validation")
|| message.toLowerCase().contains("invalid parameter")) {
return HttpStatus.BAD_REQUEST;
}
if (message.toLowerCase().contains("not found")) {
return HttpStatus.NOT_FOUND;
}
}
return HttpStatus.INTERNAL_SERVER_ERROR;
}
}
private boolean isSseRequest(HttpServletRequest request) {
String accept = request.getHeader("Accept");
if (accept != null && accept.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) {
return true;
}
String contentType = request.getContentType();
return contentType != null && contentType.contains(MediaType.TEXT_EVENT_STREAM_VALUE);
}
private String toJsonPayload(Map<String, Object> payload) {
try {
return objectMapper.writeValueAsString(payload);
} catch (JsonProcessingException exc) {
log.warn("Failed to serialize SSE error payload, falling back to string", exc);
String message = payload.getOrDefault("message", "An error occurred").toString();
return "{\"error\":\"Error\",\"message\":\"" + message + "\",\"status\":500}";
}
}
public static class ErrorResponse {
public final String error;
public final String message;
public final int status;
public ErrorResponse(String error, String message, int status) {
this.error = error;
this.message = message;
this.status = status;
}
}
}
@@ -1,228 +0,0 @@
package stirling.software.saas.interceptor;
import org.springframework.context.annotation.Profile;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.CreditConsumptionResult;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.SaasTeamExtensionService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
import stirling.software.saas.util.CreditHeaderUtils;
// Legacy credit-billing success advice. PAYG writes its own ledger entries via
// JobChargeService — disabled by default in saas, activate legacy-credits profile if needed.
@RestControllerAdvice
@Profile("saas & legacy-credits")
@Slf4j
public class CreditSuccessAdvice implements ResponseBodyAdvice<Object> {
private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE";
private static final String ATTR_APIKEY = "CREDIT_API_KEY";
private static final String ATTR_CHARGED = "CREDIT_CHARGED";
private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
private final CreditService creditService;
private final TeamCreditService teamCreditService;
private final UserRepository userRepository;
private final SaasTeamExtensionService saasTeamExtensionService;
private final CreditHeaderUtils creditHeaderUtils;
private final Counter creditsConsumedCounter;
public CreditSuccessAdvice(
CreditService creditService,
TeamCreditService teamCreditService,
UserRepository userRepository,
SaasTeamExtensionService saasTeamExtensionService,
CreditHeaderUtils creditHeaderUtils,
MeterRegistry meterRegistry) {
this.creditService = creditService;
this.teamCreditService = teamCreditService;
this.userRepository = userRepository;
this.saasTeamExtensionService = saasTeamExtensionService;
this.creditHeaderUtils = creditHeaderUtils;
this.creditsConsumedCounter =
Counter.builder("credits.consumed")
.description("Number of credits actually consumed")
.tag("source", "success")
.register(meterRegistry);
}
@Override
public boolean supports(
MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
// Only REST bodies; this covers @ResponseBody and ResponseEntity
return true;
}
@Override
public Object beforeBodyWrite(
Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
if (!(request instanceof ServletServerHttpRequest)) {
return body;
}
var servletReq = ((ServletServerHttpRequest) request).getServletRequest();
if (!Boolean.TRUE.equals(servletReq.getAttribute(ATTR_ELIGIBLE))) {
return body;
}
if (servletReq.getAttribute(ATTR_CHARGED) != null) {
return body;
}
// If the handler returned an error ResponseEntity (>=400) without throwing,
// don't spend here; the error advice will decide.
int status = 200;
if (response instanceof ServletServerHttpResponse) {
status = ((ServletServerHttpResponse) response).getServletResponse().getStatus();
}
if (status >= 400) {
log.debug(
"[CREDIT-DEBUG] CreditSuccessAdvice: Error status {} detected, skipping credit consumption",
status);
return body;
}
var apiKey = (String) servletReq.getAttribute(ATTR_APIKEY);
var resourceWeight = (Integer) servletReq.getAttribute(ATTR_RESOURCE_WEIGHT);
var isApiRequest = (Boolean) servletReq.getAttribute("IS_API_REQUEST");
int creditAmount = resourceWeight != null ? resourceWeight : 1;
if (apiKey != null) {
// Get current user
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
User user = null;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (Exception e) {
log.warn(
"[CREDIT-DEBUG] CreditSuccessAdvice: Could not get user for team check: {}",
e.getMessage());
}
if (user == null) {
log.error(
"[CREDIT-DEBUG] CreditSuccessAdvice: Unable to resolve user - skipping credit consumption");
return body;
}
// Check if user is in a non-personal team (must match UnifiedCreditInterceptor logic)
// IMPORTANT: Limited API users (anonymous, extra limited) always use personal credits,
// never team credits
boolean isLimitedApiUser =
auth.getAuthorities().stream()
.anyMatch(
authority ->
"ROLE_LIMITED_API_USER".equals(authority.getAuthority())
|| "ROLE_EXTRA_LIMITED_API_USER"
.equals(authority.getAuthority()));
Long targetTeamId = null;
if (!isLimitedApiUser
&& user.getTeam() != null
&& !saasTeamExtensionService.isPersonal(user.getTeam())) {
targetTeamId = user.getTeam().getId();
}
final boolean consumed;
final String creditSource;
if (targetTeamId != null) {
// User is in a non-personal team - use waterfall with leader overage
CreditConsumptionResult result =
teamCreditService.consumeCreditWithWaterfall(targetTeamId, creditAmount);
consumed = result.isSuccess();
creditSource = result.getSource();
if (!consumed) {
log.error(
"[CREDIT-DEBUG] CreditSuccessAdvice: Team credit consumption failed:"
+ " {}",
result.getMessage());
} else {
log.debug(
"[CREDIT-DEBUG] CreditSuccessAdvice: Consumed {} credits from team {}"
+ " via {}",
creditAmount,
targetTeamId,
creditSource);
}
} else {
// No team - use waterfall logic for individual credits
boolean isApiRequestFlag = Boolean.TRUE.equals(isApiRequest);
CreditConsumptionResult result =
creditService.consumeCreditWithWaterfall(
user, creditAmount, isApiRequestFlag);
consumed = result.isSuccess();
creditSource = result.getSource();
if (!consumed) {
log.error(
"[CREDIT-DEBUG] CreditSuccessAdvice: Credit consumption failed for user: {} - {}",
user.getUsername(),
result.getMessage());
}
}
if (consumed) {
servletReq.setAttribute(ATTR_CHARGED, Boolean.TRUE);
creditsConsumedCounter.increment();
// Set remaining credits header
int remainingCredits =
creditHeaderUtils.getRemainingCredits(
user, creditService, teamCreditService);
if (remainingCredits >= 0) {
response.getHeaders()
.set("X-Credits-Remaining", Integer.toString(remainingCredits));
log.warn(
"[CREDIT-HEADER] Added X-Credits-Remaining header: {}",
remainingCredits);
}
if (creditSource != null) {
response.getHeaders().set("X-Credit-Source", creditSource);
}
log.info(
"[CREDIT-DEBUG] CreditSuccessAdvice: {} credits consumed from {} for user: {}",
creditAmount,
creditSource,
user.getUsername());
}
} else {
log.warn("[CREDIT-DEBUG] CreditSuccessAdvice: No apiKey attribute found");
}
return body;
}
private String maskApiKey(String apiKey) {
if (apiKey == null || apiKey.length() < 8) {
return "***";
}
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
}
}
@@ -1,493 +0,0 @@
package stirling.software.saas.interceptor;
import org.springframework.context.annotation.Profile;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.AsyncHandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.model.TeamCredit;
import stirling.software.saas.model.UserCredit;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.ErrorTrackingService;
import stirling.software.saas.service.SaasTeamExtensionService;
import stirling.software.saas.service.SaasUserExtensionService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
// Legacy credit-billing interceptor. PAYG replaces this with PaygChargeInterceptor — disabled
// by default in saas, activate legacy-credits profile to bring it back.
@Component
@Profile("saas & legacy-credits")
@Slf4j
public class UnifiedCreditInterceptor implements AsyncHandlerInterceptor {
private final CreditService creditService;
private final ErrorTrackingService errorTrackingService;
private final CreditsProperties creditsProperties;
private final UserRepository userRepository;
private final TeamCreditService teamCreditService;
private final TeamMembershipRepository membershipRepository;
private final SaasUserExtensionService saasUserExtensionService;
private final SaasTeamExtensionService saasTeamExtensionService;
private final Counter creditsCheckedCounter;
private final Counter creditsRejectedCounter;
private final Counter jwtBypassCounter;
private final Timer creditCheckTimer;
private static final String ATTR_CREDIT_ELIGIBLE = "CREDIT_ELIGIBLE";
private static final String ATTR_API_KEY = "CREDIT_API_KEY";
private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
private static final String ATTR_CHARGED = "CREDIT_CHARGED";
public UnifiedCreditInterceptor(
CreditService creditService,
ErrorTrackingService errorTrackingService,
CreditsProperties creditsProperties,
UserRepository userRepository,
TeamCreditService teamCreditService,
TeamMembershipRepository membershipRepository,
SaasUserExtensionService saasUserExtensionService,
SaasTeamExtensionService saasTeamExtensionService,
MeterRegistry meterRegistry) {
this.creditService = creditService;
this.errorTrackingService = errorTrackingService;
this.creditsProperties = creditsProperties;
this.userRepository = userRepository;
this.teamCreditService = teamCreditService;
this.membershipRepository = membershipRepository;
this.saasUserExtensionService = saasUserExtensionService;
this.saasTeamExtensionService = saasTeamExtensionService;
this.creditsCheckedCounter =
Counter.builder("credits.validation.checked")
.description("Number of requests that had credit validation performed")
.register(meterRegistry);
this.creditsRejectedCounter =
Counter.builder("credits.validation.rejected")
.description("Number of requests rejected due to insufficient credits")
.register(meterRegistry);
this.jwtBypassCounter =
Counter.builder("credits.validation.jwt_bypass")
.description("Number of JWT requests that bypassed credit validation")
.register(meterRegistry);
this.creditCheckTimer =
Timer.builder("credits.validation.duration")
.description("Time taken to validate credits")
.register(meterRegistry);
}
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
log.debug(
"[CREDIT-DEBUG] UnifiedCreditInterceptor.preHandle() - handler: {}",
handler.getClass().getSimpleName());
// Credits system disabled - allow all requests
if (!creditsProperties.isEnabled()) {
log.debug("[CREDIT-DEBUG] Credits system disabled - allowing request");
return true;
}
// Only apply to @AutoJobPostMapping endpoints and extract resource weight
if (!(handler instanceof HandlerMethod hm)
|| !hm.getMethod().isAnnotationPresent(AutoJobPostMapping.class)) {
log.debug(
"[CREDIT-DEBUG] Handler not eligible for credit validation (no @AutoJobPostMapping)");
return true;
}
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
log.debug(
"[CREDIT-DEBUG] Authentication: {}",
auth != null ? auth.getClass().getSimpleName() : "null");
User currentUser = null;
// API key authentication always needs credit validation
if (auth instanceof ApiKeyAuthenticationToken) {
// API key users - proceed with normal credit validation
currentUser = (User) auth.getPrincipal();
} else if (auth != null && auth.isAuthenticated()) {
// JWT users - get user from authentication details
// JwtAuthenticationToken.getPrincipal() might not be a User object
// so we need to look up the user by the Supabase ID from auth.getName()
String supabaseId = AuthenticationUtils.extractSupabaseId(auth);
log.debug("[CREDIT-DEBUG] JWT authentication detected, Supabase ID: {}", supabaseId);
// Look up the User object that should exist (authentication succeeded)
try {
java.util.UUID supabaseUuid = java.util.UUID.fromString(supabaseId);
java.util.Optional<User> userOpt = userRepository.findBySupabaseId(supabaseUuid);
if (userOpt.isEmpty()) {
log.error(
"[CREDIT-DEBUG] JWT authenticated but no User found for Supabase ID: {}",
supabaseId);
response.setStatus(500);
response.setContentType("application/json");
response.getWriter()
.write(
"{\"error\":\"USER_NOT_FOUND\",\"message\":\"Authenticated user not found in database\",\"status\":500}");
return false;
}
currentUser = userOpt.get();
if (shouldApplyCreditsToJwtUser(currentUser)) {
// Anonymous users or other limited JWT users should consume credits
log.debug(
"[CREDIT-DEBUG] JWT user {} subject to credit validation due to limited role",
currentUser.getUsername());
} else {
jwtBypassCounter.increment();
log.debug(
"[CREDIT-DEBUG] JWT user {} bypassing credit validation (unlimited role)",
currentUser.getUsername());
return true;
}
} catch (IllegalArgumentException e) {
log.error("[CREDIT-DEBUG] Invalid Supabase ID format: {}", supabaseId);
response.setStatus(400);
response.setContentType("application/json");
response.getWriter()
.write(
"{\"error\":\"INVALID_USER_ID\",\"message\":\"Invalid user identifier format\",\"status\":400}");
return false;
}
} else {
// SECURITY: Block all non-authenticated requests
log.warn(
"[CREDIT-DEBUG] Non-authenticated request blocked - authentication required for credit-controlled endpoints");
response.setStatus(401); // 401 Unauthorized
response.setContentType("application/json");
response.getWriter()
.write(
"{\"error\":\"AUTHENTICATION_REQUIRED\",\"message\":\"Authentication required to access this endpoint\",\"status\":401}");
return false;
}
// Extract resource weight from annotation
AutoJobPostMapping annotation = hm.getMethod().getAnnotation(AutoJobPostMapping.class);
int resourceWeight =
Math.max(1, Math.min(100, annotation.resourceWeight())); // Clamp to 1-100
String apiKey = getApiKeyForUser(auth, currentUser);
String maskedApiKey = maskApiKey(apiKey);
log.debug(
"[CREDIT-DEBUG] Credit validation for user: {}, API key: {}, resource weight: {}",
currentUser.getUsername(),
maskedApiKey,
resourceWeight);
// Track that we're performing credit validation
creditsCheckedCounter.increment();
// Check if user has SUFFICIENT credits for this operation (with timing)
Timer.Sample sample = Timer.start();
boolean hasSufficientCredits;
int availableCredits = 0;
// Check if user is a limited API user (anonymous, extra limited)
// Limited API users always use personal credits, never team credits
boolean isLimitedApiUser =
currentUser.getAuthorities().stream()
.anyMatch(
authority ->
"ROLE_LIMITED_API_USER".equals(authority.getAuthority())
|| "ROLE_EXTRA_LIMITED_API_USER"
.equals(authority.getAuthority()));
if (auth instanceof ApiKeyAuthenticationToken) {
// API key auth - get credit balance
java.util.Optional<UserCredit> userCreditsOpt =
creditService.getUserCreditsByApiKey(apiKey);
availableCredits = userCreditsOpt.map(UserCredit::getTotalAvailableCredits).orElse(0);
hasSufficientCredits = availableCredits >= resourceWeight;
} else {
// JWT user - check team credits if user is in a non-personal team, otherwise personal
// credits
Long teamId = null;
if (!isLimitedApiUser
&& currentUser.getTeam() != null
&& !saasTeamExtensionService.isPersonal(currentUser.getTeam())) {
teamId = currentUser.getTeam().getId();
}
if (teamId != null) {
// User is in a non-personal team - check team credits + leader overage billing
java.util.Optional<TeamCredit> teamCredits =
teamCreditService.getTeamCredits(teamId);
availableCredits = teamCredits.map(TeamCredit::getTotalAvailableCredits).orElse(0);
// Check if sufficient credits OR team leader has metered billing
boolean hasTeamCredits = availableCredits >= resourceWeight;
boolean leaderHasMetered = checkTeamLeaderMeteredBilling(currentUser.getTeam());
hasSufficientCredits = hasTeamCredits || leaderHasMetered;
log.debug(
"[CREDIT-DEBUG] Checking team {} credits for user {}: available={}"
+ " required={} hasCredits={} leaderMetered={} sufficient={}",
teamId,
currentUser.getUsername(),
availableCredits,
resourceWeight,
hasTeamCredits,
leaderHasMetered,
hasSufficientCredits);
} else {
// Personal team or no team - check personal credits
UserCredit userCredits = creditService.getOrCreateUserCredits(currentUser);
availableCredits = userCredits.getTotalAvailableCredits();
hasSufficientCredits = availableCredits >= resourceWeight;
log.debug(
"[CREDIT-DEBUG] Checking personal credits for user {}: available={} required={} sufficient={}",
currentUser.getUsername(),
availableCredits,
resourceWeight,
hasSufficientCredits);
}
}
sample.stop(creditCheckTimer);
// Check if user has metered billing enabled (they can use overage credits even with
// insufficient free credits)
boolean hasMeteredBilling = saasUserExtensionService.isMeteredBillingEnabled(currentUser);
if (!hasSufficientCredits && !hasMeteredBilling) {
creditsRejectedCounter.increment();
// Enhanced message for team members
// Note: Limited API users always use personal credits, so they get personal message
String message;
if (!isLimitedApiUser
&& currentUser.getTeam() != null
&& !saasTeamExtensionService.isPersonal(currentUser.getTeam())) {
message =
"Insufficient team credits. Team leader must enable overage billing for"
+ " uninterrupted service.";
} else {
message =
"Insufficient API credits. Please purchase more credits or wait for your"
+ " monthly cycle credits to reset.";
}
log.warn(
"[CREDIT-DEBUG] Credit validation rejected - Method: {}, URI: {}, IP: {},"
+ " User-Agent: {}, User: {}, Supabase ID: {}, Reason: {}",
request.getMethod(),
request.getRequestURI(),
getClientIpAddress(request),
request.getHeader("User-Agent"),
currentUser.getUsername(),
currentUser.getSupabaseId(),
message);
response.setStatus(429); // 429 Too Many Requests
response.setContentType("application/json");
response.getWriter()
.write(
String.format(
"{\"error\":\"INSUFFICIENT_CREDITS\",\"message\":\"%s\",\"status\":429}",
message));
response.getWriter().flush();
return false;
}
// Log when metered billing users are using overage credits
if (!hasSufficientCredits && hasMeteredBilling) {
log.info(
"[CREDIT-DEBUG] Metered billing user {} proceeding with insufficient free credits (have: {}, need: {}) - will use overage credits (billed monthly)",
currentUser.getUsername(),
availableCredits,
resourceWeight);
}
// Mark request as eligible for credit consumption
request.setAttribute(ATTR_CREDIT_ELIGIBLE, Boolean.TRUE);
request.setAttribute(ATTR_API_KEY, apiKey);
request.setAttribute(ATTR_RESOURCE_WEIGHT, resourceWeight);
// Store whether this is API key or JWT authentication for advice classes
boolean isApiKeyAuth = auth instanceof ApiKeyAuthenticationToken;
request.setAttribute("IS_API_KEY_AUTH", isApiKeyAuth);
// Store IS_API_REQUEST for waterfall logic (API key requests always consume credits)
request.setAttribute("IS_API_REQUEST", isApiKeyAuth);
log.debug(
"[CREDIT-DEBUG] Credit validation passed - request marked as eligible for consumption (will consume after success/error)");
return true;
}
@Override
public void postHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView)
throws Exception {
// Success path now handled by CreditSuccessAdvice - no spending in postHandle anymore
log.debug("[CREDIT-DEBUG] postHandle: Success path will be handled by CreditSuccessAdvice");
}
@Override
public void afterCompletion(
HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
throws Exception {
// Error path now handled by CreditErrorAdvice - no spending in afterCompletion anymore
if (ex != null) {
log.debug(
"[CREDIT-DEBUG] afterCompletion: Error path will be handled by CreditErrorAdvice: {}",
ex.getClass().getSimpleName());
} else {
log.debug(
"[CREDIT-DEBUG] afterCompletion: Success path already handled by CreditSuccessAdvice");
}
}
@Override
public void afterConcurrentHandlingStarted(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
// For async requests (Callable, DeferredResult, etc.), prevent duplicate processing
// The actual postHandle/afterCompletion will be called when async processing completes
log.debug(
"[CREDIT-DEBUG] afterConcurrentHandlingStarted: Async processing started - skipping interceptor logic");
}
private String maskApiKey(String apiKey) {
if (apiKey == null || apiKey.length() < 8) {
return "***";
}
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
}
private String getClientIpAddress(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
String xRealIp = request.getHeader("X-Real-IP");
if (xRealIp != null && !xRealIp.isEmpty()) {
return xRealIp;
}
return request.getRemoteAddr();
}
/**
* Determines if credit limits should apply to a JWT user.
*
* <p>Rules:
*
* <ul>
* <li>Metered billing users: always consume (free tier first, then report overage to Stripe)
* <li>Anonymous users: consume credits (web/API)
* <li>Regular users: consume credits (web/API)
* <li>Pro users: unlimited on web UI (waterfall logic handles this), but subject to checks
* <li>API users: always consume credits
* <li>Internal API users: unlimited everywhere
* <li>Admin users: unlimited everywhere
* </ul>
*/
private boolean shouldApplyCreditsToJwtUser(User user) {
String roles = user.getRolesAsString();
// Internal API users are unlimited everywhere (for backend internal operations)
if (roles.contains("STIRLING-PDF-BACKEND-API-USER")) {
log.debug("[CREDIT-DEBUG] Internal API user {} - unlimited usage", user.getUsername());
return false;
}
// Pro users: Let them through to waterfall logic
// (Pro gets unlimited UI but API still consumes credits)
if (roles.contains("ROLE_PRO_USER")) {
log.debug(
"[CREDIT-DEBUG] Pro user {} - will be handled by waterfall logic",
user.getUsername());
return true; // Changed from false - let waterfall handle Pro exemption
}
// Admin users are unlimited everywhere
if (roles.contains("ROLE_ADMIN")) {
log.debug("[CREDIT-DEBUG] Admin user {} - unlimited usage", user.getUsername());
return false;
}
// All other users (anonymous, regular, limited API users, metered billing) consume credits
log.debug(
"[CREDIT-DEBUG] User {} with roles {} - subject to credit limits",
user.getUsername(),
roles);
return true;
}
/**
* Gets the identifier for credit consumption. For API key users, use their actual API key. For
* JWT users, use the Supabase ID as identifier (auth.getName() returns Supabase ID).
*/
private String getApiKeyForUser(Authentication auth, User user) {
if (auth instanceof ApiKeyAuthenticationToken) {
return user.getApiKey();
} else {
// For JWT users, return Supabase ID as the credit consumption identifier
return AuthenticationUtils.extractSupabaseId(auth);
}
}
/**
* Check if team leader has metered billing enabled. This allows teams to use overage billing
* when team credits are exhausted.
*
* @param team the team to check
* @return true if team leader has metered billing enabled
*/
private boolean checkTeamLeaderMeteredBilling(stirling.software.proprietary.model.Team team) {
if (team == null || team.getId() == null) {
return false;
}
try {
java.util.List<stirling.software.saas.model.TeamMembership> leaders =
membershipRepository.findByTeamIdAndRole(
team.getId(),
stirling.software.common.model.enumeration.TeamRole.LEADER);
if (leaders.isEmpty()) {
return false;
}
User leader = leaders.get(0).getUser();
return saasUserExtensionService.isMeteredBillingEnabled(leader);
} catch (Exception e) {
log.error("Error checking team leader metered billing: {}", e.getMessage());
return false;
}
}
}
@@ -1,69 +0,0 @@
package stirling.software.saas.model;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* Result of a credit consumption attempt with explicit waterfall logic. Indicates whether the
* operation succeeded and which credit source was used.
*/
@Data
@AllArgsConstructor
public class CreditConsumptionResult {
/** Whether the credit consumption succeeded */
private boolean success;
/**
* The credit source used for this operation. Possible values: "PRO_PLAN" (Pro user with
* unlimited UI access, no credits consumed); "CYCLE_CREDITS" (free monthly cycle credit
* allocation); "BOUGHT_CREDITS" (one-time purchased credits); "METERED_SUBSCRIPTION"
* (pay-what-you-use metered billing, reported to Stripe); null (operation failed; see message
* for reason).
*/
private String source;
/** Human-readable message about the result */
private String message;
/**
* Creates a successful result for unlimited access (Pro plan UI requests).
*
* @param source The credit source (typically "PRO_PLAN")
* @return CreditConsumptionResult indicating unlimited access
*/
public static CreditConsumptionResult unlimited(String source) {
return new CreditConsumptionResult(true, source, "Unlimited access");
}
/**
* Creates a successful result for credit consumption.
*
* @param source The credit source used
* @return CreditConsumptionResult indicating success
*/
public static CreditConsumptionResult success(String source) {
return new CreditConsumptionResult(true, source, "Credits consumed");
}
/**
* Creates a failure result.
*
* @param reason The reason for failure
* @return CreditConsumptionResult indicating failure
*/
public static CreditConsumptionResult failure(String reason) {
return new CreditConsumptionResult(false, null, reason);
}
/**
* Creates a failure result with custom message.
*
* @param reason The reason code
* @param message Custom human-readable message
* @return CreditConsumptionResult indicating failure
*/
public static CreditConsumptionResult failure(String reason, String message) {
return new CreditConsumptionResult(false, null, message != null ? message : reason);
}
}
@@ -1,139 +0,0 @@
package stirling.software.saas.model;
public enum ProcessingErrorType {
/**
* Validation errors. should never cost credits. Examples: missing parameters, invalid file
* types, size limits exceeded, malformed requests, authentication failures
*/
VALIDATION_ERROR,
/**
* Processing errors. should cost credits after 3rd attempt per user/endpoint. Examples: corrupt
* PDF files, unsupported PDF features, memory issues during processing, OCR failures on valid
* PDFs, conversion errors on valid files
*/
PROCESSING_ERROR,
/**
* System errors. should not cost credits (our fault). Examples: database connection issues,
* filesystem problems, service unavailable, internal server errors
*/
SYSTEM_ERROR;
/** Determine error type from exception and HTTP status */
public static ProcessingErrorType classifyError(
Throwable throwable, int httpStatus, String endpoint) {
if (throwable == null) {
return classifyByHttpStatus(httpStatus);
}
String errorMessage = throwable.getMessage();
String exceptionClass = throwable.getClass().getSimpleName();
// Validation errors (client-side issues)
if (httpStatus == 400 || httpStatus == 422) {
if (isValidationError(errorMessage, exceptionClass)) {
return VALIDATION_ERROR;
}
}
// Authentication/Authorization errors
if (httpStatus == 401 || httpStatus == 403) {
return VALIDATION_ERROR;
}
// Rate limiting
if (httpStatus == 429) {
return VALIDATION_ERROR;
}
// System errors (our fault)
if (httpStatus >= 500 || isSystemError(errorMessage, exceptionClass)) {
return SYSTEM_ERROR;
}
// Processing errors (user's data issue but valid request)
if (isProcessingError(errorMessage, exceptionClass, endpoint)) {
return PROCESSING_ERROR;
}
// Default to validation error to be safe
return VALIDATION_ERROR;
}
private static ProcessingErrorType classifyByHttpStatus(int httpStatus) {
if (httpStatus >= 400 && httpStatus < 500) {
return VALIDATION_ERROR;
} else if (httpStatus >= 500) {
return SYSTEM_ERROR;
}
return VALIDATION_ERROR;
}
private static boolean isValidationError(String errorMessage, String exceptionClass) {
if (errorMessage == null && exceptionClass == null) return false;
String[] validationKeywords = {
"validation", "invalid parameter", "missing parameter", "malformed",
"bad request", "illegal argument", "file too large", "unsupported file type",
"empty file", "no file provided", "invalid format"
};
String[] validationExceptions = {
"IllegalArgumentException",
"ValidationException",
"BindException",
"MethodArgumentNotValidException",
"MissingServletRequestParameterException",
"HttpMessageNotReadableException",
"MaxUploadSizeExceededException"
};
return containsAny(errorMessage, validationKeywords)
|| containsAny(exceptionClass, validationExceptions);
}
private static boolean isSystemError(String errorMessage, String exceptionClass) {
if (errorMessage == null && exceptionClass == null) return false;
String[] systemExceptions = {
"SQLException",
"IOException",
"OutOfMemoryError",
"TimeoutException",
"ConnectException",
"UnknownHostException",
"ServiceUnavailableException"
};
return containsAny(exceptionClass, systemExceptions);
}
private static boolean isProcessingError(
String errorMessage, String exceptionClass, String endpoint) {
if (errorMessage == null && exceptionClass == null) return false;
String[] processingExceptions = {
"PDFException", "COSVisitorException", "InvalidPDFException",
"ConversionException", "OCRException", "ParseException"
};
// If we're checking errors for an endpoint, it's already been identified as a tracked
// endpoint
// through @AutoJobPostMapping annotation, so we can assume it's a PDF processing endpoint
return containsAny(exceptionClass, processingExceptions)
|| (endpoint != null && !isValidationError(errorMessage, exceptionClass));
}
private static boolean containsAny(String text, String[] keywords) {
if (text == null) return false;
String lowerText = text.toLowerCase();
for (String keyword : keywords) {
if (lowerText.contains(keyword.toLowerCase())) {
return true;
}
}
return false;
}
}
@@ -1,131 +0,0 @@
package stirling.software.saas.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.model.Team;
/** Shared credit pool for multi-member teams; see {@link UserCredit} for the per-user variant. */
@Entity
@Table(name = "team_credits")
@NoArgsConstructor
@Getter
@Setter
public class TeamCredit implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "credit_id")
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id", nullable = false, unique = true)
@OnDelete(action = OnDeleteAction.CASCADE)
private Team team;
@Column(name = "cycle_credits_remaining")
private Integer cycleCreditsRemaining = 0;
@Column(name = "cycle_credits_allocated")
private Integer cycleCreditsAllocated = 0;
@Column(name = "bought_credits_remaining")
private Integer boughtCreditsRemaining = 0;
@Column(name = "total_bought_credits")
private Integer totalBoughtCredits = 0;
@Column(name = "last_cycle_reset_at")
private LocalDateTime lastCycleResetAt;
@Column(name = "last_api_usage")
private LocalDateTime lastApiUsage;
@Column(name = "total_api_calls_made")
private Long totalApiCallsMade = 0L;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Version
@Column(name = "version")
private Long version;
public TeamCredit(Team team) {
this.team = team;
}
public int getTotalAvailableCredits() {
return (cycleCreditsRemaining != null ? cycleCreditsRemaining : 0)
+ (boughtCreditsRemaining != null ? boughtCreditsRemaining : 0);
}
public boolean hasCreditsAvailable() {
return getTotalAvailableCredits() > 0;
}
/**
* Consume a credit from the team pool. Consumes cycle credits first, then bought credits.
*
* @return true if a credit was consumed, false if no credits available
*/
public boolean consumeCredit() {
if (cycleCreditsRemaining != null && cycleCreditsRemaining > 0) {
cycleCreditsRemaining--;
totalApiCallsMade++;
lastApiUsage = LocalDateTime.now();
return true;
} else if (boughtCreditsRemaining != null && boughtCreditsRemaining > 0) {
boughtCreditsRemaining--;
totalApiCallsMade++;
lastApiUsage = LocalDateTime.now();
return true;
}
return false;
}
public void addBoughtCredits(int credits) {
if (credits > 0) {
boughtCreditsRemaining =
(boughtCreditsRemaining != null ? boughtCreditsRemaining : 0) + credits;
totalBoughtCredits = (totalBoughtCredits != null ? totalBoughtCredits : 0) + credits;
}
}
public void resetCycleCredits(int cycleAllocation, LocalDateTime resetTime) {
this.cycleCreditsAllocated = cycleAllocation;
this.cycleCreditsRemaining = cycleAllocation;
this.lastCycleResetAt = resetTime;
}
public boolean isCycleResetDue(LocalDateTime lastScheduledReset) {
return lastCycleResetAt == null || lastCycleResetAt.isBefore(lastScheduledReset);
}
}
@@ -1,133 +0,0 @@
package stirling.software.saas.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
/**
* Per-user credit pool. Layers a renewable monthly cycle pool ({@code cycleCreditsRemaining}) over
* a non-expiring purchased pool ({@code boughtCreditsRemaining}); cycle credits consume first.
*/
@Entity
@Table(name = "user_credits")
@NoArgsConstructor
@Getter
@Setter
public class UserCredit implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "credit_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;
@Column(name = "cycle_credits_remaining")
private Integer cycleCreditsRemaining = 0;
@Column(name = "cycle_credits_allocated")
private Integer cycleCreditsAllocated = 0;
@Column(name = "bought_credits_remaining")
private Integer boughtCreditsRemaining = 0;
@Column(name = "total_bought_credits")
private Integer totalBoughtCredits = 0;
@Column(name = "last_cycle_reset_at")
private LocalDateTime lastCycleResetAt;
@Column(name = "last_api_usage")
private LocalDateTime lastApiUsage;
@Column(name = "total_api_calls_made")
private Long totalApiCallsMade = 0L;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Version
@Column(name = "version")
private Long version;
public UserCredit(User user) {
this.user = user;
// Cycle credits are initialized by CreditService after this object is created,
// typically during user registration or at the start of a new billing cycle,
// using values from the application configuration.
}
public int getTotalAvailableCredits() {
return (cycleCreditsRemaining != null ? cycleCreditsRemaining : 0)
+ (boughtCreditsRemaining != null ? boughtCreditsRemaining : 0);
}
public boolean hasCreditsAvailable() {
return getTotalAvailableCredits() > 0;
}
public boolean consumeCredit() {
// Consume cycle credits first, then bought credits.
if (cycleCreditsRemaining != null && cycleCreditsRemaining > 0) {
cycleCreditsRemaining--;
totalApiCallsMade++;
lastApiUsage = LocalDateTime.now();
return true;
} else if (boughtCreditsRemaining != null && boughtCreditsRemaining > 0) {
boughtCreditsRemaining--;
totalApiCallsMade++;
lastApiUsage = LocalDateTime.now();
return true;
}
return false;
}
public void addBoughtCredits(int credits) {
if (credits > 0) {
boughtCreditsRemaining =
(boughtCreditsRemaining != null ? boughtCreditsRemaining : 0) + credits;
totalBoughtCredits = (totalBoughtCredits != null ? totalBoughtCredits : 0) + credits;
}
}
public void resetCycleCredits(int cycleAllocation, LocalDateTime resetTime) {
this.cycleCreditsAllocated = cycleAllocation;
this.cycleCreditsRemaining = cycleAllocation;
this.lastCycleResetAt = resetTime;
}
public boolean isCycleResetDue(LocalDateTime lastScheduledReset) {
return lastCycleResetAt == null || lastCycleResetAt.isBefore(lastScheduledReset);
}
}
@@ -1,95 +0,0 @@
package stirling.software.saas.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
@Entity
@Table(name = "user_error_tracker")
@NoArgsConstructor
@Getter
@Setter
public class UserErrorTracker implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "error_tracker_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "endpoint")
private String endpoint;
@Column(name = "processing_error_count")
private Integer processingErrorCount = 0;
@Column(name = "last_processing_error")
private LocalDateTime lastProcessingError;
@Column(name = "reset_after")
private LocalDateTime resetAfter;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public UserErrorTracker(User user, String endpoint, int ttlMinutes) {
this.user = user;
this.endpoint = endpoint;
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
}
public boolean shouldChargeForProcessingError(int freeProcessingErrors) {
return processingErrorCount != null && processingErrorCount > freeProcessingErrors;
}
public void recordProcessingError(int ttlMinutes) {
this.processingErrorCount = (processingErrorCount != null ? processingErrorCount : 0) + 1;
this.lastProcessingError = LocalDateTime.now();
// Refresh TTL on each error
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
}
public void resetErrorCount(int ttlMinutes) {
this.processingErrorCount = 0;
this.lastProcessingError = null;
this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes);
}
public boolean isExpired() {
return resetAfter != null && LocalDateTime.now().isAfter(resetAfter);
}
public int getErrorsUntilCharged(int freeProcessingErrors) {
int current = processingErrorCount != null ? processingErrorCount : 0;
return Math.max(0, freeProcessingErrors + 1 - current);
}
}
@@ -51,10 +51,9 @@ import stirling.software.saas.payg.wallet.WalletLedgerEntry;
* The real-charging path lives in a separate follow-up and reuses the same orchestration — only the
* side-effect (shadow row vs ledger entry + Stripe call) differs.
*
* <p>The {@code legacyCreditsCharged} field on the shadow row is set to {@code 0} here. When the
* legacy {@code CreditService} is wired to call this service (separate PR), the legacy debit amount
* becomes available and {@code diffPct} can be computed against it; until then the shadow row
* captures the PAYG units only.
* <p>The {@code legacyCreditsCharged} field on the shadow row is set to {@code 0}: the legacy
* credit engine has been removed, so there is no legacy debit to compare against and {@code
* diffPct} stays {@code 0}. The shadow row captures the PAYG units only.
*/
@Service
@Profile("saas")
@@ -281,8 +280,7 @@ public class JobChargeService {
// Free-vs-paid split fixed at charge time: paid (metered) = paygUnits - freeUnitsConsumed,
// and a refund restores freeUnitsConsumed to the team's grant.
row.setFreeUnitsConsumed(freeUnitsConsumed);
// No legacy comparison yet — wired when the shadow path is connected to the legacy
// CreditService in the follow-up PR. Until then, diff stays at 0.
// No legacy comparison: the legacy credit engine has been removed, so diff stays at 0.
row.setLegacyCreditsCharged(0);
row.setDiffPct(0);
row.setStatus(ShadowChargeStatus.CHARGED);
@@ -53,9 +53,7 @@ import stirling.software.saas.payg.model.ProcessType;
import stirling.software.saas.util.AuthenticationUtils;
/**
* The hot-path PAYG interceptor. Mirrors the {@code UnifiedCreditInterceptor} shape: registered
* after it in {@code PaygWebMvcConfig} so legacy credit-rejection short-circuits before we waste
* work hashing inputs.
* The hot-path PAYG interceptor, registered in {@code PaygWebMvcConfig}.
*
* <p>{@code preHandle}: gates on {@code @AutoJobPostMapping} OR {@code @RequiresFeature} (the
* latter lets AI controllers — JSON-bodied, no AutoJobPostMapping — bill correctly), reads the
@@ -18,10 +18,8 @@ import stirling.software.saas.payg.entitlement.EntitlementGuard;
* <li>{@link PaygResponseBodyWrapperFilter} as a Servlet filter — registered with no explicit
* order so it sits at the end of the Spring filter chain (after all security filters). Pure
* response-wrapping plumbing.
* <li>{@link PaygChargeInterceptor} as a Spring MVC interceptor — registered AFTER {@code
* UnifiedCreditInterceptor} so legacy credit rejections short-circuit before we hash inputs.
* Both intercept {@code /api/**} with the same admin/info/health exclusions as the legacy
* config.
* <li>{@link PaygChargeInterceptor} as a Spring MVC interceptor — intercepts {@code /api/**} with
* admin/info/health exclusions.
* </ul>
*/
@Configuration
@@ -42,10 +40,9 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
}
/**
* The {@code PaygChargeInterceptor} runs after the {@link #ENTITLEMENT_GUARD_ORDER guard} (and
* after the legacy {@code UnifiedCreditInterceptor}, default order 0), so {@code openProcess}
* only fires for requests the guard has admitted. See {@link #ENTITLEMENT_GUARD_ORDER} for the
* full ordering rationale.
* The {@code PaygChargeInterceptor} runs after the {@link #ENTITLEMENT_GUARD_ORDER guard}, so
* {@code openProcess} only fires for requests the guard has admitted. See {@link
* #ENTITLEMENT_GUARD_ORDER} for the full ordering rationale.
*/
public static final int INTERCEPTOR_ORDER = 1000;
@@ -57,9 +54,7 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
* short-circuits with its 402 before the charge interceptor ever runs. A blocked request
* therefore never opens a process, materialises inputs, or writes a charge: a refused operation
* must not bill, and running the guard first guarantees that structurally rather than by
* compensating after the fact. Stays above the legacy {@code UnifiedCreditInterceptor} (default
* order 0, only registered under the {@code legacy-credits} profile) so a legacy rejection
* still wins.
* compensating after the fact.
*/
public static final int ENTITLEMENT_GUARD_ORDER = 900;
@@ -67,20 +62,12 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(paygChargeInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns(
"/api/v1/credits/**",
"/api/v1/config/**",
"/api/v1/info/**",
"/api/v1/admin/**")
.excludePathPatterns("/api/v1/config/**", "/api/v1/info/**", "/api/v1/admin/**")
.order(INTERCEPTOR_ORDER);
registry.addInterceptor(entitlementGuard)
.addPathPatterns("/api/**")
.excludePathPatterns(
"/api/v1/credits/**",
"/api/v1/config/**",
"/api/v1/info/**",
"/api/v1/admin/**")
.excludePathPatterns("/api/v1/config/**", "/api/v1/info/**", "/api/v1/admin/**")
.order(ENTITLEMENT_GUARD_ORDER);
}
}
@@ -1,68 +0,0 @@
package stirling.software.saas.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.saas.model.TeamCredit;
@Repository
public interface TeamCreditRepository extends JpaRepository<TeamCredit, Long> {
/** Find team credits by team ID. */
@Query("SELECT tc FROM TeamCredit tc WHERE tc.team.id = :teamId")
Optional<TeamCredit> findByTeamId(@Param("teamId") Long teamId);
/**
* Atomically consume credits from the team pool. Uses the {@code @Version} column on {@link
* TeamCredit} for optimistic locking - concurrent attempts will fail-fast rather than
* over-deduct. Returns 1 on success, 0 if insufficient balance or version conflict.
*/
@Modifying
@Query(
value =
"""
UPDATE team_credits
SET cycle_credits_remaining = CASE
WHEN cycle_credits_remaining >= :amount THEN cycle_credits_remaining - :amount
WHEN cycle_credits_remaining > 0 AND bought_credits_remaining >= (:amount - cycle_credits_remaining)
THEN 0
ELSE cycle_credits_remaining
END,
bought_credits_remaining = CASE
WHEN cycle_credits_remaining >= :amount THEN bought_credits_remaining
WHEN cycle_credits_remaining > 0 AND bought_credits_remaining >= (:amount - cycle_credits_remaining)
THEN bought_credits_remaining - (:amount - cycle_credits_remaining)
WHEN cycle_credits_remaining = 0 AND bought_credits_remaining >= :amount
THEN bought_credits_remaining - :amount
ELSE bought_credits_remaining
END,
total_api_calls_made = total_api_calls_made + :amount,
last_api_usage = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP,
version = version + 1
WHERE team_id = :teamId
AND (cycle_credits_remaining + bought_credits_remaining) >= :amount
""",
nativeQuery = true)
int consumeCredit(@Param("teamId") Long teamId, @Param("amount") int amount);
@Query(
"SELECT CASE WHEN COUNT(tc) > 0 THEN true ELSE false END FROM TeamCredit tc WHERE tc.team.id = :teamId")
boolean existsByTeamId(@Param("teamId") Long teamId);
@Modifying
@Query("DELETE FROM TeamCredit tc WHERE tc.team.id = :teamId")
void deleteByTeamId(@Param("teamId") Long teamId);
@Query(
"SELECT tc FROM TeamCredit tc WHERE tc.lastCycleResetAt IS NULL OR tc.lastCycleResetAt < :lastScheduledReset")
List<TeamCredit> findCreditsNeedingCycleReset(
@Param("lastScheduledReset") LocalDateTime lastScheduledReset);
}
@@ -1,148 +0,0 @@
package stirling.software.saas.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.UserCredit;
/**
* JPA repository for {@link UserCredit}. Includes JPQL queries for the common read paths and native
* SQL for atomic credit-consumption updates (avoids select-then-update races).
*
* <p>Native queries reference {@code user_credits} and {@code users} unqualified — they pick up
* Hibernate's {@code default_schema} (set to {@code stirling_pdf} in {@code
* application-saas.properties}). Keeping the schema out of the SQL means a future schema rename is
* a one-property change instead of a sweep of native SQL.
*/
@Repository
public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
Optional<UserCredit> findByUser(User user);
Optional<UserCredit> findByUserId(Long userId);
@Query(
"SELECT uc FROM UserCredit uc WHERE uc.lastCycleResetAt IS NULL OR uc.lastCycleResetAt < :lastScheduledReset")
List<UserCredit> findCreditsNeedingCycleReset(
@Param("lastScheduledReset") LocalDateTime lastScheduledReset);
@Query("SELECT SUM(uc.totalApiCallsMade) FROM UserCredit uc")
Long getTotalApiCallsAcrossAllUsers();
@Query("SELECT SUM(uc.cycleCreditsRemaining + uc.boughtCreditsRemaining) FROM UserCredit uc")
Long getTotalAvailableCreditsAcrossAllUsers();
@Query("SELECT uc FROM UserCredit uc WHERE uc.user.apiKey = :apiKey")
Optional<UserCredit> findByUserApiKey(@Param("apiKey") String apiKey);
@Query("SELECT uc FROM UserCredit uc WHERE uc.user.supabaseId = :supabaseId")
Optional<UserCredit> findBySupabaseId(@Param("supabaseId") UUID supabaseId);
@Query("SELECT COUNT(uc) FROM UserCredit uc WHERE uc.lastApiUsage >= :since")
Long countActiveUsersInPeriod(@Param("since") LocalDateTime since);
@Modifying
@Query(
value =
"UPDATE user_credits "
+ "SET "
+ " cycle_credits_remaining = "
+ " CASE "
+ " WHEN cycle_credits_remaining >= :creditAmount THEN cycle_credits_remaining - :creditAmount "
+ " ELSE 0 "
+ " END, "
+ " bought_credits_remaining = "
+ " CASE "
+ " WHEN cycle_credits_remaining < :creditAmount "
+ " THEN GREATEST(0, bought_credits_remaining - (:creditAmount - cycle_credits_remaining)) "
+ " ELSE bought_credits_remaining "
+ " END, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT user_id FROM users WHERE api_key = :apiKey) "
+ " AND (cycle_credits_remaining + bought_credits_remaining >= :creditAmount)",
nativeQuery = true)
int consumeCredit(@Param("apiKey") String apiKey, @Param("creditAmount") int creditAmount);
@Modifying
@Query(
value =
"UPDATE user_credits "
+ "SET "
+ " cycle_credits_remaining = "
+ " CASE "
+ " WHEN cycle_credits_remaining >= :creditAmount THEN cycle_credits_remaining - :creditAmount "
+ " ELSE 0 "
+ " END, "
+ " bought_credits_remaining = "
+ " CASE "
+ " WHEN cycle_credits_remaining < :creditAmount "
+ " THEN GREATEST(0, bought_credits_remaining - (:creditAmount - cycle_credits_remaining)) "
+ " ELSE bought_credits_remaining "
+ " END, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
+ " AND (cycle_credits_remaining + bought_credits_remaining >= :creditAmount)",
nativeQuery = true)
int consumeCreditBySupabaseId(
@Param("supabaseId") UUID supabaseId, @Param("creditAmount") int creditAmount);
/**
* Consumes ONLY cycle credits (does not touch bought credits). Used in explicit waterfall
* logic.
*/
@Modifying
@Query(
value =
"UPDATE user_credits "
+ "SET "
+ " cycle_credits_remaining = cycle_credits_remaining - :amount, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
+ " AND cycle_credits_remaining >= :amount",
nativeQuery = true)
int consumeCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
/** Consumes ONLY bought credits (does not touch cycle credits). */
@Modifying
@Query(
value =
"UPDATE user_credits "
+ "SET "
+ " bought_credits_remaining = bought_credits_remaining - :amount, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
+ " AND bought_credits_remaining >= :amount",
nativeQuery = true)
int consumeBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
/** Checks if user has sufficient cycle credits (does NOT consume them). */
@Query(
value =
"SELECT CASE WHEN uc.cycle_credits_remaining >= :amount THEN TRUE ELSE FALSE END "
+ "FROM user_credits uc "
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId)",
nativeQuery = true)
Boolean hasCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
/** Checks if user has sufficient bought credits (does NOT consume them). */
@Query(
value =
"SELECT CASE WHEN uc.bought_credits_remaining >= :amount THEN TRUE ELSE FALSE END "
+ "FROM user_credits uc "
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId)",
nativeQuery = true)
Boolean hasBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
}
@@ -1,41 +0,0 @@
package stirling.software.saas.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.UserErrorTracker;
public interface UserErrorTrackerRepository extends JpaRepository<UserErrorTracker, Long> {
Optional<UserErrorTracker> findByUserAndEndpoint(User user, String endpoint);
Optional<UserErrorTracker> findByUserIdAndEndpoint(Long userId, String endpoint);
@Query(
"SELECT uet FROM UserErrorTracker uet WHERE uet.user.apiKey = :apiKey AND uet.endpoint = :endpoint")
Optional<UserErrorTracker> findByUserApiKeyAndEndpoint(
@Param("apiKey") String apiKey, @Param("endpoint") String endpoint);
@Query("SELECT uet FROM UserErrorTracker uet WHERE uet.resetAfter <= :currentDateTime")
List<UserErrorTracker> findExpiredErrorTrackers(
@Param("currentDateTime") LocalDateTime currentDateTime);
@Modifying
@Query("DELETE FROM UserErrorTracker uet WHERE uet.resetAfter <= :currentDateTime")
int deleteExpiredErrorTrackers(@Param("currentDateTime") LocalDateTime currentDateTime);
@Query(
"SELECT uet FROM UserErrorTracker uet WHERE uet.user = :user AND uet.processingErrorCount >= 3")
List<UserErrorTracker> findHighErrorCountForUser(@Param("user") User user);
@Query(
"SELECT COUNT(uet) FROM UserErrorTracker uet WHERE uet.processingErrorCount >= :threshold")
Long countUsersWithHighErrorCount(@Param("threshold") int threshold);
}
@@ -63,7 +63,6 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
private final TeamService teamService;
private final UserService userService;
private final SupabaseUserService supabaseUserService;
private final stirling.software.saas.service.CreditService creditService;
private final SaasTeamService saasTeamService;
private final JwtDecoder jwtDecoder;
private final AuthenticationEntryPoint authenticationEntryPoint =
@@ -73,13 +72,11 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
TeamService teamService,
UserService userService,
SupabaseUserService supabaseUserService,
stirling.software.saas.service.CreditService creditService,
SaasTeamService saasTeamService,
JwtDecoder jwtDecoder) {
this.teamService = teamService;
this.userService = userService;
this.supabaseUserService = supabaseUserService;
this.creditService = creditService;
this.saasTeamService = saasTeamService;
this.jwtDecoder = jwtDecoder;
}
@@ -265,7 +262,10 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
user.setUsername(supabaseUser.getEmail());
}
try {
return userService.saveUser(user);
User saved = userService.saveUser(user);
// Give the account its own team rather than the shared Default team.
saved.setTeam(saasTeamService.ensurePersonalTeam(saved));
return saved;
} catch (DataIntegrityViolationException e) {
log.warn(
"Email collision upgrading anonymous user {} to {}: {}",
@@ -347,7 +347,8 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
newUser.setEnabled(true);
newUser.setFirstLogin(true);
newUser.setRoleName(roleId);
newUser.setTeam(teamService.getOrCreateDefaultTeam());
// No shared Default team; a per-user personal team is assigned after save (team_id
// nullable).
newUser.setAuthenticationType(authenticationType);
newUser.setSupabaseId(supabaseId);
newUser.addAuthority(new Authority(roleId, newUser));
@@ -382,18 +383,7 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
// Only the DB-race winner runs first-time init; the losers skip it.
if (weCreatedThisUser) {
try {
creditService.getOrCreateUserCredits(savedUser);
} catch (Exception e) {
log.warn(
"Failed to initialize credits for new user {} ({}): {}",
LogRedactionUtils.redactSupabaseId(supabaseId),
LogRedactionUtils.redactEmail(savedUser.getUsername()),
e.getMessage());
}
try {
saasTeamService.createPersonalTeam(savedUser);
savedUser = userService.findBySupabaseId(supabaseId).orElse(savedUser);
savedUser.setTeam(saasTeamService.ensurePersonalTeam(savedUser));
} catch (Exception e) {
log.warn(
"Failed to create personal team for new user {} ({}): {}",
@@ -49,7 +49,6 @@ import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.SaasTeamService;
import stirling.software.saas.service.SupabaseUserService;
@@ -66,7 +65,6 @@ public class SupabaseSecurityConfig {
private final UserService userService;
private final TeamService teamService;
private final SupabaseUserService supabaseUserService;
private final CreditService creditService;
private final SaasTeamService saasTeamService;
private final ApplicationProperties applicationProperties;
@@ -121,7 +119,6 @@ public class SupabaseSecurityConfig {
teamService,
userService,
supabaseUserService,
creditService,
saasTeamService,
jwtDecoder),
BearerTokenAuthenticationFilter.class)
@@ -260,7 +257,7 @@ public class SupabaseSecurityConfig {
applicationProperties.getSystem() != null
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty();
List<String> origins =
List<String> configuredOrigins =
operatorOverride
? applicationProperties.getSystem().getCorsAllowedOrigins()
: List.of(
@@ -270,6 +267,18 @@ public class SupabaseSecurityConfig {
"https://stirling.com",
"https://app.stirling.com",
"https://api.stirling.com");
// Always allow the desktop (Tauri) app's webview origins so the bundled
// desktop client can reach the cloud backend regardless of the operator's
// configured web origins. A browser can never present a tauri:// (or
// tauri.localhost) origin, so these are desktop-app identities — safe to
// allow alongside allowCredentials=true. Mirrors core WebMvcConfig.
List<String> origins = new ArrayList<>(configuredOrigins);
for (String desktopOrigin :
List.of("tauri://localhost", "http://tauri.localhost", "https://tauri.localhost")) {
if (!origins.contains(desktopOrigin)) {
origins.add(desktopOrigin);
}
}
if (origins.stream().anyMatch(o -> o.contains("*"))) {
log.warn(
"CORS origins contain a wildcard paired with allowCredentials=true: {}."
@@ -287,8 +296,9 @@ public class SupabaseSecurityConfig {
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY"));
cfg.setExposedHeaders(List.of("WWW-Authenticate", "X-Credits-Remaining"));
"X-API-KEY",
"X-Browser-Id"));
cfg.setExposedHeaders(List.of("WWW-Authenticate"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
@@ -1,67 +0,0 @@
package stirling.software.saas.service;
import java.time.LocalDateTime;
import java.time.ZoneId;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.config.CreditsProperties;
@Service
@Profile("saas")
@Slf4j
@RequiredArgsConstructor
public class CreditResetScheduler {
private final CreditService creditService;
private final CreditsProperties creditsProperties;
/**
* Reset cycle credits for all users and teams on the 1st of each month at 2 AM UTC This runs
* monthly, resetting credits based on user roles and team seats
*/
@Scheduled(cron = "${credits.reset.cron:0 0 2 1 * *}", zone = "${credits.reset.zone:UTC}")
public void resetCycleCredits() {
log.info(
"Starting monthly credit reset for all users and teams (schedule: {}, zone: {})",
creditsProperties.getReset().getCron(),
creditsProperties.getReset().getZone());
try {
ZoneId configuredZone = ZoneId.of(creditsProperties.getReset().getZone());
LocalDateTime resetTime = LocalDateTime.now(configuredZone);
creditService.resetCycleCreditsForAllUsers(resetTime);
creditService.resetCycleCreditsForAllTeams(resetTime);
log.info("Monthly credit reset completed successfully at {}", resetTime);
} catch (Exception e) {
log.error("Error during monthly credit reset", e);
}
}
// NOTE: The startup catch-up reset (formerly @EventListener(ApplicationReadyEvent)) was
// removed. It bulk-looped every user on each boot (per-row save), hammering the DB and
// stalling boot on large user tables. Per-user cycle resets already happen lazily in
// CreditService.getOrCreateUserCredits (isCycleResetDue), and the monthly cron above still
// performs the scheduled reset.
/**
* Cleanup and maintenance task; runs daily at 3 AM UTC. Performs maintenance tasks like
* cleaning up old data.
*/
@Scheduled(cron = "0 0 3 * * *", zone = "UTC")
public void performDailyMaintenance() {
log.debug("Starting daily credit system maintenance");
try {
// API call history cleanup is no longer needed; audit system handles this
log.debug("Daily credit system maintenance completed");
} catch (Exception e) {
log.error("Error during daily credit system maintenance", e);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,315 +0,0 @@
package stirling.software.saas.service;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.model.ProcessingErrorType;
import stirling.software.saas.model.UserErrorTracker;
import stirling.software.saas.repository.UserErrorTrackerRepository;
@Service
@Profile("saas")
@Slf4j
@Transactional
public class ErrorTrackingService {
private final UserErrorTrackerRepository errorTrackerRepository;
private final UserRepository userRepository;
private final CreditsProperties creditsProperties;
/**
* Local cache for error counts to reduce database chatter.
*
* <p>This cache is used to temporarily store error counts for each API key and endpoint,
* reducing the frequency of database writes and lookups.
*
* <p><b>Nullability:</b> This field may be {@code null} if local caching is disabled via {@link
* CreditsProperties#getCache()#isLocalEnabled()}. All usages must check for null before
* accessing or invoking methods on this cache.
*
* <p><b>Lifecycle:</b> The cache is initialized in the constructor based on configuration and
* remains unchanged for the lifetime of this service instance.
*
* <p><b>Thread-safety:</b> The underlying Caffeine cache is thread-safe.
*/
private final Cache<String, ErrorCountCache> errorCountCache;
public ErrorTrackingService(
UserErrorTrackerRepository errorTrackerRepository,
UserRepository userRepository,
CreditsProperties creditsProperties) {
this.errorTrackerRepository = errorTrackerRepository;
this.userRepository = userRepository;
this.creditsProperties = creditsProperties;
// Initialize cache based on configuration
this.errorCountCache =
creditsProperties.getCache().isLocalEnabled()
? Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(
creditsProperties.getErrors().getTtlMinutes(),
TimeUnit.MINUTES)
.build()
: null;
}
/**
* Record an error and determine if credits should be consumed
*
* @param apiKey User's API key
* @param endpoint The endpoint that failed
* @param throwable The exception that occurred
* @param httpStatus HTTP response status
* @return true if credits should be consumed for this error
*/
public boolean recordErrorAndShouldConsumeCredit(
String apiKey, String endpoint, Throwable throwable, int httpStatus) {
ProcessingErrorType errorType =
ProcessingErrorType.classifyError(throwable, httpStatus, endpoint);
// Never charge for validation errors or system errors
if (errorType != ProcessingErrorType.PROCESSING_ERROR) {
log.debug(
"Error classified as {}, no credit consumption for API key: {}, endpoint: {}",
errorType,
maskApiKey(apiKey),
endpoint);
return false;
}
String cacheKey = apiKey + "|" + endpoint;
if (errorCountCache != null) {
// Use cache for fast tracking
ErrorCountCache cachedCount = errorCountCache.get(cacheKey, k -> new ErrorCountCache());
cachedCount.incrementErrorCount();
boolean shouldCharge =
cachedCount.getErrorCount()
> creditsProperties.getErrors().getFreeProcessingErrors();
// Persist to DB when crossing the charging threshold or on first error
if (shouldCharge
&& cachedCount.getErrorCount()
== creditsProperties.getErrors().getFreeProcessingErrors() + 1) {
persistErrorToDatabase(apiKey, endpoint);
}
log.info(
"Processing error recorded (cached) for API key: {}, endpoint: {}, error count: {}, will charge: {}",
maskApiKey(apiKey),
endpoint,
cachedCount.getErrorCount(),
shouldCharge);
return shouldCharge;
} else {
// Fallback to direct DB tracking
return recordErrorDirectToDatabase(apiKey, endpoint);
}
}
private boolean recordErrorDirectToDatabase(String apiKey, String endpoint) {
Optional<User> userOpt = userRepository.findByApiKey(apiKey);
if (userOpt.isEmpty()) {
log.warn("User not found for API key: {}", maskApiKey(apiKey));
return false;
}
User user = userOpt.get();
UserErrorTracker tracker = getOrCreateErrorTracker(user, endpoint);
tracker.recordProcessingError(creditsProperties.getErrors().getTtlMinutes());
errorTrackerRepository.save(tracker);
boolean shouldCharge =
tracker.shouldChargeForProcessingError(
creditsProperties.getErrors().getFreeProcessingErrors());
log.info(
"Processing error recorded (DB) for user: {}, endpoint: {}, error count: {}, will charge: {}",
user.getUsername(),
endpoint,
tracker.getProcessingErrorCount(),
shouldCharge);
return shouldCharge;
}
private void persistErrorToDatabase(String apiKey, String endpoint) {
try {
Optional<User> userOpt = userRepository.findByApiKey(apiKey);
if (userOpt.isPresent()) {
User user = userOpt.get();
UserErrorTracker tracker = getOrCreateErrorTracker(user, endpoint);
// Set to threshold + 1 to indicate charging has started
tracker.setProcessingErrorCount(
creditsProperties.getErrors().getFreeProcessingErrors() + 1);
tracker.setLastProcessingError(LocalDateTime.now());
tracker.setResetAfter(
LocalDateTime.now()
.plusMinutes(creditsProperties.getErrors().getTtlMinutes()));
errorTrackerRepository.save(tracker);
log.debug(
"Persisted error threshold crossing to DB for API key: {}, endpoint: {}",
maskApiKey(apiKey),
endpoint);
}
} catch (Exception e) {
log.error(
"Failed to persist error to database for API key: {}, endpoint: {}",
maskApiKey(apiKey),
endpoint,
e);
}
}
/** Check if a user has high error counts that might indicate abuse */
public boolean hasHighErrorCount(String apiKey, String endpoint) {
Optional<UserErrorTracker> trackerOpt =
errorTrackerRepository.findByUserApiKeyAndEndpoint(apiKey, endpoint);
return trackerOpt
.map(
t ->
t.shouldChargeForProcessingError(
creditsProperties.getErrors().getFreeProcessingErrors()))
.orElse(false);
}
/** Get error information for a user and endpoint */
public ErrorInfo getErrorInfo(String apiKey, String endpoint) {
String cacheKey = apiKey + "|" + endpoint;
if (errorCountCache != null) {
// Check cache first
ErrorCountCache cachedCount = errorCountCache.getIfPresent(cacheKey);
if (cachedCount != null) {
int currentCount = cachedCount.getErrorCount();
int freeErrors = creditsProperties.getErrors().getFreeProcessingErrors();
return new ErrorInfo(
currentCount,
Math.max(0, freeErrors - currentCount),
currentCount > freeErrors,
cachedCount.getLastErrorTime());
}
}
// Fallback to DB
Optional<UserErrorTracker> trackerOpt =
errorTrackerRepository.findByUserApiKeyAndEndpoint(apiKey, endpoint);
if (trackerOpt.isEmpty()) {
return new ErrorInfo(
0, creditsProperties.getErrors().getFreeProcessingErrors(), false, null);
}
UserErrorTracker tracker = trackerOpt.get();
// Reset if expired
if (tracker.isExpired()) {
tracker.resetErrorCount(creditsProperties.getErrors().getTtlMinutes());
errorTrackerRepository.save(tracker);
return new ErrorInfo(
0, creditsProperties.getErrors().getFreeProcessingErrors(), false, null);
}
return new ErrorInfo(
tracker.getProcessingErrorCount(),
tracker.getErrorsUntilCharged(
creditsProperties.getErrors().getFreeProcessingErrors()),
tracker.shouldChargeForProcessingError(
creditsProperties.getErrors().getFreeProcessingErrors()),
tracker.getLastProcessingError());
}
private UserErrorTracker getOrCreateErrorTracker(User user, String endpoint) {
Optional<UserErrorTracker> existing =
errorTrackerRepository.findByUserAndEndpoint(user, endpoint);
if (existing.isPresent()) {
UserErrorTracker tracker = existing.get();
// Reset if expired
if (tracker.isExpired()) {
tracker.resetErrorCount(creditsProperties.getErrors().getTtlMinutes());
}
return tracker;
}
// Create new tracker
return new UserErrorTracker(user, endpoint, creditsProperties.getErrors().getTtlMinutes());
}
/** Clean up expired error trackers every hour */
@Scheduled(cron = "0 0 * * * *")
public void cleanupExpiredErrorTrackers() {
try {
int deleted = errorTrackerRepository.deleteExpiredErrorTrackers(LocalDateTime.now());
if (deleted > 0) {
log.debug("Cleaned up {} expired error trackers", deleted);
}
} catch (Exception e) {
log.error("Error cleaning up expired error trackers", e);
}
}
private String maskApiKey(String apiKey) {
if (apiKey == null || apiKey.length() < 8) {
return "***";
}
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
}
/** Information about user's error status for an endpoint */
public static class ErrorInfo {
public final int currentErrorCount;
public final int errorsUntilCharged;
public final boolean isChargingForErrors;
public final LocalDateTime lastError;
public ErrorInfo(
int currentErrorCount,
int errorsUntilCharged,
boolean isChargingForErrors,
LocalDateTime lastError) {
this.currentErrorCount = currentErrorCount;
this.errorsUntilCharged = errorsUntilCharged;
this.isChargingForErrors = isChargingForErrors;
this.lastError = lastError;
}
}
/** Cache entry for tracking error counts in memory */
private static class ErrorCountCache {
private int errorCount = 0;
private LocalDateTime lastErrorTime = LocalDateTime.now();
public void incrementErrorCount() {
errorCount++;
lastErrorTime = LocalDateTime.now();
}
public int getErrorCount() {
return errorCount;
}
public LocalDateTime getLastErrorTime() {
return lastErrorTime;
}
}
}
@@ -2,7 +2,6 @@ package stirling.software.saas.service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
@@ -21,16 +20,12 @@ import stirling.software.proprietary.security.database.repository.UserRepository
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.saas.billing.repository.BillingSubscriptionRepository;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.config.SupabaseConfigurationProperties;
import stirling.software.saas.model.TeamCredit;
import stirling.software.saas.model.TeamInvitation;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.repository.SaasTeamExtensionsRepository;
import stirling.software.saas.repository.TeamCreditRepository;
import stirling.software.saas.repository.TeamInvitationRepository;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.repository.UserCreditRepository;
/** SaaS-only team management: invitations, personal teams, seat caps, paid-subscription gating. */
@Service
@@ -43,11 +38,7 @@ public class SaasTeamService {
private final TeamMembershipRepository membershipRepository;
private final TeamInvitationRepository invitationRepository;
private final UserRepository userRepository;
private final UserCreditRepository userCreditRepository;
private final BillingSubscriptionRepository billingSubscriptionRepository;
private final TeamCreditService teamCreditService;
private final TeamCreditRepository teamCreditRepository;
private final CreditsProperties creditsProperties;
private final RestTemplate restTemplate;
private final RateLimitService rateLimitService;
private final SupabaseConfigurationProperties supabaseConfig;
@@ -59,6 +50,16 @@ public class SaasTeamService {
public static final String DEFAULT_TEAM_NAME = "Default";
public static final String INTERNAL_TEAM_NAME = "Internal";
/** Returns the user's personal team, creating one if they have none. Idempotent. */
@Transactional
public Team ensurePersonalTeam(User user) {
Team existing = user.getTeam();
if (existing != null && saasTeamExtensionService.isPersonal(existing)) {
return existing;
}
return createPersonalTeam(user);
}
/**
* Create personal team for new user during signup or migrate existing user from Default team
*
@@ -100,9 +101,6 @@ public class SaasTeamService {
user.setTeam(savedTeam);
userRepository.save(user);
// Initialize team credits
teamCreditService.initializeTeamCredits(savedTeam, user);
// Clean up old Default/Internal team membership
if (oldTeam != null
&& (DEFAULT_TEAM_NAME.equals(oldTeam.getName())
@@ -783,64 +781,6 @@ public class SaasTeamService {
teamRepository.save(team);
Optional<TeamCredit> creditOpt = teamCreditRepository.findByTeamId(teamId);
int fixedAllocation =
creditsProperties.getCycle().getAllocations().getOrDefault("ROLE_PRO_USER", 500);
if (creditOpt.isPresent()) {
TeamCredit credit = creditOpt.get();
int oldAllocation =
credit.getCycleCreditsAllocated() != null
? credit.getCycleCreditsAllocated()
: 0;
if (oldAllocation != fixedAllocation) {
int currentRemaining =
credit.getCycleCreditsRemaining() != null
? credit.getCycleCreditsRemaining()
: 0;
int allocationDifference = fixedAllocation - oldAllocation;
credit.setCycleCreditsAllocated(fixedAllocation);
int newRemaining = Math.max(0, currentRemaining + allocationDifference);
credit.setCycleCreditsRemaining(newRemaining);
teamCreditRepository.save(credit);
log.info(
"Updated team {} credit allocation: {} -> {} (fixed PRO amount). Remaining: {} -> {}",
teamId,
oldAllocation,
fixedAllocation,
currentRemaining,
newRemaining);
} else {
log.debug(
"Team {} already has fixed allocation of {} credits, no update needed",
teamId,
fixedAllocation);
}
} else {
log.warn("Team {} missing credit record; creating with fixed allocation", teamId);
TeamCredit credit = new TeamCredit(team);
credit.setCycleCreditsAllocated(fixedAllocation);
credit.setCycleCreditsRemaining(fixedAllocation);
credit.setBoughtCreditsRemaining(0);
credit.setTotalBoughtCredits(0);
credit.setTotalApiCallsMade(0L);
credit.setLastCycleResetAt(LocalDateTime.now());
teamCreditRepository.save(credit);
log.info(
"Created team_credits record for team {} with {} fixed credits (unlimited seats model)",
teamId,
fixedAllocation);
}
log.info(
"Team {} seat allocation updated: maxSeats={}, seatsUsed={}, isPersonal={}",
teamId,
@@ -31,6 +31,7 @@ public class SaasUserAccountService {
private final SupabaseUserService supabaseUserService;
private final SaasUserExtensionService saasUserExtensionService;
private final SaasTeamExtensionService saasTeamExtensionService;
private final SaasTeamService saasTeamService;
/**
* Resolve a local {@link User} from a Supabase UUID string. Throws if the ID format is invalid
@@ -173,6 +174,8 @@ public class SaasUserAccountService {
user.setUsername(email);
}
user = userService.saveUser(user);
// Give the upgraded user their own team rather than the shared Default team.
user.setTeam(saasTeamService.ensurePersonalTeam(user));
log.info(
"Upgraded anonymous user {} to {} ({})",
user.getId(),
@@ -1,279 +0,0 @@
package stirling.software.saas.service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.billing.service.StripeUsageReportingService;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.model.CreditConsumptionResult;
import stirling.software.saas.model.TeamCredit;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.repository.TeamCreditRepository;
import stirling.software.saas.repository.TeamMembershipRepository;
/**
* Service for managing team credit pools. Handles credit initialization, consumption, and cycle
* resets for teams.
*/
@Service
@Profile("saas")
@RequiredArgsConstructor
@Slf4j
public class TeamCreditService {
private final TeamCreditRepository teamCreditRepository;
private final TeamMembershipRepository membershipRepository;
private final CreditsProperties creditsProperties;
private final StripeUsageReportingService stripeUsageReportingService;
private final SaasUserExtensionService saasUserExtensionService;
/** Initialise a fixed PRO credit allocation for a new team. */
@Transactional
public TeamCredit initializeTeamCredits(Team team, User primaryUser) {
Optional<TeamCredit> existing = teamCreditRepository.findByTeamId(team.getId());
if (existing.isPresent()) {
log.debug("Team credits already exist for team {}", team.getId());
return existing.get();
}
TeamCredit credits = new TeamCredit(team);
// Fixed PRO allocation; seat-independent.
int proAllocation =
creditsProperties.getCycle().getAllocations().getOrDefault("ROLE_PRO_USER", 500);
int totalCycleAllocation = proAllocation;
credits.setCycleCreditsAllocated(totalCycleAllocation);
credits.setCycleCreditsRemaining(totalCycleAllocation);
credits.setLastCycleResetAt(LocalDateTime.now());
TeamCredit saved = teamCreditRepository.save(credits);
log.info(
"Initialized team credits for team {} with {} cycle credits (fixed PRO amount)",
team.getId(),
totalCycleAllocation);
return saved;
}
/**
* Check if team has credits available
*
* @param teamId the team ID
* @return true if team has credits available
*/
public boolean hasCreditsAvailable(Long teamId) {
return teamCreditRepository
.findByTeamId(teamId)
.map(TeamCredit::hasCreditsAvailable)
.orElse(false);
}
/**
* Atomically consume credits from team pool
*
* @param teamId the team ID
* @param amount number of credits to consume
* @return true if credits were consumed, false if insufficient credits or version conflict
*/
@Transactional
public boolean consumeCredit(Long teamId, int amount) {
int rowsUpdated = teamCreditRepository.consumeCredit(teamId, amount);
if (rowsUpdated == 0) {
log.warn(
"Failed to consume {} credits for team {} (insufficient credits or version conflict)",
amount,
teamId);
return false;
}
log.debug("Consumed {} credits for team {}", amount, teamId);
return true;
}
/**
* Get team credit summary for a user's team.
*
* @param user the user
* @return Optional of TeamCredit for the user's team
*/
public Optional<TeamCredit> getCreditSummaryForUser(User user) {
if (user.getTeam() == null) {
log.warn("User {} has no team assigned", user.getId());
return Optional.empty();
}
Long teamId = user.getTeam().getId();
log.debug("Using user's team {} for credit summary", teamId);
return teamCreditRepository.findByTeamId(teamId);
}
/**
* Get team credits by team ID
*
* @param teamId the team ID
* @return Optional of TeamCredit
*/
public Optional<TeamCredit> getTeamCredits(Long teamId) {
return teamCreditRepository.findByTeamId(teamId);
}
/**
* Add bought credits to team pool
*
* @param teamId the team ID
* @param credits number of credits to add
*/
@Transactional
public void addBoughtCredits(Long teamId, int credits) {
TeamCredit teamCredit =
teamCreditRepository
.findByTeamId(teamId)
.orElseThrow(() -> new IllegalArgumentException("Team credits not found"));
teamCredit.addBoughtCredits(credits);
teamCreditRepository.save(teamCredit);
log.info("Added {} bought credits to team {}", credits, teamId);
}
/**
* Reset cycle credits for team
*
* @param teamId the team ID
* @param cycleAllocation new cycle allocation
* @param resetTime reset timestamp
*/
@Transactional
public void resetCycleCredits(Long teamId, int cycleAllocation, LocalDateTime resetTime) {
TeamCredit teamCredit =
teamCreditRepository
.findByTeamId(teamId)
.orElseThrow(() -> new IllegalArgumentException("Team credits not found"));
teamCredit.resetCycleCredits(cycleAllocation, resetTime);
teamCreditRepository.save(teamCredit);
log.info("Reset cycle credits for team {} to {}", teamId, cycleAllocation);
}
/**
* Consume from the team credit pool; falls through to the team leader's metered Stripe billing
* when the pool is exhausted.
*/
@Transactional
public CreditConsumptionResult consumeCreditWithWaterfall(Long teamId, int amount) {
log.debug("[TEAM-CREDIT] Starting consumption for team {} - amount: {}", teamId, amount);
// Step 1: Try consuming from team credit pool
int rowsUpdated = teamCreditRepository.consumeCredit(teamId, amount);
if (rowsUpdated == 1) {
log.info("[TEAM-CREDIT] Consumed {} credits from team {} pool", amount, teamId);
return CreditConsumptionResult.success("TEAM_CREDITS");
}
log.warn("[TEAM-CREDIT] Team {} credit pool exhausted; checking leader overage", teamId);
// Step 2: Get team leader
Optional<User> leaderOpt = getTeamLeader(teamId);
if (leaderOpt.isEmpty()) {
log.error("[TEAM-CREDIT] Team {} has no leader; cannot use overage billing", teamId);
return CreditConsumptionResult.failure("NO_TEAM_LEADER");
}
User teamLeader = leaderOpt.get();
// Step 3: Check if team leader has metered billing enabled
if (!saasUserExtensionService.isMeteredBillingEnabled(teamLeader)) {
log.warn(
"[TEAM-CREDIT] Team {} leader {} does not have metered billing enabled",
teamId,
teamLeader.getUsername());
return CreditConsumptionResult.failure(
"TEAM_CREDITS_EXHAUSTED_NO_OVERAGE",
"Team credits exhausted. Team leader must enable overage billing for"
+ " uninterrupted service.");
}
// Step 4: Report overage to Stripe via team leader's metered billing
String leaderSupabaseId =
teamLeader.getSupabaseId() != null ? teamLeader.getSupabaseId().toString() : null;
if (leaderSupabaseId == null) {
log.error("[TEAM-CREDIT] Team leader {} has no Supabase ID", teamLeader.getUsername());
return CreditConsumptionResult.failure("LEADER_NO_SUPABASE_ID");
}
try {
String operationId = org.slf4j.MDC.get("requestId");
if (operationId == null || operationId.isBlank()) {
operationId = java.util.UUID.randomUUID().toString();
}
String idempotencyKey =
stripeUsageReportingService.generateIdempotencyKey(
leaderSupabaseId, amount, operationId);
log.info(
"[TEAM-CREDIT] Reporting {} overage credits to Stripe for team {} leader {}",
amount,
teamId,
teamLeader.getUsername());
boolean reported =
stripeUsageReportingService.reportUsageToStripe(
leaderSupabaseId, amount, idempotencyKey);
if (reported) {
log.info(
"[TEAM-CREDIT] Successfully reported {} overage credits for team {} via"
+ " leader {}",
amount,
teamId,
teamLeader.getUsername());
return CreditConsumptionResult.success("TEAM_LEADER_METERED");
} else {
log.error("[TEAM-CREDIT] Failed to report overage to Stripe for team {}", teamId);
return CreditConsumptionResult.failure(
"STRIPE_REPORTING_FAILED",
"Unable to report usage to Stripe. Please try again.");
}
} catch (Exception e) {
log.error(
"[TEAM-CREDIT] Exception reporting overage for team {}: {}",
teamId,
e.getMessage(),
e);
return CreditConsumptionResult.failure(
"STRIPE_REPORTING_ERROR", "Error reporting usage: " + e.getMessage());
}
}
/** Returns the team's LEADER (first one if multiple exist) for overage-billing routing. */
private Optional<User> getTeamLeader(Long teamId) {
List<TeamMembership> leaders =
membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER);
if (leaders.isEmpty()) {
log.warn("Team {} has no leaders", teamId);
return Optional.empty();
}
// Return first leader (typically only one leader per team)
TeamMembership leader = leaders.get(0);
User leaderUser = leader.getUser();
log.debug(
"Found team {} leader: {} (user ID: {})",
teamId,
leaderUser.getUsername(),
leaderUser.getId());
return Optional.of(leaderUser);
}
}
@@ -12,10 +12,9 @@ import stirling.software.proprietary.security.database.repository.AuthorityRepos
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.util.LogRedactionUtils;
/** Changes user roles and refreshes their credit allocation. */
/** Changes user roles (and the matching authority grant/revoke). */
@Service
@Profile("saas")
@RequiredArgsConstructor
@@ -24,8 +23,6 @@ public class UserRoleService {
private final UserRepository userRepository;
private final AuthorityRepository authorityRepository;
private final CreditService creditService;
private final CreditsProperties creditsProperties;
/**
* Change a user's role
@@ -58,7 +55,7 @@ public class UserRoleService {
/**
* Downgrade a user to FREE tier (ROLE_USER)
*
* <p>Changes role from PRO_USER to USER and resets cycle credit allocation to FREE tier.
* <p>Revokes ROLE_PRO_USER by changing the role/authority from PRO_USER to USER.
*
* @param user the user to downgrade
*/
@@ -70,24 +67,15 @@ public class UserRoleService {
changeRole(user, Role.USER.getRoleId());
// Reset credits to FREE tier allocation
int freeAllocation =
creditsProperties
.getCycle()
.getAllocations()
.getOrDefault(Role.USER.getRoleId(), 25);
creditService.resetCycleAllocationForRoleChange(user.getId(), freeAllocation);
log.info(
"Successfully downgraded user {} to FREE with {} cycle credits",
LogRedactionUtils.redactEmail(user.getUsername()),
freeAllocation);
"Successfully downgraded user {} to FREE",
LogRedactionUtils.redactEmail(user.getUsername()));
}
/**
* Upgrade a user to PRO tier (ROLE_PRO_USER)
*
* <p>Changes role from USER to PRO_USER and resets cycle credit allocation to PRO tier.
* <p>Grants ROLE_PRO_USER by changing the role/authority from USER to PRO_USER.
*
* @param user the user to upgrade
*/
@@ -98,30 +86,8 @@ public class UserRoleService {
changeRole(user, Role.PRO_USER.getRoleId());
// Reset credits to PRO tier allocation
int proAllocation =
creditsProperties
.getCycle()
.getAllocations()
.getOrDefault(Role.PRO_USER.getRoleId(), 100);
creditService.resetCycleAllocationForRoleChange(user.getId(), proAllocation);
log.info(
"Successfully upgraded user {} to PRO with {} cycle credits",
LogRedactionUtils.redactEmail(user.getUsername()),
proAllocation);
}
/**
* Get credit allocation for a specific role
*
* @param roleId the role ID (e.g., "ROLE_USER", "ROLE_PRO_USER")
* @return the cycle credit allocation for that role
*/
public int getCreditAllocationForRole(String roleId) {
return creditsProperties
.getCycle()
.getAllocations()
.getOrDefault(roleId, Role.USER.getRoleId().equals(roleId) ? 25 : 100);
"Successfully upgraded user {} to PRO",
LogRedactionUtils.redactEmail(user.getUsername()));
}
}
@@ -1,97 +0,0 @@
package stirling.software.saas.util;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.TeamCredit;
import stirling.software.saas.model.UserCredit;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.SaasTeamExtensionService;
import stirling.software.saas.service.TeamCreditService;
/**
* Resolves the user's remaining credit balance. Uses the team pool for non-personal team members,
* otherwise the user's individual credits (looked up by Supabase ID or API key).
*/
@Component
@Profile("saas")
@RequiredArgsConstructor
@Slf4j
public class CreditHeaderUtils {
private final SaasTeamExtensionService saasTeamExtensionService;
/**
* Get the remaining credits for a user, checking team credits first (non-personal teams only).
*
* @param user The user whose credits to check
* @param creditService The credit service to fetch user credits
* @param teamCreditService The team credit service to fetch team credits
* @return The remaining credit balance, or -1 if credits cannot be determined
*/
public int getRemainingCredits(
User user, CreditService creditService, TeamCreditService teamCreditService) {
try {
// Limited-API users always read personal credits.
boolean isLimitedApiUser =
user.getAuthorities().stream()
.anyMatch(
authority ->
"ROLE_LIMITED_API_USER".equals(authority.getAuthority())
|| "ROLE_EXTRA_LIMITED_API_USER"
.equals(authority.getAuthority()));
Long targetTeamId = null;
if (!isLimitedApiUser
&& user.getTeam() != null
&& !saasTeamExtensionService.isPersonal(user.getTeam())) {
targetTeamId = user.getTeam().getId();
}
if (targetTeamId != null) {
return teamCreditService
.getTeamCredits(targetTeamId)
.map(TeamCredit::getTotalAvailableCredits)
.orElse(-1);
} else {
log.debug(
"[CREDIT-HEADER] Getting personal credits - SupabaseId: {}, ApiKey: {}, Username: {}",
user.getSupabaseId(),
user.getApiKey() != null ? "present" : "null",
user.getUsername());
Optional<UserCredit> credits;
if (user.getSupabaseId() != null) {
credits =
creditService.getUserCreditsBySupabaseId(
user.getSupabaseId().toString());
log.debug(
"[CREDIT-HEADER] Looked up by SupabaseId - Found: {}",
credits.isPresent());
} else if (user.getApiKey() != null) {
credits = creditService.getUserCreditsByApiKey(user.getApiKey());
log.debug(
"[CREDIT-HEADER] Looked up by ApiKey - Found: {}", credits.isPresent());
} else {
log.warn(
"[CREDIT-HEADER] No SupabaseId or ApiKey for user: {}",
user.getUsername());
return -1;
}
int remaining = credits.map(UserCredit::getTotalAvailableCredits).orElse(-1);
log.debug("[CREDIT-HEADER] Returning credits: {}", remaining);
return remaining;
}
} catch (Exception e) {
log.warn("[CREDIT-HEADER] Could not get remaining credits: {}", e.getMessage(), e);
return -1;
}
}
}
@@ -0,0 +1,946 @@
package stirling.software.saas.ai.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.ai.controller.AiCreateController.AiCreateSessionResponse;
import stirling.software.saas.ai.controller.AiCreateController.AiCreateSessionSummary;
import stirling.software.saas.ai.controller.AiCreateController.CreateSessionRequest;
import stirling.software.saas.ai.controller.AiCreateController.CreateSessionResponse;
import stirling.software.saas.ai.controller.AiCreateController.DraftRequest;
import stirling.software.saas.ai.controller.AiCreateController.DraftSection;
import stirling.software.saas.ai.controller.AiCreateController.OutlineRequest;
import stirling.software.saas.ai.controller.AiCreateController.RepromptRequest;
import stirling.software.saas.ai.controller.AiCreateController.TemplateRequest;
import stirling.software.saas.ai.model.AiCreateSession;
import stirling.software.saas.ai.model.AiCreateSessionStatus;
import stirling.software.saas.ai.repository.AiCreateSessionRepository.AiCreateSessionSummaryProjection;
import stirling.software.saas.ai.service.AiCreateProxyService;
import stirling.software.saas.ai.service.AiCreateSessionService;
import stirling.software.saas.payg.charge.ChargeContext;
import stirling.software.saas.payg.charge.JobChargeService;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.ProcessType;
/**
* Pure unit tests for {@link AiCreateController}. All collaborators are mocked; the controller's
* handler methods are invoked directly and asserted via {@link ResponseEntity} / {@code verify}.
*
* <p>The controller reads {@code SecurityContextHolder} in the charge path, so each relevant test
* seeds an authentication and {@link #clearSecurityContext()} resets it afterwards.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AiCreateControllerTest {
@Mock private AiCreateSessionService sessionService;
@Mock private AiCreateProxyService proxyService;
@Mock private UserRepository userRepository;
@Mock private JobChargeService jobChargeService;
private AiCreateController controller;
@org.junit.jupiter.api.BeforeEach
void setUp() {
controller =
new AiCreateController(
sessionService, proxyService, userRepository, jobChargeService);
}
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
// ----------------------------------------------------------------------------------------------
// createSession + chargeForCreate
// ----------------------------------------------------------------------------------------------
@Nested
@DisplayName("createSession")
class CreateSession {
@Test
@DisplayName("happy path returns 200 with the new sessionId and charges one AI unit")
void createSession_happyPath_returnsIdAndCharges() {
authenticateWeb(userWithTeam(7L, 100L));
AiCreateSession created = session("sess-1", "user-x");
when(sessionService.createSession(
"write a report", "letter", "tmpl-1", "tex", "preview"))
.thenReturn(created);
CreateSessionRequest req =
new CreateSessionRequest(
"write a report", "letter", "tmpl-1", "tex", "preview");
ResponseEntity<CreateSessionResponse> resp = controller.createSession(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().sessionId()).isEqualTo("sess-1");
verify(sessionService)
.createSession("write a report", "letter", "tmpl-1", "tex", "preview");
}
@Test
@DisplayName("WEB auth charges a single AI unit with WEB source and the user's team")
void createSession_webAuth_chargesAiUnitWithWebSource() {
authenticateWeb(userWithTeam(7L, 100L));
when(sessionService.createSession(any(), any(), any(), any(), any()))
.thenReturn(session("sess-1", "user-x"));
controller.createSession(new CreateSessionRequest("p", null, null, null, null));
ArgumentCaptor<ChargeContext> ctx = ArgumentCaptor.forClass(ChargeContext.class);
verify(jobChargeService).chargeStandalone(ctx.capture(), eq(1));
ChargeContext c = ctx.getValue();
assertThat(c.ownerUserId()).isEqualTo(7L);
assertThat(c.ownerTeamId()).isEqualTo(100L);
assertThat(c.source()).isEqualTo(JobSource.WEB);
assertThat(c.processType()).isEqualTo(ProcessType.SINGLE_TOOL);
assertThat(c.billingCategory()).isEqualTo(BillingCategory.AI);
}
@Test
@DisplayName("API-key auth charges with API source (AI usage billed the same as web)")
void createSession_apiKeyAuth_chargesAiUnitWithApiSource() {
User user = userWithTeam(7L, 100L);
authenticateApiKey(user);
when(sessionService.createSession(any(), any(), any(), any(), any()))
.thenReturn(session("sess-1", "user-x"));
controller.createSession(new CreateSessionRequest("p", null, null, null, null));
ArgumentCaptor<ChargeContext> ctx = ArgumentCaptor.forClass(ChargeContext.class);
verify(jobChargeService).chargeStandalone(ctx.capture(), eq(1));
assertThat(ctx.getValue().source()).isEqualTo(JobSource.API);
assertThat(ctx.getValue().billingCategory()).isEqualTo(BillingCategory.AI);
}
@Test
@DisplayName("null prompt is rejected with 400 and never reaches the service")
void createSession_nullPrompt_throwsBadRequest() {
CreateSessionRequest req = new CreateSessionRequest(null, null, null, null, null);
assertThatThrownBy(() -> controller.createSession(req))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST));
verifyNoInteractions(sessionService);
verifyNoInteractions(jobChargeService);
}
@Test
@DisplayName("blank/whitespace prompt is rejected with 400")
void createSession_blankPrompt_throwsBadRequest() {
CreateSessionRequest req = new CreateSessionRequest(" ", null, null, null, null);
assertThatThrownBy(() -> controller.createSession(req))
.isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(sessionService);
verifyNoInteractions(jobChargeService);
}
@Test
@DisplayName("no authentication: session still created, charge is skipped (no NPE)")
void createSession_noAuth_skipsChargeButCreatesSession() {
SecurityContextHolder.clearContext();
when(sessionService.createSession(any(), any(), any(), any(), any()))
.thenReturn(session("sess-1", "user-x"));
ResponseEntity<CreateSessionResponse> resp =
controller.createSession(new CreateSessionRequest("p", null, null, null, null));
assertThat(resp.getBody().sessionId()).isEqualTo("sess-1");
verify(jobChargeService, never())
.chargeStandalone(any(), org.mockito.ArgumentMatchers.anyInt());
}
@Test
@DisplayName("user has no team: charge is skipped (free-grant accounting needs a team)")
void createSession_userWithoutTeam_skipsCharge() {
User user = new User();
user.setId(7L);
// No team.
authenticateWeb(user);
when(sessionService.createSession(any(), any(), any(), any(), any()))
.thenReturn(session("sess-1", "user-x"));
controller.createSession(new CreateSessionRequest("p", null, null, null, null));
verify(jobChargeService, never())
.chargeStandalone(any(), org.mockito.ArgumentMatchers.anyInt());
}
@Test
@DisplayName("charge failure is best-effort: session is still returned to the caller")
void createSession_chargeThrows_sessionStillSucceeds() {
authenticateWeb(userWithTeam(7L, 100L));
when(sessionService.createSession(any(), any(), any(), any(), any()))
.thenReturn(session("sess-1", "user-x"));
when(jobChargeService.chargeStandalone(any(), org.mockito.ArgumentMatchers.anyInt()))
.thenThrow(new IllegalStateException("stripe down"));
ResponseEntity<CreateSessionResponse> resp =
controller.createSession(new CreateSessionRequest("p", null, null, null, null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody().sessionId()).isEqualTo("sess-1");
}
}
// ----------------------------------------------------------------------------------------------
// deleteSession
// ----------------------------------------------------------------------------------------------
@Test
@DisplayName("deleteSession returns 204 and delegates to the service")
void deleteSession_returnsNoContentAndDelegates() {
ResponseEntity<Void> resp = controller.deleteSession("sess-1");
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
assertThat(resp.getBody()).isNull();
verify(sessionService).deleteSessionForCurrentUser("sess-1");
}
@Test
@DisplayName("deleteSession propagates a not-found from the service")
void deleteSession_propagatesServiceError() {
org.mockito.Mockito.doThrow(new ResponseStatusException(HttpStatus.NOT_FOUND))
.when(sessionService)
.deleteSessionForCurrentUser("missing");
assertThatThrownBy(() -> controller.deleteSession("missing"))
.isInstanceOf(ResponseStatusException.class);
}
// ----------------------------------------------------------------------------------------------
// getSession + toResponse mapping
// ----------------------------------------------------------------------------------------------
@Test
@DisplayName("getSession maps every entity field onto the response record")
void getSession_mapsAllFields() {
AiCreateSession s = session("sess-1", "user-x");
s.setDocType("letter");
s.setTemplateId("tmpl-1");
s.setTemplateTex("\\documentclass{}");
s.setPreviewTex("preview-tex");
s.setPromptInitial("first prompt");
s.setPromptLatest("latest prompt");
s.setOutlineText("- one\n- two");
s.setOutlineFilename("outline.txt");
s.setOutlineApproved(true);
s.setOutlineConstraints("{\"tone\":\"formal\"}");
s.setDraftSections("[{\"label\":\"Intro\",\"value\":\"hi\"}]");
s.setPolishedLatex("\\section{Intro}");
s.setPdfUrl("https://signed/url.pdf");
Instant created = Instant.parse("2024-01-01T00:00:00Z");
Instant updated = Instant.parse("2024-01-02T00:00:00Z");
s.setCreatedAt(created);
s.setUpdatedAt(updated);
s.setStatus(AiCreateSessionStatus.DRAFT_READY);
when(sessionService.getSessionForCurrentUser("sess-1")).thenReturn(s);
ResponseEntity<AiCreateSessionResponse> resp = controller.getSession("sess-1");
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
AiCreateSessionResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.sessionId()).isEqualTo("sess-1");
assertThat(body.userId()).isEqualTo("user-x");
assertThat(body.docType()).isEqualTo("letter");
assertThat(body.templateId()).isEqualTo("tmpl-1");
assertThat(body.templateTex()).isEqualTo("\\documentclass{}");
assertThat(body.previewTex()).isEqualTo("preview-tex");
assertThat(body.promptInitial()).isEqualTo("first prompt");
assertThat(body.promptLatest()).isEqualTo("latest prompt");
assertThat(body.outlineText()).isEqualTo("- one\n- two");
assertThat(body.outlineFilename()).isEqualTo("outline.txt");
assertThat(body.outlineApproved()).isTrue();
assertThat(body.outlineConstraints()).containsEntry("tone", "formal");
assertThat(body.draftSections()).containsExactly(new DraftSection("Intro", "hi"));
assertThat(body.polishedLatex()).isEqualTo("\\section{Intro}");
assertThat(body.pdfUrl()).isEqualTo("https://signed/url.pdf");
assertThat(body.createdAt()).isEqualTo(created);
assertThat(body.updatedAt()).isEqualTo(updated);
assertThat(body.status()).isEqualTo("DRAFT_READY");
}
@Test
@DisplayName("getSession with null status maps status to null and null payloads to null")
void getSession_nullStatusAndPayloads_mapToNull() {
AiCreateSession s = session("sess-1", "user-x");
s.setStatus(null);
s.setOutlineConstraints(null);
s.setDraftSections(null);
when(sessionService.getSessionForCurrentUser("sess-1")).thenReturn(s);
AiCreateSessionResponse body = controller.getSession("sess-1").getBody();
assertThat(body).isNotNull();
assertThat(body.status()).isNull();
assertThat(body.outlineConstraints()).isNull();
assertThat(body.draftSections()).isNull();
}
@Test
@DisplayName("getSession with blank payloads parses to null rather than throwing")
void getSession_blankPayloads_mapToNull() {
AiCreateSession s = session("sess-1", "user-x");
s.setOutlineConstraints(" ");
s.setDraftSections("");
when(sessionService.getSessionForCurrentUser("sess-1")).thenReturn(s);
AiCreateSessionResponse body = controller.getSession("sess-1").getBody();
assertThat(body.outlineConstraints()).isNull();
assertThat(body.draftSections()).isNull();
}
@Test
@DisplayName("getSession with malformed JSON payloads degrades to null (logged, not thrown)")
void getSession_malformedPayloads_mapToNull() {
AiCreateSession s = session("sess-1", "user-x");
s.setOutlineConstraints("{not-valid-json");
s.setDraftSections("[oops");
when(sessionService.getSessionForCurrentUser("sess-1")).thenReturn(s);
AiCreateSessionResponse body = controller.getSession("sess-1").getBody();
assertThat(body.outlineConstraints()).isNull();
assertThat(body.draftSections()).isNull();
}
@Test
@DisplayName("getSession propagates a not-found from the service")
void getSession_notFound_propagates() {
when(sessionService.getSessionForCurrentUser("missing"))
.thenThrow(
new ResponseStatusException(HttpStatus.NOT_FOUND, "AI session not found"));
assertThatThrownBy(() -> controller.getSession("missing"))
.isInstanceOf(ResponseStatusException.class);
}
// ----------------------------------------------------------------------------------------------
// listSessions + toSummary mapping + page/size clamping
// ----------------------------------------------------------------------------------------------
@Nested
@DisplayName("listSessions")
class ListSessions {
@Test
@DisplayName("maps projections to summaries and forwards includeDrafts")
void listSessions_mapsProjections() {
AiCreateSessionSummaryProjection p =
projection(
"sess-1",
"letter",
"tmpl-1",
"latest",
"initial",
AiCreateSessionStatus.SAVED,
"https://pdf",
Instant.parse("2024-01-01T00:00:00Z"),
Instant.parse("2024-01-02T00:00:00Z"));
when(sessionService.listSessionSummariesForCurrentUser(any(), eq(true)))
.thenReturn(List.of(p));
ResponseEntity<List<AiCreateSessionSummary>> resp =
controller.listSessions(0, 10, true);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody()).hasSize(1);
AiCreateSessionSummary summary = resp.getBody().get(0);
assertThat(summary.sessionId()).isEqualTo("sess-1");
assertThat(summary.docType()).isEqualTo("letter");
assertThat(summary.templateId()).isEqualTo("tmpl-1");
assertThat(summary.promptLatest()).isEqualTo("latest");
assertThat(summary.promptInitial()).isEqualTo("initial");
assertThat(summary.status()).isEqualTo("SAVED");
assertThat(summary.pdfUrl()).isEqualTo("https://pdf");
}
@Test
@DisplayName("projection with null status maps to a null status string")
void listSessions_nullStatus_mapsToNull() {
AiCreateSessionSummaryProjection p =
projection("s", null, null, null, null, null, null, null, null);
when(sessionService.listSessionSummariesForCurrentUser(any(), eq(false)))
.thenReturn(List.of(p));
AiCreateSessionSummary summary = controller.listSessions(0, 10, false).getBody().get(0);
assertThat(summary.status()).isNull();
}
@Test
@DisplayName("negative page is clamped to 0")
void listSessions_negativePage_clampedToZero() {
when(sessionService.listSessionSummariesForCurrentUser(any(), anyBoolean()))
.thenReturn(List.of());
controller.listSessions(-5, 10, false);
ArgumentCaptor<org.springframework.data.domain.PageRequest> pr =
ArgumentCaptor.forClass(org.springframework.data.domain.PageRequest.class);
verify(sessionService).listSessionSummariesForCurrentUser(pr.capture(), eq(false));
assertThat(pr.getValue().getPageNumber()).isZero();
}
@Test
@DisplayName("size above 50 is capped at 50")
void listSessions_oversizeSize_cappedAt50() {
when(sessionService.listSessionSummariesForCurrentUser(any(), anyBoolean()))
.thenReturn(List.of());
controller.listSessions(0, 9999, false);
ArgumentCaptor<org.springframework.data.domain.PageRequest> pr =
ArgumentCaptor.forClass(org.springframework.data.domain.PageRequest.class);
verify(sessionService).listSessionSummariesForCurrentUser(pr.capture(), eq(false));
assertThat(pr.getValue().getPageSize()).isEqualTo(50);
}
@Test
@DisplayName("size below 1 is floored to 1")
void listSessions_zeroSize_flooredToOne() {
when(sessionService.listSessionSummariesForCurrentUser(any(), anyBoolean()))
.thenReturn(List.of());
controller.listSessions(0, 0, false);
ArgumentCaptor<org.springframework.data.domain.PageRequest> pr =
ArgumentCaptor.forClass(org.springframework.data.domain.PageRequest.class);
verify(sessionService).listSessionSummariesForCurrentUser(pr.capture(), eq(false));
assertThat(pr.getValue().getPageSize()).isEqualTo(1);
}
@Test
@DisplayName("empty result yields an empty list, not null")
void listSessions_empty_returnsEmptyList() {
when(sessionService.listSessionSummariesForCurrentUser(any(), anyBoolean()))
.thenReturn(List.of());
ResponseEntity<List<AiCreateSessionSummary>> resp =
controller.listSessions(0, 10, false);
assertThat(resp.getBody()).isEmpty();
}
}
// ----------------------------------------------------------------------------------------------
// updateOutline
// ----------------------------------------------------------------------------------------------
@Nested
@DisplayName("updateOutline")
class UpdateOutline {
@Test
@DisplayName("serializes constraints to JSON and forwards text + filename")
void updateOutline_serializesConstraints() {
AiCreateSession updated = session("sess-1", "user-x");
when(sessionService.updateOutline(eq("sess-1"), eq("the outline"), eq("o.txt"), any()))
.thenReturn(updated);
OutlineRequest req =
new OutlineRequest("the outline", "o.txt", Map.of("tone", "formal"));
ResponseEntity<AiCreateSessionResponse> resp = controller.updateOutline("sess-1", req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
ArgumentCaptor<String> payload = ArgumentCaptor.forClass(String.class);
verify(sessionService)
.updateOutline(eq("sess-1"), eq("the outline"), eq("o.txt"), payload.capture());
assertThat(payload.getValue()).contains("\"tone\":\"formal\"");
}
@Test
@DisplayName("null constraints forward a null payload (use AI-generated outline)")
void updateOutline_nullConstraints_forwardsNullPayload() {
when(sessionService.updateOutline(any(), any(), any(), any()))
.thenReturn(session("sess-1", "user-x"));
controller.updateOutline("sess-1", new OutlineRequest("text", null, null));
verify(sessionService).updateOutline("sess-1", "text", null, null);
}
@Test
@DisplayName("empty outline string is allowed (signals AI-generated outline)")
void updateOutline_emptyOutlineText_isAllowed() {
when(sessionService.updateOutline(any(), any(), any(), any()))
.thenReturn(session("sess-1", "user-x"));
controller.updateOutline("sess-1", new OutlineRequest("", null, null));
verify(sessionService).updateOutline("sess-1", "", null, null);
}
@Test
@DisplayName("null outline text is rejected with 400 before touching the service")
void updateOutline_nullText_throwsBadRequest() {
OutlineRequest req = new OutlineRequest(null, "o.txt", Map.of("a", "b"));
assertThatThrownBy(() -> controller.updateOutline("sess-1", req))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST));
verifyNoInteractions(sessionService);
}
}
// ----------------------------------------------------------------------------------------------
// reprompt
// ----------------------------------------------------------------------------------------------
@Test
@DisplayName("reprompt forwards the prompt and returns the mapped session")
void reprompt_forwardsPrompt() {
AiCreateSession s = session("sess-1", "user-x");
s.setStatus(AiCreateSessionStatus.OUTLINE_PENDING);
when(sessionService.reprompt("sess-1", "new prompt")).thenReturn(s);
ResponseEntity<AiCreateSessionResponse> resp =
controller.reprompt("sess-1", new RepromptRequest("new prompt"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody().sessionId()).isEqualTo("sess-1");
assertThat(resp.getBody().status()).isEqualTo("OUTLINE_PENDING");
verify(sessionService).reprompt("sess-1", "new prompt");
}
@Test
@DisplayName("reprompt with null prompt is rejected with 400")
void reprompt_nullPrompt_throwsBadRequest() {
assertThatThrownBy(() -> controller.reprompt("sess-1", new RepromptRequest(null)))
.isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(sessionService);
}
@Test
@DisplayName("reprompt with blank prompt is rejected with 400")
void reprompt_blankPrompt_throwsBadRequest() {
assertThatThrownBy(() -> controller.reprompt("sess-1", new RepromptRequest(" ")))
.isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(sessionService);
}
// ----------------------------------------------------------------------------------------------
// updateDraft
// ----------------------------------------------------------------------------------------------
@Nested
@DisplayName("updateDraft")
class UpdateDraft {
@Test
@DisplayName("serializes draft sections to a JSON array and forwards it")
void updateDraft_serializesSections() {
when(sessionService.updateDraftSections(eq("sess-1"), any()))
.thenReturn(session("sess-1", "user-x"));
DraftRequest req =
new DraftRequest(
List.of(
new DraftSection("Intro", "hi"),
new DraftSection("Body", "x")));
controller.updateDraft("sess-1", req);
ArgumentCaptor<String> payload = ArgumentCaptor.forClass(String.class);
verify(sessionService).updateDraftSections(eq("sess-1"), payload.capture());
assertThat(payload.getValue()).contains("\"label\":\"Intro\"");
assertThat(payload.getValue()).contains("\"value\":\"hi\"");
}
@Test
@DisplayName("empty list is allowed and serialized to []")
void updateDraft_emptyList_serializesToEmptyArray() {
when(sessionService.updateDraftSections(eq("sess-1"), any()))
.thenReturn(session("sess-1", "user-x"));
controller.updateDraft("sess-1", new DraftRequest(List.of()));
verify(sessionService).updateDraftSections("sess-1", "[]");
}
@Test
@DisplayName("null draft sections is rejected with 400")
void updateDraft_nullSections_throwsBadRequest() {
assertThatThrownBy(() -> controller.updateDraft("sess-1", new DraftRequest(null)))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST));
verifyNoInteractions(sessionService);
}
}
// ----------------------------------------------------------------------------------------------
// updateTemplate
// ----------------------------------------------------------------------------------------------
@Nested
@DisplayName("updateTemplate")
class UpdateTemplate {
@Test
@DisplayName("docType only is accepted and forwarded")
void updateTemplate_docTypeOnly() {
when(sessionService.updateTemplate("sess-1", "letter", null))
.thenReturn(session("sess-1", "user-x"));
ResponseEntity<AiCreateSessionResponse> resp =
controller.updateTemplate("sess-1", new TemplateRequest("letter", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(sessionService).updateTemplate("sess-1", "letter", null);
}
@Test
@DisplayName("templateId only is accepted and forwarded")
void updateTemplate_templateIdOnly() {
when(sessionService.updateTemplate("sess-1", null, "tmpl-9"))
.thenReturn(session("sess-1", "user-x"));
controller.updateTemplate("sess-1", new TemplateRequest(null, "tmpl-9"));
verify(sessionService).updateTemplate("sess-1", null, "tmpl-9");
}
@Test
@DisplayName("both docType and templateId null is rejected with 400")
void updateTemplate_bothNull_throwsBadRequest() {
assertThatThrownBy(
() ->
controller.updateTemplate(
"sess-1", new TemplateRequest(null, null)))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST));
verifyNoInteractions(sessionService);
}
@Test
@DisplayName("both docType and templateId blank is rejected with 400")
void updateTemplate_bothBlank_throwsBadRequest() {
assertThatThrownBy(
() ->
controller.updateTemplate(
"sess-1", new TemplateRequest(" ", "")))
.isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(sessionService);
}
}
// ----------------------------------------------------------------------------------------------
// fillFields (proxy, no credit header)
// ----------------------------------------------------------------------------------------------
@Nested
@DisplayName("fillFields")
class FillFields {
@Test
@DisplayName("checks ownership, proxies POST, copies headers, and streams the body through")
void fillFields_proxiesAndStreams() throws Exception {
HttpServletRequest req = mock(HttpServletRequest.class);
HttpResponse<InputStream> upstream =
upstreamResponse(
200,
"section data",
httpHeaders(Map.of(HttpHeaders.CONTENT_TYPE, "application/json")));
when(proxyService.forward(
eq("POST"),
eq("/api/create/sessions/sess-1/fields"),
eq(req),
eq(false)))
.thenReturn(upstream);
ResponseEntity<StreamingResponseBody> resp = controller.fillFields("sess-1", req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
.isEqualTo("application/json");
// Ownership guard runs before the proxy.
verify(sessionService).getSessionForCurrentUser("sess-1");
assertThat(drain(resp.getBody())).isEqualTo("section data");
}
@Test
@DisplayName("ownership failure short-circuits before proxying")
void fillFields_ownershipFailure_doesNotProxy() throws Exception {
HttpServletRequest req = mock(HttpServletRequest.class);
when(sessionService.getSessionForCurrentUser("sess-1"))
.thenThrow(new ResponseStatusException(HttpStatus.NOT_FOUND));
assertThatThrownBy(() -> controller.fillFields("sess-1", req))
.isInstanceOf(ResponseStatusException.class);
verify(proxyService, never()).forward(any(), any(), any(), anyBoolean());
}
@Test
@DisplayName("upstream error: returns 503 with a JSON error body, never throws")
void fillFields_proxyThrows_returns503() throws Exception {
HttpServletRequest req = mock(HttpServletRequest.class);
when(proxyService.forward(any(), any(), any(), anyBoolean()))
.thenThrow(new java.io.IOException("backend down"));
ResponseEntity<StreamingResponseBody> resp = controller.fillFields("sess-1", req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON);
assertThat(drain(resp.getBody())).contains("AI backend unavailable");
}
}
// ----------------------------------------------------------------------------------------------
// stream (proxy, accept event-stream)
// ----------------------------------------------------------------------------------------------
@Nested
@DisplayName("stream")
class Stream {
@Test
@DisplayName(
"checks ownership, proxies GET as event-stream, defaults Content-Type, and streams")
void stream_proxiesEventStreamAndStreams() throws Exception {
HttpServletRequest req = mock(HttpServletRequest.class);
HttpResponse<InputStream> upstream =
upstreamResponse(200, "data: hi\n\n", httpHeaders(Map.of()));
when(proxyService.forward(
eq("GET"), eq("/api/create/sessions/sess-1/stream"), eq(req), eq(true)))
.thenReturn(upstream);
ResponseEntity<StreamingResponseBody> resp = controller.stream("sess-1", req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
// No upstream Content-Type → defaulted to text/event-stream.
assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
.isEqualTo(MediaType.TEXT_EVENT_STREAM_VALUE);
// Ownership guard runs before the proxy.
verify(sessionService).getSessionForCurrentUser("sess-1");
assertThat(drain(resp.getBody())).isEqualTo("data: hi\n\n");
}
@Test
@DisplayName("upstream non-2xx status is passed through; explicit Content-Type wins")
void stream_upstreamStatusAndExplicitContentTypePassedThrough() throws Exception {
SecurityContextHolder.clearContext();
HttpServletRequest req = mock(HttpServletRequest.class);
HttpResponse<InputStream> upstream =
upstreamResponse(
404,
"not found",
httpHeaders(
Map.of(
HttpHeaders.CONTENT_TYPE,
"text/plain",
HttpHeaders.CACHE_CONTROL,
"no-cache")));
when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream);
ResponseEntity<StreamingResponseBody> resp = controller.stream("sess-1", req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
.isEqualTo("text/plain");
assertThat(resp.getHeaders().getFirst(HttpHeaders.CACHE_CONTROL)).isEqualTo("no-cache");
}
@Test
@DisplayName("unmappable upstream status code falls back to 502 Bad Gateway")
void stream_unresolvableStatus_fallsBackToBadGateway() throws Exception {
SecurityContextHolder.clearContext();
HttpServletRequest req = mock(HttpServletRequest.class);
// 299 is not a defined HttpStatus enum constant → HttpStatus.resolve returns null.
HttpResponse<InputStream> upstream =
upstreamResponse(299, "weird", httpHeaders(Map.of()));
when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream);
ResponseEntity<StreamingResponseBody> resp = controller.stream("sess-1", req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
}
@Test
@DisplayName("ownership failure short-circuits before proxying")
void stream_ownershipFailure_doesNotProxy() throws Exception {
HttpServletRequest req = mock(HttpServletRequest.class);
when(sessionService.getSessionForCurrentUser("sess-1"))
.thenThrow(new ResponseStatusException(HttpStatus.NOT_FOUND));
assertThatThrownBy(() -> controller.stream("sess-1", req))
.isInstanceOf(ResponseStatusException.class);
verify(proxyService, never()).forward(any(), any(), any(), anyBoolean());
}
}
// ----------------------------------------------------------------------------------------------
// helpers
// ----------------------------------------------------------------------------------------------
private static AiCreateSession session(String sessionId, String userId) {
AiCreateSession s = new AiCreateSession();
s.setSessionId(sessionId);
s.setUserId(userId);
return s;
}
private static User userWithTeam(long userId, long teamId) {
User user = new User();
user.setId(userId);
Team team = new Team();
team.setId(teamId);
user.setTeam(team);
return user;
}
/**
* Authenticated WEB principal: 3-arg ctor so isAuthenticated()==true, principal is the User.
*/
private static void authenticateWeb(User user) {
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(user, null, List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
}
private static void authenticateApiKey(User user) {
ApiKeyAuthenticationToken auth =
new ApiKeyAuthenticationToken(user, "the-api-key", List.of());
SecurityContextHolder.getContext().setAuthentication(auth);
}
private static java.net.http.HttpHeaders httpHeaders(Map<String, String> single) {
Map<String, List<String>> multi = new java.util.HashMap<>();
single.forEach((k, v) -> multi.put(k, List.of(v)));
return java.net.http.HttpHeaders.of(multi, (k, v) -> true);
}
@SuppressWarnings("unchecked")
private static HttpResponse<InputStream> upstreamResponse(
int status, String body, java.net.http.HttpHeaders headers) {
HttpResponse<InputStream> response = mock(HttpResponse.class);
when(response.statusCode()).thenReturn(status);
when(response.headers()).thenReturn(headers);
when(response.body())
.thenReturn(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)));
return response;
}
private static String drain(StreamingResponseBody body) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
body.writeTo(out);
return out.toString(StandardCharsets.UTF_8);
}
private static AiCreateSessionSummaryProjection projection(
String sessionId,
String docType,
String templateId,
String promptLatest,
String promptInitial,
AiCreateSessionStatus status,
String pdfUrl,
Instant createdAt,
Instant updatedAt) {
return new AiCreateSessionSummaryProjection() {
@Override
public String getSessionId() {
return sessionId;
}
@Override
public String getDocType() {
return docType;
}
@Override
public String getTemplateId() {
return templateId;
}
@Override
public String getPromptLatest() {
return promptLatest;
}
@Override
public String getPromptInitial() {
return promptInitial;
}
@Override
public AiCreateSessionStatus getStatus() {
return status;
}
@Override
public String getPdfUrl() {
return pdfUrl;
}
@Override
public Instant getCreatedAt() {
return createdAt;
}
@Override
public Instant getUpdatedAt() {
return updatedAt;
}
};
}
}
@@ -0,0 +1,457 @@
package stirling.software.saas.ai.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.saas.ai.model.AiCreateSession;
import stirling.software.saas.ai.model.AiCreateSessionStatus;
import stirling.software.saas.ai.service.AiCreateSessionService;
/**
* Unit tests for {@link AiCreateInternalController}. The controller is a thin internal facade over
* {@link AiCreateSessionService}: it maps an entity to a response record, and on update serialises
* the JSON-shaped fields (outline constraints / draft sections) before delegating. We mock the
* service and assert the {@link ResponseEntity} plus the exact arguments forwarded.
*/
@ExtendWith(MockitoExtension.class)
class AiCreateInternalControllerTest {
@Mock private AiCreateSessionService sessionService;
// The controller's @RequiredArgsConstructor only takes sessionService; the ObjectMapper field
// is an inline initializer, so a real Jackson instance is exercised by these tests.
private AiCreateInternalController controller;
@BeforeEach
void setUp() {
controller = new AiCreateInternalController(sessionService);
}
// --- helpers -------------------------------------------------------------------------------
private static AiCreateSession session(String sessionId) {
AiCreateSession s = new AiCreateSession();
s.setSessionId(sessionId);
s.setUserId("user-1");
s.setDocType("report");
s.setTemplateId("tmpl-1");
s.setTemplateTex("\\documentclass{article}");
s.setPreviewTex("preview");
s.setPromptInitial("initial prompt");
s.setPromptLatest("latest prompt");
s.setOutlineText("outline body");
s.setOutlineFilename("outline.txt");
s.setOutlineApproved(true);
s.setPolishedLatex("\\section{x}");
s.setPdfUrl("https://example.com/doc.pdf");
s.setStatus(AiCreateSessionStatus.DRAFT_READY);
return s;
}
@Nested
@DisplayName("getSession")
class GetSession {
@Test
@DisplayName("returns 200 with the session mapped to a response record")
void getSession_mapsAllScalarFields() {
AiCreateSession s = session("sess-abc");
when(sessionService.getSession("sess-abc")).thenReturn(s);
ResponseEntity<AiCreateController.AiCreateSessionResponse> resp =
controller.getSession("sess-abc");
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
AiCreateController.AiCreateSessionResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.sessionId()).isEqualTo("sess-abc");
assertThat(body.userId()).isEqualTo("user-1");
assertThat(body.docType()).isEqualTo("report");
assertThat(body.templateId()).isEqualTo("tmpl-1");
assertThat(body.templateTex()).isEqualTo("\\documentclass{article}");
assertThat(body.previewTex()).isEqualTo("preview");
assertThat(body.promptInitial()).isEqualTo("initial prompt");
assertThat(body.promptLatest()).isEqualTo("latest prompt");
assertThat(body.outlineText()).isEqualTo("outline body");
assertThat(body.outlineFilename()).isEqualTo("outline.txt");
assertThat(body.outlineApproved()).isTrue();
assertThat(body.polishedLatex()).isEqualTo("\\section{x}");
assertThat(body.pdfUrl()).isEqualTo("https://example.com/doc.pdf");
assertThat(body.status()).isEqualTo("DRAFT_READY");
verify(sessionService).getSession("sess-abc");
}
@Test
@DisplayName("maps a null status to a null status string, not an NPE")
void getSession_nullStatus_mapsToNull() {
AiCreateSession s = session("sess-null-status");
s.setStatus(null);
when(sessionService.getSession("sess-null-status")).thenReturn(s);
ResponseEntity<AiCreateController.AiCreateSessionResponse> resp =
controller.getSession("sess-null-status");
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().status()).isNull();
}
@Test
@DisplayName("leaves outlineConstraints/draftSections null when the entity stored none")
void getSession_noJsonPayloads_yieldsNullCollections() {
AiCreateSession s = session("sess-empty-json");
s.setOutlineConstraints(null);
s.setDraftSections(" "); // blank string is treated as absent
when(sessionService.getSession("sess-empty-json")).thenReturn(s);
AiCreateController.AiCreateSessionResponse body =
controller.getSession("sess-empty-json").getBody();
assertThat(body).isNotNull();
assertThat(body.outlineConstraints()).isNull();
assertThat(body.draftSections()).isNull();
}
@Test
@DisplayName("parses stored outlineConstraints/draftSections JSON back into the response")
void getSession_parsesStoredJsonPayloads() {
AiCreateSession s = session("sess-json");
s.setOutlineConstraints("{\"tone\":\"formal\",\"pages\":3}");
s.setDraftSections("[{\"label\":\"Intro\",\"value\":\"hello\"}]");
when(sessionService.getSession("sess-json")).thenReturn(s);
AiCreateController.AiCreateSessionResponse body =
controller.getSession("sess-json").getBody();
assertThat(body).isNotNull();
assertThat(body.outlineConstraints())
.containsEntry("tone", "formal")
.containsEntry("pages", 3);
assertThat(body.draftSections()).hasSize(1);
assertThat(body.draftSections().get(0).label()).isEqualTo("Intro");
assertThat(body.draftSections().get(0).value()).isEqualTo("hello");
}
@Test
@DisplayName("malformed stored JSON is swallowed and surfaces as null, not a 500")
void getSession_malformedJson_returnsNullCollections() {
AiCreateSession s = session("sess-bad-json");
s.setOutlineConstraints("{not valid json");
s.setDraftSections("[also not valid");
when(sessionService.getSession("sess-bad-json")).thenReturn(s);
AiCreateController.AiCreateSessionResponse body =
controller.getSession("sess-bad-json").getBody();
assertThat(body).isNotNull();
assertThat(body.outlineConstraints()).isNull();
assertThat(body.draftSections()).isNull();
}
@Test
@DisplayName("propagates a 404 ResponseStatusException from the service")
void getSession_notFound_propagates() {
when(sessionService.getSession("missing"))
.thenThrow(
new ResponseStatusException(
HttpStatus.NOT_FOUND, "AI session not found"));
assertThatThrownBy(() -> controller.getSession("missing"))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
ex ->
assertThat(((ResponseStatusException) ex).getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND));
}
}
@Nested
@DisplayName("updateSession")
class UpdateSession {
@Test
@DisplayName("forwards every scalar field and serialises JSON payloads to the service")
void updateSession_serialisesPayloadsAndForwardsAllFields() {
AiCreateSession updated = session("sess-1");
when(sessionService.applyInternalUpdate(
eq("sess-1"),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any()))
.thenReturn(updated);
AiCreateInternalController.UpdateSessionRequest req =
new AiCreateInternalController.UpdateSessionRequest(
"new outline",
"new.txt",
Boolean.TRUE,
Map.of("tone", "casual"),
List.of(new AiCreateController.DraftSection("Body", "content")),
"\\section{polished}",
"https://example.com/out.pdf",
"letter",
"tmpl-9",
AiCreateSessionStatus.POLISHED_READY);
ResponseEntity<AiCreateController.AiCreateSessionResponse> resp =
controller.updateSession("sess-1", req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().sessionId()).isEqualTo("sess-1");
ArgumentCaptor<String> constraints = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> sections = ArgumentCaptor.forClass(String.class);
verify(sessionService)
.applyInternalUpdate(
eq("sess-1"),
eq("new outline"),
eq("new.txt"),
eq(Boolean.TRUE),
constraints.capture(),
sections.capture(),
eq("\\section{polished}"),
eq("https://example.com/out.pdf"),
eq("letter"),
eq("tmpl-9"),
eq(AiCreateSessionStatus.POLISHED_READY));
// Constraints serialised to a JSON object string carrying the map entry.
assertThat(constraints.getValue()).contains("\"tone\"").contains("\"casual\"");
// Draft sections serialised to a JSON array string carrying the record fields.
assertThat(sections.getValue())
.startsWith("[")
.contains("\"label\":\"Body\"")
.contains("\"value\":\"content\"");
}
@Test
@DisplayName(
"passes null payloads through when outline constraints / draft sections absent")
void updateSession_nullCollections_forwardsNullPayloads() {
AiCreateSession updated = session("sess-2");
when(sessionService.applyInternalUpdate(
eq("sess-2"),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull()))
.thenReturn(updated);
AiCreateInternalController.UpdateSessionRequest req =
new AiCreateInternalController.UpdateSessionRequest(
null, null, null, null, null, null, null, null, null, null);
ResponseEntity<AiCreateController.AiCreateSessionResponse> resp =
controller.updateSession("sess-2", req);
assertThat(resp.getBody()).isNotNull();
// Both JSON-shaped fields forwarded as null (not "null" strings) since they were
// absent.
verify(sessionService)
.applyInternalUpdate(
eq("sess-2"),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull());
}
@Test
@DisplayName("serialises an empty constraints map / sections list to '{}' and '[]'")
void updateSession_emptyCollections_serialiseToEmptyJson() {
when(sessionService.applyInternalUpdate(
eq("sess-3"),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any()))
.thenReturn(session("sess-3"));
AiCreateInternalController.UpdateSessionRequest req =
new AiCreateInternalController.UpdateSessionRequest(
null, null, null, Map.of(), List.of(), null, null, null, null, null);
controller.updateSession("sess-3", req);
ArgumentCaptor<String> constraints = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> sections = ArgumentCaptor.forClass(String.class);
verify(sessionService)
.applyInternalUpdate(
eq("sess-3"),
isNull(),
isNull(),
isNull(),
constraints.capture(),
sections.capture(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull());
// Empty (but present) collections still serialise: distinguishes "absent" from "empty".
assertThat(constraints.getValue()).isEqualTo("{}");
assertThat(sections.getValue()).isEqualTo("[]");
}
@Test
@DisplayName("response reflects the entity the service returns after the update")
void updateSession_responseReflectsReturnedEntity() {
AiCreateSession returned = session("sess-4");
returned.setStatus(AiCreateSessionStatus.SAVED);
returned.setPdfUrl("https://example.com/final.pdf");
when(sessionService.applyInternalUpdate(
eq("sess-4"),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any()))
.thenReturn(returned);
AiCreateInternalController.UpdateSessionRequest req =
new AiCreateInternalController.UpdateSessionRequest(
"x", null, null, null, null, null, null, null, null, null);
AiCreateController.AiCreateSessionResponse body =
controller.updateSession("sess-4", req).getBody();
assertThat(body).isNotNull();
assertThat(body.status()).isEqualTo("SAVED");
assertThat(body.pdfUrl()).isEqualTo("https://example.com/final.pdf");
}
@Test
@DisplayName("propagates a 404 when the service cannot find the session to update")
void updateSession_notFound_propagates() {
when(sessionService.applyInternalUpdate(
eq("missing"),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any()))
.thenThrow(
new ResponseStatusException(
HttpStatus.NOT_FOUND, "AI session not found"));
AiCreateInternalController.UpdateSessionRequest req =
new AiCreateInternalController.UpdateSessionRequest(
"x", null, null, null, null, null, null, null, null, null);
assertThatThrownBy(() -> controller.updateSession("missing", req))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
ex ->
assertThat(((ResponseStatusException) ex).getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND));
}
@Test
@DisplayName("round-trip: serialised draft sections parse back identically in the response")
void updateSession_draftSectionsRoundTrip() {
// Service echoes back the payload it was handed so we can confirm serialise -> store ->
// parse is lossless for the DraftSection shape.
when(sessionService.applyInternalUpdate(
eq("sess-rt"),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any(),
any()))
.thenAnswer(
inv -> {
AiCreateSession s = session("sess-rt");
s.setOutlineConstraints((String) inv.getArgument(4));
s.setDraftSections((String) inv.getArgument(5));
return s;
});
AiCreateInternalController.UpdateSessionRequest req =
new AiCreateInternalController.UpdateSessionRequest(
null,
null,
null,
Map.of("depth", "deep"),
List.of(
new AiCreateController.DraftSection("A", "1"),
new AiCreateController.DraftSection("B", "2")),
null,
null,
null,
null,
null);
AiCreateController.AiCreateSessionResponse body =
controller.updateSession("sess-rt", req).getBody();
assertThat(body).isNotNull();
assertThat(body.outlineConstraints()).containsEntry("depth", "deep");
assertThat(body.draftSections()).hasSize(2);
assertThat(body.draftSections().get(0).label()).isEqualTo("A");
assertThat(body.draftSections().get(0).value()).isEqualTo("1");
assertThat(body.draftSections().get(1).label()).isEqualTo("B");
assertThat(body.draftSections().get(1).value()).isEqualTo("2");
}
}
}
@@ -0,0 +1,569 @@
package stirling.software.saas.ai.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
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.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.saas.ai.service.AiProxyService;
/**
* Pure unit tests for {@link AiProxyController}. Every collaborator is mocked; each handler is
* invoked directly and asserted via {@link ResponseEntity} / {@code verify}.
*
* <p>All endpoints funnel through one private {@code proxy(method, path, request,
* acceptEventStream)} helper, so the suite has two halves:
*
* <ol>
* <li>per-endpoint tests that pin the exact {@code (method, path, acceptEventStream)} contract a
* given handler forwards (the path-mapping surface), and
* <li>behavioural tests around the single shared {@code proxy} body: header copy, status
* resolution, and the 503 error fallback.
* </ol>
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AiProxyControllerTest {
@Mock private AiProxyService aiProxyService;
private AiProxyController controller;
@org.junit.jupiter.api.BeforeEach
void setUp() {
controller = new AiProxyController(aiProxyService);
}
// ----------------------------------------------------------------------------------------------
// Endpoint path/method mapping — each handler pins the exact upstream contract it forwards.
// ----------------------------------------------------------------------------------------------
@Nested
@DisplayName("endpoint → upstream (method, path, acceptEventStream) mapping")
class EndpointMapping {
@Test
@DisplayName("generateSection POSTs to /api/generate_section, non-stream")
void generateSection() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/generate_section", req, false, ok("body"));
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(aiProxyService).forward("POST", "/api/generate_section", req, false);
}
@Test
@DisplayName("generateAllSections POSTs to /api/generate_all_sections")
void generateAllSections() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/generate_all_sections", req, false, ok("body"));
controller.generateAllSections(req);
verify(aiProxyService).forward("POST", "/api/generate_all_sections", req, false);
}
@Test
@DisplayName("intentCheck POSTs to /api/intent/check")
void intentCheck() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/intent/check", req, false, ok("body"));
controller.intentCheck(req);
verify(aiProxyService).forward("POST", "/api/intent/check", req, false);
}
@Test
@DisplayName("chatRoute POSTs to /api/chat/route")
void chatRoute() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("body"));
controller.chatRoute(req);
verify(aiProxyService).forward("POST", "/api/chat/route", req, false);
}
@Test
@DisplayName("createSmartFolder POSTs to /api/chat/create-smart-folder")
void createSmartFolder() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/chat/create-smart-folder", req, false, ok("body"));
controller.createSmartFolder(req);
verify(aiProxyService).forward("POST", "/api/chat/create-smart-folder", req, false);
}
@Test
@DisplayName("chatInfo POSTs to /api/chat/info")
void chatInfo() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/chat/info", req, false, ok("body"));
controller.chatInfo(req);
verify(aiProxyService).forward("POST", "/api/chat/info", req, false);
}
@Test
@DisplayName("pdfAnswer POSTs to /api/pdf/answer")
void pdfAnswer() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/pdf/answer", req, false, ok("body"));
controller.pdfAnswer(req);
verify(aiProxyService).forward("POST", "/api/pdf/answer", req, false);
}
@Test
@DisplayName("progressiveRender POSTs to /api/progressive_render")
void progressiveRender() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/progressive_render", req, false, ok("body"));
controller.progressiveRender(req);
verify(aiProxyService).forward("POST", "/api/progressive_render", req, false);
}
@Test
@DisplayName("versions GETs /api/versions/{userId} with the path variable interpolated")
void versions() throws Exception {
HttpServletRequest req = req();
stubForward("GET", "/api/versions/user-42", req, false, ok("body"));
controller.versions("user-42", req);
verify(aiProxyService).forward("GET", "/api/versions/user-42", req, false);
}
@Test
@DisplayName("style (GET) GETs /api/style/{userId}")
void styleGet() throws Exception {
HttpServletRequest req = req();
stubForward("GET", "/api/style/user-42", req, false, ok("body"));
controller.style("user-42", req);
verify(aiProxyService).forward("GET", "/api/style/user-42", req, false);
}
@Test
@DisplayName("updateStyle POSTs /api/style/{userId}")
void updateStyle() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/style/user-42", req, false, ok("body"));
controller.updateStyle("user-42", req);
verify(aiProxyService).forward("POST", "/api/style/user-42", req, false);
}
@Test
@DisplayName("importTemplate POSTs /api/import_template")
void importTemplate() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/import_template", req, false, ok("body"));
controller.importTemplate(req);
verify(aiProxyService).forward("POST", "/api/import_template", req, false);
}
@Test
@DisplayName("createEditSession POSTs /api/edit/sessions")
void createEditSession() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/edit/sessions", req, false, ok("body"));
controller.createEditSession(req);
verify(aiProxyService).forward("POST", "/api/edit/sessions", req, false);
}
@Test
@DisplayName("editSessionMessage POSTs /api/edit/sessions/{id}/messages")
void editSessionMessage() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/edit/sessions/sess-9/messages", req, false, ok("body"));
controller.editSessionMessage("sess-9", req);
verify(aiProxyService)
.forward("POST", "/api/edit/sessions/sess-9/messages", req, false);
}
@Test
@DisplayName("editSessionAttachment POSTs /api/edit/sessions/{id}/attachments")
void editSessionAttachment() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/edit/sessions/sess-9/attachments", req, false, ok("body"));
controller.editSessionAttachment("sess-9", req);
verify(aiProxyService)
.forward("POST", "/api/edit/sessions/sess-9/attachments", req, false);
}
@Test
@DisplayName("runEditSession POSTs /api/edit/sessions/{id}/run as an event stream")
void runEditSession() throws Exception {
HttpServletRequest req = req();
// acceptEventStream == true here.
stubForward("POST", "/api/edit/sessions/sess-9/run", req, true, ok("data: x\n\n"));
controller.runEditSession("sess-9", req);
verify(aiProxyService).forward("POST", "/api/edit/sessions/sess-9/run", req, true);
}
@Test
@DisplayName("pdfEditorDocument GETs /api/pdf-editor/document")
void pdfEditorDocument() throws Exception {
HttpServletRequest req = req();
stubForward("GET", "/api/pdf-editor/document", req, false, ok("body"));
controller.pdfEditorDocument(req);
verify(aiProxyService).forward("GET", "/api/pdf-editor/document", req, false);
}
@Test
@DisplayName("pdfEditorUpload POSTs /api/pdf-editor/upload")
void pdfEditorUpload() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/pdf-editor/upload", req, false, ok("body"));
controller.pdfEditorUpload(req);
verify(aiProxyService).forward("POST", "/api/pdf-editor/upload", req, false);
}
}
// ----------------------------------------------------------------------------------------------
// output(**) — derives the upstream path from the raw request URI minus the proxy prefix.
// ----------------------------------------------------------------------------------------------
@Nested
@DisplayName("output(**) wildcard path derivation")
class OutputPathDerivation {
@Test
@DisplayName(
"strips the contextPath + /api/v1/ai/output/ prefix and forwards the remainder")
void output_stripsPrefixAndForwardsRemainder() throws Exception {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getContextPath()).thenReturn("");
when(req.getRequestURI()).thenReturn("/api/v1/ai/output/foo/bar.png");
stubForward("GET", "/output/foo/bar.png", req, false, ok("img-bytes"));
ResponseEntity<StreamingResponseBody> resp = controller.output(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(aiProxyService).forward("GET", "/output/foo/bar.png", req, false);
}
@Test
@DisplayName("honours a non-empty servlet contextPath when computing the prefix")
void output_honoursContextPath() throws Exception {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getContextPath()).thenReturn("/stirling");
when(req.getRequestURI()).thenReturn("/stirling/api/v1/ai/output/nested/file.pdf");
stubForward("GET", "/output/nested/file.pdf", req, false, ok("pdf"));
controller.output(req);
verify(aiProxyService).forward("GET", "/output/nested/file.pdf", req, false);
}
@Test
@DisplayName("URI not under the expected prefix yields an empty remainder (path /output/)")
void output_uriOutsidePrefix_emptyRemainder() throws Exception {
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getContextPath()).thenReturn("");
// Does not start with /api/v1/ai/output/ → substring branch skipped, path stays "".
when(req.getRequestURI()).thenReturn("/totally/different");
stubForward("GET", "/output/", req, false, ok("body"));
controller.output(req);
verify(aiProxyService).forward("GET", "/output/", req, false);
}
}
// ----------------------------------------------------------------------------------------------
// Shared proxy body — header copy + status resolution + streaming.
// ----------------------------------------------------------------------------------------------
@Nested
@DisplayName("proxy() header copy, status resolution and streaming")
class ProxyBody {
@Test
@DisplayName("copies the whitelisted upstream headers onto the response")
void copiesWhitelistedHeaders() throws Exception {
HttpServletRequest req = req();
HttpResponse<InputStream> upstream =
upstreamResponse(
200,
"payload",
httpHeaders(
Map.of(
HttpHeaders.CONTENT_TYPE,
"application/json",
HttpHeaders.CACHE_CONTROL,
"no-cache",
"X-Accel-Buffering",
"no",
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=a.pdf",
HttpHeaders.CONTENT_LENGTH,
"7")));
when(aiProxyService.forward(any(), any(), any(), anyBoolean())).thenReturn(upstream);
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
HttpHeaders h = resp.getHeaders();
assertThat(h.getFirst(HttpHeaders.CONTENT_TYPE)).isEqualTo("application/json");
assertThat(h.getFirst(HttpHeaders.CACHE_CONTROL)).isEqualTo("no-cache");
assertThat(h.getFirst("X-Accel-Buffering")).isEqualTo("no");
assertThat(h.getFirst(HttpHeaders.CONTENT_DISPOSITION))
.isEqualTo("attachment; filename=a.pdf");
assertThat(h.getFirst(HttpHeaders.CONTENT_LENGTH)).isEqualTo("7");
}
@Test
@DisplayName("streams the upstream body straight through to the output stream")
void streamsBodyThrough() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/generate_section", req, false, ok("hello-stream"));
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
assertThat(drain(resp.getBody())).isEqualTo("hello-stream");
}
@Test
@DisplayName("upstream non-2xx status is passed through verbatim")
void passesThroughUpstreamStatus() throws Exception {
HttpServletRequest req = req();
HttpResponse<InputStream> upstream =
upstreamResponse(404, "nope", httpHeaders(Map.of()));
when(aiProxyService.forward(any(), any(), any(), anyBoolean())).thenReturn(upstream);
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
@DisplayName("unmappable upstream status (299) falls back to 502 Bad Gateway")
void unmappableStatus_fallsBackToBadGateway() throws Exception {
HttpServletRequest req = req();
// 299 is not a defined HttpStatus enum constant → HttpStatus.resolve returns null.
HttpResponse<InputStream> upstream =
upstreamResponse(299, "weird", httpHeaders(Map.of()));
when(aiProxyService.forward(any(), any(), any(), anyBoolean())).thenReturn(upstream);
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
}
@Test
@DisplayName(
"event-stream endpoint with no upstream Content-Type defaults to text/event-stream")
void eventStreamDefaultsContentType() throws Exception {
HttpServletRequest req = req();
// runEditSession uses acceptEventStream == true.
HttpResponse<InputStream> upstream =
upstreamResponse(200, "data: x\n\n", httpHeaders(Map.of()));
when(aiProxyService.forward(
eq("POST"), eq("/api/edit/sessions/s/run"), eq(req), eq(true)))
.thenReturn(upstream);
ResponseEntity<StreamingResponseBody> resp = controller.runEditSession("s", req);
assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
.isEqualTo(MediaType.TEXT_EVENT_STREAM_VALUE);
}
@Test
@DisplayName("event-stream endpoint keeps an explicit upstream Content-Type (no override)")
void eventStreamKeepsExplicitContentType() throws Exception {
HttpServletRequest req = req();
HttpResponse<InputStream> upstream =
upstreamResponse(
200,
"data: x\n\n",
httpHeaders(Map.of(HttpHeaders.CONTENT_TYPE, "text/plain")));
when(aiProxyService.forward(
eq("POST"), eq("/api/edit/sessions/s/run"), eq(req), eq(true)))
.thenReturn(upstream);
ResponseEntity<StreamingResponseBody> resp = controller.runEditSession("s", req);
assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
.isEqualTo("text/plain");
}
@Test
@DisplayName("non-event-stream endpoint with no upstream Content-Type leaves it unset")
void nonEventStream_noContentType_leavesUnset() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/generate_section", req, false, ok("body"));
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
assertThat(resp.getHeaders().containsHeader(HttpHeaders.CONTENT_TYPE)).isFalse();
}
@Test
@DisplayName("a header carrying CR/LF injection is dropped, not copied")
void crlfInjectionHeaderDropped() throws Exception {
HttpServletRequest req = req();
HttpResponse<InputStream> upstream =
upstreamResponse(
200,
"body",
httpHeaders(
Map.of(
HttpHeaders.CACHE_CONTROL,
"no-cache\r\nX-Injected: evil")));
when(aiProxyService.forward(any(), any(), any(), anyBoolean())).thenReturn(upstream);
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
assertThat(resp.getHeaders().containsHeader(HttpHeaders.CACHE_CONTROL)).isFalse();
assertThat(resp.getHeaders().containsHeader("X-Injected")).isFalse();
}
@Test
@DisplayName("absent upstream headers are simply omitted (no blank values set)")
void absentHeadersOmitted() throws Exception {
HttpServletRequest req = req();
stubForward("POST", "/api/generate_section", req, false, ok("body"));
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
assertThat(resp.getHeaders().containsHeader(HttpHeaders.CACHE_CONTROL)).isFalse();
assertThat(resp.getHeaders().containsHeader(HttpHeaders.CONTENT_DISPOSITION)).isFalse();
assertThat(resp.getHeaders().containsHeader("X-Accel-Buffering")).isFalse();
}
}
// ----------------------------------------------------------------------------------------------
// Error fallback — any forward() failure becomes a 503 with a JSON error body, never throws.
// ----------------------------------------------------------------------------------------------
@Nested
@DisplayName("error fallback")
class ErrorFallback {
@Test
@DisplayName("IOException from forward yields 503 + JSON error body")
void ioException_returns503() throws Exception {
HttpServletRequest req = req();
when(aiProxyService.forward(any(), any(), any(), anyBoolean()))
.thenThrow(new java.io.IOException("backend down"));
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON);
assertThat(drain(resp.getBody())).contains("AI backend unavailable");
}
@Test
@DisplayName("InterruptedException from forward also degrades to a 503, never propagates")
void interruptedException_returns503() throws Exception {
HttpServletRequest req = req();
when(aiProxyService.forward(any(), any(), any(), anyBoolean()))
.thenThrow(new InterruptedException("interrupted"));
ResponseEntity<StreamingResponseBody> resp = controller.generateSection(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(drain(resp.getBody())).contains("AI backend unavailable");
}
}
// ----------------------------------------------------------------------------------------------
// helpers
// ----------------------------------------------------------------------------------------------
/** A bare mocked request — these handlers never read from it directly (the service does). */
private static HttpServletRequest req() {
return mock(HttpServletRequest.class);
}
private void stubForward(
String method,
String path,
HttpServletRequest req,
boolean acceptEventStream,
HttpResponse<InputStream> response)
throws Exception {
when(aiProxyService.forward(eq(method), eq(path), eq(req), eq(acceptEventStream)))
.thenReturn(response);
}
private static HttpResponse<InputStream> ok(String body) {
return upstreamResponse(200, body, httpHeaders(Map.of()));
}
private static java.net.http.HttpHeaders httpHeaders(Map<String, String> single) {
Map<String, List<String>> multi = new java.util.HashMap<>();
single.forEach((k, v) -> multi.put(k, List.of(v)));
return java.net.http.HttpHeaders.of(multi, (k, v) -> true);
}
@SuppressWarnings("unchecked")
private static HttpResponse<InputStream> upstreamResponse(
int status, String body, java.net.http.HttpHeaders headers) {
HttpResponse<InputStream> response = mock(HttpResponse.class);
when(response.statusCode()).thenReturn(status);
when(response.headers()).thenReturn(headers);
when(response.body())
.thenReturn(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)));
return response;
}
private static String drain(StreamingResponseBody body) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
body.writeTo(out);
return out.toString(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,676 @@
package stirling.software.saas.ai.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.test.util.ReflectionTestUtils;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.service.UserService;
/**
* Unit tests for {@link AiCreateProxyService}.
*
* <p>The service forwards an inbound HTTP request to the AI "create" backend. It builds its own
* {@link HttpClient} in the constructor (no injection), so each test swaps in a mocked client via
* {@link ReflectionTestUtils} and captures the outgoing {@link HttpRequest} to assert on the URL,
* method and headers. All collaborators ({@link HttpServletRequest}, {@link UserService}, {@link
* UserRepository}) are mocked; no Spring context, DB or real network is involved.
*
* <p>Header semantics under test: Content-Type and Authorization are forwarded only when present
* and non-blank; X-API-KEY is taken from the inbound header first and otherwise resolved from the
* authenticated user (any lookup failure is swallowed); Accept is overridden to {@code
* text/event-stream} when SSE is requested. GET/DELETE send no body; other methods stream the
* request input stream lazily.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AiCreateProxyServiceTest {
private static final String BASE_URL = "http://ai-backend:5001";
@Mock private UserRepository userRepository;
@Mock private UserService userService;
@Mock private HttpServletRequest request;
@Mock private HttpClient httpClient;
@SuppressWarnings("unchecked")
private final HttpResponse<InputStream> response =
(HttpResponse<InputStream>) org.mockito.Mockito.mock(HttpResponse.class);
private AiCreateProxyService service;
@BeforeEach
void setUp() throws Exception {
service = new AiCreateProxyService(BASE_URL, userRepository, userService);
// Swap the internally-built client for our mock so no real network call happens.
ReflectionTestUtils.setField(service, "httpClient", httpClient);
// Default: the mocked client returns our stub response for any send().
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
.thenReturn(response);
}
/** Capture the single HttpRequest the service hands to the client. */
private HttpRequest captureSentRequest() throws Exception {
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(captor.capture(), any(HttpResponse.BodyHandler.class));
return captor.getValue();
}
private static String header(HttpRequest req, String name) {
return req.headers().firstValue(name).orElse(null);
}
/** Build a fresh service backed by the shared mock client for base-URL variations. */
private AiCreateProxyService serviceWithBase(String base) {
AiCreateProxyService svc = new AiCreateProxyService(base, userRepository, userService);
ReflectionTestUtils.setField(svc, "httpClient", httpClient);
return svc;
}
@Nested
@DisplayName("target URL assembly")
class UrlAssembly {
@Test
@DisplayName("joins base + leading-slash path + query string")
void joinsBasePathAndQuery() throws Exception {
when(request.getQueryString()).thenReturn("model=foo&n=2");
service.forward("GET", "/v1/chat", request, false);
assertThat(captureSentRequest().uri())
.isEqualTo(URI.create("http://ai-backend:5001/v1/chat?model=foo&n=2"));
}
@Test
@DisplayName("prepends a slash when the path lacks one")
void prependsMissingSlash() throws Exception {
when(request.getQueryString()).thenReturn(null);
service.forward("GET", "v1/health", request, false);
assertThat(captureSentRequest().uri())
.isEqualTo(URI.create("http://ai-backend:5001/v1/health"));
}
@Test
@DisplayName("null query string is ignored")
void nullQueryIgnored() throws Exception {
when(request.getQueryString()).thenReturn(null);
service.forward("GET", "/v1/ping", request, false);
assertThat(captureSentRequest().uri())
.isEqualTo(URI.create("http://ai-backend:5001/v1/ping"));
}
@Test
@DisplayName("blank query string is ignored")
void blankQueryIgnored() throws Exception {
when(request.getQueryString()).thenReturn(" ");
service.forward("GET", "/v1/ping", request, false);
assertThat(captureSentRequest().uri())
.isEqualTo(URI.create("http://ai-backend:5001/v1/ping"));
}
@Test
@DisplayName("trailing slash on the configured base URL is trimmed")
void trimsTrailingSlashOnBase() throws Exception {
AiCreateProxyService svc = serviceWithBase("http://ai-backend:5001/");
when(request.getQueryString()).thenReturn(null);
svc.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().uri())
.isEqualTo(URI.create("http://ai-backend:5001/v1/x"));
}
@Test
@DisplayName("surrounding whitespace on the base URL is trimmed")
void trimsWhitespaceOnBase() throws Exception {
AiCreateProxyService svc = serviceWithBase(" http://ai-backend:5001 ");
when(request.getQueryString()).thenReturn(null);
svc.forward("GET", "/v1/y", request, false);
assertThat(captureSentRequest().uri())
.isEqualTo(URI.create("http://ai-backend:5001/v1/y"));
}
@Test
@DisplayName("blank base URL falls back to the localhost default")
void blankBaseUrlFallsBackToDefault() throws Exception {
AiCreateProxyService svc = serviceWithBase(" ");
when(request.getQueryString()).thenReturn(null);
svc.forward("GET", "/v1/z", request, false);
assertThat(captureSentRequest().uri())
.isEqualTo(URI.create("http://localhost:5001/v1/z"));
}
@Test
@DisplayName("null base URL falls back to the localhost default")
void nullBaseUrlFallsBackToDefault() throws Exception {
AiCreateProxyService svc = serviceWithBase(null);
when(request.getQueryString()).thenReturn(null);
svc.forward("GET", "/v1/q", request, false);
assertThat(captureSentRequest().uri())
.isEqualTo(URI.create("http://localhost:5001/v1/q"));
}
}
@Nested
@DisplayName("Content-Type header forwarding")
class ContentTypeForwarding {
@Test
@DisplayName("forwards a present inbound Content-Type on a body-bearing request")
void forwardsPresentContentType() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getContentType()).thenReturn("application/json");
when(request.getInputStream())
.thenReturn(servletInputStream("{}".getBytes(StandardCharsets.UTF_8)));
service.forward("POST", "/v1/chat", request, false);
assertThat(header(captureSentRequest(), "Content-Type")).isEqualTo("application/json");
}
@Test
@DisplayName("omits Content-Type when the inbound value is null")
void omitsWhenNull() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getContentType()).thenReturn(null);
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("Content-Type")).isEmpty();
}
@Test
@DisplayName("omits Content-Type when the inbound value is blank")
void omitsWhenBlank() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getContentType()).thenReturn(" ");
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("Content-Type")).isEmpty();
}
}
@Nested
@DisplayName("Authorization header forwarding")
class AuthorizationForwarding {
@Test
@DisplayName("forwards a present Authorization header verbatim")
void forwardsPresentAuth() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("Authorization")).thenReturn("Bearer abc.def");
service.forward("GET", "/v1/x", request, false);
assertThat(header(captureSentRequest(), "Authorization")).isEqualTo("Bearer abc.def");
}
@Test
@DisplayName("omits the header when Authorization is null")
void omitsWhenNull() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("Authorization")).thenReturn(null);
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("Authorization")).isEmpty();
}
@Test
@DisplayName("omits the header when Authorization is blank")
void omitsWhenBlank() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("Authorization")).thenReturn(" ");
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("Authorization")).isEmpty();
}
}
@Nested
@DisplayName("X-API-KEY resolution")
class ApiKeyResolution {
@Test
@DisplayName("uses the X-API-KEY header from the request when present (no user lookup)")
void usesRequestHeaderAndSkipsUserLookup() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("X-API-KEY")).thenReturn("req-key-123");
service.forward("GET", "/v1/x", request, false);
assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("req-key-123");
// Header short-circuits the authenticated-user fallback entirely.
verifyNoInteractions(userService);
}
@Test
@DisplayName("falls back to the authenticated user's API key when the header is absent")
void fallsBackToUserApiKey() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("X-API-KEY")).thenReturn(null);
when(userService.getCurrentUsername()).thenReturn("alice");
when(userService.getApiKeyForUser("alice")).thenReturn("user-key-xyz");
service.forward("GET", "/v1/x", request, false);
assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("user-key-xyz");
verify(userService).getApiKeyForUser("alice");
}
@Test
@DisplayName("falls back to the user key when the inbound header is blank")
void blankHeaderTriggersFallback() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("X-API-KEY")).thenReturn(" ");
when(userService.getCurrentUsername()).thenReturn("bob");
when(userService.getApiKeyForUser("bob")).thenReturn("bob-key");
service.forward("GET", "/v1/x", request, false);
assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("bob-key");
}
@Test
@DisplayName("no X-API-KEY header is set when there is no authenticated user")
void noUser_noHeader() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("X-API-KEY")).thenReturn(null);
when(userService.getCurrentUsername()).thenReturn(null);
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
// Username was null/blank, so the key lookup is never attempted.
verify(userService, never()).getApiKeyForUser(any());
}
@Test
@DisplayName("blank username from the security context yields no header")
void blankUsername_noHeader() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("X-API-KEY")).thenReturn(null);
when(userService.getCurrentUsername()).thenReturn(" ");
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
verify(userService, never()).getApiKeyForUser(any());
}
@Test
@DisplayName("a resolved-but-blank user key is not forwarded")
void blankResolvedKey_noHeader() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("X-API-KEY")).thenReturn(null);
when(userService.getCurrentUsername()).thenReturn("carol");
when(userService.getApiKeyForUser("carol")).thenReturn("");
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
}
@Test
@DisplayName("a null resolved user key is not forwarded")
void nullResolvedKey_noHeader() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("X-API-KEY")).thenReturn(null);
when(userService.getCurrentUsername()).thenReturn("dan");
when(userService.getApiKeyForUser("dan")).thenReturn(null);
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
}
@Test
@DisplayName("an exception while resolving the user key is swallowed; no header forwarded")
void userKeyLookupThrows_isSwallowed() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("X-API-KEY")).thenReturn(null);
when(userService.getCurrentUsername()).thenReturn("erin");
when(userService.getApiKeyForUser("erin"))
.thenThrow(new RuntimeException("key store offline"));
// Must not propagate: extractUserApiKey() catches and returns null.
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
}
}
@Nested
@DisplayName("Accept header handling")
class AcceptHandling {
@Test
@DisplayName("acceptEventStream overrides any inbound Accept with text/event-stream")
void eventStreamOverrides() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("Accept")).thenReturn("application/json");
service.forward("GET", "/v1/stream", request, true);
assertThat(header(captureSentRequest(), "Accept")).isEqualTo("text/event-stream");
}
@Test
@DisplayName("event stream is requested even with no inbound Accept header")
void eventStreamWithoutInboundAccept() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("Accept")).thenReturn(null);
service.forward("GET", "/v1/stream", request, true);
assertThat(header(captureSentRequest(), "Accept")).isEqualTo("text/event-stream");
}
@Test
@DisplayName("passes a non-stream Accept header through unchanged")
void passesInboundAcceptThrough() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("Accept")).thenReturn("application/json");
service.forward("GET", "/v1/x", request, false);
assertThat(header(captureSentRequest(), "Accept")).isEqualTo("application/json");
}
@Test
@DisplayName("no Accept header set when inbound Accept is absent and SSE not requested")
void noAcceptWhenAbsentAndNotStreaming() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("Accept")).thenReturn(null);
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("Accept")).isEmpty();
}
@Test
@DisplayName("blank inbound Accept is not forwarded when SSE not requested")
void blankAcceptNotForwarded() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("Accept")).thenReturn(" ");
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("Accept")).isEmpty();
}
}
@Nested
@DisplayName("HTTP method and body publisher selection")
class MethodAndBody {
@Test
@DisplayName("GET sends an empty body and never reads the request input stream")
void getHasNoBody() throws Exception {
when(request.getQueryString()).thenReturn(null);
service.forward("GET", "/v1/x", request, false);
HttpRequest sent = captureSentRequest();
assertThat(sent.method()).isEqualTo("GET");
assertThat(sent.bodyPublisher()).isPresent();
assertThat(sent.bodyPublisher().get().contentLength()).isZero();
// GET/DELETE short-circuit before touching the body.
verify(request, never()).getInputStream();
}
@Test
@DisplayName("DELETE sends an empty body and never reads the request input stream")
void deleteHasNoBody() throws Exception {
when(request.getQueryString()).thenReturn(null);
service.forward("DELETE", "/v1/item/9", request, false);
HttpRequest sent = captureSentRequest();
assertThat(sent.method()).isEqualTo("DELETE");
assertThat(sent.bodyPublisher().get().contentLength()).isZero();
verify(request, never()).getInputStream();
}
@Test
@DisplayName("method name matching is case-insensitive for the no-body branch")
void lowercaseGetStillNoBody() throws Exception {
when(request.getQueryString()).thenReturn(null);
service.forward("get", "/v1/x", request, false);
HttpRequest sent = captureSentRequest();
assertThat(sent.method()).isEqualToIgnoringCase("get");
assertThat(sent.bodyPublisher().get().contentLength()).isZero();
verify(request, never()).getInputStream();
}
@Test
@DisplayName("lowercase delete also routes through the no-body branch")
void lowercaseDeleteNoBody() throws Exception {
when(request.getQueryString()).thenReturn(null);
service.forward("delete", "/v1/item/1", request, false);
HttpRequest sent = captureSentRequest();
assertThat(sent.bodyPublisher().get().contentLength()).isZero();
verify(request, never()).getInputStream();
}
@Test
@DisplayName("POST streams the request input stream as an unknown-length body")
void postStreamsInputStream() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getContentType()).thenReturn("application/json");
byte[] payload = "{\"x\":1}".getBytes(StandardCharsets.UTF_8);
when(request.getInputStream()).thenReturn(servletInputStream(payload));
service.forward("POST", "/v1/chat", request, false);
HttpRequest sent = captureSentRequest();
assertThat(sent.method()).isEqualTo("POST");
assertThat(sent.bodyPublisher()).isPresent();
// ofInputStream publishes with an unknown content length (-1).
assertThat(sent.bodyPublisher().get().contentLength()).isEqualTo(-1L);
}
@Test
@DisplayName("PUT also streams the request body via the input-stream publisher")
void putStreamsInputStream() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getContentType()).thenReturn(null);
when(request.getInputStream())
.thenReturn(servletInputStream("raw".getBytes(StandardCharsets.UTF_8)));
service.forward("PUT", "/v1/item/3", request, false);
HttpRequest sent = captureSentRequest();
assertThat(sent.method()).isEqualTo("PUT");
assertThat(sent.bodyPublisher().get().contentLength()).isEqualTo(-1L);
}
@Test
@DisplayName(
"the streamed body publisher lazily emits the exact request bytes when drained")
void streamedBodyContainsRequestBytes() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getContentType()).thenReturn("application/json");
when(request.getInputStream())
.thenReturn(servletInputStream("hello-body".getBytes(StandardCharsets.UTF_8)));
service.forward("POST", "/v1/chat", request, false);
HttpRequest sent = captureSentRequest();
// The supplier is lazy: getInputStream() is only invoked once the body is consumed.
String body = drainBody(sent.bodyPublisher().get());
assertThat(body).isEqualTo("hello-body");
}
@Test
@DisplayName(
"an IOException while opening the request stream surfaces as UncheckedIOException")
void inputStreamFailureBecomesUnchecked() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getContentType()).thenReturn("application/json");
when(request.getInputStream()).thenThrow(new IOException("stream gone"));
service.forward("POST", "/v1/chat", request, false);
// The failure only triggers when the lazy supplier runs at body-drain time.
HttpRequest sent = captureSentRequest();
assertThatThrownBy(() -> drainBody(sent.bodyPublisher().get()))
.isInstanceOf(UncheckedIOException.class)
.hasRootCauseInstanceOf(IOException.class);
}
}
@Nested
@DisplayName("response propagation and send delegation")
class SendDelegation {
@Test
@DisplayName("returns exactly the response produced by the underlying client")
void returnsClientResponse() throws Exception {
when(request.getQueryString()).thenReturn(null);
HttpResponse<InputStream> result = service.forward("GET", "/v1/x", request, false);
assertThat(result).isSameAs(response);
}
@Test
@DisplayName("an IOException from the client propagates to the caller")
void clientIoExceptionPropagates() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
.thenThrow(new IOException("connection refused"));
assertThatThrownBy(() -> service.forward("GET", "/v1/x", request, false))
.isInstanceOf(IOException.class)
.hasMessage("connection refused");
}
@Test
@DisplayName("an InterruptedException from the client propagates to the caller")
void clientInterruptedExceptionPropagates() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
.thenThrow(new InterruptedException("interrupted"));
assertThatThrownBy(() -> service.forward("GET", "/v1/x", request, false))
.isInstanceOf(InterruptedException.class);
// Clear the interrupt flag the thrown InterruptedException may have left.
Thread.interrupted();
}
}
// --- helpers ------------------------------------------------------------------------------
/** Drain a BodyPublisher to a UTF-8 string, propagating any error the supplier throws. */
private static String drainBody(HttpRequest.BodyPublisher publisher) {
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
java.util.concurrent.atomic.AtomicReference<Throwable> error =
new java.util.concurrent.atomic.AtomicReference<>();
java.util.concurrent.Flow.Subscriber<java.nio.ByteBuffer> subscriber =
new java.util.concurrent.Flow.Subscriber<>() {
@Override
public void onSubscribe(java.util.concurrent.Flow.Subscription s) {
s.request(Long.MAX_VALUE);
}
@Override
public void onNext(java.nio.ByteBuffer item) {
byte[] chunk = new byte[item.remaining()];
item.get(chunk);
out.write(chunk, 0, chunk.length);
}
@Override
public void onError(Throwable t) {
error.set(t);
}
@Override
public void onComplete() {}
};
publisher.subscribe(subscriber);
Throwable t = error.get();
if (t instanceof RuntimeException re) {
throw re;
}
if (t != null) {
throw new RuntimeException(t);
}
return out.toString(StandardCharsets.UTF_8);
}
/** Minimal ServletInputStream over a fixed byte array for streaming-body tests. */
private static ServletInputStream servletInputStream(byte[] data) {
ByteArrayInputStream delegate = new ByteArrayInputStream(data);
return new ServletInputStream() {
@Override
public int read() {
return delegate.read();
}
@Override
public boolean isFinished() {
return delegate.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(jakarta.servlet.ReadListener readListener) {
// no-op: synchronous reads only in tests
}
};
}
}
@@ -0,0 +1,783 @@
package stirling.software.saas.ai.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.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.saas.ai.model.AiCreateSession;
import stirling.software.saas.ai.model.AiCreateSessionStatus;
import stirling.software.saas.ai.repository.AiCreateSessionRepository;
import stirling.software.saas.security.EnhancedJwtAuthenticationToken;
/**
* Unit tests for {@link AiCreateSessionService}.
*
* <p>The service is a thin persistence orchestrator over {@link AiCreateSessionRepository} plus a
* three-tier user-id resolution chain: {@code UserServiceInterface.getCurrentUsername()} ->
* Supabase id from the {@link SecurityContextHolder} authentication -> servlet session-scoped id ->
* the {@code "default_user"} fallback. Repository.save is stubbed to echo its argument so the
* field-mutation assertions can read back what the service set. SecurityContext and
* RequestContextHolder are reset after every test to keep the static thread-local state isolated.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AiCreateSessionServiceTest {
@Mock private AiCreateSessionRepository repository;
@Mock private UserServiceInterface userService;
private static final String SUPABASE_ID = "11111111-2222-3333-4444-555555555555";
private static final String DEFAULT_USER_ID = "default_user";
@BeforeEach
void echoSave() {
// Persistence is a no-op for these unit tests; save() returns the same managed entity so
// mutation assertions can read it back.
when(repository.save(any(AiCreateSession.class))).thenAnswer(inv -> inv.getArgument(0));
}
@AfterEach
void clearStatics() {
SecurityContextHolder.clearContext();
RequestContextHolder.resetRequestAttributes();
}
/** Service with a present (but unstubbed-by-default) UserServiceInterface. */
private AiCreateSessionService serviceWithUserService() {
return new AiCreateSessionService(repository, Optional.of(userService));
}
/** Service with no UserServiceInterface bean wired (Optional.empty). */
private AiCreateSessionService serviceWithoutUserService() {
return new AiCreateSessionService(repository, Optional.empty());
}
/** Authenticate the SecurityContext with a Supabase-id-bearing JWT token. */
private static void authenticateJwt(String supabaseId) {
Map<String, Object> headers = new HashMap<>();
headers.put("alg", "RS256");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", supabaseId);
claims.put("email", "user@example.com");
Jwt jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(3600), headers, claims);
EnhancedJwtAuthenticationToken auth =
new EnhancedJwtAuthenticationToken(
jwt,
List.of(new SimpleGrantedAuthority("ROLE_USER")),
"user@example.com",
supabaseId);
SecurityContextHolder.getContext().setAuthentication(auth);
}
/** Bind a servlet request (optionally with a live HttpSession) to the current thread. */
private static MockHttpServletRequest bindRequest(boolean withSession, String sessionId) {
MockHttpServletRequest request = new MockHttpServletRequest();
if (withSession) {
// (ServletContext, id) ctor fixes the session id so "session:<id>" is deterministic.
request.setSession(new MockHttpSession(null, sessionId));
}
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
return request;
}
/** A persisted AiCreateSession owned by the given user. */
private static AiCreateSession existingSession(String sessionId, String userId) {
AiCreateSession session = new AiCreateSession();
session.setSessionId(sessionId);
session.setUserId(userId);
session.setStatus(AiCreateSessionStatus.OUTLINE_PENDING);
return session;
}
// -------------------------------------------------------------------------------------------
// resolveUserId() — three-tier precedence chain
// -------------------------------------------------------------------------------------------
@Nested
@DisplayName("resolveUserId precedence")
class ResolveUserId {
@Test
@DisplayName("UserServiceInterface username wins over everything else")
void userServiceUsernameWins() {
// Even with a JWT auth present, the username from the user service takes priority.
authenticateJwt(SUPABASE_ID);
when(userService.getCurrentUsername()).thenReturn("alice@corp.com");
assertThat(serviceWithUserService().resolveUserId()).isEqualTo("alice@corp.com");
}
@Test
@DisplayName("blank username is ignored and the chain falls through to the JWT id")
void blankUsernameFallsThroughToJwt() {
authenticateJwt(SUPABASE_ID);
when(userService.getCurrentUsername()).thenReturn(" ");
assertThat(serviceWithUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
}
@Test
@DisplayName("anonymousUser username is ignored and the chain falls through")
void anonymousUsernameFallsThrough() {
authenticateJwt(SUPABASE_ID);
when(userService.getCurrentUsername()).thenReturn("anonymousUser");
assertThat(serviceWithUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
}
@Test
@DisplayName("null username is ignored and the chain falls through")
void nullUsernameFallsThrough() {
authenticateJwt(SUPABASE_ID);
when(userService.getCurrentUsername()).thenReturn(null);
assertThat(serviceWithUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
}
@Test
@DisplayName("a throwing user service is swallowed and the chain falls through")
void throwingUserServiceSwallowedAndFallsThrough() {
authenticateJwt(SUPABASE_ID);
when(userService.getCurrentUsername()).thenThrow(new RuntimeException("boom"));
assertThat(serviceWithUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
}
@Test
@DisplayName("absent user service bean skips tier 1 and uses the JWT id")
void absentUserServiceUsesJwt() {
authenticateJwt(SUPABASE_ID);
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
}
@Test
@DisplayName("unauthenticated 2-arg token (isAuthenticated=false) is skipped")
void unauthenticatedTokenSkipped() {
// The 2-arg UsernamePasswordAuthenticationToken ctor leaves isAuthenticated()=false,
// so the JWT branch is bypassed and we fall through to the default.
SecurityContextHolder.getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken("bob", "creds"));
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(DEFAULT_USER_ID);
}
@Test
@DisplayName("authenticated principal id is used via the generic getName() fallback")
void authenticatedPrincipalNameUsed() {
// A non-JWT authenticated token: extractSupabaseId falls back to getName().
SecurityContextHolder.getContext()
.setAuthentication(
new UsernamePasswordAuthenticationToken(
"carol",
"creds",
List.of(new SimpleGrantedAuthority("ROLE_USER"))));
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo("carol");
}
@Test
@DisplayName("authenticated 'anonymousUser' name is rejected, chain falls through")
void anonymousAuthNameRejected() {
SecurityContextHolder.getContext()
.setAuthentication(
new UsernamePasswordAuthenticationToken(
"anonymousUser",
"creds",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(DEFAULT_USER_ID);
}
@Test
@DisplayName("no auth + a live HttpSession yields a session-scoped id")
void sessionScopedIdWhenNoAuth() {
bindRequest(true, "sess-abc");
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo("session:sess-abc");
}
@Test
@DisplayName("no auth + a request without a session falls through to default")
void noSessionFallsThroughToDefault() {
bindRequest(false, null);
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(DEFAULT_USER_ID);
}
@Test
@DisplayName("no user service, no auth, no request context -> default_user")
void defaultUserWhenNothingResolves() {
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(DEFAULT_USER_ID);
}
@Test
@DisplayName("JWT id is preferred over an available session-scoped id")
void jwtPreferredOverSession() {
authenticateJwt(SUPABASE_ID);
bindRequest(true, "sess-xyz");
assertThat(serviceWithoutUserService().resolveUserId()).isEqualTo(SUPABASE_ID);
}
}
// -------------------------------------------------------------------------------------------
// createSession
// -------------------------------------------------------------------------------------------
@Nested
@DisplayName("createSession")
class CreateSession {
@Test
@DisplayName("populates every field, generates a session id, and persists once")
void populatesAndSaves() {
when(userService.getCurrentUsername()).thenReturn("owner");
AiCreateSessionService service = serviceWithUserService();
AiCreateSession out =
service.createSession(
"my prompt", "report", "tmpl-1", "\\documentclass{}", "preview");
assertThat(out.getUserId()).isEqualTo("owner");
assertThat(out.getDocType()).isEqualTo("report");
assertThat(out.getTemplateId()).isEqualTo("tmpl-1");
assertThat(out.getTemplateTex()).isEqualTo("\\documentclass{}");
assertThat(out.getPreviewTex()).isEqualTo("preview");
assertThat(out.getPromptInitial()).isEqualTo("my prompt");
assertThat(out.getPromptLatest()).isEqualTo("my prompt");
assertThat(out.isOutlineApproved()).isFalse();
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.OUTLINE_PENDING);
// A random UUID session id was generated.
assertThat(out.getSessionId()).isNotBlank();
assertThat(UUID.fromString(out.getSessionId())).isNotNull();
verify(repository).save(out);
}
@Test
@DisplayName("two sessions get distinct generated ids")
void distinctSessionIds() {
when(userService.getCurrentUsername()).thenReturn("owner");
AiCreateSessionService service = serviceWithUserService();
AiCreateSession a = service.createSession("p", null, null, null, null);
AiCreateSession b = service.createSession("p", null, null, null, null);
assertThat(a.getSessionId()).isNotEqualTo(b.getSessionId());
}
@Test
@DisplayName("uses default_user when nothing else resolves the identity")
void usesDefaultUser() {
AiCreateSession out =
serviceWithoutUserService().createSession("p", "doc", "t", "tex", "prev");
assertThat(out.getUserId()).isEqualTo(DEFAULT_USER_ID);
}
}
// -------------------------------------------------------------------------------------------
// getSession / getSessionForCurrentUser
// -------------------------------------------------------------------------------------------
@Nested
@DisplayName("getSession / getSessionForCurrentUser")
class GetSession {
@Test
@DisplayName("getSession returns the persisted row")
void getSessionReturnsRow() {
AiCreateSession row = existingSession("s1", "owner");
when(repository.findById("s1")).thenReturn(Optional.of(row));
assertThat(serviceWithoutUserService().getSession("s1")).isSameAs(row);
}
@Test
@DisplayName("getSession throws 404 when the row is missing")
void getSessionMissingThrows404() {
when(repository.findById("nope")).thenReturn(Optional.empty());
assertThatThrownBy(() -> serviceWithoutUserService().getSession("nope"))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
ex ->
assertThat(((ResponseStatusException) ex).getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND));
}
@Test
@DisplayName("getSessionForCurrentUser returns the row when the owner matches")
void ownerMatchReturnsRow() {
when(userService.getCurrentUsername()).thenReturn("owner");
AiCreateSession row = existingSession("s1", "owner");
when(repository.findById("s1")).thenReturn(Optional.of(row));
assertThat(serviceWithUserService().getSessionForCurrentUser("s1")).isSameAs(row);
}
@Test
@DisplayName("getSessionForCurrentUser hides another user's session behind a 404")
void foreignOwnerThrows404() {
when(userService.getCurrentUsername()).thenReturn("intruder");
AiCreateSession row = existingSession("s1", "owner");
when(repository.findById("s1")).thenReturn(Optional.of(row));
assertThatThrownBy(() -> serviceWithUserService().getSessionForCurrentUser("s1"))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
ex ->
assertThat(((ResponseStatusException) ex).getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND));
}
}
// -------------------------------------------------------------------------------------------
// updateOutline
// -------------------------------------------------------------------------------------------
@Nested
@DisplayName("updateOutline")
class UpdateOutline {
@Test
@DisplayName("sets outline text, filename, constraints, approval flag and APPROVED status")
void fullUpdate() {
when(userService.getCurrentUsername()).thenReturn("owner");
AiCreateSession row = existingSession("s1", "owner");
when(repository.findById("s1")).thenReturn(Optional.of(row));
AiCreateSession out =
serviceWithUserService()
.updateOutline("s1", "the outline", "outline.tex", "be brief");
assertThat(out.getOutlineText()).isEqualTo("the outline");
assertThat(out.getOutlineFilename()).isEqualTo("outline.tex");
assertThat(out.getOutlineConstraints()).isEqualTo("be brief");
assertThat(out.isOutlineApproved()).isTrue();
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.OUTLINE_APPROVED);
verify(repository).save(row);
}
@Test
@DisplayName("blank filename is not applied; null constraints are left untouched")
void blankFilenameAndNullConstraintsIgnored() {
when(userService.getCurrentUsername()).thenReturn("owner");
AiCreateSession row = existingSession("s1", "owner");
row.setOutlineFilename("keep.tex");
row.setOutlineConstraints("keep-constraints");
when(repository.findById("s1")).thenReturn(Optional.of(row));
AiCreateSession out = serviceWithUserService().updateOutline("s1", "txt", " ", null);
assertThat(out.getOutlineFilename()).isEqualTo("keep.tex");
assertThat(out.getOutlineConstraints()).isEqualTo("keep-constraints");
// Still approved + status flipped even with skipped optional fields.
assertThat(out.isOutlineApproved()).isTrue();
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.OUTLINE_APPROVED);
}
@Test
@DisplayName("empty-string constraints ARE applied (only null is skipped)")
void emptyConstraintsApplied() {
when(userService.getCurrentUsername()).thenReturn("owner");
AiCreateSession row = existingSession("s1", "owner");
row.setOutlineConstraints("old");
when(repository.findById("s1")).thenReturn(Optional.of(row));
AiCreateSession out = serviceWithUserService().updateOutline("s1", "t", "f", "");
assertThat(out.getOutlineConstraints()).isEmpty();
}
@Test
@DisplayName("a foreign session 404s before any mutation or save")
void foreignSessionBlocked() {
when(userService.getCurrentUsername()).thenReturn("intruder");
AiCreateSession row = existingSession("s1", "owner");
when(repository.findById("s1")).thenReturn(Optional.of(row));
assertThatThrownBy(() -> serviceWithUserService().updateOutline("s1", "t", "f", "c"))
.isInstanceOf(ResponseStatusException.class);
assertThat(row.getOutlineText()).isNull();
verify(repository, never()).save(any());
}
}
// -------------------------------------------------------------------------------------------
// updateDraftSections
// -------------------------------------------------------------------------------------------
@Test
@DisplayName("updateDraftSections stores sections and flips status to DRAFT_READY")
void updateDraftSections() {
when(userService.getCurrentUsername()).thenReturn("owner");
AiCreateSession row = existingSession("s1", "owner");
when(repository.findById("s1")).thenReturn(Optional.of(row));
AiCreateSession out = serviceWithUserService().updateDraftSections("s1", "section json");
assertThat(out.getDraftSections()).isEqualTo("section json");
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.DRAFT_READY);
verify(repository).save(row);
}
// -------------------------------------------------------------------------------------------
// updateTemplate
// -------------------------------------------------------------------------------------------
@Nested
@DisplayName("updateTemplate")
class UpdateTemplate {
@Test
@DisplayName("updates docType and templateId when both are non-blank")
void updatesBoth() {
when(userService.getCurrentUsername()).thenReturn("owner");
AiCreateSession row = existingSession("s1", "owner");
row.setDocType("old-doc");
row.setTemplateId("old-tmpl");
when(repository.findById("s1")).thenReturn(Optional.of(row));
AiCreateSession out =
serviceWithUserService().updateTemplate("s1", "new-doc", "new-tmpl");
assertThat(out.getDocType()).isEqualTo("new-doc");
assertThat(out.getTemplateId()).isEqualTo("new-tmpl");
verify(repository).save(row);
}
@Test
@DisplayName("null/blank inputs leave the existing template untouched")
void blankInputsKeepExisting() {
when(userService.getCurrentUsername()).thenReturn("owner");
AiCreateSession row = existingSession("s1", "owner");
row.setDocType("old-doc");
row.setTemplateId("old-tmpl");
when(repository.findById("s1")).thenReturn(Optional.of(row));
AiCreateSession out = serviceWithUserService().updateTemplate("s1", null, " ");
assertThat(out.getDocType()).isEqualTo("old-doc");
assertThat(out.getTemplateId()).isEqualTo("old-tmpl");
// Still persists (no-op save) — the method always saves.
verify(repository).save(row);
}
}
// -------------------------------------------------------------------------------------------
// reprompt
// -------------------------------------------------------------------------------------------
@Test
@DisplayName("reprompt resets all derived artifacts and re-enters OUTLINE_PENDING")
void reprompt() {
when(userService.getCurrentUsername()).thenReturn("owner");
AiCreateSession row = existingSession("s1", "owner");
row.setPromptLatest("old prompt");
row.setOutlineText("old outline");
row.setOutlineFilename("old.tex");
row.setOutlineApproved(true);
row.setOutlineConstraints("old constraints");
row.setDraftSections("old draft");
row.setPolishedLatex("old latex");
row.setPdfUrl("https://old/url");
row.setStatus(AiCreateSessionStatus.POLISHED_READY);
when(repository.findById("s1")).thenReturn(Optional.of(row));
AiCreateSession out = serviceWithUserService().reprompt("s1", "fresh prompt");
assertThat(out.getPromptLatest()).isEqualTo("fresh prompt");
assertThat(out.getOutlineText()).isNull();
assertThat(out.getOutlineFilename()).isNull();
assertThat(out.isOutlineApproved()).isFalse();
assertThat(out.getOutlineConstraints()).isNull();
assertThat(out.getDraftSections()).isNull();
assertThat(out.getPolishedLatex()).isNull();
assertThat(out.getPdfUrl()).isNull();
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.OUTLINE_PENDING);
verify(repository).save(row);
}
// -------------------------------------------------------------------------------------------
// deleteSessionForCurrentUser
// -------------------------------------------------------------------------------------------
@Nested
@DisplayName("deleteSessionForCurrentUser")
class DeleteSession {
@Test
@DisplayName("deletes the owner's session")
void deletesOwnerSession() {
when(userService.getCurrentUsername()).thenReturn("owner");
AiCreateSession row = existingSession("s1", "owner");
when(repository.findById("s1")).thenReturn(Optional.of(row));
serviceWithUserService().deleteSessionForCurrentUser("s1");
verify(repository).delete(row);
}
@Test
@DisplayName("a foreign session 404s and is never deleted")
void foreignSessionNotDeleted() {
when(userService.getCurrentUsername()).thenReturn("intruder");
AiCreateSession row = existingSession("s1", "owner");
when(repository.findById("s1")).thenReturn(Optional.of(row));
assertThatThrownBy(() -> serviceWithUserService().deleteSessionForCurrentUser("s1"))
.isInstanceOf(ResponseStatusException.class);
verify(repository, never()).delete(any());
}
}
// -------------------------------------------------------------------------------------------
// applyInternalUpdate — null-coalescing partial update, no ownership check
// -------------------------------------------------------------------------------------------
@Nested
@DisplayName("applyInternalUpdate")
class ApplyInternalUpdate {
@Test
@DisplayName("applies every non-null field including outlineApproved=false")
void appliesAllFields() {
// No ownership check on the internal path: it uses getSession, not the per-user guard.
AiCreateSession row = existingSession("s1", "owner");
row.setOutlineApproved(true);
when(repository.findById("s1")).thenReturn(Optional.of(row));
AiCreateSession out =
serviceWithoutUserService()
.applyInternalUpdate(
"s1",
"outline",
"o.tex",
Boolean.FALSE,
"constraints",
"draft",
"latex",
"https://pdf/url",
"doc",
"tmpl",
AiCreateSessionStatus.POLISHED_READY);
assertThat(out.getOutlineText()).isEqualTo("outline");
assertThat(out.getOutlineFilename()).isEqualTo("o.tex");
// Boolean.FALSE is non-null so it IS applied, flipping the prior true.
assertThat(out.isOutlineApproved()).isFalse();
assertThat(out.getOutlineConstraints()).isEqualTo("constraints");
assertThat(out.getDraftSections()).isEqualTo("draft");
assertThat(out.getPolishedLatex()).isEqualTo("latex");
assertThat(out.getPdfUrl()).isEqualTo("https://pdf/url");
assertThat(out.getDocType()).isEqualTo("doc");
assertThat(out.getTemplateId()).isEqualTo("tmpl");
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.POLISHED_READY);
verify(repository).save(row);
}
@Test
@DisplayName("all-null arguments leave the row untouched but still persist")
void allNullLeavesUntouched() {
AiCreateSession row = existingSession("s1", "owner");
row.setOutlineText("keep-outline");
row.setDocType("keep-doc");
row.setStatus(AiCreateSessionStatus.DRAFT_READY);
when(repository.findById("s1")).thenReturn(Optional.of(row));
AiCreateSession out =
serviceWithoutUserService()
.applyInternalUpdate(
"s1", null, null, null, null, null, null, null, null, null,
null);
assertThat(out.getOutlineText()).isEqualTo("keep-outline");
assertThat(out.getDocType()).isEqualTo("keep-doc");
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.DRAFT_READY);
verify(repository).save(row);
}
@Test
@DisplayName("internal path 404s when the session does not exist")
void missingSession404() {
when(repository.findById("ghost")).thenReturn(Optional.empty());
assertThatThrownBy(
() ->
serviceWithoutUserService()
.applyInternalUpdate(
"ghost", "x", null, null, null, null, null,
null, null, null, null))
.isInstanceOf(ResponseStatusException.class);
}
@Test
@DisplayName("internal path ignores ownership — updates a row owned by another user")
void ignoresOwnership() {
// applyInternalUpdate uses getSession (not getSessionForCurrentUser); current identity
// is irrelevant. Confirm a non-matching identity still updates the row.
authenticateJwt(SUPABASE_ID);
AiCreateSession row = existingSession("s1", "someone-else");
when(repository.findById("s1")).thenReturn(Optional.of(row));
AiCreateSession out =
serviceWithoutUserService()
.applyInternalUpdate(
"s1",
null,
null,
null,
null,
null,
null,
"https://done/pdf",
null,
null,
AiCreateSessionStatus.SAVED);
assertThat(out.getPdfUrl()).isEqualTo("https://done/pdf");
assertThat(out.getStatus()).isEqualTo(AiCreateSessionStatus.SAVED);
}
}
// -------------------------------------------------------------------------------------------
// list* — delegation to the right repository finder for the resolved user
// -------------------------------------------------------------------------------------------
@Nested
@DisplayName("listing methods")
class Listing {
@Test
@DisplayName("no-arg list delegates to findByUserIdOrderByUpdatedAtDesc(userId)")
void listNoArg() {
when(userService.getCurrentUsername()).thenReturn("owner");
List<AiCreateSession> expected = List.of(existingSession("s1", "owner"));
when(repository.findByUserIdOrderByUpdatedAtDesc("owner")).thenReturn(expected);
assertThat(serviceWithUserService().listSessionsForCurrentUser()).isSameAs(expected);
}
@Test
@DisplayName("paged list delegates with the pageable for the resolved user")
void listPaged() {
when(userService.getCurrentUsername()).thenReturn("owner");
Pageable pageable = PageRequest.of(0, 20);
List<AiCreateSession> expected = List.of(existingSession("s1", "owner"));
when(repository.findByUserIdOrderByUpdatedAtDesc("owner", pageable))
.thenReturn(expected);
assertThat(serviceWithUserService().listSessionsForCurrentUser(pageable))
.isSameAs(expected);
}
@Test
@DisplayName("includeDrafts=true returns the all-sessions finder")
void listIncludeDraftsTrue() {
when(userService.getCurrentUsername()).thenReturn("owner");
Pageable pageable = PageRequest.of(0, 10);
List<AiCreateSession> expected = List.of(existingSession("s1", "owner"));
when(repository.findByUserIdOrderByUpdatedAtDesc("owner", pageable))
.thenReturn(expected);
assertThat(serviceWithUserService().listSessionsForCurrentUser(pageable, true))
.isSameAs(expected);
verify(repository, never())
.findByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(any(), any());
}
@Test
@DisplayName("includeDrafts=false returns only sessions with a non-null pdfUrl")
void listIncludeDraftsFalse() {
when(userService.getCurrentUsername()).thenReturn("owner");
Pageable pageable = PageRequest.of(0, 10);
List<AiCreateSession> expected = List.of(existingSession("s1", "owner"));
when(repository.findByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc("owner", pageable))
.thenReturn(expected);
assertThat(serviceWithUserService().listSessionsForCurrentUser(pageable, false))
.isSameAs(expected);
verify(repository, never())
.findByUserIdOrderByUpdatedAtDesc(eq("owner"), any(Pageable.class));
}
@Test
@DisplayName("summary list includeDrafts=true uses the all-summaries projection finder")
void summariesIncludeDraftsTrue() {
when(userService.getCurrentUsername()).thenReturn("owner");
Pageable pageable = PageRequest.of(0, 10);
List<AiCreateSessionRepository.AiCreateSessionSummaryProjection> expected = List.of();
when(repository.findSummariesByUserIdOrderByUpdatedAtDesc("owner", pageable))
.thenReturn(expected);
assertThat(serviceWithUserService().listSessionSummariesForCurrentUser(pageable, true))
.isSameAs(expected);
verify(repository, never())
.findSummariesByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(any(), any());
}
@Test
@DisplayName("summary list includeDrafts=false uses the pdf-only summaries finder")
void summariesIncludeDraftsFalse() {
when(userService.getCurrentUsername()).thenReturn("owner");
Pageable pageable = PageRequest.of(0, 10);
List<AiCreateSessionRepository.AiCreateSessionSummaryProjection> expected = List.of();
when(repository.findSummariesByUserIdAndPdfUrlIsNotNullOrderByUpdatedAtDesc(
"owner", pageable))
.thenReturn(expected);
assertThat(serviceWithUserService().listSessionSummariesForCurrentUser(pageable, false))
.isSameAs(expected);
verify(repository, never()).findSummariesByUserIdOrderByUpdatedAtDesc(any(), any());
}
@Test
@DisplayName("listing for an unidentified caller queries the default_user partition")
void listForDefaultUser() {
List<AiCreateSession> expected = List.of();
when(repository.findByUserIdOrderByUpdatedAtDesc(DEFAULT_USER_ID)).thenReturn(expected);
assertThat(serviceWithoutUserService().listSessionsForCurrentUser()).isSameAs(expected);
ArgumentCaptor<String> userIdCaptor = ArgumentCaptor.forClass(String.class);
verify(repository).findByUserIdOrderByUpdatedAtDesc(userIdCaptor.capture());
assertThat(userIdCaptor.getValue()).isEqualTo(DEFAULT_USER_ID);
}
}
}
@@ -0,0 +1,678 @@
package stirling.software.saas.ai.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.test.util.ReflectionTestUtils;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.Part;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.service.UserService;
/**
* Unit tests for {@link AiProxyService}.
*
* <p>The service forwards HTTP requests to an AI backend. It constructs its own {@link HttpClient}
* internally (no constructor injection), so each test swaps in a mocked client via {@link
* ReflectionTestUtils} and captures the outgoing {@link HttpRequest} to assert on URL, method and
* headers. All collaborators ({@link HttpServletRequest}, {@link UserService}, {@link
* UserRepository}) are mocked; no Spring context, DB or real network is involved.
*
* <p>Header semantics under test: Authorization is forwarded when present/non-blank; X-API-KEY is
* taken from the request header first and otherwise resolved from the authenticated user; Accept is
* overridden to {@code text/event-stream} when SSE is requested; the target URL is assembled from
* the configured base URL, the path and the query string.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AiProxyServiceTest {
private static final String BASE_URL = "http://ai-backend:5001";
@Mock private UserRepository userRepository;
@Mock private UserService userService;
@Mock private HttpServletRequest request;
@Mock private HttpClient httpClient;
@SuppressWarnings("unchecked")
private final HttpResponse<InputStream> response =
(HttpResponse<InputStream>) org.mockito.Mockito.mock(HttpResponse.class);
private AiProxyService service;
@BeforeEach
void setUp() throws Exception {
service = new AiProxyService(BASE_URL, userRepository, userService);
// Swap the internally-built client for our mock so no real network call happens.
ReflectionTestUtils.setField(service, "httpClient", httpClient);
// Default: the mocked client returns our stub response for any send().
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
.thenReturn(response);
}
/** Capture the single HttpRequest the service hands to the client. */
private HttpRequest captureSentRequest() throws Exception {
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(captor.capture(), any(HttpResponse.BodyHandler.class));
return captor.getValue();
}
private static String header(HttpRequest req, String name) {
return req.headers().firstValue(name).orElse(null);
}
@Nested
@DisplayName("target URL assembly")
class UrlAssembly {
@Test
@DisplayName("joins base + leading-slash path + query string")
void joinsBasePathAndQuery() throws Exception {
when(request.getQueryString()).thenReturn("model=foo&n=2");
service.forward("GET", "/v1/chat", request, false);
HttpRequest sent = captureSentRequest();
assertThat(sent.uri())
.isEqualTo(URI.create("http://ai-backend:5001/v1/chat?model=foo&n=2"));
}
@Test
@DisplayName("prepends a slash when the path lacks one")
void prependsMissingSlash() throws Exception {
when(request.getQueryString()).thenReturn(null);
service.forward("GET", "v1/health", request, false);
assertThat(captureSentRequest().uri())
.isEqualTo(URI.create("http://ai-backend:5001/v1/health"));
}
@Test
@DisplayName("blank query string is ignored")
void blankQueryIgnored() throws Exception {
when(request.getQueryString()).thenReturn(" ");
service.forward("GET", "/v1/ping", request, false);
assertThat(captureSentRequest().uri())
.isEqualTo(URI.create("http://ai-backend:5001/v1/ping"));
}
@Test
@DisplayName("trailing slash on the configured base URL is trimmed")
void trimsTrailingSlashOnBase() throws Exception {
AiProxyService svc =
new AiProxyService("http://ai-backend:5001/", userRepository, userService);
ReflectionTestUtils.setField(svc, "httpClient", httpClient);
when(request.getQueryString()).thenReturn(null);
svc.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().uri())
.isEqualTo(URI.create("http://ai-backend:5001/v1/x"));
}
@Test
@DisplayName("surrounding whitespace on the base URL is trimmed")
void trimsWhitespaceOnBase() throws Exception {
AiProxyService svc =
new AiProxyService(" http://ai-backend:5001 ", userRepository, userService);
ReflectionTestUtils.setField(svc, "httpClient", httpClient);
when(request.getQueryString()).thenReturn(null);
svc.forward("GET", "/v1/y", request, false);
assertThat(captureSentRequest().uri())
.isEqualTo(URI.create("http://ai-backend:5001/v1/y"));
}
@Test
@DisplayName("blank base URL falls back to the localhost default")
void blankBaseUrlFallsBackToDefault() throws Exception {
AiProxyService svc = new AiProxyService(" ", userRepository, userService);
ReflectionTestUtils.setField(svc, "httpClient", httpClient);
when(request.getQueryString()).thenReturn(null);
svc.forward("GET", "/v1/z", request, false);
assertThat(captureSentRequest().uri())
.isEqualTo(URI.create("http://localhost:5001/v1/z"));
}
@Test
@DisplayName("null base URL falls back to the localhost default")
void nullBaseUrlFallsBackToDefault() throws Exception {
AiProxyService svc = new AiProxyService(null, userRepository, userService);
ReflectionTestUtils.setField(svc, "httpClient", httpClient);
when(request.getQueryString()).thenReturn(null);
svc.forward("GET", "/v1/q", request, false);
assertThat(captureSentRequest().uri())
.isEqualTo(URI.create("http://localhost:5001/v1/q"));
}
}
@Nested
@DisplayName("Authorization header forwarding")
class AuthorizationForwarding {
@Test
@DisplayName("forwards a present Authorization header verbatim")
void forwardsPresentAuth() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("Authorization")).thenReturn("Bearer abc.def");
service.forward("GET", "/v1/x", request, false);
assertThat(header(captureSentRequest(), "Authorization")).isEqualTo("Bearer abc.def");
}
@Test
@DisplayName("omits the header when Authorization is null")
void omitsWhenNull() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("Authorization")).thenReturn(null);
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("Authorization")).isEmpty();
}
@Test
@DisplayName("omits the header when Authorization is blank")
void omitsWhenBlank() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("Authorization")).thenReturn(" ");
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("Authorization")).isEmpty();
}
}
@Nested
@DisplayName("X-API-KEY resolution")
class ApiKeyResolution {
@Test
@DisplayName("uses the X-API-KEY header from the request when present (no user lookup)")
void usesRequestHeaderAndSkipsUserLookup() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("X-API-KEY")).thenReturn("req-key-123");
service.forward("GET", "/v1/x", request, false);
assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("req-key-123");
// Header short-circuits the authenticated-user fallback entirely.
verifyNoInteractions(userService);
}
@Test
@DisplayName("falls back to the authenticated user's API key when the header is absent")
void fallsBackToUserApiKey() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("X-API-KEY")).thenReturn(null);
when(userService.getCurrentUsername()).thenReturn("alice");
when(userService.getApiKeyForUser("alice")).thenReturn("user-key-xyz");
service.forward("GET", "/v1/x", request, false);
assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("user-key-xyz");
verify(userService).getApiKeyForUser("alice");
}
@Test
@DisplayName("falls back to the user key when the header is blank")
void blankHeaderTriggersFallback() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("X-API-KEY")).thenReturn(" ");
when(userService.getCurrentUsername()).thenReturn("bob");
when(userService.getApiKeyForUser("bob")).thenReturn("bob-key");
service.forward("GET", "/v1/x", request, false);
assertThat(header(captureSentRequest(), "X-API-KEY")).isEqualTo("bob-key");
}
@Test
@DisplayName("no X-API-KEY header set when there is no authenticated user")
void noUser_noHeader() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("X-API-KEY")).thenReturn(null);
when(userService.getCurrentUsername()).thenReturn(null);
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
// Username was null/blank, so the key lookup is never attempted.
verify(userService, never()).getApiKeyForUser(any());
}
@Test
@DisplayName("blank username from the security context yields no header")
void blankUsername_noHeader() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("X-API-KEY")).thenReturn(null);
when(userService.getCurrentUsername()).thenReturn(" ");
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
verify(userService, never()).getApiKeyForUser(any());
}
@Test
@DisplayName("a resolved-but-blank user key is not forwarded")
void blankResolvedKey_noHeader() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("X-API-KEY")).thenReturn(null);
when(userService.getCurrentUsername()).thenReturn("carol");
when(userService.getApiKeyForUser("carol")).thenReturn("");
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
}
@Test
@DisplayName("an exception while resolving the user key is swallowed; no header forwarded")
void userKeyLookupThrows_isSwallowed() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("X-API-KEY")).thenReturn(null);
when(userService.getCurrentUsername()).thenReturn("dave");
when(userService.getApiKeyForUser("dave"))
.thenThrow(new RuntimeException("key store offline"));
// Must not propagate: extractUserApiKey() catches and returns null.
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("X-API-KEY")).isEmpty();
}
}
@Nested
@DisplayName("Accept header handling")
class AcceptHandling {
@Test
@DisplayName("acceptEventStream overrides any inbound Accept with text/event-stream")
void eventStreamOverrides() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("Accept")).thenReturn("application/json");
service.forward("GET", "/v1/stream", request, true);
assertThat(header(captureSentRequest(), "Accept")).isEqualTo("text/event-stream");
}
@Test
@DisplayName("event stream is requested even with no inbound Accept header")
void eventStreamWithoutInboundAccept() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("Accept")).thenReturn(null);
service.forward("GET", "/v1/stream", request, true);
assertThat(header(captureSentRequest(), "Accept")).isEqualTo("text/event-stream");
}
@Test
@DisplayName("passes a non-stream Accept header through unchanged")
void passesInboundAcceptThrough() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("Accept")).thenReturn("application/json");
service.forward("GET", "/v1/x", request, false);
assertThat(header(captureSentRequest(), "Accept")).isEqualTo("application/json");
}
@Test
@DisplayName("no Accept header set when inbound Accept is absent and SSE not requested")
void noAcceptWhenAbsentAndNotStreaming() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("Accept")).thenReturn(null);
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("Accept")).isEmpty();
}
@Test
@DisplayName("blank inbound Accept is not forwarded when SSE not requested")
void blankAcceptNotForwarded() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getHeader("Accept")).thenReturn(" ");
service.forward("GET", "/v1/x", request, false);
assertThat(captureSentRequest().headers().firstValue("Accept")).isEmpty();
}
}
@Nested
@DisplayName("HTTP method and body publisher selection")
class MethodAndBody {
@Test
@DisplayName("GET sends no body and never reads the request input stream")
void getHasNoBody() throws Exception {
when(request.getQueryString()).thenReturn(null);
service.forward("GET", "/v1/x", request, false);
HttpRequest sent = captureSentRequest();
assertThat(sent.method()).isEqualTo("GET");
assertThat(sent.bodyPublisher()).isPresent();
assertThat(sent.bodyPublisher().get().contentLength()).isZero();
// GET/DELETE short-circuit before touching the body.
verify(request, never()).getInputStream();
verify(request, never()).getParts();
}
@Test
@DisplayName("DELETE sends no body and never reads the request input stream")
void deleteHasNoBody() throws Exception {
when(request.getQueryString()).thenReturn(null);
service.forward("DELETE", "/v1/item/9", request, false);
HttpRequest sent = captureSentRequest();
assertThat(sent.method()).isEqualTo("DELETE");
assertThat(sent.bodyPublisher().get().contentLength()).isZero();
verify(request, never()).getInputStream();
}
@Test
@DisplayName("method name matching is case-insensitive for the no-body branch")
void lowercaseGetStillNoBody() throws Exception {
when(request.getQueryString()).thenReturn(null);
service.forward("get", "/v1/x", request, false);
HttpRequest sent = captureSentRequest();
// Lowercase still routes through the GET/DELETE no-body branch.
assertThat(sent.method()).isEqualToIgnoringCase("get");
assertThat(sent.bodyPublisher().get().contentLength()).isZero();
verify(request, never()).getInputStream();
}
@Test
@DisplayName("POST with a plain content type streams the request input stream as the body")
void postStreamsInputStream() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getContentType()).thenReturn("application/json");
ServletInputStream sis =
servletInputStream("{\"x\":1}".getBytes(StandardCharsets.UTF_8));
when(request.getInputStream()).thenReturn(sis);
service.forward("POST", "/v1/chat", request, false);
HttpRequest sent = captureSentRequest();
assertThat(sent.method()).isEqualTo("POST");
// ofInputStream publishes with an unknown length (-1).
assertThat(sent.bodyPublisher()).isPresent();
// Inbound Content-Type is propagated since the body publisher provides none.
assertThat(header(sent, "Content-Type")).isEqualTo("application/json");
}
@Test
@DisplayName("POST with no inbound content type sets no Content-Type header")
void postWithoutContentType() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getContentType()).thenReturn(null);
when(request.getInputStream())
.thenReturn(servletInputStream("raw".getBytes(StandardCharsets.UTF_8)));
service.forward("POST", "/v1/chat", request, false);
assertThat(captureSentRequest().headers().firstValue("Content-Type")).isEmpty();
}
}
@Nested
@DisplayName("multipart/form-data re-encoding")
class Multipart {
@Test
@DisplayName("re-encodes parts and sets a generated multipart boundary Content-Type")
void reencodesMultipartBody() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getContentType()).thenReturn("multipart/form-data; boundary=inbound");
Part field = textPart("prompt", "hello world");
Part file = filePart("file", "doc.pdf", "application/pdf", "PDF-BYTES");
when(request.getParts()).thenReturn(List.of(field, file));
service.forward("POST", "/v1/upload", request, false);
HttpRequest sent = captureSentRequest();
String contentType = header(sent, "Content-Type");
assertThat(contentType).startsWith("multipart/form-data; boundary=----spdf-");
// A fresh boundary is generated rather than reusing the inbound one.
assertThat(contentType).doesNotContain("inbound");
// Body has a known length (ofByteArray), unlike the streamed-input branch.
assertThat(sent.bodyPublisher()).isPresent();
assertThat(sent.bodyPublisher().get().contentLength()).isPositive();
}
@Test
@DisplayName("the generated boundary in the header matches the one used in the body bytes")
void boundaryHeaderMatchesBody() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getContentType()).thenReturn("multipart/form-data");
Part textPart = textPart("k", "v");
when(request.getParts()).thenReturn(List.of(textPart));
service.forward("POST", "/v1/upload", request, false);
HttpRequest sent = captureSentRequest();
String contentType = header(sent, "Content-Type");
String boundary =
contentType.substring(contentType.indexOf("boundary=") + "boundary=".length());
String body = drainBody(sent.bodyPublisher().get());
assertThat(body).contains("--" + boundary);
assertThat(body).contains("--" + boundary + "--");
assertThat(body).contains("Content-Disposition: form-data; name=\"k\"").contains("v");
}
@Test
@DisplayName("a file part renders a filename in its Content-Disposition")
void filePartRendersFilename() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getContentType()).thenReturn("multipart/form-data");
Part filePart = filePart("file", "a.pdf", "application/pdf", "DATA");
when(request.getParts()).thenReturn(List.of(filePart));
service.forward("POST", "/v1/upload", request, false);
String body = drainBody(captureSentRequest().bodyPublisher().get());
assertThat(body)
.contains("Content-Disposition: form-data; name=\"file\"; filename=\"a.pdf\"")
.contains("Content-Type: application/pdf")
.contains("DATA");
}
@Test
@DisplayName("an empty parts collection still produces a valid closing boundary")
void emptyPartsClosesBoundary() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getContentType()).thenReturn("multipart/form-data");
when(request.getParts()).thenReturn(List.of());
service.forward("POST", "/v1/upload", request, false);
HttpRequest sent = captureSentRequest();
String contentType = header(sent, "Content-Type");
String boundary =
contentType.substring(contentType.indexOf("boundary=") + "boundary=".length());
// Closing delimiter line + the trailing empty writeLine each append CRLF.
assertThat(drainBody(sent.bodyPublisher().get()))
.isEqualTo("--" + boundary + "--\r\n\r\n");
}
@Test
@DisplayName("a getParts() failure is surfaced as IOException")
void getPartsFailureBecomesIoException() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(request.getContentType()).thenReturn("multipart/form-data");
when(request.getParts())
.thenThrow(new jakarta.servlet.ServletException("bad multipart"));
assertThatThrownBy(() -> service.forward("POST", "/v1/upload", request, false))
.isInstanceOf(IOException.class)
.hasMessageContaining("Failed to proxy multipart request");
// Failed before reaching the client: send() is never invoked.
verify(httpClient, never())
.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class));
}
}
@Nested
@DisplayName("response propagation and send delegation")
class SendDelegation {
@Test
@DisplayName("returns exactly the response produced by the underlying client")
void returnsClientResponse() throws Exception {
when(request.getQueryString()).thenReturn(null);
HttpResponse<InputStream> result = service.forward("GET", "/v1/x", request, false);
assertThat(result).isSameAs(response);
}
@Test
@DisplayName("an IOException from the client propagates to the caller")
void clientIoExceptionPropagates() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
.thenThrow(new IOException("connection refused"));
assertThatThrownBy(() -> service.forward("GET", "/v1/x", request, false))
.isInstanceOf(IOException.class)
.hasMessage("connection refused");
}
@Test
@DisplayName("an InterruptedException from the client propagates to the caller")
void clientInterruptedExceptionPropagates() throws Exception {
when(request.getQueryString()).thenReturn(null);
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
.thenThrow(new InterruptedException("interrupted"));
assertThatThrownBy(() -> service.forward("GET", "/v1/x", request, false))
.isInstanceOf(InterruptedException.class);
// Clear the interrupt flag the thrown InterruptedException may have left.
Thread.interrupted();
}
}
// --- helpers ------------------------------------------------------------------------------
private static Part textPart(String name, String value) throws IOException {
Part p = org.mockito.Mockito.mock(Part.class);
when(p.getName()).thenReturn(name);
when(p.getSubmittedFileName()).thenReturn(null);
when(p.getContentType()).thenReturn(null);
when(p.getInputStream())
.thenReturn(new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8)));
return p;
}
private static Part filePart(String name, String filename, String contentType, String value)
throws IOException {
Part p = org.mockito.Mockito.mock(Part.class);
when(p.getName()).thenReturn(name);
when(p.getSubmittedFileName()).thenReturn(filename);
when(p.getContentType()).thenReturn(contentType);
when(p.getInputStream())
.thenReturn(new ByteArrayInputStream(value.getBytes(StandardCharsets.UTF_8)));
return p;
}
private static String drainBody(HttpRequest.BodyPublisher publisher) {
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
java.util.concurrent.Flow.Subscriber<java.nio.ByteBuffer> subscriber =
new java.util.concurrent.Flow.Subscriber<>() {
@Override
public void onSubscribe(java.util.concurrent.Flow.Subscription s) {
s.request(Long.MAX_VALUE);
}
@Override
public void onNext(java.nio.ByteBuffer item) {
byte[] chunk = new byte[item.remaining()];
item.get(chunk);
out.write(chunk, 0, chunk.length);
}
@Override
public void onError(Throwable t) {
throw new RuntimeException(t);
}
@Override
public void onComplete() {}
};
publisher.subscribe(subscriber);
return out.toString(StandardCharsets.UTF_8);
}
/** Minimal ServletInputStream over a fixed byte array for streaming-body tests. */
private static ServletInputStream servletInputStream(byte[] data) {
ByteArrayInputStream delegate = new ByteArrayInputStream(data);
return new ServletInputStream() {
@Override
public int read() {
return delegate.read();
}
@Override
public boolean isFinished() {
return delegate.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(jakarta.servlet.ReadListener readListener) {
// no-op: synchronous reads only in tests
}
};
}
}
@@ -1,89 +0,0 @@
package stirling.software.saas.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.UUID;
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 stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.CreditService.CreditSummary;
/**
* Regression coverage for finding #15: API-key users used to always see empty credits because the
* controller blindly passed the API key string through to {@code getCreditSummaryBySupabaseId},
* which then blew up on {@code UUID.fromString}. The new code reads the User from the principal and
* prefers the linked Supabase ID, falling back to API-key-keyed credits.
*/
@ExtendWith(MockitoExtension.class)
class CreditControllerApiKeyTest {
@Mock private CreditService creditService;
@Test
void apiKeyUserWithSupabaseIdGetsResolvedToSupabaseLookup() {
UUID supabaseId = UUID.randomUUID();
User u = new User();
u.setSupabaseId(supabaseId);
CreditSummary expected = creditSummary(42, 100);
when(creditService.getCreditSummaryBySupabaseId(supabaseId.toString()))
.thenReturn(expected);
CreditController controller = new CreditController(creditService);
ApiKeyAuthenticationToken token =
new ApiKeyAuthenticationToken(u, "the-api-key", java.util.List.of());
ResponseEntity<CreditSummary> resp = controller.getUserCredits(token);
assertThat(resp.getBody()).isSameAs(expected);
verify(creditService).getCreditSummaryBySupabaseId(supabaseId.toString());
}
@Test
void apiKeyUserWithoutSupabaseIdFallsBackToApiKeyLookup() {
User u = new User();
// No supabaseId set — covers self-hosted / OSS-style API-only users.
CreditSummary expected = creditSummary(7, 25);
when(creditService.getCreditSummaryByApiKey("apikey-no-supabase")).thenReturn(expected);
CreditController controller = new CreditController(creditService);
ApiKeyAuthenticationToken token =
new ApiKeyAuthenticationToken(u, "apikey-no-supabase", java.util.List.of());
ResponseEntity<CreditSummary> resp = controller.getUserCredits(token);
assertThat(resp.getBody()).isSameAs(expected);
verify(creditService).getCreditSummaryByApiKey(eq("apikey-no-supabase"));
}
@Test
void apiKeyTokenWithoutUserPrincipalFallsBackToApiKeyLookup() {
// Edge: token wasn't constructed with a User principal. Should still attempt API-key
// lookup rather than throw.
CreditSummary expected = creditSummary(0, 0);
when(creditService.getCreditSummaryByApiKey("orphan-key")).thenReturn(expected);
CreditController controller = new CreditController(creditService);
ApiKeyAuthenticationToken token =
new ApiKeyAuthenticationToken("not-a-user", "orphan-key", java.util.List.of());
ResponseEntity<CreditSummary> resp = controller.getUserCredits(token);
assertThat(resp.getBody()).isNotNull();
verify(creditService).getCreditSummaryByApiKey("orphan-key");
}
private static CreditSummary creditSummary(int remaining, int allocated) {
return new CreditSummary(remaining, allocated, 0, 0, remaining, null, null, false);
}
}
@@ -0,0 +1,556 @@
package stirling.software.saas.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.security.Principal;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.model.SupabaseUser;
import stirling.software.saas.service.SaasUserAccountService;
import stirling.software.saas.service.SupabaseUserService;
/**
* Pure-Mockito unit tests for {@link UserRoleWebhookController}.
*
* <p>The controller is built via {@code @RequiredArgsConstructor}, so {@link InjectMocks} wires the
* three mocked collaborators ({@link UserService}, {@link SaasUserAccountService}, {@link
* SupabaseUserService}) by type. Each handler is invoked directly and the returned {@link
* ResponseEntity} (status + body) is asserted, alongside collaborator interaction verification. No
* Spring context, DB, Supabase or network is involved; {@code @PreAuthorize} is a no-op outside the
* security proxy so authorization is not exercised here.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class UserRoleWebhookControllerTest {
@Mock private UserService userService;
@Mock private SaasUserAccountService saasUserAccountService;
@Mock private SupabaseUserService supabaseUserService;
@InjectMocks private UserRoleWebhookController controller;
private static final String SUPABASE_ID = "11111111-2222-3333-4444-555555555555";
@Nested
@DisplayName("POST /upgrade")
class HandleUpgrade {
@Test
@DisplayName("returns 200 with 'upgraded' message when a promotion happened")
void upgraded() {
when(saasUserAccountService.handleUpgrade(SUPABASE_ID)).thenReturn(true);
ResponseEntity<String> response = controller.handleUpgrade(SUPABASE_ID);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isEqualTo("User upgraded to PRO successfully");
verify(saasUserAccountService).handleUpgrade(SUPABASE_ID);
}
@Test
@DisplayName("returns 200 with 'already PRO' message when nothing changed")
void alreadyPro() {
when(saasUserAccountService.handleUpgrade(SUPABASE_ID)).thenReturn(false);
ResponseEntity<String> response = controller.handleUpgrade(SUPABASE_ID);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isEqualTo("User is already PRO");
}
@Test
@DisplayName(
"maps IllegalArgumentException (bad/unknown supabaseId) to 400 'Invalid request'")
void illegalArgumentMapsTo400() {
when(saasUserAccountService.handleUpgrade(SUPABASE_ID))
.thenThrow(new IllegalArgumentException("Invalid Supabase ID format"));
ResponseEntity<String> response = controller.handleUpgrade(SUPABASE_ID);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).isEqualTo("Invalid request");
}
@Test
@DisplayName("maps any other exception to 500 'Error processing webhook'")
void unexpectedExceptionMapsTo500() {
when(saasUserAccountService.handleUpgrade(SUPABASE_ID))
.thenThrow(new RuntimeException("db down"));
ResponseEntity<String> response = controller.handleUpgrade(SUPABASE_ID);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(response.getBody()).isEqualTo("Error processing webhook");
}
}
@Nested
@DisplayName("POST /downgrade")
class HandleDowngrade {
@Test
@DisplayName("returns 200 with 'downgraded' message when a demotion happened")
void downgraded() {
when(saasUserAccountService.handleDowngrade(SUPABASE_ID)).thenReturn(true);
ResponseEntity<String> response = controller.handleDowngrade(SUPABASE_ID);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isEqualTo("User downgraded to FREE successfully");
verify(saasUserAccountService).handleDowngrade(SUPABASE_ID);
}
@Test
@DisplayName("returns 200 with 'already FREE' message when nothing changed")
void alreadyFree() {
when(saasUserAccountService.handleDowngrade(SUPABASE_ID)).thenReturn(false);
ResponseEntity<String> response = controller.handleDowngrade(SUPABASE_ID);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isEqualTo("User is already on FREE tier");
}
@Test
@DisplayName("maps IllegalArgumentException to 400 'Invalid request'")
void illegalArgumentMapsTo400() {
when(saasUserAccountService.handleDowngrade(SUPABASE_ID))
.thenThrow(new IllegalArgumentException("User not found for Supabase ID"));
ResponseEntity<String> response = controller.handleDowngrade(SUPABASE_ID);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).isEqualTo("Invalid request");
}
@Test
@DisplayName("maps any other exception to 500 'Error processing webhook'")
void unexpectedExceptionMapsTo500() {
when(saasUserAccountService.handleDowngrade(SUPABASE_ID))
.thenThrow(new RuntimeException("boom"));
ResponseEntity<String> response = controller.handleDowngrade(SUPABASE_ID);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(response.getBody()).isEqualTo("Error processing webhook");
}
}
@Nested
@DisplayName("POST /enable-metered-billing")
class EnableMeteredBilling {
@Test
@DisplayName("returns 200 'enabled' when metered billing is newly turned on")
void enabled() {
when(saasUserAccountService.enableMeteredBilling(SUPABASE_ID)).thenReturn(true);
ResponseEntity<String> response = controller.enableMeteredBilling(SUPABASE_ID);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isEqualTo("Metered billing enabled successfully");
verify(saasUserAccountService).enableMeteredBilling(SUPABASE_ID);
}
@Test
@DisplayName("returns 200 'already enabled' when no change was made")
void alreadyEnabled() {
when(saasUserAccountService.enableMeteredBilling(SUPABASE_ID)).thenReturn(false);
ResponseEntity<String> response = controller.enableMeteredBilling(SUPABASE_ID);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isEqualTo("User already has metered billing enabled");
}
@Test
@DisplayName("maps IllegalArgumentException to 400 'Invalid request'")
void illegalArgumentMapsTo400() {
when(saasUserAccountService.enableMeteredBilling(SUPABASE_ID))
.thenThrow(new IllegalArgumentException("Invalid Supabase ID format"));
ResponseEntity<String> response = controller.enableMeteredBilling(SUPABASE_ID);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).isEqualTo("Invalid request");
}
@Test
@DisplayName("maps any other exception to 500 'Error processing webhook'")
void unexpectedExceptionMapsTo500() {
when(saasUserAccountService.enableMeteredBilling(SUPABASE_ID))
.thenThrow(new RuntimeException("stripe down"));
ResponseEntity<String> response = controller.enableMeteredBilling(SUPABASE_ID);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(response.getBody()).isEqualTo("Error processing webhook");
}
}
@Nested
@DisplayName("POST /disable-metered-billing")
class DisableMeteredBilling {
@Test
@DisplayName("returns 200 'disabled' when metered billing is newly turned off")
void disabled() {
when(saasUserAccountService.disableMeteredBilling(SUPABASE_ID)).thenReturn(true);
ResponseEntity<String> response = controller.disableMeteredBilling(SUPABASE_ID);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isEqualTo("Metered billing disabled successfully");
verify(saasUserAccountService).disableMeteredBilling(SUPABASE_ID);
}
@Test
@DisplayName("returns 200 'does not have' when no change was made")
void notEnabled() {
when(saasUserAccountService.disableMeteredBilling(SUPABASE_ID)).thenReturn(false);
ResponseEntity<String> response = controller.disableMeteredBilling(SUPABASE_ID);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isEqualTo("User does not have metered billing enabled");
}
@Test
@DisplayName("maps IllegalArgumentException to 400 'Invalid request'")
void illegalArgumentMapsTo400() {
when(saasUserAccountService.disableMeteredBilling(SUPABASE_ID))
.thenThrow(new IllegalArgumentException("User not found for Supabase ID"));
ResponseEntity<String> response = controller.disableMeteredBilling(SUPABASE_ID);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).isEqualTo("Invalid request");
}
@Test
@DisplayName("maps any other exception to 500 'Error processing webhook'")
void unexpectedExceptionMapsTo500() {
when(saasUserAccountService.disableMeteredBilling(SUPABASE_ID))
.thenThrow(new RuntimeException("kaboom"));
ResponseEntity<String> response = controller.disableMeteredBilling(SUPABASE_ID);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(response.getBody()).isEqualTo("Error processing webhook");
}
}
@Nested
@DisplayName("POST /promptToAuthUser")
class PromptToAuthUser {
private static final String USERNAME = "anon-user";
private static final UUID LINKED_SUPABASE_ID =
UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
private Principal principal(String name) {
Principal p = org.mockito.Mockito.mock(Principal.class);
when(p.getName()).thenReturn(name);
return p;
}
private User anonymousUser(UUID supabaseId) {
User user = new User();
user.setUsername(USERNAME);
user.setSupabaseId(supabaseId);
user.setAuthenticationType(AuthenticationType.ANONYMOUS);
return user;
}
private SupabaseUser supabaseUserWithEmail(String email) {
SupabaseUser su = new SupabaseUser();
su.setId(LINKED_SUPABASE_ID);
su.setEmail(email);
su.setAnonymous(true);
return su;
}
@Test
@DisplayName("happy path: synchronizes upgrade and returns 200 with userId/email body")
void happyPath() {
User current = anonymousUser(LINKED_SUPABASE_ID);
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
SupabaseUser supabaseUser = supabaseUserWithEmail("new@stirling.com");
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
User upgraded = new User();
upgraded.setId(42L);
upgraded.setEmail("new@stirling.com");
upgraded.setUsername("new@stirling.com");
when(saasUserAccountService.synchronizeUserUpgrade(
supabaseUser, "new@stirling.com", "google"))
.thenReturn(upgraded);
ResponseEntity<Map<String, String>> response =
controller.promptToAuthUser("google", principal(USERNAME));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody())
.containsEntry("message", "User upgrade synchronized successfully")
.containsEntry("userId", "42")
.containsEntry("email", "new@stirling.com");
}
@Test
@DisplayName("normalizes auth method to lowercase/trimmed before delegating")
void normalizesAuthMethod() {
User current = anonymousUser(LINKED_SUPABASE_ID);
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
SupabaseUser supabaseUser = supabaseUserWithEmail("a@b.com");
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
User upgraded = new User();
upgraded.setId(7L);
upgraded.setEmail("a@b.com");
when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), anyString()))
.thenReturn(upgraded);
ResponseEntity<Map<String, String>> response =
controller.promptToAuthUser(" GitHub ", principal(USERNAME));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
ArgumentCaptor<String> methodCaptor = ArgumentCaptor.forClass(String.class);
verify(saasUserAccountService)
.synchronizeUserUpgrade(
eq(supabaseUser), eq("a@b.com"), methodCaptor.capture());
assertThat(methodCaptor.getValue()).isEqualTo("github");
}
@Test
@DisplayName("null authMethod is accepted and passed through as null")
void nullAuthMethodAccepted() {
User current = anonymousUser(LINKED_SUPABASE_ID);
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
SupabaseUser supabaseUser = supabaseUserWithEmail("a@b.com");
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
User upgraded = new User();
upgraded.setId(7L);
upgraded.setEmail("a@b.com");
when(saasUserAccountService.synchronizeUserUpgrade(
eq(supabaseUser), eq("a@b.com"), eq(null)))
.thenReturn(upgraded);
ResponseEntity<Map<String, String>> response =
controller.promptToAuthUser(null, principal(USERNAME));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(saasUserAccountService).synchronizeUserUpgrade(supabaseUser, "a@b.com", null);
}
@Test
@DisplayName("falls back to username in body when upgraded user has no email")
void emailFallsBackToUsername() {
User current = anonymousUser(LINKED_SUPABASE_ID);
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
SupabaseUser supabaseUser = supabaseUserWithEmail("canon@b.com");
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
User upgraded = new User();
upgraded.setId(9L);
upgraded.setEmail(null);
upgraded.setUsername("fallback-username");
when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), any()))
.thenReturn(upgraded);
ResponseEntity<Map<String, String>> response =
controller.promptToAuthUser("email", principal(USERNAME));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("email", "fallback-username");
}
@Test
@DisplayName("invalid auth method returns 400 without touching userService")
void invalidAuthMethodRejected() {
ResponseEntity<Map<String, String>> response =
controller.promptToAuthUser("myspace", principal(USERNAME));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).containsEntry("error", "Invalid authentication method");
verifyNoInteractions(userService);
verifyNoInteractions(saasUserAccountService);
}
@Test
@DisplayName("unknown current user (IllegalStateException) maps to 404 'User not found'")
void currentUserNotFound() {
when(userService.findByUsername(USERNAME)).thenReturn(Optional.empty());
ResponseEntity<Map<String, String>> response =
controller.promptToAuthUser("email", principal(USERNAME));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(response.getBody()).containsEntry("error", "User not found");
verify(saasUserAccountService, never()).synchronizeUserUpgrade(any(), any(), any());
}
@Test
@DisplayName("current user without a linked Supabase ID returns 400")
void noLinkedSupabaseId() {
User current = anonymousUser(null);
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
ResponseEntity<Map<String, String>> response =
controller.promptToAuthUser("email", principal(USERNAME));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody())
.containsEntry("error", "No Supabase account linked to current user");
verifyNoInteractions(supabaseUserService);
}
@Test
@DisplayName("non-anonymous user is rejected with 400")
void nonAnonymousRejected() {
User current = anonymousUser(LINKED_SUPABASE_ID);
current.setAuthenticationType(AuthenticationType.WEB);
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
when(supabaseUserService.getUser(LINKED_SUPABASE_ID))
.thenReturn(supabaseUserWithEmail("x@y.com"));
ResponseEntity<Map<String, String>> response =
controller.promptToAuthUser("email", principal(USERNAME));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody())
.containsEntry("error", "Only anonymous users can be upgraded");
verify(saasUserAccountService, never()).synchronizeUserUpgrade(any(), any(), any());
}
@Test
@DisplayName("falls back to local user email when Supabase email is blank")
void canonicalEmailFallsBackToLocal() {
User current = anonymousUser(LINKED_SUPABASE_ID);
current.setEmail("local@stirling.com");
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
SupabaseUser supabaseUser = supabaseUserWithEmail(" "); // blank
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
User upgraded = new User();
upgraded.setId(5L);
upgraded.setEmail("local@stirling.com");
when(saasUserAccountService.synchronizeUserUpgrade(
eq(supabaseUser), eq("local@stirling.com"), anyString()))
.thenReturn(upgraded);
ResponseEntity<Map<String, String>> response =
controller.promptToAuthUser("email", principal(USERNAME));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(saasUserAccountService)
.synchronizeUserUpgrade(supabaseUser, "local@stirling.com", "email");
}
@Test
@DisplayName("no email anywhere (Supabase and local both blank) returns 400")
void noEmailAnywhere() {
User current = anonymousUser(LINKED_SUPABASE_ID);
current.setEmail(null);
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
SupabaseUser supabaseUser = supabaseUserWithEmail(null);
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
ResponseEntity<Map<String, String>> response =
controller.promptToAuthUser("email", principal(USERNAME));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody())
.containsEntry("error", "No email associated with user account");
verify(saasUserAccountService, never()).synchronizeUserUpgrade(any(), any(), any());
}
@Test
@DisplayName("unexpected RuntimeException from sync maps to 500")
void unexpectedExceptionMapsTo500() {
User current = anonymousUser(LINKED_SUPABASE_ID);
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
SupabaseUser supabaseUser = supabaseUserWithEmail("a@b.com");
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), any()))
.thenThrow(new RuntimeException("db down"));
ResponseEntity<Map<String, String>> response =
controller.promptToAuthUser("email", principal(USERNAME));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(response.getBody())
.containsEntry("error", "Failed to synchronize user upgrade");
}
@Test
@DisplayName("getUser throwing (Supabase row missing) maps to 500")
void supabaseUserMissingMapsTo500() {
User current = anonymousUser(LINKED_SUPABASE_ID);
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
when(supabaseUserService.getUser(LINKED_SUPABASE_ID))
.thenThrow(new RuntimeException("Supabase user not found"));
ResponseEntity<Map<String, String>> response =
controller.promptToAuthUser("email", principal(USERNAME));
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(response.getBody())
.containsEntry("error", "Failed to synchronize user upgrade");
}
@Test
@DisplayName("all allowed auth methods are accepted (none rejected as invalid)")
void allowedAuthMethodsAccepted() {
for (String method :
new String[] {
"email", "oauth", "google", "github", "apple", "azure", "linkedin_oidc"
}) {
User current = anonymousUser(LINKED_SUPABASE_ID);
when(userService.findByUsername(USERNAME)).thenReturn(Optional.of(current));
SupabaseUser supabaseUser = supabaseUserWithEmail("a@b.com");
when(supabaseUserService.getUser(LINKED_SUPABASE_ID)).thenReturn(supabaseUser);
User upgraded = new User();
upgraded.setId(1L);
upgraded.setEmail("a@b.com");
when(saasUserAccountService.synchronizeUserUpgrade(any(), anyString(), anyString()))
.thenReturn(upgraded);
ResponseEntity<Map<String, String>> response =
controller.promptToAuthUser(method, principal(USERNAME));
assertThat(response.getStatusCode())
.as("method %s should be accepted", method)
.isEqualTo(HttpStatus.OK);
}
}
}
}

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