Compare commits

...
Author SHA1 Message Date
EthanHealy01andGitHub e9f0b82f05 Merge branch 'main' into UIUX/RemoveLegacyLogo 2026-06-17 18:32:14 +01:00
James BruntonandGitHub 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
EthanHealy01andGitHub bdd20e7606 Merge branch 'main' into UIUX/RemoveLegacyLogo 2026-06-17 16:54:46 +01:00
EthanHealy01andGitHub 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
EthanHealy01 87d493b84a fix(desktop): use modern logo for Tauri app/installer icons
Regenerate the full src-tauri icon set (icns, ico, PNG sizes, Windows
Square/Store tiles) from the modern logo (logo512) so the desktop app and
macOS DMG no longer show the legacy 'S' icon.
2026-06-17 15:32:21 +01:00
ConnorYohandGitHub 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
ConnorYohandGitHub 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
EthanHealy01 f7ea620952 Merge branch 'main' into UIUX/RemoveLegacyLogo 2026-06-17 15:05:55 +01:00
Anthony StirlingandGitHub 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 StirlingandGitHub 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 StirlingandGitHub 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
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 BruntonandGitHub 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
EthanHealy01andGitHub 5591237381 Merge branch 'main' into UIUX/RemoveLegacyLogo 2026-06-16 19:00:32 +01:00
EthanHealy01andGitHub 6df59833a9 Merge branch 'main' into UIUX/RemoveLegacyLogo 2026-06-16 17:57:46 +01:00
EthanHealy01 0f604c20d2 remove legacy logo from the app 2026-06-16 17:08:55 +01:00
330 changed files with 12269 additions and 11424 deletions
+1 -3
View File
@@ -48,11 +48,9 @@ app/core/src/main/resources/static/assets/
app/core/src/main/resources/static/index.html
app/core/src/main/resources/static/locales/
app/core/src/main/resources/static/Login/
app/core/src/main/resources/static/classic-logo/
app/core/src/main/resources/static/modern-logo/
app/core/src/main/resources/static/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/robots.txt
app/core/src/main/resources/static/pdfium/
app/core/src/main/resources/static/pdfjs/
+19
View File
@@ -262,6 +262,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 +299,7 @@ tasks:
- task: typecheck:proprietary
- task: typecheck:saas
- task: typecheck:desktop
- task: typecheck:cloud
- task: typecheck:scripts
- task: typecheck:prototypes
- task: typecheck:portal
@@ -367,3 +374,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]
+25 -1
View File
@@ -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
View File
@@ -171,4 +171,5 @@ tasks:
desc: "Clean all build artifacts"
cmds:
- task: backend:clean
- task: frontend:clean
- task: engine:clean
@@ -1132,7 +1132,6 @@ public class ApplicationProperties {
public static class Ui {
private String appNameNavbar;
private List<String> languages;
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
private boolean defaultHideUnavailableTools = false;
private boolean defaultHideUnavailableConversions = false;
private HideDisabledTools hideDisabledTools = new HideDisabledTools();
@@ -1141,14 +1140,6 @@ public class ApplicationProperties {
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
}
public String getLogoStyle() {
// Validate and return either "modern" or "classic"
if ("modern".equalsIgnoreCase(logoStyle)) {
return "modern";
}
return "classic"; // default
}
@Data
public static class HideDisabledTools {
private boolean googleDrive = false;
@@ -31,8 +31,7 @@ public class RequestUriUtils {
|| normalizedUri.startsWith("/locales/")
|| normalizedUri.startsWith("/Login/")
|| normalizedUri.startsWith("/samples/")
|| normalizedUri.startsWith("/classic-logo/")
|| normalizedUri.startsWith("/modern-logo/")
|| normalizedUri.startsWith("/logo/")
|| normalizedUri.startsWith("/og_images/")) {
return true;
}
@@ -42,7 +41,6 @@ public class RequestUriUtils {
|| "/favicon.ico".equals(normalizedUri)
|| "/manifest.json".equals(normalizedUri)
|| "/site.webmanifest".equals(normalizedUri)
|| "/manifest-classic.json".equals(normalizedUri)
|| "/index.html".equals(normalizedUri)) {
return true;
}
+1 -5
View File
@@ -218,13 +218,9 @@ def generatedFrontendPaths = [
'site.webmanifest.br',
'browserconfig.xml.gz',
'browserconfig.xml.br',
'manifest-classic.json',
'manifest-classic.json.gz',
'manifest-classic.json.br',
'locales',
'Login',
'classic-logo',
'modern-logo',
'logo',
'og_images',
'samples',
'pdfium',
@@ -81,8 +81,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
"/mstile-*.png",
"/safari-pinned-tab.svg",
"/icons/**",
"/modern-logo/**",
"/classic-logo/**",
"/logo/**",
"/robots.txt",
"/3rdPartyLicenses.json",
"/pdfjs/**",
@@ -94,8 +93,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
"/vendor/**",
"/samples/**",
"/og_images/**",
"/Login/**",
"/manifest-classic.json")
"/Login/**")
.addResourceLocations(
staticPath,
"classpath:/static/",
@@ -121,10 +119,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
"classpath:/static/Login/",
staticPath + "icons/",
"classpath:/static/icons/",
staticPath + "modern-logo/",
"classpath:/static/modern-logo/",
staticPath + "classic-logo/",
"classpath:/static/classic-logo/")
staticPath + "logo/",
"classpath:/static/logo/")
.setCacheControl(
CacheControl.maxAge(Duration.ofDays(1))
.cachePublic()
@@ -208,7 +208,6 @@ public class ConfigController {
// Extract values from ApplicationProperties
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("languages", applicationProperties.getUi().getLanguages());
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
configData.put("defaultLocale", applicationProperties.getSystem().getDefaultLocale());
// User preference defaults
@@ -203,13 +203,13 @@ public class ReactRoutingController {
// (Real storage endpoints live under /api/v1/storage/files, already
// excluded by the leading `api` token in the same regex.)
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|logo|Login|og_images|samples)[^\\.]*$}")
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
return serveIndexHtml(request);
}
@GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|pdfium|vendor|fonts|images|css|js|assets|locales|logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
throws IOException {
return serveIndexHtml(request);
@@ -301,7 +301,6 @@ autoPipeline:
ui:
appNameNavbar: "" # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
languages: [] # If empty, all languages are enabled. To restrict to specific languages, use a whitelist like ["de_DE", "pl_PL", "sv_SE"]. Empty list or not restricting any languages will enable all available languages.
defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out
defaultHideUnavailableConversions: false # Default user preference: hide disabled conversion options instead of greying them out
@@ -3,17 +3,17 @@
"name": "Stirling PDF",
"icons": [
{
"src": "modern-logo/favicon.ico",
"src": "logo/favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "modern-logo/logo192.png",
"src": "logo/logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "modern-logo/logo512.png",
"src": "logo/logo512.png",
"type": "image/png",
"sizes": "512x512"
}
+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());
}
}
}
@@ -36,6 +36,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;
@@ -73,6 +74,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 +145,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)
@@ -81,13 +81,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 +117,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 +131,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);
@@ -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());
}
}
@@ -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");
}
}
@@ -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;
}
@@ -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,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,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);
}
}
@@ -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;
@@ -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);
}
@@ -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;
}
@@ -381,16 +378,6 @@ 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);
@@ -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: {}."
@@ -288,7 +297,7 @@ public class SupabaseSecurityConfig {
"Accept",
"Origin",
"X-API-KEY"));
cfg.setExposedHeaders(List.of("WWW-Authenticate", "X-Credits-Remaining"));
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
@@ -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;
@@ -100,9 +91,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 +771,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,
@@ -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);
}
}
}
}
@@ -174,7 +174,7 @@ class JobChargeServiceTest {
assertThat(row.getJobId()).isEqualTo(newJob.getId());
assertThat(row.getPolicyId()).isEqualTo(policy.getId());
assertThat(row.getPaygUnits()).isEqualTo(4);
// Legacy comparison not wired yet — zeroed until CreditService is wired in the follow-up.
// Legacy comparison removed with the legacy credit engine — always zeroed.
assertThat(row.getLegacyCreditsCharged()).isZero();
assertThat(row.getDiffPct()).isZero();
// PAYG analytics axis: billing_category + job_source are copied from the context so the
@@ -26,9 +26,8 @@ import stirling.software.saas.payg.policy.admin.PolicyDtos.PolicyResponse;
import stirling.software.saas.payg.policy.admin.PolicyDtos.TeamOverrideRequest;
/**
* Tests {@link PricingPolicyAdminController} as a plain Java unit (matching {@code
* CreditControllerApiKeyTest}'s style — no MockMvc layer). Covers happy paths and the controller's
* error mapping (4xx for validation, 404 for missing rows).
* Tests {@link PricingPolicyAdminController} as a plain Java unit (no MockMvc layer). Covers happy
* paths and the controller's error mapping (4xx for validation, 404 for missing rows).
*/
@ExtendWith(MockitoExtension.class)
class PricingPolicyAdminControllerTest {
@@ -46,7 +46,6 @@ class SupabaseAuthenticationFilterTest {
@Mock private TeamService teamService;
@Mock private UserService userService;
@Mock private SupabaseUserService supabaseUserService;
@Mock private stirling.software.saas.service.CreditService creditService;
@Mock private stirling.software.saas.service.SaasTeamService saasTeamService;
@Mock private JwtDecoder jwtDecoder;
@@ -60,12 +59,7 @@ class SupabaseAuthenticationFilterTest {
SecurityContextHolder.clearContext();
filter =
new SupabaseAuthenticationFilter(
teamService,
userService,
supabaseUserService,
creditService,
saasTeamService,
jwtDecoder);
teamService, userService, supabaseUserService, saasTeamService, jwtDecoder);
request = new MockHttpServletRequest();
response = new MockHttpServletResponse();
chain = new MockFilterChain();
@@ -0,0 +1,351 @@
package stirling.software.saas.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.stream.Stream;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.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 stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.saas.repository.SupabaseUserRepository;
/**
* Unit tests for {@link AnonymousUserCleanupService}.
*
* <p>The service is a {@code @Scheduled} cleanup job. Its three {@code @Value} fields ({@code
* anonEnabled}, {@code retentionDays}, {@code batchSize}) are field-injected, so each scenario
* primes them with {@link ReflectionTestUtils}. The {@code cleanup()} method is invoked directly
* (no Spring scheduler). Both repositories return id {@link Stream}s that the service partitions
* into fixed-size batches via {@code Collectors.groupingBy}, then deletes each batch.
*
* <p>Important: the streams are consumed inside try-with-resources, so the SupabaseUser stream is a
* {@code Stream<UUID>} and the User stream is a {@code Stream<Long>}; each stub must return a fresh
* stream (a {@code Stream} is single-use).
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AnonymousUserCleanupServiceTest {
@Mock private UserRepository userRepository;
@Mock private SupabaseUserRepository supabaseUserRepository;
private AnonymousUserCleanupService service;
private AnonymousUserCleanupService newService(
boolean anonEnabled, int retentionDays, int batchSize) {
AnonymousUserCleanupService s =
new AnonymousUserCleanupService(userRepository, supabaseUserRepository);
ReflectionTestUtils.setField(s, "anonEnabled", anonEnabled);
ReflectionTestUtils.setField(s, "retentionDays", retentionDays);
ReflectionTestUtils.setField(s, "batchSize", batchSize);
return s;
}
private static List<UUID> uuids(int n) {
List<UUID> out = new ArrayList<>(n);
for (int i = 0; i < n; i++) {
out.add(UUID.randomUUID());
}
return out;
}
private static List<Long> longs(int n) {
List<Long> out = new ArrayList<>(n);
for (long i = 0; i < n; i++) {
out.add(i);
}
return out;
}
@Nested
@DisplayName("guard clauses - no work performed")
class Guards {
@Test
@DisplayName("anonymous auth disabled: returns early, touches neither repository")
void anonDisabled_noop() {
service = newService(false, 30, 100);
service.cleanup();
verifyNoInteractions(userRepository, supabaseUserRepository);
}
@Test
@DisplayName("retentionDays == 0: returns early, touches neither repository")
void zeroRetention_noop() {
service = newService(true, 0, 100);
service.cleanup();
verifyNoInteractions(userRepository, supabaseUserRepository);
}
@Test
@DisplayName("negative retentionDays: returns early, touches neither repository")
void negativeRetention_noop() {
service = newService(true, -5, 100);
service.cleanup();
verifyNoInteractions(userRepository, supabaseUserRepository);
}
}
@Nested
@DisplayName("cutoff date derivation")
class CutoffDate {
@Test
@DisplayName("queries both repositories with a cutoff ~retentionDays before now")
void cutoffIsRetentionDaysBeforeNow() {
service = newService(true, 30, 100);
when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
any(LocalDateTime.class)))
.thenReturn(Stream.empty());
when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
.thenReturn(Stream.empty());
LocalDateTime expectedLower = LocalDateTime.now().minusDays(30).minusMinutes(1);
LocalDateTime expectedUpper = LocalDateTime.now().minusDays(30).plusMinutes(1);
service.cleanup();
ArgumentCaptor<LocalDateTime> supaCutoff = ArgumentCaptor.forClass(LocalDateTime.class);
verify(supabaseUserRepository)
.findByCreatedAtBeforeAndIsAnonymousTrue(supaCutoff.capture());
assertThat(supaCutoff.getValue()).isBetween(expectedLower, expectedUpper);
ArgumentCaptor<LocalDateTime> userCutoff = ArgumentCaptor.forClass(LocalDateTime.class);
verify(userRepository).findByUsernameIsNullAndCreatedAtBefore(userCutoff.capture());
assertThat(userCutoff.getValue()).isBetween(expectedLower, expectedUpper);
}
@Test
@DisplayName("both repositories receive the same cutoff instant")
void sameCutoffForBothRepositories() {
service = newService(true, 7, 100);
when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
any(LocalDateTime.class)))
.thenReturn(Stream.empty());
when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
.thenReturn(Stream.empty());
service.cleanup();
ArgumentCaptor<LocalDateTime> supaCutoff = ArgumentCaptor.forClass(LocalDateTime.class);
verify(supabaseUserRepository)
.findByCreatedAtBeforeAndIsAnonymousTrue(supaCutoff.capture());
ArgumentCaptor<LocalDateTime> userCutoff = ArgumentCaptor.forClass(LocalDateTime.class);
verify(userRepository).findByUsernameIsNullAndCreatedAtBefore(userCutoff.capture());
assertThat(supaCutoff.getValue()).isEqualTo(userCutoff.getValue());
}
}
@Nested
@DisplayName("empty result sets")
class EmptyStreams {
@Test
@DisplayName("no stale users: queries run but no batch delete is issued")
void noStaleUsers_noDeletes() {
service = newService(true, 30, 100);
when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
any(LocalDateTime.class)))
.thenReturn(Stream.empty());
when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
.thenReturn(Stream.empty());
service.cleanup();
verify(supabaseUserRepository)
.findByCreatedAtBeforeAndIsAnonymousTrue(any(LocalDateTime.class));
verify(userRepository).findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class));
verify(supabaseUserRepository, never()).deleteAllByIdInBatch(any());
verify(userRepository, never()).deleteAllByIdInBatch(any());
}
}
@Nested
@DisplayName("single-batch deletion")
class SingleBatch {
@Test
@DisplayName("count below batch size deletes everything in one batch per repository")
void belowBatchSize_singleDelete() {
service = newService(true, 30, 100);
List<UUID> supaIds = uuids(3);
List<Long> userIds = longs(5);
when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
any(LocalDateTime.class)))
.thenReturn(supaIds.stream());
when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
.thenReturn(userIds.stream());
service.cleanup();
verify(supabaseUserRepository, times(1)).deleteAllByIdInBatch(supaIds);
verify(userRepository, times(1)).deleteAllByIdInBatch(userIds);
}
@Test
@DisplayName("count exactly equal to batch size still produces exactly one batch")
void exactlyBatchSize_singleDelete() {
service = newService(true, 30, 4);
List<UUID> supaIds = uuids(4);
when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
any(LocalDateTime.class)))
.thenReturn(supaIds.stream());
when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
.thenReturn(Stream.empty());
service.cleanup();
verify(supabaseUserRepository, times(1)).deleteAllByIdInBatch(supaIds);
}
}
@Nested
@DisplayName("multi-batch partitioning")
class MultiBatch {
@Test
@DisplayName("supabase ids split into fixed-size batches with a final remainder batch")
void supabaseIdsPartitioned() {
service = newService(true, 30, 2);
// 5 ids, batch 2 -> batches of [2,2,1]
List<UUID> supaIds = uuids(5);
when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
any(LocalDateTime.class)))
.thenReturn(supaIds.stream());
when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
.thenReturn(Stream.empty());
service.cleanup();
@SuppressWarnings("unchecked")
ArgumentCaptor<List<UUID>> captor = ArgumentCaptor.forClass(List.class);
verify(supabaseUserRepository, times(3)).deleteAllByIdInBatch(captor.capture());
List<List<UUID>> batches = captor.getAllValues();
// Two full batches and one remainder batch (order of values() not asserted on).
assertThat(batches).extracting(List::size).containsExactlyInAnyOrder(2, 2, 1);
// Every id is deleted exactly once across all batches.
assertThat(batches.stream().flatMap(List::stream))
.containsExactlyInAnyOrderElementsOf(supaIds);
}
@Test
@DisplayName("user ids split into fixed-size batches; even multiple yields no remainder")
void userIdsPartitionedEvenly() {
service = newService(true, 30, 3);
// 6 ids, batch 3 -> batches of [3,3]
List<Long> userIds = longs(6);
when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
any(LocalDateTime.class)))
.thenReturn(Stream.empty());
when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
.thenReturn(userIds.stream());
service.cleanup();
@SuppressWarnings("unchecked")
ArgumentCaptor<List<Long>> captor = ArgumentCaptor.forClass(List.class);
verify(userRepository, times(2)).deleteAllByIdInBatch(captor.capture());
List<List<Long>> batches = captor.getAllValues();
assertThat(batches).extracting(List::size).containsExactlyInAnyOrder(3, 3);
// Within a batch, encounter order is preserved by groupingBy.
assertThat(batches).anySatisfy(b -> assertThat(b).containsExactly(0L, 1L, 2L));
assertThat(batches).anySatisfy(b -> assertThat(b).containsExactly(3L, 4L, 5L));
}
@Test
@DisplayName("batch size of 1 produces one delete call per id")
void batchSizeOne_oneDeletePerId() {
service = newService(true, 30, 1);
List<Long> userIds = longs(4);
when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
any(LocalDateTime.class)))
.thenReturn(Stream.empty());
when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
.thenReturn(userIds.stream());
service.cleanup();
@SuppressWarnings("unchecked")
ArgumentCaptor<List<Long>> captor = ArgumentCaptor.forClass(List.class);
verify(userRepository, times(4)).deleteAllByIdInBatch(captor.capture());
assertThat(captor.getAllValues()).extracting(List::size).containsExactly(1, 1, 1, 1);
}
}
@Nested
@DisplayName("both repositories are cleaned in one run")
class BothRepositories {
@Test
@DisplayName("supabase users are processed before legacy users, each in their own batches")
void supabaseThenUsers() {
service = newService(true, 30, 2);
List<UUID> supaIds = uuids(3); // [2,1]
List<Long> userIds = longs(3); // [2,1]
when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
any(LocalDateTime.class)))
.thenReturn(supaIds.stream());
when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
.thenReturn(userIds.stream());
service.cleanup();
verify(supabaseUserRepository, times(2)).deleteAllByIdInBatch(any());
verify(userRepository, times(2)).deleteAllByIdInBatch(any());
}
}
@Nested
@DisplayName("stream lifecycle")
class StreamLifecycle {
@Test
@DisplayName("each streamed result is closed via try-with-resources")
void streamsAreClosed() {
service = newService(true, 30, 100);
boolean[] supaClosed = {false};
boolean[] userClosed = {false};
Stream<UUID> supaStream = uuids(2).stream().onClose(() -> supaClosed[0] = true);
Stream<Long> userStream = longs(2).stream().onClose(() -> userClosed[0] = true);
when(supabaseUserRepository.findByCreatedAtBeforeAndIsAnonymousTrue(
any(LocalDateTime.class)))
.thenReturn(supaStream);
when(userRepository.findByUsernameIsNullAndCreatedAtBefore(any(LocalDateTime.class)))
.thenReturn(userStream);
service.cleanup();
assertThat(supaClosed[0]).as("supabase id stream closed").isTrue();
assertThat(userClosed[0]).as("user id stream closed").isTrue();
}
}
}
@@ -0,0 +1,586 @@
package stirling.software.saas.service;
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.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.sql.SQLException;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
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.UserErrorTracker;
import stirling.software.saas.repository.UserErrorTrackerRepository;
import stirling.software.saas.service.ErrorTrackingService.ErrorInfo;
/**
* Unit tests for {@link ErrorTrackingService}.
*
* <p>The service has two distinct tracking paths chosen at construction time based on {@link
* CreditsProperties.Cache#isLocalEnabled()}: an in-memory Caffeine cache path and a direct-to-DB
* fallback. Because the cache field is final and decided in the constructor, each path is exercised
* by building the service with a tailored {@link CreditsProperties}. Defaults are {@code
* freeProcessingErrors = 2} and {@code ttlMinutes = 60}.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ErrorTrackingServiceTest {
@Mock private UserErrorTrackerRepository errorTrackerRepository;
@Mock private UserRepository userRepository;
private static final String API_KEY =
"test-api-key-0001"; // gitleaks:allow - test fixture, not a secret
private static final String ENDPOINT = "/api/v1/convert/pdf-to-img";
/** Build a CreditsProperties with the given cache + error config. */
private static CreditsProperties props(
boolean localCacheEnabled, int freeProcessingErrors, int ttlMinutes) {
CreditsProperties p = new CreditsProperties();
p.getCache().setLocalEnabled(localCacheEnabled);
p.getErrors().setFreeProcessingErrors(freeProcessingErrors);
p.getErrors().setTtlMinutes(ttlMinutes);
return p;
}
private ErrorTrackingService cachedService(int freeProcessingErrors) {
return new ErrorTrackingService(
errorTrackerRepository, userRepository, props(true, freeProcessingErrors, 60));
}
private ErrorTrackingService dbService(int freeProcessingErrors) {
return new ErrorTrackingService(
errorTrackerRepository, userRepository, props(false, freeProcessingErrors, 60));
}
private static User user(String username, String apiKey) {
User u = new User();
u.setUsername(username);
u.setApiKey(apiKey);
return u;
}
/**
* A throwable that classifies as PROCESSING_ERROR when paired with httpStatus 200 and a
* non-null endpoint: not a validation/system error, so the endpoint-based processing branch
* wins.
*/
private static Throwable processingThrowable() {
return new RuntimeException("corrupt pdf stream while rendering page");
}
@Nested
@DisplayName("recordErrorAndShouldConsumeCredit - error classification gate")
class ClassificationGate {
@Test
@DisplayName("validation error (400) never charges and never touches DB or cache")
void validationError_doesNotCharge() {
ErrorTrackingService service = cachedService(2);
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY,
ENDPOINT,
new IllegalArgumentException("missing parameter"),
400);
assertThat(charge).isFalse();
verify(errorTrackerRepository, never()).save(any());
verifyNoInteractions(userRepository);
}
@Test
@DisplayName("auth error (401) classifies as validation and never charges")
void authError_doesNotCharge() {
ErrorTrackingService service = cachedService(2);
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, new RuntimeException("denied"), 401);
assertThat(charge).isFalse();
verify(errorTrackerRepository, never()).save(any());
}
@Test
@DisplayName("system error (500) never charges")
void systemError_doesNotCharge() {
ErrorTrackingService service = cachedService(2);
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, new RuntimeException("server boom"), 500);
assertThat(charge).isFalse();
verify(errorTrackerRepository, never()).save(any());
}
@Test
@DisplayName("system exception type (SQLException) at 200 never charges")
void systemExceptionType_doesNotCharge() {
ErrorTrackingService service = cachedService(2);
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, new SQLException("db down"), 200);
assertThat(charge).isFalse();
verify(errorTrackerRepository, never()).save(any());
}
}
@Nested
@DisplayName("recordErrorAndShouldConsumeCredit - cache path (freeProcessingErrors=2)")
class CachePath {
@Test
@DisplayName("first two processing errors are free; the 3rd charges")
void firstTwoFree_thirdCharges() {
ErrorTrackingService service = cachedService(2);
User u = user("alice", API_KEY);
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
.thenReturn(Optional.empty());
// count=1 -> not > 2
assertThat(call(service)).isFalse();
// count=2 -> not > 2
assertThat(call(service)).isFalse();
// count=3 -> > 2 -> charge, and threshold-crossing persists to DB
assertThat(call(service)).isTrue();
// count=4 -> still charges, but no second persist (only on the exact crossing)
assertThat(call(service)).isTrue();
// Persisted exactly once: on the threshold-crossing call (count == free + 1).
verify(errorTrackerRepository, times(1)).save(any(UserErrorTracker.class));
}
@Test
@DisplayName("threshold-crossing persists a tracker set to free+1 with future resetAfter")
void thresholdCrossing_persistsTrackerWithCorrectCount() {
ErrorTrackingService service = cachedService(2);
User u = user("bob", API_KEY);
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
.thenReturn(Optional.empty());
LocalDateTime before = LocalDateTime.now();
call(service); // 1
call(service); // 2
call(service); // 3 -> persist
ArgumentCaptor<UserErrorTracker> captor =
ArgumentCaptor.forClass(UserErrorTracker.class);
verify(errorTrackerRepository).save(captor.capture());
UserErrorTracker saved = captor.getValue();
assertThat(saved.getProcessingErrorCount()).isEqualTo(3); // free(2) + 1
assertThat(saved.getUser()).isSameAs(u);
assertThat(saved.getEndpoint()).isEqualTo(ENDPOINT);
assertThat(saved.getLastProcessingError()).isNotNull();
assertThat(saved.getResetAfter()).isAfter(before);
}
@Test
@DisplayName("zero free errors: first processing error charges immediately")
void zeroFree_firstErrorCharges() {
ErrorTrackingService service = cachedService(0);
User u = user("carol", API_KEY);
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
.thenReturn(Optional.empty());
// count=1 > free(0) -> charge, and 1 == free+1 -> persist
assertThat(call(service)).isTrue();
verify(errorTrackerRepository, times(1)).save(any(UserErrorTracker.class));
}
@Test
@DisplayName("distinct endpoints are tracked independently in the cache")
void distinctEndpoints_trackedSeparately() {
ErrorTrackingService service = cachedService(2);
// Two errors on endpoint A, two on endpoint B -> neither crosses the free=2 threshold.
assertThat(
service.recordErrorAndShouldConsumeCredit(
API_KEY, "/a", processingThrowable(), 200))
.isFalse();
assertThat(
service.recordErrorAndShouldConsumeCredit(
API_KEY, "/a", processingThrowable(), 200))
.isFalse();
assertThat(
service.recordErrorAndShouldConsumeCredit(
API_KEY, "/b", processingThrowable(), 200))
.isFalse();
assertThat(
service.recordErrorAndShouldConsumeCredit(
API_KEY, "/b", processingThrowable(), 200))
.isFalse();
// Neither key crossed free+1, so no DB persistence at all.
verify(errorTrackerRepository, never()).save(any());
}
@Test
@DisplayName("cache path does not blow up if the user is absent at persist time")
void cachePersist_userAbsent_swallowsAndStillCharges() {
ErrorTrackingService service = cachedService(2);
// No user for this key: persistErrorToDatabase finds nothing and saves nothing.
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.empty());
assertThat(call(service)).isFalse(); // 1
assertThat(call(service)).isFalse(); // 2
assertThat(call(service)).isTrue(); // 3 -> tries persist, user missing -> no save
verify(errorTrackerRepository, never()).save(any());
}
/** Convenience: record one processing error on the standard key. */
private boolean call(ErrorTrackingService service) {
return service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, processingThrowable(), 200);
}
}
@Nested
@DisplayName("recordErrorAndShouldConsumeCredit - DB fallback path (cache disabled)")
class DbFallbackPath {
@Test
@DisplayName("unknown API key returns false and saves nothing")
void unknownApiKey_returnsFalse() {
ErrorTrackingService service = dbService(2);
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.empty());
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, processingThrowable(), 200);
assertThat(charge).isFalse();
verify(errorTrackerRepository, never()).save(any());
}
@Test
@DisplayName(
"creates a new tracker, records the error and persists; below threshold no charge")
void newTracker_belowThreshold_noCharge() {
ErrorTrackingService service = dbService(2);
User u = user("dave", API_KEY);
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
.thenReturn(Optional.empty());
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, processingThrowable(), 200);
assertThat(charge).isFalse(); // count 1, free 2
ArgumentCaptor<UserErrorTracker> captor =
ArgumentCaptor.forClass(UserErrorTracker.class);
verify(errorTrackerRepository).save(captor.capture());
assertThat(captor.getValue().getProcessingErrorCount()).isEqualTo(1);
assertThat(captor.getValue().getUser()).isSameAs(u);
}
@Test
@DisplayName("existing tracker already at the threshold rolls over to charging")
void existingTracker_atThreshold_charges() {
ErrorTrackingService service = dbService(2);
User u = user("erin", API_KEY);
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
UserErrorTracker tracker = new UserErrorTracker(u, ENDPOINT, 60);
tracker.setProcessingErrorCount(2); // at the free limit
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
.thenReturn(Optional.of(tracker));
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, processingThrowable(), 200);
// recordProcessingError bumps 2 -> 3, which is > free(2) -> charge
assertThat(charge).isTrue();
assertThat(tracker.getProcessingErrorCount()).isEqualTo(3);
verify(errorTrackerRepository).save(tracker);
}
@Test
@DisplayName("expired existing tracker is reset before recording the new error")
void expiredTracker_isResetThenRecorded() {
ErrorTrackingService service = dbService(2);
User u = user("finn", API_KEY);
when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u));
UserErrorTracker tracker = new UserErrorTracker(u, ENDPOINT, 60);
tracker.setProcessingErrorCount(5);
tracker.setResetAfter(LocalDateTime.now().minusMinutes(1)); // expired
when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT))
.thenReturn(Optional.of(tracker));
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, processingThrowable(), 200);
// reset to 0, then recordProcessingError -> 1, which is not > free(2)
assertThat(charge).isFalse();
assertThat(tracker.getProcessingErrorCount()).isEqualTo(1);
verify(errorTrackerRepository).save(tracker);
}
}
@Nested
@DisplayName("hasHighErrorCount")
class HasHighErrorCount {
@Test
@DisplayName("returns false when no tracker exists for the key")
void noTracker_false() {
ErrorTrackingService service = cachedService(2);
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
.thenReturn(Optional.empty());
assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isFalse();
}
@Test
@DisplayName("true when the tracker's count exceeds the free allowance")
void aboveFree_true() {
ErrorTrackingService service = cachedService(2);
UserErrorTracker tracker = new UserErrorTracker(user("g", API_KEY), ENDPOINT, 60);
tracker.setProcessingErrorCount(3); // > 2
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
.thenReturn(Optional.of(tracker));
assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isTrue();
}
@Test
@DisplayName("false when the count is exactly at the free allowance (boundary)")
void atFree_false() {
ErrorTrackingService service = cachedService(2);
UserErrorTracker tracker = new UserErrorTracker(user("g", API_KEY), ENDPOINT, 60);
tracker.setProcessingErrorCount(2); // not > 2
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
.thenReturn(Optional.of(tracker));
assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isFalse();
}
}
@Nested
@DisplayName("getErrorInfo")
class GetErrorInfo {
@Test
@DisplayName("cache hit reflects the live cached count, remaining free and charging flag")
void cacheHit_reportsLiveCount() {
ErrorTrackingService service = cachedService(2);
// Drive the cache to 3 errors on the key so a subsequent getErrorInfo reads it.
for (int i = 0; i < 3; i++) {
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, processingThrowable(), 200);
}
ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
assertThat(info.currentErrorCount).isEqualTo(3);
// Math.max(0, free(2) - 3) == 0
assertThat(info.errorsUntilCharged).isZero();
assertThat(info.isChargingForErrors).isTrue(); // 3 > 2
assertThat(info.lastError).isNotNull();
}
@Test
@DisplayName("cache miss falls back to an empty/zeroed ErrorInfo when no DB row exists")
void cacheMiss_noDbRow_zeroedInfo() {
ErrorTrackingService service = cachedService(2);
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
.thenReturn(Optional.empty());
ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
assertThat(info.currentErrorCount).isZero();
assertThat(info.errorsUntilCharged).isEqualTo(2); // full free allowance
assertThat(info.isChargingForErrors).isFalse();
assertThat(info.lastError).isNull();
}
@Test
@DisplayName("DB-backed (cache disabled): live tracker is reported with derived fields")
void dbBacked_liveTracker_reported() {
ErrorTrackingService service = dbService(2);
UserErrorTracker tracker = new UserErrorTracker(user("h", API_KEY), ENDPOINT, 60);
tracker.setProcessingErrorCount(3);
tracker.setLastProcessingError(LocalDateTime.now());
// not expired (constructor set resetAfter ~60m out)
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
.thenReturn(Optional.of(tracker));
ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
assertThat(info.currentErrorCount).isEqualTo(3);
// getErrorsUntilCharged = max(0, free+1 - current) = max(0, 3 - 3) = 0
assertThat(info.errorsUntilCharged).isZero();
assertThat(info.isChargingForErrors).isTrue(); // 3 > 2
assertThat(info.lastError).isNotNull();
verify(errorTrackerRepository, never()).save(any());
}
@Test
@DisplayName("DB-backed: expired tracker is reset, persisted and reported as zeroed")
void dbBacked_expiredTracker_resetAndZeroed() {
ErrorTrackingService service = dbService(2);
UserErrorTracker tracker = new UserErrorTracker(user("i", API_KEY), ENDPOINT, 60);
tracker.setProcessingErrorCount(7);
tracker.setResetAfter(LocalDateTime.now().minusMinutes(1)); // expired
when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT))
.thenReturn(Optional.of(tracker));
ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT);
assertThat(info.currentErrorCount).isZero();
assertThat(info.errorsUntilCharged).isEqualTo(2);
assertThat(info.isChargingForErrors).isFalse();
assertThat(info.lastError).isNull();
assertThat(tracker.getProcessingErrorCount()).isZero(); // reset mutated the entity
verify(errorTrackerRepository).save(tracker);
}
}
@Nested
@DisplayName("cleanupExpiredErrorTrackers")
class Cleanup {
@Test
@DisplayName("delegates to the repository delete with a 'now' cutoff")
void delegatesDelete() {
ErrorTrackingService service = cachedService(2);
when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class)))
.thenReturn(4);
service.cleanupExpiredErrorTrackers();
verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class));
}
@Test
@DisplayName("swallows repository exceptions so the scheduler keeps running")
void swallowsRepositoryException() {
ErrorTrackingService service = cachedService(2);
when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class)))
.thenThrow(new RuntimeException("delete blew up"));
// Must not propagate.
service.cleanupExpiredErrorTrackers();
verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class));
}
@Test
@DisplayName("zero deletions still completes cleanly")
void zeroDeletions_ok() {
ErrorTrackingService service = cachedService(2);
when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class)))
.thenReturn(0);
service.cleanupExpiredErrorTrackers();
verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class));
}
}
@Nested
@DisplayName("ErrorInfo value holder")
class ErrorInfoHolder {
@Test
@DisplayName("constructor wires the public fields verbatim")
void fieldsWiredVerbatim() {
LocalDateTime ts = LocalDateTime.now();
ErrorInfo info = new ErrorInfo(5, 1, true, ts);
assertThat(info.currentErrorCount).isEqualTo(5);
assertThat(info.errorsUntilCharged).isEqualTo(1);
assertThat(info.isChargingForErrors).isTrue();
assertThat(info.lastError).isEqualTo(ts);
}
}
@Nested
@DisplayName("API key masking is exercised without leaking (smoke via logging branches)")
class MaskingSmoke {
@Test
@DisplayName("short API keys are tolerated end-to-end on the cache path")
void shortApiKey_tolerated() {
ErrorTrackingService service = cachedService(2);
when(userRepository.findByApiKey(anyString())).thenReturn(Optional.empty());
// "key" is < 8 chars, masked as *** inside the service; must not throw on persist path.
boolean c1 =
service.recordErrorAndShouldConsumeCredit(
"key", ENDPOINT, processingThrowable(), 200);
boolean c2 =
service.recordErrorAndShouldConsumeCredit(
"key", ENDPOINT, processingThrowable(), 200);
boolean c3 =
service.recordErrorAndShouldConsumeCredit(
"key", ENDPOINT, processingThrowable(), 200);
assertThat(c1).isFalse();
assertThat(c2).isFalse();
assertThat(c3).isTrue();
// user absent -> no save even at threshold crossing
verify(errorTrackerRepository, never()).save(any());
}
@Test
@DisplayName("null API key is tolerated on the DB fallback path")
void nullApiKey_tolerated() {
ErrorTrackingService service = dbService(2);
when(userRepository.findByApiKey(eq(null))).thenReturn(Optional.empty());
boolean charge =
service.recordErrorAndShouldConsumeCredit(
null, ENDPOINT, processingThrowable(), 200);
assertThat(charge).isFalse();
verify(errorTrackerRepository, never()).save(any());
}
}
@Test
@DisplayName("IOException as a system error type does not charge even at 200")
void ioExceptionSystemError_doesNotCharge() {
ErrorTrackingService service = cachedService(2);
boolean charge =
service.recordErrorAndShouldConsumeCredit(
API_KEY, ENDPOINT, new IOException("disk gone"), 200);
assertThat(charge).isFalse();
verify(errorTrackerRepository, never()).save(any());
}
}
@@ -0,0 +1,229 @@
package stirling.software.saas.service;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link RateLimitService}.
*
* <p>The service keeps per-team attempt counts in two in-memory {@link
* java.util.concurrent.ConcurrentHashMap} buckets (hourly cap 50, daily cap 150) keyed by {@code
* "team:" + teamId}. The reset clock is {@link System#currentTimeMillis()} with 1h / 1d windows, so
* nothing created inside a test ever expires during the run - every assertion below is
* deterministic pure arithmetic with no sleeps or fake clock. A fresh service instance is built per
* test so the in-memory buckets start clean.
*/
class RateLimitServiceTest {
private static final int HOURLY_LIMIT = 50;
private static final int DAILY_LIMIT = 150;
private RateLimitService service;
@BeforeEach
void setUp() {
service = new RateLimitService();
}
@Nested
@DisplayName("allowInvitation - hourly limit")
class AllowInvitationHourly {
@Test
@DisplayName("first invitation for a team is allowed")
void firstInvitation_allowed() {
assertThat(service.allowInvitation(1L)).isTrue();
}
@Test
@DisplayName("exactly the hourly limit (50) of invitations are all allowed")
void upToHourlyLimit_allAllowed() {
for (int i = 1; i <= HOURLY_LIMIT; i++) {
assertThat(service.allowInvitation(1L))
.as("invitation #%d should be allowed", i)
.isTrue();
}
}
@Test
@DisplayName("the 51st invitation within the hour is rejected")
void overHourlyLimit_rejected() {
for (int i = 1; i <= HOURLY_LIMIT; i++) {
service.allowInvitation(1L);
}
// count would become 51 > 50 -> rejected
assertThat(service.allowInvitation(1L)).isFalse();
}
@Test
@DisplayName("once over the hourly cap, subsequent attempts stay rejected")
void staysRejectedOnceOverHourly() {
for (int i = 1; i <= HOURLY_LIMIT + 1; i++) {
service.allowInvitation(1L);
}
assertThat(service.allowInvitation(1L)).isFalse();
assertThat(service.allowInvitation(1L)).isFalse();
}
}
@Nested
@DisplayName("allowInvitation - per-team isolation")
class PerTeamIsolation {
@Test
@DisplayName("different teams have independent counters")
void differentTeams_independent() {
// Exhaust team 1's hourly quota.
for (int i = 1; i <= HOURLY_LIMIT; i++) {
service.allowInvitation(1L);
}
assertThat(service.allowInvitation(1L)).isFalse();
// Team 2 is untouched and fully allowed.
assertThat(service.allowInvitation(2L)).isTrue();
assertThat(service.getRemainingInvitations(2L)).isEqualTo(HOURLY_LIMIT - 1);
}
@Test
@DisplayName("null teamId is keyed as its own bucket and behaves like any team")
void nullTeamId_hasOwnBucket() {
assertThat(service.allowInvitation(null)).isTrue();
// key becomes "team:null"; remaining drops by one for that key.
assertThat(service.getRemainingInvitations(null)).isEqualTo(HOURLY_LIMIT - 1);
// A real team is unaffected.
assertThat(service.getRemainingInvitations(1L)).isEqualTo(HOURLY_LIMIT);
}
}
@Nested
@DisplayName("getRemainingInvitations")
class GetRemainingInvitations {
@Test
@DisplayName("returns the full hourly allowance when no invitation has been recorded")
void noBucket_returnsFullAllowance() {
assertThat(service.getRemainingInvitations(7L)).isEqualTo(HOURLY_LIMIT);
}
@Test
@DisplayName("decreases by one after a single allowed invitation")
void afterOneInvitation_decrementsByOne() {
service.allowInvitation(7L);
assertThat(service.getRemainingInvitations(7L)).isEqualTo(HOURLY_LIMIT - 1);
}
@Test
@DisplayName("tracks the running count across several invitations")
void tracksRunningCount() {
for (int i = 0; i < 10; i++) {
service.allowInvitation(7L);
}
assertThat(service.getRemainingInvitations(7L)).isEqualTo(HOURLY_LIMIT - 10);
}
@Test
@DisplayName("is zero exactly when the hourly limit has been fully consumed")
void atHourlyLimit_remainingIsZero() {
for (int i = 1; i <= HOURLY_LIMIT; i++) {
service.allowInvitation(7L);
}
assertThat(service.getRemainingInvitations(7L)).isZero();
}
@Test
@DisplayName("never goes negative once invitations are rejected past the cap")
void overLimit_remainingClampedAtZero() {
for (int i = 1; i <= HOURLY_LIMIT + 5; i++) {
service.allowInvitation(7L);
}
// Math.max(0, 50 - count) clamps at zero even though attempts exceeded the cap.
assertThat(service.getRemainingInvitations(7L)).isZero();
}
}
@Nested
@DisplayName("allowInvitation - daily limit and hourly rollback")
class DailyLimitAndRollback {
@Test
@DisplayName("daily cap (150) blocks attempts even though it spans multiple hourly windows")
void belowDailyLimit_acrossTeams_doesNotInterfere() {
// A single team can never reach the daily cap within one hourly window because the
// hourly cap (50) trips first. Verify the first 50 are allowed and 51st is blocked,
// confirming the hourly gate is the binding constraint here.
for (int i = 1; i <= HOURLY_LIMIT; i++) {
assertThat(service.allowInvitation(9L)).isTrue();
}
assertThat(service.allowInvitation(9L)).isFalse();
}
@Test
@DisplayName("rejection at the hourly gate does not consume the remaining count further")
void hourlyRejection_doesNotAdvanceRemaining() {
for (int i = 1; i <= HOURLY_LIMIT; i++) {
service.allowInvitation(9L);
}
assertThat(service.getRemainingInvitations(9L)).isZero();
// Rejected attempts push the internal count past the cap, but remaining stays clamped.
service.allowInvitation(9L);
assertThat(service.getRemainingInvitations(9L)).isZero();
}
}
@Nested
@DisplayName("cleanupExpiredBuckets")
class CleanupExpiredBuckets {
@Test
@DisplayName("runs cleanly when there are no buckets at all")
void emptyState_noError() {
// Nothing recorded yet; cleanup must be a harmless no-op.
service.cleanupExpiredBuckets();
}
@Test
@DisplayName("does not evict fresh (unexpired) buckets, preserving their counts")
void doesNotEvictFreshBuckets() {
service.allowInvitation(11L);
service.allowInvitation(11L);
assertThat(service.getRemainingInvitations(11L)).isEqualTo(HOURLY_LIMIT - 2);
// Buckets just created have reset times an hour/day out, so none are expired.
service.cleanupExpiredBuckets();
// Count survived the sweep.
assertThat(service.getRemainingInvitations(11L)).isEqualTo(HOURLY_LIMIT - 2);
}
@Test
@DisplayName("is idempotent across repeated invocations")
void repeatedInvocations_stable() {
service.allowInvitation(12L);
service.cleanupExpiredBuckets();
service.cleanupExpiredBuckets();
service.cleanupExpiredBuckets();
assertThat(service.getRemainingInvitations(12L)).isEqualTo(HOURLY_LIMIT - 1);
}
}
@Test
@DisplayName("daily limit constant is wider than the hourly limit (sanity on configured caps)")
void dailyWiderThanHourly() {
// This documents the relationship the service relies on: the hourly cap always trips first
// for a single uninterrupted burst, so a lone team can't reach the daily cap in one window.
assertThat(DAILY_LIMIT).isGreaterThan(HOURLY_LIMIT);
}
}
@@ -0,0 +1,585 @@
package stirling.software.saas.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.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 stirling.software.proprietary.model.Team;
import stirling.software.saas.model.SaasTeamExtensions;
import stirling.software.saas.repository.SaasTeamExtensionsRepository;
/**
* Unit tests for {@link SaasTeamExtensionService}.
*
* <p>The service is a thin read/write facade over {@link SaasTeamExtensionsRepository}. Reads
* return safe defaults when no row exists (non-personal, STANDARD type, seatsUsed=0, maxSeats=1,
* createdBy=null, hasAvailableSeats=true, canInviteMembers=true); writes create the row lazily via
* {@code getOrCreate}. Pure delegation + Optional mapping, so everything is mocked at the
* repository boundary.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SaasTeamExtensionServiceTest {
private static final Long TEAM_ID = 7L;
@Mock private SaasTeamExtensionsRepository repository;
@InjectMocks private SaasTeamExtensionService service;
/** A team with a non-null id (the common case). */
private static Team team(Long id) {
Team t = new Team();
t.setId(id);
t.setName("team-" + id);
return t;
}
private static Team team() {
return team(TEAM_ID);
}
/** A fresh extension row for the given team carrying entity defaults. */
private static SaasTeamExtensions ext(Team team) {
return new SaasTeamExtensions(team);
}
@Nested
@DisplayName("getOrCreate")
class GetOrCreate {
@Test
@DisplayName("returns the existing row without creating a new one")
void existingRow_returnedWithoutSave() {
Team team = team();
SaasTeamExtensions existing = ext(team);
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(existing));
SaasTeamExtensions result = service.getOrCreate(team);
assertThat(result).isSameAs(existing);
verify(repository, never()).save(any());
}
@Test
@DisplayName("lazily creates and saves a new row when none exists")
void missingRow_savesNew() {
Team team = team();
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
// save echoes back its argument so the returned instance is the freshly built row.
when(repository.save(any(SaasTeamExtensions.class)))
.thenAnswer(inv -> inv.getArgument(0));
SaasTeamExtensions result = service.getOrCreate(team);
ArgumentCaptor<SaasTeamExtensions> captor =
ArgumentCaptor.forClass(SaasTeamExtensions.class);
verify(repository).save(captor.capture());
SaasTeamExtensions saved = captor.getValue();
// The new row is bound to the team and carries entity defaults.
assertThat(saved.getTeam()).isSameAs(team);
assertThat(saved.getTeamId()).isEqualTo(TEAM_ID);
assertThat(saved.getTeamType()).isEqualTo(SaasTeamExtensions.TEAM_TYPE_STANDARD);
assertThat(saved.isPersonal()).isFalse();
assertThat(saved.getSeatsUsed()).isZero();
assertThat(saved.getMaxSeats()).isEqualTo(1);
assertThat(result).isSameAs(saved);
}
}
@Nested
@DisplayName("isPersonal")
class IsPersonal {
@Test
@DisplayName("null team short-circuits to false without touching the repository")
void nullTeam_false() {
assertThat(service.isPersonal(null)).isFalse();
verifyNoInteractions(repository);
}
@Test
@DisplayName("team with null id short-circuits to false without touching the repository")
void nullId_false() {
assertThat(service.isPersonal(team(null))).isFalse();
verifyNoInteractions(repository);
}
@Test
@DisplayName("no row defaults to false")
void noRow_defaultsFalse() {
Team team = team();
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
assertThat(service.isPersonal(team)).isFalse();
}
@Test
@DisplayName("reflects the persisted personal flag when a row exists")
void existingPersonalRow_true() {
Team team = team();
SaasTeamExtensions row = ext(team);
row.setIsPersonal(true);
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
assertThat(service.isPersonal(team)).isTrue();
}
}
@Nested
@DisplayName("getTeamType")
class GetTeamType {
@Test
@DisplayName("null team defaults to STANDARD without a lookup")
void nullTeam_standard() {
assertThat(service.getTeamType(null)).isEqualTo(SaasTeamExtensions.TEAM_TYPE_STANDARD);
verifyNoInteractions(repository);
}
@Test
@DisplayName("team with null id defaults to STANDARD without a lookup")
void nullId_standard() {
assertThat(service.getTeamType(team(null)))
.isEqualTo(SaasTeamExtensions.TEAM_TYPE_STANDARD);
verifyNoInteractions(repository);
}
@Test
@DisplayName("no row defaults to STANDARD")
void noRow_standard() {
Team team = team();
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
assertThat(service.getTeamType(team)).isEqualTo(SaasTeamExtensions.TEAM_TYPE_STANDARD);
}
@Test
@DisplayName("returns the persisted PERSONAL type when a row exists")
void existingRow_personalType() {
Team team = team();
SaasTeamExtensions row = ext(team);
row.setTeamType(SaasTeamExtensions.TEAM_TYPE_PERSONAL);
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
assertThat(service.getTeamType(team)).isEqualTo(SaasTeamExtensions.TEAM_TYPE_PERSONAL);
}
}
@Nested
@DisplayName("getSeatsUsed")
class GetSeatsUsed {
@Test
@DisplayName("no row defaults to 0")
void noRow_zero() {
Team team = team();
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
assertThat(service.getSeatsUsed(team)).isZero();
}
@Test
@DisplayName("returns the persisted seatsUsed value")
void existingRow_value() {
Team team = team();
SaasTeamExtensions row = ext(team);
row.setSeatsUsed(5);
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
assertThat(service.getSeatsUsed(team)).isEqualTo(5);
}
}
@Nested
@DisplayName("getMaxSeats")
class GetMaxSeats {
@Test
@DisplayName("no row defaults to 1 (not 0)")
void noRow_one() {
Team team = team();
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
assertThat(service.getMaxSeats(team)).isEqualTo(1);
}
@Test
@DisplayName("returns the persisted maxSeats value")
void existingRow_value() {
Team team = team();
SaasTeamExtensions row = ext(team);
row.setMaxSeats(25);
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
assertThat(service.getMaxSeats(team)).isEqualTo(25);
}
}
@Nested
@DisplayName("getCreatedByUserId")
class GetCreatedByUserId {
@Test
@DisplayName("no row defaults to null")
void noRow_null() {
Team team = team();
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
assertThat(service.getCreatedByUserId(team)).isNull();
}
@Test
@DisplayName("row present but creator unset is null")
void existingRow_unsetCreator_null() {
Team team = team();
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(ext(team)));
assertThat(service.getCreatedByUserId(team)).isNull();
}
@Test
@DisplayName("returns the persisted creator id")
void existingRow_value() {
Team team = team();
SaasTeamExtensions row = ext(team);
row.setCreatedByUserId(99L);
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
assertThat(service.getCreatedByUserId(team)).isEqualTo(99L);
}
}
@Nested
@DisplayName("hasAvailableSeats")
class HasAvailableSeats {
@Test
@DisplayName("no row defaults to true (optimistic)")
void noRow_true() {
Team team = team();
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
assertThat(service.hasAvailableSeats(team)).isTrue();
}
@Test
@DisplayName("standard team always has seats regardless of usage")
void standardTeam_alwaysTrue() {
Team team = team();
SaasTeamExtensions row = ext(team);
row.setIsPersonal(false);
row.setSeatsUsed(1000);
row.setMaxSeats(1);
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
assertThat(service.hasAvailableSeats(team)).isTrue();
}
@Test
@DisplayName("personal team with a free seat returns true")
void personalTeam_underCap_true() {
Team team = team();
SaasTeamExtensions row = ext(team);
row.setIsPersonal(true);
row.setSeatsUsed(0);
row.setMaxSeats(1);
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
assertThat(service.hasAvailableSeats(team)).isTrue();
}
@Test
@DisplayName("personal team at its seat cap returns false (boundary)")
void personalTeam_atCap_false() {
Team team = team();
SaasTeamExtensions row = ext(team);
row.setIsPersonal(true);
row.setSeatsUsed(1);
row.setMaxSeats(1);
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
assertThat(service.hasAvailableSeats(team)).isFalse();
}
}
@Nested
@DisplayName("canInviteMembers")
class CanInviteMembers {
@Test
@DisplayName("no row defaults to true")
void noRow_true() {
Team team = team();
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
assertThat(service.canInviteMembers(team)).isTrue();
}
@Test
@DisplayName("standard team can invite")
void standardTeam_true() {
Team team = team();
SaasTeamExtensions row = ext(team);
row.setIsPersonal(false);
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
assertThat(service.canInviteMembers(team)).isTrue();
}
@Test
@DisplayName("personal team can never invite")
void personalTeam_false() {
Team team = team();
SaasTeamExtensions row = ext(team);
row.setIsPersonal(true);
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
assertThat(service.canInviteMembers(team)).isFalse();
}
}
@Nested
@DisplayName("incrementSeatsUsed")
class IncrementSeatsUsed {
@Test
@DisplayName(
"ensures the row exists then delegates the atomic increment, returning its result")
void existingRow_delegatesIncrement() {
Team team = team();
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(ext(team)));
when(repository.incrementSeatsUsed(TEAM_ID)).thenReturn(1);
int result = service.incrementSeatsUsed(team);
assertThat(result).isEqualTo(1);
verify(repository).incrementSeatsUsed(TEAM_ID);
// Row already existed -> no lazy creation.
verify(repository, never()).save(any());
}
@Test
@DisplayName("creates the row first when missing, then increments")
void missingRow_createsThenIncrements() {
Team team = team();
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
when(repository.save(any(SaasTeamExtensions.class)))
.thenAnswer(inv -> inv.getArgument(0));
when(repository.incrementSeatsUsed(TEAM_ID)).thenReturn(1);
int result = service.incrementSeatsUsed(team);
assertThat(result).isEqualTo(1);
verify(repository).save(any(SaasTeamExtensions.class));
verify(repository).incrementSeatsUsed(TEAM_ID);
}
@Test
@DisplayName("returns 0 when the atomic update hits the personal-team cap")
void capHit_returnsZero() {
Team team = team();
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(ext(team)));
when(repository.incrementSeatsUsed(TEAM_ID)).thenReturn(0);
assertThat(service.incrementSeatsUsed(team)).isZero();
}
}
@Nested
@DisplayName("decrementSeatsUsed")
class DecrementSeatsUsed {
@Test
@DisplayName("delegates straight to the atomic decrement without creating a row")
void delegatesDecrement() {
Team team = team();
when(repository.decrementSeatsUsed(TEAM_ID)).thenReturn(1);
int result = service.decrementSeatsUsed(team);
assertThat(result).isEqualTo(1);
verify(repository).decrementSeatsUsed(TEAM_ID);
verify(repository, never()).findByTeamId(any());
verify(repository, never()).save(any());
}
@Test
@DisplayName("returns 0 when already floored at zero")
void alreadyZero_returnsZero() {
Team team = team();
when(repository.decrementSeatsUsed(TEAM_ID)).thenReturn(0);
assertThat(service.decrementSeatsUsed(team)).isZero();
}
}
@Nested
@DisplayName("setPersonal")
class SetPersonal {
@Test
@DisplayName("marking personal sets the flag and the PERSONAL team type, then saves")
void markPersonal_setsFlagAndType() {
Team team = team();
SaasTeamExtensions row = ext(team);
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
service.setPersonal(team, true);
assertThat(row.isPersonal()).isTrue();
assertThat(row.getTeamType()).isEqualTo(SaasTeamExtensions.TEAM_TYPE_PERSONAL);
verify(repository).save(row);
}
@Test
@DisplayName(
"marking non-personal clears the flag and reverts to STANDARD type, then saves")
void markNonPersonal_revertsToStandard() {
Team team = team();
SaasTeamExtensions row = ext(team);
row.setIsPersonal(true);
row.setTeamType(SaasTeamExtensions.TEAM_TYPE_PERSONAL);
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
service.setPersonal(team, false);
assertThat(row.isPersonal()).isFalse();
assertThat(row.getTeamType()).isEqualTo(SaasTeamExtensions.TEAM_TYPE_STANDARD);
verify(repository).save(row);
}
@Test
@DisplayName("creates the row first when missing, then applies the personal flag")
void missingRow_createsThenSets() {
Team team = team();
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
when(repository.save(any(SaasTeamExtensions.class)))
.thenAnswer(inv -> inv.getArgument(0));
service.setPersonal(team, true);
// getOrCreate saves the new row, then setPersonal saves the mutated row.
ArgumentCaptor<SaasTeamExtensions> captor =
ArgumentCaptor.forClass(SaasTeamExtensions.class);
verify(repository, org.mockito.Mockito.times(2)).save(captor.capture());
SaasTeamExtensions last = captor.getValue();
assertThat(last.isPersonal()).isTrue();
assertThat(last.getTeamType()).isEqualTo(SaasTeamExtensions.TEAM_TYPE_PERSONAL);
}
}
@Nested
@DisplayName("setSeats")
class SetSeats {
@Test
@DisplayName("writes both seatCount and maxSeats onto the existing row, then saves")
void existingRow_writesBoth() {
Team team = team();
SaasTeamExtensions row = ext(team);
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
service.setSeats(team, 3, 10);
assertThat(row.getSeatCount()).isEqualTo(3);
assertThat(row.getMaxSeats()).isEqualTo(10);
verify(repository).save(row);
}
@Test
@DisplayName("does not touch seatsUsed (only seatCount and maxSeats)")
void doesNotChangeSeatsUsed() {
Team team = team();
SaasTeamExtensions row = ext(team);
row.setSeatsUsed(4);
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
service.setSeats(team, 8, 8);
assertThat(row.getSeatsUsed()).isEqualTo(4);
}
@Test
@DisplayName("creates the row first when missing, then writes the seat fields")
void missingRow_createsThenWrites() {
Team team = team();
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
when(repository.save(any(SaasTeamExtensions.class)))
.thenAnswer(inv -> inv.getArgument(0));
service.setSeats(team, 2, 5);
ArgumentCaptor<SaasTeamExtensions> captor =
ArgumentCaptor.forClass(SaasTeamExtensions.class);
verify(repository, org.mockito.Mockito.times(2)).save(captor.capture());
SaasTeamExtensions last = captor.getValue();
assertThat(last.getSeatCount()).isEqualTo(2);
assertThat(last.getMaxSeats()).isEqualTo(5);
}
}
@Nested
@DisplayName("setCreatedByUserId")
class SetCreatedByUserId {
@Test
@DisplayName("writes the creator id onto the existing row, then saves")
void existingRow_writesCreator() {
Team team = team();
SaasTeamExtensions row = ext(team);
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
service.setCreatedByUserId(team, 42L);
assertThat(row.getCreatedByUserId()).isEqualTo(42L);
verify(repository).save(row);
}
@Test
@DisplayName("accepts a null creator id (clearing)")
void nullCreator_cleared() {
Team team = team();
SaasTeamExtensions row = ext(team);
row.setCreatedByUserId(5L);
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.of(row));
service.setCreatedByUserId(team, null);
assertThat(row.getCreatedByUserId()).isNull();
verify(repository).save(row);
}
@Test
@DisplayName("creates the row first when missing, then writes the creator id")
void missingRow_createsThenWrites() {
Team team = team();
when(repository.findByTeamId(TEAM_ID)).thenReturn(Optional.empty());
when(repository.save(any(SaasTeamExtensions.class)))
.thenAnswer(inv -> inv.getArgument(0));
service.setCreatedByUserId(team, 13L);
ArgumentCaptor<SaasTeamExtensions> captor =
ArgumentCaptor.forClass(SaasTeamExtensions.class);
verify(repository, org.mockito.Mockito.times(2)).save(captor.capture());
assertThat(captor.getValue().getCreatedByUserId()).isEqualTo(13L);
}
}
}
@@ -0,0 +1,475 @@
package stirling.software.saas.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
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.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.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import stirling.software.common.model.enumeration.Role;
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.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.model.SupabaseUser;
/**
* Unit tests for {@link SaasUserAccountService}.
*
* <p>The service is a thin orchestration layer over {@link UserService} and the saas extension
* services; every collaborator is mocked and the methods are invoked directly. Role state is driven
* through {@link User#getRolesAsString()} by attaching an {@link Authority} (its constructor
* registers itself on the user). Anonymous state is driven through {@link
* User#setAuthenticationType(AuthenticationType)}, which the entity stores lowercased so the
* service's case-insensitive comparison against {@code ANONYMOUS} matches.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SaasUserAccountServiceTest {
@Mock private UserService userService;
@Mock private UserRepository userRepository;
@Mock private UserRoleService userRoleService;
@Mock private SupabaseUserService supabaseUserService;
@Mock private SaasUserExtensionService saasUserExtensionService;
@Mock private SaasTeamExtensionService saasTeamExtensionService;
@InjectMocks private SaasUserAccountService service;
private static final String SUPABASE_ID = "11111111-2222-3333-4444-555555555555";
private static final UUID SUPABASE_UUID = UUID.fromString(SUPABASE_ID);
/** Build a user whose role string equals the given role id (e.g. "ROLE_USER"). */
private static User userWithRole(String roleId) {
User u = new User();
u.setUsername("alice@example.com");
// Authority's constructor registers itself onto the user's authority set.
new Authority(roleId, u);
return u;
}
private static Team team(Long id, String name) {
Team t = new Team();
t.setId(id);
t.setName(name);
return t;
}
@Nested
@DisplayName("getUserBySupabaseId")
class GetUserBySupabaseId {
@Test
@DisplayName("returns the local user when the UUID parses and a row exists")
void returnsUser() {
User u = userWithRole(Role.USER.getRoleId());
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
assertThat(service.getUserBySupabaseId(SUPABASE_ID)).isSameAs(u);
}
@Test
@DisplayName("throws with an 'invalid format' message when the id is not a UUID")
void invalidFormat_throws() {
assertThatThrownBy(() -> service.getUserBySupabaseId("not-a-uuid"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid Supabase ID format")
.hasMessageContaining("not-a-uuid");
verifyNoInteractions(userService);
}
@Test
@DisplayName("throws 'user not found' when the UUID parses but no local row exists")
void notFound_throws() {
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.getUserBySupabaseId(SUPABASE_ID))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("User not found for Supabase ID")
.hasMessageContaining(SUPABASE_ID);
}
}
@Nested
@DisplayName("handleUpgrade")
class HandleUpgrade {
@Test
@DisplayName("promotes a free (ROLE_USER) user to PRO and returns true")
void freeUser_isUpgraded() {
User u = userWithRole(Role.USER.getRoleId());
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
boolean upgraded = service.handleUpgrade(SUPABASE_ID);
assertThat(upgraded).isTrue();
verify(userRoleService).upgradeToPro(u);
}
@Test
@DisplayName("returns false and does not re-upgrade a user already on PRO")
void proUser_isNoOp() {
User u = userWithRole(Role.PRO_USER.getRoleId());
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
boolean upgraded = service.handleUpgrade(SUPABASE_ID);
assertThat(upgraded).isFalse();
verify(userRoleService, never()).upgradeToPro(any());
}
@Test
@DisplayName("returns false for any non-free role (e.g. admin/other) without upgrading")
void otherRole_isNoOp() {
User u = userWithRole("ROLE_ADMIN");
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
assertThat(service.handleUpgrade(SUPABASE_ID)).isFalse();
verify(userRoleService, never()).upgradeToPro(any());
}
@Test
@DisplayName("propagates the lookup failure when the supabase id has no local user")
void unknownUser_propagates() {
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.handleUpgrade(SUPABASE_ID))
.isInstanceOf(IllegalArgumentException.class);
verifyNoInteractions(userRoleService);
}
}
@Nested
@DisplayName("handleDowngrade")
class HandleDowngrade {
@Test
@DisplayName("downgrades a PRO user with no team to FREE and returns true")
void proWithoutTeam_isDowngraded() {
User u = userWithRole(Role.PRO_USER.getRoleId());
// no team set -> getTeam() is null
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
boolean downgraded = service.handleDowngrade(SUPABASE_ID);
assertThat(downgraded).isTrue();
verify(userRoleService).downgradeToFree(u);
}
@Test
@DisplayName(
"downgrades a PRO user whose team is personal (personal team is not a shared PRO source)")
void proWithPersonalTeam_isDowngraded() {
User u = userWithRole(Role.PRO_USER.getRoleId());
Team t = team(7L, "alice-personal");
u.setTeam(t);
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
when(saasTeamExtensionService.isPersonal(t)).thenReturn(true);
boolean downgraded = service.handleDowngrade(SUPABASE_ID);
assertThat(downgraded).isTrue();
verify(userRoleService).downgradeToFree(u);
}
@Test
@DisplayName("keeps PRO (returns false) for a PRO user on a non-personal/shared team")
void proWithSharedTeam_keepsPro() {
User u = userWithRole(Role.PRO_USER.getRoleId());
Team t = team(9L, "acme-team");
u.setTeam(t);
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
when(saasTeamExtensionService.isPersonal(t)).thenReturn(false);
boolean downgraded = service.handleDowngrade(SUPABASE_ID);
assertThat(downgraded).isFalse();
verify(userRoleService, never()).downgradeToFree(any());
}
@Test
@DisplayName("returns false without touching roles when the user is already FREE")
void freeUser_isNoOp() {
User u = userWithRole(Role.USER.getRoleId());
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
assertThat(service.handleDowngrade(SUPABASE_ID)).isFalse();
verify(userRoleService, never()).downgradeToFree(any());
verifyNoInteractions(saasTeamExtensionService);
}
}
@Nested
@DisplayName("enableMeteredBilling")
class EnableMeteredBilling {
@Test
@DisplayName("enables metered billing and returns true when it was off")
void wasOff_enables() {
User u = userWithRole(Role.PRO_USER.getRoleId());
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(false);
boolean result = service.enableMeteredBilling(SUPABASE_ID);
assertThat(result).isTrue();
verify(saasUserExtensionService).setMeteredBillingEnabled(u, true);
}
@Test
@DisplayName("returns false and does not re-enable when metered billing is already on")
void alreadyOn_isNoOp() {
User u = userWithRole(Role.PRO_USER.getRoleId());
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true);
boolean result = service.enableMeteredBilling(SUPABASE_ID);
assertThat(result).isFalse();
verify(saasUserExtensionService, never()).setMeteredBillingEnabled(any(), anyBoolean());
}
}
@Nested
@DisplayName("disableMeteredBilling")
class DisableMeteredBilling {
@Test
@DisplayName("disables metered billing and returns true when it was on")
void wasOn_disables() {
User u = userWithRole(Role.USER.getRoleId());
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(true);
boolean result = service.disableMeteredBilling(SUPABASE_ID);
assertThat(result).isTrue();
verify(saasUserExtensionService).setMeteredBillingEnabled(u, false);
}
@Test
@DisplayName("returns false and does nothing when metered billing is already off")
void alreadyOff_isNoOp() {
User u = userWithRole(Role.USER.getRoleId());
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
when(saasUserExtensionService.isMeteredBillingEnabled(u)).thenReturn(false);
boolean result = service.disableMeteredBilling(SUPABASE_ID);
assertThat(result).isFalse();
verify(saasUserExtensionService, never()).setMeteredBillingEnabled(any(), anyBoolean());
}
}
@Nested
@DisplayName("synchronizeUserUpgrade")
class SynchronizeUserUpgrade {
private static SupabaseUser supabaseUser(boolean anonymous) {
SupabaseUser su = new SupabaseUser();
su.setId(SUPABASE_UUID);
su.setAnonymous(anonymous);
return su;
}
private static User anonymousLocalUser() {
User u = new User();
u.setUsername("anon-handle");
u.setAuthenticationType(AuthenticationType.ANONYMOUS);
return u;
}
@Test
@DisplayName("throws IllegalStateException when no local user is linked to the supabase id")
void noLinkedUser_throws() {
SupabaseUser su = supabaseUser(true);
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.empty());
assertThatThrownBy(
() -> service.synchronizeUserUpgrade(su, "alice@example.com", "google"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("No local user linked to Supabase ID");
verifyNoInteractions(supabaseUserService);
}
@Test
@DisplayName("flips the supabase anonymous mirror to false and saves it")
void anonymousMirror_isFlippedAndSaved() {
SupabaseUser su = supabaseUser(true);
User u = anonymousLocalUser();
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
service.synchronizeUserUpgrade(su, "alice@example.com", "web");
assertThat(su.isAnonymous()).isFalse();
verify(supabaseUserService).save(su);
}
@Test
@DisplayName("does not save the supabase mirror when it was already non-anonymous")
void nonAnonymousMirror_isNotSaved() {
SupabaseUser su = supabaseUser(false);
User u = anonymousLocalUser();
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
service.synchronizeUserUpgrade(su, "alice@example.com", "web");
verify(supabaseUserService, never()).save(any());
}
@Test
@DisplayName("promotes an anonymous local user to WEB and copies the email into username")
void anonymousLocalUser_promotedToWeb() {
SupabaseUser su = supabaseUser(false);
User u = anonymousLocalUser();
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
User result = service.synchronizeUserUpgrade(su, "alice@example.com", "web");
assertThat(result).isSameAs(u);
// setAuthenticationType stores the enum name lowercased.
assertThat(u.getAuthenticationType()).isEqualTo("web");
assertThat(u.getEmail()).isEqualTo("alice@example.com");
assertThat(u.getUsername()).isEqualTo("alice@example.com");
verify(userService).saveUser(u);
}
@Test
@DisplayName("maps a known OAuth provider (google) to OAUTH2 auth type")
void oauthProvider_mapsToOauth2() {
SupabaseUser su = supabaseUser(false);
User u = anonymousLocalUser();
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
service.synchronizeUserUpgrade(su, "alice@example.com", "google");
assertThat(u.getAuthenticationType()).isEqualTo("oauth2");
}
@Test
@DisplayName("maps a generic 'oauth' authMethod to OAUTH2")
void genericOauth_mapsToOauth2() {
SupabaseUser su = supabaseUser(false);
User u = anonymousLocalUser();
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
service.synchronizeUserUpgrade(su, "alice@example.com", "oauth");
assertThat(u.getAuthenticationType()).isEqualTo("oauth2");
}
@Test
@DisplayName("maps an unknown authMethod to WEB")
void unknownMethod_mapsToWeb() {
SupabaseUser su = supabaseUser(false);
User u = anonymousLocalUser();
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
service.synchronizeUserUpgrade(su, "alice@example.com", "carrier-pigeon");
assertThat(u.getAuthenticationType()).isEqualTo("web");
}
@Test
@DisplayName("maps a null authMethod to WEB")
void nullMethod_mapsToWeb() {
SupabaseUser su = supabaseUser(false);
User u = anonymousLocalUser();
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
service.synchronizeUserUpgrade(su, "alice@example.com", null);
assertThat(u.getAuthenticationType()).isEqualTo("web");
}
@Test
@DisplayName(
"does not overwrite username/email when the email is blank, but still promotes the type")
void blankEmail_keepsUsername() {
SupabaseUser su = supabaseUser(false);
User u = anonymousLocalUser();
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
service.synchronizeUserUpgrade(su, " ", "google");
assertThat(u.getAuthenticationType()).isEqualTo("oauth2");
assertThat(u.getUsername()).isEqualTo("anon-handle");
assertThat(u.getEmail()).isNull();
verify(userService).saveUser(u);
}
@Test
@DisplayName("does not overwrite username/email when the email is null")
void nullEmail_keepsUsername() {
SupabaseUser su = supabaseUser(false);
User u = anonymousLocalUser();
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
service.synchronizeUserUpgrade(su, null, "web");
assertThat(u.getUsername()).isEqualTo("anon-handle");
assertThat(u.getEmail()).isNull();
}
@Test
@DisplayName("leaves a non-anonymous local user untouched and never saves it")
void nonAnonymousLocalUser_untouched() {
SupabaseUser su = supabaseUser(false);
User u = new User();
u.setUsername("existing@example.com");
u.setAuthenticationType(AuthenticationType.WEB);
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
User result = service.synchronizeUserUpgrade(su, "new@example.com", "google");
assertThat(result).isSameAs(u);
assertThat(u.getUsername()).isEqualTo("existing@example.com");
assertThat(u.getAuthenticationType()).isEqualTo("web");
verify(userService, never()).saveUser(any());
}
@Test
@DisplayName("anonymous comparison is case-insensitive (stored type is lowercased)")
void anonymousTypeMatchesCaseInsensitively() {
SupabaseUser su = supabaseUser(false);
User u = anonymousLocalUser();
// sanity: the entity stored the lowercase form, exercising the equalsIgnoreCase branch
assertThat(u.getAuthenticationType()).isEqualTo("anonymous");
when(userService.findBySupabaseId(SUPABASE_UUID)).thenReturn(Optional.of(u));
when(userService.saveUser(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
service.synchronizeUserUpgrade(su, "alice@example.com", "web");
verify(userService).saveUser(u);
}
}
}
@@ -11,7 +11,7 @@ import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/**
* Pins the contract {@code CreditService.scheduleStripeReportAfterCommit} relies on: a {@link
* Pins the contract {@code JobChargeService.close} relies on for its Stripe meter post: a {@link
* TransactionSynchronization#afterCommit()} hook fires after a successful commit and never on
* rollback.
*/
@@ -0,0 +1,205 @@
package stirling.software.saas.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.saas.model.SupabaseUser;
import stirling.software.saas.model.exception.UserNotFoundException;
import stirling.software.saas.repository.SupabaseUserRepository;
/**
* Unit tests for {@link SupabaseUserService}.
*
* <p>Thin CRUD facade over {@link SupabaseUserRepository}. Every method either delegates to the
* repository or, in {@code getUser}, translates a {@link Optional#empty()} into a {@link
* UserNotFoundException}. The Supabase boundary is fully mocked - no DB, no network.
*/
@ExtendWith(MockitoExtension.class)
class SupabaseUserServiceTest {
@Mock private SupabaseUserRepository supabaseUserRepository;
@InjectMocks private SupabaseUserService service;
private static final UUID SUPABASE_ID = UUID.fromString("11111111-2222-3333-4444-555555555555");
private static final String EMAIL = "user@example.com";
private static SupabaseUser supabaseUser(UUID id, String email, boolean anonymous) {
SupabaseUser u = new SupabaseUser();
u.setId(id);
u.setEmail(email);
u.setAnonymous(anonymous);
return u;
}
@Nested
@DisplayName("getUser")
class GetUser {
@Test
@DisplayName("returns the entity when the repository finds it by id")
void found_returnsEntity() {
SupabaseUser existing = supabaseUser(SUPABASE_ID, EMAIL, false);
when(supabaseUserRepository.findById(SUPABASE_ID)).thenReturn(Optional.of(existing));
SupabaseUser result = service.getUser(SUPABASE_ID);
assertThat(result).isSameAs(existing);
verify(supabaseUserRepository).findById(SUPABASE_ID);
}
@Test
@DisplayName("throws UserNotFoundException carrying the id when the repository is empty")
void missing_throwsUserNotFound() {
when(supabaseUserRepository.findById(SUPABASE_ID)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.getUser(SUPABASE_ID))
.isInstanceOf(UserNotFoundException.class)
.hasMessageContaining(SUPABASE_ID.toString())
.hasMessageContaining("not found");
verify(supabaseUserRepository, never()).save(any());
}
@Test
@DisplayName("looks the user up by the exact id it is given")
void passesIdThrough() {
UUID other = UUID.fromString("99999999-8888-7777-6666-555555555555");
when(supabaseUserRepository.findById(other))
.thenReturn(Optional.of(supabaseUser(other, "other@example.com", true)));
SupabaseUser result = service.getUser(other);
assertThat(result.getId()).isEqualTo(other);
verify(supabaseUserRepository).findById(other);
}
}
@Nested
@DisplayName("createSupabaseUser")
class CreateSupabaseUser {
@Test
@DisplayName("builds a SupabaseUser from the args and returns the saved entity")
void buildsAndSavesUser() {
// Repository echoes whatever it was handed.
when(supabaseUserRepository.save(any(SupabaseUser.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
SupabaseUser result = service.createSupabaseUser(SUPABASE_ID, EMAIL, false);
ArgumentCaptor<SupabaseUser> captor = ArgumentCaptor.forClass(SupabaseUser.class);
verify(supabaseUserRepository).save(captor.capture());
SupabaseUser saved = captor.getValue();
assertThat(saved.getId()).isEqualTo(SUPABASE_ID);
assertThat(saved.getEmail()).isEqualTo(EMAIL);
assertThat(saved.isAnonymous()).isFalse();
// The method returns exactly what the repository produced.
assertThat(result).isSameAs(saved);
}
@Test
@DisplayName("propagates the anonymous flag when true")
void anonymousFlagTrue() {
when(supabaseUserRepository.save(any(SupabaseUser.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
service.createSupabaseUser(SUPABASE_ID, EMAIL, true);
ArgumentCaptor<SupabaseUser> captor = ArgumentCaptor.forClass(SupabaseUser.class);
verify(supabaseUserRepository).save(captor.capture());
assertThat(captor.getValue().isAnonymous()).isTrue();
}
@Test
@DisplayName("accepts a null email and persists it unchanged (no normalisation)")
void nullEmail_persistedAsNull() {
when(supabaseUserRepository.save(any(SupabaseUser.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
service.createSupabaseUser(SUPABASE_ID, null, false);
ArgumentCaptor<SupabaseUser> captor = ArgumentCaptor.forClass(SupabaseUser.class);
verify(supabaseUserRepository).save(captor.capture());
assertThat(captor.getValue().getEmail()).isNull();
}
@Test
@DisplayName("returns the repository's instance, not a freshly built one")
void returnsRepositoryInstance() {
SupabaseUser persisted = supabaseUser(SUPABASE_ID, EMAIL, false);
when(supabaseUserRepository.save(any(SupabaseUser.class))).thenReturn(persisted);
SupabaseUser result = service.createSupabaseUser(SUPABASE_ID, EMAIL, false);
assertThat(result).isSameAs(persisted);
}
@Test
@DisplayName("does not read the user back before creating it")
void doesNotFindBeforeSave() {
when(supabaseUserRepository.save(any(SupabaseUser.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
service.createSupabaseUser(SUPABASE_ID, EMAIL, false);
verify(supabaseUserRepository, never()).findById(any());
}
}
@Nested
@DisplayName("save")
class Save {
@Test
@DisplayName("delegates straight to the repository and returns its result")
void delegatesToRepository() {
SupabaseUser input = supabaseUser(SUPABASE_ID, EMAIL, false);
SupabaseUser persisted = supabaseUser(SUPABASE_ID, EMAIL, false);
when(supabaseUserRepository.save(input)).thenReturn(persisted);
SupabaseUser result = service.save(input);
assertThat(result).isSameAs(persisted);
verify(supabaseUserRepository).save(input);
}
@Test
@DisplayName("passes a null entity through to the repository without guarding")
void nullEntity_passedThrough() {
when(supabaseUserRepository.save(null)).thenReturn(null);
SupabaseUser result = service.save(null);
assertThat(result).isNull();
verify(supabaseUserRepository).save(null);
}
}
@Test
@DisplayName("repository save failures bubble out of createSupabaseUser unchanged")
void createSupabaseUser_repositoryThrows_propagates() {
when(supabaseUserRepository.save(any(SupabaseUser.class)))
.thenThrow(new RuntimeException("constraint violation"));
assertThatThrownBy(() -> service.createSupabaseUser(SUPABASE_ID, EMAIL, false))
.isInstanceOf(RuntimeException.class)
.hasMessage("constraint violation");
}
}
@@ -0,0 +1,194 @@
package stirling.software.saas.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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.InOrder;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
/**
* Unit tests for {@link UserRoleService}.
*
* <p>The service is a thin orchestrator: it flips a user's {@link Authority} row and mirrors the
* role into the denormalized {@code roleName} column. All collaborators are mocked.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class UserRoleServiceTest {
@Mock private UserRepository userRepository;
@Mock private AuthorityRepository authorityRepository;
private static final String ROLE_USER = Role.USER.getRoleId(); // "ROLE_USER"
private static final String ROLE_PRO_USER = Role.PRO_USER.getRoleId(); // "ROLE_PRO_USER"
private UserRoleService service() {
return new UserRoleService(userRepository, authorityRepository);
}
private static User user(long id, String username, String currentRole) {
User u = new User();
u.setId(id);
u.setUsername(username);
u.setRoleName(currentRole);
return u;
}
private static Authority authority(String currentRole) {
Authority a = new Authority();
a.setId(7L);
a.setAuthority(currentRole);
return a;
}
/**
* Reads the denormalized {@code roleName} column straight off the field. {@link
* User#getRoleName()} is overridden to derive the role from the authorities set (via {@link
* Role#fromString}), so it cannot observe the column that {@code changeRole} mirrors via {@code
* setRoleName}.
*/
private static String mirroredRoleName(User u) {
return (String) ReflectionTestUtils.getField(u, "roleName");
}
@Nested
@DisplayName("changeRole")
class ChangeRole {
@Test
@DisplayName("flips the Authority row, mirrors roleName, and persists both")
void flipsAuthorityAndMirrorsRoleName() {
UserRoleService service = service();
User u = user(42L, "alice@example.com", ROLE_USER);
Authority auth = authority(ROLE_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.changeRole(u, ROLE_PRO_USER);
// Authority entity carries the new role and is saved.
assertThat(auth.getAuthority()).isEqualTo(ROLE_PRO_USER);
verify(authorityRepository).save(auth);
// Denormalized column mirrored on the User entity and saved.
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_PRO_USER);
verify(userRepository).save(u);
}
@Test
@DisplayName("persists the authority before the user (authority-first ordering)")
void persistsAuthorityBeforeUser() {
UserRoleService service = service();
User u = user(42L, "alice@example.com", ROLE_USER);
Authority auth = authority(ROLE_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.changeRole(u, ROLE_PRO_USER);
InOrder order = Mockito.inOrder(authorityRepository, userRepository);
order.verify(authorityRepository).save(auth);
order.verify(userRepository).save(u);
}
@Test
@DisplayName("looks the authority up by the user's numeric id")
void looksUpAuthorityByUserId() {
UserRoleService service = service();
User u = user(99L, "bob@example.com", ROLE_PRO_USER);
Authority auth = authority(ROLE_PRO_USER);
when(authorityRepository.findByUserId(99L)).thenReturn(auth);
service.changeRole(u, ROLE_USER);
verify(authorityRepository).findByUserId(99L);
assertThat(auth.getAuthority()).isEqualTo(ROLE_USER);
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_USER);
}
@Test
@DisplayName("setting the same role is a harmless no-op rewrite that still persists")
void sameRoleStillPersists() {
UserRoleService service = service();
User u = user(42L, "carol@example.com", ROLE_USER);
Authority auth = authority(ROLE_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.changeRole(u, ROLE_USER);
assertThat(auth.getAuthority()).isEqualTo(ROLE_USER);
verify(authorityRepository).save(auth);
verify(userRepository).save(u);
}
@Test
@DisplayName("tolerates an empty authorities set on the User (logging reads roles as \"\")")
void emptyAuthoritiesSetIsTolerated() {
UserRoleService service = service();
User u = user(42L, "dave@example.com", null);
// getRolesAsString() joins an empty set -> "" ; must not NPE in the debug log.
Authority auth = authority(null);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.changeRole(u, ROLE_PRO_USER);
assertThat(auth.getAuthority()).isEqualTo(ROLE_PRO_USER);
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_PRO_USER);
}
}
@Nested
@DisplayName("downgradeToFree")
class DowngradeToFree {
@Test
@DisplayName("sets ROLE_USER, mirrors roleName, and persists both")
void setsUserRole() {
UserRoleService service = service();
User u = user(42L, "eve@example.com", ROLE_PRO_USER);
Authority auth = authority(ROLE_PRO_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.downgradeToFree(u);
assertThat(auth.getAuthority()).isEqualTo(ROLE_USER);
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_USER);
verify(authorityRepository).save(auth);
verify(userRepository).save(u);
}
}
@Nested
@DisplayName("upgradeToPro")
class UpgradeToPro {
@Test
@DisplayName("sets ROLE_PRO_USER, mirrors roleName, and persists both")
void setsProRole() {
UserRoleService service = service();
User u = user(42L, "ivan@example.com", ROLE_USER);
Authority auth = authority(ROLE_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.upgradeToPro(u);
assertThat(auth.getAuthority()).isEqualTo(ROLE_PRO_USER);
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_PRO_USER);
verify(authorityRepository).save(auth);
verify(userRepository).save(u);
}
}
}
+2 -1
View File
@@ -187,8 +187,8 @@ subprojects {
}
// Maven Central first; mirrors below are fallbacks for niche artifacts.
mavenCentral()
maven { url = "https://build.shibboleth.net/maven/releases" }
maven { url = "https://repository.jboss.org/" }
maven { url = "https://build.shibboleth.net/maven/releases" }
}
configurations.configureEach {
@@ -585,6 +585,7 @@ repositories {
}
}
mavenCentral()
maven { url = "https://repository.jboss.org/" }
maven { url = "https://build.shibboleth.net/maven/releases" }
}
+2 -2
View File
@@ -3,14 +3,14 @@
<head>
<meta charset="UTF-8" />
<base href="%BASE_URL%" />
<link rel="icon" href="modern-logo/favicon.ico" />
<link rel="icon" href="logo/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="The Free Adobe Acrobat alternative (10M+ Downloads)"
/>
<link rel="apple-touch-icon" href="modern-logo/logo192.png" />
<link rel="apple-touch-icon" href="logo/logo192.png" />
<link rel="manifest" href="manifest.json" />
<title>Stirling PDF</title>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

@@ -1,33 +0,0 @@
<svg width="82" height="92" viewBox="0 0 82 92" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_dddd_3933_98714)">
<path d="M33.0874 84.4375L70.2191 75.6383L77.157 43.9963L33.0874 84.4375Z" fill="#D0DBDC"/>
<path d="M56.3682 0.557473C59.916 -0.147829 63.2976 2.34683 63.2422 5.67271L63.2139 8.04087L68.7031 6.95689C72.7776 6.15095 76.6575 8.97364 76.7129 12.7782L77.1016 44.0213L54.9326 64.6639L33.084 84.4374L8.27637 81.4891C4.06313 81.0356 0.875008 77.7854 0.875 73.9051V19.3036C0.875108 14.8438 4.28482 10.9886 9.08008 10.0311L56.3682 0.557473ZM46.2773 19.7586C38.7933 16.1052 27.8721 18.8006 27.8721 18.8006C19.5566 21.1692 16.6184 26.6627 15.5928 30.5682C14.8168 33.5665 15.0111 36.6907 16.1475 39.5126C18.559 45.4589 24.4073 47.2732 30.6162 49.037C38.3528 51.2221 36.8902 55.2099 36.8809 55.2352C35.9939 57.9815 33.8044 58.8885 31.9473 59.1405C30.4782 59.3421 28.9533 59.1405 27.5674 58.6366C20.0835 55.8397 17.4502 50.0448 17.4502 50.0448H16.1191L16.6182 63.0711C19.3345 64.6081 24.8785 65.8934 28.0107 66.5233C30.2837 67.002 32.6404 67.1534 34.9688 66.9266C48.1902 65.6162 48.9941 54.202 48.9941 54.202C50.4076 42.0323 38.8767 39.0091 31.8916 37.5477C30.589 37.2706 29.5911 36.9174 28.8428 36.4891C25.6828 34.7505 26.0434 30.3658 29.4805 28.8036C31.8088 27.7456 34.7749 28.0729 36.7705 28.9295C39.9027 30.2903 44.1436 35.3045 44.1436 35.3045H45.9453L46.2773 19.7586Z" fill="white"/>
<path d="M63.2779 8.03641L63.2781 57.7846L77.1651 44.2035L76.7771 11.9265C76.7494 8.65091 73.3953 6.02067 69.875 6.72618L63.2779 8.03641Z" fill="#D3D3D3"/>
</g>
<defs>
<filter id="filter0_dddd_3933_98714" x="-8.34465e-07" y="-4.17233e-07" width="81.1025" height="91.875" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.4375"/>
<feGaussianBlur stdDeviation="0.4375"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3933_98714"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="0.4375" dy="1.3125"/>
<feGaussianBlur stdDeviation="0.656251"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.09 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_3933_98714" result="effect2_dropShadow_3933_98714"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="0.875001" dy="3.0625"/>
<feGaussianBlur stdDeviation="0.875001"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="effect2_dropShadow_3933_98714" result="effect3_dropShadow_3933_98714"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="1.75" dy="5.25"/>
<feGaussianBlur stdDeviation="1.09375"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.01 0"/>
<feBlend mode="normal" in2="effect3_dropShadow_3933_98714" result="effect4_dropShadow_3933_98714"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect4_dropShadow_3933_98714" result="shape"/>
</filter>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

@@ -1,34 +0,0 @@
<svg width="82" height="92" viewBox="0 0 82 92" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.2" filter="url(#filter0_dddd_3933_98709)">
<path d="M56.3678 0.557543L9.07973 10.0315C4.2844 10.989 0.875 14.8441 0.875 19.3039V73.9053C0.875 77.7856 4.06265 81.0359 8.27589 81.4895L33.0841 84.4375L54.9327 64.6642L77.1014 44.0219L76.7133 12.778C76.6579 8.97326 72.7773 6.15122 68.7027 6.95752L63.2143 8.04098L63.2421 5.67248C63.2975 2.34651 59.9158 -0.147965 56.3678 0.557543Z" fill="#FF4B4B"/>
<path d="M63.269 8.04043V57.5772L77.1561 43.9962L76.7681 11.7192C76.7403 8.44358 73.3864 6.02469 69.8661 6.7302L63.269 8.04043Z" fill="#545454"/>
<path d="M33.0873 84.4375L70.2189 75.6383L77.1569 43.9963L33.0873 84.4375Z" fill="#D0DBDC"/>
<path d="M44.1432 35.3049C44.1432 35.3049 39.9022 30.2907 36.77 28.9301C34.7743 28.0734 31.8084 27.7459 29.48 28.8041C26.0429 30.3663 25.6826 34.7506 28.8425 36.4891C29.5909 36.9175 30.5888 37.2702 31.8915 37.5474C38.8767 39.0088 50.4076 42.0324 48.994 54.2024C48.994 54.2024 48.1901 65.6166 34.9683 66.9268C32.6399 67.1536 30.2839 67.0024 28.0109 66.5236C24.8787 65.8937 19.335 64.6087 16.6185 63.0717L16.1196 50.045H17.4501C17.4501 50.045 20.0834 55.8402 27.5674 58.6371C28.9534 59.141 30.4779 59.3426 31.947 59.141C33.8041 58.889 35.9939 57.982 36.8809 55.2355C36.8809 55.2355 38.3777 51.2292 30.6165 49.0371C24.4075 47.2733 18.5589 45.4592 16.1473 39.5127C15.0109 36.6907 14.8168 33.5663 15.593 30.5679C16.6185 26.6624 19.5567 21.1695 27.8723 18.801C27.8723 18.801 38.7935 16.105 46.2776 19.7585L45.9449 35.3049H44.1432Z" fill="white"/>
</g>
<defs>
<filter id="filter0_dddd_3933_98709" x="-8.34465e-07" y="-4.17233e-07" width="81.0944" height="91.875" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.4375"/>
<feGaussianBlur stdDeviation="0.4375"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3933_98709"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="0.4375" dy="1.3125"/>
<feGaussianBlur stdDeviation="0.656251"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.09 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_3933_98709" result="effect2_dropShadow_3933_98709"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="0.875001" dy="3.0625"/>
<feGaussianBlur stdDeviation="0.875001"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="effect2_dropShadow_3933_98709" result="effect3_dropShadow_3933_98709"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="1.75" dy="5.25"/>
<feGaussianBlur stdDeviation="1.09375"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.01 0"/>
<feBlend mode="normal" in2="effect3_dropShadow_3933_98709" result="effect4_dropShadow_3933_98709"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect4_dropShadow_3933_98709" result="shape"/>
</filter>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

@@ -1,5 +0,0 @@
<svg width="77" height="84" viewBox="0 0 77 84" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M32.2124 83.9997L69.3441 75.2005L76.282 43.5585L32.2124 83.9997Z" fill="#D0DBDC"/>
<path d="M55.4932 0.119995C59.0409 -0.585293 62.4223 1.9086 62.3672 5.23425L62.3389 7.60339L67.8281 6.51941C71.9024 5.71349 75.7821 8.53542 75.8379 12.3397L76.2266 43.5839L54.0576 64.2264L32.209 83.9999L7.40137 81.0516C3.18818 80.5981 8.70906e-05 77.3479 0 73.4677V18.8661C3.4631e-05 14.4063 3.40977 10.5511 8.20508 9.59363L55.4932 0.119995ZM45.4023 19.3212C37.9183 15.6676 26.9971 18.3632 26.9971 18.3632C18.6818 20.7316 15.7434 26.2243 14.7178 30.1298C13.9417 33.1282 14.136 36.253 15.2725 39.0751C17.684 45.0213 23.5324 46.8357 29.7412 48.5995C37.5024 50.7916 36.0059 54.7977 36.0059 54.7977C35.1189 57.544 32.9294 58.451 31.0723 58.703C29.6032 58.9046 28.0783 58.703 26.6924 58.1991C19.2266 55.4091 16.588 49.6354 16.5752 49.6073H15.2441L15.7432 62.6337C18.4595 64.1706 24.0035 65.4559 27.1357 66.0858C29.4087 66.5646 31.7654 66.7159 34.0938 66.4891C47.3152 65.1787 48.1191 53.7645 48.1191 53.7645C49.5327 41.5946 38.0017 38.5707 31.0166 37.1093C29.714 36.8321 28.7161 36.4799 27.9678 36.0516C24.8078 34.3131 25.1684 29.9283 28.6055 28.3661C30.9338 27.3081 33.8999 27.6354 35.8955 28.4921C39.0277 29.8529 43.2686 34.8671 43.2686 34.8671H45.0703L45.4023 19.3212Z" fill="white"/>
<path d="M62.394 7.60263V57.1394L76.2811 43.5584L75.8931 11.2814C75.8653 8.00577 72.5114 5.58689 68.9911 6.2924L62.394 7.60263Z" fill="#EEEEEE"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

@@ -2747,7 +2747,7 @@ summary = "Ran {{count}} tools"
unknownTool = "Unknown tool"
[cloudBadge]
tooltip = "This operation will use your cloud credits"
tooltip = "Runs in the cloud (included, no extra charge)"
[color.eyeDropper]
tooltip = "Pick colour from screen"
@@ -2756,7 +2756,6 @@ tooltip = "Pick colour from screen"
title = "Choose colour"
[common]
available = "available"
back = "Back"
cancel = "Cancel"
close = "Close"
@@ -2769,13 +2768,11 @@ error = "Error"
expand = "Expand"
loading = "Loading..."
next = "Next"
operation = "this operation"
preview = "Preview"
previous = "Previous"
refresh = "Refresh"
retry = "Retry"
save = "Save"
used = "used"
[compare]
clearSelected = "Clear selected"
@@ -3021,9 +3018,7 @@ title = "Upgrade Guest Account"
upgradeButton = "Upgrade Account"
[config.apiKeys]
chartAriaLabel = "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}"
copyKeyAriaLabel = "Copy API key"
creditsRemaining = "Credits Remaining"
description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one."
docsDescription = "Learn more about integrating with Stirling PDF:"
docsLink = "API Documentation"
@@ -3031,14 +3026,9 @@ docsTitle = "API Documentation"
generateError = "We couldn't generate your API key."
goToAccount = "Go to Account"
guestInfo = "Guest users do not receive API keys. Create an account to get an API key you can use in your applications."
includedCredits = "Included credits"
intro = "Use your API key to programmatically access Stirling PDF's processing capabilities."
label = "API Key"
lastApiUse = "Last API Use"
nextReset = "Next Reset"
overlayMessage = "Generate a key to see credits and available credits"
publicKeyAriaLabel = "Public API key"
purchasedCredits = "Purchased credits"
refreshAriaLabel = "Refresh API key"
schemaLink = "API Schema Reference"
usage = "Include this key in the X-API-KEY header with all API requests."
@@ -3222,68 +3212,25 @@ posthog = "PostHog Analytics"
scarf = "Scarf Pixel"
[credits]
enableOverageBilling = "Enable Overage Billing"
maybeLater = "Maybe later"
upgrade = "Upgrade"
[credits.insufficient]
brief = "Insufficient credits. You need {{required}} credits but have {{current}}."
freeTier = "Upgrade to Team for 10x more credits and unlimited overage billing."
managedMember = "Please contact your team leader for assistance."
message = "You do not have enough credits to run {{tool}}. You currently have {{current}} credits."
messageWithAmount = "You need {{required}} credits to run {{tool}}, but you only have {{current}}."
teamMember = "Enable overage billing to never run out of credits."
title = "Insufficient Credits"
[credits.modal]
advancedMonitoring = "Advanced monitoring"
allInOneWorkspace = "All-in-one PDF workspace (viewer, tools & agent)"
apiSandbox = "API sandbox"
contactSales = "Contact Sales"
creditsRemaining = "{{current}} of {{total}} remaining"
creditsThisMonth = "Monthly credits"
current = "Current Plan"
customPricing = "Custom"
dedicatedSupportSlas = "Dedicated support & SLAs"
enterpriseSubscription = "Enterprise"
everythingInCredits = "Everything in Credits, plus:"
everythingInFree = "Everything in Free, plus:"
fasterThroughput = "10x faster throughput"
forRegularWork = "For regular PDF work:"
freeTier = "Free Tier"
fullyPrivateFiles = "Fully private files"
largeFileProcessing = "Large file processing"
managedMemberMessage = "You have unlimited access to credits through your team. If you need assistance, please contact your team leader."
managedMemberTitle = "Unlimited Credits"
meteringBillingNote = "Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings."
meteringExplanation = "Your Team plan includes {{credits}} credits per month. When you run out, overage billing automatically provides additional credits so you never have to stop working."
meteringIncluded = "{{credits}} credits/month included with Team"
meteringNeverRunOut = "Never run out of credits"
meteringNoCommitment = "No commitment, cancel anytime"
meteringPayAsYouGo = "Only pay for what you use"
meteringPrice = "Additional credits at {{price}}/credit"
meteringTitle = "Pay-What-You-Use Overage Billing"
monthlyCredits = "monthly credits"
orgWideAccess = "Org-wide access controls"
overage = "overage"
perMonth = "/month"
popular = "Popular"
premiumAiModels = "Premium AI models"
prioritySupport = "Priority support"
privateDocCloud = "Private Document Cloud"
ragFineTuning = "RAG + fine-tuning"
secureApiAccess = "Secure API access"
selfHostLink = "Review the docs and plans"
selfHostPrompt = "Want to self host?"
standardThroughput = "Standard throughput"
subtitle = "Upgrade to Team for 10x the credits and faster processing."
subtitlePro = "Enable automatic overage billing to never run out of credits."
teamLeaderOnly = "Only team leaders can enable overage billing"
teamSubscription = "Team"
titleExhausted = "You've used your free credits"
titleExhaustedPro = "You have run out of credits"
unlimitedApiAccess = "Unlimited API access"
unlimitedMonthlyCredits = "Site Licence"
unlimitedSeats = "Unlimited seats"
[crop]
@@ -5394,11 +5341,7 @@ resetsTomorrow = "Resets tomorrow"
thisPeriod = "This billing period"
[payment]
autoClose = "This window will close automatically..."
canCloseWindow = "You can now close this window."
checkoutInstructions = "Complete your purchase in the browser window that just opened. After payment is complete, return here and click the button below to refresh your billing information."
checkoutOpened = "Checkout Opened in Browser"
closeLater = "I'll Do This Later"
error = "Payment Error"
generatingLicense = "Generating your licence key..."
licenseActivated = "Licence activated! Your licence key has been saved. A confirmation email has been sent to your registered email address."
@@ -5414,7 +5357,6 @@ paymentCanceled = "Payment was cancelled. No charges were made."
perMonth = "/month"
preparing = "Preparing your checkout..."
redirecting = "Redirecting to secure checkout..."
refreshBilling = "I've Completed Payment - Refresh Billing"
success = "Payment Successful!"
successMessage = "Your subscription has been activated successfully. You will receive a confirmation email shortly."
syncError = "Payment successful but licence sync failed. Your licence will be updated shortly. Please contact support if issues persist."
@@ -5704,13 +5646,10 @@ title = "Pipeline"
[plan]
contact = "Contact Us"
currency = "Currency"
current = "Current Plan"
customPricing = "Custom"
featureComparison = "Feature Comparison"
from = "From"
hideComparison = "Hide Feature Comparison"
included = "Included"
licensedSeats = "Licensed: {{count}} seats"
manage = "Manage"
perMonth = "/month"
@@ -5728,7 +5667,6 @@ small = "500 Credits"
xsmall = "100 Credits"
[plan.availablePlans]
loadError = "Unable to load plan pricing. Using default values."
subtitle = "Choose the plan that fits your needs"
title = "Available Plans"
@@ -5739,7 +5677,6 @@ highlight3 = "Latest features"
name = "Enterprise"
requiresServer = "Requires Server"
requiresServerMessage = "Please upgrade to the Server plan first before upgrading to Enterprise."
siteLicense = "Site Licence"
[plan.feature]
api = "API Access"
@@ -5846,16 +5783,9 @@ success = "Licence Activated!"
successMessage = "Your licence has been successfully activated. You can now close this window."
[plan.team]
name = "Team"
[plan.trial]
continueWithFree = "Continue with Free"
expired = "Your Trial Has Ended"
expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier."
freeTierLimitations = "Free tier includes basic PDF tools with usage limits."
message = ""
subscribe = "Subscribe to Pro"
subscribeToPro = "Subscribe to Pro"
[policies]
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
@@ -1034,14 +1034,6 @@ description = "Languages that users can select from (leave empty to enable all l
label = "Available Languages"
placeholder = "Select languages"
[admin.settings.general.logoStyle]
classic = "Classic"
classicAlt = "Classic logo"
description = "Set the default logo style for all users on this server. Users can override this setting in their personal preferences."
label = "Logo Style"
modern = "Modern"
modernAlt = "Modern logo"
[admin.settings.general.showUpdate]
description = "Display notifications when a new version is available"
label = "Show Update Notifications"
@@ -2730,7 +2722,6 @@ tooltip = "Pick color from screen"
title = "Choose color"
[common]
available = "available"
back = "Back"
cancel = "Cancel"
close = "Close"
@@ -2743,13 +2734,11 @@ error = "Error"
expand = "Expand"
loading = "Loading..."
next = "Next"
operation = "this operation"
preview = "Preview"
previous = "Previous"
refresh = "Refresh"
retry = "Retry"
save = "Save"
used = "used"
[compare]
clearSelected = "Clear selected"
@@ -2995,9 +2984,7 @@ title = "Upgrade Guest Account"
upgradeButton = "Upgrade Account"
[config.apiKeys]
chartAriaLabel = "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}"
copyKeyAriaLabel = "Copy API key"
creditsRemaining = "Credits Remaining"
description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one."
docsDescription = "Learn more about integrating with Stirling PDF:"
docsLink = "API Documentation"
@@ -3005,14 +2992,9 @@ docsTitle = "API Documentation"
generateError = "We couldn't generate your API key."
goToAccount = "Go to Account"
guestInfo = "Guest users do not receive API keys. Create an account to get an API key you can use in your applications."
includedCredits = "Included credits"
intro = "Use your API key to programmatically access Stirling PDF's processing capabilities."
label = "API Key"
lastApiUse = "Last API Use"
nextReset = "Next Reset"
overlayMessage = "Generate a key to see credits and available credits"
publicKeyAriaLabel = "Public API key"
purchasedCredits = "Purchased credits"
refreshAriaLabel = "Refresh API key"
schemaLink = "API Schema Reference"
usage = "Include this key in the X-API-KEY header with all API requests."
@@ -3196,68 +3178,25 @@ posthog = "PostHog Analytics"
scarf = "Scarf Pixel"
[credits]
enableOverageBilling = "Enable Overage Billing"
maybeLater = "Maybe later"
upgrade = "Upgrade"
[credits.insufficient]
brief = "Insufficient credits. You need {{required}} credits but have {{current}}."
freeTier = "Upgrade to Team for 10x more credits and unlimited overage billing."
managedMember = "Please contact your team leader for assistance."
message = "You do not have enough credits to run {{tool}}. You currently have {{current}} credits."
messageWithAmount = "You need {{required}} credits to run {{tool}}, but you only have {{current}}."
teamMember = "Enable overage billing to never run out of credits."
title = "Insufficient Credits"
[credits.modal]
advancedMonitoring = "Advanced monitoring"
allInOneWorkspace = "All-in-one PDF workspace (viewer, tools & agent)"
apiSandbox = "API sandbox"
contactSales = "Contact Sales"
creditsRemaining = "{{current}} of {{total}} remaining"
creditsThisMonth = "Monthly credits"
current = "Current Plan"
customPricing = "Custom"
dedicatedSupportSlas = "Dedicated support & SLAs"
enterpriseSubscription = "Enterprise"
everythingInCredits = "Everything in Credits, plus:"
everythingInFree = "Everything in Free, plus:"
fasterThroughput = "10x faster throughput"
forRegularWork = "For regular PDF work:"
freeTier = "Free Tier"
fullyPrivateFiles = "Fully private files"
largeFileProcessing = "Large file processing"
managedMemberMessage = "You have unlimited access to credits through your team. If you need assistance, please contact your team leader."
managedMemberTitle = "Unlimited Credits"
meteringBillingNote = "Overage credits are billed monthly alongside your Team subscription. Track your usage anytime in your account settings."
meteringExplanation = "Your Team plan includes {{credits}} credits per month. When you run out, overage billing automatically provides additional credits so you never have to stop working."
meteringIncluded = "{{credits}} credits/month included with Team"
meteringNeverRunOut = "Never run out of credits"
meteringNoCommitment = "No commitment, cancel anytime"
meteringPayAsYouGo = "Only pay for what you use"
meteringPrice = "Additional credits at {{price}}/credit"
meteringTitle = "Pay-What-You-Use Overage Billing"
monthlyCredits = "monthly credits"
orgWideAccess = "Org-wide access controls"
overage = "overage"
perMonth = "/month"
popular = "Popular"
premiumAiModels = "Premium AI models"
prioritySupport = "Priority support"
privateDocCloud = "Private Document Cloud"
ragFineTuning = "RAG + fine-tuning"
secureApiAccess = "Secure API access"
selfHostLink = "Review the docs and plans"
selfHostPrompt = "Want to self host?"
standardThroughput = "Standard throughput"
subtitle = "Upgrade to Team for 10x the credits and faster processing."
subtitlePro = "Enable automatic overage billing to never run out of credits."
teamLeaderOnly = "Only team leaders can enable overage billing"
teamSubscription = "Team"
titleExhausted = "You've used your free credits"
titleExhaustedPro = "You have run out of credits"
unlimitedApiAccess = "Unlimited API access"
unlimitedMonthlyCredits = "Site License"
unlimitedSeats = "Unlimited seats"
[crop]
@@ -5363,11 +5302,7 @@ resetsTomorrow = "Resets tomorrow"
thisPeriod = "This billing period"
[payment]
autoClose = "This window will close automatically..."
canCloseWindow = "You can now close this window."
checkoutInstructions = "Complete your purchase in the browser window that just opened. After payment is complete, return here and click the button below to refresh your billing information."
checkoutOpened = "Checkout Opened in Browser"
closeLater = "I'll Do This Later"
error = "Payment Error"
generatingLicense = "Generating your license key..."
licenseActivated = "License activated! Your license key has been saved. A confirmation email has been sent to your registered email address."
@@ -5383,7 +5318,6 @@ paymentCanceled = "Payment was canceled. No charges were made."
perMonth = "/month"
preparing = "Preparing your checkout..."
redirecting = "Redirecting to secure checkout..."
refreshBilling = "I've Completed Payment - Refresh Billing"
success = "Payment Successful!"
successMessage = "Your subscription has been activated successfully. You will receive a confirmation email shortly."
syncError = "Payment successful but license sync failed. Your license will be updated shortly. Please contact support if issues persist."
@@ -5673,13 +5607,10 @@ title = "Pipeline"
[plan]
contact = "Contact Us"
currency = "Currency"
current = "Current Plan"
customPricing = "Custom"
featureComparison = "Feature Comparison"
from = "From"
hideComparison = "Hide Feature Comparison"
included = "Included"
licensedSeats = "Licensed: {{count}} seats"
manage = "Manage"
perMonth = "/month"
@@ -5697,7 +5628,6 @@ small = "500 Credits"
xsmall = "100 Credits"
[plan.availablePlans]
loadError = "Unable to load plan pricing. Using default values."
subtitle = "Choose the plan that fits your needs"
title = "Available Plans"
@@ -5708,7 +5638,6 @@ highlight3 = "Latest features"
name = "Enterprise"
requiresServer = "Requires Server"
requiresServerMessage = "Please upgrade to the Server plan first before upgrading to Enterprise."
siteLicense = "Site License"
[plan.feature]
api = "API Access"
@@ -5815,15 +5744,8 @@ success = "License Activated!"
successMessage = "Your license has been successfully activated. You can now close this window."
[plan.team]
name = "Team"
[plan.trial]
continueWithFree = "Continue with Free"
expired = "Your Trial Has Ended"
expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier."
freeTierLimitations = "Free tier includes basic PDF tools with usage limits."
subscribe = "Subscribe to Pro"
subscribeToPro = "Subscribe to Pro"
[policies]
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Before

Width:  |  Height:  |  Size: 253 B

After

Width:  |  Height:  |  Size: 253 B

Before

Width:  |  Height:  |  Size: 253 B

After

Width:  |  Height:  |  Size: 253 B

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 339 B

After

Width:  |  Height:  |  Size: 339 B

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

@@ -1,25 +0,0 @@
{
"short_name": "Stirling PDF",
"name": "Stirling PDF",
"icons": [
{
"src": "classic-logo/favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "classic-logo/logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "classic-logo/logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
+3 -3
View File
@@ -3,17 +3,17 @@
"name": "Stirling PDF",
"icons": [
{
"src": "modern-logo/favicon.ico",
"src": "logo/favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "modern-logo/logo192.png",
"src": "logo/logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "modern-logo/logo512.png",
"src": "logo/logo512.png",
"type": "image/png",
"sizes": "512x512"
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.2 KiB

@@ -11,27 +11,27 @@
<div class="page page-1">
<div class="decorative-shapes">
<img
src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg"
src="../../public/logo/StirlingPDFLogoNoTextLight.svg"
class="shape shape-1"
alt=""
/>
<img
src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg"
src="../../public/logo/StirlingPDFLogoNoTextDark.svg"
class="shape shape-2"
alt=""
/>
<img
src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg"
src="../../public/logo/StirlingPDFLogoNoTextLight.svg"
class="shape shape-3"
alt=""
/>
<img
src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg"
src="../../public/logo/StirlingPDFLogoNoTextDark.svg"
class="shape shape-4"
alt=""
/>
<img
src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg"
src="../../public/logo/StirlingPDFLogoNoTextLight.svg"
class="shape shape-5"
alt=""
/>
@@ -39,7 +39,7 @@
<div class="hero-content">
<div class="logo-container">
<img
src="../../public/modern-logo/StirlingPDFLogoWhiteText.svg"
src="../../public/logo/StirlingPDFLogoWhiteText.svg"
alt="Stirling PDF"
class="hero-logo"
/>

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