mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
refactor(saas): remove the legacy credits engine (FE + Java) (#6687)
Complete legacy-credits teardown ("Group 3"). The per-user/per-team
credit model is fully superseded by PAYG (`wallet_ledger`) — confirmed
no PAYG code references it. Authorized to also remove the `TeamCredit`
pool + its monthly reset.
## Frontend (saas)
- Deleted `saas/hooks/useCredits.ts`, `apiKeys/hooks/useCredits.ts`,
`types/credits.ts`, `apiKeys/UsageSection.tsx`.
- `UseSession.tsx`: removed credit members (`creditBalance`,
`creditSummary`, `hasSufficientCredits`, `updateCredits`,
`refreshCredits`, `fetchCredits`) + the credit types + global
credit-update callback. **Kept** `isPro`/`refreshProStatus` and the
Supabase auth subscription listener.
- `services/apiClient.ts`: removed the dead `x-credits-remaining`
handler + low-credit plumbing (token-refresh / PAYG / 401 logic
untouched).
- Credit refs removed from `ApiKeys.tsx`, `AppConfigModal.tsx`,
`auth/teamSession.ts`.
## Java (:saas)
**Deleted (15):** `UserCredit`(+repo),
`TeamCredit`(+repo)+`TeamCreditService`, `CreditService`,
`CreditHeaderUtils`, `CreditResetScheduler`, `CreditController`,
`CreditInterceptorConfig`, `UnifiedCreditInterceptor`,
`CreditSuccessAdvice`, `CreditErrorAdvice`, `CreditConsumptionResult` (+
the CreditController test).
**Edited — stripped legacy credit side-effects, preserved
auth/role/AI/PAYG logic:**
- `AiCreate`/`AiProxyController`: dropped the
`X-Credits-Remaining`/`X-Credit-Source` response header (its only
consumer, the desktop credit system, was already removed).
- `SaasTeamService`: dropped UserCredit/TeamCredit init on team-create +
seat-update.
- `SupabaseAuthenticationFilter` / `SupabaseSecurityConfig`: dropped
`getOrCreateUserCredits` on signup + the credit field/CORS header.
- `UserRoleService`: dropped `resetCycleAllocationForRoleChange`;
`ROLE_PRO_USER` grant/revoke preserved.
- proprietary `UserRepository`: dropped
`findUsersWithApiKeyButNoCredits()`.
- Tests updated to drop credit mocks/refs.
## Kept / scope
- `isPro` / `is_pro` RPC / `ROLE_PRO_USER` (that's the separate Group-4
/ EE effort) and **all PAYG** are untouched.
- **No DB tables dropped.** `user_credits`/`team_credits` stay until a
later **gated** migration — which this PR unblocks (the JPA entities
that pinned them are gone).
## Verify
`:saas:compileJava` + `:saas:compileTestJava` pass; FE `tsc --noEmit`
(saas) + eslint clean; 0 stray artifacts; no residual source refs to the
deleted classes.
## Follow-up (not in this PR)
`ErrorTrackingService` (+
`UserErrorTracker`/`ProcessingErrorType`/`CreditsProperties`) is now a
dead island — its only callers were the deleted interceptors. Safe to
delete, but it cascades beyond the credit scope, so it's a separate
tidy-up.
Targets `feat/desktop-cloud-saas-reuse`.
This commit is contained in:
-9
@@ -105,15 +105,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
|||||||
Stream<Long> findByUsernameIsNullAndCreatedAtBefore(
|
Stream<Long> findByUsernameIsNullAndCreatedAtBefore(
|
||||||
@Param("cutoffDate") LocalDateTime cutoffDate);
|
@Param("cutoffDate") LocalDateTime cutoffDate);
|
||||||
|
|
||||||
/** Users with an API key but no row in {@code user_credits}. */
|
|
||||||
@Query(
|
|
||||||
value =
|
|
||||||
"SELECT u.* FROM users u "
|
|
||||||
+ "LEFT JOIN user_credits uc ON uc.user_id = u.user_id "
|
|
||||||
+ "WHERE u.api_key IS NOT NULL AND uc.user_id IS NULL",
|
|
||||||
nativeQuery = true)
|
|
||||||
List<User> findUsersWithApiKeyButNoCredits();
|
|
||||||
|
|
||||||
/** Single-shot UPDATE that reassigns a user to a different team. */
|
/** Single-shot UPDATE that reassigns a user to a different team. */
|
||||||
@Modifying
|
@Modifying
|
||||||
@Query("UPDATE User u SET u.team.id = :teamId WHERE u.id = :userId")
|
@Query("UPDATE User u SET u.team.id = :teamId WHERE u.id = :userId")
|
||||||
|
|||||||
+3
-49
@@ -51,10 +51,7 @@ import stirling.software.saas.payg.model.BillingCategory;
|
|||||||
import stirling.software.saas.payg.model.FeatureGate;
|
import stirling.software.saas.payg.model.FeatureGate;
|
||||||
import stirling.software.saas.payg.model.JobSource;
|
import stirling.software.saas.payg.model.JobSource;
|
||||||
import stirling.software.saas.payg.model.ProcessType;
|
import stirling.software.saas.payg.model.ProcessType;
|
||||||
import stirling.software.saas.service.CreditService;
|
|
||||||
import stirling.software.saas.service.TeamCreditService;
|
|
||||||
import stirling.software.saas.util.AuthenticationUtils;
|
import stirling.software.saas.util.AuthenticationUtils;
|
||||||
import stirling.software.saas.util.CreditHeaderUtils;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@Profile("saas")
|
@Profile("saas")
|
||||||
@@ -69,10 +66,7 @@ public class AiCreateController {
|
|||||||
private final AiCreateSessionService sessionService;
|
private final AiCreateSessionService sessionService;
|
||||||
private final AiCreateProxyService proxyService;
|
private final AiCreateProxyService proxyService;
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
private final CreditService creditService;
|
|
||||||
private final TeamCreditService teamCreditService;
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final CreditHeaderUtils creditHeaderUtils;
|
|
||||||
private final JobChargeService jobChargeService;
|
private final JobChargeService jobChargeService;
|
||||||
|
|
||||||
@PostMapping("/sessions")
|
@PostMapping("/sessions")
|
||||||
@@ -233,8 +227,7 @@ public class AiCreateController {
|
|||||||
@PathVariable String sessionId, HttpServletRequest request) {
|
@PathVariable String sessionId, HttpServletRequest request) {
|
||||||
sessionService.getSessionForCurrentUser(sessionId);
|
sessionService.getSessionForCurrentUser(sessionId);
|
||||||
log.info("AI create fillFields sessionId={}", sessionId);
|
log.info("AI create fillFields sessionId={}", sessionId);
|
||||||
return proxy(
|
return proxy("POST", "/api/create/sessions/" + sessionId + "/fields", request, false);
|
||||||
"POST", "/api/create/sessions/" + sessionId + "/fields", request, false, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(
|
@GetMapping(
|
||||||
@@ -243,20 +236,11 @@ public class AiCreateController {
|
|||||||
public ResponseEntity<StreamingResponseBody> stream(
|
public ResponseEntity<StreamingResponseBody> stream(
|
||||||
@PathVariable String sessionId, HttpServletRequest request) {
|
@PathVariable String sessionId, HttpServletRequest request) {
|
||||||
sessionService.getSessionForCurrentUser(sessionId);
|
sessionService.getSessionForCurrentUser(sessionId);
|
||||||
return proxy(
|
return proxy("GET", "/api/create/sessions/" + sessionId + "/stream", request, true);
|
||||||
"GET",
|
|
||||||
"/api/create/sessions/" + sessionId + "/stream",
|
|
||||||
request,
|
|
||||||
true,
|
|
||||||
true); // Add credits header: frontend endpoint that triggers AI
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResponseEntity<StreamingResponseBody> proxy(
|
private ResponseEntity<StreamingResponseBody> proxy(
|
||||||
String method,
|
String method, String path, HttpServletRequest request, boolean acceptEventStream) {
|
||||||
String path,
|
|
||||||
HttpServletRequest request,
|
|
||||||
boolean acceptEventStream,
|
|
||||||
boolean includeCreditsHeader) {
|
|
||||||
try {
|
try {
|
||||||
HttpResponse<InputStream> response =
|
HttpResponse<InputStream> response =
|
||||||
proxyService.forward(method, path, request, acceptEventStream);
|
proxyService.forward(method, path, request, acceptEventStream);
|
||||||
@@ -270,11 +254,6 @@ public class AiCreateController {
|
|||||||
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE);
|
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add credit headers if requested
|
|
||||||
if (includeCreditsHeader) {
|
|
||||||
addCreditHeaders(headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
StreamingResponseBody body =
|
StreamingResponseBody body =
|
||||||
outputStream -> {
|
outputStream -> {
|
||||||
try (InputStream inputStream = response.body()) {
|
try (InputStream inputStream = response.body()) {
|
||||||
@@ -302,31 +281,6 @@ public class AiCreateController {
|
|||||||
.ifPresent(value -> headers.set(headerName, value));
|
.ifPresent(value -> headers.set(headerName, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Add credit headers to the response headers.
|
|
||||||
*
|
|
||||||
* @param headers The headers to add credit information to
|
|
||||||
*/
|
|
||||||
private void addCreditHeaders(HttpHeaders headers) {
|
|
||||||
try {
|
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
||||||
if (auth == null || !auth.isAuthenticated()) {
|
|
||||||
log.debug("[AI-CREATE] No authentication found, skipping credit header");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
|
||||||
int remainingCredits =
|
|
||||||
creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService);
|
|
||||||
if (remainingCredits >= 0) {
|
|
||||||
headers.set("X-Credits-Remaining", Integer.toString(remainingCredits));
|
|
||||||
log.warn("[AI-CREATE] Added X-Credits-Remaining header: {}", remainingCredits);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[AI-CREATE] Failed to add credit header: {}", e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public record CreateSessionRequest(
|
public record CreateSessionRequest(
|
||||||
String prompt,
|
String prompt,
|
||||||
String docType,
|
String docType,
|
||||||
|
|||||||
+22
-80
@@ -9,8 +9,6 @@ import org.springframework.http.HttpHeaders;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@@ -25,15 +23,9 @@ import jakarta.servlet.http.HttpServletRequest;
|
|||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
|
||||||
import stirling.software.proprietary.security.model.User;
|
|
||||||
import stirling.software.saas.ai.service.AiProxyService;
|
import stirling.software.saas.ai.service.AiProxyService;
|
||||||
import stirling.software.saas.payg.cap.RequiresFeature;
|
import stirling.software.saas.payg.cap.RequiresFeature;
|
||||||
import stirling.software.saas.payg.model.FeatureGate;
|
import stirling.software.saas.payg.model.FeatureGate;
|
||||||
import stirling.software.saas.service.CreditService;
|
|
||||||
import stirling.software.saas.service.TeamCreditService;
|
|
||||||
import stirling.software.saas.util.AuthenticationUtils;
|
|
||||||
import stirling.software.saas.util.CreditHeaderUtils;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@Profile("saas")
|
@Profile("saas")
|
||||||
@@ -45,103 +37,89 @@ import stirling.software.saas.util.CreditHeaderUtils;
|
|||||||
public class AiProxyController {
|
public class AiProxyController {
|
||||||
|
|
||||||
private final AiProxyService aiProxyService;
|
private final AiProxyService aiProxyService;
|
||||||
private final CreditService creditService;
|
|
||||||
private final TeamCreditService teamCreditService;
|
|
||||||
private final UserRepository userRepository;
|
|
||||||
private final CreditHeaderUtils creditHeaderUtils;
|
|
||||||
|
|
||||||
public AiProxyController(
|
public AiProxyController(AiProxyService aiProxyService) {
|
||||||
AiProxyService aiProxyService,
|
|
||||||
CreditService creditService,
|
|
||||||
TeamCreditService teamCreditService,
|
|
||||||
UserRepository userRepository,
|
|
||||||
CreditHeaderUtils creditHeaderUtils) {
|
|
||||||
this.aiProxyService = aiProxyService;
|
this.aiProxyService = aiProxyService;
|
||||||
this.creditService = creditService;
|
|
||||||
this.teamCreditService = teamCreditService;
|
|
||||||
this.userRepository = userRepository;
|
|
||||||
this.creditHeaderUtils = creditHeaderUtils;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/generate_section")
|
@PostMapping("/generate_section")
|
||||||
public ResponseEntity<StreamingResponseBody> generateSection(HttpServletRequest request) {
|
public ResponseEntity<StreamingResponseBody> generateSection(HttpServletRequest request) {
|
||||||
return proxy("POST", "/api/generate_section", request, false, false);
|
return proxy("POST", "/api/generate_section", request, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/generate_all_sections")
|
@PostMapping("/generate_all_sections")
|
||||||
public ResponseEntity<StreamingResponseBody> generateAllSections(HttpServletRequest request) {
|
public ResponseEntity<StreamingResponseBody> generateAllSections(HttpServletRequest request) {
|
||||||
return proxy("POST", "/api/generate_all_sections", request, false, false);
|
return proxy("POST", "/api/generate_all_sections", request, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/intent/check")
|
@PostMapping("/intent/check")
|
||||||
public ResponseEntity<StreamingResponseBody> intentCheck(HttpServletRequest request) {
|
public ResponseEntity<StreamingResponseBody> intentCheck(HttpServletRequest request) {
|
||||||
return proxy("POST", "/api/intent/check", request, false, false);
|
return proxy("POST", "/api/intent/check", request, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/chat/route")
|
@PostMapping("/chat/route")
|
||||||
public ResponseEntity<StreamingResponseBody> chatRoute(HttpServletRequest request) {
|
public ResponseEntity<StreamingResponseBody> chatRoute(HttpServletRequest request) {
|
||||||
return proxy("POST", "/api/chat/route", request, false, true);
|
return proxy("POST", "/api/chat/route", request, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/chat/create-smart-folder")
|
@PostMapping("/chat/create-smart-folder")
|
||||||
public ResponseEntity<StreamingResponseBody> createSmartFolder(HttpServletRequest request) {
|
public ResponseEntity<StreamingResponseBody> createSmartFolder(HttpServletRequest request) {
|
||||||
return proxy("POST", "/api/chat/create-smart-folder", request, false, true);
|
return proxy("POST", "/api/chat/create-smart-folder", request, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/chat/info")
|
@PostMapping("/chat/info")
|
||||||
public ResponseEntity<StreamingResponseBody> chatInfo(HttpServletRequest request) {
|
public ResponseEntity<StreamingResponseBody> chatInfo(HttpServletRequest request) {
|
||||||
return proxy("POST", "/api/chat/info", request, false, true);
|
return proxy("POST", "/api/chat/info", request, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/pdf/answer")
|
@PostMapping("/pdf/answer")
|
||||||
public ResponseEntity<StreamingResponseBody> pdfAnswer(HttpServletRequest request) {
|
public ResponseEntity<StreamingResponseBody> pdfAnswer(HttpServletRequest request) {
|
||||||
return proxy("POST", "/api/pdf/answer", request, false, false);
|
return proxy("POST", "/api/pdf/answer", request, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/progressive_render")
|
@PostMapping("/progressive_render")
|
||||||
public ResponseEntity<StreamingResponseBody> progressiveRender(HttpServletRequest request) {
|
public ResponseEntity<StreamingResponseBody> progressiveRender(HttpServletRequest request) {
|
||||||
return proxy("POST", "/api/progressive_render", request, false, false);
|
return proxy("POST", "/api/progressive_render", request, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/versions/{userId}")
|
@GetMapping("/versions/{userId}")
|
||||||
public ResponseEntity<StreamingResponseBody> versions(
|
public ResponseEntity<StreamingResponseBody> versions(
|
||||||
@PathVariable("userId") String userId, HttpServletRequest request) {
|
@PathVariable("userId") String userId, HttpServletRequest request) {
|
||||||
return proxy("GET", "/api/versions/" + userId, request, false, false);
|
return proxy("GET", "/api/versions/" + userId, request, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/style/{userId}")
|
@GetMapping("/style/{userId}")
|
||||||
public ResponseEntity<StreamingResponseBody> style(
|
public ResponseEntity<StreamingResponseBody> style(
|
||||||
@PathVariable("userId") String userId, HttpServletRequest request) {
|
@PathVariable("userId") String userId, HttpServletRequest request) {
|
||||||
return proxy("GET", "/api/style/" + userId, request, false, false);
|
return proxy("GET", "/api/style/" + userId, request, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/style/{userId}")
|
@PostMapping("/style/{userId}")
|
||||||
public ResponseEntity<StreamingResponseBody> updateStyle(
|
public ResponseEntity<StreamingResponseBody> updateStyle(
|
||||||
@PathVariable("userId") String userId, HttpServletRequest request) {
|
@PathVariable("userId") String userId, HttpServletRequest request) {
|
||||||
return proxy("POST", "/api/style/" + userId, request, false, false);
|
return proxy("POST", "/api/style/" + userId, request, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/import_template")
|
@PostMapping("/import_template")
|
||||||
public ResponseEntity<StreamingResponseBody> importTemplate(HttpServletRequest request) {
|
public ResponseEntity<StreamingResponseBody> importTemplate(HttpServletRequest request) {
|
||||||
return proxy("POST", "/api/import_template", request, false, false);
|
return proxy("POST", "/api/import_template", request, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/edit/sessions")
|
@PostMapping("/edit/sessions")
|
||||||
public ResponseEntity<StreamingResponseBody> createEditSession(HttpServletRequest request) {
|
public ResponseEntity<StreamingResponseBody> createEditSession(HttpServletRequest request) {
|
||||||
return proxy("POST", "/api/edit/sessions", request, false, false);
|
return proxy("POST", "/api/edit/sessions", request, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/edit/sessions/{sessionId}/messages")
|
@PostMapping("/edit/sessions/{sessionId}/messages")
|
||||||
public ResponseEntity<StreamingResponseBody> editSessionMessage(
|
public ResponseEntity<StreamingResponseBody> editSessionMessage(
|
||||||
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
|
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
|
||||||
return proxy("POST", "/api/edit/sessions/" + sessionId + "/messages", request, false, true);
|
return proxy("POST", "/api/edit/sessions/" + sessionId + "/messages", request, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/edit/sessions/{sessionId}/attachments")
|
@PostMapping("/edit/sessions/{sessionId}/attachments")
|
||||||
public ResponseEntity<StreamingResponseBody> editSessionAttachment(
|
public ResponseEntity<StreamingResponseBody> editSessionAttachment(
|
||||||
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
|
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
|
||||||
return proxy(
|
return proxy("POST", "/api/edit/sessions/" + sessionId + "/attachments", request, false);
|
||||||
"POST", "/api/edit/sessions/" + sessionId + "/attachments", request, false, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(
|
@PostMapping(
|
||||||
@@ -149,17 +127,17 @@ public class AiProxyController {
|
|||||||
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
public ResponseEntity<StreamingResponseBody> runEditSession(
|
public ResponseEntity<StreamingResponseBody> runEditSession(
|
||||||
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
|
@PathVariable("sessionId") String sessionId, HttpServletRequest request) {
|
||||||
return proxy("POST", "/api/edit/sessions/" + sessionId + "/run", request, true, false);
|
return proxy("POST", "/api/edit/sessions/" + sessionId + "/run", request, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/pdf-editor/document")
|
@GetMapping("/pdf-editor/document")
|
||||||
public ResponseEntity<StreamingResponseBody> pdfEditorDocument(HttpServletRequest request) {
|
public ResponseEntity<StreamingResponseBody> pdfEditorDocument(HttpServletRequest request) {
|
||||||
return proxy("GET", "/api/pdf-editor/document", request, false, false);
|
return proxy("GET", "/api/pdf-editor/document", request, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/pdf-editor/upload")
|
@PostMapping("/pdf-editor/upload")
|
||||||
public ResponseEntity<StreamingResponseBody> pdfEditorUpload(HttpServletRequest request) {
|
public ResponseEntity<StreamingResponseBody> pdfEditorUpload(HttpServletRequest request) {
|
||||||
return proxy("POST", "/api/pdf-editor/upload", request, false, false);
|
return proxy("POST", "/api/pdf-editor/upload", request, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/output/**")
|
@GetMapping("/output/**")
|
||||||
@@ -167,27 +145,22 @@ public class AiProxyController {
|
|||||||
String requestUri = request.getRequestURI();
|
String requestUri = request.getRequestURI();
|
||||||
String prefix = request.getContextPath() + "/api/v1/ai/output/";
|
String prefix = request.getContextPath() + "/api/v1/ai/output/";
|
||||||
String path = requestUri.startsWith(prefix) ? requestUri.substring(prefix.length()) : "";
|
String path = requestUri.startsWith(prefix) ? requestUri.substring(prefix.length()) : "";
|
||||||
return proxy("GET", "/output/" + path, request, false, false);
|
return proxy("GET", "/output/" + path, request, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health endpoint at /api/v1/ai/health is owned by the proprietary AiEngineController; both
|
// Health endpoint at /api/v1/ai/health is owned by the proprietary AiEngineController; both
|
||||||
// proxy to the same backing AI engine. No need for credit-aware wrapping on a health probe.
|
// proxy to the same backing AI engine. No need for credit-aware wrapping on a health probe.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy method that optionally adds credit headers.
|
* Proxy method.
|
||||||
*
|
*
|
||||||
* @param method HTTP method
|
* @param method HTTP method
|
||||||
* @param path API path
|
* @param path API path
|
||||||
* @param request The incoming request
|
* @param request The incoming request
|
||||||
* @param acceptEventStream Whether to accept event stream responses
|
* @param acceptEventStream Whether to accept event stream responses
|
||||||
* @param includeCreditsHeader Whether to add credit balance header
|
|
||||||
*/
|
*/
|
||||||
private ResponseEntity<StreamingResponseBody> proxy(
|
private ResponseEntity<StreamingResponseBody> proxy(
|
||||||
String method,
|
String method, String path, HttpServletRequest request, boolean acceptEventStream) {
|
||||||
String path,
|
|
||||||
HttpServletRequest request,
|
|
||||||
boolean acceptEventStream,
|
|
||||||
boolean includeCreditsHeader) {
|
|
||||||
try {
|
try {
|
||||||
// Forward to AI backend
|
// Forward to AI backend
|
||||||
HttpResponse<InputStream> aiResponse =
|
HttpResponse<InputStream> aiResponse =
|
||||||
@@ -204,11 +177,6 @@ public class AiProxyController {
|
|||||||
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE);
|
headers.set(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_EVENT_STREAM_VALUE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add credit headers if requested (after AI processing completes)
|
|
||||||
if (includeCreditsHeader) {
|
|
||||||
addCreditHeaders(headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
StreamingResponseBody body =
|
StreamingResponseBody body =
|
||||||
outputStream -> {
|
outputStream -> {
|
||||||
try (InputStream inputStream = aiResponse.body()) {
|
try (InputStream inputStream = aiResponse.body()) {
|
||||||
@@ -247,30 +215,4 @@ public class AiProxyController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Add credit headers to the response headers.
|
|
||||||
*
|
|
||||||
* @param headers The headers to add credit information to
|
|
||||||
*/
|
|
||||||
private void addCreditHeaders(HttpHeaders headers) {
|
|
||||||
try {
|
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
||||||
if (auth == null || !auth.isAuthenticated()) {
|
|
||||||
log.debug("[AI-PROXY] No authentication found, skipping credit header");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
|
||||||
int remainingCredits =
|
|
||||||
creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService);
|
|
||||||
if (remainingCredits >= 0) {
|
|
||||||
headers.set("X-Credits-Remaining", Integer.toString(remainingCredits));
|
|
||||||
log.warn("[AI-PROXY] Added X-Credits-Remaining header: {}", remainingCredits);
|
|
||||||
}
|
|
||||||
headers.set("X-Credit-Source", "AI_TOOL_CALL");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("[AI-PROXY] Failed to add credit header: {}", e.getMessage(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
package stirling.software.saas.config;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
|
|
||||||
import stirling.software.saas.interceptor.UnifiedCreditInterceptor;
|
|
||||||
|
|
||||||
// Legacy credit-billing path. Disabled in saas-PAYG by default — activate the legacy-credits
|
|
||||||
// profile explicitly (`--spring.profiles.active=saas,dev,legacy-credits`) if you need it back.
|
|
||||||
@Configuration
|
|
||||||
@Profile("saas & legacy-credits")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class CreditInterceptorConfig implements WebMvcConfigurer {
|
|
||||||
|
|
||||||
private final UnifiedCreditInterceptor unifiedCreditInterceptor;
|
|
||||||
private final CreditsProperties creditsProperties;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void addInterceptors(InterceptorRegistry registry) {
|
|
||||||
if (creditsProperties.isEnabled()) {
|
|
||||||
registry.addInterceptor(unifiedCreditInterceptor)
|
|
||||||
.addPathPatterns("/api/**")
|
|
||||||
.excludePathPatterns(
|
|
||||||
"/api/v1/credits/**", "/api/v1/config/**", "/api/v1/info/**");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
package stirling.software.saas.controller;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Hidden;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
|
||||||
import io.swagger.v3.oas.annotations.media.Content;
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
|
||||||
import stirling.software.proprietary.security.model.User;
|
|
||||||
import stirling.software.saas.security.EnhancedJwtAuthenticationToken;
|
|
||||||
import stirling.software.saas.service.CreditService;
|
|
||||||
import stirling.software.saas.service.CreditService.CreditSummary;
|
|
||||||
import stirling.software.saas.util.LogRedactionUtils;
|
|
||||||
|
|
||||||
// Legacy credit-billing endpoints. PAYG replaces this — gated behind legacy-credits profile.
|
|
||||||
@RestController
|
|
||||||
@Profile("saas & legacy-credits")
|
|
||||||
@RequestMapping("/api/v1/credits")
|
|
||||||
@Tag(name = "Credit Management", description = "Endpoints for managing user API credits")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
public class CreditController {
|
|
||||||
|
|
||||||
private final CreditService creditService;
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
@Hidden
|
|
||||||
@Operation(
|
|
||||||
summary = "Get user credit information",
|
|
||||||
description =
|
|
||||||
"Retrieve current credit balance and usage statistics for the authenticated user")
|
|
||||||
@ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "Credit information retrieved successfully",
|
|
||||||
content = @Content(schema = @Schema(implementation = CreditSummary.class)))
|
|
||||||
public ResponseEntity<CreditSummary> getUserCredits(Authentication authentication) {
|
|
||||||
return ResponseEntity.ok(getCreditSummaryForAuthentication(authentication));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/purchase")
|
|
||||||
@Hidden
|
|
||||||
@Operation(
|
|
||||||
summary = "Purchase additional credits",
|
|
||||||
description = "Add bought credits to user account (admin only)")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
@ApiResponse(responseCode = "200", description = "Credits purchased successfully")
|
|
||||||
public ResponseEntity<Map<String, Object>> purchaseCredits(
|
|
||||||
@RequestParam("username") String username, @RequestParam("credits") int credits) {
|
|
||||||
|
|
||||||
if (credits <= 0) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Credits must be positive"));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
creditService.addBoughtCredits(username, credits);
|
|
||||||
log.info("Admin added {} credits to user: {}", credits, username);
|
|
||||||
return ResponseEntity.ok(Map.of("success", true, "creditsAdded", credits));
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
log.warn("purchaseCredits rejected: {}", e.getMessage());
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/purchase-by-supabase-id")
|
|
||||||
@Hidden
|
|
||||||
@Operation(
|
|
||||||
summary = "Purchase additional credits by Supabase ID",
|
|
||||||
description = "Add bought credits to user account using Supabase ID (admin only)")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
@ApiResponse(responseCode = "200", description = "Credits purchased successfully")
|
|
||||||
public ResponseEntity<Map<String, Object>> purchaseCreditsBySupabaseId(
|
|
||||||
@RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) {
|
|
||||||
|
|
||||||
if (credits <= 0) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Credits must be positive"));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
creditService.addBoughtCreditsBySupabaseId(supabaseId, credits);
|
|
||||||
log.info(
|
|
||||||
"Admin added {} credits to user with Supabase ID: {}",
|
|
||||||
credits,
|
|
||||||
LogRedactionUtils.redactSupabaseId(supabaseId));
|
|
||||||
return ResponseEntity.ok(Map.of("success", true, "creditsAdded", credits));
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
log.warn("purchaseCreditsBySupabaseId rejected: {}", e.getMessage());
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/user/{username}")
|
|
||||||
@Hidden
|
|
||||||
@Operation(
|
|
||||||
summary = "Get credit information for specific user",
|
|
||||||
description = "Retrieve credit information for a specific user (admin only)")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
@ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "User credit information retrieved successfully",
|
|
||||||
content = @Content(schema = @Schema(implementation = CreditSummary.class)))
|
|
||||||
public ResponseEntity<CreditSummary> getUserCreditsAdmin(
|
|
||||||
@PathVariable("username") String username) {
|
|
||||||
CreditSummary summary = creditService.getCreditSummary(username);
|
|
||||||
return ResponseEntity.ok(summary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/user-by-supabase-id/{supabaseId}")
|
|
||||||
@Hidden
|
|
||||||
@Operation(
|
|
||||||
summary = "Get credit information for specific user by Supabase ID",
|
|
||||||
description =
|
|
||||||
"Retrieve credit information for a specific user using Supabase ID (admin only)")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
@ApiResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "User credit information retrieved successfully",
|
|
||||||
content = @Content(schema = @Schema(implementation = CreditSummary.class)))
|
|
||||||
public ResponseEntity<CreditSummary> getUserCreditsAdminBySupabaseId(
|
|
||||||
@PathVariable("supabaseId") String supabaseId) {
|
|
||||||
CreditSummary summary = creditService.getCreditSummaryBySupabaseId(supabaseId);
|
|
||||||
return ResponseEntity.ok(summary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/reset-cycle")
|
|
||||||
@Hidden
|
|
||||||
@Operation(
|
|
||||||
summary = "Reset cycle credits for all users",
|
|
||||||
description = "Manually trigger cycle credit reset for all users (admin only)")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
@ApiResponse(responseCode = "200", description = "Cycle credits reset successfully")
|
|
||||||
public ResponseEntity<String> resetCycleCredits() {
|
|
||||||
creditService.resetCycleCreditsForAllUsers();
|
|
||||||
log.info("Manual cycle credit reset triggered by admin");
|
|
||||||
return ResponseEntity.ok("Cycle credits reset successfully for all users");
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/set-bought-credits")
|
|
||||||
@Hidden
|
|
||||||
@Operation(
|
|
||||||
summary = "Set user's bought credits to a specific amount",
|
|
||||||
description =
|
|
||||||
"Hard set the bought credits balance for a specific user to an exact amount (admin only)")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
@ApiResponse(responseCode = "200", description = "Bought credits set successfully")
|
|
||||||
public ResponseEntity<Map<String, Object>> setBoughtCredits(
|
|
||||||
@RequestParam("username") String username, @RequestParam("credits") int credits) {
|
|
||||||
|
|
||||||
if (credits < 0) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
creditService.setBoughtCredits(username, credits);
|
|
||||||
log.info("Admin set bought credits to {} for user: {}", credits, username);
|
|
||||||
return ResponseEntity.ok(Map.of("success", true, "boughtCredits", credits));
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
log.warn("setBoughtCredits rejected: {}", e.getMessage());
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/set-bought-credits-by-supabase-id")
|
|
||||||
@Hidden
|
|
||||||
@Operation(
|
|
||||||
summary = "Set user's bought credits to a specific amount by Supabase ID",
|
|
||||||
description =
|
|
||||||
"Hard set the bought credits balance for a specific user using Supabase ID to an exact amount (admin only)")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
@ApiResponse(responseCode = "200", description = "Bought credits set successfully")
|
|
||||||
public ResponseEntity<Map<String, Object>> setBoughtCreditsBySupabaseId(
|
|
||||||
@RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) {
|
|
||||||
|
|
||||||
if (credits < 0) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
creditService.setBoughtCreditsBySupabaseId(supabaseId, credits);
|
|
||||||
log.info(
|
|
||||||
"Admin set bought credits to {} for user with Supabase ID: {}",
|
|
||||||
credits,
|
|
||||||
LogRedactionUtils.redactSupabaseId(supabaseId));
|
|
||||||
return ResponseEntity.ok(Map.of("success", true, "boughtCredits", credits));
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
log.warn("setBoughtCreditsBySupabaseId rejected: {}", e.getMessage());
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/set-cycle-credits")
|
|
||||||
@Hidden
|
|
||||||
@Operation(
|
|
||||||
summary = "Set user's cycle credits remaining to a specific amount",
|
|
||||||
description =
|
|
||||||
"Hard set the cycle credits remaining balance for a specific user to an exact amount (admin only)")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
@ApiResponse(responseCode = "200", description = "Cycle credits set successfully")
|
|
||||||
public ResponseEntity<Map<String, Object>> setCycleCredits(
|
|
||||||
@RequestParam("username") String username, @RequestParam("credits") int credits) {
|
|
||||||
|
|
||||||
if (credits < 0) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
creditService.setCycleCredits(username, credits);
|
|
||||||
log.info("Admin set cycle credits to {} for user: {}", credits, username);
|
|
||||||
return ResponseEntity.ok(Map.of("success", true, "cycleCredits", credits));
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
log.warn("setCycleCredits rejected: {}", e.getMessage());
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/set-cycle-credits-by-supabase-id")
|
|
||||||
@Hidden
|
|
||||||
@Operation(
|
|
||||||
summary = "Set user's cycle credits remaining to a specific amount by Supabase ID",
|
|
||||||
description =
|
|
||||||
"Hard set the cycle credits remaining balance for a specific user using Supabase ID to an exact amount (admin only)")
|
|
||||||
@PreAuthorize("hasRole('ADMIN')")
|
|
||||||
@ApiResponse(responseCode = "200", description = "Cycle credits set successfully")
|
|
||||||
public ResponseEntity<Map<String, Object>> setCycleCreditsBySupabaseId(
|
|
||||||
@RequestParam("supabaseId") String supabaseId, @RequestParam("credits") int credits) {
|
|
||||||
|
|
||||||
if (credits < 0) {
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Credits cannot be negative"));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
creditService.setCycleCreditsBySupabaseId(supabaseId, credits);
|
|
||||||
log.info(
|
|
||||||
"Admin set cycle credits to {} for user with Supabase ID: {}",
|
|
||||||
credits,
|
|
||||||
LogRedactionUtils.redactSupabaseId(supabaseId));
|
|
||||||
return ResponseEntity.ok(Map.of("success", true, "cycleCredits", credits));
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
log.warn("setCycleCreditsBySupabaseId rejected: {}", e.getMessage());
|
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid request"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/usage")
|
|
||||||
@Hidden
|
|
||||||
@Operation(
|
|
||||||
summary = "Get credit usage summary",
|
|
||||||
description = "Get overview of credit usage (for authenticated user or admin view)")
|
|
||||||
public ResponseEntity<UsageSummary> getCreditUsage(Authentication authentication) {
|
|
||||||
CreditSummary summary = getCreditSummaryForAuthentication(authentication);
|
|
||||||
|
|
||||||
// For unlimited users, don't show meaningless huge usage numbers
|
|
||||||
int cycleCreditsUsed =
|
|
||||||
summary.unlimited
|
|
||||||
? 0
|
|
||||||
: (summary.cycleCreditsAllocated - summary.cycleCreditsRemaining);
|
|
||||||
|
|
||||||
UsageSummary usage =
|
|
||||||
new UsageSummary(
|
|
||||||
cycleCreditsUsed,
|
|
||||||
summary.totalBoughtCredits - summary.boughtCreditsRemaining,
|
|
||||||
summary.totalAvailableCredits,
|
|
||||||
summary.unlimited);
|
|
||||||
|
|
||||||
return ResponseEntity.ok(usage);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolves the current authentication to a credit summary, handling JWT and API-key auth. */
|
|
||||||
private CreditSummary getCreditSummaryForAuthentication(Authentication authentication) {
|
|
||||||
if (authentication instanceof EnhancedJwtAuthenticationToken enhancedJwt) {
|
|
||||||
return creditService.getCreditSummaryBySupabaseId(enhancedJwt.getSupabaseId());
|
|
||||||
}
|
|
||||||
if (authentication instanceof ApiKeyAuthenticationToken apiKeyToken) {
|
|
||||||
String apiKey = (String) apiKeyToken.getCredentials();
|
|
||||||
// Principal is the resolved User entity (per SupabaseAuthenticationFilter). Prefer the
|
|
||||||
// linked Supabase ID; fall back to API-key-keyed credits if there's no supabase link
|
|
||||||
// or no User row (e.g. legacy API-key-only deployments).
|
|
||||||
if (apiKeyToken.getPrincipal() instanceof User user && user.getSupabaseId() != null) {
|
|
||||||
return creditService.getCreditSummaryBySupabaseId(user.getSupabaseId().toString());
|
|
||||||
}
|
|
||||||
return creditService.getCreditSummaryByApiKey(apiKey);
|
|
||||||
}
|
|
||||||
return creditService.getCreditSummaryBySupabaseId(authentication.getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class UsageSummary {
|
|
||||||
public final int cycleCreditsUsed;
|
|
||||||
public final int boughtCreditsUsed;
|
|
||||||
public final int creditsRemaining;
|
|
||||||
public final boolean unlimited;
|
|
||||||
|
|
||||||
public UsageSummary(
|
|
||||||
int cycleCreditsUsed,
|
|
||||||
int boughtCreditsUsed,
|
|
||||||
int creditsRemaining,
|
|
||||||
boolean unlimited) {
|
|
||||||
this.cycleCreditsUsed = cycleCreditsUsed;
|
|
||||||
this.boughtCreditsUsed = boughtCreditsUsed;
|
|
||||||
this.creditsRemaining = creditsRemaining;
|
|
||||||
this.unlimited = unlimited;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
package stirling.software.saas.interceptor;
|
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.core.annotation.Order;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
|
|
||||||
import io.micrometer.core.instrument.Counter;
|
|
||||||
import io.micrometer.core.instrument.MeterRegistry;
|
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
|
||||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
|
||||||
import stirling.software.proprietary.security.model.User;
|
|
||||||
import stirling.software.saas.model.CreditConsumptionResult;
|
|
||||||
import stirling.software.saas.service.CreditService;
|
|
||||||
import stirling.software.saas.service.ErrorTrackingService;
|
|
||||||
import stirling.software.saas.service.SaasTeamExtensionService;
|
|
||||||
import stirling.software.saas.service.TeamCreditService;
|
|
||||||
import stirling.software.saas.util.AuthenticationUtils;
|
|
||||||
import stirling.software.saas.util.CreditHeaderUtils;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scoped to controllers annotated with {@link AutoJobPostMapping} so it doesn't hijack the global
|
|
||||||
* exception flow.
|
|
||||||
*/
|
|
||||||
// Legacy credit-billing error advice. PAYG handles its own error semantics via
|
|
||||||
// PaygChargeInterceptor — disabled by default in saas, activate legacy-credits profile if needed.
|
|
||||||
@RestControllerAdvice(annotations = AutoJobPostMapping.class)
|
|
||||||
@Profile("saas & legacy-credits")
|
|
||||||
@Slf4j
|
|
||||||
@Order(1)
|
|
||||||
public class CreditErrorAdvice {
|
|
||||||
|
|
||||||
private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE";
|
|
||||||
private static final String ATTR_APIKEY = "CREDIT_API_KEY";
|
|
||||||
private static final String ATTR_CHARGED = "CREDIT_CHARGED";
|
|
||||||
private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
|
|
||||||
|
|
||||||
private final CreditService creditService;
|
|
||||||
private final TeamCreditService teamCreditService;
|
|
||||||
private final UserRepository userRepository;
|
|
||||||
private final ErrorTrackingService errorTrackingService;
|
|
||||||
private final SaasTeamExtensionService saasTeamExtensionService;
|
|
||||||
private final CreditHeaderUtils creditHeaderUtils;
|
|
||||||
private final Counter creditsConsumedCounter;
|
|
||||||
// Inlined: Stirling's parent build uses Jackson 3 (tools.jackson), no Jackson 2 ObjectMapper
|
|
||||||
// bean in the context. Stateless usage, so a fresh instance is fine.
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
|
||||||
|
|
||||||
public CreditErrorAdvice(
|
|
||||||
CreditService creditService,
|
|
||||||
TeamCreditService teamCreditService,
|
|
||||||
UserRepository userRepository,
|
|
||||||
ErrorTrackingService errorTrackingService,
|
|
||||||
SaasTeamExtensionService saasTeamExtensionService,
|
|
||||||
CreditHeaderUtils creditHeaderUtils,
|
|
||||||
MeterRegistry meterRegistry) {
|
|
||||||
this.creditService = creditService;
|
|
||||||
this.teamCreditService = teamCreditService;
|
|
||||||
this.userRepository = userRepository;
|
|
||||||
this.errorTrackingService = errorTrackingService;
|
|
||||||
this.saasTeamExtensionService = saasTeamExtensionService;
|
|
||||||
this.creditHeaderUtils = creditHeaderUtils;
|
|
||||||
this.creditsConsumedCounter =
|
|
||||||
Counter.builder("credits.consumed")
|
|
||||||
.description("Number of credits actually consumed")
|
|
||||||
.tag("source", "error")
|
|
||||||
.register(meterRegistry);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ExceptionHandler(Throwable.class)
|
|
||||||
public ResponseEntity<Object> handleThrowable(HttpServletRequest request, Throwable ex) {
|
|
||||||
HttpStatus status = determineHttpStatus(ex);
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] CreditErrorAdvice: Handling exception: {} -> {}",
|
|
||||||
ex.getClass().getSimpleName(),
|
|
||||||
status);
|
|
||||||
|
|
||||||
String message = Optional.ofNullable(ex.getMessage()).orElse("An error occurred");
|
|
||||||
// Build error body
|
|
||||||
Map<String, Object> body = new HashMap<>();
|
|
||||||
body.put("error", ex.getClass().getSimpleName());
|
|
||||||
body.put("message", message);
|
|
||||||
body.put("status", status.value());
|
|
||||||
|
|
||||||
var builder = ResponseEntity.status(status);
|
|
||||||
|
|
||||||
// Handle credit consumption for errors
|
|
||||||
if (Boolean.TRUE.equals(request.getAttribute(ATTR_ELIGIBLE))
|
|
||||||
&& request.getAttribute(ATTR_CHARGED) == null) {
|
|
||||||
|
|
||||||
var apiKey = (String) request.getAttribute(ATTR_APIKEY);
|
|
||||||
var resourceWeight = (Integer) request.getAttribute(ATTR_RESOURCE_WEIGHT);
|
|
||||||
var isApiRequest = (Boolean) request.getAttribute("IS_API_REQUEST");
|
|
||||||
int creditAmount = resourceWeight != null ? resourceWeight : 1;
|
|
||||||
|
|
||||||
String identifierForErrorTracking =
|
|
||||||
apiKey; // Keep using apiKey/username for error tracking
|
|
||||||
if (apiKey != null
|
|
||||||
&& errorTrackingService.recordErrorAndShouldConsumeCredit(
|
|
||||||
identifierForErrorTracking,
|
|
||||||
request.getRequestURI(),
|
|
||||||
ex,
|
|
||||||
status.value())) {
|
|
||||||
|
|
||||||
// Get current user
|
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
||||||
User user = null;
|
|
||||||
try {
|
|
||||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn(
|
|
||||||
"[CREDIT-DEBUG] CreditErrorAdvice: Could not get user for team check: {}",
|
|
||||||
e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user == null) {
|
|
||||||
log.error(
|
|
||||||
"[CREDIT-DEBUG] CreditErrorAdvice: Unable to resolve user - skipping credit consumption");
|
|
||||||
} else {
|
|
||||||
// Check if user is in a non-personal team (must match UnifiedCreditInterceptor
|
|
||||||
// logic)
|
|
||||||
Long targetTeamId = null;
|
|
||||||
if (user.getTeam() != null
|
|
||||||
&& !saasTeamExtensionService.isPersonal(user.getTeam())) {
|
|
||||||
targetTeamId = user.getTeam().getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean consumed = false;
|
|
||||||
String creditSource = null;
|
|
||||||
|
|
||||||
if (targetTeamId != null) {
|
|
||||||
// User is in a non-personal team - consume from team credit pool
|
|
||||||
consumed = teamCreditService.consumeCredit(targetTeamId, creditAmount);
|
|
||||||
creditSource = "TEAM_CREDITS";
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] CreditErrorAdvice: Consumed {} credits from team {}",
|
|
||||||
creditAmount,
|
|
||||||
targetTeamId);
|
|
||||||
} else {
|
|
||||||
// No team - use waterfall logic for individual credits
|
|
||||||
boolean isApiRequestFlag = Boolean.TRUE.equals(isApiRequest);
|
|
||||||
CreditConsumptionResult result =
|
|
||||||
creditService.consumeCreditWithWaterfall(
|
|
||||||
user, creditAmount, isApiRequestFlag);
|
|
||||||
consumed = result.isSuccess();
|
|
||||||
creditSource = result.getSource();
|
|
||||||
|
|
||||||
if (!consumed) {
|
|
||||||
log.error(
|
|
||||||
"[CREDIT-DEBUG] CreditErrorAdvice: Credit consumption failed for user: {} - {}",
|
|
||||||
user.getUsername(),
|
|
||||||
result.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (consumed) {
|
|
||||||
request.setAttribute(ATTR_CHARGED, Boolean.TRUE);
|
|
||||||
creditsConsumedCounter.increment();
|
|
||||||
|
|
||||||
// Set remaining credits header
|
|
||||||
int remainingCredits =
|
|
||||||
creditHeaderUtils.getRemainingCredits(
|
|
||||||
user, creditService, teamCreditService);
|
|
||||||
if (remainingCredits >= 0) {
|
|
||||||
builder.header(
|
|
||||||
"X-Credits-Remaining", Integer.toString(remainingCredits));
|
|
||||||
log.warn(
|
|
||||||
"[CREDIT-HEADER] Added X-Credits-Remaining header: {}",
|
|
||||||
remainingCredits);
|
|
||||||
}
|
|
||||||
if (creditSource != null) {
|
|
||||||
builder.header("X-Credit-Source", creditSource);
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"[CREDIT-DEBUG] CreditErrorAdvice: {} credits consumed from {} for user: {} (error case)",
|
|
||||||
creditAmount,
|
|
||||||
creditSource,
|
|
||||||
user.getUsername());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] CreditErrorAdvice: ErrorTrackingService says do NOT consume credit for this error");
|
|
||||||
}
|
|
||||||
} else if (request.getAttribute(ATTR_CHARGED) != null) {
|
|
||||||
// Already charged, set header if user is authenticated
|
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
||||||
if (auth != null && auth.isAuthenticated()) {
|
|
||||||
try {
|
|
||||||
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
|
||||||
int remainingCredits =
|
|
||||||
creditHeaderUtils.getRemainingCredits(
|
|
||||||
user, creditService, teamCreditService);
|
|
||||||
if (remainingCredits >= 0) {
|
|
||||||
builder.header("X-Credits-Remaining", Integer.toString(remainingCredits));
|
|
||||||
log.warn(
|
|
||||||
"[CREDIT-HEADER] Added X-Credits-Remaining header: {}",
|
|
||||||
remainingCredits);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-HEADER] Could not add credits header for already charged error: {}",
|
|
||||||
e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.debug("[CREDIT-DEBUG] CreditErrorAdvice: Header set for already charged error");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSseRequest(request)) {
|
|
||||||
String payload = toJsonPayload(body);
|
|
||||||
String sseBody = "event: error\ndata: " + payload + "\n\n";
|
|
||||||
return builder.contentType(MediaType.TEXT_EVENT_STREAM).body(sseBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.body(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String maskApiKey(String apiKey) {
|
|
||||||
if (apiKey == null || apiKey.length() < 8) {
|
|
||||||
return "***";
|
|
||||||
}
|
|
||||||
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
private HttpStatus determineHttpStatus(Throwable throwable) {
|
|
||||||
// Map common exceptions to HTTP status codes
|
|
||||||
String exceptionClass = throwable.getClass().getSimpleName();
|
|
||||||
switch (exceptionClass) {
|
|
||||||
case "IllegalArgumentException":
|
|
||||||
case "ValidationException":
|
|
||||||
case "MethodArgumentNotValidException":
|
|
||||||
return HttpStatus.BAD_REQUEST;
|
|
||||||
case "AccessDeniedException":
|
|
||||||
return HttpStatus.FORBIDDEN;
|
|
||||||
case "UsernameNotFoundException":
|
|
||||||
return HttpStatus.UNAUTHORIZED;
|
|
||||||
case "HttpMessageNotReadableException":
|
|
||||||
return HttpStatus.BAD_REQUEST;
|
|
||||||
case "MaxUploadSizeExceededException":
|
|
||||||
return HttpStatus.PAYLOAD_TOO_LARGE;
|
|
||||||
case "UnsupportedOperationException":
|
|
||||||
return HttpStatus.NOT_IMPLEMENTED;
|
|
||||||
default:
|
|
||||||
// Check error message for clues
|
|
||||||
String message = throwable.getMessage();
|
|
||||||
if (message != null) {
|
|
||||||
if (message.toLowerCase().contains("validation")
|
|
||||||
|| message.toLowerCase().contains("invalid parameter")) {
|
|
||||||
return HttpStatus.BAD_REQUEST;
|
|
||||||
}
|
|
||||||
if (message.toLowerCase().contains("not found")) {
|
|
||||||
return HttpStatus.NOT_FOUND;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return HttpStatus.INTERNAL_SERVER_ERROR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isSseRequest(HttpServletRequest request) {
|
|
||||||
String accept = request.getHeader("Accept");
|
|
||||||
if (accept != null && accept.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
String contentType = request.getContentType();
|
|
||||||
return contentType != null && contentType.contains(MediaType.TEXT_EVENT_STREAM_VALUE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String toJsonPayload(Map<String, Object> payload) {
|
|
||||||
try {
|
|
||||||
return objectMapper.writeValueAsString(payload);
|
|
||||||
} catch (JsonProcessingException exc) {
|
|
||||||
log.warn("Failed to serialize SSE error payload, falling back to string", exc);
|
|
||||||
String message = payload.getOrDefault("message", "An error occurred").toString();
|
|
||||||
return "{\"error\":\"Error\",\"message\":\"" + message + "\",\"status\":500}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class ErrorResponse {
|
|
||||||
public final String error;
|
|
||||||
public final String message;
|
|
||||||
public final int status;
|
|
||||||
|
|
||||||
public ErrorResponse(String error, String message, int status) {
|
|
||||||
this.error = error;
|
|
||||||
this.message = message;
|
|
||||||
this.status = status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
package stirling.software.saas.interceptor;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.core.MethodParameter;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.http.converter.HttpMessageConverter;
|
|
||||||
import org.springframework.http.server.ServerHttpRequest;
|
|
||||||
import org.springframework.http.server.ServerHttpResponse;
|
|
||||||
import org.springframework.http.server.ServletServerHttpRequest;
|
|
||||||
import org.springframework.http.server.ServletServerHttpResponse;
|
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
|
|
||||||
|
|
||||||
import io.micrometer.core.instrument.Counter;
|
|
||||||
import io.micrometer.core.instrument.MeterRegistry;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
|
||||||
import stirling.software.proprietary.security.model.User;
|
|
||||||
import stirling.software.saas.model.CreditConsumptionResult;
|
|
||||||
import stirling.software.saas.service.CreditService;
|
|
||||||
import stirling.software.saas.service.SaasTeamExtensionService;
|
|
||||||
import stirling.software.saas.service.TeamCreditService;
|
|
||||||
import stirling.software.saas.util.AuthenticationUtils;
|
|
||||||
import stirling.software.saas.util.CreditHeaderUtils;
|
|
||||||
|
|
||||||
// Legacy credit-billing success advice. PAYG writes its own ledger entries via
|
|
||||||
// JobChargeService — disabled by default in saas, activate legacy-credits profile if needed.
|
|
||||||
@RestControllerAdvice
|
|
||||||
@Profile("saas & legacy-credits")
|
|
||||||
@Slf4j
|
|
||||||
public class CreditSuccessAdvice implements ResponseBodyAdvice<Object> {
|
|
||||||
|
|
||||||
private static final String ATTR_ELIGIBLE = "CREDIT_ELIGIBLE";
|
|
||||||
private static final String ATTR_APIKEY = "CREDIT_API_KEY";
|
|
||||||
private static final String ATTR_CHARGED = "CREDIT_CHARGED";
|
|
||||||
private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
|
|
||||||
|
|
||||||
private final CreditService creditService;
|
|
||||||
private final TeamCreditService teamCreditService;
|
|
||||||
private final UserRepository userRepository;
|
|
||||||
private final SaasTeamExtensionService saasTeamExtensionService;
|
|
||||||
private final CreditHeaderUtils creditHeaderUtils;
|
|
||||||
private final Counter creditsConsumedCounter;
|
|
||||||
|
|
||||||
public CreditSuccessAdvice(
|
|
||||||
CreditService creditService,
|
|
||||||
TeamCreditService teamCreditService,
|
|
||||||
UserRepository userRepository,
|
|
||||||
SaasTeamExtensionService saasTeamExtensionService,
|
|
||||||
CreditHeaderUtils creditHeaderUtils,
|
|
||||||
MeterRegistry meterRegistry) {
|
|
||||||
this.creditService = creditService;
|
|
||||||
this.teamCreditService = teamCreditService;
|
|
||||||
this.userRepository = userRepository;
|
|
||||||
this.saasTeamExtensionService = saasTeamExtensionService;
|
|
||||||
this.creditHeaderUtils = creditHeaderUtils;
|
|
||||||
this.creditsConsumedCounter =
|
|
||||||
Counter.builder("credits.consumed")
|
|
||||||
.description("Number of credits actually consumed")
|
|
||||||
.tag("source", "success")
|
|
||||||
.register(meterRegistry);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean supports(
|
|
||||||
MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
|
|
||||||
// Only REST bodies; this covers @ResponseBody and ResponseEntity
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Object beforeBodyWrite(
|
|
||||||
Object body,
|
|
||||||
MethodParameter returnType,
|
|
||||||
MediaType selectedContentType,
|
|
||||||
Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
|
||||||
ServerHttpRequest request,
|
|
||||||
ServerHttpResponse response) {
|
|
||||||
|
|
||||||
if (!(request instanceof ServletServerHttpRequest)) {
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
var servletReq = ((ServletServerHttpRequest) request).getServletRequest();
|
|
||||||
if (!Boolean.TRUE.equals(servletReq.getAttribute(ATTR_ELIGIBLE))) {
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (servletReq.getAttribute(ATTR_CHARGED) != null) {
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the handler returned an error ResponseEntity (>=400) without throwing,
|
|
||||||
// don't spend here; the error advice will decide.
|
|
||||||
int status = 200;
|
|
||||||
if (response instanceof ServletServerHttpResponse) {
|
|
||||||
status = ((ServletServerHttpResponse) response).getServletResponse().getStatus();
|
|
||||||
}
|
|
||||||
if (status >= 400) {
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] CreditSuccessAdvice: Error status {} detected, skipping credit consumption",
|
|
||||||
status);
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
var apiKey = (String) servletReq.getAttribute(ATTR_APIKEY);
|
|
||||||
var resourceWeight = (Integer) servletReq.getAttribute(ATTR_RESOURCE_WEIGHT);
|
|
||||||
var isApiRequest = (Boolean) servletReq.getAttribute("IS_API_REQUEST");
|
|
||||||
int creditAmount = resourceWeight != null ? resourceWeight : 1;
|
|
||||||
|
|
||||||
if (apiKey != null) {
|
|
||||||
// Get current user
|
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
||||||
User user = null;
|
|
||||||
try {
|
|
||||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn(
|
|
||||||
"[CREDIT-DEBUG] CreditSuccessAdvice: Could not get user for team check: {}",
|
|
||||||
e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user == null) {
|
|
||||||
log.error(
|
|
||||||
"[CREDIT-DEBUG] CreditSuccessAdvice: Unable to resolve user - skipping credit consumption");
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is in a non-personal team (must match UnifiedCreditInterceptor logic)
|
|
||||||
// IMPORTANT: Limited API users (anonymous, extra limited) always use personal credits,
|
|
||||||
// never team credits
|
|
||||||
boolean isLimitedApiUser =
|
|
||||||
auth.getAuthorities().stream()
|
|
||||||
.anyMatch(
|
|
||||||
authority ->
|
|
||||||
"ROLE_LIMITED_API_USER".equals(authority.getAuthority())
|
|
||||||
|| "ROLE_EXTRA_LIMITED_API_USER"
|
|
||||||
.equals(authority.getAuthority()));
|
|
||||||
Long targetTeamId = null;
|
|
||||||
if (!isLimitedApiUser
|
|
||||||
&& user.getTeam() != null
|
|
||||||
&& !saasTeamExtensionService.isPersonal(user.getTeam())) {
|
|
||||||
targetTeamId = user.getTeam().getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
final boolean consumed;
|
|
||||||
final String creditSource;
|
|
||||||
|
|
||||||
if (targetTeamId != null) {
|
|
||||||
// User is in a non-personal team - use waterfall with leader overage
|
|
||||||
CreditConsumptionResult result =
|
|
||||||
teamCreditService.consumeCreditWithWaterfall(targetTeamId, creditAmount);
|
|
||||||
consumed = result.isSuccess();
|
|
||||||
creditSource = result.getSource();
|
|
||||||
|
|
||||||
if (!consumed) {
|
|
||||||
log.error(
|
|
||||||
"[CREDIT-DEBUG] CreditSuccessAdvice: Team credit consumption failed:"
|
|
||||||
+ " {}",
|
|
||||||
result.getMessage());
|
|
||||||
} else {
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] CreditSuccessAdvice: Consumed {} credits from team {}"
|
|
||||||
+ " via {}",
|
|
||||||
creditAmount,
|
|
||||||
targetTeamId,
|
|
||||||
creditSource);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No team - use waterfall logic for individual credits
|
|
||||||
boolean isApiRequestFlag = Boolean.TRUE.equals(isApiRequest);
|
|
||||||
CreditConsumptionResult result =
|
|
||||||
creditService.consumeCreditWithWaterfall(
|
|
||||||
user, creditAmount, isApiRequestFlag);
|
|
||||||
consumed = result.isSuccess();
|
|
||||||
creditSource = result.getSource();
|
|
||||||
|
|
||||||
if (!consumed) {
|
|
||||||
log.error(
|
|
||||||
"[CREDIT-DEBUG] CreditSuccessAdvice: Credit consumption failed for user: {} - {}",
|
|
||||||
user.getUsername(),
|
|
||||||
result.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (consumed) {
|
|
||||||
servletReq.setAttribute(ATTR_CHARGED, Boolean.TRUE);
|
|
||||||
creditsConsumedCounter.increment();
|
|
||||||
|
|
||||||
// Set remaining credits header
|
|
||||||
int remainingCredits =
|
|
||||||
creditHeaderUtils.getRemainingCredits(
|
|
||||||
user, creditService, teamCreditService);
|
|
||||||
if (remainingCredits >= 0) {
|
|
||||||
response.getHeaders()
|
|
||||||
.set("X-Credits-Remaining", Integer.toString(remainingCredits));
|
|
||||||
log.warn(
|
|
||||||
"[CREDIT-HEADER] Added X-Credits-Remaining header: {}",
|
|
||||||
remainingCredits);
|
|
||||||
}
|
|
||||||
if (creditSource != null) {
|
|
||||||
response.getHeaders().set("X-Credit-Source", creditSource);
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"[CREDIT-DEBUG] CreditSuccessAdvice: {} credits consumed from {} for user: {}",
|
|
||||||
creditAmount,
|
|
||||||
creditSource,
|
|
||||||
user.getUsername());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.warn("[CREDIT-DEBUG] CreditSuccessAdvice: No apiKey attribute found");
|
|
||||||
}
|
|
||||||
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String maskApiKey(String apiKey) {
|
|
||||||
if (apiKey == null || apiKey.length() < 8) {
|
|
||||||
return "***";
|
|
||||||
}
|
|
||||||
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-493
@@ -1,493 +0,0 @@
|
|||||||
package stirling.software.saas.interceptor;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.security.core.Authentication;
|
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.web.method.HandlerMethod;
|
|
||||||
import org.springframework.web.servlet.AsyncHandlerInterceptor;
|
|
||||||
import org.springframework.web.servlet.ModelAndView;
|
|
||||||
|
|
||||||
import io.micrometer.core.instrument.Counter;
|
|
||||||
import io.micrometer.core.instrument.MeterRegistry;
|
|
||||||
import io.micrometer.core.instrument.Timer;
|
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
|
||||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
|
||||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
|
||||||
import stirling.software.proprietary.security.model.User;
|
|
||||||
import stirling.software.saas.config.CreditsProperties;
|
|
||||||
import stirling.software.saas.model.TeamCredit;
|
|
||||||
import stirling.software.saas.model.UserCredit;
|
|
||||||
import stirling.software.saas.repository.TeamMembershipRepository;
|
|
||||||
import stirling.software.saas.service.CreditService;
|
|
||||||
import stirling.software.saas.service.ErrorTrackingService;
|
|
||||||
import stirling.software.saas.service.SaasTeamExtensionService;
|
|
||||||
import stirling.software.saas.service.SaasUserExtensionService;
|
|
||||||
import stirling.software.saas.service.TeamCreditService;
|
|
||||||
import stirling.software.saas.util.AuthenticationUtils;
|
|
||||||
|
|
||||||
// Legacy credit-billing interceptor. PAYG replaces this with PaygChargeInterceptor — disabled
|
|
||||||
// by default in saas, activate legacy-credits profile to bring it back.
|
|
||||||
@Component
|
|
||||||
@Profile("saas & legacy-credits")
|
|
||||||
@Slf4j
|
|
||||||
public class UnifiedCreditInterceptor implements AsyncHandlerInterceptor {
|
|
||||||
|
|
||||||
private final CreditService creditService;
|
|
||||||
private final ErrorTrackingService errorTrackingService;
|
|
||||||
private final CreditsProperties creditsProperties;
|
|
||||||
private final UserRepository userRepository;
|
|
||||||
private final TeamCreditService teamCreditService;
|
|
||||||
private final TeamMembershipRepository membershipRepository;
|
|
||||||
private final SaasUserExtensionService saasUserExtensionService;
|
|
||||||
private final SaasTeamExtensionService saasTeamExtensionService;
|
|
||||||
|
|
||||||
private final Counter creditsCheckedCounter;
|
|
||||||
private final Counter creditsRejectedCounter;
|
|
||||||
private final Counter jwtBypassCounter;
|
|
||||||
private final Timer creditCheckTimer;
|
|
||||||
|
|
||||||
private static final String ATTR_CREDIT_ELIGIBLE = "CREDIT_ELIGIBLE";
|
|
||||||
private static final String ATTR_API_KEY = "CREDIT_API_KEY";
|
|
||||||
private static final String ATTR_RESOURCE_WEIGHT = "CREDIT_RESOURCE_WEIGHT";
|
|
||||||
private static final String ATTR_CHARGED = "CREDIT_CHARGED";
|
|
||||||
|
|
||||||
public UnifiedCreditInterceptor(
|
|
||||||
CreditService creditService,
|
|
||||||
ErrorTrackingService errorTrackingService,
|
|
||||||
CreditsProperties creditsProperties,
|
|
||||||
UserRepository userRepository,
|
|
||||||
TeamCreditService teamCreditService,
|
|
||||||
TeamMembershipRepository membershipRepository,
|
|
||||||
SaasUserExtensionService saasUserExtensionService,
|
|
||||||
SaasTeamExtensionService saasTeamExtensionService,
|
|
||||||
MeterRegistry meterRegistry) {
|
|
||||||
this.creditService = creditService;
|
|
||||||
this.errorTrackingService = errorTrackingService;
|
|
||||||
this.creditsProperties = creditsProperties;
|
|
||||||
this.userRepository = userRepository;
|
|
||||||
this.teamCreditService = teamCreditService;
|
|
||||||
this.membershipRepository = membershipRepository;
|
|
||||||
this.saasUserExtensionService = saasUserExtensionService;
|
|
||||||
this.saasTeamExtensionService = saasTeamExtensionService;
|
|
||||||
|
|
||||||
this.creditsCheckedCounter =
|
|
||||||
Counter.builder("credits.validation.checked")
|
|
||||||
.description("Number of requests that had credit validation performed")
|
|
||||||
.register(meterRegistry);
|
|
||||||
this.creditsRejectedCounter =
|
|
||||||
Counter.builder("credits.validation.rejected")
|
|
||||||
.description("Number of requests rejected due to insufficient credits")
|
|
||||||
.register(meterRegistry);
|
|
||||||
this.jwtBypassCounter =
|
|
||||||
Counter.builder("credits.validation.jwt_bypass")
|
|
||||||
.description("Number of JWT requests that bypassed credit validation")
|
|
||||||
.register(meterRegistry);
|
|
||||||
this.creditCheckTimer =
|
|
||||||
Timer.builder("credits.validation.duration")
|
|
||||||
.description("Time taken to validate credits")
|
|
||||||
.register(meterRegistry);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean preHandle(
|
|
||||||
HttpServletRequest request, HttpServletResponse response, Object handler)
|
|
||||||
throws Exception {
|
|
||||||
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] UnifiedCreditInterceptor.preHandle() - handler: {}",
|
|
||||||
handler.getClass().getSimpleName());
|
|
||||||
|
|
||||||
// Credits system disabled - allow all requests
|
|
||||||
if (!creditsProperties.isEnabled()) {
|
|
||||||
log.debug("[CREDIT-DEBUG] Credits system disabled - allowing request");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only apply to @AutoJobPostMapping endpoints and extract resource weight
|
|
||||||
if (!(handler instanceof HandlerMethod hm)
|
|
||||||
|| !hm.getMethod().isAnnotationPresent(AutoJobPostMapping.class)) {
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] Handler not eligible for credit validation (no @AutoJobPostMapping)");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
|
||||||
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] Authentication: {}",
|
|
||||||
auth != null ? auth.getClass().getSimpleName() : "null");
|
|
||||||
|
|
||||||
User currentUser = null;
|
|
||||||
|
|
||||||
// API key authentication always needs credit validation
|
|
||||||
if (auth instanceof ApiKeyAuthenticationToken) {
|
|
||||||
// API key users - proceed with normal credit validation
|
|
||||||
currentUser = (User) auth.getPrincipal();
|
|
||||||
} else if (auth != null && auth.isAuthenticated()) {
|
|
||||||
// JWT users - get user from authentication details
|
|
||||||
// JwtAuthenticationToken.getPrincipal() might not be a User object
|
|
||||||
// so we need to look up the user by the Supabase ID from auth.getName()
|
|
||||||
|
|
||||||
String supabaseId = AuthenticationUtils.extractSupabaseId(auth);
|
|
||||||
log.debug("[CREDIT-DEBUG] JWT authentication detected, Supabase ID: {}", supabaseId);
|
|
||||||
|
|
||||||
// Look up the User object that should exist (authentication succeeded)
|
|
||||||
try {
|
|
||||||
java.util.UUID supabaseUuid = java.util.UUID.fromString(supabaseId);
|
|
||||||
java.util.Optional<User> userOpt = userRepository.findBySupabaseId(supabaseUuid);
|
|
||||||
if (userOpt.isEmpty()) {
|
|
||||||
log.error(
|
|
||||||
"[CREDIT-DEBUG] JWT authenticated but no User found for Supabase ID: {}",
|
|
||||||
supabaseId);
|
|
||||||
response.setStatus(500);
|
|
||||||
response.setContentType("application/json");
|
|
||||||
response.getWriter()
|
|
||||||
.write(
|
|
||||||
"{\"error\":\"USER_NOT_FOUND\",\"message\":\"Authenticated user not found in database\",\"status\":500}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
currentUser = userOpt.get();
|
|
||||||
|
|
||||||
if (shouldApplyCreditsToJwtUser(currentUser)) {
|
|
||||||
// Anonymous users or other limited JWT users should consume credits
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] JWT user {} subject to credit validation due to limited role",
|
|
||||||
currentUser.getUsername());
|
|
||||||
} else {
|
|
||||||
jwtBypassCounter.increment();
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] JWT user {} bypassing credit validation (unlimited role)",
|
|
||||||
currentUser.getUsername());
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
log.error("[CREDIT-DEBUG] Invalid Supabase ID format: {}", supabaseId);
|
|
||||||
response.setStatus(400);
|
|
||||||
response.setContentType("application/json");
|
|
||||||
response.getWriter()
|
|
||||||
.write(
|
|
||||||
"{\"error\":\"INVALID_USER_ID\",\"message\":\"Invalid user identifier format\",\"status\":400}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// SECURITY: Block all non-authenticated requests
|
|
||||||
log.warn(
|
|
||||||
"[CREDIT-DEBUG] Non-authenticated request blocked - authentication required for credit-controlled endpoints");
|
|
||||||
response.setStatus(401); // 401 Unauthorized
|
|
||||||
response.setContentType("application/json");
|
|
||||||
response.getWriter()
|
|
||||||
.write(
|
|
||||||
"{\"error\":\"AUTHENTICATION_REQUIRED\",\"message\":\"Authentication required to access this endpoint\",\"status\":401}");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract resource weight from annotation
|
|
||||||
AutoJobPostMapping annotation = hm.getMethod().getAnnotation(AutoJobPostMapping.class);
|
|
||||||
int resourceWeight =
|
|
||||||
Math.max(1, Math.min(100, annotation.resourceWeight())); // Clamp to 1-100
|
|
||||||
|
|
||||||
String apiKey = getApiKeyForUser(auth, currentUser);
|
|
||||||
String maskedApiKey = maskApiKey(apiKey);
|
|
||||||
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] Credit validation for user: {}, API key: {}, resource weight: {}",
|
|
||||||
currentUser.getUsername(),
|
|
||||||
maskedApiKey,
|
|
||||||
resourceWeight);
|
|
||||||
|
|
||||||
// Track that we're performing credit validation
|
|
||||||
creditsCheckedCounter.increment();
|
|
||||||
|
|
||||||
// Check if user has SUFFICIENT credits for this operation (with timing)
|
|
||||||
Timer.Sample sample = Timer.start();
|
|
||||||
boolean hasSufficientCredits;
|
|
||||||
int availableCredits = 0;
|
|
||||||
|
|
||||||
// Check if user is a limited API user (anonymous, extra limited)
|
|
||||||
// Limited API users always use personal credits, never team credits
|
|
||||||
boolean isLimitedApiUser =
|
|
||||||
currentUser.getAuthorities().stream()
|
|
||||||
.anyMatch(
|
|
||||||
authority ->
|
|
||||||
"ROLE_LIMITED_API_USER".equals(authority.getAuthority())
|
|
||||||
|| "ROLE_EXTRA_LIMITED_API_USER"
|
|
||||||
.equals(authority.getAuthority()));
|
|
||||||
|
|
||||||
if (auth instanceof ApiKeyAuthenticationToken) {
|
|
||||||
// API key auth - get credit balance
|
|
||||||
java.util.Optional<UserCredit> userCreditsOpt =
|
|
||||||
creditService.getUserCreditsByApiKey(apiKey);
|
|
||||||
availableCredits = userCreditsOpt.map(UserCredit::getTotalAvailableCredits).orElse(0);
|
|
||||||
hasSufficientCredits = availableCredits >= resourceWeight;
|
|
||||||
} else {
|
|
||||||
// JWT user - check team credits if user is in a non-personal team, otherwise personal
|
|
||||||
// credits
|
|
||||||
Long teamId = null;
|
|
||||||
if (!isLimitedApiUser
|
|
||||||
&& currentUser.getTeam() != null
|
|
||||||
&& !saasTeamExtensionService.isPersonal(currentUser.getTeam())) {
|
|
||||||
teamId = currentUser.getTeam().getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (teamId != null) {
|
|
||||||
// User is in a non-personal team - check team credits + leader overage billing
|
|
||||||
java.util.Optional<TeamCredit> teamCredits =
|
|
||||||
teamCreditService.getTeamCredits(teamId);
|
|
||||||
availableCredits = teamCredits.map(TeamCredit::getTotalAvailableCredits).orElse(0);
|
|
||||||
|
|
||||||
// Check if sufficient credits OR team leader has metered billing
|
|
||||||
boolean hasTeamCredits = availableCredits >= resourceWeight;
|
|
||||||
boolean leaderHasMetered = checkTeamLeaderMeteredBilling(currentUser.getTeam());
|
|
||||||
|
|
||||||
hasSufficientCredits = hasTeamCredits || leaderHasMetered;
|
|
||||||
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] Checking team {} credits for user {}: available={}"
|
|
||||||
+ " required={} hasCredits={} leaderMetered={} sufficient={}",
|
|
||||||
teamId,
|
|
||||||
currentUser.getUsername(),
|
|
||||||
availableCredits,
|
|
||||||
resourceWeight,
|
|
||||||
hasTeamCredits,
|
|
||||||
leaderHasMetered,
|
|
||||||
hasSufficientCredits);
|
|
||||||
} else {
|
|
||||||
// Personal team or no team - check personal credits
|
|
||||||
UserCredit userCredits = creditService.getOrCreateUserCredits(currentUser);
|
|
||||||
availableCredits = userCredits.getTotalAvailableCredits();
|
|
||||||
hasSufficientCredits = availableCredits >= resourceWeight;
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] Checking personal credits for user {}: available={} required={} sufficient={}",
|
|
||||||
currentUser.getUsername(),
|
|
||||||
availableCredits,
|
|
||||||
resourceWeight,
|
|
||||||
hasSufficientCredits);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sample.stop(creditCheckTimer);
|
|
||||||
|
|
||||||
// Check if user has metered billing enabled (they can use overage credits even with
|
|
||||||
// insufficient free credits)
|
|
||||||
boolean hasMeteredBilling = saasUserExtensionService.isMeteredBillingEnabled(currentUser);
|
|
||||||
|
|
||||||
if (!hasSufficientCredits && !hasMeteredBilling) {
|
|
||||||
creditsRejectedCounter.increment();
|
|
||||||
|
|
||||||
// Enhanced message for team members
|
|
||||||
// Note: Limited API users always use personal credits, so they get personal message
|
|
||||||
String message;
|
|
||||||
if (!isLimitedApiUser
|
|
||||||
&& currentUser.getTeam() != null
|
|
||||||
&& !saasTeamExtensionService.isPersonal(currentUser.getTeam())) {
|
|
||||||
message =
|
|
||||||
"Insufficient team credits. Team leader must enable overage billing for"
|
|
||||||
+ " uninterrupted service.";
|
|
||||||
} else {
|
|
||||||
message =
|
|
||||||
"Insufficient API credits. Please purchase more credits or wait for your"
|
|
||||||
+ " monthly cycle credits to reset.";
|
|
||||||
}
|
|
||||||
|
|
||||||
log.warn(
|
|
||||||
"[CREDIT-DEBUG] Credit validation rejected - Method: {}, URI: {}, IP: {},"
|
|
||||||
+ " User-Agent: {}, User: {}, Supabase ID: {}, Reason: {}",
|
|
||||||
request.getMethod(),
|
|
||||||
request.getRequestURI(),
|
|
||||||
getClientIpAddress(request),
|
|
||||||
request.getHeader("User-Agent"),
|
|
||||||
currentUser.getUsername(),
|
|
||||||
currentUser.getSupabaseId(),
|
|
||||||
message);
|
|
||||||
|
|
||||||
response.setStatus(429); // 429 Too Many Requests
|
|
||||||
response.setContentType("application/json");
|
|
||||||
response.getWriter()
|
|
||||||
.write(
|
|
||||||
String.format(
|
|
||||||
"{\"error\":\"INSUFFICIENT_CREDITS\",\"message\":\"%s\",\"status\":429}",
|
|
||||||
message));
|
|
||||||
response.getWriter().flush();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log when metered billing users are using overage credits
|
|
||||||
if (!hasSufficientCredits && hasMeteredBilling) {
|
|
||||||
log.info(
|
|
||||||
"[CREDIT-DEBUG] Metered billing user {} proceeding with insufficient free credits (have: {}, need: {}) - will use overage credits (billed monthly)",
|
|
||||||
currentUser.getUsername(),
|
|
||||||
availableCredits,
|
|
||||||
resourceWeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark request as eligible for credit consumption
|
|
||||||
request.setAttribute(ATTR_CREDIT_ELIGIBLE, Boolean.TRUE);
|
|
||||||
request.setAttribute(ATTR_API_KEY, apiKey);
|
|
||||||
request.setAttribute(ATTR_RESOURCE_WEIGHT, resourceWeight);
|
|
||||||
|
|
||||||
// Store whether this is API key or JWT authentication for advice classes
|
|
||||||
boolean isApiKeyAuth = auth instanceof ApiKeyAuthenticationToken;
|
|
||||||
request.setAttribute("IS_API_KEY_AUTH", isApiKeyAuth);
|
|
||||||
|
|
||||||
// Store IS_API_REQUEST for waterfall logic (API key requests always consume credits)
|
|
||||||
request.setAttribute("IS_API_REQUEST", isApiKeyAuth);
|
|
||||||
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] Credit validation passed - request marked as eligible for consumption (will consume after success/error)");
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void postHandle(
|
|
||||||
HttpServletRequest request,
|
|
||||||
HttpServletResponse response,
|
|
||||||
Object handler,
|
|
||||||
ModelAndView modelAndView)
|
|
||||||
throws Exception {
|
|
||||||
// Success path now handled by CreditSuccessAdvice - no spending in postHandle anymore
|
|
||||||
log.debug("[CREDIT-DEBUG] postHandle: Success path will be handled by CreditSuccessAdvice");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void afterCompletion(
|
|
||||||
HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
|
|
||||||
throws Exception {
|
|
||||||
// Error path now handled by CreditErrorAdvice - no spending in afterCompletion anymore
|
|
||||||
if (ex != null) {
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] afterCompletion: Error path will be handled by CreditErrorAdvice: {}",
|
|
||||||
ex.getClass().getSimpleName());
|
|
||||||
} else {
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] afterCompletion: Success path already handled by CreditSuccessAdvice");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void afterConcurrentHandlingStarted(
|
|
||||||
HttpServletRequest request, HttpServletResponse response, Object handler)
|
|
||||||
throws Exception {
|
|
||||||
// For async requests (Callable, DeferredResult, etc.), prevent duplicate processing
|
|
||||||
// The actual postHandle/afterCompletion will be called when async processing completes
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] afterConcurrentHandlingStarted: Async processing started - skipping interceptor logic");
|
|
||||||
}
|
|
||||||
|
|
||||||
private String maskApiKey(String apiKey) {
|
|
||||||
if (apiKey == null || apiKey.length() < 8) {
|
|
||||||
return "***";
|
|
||||||
}
|
|
||||||
return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getClientIpAddress(HttpServletRequest request) {
|
|
||||||
String xForwardedFor = request.getHeader("X-Forwarded-For");
|
|
||||||
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
|
|
||||||
return xForwardedFor.split(",")[0].trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
String xRealIp = request.getHeader("X-Real-IP");
|
|
||||||
if (xRealIp != null && !xRealIp.isEmpty()) {
|
|
||||||
return xRealIp;
|
|
||||||
}
|
|
||||||
|
|
||||||
return request.getRemoteAddr();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines if credit limits should apply to a JWT user.
|
|
||||||
*
|
|
||||||
* <p>Rules:
|
|
||||||
*
|
|
||||||
* <ul>
|
|
||||||
* <li>Metered billing users: always consume (free tier first, then report overage to Stripe)
|
|
||||||
* <li>Anonymous users: consume credits (web/API)
|
|
||||||
* <li>Regular users: consume credits (web/API)
|
|
||||||
* <li>Pro users: unlimited on web UI (waterfall logic handles this), but subject to checks
|
|
||||||
* <li>API users: always consume credits
|
|
||||||
* <li>Internal API users: unlimited everywhere
|
|
||||||
* <li>Admin users: unlimited everywhere
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
private boolean shouldApplyCreditsToJwtUser(User user) {
|
|
||||||
String roles = user.getRolesAsString();
|
|
||||||
|
|
||||||
// Internal API users are unlimited everywhere (for backend internal operations)
|
|
||||||
if (roles.contains("STIRLING-PDF-BACKEND-API-USER")) {
|
|
||||||
log.debug("[CREDIT-DEBUG] Internal API user {} - unlimited usage", user.getUsername());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pro users: Let them through to waterfall logic
|
|
||||||
// (Pro gets unlimited UI but API still consumes credits)
|
|
||||||
if (roles.contains("ROLE_PRO_USER")) {
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] Pro user {} - will be handled by waterfall logic",
|
|
||||||
user.getUsername());
|
|
||||||
return true; // Changed from false - let waterfall handle Pro exemption
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin users are unlimited everywhere
|
|
||||||
if (roles.contains("ROLE_ADMIN")) {
|
|
||||||
log.debug("[CREDIT-DEBUG] Admin user {} - unlimited usage", user.getUsername());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// All other users (anonymous, regular, limited API users, metered billing) consume credits
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-DEBUG] User {} with roles {} - subject to credit limits",
|
|
||||||
user.getUsername(),
|
|
||||||
roles);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the identifier for credit consumption. For API key users, use their actual API key. For
|
|
||||||
* JWT users, use the Supabase ID as identifier (auth.getName() returns Supabase ID).
|
|
||||||
*/
|
|
||||||
private String getApiKeyForUser(Authentication auth, User user) {
|
|
||||||
if (auth instanceof ApiKeyAuthenticationToken) {
|
|
||||||
return user.getApiKey();
|
|
||||||
} else {
|
|
||||||
// For JWT users, return Supabase ID as the credit consumption identifier
|
|
||||||
return AuthenticationUtils.extractSupabaseId(auth);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if team leader has metered billing enabled. This allows teams to use overage billing
|
|
||||||
* when team credits are exhausted.
|
|
||||||
*
|
|
||||||
* @param team the team to check
|
|
||||||
* @return true if team leader has metered billing enabled
|
|
||||||
*/
|
|
||||||
private boolean checkTeamLeaderMeteredBilling(stirling.software.proprietary.model.Team team) {
|
|
||||||
if (team == null || team.getId() == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
java.util.List<stirling.software.saas.model.TeamMembership> leaders =
|
|
||||||
membershipRepository.findByTeamIdAndRole(
|
|
||||||
team.getId(),
|
|
||||||
stirling.software.common.model.enumeration.TeamRole.LEADER);
|
|
||||||
|
|
||||||
if (leaders.isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
User leader = leaders.get(0).getUser();
|
|
||||||
return saasUserExtensionService.isMeteredBillingEnabled(leader);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Error checking team leader metered billing: {}", e.getMessage());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package stirling.software.saas.model;
|
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of a credit consumption attempt with explicit waterfall logic. Indicates whether the
|
|
||||||
* operation succeeded and which credit source was used.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@AllArgsConstructor
|
|
||||||
public class CreditConsumptionResult {
|
|
||||||
|
|
||||||
/** Whether the credit consumption succeeded */
|
|
||||||
private boolean success;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The credit source used for this operation. Possible values: "PRO_PLAN" (Pro user with
|
|
||||||
* unlimited UI access, no credits consumed); "CYCLE_CREDITS" (free monthly cycle credit
|
|
||||||
* allocation); "BOUGHT_CREDITS" (one-time purchased credits); "METERED_SUBSCRIPTION"
|
|
||||||
* (pay-what-you-use metered billing, reported to Stripe); null (operation failed; see message
|
|
||||||
* for reason).
|
|
||||||
*/
|
|
||||||
private String source;
|
|
||||||
|
|
||||||
/** Human-readable message about the result */
|
|
||||||
private String message;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a successful result for unlimited access (Pro plan UI requests).
|
|
||||||
*
|
|
||||||
* @param source The credit source (typically "PRO_PLAN")
|
|
||||||
* @return CreditConsumptionResult indicating unlimited access
|
|
||||||
*/
|
|
||||||
public static CreditConsumptionResult unlimited(String source) {
|
|
||||||
return new CreditConsumptionResult(true, source, "Unlimited access");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a successful result for credit consumption.
|
|
||||||
*
|
|
||||||
* @param source The credit source used
|
|
||||||
* @return CreditConsumptionResult indicating success
|
|
||||||
*/
|
|
||||||
public static CreditConsumptionResult success(String source) {
|
|
||||||
return new CreditConsumptionResult(true, source, "Credits consumed");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a failure result.
|
|
||||||
*
|
|
||||||
* @param reason The reason for failure
|
|
||||||
* @return CreditConsumptionResult indicating failure
|
|
||||||
*/
|
|
||||||
public static CreditConsumptionResult failure(String reason) {
|
|
||||||
return new CreditConsumptionResult(false, null, reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a failure result with custom message.
|
|
||||||
*
|
|
||||||
* @param reason The reason code
|
|
||||||
* @param message Custom human-readable message
|
|
||||||
* @return CreditConsumptionResult indicating failure
|
|
||||||
*/
|
|
||||||
public static CreditConsumptionResult failure(String reason, String message) {
|
|
||||||
return new CreditConsumptionResult(false, null, message != null ? message : reason);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package stirling.software.saas.model;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
|
||||||
import org.hibernate.annotations.OnDelete;
|
|
||||||
import org.hibernate.annotations.OnDeleteAction;
|
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.FetchType;
|
|
||||||
import jakarta.persistence.GeneratedValue;
|
|
||||||
import jakarta.persistence.GenerationType;
|
|
||||||
import jakarta.persistence.Id;
|
|
||||||
import jakarta.persistence.JoinColumn;
|
|
||||||
import jakarta.persistence.OneToOne;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import jakarta.persistence.Version;
|
|
||||||
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import lombok.Setter;
|
|
||||||
|
|
||||||
import stirling.software.proprietary.model.Team;
|
|
||||||
|
|
||||||
/** Shared credit pool for multi-member teams; see {@link UserCredit} for the per-user variant. */
|
|
||||||
@Entity
|
|
||||||
@Table(name = "team_credits")
|
|
||||||
@NoArgsConstructor
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
public class TeamCredit implements Serializable {
|
|
||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
@Column(name = "credit_id")
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@OneToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "team_id", nullable = false, unique = true)
|
|
||||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
|
||||||
private Team team;
|
|
||||||
|
|
||||||
@Column(name = "cycle_credits_remaining")
|
|
||||||
private Integer cycleCreditsRemaining = 0;
|
|
||||||
|
|
||||||
@Column(name = "cycle_credits_allocated")
|
|
||||||
private Integer cycleCreditsAllocated = 0;
|
|
||||||
|
|
||||||
@Column(name = "bought_credits_remaining")
|
|
||||||
private Integer boughtCreditsRemaining = 0;
|
|
||||||
|
|
||||||
@Column(name = "total_bought_credits")
|
|
||||||
private Integer totalBoughtCredits = 0;
|
|
||||||
|
|
||||||
@Column(name = "last_cycle_reset_at")
|
|
||||||
private LocalDateTime lastCycleResetAt;
|
|
||||||
|
|
||||||
@Column(name = "last_api_usage")
|
|
||||||
private LocalDateTime lastApiUsage;
|
|
||||||
|
|
||||||
@Column(name = "total_api_calls_made")
|
|
||||||
private Long totalApiCallsMade = 0L;
|
|
||||||
|
|
||||||
@CreationTimestamp
|
|
||||||
@Column(name = "created_at", updatable = false)
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
@UpdateTimestamp
|
|
||||||
@Column(name = "updated_at")
|
|
||||||
private LocalDateTime updatedAt;
|
|
||||||
|
|
||||||
@Version
|
|
||||||
@Column(name = "version")
|
|
||||||
private Long version;
|
|
||||||
|
|
||||||
public TeamCredit(Team team) {
|
|
||||||
this.team = team;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getTotalAvailableCredits() {
|
|
||||||
return (cycleCreditsRemaining != null ? cycleCreditsRemaining : 0)
|
|
||||||
+ (boughtCreditsRemaining != null ? boughtCreditsRemaining : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasCreditsAvailable() {
|
|
||||||
return getTotalAvailableCredits() > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Consume a credit from the team pool. Consumes cycle credits first, then bought credits.
|
|
||||||
*
|
|
||||||
* @return true if a credit was consumed, false if no credits available
|
|
||||||
*/
|
|
||||||
public boolean consumeCredit() {
|
|
||||||
if (cycleCreditsRemaining != null && cycleCreditsRemaining > 0) {
|
|
||||||
cycleCreditsRemaining--;
|
|
||||||
totalApiCallsMade++;
|
|
||||||
lastApiUsage = LocalDateTime.now();
|
|
||||||
return true;
|
|
||||||
} else if (boughtCreditsRemaining != null && boughtCreditsRemaining > 0) {
|
|
||||||
boughtCreditsRemaining--;
|
|
||||||
totalApiCallsMade++;
|
|
||||||
lastApiUsage = LocalDateTime.now();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void addBoughtCredits(int credits) {
|
|
||||||
if (credits > 0) {
|
|
||||||
boughtCreditsRemaining =
|
|
||||||
(boughtCreditsRemaining != null ? boughtCreditsRemaining : 0) + credits;
|
|
||||||
totalBoughtCredits = (totalBoughtCredits != null ? totalBoughtCredits : 0) + credits;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void resetCycleCredits(int cycleAllocation, LocalDateTime resetTime) {
|
|
||||||
this.cycleCreditsAllocated = cycleAllocation;
|
|
||||||
this.cycleCreditsRemaining = cycleAllocation;
|
|
||||||
this.lastCycleResetAt = resetTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isCycleResetDue(LocalDateTime lastScheduledReset) {
|
|
||||||
return lastCycleResetAt == null || lastCycleResetAt.isBefore(lastScheduledReset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
package stirling.software.saas.model;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
|
||||||
import org.hibernate.annotations.OnDelete;
|
|
||||||
import org.hibernate.annotations.OnDeleteAction;
|
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
|
||||||
import jakarta.persistence.Entity;
|
|
||||||
import jakarta.persistence.FetchType;
|
|
||||||
import jakarta.persistence.GeneratedValue;
|
|
||||||
import jakarta.persistence.GenerationType;
|
|
||||||
import jakarta.persistence.Id;
|
|
||||||
import jakarta.persistence.JoinColumn;
|
|
||||||
import jakarta.persistence.ManyToOne;
|
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import jakarta.persistence.Version;
|
|
||||||
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import lombok.Setter;
|
|
||||||
|
|
||||||
import stirling.software.proprietary.security.model.User;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-user credit pool. Layers a renewable monthly cycle pool ({@code cycleCreditsRemaining}) over
|
|
||||||
* a non-expiring purchased pool ({@code boughtCreditsRemaining}); cycle credits consume first.
|
|
||||||
*/
|
|
||||||
@Entity
|
|
||||||
@Table(name = "user_credits")
|
|
||||||
@NoArgsConstructor
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
public class UserCredit implements Serializable {
|
|
||||||
|
|
||||||
private static final long serialVersionUID = 1L;
|
|
||||||
|
|
||||||
@Id
|
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
|
||||||
@Column(name = "credit_id")
|
|
||||||
private Long id;
|
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
|
||||||
@JoinColumn(name = "user_id", nullable = false)
|
|
||||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
|
||||||
private User user;
|
|
||||||
|
|
||||||
@Column(name = "cycle_credits_remaining")
|
|
||||||
private Integer cycleCreditsRemaining = 0;
|
|
||||||
|
|
||||||
@Column(name = "cycle_credits_allocated")
|
|
||||||
private Integer cycleCreditsAllocated = 0;
|
|
||||||
|
|
||||||
@Column(name = "bought_credits_remaining")
|
|
||||||
private Integer boughtCreditsRemaining = 0;
|
|
||||||
|
|
||||||
@Column(name = "total_bought_credits")
|
|
||||||
private Integer totalBoughtCredits = 0;
|
|
||||||
|
|
||||||
@Column(name = "last_cycle_reset_at")
|
|
||||||
private LocalDateTime lastCycleResetAt;
|
|
||||||
|
|
||||||
@Column(name = "last_api_usage")
|
|
||||||
private LocalDateTime lastApiUsage;
|
|
||||||
|
|
||||||
@Column(name = "total_api_calls_made")
|
|
||||||
private Long totalApiCallsMade = 0L;
|
|
||||||
|
|
||||||
@CreationTimestamp
|
|
||||||
@Column(name = "created_at", updatable = false)
|
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
@UpdateTimestamp
|
|
||||||
@Column(name = "updated_at")
|
|
||||||
private LocalDateTime updatedAt;
|
|
||||||
|
|
||||||
@Version
|
|
||||||
@Column(name = "version")
|
|
||||||
private Long version;
|
|
||||||
|
|
||||||
public UserCredit(User user) {
|
|
||||||
this.user = user;
|
|
||||||
// Cycle credits are initialized by CreditService after this object is created,
|
|
||||||
// typically during user registration or at the start of a new billing cycle,
|
|
||||||
// using values from the application configuration.
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getTotalAvailableCredits() {
|
|
||||||
return (cycleCreditsRemaining != null ? cycleCreditsRemaining : 0)
|
|
||||||
+ (boughtCreditsRemaining != null ? boughtCreditsRemaining : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasCreditsAvailable() {
|
|
||||||
return getTotalAvailableCredits() > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean consumeCredit() {
|
|
||||||
// Consume cycle credits first, then bought credits.
|
|
||||||
if (cycleCreditsRemaining != null && cycleCreditsRemaining > 0) {
|
|
||||||
cycleCreditsRemaining--;
|
|
||||||
totalApiCallsMade++;
|
|
||||||
lastApiUsage = LocalDateTime.now();
|
|
||||||
return true;
|
|
||||||
} else if (boughtCreditsRemaining != null && boughtCreditsRemaining > 0) {
|
|
||||||
boughtCreditsRemaining--;
|
|
||||||
totalApiCallsMade++;
|
|
||||||
lastApiUsage = LocalDateTime.now();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void addBoughtCredits(int credits) {
|
|
||||||
if (credits > 0) {
|
|
||||||
boughtCreditsRemaining =
|
|
||||||
(boughtCreditsRemaining != null ? boughtCreditsRemaining : 0) + credits;
|
|
||||||
totalBoughtCredits = (totalBoughtCredits != null ? totalBoughtCredits : 0) + credits;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void resetCycleCredits(int cycleAllocation, LocalDateTime resetTime) {
|
|
||||||
this.cycleCreditsAllocated = cycleAllocation;
|
|
||||||
this.cycleCreditsRemaining = cycleAllocation;
|
|
||||||
this.lastCycleResetAt = resetTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isCycleResetDue(LocalDateTime lastScheduledReset) {
|
|
||||||
return lastCycleResetAt == null || lastCycleResetAt.isBefore(lastScheduledReset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -51,10 +51,9 @@ import stirling.software.saas.payg.wallet.WalletLedgerEntry;
|
|||||||
* The real-charging path lives in a separate follow-up and reuses the same orchestration — only the
|
* The real-charging path lives in a separate follow-up and reuses the same orchestration — only the
|
||||||
* side-effect (shadow row vs ledger entry + Stripe call) differs.
|
* side-effect (shadow row vs ledger entry + Stripe call) differs.
|
||||||
*
|
*
|
||||||
* <p>The {@code legacyCreditsCharged} field on the shadow row is set to {@code 0} here. When the
|
* <p>The {@code legacyCreditsCharged} field on the shadow row is set to {@code 0}: the legacy
|
||||||
* legacy {@code CreditService} is wired to call this service (separate PR), the legacy debit amount
|
* credit engine has been removed, so there is no legacy debit to compare against and {@code
|
||||||
* becomes available and {@code diffPct} can be computed against it; until then the shadow row
|
* diffPct} stays {@code 0}. The shadow row captures the PAYG units only.
|
||||||
* captures the PAYG units only.
|
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@Profile("saas")
|
@Profile("saas")
|
||||||
@@ -281,8 +280,7 @@ public class JobChargeService {
|
|||||||
// Free-vs-paid split fixed at charge time: paid (metered) = paygUnits - freeUnitsConsumed,
|
// Free-vs-paid split fixed at charge time: paid (metered) = paygUnits - freeUnitsConsumed,
|
||||||
// and a refund restores freeUnitsConsumed to the team's grant.
|
// and a refund restores freeUnitsConsumed to the team's grant.
|
||||||
row.setFreeUnitsConsumed(freeUnitsConsumed);
|
row.setFreeUnitsConsumed(freeUnitsConsumed);
|
||||||
// No legacy comparison yet — wired when the shadow path is connected to the legacy
|
// No legacy comparison: the legacy credit engine has been removed, so diff stays at 0.
|
||||||
// CreditService in the follow-up PR. Until then, diff stays at 0.
|
|
||||||
row.setLegacyCreditsCharged(0);
|
row.setLegacyCreditsCharged(0);
|
||||||
row.setDiffPct(0);
|
row.setDiffPct(0);
|
||||||
row.setStatus(ShadowChargeStatus.CHARGED);
|
row.setStatus(ShadowChargeStatus.CHARGED);
|
||||||
|
|||||||
+1
-3
@@ -53,9 +53,7 @@ import stirling.software.saas.payg.model.ProcessType;
|
|||||||
import stirling.software.saas.util.AuthenticationUtils;
|
import stirling.software.saas.util.AuthenticationUtils;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The hot-path PAYG interceptor. Mirrors the {@code UnifiedCreditInterceptor} shape: registered
|
* The hot-path PAYG interceptor, registered in {@code PaygWebMvcConfig}.
|
||||||
* after it in {@code PaygWebMvcConfig} so legacy credit-rejection short-circuits before we waste
|
|
||||||
* work hashing inputs.
|
|
||||||
*
|
*
|
||||||
* <p>{@code preHandle}: gates on {@code @AutoJobPostMapping} OR {@code @RequiresFeature} (the
|
* <p>{@code preHandle}: gates on {@code @AutoJobPostMapping} OR {@code @RequiresFeature} (the
|
||||||
* latter lets AI controllers — JSON-bodied, no AutoJobPostMapping — bill correctly), reads the
|
* latter lets AI controllers — JSON-bodied, no AutoJobPostMapping — bill correctly), reads the
|
||||||
|
|||||||
@@ -18,10 +18,8 @@ import stirling.software.saas.payg.entitlement.EntitlementGuard;
|
|||||||
* <li>{@link PaygResponseBodyWrapperFilter} as a Servlet filter — registered with no explicit
|
* <li>{@link PaygResponseBodyWrapperFilter} as a Servlet filter — registered with no explicit
|
||||||
* order so it sits at the end of the Spring filter chain (after all security filters). Pure
|
* order so it sits at the end of the Spring filter chain (after all security filters). Pure
|
||||||
* response-wrapping plumbing.
|
* response-wrapping plumbing.
|
||||||
* <li>{@link PaygChargeInterceptor} as a Spring MVC interceptor — registered AFTER {@code
|
* <li>{@link PaygChargeInterceptor} as a Spring MVC interceptor — intercepts {@code /api/**} with
|
||||||
* UnifiedCreditInterceptor} so legacy credit rejections short-circuit before we hash inputs.
|
* admin/info/health exclusions.
|
||||||
* Both intercept {@code /api/**} with the same admin/info/health exclusions as the legacy
|
|
||||||
* config.
|
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@@ -42,10 +40,9 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The {@code PaygChargeInterceptor} runs after the {@link #ENTITLEMENT_GUARD_ORDER guard} (and
|
* The {@code PaygChargeInterceptor} runs after the {@link #ENTITLEMENT_GUARD_ORDER guard}, so
|
||||||
* after the legacy {@code UnifiedCreditInterceptor}, default order 0), so {@code openProcess}
|
* {@code openProcess} only fires for requests the guard has admitted. See {@link
|
||||||
* only fires for requests the guard has admitted. See {@link #ENTITLEMENT_GUARD_ORDER} for the
|
* #ENTITLEMENT_GUARD_ORDER} for the full ordering rationale.
|
||||||
* full ordering rationale.
|
|
||||||
*/
|
*/
|
||||||
public static final int INTERCEPTOR_ORDER = 1000;
|
public static final int INTERCEPTOR_ORDER = 1000;
|
||||||
|
|
||||||
@@ -57,9 +54,7 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
|
|||||||
* short-circuits with its 402 before the charge interceptor ever runs. A blocked request
|
* short-circuits with its 402 before the charge interceptor ever runs. A blocked request
|
||||||
* therefore never opens a process, materialises inputs, or writes a charge: a refused operation
|
* therefore never opens a process, materialises inputs, or writes a charge: a refused operation
|
||||||
* must not bill, and running the guard first guarantees that structurally rather than by
|
* must not bill, and running the guard first guarantees that structurally rather than by
|
||||||
* compensating after the fact. Stays above the legacy {@code UnifiedCreditInterceptor} (default
|
* compensating after the fact.
|
||||||
* order 0, only registered under the {@code legacy-credits} profile) so a legacy rejection
|
|
||||||
* still wins.
|
|
||||||
*/
|
*/
|
||||||
public static final int ENTITLEMENT_GUARD_ORDER = 900;
|
public static final int ENTITLEMENT_GUARD_ORDER = 900;
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
package stirling.software.saas.repository;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.data.repository.query.Param;
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
|
|
||||||
import stirling.software.saas.model.TeamCredit;
|
|
||||||
|
|
||||||
@Repository
|
|
||||||
public interface TeamCreditRepository extends JpaRepository<TeamCredit, Long> {
|
|
||||||
|
|
||||||
/** Find team credits by team ID. */
|
|
||||||
@Query("SELECT tc FROM TeamCredit tc WHERE tc.team.id = :teamId")
|
|
||||||
Optional<TeamCredit> findByTeamId(@Param("teamId") Long teamId);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Atomically consume credits from the team pool. Uses the {@code @Version} column on {@link
|
|
||||||
* TeamCredit} for optimistic locking - concurrent attempts will fail-fast rather than
|
|
||||||
* over-deduct. Returns 1 on success, 0 if insufficient balance or version conflict.
|
|
||||||
*/
|
|
||||||
@Modifying
|
|
||||||
@Query(
|
|
||||||
value =
|
|
||||||
"""
|
|
||||||
UPDATE team_credits
|
|
||||||
SET cycle_credits_remaining = CASE
|
|
||||||
WHEN cycle_credits_remaining >= :amount THEN cycle_credits_remaining - :amount
|
|
||||||
WHEN cycle_credits_remaining > 0 AND bought_credits_remaining >= (:amount - cycle_credits_remaining)
|
|
||||||
THEN 0
|
|
||||||
ELSE cycle_credits_remaining
|
|
||||||
END,
|
|
||||||
bought_credits_remaining = CASE
|
|
||||||
WHEN cycle_credits_remaining >= :amount THEN bought_credits_remaining
|
|
||||||
WHEN cycle_credits_remaining > 0 AND bought_credits_remaining >= (:amount - cycle_credits_remaining)
|
|
||||||
THEN bought_credits_remaining - (:amount - cycle_credits_remaining)
|
|
||||||
WHEN cycle_credits_remaining = 0 AND bought_credits_remaining >= :amount
|
|
||||||
THEN bought_credits_remaining - :amount
|
|
||||||
ELSE bought_credits_remaining
|
|
||||||
END,
|
|
||||||
total_api_calls_made = total_api_calls_made + :amount,
|
|
||||||
last_api_usage = CURRENT_TIMESTAMP,
|
|
||||||
updated_at = CURRENT_TIMESTAMP,
|
|
||||||
version = version + 1
|
|
||||||
WHERE team_id = :teamId
|
|
||||||
AND (cycle_credits_remaining + bought_credits_remaining) >= :amount
|
|
||||||
""",
|
|
||||||
nativeQuery = true)
|
|
||||||
int consumeCredit(@Param("teamId") Long teamId, @Param("amount") int amount);
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT CASE WHEN COUNT(tc) > 0 THEN true ELSE false END FROM TeamCredit tc WHERE tc.team.id = :teamId")
|
|
||||||
boolean existsByTeamId(@Param("teamId") Long teamId);
|
|
||||||
|
|
||||||
@Modifying
|
|
||||||
@Query("DELETE FROM TeamCredit tc WHERE tc.team.id = :teamId")
|
|
||||||
void deleteByTeamId(@Param("teamId") Long teamId);
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT tc FROM TeamCredit tc WHERE tc.lastCycleResetAt IS NULL OR tc.lastCycleResetAt < :lastScheduledReset")
|
|
||||||
List<TeamCredit> findCreditsNeedingCycleReset(
|
|
||||||
@Param("lastScheduledReset") LocalDateTime lastScheduledReset);
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
package stirling.software.saas.repository;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
|
||||||
import org.springframework.data.jpa.repository.Query;
|
|
||||||
import org.springframework.data.repository.query.Param;
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
|
|
||||||
import stirling.software.proprietary.security.model.User;
|
|
||||||
import stirling.software.saas.model.UserCredit;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JPA repository for {@link UserCredit}. Includes JPQL queries for the common read paths and native
|
|
||||||
* SQL for atomic credit-consumption updates (avoids select-then-update races).
|
|
||||||
*
|
|
||||||
* <p>Native queries reference {@code user_credits} and {@code users} unqualified — they pick up
|
|
||||||
* Hibernate's {@code default_schema} (set to {@code stirling_pdf} in {@code
|
|
||||||
* application-saas.properties}). Keeping the schema out of the SQL means a future schema rename is
|
|
||||||
* a one-property change instead of a sweep of native SQL.
|
|
||||||
*/
|
|
||||||
@Repository
|
|
||||||
public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
|
|
||||||
|
|
||||||
Optional<UserCredit> findByUser(User user);
|
|
||||||
|
|
||||||
Optional<UserCredit> findByUserId(Long userId);
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT uc FROM UserCredit uc WHERE uc.lastCycleResetAt IS NULL OR uc.lastCycleResetAt < :lastScheduledReset")
|
|
||||||
List<UserCredit> findCreditsNeedingCycleReset(
|
|
||||||
@Param("lastScheduledReset") LocalDateTime lastScheduledReset);
|
|
||||||
|
|
||||||
@Query("SELECT SUM(uc.totalApiCallsMade) FROM UserCredit uc")
|
|
||||||
Long getTotalApiCallsAcrossAllUsers();
|
|
||||||
|
|
||||||
@Query("SELECT SUM(uc.cycleCreditsRemaining + uc.boughtCreditsRemaining) FROM UserCredit uc")
|
|
||||||
Long getTotalAvailableCreditsAcrossAllUsers();
|
|
||||||
|
|
||||||
@Query("SELECT uc FROM UserCredit uc WHERE uc.user.apiKey = :apiKey")
|
|
||||||
Optional<UserCredit> findByUserApiKey(@Param("apiKey") String apiKey);
|
|
||||||
|
|
||||||
@Query("SELECT uc FROM UserCredit uc WHERE uc.user.supabaseId = :supabaseId")
|
|
||||||
Optional<UserCredit> findBySupabaseId(@Param("supabaseId") UUID supabaseId);
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(uc) FROM UserCredit uc WHERE uc.lastApiUsage >= :since")
|
|
||||||
Long countActiveUsersInPeriod(@Param("since") LocalDateTime since);
|
|
||||||
|
|
||||||
@Modifying
|
|
||||||
@Query(
|
|
||||||
value =
|
|
||||||
"UPDATE user_credits "
|
|
||||||
+ "SET "
|
|
||||||
+ " cycle_credits_remaining = "
|
|
||||||
+ " CASE "
|
|
||||||
+ " WHEN cycle_credits_remaining >= :creditAmount THEN cycle_credits_remaining - :creditAmount "
|
|
||||||
+ " ELSE 0 "
|
|
||||||
+ " END, "
|
|
||||||
+ " bought_credits_remaining = "
|
|
||||||
+ " CASE "
|
|
||||||
+ " WHEN cycle_credits_remaining < :creditAmount "
|
|
||||||
+ " THEN GREATEST(0, bought_credits_remaining - (:creditAmount - cycle_credits_remaining)) "
|
|
||||||
+ " ELSE bought_credits_remaining "
|
|
||||||
+ " END, "
|
|
||||||
+ " total_api_calls_made = total_api_calls_made + 1, "
|
|
||||||
+ " last_api_usage = now() "
|
|
||||||
+ "WHERE user_id = (SELECT user_id FROM users WHERE api_key = :apiKey) "
|
|
||||||
+ " AND (cycle_credits_remaining + bought_credits_remaining >= :creditAmount)",
|
|
||||||
nativeQuery = true)
|
|
||||||
int consumeCredit(@Param("apiKey") String apiKey, @Param("creditAmount") int creditAmount);
|
|
||||||
|
|
||||||
@Modifying
|
|
||||||
@Query(
|
|
||||||
value =
|
|
||||||
"UPDATE user_credits "
|
|
||||||
+ "SET "
|
|
||||||
+ " cycle_credits_remaining = "
|
|
||||||
+ " CASE "
|
|
||||||
+ " WHEN cycle_credits_remaining >= :creditAmount THEN cycle_credits_remaining - :creditAmount "
|
|
||||||
+ " ELSE 0 "
|
|
||||||
+ " END, "
|
|
||||||
+ " bought_credits_remaining = "
|
|
||||||
+ " CASE "
|
|
||||||
+ " WHEN cycle_credits_remaining < :creditAmount "
|
|
||||||
+ " THEN GREATEST(0, bought_credits_remaining - (:creditAmount - cycle_credits_remaining)) "
|
|
||||||
+ " ELSE bought_credits_remaining "
|
|
||||||
+ " END, "
|
|
||||||
+ " total_api_calls_made = total_api_calls_made + 1, "
|
|
||||||
+ " last_api_usage = now() "
|
|
||||||
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
|
|
||||||
+ " AND (cycle_credits_remaining + bought_credits_remaining >= :creditAmount)",
|
|
||||||
nativeQuery = true)
|
|
||||||
int consumeCreditBySupabaseId(
|
|
||||||
@Param("supabaseId") UUID supabaseId, @Param("creditAmount") int creditAmount);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Consumes ONLY cycle credits (does not touch bought credits). Used in explicit waterfall
|
|
||||||
* logic.
|
|
||||||
*/
|
|
||||||
@Modifying
|
|
||||||
@Query(
|
|
||||||
value =
|
|
||||||
"UPDATE user_credits "
|
|
||||||
+ "SET "
|
|
||||||
+ " cycle_credits_remaining = cycle_credits_remaining - :amount, "
|
|
||||||
+ " total_api_calls_made = total_api_calls_made + 1, "
|
|
||||||
+ " last_api_usage = now() "
|
|
||||||
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
|
|
||||||
+ " AND cycle_credits_remaining >= :amount",
|
|
||||||
nativeQuery = true)
|
|
||||||
int consumeCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
|
|
||||||
|
|
||||||
/** Consumes ONLY bought credits (does not touch cycle credits). */
|
|
||||||
@Modifying
|
|
||||||
@Query(
|
|
||||||
value =
|
|
||||||
"UPDATE user_credits "
|
|
||||||
+ "SET "
|
|
||||||
+ " bought_credits_remaining = bought_credits_remaining - :amount, "
|
|
||||||
+ " total_api_calls_made = total_api_calls_made + 1, "
|
|
||||||
+ " last_api_usage = now() "
|
|
||||||
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
|
|
||||||
+ " AND bought_credits_remaining >= :amount",
|
|
||||||
nativeQuery = true)
|
|
||||||
int consumeBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
|
|
||||||
|
|
||||||
/** Checks if user has sufficient cycle credits (does NOT consume them). */
|
|
||||||
@Query(
|
|
||||||
value =
|
|
||||||
"SELECT CASE WHEN uc.cycle_credits_remaining >= :amount THEN TRUE ELSE FALSE END "
|
|
||||||
+ "FROM user_credits uc "
|
|
||||||
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId)",
|
|
||||||
nativeQuery = true)
|
|
||||||
Boolean hasCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
|
|
||||||
|
|
||||||
/** Checks if user has sufficient bought credits (does NOT consume them). */
|
|
||||||
@Query(
|
|
||||||
value =
|
|
||||||
"SELECT CASE WHEN uc.bought_credits_remaining >= :amount THEN TRUE ELSE FALSE END "
|
|
||||||
+ "FROM user_credits uc "
|
|
||||||
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId)",
|
|
||||||
nativeQuery = true)
|
|
||||||
Boolean hasBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
|
|
||||||
}
|
|
||||||
-13
@@ -63,7 +63,6 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
private final TeamService teamService;
|
private final TeamService teamService;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final SupabaseUserService supabaseUserService;
|
private final SupabaseUserService supabaseUserService;
|
||||||
private final stirling.software.saas.service.CreditService creditService;
|
|
||||||
private final SaasTeamService saasTeamService;
|
private final SaasTeamService saasTeamService;
|
||||||
private final JwtDecoder jwtDecoder;
|
private final JwtDecoder jwtDecoder;
|
||||||
private final AuthenticationEntryPoint authenticationEntryPoint =
|
private final AuthenticationEntryPoint authenticationEntryPoint =
|
||||||
@@ -73,13 +72,11 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
TeamService teamService,
|
TeamService teamService,
|
||||||
UserService userService,
|
UserService userService,
|
||||||
SupabaseUserService supabaseUserService,
|
SupabaseUserService supabaseUserService,
|
||||||
stirling.software.saas.service.CreditService creditService,
|
|
||||||
SaasTeamService saasTeamService,
|
SaasTeamService saasTeamService,
|
||||||
JwtDecoder jwtDecoder) {
|
JwtDecoder jwtDecoder) {
|
||||||
this.teamService = teamService;
|
this.teamService = teamService;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
this.supabaseUserService = supabaseUserService;
|
this.supabaseUserService = supabaseUserService;
|
||||||
this.creditService = creditService;
|
|
||||||
this.saasTeamService = saasTeamService;
|
this.saasTeamService = saasTeamService;
|
||||||
this.jwtDecoder = jwtDecoder;
|
this.jwtDecoder = jwtDecoder;
|
||||||
}
|
}
|
||||||
@@ -381,16 +378,6 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
|
|
||||||
// Only the DB-race winner runs first-time init; the losers skip it.
|
// Only the DB-race winner runs first-time init; the losers skip it.
|
||||||
if (weCreatedThisUser) {
|
if (weCreatedThisUser) {
|
||||||
try {
|
|
||||||
creditService.getOrCreateUserCredits(savedUser);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn(
|
|
||||||
"Failed to initialize credits for new user {} ({}): {}",
|
|
||||||
LogRedactionUtils.redactSupabaseId(supabaseId),
|
|
||||||
LogRedactionUtils.redactEmail(savedUser.getUsername()),
|
|
||||||
e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
saasTeamService.createPersonalTeam(savedUser);
|
saasTeamService.createPersonalTeam(savedUser);
|
||||||
savedUser = userService.findBySupabaseId(supabaseId).orElse(savedUser);
|
savedUser = userService.findBySupabaseId(supabaseId).orElse(savedUser);
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ import stirling.software.common.util.RequestUriUtils;
|
|||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
import stirling.software.proprietary.security.service.TeamService;
|
import stirling.software.proprietary.security.service.TeamService;
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
import stirling.software.proprietary.security.service.UserService;
|
||||||
import stirling.software.saas.service.CreditService;
|
|
||||||
import stirling.software.saas.service.SaasTeamService;
|
import stirling.software.saas.service.SaasTeamService;
|
||||||
import stirling.software.saas.service.SupabaseUserService;
|
import stirling.software.saas.service.SupabaseUserService;
|
||||||
|
|
||||||
@@ -66,7 +65,6 @@ public class SupabaseSecurityConfig {
|
|||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final TeamService teamService;
|
private final TeamService teamService;
|
||||||
private final SupabaseUserService supabaseUserService;
|
private final SupabaseUserService supabaseUserService;
|
||||||
private final CreditService creditService;
|
|
||||||
private final SaasTeamService saasTeamService;
|
private final SaasTeamService saasTeamService;
|
||||||
private final ApplicationProperties applicationProperties;
|
private final ApplicationProperties applicationProperties;
|
||||||
|
|
||||||
@@ -121,7 +119,6 @@ public class SupabaseSecurityConfig {
|
|||||||
teamService,
|
teamService,
|
||||||
userService,
|
userService,
|
||||||
supabaseUserService,
|
supabaseUserService,
|
||||||
creditService,
|
|
||||||
saasTeamService,
|
saasTeamService,
|
||||||
jwtDecoder),
|
jwtDecoder),
|
||||||
BearerTokenAuthenticationFilter.class)
|
BearerTokenAuthenticationFilter.class)
|
||||||
@@ -300,7 +297,7 @@ public class SupabaseSecurityConfig {
|
|||||||
"Accept",
|
"Accept",
|
||||||
"Origin",
|
"Origin",
|
||||||
"X-API-KEY"));
|
"X-API-KEY"));
|
||||||
cfg.setExposedHeaders(List.of("WWW-Authenticate", "X-Credits-Remaining"));
|
cfg.setExposedHeaders(List.of("WWW-Authenticate"));
|
||||||
cfg.setAllowCredentials(true);
|
cfg.setAllowCredentials(true);
|
||||||
cfg.setMaxAge(3600L);
|
cfg.setMaxAge(3600L);
|
||||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
package stirling.software.saas.service;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneId;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import stirling.software.saas.config.CreditsProperties;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@Profile("saas")
|
|
||||||
@Slf4j
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class CreditResetScheduler {
|
|
||||||
|
|
||||||
private final CreditService creditService;
|
|
||||||
private final CreditsProperties creditsProperties;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset cycle credits for all users and teams on the 1st of each month at 2 AM UTC This runs
|
|
||||||
* monthly, resetting credits based on user roles and team seats
|
|
||||||
*/
|
|
||||||
@Scheduled(cron = "${credits.reset.cron:0 0 2 1 * *}", zone = "${credits.reset.zone:UTC}")
|
|
||||||
public void resetCycleCredits() {
|
|
||||||
log.info(
|
|
||||||
"Starting monthly credit reset for all users and teams (schedule: {}, zone: {})",
|
|
||||||
creditsProperties.getReset().getCron(),
|
|
||||||
creditsProperties.getReset().getZone());
|
|
||||||
|
|
||||||
try {
|
|
||||||
ZoneId configuredZone = ZoneId.of(creditsProperties.getReset().getZone());
|
|
||||||
LocalDateTime resetTime = LocalDateTime.now(configuredZone);
|
|
||||||
creditService.resetCycleCreditsForAllUsers(resetTime);
|
|
||||||
creditService.resetCycleCreditsForAllTeams(resetTime);
|
|
||||||
log.info("Monthly credit reset completed successfully at {}", resetTime);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Error during monthly credit reset", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: The startup catch-up reset (formerly @EventListener(ApplicationReadyEvent)) was
|
|
||||||
// removed. It bulk-looped every user on each boot (per-row save), hammering the DB and
|
|
||||||
// stalling boot on large user tables. Per-user cycle resets already happen lazily in
|
|
||||||
// CreditService.getOrCreateUserCredits (isCycleResetDue), and the monthly cron above still
|
|
||||||
// performs the scheduled reset.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup and maintenance task; runs daily at 3 AM UTC. Performs maintenance tasks like
|
|
||||||
* cleaning up old data.
|
|
||||||
*/
|
|
||||||
@Scheduled(cron = "0 0 3 * * *", zone = "UTC")
|
|
||||||
public void performDailyMaintenance() {
|
|
||||||
log.debug("Starting daily credit system maintenance");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// API call history cleanup is no longer needed; audit system handles this
|
|
||||||
log.debug("Daily credit system maintenance completed");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Error during daily credit system maintenance", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@ package stirling.software.saas.service;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.context.annotation.Profile;
|
||||||
@@ -21,16 +20,12 @@ import stirling.software.proprietary.security.database.repository.UserRepository
|
|||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||||
import stirling.software.saas.billing.repository.BillingSubscriptionRepository;
|
import stirling.software.saas.billing.repository.BillingSubscriptionRepository;
|
||||||
import stirling.software.saas.config.CreditsProperties;
|
|
||||||
import stirling.software.saas.config.SupabaseConfigurationProperties;
|
import stirling.software.saas.config.SupabaseConfigurationProperties;
|
||||||
import stirling.software.saas.model.TeamCredit;
|
|
||||||
import stirling.software.saas.model.TeamInvitation;
|
import stirling.software.saas.model.TeamInvitation;
|
||||||
import stirling.software.saas.model.TeamMembership;
|
import stirling.software.saas.model.TeamMembership;
|
||||||
import stirling.software.saas.repository.SaasTeamExtensionsRepository;
|
import stirling.software.saas.repository.SaasTeamExtensionsRepository;
|
||||||
import stirling.software.saas.repository.TeamCreditRepository;
|
|
||||||
import stirling.software.saas.repository.TeamInvitationRepository;
|
import stirling.software.saas.repository.TeamInvitationRepository;
|
||||||
import stirling.software.saas.repository.TeamMembershipRepository;
|
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||||
import stirling.software.saas.repository.UserCreditRepository;
|
|
||||||
|
|
||||||
/** SaaS-only team management: invitations, personal teams, seat caps, paid-subscription gating. */
|
/** SaaS-only team management: invitations, personal teams, seat caps, paid-subscription gating. */
|
||||||
@Service
|
@Service
|
||||||
@@ -43,11 +38,7 @@ public class SaasTeamService {
|
|||||||
private final TeamMembershipRepository membershipRepository;
|
private final TeamMembershipRepository membershipRepository;
|
||||||
private final TeamInvitationRepository invitationRepository;
|
private final TeamInvitationRepository invitationRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final UserCreditRepository userCreditRepository;
|
|
||||||
private final BillingSubscriptionRepository billingSubscriptionRepository;
|
private final BillingSubscriptionRepository billingSubscriptionRepository;
|
||||||
private final TeamCreditService teamCreditService;
|
|
||||||
private final TeamCreditRepository teamCreditRepository;
|
|
||||||
private final CreditsProperties creditsProperties;
|
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
private final RateLimitService rateLimitService;
|
private final RateLimitService rateLimitService;
|
||||||
private final SupabaseConfigurationProperties supabaseConfig;
|
private final SupabaseConfigurationProperties supabaseConfig;
|
||||||
@@ -100,9 +91,6 @@ public class SaasTeamService {
|
|||||||
user.setTeam(savedTeam);
|
user.setTeam(savedTeam);
|
||||||
userRepository.save(user);
|
userRepository.save(user);
|
||||||
|
|
||||||
// Initialize team credits
|
|
||||||
teamCreditService.initializeTeamCredits(savedTeam, user);
|
|
||||||
|
|
||||||
// Clean up old Default/Internal team membership
|
// Clean up old Default/Internal team membership
|
||||||
if (oldTeam != null
|
if (oldTeam != null
|
||||||
&& (DEFAULT_TEAM_NAME.equals(oldTeam.getName())
|
&& (DEFAULT_TEAM_NAME.equals(oldTeam.getName())
|
||||||
@@ -783,64 +771,6 @@ public class SaasTeamService {
|
|||||||
|
|
||||||
teamRepository.save(team);
|
teamRepository.save(team);
|
||||||
|
|
||||||
Optional<TeamCredit> creditOpt = teamCreditRepository.findByTeamId(teamId);
|
|
||||||
|
|
||||||
int fixedAllocation =
|
|
||||||
creditsProperties.getCycle().getAllocations().getOrDefault("ROLE_PRO_USER", 500);
|
|
||||||
|
|
||||||
if (creditOpt.isPresent()) {
|
|
||||||
TeamCredit credit = creditOpt.get();
|
|
||||||
|
|
||||||
int oldAllocation =
|
|
||||||
credit.getCycleCreditsAllocated() != null
|
|
||||||
? credit.getCycleCreditsAllocated()
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
if (oldAllocation != fixedAllocation) {
|
|
||||||
int currentRemaining =
|
|
||||||
credit.getCycleCreditsRemaining() != null
|
|
||||||
? credit.getCycleCreditsRemaining()
|
|
||||||
: 0;
|
|
||||||
int allocationDifference = fixedAllocation - oldAllocation;
|
|
||||||
|
|
||||||
credit.setCycleCreditsAllocated(fixedAllocation);
|
|
||||||
|
|
||||||
int newRemaining = Math.max(0, currentRemaining + allocationDifference);
|
|
||||||
credit.setCycleCreditsRemaining(newRemaining);
|
|
||||||
|
|
||||||
teamCreditRepository.save(credit);
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"Updated team {} credit allocation: {} -> {} (fixed PRO amount). Remaining: {} -> {}",
|
|
||||||
teamId,
|
|
||||||
oldAllocation,
|
|
||||||
fixedAllocation,
|
|
||||||
currentRemaining,
|
|
||||||
newRemaining);
|
|
||||||
} else {
|
|
||||||
log.debug(
|
|
||||||
"Team {} already has fixed allocation of {} credits, no update needed",
|
|
||||||
teamId,
|
|
||||||
fixedAllocation);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.warn("Team {} missing credit record; creating with fixed allocation", teamId);
|
|
||||||
TeamCredit credit = new TeamCredit(team);
|
|
||||||
|
|
||||||
credit.setCycleCreditsAllocated(fixedAllocation);
|
|
||||||
credit.setCycleCreditsRemaining(fixedAllocation);
|
|
||||||
credit.setBoughtCreditsRemaining(0);
|
|
||||||
credit.setTotalBoughtCredits(0);
|
|
||||||
credit.setTotalApiCallsMade(0L);
|
|
||||||
credit.setLastCycleResetAt(LocalDateTime.now());
|
|
||||||
teamCreditRepository.save(credit);
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"Created team_credits record for team {} with {} fixed credits (unlimited seats model)",
|
|
||||||
teamId,
|
|
||||||
fixedAllocation);
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"Team {} seat allocation updated: maxSeats={}, seatsUsed={}, isPersonal={}",
|
"Team {} seat allocation updated: maxSeats={}, seatsUsed={}, isPersonal={}",
|
||||||
teamId,
|
teamId,
|
||||||
|
|||||||
@@ -1,279 +0,0 @@
|
|||||||
package stirling.software.saas.service;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import stirling.software.common.model.enumeration.TeamRole;
|
|
||||||
import stirling.software.proprietary.model.Team;
|
|
||||||
import stirling.software.proprietary.security.model.User;
|
|
||||||
import stirling.software.saas.billing.service.StripeUsageReportingService;
|
|
||||||
import stirling.software.saas.config.CreditsProperties;
|
|
||||||
import stirling.software.saas.model.CreditConsumptionResult;
|
|
||||||
import stirling.software.saas.model.TeamCredit;
|
|
||||||
import stirling.software.saas.model.TeamMembership;
|
|
||||||
import stirling.software.saas.repository.TeamCreditRepository;
|
|
||||||
import stirling.software.saas.repository.TeamMembershipRepository;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service for managing team credit pools. Handles credit initialization, consumption, and cycle
|
|
||||||
* resets for teams.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@Profile("saas")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
public class TeamCreditService {
|
|
||||||
|
|
||||||
private final TeamCreditRepository teamCreditRepository;
|
|
||||||
private final TeamMembershipRepository membershipRepository;
|
|
||||||
private final CreditsProperties creditsProperties;
|
|
||||||
private final StripeUsageReportingService stripeUsageReportingService;
|
|
||||||
private final SaasUserExtensionService saasUserExtensionService;
|
|
||||||
|
|
||||||
/** Initialise a fixed PRO credit allocation for a new team. */
|
|
||||||
@Transactional
|
|
||||||
public TeamCredit initializeTeamCredits(Team team, User primaryUser) {
|
|
||||||
Optional<TeamCredit> existing = teamCreditRepository.findByTeamId(team.getId());
|
|
||||||
if (existing.isPresent()) {
|
|
||||||
log.debug("Team credits already exist for team {}", team.getId());
|
|
||||||
return existing.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
TeamCredit credits = new TeamCredit(team);
|
|
||||||
|
|
||||||
// Fixed PRO allocation; seat-independent.
|
|
||||||
int proAllocation =
|
|
||||||
creditsProperties.getCycle().getAllocations().getOrDefault("ROLE_PRO_USER", 500);
|
|
||||||
int totalCycleAllocation = proAllocation;
|
|
||||||
|
|
||||||
credits.setCycleCreditsAllocated(totalCycleAllocation);
|
|
||||||
credits.setCycleCreditsRemaining(totalCycleAllocation);
|
|
||||||
credits.setLastCycleResetAt(LocalDateTime.now());
|
|
||||||
|
|
||||||
TeamCredit saved = teamCreditRepository.save(credits);
|
|
||||||
log.info(
|
|
||||||
"Initialized team credits for team {} with {} cycle credits (fixed PRO amount)",
|
|
||||||
team.getId(),
|
|
||||||
totalCycleAllocation);
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if team has credits available
|
|
||||||
*
|
|
||||||
* @param teamId the team ID
|
|
||||||
* @return true if team has credits available
|
|
||||||
*/
|
|
||||||
public boolean hasCreditsAvailable(Long teamId) {
|
|
||||||
return teamCreditRepository
|
|
||||||
.findByTeamId(teamId)
|
|
||||||
.map(TeamCredit::hasCreditsAvailable)
|
|
||||||
.orElse(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Atomically consume credits from team pool
|
|
||||||
*
|
|
||||||
* @param teamId the team ID
|
|
||||||
* @param amount number of credits to consume
|
|
||||||
* @return true if credits were consumed, false if insufficient credits or version conflict
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public boolean consumeCredit(Long teamId, int amount) {
|
|
||||||
int rowsUpdated = teamCreditRepository.consumeCredit(teamId, amount);
|
|
||||||
if (rowsUpdated == 0) {
|
|
||||||
log.warn(
|
|
||||||
"Failed to consume {} credits for team {} (insufficient credits or version conflict)",
|
|
||||||
amount,
|
|
||||||
teamId);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
log.debug("Consumed {} credits for team {}", amount, teamId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get team credit summary for a user's team.
|
|
||||||
*
|
|
||||||
* @param user the user
|
|
||||||
* @return Optional of TeamCredit for the user's team
|
|
||||||
*/
|
|
||||||
public Optional<TeamCredit> getCreditSummaryForUser(User user) {
|
|
||||||
if (user.getTeam() == null) {
|
|
||||||
log.warn("User {} has no team assigned", user.getId());
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
Long teamId = user.getTeam().getId();
|
|
||||||
log.debug("Using user's team {} for credit summary", teamId);
|
|
||||||
return teamCreditRepository.findByTeamId(teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get team credits by team ID
|
|
||||||
*
|
|
||||||
* @param teamId the team ID
|
|
||||||
* @return Optional of TeamCredit
|
|
||||||
*/
|
|
||||||
public Optional<TeamCredit> getTeamCredits(Long teamId) {
|
|
||||||
return teamCreditRepository.findByTeamId(teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add bought credits to team pool
|
|
||||||
*
|
|
||||||
* @param teamId the team ID
|
|
||||||
* @param credits number of credits to add
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public void addBoughtCredits(Long teamId, int credits) {
|
|
||||||
TeamCredit teamCredit =
|
|
||||||
teamCreditRepository
|
|
||||||
.findByTeamId(teamId)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Team credits not found"));
|
|
||||||
|
|
||||||
teamCredit.addBoughtCredits(credits);
|
|
||||||
teamCreditRepository.save(teamCredit);
|
|
||||||
log.info("Added {} bought credits to team {}", credits, teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset cycle credits for team
|
|
||||||
*
|
|
||||||
* @param teamId the team ID
|
|
||||||
* @param cycleAllocation new cycle allocation
|
|
||||||
* @param resetTime reset timestamp
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public void resetCycleCredits(Long teamId, int cycleAllocation, LocalDateTime resetTime) {
|
|
||||||
TeamCredit teamCredit =
|
|
||||||
teamCreditRepository
|
|
||||||
.findByTeamId(teamId)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Team credits not found"));
|
|
||||||
|
|
||||||
teamCredit.resetCycleCredits(cycleAllocation, resetTime);
|
|
||||||
teamCreditRepository.save(teamCredit);
|
|
||||||
log.info("Reset cycle credits for team {} to {}", teamId, cycleAllocation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Consume from the team credit pool; falls through to the team leader's metered Stripe billing
|
|
||||||
* when the pool is exhausted.
|
|
||||||
*/
|
|
||||||
@Transactional
|
|
||||||
public CreditConsumptionResult consumeCreditWithWaterfall(Long teamId, int amount) {
|
|
||||||
log.debug("[TEAM-CREDIT] Starting consumption for team {} - amount: {}", teamId, amount);
|
|
||||||
|
|
||||||
// Step 1: Try consuming from team credit pool
|
|
||||||
int rowsUpdated = teamCreditRepository.consumeCredit(teamId, amount);
|
|
||||||
if (rowsUpdated == 1) {
|
|
||||||
log.info("[TEAM-CREDIT] Consumed {} credits from team {} pool", amount, teamId);
|
|
||||||
return CreditConsumptionResult.success("TEAM_CREDITS");
|
|
||||||
}
|
|
||||||
|
|
||||||
log.warn("[TEAM-CREDIT] Team {} credit pool exhausted; checking leader overage", teamId);
|
|
||||||
|
|
||||||
// Step 2: Get team leader
|
|
||||||
Optional<User> leaderOpt = getTeamLeader(teamId);
|
|
||||||
if (leaderOpt.isEmpty()) {
|
|
||||||
log.error("[TEAM-CREDIT] Team {} has no leader; cannot use overage billing", teamId);
|
|
||||||
return CreditConsumptionResult.failure("NO_TEAM_LEADER");
|
|
||||||
}
|
|
||||||
|
|
||||||
User teamLeader = leaderOpt.get();
|
|
||||||
|
|
||||||
// Step 3: Check if team leader has metered billing enabled
|
|
||||||
if (!saasUserExtensionService.isMeteredBillingEnabled(teamLeader)) {
|
|
||||||
log.warn(
|
|
||||||
"[TEAM-CREDIT] Team {} leader {} does not have metered billing enabled",
|
|
||||||
teamId,
|
|
||||||
teamLeader.getUsername());
|
|
||||||
return CreditConsumptionResult.failure(
|
|
||||||
"TEAM_CREDITS_EXHAUSTED_NO_OVERAGE",
|
|
||||||
"Team credits exhausted. Team leader must enable overage billing for"
|
|
||||||
+ " uninterrupted service.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Report overage to Stripe via team leader's metered billing
|
|
||||||
String leaderSupabaseId =
|
|
||||||
teamLeader.getSupabaseId() != null ? teamLeader.getSupabaseId().toString() : null;
|
|
||||||
|
|
||||||
if (leaderSupabaseId == null) {
|
|
||||||
log.error("[TEAM-CREDIT] Team leader {} has no Supabase ID", teamLeader.getUsername());
|
|
||||||
return CreditConsumptionResult.failure("LEADER_NO_SUPABASE_ID");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
String operationId = org.slf4j.MDC.get("requestId");
|
|
||||||
if (operationId == null || operationId.isBlank()) {
|
|
||||||
operationId = java.util.UUID.randomUUID().toString();
|
|
||||||
}
|
|
||||||
String idempotencyKey =
|
|
||||||
stripeUsageReportingService.generateIdempotencyKey(
|
|
||||||
leaderSupabaseId, amount, operationId);
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"[TEAM-CREDIT] Reporting {} overage credits to Stripe for team {} leader {}",
|
|
||||||
amount,
|
|
||||||
teamId,
|
|
||||||
teamLeader.getUsername());
|
|
||||||
|
|
||||||
boolean reported =
|
|
||||||
stripeUsageReportingService.reportUsageToStripe(
|
|
||||||
leaderSupabaseId, amount, idempotencyKey);
|
|
||||||
|
|
||||||
if (reported) {
|
|
||||||
log.info(
|
|
||||||
"[TEAM-CREDIT] Successfully reported {} overage credits for team {} via"
|
|
||||||
+ " leader {}",
|
|
||||||
amount,
|
|
||||||
teamId,
|
|
||||||
teamLeader.getUsername());
|
|
||||||
return CreditConsumptionResult.success("TEAM_LEADER_METERED");
|
|
||||||
} else {
|
|
||||||
log.error("[TEAM-CREDIT] Failed to report overage to Stripe for team {}", teamId);
|
|
||||||
return CreditConsumptionResult.failure(
|
|
||||||
"STRIPE_REPORTING_FAILED",
|
|
||||||
"Unable to report usage to Stripe. Please try again.");
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error(
|
|
||||||
"[TEAM-CREDIT] Exception reporting overage for team {}: {}",
|
|
||||||
teamId,
|
|
||||||
e.getMessage(),
|
|
||||||
e);
|
|
||||||
return CreditConsumptionResult.failure(
|
|
||||||
"STRIPE_REPORTING_ERROR", "Error reporting usage: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the team's LEADER (first one if multiple exist) for overage-billing routing. */
|
|
||||||
private Optional<User> getTeamLeader(Long teamId) {
|
|
||||||
List<TeamMembership> leaders =
|
|
||||||
membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER);
|
|
||||||
|
|
||||||
if (leaders.isEmpty()) {
|
|
||||||
log.warn("Team {} has no leaders", teamId);
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return first leader (typically only one leader per team)
|
|
||||||
TeamMembership leader = leaders.get(0);
|
|
||||||
User leaderUser = leader.getUser();
|
|
||||||
log.debug(
|
|
||||||
"Found team {} leader: {} (user ID: {})",
|
|
||||||
teamId,
|
|
||||||
leaderUser.getUsername(),
|
|
||||||
leaderUser.getId());
|
|
||||||
|
|
||||||
return Optional.of(leaderUser);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,10 +12,9 @@ import stirling.software.proprietary.security.database.repository.AuthorityRepos
|
|||||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
import stirling.software.proprietary.security.model.Authority;
|
import stirling.software.proprietary.security.model.Authority;
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
import stirling.software.saas.config.CreditsProperties;
|
|
||||||
import stirling.software.saas.util.LogRedactionUtils;
|
import stirling.software.saas.util.LogRedactionUtils;
|
||||||
|
|
||||||
/** Changes user roles and refreshes their credit allocation. */
|
/** Changes user roles (and the matching authority grant/revoke). */
|
||||||
@Service
|
@Service
|
||||||
@Profile("saas")
|
@Profile("saas")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -24,8 +23,6 @@ public class UserRoleService {
|
|||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final AuthorityRepository authorityRepository;
|
private final AuthorityRepository authorityRepository;
|
||||||
private final CreditService creditService;
|
|
||||||
private final CreditsProperties creditsProperties;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Change a user's role
|
* Change a user's role
|
||||||
@@ -58,7 +55,7 @@ public class UserRoleService {
|
|||||||
/**
|
/**
|
||||||
* Downgrade a user to FREE tier (ROLE_USER)
|
* Downgrade a user to FREE tier (ROLE_USER)
|
||||||
*
|
*
|
||||||
* <p>Changes role from PRO_USER to USER and resets cycle credit allocation to FREE tier.
|
* <p>Revokes ROLE_PRO_USER by changing the role/authority from PRO_USER to USER.
|
||||||
*
|
*
|
||||||
* @param user the user to downgrade
|
* @param user the user to downgrade
|
||||||
*/
|
*/
|
||||||
@@ -70,24 +67,15 @@ public class UserRoleService {
|
|||||||
|
|
||||||
changeRole(user, Role.USER.getRoleId());
|
changeRole(user, Role.USER.getRoleId());
|
||||||
|
|
||||||
// Reset credits to FREE tier allocation
|
|
||||||
int freeAllocation =
|
|
||||||
creditsProperties
|
|
||||||
.getCycle()
|
|
||||||
.getAllocations()
|
|
||||||
.getOrDefault(Role.USER.getRoleId(), 25);
|
|
||||||
creditService.resetCycleAllocationForRoleChange(user.getId(), freeAllocation);
|
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"Successfully downgraded user {} to FREE with {} cycle credits",
|
"Successfully downgraded user {} to FREE",
|
||||||
LogRedactionUtils.redactEmail(user.getUsername()),
|
LogRedactionUtils.redactEmail(user.getUsername()));
|
||||||
freeAllocation);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upgrade a user to PRO tier (ROLE_PRO_USER)
|
* Upgrade a user to PRO tier (ROLE_PRO_USER)
|
||||||
*
|
*
|
||||||
* <p>Changes role from USER to PRO_USER and resets cycle credit allocation to PRO tier.
|
* <p>Grants ROLE_PRO_USER by changing the role/authority from USER to PRO_USER.
|
||||||
*
|
*
|
||||||
* @param user the user to upgrade
|
* @param user the user to upgrade
|
||||||
*/
|
*/
|
||||||
@@ -98,30 +86,8 @@ public class UserRoleService {
|
|||||||
|
|
||||||
changeRole(user, Role.PRO_USER.getRoleId());
|
changeRole(user, Role.PRO_USER.getRoleId());
|
||||||
|
|
||||||
// Reset credits to PRO tier allocation
|
|
||||||
int proAllocation =
|
|
||||||
creditsProperties
|
|
||||||
.getCycle()
|
|
||||||
.getAllocations()
|
|
||||||
.getOrDefault(Role.PRO_USER.getRoleId(), 100);
|
|
||||||
creditService.resetCycleAllocationForRoleChange(user.getId(), proAllocation);
|
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"Successfully upgraded user {} to PRO with {} cycle credits",
|
"Successfully upgraded user {} to PRO",
|
||||||
LogRedactionUtils.redactEmail(user.getUsername()),
|
LogRedactionUtils.redactEmail(user.getUsername()));
|
||||||
proAllocation);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get credit allocation for a specific role
|
|
||||||
*
|
|
||||||
* @param roleId the role ID (e.g., "ROLE_USER", "ROLE_PRO_USER")
|
|
||||||
* @return the cycle credit allocation for that role
|
|
||||||
*/
|
|
||||||
public int getCreditAllocationForRole(String roleId) {
|
|
||||||
return creditsProperties
|
|
||||||
.getCycle()
|
|
||||||
.getAllocations()
|
|
||||||
.getOrDefault(roleId, Role.USER.getRoleId().equals(roleId) ? 25 : 100);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
package stirling.software.saas.util;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import org.springframework.context.annotation.Profile;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import stirling.software.proprietary.security.model.User;
|
|
||||||
import stirling.software.saas.model.TeamCredit;
|
|
||||||
import stirling.software.saas.model.UserCredit;
|
|
||||||
import stirling.software.saas.service.CreditService;
|
|
||||||
import stirling.software.saas.service.SaasTeamExtensionService;
|
|
||||||
import stirling.software.saas.service.TeamCreditService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the user's remaining credit balance. Uses the team pool for non-personal team members,
|
|
||||||
* otherwise the user's individual credits (looked up by Supabase ID or API key).
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
@Profile("saas")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
|
||||||
public class CreditHeaderUtils {
|
|
||||||
|
|
||||||
private final SaasTeamExtensionService saasTeamExtensionService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the remaining credits for a user, checking team credits first (non-personal teams only).
|
|
||||||
*
|
|
||||||
* @param user The user whose credits to check
|
|
||||||
* @param creditService The credit service to fetch user credits
|
|
||||||
* @param teamCreditService The team credit service to fetch team credits
|
|
||||||
* @return The remaining credit balance, or -1 if credits cannot be determined
|
|
||||||
*/
|
|
||||||
public int getRemainingCredits(
|
|
||||||
User user, CreditService creditService, TeamCreditService teamCreditService) {
|
|
||||||
try {
|
|
||||||
// Limited-API users always read personal credits.
|
|
||||||
boolean isLimitedApiUser =
|
|
||||||
user.getAuthorities().stream()
|
|
||||||
.anyMatch(
|
|
||||||
authority ->
|
|
||||||
"ROLE_LIMITED_API_USER".equals(authority.getAuthority())
|
|
||||||
|| "ROLE_EXTRA_LIMITED_API_USER"
|
|
||||||
.equals(authority.getAuthority()));
|
|
||||||
|
|
||||||
Long targetTeamId = null;
|
|
||||||
if (!isLimitedApiUser
|
|
||||||
&& user.getTeam() != null
|
|
||||||
&& !saasTeamExtensionService.isPersonal(user.getTeam())) {
|
|
||||||
targetTeamId = user.getTeam().getId();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetTeamId != null) {
|
|
||||||
return teamCreditService
|
|
||||||
.getTeamCredits(targetTeamId)
|
|
||||||
.map(TeamCredit::getTotalAvailableCredits)
|
|
||||||
.orElse(-1);
|
|
||||||
} else {
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-HEADER] Getting personal credits - SupabaseId: {}, ApiKey: {}, Username: {}",
|
|
||||||
user.getSupabaseId(),
|
|
||||||
user.getApiKey() != null ? "present" : "null",
|
|
||||||
user.getUsername());
|
|
||||||
|
|
||||||
Optional<UserCredit> credits;
|
|
||||||
if (user.getSupabaseId() != null) {
|
|
||||||
credits =
|
|
||||||
creditService.getUserCreditsBySupabaseId(
|
|
||||||
user.getSupabaseId().toString());
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-HEADER] Looked up by SupabaseId - Found: {}",
|
|
||||||
credits.isPresent());
|
|
||||||
} else if (user.getApiKey() != null) {
|
|
||||||
credits = creditService.getUserCreditsByApiKey(user.getApiKey());
|
|
||||||
log.debug(
|
|
||||||
"[CREDIT-HEADER] Looked up by ApiKey - Found: {}", credits.isPresent());
|
|
||||||
} else {
|
|
||||||
log.warn(
|
|
||||||
"[CREDIT-HEADER] No SupabaseId or ApiKey for user: {}",
|
|
||||||
user.getUsername());
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
int remaining = credits.map(UserCredit::getTotalAvailableCredits).orElse(-1);
|
|
||||||
log.debug("[CREDIT-HEADER] Returning credits: {}", remaining);
|
|
||||||
return remaining;
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[CREDIT-HEADER] Could not get remaining credits: {}", e.getMessage(), e);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+9
-98
@@ -64,17 +64,13 @@ import stirling.software.saas.payg.charge.JobChargeService;
|
|||||||
import stirling.software.saas.payg.model.BillingCategory;
|
import stirling.software.saas.payg.model.BillingCategory;
|
||||||
import stirling.software.saas.payg.model.JobSource;
|
import stirling.software.saas.payg.model.JobSource;
|
||||||
import stirling.software.saas.payg.model.ProcessType;
|
import stirling.software.saas.payg.model.ProcessType;
|
||||||
import stirling.software.saas.service.CreditService;
|
|
||||||
import stirling.software.saas.service.TeamCreditService;
|
|
||||||
import stirling.software.saas.util.CreditHeaderUtils;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure unit tests for {@link AiCreateController}. All collaborators are mocked; the controller's
|
* Pure unit tests for {@link AiCreateController}. All collaborators are mocked; the controller's
|
||||||
* handler methods are invoked directly and asserted via {@link ResponseEntity} / {@code verify}.
|
* handler methods are invoked directly and asserted via {@link ResponseEntity} / {@code verify}.
|
||||||
*
|
*
|
||||||
* <p>The controller reads {@code SecurityContextHolder} in the charge + credit-header paths, so
|
* <p>The controller reads {@code SecurityContextHolder} in the charge path, so each relevant test
|
||||||
* each relevant test seeds an authentication and {@link #clearSecurityContext()} resets it
|
* seeds an authentication and {@link #clearSecurityContext()} resets it afterwards.
|
||||||
* afterwards.
|
|
||||||
*/
|
*/
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||||
@@ -82,10 +78,7 @@ class AiCreateControllerTest {
|
|||||||
|
|
||||||
@Mock private AiCreateSessionService sessionService;
|
@Mock private AiCreateSessionService sessionService;
|
||||||
@Mock private AiCreateProxyService proxyService;
|
@Mock private AiCreateProxyService proxyService;
|
||||||
@Mock private CreditService creditService;
|
|
||||||
@Mock private TeamCreditService teamCreditService;
|
|
||||||
@Mock private UserRepository userRepository;
|
@Mock private UserRepository userRepository;
|
||||||
@Mock private CreditHeaderUtils creditHeaderUtils;
|
|
||||||
@Mock private JobChargeService jobChargeService;
|
@Mock private JobChargeService jobChargeService;
|
||||||
|
|
||||||
private AiCreateController controller;
|
private AiCreateController controller;
|
||||||
@@ -94,13 +87,7 @@ class AiCreateControllerTest {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
controller =
|
controller =
|
||||||
new AiCreateController(
|
new AiCreateController(
|
||||||
sessionService,
|
sessionService, proxyService, userRepository, jobChargeService);
|
||||||
proxyService,
|
|
||||||
creditService,
|
|
||||||
teamCreditService,
|
|
||||||
userRepository,
|
|
||||||
creditHeaderUtils,
|
|
||||||
jobChargeService);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
@@ -729,8 +716,6 @@ class AiCreateControllerTest {
|
|||||||
// Ownership guard runs before the proxy.
|
// Ownership guard runs before the proxy.
|
||||||
verify(sessionService).getSessionForCurrentUser("sess-1");
|
verify(sessionService).getSessionForCurrentUser("sess-1");
|
||||||
assertThat(drain(resp.getBody())).isEqualTo("section data");
|
assertThat(drain(resp.getBody())).isEqualTo("section data");
|
||||||
// No credit header on the non-AI-triggering endpoint.
|
|
||||||
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -761,7 +746,7 @@ class AiCreateControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------------------------
|
||||||
// stream (proxy, accept event-stream + credit header)
|
// stream (proxy, accept event-stream)
|
||||||
// ----------------------------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
@@ -769,13 +754,9 @@ class AiCreateControllerTest {
|
|||||||
class Stream {
|
class Stream {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("proxies GET as event-stream and adds X-Credits-Remaining for authed user")
|
@DisplayName(
|
||||||
void stream_addsCreditHeader() throws Exception {
|
"checks ownership, proxies GET as event-stream, defaults Content-Type, and streams")
|
||||||
User user = userWithTeam(7L, 100L);
|
void stream_proxiesEventStreamAndStreams() throws Exception {
|
||||||
authenticateWeb(user);
|
|
||||||
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
|
|
||||||
.thenReturn(42);
|
|
||||||
|
|
||||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||||
HttpResponse<InputStream> upstream =
|
HttpResponse<InputStream> upstream =
|
||||||
upstreamResponse(200, "data: hi\n\n", httpHeaders(Map.of()));
|
upstreamResponse(200, "data: hi\n\n", httpHeaders(Map.of()));
|
||||||
@@ -789,80 +770,11 @@ class AiCreateControllerTest {
|
|||||||
// No upstream Content-Type → defaulted to text/event-stream.
|
// No upstream Content-Type → defaulted to text/event-stream.
|
||||||
assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
|
assertThat(resp.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE))
|
||||||
.isEqualTo(MediaType.TEXT_EVENT_STREAM_VALUE);
|
.isEqualTo(MediaType.TEXT_EVENT_STREAM_VALUE);
|
||||||
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("42");
|
// Ownership guard runs before the proxy.
|
||||||
verify(sessionService).getSessionForCurrentUser("sess-1");
|
verify(sessionService).getSessionForCurrentUser("sess-1");
|
||||||
assertThat(drain(resp.getBody())).isEqualTo("data: hi\n\n");
|
assertThat(drain(resp.getBody())).isEqualTo("data: hi\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("negative remaining credits suppresses the credit header")
|
|
||||||
void stream_negativeCredits_omitsHeader() throws Exception {
|
|
||||||
User user = userWithTeam(7L, 100L);
|
|
||||||
authenticateWeb(user);
|
|
||||||
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
|
|
||||||
.thenReturn(-1);
|
|
||||||
|
|
||||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
|
||||||
HttpResponse<InputStream> upstream = upstreamResponse(200, "x", httpHeaders(Map.of()));
|
|
||||||
when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream);
|
|
||||||
|
|
||||||
ResponseEntity<StreamingResponseBody> resp = controller.stream("sess-1", req);
|
|
||||||
|
|
||||||
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("zero remaining credits still emits the header (>= 0 boundary)")
|
|
||||||
void stream_zeroCredits_emitsHeader() throws Exception {
|
|
||||||
User user = userWithTeam(7L, 100L);
|
|
||||||
authenticateWeb(user);
|
|
||||||
when(creditHeaderUtils.getRemainingCredits(user, creditService, teamCreditService))
|
|
||||||
.thenReturn(0);
|
|
||||||
|
|
||||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
|
||||||
HttpResponse<InputStream> upstream = upstreamResponse(200, "x", httpHeaders(Map.of()));
|
|
||||||
when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream);
|
|
||||||
|
|
||||||
ResponseEntity<StreamingResponseBody> resp = controller.stream("sess-1", req);
|
|
||||||
|
|
||||||
assertThat(resp.getHeaders().getFirst("X-Credits-Remaining")).isEqualTo("0");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("unauthenticated context: stream still proxies, no credit header, no NPE")
|
|
||||||
void stream_noAuth_omitsCreditHeader() throws Exception {
|
|
||||||
SecurityContextHolder.clearContext();
|
|
||||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
|
||||||
HttpResponse<InputStream> upstream = upstreamResponse(200, "x", httpHeaders(Map.of()));
|
|
||||||
when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream);
|
|
||||||
|
|
||||||
ResponseEntity<StreamingResponseBody> resp = controller.stream("sess-1", req);
|
|
||||||
|
|
||||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
|
|
||||||
verifyNoInteractions(creditHeaderUtils);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("credit-header lookup blowing up does not break the stream response")
|
|
||||||
void stream_creditLookupThrows_stillStreams() throws Exception {
|
|
||||||
User user = userWithTeam(7L, 100L);
|
|
||||||
authenticateWeb(user);
|
|
||||||
when(creditHeaderUtils.getRemainingCredits(any(), any(), any()))
|
|
||||||
.thenThrow(new RuntimeException("boom"));
|
|
||||||
|
|
||||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
|
||||||
HttpResponse<InputStream> upstream =
|
|
||||||
upstreamResponse(200, "payload", httpHeaders(Map.of()));
|
|
||||||
when(proxyService.forward(any(), any(), any(), eq(true))).thenReturn(upstream);
|
|
||||||
|
|
||||||
ResponseEntity<StreamingResponseBody> resp = controller.stream("sess-1", req);
|
|
||||||
|
|
||||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
assertThat(resp.getHeaders().containsHeader("X-Credits-Remaining")).isFalse();
|
|
||||||
assertThat(drain(resp.getBody())).isEqualTo("payload");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("upstream non-2xx status is passed through; explicit Content-Type wins")
|
@DisplayName("upstream non-2xx status is passed through; explicit Content-Type wins")
|
||||||
void stream_upstreamStatusAndExplicitContentTypePassedThrough() throws Exception {
|
void stream_upstreamStatusAndExplicitContentTypePassedThrough() throws Exception {
|
||||||
@@ -904,7 +816,7 @@ class AiCreateControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("ownership failure short-circuits before proxy/credit work")
|
@DisplayName("ownership failure short-circuits before proxying")
|
||||||
void stream_ownershipFailure_doesNotProxy() throws Exception {
|
void stream_ownershipFailure_doesNotProxy() throws Exception {
|
||||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||||
when(sessionService.getSessionForCurrentUser("sess-1"))
|
when(sessionService.getSessionForCurrentUser("sess-1"))
|
||||||
@@ -913,7 +825,6 @@ class AiCreateControllerTest {
|
|||||||
assertThatThrownBy(() -> controller.stream("sess-1", req))
|
assertThatThrownBy(() -> controller.stream("sess-1", req))
|
||||||
.isInstanceOf(ResponseStatusException.class);
|
.isInstanceOf(ResponseStatusException.class);
|
||||||
verify(proxyService, never()).forward(any(), any(), any(), anyBoolean());
|
verify(proxyService, never()).forward(any(), any(), any(), anyBoolean());
|
||||||
verifyNoInteractions(creditHeaderUtils);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+50
-283
@@ -6,7 +6,6 @@ import static org.mockito.ArgumentMatchers.anyBoolean;
|
|||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.verifyNoInteractions;
|
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
@@ -17,7 +16,6 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Nested;
|
import org.junit.jupiter.api.Nested;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -30,70 +28,41 @@ import org.springframework.http.HttpHeaders;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
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 org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
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.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
|
* Pure unit tests for {@link AiProxyController}. Every collaborator is mocked; each handler is
|
||||||
* invoked directly and asserted via {@link ResponseEntity} / {@code verify}.
|
* invoked directly and asserted via {@link ResponseEntity} / {@code verify}.
|
||||||
*
|
*
|
||||||
* <p>All endpoints funnel through one private {@code proxy(method, path, request,
|
* <p>All endpoints funnel through one private {@code proxy(method, path, request,
|
||||||
* acceptEventStream, includeCreditsHeader)} helper, so the suite has two halves:
|
* acceptEventStream)} helper, so the suite has two halves:
|
||||||
*
|
*
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>per-endpoint tests that pin the exact {@code (method, path, acceptEventStream)} contract a
|
* <li>per-endpoint tests that pin the exact {@code (method, path, acceptEventStream)} contract a
|
||||||
* given handler forwards (the path-mapping surface), and
|
* given handler forwards (the path-mapping surface), and
|
||||||
* <li>behavioural tests around the single shared {@code proxy} body: header copy, status
|
* <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
|
* resolution, and the 503 error fallback.
|
||||||
* includeCreditsHeader}.
|
|
||||||
* </ol>
|
* </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)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||||
class AiProxyControllerTest {
|
class AiProxyControllerTest {
|
||||||
|
|
||||||
@Mock private AiProxyService aiProxyService;
|
@Mock private AiProxyService aiProxyService;
|
||||||
@Mock private CreditService creditService;
|
|
||||||
@Mock private TeamCreditService teamCreditService;
|
|
||||||
@Mock private UserRepository userRepository;
|
|
||||||
@Mock private CreditHeaderUtils creditHeaderUtils;
|
|
||||||
|
|
||||||
private AiProxyController controller;
|
private AiProxyController controller;
|
||||||
|
|
||||||
@org.junit.jupiter.api.BeforeEach
|
@org.junit.jupiter.api.BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
controller =
|
controller = new AiProxyController(aiProxyService);
|
||||||
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.
|
// 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
|
@Nested
|
||||||
@@ -101,7 +70,7 @@ class AiProxyControllerTest {
|
|||||||
class EndpointMapping {
|
class EndpointMapping {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("generateSection POSTs to /api/generate_section, non-stream, no credit header")
|
@DisplayName("generateSection POSTs to /api/generate_section, non-stream")
|
||||||
void generateSection() throws Exception {
|
void generateSection() throws Exception {
|
||||||
HttpServletRequest req = req();
|
HttpServletRequest req = req();
|
||||||
stubForward("POST", "/api/generate_section", req, false, ok("body"));
|
stubForward("POST", "/api/generate_section", req, false, ok("body"));
|
||||||
@@ -110,7 +79,6 @@ class AiProxyControllerTest {
|
|||||||
|
|
||||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
verify(aiProxyService).forward("POST", "/api/generate_section", req, false);
|
verify(aiProxyService).forward("POST", "/api/generate_section", req, false);
|
||||||
assertThat(resp.getHeaders().containsHeader("X-Credit-Source")).isFalse();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -135,6 +103,39 @@ class AiProxyControllerTest {
|
|||||||
verify(aiProxyService).forward("POST", "/api/intent/check", req, false);
|
verify(aiProxyService).forward("POST", "/api/intent/check", req, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("chatRoute POSTs to /api/chat/route")
|
||||||
|
void chatRoute() throws Exception {
|
||||||
|
HttpServletRequest req = req();
|
||||||
|
stubForward("POST", "/api/chat/route", req, false, ok("body"));
|
||||||
|
|
||||||
|
controller.chatRoute(req);
|
||||||
|
|
||||||
|
verify(aiProxyService).forward("POST", "/api/chat/route", req, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("createSmartFolder POSTs to /api/chat/create-smart-folder")
|
||||||
|
void createSmartFolder() throws Exception {
|
||||||
|
HttpServletRequest req = req();
|
||||||
|
stubForward("POST", "/api/chat/create-smart-folder", req, false, ok("body"));
|
||||||
|
|
||||||
|
controller.createSmartFolder(req);
|
||||||
|
|
||||||
|
verify(aiProxyService).forward("POST", "/api/chat/create-smart-folder", req, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("chatInfo POSTs to /api/chat/info")
|
||||||
|
void chatInfo() throws Exception {
|
||||||
|
HttpServletRequest req = req();
|
||||||
|
stubForward("POST", "/api/chat/info", req, false, ok("body"));
|
||||||
|
|
||||||
|
controller.chatInfo(req);
|
||||||
|
|
||||||
|
verify(aiProxyService).forward("POST", "/api/chat/info", req, false);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("pdfAnswer POSTs to /api/pdf/answer")
|
@DisplayName("pdfAnswer POSTs to /api/pdf/answer")
|
||||||
void pdfAnswer() throws Exception {
|
void pdfAnswer() throws Exception {
|
||||||
@@ -212,6 +213,18 @@ class AiProxyControllerTest {
|
|||||||
verify(aiProxyService).forward("POST", "/api/edit/sessions", req, false);
|
verify(aiProxyService).forward("POST", "/api/edit/sessions", req, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("editSessionMessage POSTs /api/edit/sessions/{id}/messages")
|
||||||
|
void editSessionMessage() throws Exception {
|
||||||
|
HttpServletRequest req = req();
|
||||||
|
stubForward("POST", "/api/edit/sessions/sess-9/messages", req, false, ok("body"));
|
||||||
|
|
||||||
|
controller.editSessionMessage("sess-9", req);
|
||||||
|
|
||||||
|
verify(aiProxyService)
|
||||||
|
.forward("POST", "/api/edit/sessions/sess-9/messages", req, false);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("editSessionAttachment POSTs /api/edit/sessions/{id}/attachments")
|
@DisplayName("editSessionAttachment POSTs /api/edit/sessions/{id}/attachments")
|
||||||
void editSessionAttachment() throws Exception {
|
void editSessionAttachment() throws Exception {
|
||||||
@@ -228,7 +241,7 @@ class AiProxyControllerTest {
|
|||||||
@DisplayName("runEditSession POSTs /api/edit/sessions/{id}/run as an event stream")
|
@DisplayName("runEditSession POSTs /api/edit/sessions/{id}/run as an event stream")
|
||||||
void runEditSession() throws Exception {
|
void runEditSession() throws Exception {
|
||||||
HttpServletRequest req = req();
|
HttpServletRequest req = req();
|
||||||
// acceptEventStream == true here (and no credit header).
|
// acceptEventStream == true here.
|
||||||
stubForward("POST", "/api/edit/sessions/sess-9/run", req, true, ok("data: x\n\n"));
|
stubForward("POST", "/api/edit/sessions/sess-9/run", req, true, ok("data: x\n\n"));
|
||||||
|
|
||||||
controller.runEditSession("sess-9", req);
|
controller.runEditSession("sess-9", req);
|
||||||
@@ -505,234 +518,6 @@ class AiProxyControllerTest {
|
|||||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||||
assertThat(drain(resp.getBody())).contains("AI backend 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------------------------
|
||||||
@@ -759,24 +544,6 @@ class AiProxyControllerTest {
|
|||||||
return upstreamResponse(200, body, httpHeaders(Map.of()));
|
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) {
|
private static java.net.http.HttpHeaders httpHeaders(Map<String, String> single) {
|
||||||
Map<String, List<String>> multi = new java.util.HashMap<>();
|
Map<String, List<String>> multi = new java.util.HashMap<>();
|
||||||
single.forEach((k, v) -> multi.put(k, List.of(v)));
|
single.forEach((k, v) -> multi.put(k, List.of(v)));
|
||||||
|
|||||||
-89
@@ -1,89 +0,0 @@
|
|||||||
package stirling.software.saas.controller;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
|
|
||||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
|
||||||
import stirling.software.proprietary.security.model.User;
|
|
||||||
import stirling.software.saas.service.CreditService;
|
|
||||||
import stirling.software.saas.service.CreditService.CreditSummary;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression coverage for finding #15: API-key users used to always see empty credits because the
|
|
||||||
* controller blindly passed the API key string through to {@code getCreditSummaryBySupabaseId},
|
|
||||||
* which then blew up on {@code UUID.fromString}. The new code reads the User from the principal and
|
|
||||||
* prefers the linked Supabase ID, falling back to API-key-keyed credits.
|
|
||||||
*/
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class CreditControllerApiKeyTest {
|
|
||||||
|
|
||||||
@Mock private CreditService creditService;
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void apiKeyUserWithSupabaseIdGetsResolvedToSupabaseLookup() {
|
|
||||||
UUID supabaseId = UUID.randomUUID();
|
|
||||||
User u = new User();
|
|
||||||
u.setSupabaseId(supabaseId);
|
|
||||||
|
|
||||||
CreditSummary expected = creditSummary(42, 100);
|
|
||||||
when(creditService.getCreditSummaryBySupabaseId(supabaseId.toString()))
|
|
||||||
.thenReturn(expected);
|
|
||||||
|
|
||||||
CreditController controller = new CreditController(creditService);
|
|
||||||
ApiKeyAuthenticationToken token =
|
|
||||||
new ApiKeyAuthenticationToken(u, "the-api-key", java.util.List.of());
|
|
||||||
|
|
||||||
ResponseEntity<CreditSummary> resp = controller.getUserCredits(token);
|
|
||||||
|
|
||||||
assertThat(resp.getBody()).isSameAs(expected);
|
|
||||||
verify(creditService).getCreditSummaryBySupabaseId(supabaseId.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void apiKeyUserWithoutSupabaseIdFallsBackToApiKeyLookup() {
|
|
||||||
User u = new User();
|
|
||||||
// No supabaseId set — covers self-hosted / OSS-style API-only users.
|
|
||||||
CreditSummary expected = creditSummary(7, 25);
|
|
||||||
when(creditService.getCreditSummaryByApiKey("apikey-no-supabase")).thenReturn(expected);
|
|
||||||
|
|
||||||
CreditController controller = new CreditController(creditService);
|
|
||||||
ApiKeyAuthenticationToken token =
|
|
||||||
new ApiKeyAuthenticationToken(u, "apikey-no-supabase", java.util.List.of());
|
|
||||||
|
|
||||||
ResponseEntity<CreditSummary> resp = controller.getUserCredits(token);
|
|
||||||
|
|
||||||
assertThat(resp.getBody()).isSameAs(expected);
|
|
||||||
verify(creditService).getCreditSummaryByApiKey(eq("apikey-no-supabase"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void apiKeyTokenWithoutUserPrincipalFallsBackToApiKeyLookup() {
|
|
||||||
// Edge: token wasn't constructed with a User principal. Should still attempt API-key
|
|
||||||
// lookup rather than throw.
|
|
||||||
CreditSummary expected = creditSummary(0, 0);
|
|
||||||
when(creditService.getCreditSummaryByApiKey("orphan-key")).thenReturn(expected);
|
|
||||||
|
|
||||||
CreditController controller = new CreditController(creditService);
|
|
||||||
ApiKeyAuthenticationToken token =
|
|
||||||
new ApiKeyAuthenticationToken("not-a-user", "orphan-key", java.util.List.of());
|
|
||||||
|
|
||||||
ResponseEntity<CreditSummary> resp = controller.getUserCredits(token);
|
|
||||||
|
|
||||||
assertThat(resp.getBody()).isNotNull();
|
|
||||||
verify(creditService).getCreditSummaryByApiKey("orphan-key");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static CreditSummary creditSummary(int remaining, int allocated) {
|
|
||||||
return new CreditSummary(remaining, allocated, 0, 0, remaining, null, null, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,725 +0,0 @@
|
|||||||
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
@@ -1,679 +0,0 @@
|
|||||||
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
@@ -1,806 +0,0 @@
|
|||||||
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() {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -174,7 +174,7 @@ class JobChargeServiceTest {
|
|||||||
assertThat(row.getJobId()).isEqualTo(newJob.getId());
|
assertThat(row.getJobId()).isEqualTo(newJob.getId());
|
||||||
assertThat(row.getPolicyId()).isEqualTo(policy.getId());
|
assertThat(row.getPolicyId()).isEqualTo(policy.getId());
|
||||||
assertThat(row.getPaygUnits()).isEqualTo(4);
|
assertThat(row.getPaygUnits()).isEqualTo(4);
|
||||||
// Legacy comparison not wired yet — zeroed until CreditService is wired in the follow-up.
|
// Legacy comparison removed with the legacy credit engine — always zeroed.
|
||||||
assertThat(row.getLegacyCreditsCharged()).isZero();
|
assertThat(row.getLegacyCreditsCharged()).isZero();
|
||||||
assertThat(row.getDiffPct()).isZero();
|
assertThat(row.getDiffPct()).isZero();
|
||||||
// PAYG analytics axis: billing_category + job_source are copied from the context so the
|
// PAYG analytics axis: billing_category + job_source are copied from the context so the
|
||||||
|
|||||||
+2
-3
@@ -26,9 +26,8 @@ import stirling.software.saas.payg.policy.admin.PolicyDtos.PolicyResponse;
|
|||||||
import stirling.software.saas.payg.policy.admin.PolicyDtos.TeamOverrideRequest;
|
import stirling.software.saas.payg.policy.admin.PolicyDtos.TeamOverrideRequest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests {@link PricingPolicyAdminController} as a plain Java unit (matching {@code
|
* Tests {@link PricingPolicyAdminController} as a plain Java unit (no MockMvc layer). Covers happy
|
||||||
* CreditControllerApiKeyTest}'s style — no MockMvc layer). Covers happy paths and the controller's
|
* paths and the controller's error mapping (4xx for validation, 404 for missing rows).
|
||||||
* error mapping (4xx for validation, 404 for missing rows).
|
|
||||||
*/
|
*/
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class PricingPolicyAdminControllerTest {
|
class PricingPolicyAdminControllerTest {
|
||||||
|
|||||||
+1
-7
@@ -46,7 +46,6 @@ class SupabaseAuthenticationFilterTest {
|
|||||||
@Mock private TeamService teamService;
|
@Mock private TeamService teamService;
|
||||||
@Mock private UserService userService;
|
@Mock private UserService userService;
|
||||||
@Mock private SupabaseUserService supabaseUserService;
|
@Mock private SupabaseUserService supabaseUserService;
|
||||||
@Mock private stirling.software.saas.service.CreditService creditService;
|
|
||||||
@Mock private stirling.software.saas.service.SaasTeamService saasTeamService;
|
@Mock private stirling.software.saas.service.SaasTeamService saasTeamService;
|
||||||
@Mock private JwtDecoder jwtDecoder;
|
@Mock private JwtDecoder jwtDecoder;
|
||||||
|
|
||||||
@@ -60,12 +59,7 @@ class SupabaseAuthenticationFilterTest {
|
|||||||
SecurityContextHolder.clearContext();
|
SecurityContextHolder.clearContext();
|
||||||
filter =
|
filter =
|
||||||
new SupabaseAuthenticationFilter(
|
new SupabaseAuthenticationFilter(
|
||||||
teamService,
|
teamService, userService, supabaseUserService, saasTeamService, jwtDecoder);
|
||||||
userService,
|
|
||||||
supabaseUserService,
|
|
||||||
creditService,
|
|
||||||
saasTeamService,
|
|
||||||
jwtDecoder);
|
|
||||||
request = new MockHttpServletRequest();
|
request = new MockHttpServletRequest();
|
||||||
response = new MockHttpServletResponse();
|
response = new MockHttpServletResponse();
|
||||||
chain = new MockFilterChain();
|
chain = new MockFilterChain();
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
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
+1
-1
@@ -11,7 +11,7 @@ import org.springframework.transaction.support.TransactionSynchronization;
|
|||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pins the contract {@code CreditService.scheduleStripeReportAfterCommit} relies on: a {@link
|
* Pins the contract {@code JobChargeService.close} relies on for its Stripe meter post: a {@link
|
||||||
* TransactionSynchronization#afterCommit()} hook fires after a successful commit and never on
|
* TransactionSynchronization#afterCommit()} hook fires after a successful commit and never on
|
||||||
* rollback.
|
* rollback.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,612 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
package stirling.software.saas.service;
|
package stirling.software.saas.service;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.verify;
|
||||||
import static org.mockito.Mockito.when;
|
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.DisplayName;
|
||||||
import org.junit.jupiter.api.Nested;
|
import org.junit.jupiter.api.Nested;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -25,20 +21,12 @@ import stirling.software.proprietary.security.database.repository.AuthorityRepos
|
|||||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
import stirling.software.proprietary.security.model.Authority;
|
import stirling.software.proprietary.security.model.Authority;
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
import stirling.software.saas.config.CreditsProperties;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for {@link UserRoleService}.
|
* Unit tests for {@link UserRoleService}.
|
||||||
*
|
*
|
||||||
* <p>The service is a thin orchestrator: it flips a user's {@link Authority} row, mirrors the role
|
* <p>The service is a thin orchestrator: it flips a user's {@link Authority} row and mirrors the
|
||||||
* into the denormalized {@code roleName} column, and (for the upgrade/downgrade helpers) resets the
|
* role into the denormalized {@code roleName} column. All collaborators are mocked.
|
||||||
* 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)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||||
@@ -46,33 +34,12 @@ class UserRoleServiceTest {
|
|||||||
|
|
||||||
@Mock private UserRepository userRepository;
|
@Mock private UserRepository userRepository;
|
||||||
@Mock private AuthorityRepository authorityRepository;
|
@Mock private AuthorityRepository authorityRepository;
|
||||||
@Mock private CreditService creditService;
|
|
||||||
|
|
||||||
private static final String ROLE_USER = Role.USER.getRoleId(); // "ROLE_USER"
|
private static final String ROLE_USER = Role.USER.getRoleId(); // "ROLE_USER"
|
||||||
private static final String ROLE_PRO_USER = Role.PRO_USER.getRoleId(); // "ROLE_PRO_USER"
|
private 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() {
|
||||||
private UserRoleService service(CreditsProperties props) {
|
return new UserRoleService(userRepository, authorityRepository);
|
||||||
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) {
|
private static User user(long id, String username, String currentRole) {
|
||||||
@@ -107,7 +74,7 @@ class UserRoleServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("flips the Authority row, mirrors roleName, and persists both")
|
@DisplayName("flips the Authority row, mirrors roleName, and persists both")
|
||||||
void flipsAuthorityAndMirrorsRoleName() {
|
void flipsAuthorityAndMirrorsRoleName() {
|
||||||
UserRoleService service = service(defaultProps());
|
UserRoleService service = service();
|
||||||
User u = user(42L, "[email protected]", ROLE_USER);
|
User u = user(42L, "[email protected]", ROLE_USER);
|
||||||
Authority auth = authority(ROLE_USER);
|
Authority auth = authority(ROLE_USER);
|
||||||
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
||||||
@@ -120,15 +87,12 @@ class UserRoleServiceTest {
|
|||||||
// Denormalized column mirrored on the User entity and saved.
|
// Denormalized column mirrored on the User entity and saved.
|
||||||
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_PRO_USER);
|
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_PRO_USER);
|
||||||
verify(userRepository).save(u);
|
verify(userRepository).save(u);
|
||||||
// No credit reset on the bare changeRole path.
|
|
||||||
verify(creditService, never())
|
|
||||||
.resetCycleAllocationForRoleChange(Mockito.anyLong(), Mockito.anyInt());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("persists the authority before the user (authority-first ordering)")
|
@DisplayName("persists the authority before the user (authority-first ordering)")
|
||||||
void persistsAuthorityBeforeUser() {
|
void persistsAuthorityBeforeUser() {
|
||||||
UserRoleService service = service(defaultProps());
|
UserRoleService service = service();
|
||||||
User u = user(42L, "[email protected]", ROLE_USER);
|
User u = user(42L, "[email protected]", ROLE_USER);
|
||||||
Authority auth = authority(ROLE_USER);
|
Authority auth = authority(ROLE_USER);
|
||||||
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
||||||
@@ -143,7 +107,7 @@ class UserRoleServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("looks the authority up by the user's numeric id")
|
@DisplayName("looks the authority up by the user's numeric id")
|
||||||
void looksUpAuthorityByUserId() {
|
void looksUpAuthorityByUserId() {
|
||||||
UserRoleService service = service(defaultProps());
|
UserRoleService service = service();
|
||||||
User u = user(99L, "[email protected]", ROLE_PRO_USER);
|
User u = user(99L, "[email protected]", ROLE_PRO_USER);
|
||||||
Authority auth = authority(ROLE_PRO_USER);
|
Authority auth = authority(ROLE_PRO_USER);
|
||||||
when(authorityRepository.findByUserId(99L)).thenReturn(auth);
|
when(authorityRepository.findByUserId(99L)).thenReturn(auth);
|
||||||
@@ -158,7 +122,7 @@ class UserRoleServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("setting the same role is a harmless no-op rewrite that still persists")
|
@DisplayName("setting the same role is a harmless no-op rewrite that still persists")
|
||||||
void sameRoleStillPersists() {
|
void sameRoleStillPersists() {
|
||||||
UserRoleService service = service(defaultProps());
|
UserRoleService service = service();
|
||||||
User u = user(42L, "[email protected]", ROLE_USER);
|
User u = user(42L, "[email protected]", ROLE_USER);
|
||||||
Authority auth = authority(ROLE_USER);
|
Authority auth = authority(ROLE_USER);
|
||||||
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
||||||
@@ -173,7 +137,7 @@ class UserRoleServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("tolerates an empty authorities set on the User (logging reads roles as \"\")")
|
@DisplayName("tolerates an empty authorities set on the User (logging reads roles as \"\")")
|
||||||
void emptyAuthoritiesSetIsTolerated() {
|
void emptyAuthoritiesSetIsTolerated() {
|
||||||
UserRoleService service = service(defaultProps());
|
UserRoleService service = service();
|
||||||
User u = user(42L, "[email protected]", null);
|
User u = user(42L, "[email protected]", null);
|
||||||
// getRolesAsString() joins an empty set -> "" ; must not NPE in the debug log.
|
// getRolesAsString() joins an empty set -> "" ; must not NPE in the debug log.
|
||||||
Authority auth = authority(null);
|
Authority auth = authority(null);
|
||||||
@@ -191,9 +155,9 @@ class UserRoleServiceTest {
|
|||||||
class DowngradeToFree {
|
class DowngradeToFree {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("sets ROLE_USER and resets credits to the configured FREE allocation (50)")
|
@DisplayName("sets ROLE_USER, mirrors roleName, and persists both")
|
||||||
void setsUserRoleAndResetsCreditsFromConfig() {
|
void setsUserRole() {
|
||||||
UserRoleService service = service(defaultProps());
|
UserRoleService service = service();
|
||||||
User u = user(42L, "[email protected]", ROLE_PRO_USER);
|
User u = user(42L, "[email protected]", ROLE_PRO_USER);
|
||||||
Authority auth = authority(ROLE_PRO_USER);
|
Authority auth = authority(ROLE_PRO_USER);
|
||||||
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
||||||
@@ -204,49 +168,6 @@ class UserRoleServiceTest {
|
|||||||
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_USER);
|
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_USER);
|
||||||
verify(authorityRepository).save(auth);
|
verify(authorityRepository).save(auth);
|
||||||
verify(userRepository).save(u);
|
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, "[email protected]", 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, "[email protected]", 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, "[email protected]", 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,9 +176,9 @@ class UserRoleServiceTest {
|
|||||||
class UpgradeToPro {
|
class UpgradeToPro {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("sets ROLE_PRO_USER and resets credits to the configured PRO allocation (500)")
|
@DisplayName("sets ROLE_PRO_USER, mirrors roleName, and persists both")
|
||||||
void setsProRoleAndResetsCreditsFromConfig() {
|
void setsProRole() {
|
||||||
UserRoleService service = service(defaultProps());
|
UserRoleService service = service();
|
||||||
User u = user(42L, "[email protected]", ROLE_USER);
|
User u = user(42L, "[email protected]", ROLE_USER);
|
||||||
Authority auth = authority(ROLE_USER);
|
Authority auth = authority(ROLE_USER);
|
||||||
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
when(authorityRepository.findByUserId(42L)).thenReturn(auth);
|
||||||
@@ -268,111 +189,6 @@ class UserRoleServiceTest {
|
|||||||
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_PRO_USER);
|
assertThat(mirroredRoleName(u)).isEqualTo(ROLE_PRO_USER);
|
||||||
verify(authorityRepository).save(auth);
|
verify(authorityRepository).save(auth);
|
||||||
verify(userRepository).save(u);
|
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, "[email protected]", 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, "[email protected]", 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, "[email protected]", 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,348 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2756,7 +2756,6 @@ tooltip = "Pick colour from screen"
|
|||||||
title = "Choose colour"
|
title = "Choose colour"
|
||||||
|
|
||||||
[common]
|
[common]
|
||||||
available = "available"
|
|
||||||
back = "Back"
|
back = "Back"
|
||||||
cancel = "Cancel"
|
cancel = "Cancel"
|
||||||
close = "Close"
|
close = "Close"
|
||||||
@@ -2774,7 +2773,6 @@ previous = "Previous"
|
|||||||
refresh = "Refresh"
|
refresh = "Refresh"
|
||||||
retry = "Retry"
|
retry = "Retry"
|
||||||
save = "Save"
|
save = "Save"
|
||||||
used = "used"
|
|
||||||
|
|
||||||
[compare]
|
[compare]
|
||||||
clearSelected = "Clear selected"
|
clearSelected = "Clear selected"
|
||||||
@@ -3020,9 +3018,7 @@ title = "Upgrade Guest Account"
|
|||||||
upgradeButton = "Upgrade Account"
|
upgradeButton = "Upgrade Account"
|
||||||
|
|
||||||
[config.apiKeys]
|
[config.apiKeys]
|
||||||
chartAriaLabel = "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}"
|
|
||||||
copyKeyAriaLabel = "Copy API key"
|
copyKeyAriaLabel = "Copy API key"
|
||||||
creditsRemaining = "Credits Remaining"
|
|
||||||
description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one."
|
description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one."
|
||||||
docsDescription = "Learn more about integrating with Stirling PDF:"
|
docsDescription = "Learn more about integrating with Stirling PDF:"
|
||||||
docsLink = "API Documentation"
|
docsLink = "API Documentation"
|
||||||
@@ -3030,14 +3026,9 @@ docsTitle = "API Documentation"
|
|||||||
generateError = "We couldn't generate your API key."
|
generateError = "We couldn't generate your API key."
|
||||||
goToAccount = "Go to Account"
|
goToAccount = "Go to Account"
|
||||||
guestInfo = "Guest users do not receive API keys. Create an account to get an API key you can use in your applications."
|
guestInfo = "Guest users do not receive API keys. Create an account to get an API key you can use in your applications."
|
||||||
includedCredits = "Included credits"
|
|
||||||
intro = "Use your API key to programmatically access Stirling PDF's processing capabilities."
|
intro = "Use your API key to programmatically access Stirling PDF's processing capabilities."
|
||||||
label = "API Key"
|
label = "API Key"
|
||||||
lastApiUse = "Last API Use"
|
|
||||||
nextReset = "Next Reset"
|
|
||||||
overlayMessage = "Generate a key to see credits and available credits"
|
|
||||||
publicKeyAriaLabel = "Public API key"
|
publicKeyAriaLabel = "Public API key"
|
||||||
purchasedCredits = "Purchased credits"
|
|
||||||
refreshAriaLabel = "Refresh API key"
|
refreshAriaLabel = "Refresh API key"
|
||||||
schemaLink = "API Schema Reference"
|
schemaLink = "API Schema Reference"
|
||||||
usage = "Include this key in the X-API-KEY header with all API requests."
|
usage = "Include this key in the X-API-KEY header with all API requests."
|
||||||
|
|||||||
@@ -2730,7 +2730,6 @@ tooltip = "Pick color from screen"
|
|||||||
title = "Choose color"
|
title = "Choose color"
|
||||||
|
|
||||||
[common]
|
[common]
|
||||||
available = "available"
|
|
||||||
back = "Back"
|
back = "Back"
|
||||||
cancel = "Cancel"
|
cancel = "Cancel"
|
||||||
close = "Close"
|
close = "Close"
|
||||||
@@ -2748,7 +2747,6 @@ previous = "Previous"
|
|||||||
refresh = "Refresh"
|
refresh = "Refresh"
|
||||||
retry = "Retry"
|
retry = "Retry"
|
||||||
save = "Save"
|
save = "Save"
|
||||||
used = "used"
|
|
||||||
|
|
||||||
[compare]
|
[compare]
|
||||||
clearSelected = "Clear selected"
|
clearSelected = "Clear selected"
|
||||||
@@ -2994,9 +2992,7 @@ title = "Upgrade Guest Account"
|
|||||||
upgradeButton = "Upgrade Account"
|
upgradeButton = "Upgrade Account"
|
||||||
|
|
||||||
[config.apiKeys]
|
[config.apiKeys]
|
||||||
chartAriaLabel = "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}"
|
|
||||||
copyKeyAriaLabel = "Copy API key"
|
copyKeyAriaLabel = "Copy API key"
|
||||||
creditsRemaining = "Credits Remaining"
|
|
||||||
description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one."
|
description = "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one."
|
||||||
docsDescription = "Learn more about integrating with Stirling PDF:"
|
docsDescription = "Learn more about integrating with Stirling PDF:"
|
||||||
docsLink = "API Documentation"
|
docsLink = "API Documentation"
|
||||||
@@ -3004,14 +3000,9 @@ docsTitle = "API Documentation"
|
|||||||
generateError = "We couldn't generate your API key."
|
generateError = "We couldn't generate your API key."
|
||||||
goToAccount = "Go to Account"
|
goToAccount = "Go to Account"
|
||||||
guestInfo = "Guest users do not receive API keys. Create an account to get an API key you can use in your applications."
|
guestInfo = "Guest users do not receive API keys. Create an account to get an API key you can use in your applications."
|
||||||
includedCredits = "Included credits"
|
|
||||||
intro = "Use your API key to programmatically access Stirling PDF's processing capabilities."
|
intro = "Use your API key to programmatically access Stirling PDF's processing capabilities."
|
||||||
label = "API Key"
|
label = "API Key"
|
||||||
lastApiUse = "Last API Use"
|
|
||||||
nextReset = "Next Reset"
|
|
||||||
overlayMessage = "Generate a key to see credits and available credits"
|
|
||||||
publicKeyAriaLabel = "Public API key"
|
publicKeyAriaLabel = "Public API key"
|
||||||
purchasedCredits = "Purchased credits"
|
|
||||||
refreshAriaLabel = "Refresh API key"
|
refreshAriaLabel = "Refresh API key"
|
||||||
schemaLink = "API Schema Reference"
|
schemaLink = "API Schema Reference"
|
||||||
usage = "Include this key in the X-API-KEY header with all API requests."
|
usage = "Include this key in the X-API-KEY header with all API requests."
|
||||||
|
|||||||
@@ -14,12 +14,6 @@ import type {
|
|||||||
User as SupabaseUser,
|
User as SupabaseUser,
|
||||||
AuthError,
|
AuthError,
|
||||||
} from "@supabase/supabase-js";
|
} from "@supabase/supabase-js";
|
||||||
import {
|
|
||||||
CreditSummary,
|
|
||||||
SubscriptionInfo,
|
|
||||||
CreditCheckResult,
|
|
||||||
} from "@app/types/credits";
|
|
||||||
import { setGlobalCreditUpdateCallback } from "@app/services/apiClient";
|
|
||||||
import { synchronizeUserUpgrade } from "@app/services/userService";
|
import { synchronizeUserUpgrade } from "@app/services/userService";
|
||||||
import {
|
import {
|
||||||
syncOAuthAvatar,
|
syncOAuthAvatar,
|
||||||
@@ -70,17 +64,11 @@ interface AuthContextType {
|
|||||||
isAnonymous: boolean;
|
isAnonymous: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: AuthError | null;
|
error: AuthError | null;
|
||||||
creditBalance: number | null;
|
|
||||||
subscription: SubscriptionInfo | null;
|
|
||||||
creditSummary: CreditSummary | null;
|
|
||||||
isPro: boolean | null;
|
isPro: boolean | null;
|
||||||
profilePictureUrl: string | null;
|
profilePictureUrl: string | null;
|
||||||
profilePictureMetadata: ProfilePictureMetadata | null;
|
profilePictureMetadata: ProfilePictureMetadata | null;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
refreshSession: () => Promise<void>;
|
refreshSession: () => Promise<void>;
|
||||||
hasSufficientCredits: (requiredCredits: number) => CreditCheckResult;
|
|
||||||
updateCredits: (newBalance: number) => void;
|
|
||||||
refreshCredits: () => Promise<void>;
|
|
||||||
refreshProStatus: () => Promise<void>;
|
refreshProStatus: () => Promise<void>;
|
||||||
refreshProfilePicture: () => Promise<void>;
|
refreshProfilePicture: () => Promise<void>;
|
||||||
refreshProfilePictureMetadata: () => Promise<void>;
|
refreshProfilePictureMetadata: () => Promise<void>;
|
||||||
@@ -93,21 +81,11 @@ const AuthContext = createContext<AuthContextType>({
|
|||||||
isAnonymous: false,
|
isAnonymous: false,
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
creditBalance: null,
|
|
||||||
subscription: null,
|
|
||||||
creditSummary: null,
|
|
||||||
isPro: null,
|
isPro: null,
|
||||||
profilePictureUrl: null,
|
profilePictureUrl: null,
|
||||||
profilePictureMetadata: null,
|
profilePictureMetadata: null,
|
||||||
signOut: async () => {},
|
signOut: async () => {},
|
||||||
refreshSession: async () => {},
|
refreshSession: async () => {},
|
||||||
hasSufficientCredits: () => ({
|
|
||||||
hasSufficientCredits: false,
|
|
||||||
currentBalance: 0,
|
|
||||||
requiredCredits: 0,
|
|
||||||
}),
|
|
||||||
updateCredits: () => {},
|
|
||||||
refreshCredits: async () => {},
|
|
||||||
refreshProStatus: async () => {},
|
refreshProStatus: async () => {},
|
||||||
refreshProfilePicture: async () => {},
|
refreshProfilePicture: async () => {},
|
||||||
refreshProfilePictureMetadata: async () => {},
|
refreshProfilePictureMetadata: async () => {},
|
||||||
@@ -117,13 +95,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [session, setSession] = useState<Session | null>(null);
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<AuthError | null>(null);
|
const [error, setError] = useState<AuthError | null>(null);
|
||||||
const [creditBalance, setCreditBalance] = useState<number | null>(null);
|
|
||||||
const [subscription, setSubscription] = useState<SubscriptionInfo | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [creditSummary, setCreditSummary] = useState<CreditSummary | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [isPro, setIsPro] = useState<boolean | null>(null);
|
const [isPro, setIsPro] = useState<boolean | null>(null);
|
||||||
const [profilePictureUrl, setProfilePictureUrl] = useState<string | null>(
|
const [profilePictureUrl, setProfilePictureUrl] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
@@ -131,19 +102,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [profilePictureMetadata, setProfilePictureMetadata] =
|
const [profilePictureMetadata, setProfilePictureMetadata] =
|
||||||
useState<ProfilePictureMetadata | null>(null);
|
useState<ProfilePictureMetadata | null>(null);
|
||||||
|
|
||||||
// Legacy weekly-credits feed (GET /api/v1/credits) is dead. PAYG replaces it via
|
|
||||||
// useWallet() reading /api/v1/payg/wallet. Symbols are kept as no-ops so existing
|
|
||||||
// consumers of useAuth() that still destructure creditBalance / refreshCredits
|
|
||||||
// compile cleanly; values just stay null forever and refreshCredits is a noop.
|
|
||||||
// _ underscore on the param keeps the public signature stable for callers.
|
|
||||||
const fetchCredits = useCallback(async (_sessionToUse?: Session | null) => {
|
|
||||||
/* legacy credit fetch removed — see comment above */
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const refreshCredits = useCallback(async () => {
|
|
||||||
/* legacy credit refresh removed — useWallet() replaces this */
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchProStatus = useCallback(
|
const fetchProStatus = useCallback(
|
||||||
async (sessionToUse?: Session | null) => {
|
async (sessionToUse?: Session | null) => {
|
||||||
const currentSession = sessionToUse ?? session;
|
const currentSession = sessionToUse ?? session;
|
||||||
@@ -289,46 +247,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
await fetchProfilePictureMetadata();
|
await fetchProfilePictureMetadata();
|
||||||
}, [fetchProfilePictureMetadata]);
|
}, [fetchProfilePictureMetadata]);
|
||||||
|
|
||||||
const updateCredits = useCallback(
|
|
||||||
(newBalance: number) => {
|
|
||||||
console.debug("[Auth Debug] Updating credit balance:", {
|
|
||||||
from: creditBalance,
|
|
||||||
to: newBalance,
|
|
||||||
});
|
|
||||||
setCreditBalance(newBalance);
|
|
||||||
// Also update the creditSummary if it exists
|
|
||||||
if (creditSummary) {
|
|
||||||
const updatedSummary: CreditSummary = {
|
|
||||||
...creditSummary,
|
|
||||||
creditsRemaining: newBalance,
|
|
||||||
currentCredits: newBalance,
|
|
||||||
};
|
|
||||||
setCreditSummary(updatedSummary);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[creditSummary],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasSufficientCredits = useCallback(
|
|
||||||
(requiredCredits: number): CreditCheckResult => {
|
|
||||||
const currentBalance = creditBalance ?? 0;
|
|
||||||
const hasSufficient = currentBalance >= requiredCredits;
|
|
||||||
console.debug("[Auth Debug] Credit check:", {
|
|
||||||
requiredCredits,
|
|
||||||
currentBalance,
|
|
||||||
hasSufficient,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasSufficientCredits: hasSufficient,
|
|
||||||
currentBalance,
|
|
||||||
requiredCredits,
|
|
||||||
shortfall: hasSufficient ? undefined : requiredCredits - currentBalance,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[creditBalance],
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshSession = async () => {
|
const refreshSession = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -372,11 +290,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set up global credit update callback
|
|
||||||
useEffect(() => {
|
|
||||||
setGlobalCreditUpdateCallback(updateCredits);
|
|
||||||
}, [updateCredits]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
@@ -399,7 +312,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
setSession(data.session);
|
setSession(data.session);
|
||||||
|
|
||||||
// Fetch credits, pro status, profile picture metadata, and profile picture using the session from the response
|
// Fetch pro status, profile picture metadata, and profile picture using the session from the response
|
||||||
if (data.session?.user) {
|
if (data.session?.user) {
|
||||||
// Sync OAuth avatar in background; fetch the picture once the
|
// Sync OAuth avatar in background; fetch the picture once the
|
||||||
// sync settles instead of guessing with a fixed delay.
|
// sync settles instead of guessing with a fixed delay.
|
||||||
@@ -413,7 +326,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
})
|
})
|
||||||
.then(() => fetchProfilePicture(data.session));
|
.then(() => fetchProfilePicture(data.session));
|
||||||
|
|
||||||
await fetchCredits(data.session);
|
|
||||||
await fetchProStatus(data.session);
|
await fetchProStatus(data.session);
|
||||||
await fetchProfilePictureMetadata(data.session);
|
await fetchProfilePictureMetadata(data.session);
|
||||||
}
|
}
|
||||||
@@ -458,10 +370,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// Additional handling for specific events
|
// Additional handling for specific events
|
||||||
if (event === "SIGNED_OUT") {
|
if (event === "SIGNED_OUT") {
|
||||||
console.debug("[Auth Debug] User signed out, clearing session");
|
console.debug("[Auth Debug] User signed out, clearing session");
|
||||||
// Clear credit data, pro status, profile picture, and metadata on sign out
|
// Clear pro status, profile picture, and metadata on sign out
|
||||||
setCreditBalance(null);
|
|
||||||
setCreditSummary(null);
|
|
||||||
setSubscription(null);
|
|
||||||
setIsPro(null);
|
setIsPro(null);
|
||||||
setProfilePictureUrl(null);
|
setProfilePictureUrl(null);
|
||||||
setProfilePictureMetadata(null);
|
setProfilePictureMetadata(null);
|
||||||
@@ -490,7 +399,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
// Fetch user data in parallel
|
// Fetch user data in parallel
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetchCredits(newSession),
|
|
||||||
fetchProStatus(newSession),
|
fetchProStatus(newSession),
|
||||||
fetchProfilePictureMetadata(newSession),
|
fetchProfilePictureMetadata(newSession),
|
||||||
]).then(() => {
|
]).then(() => {
|
||||||
@@ -506,10 +414,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
} else if (event === "TOKEN_REFRESHED") {
|
} else if (event === "TOKEN_REFRESHED") {
|
||||||
console.debug("[Auth Debug] Token refreshed");
|
console.debug("[Auth Debug] Token refreshed");
|
||||||
// Optionally refresh credits, pro status, profile picture metadata, and profile picture on token refresh
|
// Optionally refresh pro status, profile picture metadata, and profile picture on token refresh
|
||||||
if (newSession?.user) {
|
if (newSession?.user) {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetchCredits(newSession),
|
|
||||||
fetchProStatus(newSession),
|
fetchProStatus(newSession),
|
||||||
fetchProfilePictureMetadata(newSession),
|
fetchProfilePictureMetadata(newSession),
|
||||||
fetchProfilePicture(newSession),
|
fetchProfilePicture(newSession),
|
||||||
@@ -547,10 +454,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
"[Auth Debug] User upgrade synchronized successfully",
|
"[Auth Debug] User upgrade synchronized successfully",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refresh credits, pro status, profile picture metadata, and profile picture after upgrade
|
// Refresh pro status, profile picture metadata, and profile picture after upgrade
|
||||||
if (newSession?.user) {
|
if (newSession?.user) {
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
fetchCredits(newSession),
|
|
||||||
fetchProStatus(newSession),
|
fetchProStatus(newSession),
|
||||||
fetchProfilePictureMetadata(newSession),
|
fetchProfilePictureMetadata(newSession),
|
||||||
fetchProfilePicture(newSession),
|
fetchProfilePicture(newSession),
|
||||||
@@ -589,17 +495,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
isAnonymous: Boolean(user?.is_anonymous),
|
isAnonymous: Boolean(user?.is_anonymous),
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
creditBalance,
|
|
||||||
subscription,
|
|
||||||
creditSummary,
|
|
||||||
isPro,
|
isPro,
|
||||||
profilePictureUrl,
|
profilePictureUrl,
|
||||||
profilePictureMetadata,
|
profilePictureMetadata,
|
||||||
signOut,
|
signOut,
|
||||||
refreshSession,
|
refreshSession,
|
||||||
hasSufficientCredits,
|
|
||||||
updateCredits,
|
|
||||||
refreshCredits,
|
|
||||||
refreshProStatus,
|
refreshProStatus,
|
||||||
refreshProfilePicture,
|
refreshProfilePicture,
|
||||||
refreshProfilePictureMetadata,
|
refreshProfilePictureMetadata,
|
||||||
|
|||||||
@@ -17,19 +17,18 @@ import type { TeamAuth } from "@cloud/auth/teamSession";
|
|||||||
export type { TeamAuth } from "@cloud/auth/teamSession";
|
export type { TeamAuth } from "@cloud/auth/teamSession";
|
||||||
|
|
||||||
export function useTeamAuth(): TeamAuth {
|
export function useTeamAuth(): TeamAuth {
|
||||||
const { user, refreshCredits, refreshSession } = useAuth();
|
const { user, refreshSession } = useAuth();
|
||||||
|
|
||||||
// Teams require a signed-in, non-anonymous user — anonymous (guest) sessions
|
// Teams require a signed-in, non-anonymous user — anonymous (guest) sessions
|
||||||
// never load or manage teams.
|
// never load or manage teams.
|
||||||
const canUseTeams = !!user && !isUserAnonymous(user);
|
const canUseTeams = !!user && !isUserAnonymous(user);
|
||||||
|
|
||||||
// After a membership change the user's billing tier may have changed, so
|
// After a membership change the user's billing tier may have changed, so
|
||||||
// refresh credits + the Supabase session (mirrors the pre-move accept/leave
|
// refresh the Supabase session (mirrors the pre-move accept/leave flow in
|
||||||
// flow in saas SaaSTeamContext).
|
// saas SaaSTeamContext).
|
||||||
const refreshAfterMembershipChange = useCallback(async () => {
|
const refreshAfterMembershipChange = useCallback(async () => {
|
||||||
await refreshCredits();
|
|
||||||
await refreshSession();
|
await refreshSession();
|
||||||
}, [refreshCredits, refreshSession]);
|
}, [refreshSession]);
|
||||||
|
|
||||||
return { canUseTeams, refreshAfterMembershipChange };
|
return { canUseTeams, refreshAfterMembershipChange };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface AppConfigModalProps {
|
|||||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||||
|
|
||||||
const { signOut, user, creditBalance, refreshCredits } = useAuth();
|
const { signOut, user } = useAuth();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [active, setActive] = useState<NavKey>("overview");
|
const [active, setActive] = useState<NavKey>("overview");
|
||||||
@@ -95,35 +95,6 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
|||||||
window.removeEventListener("appConfig:overlay", handler as EventListener);
|
window.removeEventListener("appConfig:overlay", handler as EventListener);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// When the modal opens to Plan, proactively refresh credits and log values
|
|
||||||
useEffect(() => {
|
|
||||||
if (!opened) return;
|
|
||||||
if (active !== "plan") return;
|
|
||||||
console.log(
|
|
||||||
"[AppConfigModal] Opening Plan section. Current creditBalance:",
|
|
||||||
creditBalance,
|
|
||||||
);
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
await refreshCredits();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(
|
|
||||||
"[AppConfigModal] Failed to refresh credits on Plan open:",
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [opened, active]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!opened) return;
|
|
||||||
if (active !== "plan") return;
|
|
||||||
console.log(
|
|
||||||
"[AppConfigModal] Credit balance updated while viewing Plan:",
|
|
||||||
creditBalance,
|
|
||||||
);
|
|
||||||
}, [opened, active, creditBalance]);
|
|
||||||
|
|
||||||
const colors = useMemo(
|
const colors = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
navBg: "var(--modal-nav-bg)",
|
navBg: "var(--modal-nav-bg)",
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Anchor, Group, Stack, Text, Button, Paper } from "@mantine/core";
|
import { Anchor, Group, Stack, Text, Button, Paper } from "@mantine/core";
|
||||||
import UsageSection from "@app/components/shared/config/configSections/apiKeys/UsageSection";
|
|
||||||
import ApiKeySection from "@app/components/shared/config/configSections/apiKeys/ApiKeySection";
|
import ApiKeySection from "@app/components/shared/config/configSections/apiKeys/ApiKeySection";
|
||||||
import RefreshModal from "@app/components/shared/config/configSections/apiKeys/RefreshModal";
|
import RefreshModal from "@app/components/shared/config/configSections/apiKeys/RefreshModal";
|
||||||
import { useCredits } from "@app/components/shared/config/configSections/apiKeys/hooks/useCredits";
|
|
||||||
import useApiKey from "@app/components/shared/config/configSections/apiKeys/hooks/useApiKey";
|
import useApiKey from "@app/components/shared/config/configSections/apiKeys/hooks/useApiKey";
|
||||||
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
|
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -17,7 +15,6 @@ export default function ApiKeys() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
||||||
|
|
||||||
const { data: credits, isLoading: creditsLoading } = useCredits();
|
|
||||||
const {
|
const {
|
||||||
apiKey,
|
apiKey,
|
||||||
isLoading: apiKeyLoading,
|
isLoading: apiKeyLoading,
|
||||||
@@ -25,7 +22,6 @@ export default function ApiKeys() {
|
|||||||
isRefreshing,
|
isRefreshing,
|
||||||
error: apiKeyError,
|
error: apiKeyError,
|
||||||
refetch,
|
refetch,
|
||||||
hasAttempted,
|
|
||||||
} = useApiKey();
|
} = useApiKey();
|
||||||
|
|
||||||
const copy = async (text: string, tag: string) => {
|
const copy = async (text: string, tag: string) => {
|
||||||
@@ -53,22 +49,8 @@ export default function ApiKeys() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showUsage = Boolean(credits);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={20} p={0}>
|
<Stack gap={20} p={0}>
|
||||||
{showUsage && (
|
|
||||||
<UsageSection
|
|
||||||
apiUsage={credits!}
|
|
||||||
obscured={Boolean(!apiKey && hasAttempted && !isAnonymous)}
|
|
||||||
overlayMessage={t(
|
|
||||||
"config.apiKeys.overlayMessage",
|
|
||||||
"Generate a key to see credits and available credits",
|
|
||||||
)}
|
|
||||||
loading={creditsLoading}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isAnonymous && apiKeyError && (
|
{!isAnonymous && apiKeyError && (
|
||||||
<Text size="sm" c="red.5">
|
<Text size="sm" c="red.5">
|
||||||
{t(
|
{t(
|
||||||
|
|||||||
-159
@@ -1,159 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Paper, Stack, Group, Text, Divider } from "@mantine/core";
|
|
||||||
import StackedBarChart from "@app/components/shared/charts/StackedBarChart";
|
|
||||||
import { FractionData } from "@app/types/charts";
|
|
||||||
import { ApiCredits as ApiUsage } from "@app/types/credits";
|
|
||||||
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
|
|
||||||
import { formatUTC } from "@app/components/shared/utils/date";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
// Using shared ApiCredits type as ApiUsage
|
|
||||||
|
|
||||||
interface UsageSectionProps {
|
|
||||||
apiUsage: ApiUsage;
|
|
||||||
obscured?: boolean;
|
|
||||||
overlayMessage?: string;
|
|
||||||
loading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UsageSection({
|
|
||||||
apiUsage,
|
|
||||||
obscured,
|
|
||||||
overlayMessage,
|
|
||||||
loading,
|
|
||||||
}: UsageSectionProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const weeklyUsed =
|
|
||||||
apiUsage.weeklyCreditsAllocated - apiUsage.weeklyCreditsRemaining;
|
|
||||||
const boughtUsed =
|
|
||||||
apiUsage.totalBoughtCredits - apiUsage.boughtCreditsRemaining;
|
|
||||||
|
|
||||||
// Totals for overall usage visualization
|
|
||||||
const totalRemaining = Math.max(apiUsage.totalAvailableCredits, 0);
|
|
||||||
|
|
||||||
const formatDate = (iso: string, withTime: boolean) =>
|
|
||||||
formatUTC(iso, withTime);
|
|
||||||
|
|
||||||
// Prepare data for the stacked bar chart
|
|
||||||
const fractions: FractionData[] = [
|
|
||||||
{
|
|
||||||
name: t("config.apiKeys.includedCredits", "Included credits"),
|
|
||||||
numerator: Math.max(0, weeklyUsed),
|
|
||||||
denominator: Math.max(0, apiUsage.weeklyCreditsAllocated),
|
|
||||||
numeratorLabel: t("common.used", "used"),
|
|
||||||
denominatorLabel: t("common.available", "available"),
|
|
||||||
color: "var(--usage-weekly-active)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("config.apiKeys.purchasedCredits", "Purchased credits"),
|
|
||||||
numerator: Math.max(0, boughtUsed),
|
|
||||||
denominator: Math.max(0, apiUsage.totalBoughtCredits),
|
|
||||||
numeratorLabel: t("common.used", "used"),
|
|
||||||
denominatorLabel: t("common.available", "available"),
|
|
||||||
color: "var(--usage-bought-active)",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: "relative" }}>
|
|
||||||
<Paper
|
|
||||||
radius="md"
|
|
||||||
p={18}
|
|
||||||
style={{
|
|
||||||
background: "var(--api-keys-card-bg)",
|
|
||||||
border: "1px solid var(--api-keys-card-border)",
|
|
||||||
boxShadow: "0 2px 8px var(--api-keys-card-shadow)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
gap={12}
|
|
||||||
style={{
|
|
||||||
fontFamily:
|
|
||||||
'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Text fw={500}>
|
|
||||||
{t("config.apiKeys.creditsRemaining", "Credits Remaining")}:{" "}
|
|
||||||
{loading ? (
|
|
||||||
<SkeletonLoader type="block" width={40} height={14} />
|
|
||||||
) : (
|
|
||||||
totalRemaining
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<StackedBarChart
|
|
||||||
fractions={fractions}
|
|
||||||
width={640}
|
|
||||||
height={22}
|
|
||||||
showLegend={true}
|
|
||||||
tooltipPosition="top"
|
|
||||||
loading={Boolean(loading)}
|
|
||||||
animate={!loading}
|
|
||||||
animationDurationMs={900}
|
|
||||||
ariaLabel={t("config.apiKeys.chartAriaLabel", {
|
|
||||||
includedUsed: Math.max(0, weeklyUsed),
|
|
||||||
includedTotal: Math.max(0, apiUsage.weeklyCreditsAllocated),
|
|
||||||
purchasedUsed: Math.max(0, boughtUsed),
|
|
||||||
purchasedTotal: Math.max(0, apiUsage.totalBoughtCredits),
|
|
||||||
defaultValue:
|
|
||||||
"Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}",
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Divider my={4} />
|
|
||||||
|
|
||||||
<Group justify="space-between" wrap="wrap">
|
|
||||||
<Group gap="lg">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("config.apiKeys.nextReset", "Next Reset")}:{" "}
|
|
||||||
{loading ? (
|
|
||||||
<SkeletonLoader type="block" width={120} height={12} />
|
|
||||||
) : (
|
|
||||||
formatDate(apiUsage.weeklyResetDate, false)
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("config.apiKeys.lastApiUse", "Last API Use")}:{" "}
|
|
||||||
{loading ? (
|
|
||||||
<SkeletonLoader type="block" width={160} height={12} />
|
|
||||||
) : (
|
|
||||||
formatDate(apiUsage.lastApiUsage, true)
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{obscured && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
textAlign: "center",
|
|
||||||
padding: 16,
|
|
||||||
color: "var(--mantine-color-text)",
|
|
||||||
background: "rgba(16, 18, 27, 0.55)",
|
|
||||||
backdropFilter: "blur(6px)",
|
|
||||||
WebkitBackdropFilter: "blur(6px)",
|
|
||||||
borderRadius: 12,
|
|
||||||
border: "1px solid rgba(255,255,255,0.06)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{overlayMessage ||
|
|
||||||
t(
|
|
||||||
"config.apiKeys.overlayMessage",
|
|
||||||
"Generate a key to see credits and available credits",
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-40
@@ -1,40 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { useAuth } from "@app/auth/UseSession";
|
|
||||||
import { ApiCredits } from "@app/types/credits";
|
|
||||||
import { isUserAnonymous } from "@app/auth/supabase";
|
|
||||||
|
|
||||||
export function useCredits() {
|
|
||||||
const { session, loading, user } = useAuth();
|
|
||||||
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
|
||||||
// Gutted hook (legacy /api/v1/credits is dead) — these stay at their initial values; only
|
|
||||||
// hasAttempted toggles, so the rest have no setters.
|
|
||||||
const [data] = useState<ApiCredits | null>(null);
|
|
||||||
const [isLoading] = useState<boolean>(false);
|
|
||||||
const [error] = useState<Error | null>(null);
|
|
||||||
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// Legacy weekly-credits endpoint (/api/v1/credits) is dead. PAYG replaces this — the
|
|
||||||
// wallet hook (useWallet) carries the equivalent state via /api/v1/payg/wallet. The
|
|
||||||
// hook surface is preserved for the ApiKeys page consumer (it destructures `data`,
|
|
||||||
// `isLoading`, `error`, `refetch`, `hasAttempted`), but `data` always stays null —
|
|
||||||
// the consumer's usage widget will render its "no data" state.
|
|
||||||
const fetchCredits = useCallback(async () => {
|
|
||||||
setHasAttempted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loading && session && !hasAttempted && !isAnonymous) {
|
|
||||||
setHasAttempted(true);
|
|
||||||
}
|
|
||||||
}, [loading, session, hasAttempted, isAnonymous]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
refetch: fetchCredits,
|
|
||||||
hasAttempted,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default useCredits;
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { useAuth } from "@app/auth/UseSession";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for credit management and checking in tools.
|
|
||||||
* Provides easy access to credit balance, subscription info, and validation functions.
|
|
||||||
*/
|
|
||||||
export const useCredits = () => {
|
|
||||||
const {
|
|
||||||
creditBalance,
|
|
||||||
subscription,
|
|
||||||
creditSummary,
|
|
||||||
isPro,
|
|
||||||
hasSufficientCredits,
|
|
||||||
updateCredits,
|
|
||||||
refreshCredits,
|
|
||||||
} = useAuth();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user-friendly credit status message
|
|
||||||
*/
|
|
||||||
const getCreditStatusMessage = (): string => {
|
|
||||||
if (creditBalance === 0) {
|
|
||||||
return "No credits remaining";
|
|
||||||
}
|
|
||||||
if (creditBalance === null) {
|
|
||||||
return "Credits loading...";
|
|
||||||
}
|
|
||||||
return `${creditBalance} credits available`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
creditBalance,
|
|
||||||
subscription,
|
|
||||||
creditSummary,
|
|
||||||
isPro,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
refreshCredits,
|
|
||||||
updateCredits,
|
|
||||||
|
|
||||||
// Utilities
|
|
||||||
getCreditStatusMessage,
|
|
||||||
hasSufficientCredits,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,24 +1,12 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { supabase } from "@app/auth/supabase";
|
import { supabase } from "@app/auth/supabase";
|
||||||
import { handleHttpError } from "@app/services/httpErrorHandler";
|
import { handleHttpError } from "@app/services/httpErrorHandler";
|
||||||
import { alert } from "@app/components/toast";
|
|
||||||
import { openPlanSettings } from "@app/utils/appSettings";
|
|
||||||
import {
|
import {
|
||||||
classifyPaygError,
|
classifyPaygError,
|
||||||
handlePaygError,
|
handlePaygError,
|
||||||
} from "@app/services/paygErrorInterceptor";
|
} from "@app/services/paygErrorInterceptor";
|
||||||
import { withBasePath } from "@app/constants/app";
|
import { withBasePath } from "@app/constants/app";
|
||||||
|
|
||||||
// Global credit update callback - will be set by the AuthProvider
|
|
||||||
let globalCreditUpdateCallback: ((credits: number) => void) | null = null;
|
|
||||||
|
|
||||||
// Function to set the global credit update callback
|
|
||||||
export const setGlobalCreditUpdateCallback = (
|
|
||||||
callback: (credits: number) => void,
|
|
||||||
) => {
|
|
||||||
globalCreditUpdateCallback = callback;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper: decode base64url JWT payload safely
|
// Helper: decode base64url JWT payload safely
|
||||||
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
@@ -46,20 +34,6 @@ const apiClient = axios.create({
|
|||||||
responseType: "json",
|
responseType: "json",
|
||||||
});
|
});
|
||||||
|
|
||||||
const LOW_CREDIT_THRESHOLD = 10;
|
|
||||||
function notifyLowCredits(credits: number) {
|
|
||||||
const title = "Credit balance low";
|
|
||||||
const body = `You have ${credits} credits remaining.`;
|
|
||||||
alert({
|
|
||||||
alertType: "warning",
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
buttonText: "Top up",
|
|
||||||
buttonCallback: () => openPlanSettings(),
|
|
||||||
isPersistentPopup: true,
|
|
||||||
location: "bottom-right",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Request interceptor to add JWT token to all requests
|
// Request interceptor to add JWT token to all requests
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
async (config) => {
|
async (config) => {
|
||||||
@@ -136,34 +110,9 @@ function refreshSessionOnce(): ReturnType<typeof supabase.auth.refreshSession> {
|
|||||||
return inFlightRefresh;
|
return inFlightRefresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response interceptor for handling token refresh and credit updates
|
// Response interceptor for handling token refresh
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => {
|
(response) => response,
|
||||||
// Check for X-Credits-Remaining header and update credits automatically
|
|
||||||
const creditsRemaining = response.headers["x-credits-remaining"];
|
|
||||||
if (creditsRemaining && globalCreditUpdateCallback) {
|
|
||||||
const credits = parseInt(creditsRemaining, 10);
|
|
||||||
if (!isNaN(credits) && credits >= 0) {
|
|
||||||
console.debug(
|
|
||||||
"[API Client] Updating credits from response header:",
|
|
||||||
credits,
|
|
||||||
"for URL:",
|
|
||||||
response.config?.url,
|
|
||||||
);
|
|
||||||
globalCreditUpdateCallback(credits);
|
|
||||||
// Show low-credit toast with top-up button when below threshold
|
|
||||||
if (credits < LOW_CREDIT_THRESHOLD) {
|
|
||||||
notifyLowCredits(credits);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
"[API Client] Invalid credits value in response header:",
|
|
||||||
creditsRemaining,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
async (error) => {
|
async (error) => {
|
||||||
const originalRequest = error.config || {};
|
const originalRequest = error.config || {};
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
export interface ApiCredits {
|
|
||||||
weeklyCreditsRemaining: number;
|
|
||||||
weeklyCreditsAllocated: number;
|
|
||||||
boughtCreditsRemaining: number;
|
|
||||||
totalBoughtCredits: number;
|
|
||||||
totalAvailableCredits: number;
|
|
||||||
weeklyResetDate: string;
|
|
||||||
lastApiUsage: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreditSummary {
|
|
||||||
currentCredits: number;
|
|
||||||
maxCredits: number;
|
|
||||||
creditsUsed: number;
|
|
||||||
creditsRemaining: number;
|
|
||||||
resetDate: string; // ISO date string
|
|
||||||
weeklyAllowance: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubscriptionInfo {
|
|
||||||
id?: string;
|
|
||||||
status: "active" | "inactive" | "cancelled" | "expired";
|
|
||||||
tier: "free" | "basic" | "premium" | "enterprise";
|
|
||||||
startDate?: string; // ISO date string
|
|
||||||
endDate?: string; // ISO date string
|
|
||||||
creditsPerWeek?: number;
|
|
||||||
maxCredits?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreditCheckResult {
|
|
||||||
hasSufficientCredits: boolean;
|
|
||||||
currentBalance: number;
|
|
||||||
requiredCredits: number;
|
|
||||||
shortfall?: number;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user