Compare commits

..
Author SHA1 Message Date
Reece 082bcc34c2 refactor(shared): extract FileDocIcon + CloudStorageIcons into @shared with stories
Move two zero-coupling icon components out of the editor into the shared SUI
library (frontend/shared/components), export them from the barrel, and add
Storybook stories. Editor consumers now import them from @shared instead of
@app. Both are fully self-contained (pure SVG, no app context, inline colours /
caller-supplied className), so the move is mechanical and nothing else changes.

First slice of an incremental extraction; it establishes the move + re-point +
Storybook pattern to repeat for further components.
2026-06-17 15:38:53 +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
176 changed files with 16569 additions and 7294 deletions
+7
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
+23 -1
View File
@@ -169,7 +169,29 @@ 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/`).
#### Component Override Pattern (Stub/Shadow)
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
+2
View File
@@ -16,6 +16,8 @@ if that directory exists, is licensed under the license defined in "frontend/edi
if that directory exists, is licensed under the license defined in "frontend/editor/src/desktop/LICENSE".
* All content that resides under the "frontend/editor/src/saas/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/saas/LICENSE".
* All content that resides under the "frontend/editor/src/cloud/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE".
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
* All content that resides under the "frontend/portal/" directory of this repository,
+1 -1
View File
@@ -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());
}
}
}
@@ -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");
}
}
@@ -260,7 +260,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 +270,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: {}."
@@ -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,802 @@
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.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.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.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.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.User;
import stirling.software.saas.ai.service.AiProxyService;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.CreditHeaderUtils;
/**
* 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, includeCreditsHeader)} 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, the 503 error fallback, and the credit-header path keyed off {@code
* includeCreditsHeader}.
* </ol>
*
* <p>The credit-header branch reads {@code SecurityContextHolder}, so the relevant tests seed an
* authentication and {@link #clearSecurityContext()} resets it afterwards.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class AiProxyControllerTest {
@Mock private AiProxyService aiProxyService;
@Mock private CreditService creditService;
@Mock private TeamCreditService teamCreditService;
@Mock private UserRepository userRepository;
@Mock private CreditHeaderUtils creditHeaderUtils;
private AiProxyController controller;
@org.junit.jupiter.api.BeforeEach
void setUp() {
controller =
new AiProxyController(
aiProxyService,
creditService,
teamCreditService,
userRepository,
creditHeaderUtils);
}
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
// ----------------------------------------------------------------------------------------------
// Endpoint path/method mapping — each handler pins the exact upstream contract it forwards.
// None of these endpoints request the credit header except chat/* and edit message; see the
// dedicated credit-header section for those.
// ----------------------------------------------------------------------------------------------
@Nested
@DisplayName("endpoint → upstream (method, path, acceptEventStream) mapping")
class EndpointMapping {
@Test
@DisplayName("generateSection POSTs to /api/generate_section, non-stream, no credit header")
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);
assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
}
@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("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("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 (and no credit header).
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");
}
@Test
@DisplayName("a credit-bearing endpoint failing in forward never reaches the credit path")
void creditEndpointFailure_skipsCreditWork() throws Exception {
HttpServletRequest req = req();
authenticateWeb(userWithTeam(7L, 100L));
when(aiProxyService.forward(any(), any(), any(), anyBoolean()))
.thenThrow(new java.io.IOException("down"));
ResponseEntity<StreamingResponseBody> resp = controller.chatRoute(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
// The credit header is only added after a successful forward.
verifyNoInteractions(creditHeaderUtils);
assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
}
}
// ----------------------------------------------------------------------------------------------
// Credit headers — only the chat/* and edit-message endpoints set includeCreditsHeader = true.
// ----------------------------------------------------------------------------------------------
@Nested
@DisplayName(
"credit-header endpoints (chatRoute / createSmartFolder / chatInfo / editSessionMessage)")
class CreditHeaders {
@Test
@DisplayName(
"chatRoute POSTs /api/chat/route and adds X-Credits-Remaining for an authed user")
void chatRoute_addsCreditHeaders() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
.thenReturn(42);
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("reply"));
ResponseEntity<StreamingResponseBody> resp = controller.chatRoute(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("42");
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL");
verify(aiProxyService).forward("POST", "/api/chat/route", req, false);
}
@Test
@DisplayName("createSmartFolder POSTs /api/chat/create-smart-folder with the credit header")
void createSmartFolder_addsCreditHeaders() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
.thenReturn(5);
HttpServletRequest req = req();
stubForward("POST", "/api/chat/create-smart-folder", req, false, ok("folder"));
ResponseEntity<StreamingResponseBody> resp = controller.createSmartFolder(req);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("5");
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL");
verify(aiProxyService).forward("POST", "/api/chat/create-smart-folder", req, false);
}
@Test
@DisplayName("chatInfo POSTs /api/chat/info with the credit header")
void chatInfo_addsCreditHeaders() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
.thenReturn(3);
HttpServletRequest req = req();
stubForward("POST", "/api/chat/info", req, false, ok("info"));
ResponseEntity<StreamingResponseBody> resp = controller.chatInfo(req);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("3");
verify(aiProxyService).forward("POST", "/api/chat/info", req, false);
}
@Test
@DisplayName(
"editSessionMessage POSTs /api/edit/sessions/{id}/messages with the credit header")
void editSessionMessage_addsCreditHeaders() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
.thenReturn(99);
HttpServletRequest req = req();
stubForward("POST", "/api/edit/sessions/sess-9/messages", req, false, ok("msg"));
ResponseEntity<StreamingResponseBody> resp =
controller.editSessionMessage("sess-9", req);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("99");
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL");
verify(aiProxyService)
.forward("POST", "/api/edit/sessions/sess-9/messages", req, false);
}
@Test
@DisplayName("zero remaining credits still emits the header (>= 0 boundary)")
void zeroCredits_emitsHeader() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
.thenReturn(0);
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("reply"));
ResponseEntity<StreamingResponseBody> resp = controller.chatRoute(req);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("0");
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL");
}
@Test
@DisplayName(
"negative remaining credits omits X-Credits-Remaining but still sets the source")
void negativeCredits_omitsRemainingButKeepsSource() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
.thenReturn(-1);
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("reply"));
ResponseEntity<StreamingResponseBody> resp = controller.chatRoute(req);
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
// X-Credit-Source is set unconditionally once we reach the credit path.
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("AI_TOOL_CALL");
}
@Test
@DisplayName(
"no authentication: credit work is skipped, no credit headers, response still streams")
void noAuth_skipsCreditHeaders() throws Exception {
SecurityContextHolder.clearContext();
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("reply"));
ResponseEntity<StreamingResponseBody> resp = controller.chatRoute(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
verifyNoInteractions(creditHeaderUtils);
assertThat(drain(resp.getBody())).isEqualTo("reply");
}
@Test
@DisplayName("an unauthenticated token (isAuthenticated()==false) is treated as no-auth")
void unauthenticatedToken_skipsCreditHeaders() throws Exception {
// 2-arg ctor → isAuthenticated() == false, so the credit branch short-circuits.
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken("alice", "pw");
SecurityContextHolder.getContext().setAuthentication(token);
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("reply"));
ResponseEntity<StreamingResponseBody> resp = controller.chatRoute(req);
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
verifyNoInteractions(creditHeaderUtils);
}
@Test
@DisplayName("credit-header work blowing up is swallowed; the response still streams")
void creditLookupThrows_stillStreams() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(any(), any(), any()))
.thenThrow(new RuntimeException("boom"));
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("payload"));
ResponseEntity<StreamingResponseBody> resp = controller.chatRoute(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
// The catch in addCreditHeaders runs before X-Credit-Source is set, so neither lands.
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
assertThat(drain(resp.getBody())).isEqualTo("payload");
}
@Test
@DisplayName("resolving the current user failing is swallowed; the response still streams")
void getCurrentUserThrows_stillStreams() throws Exception {
// EnhancedJwtAuthenticationToken is the only auth type with a getCurrentUser DB lookup,
// but a plain authed token whose principal is a User short-circuits there. To exercise
// the swallow we make the downstream credit lookup throw (covered above); here we
// assert
// that a non-User principal that can't be resolved doesn't break the stream.
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken("alice", "pw", List.of());
// principal is the String "alice"; getCurrentUser will hit
// userRepository.findByUsername.
when(userRepository.findByUsername("alice")).thenReturn(java.util.Optional.empty());
SecurityContextHolder.getContext().setAuthentication(token);
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("payload"));
ResponseEntity<StreamingResponseBody> resp = controller.chatRoute(req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
assertThat(drain(resp.getBody())).isEqualTo("payload");
verifyNoInteractions(creditHeaderUtils);
}
@Test
@DisplayName(
"an authed User principal is passed straight to the credit utils (no repo lookup)")
void authedUserPrincipal_passedToCreditUtils() throws Exception {
User user = userWithTeam(7L, 100L);
authenticateWeb(user);
when(creditHeaderUtils.getRemainingCredits(any(), any(), any())).thenReturn(11);
HttpServletRequest req = req();
stubForward("POST", "/api/chat/route", req, false, ok("reply"));
controller.chatRoute(req);
// The principal User is forwarded verbatim alongside both credit services.
verify(creditHeaderUtils).getRemainingCredits(user, creditService, teamCreditService);
verifyNoInteractions(userRepository);
}
}
// ----------------------------------------------------------------------------------------------
// 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 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 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
}
};
}
}
@@ -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);
}
}
}
}
@@ -0,0 +1,725 @@
package stirling.software.saas.interceptor;
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.anyInt;
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.util.List;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.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.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import stirling.software.proprietary.model.Team;
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.CreditHeaderUtils;
/**
* Unit tests for {@link CreditErrorAdvice}.
*
* <p>The advice is a {@code @RestControllerAdvice} that maps thrown exceptions to a status/body
* and, when the request was flagged eligible (and not already charged), consumes a credit through
* either the team pool or the individual waterfall. Collaborators are mocked; the {@link
* MeterRegistry} is a real {@link SimpleMeterRegistry} so the {@code credits.consumed} counter is
* exercised.
*
* <p>Authentication is driven through {@link SecurityContextHolder} using a 3-arg {@code
* UsernamePasswordAuthenticationToken} (authenticated=true) whose principal is the live {@link
* User} object, so {@code AuthenticationUtils.getCurrentUser} resolves it directly via {@code
* instanceof User} without touching the repository.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CreditErrorAdviceTest {
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 static final String ATTR_IS_API = "IS_API_REQUEST";
private static final String API_KEY = "apikey-abcdefgh";
private static final String URI = "/api/v1/convert/pdf-to-img";
@Mock private CreditService creditService;
@Mock private TeamCreditService teamCreditService;
@Mock private UserRepository userRepository;
@Mock private ErrorTrackingService errorTrackingService;
@Mock private SaasTeamExtensionService saasTeamExtensionService;
@Mock private CreditHeaderUtils creditHeaderUtils;
private MeterRegistry meterRegistry;
private CreditErrorAdvice advice;
@BeforeEach
void setUp() {
meterRegistry = new SimpleMeterRegistry();
advice =
new CreditErrorAdvice(
creditService,
teamCreditService,
userRepository,
errorTrackingService,
saasTeamExtensionService,
creditHeaderUtils,
meterRegistry);
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
// --- helpers --------------------------------------------------------------------------------
private static User user(String username) {
User u = new User();
u.setUsername(username);
return u;
}
private static Team team(Long id) {
Team t = new Team();
t.setId(id);
return t;
}
/** Put the user on the SecurityContext as an authenticated principal. */
private void authenticate(User user) {
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(
user, null, List.of(new SimpleGrantedAuthority("ROLE_USER")));
SecurityContextHolder.getContext().setAuthentication(token);
}
/** Base request that is credit-eligible with an api key, resource weight 1 and not charged. */
private MockHttpServletRequest eligibleRequest() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI(URI);
req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
req.setAttribute(ATTR_APIKEY, API_KEY);
req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(1));
return req;
}
@SuppressWarnings("unchecked")
private static Map<String, Object> bodyOf(ResponseEntity<Object> resp) {
return (Map<String, Object>) resp.getBody();
}
private double counter() {
return meterRegistry.get("credits.consumed").counter().count();
}
// --- status mapping -------------------------------------------------------------------------
@Nested
@DisplayName("determineHttpStatus mapping (via handleThrowable)")
class StatusMapping {
@Test
@DisplayName("IllegalArgumentException -> 400 BAD_REQUEST")
void illegalArgument_400() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp =
advice.handleThrowable(req, new IllegalArgumentException("bad"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(bodyOf(resp))
.containsEntry("error", "IllegalArgumentException")
.containsEntry("message", "bad")
.containsEntry("status", 400);
}
@Test
@DisplayName("AccessDeniedException -> 403 FORBIDDEN")
void accessDenied_403() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp =
advice.handleThrowable(
req,
new org.springframework.security.access.AccessDeniedException("nope"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
@DisplayName("UsernameNotFoundException -> 401 UNAUTHORIZED")
void usernameNotFound_401() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp =
advice.handleThrowable(
req,
new org.springframework.security.core.userdetails
.UsernameNotFoundException("who"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@DisplayName("UnsupportedOperationException -> 501 NOT_IMPLEMENTED")
void unsupported_501() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp =
advice.handleThrowable(req, new UnsupportedOperationException("later"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_IMPLEMENTED);
}
@Test
@DisplayName("unknown exception with no message clue -> 500 INTERNAL_SERVER_ERROR")
void unknown_500() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("boom"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
}
@Test
@DisplayName("message containing 'not found' -> 404 NOT_FOUND")
void messageNotFound_404() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp =
advice.handleThrowable(req, new RuntimeException("Resource not found here"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
@DisplayName("message containing 'validation' -> 400 BAD_REQUEST")
void messageValidation_400() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp =
advice.handleThrowable(req, new RuntimeException("Validation of input failed"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("message containing 'invalid parameter' -> 400 BAD_REQUEST")
void messageInvalidParameter_400() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp =
advice.handleThrowable(req, new RuntimeException("invalid parameter: x"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
@DisplayName("null message falls back to 'An error occurred' and 500")
void nullMessage_default() {
MockHttpServletRequest req = new MockHttpServletRequest();
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException());
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(bodyOf(resp)).containsEntry("message", "An error occurred");
}
}
// --- no credit handling (gates closed) ------------------------------------------------------
@Nested
@DisplayName("credit handling gate is closed -> no consumption")
class GateClosed {
@Test
@DisplayName("request not eligible: no error tracking, no consumption")
void notEligible_noConsumption() {
MockHttpServletRequest req = new MockHttpServletRequest();
// ATTR_ELIGIBLE absent
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
verifyNoInteractions(errorTrackingService, creditService, teamCreditService);
assertThat(counter()).isZero();
}
@Test
@DisplayName("eligible but already charged and unauthenticated: no consumption, no header")
void alreadyCharged_unauthenticated_noHeader() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_CHARGED, Boolean.TRUE);
// no SecurityContext authentication
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
verifyNoInteractions(errorTrackingService, creditService, teamCreditService);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
assertThat(counter()).isZero();
}
@Test
@DisplayName(
"eligible, null api key: error tracking is skipped entirely (apiKey != null guard)")
void eligibleNullApiKey_noTracking() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI(URI);
req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(1));
// no ATTR_APIKEY -> apiKey is null
advice.handleThrowable(req, new RuntimeException("x"));
verifyNoInteractions(errorTrackingService, creditService, teamCreditService);
assertThat(counter()).isZero();
}
@Test
@DisplayName("eligible with api key but tracking says do NOT consume: no charge")
void trackingSaysNo_noConsumption() {
MockHttpServletRequest req = eligibleRequest();
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(false);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
verify(errorTrackingService)
.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), eq(500));
verifyNoInteractions(creditService, teamCreditService);
assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
assertThat(counter()).isZero();
}
}
// --- individual (waterfall) consumption -----------------------------------------------------
@Nested
@DisplayName("individual credit consumption (no team)")
class IndividualConsumption {
@Test
@DisplayName("waterfall success: marks charged, increments counter, sets both headers")
void waterfallSuccess_chargesAndHeaders() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(3));
req.setAttribute(ATTR_IS_API, Boolean.TRUE);
User u = user("alice");
authenticate(u);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(creditService.consumeCreditWithWaterfall(u, 3, true))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(42);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
assertThat(counter()).isEqualTo(1.0d);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("42");
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("CYCLE_CREDITS");
verify(creditService).consumeCreditWithWaterfall(u, 3, true);
verify(teamCreditService, never()).consumeCredit(any(), anyInt());
}
@Test
@DisplayName("resource weight absent defaults credit amount to 1")
void weightAbsent_defaultsToOne() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setRequestURI(URI);
req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
req.setAttribute(ATTR_APIKEY, API_KEY);
// no resource weight, no IS_API_REQUEST -> isApiRequestFlag false
User u = user("bob");
authenticate(u);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("BOUGHT_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(0);
advice.handleThrowable(req, new RuntimeException("x"));
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
}
@Test
@DisplayName("waterfall failure: not charged, counter stays zero, no headers")
void waterfallFailure_notCharged() {
MockHttpServletRequest req = eligibleRequest();
User u = user("carol");
authenticate(u);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.failure("INSUFFICIENT_CREDITS"));
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
assertThat(counter()).isZero();
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isNull();
}
@Test
@DisplayName("success but negative remaining: charged + source header, no remaining header")
void successNegativeRemaining_noRemainingHeader() {
MockHttpServletRequest req = eligibleRequest();
User u = user("dave");
authenticate(u);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("METERED_SUBSCRIPTION"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(-1);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
assertThat(counter()).isEqualTo(1.0d);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
assertThat(resp.getHeaders().getFirst("X-Credit-Source"))
.isEqualTo("METERED_SUBSCRIPTION");
}
@Test
@DisplayName("success with null source: charged, remaining header set, no source header")
void successNullSource_noSourceHeader() {
MockHttpServletRequest req = eligibleRequest();
User u = user("erin");
authenticate(u);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
// success=true but source=null (unusual but defensively handled by the advice)
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(new CreditConsumptionResult(true, null, "ok"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(7);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("7");
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isNull();
}
@Test
@DisplayName("personal team is treated as no team -> waterfall, not team pool")
void personalTeam_usesWaterfall() {
MockHttpServletRequest req = eligibleRequest();
User u = user("frank");
u.setTeam(team(99L));
authenticate(u);
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(true);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(5);
advice.handleThrowable(req, new RuntimeException("x"));
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
verify(teamCreditService, never()).consumeCredit(any(), anyInt());
}
}
// --- team consumption -----------------------------------------------------------------------
@Nested
@DisplayName("team credit consumption (non-personal team)")
class TeamConsumption {
@Test
@DisplayName("non-personal team success: consumes from team pool, source TEAM_CREDITS")
void teamSuccess_consumesTeamPool() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(2));
User u = user("gina");
u.setTeam(team(77L));
authenticate(u);
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(teamCreditService.consumeCredit(77L, 2)).thenReturn(true);
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(100);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
assertThat(counter()).isEqualTo(1.0d);
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("TEAM_CREDITS");
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("100");
verify(teamCreditService).consumeCredit(77L, 2);
verify(creditService, never())
.consumeCreditWithWaterfall(any(), anyInt(), anyBoolean());
}
@Test
@DisplayName("non-personal team failure: not charged, counter stays zero")
void teamFailure_notCharged() {
MockHttpServletRequest req = eligibleRequest();
User u = user("hank");
u.setTeam(team(55L));
authenticate(u);
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(teamCreditService.consumeCredit(55L, 1)).thenReturn(false);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
assertThat(counter()).isZero();
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isNull();
verify(creditService, never())
.consumeCreditWithWaterfall(any(), anyInt(), anyBoolean());
}
}
// --- user resolution edge cases -------------------------------------------------------------
@Nested
@DisplayName("user resolution edge cases (tracking says consume)")
class UserResolution {
@Test
@DisplayName("no authentication: getCurrentUser throws, user null -> no consumption")
void noAuth_userNull_noConsumption() {
MockHttpServletRequest req = eligibleRequest();
// no SecurityContext authentication -> AuthenticationUtils throws SecurityException
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
assertThat(counter()).isZero();
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
verifyNoInteractions(creditService, teamCreditService);
}
}
// --- already-charged header path ------------------------------------------------------------
@Nested
@DisplayName("already-charged path sets remaining header when authenticated")
class AlreadyCharged {
@Test
@DisplayName(
"already charged + authenticated + non-negative remaining: header set, no consumption")
void alreadyCharged_authenticated_setsHeader() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_CHARGED, Boolean.TRUE);
User u = user("ian");
authenticate(u);
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(9);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("9");
// Already-charged branch never records an error or consumes again.
verifyNoInteractions(errorTrackingService, teamCreditService);
verify(creditService, never())
.consumeCreditWithWaterfall(any(), anyInt(), anyBoolean());
assertThat(counter()).isZero();
}
@Test
@DisplayName("already charged + authenticated + negative remaining: no header")
void alreadyCharged_negativeRemaining_noHeader() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_CHARGED, Boolean.TRUE);
User u = user("jane");
authenticate(u);
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(-1);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
}
@Test
@DisplayName("already charged: header lookup throwing is swallowed (no propagation)")
void alreadyCharged_headerLookupThrows_swallowed() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_CHARGED, Boolean.TRUE);
User u = user("kyle");
authenticate(u);
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenThrow(new RuntimeException("header boom"));
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
// Must not propagate; status still computed and no remaining header set.
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isNull();
}
}
// --- SSE response shaping --------------------------------------------------------------------
@Nested
@DisplayName("SSE response shaping")
class SseShaping {
@Test
@DisplayName("Accept text/event-stream: body is SSE framed text with event: error")
void acceptHeader_producesSseBody() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.addHeader("Accept", MediaType.TEXT_EVENT_STREAM_VALUE);
ResponseEntity<Object> resp =
advice.handleThrowable(req, new IllegalArgumentException("bad"));
assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM);
assertThat(resp.getBody()).isInstanceOf(String.class);
String sse = (String) resp.getBody();
assertThat(sse).startsWith("event: error\ndata: ").endsWith("\n\n");
assertThat(sse).contains("\"error\":\"IllegalArgumentException\"");
assertThat(sse).contains("\"status\":400");
}
@Test
@DisplayName("Content-Type text/event-stream also triggers SSE framing")
void contentTypeHeader_producesSseBody() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("boom"));
assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM);
assertThat((String) resp.getBody()).startsWith("event: error\ndata: ");
}
@Test
@DisplayName("non-SSE request returns the Map body, not SSE text")
void nonSse_returnsMapBody() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.addHeader("Accept", MediaType.APPLICATION_JSON_VALUE);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("boom"));
assertThat(resp.getBody()).isInstanceOf(Map.class);
assertThat(resp.getHeaders().getContentType()).isNull();
}
@Test
@DisplayName(
"SSE framing carries through after a successful team charge (headers + SSE body)")
void sseWithTeamCharge_headersAndSseBody() {
MockHttpServletRequest req = eligibleRequest();
req.addHeader("Accept", MediaType.TEXT_EVENT_STREAM_VALUE);
User u = user("liz");
u.setTeam(team(33L));
authenticate(u);
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(teamCreditService.consumeCredit(33L, 1)).thenReturn(true);
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(8);
ResponseEntity<Object> resp = advice.handleThrowable(req, new RuntimeException("x"));
assertThat(resp.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM);
assertThat(resp.getHeaders().getFirst("X-Credit-Source")).isEqualTo("TEAM_CREDITS");
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("8");
assertThat((String) resp.getBody()).startsWith("event: error\ndata: ");
}
}
// --- counter accumulation across calls -------------------------------------------------------
@Test
@DisplayName("counter accumulates across multiple successful charges")
void counterAccumulates() {
User u = user("mike");
authenticate(u);
when(errorTrackingService.recordErrorAndShouldConsumeCredit(
eq(API_KEY), eq(URI), any(Throwable.class), anyInt()))
.thenReturn(true);
when(creditService.consumeCreditWithWaterfall(eq(u), eq(1), eq(false)))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(3);
advice.handleThrowable(eligibleRequest(), new RuntimeException("a"));
advice.handleThrowable(eligibleRequest(), new RuntimeException("b"));
assertThat(counter()).isEqualTo(2.0d);
verify(creditService, times(2)).consumeCreditWithWaterfall(u, 1, false);
}
@Test
@DisplayName("ErrorResponse value holder wires its fields verbatim")
void errorResponseHolder() {
CreditErrorAdvice.ErrorResponse er =
new CreditErrorAdvice.ErrorResponse("Boom", "it broke", 500);
assertThat(er.error).isEqualTo("Boom");
assertThat(er.message).isEqualTo("it broke");
assertThat(er.status).isEqualTo(500);
}
}
@@ -0,0 +1,679 @@
package stirling.software.saas.interceptor;
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.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
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 org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import stirling.software.proprietary.model.Team;
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.CreditHeaderUtils;
/**
* Unit tests for {@link CreditSuccessAdvice}.
*
* <p>The advice is a {@code @RestControllerAdvice} {@code ResponseBodyAdvice} that, on a successful
* (status &lt; 400) response previously flagged credit-eligible (and not already charged), consumes
* a credit either from the team pool (non-personal team) or via the individual waterfall, then sets
* {@code X-Credits-Remaining} / {@code X-Credit-Source} headers and increments a {@code
* credits.consumed} counter. Collaborators are mocked; the {@link MeterRegistry} is a real {@link
* SimpleMeterRegistry} so the counter is exercised.
*
* <p>{@link ServerHttpRequest}/response are constructed by wrapping {@link MockHttpServletRequest}
* and {@link MockHttpServletResponse} in {@link ServletServerHttpRequest}/{@link
* ServletServerHttpResponse} so the advice's {@code instanceof} unwrapping and status read work
* against the mock servlet objects. {@code beforeBodyWrite}'s {@code MethodParameter}, {@code
* MediaType} and converter type arguments are unused by the charging logic, so {@code null} is
* passed for them.
*
* <p>Authentication is driven through {@link SecurityContextHolder} using a 3-arg {@code
* UsernamePasswordAuthenticationToken} (authenticated=true) whose principal is the live {@link
* User} object, so {@code AuthenticationUtils.getCurrentUser} resolves it directly via {@code
* instanceof User} without touching the repository. The authorities on that token drive the
* limited-API-user check (which the advice reads from the authentication, not the user).
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CreditSuccessAdviceTest {
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 static final String ATTR_IS_API = "IS_API_REQUEST";
private static final String API_KEY = "apikey-abcdefgh";
@Mock private CreditService creditService;
@Mock private TeamCreditService teamCreditService;
@Mock private UserRepository userRepository;
@Mock private SaasTeamExtensionService saasTeamExtensionService;
@Mock private CreditHeaderUtils creditHeaderUtils;
private MeterRegistry meterRegistry;
private CreditSuccessAdvice advice;
@BeforeEach
void setUp() {
meterRegistry = new SimpleMeterRegistry();
advice =
new CreditSuccessAdvice(
creditService,
teamCreditService,
userRepository,
saasTeamExtensionService,
creditHeaderUtils,
meterRegistry);
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
// --- helpers --------------------------------------------------------------------------------
private static User user(String username) {
User u = new User();
u.setUsername(username);
return u;
}
private static Team team(Long id) {
Team t = new Team();
t.setId(id);
return t;
}
/** Authenticate with the default ROLE_USER authority (not a limited-API user). */
private void authenticate(User user) {
authenticate(user, "ROLE_USER");
}
/** Put the user on the SecurityContext as an authenticated principal with given authorities. */
private void authenticate(User user, String... authorities) {
var grants = java.util.Arrays.stream(authorities).map(SimpleGrantedAuthority::new).toList();
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(user, null, grants);
SecurityContextHolder.getContext().setAuthentication(token);
}
/** Base request: credit-eligible with an api key, resource weight 1, not charged. */
private MockHttpServletRequest eligibleRequest() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
req.setAttribute(ATTR_APIKEY, API_KEY);
req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(1));
return req;
}
/** Holder so a test can read back both the returned body and the underlying servlet objects. */
private static final class Exchange {
final MockHttpServletRequest servletReq;
final MockHttpServletResponse servletResp;
final ServletServerHttpResponse response;
Exchange(MockHttpServletRequest servletReq, MockHttpServletResponse servletResp) {
this.servletReq = servletReq;
this.servletResp = servletResp;
this.response = new ServletServerHttpResponse(servletResp);
}
String header(String name) {
// Read from the live ServerHttpResponse headers the advice wrote to.
return response.getHeaders().getFirst(name);
}
}
/**
* Invoke beforeBodyWrite against the given servlet request/response and return the exchange.
*/
private Object invoke(Exchange ex, Object body) {
ServletServerHttpRequest request = new ServletServerHttpRequest(ex.servletReq);
return advice.beforeBodyWrite(body, null, null, null, request, ex.response);
}
private Object invoke(MockHttpServletRequest servletReq, Object body) {
return invoke(new Exchange(servletReq, new MockHttpServletResponse()), body);
}
private double counter() {
return meterRegistry.get("credits.consumed").counter().count();
}
// --- supports() -----------------------------------------------------------------------------
@Test
@DisplayName("supports() returns true for any return type / converter")
void supports_alwaysTrue() {
assertThat(advice.supports(null, null)).isTrue();
}
// --- gates that short-circuit (body returned unchanged, nothing consumed) --------------------
@Nested
@DisplayName("short-circuit gates -> body returned unchanged, no consumption")
class Gates {
@Test
@DisplayName("non-servlet request: returns body untouched, no interactions")
void nonServletRequest_returnsBody() {
ServerHttpRequest notServlet = org.mockito.Mockito.mock(ServerHttpRequest.class);
ServletServerHttpResponse resp =
new ServletServerHttpResponse(new MockHttpServletResponse());
Object body = "BODY";
Object out = advice.beforeBodyWrite(body, null, null, null, notServlet, resp);
assertThat(out).isSameAs(body);
verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
assertThat(counter()).isZero();
}
@Test
@DisplayName("not eligible (attribute absent): no consumption")
void notEligible_noConsumption() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setAttribute(ATTR_APIKEY, API_KEY);
Object out = invoke(req, "BODY");
assertThat(out).isEqualTo("BODY");
verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
assertThat(counter()).isZero();
}
@Test
@DisplayName("eligible attribute not Boolean.TRUE (e.g. Boolean.FALSE): no consumption")
void eligibleFalse_noConsumption() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_ELIGIBLE, Boolean.FALSE);
authenticate(user("x"));
invoke(req, "BODY");
verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
assertThat(counter()).isZero();
}
@Test
@DisplayName("already charged (CREDIT_CHARGED set): no second consumption")
void alreadyCharged_noConsumption() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_CHARGED, Boolean.TRUE);
authenticate(user("x"));
invoke(req, "BODY");
verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
assertThat(counter()).isZero();
}
@Test
@DisplayName("error status >= 400 on response: skip consumption, error advice decides")
void errorStatus_skipsConsumption() {
MockHttpServletRequest req = eligibleRequest();
authenticate(user("x"));
MockHttpServletResponse servletResp = new MockHttpServletResponse();
servletResp.setStatus(500);
Exchange ex = new Exchange(req, servletResp);
Object out = invoke(ex, "BODY");
assertThat(out).isEqualTo("BODY");
verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
assertThat(counter()).isZero();
}
@Test
@DisplayName("status exactly 400 is treated as error -> skip")
void status400_skipsConsumption() {
MockHttpServletRequest req = eligibleRequest();
authenticate(user("x"));
MockHttpServletResponse servletResp = new MockHttpServletResponse();
servletResp.setStatus(400);
invoke(new Exchange(req, servletResp), "BODY");
verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
assertThat(counter()).isZero();
}
@Test
@DisplayName("status 399 (just below 400) still consumes")
void status399_stillConsumes() {
MockHttpServletRequest req = eligibleRequest();
User u = user("edge");
authenticate(u);
MockHttpServletResponse servletResp = new MockHttpServletResponse();
servletResp.setStatus(399);
Exchange ex = new Exchange(req, servletResp);
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(5);
invoke(ex, "BODY");
assertThat(counter()).isEqualTo(1.0d);
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
}
@Test
@DisplayName("eligible but apiKey attribute absent: no consumption (apiKey != null guard)")
void nullApiKey_noConsumption() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(1));
// no ATTR_APIKEY -> apiKey null
authenticate(user("x"));
invoke(req, "BODY");
verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
assertThat(counter()).isZero();
}
}
// --- individual (waterfall) consumption -----------------------------------------------------
@Nested
@DisplayName("individual credit consumption (no team)")
class IndividualConsumption {
@Test
@DisplayName("waterfall success: marks charged, increments counter, sets both headers")
void waterfallSuccess_chargesAndHeaders() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(3));
req.setAttribute(ATTR_IS_API, Boolean.TRUE);
User u = user("alice");
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(creditService.consumeCreditWithWaterfall(u, 3, true))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(42);
Object out = invoke(ex, "BODY");
assertThat(out).isEqualTo("BODY");
assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
assertThat(counter()).isEqualTo(1.0d);
assertThat(ex.header("X-Credits-Remaining")).isEqualTo("42");
assertThat(ex.header("X-Credit-Source")).isEqualTo("CYCLE_CREDITS");
verify(creditService).consumeCreditWithWaterfall(u, 3, true);
verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt());
}
@Test
@DisplayName("resource weight absent defaults credit amount to 1, IS_API absent -> false")
void weightAbsent_defaultsToOne() {
MockHttpServletRequest req = new MockHttpServletRequest();
req.setAttribute(ATTR_ELIGIBLE, Boolean.TRUE);
req.setAttribute(ATTR_APIKEY, API_KEY);
// no resource weight, no IS_API_REQUEST -> isApiRequestFlag false
User u = user("bob");
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("BOUGHT_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(0);
invoke(ex, "BODY");
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
// remaining 0 is non-negative -> header set
assertThat(ex.header("X-Credits-Remaining")).isEqualTo("0");
assertThat(ex.header("X-Credit-Source")).isEqualTo("BOUGHT_CREDITS");
}
@Test
@DisplayName("IS_API_REQUEST=false is passed through as false to the waterfall")
void isApiFalse_passedThrough() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_IS_API, Boolean.FALSE);
User u = user("ivy");
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(3);
invoke(ex, "BODY");
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
}
@Test
@DisplayName(
"waterfall failure (insufficient credits): not charged, counter zero, no headers")
void waterfallFailure_notCharged() {
MockHttpServletRequest req = eligibleRequest();
User u = user("carol");
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.failure("INSUFFICIENT_CREDITS"));
invoke(ex, "BODY");
assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
assertThat(counter()).isZero();
assertThat(ex.header("X-Credits-Remaining")).isNull();
assertThat(ex.header("X-Credit-Source")).isNull();
// header utils is only consulted after a successful charge
verifyNoInteractions(creditHeaderUtils);
}
@Test
@DisplayName("success but negative remaining: charged + source header, no remaining header")
void successNegativeRemaining_noRemainingHeader() {
MockHttpServletRequest req = eligibleRequest();
User u = user("dave");
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("METERED_SUBSCRIPTION"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(-1);
invoke(ex, "BODY");
assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
assertThat(counter()).isEqualTo(1.0d);
assertThat(ex.header("X-Credits-Remaining")).isNull();
assertThat(ex.header("X-Credit-Source")).isEqualTo("METERED_SUBSCRIPTION");
}
@Test
@DisplayName("success with null source: charged, remaining header set, no source header")
void successNullSource_noSourceHeader() {
MockHttpServletRequest req = eligibleRequest();
User u = user("erin");
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
// success=true but source=null (defensively handled by the advice)
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(new CreditConsumptionResult(true, null, "ok"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(7);
invoke(ex, "BODY");
assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
assertThat(ex.header("X-Credits-Remaining")).isEqualTo("7");
assertThat(ex.header("X-Credit-Source")).isNull();
}
@Test
@DisplayName("user with personal team is treated as no team -> waterfall, not team pool")
void personalTeam_usesWaterfall() {
MockHttpServletRequest req = eligibleRequest();
User u = user("frank");
u.setTeam(team(99L));
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(true);
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(5);
invoke(ex, "BODY");
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt());
}
}
// --- limited-API users always use personal credits ------------------------------------------
@Nested
@DisplayName("limited-API users always use personal credits (never team pool)")
class LimitedApiUsers {
@Test
@DisplayName("ROLE_LIMITED_API_USER in a non-personal team still uses the waterfall")
void limitedApiUser_usesWaterfall() {
MockHttpServletRequest req = eligibleRequest();
User u = user("lim");
u.setTeam(team(77L));
authenticate(u, "ROLE_LIMITED_API_USER");
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(2);
invoke(ex, "BODY");
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt());
// isPersonal short-circuited by the limited-API check; team service untouched
verify(saasTeamExtensionService, never()).isPersonal(any());
}
@Test
@DisplayName("ROLE_EXTRA_LIMITED_API_USER in a non-personal team still uses the waterfall")
void extraLimitedApiUser_usesWaterfall() {
MockHttpServletRequest req = eligibleRequest();
User u = user("xlim");
u.setTeam(team(88L));
authenticate(u, "ROLE_EXTRA_LIMITED_API_USER");
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(1);
invoke(ex, "BODY");
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt());
}
}
// --- team consumption -----------------------------------------------------------------------
@Nested
@DisplayName("team credit consumption (non-personal team)")
class TeamConsumption {
@Test
@DisplayName("non-personal team success: consumes from team pool, sets source header")
void teamSuccess_consumesTeamPool() {
MockHttpServletRequest req = eligibleRequest();
req.setAttribute(ATTR_RESOURCE_WEIGHT, Integer.valueOf(2));
User u = user("gina");
u.setTeam(team(77L));
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(teamCreditService.consumeCreditWithWaterfall(77L, 2))
.thenReturn(CreditConsumptionResult.success("TEAM_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(100);
Object out = invoke(ex, "BODY");
assertThat(out).isEqualTo("BODY");
assertThat(req.getAttribute(ATTR_CHARGED)).isEqualTo(Boolean.TRUE);
assertThat(counter()).isEqualTo(1.0d);
assertThat(ex.header("X-Credit-Source")).isEqualTo("TEAM_CREDITS");
assertThat(ex.header("X-Credits-Remaining")).isEqualTo("100");
verify(teamCreditService).consumeCreditWithWaterfall(77L, 2);
verify(creditService, never())
.consumeCreditWithWaterfall(any(), anyInt(), anyBoolean());
}
@Test
@DisplayName("non-personal team success via leader overage source is propagated")
void teamSuccess_overageSourcePropagated() {
MockHttpServletRequest req = eligibleRequest();
User u = user("greg");
u.setTeam(team(12L));
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(teamCreditService.consumeCreditWithWaterfall(12L, 1))
.thenReturn(CreditConsumptionResult.success("METERED_SUBSCRIPTION"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(0);
invoke(ex, "BODY");
assertThat(ex.header("X-Credit-Source")).isEqualTo("METERED_SUBSCRIPTION");
}
@Test
@DisplayName("non-personal team failure: not charged, counter zero, no headers")
void teamFailure_notCharged() {
MockHttpServletRequest req = eligibleRequest();
User u = user("hank");
u.setTeam(team(55L));
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(teamCreditService.consumeCreditWithWaterfall(55L, 1))
.thenReturn(
CreditConsumptionResult.failure("TEAM_CREDITS_EXHAUSTED_NO_OVERAGE"));
invoke(ex, "BODY");
assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
assertThat(counter()).isZero();
assertThat(ex.header("X-Credits-Remaining")).isNull();
assertThat(ex.header("X-Credit-Source")).isNull();
verify(creditService, never())
.consumeCreditWithWaterfall(any(), anyInt(), anyBoolean());
verifyNoInteractions(creditHeaderUtils);
}
@Test
@DisplayName("team with null id is treated as no team -> waterfall used")
void teamNullId_usesWaterfall() {
MockHttpServletRequest req = eligibleRequest();
User u = user("nina");
u.setTeam(team(null)); // non-personal (isPersonal false) but id null
authenticate(u);
Exchange ex = new Exchange(req, new MockHttpServletResponse());
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(4);
invoke(ex, "BODY");
// targetTeamId resolves to null (team id null) -> individual waterfall
verify(creditService).consumeCreditWithWaterfall(u, 1, false);
verify(teamCreditService, never()).consumeCreditWithWaterfall(anyLong(), anyInt());
}
}
// --- user resolution edge cases -------------------------------------------------------------
@Nested
@DisplayName("user resolution edge cases")
class UserResolution {
@Test
@DisplayName("no authentication: getCurrentUser throws, user null -> no consumption")
void noAuth_userNull_noConsumption() {
MockHttpServletRequest req = eligibleRequest();
// no SecurityContext authentication -> AuthenticationUtils throws SecurityException
Object out = invoke(req, "BODY");
assertThat(out).isEqualTo("BODY");
assertThat(req.getAttribute(ATTR_CHARGED)).isNull();
assertThat(counter()).isZero();
verifyNoInteractions(creditService, teamCreditService, creditHeaderUtils);
}
}
// --- counter accumulation -------------------------------------------------------------------
@Test
@DisplayName("counter accumulates across multiple successful charges")
void counterAccumulates() {
User u = user("mike");
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(3);
// fresh request each time so ATTR_CHARGED from a prior call doesn't block the next
authenticate(u);
invoke(eligibleRequest(), "A");
invoke(eligibleRequest(), "B");
assertThat(counter()).isEqualTo(2.0d);
verify(creditService, times(2)).consumeCreditWithWaterfall(u, 1, false);
}
@Test
@DisplayName("body is always returned verbatim (including null) regardless of charging")
void bodyReturnedVerbatim() {
User u = user("nora");
authenticate(u);
when(creditService.consumeCreditWithWaterfall(u, 1, false))
.thenReturn(CreditConsumptionResult.success("CYCLE_CREDITS"));
when(creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService))
.thenReturn(1);
Object out = invoke(eligibleRequest(), null);
assertThat(out).isNull();
}
}
@@ -0,0 +1,806 @@
package stirling.software.saas.interceptor;
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.lang.reflect.Method;
import java.util.List;
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.mockito.Mockito;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.method.HandlerMethod;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.config.CreditsProperties;
import stirling.software.saas.model.TeamCredit;
import stirling.software.saas.model.TeamMembership;
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;
/**
* Pure-Mockito unit tests for {@link UnifiedCreditInterceptor}. No Spring context: request/response
* are Spring's mock servlet objects, the SecurityContext is populated with real token types
* (matching the sibling {@code PaygChargeInterceptorTest} house style) and every collaborator is a
* Mockito mock. A real {@link SimpleMeterRegistry} backs the counters/timer so increments can be
* asserted directly.
*/
class UnifiedCreditInterceptorTest {
private CreditService creditService;
private ErrorTrackingService errorTrackingService;
private CreditsProperties creditsProperties;
private UserRepository userRepository;
private TeamCreditService teamCreditService;
private TeamMembershipRepository membershipRepository;
private SaasUserExtensionService saasUserExtensionService;
private SaasTeamExtensionService saasTeamExtensionService;
private MeterRegistry meterRegistry;
private UnifiedCreditInterceptor interceptor;
@BeforeEach
void setUp() {
creditService = Mockito.mock(CreditService.class);
errorTrackingService = Mockito.mock(ErrorTrackingService.class);
creditsProperties = new CreditsProperties();
userRepository = Mockito.mock(UserRepository.class);
teamCreditService = Mockito.mock(TeamCreditService.class);
membershipRepository = Mockito.mock(TeamMembershipRepository.class);
saasUserExtensionService = Mockito.mock(SaasUserExtensionService.class);
saasTeamExtensionService = Mockito.mock(SaasTeamExtensionService.class);
meterRegistry = new SimpleMeterRegistry();
interceptor =
new UnifiedCreditInterceptor(
creditService,
errorTrackingService,
creditsProperties,
userRepository,
teamCreditService,
membershipRepository,
saasUserExtensionService,
saasTeamExtensionService,
meterRegistry);
SecurityContextHolder.clearContext();
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
// --- gate short-circuits ---------------------------------------------------------------------
@Nested
@DisplayName("preHandle gate / short-circuit branches")
class GateBranches {
@Test
@DisplayName("credits disabled allows request without touching any collaborator")
void creditsDisabled_allowsAndSkipsValidation() throws Exception {
creditsProperties.setEnabled(false);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
verifyNoInteractions(creditService, teamCreditService, userRepository);
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isNull();
}
@Test
@DisplayName("non-HandlerMethod handler is out of scope and allowed")
void plainHandlerObject_isAllowed() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, new Object());
assertThat(cont).isTrue();
verifyNoInteractions(creditService, teamCreditService);
}
@Test
@DisplayName("HandlerMethod without @AutoJobPostMapping is out of scope and allowed")
void handlerWithoutAnnotation_isAllowed() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForPlain());
assertThat(cont).isTrue();
verifyNoInteractions(creditService, teamCreditService);
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isNull();
}
}
// --- authentication gating -------------------------------------------------------------------
@Nested
@DisplayName("authentication blocking")
class AuthBlocking {
@Test
@DisplayName("null authentication is blocked with 401")
void nullAuth_blockedWith401() throws Exception {
SecurityContextHolder.clearContext();
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(401);
assertThat(res.getContentType()).isEqualTo("application/json");
assertThat(res.getContentAsString()).contains("AUTHENTICATION_REQUIRED");
verifyNoInteractions(creditService, teamCreditService);
}
@Test
@DisplayName("unauthenticated token (isAuthenticated=false) is blocked with 401")
void unauthenticatedToken_blockedWith401() throws Exception {
// 2-arg ctor leaves isAuthenticated()=false, so it falls through to the security block.
SecurityContextHolder.getContext()
.setAuthentication(new UsernamePasswordAuthenticationToken("someone", "creds"));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(401);
assertThat(res.getContentAsString()).contains("AUTHENTICATION_REQUIRED");
}
}
// --- JWT lookup / role bypass ----------------------------------------------------------------
@Nested
@DisplayName("JWT authentication: lookup, role bypass and bad identifiers")
class JwtLookupAndBypass {
@Test
@DisplayName("JWT user not found in repository returns 500 USER_NOT_FOUND")
void jwtUserMissing_returns500() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.empty());
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(500);
assertThat(res.getContentAsString()).contains("USER_NOT_FOUND");
verify(creditService, never()).getOrCreateUserCredits(any());
}
@Test
@DisplayName("JWT with non-UUID identifier returns 400 INVALID_USER_ID")
void jwtInvalidSupabaseIdFormat_returns400() throws Exception {
// auth.getName() is not a UUID → UUID.fromString throws IllegalArgumentException.
authenticateJwt("not-a-uuid", "ROLE_USER");
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(400);
assertThat(res.getContentAsString()).contains("INVALID_USER_ID");
verify(creditService, never()).getOrCreateUserCredits(any());
}
@Test
@DisplayName("admin JWT user bypasses credit validation and bumps jwt_bypass counter")
void adminJwtUser_bypassesValidation() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_ADMIN");
User admin = makeUser(1L, null, "ROLE_ADMIN");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(admin));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isNull();
assertThat(meterRegistry.counter("credits.validation.jwt_bypass").count())
.isEqualTo(1.0);
verify(creditService, never()).getOrCreateUserCredits(any());
}
@Test
@DisplayName("internal backend-API JWT user bypasses credit validation")
void internalBackendApiUser_bypassesValidation() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "STIRLING-PDF-BACKEND-API-USER");
User internal = makeUser(2L, null, "STIRLING-PDF-BACKEND-API-USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(internal));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
assertThat(meterRegistry.counter("credits.validation.jwt_bypass").count())
.isEqualTo(1.0);
verify(creditService, never()).getOrCreateUserCredits(any());
}
@Test
@DisplayName("pro JWT user is NOT bypassed - still flows into waterfall credit checks")
void proJwtUser_isSubjectToCreditChecks() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_PRO_USER");
User pro = makeUser(3L, null, "ROLE_PRO_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(pro));
when(creditService.getOrCreateUserCredits(pro)).thenReturn(userCredit(100));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
// Pro is not bypassed; it consumed the credit-check path so the eligible flag is set.
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
assertThat(meterRegistry.counter("credits.validation.jwt_bypass").count())
.isEqualTo(0.0);
verify(creditService).getOrCreateUserCredits(pro);
}
}
// --- JWT personal-credit path ----------------------------------------------------------------
@Nested
@DisplayName("JWT personal-credit path")
class JwtPersonalCredits {
@Test
@DisplayName("sufficient personal credits passes and marks request eligible")
void sufficientPersonalCredits_passes() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
User user = makeUser(10L, null, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(50));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
// JWT credit identifier is the Supabase id, weight clamps to 1 (default sentinel).
assertThat(req.getAttribute("CREDIT_API_KEY")).isEqualTo(supabaseId);
assertThat(req.getAttribute("CREDIT_RESOURCE_WEIGHT")).isEqualTo(1);
assertThat(req.getAttribute("IS_API_KEY_AUTH")).isEqualTo(false);
assertThat(req.getAttribute("IS_API_REQUEST")).isEqualTo(false);
assertThat(meterRegistry.counter("credits.validation.checked").count()).isEqualTo(1.0);
verify(teamCreditService, never()).getTeamCredits(any());
}
@Test
@DisplayName("insufficient personal credits without metered billing is rejected with 429")
void insufficientPersonalCredits_rejectedWith429() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
User user = makeUser(11L, null, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(0));
when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(429);
assertThat(res.getContentAsString()).contains("INSUFFICIENT_CREDITS");
// Personal (not team) message wording.
assertThat(res.getContentAsString()).contains("Insufficient API credits");
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isNull();
assertThat(meterRegistry.counter("credits.validation.rejected").count()).isEqualTo(1.0);
}
@Test
@DisplayName(
"insufficient personal credits but metered billing enabled proceeds on overage")
void insufficientPersonalCredits_withMeteredBilling_proceeds() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
User user = makeUser(12L, null, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(0));
when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(true);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
assertThat(meterRegistry.counter("credits.validation.rejected").count()).isEqualTo(0.0);
}
}
// --- JWT team-credit path --------------------------------------------------------------------
@Nested
@DisplayName("JWT team-credit path")
class JwtTeamCredits {
@Test
@DisplayName("non-personal team with sufficient team credits passes")
void teamCreditsSufficient_passes() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
Team team = makeTeam(500L);
User user = makeUser(20L, team, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
when(teamCreditService.getTeamCredits(500L)).thenReturn(Optional.of(teamCredit(80)));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
verify(teamCreditService).getTeamCredits(500L);
verify(creditService, never()).getOrCreateUserCredits(any());
}
@Test
@DisplayName("non-personal team with no credits but leader metered billing proceeds")
void teamCreditsExhausted_leaderMetered_proceeds() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
Team team = makeTeam(501L);
User user = makeUser(21L, team, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
when(teamCreditService.getTeamCredits(501L)).thenReturn(Optional.of(teamCredit(0)));
// Leader has metered billing → overage allowed even with zero team credits.
User leader = makeUser(99L, team, "ROLE_USER");
TeamMembership leaderMembership = new TeamMembership();
leaderMembership.setUser(leader);
leaderMembership.setRole(TeamRole.LEADER);
when(membershipRepository.findByTeamIdAndRole(501L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership));
when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
}
@Test
@DisplayName(
"non-personal team with no credits and no leader metered is rejected (team msg)")
void teamCreditsExhausted_noLeaderMetered_rejectedWithTeamMessage() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
Team team = makeTeam(502L);
User user = makeUser(22L, team, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
when(teamCreditService.getTeamCredits(502L)).thenReturn(Optional.of(teamCredit(0)));
when(membershipRepository.findByTeamIdAndRole(502L, TeamRole.LEADER))
.thenReturn(List.of());
when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(429);
assertThat(res.getContentAsString()).contains("Insufficient team credits");
assertThat(meterRegistry.counter("credits.validation.rejected").count()).isEqualTo(1.0);
}
@Test
@DisplayName("personal team falls back to personal credits, not team credits")
void personalTeam_usesPersonalCredits() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
Team team = makeTeam(503L);
User user = makeUser(23L, team, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(saasTeamExtensionService.isPersonal(team)).thenReturn(true);
when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(10));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
verify(creditService).getOrCreateUserCredits(user);
verify(teamCreditService, never()).getTeamCredits(any());
}
@Test
@DisplayName("limited-API JWT user in a team uses personal credits, never team credits")
void limitedApiUserInTeam_usesPersonalCredits() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_LIMITED_API_USER");
Team team = makeTeam(504L);
User user = makeUser(24L, team, "ROLE_LIMITED_API_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(creditService.getOrCreateUserCredits(user)).thenReturn(userCredit(5));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
verify(creditService).getOrCreateUserCredits(user);
verify(teamCreditService, never()).getTeamCredits(any());
verify(saasTeamExtensionService, never()).isPersonal(any());
}
@Test
@DisplayName("leader-metered lookup that throws is swallowed and treated as not-metered")
void leaderMeteredLookupThrows_treatedAsNotMetered() throws Exception {
String supabaseId = UUID.randomUUID().toString();
authenticateJwt(supabaseId, "ROLE_USER");
Team team = makeTeam(505L);
User user = makeUser(25L, team, "ROLE_USER");
when(userRepository.findBySupabaseId(UUID.fromString(supabaseId)))
.thenReturn(Optional.of(user));
when(saasTeamExtensionService.isPersonal(team)).thenReturn(false);
when(teamCreditService.getTeamCredits(505L)).thenReturn(Optional.of(teamCredit(0)));
when(membershipRepository.findByTeamIdAndRole(505L, TeamRole.LEADER))
.thenThrow(new RuntimeException("db down"));
when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
// Exception in checkTeamLeaderMeteredBilling is caught → false → rejected, not a 500.
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(429);
}
}
// --- API key path ----------------------------------------------------------------------------
@Nested
@DisplayName("API key authentication path")
class ApiKeyPath {
@Test
@DisplayName("API key with sufficient credits passes and flags API request attributes")
void apiKeySufficientCredits_passes() throws Exception {
User user = makeUser(30L, null, "ROLE_API");
user.setApiKey("sk-abcd1234efgh5678");
authenticateApiKey(user, "sk-abcd1234efgh5678");
when(creditService.getUserCreditsByApiKey("sk-abcd1234efgh5678"))
.thenReturn(Optional.of(userCredit(20)));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
assertThat(req.getAttribute("CREDIT_API_KEY")).isEqualTo("sk-abcd1234efgh5678");
assertThat(req.getAttribute("IS_API_KEY_AUTH")).isEqualTo(true);
assertThat(req.getAttribute("IS_API_REQUEST")).isEqualTo(true);
verify(creditService).getUserCreditsByApiKey("sk-abcd1234efgh5678");
verify(creditService, never()).getOrCreateUserCredits(any());
}
@Test
@DisplayName("API key with no credit row defaults to zero balance and is rejected")
void apiKeyNoCreditRow_rejectedWith429() throws Exception {
User user = makeUser(31L, null, "ROLE_API");
user.setApiKey("sk-zzzz0000zzzz0000");
authenticateApiKey(user, "sk-zzzz0000zzzz0000");
when(creditService.getUserCreditsByApiKey("sk-zzzz0000zzzz0000"))
.thenReturn(Optional.empty());
when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(429);
// API key users always use personal credits → personal message even with a team absent.
assertThat(res.getContentAsString()).contains("Insufficient API credits");
}
@Test
@DisplayName("API key insufficient credits but metered billing enabled proceeds")
void apiKeyInsufficientCredits_withMeteredBilling_proceeds() throws Exception {
User user = makeUser(32L, null, "ROLE_API");
user.setApiKey("sk-meter0000meter0");
authenticateApiKey(user, "sk-meter0000meter0");
when(creditService.getUserCreditsByApiKey("sk-meter0000meter0"))
.thenReturn(Optional.of(userCredit(0)));
when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(true);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForAuto());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
}
}
// --- resource weight clamping ----------------------------------------------------------------
@Nested
@DisplayName("resource weight clamping")
class ResourceWeightClamping {
@Test
@DisplayName("weight above 100 clamps to 100")
void weightAbove100_clampsTo100() throws Exception {
User user = makeUser(40L, null, "ROLE_API");
user.setApiKey("sk-clamp0000clamp00");
authenticateApiKey(user, "sk-clamp0000clamp00");
when(creditService.getUserCreditsByApiKey("sk-clamp0000clamp00"))
.thenReturn(Optional.of(userCredit(1000)));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForHeavy());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_RESOURCE_WEIGHT")).isEqualTo(100);
}
@Test
@DisplayName("a weight-100 job is rejected when balance is below 100 (no rounding leak)")
void weight100_balance50_rejected() throws Exception {
User user = makeUser(41L, null, "ROLE_API");
user.setApiKey("sk-tight0000tight00");
authenticateApiKey(user, "sk-tight0000tight00");
when(creditService.getUserCreditsByApiKey("sk-tight0000tight00"))
.thenReturn(Optional.of(userCredit(50)));
when(saasUserExtensionService.isMeteredBillingEnabled(user)).thenReturn(false);
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForHeavy());
assertThat(cont).isFalse();
assertThat(res.getStatus()).isEqualTo(429);
}
@Test
@DisplayName("exact balance == weight is sufficient (>= boundary)")
void exactBalanceEqualsWeight_passes() throws Exception {
User user = makeUser(42L, null, "ROLE_API");
user.setApiKey("sk-exact0000exact00");
authenticateApiKey(user, "sk-exact0000exact00");
// Heavy endpoint weight clamps to 100; exactly 100 credits is sufficient.
when(creditService.getUserCreditsByApiKey("sk-exact0000exact00"))
.thenReturn(Optional.of(userCredit(100)));
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean cont = interceptor.preHandle(req, res, handlerMethodForHeavy());
assertThat(cont).isTrue();
assertThat(req.getAttribute("CREDIT_ELIGIBLE")).isEqualTo(Boolean.TRUE);
}
}
// --- lifecycle no-ops ------------------------------------------------------------------------
@Nested
@DisplayName("postHandle / afterCompletion / afterConcurrentHandlingStarted are no-ops")
class LifecycleNoOps {
@Test
@DisplayName("postHandle does no spending and touches no collaborator")
void postHandle_isNoOp() throws Exception {
interceptor.postHandle(
new MockHttpServletRequest(),
new MockHttpServletResponse(),
handlerMethodForAuto(),
null);
verifyNoInteractions(
creditService, teamCreditService, errorTrackingService, userRepository);
}
@Test
@DisplayName("afterCompletion with no exception does no spending")
void afterCompletion_success_isNoOp() throws Exception {
interceptor.afterCompletion(
new MockHttpServletRequest(),
new MockHttpServletResponse(),
handlerMethodForAuto(),
null);
verifyNoInteractions(creditService, teamCreditService, errorTrackingService);
}
@Test
@DisplayName("afterCompletion with an exception still does no spending")
void afterCompletion_withException_isNoOp() throws Exception {
interceptor.afterCompletion(
new MockHttpServletRequest(),
new MockHttpServletResponse(),
handlerMethodForAuto(),
new RuntimeException("boom"));
verifyNoInteractions(creditService, teamCreditService, errorTrackingService);
}
@Test
@DisplayName("afterConcurrentHandlingStarted does no spending")
void afterConcurrentHandlingStarted_isNoOp() throws Exception {
interceptor.afterConcurrentHandlingStarted(
new MockHttpServletRequest(),
new MockHttpServletResponse(),
handlerMethodForAuto());
verifyNoInteractions(creditService, teamCreditService, errorTrackingService);
}
}
// --- helpers ---------------------------------------------------------------------------------
private void authenticateJwt(String name, String role) {
// Not an EnhancedJwtAuthenticationToken, so extractSupabaseId falls back to auth.getName().
// 3-arg ctor → isAuthenticated()=true so the JWT branch is taken.
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(
name, null, List.of(new SimpleGrantedAuthority(role)));
SecurityContextHolder.getContext().setAuthentication(token);
}
private void authenticateApiKey(User user, String apiKey) {
ApiKeyAuthenticationToken token =
new ApiKeyAuthenticationToken(
user, apiKey, List.of(new SimpleGrantedAuthority("ROLE_API")));
SecurityContextHolder.getContext().setAuthentication(token);
}
private static UserCredit userCredit(int cycleCredits) {
UserCredit c = new UserCredit();
c.setCycleCreditsRemaining(cycleCredits);
c.setBoughtCreditsRemaining(0);
return c;
}
private static TeamCredit teamCredit(int cycleCredits) {
TeamCredit c = new TeamCredit();
c.setCycleCreditsRemaining(cycleCredits);
c.setBoughtCreditsRemaining(0);
return c;
}
private static Team makeTeam(Long id) {
Team team = new Team();
team.setId(id);
return team;
}
private static User makeUser(Long id, Team team, String role) {
User user = new User();
try {
java.lang.reflect.Field idField = User.class.getDeclaredField("id");
idField.setAccessible(true);
idField.set(user, id);
} catch (ReflectiveOperationException e) {
throw new RuntimeException(e);
}
user.setUsername("user-" + id);
user.setSupabaseId(UUID.randomUUID());
if (team != null) {
user.setTeam(team);
}
if (role != null) {
// Authority ctor self-registers into user.getAuthorities().
new Authority(role, user);
}
return user;
}
private static HandlerMethod handlerMethodForAuto() {
return handlerMethod("handleAuto");
}
private static HandlerMethod handlerMethodForHeavy() {
return handlerMethod("handleHeavy");
}
private static HandlerMethod handlerMethodForPlain() {
return handlerMethod("handlePlain");
}
private static HandlerMethod handlerMethod(String name) {
try {
Method m = FakeController.class.getDeclaredMethod(name);
return new HandlerMethod(new FakeController(), m);
} catch (NoSuchMethodException e) {
throw new RuntimeException(e);
}
}
static class FakeController {
// No explicit resourceWeight → default sentinel Integer.MIN_VALUE → clamps to 1.
@AutoJobPostMapping(value = "/auto")
public void handleAuto() {}
// Above-max weight to exercise the clamp-to-100 branch.
@AutoJobPostMapping(value = "/heavy", resourceWeight = 5000)
public void handleHeavy() {}
public void handlePlain() {}
}
}
@@ -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,219 @@
package stirling.software.saas.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
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 java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
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.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.saas.config.CreditsProperties;
/**
* Unit tests for {@link CreditResetScheduler}.
*
* <p>The scheduler has two {@code @Scheduled} entry points that are invoked directly (no Spring
* context, no real cron firing). {@code resetCycleCredits()} parses the configured zone, builds a
* {@code LocalDateTime} in that zone, and delegates to {@link CreditService} for users then teams,
* swallowing any exception. {@code performDailyMaintenance()} is a pure no-op log step that must
* never touch {@link CreditService}.
*/
@ExtendWith(MockitoExtension.class)
class CreditResetSchedulerTest {
@Mock private CreditService creditService;
/** Real CreditsProperties (a simple @Data POJO) configured per test via its nested Reset. */
private static CreditsProperties props(String cron, String zone) {
CreditsProperties p = new CreditsProperties();
p.getReset().setCron(cron);
p.getReset().setZone(zone);
return p;
}
private CreditResetScheduler scheduler(CreditsProperties props) {
return new CreditResetScheduler(creditService, props);
}
@Nested
@DisplayName("resetCycleCredits - happy path")
class ResetHappyPath {
@Test
@DisplayName("resets users then teams exactly once each")
void resetsUsersThenTeamsOnce() {
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
s.resetCycleCredits();
verify(creditService, times(1)).resetCycleCreditsForAllUsers(any(LocalDateTime.class));
verify(creditService, times(1)).resetCycleCreditsForAllTeams(any(LocalDateTime.class));
}
@Test
@DisplayName("users are reset strictly before teams")
void usersResetBeforeTeams() {
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
s.resetCycleCredits();
InOrder order = inOrder(creditService);
order.verify(creditService).resetCycleCreditsForAllUsers(any(LocalDateTime.class));
order.verify(creditService).resetCycleCreditsForAllTeams(any(LocalDateTime.class));
order.verifyNoMoreInteractions();
}
@Test
@DisplayName("passes a non-null reset time and uses the same instant for users and teams")
void passesSameNonNullResetTimeToBoth() {
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
s.resetCycleCredits();
ArgumentCaptor<LocalDateTime> userTime = ArgumentCaptor.forClass(LocalDateTime.class);
ArgumentCaptor<LocalDateTime> teamTime = ArgumentCaptor.forClass(LocalDateTime.class);
verify(creditService).resetCycleCreditsForAllUsers(userTime.capture());
verify(creditService).resetCycleCreditsForAllTeams(teamTime.capture());
assertThat(userTime.getValue()).isNotNull();
assertThat(teamTime.getValue()).isNotNull();
// The very same LocalDateTime instance is threaded through both calls.
assertThat(teamTime.getValue()).isSameAs(userTime.getValue());
}
@Test
@DisplayName("reset time is computed in the configured zone (~now)")
void resetTimeMatchesConfiguredZone() {
String zone = "UTC";
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", zone));
LocalDateTime expected = LocalDateTime.now(ZoneId.of(zone));
s.resetCycleCredits();
ArgumentCaptor<LocalDateTime> captor = ArgumentCaptor.forClass(LocalDateTime.class);
verify(creditService).resetCycleCreditsForAllUsers(captor.capture());
// Wall-clock now in the same zone; allow generous slack to avoid flakiness.
assertThat(captor.getValue()).isCloseTo(expected, within(10, ChronoUnit.SECONDS));
}
@Test
@DisplayName("a non-UTC zone is honored when building the reset time")
void nonUtcZoneHonored() {
String zone = "America/New_York";
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", zone));
LocalDateTime expected = LocalDateTime.now(ZoneId.of(zone));
s.resetCycleCredits();
ArgumentCaptor<LocalDateTime> captor = ArgumentCaptor.forClass(LocalDateTime.class);
verify(creditService).resetCycleCreditsForAllUsers(captor.capture());
assertThat(captor.getValue()).isCloseTo(expected, within(10, ChronoUnit.SECONDS));
}
}
@Nested
@DisplayName("resetCycleCredits - error handling (exceptions are swallowed)")
class ResetErrorHandling {
@Test
@DisplayName("user-reset failure is swallowed and short-circuits the team reset")
void userResetThrows_swallowedAndTeamsSkipped() {
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
doThrow(new RuntimeException("user reset boom"))
.when(creditService)
.resetCycleCreditsForAllUsers(any(LocalDateTime.class));
// Must not propagate.
s.resetCycleCredits();
verify(creditService).resetCycleCreditsForAllUsers(any(LocalDateTime.class));
// Exception thrown before the team call is reached.
verify(creditService, never()).resetCycleCreditsForAllTeams(any(LocalDateTime.class));
}
@Test
@DisplayName("team-reset failure is swallowed after users already reset")
void teamResetThrows_swallowedAfterUsersReset() {
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
doThrow(new RuntimeException("team reset boom"))
.when(creditService)
.resetCycleCreditsForAllTeams(any(LocalDateTime.class));
// Must not propagate.
s.resetCycleCredits();
verify(creditService).resetCycleCreditsForAllUsers(any(LocalDateTime.class));
verify(creditService).resetCycleCreditsForAllTeams(any(LocalDateTime.class));
}
@Test
@DisplayName("an invalid zone string is swallowed and no reset work is attempted")
void invalidZone_swallowedNoResets() {
// ZoneId.of on a bad id throws DateTimeException before any delegation occurs.
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "Not/AZone"));
// Must not propagate.
s.resetCycleCredits();
verifyNoInteractions(creditService);
}
@Test
@DisplayName("any RuntimeException subtype from the user reset is swallowed")
void userResetRuntimeExceptionTolerated() {
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
// Production catches (Exception e); any RuntimeException is absorbed.
doThrow(new IllegalStateException("transient"))
.when(creditService)
.resetCycleCreditsForAllUsers(any(LocalDateTime.class));
s.resetCycleCredits();
verify(creditService).resetCycleCreditsForAllUsers(any(LocalDateTime.class));
verify(creditService, never()).resetCycleCreditsForAllTeams(any(LocalDateTime.class));
}
}
@Nested
@DisplayName("performDailyMaintenance")
class DailyMaintenance {
@Test
@DisplayName("runs cleanly and never touches the credit service")
void noOp_doesNotTouchCreditService() {
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
s.performDailyMaintenance();
verifyNoInteractions(creditService);
}
@Test
@DisplayName("is idempotent across repeated invocations")
void repeatedInvocationsRemainNoOp() {
CreditResetScheduler s = scheduler(props("0 0 2 1 * *", "UTC"));
s.performDailyMaintenance();
s.performDailyMaintenance();
s.performDailyMaintenance();
verifyNoInteractions(creditService);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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);
}
}
}
@@ -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,612 @@
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.anyInt;
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.time.LocalDateTime;
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.slf4j.MDC;
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;
/**
* Unit tests for {@link TeamCreditService}. Collaborators (repositories, billing/extension
* services) are mocked; credit math is pure arithmetic so exact numbers are asserted. The waterfall
* path is exercised across every branch: pool hit, no leader, metered-billing disabled, missing
* Supabase id, Stripe report success/failure, and a thrown exception during reporting.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class TeamCreditServiceTest {
@Mock private TeamCreditRepository teamCreditRepository;
@Mock private TeamMembershipRepository membershipRepository;
@Mock private StripeUsageReportingService stripeUsageReportingService;
@Mock private SaasUserExtensionService saasUserExtensionService;
// Real CreditsProperties carries the production default allocations (ROLE_PRO_USER -> 500).
private final CreditsProperties creditsProperties = new CreditsProperties();
private TeamCreditService service;
@BeforeEach
void setUp() {
service =
new TeamCreditService(
teamCreditRepository,
membershipRepository,
creditsProperties,
stripeUsageReportingService,
saasUserExtensionService);
}
@AfterEach
void clearMdc() {
MDC.clear();
}
// ----------------------------------------------------------------------------------------
// Fixtures
// ----------------------------------------------------------------------------------------
private static Team team(Long id) {
Team t = new Team();
t.setId(id);
return t;
}
private static User user(Long id, String username) {
User u = new User();
u.setId(id);
u.setUsername(username);
return u;
}
private static TeamCredit credit(int cycleRemaining, int boughtRemaining) {
TeamCredit c = new TeamCredit();
c.setCycleCreditsRemaining(cycleRemaining);
c.setCycleCreditsAllocated(cycleRemaining);
c.setBoughtCreditsRemaining(boughtRemaining);
c.setTotalBoughtCredits(boughtRemaining);
return c;
}
private static TeamMembership leaderMembership(User leader) {
TeamMembership m = new TeamMembership();
m.setRole(TeamRole.LEADER);
m.setUser(leader);
return m;
}
// ----------------------------------------------------------------------------------------
// initializeTeamCredits
// ----------------------------------------------------------------------------------------
@Nested
@DisplayName("initializeTeamCredits")
class InitializeTeamCredits {
@Test
@DisplayName("returns the existing row without saving when credits already exist")
void returnsExistingWithoutSaving() {
Team team = team(100L);
TeamCredit existing = credit(123, 0);
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(existing));
TeamCredit result = service.initializeTeamCredits(team, user(1L, "primary"));
assertThat(result).isSameAs(existing);
verify(teamCreditRepository, never()).save(any());
}
@Test
@DisplayName("seeds the fixed PRO allocation (500) into both cycle fields for a new team")
void seedsFixedProAllocation() {
Team team = team(100L);
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
when(teamCreditRepository.save(any(TeamCredit.class)))
.thenAnswer(inv -> inv.getArgument(0));
TeamCredit result = service.initializeTeamCredits(team, user(1L, "primary"));
// 500 is the production ROLE_PRO_USER default in CreditsProperties.
assertThat(result.getCycleCreditsAllocated()).isEqualTo(500);
assertThat(result.getCycleCreditsRemaining()).isEqualTo(500);
assertThat(result.getLastCycleResetAt()).isNotNull();
assertThat(result.getTeam()).isSameAs(team);
ArgumentCaptor<TeamCredit> captor = ArgumentCaptor.forClass(TeamCredit.class);
verify(teamCreditRepository).save(captor.capture());
assertThat(captor.getValue().getCycleCreditsRemaining()).isEqualTo(500);
}
@Test
@DisplayName("honours a custom ROLE_PRO_USER allocation from properties")
void honoursCustomAllocation() {
CreditsProperties custom = new CreditsProperties();
custom.getCycle().setAllocations(Map.of("ROLE_PRO_USER", 750));
TeamCreditService svc =
new TeamCreditService(
teamCreditRepository,
membershipRepository,
custom,
stripeUsageReportingService,
saasUserExtensionService);
Team team = team(100L);
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
when(teamCreditRepository.save(any(TeamCredit.class)))
.thenAnswer(inv -> inv.getArgument(0));
TeamCredit result = svc.initializeTeamCredits(team, user(1L, "primary"));
assertThat(result.getCycleCreditsAllocated()).isEqualTo(750);
assertThat(result.getCycleCreditsRemaining()).isEqualTo(750);
}
@Test
@DisplayName("falls back to 500 when the allocation map has no ROLE_PRO_USER entry")
void fallsBackTo500WhenKeyMissing() {
CreditsProperties custom = new CreditsProperties();
custom.getCycle().setAllocations(Map.of("ROLE_USER", 50));
TeamCreditService svc =
new TeamCreditService(
teamCreditRepository,
membershipRepository,
custom,
stripeUsageReportingService,
saasUserExtensionService);
Team team = team(100L);
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
when(teamCreditRepository.save(any(TeamCredit.class)))
.thenAnswer(inv -> inv.getArgument(0));
TeamCredit result = svc.initializeTeamCredits(team, user(1L, "primary"));
assertThat(result.getCycleCreditsAllocated()).isEqualTo(500);
assertThat(result.getCycleCreditsRemaining()).isEqualTo(500);
}
}
// ----------------------------------------------------------------------------------------
// hasCreditsAvailable
// ----------------------------------------------------------------------------------------
@Nested
@DisplayName("hasCreditsAvailable")
class HasCreditsAvailable {
@Test
@DisplayName("true when the team pool has cycle or bought credits left")
void trueWhenCreditsRemain() {
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(credit(3, 0)));
assertThat(service.hasCreditsAvailable(100L)).isTrue();
}
@Test
@DisplayName("true when only bought credits remain")
void trueWhenOnlyBoughtRemain() {
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(credit(0, 5)));
assertThat(service.hasCreditsAvailable(100L)).isTrue();
}
@Test
@DisplayName("false when the pool is empty")
void falseWhenPoolEmpty() {
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(credit(0, 0)));
assertThat(service.hasCreditsAvailable(100L)).isFalse();
}
@Test
@DisplayName("false when no credit row exists for the team")
void falseWhenNoRow() {
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
assertThat(service.hasCreditsAvailable(100L)).isFalse();
}
}
// ----------------------------------------------------------------------------------------
// consumeCredit
// ----------------------------------------------------------------------------------------
@Nested
@DisplayName("consumeCredit")
class ConsumeCredit {
@Test
@DisplayName("true when the atomic update affected a row")
void trueWhenRowUpdated() {
when(teamCreditRepository.consumeCredit(100L, 2)).thenReturn(1);
assertThat(service.consumeCredit(100L, 2)).isTrue();
verify(teamCreditRepository).consumeCredit(100L, 2);
}
@Test
@DisplayName("false when the atomic update affected no rows (insufficient / conflict)")
void falseWhenNoRowUpdated() {
when(teamCreditRepository.consumeCredit(100L, 5)).thenReturn(0);
assertThat(service.consumeCredit(100L, 5)).isFalse();
}
}
// ----------------------------------------------------------------------------------------
// getCreditSummaryForUser
// ----------------------------------------------------------------------------------------
@Nested
@DisplayName("getCreditSummaryForUser")
class GetCreditSummaryForUser {
@Test
@DisplayName("empty when the user has no team assigned")
void emptyWhenNoTeam() {
User u = user(7L, "noteam");
u.setTeam(null);
Optional<TeamCredit> result = service.getCreditSummaryForUser(u);
assertThat(result).isEmpty();
verifyNoInteractions(teamCreditRepository);
}
@Test
@DisplayName("delegates to the repository for the user's team id")
void delegatesToRepository() {
User u = user(7L, "withteam");
u.setTeam(team(200L));
TeamCredit row = credit(10, 0);
when(teamCreditRepository.findByTeamId(200L)).thenReturn(Optional.of(row));
Optional<TeamCredit> result = service.getCreditSummaryForUser(u);
assertThat(result).containsSame(row);
verify(teamCreditRepository).findByTeamId(200L);
}
}
// ----------------------------------------------------------------------------------------
// getTeamCredits
// ----------------------------------------------------------------------------------------
@Nested
@DisplayName("getTeamCredits")
class GetTeamCredits {
@Test
@DisplayName("passes through the repository optional when present")
void presentPassesThrough() {
TeamCredit row = credit(5, 5);
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(row));
assertThat(service.getTeamCredits(100L)).containsSame(row);
}
@Test
@DisplayName("empty when the repository has no row")
void emptyWhenAbsent() {
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
assertThat(service.getTeamCredits(100L)).isEmpty();
}
}
// ----------------------------------------------------------------------------------------
// addBoughtCredits
// ----------------------------------------------------------------------------------------
@Nested
@DisplayName("addBoughtCredits")
class AddBoughtCredits {
@Test
@DisplayName("adds to bought and total counters then saves")
void addsAndSaves() {
TeamCredit row = credit(0, 10);
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(row));
service.addBoughtCredits(100L, 25);
assertThat(row.getBoughtCreditsRemaining()).isEqualTo(35);
assertThat(row.getTotalBoughtCredits()).isEqualTo(35);
verify(teamCreditRepository).save(row);
}
@Test
@DisplayName("throws IllegalArgumentException when no credit row exists")
void throwsWhenMissing() {
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.addBoughtCredits(100L, 25))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Team credits not found");
verify(teamCreditRepository, never()).save(any());
}
}
// ----------------------------------------------------------------------------------------
// resetCycleCredits
// ----------------------------------------------------------------------------------------
@Nested
@DisplayName("resetCycleCredits")
class ResetCycleCredits {
@Test
@DisplayName("overwrites cycle allocation/remaining and reset timestamp then saves")
void resetsAndSaves() {
TeamCredit row = credit(1, 0);
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.of(row));
LocalDateTime resetTime = LocalDateTime.of(2026, 1, 1, 2, 0);
service.resetCycleCredits(100L, 600, resetTime);
assertThat(row.getCycleCreditsAllocated()).isEqualTo(600);
assertThat(row.getCycleCreditsRemaining()).isEqualTo(600);
assertThat(row.getLastCycleResetAt()).isEqualTo(resetTime);
verify(teamCreditRepository).save(row);
}
@Test
@DisplayName("throws IllegalArgumentException when no credit row exists")
void throwsWhenMissing() {
when(teamCreditRepository.findByTeamId(100L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.resetCycleCredits(100L, 600, LocalDateTime.now()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Team credits not found");
verify(teamCreditRepository, never()).save(any());
}
}
// ----------------------------------------------------------------------------------------
// consumeCreditWithWaterfall
// ----------------------------------------------------------------------------------------
@Nested
@DisplayName("consumeCreditWithWaterfall")
class ConsumeCreditWithWaterfall {
@Test
@DisplayName("pool hit returns success(TEAM_CREDITS) and never touches overage billing")
void poolHitShortCircuits() {
when(teamCreditRepository.consumeCredit(100L, 3)).thenReturn(1);
CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 3);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getSource()).isEqualTo("TEAM_CREDITS");
verifyNoInteractions(membershipRepository);
verifyNoInteractions(saasUserExtensionService);
verifyNoInteractions(stripeUsageReportingService);
}
@Test
@DisplayName("pool exhausted and no leader returns failure(NO_TEAM_LEADER)")
void poolExhaustedNoLeader() {
when(teamCreditRepository.consumeCredit(100L, 3)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of());
CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 3);
assertThat(result.isSuccess()).isFalse();
// failure(reason) sets the message to the reason code.
assertThat(result.getMessage()).isEqualTo("NO_TEAM_LEADER");
verifyNoInteractions(saasUserExtensionService);
verifyNoInteractions(stripeUsageReportingService);
}
@Test
@DisplayName("leader without metered billing returns the no-overage failure message")
void leaderMeteredBillingDisabled() {
User leader = user(9L, "leader");
leader.setSupabaseId(UUID.randomUUID());
when(teamCreditRepository.consumeCredit(100L, 3)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership(leader)));
when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(false);
CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 3);
assertThat(result.isSuccess()).isFalse();
assertThat(result.getMessage()).contains("Team credits exhausted");
verifyNoInteractions(stripeUsageReportingService);
}
@Test
@DisplayName("leader with metered billing but no Supabase id returns LEADER_NO_SUPABASE_ID")
void leaderMissingSupabaseId() {
User leader = user(9L, "leader");
leader.setSupabaseId(null);
when(teamCreditRepository.consumeCredit(100L, 3)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership(leader)));
when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 3);
assertThat(result.isSuccess()).isFalse();
assertThat(result.getMessage()).isEqualTo("LEADER_NO_SUPABASE_ID");
verifyNoInteractions(stripeUsageReportingService);
}
@Test
@DisplayName("successful Stripe overage report returns success(TEAM_LEADER_METERED)")
void overageReportedSuccessfully() {
UUID supabaseId = UUID.randomUUID();
User leader = user(9L, "leader");
leader.setSupabaseId(supabaseId);
when(teamCreditRepository.consumeCredit(100L, 4)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership(leader)));
when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
when(stripeUsageReportingService.generateIdempotencyKey(
eq(supabaseId.toString()), eq(4), anyString()))
.thenReturn("idem-key");
when(stripeUsageReportingService.reportUsageToStripe(
supabaseId.toString(), 4, "idem-key"))
.thenReturn(true);
CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 4);
assertThat(result.isSuccess()).isTrue();
assertThat(result.getSource()).isEqualTo("TEAM_LEADER_METERED");
verify(stripeUsageReportingService)
.reportUsageToStripe(supabaseId.toString(), 4, "idem-key");
}
@Test
@DisplayName("uses the MDC requestId as the stable operation id for the idempotency key")
void usesMdcRequestIdForIdempotency() {
UUID supabaseId = UUID.randomUUID();
User leader = user(9L, "leader");
leader.setSupabaseId(supabaseId);
MDC.put("requestId", "req-123");
when(teamCreditRepository.consumeCredit(100L, 2)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership(leader)));
when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
when(stripeUsageReportingService.generateIdempotencyKey(
anyString(), anyInt(), anyString()))
.thenReturn("idem-key");
when(stripeUsageReportingService.reportUsageToStripe(
anyString(), anyInt(), anyString()))
.thenReturn(true);
service.consumeCreditWithWaterfall(100L, 2);
verify(stripeUsageReportingService)
.generateIdempotencyKey(supabaseId.toString(), 2, "req-123");
}
@Test
@DisplayName("falls back to a random operation id when no requestId is in MDC")
void generatesRandomOperationIdWhenNoMdc() {
UUID supabaseId = UUID.randomUUID();
User leader = user(9L, "leader");
leader.setSupabaseId(supabaseId);
// No MDC requestId set -> a random UUID string is generated.
when(teamCreditRepository.consumeCredit(100L, 2)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership(leader)));
when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
when(stripeUsageReportingService.generateIdempotencyKey(
anyString(), anyInt(), anyString()))
.thenReturn("idem-key");
when(stripeUsageReportingService.reportUsageToStripe(
anyString(), anyInt(), anyString()))
.thenReturn(true);
CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 2);
assertThat(result.isSuccess()).isTrue();
ArgumentCaptor<String> opId = ArgumentCaptor.forClass(String.class);
verify(stripeUsageReportingService)
.generateIdempotencyKey(eq(supabaseId.toString()), eq(2), opId.capture());
assertThat(opId.getValue()).isNotBlank();
}
@Test
@DisplayName("Stripe report returning false yields failure(STRIPE_REPORTING_FAILED)")
void overageReportFailed() {
UUID supabaseId = UUID.randomUUID();
User leader = user(9L, "leader");
leader.setSupabaseId(supabaseId);
when(teamCreditRepository.consumeCredit(100L, 4)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership(leader)));
when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
when(stripeUsageReportingService.generateIdempotencyKey(
anyString(), anyInt(), anyString()))
.thenReturn("idem-key");
when(stripeUsageReportingService.reportUsageToStripe(
anyString(), anyInt(), anyString()))
.thenReturn(false);
CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 4);
assertThat(result.isSuccess()).isFalse();
assertThat(result.getMessage()).contains("Unable to report usage to Stripe");
}
@Test
@DisplayName("exception during reporting is caught and surfaced as STRIPE_REPORTING_ERROR")
void overageReportThrows() {
UUID supabaseId = UUID.randomUUID();
User leader = user(9L, "leader");
leader.setSupabaseId(supabaseId);
when(teamCreditRepository.consumeCredit(100L, 4)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership(leader)));
when(saasUserExtensionService.isMeteredBillingEnabled(leader)).thenReturn(true);
when(stripeUsageReportingService.generateIdempotencyKey(
anyString(), anyInt(), anyString()))
.thenReturn("idem-key");
when(stripeUsageReportingService.reportUsageToStripe(
anyString(), anyInt(), anyString()))
.thenThrow(new RuntimeException("boom"));
CreditConsumptionResult result = service.consumeCreditWithWaterfall(100L, 4);
assertThat(result.isSuccess()).isFalse();
assertThat(result.getMessage()).contains("Error reporting usage: boom");
}
@Test
@DisplayName("picks the first leader when several leaders exist on the team")
void picksFirstLeaderOfMany() {
UUID firstSupabaseId = UUID.randomUUID();
User first = user(1L, "first-leader");
first.setSupabaseId(firstSupabaseId);
User second = user(2L, "second-leader");
second.setSupabaseId(UUID.randomUUID());
when(teamCreditRepository.consumeCredit(100L, 1)).thenReturn(0);
when(membershipRepository.findByTeamIdAndRole(100L, TeamRole.LEADER))
.thenReturn(List.of(leaderMembership(first), leaderMembership(second)));
when(saasUserExtensionService.isMeteredBillingEnabled(first)).thenReturn(true);
when(stripeUsageReportingService.generateIdempotencyKey(
anyString(), anyInt(), anyString()))
.thenReturn("idem-key");
when(stripeUsageReportingService.reportUsageToStripe(
anyString(), anyInt(), anyString()))
.thenReturn(true);
service.consumeCreditWithWaterfall(100L, 1);
// Only the first leader's identity is checked / reported on.
verify(saasUserExtensionService).isMeteredBillingEnabled(first);
verify(saasUserExtensionService, never()).isMeteredBillingEnabled(second);
verify(stripeUsageReportingService)
.generateIdempotencyKey(eq(firstSupabaseId.toString()), eq(1), anyString());
}
}
}
@@ -0,0 +1,378 @@
package stirling.software.saas.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.HashMap;
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.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;
import stirling.software.saas.config.CreditsProperties;
/**
* Unit tests for {@link UserRoleService}.
*
* <p>The service is a thin orchestrator: it flips a user's {@link Authority} row, mirrors the role
* into the denormalized {@code roleName} column, and (for the upgrade/downgrade helpers) resets the
* cycle credit allocation via {@link CreditService}. All collaborators are mocked; allocation math
* is pure arithmetic and asserted exactly.
*
* <p>Allocation note: {@link CreditsProperties}'s default map already carries ROLE_USER=50 and
* ROLE_PRO_USER=500, so {@code getOrDefault} returns those, NOT the inline 25/100 fallbacks baked
* into the service. The inline fallbacks only surface when the allocations map lacks the key, which
* is exercised separately with an emptied map.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class UserRoleServiceTest {
@Mock private UserRepository userRepository;
@Mock private AuthorityRepository authorityRepository;
@Mock private CreditService creditService;
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"
/** Build the service with the supplied properties (allocations differ per test). */
private UserRoleService service(CreditsProperties props) {
return new UserRoleService(userRepository, authorityRepository, creditService, props);
}
/** Default properties: allocations map carries ROLE_USER=50, ROLE_PRO_USER=500. */
private static CreditsProperties defaultProps() {
return new CreditsProperties();
}
/** Properties whose allocations map is empty, forcing the service's inline 25/100 fallbacks. */
private static CreditsProperties emptyAllocationsProps() {
CreditsProperties p = new CreditsProperties();
p.getCycle().setAllocations(new HashMap<>());
return p;
}
/** Properties whose allocations map holds the exact values we want to assert against. */
private static CreditsProperties propsWith(Map<String, Integer> allocations) {
CreditsProperties p = new CreditsProperties();
p.getCycle().setAllocations(new HashMap<>(allocations));
return p;
}
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(defaultProps());
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);
// No credit reset on the bare changeRole path.
verify(creditService, never())
.resetCycleAllocationForRoleChange(Mockito.anyLong(), Mockito.anyInt());
}
@Test
@DisplayName("persists the authority before the user (authority-first ordering)")
void persistsAuthorityBeforeUser() {
UserRoleService service = service(defaultProps());
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(defaultProps());
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(defaultProps());
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(defaultProps());
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 and resets credits to the configured FREE allocation (50)")
void setsUserRoleAndResetsCreditsFromConfig() {
UserRoleService service = service(defaultProps());
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);
// Config map has ROLE_USER=50, so getOrDefault returns 50 (NOT the inline 25 fallback).
verify(creditService).resetCycleAllocationForRoleChange(42L, 50);
}
@Test
@DisplayName("uses the inline 25 fallback when the allocations map omits ROLE_USER")
void usesInlineFallbackWhenAllocationMissing() {
UserRoleService service = service(emptyAllocationsProps());
User u = user(42L, "frank@example.com", ROLE_PRO_USER);
Authority auth = authority(ROLE_PRO_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.downgradeToFree(u);
verify(creditService).resetCycleAllocationForRoleChange(42L, 25);
}
@Test
@DisplayName("honours an explicit ROLE_USER allocation override")
void honoursExplicitAllocationOverride() {
UserRoleService service = service(propsWith(Map.of(ROLE_USER, 7)));
User u = user(42L, "grace@example.com", ROLE_PRO_USER);
Authority auth = authority(ROLE_PRO_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.downgradeToFree(u);
verify(creditService).resetCycleAllocationForRoleChange(42L, 7);
}
@Test
@DisplayName("changes the role before resetting credits (role flip precedes the reset)")
void changesRoleBeforeResettingCredits() {
UserRoleService service = service(defaultProps());
User u = user(42L, "heidi@example.com", ROLE_PRO_USER);
Authority auth = authority(ROLE_PRO_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.downgradeToFree(u);
InOrder order = Mockito.inOrder(userRepository, creditService);
order.verify(userRepository).save(u);
order.verify(creditService).resetCycleAllocationForRoleChange(42L, 50);
}
}
@Nested
@DisplayName("upgradeToPro")
class UpgradeToPro {
@Test
@DisplayName("sets ROLE_PRO_USER and resets credits to the configured PRO allocation (500)")
void setsProRoleAndResetsCreditsFromConfig() {
UserRoleService service = service(defaultProps());
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);
// Config map has ROLE_PRO_USER=500, so getOrDefault returns 500 (NOT inline 100).
verify(creditService).resetCycleAllocationForRoleChange(42L, 500);
}
@Test
@DisplayName("uses the inline 100 fallback when the allocations map omits ROLE_PRO_USER")
void usesInlineFallbackWhenAllocationMissing() {
UserRoleService service = service(emptyAllocationsProps());
User u = user(42L, "judy@example.com", ROLE_USER);
Authority auth = authority(ROLE_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.upgradeToPro(u);
verify(creditService).resetCycleAllocationForRoleChange(42L, 100);
}
@Test
@DisplayName("honours an explicit ROLE_PRO_USER allocation override")
void honoursExplicitAllocationOverride() {
UserRoleService service = service(propsWith(Map.of(ROLE_PRO_USER, 9999)));
User u = user(42L, "mallory@example.com", ROLE_USER);
Authority auth = authority(ROLE_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.upgradeToPro(u);
verify(creditService).resetCycleAllocationForRoleChange(42L, 9999);
}
@Test
@DisplayName("changes the role before resetting credits (role flip precedes the reset)")
void changesRoleBeforeResettingCredits() {
UserRoleService service = service(defaultProps());
User u = user(42L, "niaj@example.com", ROLE_USER);
Authority auth = authority(ROLE_USER);
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
service.upgradeToPro(u);
InOrder order = Mockito.inOrder(userRepository, creditService);
order.verify(userRepository).save(u);
order.verify(creditService).resetCycleAllocationForRoleChange(42L, 500);
}
}
@Nested
@DisplayName("getCreditAllocationForRole")
class GetCreditAllocationForRole {
@Test
@DisplayName("returns the configured allocation when the role is present in the map")
void returnsConfiguredAllocation() {
UserRoleService service = service(defaultProps());
// Default map: ROLE_USER=50, ROLE_PRO_USER=500, ROLE_ADMIN=1000.
assertThat(service.getCreditAllocationForRole(ROLE_USER)).isEqualTo(50);
assertThat(service.getCreditAllocationForRole(ROLE_PRO_USER)).isEqualTo(500);
assertThat(service.getCreditAllocationForRole("ROLE_ADMIN")).isEqualTo(1000);
}
@Test
@DisplayName("ROLE_USER falls back to 25 when missing from the allocations map")
void userRoleFallsBackTo25() {
UserRoleService service = service(emptyAllocationsProps());
assertThat(service.getCreditAllocationForRole(ROLE_USER)).isEqualTo(25);
}
@Test
@DisplayName("any non-ROLE_USER role falls back to 100 when missing from the map")
void nonUserRoleFallsBackTo100() {
UserRoleService service = service(emptyAllocationsProps());
assertThat(service.getCreditAllocationForRole(ROLE_PRO_USER)).isEqualTo(100);
assertThat(service.getCreditAllocationForRole("ROLE_ADMIN")).isEqualTo(100);
assertThat(service.getCreditAllocationForRole("ROLE_UNKNOWN")).isEqualTo(100);
}
@Test
@DisplayName("null role id is treated as non-ROLE_USER and falls back to 100")
void nullRoleIdFallsBackTo100() {
UserRoleService service = service(emptyAllocationsProps());
// getOrDefault(null, ...) misses (no null key); the ternary's equals(null) is false
// -> 100. Must not NPE because Role.USER.getRoleId().equals(roleId) is null-safe.
assertThat(service.getCreditAllocationForRole(null)).isEqualTo(100);
}
@Test
@DisplayName("a configured value of zero is returned verbatim, not replaced by a fallback")
void zeroAllocationReturnedVerbatim() {
UserRoleService service = service(propsWith(Map.of("ROLE_WEB_ONLY_USER", 0)));
assertThat(service.getCreditAllocationForRole("ROLE_WEB_ONLY_USER")).isZero();
}
@Test
@DisplayName("getCreditAllocationForRole never touches the persistence collaborators")
void doesNotTouchRepositories() {
UserRoleService service = service(defaultProps());
service.getCreditAllocationForRole(ROLE_USER);
Mockito.verifyNoInteractions(userRepository, authorityRepository, creditService);
}
}
}
@@ -0,0 +1,348 @@
package stirling.software.saas.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.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.proprietary.model.Team;
import stirling.software.proprietary.security.model.Authority;
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;
/**
* Unit tests for {@link CreditHeaderUtils#getRemainingCredits(User, CreditService,
* TeamCreditService)}.
*
* <p>The method resolves a remaining-credit balance by routing between two pools:
*
* <ul>
* <li><b>Team pool</b> - used only when the user is NOT a limited-API user, has a team, and that
* team is not "personal" per {@link SaasTeamExtensionService#isPersonal}.
* <li><b>Personal pool</b> - otherwise; looked up by Supabase id first, then API key, else -1.
* </ul>
*
* Any thrown exception is swallowed and yields the sentinel {@code -1}. All collaborators are
* mocked; the assertions are pure arithmetic on the resolved balance.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CreditHeaderUtilsTest {
@Mock private SaasTeamExtensionService saasTeamExtensionService;
@Mock private CreditService creditService;
@Mock private TeamCreditService teamCreditService;
@InjectMocks private CreditHeaderUtils creditHeaderUtils;
private static final long TEAM_ID = 77L;
private static final UUID SUPABASE_ID = UUID.fromString("00000000-0000-0000-0000-000000000123");
private static final String API_KEY = "api-key-abcdef";
// --- builders -------------------------------------------------------------------------------
private static User user() {
User u = new User();
u.setUsername("tester");
return u;
}
private static Team team(Long id) {
Team t = new Team();
t.setId(id);
t.setName("team-" + id);
return t;
}
/** Authority ctor self-registers on the user's authority set. */
private static void grant(User u, String role) {
new Authority(role, u);
}
private static UserCredit userCredit(int cycle, int bought) {
UserCredit c = new UserCredit(user());
c.setCycleCreditsRemaining(cycle);
c.setBoughtCreditsRemaining(bought);
return c;
}
private static TeamCredit teamCredit(int cycle, int bought) {
TeamCredit c = new TeamCredit(team(TEAM_ID));
c.setCycleCreditsRemaining(cycle);
c.setBoughtCreditsRemaining(bought);
return c;
}
private int call(User u) {
return creditHeaderUtils.getRemainingCredits(u, creditService, teamCreditService);
}
// --- team pool routing ----------------------------------------------------------------------
@Nested
@DisplayName("team pool path (non-limited user on a non-personal team)")
class TeamPool {
@Test
@DisplayName(
"returns the team's total available credits and never touches personal lookups")
void nonPersonalTeam_usesTeamPool() {
User u = user();
u.setTeam(team(TEAM_ID));
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(teamCreditService.getTeamCredits(TEAM_ID))
.thenReturn(Optional.of(teamCredit(40, 10)));
assertThat(call(u)).isEqualTo(50);
verify(teamCreditService).getTeamCredits(TEAM_ID);
verifyNoInteractions(creditService);
}
@Test
@DisplayName("missing team credit row yields the -1 sentinel")
void nonPersonalTeam_missingRow_returnsMinusOne() {
User u = user();
u.setSupabaseId(SUPABASE_ID);
u.setTeam(team(TEAM_ID));
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(teamCreditService.getTeamCredits(TEAM_ID)).thenReturn(Optional.empty());
assertThat(call(u)).isEqualTo(-1);
// Team path chosen, so the personal supabase lookup must never run.
verifyNoInteractions(creditService);
}
@Test
@DisplayName("zero team credits returns 0, not the sentinel")
void nonPersonalTeam_zeroBalance_returnsZero() {
User u = user();
u.setTeam(team(TEAM_ID));
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(teamCreditService.getTeamCredits(TEAM_ID))
.thenReturn(Optional.of(teamCredit(0, 0)));
assertThat(call(u)).isZero();
}
}
// --- personal pool routing ------------------------------------------------------------------
@Nested
@DisplayName("personal pool path")
class PersonalPool {
@Test
@DisplayName("personal team falls through to the user's individual credits")
void personalTeam_usesPersonalCredits() {
User u = user();
u.setSupabaseId(SUPABASE_ID);
u.setTeam(team(TEAM_ID));
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(true);
when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString()))
.thenReturn(Optional.of(userCredit(15, 5)));
assertThat(call(u)).isEqualTo(20);
// Personal path chosen -> team pool untouched.
verify(teamCreditService, never()).getTeamCredits(any());
}
@Test
@DisplayName(
"no team at all uses personal credits and never consults the extension service")
void noTeam_usesPersonalCredits() {
User u = user();
u.setSupabaseId(SUPABASE_ID);
when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString()))
.thenReturn(Optional.of(userCredit(7, 0)));
assertThat(call(u)).isEqualTo(7);
verifyNoInteractions(saasTeamExtensionService);
verify(teamCreditService, never()).getTeamCredits(any());
}
@Test
@DisplayName("supabase id is preferred over api key when both are present")
void supabaseIdPreferredOverApiKey() {
User u = user();
u.setSupabaseId(SUPABASE_ID);
u.setApiKey(API_KEY);
when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString()))
.thenReturn(Optional.of(userCredit(3, 4)));
assertThat(call(u)).isEqualTo(7);
verify(creditService).getUserCreditsBySupabaseId(SUPABASE_ID.toString());
verify(creditService, never()).getUserCreditsByApiKey(anyString());
}
@Test
@DisplayName("falls back to api key lookup when supabase id is absent")
void apiKeyFallback_whenNoSupabaseId() {
User u = user();
u.setApiKey(API_KEY);
when(creditService.getUserCreditsByApiKey(API_KEY))
.thenReturn(Optional.of(userCredit(9, 1)));
assertThat(call(u)).isEqualTo(10);
verify(creditService).getUserCreditsByApiKey(API_KEY);
verify(creditService, never()).getUserCreditsBySupabaseId(anyString());
}
@Test
@DisplayName("supabase lookup returning empty yields -1")
void supabaseLookupEmpty_returnsMinusOne() {
User u = user();
u.setSupabaseId(SUPABASE_ID);
when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString()))
.thenReturn(Optional.empty());
assertThat(call(u)).isEqualTo(-1);
}
@Test
@DisplayName("api key lookup returning empty yields -1")
void apiKeyLookupEmpty_returnsMinusOne() {
User u = user();
u.setApiKey(API_KEY);
when(creditService.getUserCreditsByApiKey(API_KEY)).thenReturn(Optional.empty());
assertThat(call(u)).isEqualTo(-1);
}
@Test
@DisplayName("neither supabase id nor api key present yields -1 with no lookups")
void noIdentifiers_returnsMinusOne() {
User u = user(); // no supabaseId, no apiKey, no team
assertThat(call(u)).isEqualTo(-1);
verifyNoInteractions(creditService);
verifyNoInteractions(teamCreditService);
}
}
// --- limited-api user override --------------------------------------------------------------
@Nested
@DisplayName("limited-api users always read personal credits")
class LimitedApiOverride {
@Test
@DisplayName("ROLE_LIMITED_API_USER on a non-personal team still reads personal credits")
void limitedApiUser_skipsTeamPool() {
User u = user();
u.setApiKey(API_KEY);
u.setTeam(team(TEAM_ID));
grant(u, "ROLE_LIMITED_API_USER");
when(creditService.getUserCreditsByApiKey(API_KEY))
.thenReturn(Optional.of(userCredit(2, 0)));
assertThat(call(u)).isEqualTo(2);
// The team branch is gated out, so neither the extension nor team-credit services run.
verifyNoInteractions(saasTeamExtensionService);
verify(teamCreditService, never()).getTeamCredits(any());
}
@Test
@DisplayName("ROLE_EXTRA_LIMITED_API_USER also forces the personal pool")
void extraLimitedApiUser_skipsTeamPool() {
User u = user();
u.setSupabaseId(SUPABASE_ID);
u.setTeam(team(TEAM_ID));
grant(u, "ROLE_EXTRA_LIMITED_API_USER");
when(creditService.getUserCreditsBySupabaseId(SUPABASE_ID.toString()))
.thenReturn(Optional.of(userCredit(6, 0)));
assertThat(call(u)).isEqualTo(6);
verifyNoInteractions(saasTeamExtensionService);
verify(teamCreditService, never()).getTeamCredits(any());
}
@Test
@DisplayName(
"a non-limited role does not divert a non-personal team member off the team pool")
void unrelatedRole_keepsTeamPool() {
User u = user();
u.setTeam(team(TEAM_ID));
grant(u, "ROLE_USER");
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(teamCreditService.getTeamCredits(TEAM_ID))
.thenReturn(Optional.of(teamCredit(11, 0)));
assertThat(call(u)).isEqualTo(11);
verify(teamCreditService).getTeamCredits(TEAM_ID);
verifyNoInteractions(creditService);
}
}
// --- error swallowing -----------------------------------------------------------------------
@Nested
@DisplayName("exceptions are swallowed and produce the -1 sentinel")
class ErrorHandling {
@Test
@DisplayName("team credit service throwing is caught and returns -1")
void teamServiceThrows_returnsMinusOne() {
User u = user();
u.setTeam(team(TEAM_ID));
when(saasTeamExtensionService.isPersonal(u.getTeam())).thenReturn(false);
when(teamCreditService.getTeamCredits(TEAM_ID))
.thenThrow(new RuntimeException("db down"));
assertThat(call(u)).isEqualTo(-1);
}
@Test
@DisplayName("credit service throwing on personal lookup is caught and returns -1")
void creditServiceThrows_returnsMinusOne() {
User u = user();
u.setSupabaseId(SUPABASE_ID);
when(creditService.getUserCreditsBySupabaseId(anyString()))
.thenThrow(new RuntimeException("lookup boom"));
assertThat(call(u)).isEqualTo(-1);
}
@Test
@DisplayName(
"extension service throwing during personal-team check is caught and returns -1")
void extensionServiceThrows_returnsMinusOne() {
User u = user();
u.setSupabaseId(SUPABASE_ID);
u.setTeam(team(TEAM_ID));
when(saasTeamExtensionService.isPersonal(u.getTeam()))
.thenThrow(new RuntimeException("extension boom"));
assertThat(call(u)).isEqualTo(-1);
}
}
}
+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" }
}
@@ -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"
@@ -2769,7 +2769,6 @@ error = "Error"
expand = "Expand"
loading = "Loading..."
next = "Next"
operation = "this operation"
preview = "Preview"
previous = "Previous"
refresh = "Refresh"
@@ -3222,68 +3221,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 +5350,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 +5366,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 +5655,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 +5676,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 +5686,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 +5792,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."
@@ -4,10 +4,6 @@ apply = "Apply"
applyAndContinue = "Save & Leave"
areYouSure = "Are you sure you want to leave?"
back = "Back"
wizardContinue = "Continue"
wizardProgress = "Step progress"
wizardStepProgress = "Step {{current}} of {{total}}"
wizardCompleteStep = "Complete this step to continue."
black = "Black"
blue = "Blue"
cancel = "Cancel"
@@ -2747,7 +2743,6 @@ error = "Error"
expand = "Expand"
loading = "Loading..."
next = "Next"
operation = "this operation"
preview = "Preview"
previous = "Previous"
refresh = "Refresh"
@@ -3200,68 +3195,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]
@@ -3875,9 +3827,6 @@ title = "File to PDF"
[fileUpload]
dropFilesHere = "Drop files here or click the upload button"
dropTitle = "Drop your file here"
dropSubtitle = "or pick one from your computer"
addAtLeast = "Add at least {{count}} files"
dropFilesHereOpen = "Drop files here or click the open button"
filesAvailable = "files available"
loadFromStorage = "Load from Storage"
@@ -5370,11 +5319,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."
@@ -5390,7 +5335,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."
@@ -5680,13 +5624,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"
@@ -5704,7 +5645,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"
@@ -5715,7 +5655,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"
@@ -5822,15 +5761,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."
@@ -6249,8 +6181,6 @@ applyWarning = "⚠️ Permanent application, cannot be undone and the data unde
colorLabel = "Redaction Color"
controlsTitle = "Manual Redaction Controls"
instructions = "Select text or draw areas on the PDF to mark content for redaction."
markArea = "Mark Area"
markText = "Mark Text"
title = "Redaction Tools"
[redact.modeSelector]
+51
View File
@@ -0,0 +1,51 @@
Stirling PDF User License
Copyright (c) 2025 Stirling PDF Inc.
License Scope & Usage Rights
Production use of the Stirling PDF Software is only permitted with a valid Stirling PDF User License.
For purposes of this license, “the Software” refers to the Stirling PDF application and any associated documentation files
provided by Stirling PDF Inc. You or your organization may not use the Software in production, at scale, or for business-critical
processes unless you have agreed to, and remain in compliance with, the Stirling PDF Subscription Terms of Service
(https://www.stirlingpdf.com/terms) or another valid agreement with Stirling PDF, and hold an active User License subscription
covering the appropriate number of licensed users.
Trial and Minimal Use
You may use the Software without a paid subscription for the sole purposes of internal trial, evaluation, or minimal use, provided that:
* Use is limited to the capabilities and restrictions defined by the Software itself;
* You do not copy, distribute, sublicense, reverse-engineer, or use the Software in client-facing or commercial contexts.
Continued use beyond this scope requires a valid Stirling PDF User License.
Modifications and Derivative Works
You may modify the Software only for development or internal testing purposes. Any such modifications or derivative works:
* May not be deployed in production environments without a valid User License;
* May not be distributed or sublicensed;
* Remain the intellectual property of Stirling PDF and/or its licensors;
* May only be used, copied, or exploited in accordance with the terms of a valid Stirling PDF User License subscription.
Prohibited Actions
Unless explicitly permitted by a paid license or separate agreement, you may not:
* Use the Software in production environments;
* Copy, merge, distribute, sublicense, or sell the Software;
* Remove or alter any licensing or copyright notices;
* Circumvent access restrictions or licensing requirements.
Third-Party Components
The Stirling PDF Software may include components subject to separate open source licenses. Such components remain governed by
their original license terms as provided by their respective owners.
Disclaimer
THE SOFTWARE IS PROVIDED “AS IS,” WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+1
View File
@@ -0,0 +1 @@
export const CLOUD_LAYER_PROBE = "cloud" as const;
+16
View File
@@ -0,0 +1,16 @@
/**
* Auth/session seam (@app/auth/session). saas keeps a Supabase web session;
* desktop keeps a JWT in the Tauri secure store — cloud code reads the token
* through this seam instead. Default no-op; saas/ and desktop/ shadow it.
*/
/** Minimal session shape shared across platforms. */
export interface AppSession {
/** Bearer access token for authenticated API calls, or null when signed out. */
accessToken: string | null;
}
/** The current access token for authenticated API calls, or null when signed out. */
export async function getAccessToken(): Promise<string | null> {
return null;
}
@@ -0,0 +1,37 @@
/**
* Team-session auth seam (@app/auth/teamSession). {@link SaaSTeamContext} needs
* just two things from auth (can-use-teams + a post-membership refresh), so it
* consumes them through this narrow seam rather than reaching a platform auth
* surface directly. Default reports no access; saas/ and desktop/ shadow it.
*/
/**
* The minimal auth surface {@link SaaSTeamContext} depends on.
*
* Each platform supplies its own implementation:
* - {@link canUseTeams} is {@code true} only for a signed-in, non-anonymous
* user (web: Supabase non-anonymous session; desktop: a valid authService
* token). When {@code false} the context stays empty and makes no API calls.
* - {@link refreshAfterMembershipChange} is invoked after a membership change
* (accepting an invite, leaving a team) so the platform can refresh any
* derived auth-tier state. On web this refreshes credits + the Supabase
* session; on desktop it is a no-op (no such derived state).
*/
export interface TeamAuth {
/** Whether the current session may load/manage teams (signed in, not anonymous). */
canUseTeams: boolean;
/** Refresh derived auth state after a team membership change. */
refreshAfterMembershipChange: () => Promise<void>;
}
/**
* Resolve the team-relevant auth surface for the current session. Cloud default
* reports no access + a no-op refresh; saas/desktop shadow it. Implemented as a
* hook so the saas/desktop impls can subscribe to live auth state.
*/
export function useTeamAuth(): TeamAuth {
return {
canUseTeams: false,
refreshAfterMembershipChange: async () => {},
};
}
@@ -14,6 +14,12 @@ import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
interface SaasOnboardingModalProps {
opened: boolean;
onClose: () => void;
/**
* Drop the closing "desktop-install" slide. Set by the desktop app, which
* reuses this flow but has no reason to pitch its own download. Defaults to
* false (slide shown) so the web (saas) flow is unchanged.
*/
hideDesktopInstall?: boolean;
}
export default function SaasOnboardingModal(props: SaasOnboardingModalProps) {
@@ -5,21 +5,30 @@ export interface SaasFlowInputs {
showUsageSlide: boolean;
/** Team leaders only — invited members and anonymous guests skip the team slide. */
showTeamSlide: boolean;
/**
* Drop the closing "desktop-install" slide. The web (saas) flow pitches the
* desktop download, but the desktop app reuses this same flow and is already
* the desktop app, so it omits that slide. Defaults to false (slide shown).
*/
hideDesktopInstall?: boolean;
}
/**
* Resolves the SaaS onboarding slide sequence. The free-editor pitch and
* desktop install bookend the flow; the usage meter and team slides slot in
* when their conditions hold.
* when their conditions hold. When {@link SaasFlowInputs.hideDesktopInstall} is
* set, the closing desktop-install slide is dropped (used by the desktop app,
* which has no reason to pitch its own download).
*/
export function resolveSaasFlow({
showUsageSlide,
showTeamSlide,
hideDesktopInstall = false,
}: SaasFlowInputs): SlideId[] {
return [
"free-editor",
...(showUsageSlide ? (["usage"] as const) : []),
...(showTeamSlide ? (["team"] as const) : []),
"desktop-install",
...(hideDesktopInstall ? [] : (["desktop-install"] as const)),
];
}
@@ -11,6 +11,7 @@ import {
} from "@app/components/onboarding/saasOnboardingFlowConfig";
import { resolveSaasFlow } from "@app/components/onboarding/saasFlowResolver";
import { DOWNLOAD_URLS } from "@app/constants/downloads";
import { openExternal } from "@app/platform/openExternal";
interface UseSaasOnboardingStateResult {
currentStep: number;
@@ -24,11 +25,18 @@ interface UseSaasOnboardingStateResult {
interface UseSaasOnboardingStateProps {
opened: boolean;
onClose: () => void;
/**
* Drop the closing "desktop-install" slide. The desktop app reuses this
* flow but has no reason to pitch its own download. Defaults to false
* (slide shown) so the web (saas) flow is unchanged.
*/
hideDesktopInstall?: boolean;
}
export function useSaasOnboardingState({
opened,
onClose,
hideDesktopInstall = false,
}: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null {
const { loading } = useAuth();
const { wallet } = useWallet();
@@ -80,8 +88,9 @@ export function useSaasOnboardingState({
const showTeamSlide = isTeamLeader;
const flowSlideIds = useMemo(
() => resolveSaasFlow({ showUsageSlide, showTeamSlide }),
[showUsageSlide, showTeamSlide],
() =>
resolveSaasFlow({ showUsageSlide, showTeamSlide, hideDesktopInstall }),
[showUsageSlide, showTeamSlide, hideDesktopInstall],
);
const totalSteps = flowSlideIds.length;
const maxIndex = Math.max(totalSteps - 1, 0);
@@ -136,10 +145,11 @@ export function useSaasOnboardingState({
onClose();
return;
case "download-selected": {
// Open download URL in new tab
// Open the download URL in the user's browser via the platform seam
// (saas opens a new tab, desktop hands off to the OS shell).
const downloadUrl = selectedDownloadUrlRef.current || os.url;
if (downloadUrl) {
window.open(downloadUrl, "_blank", "noopener,noreferrer");
void openExternal(downloadUrl);
}
// Then advance to next slide or close if last
if (currentStep === maxIndex) {
@@ -0,0 +1,45 @@
import React from "react";
import { type TFunction } from "i18next";
import {
type ConfigNavSection,
type ConfigNavItem,
} from "@core/components/shared/config/configNavSections";
import Plan from "@app/components/shared/config/configSections/Plan";
import TeamSection from "@app/components/shared/config/configSections/TeamSection";
/**
* Shared cloud config nav-section builders, composed by both the saas (web) and
* desktop (Tauri) nav wrappers. The Plan (wallet-driven PAYG dashboard + spend
* cap) and Team sections are identical across platforms; each leaf owns its own
* modal chrome and appends its leaf-only sections around these.
*/
type Translate = TFunction<"translation", undefined>;
/** The Plan (billing) nav item — wallet-driven PAYG dashboard + spend cap. */
export function createCloudPlanNavItem(t: Translate): ConfigNavItem {
return {
key: "plan",
label: t("config.plan", "Plan"),
icon: "credit-card",
component: <Plan />,
};
}
/** The Team nav item — shared SaaS team management (invite/rename/members). */
export function createCloudTeamNavItem(t: Translate): ConfigNavItem {
return {
key: "teams",
label: t("config.team", "Team"),
icon: "groups-rounded",
component: <TeamSection />,
};
}
/** Billing nav section wrapping the Plan item, for leaves that group it (saas). */
export function createCloudBillingSection(t: Translate): ConfigNavSection {
return {
title: "Billing",
items: [createCloudPlanNavItem(t)],
};
}
@@ -9,7 +9,7 @@
* <pre>
* // In UpgradeModal.tsx — only when the user advances to step 2:
* const StripeCheckoutPanel = React.lazy(
* () => import("./StripeCheckoutPanel"),
* () => import("@app/components/shared/config/configSections/StripeCheckoutPanel"),
* );
* </pre>
*
@@ -27,11 +27,12 @@
*
* <h2>Architecture</h2>
*
* Stripe-touching code lives in Supabase edge functions, not the Java
* backend. This panel invokes {@code create-checkout-session}
* directly via {@code supabase.functions.invoke()} - same pattern {@link
* usePlans} already uses for {@code stripe-price-lookup}. The auth JWT is
* attached automatically by the Supabase client.
* Stripe-touching code lives in Supabase edge functions, not the Java backend.
* This panel no longer talks to Supabase directly — it mints the PAYG checkout
* session through the {@code @app/services/billing} seam
* ({@link createCheckoutSession} with a {@code teamId}), which each platform
* implements (web supabase client vs Tauri fetch). The seam drives the
* {@code create-checkout-session} edge function.
*
* <p>The edge function is the canonical place Stripe Checkout
* Sessions get created - it uses the Stripe Sync Engine tables, has dedicated
@@ -42,16 +43,19 @@
* <h2>Behaviour</h2>
*
* <ol>
* <li>On mount: calls {@code supabase.functions.invoke("create-checkout-session", {team_id,
* currency, success_url, cancel_url})} to obtain a {@code client_secret}. The spending cap is
* NOT set here — it's an application-layer setting applied via {@code PATCH /payg/cap} after
* the subscription lands.
* <li>If no {@code VITE_STRIPE_PUBLISHABLE_KEY} is configured OR the edge
* function isn't deployed yet (errors out / returns a {@code cs_mock_}
* sentinel), render a clearly-labelled placeholder + "Continue with
* mock" button so the post-completion path stays testable.
* <li>On mount: calls {@link createCheckoutSession} with the {@code teamId},
* {@code currency} and billing email to obtain a {@code clientSecret}. The
* spending cap is NOT set here — it's an application-layer setting applied
* via {@code PATCH /payg/cap} after the subscription lands.
* <li>If no Stripe publishable key is configured OR the edge function isn't
* deployed yet (errors out / returns a {@code cs_mock_} sentinel), render
* a clearly-labelled placeholder + "Continue with mock" button so the
* post-completion path stays testable.
* <li>If only a hosted {@code url} comes back (the redirect fallback), hand it
* to the system browser via {@link openExternal}.
* <li>Otherwise render the real {@code <EmbeddedCheckoutProvider>} +
* {@code <EmbeddedCheckout>} iframe.
* {@code <EmbeddedCheckout>} iframe. The Tauri webview has no CSP, so
* embedded checkout works on desktop too.
* </ol>
*
* The parent {@link UpgradeModal} passes {@code onComplete} which fires when
@@ -60,8 +64,13 @@
*/
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { supabase } from "@app/auth/supabase";
import { useAuth } from "@app/auth/UseSession";
import {
createCheckoutSession,
getStripePublishableKey,
} from "@app/services/billing";
import { openExternal } from "@app/platform/openExternal";
import { getWalletDevPreview } from "@app/hooks/walletDevPreview";
// Eager static imports here are OK because this whole module is itself lazy-
// imported by the modal. They land in the same lazy chunk.
@@ -89,21 +98,6 @@ export interface StripeCheckoutPanelProps {
onError?: (message: string) => void;
}
/**
* Response shape from the {@code create-checkout-session} Supabase edge
* function. Mirrors what the function returns.
*/
interface CheckoutResponse {
/** Stripe Checkout Session client_secret. */
client_secret: string;
/**
* Optional sentinel: edge functions in non-prod environments may return a
* stubbed secret prefixed {@code cs_mock_} so the FE knows to render the
* placeholder rather than try to mount a real iframe with a bad secret.
*/
mock?: boolean;
}
// Singleton Stripe promise — created on first use and reused for the lifetime
// of the tab. {@code loadStripe} is dynamically imported so the actual SDK
// chunk is only pulled when this code path runs.
@@ -148,16 +142,14 @@ const StripeCheckoutPanel: React.FC<StripeCheckoutPanelProps> = ({
const tRef = useRef(t);
tRef.current = t;
const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
const publishableKey = getStripePublishableKey();
// Dev preview route has no backend — skip the API call and go straight
// to the mock placeholder so the design + completion path stay testable.
// Both checks required so a production tenant with a /dev/ URL prefix
// can't accidentally trigger the placeholder.
const devPreview =
import.meta.env.DEV &&
typeof window !== "undefined" &&
window.location.pathname.startsWith("/dev/");
// The dev-preview detection (import.meta.env.DEV + a /dev/ path) lives behind
// the walletDevPreview seam since cloud may not read either directly; it is
// a saas-only affordance and resolves to null on desktop / prod.
const devPreview = getWalletDevPreview() !== null;
useEffect(() => {
if (devPreview) {
@@ -177,44 +169,37 @@ const StripeCheckoutPanel: React.FC<StripeCheckoutPanelProps> = ({
let cancelled = false;
async function createSession() {
try {
// Direct Supabase edge function invocation. We call
// {@code create-checkout-session} (not {@code create-payg-team-subscription} —
// that one creates a subscription directly without going through the
// hosted Stripe Embedded Checkout iframe and doesn't return a
// client_secret). team_id is required because the edge fn runs
// outside our Spring Security context and can't resolve it from the
// JWT alone. The cap is *not* set during checkout — it's an
// application-layer setting, applied via PATCH /payg/cap after the
// subscription lands. window.location.href is the success_url so the
// user comes back to the Plan tab after Stripe finishes the redirect.
const returnUrl = window.location.href;
const { data, error: invokeError } =
await supabase.functions.invoke<CheckoutResponse>(
"create-checkout-session",
{
body: {
team_id: teamId,
currency,
success_url: returnUrl,
cancel_url: returnUrl,
// Maps to Stripe's customer_email when the team has no Stripe
// customer yet — prefills + locks the email field in Checkout.
// Teams with an existing customer get the email locked from the
// customer record instead; this field is ignored for them.
...(billingEmail ? { billing_owner_email: billingEmail } : {}),
},
},
);
if (invokeError) {
throw invokeError;
// Mint the PAYG checkout session through the billing seam. Passing a
// teamId routes it to the {@code create-checkout-session} edge function
// (not {@code create-payg-team-subscription} — that one subscribes
// directly without the embedded iframe and returns no client_secret).
// team_id is required because the edge fn runs outside our Spring
// Security context and can't resolve it from the JWT alone. The cap is
// *not* set during checkout — it's applied via PATCH /payg/cap after
// the subscription lands. The platform impl supplies the success/cancel
// return URL (browser origin on web, deep link on desktop).
const session = await createCheckoutSession({
teamId,
currency,
// Maps to Stripe's customer_email when the team has no Stripe
// customer yet — prefills + locks the email field in Checkout. Teams
// with an existing customer get the email locked from the customer
// record instead; this field is ignored for them.
billingOwnerEmail: billingEmail,
});
if (cancelled) return;
// Hosted-url fallback: no embedded iframe, hand the URL to the system
// browser. The deep-link / origin return URL brings the user back.
if (session.url && !session.clientSecret) {
await openExternal(session.url);
return;
}
if (!data?.client_secret) {
if (!session.clientSecret) {
throw new Error("Edge function returned no client_secret");
}
if (cancelled) return;
setClientSecret(data.client_secret);
setClientSecret(session.clientSecret);
setIsMock(
Boolean(data.mock) || data.client_secret.startsWith("cs_mock_"),
Boolean(session.mock) || session.clientSecret.startsWith("cs_mock_"),
);
} catch (e: unknown) {
if (cancelled) return;
@@ -43,7 +43,10 @@ function dispatchOverlay(open: boolean) {
// Lazy-loaded so the @stripe/stripe-js bundle only downloads when the user
// reaches step 2. See StripeCheckoutPanel.tsx for the full pattern + the
// chunk-graph reasoning.
const StripeCheckoutPanel = React.lazy(() => import("./StripeCheckoutPanel"));
const StripeCheckoutPanel = React.lazy(
() =>
import("@app/components/shared/config/configSections/StripeCheckoutPanel"),
);
interface UpgradeModalProps {
open: boolean;
@@ -7,12 +7,16 @@ import {
useCallback,
} from "react";
import apiClient from "@app/services/apiClient";
import { useAuth } from "@app/auth/UseSession";
import { isUserAnonymous } from "@app/auth/supabase";
import { useTeamAuth } from "@app/auth/teamSession";
/**
* SaaS web implementation of SaaS Team Context
* Provides team management for authenticated (non-anonymous) users
* Shared (cloud) SaaS Team Context.
*
* Provides team management for authenticated (non-anonymous) users. The
* platform-specific auth bits — whether teams may be used at all, and how to
* refresh derived auth state after a membership change — come from the
* {@code @app/auth/teamSession} seam (Supabase web session on saas, authService
* on desktop), keeping this context free of any platform auth coupling.
*/
interface Team {
@@ -92,8 +96,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
>([]);
const [loading, setLoading] = useState(true);
const { user, refreshCredits, refreshSession } = useAuth();
const canUseTeams = !!user && !isUserAnonymous(user);
const { canUseTeams, refreshAfterMembershipChange } = useTeamAuth();
const fetchMyTeams = useCallback(async () => {
if (!canUseTeams) return null;
@@ -223,8 +226,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
await apiClient.post(`/api/v1/team/invitations/${token}/accept`);
await fetchReceivedInvitations();
await refreshTeams();
await refreshCredits();
await refreshSession();
await refreshAfterMembershipChange();
};
const rejectInvitation = async (token: string) => {
@@ -255,8 +257,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
await apiClient.post(`/api/v1/team/${currentTeam.teamId}/leave`);
await refreshTeams();
await refreshCredits();
await refreshSession();
await refreshAfterMembershipChange();
};
const isTeamLeader = currentTeam?.isLeader ?? false;
@@ -37,15 +37,19 @@
* When the hook is rendered outside the saas app (e.g. on {@code
* /dev/payg-preview} during local design work) the {@code AppConfigContext}
* provider is not mounted and no backend is available. The hook detects that
* via {@code import.meta.env.DEV} + the {@code /dev/} path and falls back to
* a synthesised snapshot whose subscription state is read from
* {@code localStorage} (key {@code stirling.payg.devSubscription}). Both
* conditions are required so a production tenant whose URL happens to start
* with {@code /dev/} can't trigger the fallback.
* via the {@code @app/hooks/walletDevPreview} seam and, when it returns a live
* channel, falls back to a synthesised snapshot whose subscription state is
* read from {@code localStorage}. The detection + synthesis (which read
* {@code import.meta.env}, {@code window.location} and web storage — all banned
* in cloud/) live in the saas leaf's impl of that seam; this hook just consults
* it. Desktop's cascade falls through to the cloud default (no dev preview), so
* it always fetches the real wallet.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import apiClient from "@app/services/apiClient";
import { supabase } from "@app/auth/supabase";
import { createPortalSession } from "@app/services/billing";
import { openExternal } from "@app/platform/openExternal";
import { getWalletDevPreview } from "@app/hooks/walletDevPreview";
// ─── Public types ───────────────────────────────────────────────────────
@@ -185,24 +189,20 @@ export interface UseWalletResult {
*/
updateCap: (capUsd: number | null) => Promise<void>;
/**
* Mint a Stripe Customer Portal session and navigate to it. Calls
* the {@code create-customer-portal-session} Supabase edge function
* directly with the user's JWT (same pattern as checkout — no backend
* proxy; the function's RPC enforces team membership) and assigns
* {@code window.location} to the returned URL — same-tab redirect.
* We do not use {@code window.open(...,"_blank")} after an {@code await}
* because browsers treat it as non-user-gesture and silently popup-block
* it; the portal is a full-page experience anyway and Stripe redirects
* back to {@code return_url} on close. Throws on error so the caller can
* show a friendly toast — notably 404 {@code team_not_subscribed}.
* Mint a Stripe Customer Portal session and send the user to it. Mints the
* session via the {@code @app/services/billing} seam (passing the caller's
* {@code teamId}, which the PAYG portal edge function needs to resolve the
* team outside Spring Security) and opens the returned URL via the
* {@code @app/platform/openExternal} seam — so web and desktop each route it
* the platform-appropriate way (new tab on web, system browser on desktop).
* Throws on error so the caller can show a friendly toast — notably 404
* {@code team_not_subscribed}.
*/
openPortal: () => Promise<void>;
}
// ─── Implementation ─────────────────────────────────────────────────────
const STORAGE_KEY = "stirling.payg.devSubscription";
/**
* Stable reference reuse — if the new payload deep-equals the previous one,
* return the previous object so React's reference check short-circuits child
@@ -216,6 +216,7 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
if (!prev) return next;
if (
prev.status !== next.status ||
prev.teamId !== next.teamId ||
prev.role !== next.role ||
prev.billingPeriodStart !== next.billingPeriodStart ||
prev.billingPeriodEnd !== next.billingPeriodEnd ||
@@ -263,87 +264,13 @@ function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
return prev;
}
/**
* Synthesise a wallet snapshot for the dev preview route. Mirrors the same
* shape the backend returns. Subscription state comes from localStorage so
* the modal's "mark subscribed" action survives a reload.
*/
function buildDevPreviewWallet(role: WalletRole): Wallet {
const subscribed =
typeof window !== "undefined" &&
(() => {
try {
return window.localStorage.getItem(STORAGE_KEY) === "subscribed";
} catch {
return false;
}
})();
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const isoDay = (d: Date) => d.toISOString().slice(0, 10);
return {
teamId: null,
status: subscribed ? "subscribed" : "free",
role,
billingPeriodStart: isoDay(periodStart),
billingPeriodEnd: isoDay(periodEnd),
billableUsed: 62,
billableLimit: subscribed ? 1250 : 500,
freeAllowance: 500,
// One-time grant: a free team has used 62 of 500 (438 left); the dev
// subscribed team is shown with its grant fully spent (kept across the
// subscribe — it just no longer gates them).
freeRemaining: subscribed ? 0 : 438,
// Free teams also carry a rate now — the backend resolves it from the
// default policy's USD Price so the upgrade-flow cap estimate ("≈ N paid
// PDFs/month") can render before subscribing. Mirror that here.
pricePerDocMinor: 2,
currency: "usd",
estimatedBillMinor: subscribed ? 0 : null,
capUsd: subscribed ? 25 : null,
noCap: false,
stripeSubscriptionId: subscribed ? "sub_devpreview" : null,
spendUnitsThisPeriod: 62,
// Wave 1 backend (PR #6574) returns a per-category breakdown so the
// hero panel can split AI / automation / API. Use realistic but
// tier-distinguishable mock values so the dev preview shows a
// different visual when the localStorage flip toggles subscribed.
categoryBreakdown: subscribed
? { api: 12, ai: 35, automation: 15 }
: { api: 5, ai: 40, automation: 17 },
// Members are populated in the leader view by the real backend
// (joining team_memberships); the dev preview returns an empty
// array — Plan.tsx + PaygLeader still resolve role via wallet.role,
// so empty members just hides the sub-caps card.
members: [],
// Activity feed is V1 = [], the backend ships this in Wave 2 once
// payg_meter_event_log is read-accessible from the wallet endpoint.
recent: [],
};
}
/** True when we're rendered outside the real saas app (e.g. dev preview route). */
function isDevPreviewContext(): boolean {
// Both checks required: production builds drop the path check, so a real
// tenant whose URL begins with /dev/ can't accidentally hit the synthesised
// fallback.
if (!import.meta.env.DEV) return false;
if (typeof window === "undefined") return false;
return window.location.pathname.startsWith("/dev/");
}
/** Best-effort role read for dev preview — flips per query string ?role=member. */
function devPreviewRole(): WalletRole {
if (typeof window === "undefined") return "leader";
const url = new URL(window.location.href);
return url.searchParams.get("role") === "member" ? "member" : "leader";
}
export function useWallet(): UseWalletResult {
const devPreview = useRef<boolean>(isDevPreviewContext()).current;
// Resolved once: the dev-preview side-channel when rendered outside the real
// app (saas /dev/payg-preview route), else null (every real build + desktop).
// The detection + synthesis live behind the @app/hooks/walletDevPreview seam
// because they read import.meta.env / window.location / localStorage, which
// cloud/ may not touch directly.
const devPreview = useRef(getWalletDevPreview()).current;
const [wallet, setWallet] = useState<Wallet | null>(null);
const [loading, setLoading] = useState<boolean>(true);
@@ -369,7 +296,7 @@ export function useWallet(): UseWalletResult {
setError(null);
if (devPreview) {
const synth = buildDevPreviewWallet(devPreviewRole());
const synth = devPreview.buildWallet(devPreview.role());
if (cancelled || reqId !== latestReqId.current) return;
setWallet((prev) => reuseIfEqual(prev, synth));
setLoading(false);
@@ -417,11 +344,7 @@ export function useWallet(): UseWalletResult {
const markSubscribed = useCallback(
async (capUsd: number | null) => {
if (devPreview) {
try {
window.localStorage.setItem(STORAGE_KEY, "subscribed");
} catch {
/* storage unavailable */
}
devPreview.markSubscribed();
await refetch();
return;
}
@@ -479,39 +402,22 @@ export function useWallet(): UseWalletResult {
const openPortal = useCallback(async () => {
if (devPreview) {
// No real Stripe in dev preview — navigate to a placeholder so the
// click still feels alive. Same-tab to match the real-flow behaviour.
window.location.assign("https://billing.stripe.com/p/login/mock");
// No real Stripe in dev preview — open a placeholder so the click still
// feels alive. Routed through the openExternal seam to stay portable.
await openExternal("https://billing.stripe.com/p/login/mock");
return;
}
// Direct edge-fn invocation with the user's JWT — same pattern as
// create-checkout-session. The payg_get_checkout_context RPC inside the
// fn enforces team membership, so no backend proxy is needed; team_id
// must be a NUMBER (the fn type-checks and rejects strings).
// Mint the portal session through the billing seam, passing teamId: the
// PAYG portal edge function needs it to resolve the caller's team outside
// Spring Security (its RPC enforces team membership). Then hand the URL to
// the openExternal seam so each platform routes it appropriately. The seam
// throws on error (e.g. 404 team_not_subscribed) so callers can toast.
const teamId = wallet?.teamId;
if (teamId == null) {
throw new Error("No team resolved yet");
}
const { data, error: invokeError } = await supabase.functions.invoke<{
url?: string;
error?: string;
}>("create-customer-portal-session", {
body: { team_id: teamId, return_url: window.location.href },
});
if (invokeError) {
// FunctionsHttpError etc. — the StripePortalLink caller catches and
// shows a friendly toast (404 team_not_subscribed, 403, outage).
throw invokeError;
}
if (!data?.url) {
throw new Error(data?.error ?? "Portal session response missing url");
}
// Same-tab navigation rather than window.open(...,"_blank"): Stripe's
// customer portal is a full-page experience and brings the user back
// via return_url, so a popup buys us nothing — and window.open after
// an awaited promise is treated as non-user-gesture and silently
// popup-blocked by most browsers.
window.location.assign(data.url);
const { url } = await createPortalSession({ teamId });
await openExternal(url);
}, [devPreview, wallet?.teamId]);
return {
@@ -0,0 +1,46 @@
/**
* Wallet dev-preview seam (@app/hooks/walletDevPreview).
*
* The cloud/ layer is the SHARED hosted experience consumed by BOTH the saas
* (web) and desktop (Tauri) leaves, so it must stay platform-portable: it can't
* read {@code import.meta.env}, {@code window.location} or web storage directly
* (the cloud ESLint guardrail enforces this). The PAYG dev-preview route
* ({@code /dev/payg-preview}) is a saas-only local-design affordance that
* synthesises a wallet from {@code localStorage} when the real backend isn't
* mounted — all three of those banned reads. {@link useWallet} reaches that
* affordance through this seam instead.
*
* This module is the DEFAULT + the shared TypeScript contract: it reports
* "not a dev preview" so the real backend fetch always runs. The saas leaf
* shadows it with saas/hooks/walletDevPreview.ts, which supplies the synthesis;
* desktop has no dev-preview route, so the cascade falls through to this
* default. Returning {@code null} from {@link getWalletDevPreview} is the
* canonical "no dev preview active" signal.
*/
import type { Wallet, WalletRole } from "@app/hooks/useWallet";
/**
* The dev-preview side-channel {@link useWallet} consumes when rendered outside
* the real app (the saas {@code /dev/payg-preview} route). When active it stands
* in for the backend: {@link buildWallet} synthesises the snapshot and
* {@link markSubscribed} flips the simulated subscription state. {@code null}
* (the cloud default + every desktop build) means "no dev preview — fetch the
* real wallet".
*/
export interface WalletDevPreview {
/** Synthesise the dev-preview wallet snapshot (subscription state from storage). */
buildWallet: (role: WalletRole) => Wallet;
/** Best-effort role read for the preview — flips per {@code ?role=member}. */
role: () => WalletRole;
/** Flip the simulated subscription state to subscribed (persisted across reload). */
markSubscribed: () => void;
}
/**
* Resolve the active dev-preview side-channel, or {@code null} when we're in a
* real build / on a real route. The cloud default + desktop always return
* {@code null}; the saas leaf returns a live channel only on the dev route.
*/
export function getWalletDevPreview(): WalletDevPreview | null {
return null;
}
@@ -0,0 +1,28 @@
/**
* Open-external-URL seam (@app/platform/openExternal).
*
* The cloud/ layer is the SHARED hosted experience consumed by BOTH the saas
* (web) and desktop (Tauri) leaves. Opening a URL in the user's real browser
* differs per platform — saas hands it to the browser via window.open /
* location.assign, desktop hands it to the OS via the Tauri shell plugin so it
* escapes the embedded webview. Cloud code must not reach either of those
* directly, so it opens external URLs through this seam.
*
* This module is the DEFAULT + the shared TypeScript contract. Real builds
* shadow it with saas/platform/openExternal.ts and desktop/platform/
* openExternal.ts; this default body is only reached by the cloud-standalone
* typecheck, so it throws to make an accidental real-build resolution loud.
*/
/** Opens an external URL in the user's system browser. */
export type OpenExternal = (url: string) => Promise<void>;
/**
* Opens an external URL in the user's system browser. Each platform supplies
* its own implementation; this default is never reached in a real build.
*/
export const openExternal: OpenExternal = async (
_url: string,
): Promise<void> => {
throw new Error("openExternal: platform impl required");
};
@@ -0,0 +1,80 @@
/**
* Billing data seam (@app/services/billing).
*
* Creating Stripe Checkout / Customer Portal sessions touches platform-specific
* transport (saas: supabase-js web client; desktop: Tauri native HTTP with an
* explicit bearer + deep-link return URL). Cloud code can't reach those
* directly, so it mints sessions through this seam. This module is the DEFAULT +
* shared contract; saas/services/billing.ts and desktop/services/billing.ts
* shadow it. The default bodies throw so an accidental real-build resolution is
* loud (only the cloud-standalone typecheck reaches them).
*/
/**
* Parameters for {@link createCheckoutSession}, which drives the PAYG
* {@code create-checkout-session} edge function (see StripeCheckoutPanel). The
* function runs outside Spring Security, so it needs the caller's {@link teamId}
* (it can't resolve the team from the JWT alone). The platform impl supplies the
* return URL itself (browser origin on web, deep-link scheme on desktop), so it
* is intentionally NOT part of this shape.
*/
export interface CheckoutParams {
/** The caller's team id. Required — scopes the PAYG subscription. */
teamId: number;
/** Lower-case 3-letter ISO currency (e.g. {@code "gbp"}). Selects the Stripe Price. */
currency?: string;
/** Billing email for the Checkout Session; maps to Stripe {@code customer_email} when the team has no customer yet. */
billingOwnerEmail?: string | null;
}
/**
* Result of {@link createCheckoutSession}. Embedded checkout yields a
* {@code clientSecret}; hosted checkout yields a {@code url}. Exactly one is set.
*/
export interface CheckoutSession {
/** Stripe Checkout Session client secret for embedded mode. */
clientSecret?: string;
/** Hosted Stripe Checkout URL for redirect mode. */
url?: string;
/** Non-prod sentinel: a stubbed secret (prefixed {@code cs_mock_}) renders a placeholder instead of a real iframe. */
mock?: boolean;
}
/** Result of {@link createPortalSession}. */
export interface PortalSession {
/** Stripe Customer Portal URL to send the user to. */
url: string;
}
/**
* Parameters for {@link createPortalSession}. The PAYG portal edge function
* needs the caller's {@code teamId} (runs outside Spring Security).
*/
export interface PortalParams {
/** The caller's team id; required by the PAYG portal edge function. */
teamId: number;
}
/** Create a Stripe Checkout Session via the SaaS billing backend (platform impl required). */
export async function createCheckoutSession(
_params: CheckoutParams,
): Promise<CheckoutSession> {
throw new Error("billing: platform impl required");
}
/** Mint a Stripe Customer Portal session via the SaaS billing backend (platform impl required). */
export async function createPortalSession(
_params: PortalParams,
): Promise<PortalSession> {
throw new Error("billing: platform impl required");
}
/**
* The Stripe publishable key used to initialise {@code loadStripe()} for
* embedded checkout. Sourced through the seam because cloud code may not read
* {@code import.meta.env} directly. The cloud default returns "" so the checkout
* component falls back to its mock placeholder rather than throwing.
*/
export function getStripePublishableKey(): string {
return "";
}
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "../../",
"paths": {
"@app/*": ["src/cloud/*", "src/proprietary/*", "src/core/*"],
"@cloud/*": ["src/cloud/*"],
"@proprietary/*": ["src/proprietary/*"],
"@core/*": ["src/core/*"],
"@shared/*": ["../shared/*"]
}
},
"include": [
"../global.d.ts",
"../*.js",
"../*.ts",
"../*.tsx",
"../core/setupTests.ts",
"."
]
}
@@ -10,7 +10,7 @@ import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useIsMobile } from "@app/hooks/useIsMobile";
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
import { GoogleDriveIcon } from "@shared/components/CloudStorageIcons";
interface FileSourceButtonsProps {
horizontal?: boolean;
@@ -26,7 +26,7 @@ import {
useIndexedDBRevision,
} from "@app/contexts/IndexedDBContext";
import { accountService } from "@app/services/accountService";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
import { GoogleDriveIcon } from "@shared/components/CloudStorageIcons";
import { Wordmark } from "@app/components/shared/Wordmark";
import type { StirlingFileStub } from "@app/types/fileContext";
import MenuIcon from "@mui/icons-material/Menu";
@@ -6,7 +6,7 @@ import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import type { FileId } from "@app/types/file";
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
import { FileDocIcon } from "@shared/components/FileDocIcon";
import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon";
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
import { IMAGE_EXTENSIONS } from "@app/utils/fileUtils";
@@ -1,9 +0,0 @@
/**
* Core stub for SaaS Teams Section
* Desktop layer provides the real implementation
* This component only appears in SaaS mode
*/
export function SaaSTeamsSection() {
// Core stub - return null (no team management in web builds)
return null;
}
@@ -1,8 +0,0 @@
/**
* Core stub for SaasPlanSection
* This component returns null in non-desktop builds
* The desktop layer shadows this with the real implementation
*/
export function SaasPlanSection() {
return null;
}
@@ -1,6 +1,6 @@
import React from "react";
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
import type { FileDocVariant } from "@app/components/shared/FileDocIcon";
import { FileDocIcon } from "@shared/components/FileDocIcon";
import type { FileDocVariant } from "@shared/components/FileDocIcon";
import type { StirlingFileStub } from "@app/types/fileContext";
import { detectFileExtension } from "@app/utils/fileUtils";
@@ -1,13 +0,0 @@
/**
* Core stub for Credit Exhausted Modal
* Desktop build overrides this with actual modal implementation
*/
interface CreditExhaustedModalProps {
opened: boolean;
onClose: () => void;
}
export function CreditExhaustedModal(_props: CreditExhaustedModalProps) {
return null;
}
@@ -1,17 +0,0 @@
/**
* Core stub for Insufficient Credits Modal
* Desktop build overrides this with actual modal implementation
*/
interface InsufficientCreditsModalProps {
opened: boolean;
onClose: () => void;
toolId?: string;
requiredCredits?: number;
}
export function InsufficientCreditsModal(
_props: InsufficientCreditsModalProps,
) {
return null;
}
@@ -66,18 +66,7 @@ export default function ToolPanel({
</div>
) : (
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea
h="100%"
styles={{
// Let the tool fill the panel height so the slide wizard can
// center its content and pin its CTAs to the bottom.
content: {
minHeight: "100%",
display: "flex",
flexDirection: "column",
},
}}
>
<ScrollArea h="100%">
{selectedToolKey ? (
<ToolRenderer
selectedToolKey={selectedToolKey}
@@ -1,8 +1,6 @@
import { useTranslation } from "react-i18next";
import { useEffect, useRef, useCallback } from "react";
import { Button, Stack, Text, Divider, ColorInput, Group } from "@mantine/core";
import HighlightAltIcon from "@mui/icons-material/HighlightAlt";
import CropFreeIcon from "@mui/icons-material/CropFree";
import { Button, Stack, Text, Divider, ColorInput } from "@mantine/core";
import { useRedaction, useRedactionMode } from "@app/contexts/RedactionContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { useSignature } from "@app/contexts/SignatureContext";
@@ -23,8 +21,7 @@ export default function ManualRedactionControls({
// Use our RedactionContext which bridges to EmbedPDF
const {
activateTextSelection,
activateMarquee,
activateManualRedact,
redactionsApplied,
setActiveType,
setManualRedactColor,
@@ -53,9 +50,9 @@ export default function ManualRedactionControls({
// Guard: pause auto-reactivation during save/export to avoid interfering with EmbedPDF
const isSavingRef = useRef(false);
// Keep a redaction tool active while this component is mounted. If anything
// deactivates it (annotation tools, file switch, etc.) this re-enables it —
// defaulting to text selection so "Mark Text" is the initial active mode.
// Keep redaction tool active at all times while this component is mounted.
// If anything deactivates it (annotation tools, text selection, file switch, etc.)
// this re-enables it automatically — no manual "Activate" button needed.
useEffect(() => {
if (
disabled ||
@@ -80,7 +77,7 @@ export default function ManualRedactionControls({
// Small delay to avoid racing with EmbedPDF's own state updates
const timer = setTimeout(() => {
if (!isSavingRef.current) {
activateTextSelection();
activateManualRedact();
}
}, 50);
return () => clearTimeout(timer);
@@ -93,7 +90,7 @@ export default function ManualRedactionControls({
showNavigationWarning,
setAnnotationMode,
signatureApiRef,
activateTextSelection,
activateManualRedact,
]);
// Reset redaction tool when switching between files
@@ -126,28 +123,6 @@ export default function ManualRedactionControls({
const isApiReady = isBridgeReady;
// Which manual-redaction sub-mode is active (drives the tool button highlight).
const isSelectionActive = activeType === "redactSelection";
const isMarqueeActive = activeType === "marqueeRedact";
// Switch to a redaction sub-mode, first stepping out of annotation mode/tools.
const switchMode = useCallback(
(activate: () => void) => {
if (isAnnotationMode) {
setAnnotationMode(false);
if (signatureApiRef?.current) {
try {
signatureApiRef.current.deactivateTools();
} catch (error) {
console.log("Unable to deactivate annotation tools:", error);
}
}
}
activate();
},
[isAnnotationMode, setAnnotationMode, signatureApiRef],
);
return (
<>
<Divider my="sm" />
@@ -163,54 +138,6 @@ export default function ManualRedactionControls({
)}
</Text>
<Group gap="sm" grow wrap="nowrap">
<Button
variant={
isSelectionActive && !isAnnotationMode ? "filled" : "outline"
}
color={isSelectionActive && !isAnnotationMode ? "blue" : "gray"}
leftSection={
<HighlightAltIcon style={{ fontSize: 18, flexShrink: 0 }} />
}
onClick={() => switchMode(activateTextSelection)}
disabled={disabled || !isApiReady}
size="sm"
styles={{
root: { minWidth: 0 },
label: {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
}}
>
{t("redact.manual.markText", "Mark Text")}
</Button>
<Button
variant={
isMarqueeActive && !isAnnotationMode ? "filled" : "outline"
}
color={isMarqueeActive && !isAnnotationMode ? "blue" : "gray"}
leftSection={
<CropFreeIcon style={{ fontSize: 18, flexShrink: 0 }} />
}
onClick={() => switchMode(activateMarquee)}
disabled={disabled || !isApiReady}
size="sm"
styles={{
root: { minWidth: 0 },
label: {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
}}
>
{t("redact.manual.markArea", "Mark Area")}
</Button>
</Group>
<ColorInput
label={t("redact.manual.colorLabel", "Redaction Colour")}
value={manualRedactColor}
@@ -24,7 +24,7 @@ export interface ReviewToolStepProps<TParams = unknown> {
onCollapsedClick?: () => void;
}
export function ReviewStepContent<TParams = unknown>({
function ReviewStepContent<TParams = unknown>({
operation,
onFileClick,
onUndo,
@@ -1,126 +0,0 @@
/* Slide-based tool wizard: a calm segmented progress bar, one step per slide
with centered content, and big Back / Continue CTAs pinned to the bottom. */
.tool-step-wizard {
/* Fill the panel height (the parent ScrollArea content is a flex column with
min-height:100%). flex-basis:0 makes this resolve to exactly the viewport
height so the body can scroll internally and the CTAs stay pinned. */
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
width: 100%;
}
.tool-step-wizard__title {
flex-shrink: 0;
padding: 0.5rem 0.75rem 0;
}
/* ---- Progress bar ---- */
.tool-step-wizard__progress {
display: flex;
gap: 6px;
align-items: center;
padding: 0.85rem 1rem 0.45rem;
flex-shrink: 0;
}
.tool-step-wizard__segment {
all: unset;
flex: 1 1 0;
height: 4px;
border-radius: 9999px;
background: var(--onboarding-step-inactive);
position: relative;
overflow: hidden;
cursor: default;
box-sizing: border-box;
}
.tool-step-wizard__segment[data-jumpable] {
cursor: pointer;
}
.tool-step-wizard__segment-fill {
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--mantine-color-blue-filled);
transform: scaleX(0);
transform-origin: left center;
transition: transform 0.3s ease;
}
.tool-step-wizard__segment[data-filled] .tool-step-wizard__segment-fill {
transform: scaleX(1);
}
.tool-step-wizard__caption {
flex-shrink: 0;
padding: 0 1rem 0.25rem;
}
/* ---- Slide body: top-aligned content that scrolls when tall ---- */
.tool-step-wizard__body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
/* Horizontal padding lives on the slide + CTA buttons so the CTA's top
border can span edge-to-edge. */
padding-top: 0.75rem;
}
.tool-step-wizard__slide {
width: 100%;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 0 1rem;
animation: wizard-slide-in 0.2s ease;
}
@keyframes wizard-slide-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.tool-step-wizard__slide-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* ---- CTAs: sit right after the content, stick to the bottom on overflow ---- */
.tool-step-wizard__cta {
position: sticky;
bottom: 0;
/* Flex column so each button stretches to the container width minus its own
horizontal margin (mx="md") — never width:100% + margin, which overflows. */
display: flex;
flex-direction: column;
margin-top: 0.75rem;
padding-bottom: 0.85rem;
border-top: 1px solid var(--border-subtle);
background: var(--bg-toolbar);
z-index: 1;
}
.tool-step-wizard__cta-btn {
min-height: 2.85rem !important;
}
@media (prefers-reduced-motion: reduce) {
.tool-step-wizard__segment-fill,
.tool-step-wizard__slide {
transition: none !important;
animation: none !important;
}
}
@@ -1,281 +0,0 @@
import React, { useEffect, useState } from "react";
import { Box, Button, Flex, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Tooltip } from "@app/components/shared/Tooltip";
import {
ToolWorkflowTitle,
ToolWorkflowTitleProps,
} from "@app/components/tools/shared/ToolWorkflowTitle";
import type { TooltipTip } from "@app/types/tips";
import "@app/components/tools/shared/ToolStepWizard.css";
export interface WizardSlide {
key: string;
title: string;
content: React.ReactNode;
/** Whether the user may advance past this slide (gates Continue + forward jumps). */
isValid: boolean;
/** Tooltip shown on the Continue button when this slide blocks advancing. */
blockedHint?: string;
/**
* Hide the bottom Continue button for this slide — used by the Files slide,
* whose own upload CTA advances the wizard once enough files are added.
*/
hideContinue?: boolean;
/**
* Invoked when the user navigates to this slide by clicking its progress-bar
* segment in terminal (results) mode — used to reset the results so the tool
* returns to editing this step.
*/
onActivate?: () => void;
tooltip?: {
content?: React.ReactNode;
tips?: TooltipTip[];
header?: {
title: string;
logo?: React.ReactNode;
};
};
}
export interface ToolStepWizardProps {
slides: WizardSlide[];
title?: ToolWorkflowTitleProps;
/** Primary CTA on the final slide (the execute button). */
executeSlot?: React.ReactNode;
/** Rendered just beneath the execute button on the final slide. */
belowExecuteButton?: React.ReactNode;
/** Optional preview content shown on the final slide above the CTA. */
preview?: React.ReactNode;
/**
* Terminal (results) mode: the flow is complete, so the wizard sits on the
* last slide with the whole progress bar filled and no Continue/Back CTAs (the
* review content carries its own undo/download). Clicking an earlier bar
* segment fires that slide's onActivate (reset) to return to editing it.
*/
terminal?: boolean;
}
/**
* Renders a tool's steps as a slide wizard: a segmented progress bar at the top,
* one step per slide with centered content, and large Back / Continue CTAs at
* the bottom. The final slide swaps Continue for the tool's execute button. A
* single-step flow drops the progress bar / step counter / title and just shows
* the centered content and its primary action.
*
* Navigation is gated: Continue and forward jumps are blocked until the current
* slide is valid; Back (and clicking an earlier segment) is always allowed.
*/
export function ToolStepWizard({
slides,
title,
executeSlot,
belowExecuteButton,
preview,
terminal = false,
}: ToolStepWizardProps) {
const { t } = useTranslation();
const [activeKey, setActiveKey] = useState<string>(() => slides[0]?.key);
// Keep activeKey valid as the slide set changes (e.g. the Files slide drops
// out once a file is selected). Falls back to the first slide.
useEffect(() => {
if (slides.length > 0 && !slides.some((s) => s.key === activeKey)) {
setActiveKey(slides[0].key);
}
}, [slides, activeKey]);
if (slides.length === 0) return null;
// In terminal (results) mode the flow is complete: lock onto the last slide.
const rawIndex = slides.findIndex((s) => s.key === activeKey);
const currentIndex = terminal
? slides.length - 1
: rawIndex < 0
? 0
: rawIndex;
const current = slides[currentIndex];
const isLast = currentIndex === slides.length - 1;
const canAdvance = current.isValid;
const showBack = !terminal && currentIndex > 0;
// A single-step flow is just centered content + its action — no progress bar,
// step counter, or title (a lone full-width segment reads as an odd solid
// line). The chrome appears only with 2+ steps.
const showChrome = slides.length > 1;
const showContinue = !isLast && !current.hideContinue;
const hasCta = isLast
? Boolean(executeSlot) || Boolean(belowExecuteButton)
: showContinue || showBack;
// Forward jumps require every intervening slide to be valid; back is free.
// In terminal (results) mode every earlier step is reachable to go back and edit.
const canJumpTo = (target: number) => {
if (terminal) return target < currentIndex;
if (target === currentIndex) return false;
if (target < currentIndex) return true;
for (let i = currentIndex; i < target; i++) {
if (!slides[i].isValid) return false;
}
return true;
};
const goTo = (target: number) => {
if (target < 0 || target >= slides.length) return;
// In terminal mode, navigating back resets the results (returns to editing).
if (terminal) slides[target].onActivate?.();
setActiveKey(slides[target].key);
};
return (
<div className="tool-step-wizard">
{title && (
<div className="tool-step-wizard__title">
<ToolWorkflowTitle {...title} />
</div>
)}
{showChrome && (
<div
className="tool-step-wizard__progress"
role="tablist"
aria-label={t("wizardProgress", "Step progress")}
>
{slides.map((slide, index) => {
const filled = index <= currentIndex;
const isCurrent = index === currentIndex;
const jumpable = canJumpTo(index);
return (
<button
key={slide.key}
type="button"
className="tool-step-wizard__segment"
data-filled={filled || undefined}
data-current={isCurrent || undefined}
data-jumpable={jumpable || undefined}
aria-current={isCurrent ? "step" : undefined}
aria-label={`${t(
"wizardStepProgress",
"Step {{current}} of {{total}}",
{
current: index + 1,
total: slides.length,
},
)}: ${slide.title}`}
disabled={!jumpable && !isCurrent}
onClick={() => jumpable && goTo(index)}
>
<span className="tool-step-wizard__segment-fill" />
</button>
);
})}
</div>
)}
{showChrome && (
<Text
className="tool-step-wizard__caption"
size="xs"
c="dimmed"
ta="center"
>
{t("wizardStepProgress", "Step {{current}} of {{total}}", {
current: currentIndex + 1,
total: slides.length,
})}
</Text>
)}
<div className="tool-step-wizard__body">
<div className="tool-step-wizard__slide" key={current.key}>
{showChrome &&
(current.tooltip ? (
<Tooltip
content={current.tooltip.content}
tips={current.tooltip.tips}
header={current.tooltip.header}
sidebarTooltip={true}
pinOnClick={true}
>
<Flex align="center" justify="center" gap="xs">
<Text fw={600} size="md">
{current.title}
</Text>
<LocalIcon
icon="info-outline-rounded"
width="1.25rem"
height="1.25rem"
style={{ color: "var(--icon-files-color)" }}
/>
</Flex>
</Tooltip>
) : (
<Text fw={600} size="md" ta="center">
{current.title}
</Text>
))}
<div className="tool-step-wizard__slide-content">
{current.content}
</div>
{isLast && preview && <div>{preview}</div>}
</div>
{hasCta && (
<div className="tool-step-wizard__cta">
{isLast ? (
(executeSlot ?? null)
) : showContinue ? (
!canAdvance && current.blockedHint ? (
<Tooltip content={current.blockedHint} position="top" arrow>
<Box mx="md" mt="md">
<Button
fullWidth
disabled
size="md"
color="blue"
className="tool-step-wizard__cta-btn"
data-testid="wizard-continue"
>
{t("wizardContinue", "Continue")}
</Button>
</Box>
</Tooltip>
) : (
<Button
mx="md"
mt="md"
size="md"
color="blue"
disabled={!canAdvance}
onClick={() => canAdvance && goTo(currentIndex + 1)}
className="tool-step-wizard__cta-btn"
data-testid="wizard-continue"
>
{t("wizardContinue", "Continue")}
</Button>
)
) : null}
{isLast && belowExecuteButton}
{showBack && (
<Button
mx="md"
mt="sm"
size="md"
variant="default"
onClick={() => goTo(currentIndex - 1)}
className="tool-step-wizard__cta-btn"
data-testid="wizard-back"
>
{t("back", "Back")}
</Button>
)}
</div>
)}
</div>
</div>
);
}
export default ToolStepWizard;
@@ -1,42 +0,0 @@
/* Large, clean upload CTA used as the Files slide in the tool step wizard. */
.wizard-files-dropzone {
width: 100%;
border: 1.5px dashed var(--border-subtle, var(--mantine-color-default-border));
border-radius: var(--mantine-radius-lg, 1rem);
background: transparent;
padding: 2.25rem 1.25rem;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
}
.wizard-files-dropzone:hover {
border-color: var(--mantine-color-blue-filled);
background: color-mix(
in srgb,
var(--mantine-color-blue-filled) 5%,
transparent
);
}
.wizard-files-dropzone[data-accept] {
border-color: var(--mantine-color-blue-filled);
background: var(--mantine-color-blue-light);
}
.wizard-files-dropzone[data-reject] {
border-color: var(--mantine-color-red-filled);
}
.wizard-files-dropzone__icon {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
margin-bottom: 0.25rem;
}
@@ -1,104 +0,0 @@
import { Button, Group, Stack, Text } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { StirlingFile } from "@app/types/fileContext";
import "@app/components/tools/shared/WizardFilesStep.css";
export interface WizardFilesStepProps {
selectedFiles: StirlingFile[];
minFiles?: number;
}
/**
* The Files slide of the tool step wizard: a large drag-and-drop upload CTA.
* Adding enough files auto-advances the wizard (the slide drops out), so this
* slide owns the upload action and has no Continue button of its own.
*/
export function WizardFilesStep({
selectedFiles,
minFiles = 1,
}: WizardFilesStepProps) {
const { t } = useTranslation();
const { addFiles } = useFileHandler();
const { openFilesModal } = useFilesModalContext();
const terminology = useFileActionTerminology();
const handleDrop = (files: File[]) => {
if (files.length > 0) void addFiles(files);
};
const handleUpload = () => {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.accept = ".pdf,application/pdf";
input.onchange = (event) => {
const files = Array.from((event.target as HTMLInputElement).files || []);
if (files.length > 0) void addFiles(files);
};
input.click();
};
const needsMore = minFiles > 1;
const count = selectedFiles.length;
return (
<Dropzone
onDrop={handleDrop}
multiple
activateOnClick={false}
enablePointerEvents
className="wizard-files-dropzone"
aria-label={terminology.dropFilesHere}
>
<Stack align="center" gap="xs">
<div className="wizard-files-dropzone__icon">
<LocalIcon icon="upload" width="1.6rem" height="1.6rem" />
</div>
<Text fw={600} size="lg" ta="center">
{t("fileUpload.dropTitle", "Drop your file here")}
</Text>
<Text size="sm" c="dimmed" ta="center">
{needsMore
? t("fileUpload.addAtLeast", "Add at least {{count}} files", {
count: minFiles,
})
: t("fileUpload.dropSubtitle", "or pick one from your computer")}
</Text>
{count > 0 && (
<Text size="xs" c="dimmed" ta="center">
{t("filesSelected", "{{count}} files", { count })}
</Text>
)}
<Group gap="sm" justify="center" mt="sm">
<Button
onClick={handleUpload}
leftSection={
<LocalIcon
icon="upload"
width="1rem"
height="1rem"
style={{ color: "white" }}
/>
}
>
{terminology.uploadFromComputer}
</Button>
<Button
variant="default"
onClick={() => openFilesModal()}
leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />}
>
{terminology.addFiles}
</Button>
</Group>
</Stack>
</Dropzone>
);
}
export default WizardFilesStep;
@@ -5,12 +5,6 @@ import {
ToolStepProvider,
} from "@app/components/tools/shared/ToolStep";
import { ScopedOperationButton } from "@app/components/tools/shared/ScopedOperationButton";
import {
ToolStepWizard,
WizardSlide,
} from "@app/components/tools/shared/ToolStepWizard";
import { WizardFilesStep } from "@app/components/tools/shared/WizardFilesStep";
import { ReviewStepContent } from "@app/components/tools/shared/ReviewToolStep";
import { ToolOperationHook } from "@app/hooks/tools/shared/useToolOperation";
import {
ToolWorkflowTitle,
@@ -19,7 +13,6 @@ import {
import { StirlingFile } from "@app/types/fileContext";
import type { TooltipTip } from "@app/types/tips";
import type { ExecuteDisabledReason } from "@app/hooks/tools/shared/toolOperationTypes";
import i18n from "@app/i18n";
export interface FilesStepConfig {
selectedFiles: StirlingFile[];
@@ -34,12 +27,6 @@ export interface MiddleStepConfig {
isVisible?: boolean;
isCollapsed?: boolean;
onCollapsedClick?: () => void;
/**
* In the slide wizard, whether the user may advance past this step. Defaults
* to true (the step is always passable). Set false to block Continue and
* forward jumps until the step's inputs are valid.
*/
canContinue?: boolean;
content: React.ReactNode;
tooltip?: {
content?: React.ReactNode;
@@ -111,141 +98,11 @@ export interface ToolFlowConfig<TParams = unknown> {
export function createToolFlow<TParams = unknown>(
config: ToolFlowConfig<TParams>,
) {
// Execute button, shared by the wizard (final-slide CTA) and the accordion.
const renderExecuteButton = (): React.ReactNode => {
const eb = config.executeButton;
if (!eb || eb.isVisible === false) return null;
const hasFiles = (config.files.selectedFiles?.length ?? 0) > 0;
// Explicit disabledReason wins; otherwise derive from structured fields.
const effectiveDisabledReason: ExecuteDisabledReason =
eb.disabledReason !== undefined
? eb.disabledReason
: eb.endpointEnabled === false
? "endpointUnavailable"
: !hasFiles
? "noFiles"
: eb.paramsValid === false
? "invalidParams"
: null;
return (
<ScopedOperationButton
selectedFiles={config.files.selectedFiles ?? []}
disableScopeHints={eb.disableScopeHints}
onClick={eb.onClick}
isLoading={config.review.operation.isLoading}
disabled={eb.disabled}
disabledReason={effectiveDisabledReason}
loadingText={eb.loadingText}
submitText={eb.text}
showCloudBadge={
eb.showCloudBadge ?? config.review.operation.willUseCloud ?? false
}
data-testid={eb.testId}
data-tour="run-button"
/>
);
};
// ---- Build the pre-execution slide list for the wizard ----
const minFiles = config.files.minFiles ?? 1;
const filesCount = config.files.selectedFiles?.length ?? 0;
const filesSatisfied = filesCount >= minFiles;
const filesVisible = config.files.isVisible !== false;
// Mirror the accordion's "collapse the Files step once selected": the Files
// slide only appears while files are still needed.
const includeFilesSlide = filesVisible && !filesSatisfied;
const middleSteps = config.steps.filter((s) => s.isVisible !== false);
const middleSlides: WizardSlide[] = middleSteps.map((step, index) => ({
key: `step-${index}-${step.title}`,
title: step.title,
content: step.content,
tooltip: step.tooltip,
isValid: step.canContinue ?? true,
blockedHint: i18n.t(
"wizardCompleteStep",
"Complete this step to continue.",
),
}));
// Key by tool identity so switching tools resets the wizard, while
// adding/removing slides within a tool is handled by the wizard itself.
const wizardKey = `${config.executeButton?.text ?? ""}|${middleSteps
.map((s) => s.title)
.join(">")}`;
// ---- Results view: a completed progress bar + the review content (no
// collapsed step sections). The review is the final, terminal slide. ----
if (config.review.isVisible) {
// Clicking an earlier segment returns to editing that step: run the step's
// own reset (clears preview etc.) and clear results so we leave results mode.
const resultSlides: WizardSlide[] = [
...middleSlides.map((slide, index) => ({
...slide,
onActivate: () => {
middleSteps[index].onCollapsedClick?.();
config.review.operation.resetResults();
},
})),
{
key: "__review__",
title: config.review.title,
content: (
<ReviewStepContent
operation={config.review.operation}
onFileClick={config.review.onFileClick}
onUndo={config.review.onUndo}
/>
),
isValid: true,
},
];
return (
<ToolStepWizard
key={wizardKey}
slides={resultSlides}
title={config.title}
terminal
/>
);
}
// ---- Pre-execution flow ----
const wizardSlides: WizardSlide[] = [];
if (includeFilesSlide) {
wizardSlides.push({
key: "__files__",
title: i18n.t("files.title", "Files"),
content: (
<WizardFilesStep
selectedFiles={config.files.selectedFiles}
minFiles={config.files.minFiles}
/>
),
isValid: filesSatisfied,
// The upload CTA advances the wizard once enough files are added.
hideContinue: true,
});
}
wizardSlides.push(...middleSlides);
if (wizardSlides.length >= 1) {
return (
<ToolStepWizard
key={wizardKey}
slides={wizardSlides}
title={config.title}
executeSlot={renderExecuteButton()}
belowExecuteButton={config.belowExecuteButton}
preview={filesCount > 0 ? config.preview : undefined}
/>
);
}
// ---- Accordion fallback (rare zero-slide tool: file selected, no steps) ----
const steps = createToolSteps();
return (
<Stack gap="sm" p="sm">
{/* <Stack gap="sm" p="sm" h="100%" w="100%" style={{ overflow: 'auto' }}> */}
<ToolStepProvider forceStepNumbers={config.forceStepNumbers}>
{config.title && <ToolWorkflowTitle {...config.title} />}
@@ -274,19 +131,50 @@ export function createToolFlow<TParams = unknown>(
{/* Preview (outside steps, above execute button).
Hide when review is visible or when no files are selected. */}
{!config.review.isVisible && filesCount > 0 && config.preview}
{!config.review.isVisible &&
(config.files.selectedFiles?.length ?? 0) > 0 &&
config.preview}
{/* Execute Button */}
{(() => {
const button = renderExecuteButton();
if (!button) return null;
return (
<>
{button}
{config.belowExecuteButton}
</>
);
})()}
{config.executeButton &&
config.executeButton.isVisible !== false &&
(() => {
const eb = config.executeButton;
const hasFiles = (config.files.selectedFiles?.length ?? 0) > 0;
// Compute the disabled reason from structured fields; explicit disabledReason wins if set.
const effectiveDisabledReason: ExecuteDisabledReason =
eb.disabledReason !== undefined
? eb.disabledReason
: eb.endpointEnabled === false
? "endpointUnavailable"
: !hasFiles
? "noFiles"
: eb.paramsValid === false
? "invalidParams"
: null;
return (
<>
<ScopedOperationButton
selectedFiles={config.files.selectedFiles ?? []}
disableScopeHints={eb.disableScopeHints}
onClick={eb.onClick}
isLoading={config.review.operation.isLoading}
disabled={eb.disabled}
disabledReason={effectiveDisabledReason}
loadingText={eb.loadingText}
submitText={eb.text}
showCloudBadge={
eb.showCloudBadge ??
config.review.operation.willUseCloud ??
false
}
data-testid={eb.testId}
data-tour="run-button"
/>
{config.belowExecuteButton}
</>
);
})()}
{/* Review Step */}
{steps.createReviewStep({
@@ -1,4 +1,12 @@
import React, { useCallback, useState, useMemo, useRef } from "react";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useState,
useMemo,
useRef,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useDocumentState } from "@embedpdf/core/react";
import { useScroll } from "@embedpdf/plugin-scroll/react";
@@ -8,6 +16,7 @@ import {
PdfActionType,
type PdfLinkAnnoObject,
} from "@embedpdf/models";
import { Z_INDEX_VIEWER_FLOATING_MENU } from "@app/styles/zIndex";
// ---------------------------------------------------------------------------
// Inline SVG icons (thin-stroke, modern)
@@ -123,7 +132,9 @@ function isInternalLink(annotationLink: PdfLinkAnnoObject): boolean {
interface LinkToolbarProps {
annotationLink: PdfLinkAnnoObject;
scale: number;
toolbarRef: React.Ref<HTMLDivElement>;
left: number;
top: number;
flipped: boolean;
onNavigate: (annotationLink: PdfLinkAnnoObject) => void;
onDelete: (annotationLink: PdfLinkAnnoObject) => void;
@@ -133,11 +144,43 @@ interface LinkToolbarProps {
const TOOLBAR_HEIGHT = 32;
const TOOLBAR_GAP = 8;
const TOOLBAR_EDGE_MARGIN = 4;
const TOOLBAR_LEAVE_DELAY = 120;
interface ToolbarPlacement {
linkId: string;
left: number;
top: number;
flipped: boolean;
}
function clampToolbarCenter(centerX: number, toolbarWidth: number): number {
if (toolbarWidth <= 0 || typeof window === "undefined") return centerX;
const minCenter = TOOLBAR_EDGE_MARGIN + toolbarWidth / 2;
const maxCenter = window.innerWidth - TOOLBAR_EDGE_MARGIN - toolbarWidth / 2;
if (minCenter > maxCenter) return window.innerWidth / 2;
return Math.min(Math.max(centerX, minCenter), maxCenter);
}
function isRectOutsideViewport(rect: DOMRect): boolean {
if (typeof window === "undefined") return false;
return (
rect.bottom < 0 ||
rect.top > window.innerHeight ||
rect.right < 0 ||
rect.left > window.innerWidth
);
}
const LinkToolbar: React.FC<LinkToolbarProps> = React.memo(
({
annotationLink,
scale,
toolbarRef,
left,
top,
flipped,
onNavigate,
onDelete,
@@ -145,22 +188,20 @@ const LinkToolbar: React.FC<LinkToolbarProps> = React.memo(
onMouseLeave,
}) => {
const { t } = useTranslation();
const centerX =
(annotationLink.rect.origin.x + annotationLink.rect.size.width / 2) *
scale;
const topY = flipped
? (annotationLink.rect.origin.y + annotationLink.rect.size.height) *
scale +
TOOLBAR_GAP
: annotationLink.rect.origin.y * scale - TOOLBAR_HEIGHT - TOOLBAR_GAP;
const internal = isInternalLink(annotationLink);
const label = getLinkLabel(annotationLink);
return (
<div
ref={toolbarRef}
className={`pdf-link-toolbar${flipped ? " pdf-link-toolbar--below" : ""}`}
style={{ left: `${centerX}px`, top: `${topY}px` }}
style={{
position: "fixed",
left: `${left}px`,
top: `${top}px`,
maxWidth: `calc(100vw - ${TOOLBAR_EDGE_MARGIN * 2}px)`,
zIndex: Z_INDEX_VIEWER_FLOATING_MENU,
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
@@ -188,11 +229,7 @@ const LinkToolbar: React.FC<LinkToolbarProps> = React.memo(
e.stopPropagation();
onNavigate(annotationLink);
}}
aria-label={
internal
? `Go to page ${annotationLink.target?.type === "destination" ? annotationLink.target.destination.pageIndex + 1 : ""}`
: "Open link"
}
aria-label={internal ? `Go to ${label}` : "Open link"}
title={label}
>
{internal ? <PageIcon /> : <ExternalLinkIcon />}
@@ -223,7 +260,11 @@ export const LinkLayer: React.FC<LinkLayerProps> = ({
const documentState = useDocumentState(documentId);
const [hoveredLinkId, setHoveredLinkId] = useState<string | null>(null);
const [toolbarPlacement, setToolbarPlacement] =
useState<ToolbarPlacement | null>(null);
const leaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const linkElementRefs = useRef<Map<string, HTMLAnchorElement>>(new Map());
const toolbarElementRef = useRef<HTMLDivElement | null>(null);
// Extract link annotations for this page from EmbedPDF annotation state
const linkAnnotations = useMemo<PdfLinkAnnoObject[]>(() => {
@@ -251,6 +292,52 @@ export const LinkLayer: React.FC<LinkLayerProps> = ({
// EmbedPDF scale factor (annotation rects are in PDF points at scale 1)
const scale = documentState?.scale ?? 1;
// The portal position is measured from DOM, so this key realigns it when the page transform changes.
const toolbarPositionKey = [
scale,
documentState?.document?.pages?.[pageIndex]?.rotation ?? 0,
documentState?.rotation ?? 0,
].join(":");
const updateToolbarPlacement = useCallback((linkId: string | null) => {
if (!linkId) {
setToolbarPlacement(null);
return;
}
const linkElement = linkElementRefs.current.get(linkId);
if (!linkElement) {
setHoveredLinkId(null);
setToolbarPlacement(null);
return;
}
if (typeof window === "undefined") return;
const rect = linkElement.getBoundingClientRect();
if (isRectOutsideViewport(rect)) {
setHoveredLinkId(null);
setToolbarPlacement(null);
return;
}
const toolbarWidth = toolbarElementRef.current?.offsetWidth ?? 0;
const verticalSpace = TOOLBAR_HEIGHT + TOOLBAR_GAP + TOOLBAR_EDGE_MARGIN;
const fitsAbove = rect.top >= verticalSpace;
const fitsBelow = window.innerHeight - rect.bottom >= verticalSpace;
const centerX = rect.left + rect.width / 2;
const flipped = !fitsAbove && fitsBelow;
const top = flipped
? rect.bottom + TOOLBAR_GAP
: Math.max(TOOLBAR_EDGE_MARGIN, rect.top - TOOLBAR_HEIGHT - TOOLBAR_GAP);
setToolbarPlacement({
linkId,
left: clampToolbarCenter(centerX, toolbarWidth),
top,
flipped,
});
}, []);
const clearLeaveTimer = useCallback(() => {
if (leaveTimerRef.current) {
@@ -263,15 +350,17 @@ export const LinkLayer: React.FC<LinkLayerProps> = ({
clearLeaveTimer();
leaveTimerRef.current = setTimeout(() => {
setHoveredLinkId(null);
}, 120);
setToolbarPlacement(null);
}, TOOLBAR_LEAVE_DELAY);
}, [clearLeaveTimer]);
const handleLinkMouseEnter = useCallback(
(id: string) => {
clearLeaveTimer();
setHoveredLinkId(id);
updateToolbarPlacement(id);
},
[clearLeaveTimer],
[clearLeaveTimer, updateToolbarPlacement],
);
const handleLinkMouseLeave = useCallback(() => {
@@ -290,6 +379,7 @@ export const LinkLayer: React.FC<LinkLayerProps> = ({
(annotationLink: PdfLinkAnnoObject) => {
if (!annotationLink.target) {
setHoveredLinkId(null);
setToolbarPlacement(null);
return;
}
@@ -329,6 +419,7 @@ export const LinkLayer: React.FC<LinkLayerProps> = ({
}
setHoveredLinkId(null);
setToolbarPlacement(null);
},
[scroll],
);
@@ -336,35 +427,103 @@ export const LinkLayer: React.FC<LinkLayerProps> = ({
const handleDelete = useCallback(
(annotationLink: PdfLinkAnnoObject) => {
setHoveredLinkId(null);
setToolbarPlacement(null);
if (!scope) return;
scope.deleteAnnotation(pageIndex, annotationLink.id);
},
[scope, pageIndex],
);
useEffect(() => () => clearLeaveTimer(), [clearLeaveTimer]);
useEffect(() => {
if (!hoveredLinkId) {
setToolbarPlacement(null);
return;
}
updateToolbarPlacement(hoveredLinkId);
if (typeof window === "undefined") return;
const handleViewportChange = () => {
updateToolbarPlacement(hoveredLinkId);
};
window.addEventListener("scroll", handleViewportChange, true);
window.addEventListener("resize", handleViewportChange);
return () => {
window.removeEventListener("scroll", handleViewportChange, true);
window.removeEventListener("resize", handleViewportChange);
};
}, [
hoveredLinkId,
linkAnnotations,
toolbarPositionKey,
updateToolbarPlacement,
]);
useLayoutEffect(() => {
if (!hoveredLinkId || toolbarPlacement?.linkId !== hoveredLinkId) return;
updateToolbarPlacement(hoveredLinkId);
}, [
hoveredLinkId,
toolbarPlacement?.linkId,
toolbarPositionKey,
updateToolbarPlacement,
]);
const hoveredAnnotationLink = useMemo(
() => linkAnnotations.find((link) => link.id === hoveredLinkId) ?? null,
[linkAnnotations, hoveredLinkId],
);
if (linkAnnotations.length === 0) return null;
const toolbarPortal =
hoveredAnnotationLink &&
toolbarPlacement?.linkId === hoveredAnnotationLink.id &&
typeof document !== "undefined"
? createPortal(
<LinkToolbar
annotationLink={hoveredAnnotationLink}
toolbarRef={toolbarElementRef}
left={toolbarPlacement.left}
top={toolbarPlacement.top}
flipped={toolbarPlacement.flipped}
onNavigate={handleNavigate}
onDelete={handleDelete}
onMouseEnter={handleToolbarMouseEnter}
onMouseLeave={handleToolbarMouseLeave}
/>,
document.body,
)
: null;
return (
<div
className="absolute inset-0"
style={{ pointerEvents: "none", zIndex: 10 }}
>
{linkAnnotations.map((annotationLink) => {
const isHovered = hoveredLinkId === annotationLink.id;
const left = annotationLink.rect.origin.x * scale;
const top = annotationLink.rect.origin.y * scale;
const width = annotationLink.rect.size.width * scale;
const height = annotationLink.rect.size.height * scale;
<>
<div
className="absolute inset-0"
style={{ pointerEvents: "none", zIndex: 10 }}
>
{linkAnnotations.map((annotationLink) => {
const isHovered = hoveredLinkId === annotationLink.id;
const left = annotationLink.rect.origin.x * scale;
const top = annotationLink.rect.origin.y * scale;
const width = annotationLink.rect.size.width * scale;
const height = annotationLink.rect.size.height * scale;
// Flip toolbar below if link is near the top of the page
const flipped =
annotationLink.rect.origin.y * scale <
TOOLBAR_HEIGHT + TOOLBAR_GAP + 4;
return (
<React.Fragment key={annotationLink.id}>
{/* Hit-area overlay */}
return (
<a
key={annotationLink.id}
ref={(node) => {
if (node) {
linkElementRefs.current.set(annotationLink.id, node);
} else {
linkElementRefs.current.delete(annotationLink.id);
}
}}
href="#"
onClick={(e) => {
e.preventDefault();
@@ -387,22 +546,10 @@ export const LinkLayer: React.FC<LinkLayerProps> = ({
tabIndex={0}
aria-label={getLinkLabel(annotationLink)}
/>
{/* Floating toolbar */}
{isHovered && (
<LinkToolbar
annotationLink={annotationLink}
scale={scale}
flipped={flipped}
onNavigate={handleNavigate}
onDelete={handleDelete}
onMouseEnter={handleToolbarMouseEnter}
onMouseLeave={handleToolbarMouseLeave}
/>
)}
</React.Fragment>
);
})}
</div>
);
})}
</div>
{toolbarPortal}
</>
);
};
@@ -80,12 +80,6 @@ function RedactionAPIBridgeInner({ documentId }: { documentId: string }) {
enableRedact: () => {
redactionProvides?.enableRedact();
},
enableRedactSelection: () => {
redactionProvides?.enableRedactSelection();
},
enableMarqueeRedact: () => {
redactionProvides?.enableMarqueeRedact();
},
isRedactActive: () => {
return redactionProvides?.isRedactActive() ?? false;
},
@@ -17,8 +17,6 @@ import { RedactionMode } from "@embedpdf/plugin-redaction";
export interface RedactionAPI {
toggleRedact: () => void;
enableRedact: () => void;
enableRedactSelection: () => void;
enableMarqueeRedact: () => void;
isRedactActive: () => boolean;
endRedact: () => void;
// Common methods
@@ -213,19 +211,19 @@ export const RedactionProvider: React.FC<{ children: ReactNode }> = ({
}
}, []);
// Activate a specific manual-redaction sub-mode. The plugin (v2.14.1) has
// dedicated text-selection and marquee modes; the optimistic setActiveType is
// reconciled by RedactionAPIBridge syncing the real state from EmbedPDF.
// Legacy UI actions for backwards compatibility
// In v2.5.0, both text selection and marquee use the same unified mode
// These just activate the unified redact mode and set the active type for UI state
const activateTextSelection = useCallback(() => {
if (redactionApiRef.current) {
redactionApiRef.current.enableRedactSelection();
redactionApiRef.current.enableRedact();
setActiveType("redactSelection" as RedactionMode);
}
}, [setActiveType]);
const activateMarquee = useCallback(() => {
if (redactionApiRef.current) {
redactionApiRef.current.enableMarqueeRedact();
redactionApiRef.current.enableRedact();
setActiveType("marqueeRedact" as RedactionMode);
}
}, [setActiveType]);
@@ -1,14 +0,0 @@
/**
* Core stub for SaasBillingContext.
* Returns null in web/core builds — desktop layer shadows this with the real implementation.
* See: frontend/src/desktop/contexts/SaasBillingContext.tsx
*/
export const useSaaSBilling = (): null => null;
export function SaasBillingProvider({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
@@ -1,7 +1,8 @@
/**
* Core stub for credit checking before cloud operations
* Desktop layer shadows this with the real implementation
* In web builds, always allows the operation (no credit system)
* No-op stub kept only to satisfy useToolOperation's @app/hooks/useCreditCheck
* import. There is no pre-flight credit/usage check anymore — PAYG is enforced
* server-side (the BE returns 402 FEATURE_DEGRADED / PAYG_LIMIT_REACHED, which
* paygErrorInterceptor turns into the usage-limit modal). Always allows.
*/
export function useCreditCheck(_operationType?: string, _endpoint?: string) {
return {
+19 -12
View File
@@ -202,8 +202,6 @@
--icon-sign-color: #ffffff;
--icon-automate-bg: #a576e3;
--icon-automate-color: #ffffff;
--icon-watchedFolders-bg: #f59e0b;
--icon-watchedFolders-color: #ffffff;
--icon-files-bg: #d3e7f7;
--icon-files-color: #0a8bff;
--icon-activity-bg: #d3e7f7;
@@ -537,8 +535,6 @@
--icon-sign-color: #eaeaea;
--icon-automate-bg: #4b525a;
--icon-automate-color: #eaeaea;
--icon-watchedFolders-bg: #4b525a;
--icon-watchedFolders-color: #eaeaea;
--icon-files-bg: #4b525a;
--icon-files-color: #eaeaea;
--icon-activity-bg: #4b525a;
@@ -800,10 +796,11 @@
}
/* ── PDF Link Overlay (viewer) ── */
:root {
--link-hover-bg: rgba(10, 139, 255, 0.04);
--link-hover-border: rgba(10, 139, 255, 0.15);
--link-hover-bg: rgba(10, 139, 255, 0.1);
--link-hover-border: rgba(10, 139, 255, 0.32);
--link-hover-shadow:
0 0 0 0.5px rgba(10, 139, 255, 0.05), 0 1px 3px rgba(10, 139, 255, 0.04);
inset 0 0 0 1px rgba(255, 255, 255, 0.35),
0 1px 4px rgba(10, 139, 255, 0.12);
--link-focus-ring: rgba(10, 139, 255, 0.25);
--link-toolbar-bg: rgba(15, 23, 42, 0.88);
--link-toolbar-border: rgba(255, 255, 255, 0.1);
@@ -813,10 +810,11 @@
}
[data-mantine-color-scheme="dark"] {
--link-hover-bg: rgba(10, 139, 255, 0.06);
--link-hover-border: rgba(59, 170, 255, 0.2);
--link-hover-bg: rgba(59, 170, 255, 0.14);
--link-hover-border: rgba(59, 170, 255, 0.38);
--link-hover-shadow:
0 0 0 0.5px rgba(59, 170, 255, 0.1), 0 1px 3px rgba(10, 139, 255, 0.08);
inset 0 0 0 1px rgba(255, 255, 255, 0.14),
0 1px 4px rgba(10, 139, 255, 0.18);
--link-focus-ring: rgba(59, 170, 255, 0.3);
--link-toolbar-bg: rgba(15, 23, 42, 0.92);
--link-toolbar-border: rgba(255, 255, 255, 0.08);
@@ -847,7 +845,7 @@
.pdf-link-overlay:hover,
.pdf-link-overlay--active {
background: var(--link-hover-bg);
border-bottom: 2px solid var(--color-primary-500);
border-color: var(--link-hover-border);
box-shadow: var(--link-hover-shadow);
}
@@ -889,6 +887,7 @@
align-items: center;
height: 32px;
padding: 0 4px;
box-sizing: border-box;
pointer-events: auto;
white-space: nowrap;
user-select: none;
@@ -934,11 +933,15 @@
cursor: pointer;
transition: all 0.12s ease;
white-space: nowrap;
line-height: 1;
line-height: 1.25;
border-radius: 9999px;
}
.pdf-link-toolbar-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.25;
transition: color 0.12s ease;
}
@@ -985,6 +988,10 @@
background: rgba(255, 255, 255, 0.08);
}
.pdf-link-toolbar-btn--go {
min-width: 0;
}
.pdf-link-toolbar-sep {
width: 1px;
height: 16px;
@@ -6,6 +6,12 @@ export const Z_INDEX_OVER_FULLSCREEN_SURFACE = 1300;
export const Z_ANALYTICS_MODAL = 1301;
// Config/Settings modal - should appear above analytics modal when navigating from onboarding
export const Z_INDEX_CONFIG_MODAL = 1400;
// Modal layered directly over the settings/config modal (e.g. the Stripe
// checkout modal). Consumed by the shared cloud/ checkout component, so it
// lives in the core base both the saas and cloud cascades resolve.
// Must be strictly ABOVE the config modal (1400); 1450 is taken by the cookie
// preferences modal, so sit above the whole settings cluster (below 2000).
export const Z_INDEX_OVER_SETTINGS_MODAL = 1500;
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
@@ -34,6 +40,9 @@ export const Z_INDEX_COOKIE_PREFERENCES_MODAL = 1450;
// Sign-in modal — must appear above all app UI including config and analytics modals
export const Z_INDEX_SIGN_IN_MODAL = 9000;
// Floating viewer menus rendered through document.body portals.
export const Z_INDEX_VIEWER_FLOATING_MENU = 10000;
// Toast notifications and error displays - Always on top (higher than rainbow theme at 10000)
export const Z_INDEX_TOAST = 10001;

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