diff --git a/app/saas/src/main/java/stirling/software/saas/config/CreditsProperties.java b/app/saas/src/main/java/stirling/software/saas/config/CreditsProperties.java deleted file mode 100644 index 5b8df0d73b..0000000000 --- a/app/saas/src/main/java/stirling/software/saas/config/CreditsProperties.java +++ /dev/null @@ -1,75 +0,0 @@ -package stirling.software.saas.config; - -import java.util.Map; - -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - -import lombok.Data; - -@Data -@Component -@Profile("saas") -@ConfigurationProperties(prefix = "credits") -public class CreditsProperties { - - /** Whether the credits system is enabled */ - private boolean enabled = true; - - /** Credit allocations per billing cycle (monthly) */ - private CycleAllocations cycle = new CycleAllocations(); - - /** Reset configuration */ - private Reset reset = new Reset(); - - /** Error tracking configuration */ - private Errors errors = new Errors(); - - /** Cache configuration */ - private Cache cache = new Cache(); - - @Data - public static class CycleAllocations { - /** Whether admin role has unlimited credits */ - private boolean adminUnlimited = true; - - /** Credit allocations per billing cycle (monthly) per role */ - private Map allocations = - Map.of( - "ROLE_ADMIN", 1000, - "ROLE_PRO_USER", 500, - "ROLE_USER", 50, - "ROLE_LIMITED_API_USER", 10, - "ROLE_EXTRA_LIMITED_API_USER", 20, - "ROLE_WEB_ONLY_USER", 0, - "ROLE_DEMO_USER", 100); - } - - @Data - public static class Reset { - /** Cron expression for monthly reset (default: 1st of month 02:00 UTC) */ - private String cron = "0 0 2 1 * *"; - - /** Time zone for the reset schedule */ - private String zone = "UTC"; - } - - @Data - public static class Errors { - /** How long error counts are tracked (in minutes) */ - private int ttlMinutes = 60; - - /** Number of free processing errors before charging */ - private int freeProcessingErrors = 2; - } - - @Data - public static class Cache { - /** Enable local Caffeine cache for error counts */ - private boolean localEnabled = true; - - /** Enable Redis cache for multi-instance deployments */ - private boolean redisEnabled = false; - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/model/ProcessingErrorType.java b/app/saas/src/main/java/stirling/software/saas/model/ProcessingErrorType.java deleted file mode 100644 index f30c9a5f84..0000000000 --- a/app/saas/src/main/java/stirling/software/saas/model/ProcessingErrorType.java +++ /dev/null @@ -1,139 +0,0 @@ -package stirling.software.saas.model; - -public enum ProcessingErrorType { - - /** - * Validation errors. should never cost credits. Examples: missing parameters, invalid file - * types, size limits exceeded, malformed requests, authentication failures - */ - VALIDATION_ERROR, - - /** - * Processing errors. should cost credits after 3rd attempt per user/endpoint. Examples: corrupt - * PDF files, unsupported PDF features, memory issues during processing, OCR failures on valid - * PDFs, conversion errors on valid files - */ - PROCESSING_ERROR, - - /** - * System errors. should not cost credits (our fault). Examples: database connection issues, - * filesystem problems, service unavailable, internal server errors - */ - SYSTEM_ERROR; - - /** Determine error type from exception and HTTP status */ - public static ProcessingErrorType classifyError( - Throwable throwable, int httpStatus, String endpoint) { - if (throwable == null) { - return classifyByHttpStatus(httpStatus); - } - - String errorMessage = throwable.getMessage(); - String exceptionClass = throwable.getClass().getSimpleName(); - - // Validation errors (client-side issues) - if (httpStatus == 400 || httpStatus == 422) { - if (isValidationError(errorMessage, exceptionClass)) { - return VALIDATION_ERROR; - } - } - - // Authentication/Authorization errors - if (httpStatus == 401 || httpStatus == 403) { - return VALIDATION_ERROR; - } - - // Rate limiting - if (httpStatus == 429) { - return VALIDATION_ERROR; - } - - // System errors (our fault) - if (httpStatus >= 500 || isSystemError(errorMessage, exceptionClass)) { - return SYSTEM_ERROR; - } - - // Processing errors (user's data issue but valid request) - if (isProcessingError(errorMessage, exceptionClass, endpoint)) { - return PROCESSING_ERROR; - } - - // Default to validation error to be safe - return VALIDATION_ERROR; - } - - private static ProcessingErrorType classifyByHttpStatus(int httpStatus) { - if (httpStatus >= 400 && httpStatus < 500) { - return VALIDATION_ERROR; - } else if (httpStatus >= 500) { - return SYSTEM_ERROR; - } - return VALIDATION_ERROR; - } - - private static boolean isValidationError(String errorMessage, String exceptionClass) { - if (errorMessage == null && exceptionClass == null) return false; - - String[] validationKeywords = { - "validation", "invalid parameter", "missing parameter", "malformed", - "bad request", "illegal argument", "file too large", "unsupported file type", - "empty file", "no file provided", "invalid format" - }; - - String[] validationExceptions = { - "IllegalArgumentException", - "ValidationException", - "BindException", - "MethodArgumentNotValidException", - "MissingServletRequestParameterException", - "HttpMessageNotReadableException", - "MaxUploadSizeExceededException" - }; - - return containsAny(errorMessage, validationKeywords) - || containsAny(exceptionClass, validationExceptions); - } - - private static boolean isSystemError(String errorMessage, String exceptionClass) { - if (errorMessage == null && exceptionClass == null) return false; - - String[] systemExceptions = { - "SQLException", - "IOException", - "OutOfMemoryError", - "TimeoutException", - "ConnectException", - "UnknownHostException", - "ServiceUnavailableException" - }; - - return containsAny(exceptionClass, systemExceptions); - } - - private static boolean isProcessingError( - String errorMessage, String exceptionClass, String endpoint) { - if (errorMessage == null && exceptionClass == null) return false; - - String[] processingExceptions = { - "PDFException", "COSVisitorException", "InvalidPDFException", - "ConversionException", "OCRException", "ParseException" - }; - - // If we're checking errors for an endpoint, it's already been identified as a tracked - // endpoint - // through @AutoJobPostMapping annotation, so we can assume it's a PDF processing endpoint - return containsAny(exceptionClass, processingExceptions) - || (endpoint != null && !isValidationError(errorMessage, exceptionClass)); - } - - private static boolean containsAny(String text, String[] keywords) { - if (text == null) return false; - String lowerText = text.toLowerCase(); - for (String keyword : keywords) { - if (lowerText.contains(keyword.toLowerCase())) { - return true; - } - } - return false; - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/model/UserErrorTracker.java b/app/saas/src/main/java/stirling/software/saas/model/UserErrorTracker.java deleted file mode 100644 index 2ae97fffb7..0000000000 --- a/app/saas/src/main/java/stirling/software/saas/model/UserErrorTracker.java +++ /dev/null @@ -1,95 +0,0 @@ -package stirling.software.saas.model; - -import java.io.Serializable; -import java.time.LocalDateTime; - -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; - -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import stirling.software.proprietary.security.model.User; - -@Entity -@Table(name = "user_error_tracker") -@NoArgsConstructor -@Getter -@Setter -public class UserErrorTracker implements Serializable { - - private static final long serialVersionUID = 1L; - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "error_tracker_id") - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private User user; - - @Column(name = "endpoint") - private String endpoint; - - @Column(name = "processing_error_count") - private Integer processingErrorCount = 0; - - @Column(name = "last_processing_error") - private LocalDateTime lastProcessingError; - - @Column(name = "reset_after") - private LocalDateTime resetAfter; - - @CreationTimestamp - @Column(name = "created_at", updatable = false) - private LocalDateTime createdAt; - - @UpdateTimestamp - @Column(name = "updated_at") - private LocalDateTime updatedAt; - - public UserErrorTracker(User user, String endpoint, int ttlMinutes) { - this.user = user; - this.endpoint = endpoint; - this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes); - } - - public boolean shouldChargeForProcessingError(int freeProcessingErrors) { - return processingErrorCount != null && processingErrorCount > freeProcessingErrors; - } - - public void recordProcessingError(int ttlMinutes) { - this.processingErrorCount = (processingErrorCount != null ? processingErrorCount : 0) + 1; - this.lastProcessingError = LocalDateTime.now(); - - // Refresh TTL on each error - this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes); - } - - public void resetErrorCount(int ttlMinutes) { - this.processingErrorCount = 0; - this.lastProcessingError = null; - this.resetAfter = LocalDateTime.now().plusMinutes(ttlMinutes); - } - - public boolean isExpired() { - return resetAfter != null && LocalDateTime.now().isAfter(resetAfter); - } - - public int getErrorsUntilCharged(int freeProcessingErrors) { - int current = processingErrorCount != null ? processingErrorCount : 0; - return Math.max(0, freeProcessingErrors + 1 - current); - } -} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygWebMvcConfig.java b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygWebMvcConfig.java index 6d621ddce7..72c502fcff 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygWebMvcConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygWebMvcConfig.java @@ -62,20 +62,12 @@ public class PaygWebMvcConfig implements WebMvcConfigurer { public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(paygChargeInterceptor) .addPathPatterns("/api/**") - .excludePathPatterns( - "/api/v1/credits/**", - "/api/v1/config/**", - "/api/v1/info/**", - "/api/v1/admin/**") + .excludePathPatterns("/api/v1/config/**", "/api/v1/info/**", "/api/v1/admin/**") .order(INTERCEPTOR_ORDER); registry.addInterceptor(entitlementGuard) .addPathPatterns("/api/**") - .excludePathPatterns( - "/api/v1/credits/**", - "/api/v1/config/**", - "/api/v1/info/**", - "/api/v1/admin/**") + .excludePathPatterns("/api/v1/config/**", "/api/v1/info/**", "/api/v1/admin/**") .order(ENTITLEMENT_GUARD_ORDER); } } diff --git a/app/saas/src/main/java/stirling/software/saas/repository/UserErrorTrackerRepository.java b/app/saas/src/main/java/stirling/software/saas/repository/UserErrorTrackerRepository.java deleted file mode 100644 index c67174b85a..0000000000 --- a/app/saas/src/main/java/stirling/software/saas/repository/UserErrorTrackerRepository.java +++ /dev/null @@ -1,41 +0,0 @@ -package stirling.software.saas.repository; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; - -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.UserErrorTracker; - -public interface UserErrorTrackerRepository extends JpaRepository { - - Optional findByUserAndEndpoint(User user, String endpoint); - - Optional findByUserIdAndEndpoint(Long userId, String endpoint); - - @Query( - "SELECT uet FROM UserErrorTracker uet WHERE uet.user.apiKey = :apiKey AND uet.endpoint = :endpoint") - Optional findByUserApiKeyAndEndpoint( - @Param("apiKey") String apiKey, @Param("endpoint") String endpoint); - - @Query("SELECT uet FROM UserErrorTracker uet WHERE uet.resetAfter <= :currentDateTime") - List findExpiredErrorTrackers( - @Param("currentDateTime") LocalDateTime currentDateTime); - - @Modifying - @Query("DELETE FROM UserErrorTracker uet WHERE uet.resetAfter <= :currentDateTime") - int deleteExpiredErrorTrackers(@Param("currentDateTime") LocalDateTime currentDateTime); - - @Query( - "SELECT uet FROM UserErrorTracker uet WHERE uet.user = :user AND uet.processingErrorCount >= 3") - List findHighErrorCountForUser(@Param("user") User user); - - @Query( - "SELECT COUNT(uet) FROM UserErrorTracker uet WHERE uet.processingErrorCount >= :threshold") - Long countUsersWithHighErrorCount(@Param("threshold") int threshold); -} diff --git a/app/saas/src/main/java/stirling/software/saas/service/ErrorTrackingService.java b/app/saas/src/main/java/stirling/software/saas/service/ErrorTrackingService.java deleted file mode 100644 index 91ffa23186..0000000000 --- a/app/saas/src/main/java/stirling/software/saas/service/ErrorTrackingService.java +++ /dev/null @@ -1,315 +0,0 @@ -package stirling.software.saas.service; - -import java.time.LocalDateTime; -import java.util.Optional; -import java.util.concurrent.TimeUnit; - -import org.springframework.context.annotation.Profile; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; - -import lombok.extern.slf4j.Slf4j; - -import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.config.CreditsProperties; -import stirling.software.saas.model.ProcessingErrorType; -import stirling.software.saas.model.UserErrorTracker; -import stirling.software.saas.repository.UserErrorTrackerRepository; - -@Service -@Profile("saas") -@Slf4j -@Transactional -public class ErrorTrackingService { - - private final UserErrorTrackerRepository errorTrackerRepository; - private final UserRepository userRepository; - private final CreditsProperties creditsProperties; - - /** - * Local cache for error counts to reduce database chatter. - * - *

This cache is used to temporarily store error counts for each API key and endpoint, - * reducing the frequency of database writes and lookups. - * - *

Nullability: This field may be {@code null} if local caching is disabled via {@link - * CreditsProperties#getCache()#isLocalEnabled()}. All usages must check for null before - * accessing or invoking methods on this cache. - * - *

Lifecycle: The cache is initialized in the constructor based on configuration and - * remains unchanged for the lifetime of this service instance. - * - *

Thread-safety: The underlying Caffeine cache is thread-safe. - */ - private final Cache errorCountCache; - - public ErrorTrackingService( - UserErrorTrackerRepository errorTrackerRepository, - UserRepository userRepository, - CreditsProperties creditsProperties) { - this.errorTrackerRepository = errorTrackerRepository; - this.userRepository = userRepository; - this.creditsProperties = creditsProperties; - - // Initialize cache based on configuration - this.errorCountCache = - creditsProperties.getCache().isLocalEnabled() - ? Caffeine.newBuilder() - .maximumSize(10000) - .expireAfterWrite( - creditsProperties.getErrors().getTtlMinutes(), - TimeUnit.MINUTES) - .build() - : null; - } - - /** - * Record an error and determine if credits should be consumed - * - * @param apiKey User's API key - * @param endpoint The endpoint that failed - * @param throwable The exception that occurred - * @param httpStatus HTTP response status - * @return true if credits should be consumed for this error - */ - public boolean recordErrorAndShouldConsumeCredit( - String apiKey, String endpoint, Throwable throwable, int httpStatus) { - ProcessingErrorType errorType = - ProcessingErrorType.classifyError(throwable, httpStatus, endpoint); - - // Never charge for validation errors or system errors - if (errorType != ProcessingErrorType.PROCESSING_ERROR) { - log.debug( - "Error classified as {}, no credit consumption for API key: {}, endpoint: {}", - errorType, - maskApiKey(apiKey), - endpoint); - return false; - } - - String cacheKey = apiKey + "|" + endpoint; - - if (errorCountCache != null) { - // Use cache for fast tracking - ErrorCountCache cachedCount = errorCountCache.get(cacheKey, k -> new ErrorCountCache()); - cachedCount.incrementErrorCount(); - - boolean shouldCharge = - cachedCount.getErrorCount() - > creditsProperties.getErrors().getFreeProcessingErrors(); - - // Persist to DB when crossing the charging threshold or on first error - if (shouldCharge - && cachedCount.getErrorCount() - == creditsProperties.getErrors().getFreeProcessingErrors() + 1) { - persistErrorToDatabase(apiKey, endpoint); - } - - log.info( - "Processing error recorded (cached) for API key: {}, endpoint: {}, error count: {}, will charge: {}", - maskApiKey(apiKey), - endpoint, - cachedCount.getErrorCount(), - shouldCharge); - - return shouldCharge; - } else { - // Fallback to direct DB tracking - return recordErrorDirectToDatabase(apiKey, endpoint); - } - } - - private boolean recordErrorDirectToDatabase(String apiKey, String endpoint) { - Optional userOpt = userRepository.findByApiKey(apiKey); - if (userOpt.isEmpty()) { - log.warn("User not found for API key: {}", maskApiKey(apiKey)); - return false; - } - - User user = userOpt.get(); - UserErrorTracker tracker = getOrCreateErrorTracker(user, endpoint); - - tracker.recordProcessingError(creditsProperties.getErrors().getTtlMinutes()); - errorTrackerRepository.save(tracker); - - boolean shouldCharge = - tracker.shouldChargeForProcessingError( - creditsProperties.getErrors().getFreeProcessingErrors()); - - log.info( - "Processing error recorded (DB) for user: {}, endpoint: {}, error count: {}, will charge: {}", - user.getUsername(), - endpoint, - tracker.getProcessingErrorCount(), - shouldCharge); - - return shouldCharge; - } - - private void persistErrorToDatabase(String apiKey, String endpoint) { - try { - Optional userOpt = userRepository.findByApiKey(apiKey); - if (userOpt.isPresent()) { - User user = userOpt.get(); - UserErrorTracker tracker = getOrCreateErrorTracker(user, endpoint); - // Set to threshold + 1 to indicate charging has started - tracker.setProcessingErrorCount( - creditsProperties.getErrors().getFreeProcessingErrors() + 1); - tracker.setLastProcessingError(LocalDateTime.now()); - tracker.setResetAfter( - LocalDateTime.now() - .plusMinutes(creditsProperties.getErrors().getTtlMinutes())); - errorTrackerRepository.save(tracker); - log.debug( - "Persisted error threshold crossing to DB for API key: {}, endpoint: {}", - maskApiKey(apiKey), - endpoint); - } - } catch (Exception e) { - log.error( - "Failed to persist error to database for API key: {}, endpoint: {}", - maskApiKey(apiKey), - endpoint, - e); - } - } - - /** Check if a user has high error counts that might indicate abuse */ - public boolean hasHighErrorCount(String apiKey, String endpoint) { - Optional trackerOpt = - errorTrackerRepository.findByUserApiKeyAndEndpoint(apiKey, endpoint); - return trackerOpt - .map( - t -> - t.shouldChargeForProcessingError( - creditsProperties.getErrors().getFreeProcessingErrors())) - .orElse(false); - } - - /** Get error information for a user and endpoint */ - public ErrorInfo getErrorInfo(String apiKey, String endpoint) { - String cacheKey = apiKey + "|" + endpoint; - - if (errorCountCache != null) { - // Check cache first - ErrorCountCache cachedCount = errorCountCache.getIfPresent(cacheKey); - if (cachedCount != null) { - int currentCount = cachedCount.getErrorCount(); - int freeErrors = creditsProperties.getErrors().getFreeProcessingErrors(); - return new ErrorInfo( - currentCount, - Math.max(0, freeErrors - currentCount), - currentCount > freeErrors, - cachedCount.getLastErrorTime()); - } - } - - // Fallback to DB - Optional trackerOpt = - errorTrackerRepository.findByUserApiKeyAndEndpoint(apiKey, endpoint); - if (trackerOpt.isEmpty()) { - return new ErrorInfo( - 0, creditsProperties.getErrors().getFreeProcessingErrors(), false, null); - } - - UserErrorTracker tracker = trackerOpt.get(); - - // Reset if expired - if (tracker.isExpired()) { - tracker.resetErrorCount(creditsProperties.getErrors().getTtlMinutes()); - errorTrackerRepository.save(tracker); - return new ErrorInfo( - 0, creditsProperties.getErrors().getFreeProcessingErrors(), false, null); - } - - return new ErrorInfo( - tracker.getProcessingErrorCount(), - tracker.getErrorsUntilCharged( - creditsProperties.getErrors().getFreeProcessingErrors()), - tracker.shouldChargeForProcessingError( - creditsProperties.getErrors().getFreeProcessingErrors()), - tracker.getLastProcessingError()); - } - - private UserErrorTracker getOrCreateErrorTracker(User user, String endpoint) { - Optional existing = - errorTrackerRepository.findByUserAndEndpoint(user, endpoint); - - if (existing.isPresent()) { - UserErrorTracker tracker = existing.get(); - - // Reset if expired - if (tracker.isExpired()) { - tracker.resetErrorCount(creditsProperties.getErrors().getTtlMinutes()); - } - - return tracker; - } - - // Create new tracker - return new UserErrorTracker(user, endpoint, creditsProperties.getErrors().getTtlMinutes()); - } - - /** Clean up expired error trackers every hour */ - @Scheduled(cron = "0 0 * * * *") - public void cleanupExpiredErrorTrackers() { - try { - int deleted = errorTrackerRepository.deleteExpiredErrorTrackers(LocalDateTime.now()); - if (deleted > 0) { - log.debug("Cleaned up {} expired error trackers", deleted); - } - } catch (Exception e) { - log.error("Error cleaning up expired error trackers", e); - } - } - - private String maskApiKey(String apiKey) { - if (apiKey == null || apiKey.length() < 8) { - return "***"; - } - return apiKey.substring(0, 4) + "***" + apiKey.substring(apiKey.length() - 4); - } - - /** Information about user's error status for an endpoint */ - public static class ErrorInfo { - public final int currentErrorCount; - public final int errorsUntilCharged; - public final boolean isChargingForErrors; - public final LocalDateTime lastError; - - public ErrorInfo( - int currentErrorCount, - int errorsUntilCharged, - boolean isChargingForErrors, - LocalDateTime lastError) { - this.currentErrorCount = currentErrorCount; - this.errorsUntilCharged = errorsUntilCharged; - this.isChargingForErrors = isChargingForErrors; - this.lastError = lastError; - } - } - - /** Cache entry for tracking error counts in memory */ - private static class ErrorCountCache { - private int errorCount = 0; - private LocalDateTime lastErrorTime = LocalDateTime.now(); - - public void incrementErrorCount() { - errorCount++; - lastErrorTime = LocalDateTime.now(); - } - - public int getErrorCount() { - return errorCount; - } - - public LocalDateTime getLastErrorTime() { - return lastErrorTime; - } - } -} diff --git a/app/saas/src/test/java/stirling/software/saas/service/ErrorTrackingServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/ErrorTrackingServiceTest.java deleted file mode 100644 index 26aca1c570..0000000000 --- a/app/saas/src/test/java/stirling/software/saas/service/ErrorTrackingServiceTest.java +++ /dev/null @@ -1,586 +0,0 @@ -package stirling.software.saas.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; - -import java.io.IOException; -import java.sql.SQLException; -import java.time.LocalDateTime; -import java.util.Optional; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.junit.jupiter.MockitoSettings; -import org.mockito.quality.Strictness; - -import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.User; -import stirling.software.saas.config.CreditsProperties; -import stirling.software.saas.model.UserErrorTracker; -import stirling.software.saas.repository.UserErrorTrackerRepository; -import stirling.software.saas.service.ErrorTrackingService.ErrorInfo; - -/** - * Unit tests for {@link ErrorTrackingService}. - * - *

The service has two distinct tracking paths chosen at construction time based on {@link - * CreditsProperties.Cache#isLocalEnabled()}: an in-memory Caffeine cache path and a direct-to-DB - * fallback. Because the cache field is final and decided in the constructor, each path is exercised - * by building the service with a tailored {@link CreditsProperties}. Defaults are {@code - * freeProcessingErrors = 2} and {@code ttlMinutes = 60}. - */ -@ExtendWith(MockitoExtension.class) -@MockitoSettings(strictness = Strictness.LENIENT) -class ErrorTrackingServiceTest { - - @Mock private UserErrorTrackerRepository errorTrackerRepository; - @Mock private UserRepository userRepository; - - private static final String API_KEY = - "test-api-key-0001"; // gitleaks:allow - test fixture, not a secret - private static final String ENDPOINT = "/api/v1/convert/pdf-to-img"; - - /** Build a CreditsProperties with the given cache + error config. */ - private static CreditsProperties props( - boolean localCacheEnabled, int freeProcessingErrors, int ttlMinutes) { - CreditsProperties p = new CreditsProperties(); - p.getCache().setLocalEnabled(localCacheEnabled); - p.getErrors().setFreeProcessingErrors(freeProcessingErrors); - p.getErrors().setTtlMinutes(ttlMinutes); - return p; - } - - private ErrorTrackingService cachedService(int freeProcessingErrors) { - return new ErrorTrackingService( - errorTrackerRepository, userRepository, props(true, freeProcessingErrors, 60)); - } - - private ErrorTrackingService dbService(int freeProcessingErrors) { - return new ErrorTrackingService( - errorTrackerRepository, userRepository, props(false, freeProcessingErrors, 60)); - } - - private static User user(String username, String apiKey) { - User u = new User(); - u.setUsername(username); - u.setApiKey(apiKey); - return u; - } - - /** - * A throwable that classifies as PROCESSING_ERROR when paired with httpStatus 200 and a - * non-null endpoint: not a validation/system error, so the endpoint-based processing branch - * wins. - */ - private static Throwable processingThrowable() { - return new RuntimeException("corrupt pdf stream while rendering page"); - } - - @Nested - @DisplayName("recordErrorAndShouldConsumeCredit - error classification gate") - class ClassificationGate { - - @Test - @DisplayName("validation error (400) never charges and never touches DB or cache") - void validationError_doesNotCharge() { - ErrorTrackingService service = cachedService(2); - - boolean charge = - service.recordErrorAndShouldConsumeCredit( - API_KEY, - ENDPOINT, - new IllegalArgumentException("missing parameter"), - 400); - - assertThat(charge).isFalse(); - verify(errorTrackerRepository, never()).save(any()); - verifyNoInteractions(userRepository); - } - - @Test - @DisplayName("auth error (401) classifies as validation and never charges") - void authError_doesNotCharge() { - ErrorTrackingService service = cachedService(2); - - boolean charge = - service.recordErrorAndShouldConsumeCredit( - API_KEY, ENDPOINT, new RuntimeException("denied"), 401); - - assertThat(charge).isFalse(); - verify(errorTrackerRepository, never()).save(any()); - } - - @Test - @DisplayName("system error (500) never charges") - void systemError_doesNotCharge() { - ErrorTrackingService service = cachedService(2); - - boolean charge = - service.recordErrorAndShouldConsumeCredit( - API_KEY, ENDPOINT, new RuntimeException("server boom"), 500); - - assertThat(charge).isFalse(); - verify(errorTrackerRepository, never()).save(any()); - } - - @Test - @DisplayName("system exception type (SQLException) at 200 never charges") - void systemExceptionType_doesNotCharge() { - ErrorTrackingService service = cachedService(2); - - boolean charge = - service.recordErrorAndShouldConsumeCredit( - API_KEY, ENDPOINT, new SQLException("db down"), 200); - - assertThat(charge).isFalse(); - verify(errorTrackerRepository, never()).save(any()); - } - } - - @Nested - @DisplayName("recordErrorAndShouldConsumeCredit - cache path (freeProcessingErrors=2)") - class CachePath { - - @Test - @DisplayName("first two processing errors are free; the 3rd charges") - void firstTwoFree_thirdCharges() { - ErrorTrackingService service = cachedService(2); - User u = user("alice", API_KEY); - when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u)); - when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT)) - .thenReturn(Optional.empty()); - - // count=1 -> not > 2 - assertThat(call(service)).isFalse(); - // count=2 -> not > 2 - assertThat(call(service)).isFalse(); - // count=3 -> > 2 -> charge, and threshold-crossing persists to DB - assertThat(call(service)).isTrue(); - // count=4 -> still charges, but no second persist (only on the exact crossing) - assertThat(call(service)).isTrue(); - - // Persisted exactly once: on the threshold-crossing call (count == free + 1). - verify(errorTrackerRepository, times(1)).save(any(UserErrorTracker.class)); - } - - @Test - @DisplayName("threshold-crossing persists a tracker set to free+1 with future resetAfter") - void thresholdCrossing_persistsTrackerWithCorrectCount() { - ErrorTrackingService service = cachedService(2); - User u = user("bob", API_KEY); - when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u)); - when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT)) - .thenReturn(Optional.empty()); - - LocalDateTime before = LocalDateTime.now(); - call(service); // 1 - call(service); // 2 - call(service); // 3 -> persist - - ArgumentCaptor captor = - ArgumentCaptor.forClass(UserErrorTracker.class); - verify(errorTrackerRepository).save(captor.capture()); - UserErrorTracker saved = captor.getValue(); - assertThat(saved.getProcessingErrorCount()).isEqualTo(3); // free(2) + 1 - assertThat(saved.getUser()).isSameAs(u); - assertThat(saved.getEndpoint()).isEqualTo(ENDPOINT); - assertThat(saved.getLastProcessingError()).isNotNull(); - assertThat(saved.getResetAfter()).isAfter(before); - } - - @Test - @DisplayName("zero free errors: first processing error charges immediately") - void zeroFree_firstErrorCharges() { - ErrorTrackingService service = cachedService(0); - User u = user("carol", API_KEY); - when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u)); - when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT)) - .thenReturn(Optional.empty()); - - // count=1 > free(0) -> charge, and 1 == free+1 -> persist - assertThat(call(service)).isTrue(); - verify(errorTrackerRepository, times(1)).save(any(UserErrorTracker.class)); - } - - @Test - @DisplayName("distinct endpoints are tracked independently in the cache") - void distinctEndpoints_trackedSeparately() { - ErrorTrackingService service = cachedService(2); - - // Two errors on endpoint A, two on endpoint B -> neither crosses the free=2 threshold. - assertThat( - service.recordErrorAndShouldConsumeCredit( - API_KEY, "/a", processingThrowable(), 200)) - .isFalse(); - assertThat( - service.recordErrorAndShouldConsumeCredit( - API_KEY, "/a", processingThrowable(), 200)) - .isFalse(); - assertThat( - service.recordErrorAndShouldConsumeCredit( - API_KEY, "/b", processingThrowable(), 200)) - .isFalse(); - assertThat( - service.recordErrorAndShouldConsumeCredit( - API_KEY, "/b", processingThrowable(), 200)) - .isFalse(); - - // Neither key crossed free+1, so no DB persistence at all. - verify(errorTrackerRepository, never()).save(any()); - } - - @Test - @DisplayName("cache path does not blow up if the user is absent at persist time") - void cachePersist_userAbsent_swallowsAndStillCharges() { - ErrorTrackingService service = cachedService(2); - // No user for this key: persistErrorToDatabase finds nothing and saves nothing. - when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.empty()); - - assertThat(call(service)).isFalse(); // 1 - assertThat(call(service)).isFalse(); // 2 - assertThat(call(service)).isTrue(); // 3 -> tries persist, user missing -> no save - - verify(errorTrackerRepository, never()).save(any()); - } - - /** Convenience: record one processing error on the standard key. */ - private boolean call(ErrorTrackingService service) { - return service.recordErrorAndShouldConsumeCredit( - API_KEY, ENDPOINT, processingThrowable(), 200); - } - } - - @Nested - @DisplayName("recordErrorAndShouldConsumeCredit - DB fallback path (cache disabled)") - class DbFallbackPath { - - @Test - @DisplayName("unknown API key returns false and saves nothing") - void unknownApiKey_returnsFalse() { - ErrorTrackingService service = dbService(2); - when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.empty()); - - boolean charge = - service.recordErrorAndShouldConsumeCredit( - API_KEY, ENDPOINT, processingThrowable(), 200); - - assertThat(charge).isFalse(); - verify(errorTrackerRepository, never()).save(any()); - } - - @Test - @DisplayName( - "creates a new tracker, records the error and persists; below threshold no charge") - void newTracker_belowThreshold_noCharge() { - ErrorTrackingService service = dbService(2); - User u = user("dave", API_KEY); - when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u)); - when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT)) - .thenReturn(Optional.empty()); - - boolean charge = - service.recordErrorAndShouldConsumeCredit( - API_KEY, ENDPOINT, processingThrowable(), 200); - - assertThat(charge).isFalse(); // count 1, free 2 - - ArgumentCaptor captor = - ArgumentCaptor.forClass(UserErrorTracker.class); - verify(errorTrackerRepository).save(captor.capture()); - assertThat(captor.getValue().getProcessingErrorCount()).isEqualTo(1); - assertThat(captor.getValue().getUser()).isSameAs(u); - } - - @Test - @DisplayName("existing tracker already at the threshold rolls over to charging") - void existingTracker_atThreshold_charges() { - ErrorTrackingService service = dbService(2); - User u = user("erin", API_KEY); - when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u)); - - UserErrorTracker tracker = new UserErrorTracker(u, ENDPOINT, 60); - tracker.setProcessingErrorCount(2); // at the free limit - when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT)) - .thenReturn(Optional.of(tracker)); - - boolean charge = - service.recordErrorAndShouldConsumeCredit( - API_KEY, ENDPOINT, processingThrowable(), 200); - - // recordProcessingError bumps 2 -> 3, which is > free(2) -> charge - assertThat(charge).isTrue(); - assertThat(tracker.getProcessingErrorCount()).isEqualTo(3); - verify(errorTrackerRepository).save(tracker); - } - - @Test - @DisplayName("expired existing tracker is reset before recording the new error") - void expiredTracker_isResetThenRecorded() { - ErrorTrackingService service = dbService(2); - User u = user("finn", API_KEY); - when(userRepository.findByApiKey(API_KEY)).thenReturn(Optional.of(u)); - - UserErrorTracker tracker = new UserErrorTracker(u, ENDPOINT, 60); - tracker.setProcessingErrorCount(5); - tracker.setResetAfter(LocalDateTime.now().minusMinutes(1)); // expired - when(errorTrackerRepository.findByUserAndEndpoint(u, ENDPOINT)) - .thenReturn(Optional.of(tracker)); - - boolean charge = - service.recordErrorAndShouldConsumeCredit( - API_KEY, ENDPOINT, processingThrowable(), 200); - - // reset to 0, then recordProcessingError -> 1, which is not > free(2) - assertThat(charge).isFalse(); - assertThat(tracker.getProcessingErrorCount()).isEqualTo(1); - verify(errorTrackerRepository).save(tracker); - } - } - - @Nested - @DisplayName("hasHighErrorCount") - class HasHighErrorCount { - - @Test - @DisplayName("returns false when no tracker exists for the key") - void noTracker_false() { - ErrorTrackingService service = cachedService(2); - when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT)) - .thenReturn(Optional.empty()); - - assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isFalse(); - } - - @Test - @DisplayName("true when the tracker's count exceeds the free allowance") - void aboveFree_true() { - ErrorTrackingService service = cachedService(2); - UserErrorTracker tracker = new UserErrorTracker(user("g", API_KEY), ENDPOINT, 60); - tracker.setProcessingErrorCount(3); // > 2 - when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT)) - .thenReturn(Optional.of(tracker)); - - assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isTrue(); - } - - @Test - @DisplayName("false when the count is exactly at the free allowance (boundary)") - void atFree_false() { - ErrorTrackingService service = cachedService(2); - UserErrorTracker tracker = new UserErrorTracker(user("g", API_KEY), ENDPOINT, 60); - tracker.setProcessingErrorCount(2); // not > 2 - when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT)) - .thenReturn(Optional.of(tracker)); - - assertThat(service.hasHighErrorCount(API_KEY, ENDPOINT)).isFalse(); - } - } - - @Nested - @DisplayName("getErrorInfo") - class GetErrorInfo { - - @Test - @DisplayName("cache hit reflects the live cached count, remaining free and charging flag") - void cacheHit_reportsLiveCount() { - ErrorTrackingService service = cachedService(2); - - // Drive the cache to 3 errors on the key so a subsequent getErrorInfo reads it. - for (int i = 0; i < 3; i++) { - service.recordErrorAndShouldConsumeCredit( - API_KEY, ENDPOINT, processingThrowable(), 200); - } - - ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT); - - assertThat(info.currentErrorCount).isEqualTo(3); - // Math.max(0, free(2) - 3) == 0 - assertThat(info.errorsUntilCharged).isZero(); - assertThat(info.isChargingForErrors).isTrue(); // 3 > 2 - assertThat(info.lastError).isNotNull(); - } - - @Test - @DisplayName("cache miss falls back to an empty/zeroed ErrorInfo when no DB row exists") - void cacheMiss_noDbRow_zeroedInfo() { - ErrorTrackingService service = cachedService(2); - when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT)) - .thenReturn(Optional.empty()); - - ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT); - - assertThat(info.currentErrorCount).isZero(); - assertThat(info.errorsUntilCharged).isEqualTo(2); // full free allowance - assertThat(info.isChargingForErrors).isFalse(); - assertThat(info.lastError).isNull(); - } - - @Test - @DisplayName("DB-backed (cache disabled): live tracker is reported with derived fields") - void dbBacked_liveTracker_reported() { - ErrorTrackingService service = dbService(2); - UserErrorTracker tracker = new UserErrorTracker(user("h", API_KEY), ENDPOINT, 60); - tracker.setProcessingErrorCount(3); - tracker.setLastProcessingError(LocalDateTime.now()); - // not expired (constructor set resetAfter ~60m out) - when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT)) - .thenReturn(Optional.of(tracker)); - - ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT); - - assertThat(info.currentErrorCount).isEqualTo(3); - // getErrorsUntilCharged = max(0, free+1 - current) = max(0, 3 - 3) = 0 - assertThat(info.errorsUntilCharged).isZero(); - assertThat(info.isChargingForErrors).isTrue(); // 3 > 2 - assertThat(info.lastError).isNotNull(); - verify(errorTrackerRepository, never()).save(any()); - } - - @Test - @DisplayName("DB-backed: expired tracker is reset, persisted and reported as zeroed") - void dbBacked_expiredTracker_resetAndZeroed() { - ErrorTrackingService service = dbService(2); - UserErrorTracker tracker = new UserErrorTracker(user("i", API_KEY), ENDPOINT, 60); - tracker.setProcessingErrorCount(7); - tracker.setResetAfter(LocalDateTime.now().minusMinutes(1)); // expired - when(errorTrackerRepository.findByUserApiKeyAndEndpoint(API_KEY, ENDPOINT)) - .thenReturn(Optional.of(tracker)); - - ErrorInfo info = service.getErrorInfo(API_KEY, ENDPOINT); - - assertThat(info.currentErrorCount).isZero(); - assertThat(info.errorsUntilCharged).isEqualTo(2); - assertThat(info.isChargingForErrors).isFalse(); - assertThat(info.lastError).isNull(); - assertThat(tracker.getProcessingErrorCount()).isZero(); // reset mutated the entity - verify(errorTrackerRepository).save(tracker); - } - } - - @Nested - @DisplayName("cleanupExpiredErrorTrackers") - class Cleanup { - - @Test - @DisplayName("delegates to the repository delete with a 'now' cutoff") - void delegatesDelete() { - ErrorTrackingService service = cachedService(2); - when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class))) - .thenReturn(4); - - service.cleanupExpiredErrorTrackers(); - - verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class)); - } - - @Test - @DisplayName("swallows repository exceptions so the scheduler keeps running") - void swallowsRepositoryException() { - ErrorTrackingService service = cachedService(2); - when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class))) - .thenThrow(new RuntimeException("delete blew up")); - - // Must not propagate. - service.cleanupExpiredErrorTrackers(); - - verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class)); - } - - @Test - @DisplayName("zero deletions still completes cleanly") - void zeroDeletions_ok() { - ErrorTrackingService service = cachedService(2); - when(errorTrackerRepository.deleteExpiredErrorTrackers(any(LocalDateTime.class))) - .thenReturn(0); - - service.cleanupExpiredErrorTrackers(); - - verify(errorTrackerRepository).deleteExpiredErrorTrackers(any(LocalDateTime.class)); - } - } - - @Nested - @DisplayName("ErrorInfo value holder") - class ErrorInfoHolder { - - @Test - @DisplayName("constructor wires the public fields verbatim") - void fieldsWiredVerbatim() { - LocalDateTime ts = LocalDateTime.now(); - ErrorInfo info = new ErrorInfo(5, 1, true, ts); - - assertThat(info.currentErrorCount).isEqualTo(5); - assertThat(info.errorsUntilCharged).isEqualTo(1); - assertThat(info.isChargingForErrors).isTrue(); - assertThat(info.lastError).isEqualTo(ts); - } - } - - @Nested - @DisplayName("API key masking is exercised without leaking (smoke via logging branches)") - class MaskingSmoke { - - @Test - @DisplayName("short API keys are tolerated end-to-end on the cache path") - void shortApiKey_tolerated() { - ErrorTrackingService service = cachedService(2); - when(userRepository.findByApiKey(anyString())).thenReturn(Optional.empty()); - - // "key" is < 8 chars, masked as *** inside the service; must not throw on persist path. - boolean c1 = - service.recordErrorAndShouldConsumeCredit( - "key", ENDPOINT, processingThrowable(), 200); - boolean c2 = - service.recordErrorAndShouldConsumeCredit( - "key", ENDPOINT, processingThrowable(), 200); - boolean c3 = - service.recordErrorAndShouldConsumeCredit( - "key", ENDPOINT, processingThrowable(), 200); - - assertThat(c1).isFalse(); - assertThat(c2).isFalse(); - assertThat(c3).isTrue(); - // user absent -> no save even at threshold crossing - verify(errorTrackerRepository, never()).save(any()); - } - - @Test - @DisplayName("null API key is tolerated on the DB fallback path") - void nullApiKey_tolerated() { - ErrorTrackingService service = dbService(2); - when(userRepository.findByApiKey(eq(null))).thenReturn(Optional.empty()); - - boolean charge = - service.recordErrorAndShouldConsumeCredit( - null, ENDPOINT, processingThrowable(), 200); - - assertThat(charge).isFalse(); - verify(errorTrackerRepository, never()).save(any()); - } - } - - @Test - @DisplayName("IOException as a system error type does not charge even at 200") - void ioExceptionSystemError_doesNotCharge() { - ErrorTrackingService service = cachedService(2); - - boolean charge = - service.recordErrorAndShouldConsumeCredit( - API_KEY, ENDPOINT, new IOException("disk gone"), 200); - - assertThat(charge).isFalse(); - verify(errorTrackerRepository, never()).save(any()); - } -}