Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
082bcc34c2 | ||
|
|
df9dbc5179 | ||
|
|
de9242c4f7 | ||
|
|
460c037bbb | ||
|
|
cd7264a76a | ||
|
|
ef0deef4f2 | ||
|
|
65fcc036fe |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
+35
-5
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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();
|
||||
|
||||
+43
-5
@@ -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);
|
||||
|
||||
+195
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
-18
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -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));
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+151
@@ -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: {}."
|
||||
|
||||
+1035
File diff suppressed because it is too large
Load Diff
+457
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+802
@@ -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);
|
||||
}
|
||||
}
|
||||
+676
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+783
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+1355
File diff suppressed because it is too large
Load Diff
+556
@@ -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);
|
||||
}
|
||||
}
|
||||
+679
@@ -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 < 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();
|
||||
}
|
||||
}
|
||||
+806
@@ -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() {}
|
||||
}
|
||||
}
|
||||
+351
@@ -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);
|
||||
}
|
||||
}
|
||||
+585
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+475
@@ -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
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
export const CLOUD_LAYER_PROBE = "cloud" as const;
|
||||
@@ -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 () => {},
|
||||
};
|
||||
}
|
||||
+6
@@ -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) {
|
||||
+11
-2
@@ -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)),
|
||||
];
|
||||
}
|
||||
+14
-4
@@ -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)],
|
||||
};
|
||||
}
|
||||
+57
-72
@@ -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;
|
||||
+4
-1
@@ -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;
|
||||
+11
-10
@@ -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
-131
@@ -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 "";
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user