Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 4bef417bae build(deps): bump actions/ai-inference from 2.1.0 to 2.1.1
Bumps [actions/ai-inference](https://github.com/actions/ai-inference) from 2.1.0 to 2.1.1.
- [Release notes](https://github.com/actions/ai-inference/releases)
- [Commits](https://github.com/actions/ai-inference/compare/17ff458cb182449bbb2e43701fcd98f6af8f6570...a7805884c80886efc241e94a5351df715968a0ad)

---
updated-dependencies:
- dependency-name: actions/ai-inference
  dependency-version: 2.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-27 22:22:47 +00:00
475 changed files with 18083 additions and 30771 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-desktop
pkgver=2.14.1
pkgver=2.13.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
pkgname=stirling-pdf-server-bin
pkgver=2.14.1
pkgver=2.13.2
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
+1 -1
View File
@@ -87,7 +87,7 @@ jobs:
- name: AI PR Title Analysis
if: steps.actor.outputs.is_repo_dev == 'true'
id: ai-title-analysis
uses: actions/ai-inference@17ff458cb182449bbb2e43701fcd98f6af8f6570 # v2.1.0
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
with:
model: openai/gpt-4o
system-prompt-file: ".github/config/system-prompt.txt"
@@ -64,19 +64,11 @@ jobs:
gradle-version: 9.6.0
cache-disabled: true
# When the PR changes the base image, test.sh builds it locally
# (stirling-pdf-base:local) into the daemon image store. A buildx
# container builder can't see that store, so skip it here and let
# `docker buildx build` fall back to the default docker driver, which
# resolves the local base. The gha cache backend is also skipped (its
# runtime token isn't exposed) since the docker driver can't use it.
- name: Set up Docker Buildx
if: inputs.docker-base-changed != 'true'
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
# Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend.
- name: Expose GitHub runtime for Buildx cache
if: inputs.docker-base-changed != 'true'
uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
- name: Install Docker Compose
+11 -30
View File
@@ -356,24 +356,25 @@ jobs:
exit 1
fi
- name: Build Tauri app (signed)
if: inputs.sign
- name: Build Tauri app
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Apple signing/notarization env is blanked when sign is false so the
# cache warmer compiles an unsigned bundle and skips notarization.
APPLE_CERTIFICATE: ${{ inputs.sign && secrets.APPLE_CERTIFICATE || '' }}
APPLE_CERTIFICATE_PASSWORD: ${{ inputs.sign && secrets.APPLE_CERTIFICATE_PASSWORD || '' }}
APPLE_SIGNING_IDENTITY: ${{ inputs.sign && env.APPLE_SIGNING_IDENTITY || '' }}
APPLE_ID: ${{ inputs.sign && secrets.APPLE_ID || '' }}
APPLE_PASSWORD: ${{ inputs.sign && secrets.APPLE_ID_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ inputs.sign && secrets.APPLE_TEAM_ID || '' }}
# AppImage signing — three env vars work together:
# SIGN=1 tells linuxdeploy-plugin-appimage to forward --sign to appimagetool
# APPIMAGETOOL_SIGN_PASSPHRASE appimagetool uses this to unlock the GPG key non-interactively
# SIGN_KEY appimagetool picks the key matching this fingerprint
# Without SIGN=1, the other two are ignored and the AppImage is built unsigned even if a key is present.
# Mirror the Windows/macOS gate: only sign when secret is present AND ref is main (skips PRs from forks/Dependabot).
SIGN: ${{ (env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
# Mirror the Windows/macOS gate: only sign when enabled AND secret is present AND ref is main (skips PRs from forks/Dependabot and the cache warmer).
SIGN: ${{ (inputs.sign && env.RELEASE_GPG_PRIVATE_KEY != '' && github.ref == 'refs/heads/main') && '1' || '0' }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.RELEASE_GPG_PASSPHRASE }}
SIGN_KEY: ${{ vars.RELEASE_GPG_FINGERPRINT }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -391,26 +392,6 @@ jobs:
# failure (#6127 onwards) does not tank deb/rpm uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
- name: Build Tauri app (unsigned)
if: ${{ !inputs.sign }}
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SIGN: "0"
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb' }}
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL || 'https://app.stirlingpdf.com' }}
VITE_SAAS_BACKEND_API_URL: ${{ secrets.VITE_SAAS_BACKEND_API_URL || 'https://api.stirlingpdf.com' }}
CI: true
with:
projectPath: ./frontend/editor
tauriScript: npx tauri
# Linux: build deb+rpm only here. AppImage runs in its own
# continue-on-error step below so its persistent linuxdeploy
# failure (#6127 onwards) does not tank deb/rpm uploads.
args: ${{ matrix.platform == 'ubuntu-22.04' && '--bundles deb,rpm' || matrix.args }}
# AppImage is decoupled so its linuxdeploy run gets a fresh process
# (rpm scratch state torn down) and its failure can't tank deb/rpm.
- name: Build Tauri app (Linux AppImage)
+1 -16
View File
@@ -155,19 +155,6 @@ jobs:
echo "platforms=linux/amd64,linux/arm64/v8" >> "$GITHUB_OUTPUT"
fi
# Base-changed PRs build the embedded image with the local docker driver
# so the locally-built stirling-pdf-base:pr-test (in the daemon image
# store) resolves. A buildx container builder cannot see it and would try
# to pull it from a registry, which fails. Single-platform, no gha cache.
- name: Build ${{ matrix.docker-rev }} against local base (PR base change)
if: github.event_name == 'pull_request' && inputs.docker-base-changed == 'true'
run: |
DOCKER_BUILDKIT=1 docker build \
--build-arg BASE_IMAGE=${{ steps.build-params.outputs.base_image }} \
--file ./${{ matrix.docker-rev }} \
--tag stirling-pdf-embedded:pr-test \
.
- name: Build ${{ matrix.docker-rev }} (Depot)
if: env.USE_DEPOT == 'true'
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.16.0
@@ -182,10 +169,8 @@ jobs:
provenance: true
sbom: true
# Fork PRs that did NOT change the base use the buildx container builder
# (multi-platform + gha cache) against the published base image.
- name: Build ${{ matrix.docker-rev }} (Docker fork fallback)
if: env.USE_DEPOT != 'true' && inputs.docker-base-changed != 'true'
if: env.USE_DEPOT != 'true'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
builder: ${{ steps.buildx.outputs.name }}
-8
View File
@@ -19,11 +19,3 @@ frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
# False positive: generic-api-key matches the Java type name "X509Certificate"
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java:generic-api-key:224
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
.github/workflows/tauri-build.yml:generic-api-key:402
-4
View File
@@ -30,10 +30,6 @@ tasks:
dev:proprietary:
desc: "Start backend dev server in proprietary mode"
# `dotenv:` reads from the root Taskfile's directory (".") because this
# subtaskfile is included with `dir: .`. Local overrides in
# .env.proprietary.local win over the committed .env.proprietary defaults.
dotenv: ['app/.env.proprietary.local', 'app/.env.proprietary']
ignore_error: true
vars:
PORT: '{{.PORT | default "8080"}}'
+10 -26
View File
@@ -149,32 +149,16 @@ tasks:
# Pin jlink to JAVA_HOME so the bundled JRE matches the JDK the build
# uses. Bare `jlink` on PATH can resolve to an older system Java (the
# ubuntu runner ships Java 11), producing a runtime jlink:verify rejects.
#
# jdk.crypto.mscapi (the Windows certificate store / SunMSCAPI provider, used by
# hardware-backed cert signing) is a Windows-only module - it only exists in a Windows
# JDK's jmods, so it is added on Windows only or jlink fails to resolve it elsewhere.
- cmd: |
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$JLINK" \
--add-modules {{.JLINK_MODULES}},jdk.crypto.mscapi \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
platforms: [windows]
- cmd: |
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$JLINK" \
--add-modules {{.JLINK_MODULES}} \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
platforms: [linux, darwin]
- |
JLINK="${JAVA_HOME:+$JAVA_HOME/bin/}jlink"
JLINK_COMPRESS="$("$JLINK" --help 2>&1 | grep -q 'zip-\[0-9\]' && echo zip-6 || echo 2)"
"$JLINK" \
--add-modules {{.JLINK_MODULES}} \
--strip-debug \
--compress="$JLINK_COMPRESS" \
--no-header-files \
--no-man-pages \
--output runtime/jre
# jlink emits its files mode 444 (read-only). Tauri's build-script
# resource copier preserves source permissions when staging
# `runtime/jre/**/*` into `target/<profile>/runtime/jre/...`, so the
-8
View File
@@ -1,8 +0,0 @@
# Committed defaults for `task backend:dev:proprietary` (self-hosted / proprietary
# flavor). Local overrides + secrets live in app/.env.proprietary.local (ignored).
# Combined-billing account link (Mode A). Feature-flagged: OFF until release.
# Flip to true in app/.env.proprietary.local to test linking locally.
STIRLING_BILLING_ACCOUNT_LINK_ENABLED=false
# SaaS base URL the linked instance calls (register + entitlement).
STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL=https://stirling.com/app
-1
View File
@@ -1,4 +1,3 @@
# Whitelist committed env defaults. `.env.saas.local` (and any other .env*)
# stays ignored via the root .gitignore.
!.env.saas
!.env.proprietary
@@ -595,7 +595,7 @@ public class ApplicationProperties {
public static class SAML2 {
private String provider;
private Boolean enabled = false;
private Boolean autoCreateUser = true;
private Boolean autoCreateUser = false;
private Boolean blockRegistration = false;
private String registrationId = "stirling";
@@ -672,7 +672,7 @@ public class ApplicationProperties {
private String issuer;
private String clientId;
@ToString.Exclude private String clientSecret;
private Boolean autoCreateUser = true;
private Boolean autoCreateUser = false;
private Boolean blockRegistration = false;
private String useAsUsername;
private Collection<String> scopes = new ArrayList<>();
@@ -743,6 +743,7 @@ public class ApplicationProperties {
@Data
public static class Jwt {
private boolean enableKeystore = true;
private boolean enableKeyRotation = false;
private boolean enableKeyCleanup = true;
/**
@@ -846,8 +847,8 @@ public class ApplicationProperties {
@Data
public static class Trust {
private boolean serverAsAnchor = true;
private boolean useSystemTrust = true;
private boolean useMozillaBundle = true;
private boolean useSystemTrust = false;
private boolean useMozillaBundle = false;
private boolean useAATL = false;
private boolean useEUTL = false;
}
@@ -890,10 +891,10 @@ public class ApplicationProperties {
private Boolean enableAnalytics;
private Boolean enablePosthog;
private Boolean enableScarf;
private Boolean enableDesktopInstallSlide = true;
private Boolean enableDesktopInstallSlide;
private Datasource datasource;
private boolean disableSanitize;
private int maxDPI = 500;
private int maxDPI;
private boolean enableUrlToPDF;
private Html html = new Html();
private CustomPaths customPaths = new CustomPaths();
@@ -907,9 +908,8 @@ public class ApplicationProperties {
private String frontendUrl; // Frontend URL for invite email links (e.g.
// 'https://app.example.com'). If not set, falls back to backendUrl.
private boolean enableMobileScanner = true; // Enable mobile phone QR code upload feature
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
private ServerCertificate serverCertificate = new ServerCertificate();
@Data
public static class MobileScannerSettings {
@@ -919,16 +919,6 @@ public class ApplicationProperties {
private boolean stretchToFit = false; // Whether to stretch image to fill page
}
@Data
public static class ServerCertificate {
private boolean enabled =
true; // Enable server-side "Sign with Stirling-PDF" certificate
private String organizationName = "Stirling PDF Inc";
private int validity = 365; // Certificate validity in days
private boolean regenerateOnStartup =
false; // Generate a new certificate on each startup
}
public boolean isAnalyticsEnabled() {
return this.enableAnalytics != null && this.enableAnalytics;
}
@@ -1013,7 +1003,7 @@ public class ApplicationProperties {
@Data
public static class Sharing {
private boolean enabled = false;
private boolean linkEnabled = true;
private boolean linkEnabled = false;
private boolean emailEnabled = false;
private int linkExpirationDays = 3;
}
@@ -1187,7 +1177,7 @@ public class ApplicationProperties {
@Data
public static class Metrics {
private boolean enabled = true;
private boolean enabled;
}
@Data
@@ -1239,7 +1229,7 @@ public class ApplicationProperties {
private boolean enableInvites = false;
private int inviteLinkExpiryHours = 72; // Default: 72 hours (3 days)
private String host;
private int port = 587;
private int port;
private String username;
@ToString.Exclude private String password;
private String from;
@@ -1266,10 +1256,10 @@ public class ApplicationProperties {
@ToString.Exclude private String botToken;
private String botUsername;
private String pipelineInboxFolder = "telegram";
private Boolean customFolderSuffix = true;
private Boolean enableAllowUserIDs = true;
private Boolean customFolderSuffix = false;
private Boolean enableAllowUserIDs = false;
private List<Long> allowUserIDs = new ArrayList<>();
private Boolean enableAllowChannelIDs = true;
private Boolean enableAllowChannelIDs = false;
private List<Long> allowChannelIDs = new ArrayList<>();
private long processingTimeoutSeconds = 180;
private long pollingIntervalMillis = 2000;
@@ -24,14 +24,12 @@ import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.Locale;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.SignatureInterface;
import org.bouncycastle.cert.jcajce.JcaCertStore;
@@ -52,13 +50,6 @@ public abstract class CreateSignatureBase implements SignatureInterface {
@Getter private Certificate[] certificateChain;
@Setter private String tsaUrl;
/**
* Provider that must service the signing operation. Set for hardware-held keys (SunPKCS11 for
* USB tokens, SunMSCAPI for the Windows store) so the {@link java.security.Signature} runs on
* the token. Left {@code null} for software keystores, which use the default provider.
*/
@Setter private Provider signingProvider;
/**
* Specifies whether the external signing scenario should be used. If set to {@code true},
* external signing will be performed and {@link SignatureInterface} will be used for signing.
@@ -89,48 +80,25 @@ public abstract class CreateSignatureBase implements SignatureInterface {
NoSuchAlgorithmException,
IOException,
CertificateException {
this(keystore, pin, null);
}
/**
* Initialize the signature creator, optionally selecting a specific certificate by alias. A
* hardware token / the Windows store can hold several certificates, so the caller picks one;
* when {@code requestedAlias} is null the first usable entry is used (software keystore
* behaviour).
*
* @param keystore the keystore (software, PKCS#11 or Windows-MY)
* @param pin the keystore / token PIN, may be null for the Windows store
* @param requestedAlias the alias to sign with, or null to pick the first usable entry
*/
public CreateSignatureBase(KeyStore keystore, char[] pin, String requestedAlias)
throws KeyStoreException,
UnrecoverableKeyException,
NoSuchAlgorithmException,
IOException,
CertificateException {
if (requestedAlias != null
&& !requestedAlias.isBlank()
&& keystore.containsAlias(requestedAlias)) {
privateKey = (PrivateKey) keystore.getKey(requestedAlias, pin);
certificateChain = resolveChain(keystore, requestedAlias);
if (certificateChain == null) {
throw new IOException("Could not find certificate for alias " + requestedAlias);
}
checkValidity(certificateChain[0]);
return;
}
// grabs the first alias from the keystore and gets the private key.
// grabs the first alias from the keystore and get the private key. An
// alternative method or constructor could be used for setting a specific
// alias that should be used.
Enumeration<String> aliases = keystore.aliases();
String alias;
Certificate cert = null;
while (cert == null && aliases.hasMoreElements()) {
String alias = aliases.nextElement();
alias = aliases.nextElement();
privateKey = (PrivateKey) keystore.getKey(alias, pin);
Certificate[] certChain = resolveChain(keystore, alias);
Certificate[] certChain = keystore.getCertificateChain(alias);
if (certChain != null) {
certificateChain = certChain;
cert = certChain[0];
checkValidity(cert);
if (cert instanceof X509Certificate) {
// avoid expired certificate
((X509Certificate) cert).checkValidity();
//// SigUtils.checkCertificateUsage((X509Certificate) cert);
}
}
}
@@ -139,27 +107,6 @@ public abstract class CreateSignatureBase implements SignatureInterface {
}
}
/**
* Resolve the certificate chain for an alias. PKCS#11 tokens and the Windows store frequently
* expose only the leaf certificate (a null chain), so fall back to the single certificate.
*/
private static Certificate[] resolveChain(KeyStore keystore, String alias)
throws KeyStoreException {
Certificate[] chain = keystore.getCertificateChain(alias);
if (chain != null && chain.length > 0) {
return chain;
}
Certificate single = keystore.getCertificate(alias);
return single != null ? new Certificate[] {single} : null;
}
private static void checkValidity(Certificate cert) throws CertificateException {
if (cert instanceof X509Certificate x509Cert) {
// avoid expired certificate
x509Cert.checkValidity();
}
}
public final void setPrivateKey(PrivateKey privateKey) {
this.privateKey = privateKey;
}
@@ -189,18 +136,12 @@ public abstract class CreateSignatureBase implements SignatureInterface {
try {
CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
X509Certificate cert = (X509Certificate) certificateChain[0];
JcaContentSignerBuilder signerBuilder =
new JcaContentSignerBuilder(resolveSignatureAlgorithm(privateKey, cert));
// Hardware keys (PKCS#11 / Windows store) must sign on their own provider so the
// operation runs on the token; software keys use the default provider.
if (signingProvider != null) {
signerBuilder.setProvider(signingProvider);
}
ContentSigner signer = signerBuilder.build(privateKey);
ContentSigner sha1Signer =
new JcaContentSignerBuilder("SHA256WithRSA").build(privateKey);
gen.addSignerInfoGenerator(
new JcaSignerInfoGeneratorBuilder(
new JcaDigestCalculatorProviderBuilder().build())
.build(signer, cert));
.build(sha1Signer, cert));
gen.addCertificates(new JcaCertStore(Arrays.asList(certificateChain)));
CMSProcessableInputStream msg = new CMSProcessableInputStream(content);
CMSSignedData signedData = gen.generate(msg, false);
@@ -216,26 +157,4 @@ public abstract class CreateSignatureBase implements SignatureInterface {
throw new IOException(e);
}
}
/**
* Pick a SHA-256 signature algorithm that matches the key type. RSA keeps the historical
* default; EC / EdDSA tokens are common, so they are handled too.
*/
private static String resolveSignatureAlgorithm(PrivateKey key, X509Certificate cert) {
String alg = key.getAlgorithm();
if (alg == null || alg.isBlank()) {
alg = cert.getPublicKey().getAlgorithm();
}
alg = alg == null ? "" : alg.toUpperCase(Locale.ROOT);
if (alg.contains("ED25519") || alg.contains("EDDSA")) {
return "Ed25519";
}
if (alg.contains("EC")) { // EC, ECDSA
return "SHA256withECDSA";
}
if (alg.contains("DSA")) {
return "SHA256withDSA";
}
return "SHA256withRSA";
}
}
@@ -85,6 +85,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
"/icons/**",
"/modern-logo/**",
"/classic-logo/**",
"/robots.txt",
"/3rdPartyLicenses.json",
"/pdfjs/**",
"/pdfjs-legacy/**",
@@ -3,12 +3,9 @@ package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
@@ -264,19 +261,10 @@ public class RearrangePagesPDFController {
log.info("newPageOrder = {}", newPageOrder);
log.info("totalPages = {}", totalPages);
// Snapshot desired pages before mutating the tree; clone repeats (e.g. DUPLICATE)
// so each slot is a distinct node, not one PDPage under multiple /Kids.
// Snapshot the desired pages before mutating the source document's page tree.
List<PDPage> newPages = new ArrayList<>(newPageOrder.size());
Set<Integer> seenIndices = new HashSet<>();
for (Integer idx : newPageOrder) {
PDPage page = document.getPage(idx);
if (!seenIndices.add(idx)) {
// Duplicate index: distinct page node sharing content/resources.
COSDictionary clonedDict = new COSDictionary();
clonedDict.addAll(page.getCOSObject());
page = new PDPage(clonedDict);
}
newPages.add(page);
newPages.add(document.getPage(idx));
}
// Rearrange in-place on the source document rather than copying pages into a
@@ -350,18 +350,6 @@ public class ConfigController {
"serverCertificateEnabled",
serverCertificateService != null && serverCertificateService.isEnabled());
// Hardware-backed signing (Windows store / USB PKCS#11 tokens) is only viable on the
// desktop bundle, where the backend runs locally in the user's session. The Tauri
// bundle signals this via STIRLING_PDF_TAURI_MODE (machineType is Server-jar there);
// the bare-jar desktop launcher signals it via a Client-* machineType.
boolean hardwareSigningAvailable =
Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"));
if (!hardwareSigningAvailable && applicationContext.containsBean("machineType")) {
String mt = applicationContext.getBean("machineType", String.class);
hardwareSigningAvailable = mt != null && mt.startsWith("Client-");
}
configData.put("hardwareSigningAvailable", hardwareSigningAvailable);
// Legal settings
configData.put(
"termsAndConditions", applicationProperties.getLegal().getTermsAndConditions());
@@ -70,13 +70,10 @@ import io.micrometer.common.util.StringUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
import stirling.software.SPDF.service.HardwareKeyStoreService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -112,17 +109,14 @@ public class CertSignController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ServerCertificateServiceInterface serverCertificateService;
private final TempFileManager tempFileManager;
private final HardwareKeyStoreService hardwareKeyStoreService;
public CertSignController(
CustomPDFDocumentFactory pdfDocumentFactory,
@Autowired(required = false) ServerCertificateServiceInterface serverCertificateService,
TempFileManager tempFileManager,
HardwareKeyStoreService hardwareKeyStoreService) {
TempFileManager tempFileManager) {
this.pdfDocumentFactory = pdfDocumentFactory;
this.serverCertificateService = serverCertificateService;
this.tempFileManager = tempFileManager;
this.hardwareKeyStoreService = hardwareKeyStoreService;
}
public static void sign(
@@ -176,8 +170,7 @@ public class CertSignController {
"This endpoint accepts a PDF file, a digital certificate and related"
+ " information to sign the PDF. It then returns the digitally signed PDF"
+ " file. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<Resource> signPDFWithCert(
@ModelAttribute SignPDFWithCertRequest request, HttpServletRequest httpRequest)
public ResponseEntity<Resource> signPDFWithCert(@ModelAttribute SignPDFWithCertRequest request)
throws Exception {
MultipartFile pdf = request.getFileInput();
String certType = request.getCertType();
@@ -203,8 +196,6 @@ public class CertSignController {
KeyStore ks = null;
String keystorePassword = password;
Provider signingProvider = null;
HardwareKeyStoreService.Pkcs11Session pkcs11Session = null;
switch (certType) {
case "PEM":
@@ -254,31 +245,6 @@ public class CertSignController {
ks = serverCertificateService.getServerKeyStore();
keystorePassword = serverCertificateService.getServerCertificatePassword();
break;
case "WINDOWS_STORE":
hardwareKeyStoreService.assertLocalDesktop(httpRequest);
ks = hardwareKeyStoreService.loadWindowsKeyStore();
signingProvider = hardwareKeyStoreService.windowsProvider();
// PIN is prompted by the Windows CSP / token middleware, not passed here.
keystorePassword = password;
break;
case "PKCS11":
hardwareKeyStoreService.assertLocalDesktop(httpRequest);
char[] pkcs11Pin = password != null ? password.toCharArray() : null;
try {
pkcs11Session =
hardwareKeyStoreService.openPkcs11(
request.getPkcs11LibraryPath(),
request.getPkcs11Slot(),
pkcs11Pin);
} finally {
if (pkcs11Pin != null) {
java.util.Arrays.fill(pkcs11Pin, '\0');
}
}
ks = pkcs11Session.keyStore();
signingProvider = pkcs11Session.provider();
keystorePassword = password;
break;
default:
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
@@ -286,9 +252,7 @@ public class CertSignController {
"certificate type: " + certType);
}
char[] pin = keystorePassword != null ? keystorePassword.toCharArray() : null;
CreateSignature createSignature =
new CreateSignature(ks, pin, request.getAlias(), signingProvider);
CreateSignature createSignature = new CreateSignature(ks, keystorePassword.toCharArray());
TempFile signedOut = tempFileManager.createManagedTempFile(".pdf");
try (OutputStream os = new FileOutputStream(signedOut.getFile())) {
sign(
@@ -305,14 +269,6 @@ public class CertSignController {
} catch (IOException e) {
signedOut.close();
throw e;
} finally {
// Clear the PIN copy and log out the token session once signing is done.
if (pin != null) {
java.util.Arrays.fill(pin, '\0');
}
if (pkcs11Session != null) {
pkcs11Session.close();
}
}
// Return the signed PDF
return WebResponseUtils.pdfFileToWebResponse(
@@ -368,22 +324,7 @@ public class CertSignController {
NoSuchAlgorithmException,
IOException,
CertificateException {
this(keystore, pin, null, null);
}
public CreateSignature(
KeyStore keystore, char[] pin, String alias, Provider signingProvider)
throws KeyStoreException,
UnrecoverableKeyException,
NoSuchAlgorithmException,
IOException,
CertificateException {
super(keystore, pin, alias);
setSigningProvider(signingProvider);
loadLogo();
}
private void loadLogo() throws IOException {
super(keystore, pin);
ClassPathResource resource = new ClassPathResource("static/images/signature.png");
try (InputStream is = resource.getInputStream()) {
logoFile = Files.createTempFile("signature", ".png").toFile();
@@ -1,85 +0,0 @@
package stirling.software.SPDF.controller.api.security;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.HardwareCertificateInfo;
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
import stirling.software.SPDF.model.api.security.Pkcs11CertificatesRequest;
import stirling.software.SPDF.service.HardwareKeyStoreService;
/**
* Lets the desktop frontend discover which hardware-backed signing options the local backend can
* reach (Windows certificate store, plugged-in USB / PKCS#11 tokens) and enumerate the certificates
* available to sign with. Enumeration endpoints are restricted to the desktop bundle, reached over
* loopback - see {@link HardwareKeyStoreService#assertLocalDesktop}.
*/
@RestController
@RequestMapping("/api/v1/security/cert-sign/hardware")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Security", description = "Security APIs")
public class HardwareSigningController {
private final HardwareKeyStoreService hardwareKeyStoreService;
@GetMapping("/capabilities")
@Operation(
summary = "Hardware signing capabilities",
description =
"Reports whether hardware-backed signing is available on this device and which"
+ " PKCS#11 driver libraries were detected. Returns desktop=false when"
+ " not running as the desktop app.")
public ResponseEntity<HardwareSigningCapabilities> getCapabilities() {
return ResponseEntity.ok(hardwareKeyStoreService.capabilities());
}
@GetMapping("/windows-certificates")
@Operation(
summary = "List Windows certificate store signing certificates",
description =
"Enumerates certificates with a usable private key from the current user's"
+ " Windows certificate store. Desktop-only, loopback-only.")
public ResponseEntity<List<HardwareCertificateInfo>> getWindowsCertificates(
HttpServletRequest request) throws Exception {
hardwareKeyStoreService.assertLocalDesktop(request);
return ResponseEntity.ok(hardwareKeyStoreService.listWindowsCertificates());
}
@PostMapping("/pkcs11-certificates")
@Operation(
summary = "List PKCS#11 token signing certificates",
description =
"Logs into a PKCS#11 token with the supplied PIN and enumerates its signing"
+ " certificates. The PIN is used only for this call. Desktop-only,"
+ " loopback-only.")
public ResponseEntity<List<HardwareCertificateInfo>> getPkcs11Certificates(
HttpServletRequest request, @RequestBody Pkcs11CertificatesRequest body)
throws Exception {
hardwareKeyStoreService.assertLocalDesktop(request);
char[] pin = body.pin() != null ? body.pin().toCharArray() : null;
try {
return ResponseEntity.ok(
hardwareKeyStoreService.listPkcs11Certificates(
body.libraryPath(), body.slot(), pin));
} finally {
if (pin != null) {
java.util.Arrays.fill(pin, '\0');
}
}
}
}
@@ -102,27 +102,8 @@ public class ValidateSignatureController {
try (PDDocument document = pdfDocumentFactory.load(file.getInputStream())) {
List<PDSignature> signatures = document.getSignatureDictionaries();
// Detect content appended outside every signature's ByteRange (added after signing). A
// properly signed document has its last signature cover all the way to EOF; if the
// furthest any signature reaches stops short of the file length, the tail is unsigned.
// Taking the max across all signatures avoids false positives on legitimately
// multi-signed PDFs, where an earlier signature intentionally omits later revisions.
long fileLength = file.getSize();
long maxCovered = 0;
for (PDSignature sig : signatures) {
int[] byteRange = sig.getByteRange();
if (byteRange != null && byteRange.length == 4) {
long end = (long) byteRange[2] + byteRange[3];
if (end > maxCovered) {
maxCovered = end;
}
}
}
boolean documentCovered = maxCovered <= 0 || maxCovered >= fileLength;
for (PDSignature sig : signatures) {
SignatureValidationResult result = new SignatureValidationResult();
result.setCoversEntireDocument(documentCovered);
try {
byte[] signedContent = sig.getSignedContent(file.getInputStream());
@@ -1,30 +0,0 @@
package stirling.software.SPDF.controller.web;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import stirling.software.common.model.ApplicationProperties;
/**
* Serves /robots.txt dynamically so the system.googlevisibility flag actually controls
* search-engine indexing. 'true' returns an allow-all policy; 'false' returns a disallow-all policy
* to keep the instance out of search engines (useful for embedded/internal deployments).
*/
@RestController
public class RobotsController {
private final ApplicationProperties applicationProperties;
public RobotsController(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
@GetMapping(value = "/robots.txt", produces = MediaType.TEXT_PLAIN_VALUE)
@ResponseBody
public String robotsTxt() {
boolean allowIndexing = applicationProperties.getSystem().isGooglevisibility();
return "User-agent: *\n" + (allowIndexing ? "Allow: /\n" : "Disallow: /\n");
}
}
@@ -1,21 +0,0 @@
package stirling.software.SPDF.model.api.security;
/**
* Metadata for a single signing certificate discovered on a hardware source (Windows certificate
* store or a PKCS#11 token). Returned to the desktop frontend so the user can pick which
* certificate to sign with. Never carries private key material - signing always happens on the
* token / OS.
*/
public record HardwareCertificateInfo(
String alias,
String source,
String subject,
String issuer,
String subjectCommonName,
String issuerCommonName,
String serialNumber,
String keyAlgorithm,
String notBefore,
String notAfter,
boolean expired,
boolean notYetValid) {}
@@ -1,19 +0,0 @@
package stirling.software.SPDF.model.api.security;
import java.util.List;
/**
* Describes what hardware-backed signing the local backend can offer. Only meaningful on the
* desktop bundle, where the backend runs as a local sidecar in the signed-in user's session and can
* reach the Windows certificate store / a plugged-in USB PKCS#11 token.
*/
public record HardwareSigningCapabilities(
boolean desktop,
String osName,
boolean windowsStoreSupported,
boolean pkcs11Supported,
List<Pkcs11LibraryInfo> detectedLibraries) {
/** A PKCS#11 driver library detected on disk (or supplied via configuration). */
public record Pkcs11LibraryInfo(String name, String path) {}
}
@@ -1,7 +0,0 @@
package stirling.software.SPDF.model.api.security;
/**
* Request body for enumerating the certificates on a PKCS#11 token. The PIN is required to log into
* the token; it is used only for the duration of the call and never stored.
*/
public record Pkcs11CertificatesRequest(String libraryPath, Integer slot, String pin) {}
@@ -14,10 +14,8 @@ import stirling.software.common.model.api.PDFFile;
public class SignPDFWithCertRequest extends PDFFile {
@Schema(
description =
"The type of the digital certificate. WINDOWS_STORE and PKCS11 are"
+ " hardware-backed and only available in the desktop app.",
allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER", "WINDOWS_STORE", "PKCS11"},
description = "The type of the digital certificate",
allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER"},
requiredMode = Schema.RequiredMode.REQUIRED)
private String certType;
@@ -41,31 +39,9 @@ public class SignPDFWithCertRequest extends PDFFile {
@Schema(description = "The JKS keystore file (Java Key Store)")
private MultipartFile jksFile;
@Schema(
description =
"The password for the keystore / private key, or the token PIN for PKCS11",
format = "password")
@Schema(description = "The password for the keystore or the private key", format = "password")
private String password;
@Schema(
description =
"The alias of the certificate to sign with. Required for WINDOWS_STORE and"
+ " recommended for PKCS11 tokens holding multiple certificates.")
private String alias;
@Schema(
description =
"Absolute path to the PKCS#11 driver library (required for PKCS11 type). Must"
+ " be an allowed driver - a detected one or configured via"
+ " STIRLING_PKCS11_LIBRARIES.")
private String pkcs11LibraryPath;
@Schema(
description =
"Optional PKCS#11 slot index. When omitted the first slot with a token is"
+ " used.")
private Integer pkcs11Slot;
@Schema(
description = "Whether to visually show the signature in the PDF file",
defaultValue = "false",
@@ -18,11 +18,6 @@ public class SignatureValidationResult {
// Time validation
private boolean notExpired;
// Whether the document's signatures cover all of its bytes. False when content was appended
// outside every signature's ByteRange (i.e. added after signing), which the signature can't
// attest to even though the signed bytes themselves remain cryptographically intact.
private boolean coversEntireDocument = true;
// Revocation validation
private boolean revocationChecked; // true if PKIX revocation was enabled
private String revocationStatus; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
@@ -115,8 +115,7 @@ public class CertificateValidationService {
log.info("Enabled AIA certificate fetching and revocation checking");
}
// Trust only what we explicitly opt into. Desktop follows the same flags as the server -
// our own signing cert is trusted via serverAsAnchor, not by force-loading every system CA.
// Trust only what we explicitly opt into:
if (validation.getTrust().isServerAsAnchor()) loadServerCertAsAnchor();
if (validation.getTrust().isUseSystemTrust()) loadJavaSystemTrustStore();
if (validation.getTrust().isUseMozillaBundle()) loadBundledMozillaCACerts();
@@ -1,483 +0,0 @@
package stirling.software.SPDF.service;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyStore;
import java.security.Provider;
import java.security.Security;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import javax.security.auth.x500.X500Principal;
import org.bouncycastle.asn1.x500.RDN;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x500.style.BCStyle;
import org.bouncycastle.asn1.x500.style.IETFUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.HardwareCertificateInfo;
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities.Pkcs11LibraryInfo;
import stirling.software.common.util.ExceptionUtils;
/**
* Bridges PDF signing to hardware-held keys: the Windows certificate store (via the JDK SunMSCAPI
* provider) and USB / smart-card PKCS#11 tokens (via SunPKCS11). The private key never leaves the
* token - the JCA routes the actual signing operation onto the hardware.
*
* <p>These code paths are gated to the desktop bundle. On a hosted server the backend cannot reach
* a remote user's USB token anyway, and loading an arbitrary PKCS#11 driver library is effectively
* native code execution, so PKCS#11 libraries are additionally restricted to an allowlist of
* detected / configured driver paths.
*/
@Service
@Slf4j
public class HardwareKeyStoreService {
public static final String SOURCE_WINDOWS_STORE = "WINDOWS_STORE";
public static final String SOURCE_PKCS11 = "PKCS11";
private static final String WINDOWS_KEYSTORE_TYPE = "Windows-MY";
private static final String MSCAPI_PROVIDER = "SunMSCAPI";
private static final String PKCS11_BASE_PROVIDER = "SunPKCS11";
/** Extra PKCS#11 driver libraries, absolute paths, comma/`File.pathSeparator` separated. */
private static final String PKCS11_LIBRARIES_ENV = "STIRLING_PKCS11_LIBRARIES";
/** Same as {@link #PKCS11_LIBRARIES_ENV} but as a JVM system property. */
private static final String PKCS11_LIBRARIES_PROP = "stirling.pkcs11.libraries";
private final String machineType;
public HardwareKeyStoreService(
@Autowired(required = false) @Qualifier("machineType") String machineType) {
this.machineType = machineType;
}
// ---------------------------------------------------------------------
// Gating
// ---------------------------------------------------------------------
/**
* True when running as the desktop bundle (local sidecar in the user's session). The Tauri
* bundle sets {@code STIRLING_PDF_TAURI_MODE=true} (with {@code BROWSER_OPEN=false}, so
* machineType is {@code Server-jar} there); the bare-jar desktop launcher instead yields a
* {@code Client-*} machineType. Accept either.
*/
public boolean isDesktop() {
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
return true;
}
return machineType != null && machineType.startsWith("Client-");
}
public boolean isWindows() {
return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win");
}
private boolean windowsStoreSupported() {
return isWindows() && Security.getProvider(MSCAPI_PROVIDER) != null;
}
private boolean pkcs11Supported() {
return Security.getProvider(PKCS11_BASE_PROVIDER) != null;
}
/** Reject anything that is not the desktop bundle reached over loopback. */
public void assertLocalDesktop(HttpServletRequest request) {
if (!isDesktop()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.hardwareSigningDesktopOnly",
"Hardware-backed signing is only available in the Stirling PDF desktop app");
}
if (request != null && !isLocalRequest(request.getRemoteAddr())) {
throw ExceptionUtils.createIllegalArgumentException(
"error.hardwareSigningLocalOnly",
"Hardware-backed signing can only be used from this device");
}
}
/**
* True when the request originates from this machine. Loopback (incl. IPv4-mapped IPv6 like
* {@code ::ffff:127.0.0.1}) counts, as does any address bound to a local interface - so it
* works whether the desktop app reaches the sidecar over {@code localhost} or a LAN IP, while
* still rejecting other machines on the network.
*/
static boolean isLocalRequest(String remoteAddr) {
if (remoteAddr == null || remoteAddr.isBlank()) {
return false;
}
try {
InetAddress addr = InetAddress.getByName(remoteAddr);
if (addr.isLoopbackAddress() || addr.isAnyLocalAddress()) {
return true;
}
return NetworkInterface.networkInterfaces()
.anyMatch(nif -> nif.inetAddresses().anyMatch(local -> local.equals(addr)));
} catch (Exception e) {
return false;
}
}
// ---------------------------------------------------------------------
// Capabilities
// ---------------------------------------------------------------------
public HardwareSigningCapabilities capabilities() {
boolean desktop = isDesktop();
if (!desktop) {
return new HardwareSigningCapabilities(false, "", false, false, List.of());
}
return new HardwareSigningCapabilities(
true,
System.getProperty("os.name", ""),
windowsStoreSupported(),
pkcs11Supported(),
detectPkcs11Libraries());
}
/**
* Known driver install locations plus any paths configured via {@code
* STIRLING_PKCS11_LIBRARIES}.
*/
public List<Pkcs11LibraryInfo> detectPkcs11Libraries() {
Map<String, List<String>> candidates = new LinkedHashMap<>();
String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
if (os.contains("win")) {
candidates.put(
"OpenSC",
List.of(
"C:\\Program Files\\OpenSC Project\\OpenSC\\pkcs11\\opensc-pkcs11.dll"));
candidates.put(
"YubiKey (ykcs11)",
List.of("C:\\Program Files\\Yubico\\Yubico PIV Tool\\bin\\libykcs11.dll"));
candidates.put("SafeNet eToken", List.of("C:\\Windows\\System32\\eTPKCS11.dll"));
candidates.put(
"Thales/Gemalto IDPrime", List.of("C:\\Windows\\System32\\IDPrimePKCS11.dll"));
candidates.put(
"SoftHSM2",
List.of(
"C:\\Program Files\\SoftHSM2\\lib\\softhsm2-x64.dll",
"C:\\SoftHSM2\\lib\\softhsm2-x64.dll"));
} else if (os.contains("mac")) {
candidates.put(
"OpenSC",
List.of(
"/Library/OpenSC/lib/opensc-pkcs11.so",
"/usr/local/lib/opensc-pkcs11.so"));
candidates.put(
"YubiKey (ykcs11)",
List.of("/usr/local/lib/libykcs11.dylib", "/opt/homebrew/lib/libykcs11.dylib"));
candidates.put(
"SoftHSM2",
List.of(
"/usr/local/lib/softhsm/libsofthsm2.so",
"/opt/homebrew/lib/softhsm/libsofthsm2.so"));
} else {
candidates.put(
"OpenSC",
List.of(
"/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so",
"/usr/lib/opensc-pkcs11.so",
"/usr/lib64/opensc-pkcs11.so"));
candidates.put(
"YubiKey (ykcs11)",
List.of(
"/usr/lib/x86_64-linux-gnu/libykcs11.so",
"/usr/local/lib/libykcs11.so"));
candidates.put(
"SoftHSM2",
List.of(
"/usr/lib/softhsm/libsofthsm2.so",
"/usr/lib64/softhsm/libsofthsm2.so",
"/usr/local/lib/softhsm/libsofthsm2.so"));
}
List<Pkcs11LibraryInfo> result = new ArrayList<>();
candidates.forEach(
(name, paths) ->
paths.stream()
.filter(p -> Files.exists(Path.of(p)))
.findFirst()
.ifPresent(p -> result.add(new Pkcs11LibraryInfo(name, p))));
for (String configured : configuredLibraries()) {
if (Files.exists(Path.of(configured))
&& result.stream().noneMatch(l -> sameFile(l.path(), configured))) {
result.add(new Pkcs11LibraryInfo(fileName(configured), configured));
}
}
return result;
}
private static List<String> configuredLibraries() {
String env = System.getenv(PKCS11_LIBRARIES_ENV);
String prop = System.getProperty(PKCS11_LIBRARIES_PROP);
StringBuilder combined = new StringBuilder();
if (env != null && !env.isBlank()) {
combined.append(env);
}
if (prop != null && !prop.isBlank()) {
if (combined.length() > 0) {
combined.append(java.io.File.pathSeparator);
}
combined.append(prop);
}
if (combined.length() == 0) {
return List.of();
}
return Arrays.stream(combined.toString().split("[,;" + java.io.File.pathSeparator + "]"))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
// ---------------------------------------------------------------------
// Windows certificate store
// ---------------------------------------------------------------------
public KeyStore loadWindowsKeyStore() throws Exception {
if (!windowsStoreSupported()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.windowsStoreUnavailable",
"The Windows certificate store is not available on this platform");
}
KeyStore ks = KeyStore.getInstance(WINDOWS_KEYSTORE_TYPE, MSCAPI_PROVIDER);
ks.load(null, null);
return ks;
}
public Provider windowsProvider() {
return Security.getProvider(MSCAPI_PROVIDER);
}
public List<HardwareCertificateInfo> listWindowsCertificates() throws Exception {
return listSigningCertificates(loadWindowsKeyStore(), SOURCE_WINDOWS_STORE);
}
// ---------------------------------------------------------------------
// PKCS#11 tokens
// ---------------------------------------------------------------------
/**
* A configured, logged-in PKCS#11 keystore plus the provider that must service signing. Closing
* logs the session out so the PIN-authenticated session does not outlive the request. The
* provider stays cached (logout is C_Logout, not C_Finalize) so the next call reuses the same
* C_Initialize. Single-user desktop model - logout is best-effort.
*/
public record Pkcs11Session(KeyStore keyStore, Provider provider) implements AutoCloseable {
@Override
public void close() {
if (provider instanceof java.security.AuthProvider authProvider) {
try {
authProvider.logout();
} catch (Exception e) {
// Not logged in / already logged out - nothing to clear.
}
}
}
}
// One SunPKCS11 provider per driver+slot, reused across enumerate + sign. A PKCS#11 module
// typically allows C_Initialize only once per process, so configuring a fresh provider on every
// call races with the previous (not-yet-GC'd) one - the cause of "first sign fails, second
// works". Reusing the provider keeps a single C_Initialize alive for the session.
private final java.util.concurrent.ConcurrentHashMap<String, Provider> pkcs11Providers =
new java.util.concurrent.ConcurrentHashMap<>();
public Pkcs11Session openPkcs11(String libraryPath, Integer slot, char[] pin) throws Exception {
validateLibraryAllowed(libraryPath);
if (!pkcs11Supported()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pkcs11Unavailable", "PKCS#11 support is not available in this runtime");
}
String cacheKey = libraryPath + "|" + slot;
Provider provider =
pkcs11Providers.computeIfAbsent(
cacheKey, k -> buildPkcs11Provider(libraryPath, slot));
try {
KeyStore ks = KeyStore.getInstance("PKCS11", provider);
ks.load(null, pin);
return new Pkcs11Session(ks, provider);
} catch (Exception e) {
// A wrong PIN must not be retried: a second C_Login would burn the token's retry
// counter twice per attempt and can lock the token. Only rebuild on provider/init
// failures (e.g. token removed/re-inserted leaving a stale provider).
if (isAuthFailure(e)) {
throw e;
}
pkcs11Providers.remove(cacheKey, provider);
Provider fresh =
pkcs11Providers.computeIfAbsent(
cacheKey, k -> buildPkcs11Provider(libraryPath, slot));
KeyStore ks = KeyStore.getInstance("PKCS11", fresh);
ks.load(null, pin);
return new Pkcs11Session(ks, fresh);
}
}
/** True when the failure is a bad/locked PIN rather than a provider/init/device problem. */
private static boolean isAuthFailure(Throwable t) {
while (t != null) {
if (t instanceof javax.security.auth.login.FailedLoginException) {
return true;
}
String msg = t.getMessage();
if (msg != null && msg.toUpperCase(Locale.ROOT).contains("CKR_PIN")) {
return true; // CKR_PIN_INCORRECT / CKR_PIN_LOCKED / CKR_PIN_INVALID / ...
}
t = t.getCause();
}
return false;
}
private Provider buildPkcs11Provider(String libraryPath, Integer slot) {
StringBuilder config = new StringBuilder();
config.append("--name=").append(providerName(libraryPath)).append('\n');
config.append("library=").append(libraryPath).append('\n');
if (slot != null) {
config.append("slot=").append(slot).append('\n');
}
try {
return Security.getProvider(PKCS11_BASE_PROVIDER).configure(config.toString());
} catch (Exception e) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pkcs11ConfigFailed",
"Failed to initialise the PKCS#11 driver: {0}",
e.getMessage());
}
}
public List<HardwareCertificateInfo> listPkcs11Certificates(
String libraryPath, Integer slot, char[] pin) throws Exception {
try (Pkcs11Session session = openPkcs11(libraryPath, slot, pin)) {
return listSigningCertificates(session.keyStore(), SOURCE_PKCS11);
}
}
/**
* Reject driver paths that are not detected on disk / configured - blocks arbitrary DLL loads.
*/
public void validateLibraryAllowed(String libraryPath) {
if (libraryPath == null || libraryPath.isBlank()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pkcs11LibraryRequired", "A PKCS#11 driver library path is required");
}
Set<String> allowed =
detectPkcs11Libraries().stream()
.map(Pkcs11LibraryInfo::path)
.collect(Collectors.toSet());
boolean ok = allowed.stream().anyMatch(p -> sameFile(p, libraryPath));
if (!ok) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pkcs11LibraryNotAllowed",
"PKCS#11 driver is not in the allowed list. Add it via the"
+ " STIRLING_PKCS11_LIBRARIES setting: {0}",
libraryPath);
}
}
// ---------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------
private List<HardwareCertificateInfo> listSigningCertificates(KeyStore ks, String source)
throws Exception {
List<HardwareCertificateInfo> certs = new ArrayList<>();
Enumeration<String> aliases = ks.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
if (!ks.isKeyEntry(alias)) {
continue; // only entries we can sign with
}
Certificate cert = ks.getCertificate(alias);
if (cert instanceof X509Certificate x509) {
certs.add(toInfo(alias, x509, source));
}
}
return certs;
}
private static HardwareCertificateInfo toInfo(
String alias, X509Certificate cert, String source) {
java.util.Date now = new java.util.Date();
return new HardwareCertificateInfo(
alias,
source,
cert.getSubjectX500Principal().getName(),
cert.getIssuerX500Principal().getName(),
commonName(cert.getSubjectX500Principal()),
commonName(cert.getIssuerX500Principal()),
cert.getSerialNumber().toString(16),
cert.getPublicKey().getAlgorithm(),
cert.getNotBefore().toInstant().toString(),
cert.getNotAfter().toInstant().toString(),
now.after(cert.getNotAfter()),
now.before(cert.getNotBefore()));
}
private static String commonName(X500Principal principal) {
try {
X500Name x500Name = new X500Name(principal.getName());
RDN[] rdns = x500Name.getRDNs(BCStyle.CN);
if (rdns.length > 0) {
return IETFUtils.valueToString(rdns[0].getFirst().getValue());
}
} catch (Exception e) {
log.debug("Could not parse common name from {}", principal.getName());
}
return principal.getName();
}
private static String providerName(String libraryPath) {
String base = fileName(libraryPath).replaceAll("[^a-zA-Z0-9]", "");
if (base.isEmpty()) {
base = "token";
}
return "StirlingHW" + base;
}
private static String fileName(String path) {
try {
return Path.of(path).getFileName().toString();
} catch (Exception e) {
return path;
}
}
private static boolean sameFile(String a, String b) {
if (a == null || b == null) {
return false;
}
try {
Path pa = Path.of(a);
Path pb = Path.of(b);
if (Files.exists(pa) && Files.exists(pb)) {
return Files.isSameFile(pa, pb);
}
return pa.toAbsolutePath().normalize().equals(pb.toAbsolutePath().normalize());
} catch (Exception e) {
return a.equalsIgnoreCase(b);
}
}
}
@@ -62,6 +62,8 @@ security:
# IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId}
# Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration.
jwt: # This feature is currently under development and not yet fully supported. Do not use in production.
persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes for web clients (1 day).
desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days).
@@ -139,10 +141,10 @@ telegram:
botUsername: "" # Telegram bot username (without @)
pipelineInboxFolder: telegram # Name of the pipeline inbox folder for Telegram uploads
customFolderSuffix: true # set to 'true' to allow users to specify custom target folders via UserID
enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs. NOTE: only takes effect when allowUserIDs is non-empty; with an empty list every user is still allowed even when this is 'true'
allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users (the enableAllowUserIDs toggle has no effect until this list is populated).
enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs. NOTE: only takes effect when allowChannelIDs is non-empty; with an empty list every channel is still allowed even when this is 'true'
allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels (the enableAllowChannelIDs toggle has no effect until this list is populated).
enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs
allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users.
enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs
allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels.
processingTimeoutSeconds: 180 # Maximum time in seconds to wait for processing a Telegram request
pollingIntervalMillis: 2000 # Interval in milliseconds between polling for new messages
feedback:
@@ -170,7 +172,7 @@ legal:
system:
defaultLocale: "" # force a default language for new users (e.g. 'en-US', 'de-DE'). Empty string auto-detects from the browser, falling back to en-US
googlevisibility: false # 'true' serves an allow-all /robots.txt; 'false' serves a disallow-all /robots.txt to keep the instance out of search engines
googlevisibility: false # 'true' to allow Google visibility (via robots.txt), 'false' to disallow
enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes)
showUpdate: true # see when a new update is available
showUpdateOnlyAdmin: true # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
@@ -184,7 +186,7 @@ system:
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). WARNING: leaving this empty falls back to allowing ALL origins (with credentials), it does NOT disable CORS. Set explicit origins to lock it down.
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173'
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
enableMobileScanner: true # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
@@ -195,7 +197,7 @@ system:
stretchToFit: false # Whether to stretch images to fill the entire page (may distort aspect ratio). If false, images are centered with preserved aspect ratio. Only applies when convertToPdf is true.
serverCertificate:
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
organizationName: Stirling PDF Inc # Organization name for generated certificates
organizationName: Stirling-PDF # Organization name for generated certificates
validity: 365 # Certificate validity in days
regenerateOnStartup: false # Generate new certificate on each startup
html:
@@ -302,7 +304,7 @@ autoPipeline:
allowedExtensions: [] # Optional extension allow-list (case-insensitive, without the leading dot). Empty list = accept all extensions. Example: ["pdf", "tiff"]
ui:
appNameNavbar: "" # custom app/brand name. NOTE: no longer shown in the navbar (the navbar renders the logo). It IS used as the browser tab title and as the TOTP/2FA issuer label in authenticator apps. Empty falls back to "Stirling PDF"
appNameNavbar: "" # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
languages: [] # If empty, all languages are enabled. To restrict to specific languages, use a whitelist like ["de_DE", "pl_PL", "sv_SE"]. Empty list or not restricting any languages will enable all available languages.
defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out
@@ -179,13 +179,6 @@
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.code.gson:gson",
"moduleUrl": "https://github.com/google/gson",
"moduleVersion": "2.14.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.errorprone:error_prone_annotations",
"moduleUrl": "https://errorprone.info/error_prone_annotations",
@@ -193,13 +186,6 @@
"moduleLicense": "Apache 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.errorprone:error_prone_annotations",
"moduleUrl": "https://errorprone.info/error_prone_annotations",
"moduleVersion": "2.48.0",
"moduleLicense": "Apache 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.guava:failureaccess",
"moduleUrl": "https://github.com/google/guava/",
@@ -643,6 +629,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "commons-io:commons-io",
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
"moduleVersion": "2.21.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "commons-io:commons-io",
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
@@ -9,7 +9,6 @@ import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import org.apache.pdfbox.Loader;
@@ -303,11 +302,6 @@ class RearrangePagesPDFControllerTest {
assertNotNull(response);
// 2 pages * 3 duplicates = 6 final pages
assertEquals(6, realDoc.getNumberOfPages());
// Each duplicate must be a distinct page node in the saved output; a shared
// node under multiple /Kids is an invalid tree readers reject as cyclic.
List<Object> savedPages = reloadAndSnapshot(response);
assertEquals(6, savedPages.size());
assertEquals(6, new HashSet<>(savedPages).size());
}
}
@@ -329,29 +323,4 @@ class RearrangePagesPDFControllerTest {
assertEquals(4, realDoc.getNumberOfPages());
}
}
@Test
void testRearrangePages_SideStitchBooklet_RepeatedPaddingPagesAreDistinctNodes()
throws IOException {
MockMultipartFile file = createMockPdf();
RearrangePagesRequest request = new RearrangePagesRequest();
request.setFileInput(file);
request.setPageNumbers("");
request.setCustomMode("SIDE_STITCH_BOOKLET_SORT");
// 6 pages is not a multiple of 4, so booklet padding repeats the last page index
// several times; each repeat must be a distinct page node, not one shared node.
try (PDDocument realDoc = buildRealPdf(6)) {
when(pdfDocumentFactory.load(file)).thenReturn(realDoc);
ResponseEntity<Resource> response = controller.rearrangePages(request);
assertNotNull(response);
assertEquals(200, response.getStatusCode().value());
assertEquals(8, realDoc.getNumberOfPages());
List<Object> savedPages = reloadAndSnapshot(response);
assertEquals(8, savedPages.size());
assertEquals(8, new HashSet<>(savedPages).size());
}
}
}
@@ -30,10 +30,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
import stirling.software.SPDF.service.HardwareKeyStoreService;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
@@ -54,8 +51,6 @@ class CertSignControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@Mock private HardwareKeyStoreService hardwareKeyStoreService;
@Mock private HttpServletRequest httpRequest;
@InjectMocks private CertSignController certSignController;
@@ -174,8 +169,7 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -201,8 +195,7 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -228,7 +221,7 @@ class CertSignControllerTest {
IllegalArgumentException exception =
assertThrows(
IllegalArgumentException.class,
() -> certSignController.signPDFWithCert(request, httpRequest));
() -> certSignController.signPDFWithCert(request));
assertTrue(exception.getMessage().contains("PKCS12 keystore"));
}
@@ -254,8 +247,7 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -286,8 +278,7 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -318,8 +309,7 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -350,8 +340,7 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -382,8 +371,7 @@ class CertSignControllerTest {
request.setPageNumber(1);
request.setShowLogo(false);
ResponseEntity<Resource> response =
certSignController.signPDFWithCert(request, httpRequest);
ResponseEntity<Resource> response = certSignController.signPDFWithCert(request);
assertNotNull(response.getBody());
assertTrue(drainBody(response).length > 0);
@@ -89,13 +89,9 @@ class CertificateValidationServiceMoreTest {
}
private static ApplicationProperties defaultProps() {
// Test baseline: every trust source explicitly off so each test enables only what it
// exercises (the shipped POJO defaults now enable system + Mozilla trust).
// Real POJO defaults: trust all off, revocation "none".
ApplicationProperties props = new ApplicationProperties();
var trust = props.getSecurity().getValidation().getTrust();
trust.setServerAsAnchor(false);
trust.setUseSystemTrust(false);
trust.setUseMozillaBundle(false);
props.getSecurity().getValidation().getTrust().setServerAsAnchor(false);
return props;
}
@@ -1,146 +0,0 @@
package stirling.software.SPDF.service;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.SPDF.model.api.security.HardwareSigningCapabilities;
/** Unit tests for the gating / allowlist logic that protects the hardware signing paths. */
class HardwareKeyStoreServiceTest {
private static final String PKCS11_PROP = "stirling.pkcs11.libraries";
private HardwareKeyStoreService service(String machineType) {
return new HardwareKeyStoreService(machineType);
}
@Test
void isDesktop_trueOnlyForClientMachineTypes() {
assertTrue(service("Client-windows").isDesktop());
assertTrue(service("Client-mac").isDesktop());
assertTrue(service("Client-unix").isDesktop());
assertFalse(service("Server-jar").isDesktop());
assertFalse(service("Docker").isDesktop());
assertFalse(service(null).isDesktop());
}
@Test
void isDesktop_trueInTauriModeEvenWithoutClientMachineType() {
// The Tauri bundle sets STIRLING_PDF_TAURI_MODE=true while machineType stays Server-jar.
String previous = System.getProperty("STIRLING_PDF_TAURI_MODE");
try {
System.setProperty("STIRLING_PDF_TAURI_MODE", "true");
assertTrue(service("Server-jar").isDesktop());
assertTrue(service(null).isDesktop());
} finally {
if (previous == null) {
System.clearProperty("STIRLING_PDF_TAURI_MODE");
} else {
System.setProperty("STIRLING_PDF_TAURI_MODE", previous);
}
}
}
@Test
void capabilities_notDesktop_reportsUnavailable() {
HardwareSigningCapabilities caps = service("Server-jar").capabilities();
assertFalse(caps.desktop());
assertFalse(caps.windowsStoreSupported());
assertFalse(caps.pkcs11Supported());
assertTrue(caps.detectedLibraries().isEmpty());
}
@Test
void capabilities_desktop_reportsOsName() {
HardwareSigningCapabilities caps = service("Client-windows").capabilities();
assertTrue(caps.desktop());
assertFalse(caps.osName().isBlank());
}
@Test
void assertLocalDesktop_rejectsNonDesktop() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getRemoteAddr()).thenReturn("127.0.0.1");
assertThrows(
IllegalArgumentException.class,
() -> service("Server-jar").assertLocalDesktop(request));
}
@Test
void assertLocalDesktop_rejectsRemoteCallerEvenOnDesktop() {
HttpServletRequest request = mock(HttpServletRequest.class);
// 203.0.113.0/24 is TEST-NET-3 (RFC 5737) - never a real local interface address.
when(request.getRemoteAddr()).thenReturn("203.0.113.5");
assertThrows(
IllegalArgumentException.class,
() -> service("Client-windows").assertLocalDesktop(request));
}
@Test
void assertLocalDesktop_allowsLoopbackOnDesktop() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getRemoteAddr()).thenReturn("127.0.0.1");
assertDoesNotThrow(() -> service("Client-windows").assertLocalDesktop(request));
// No servlet context (e.g. internal call) is also allowed.
assertDoesNotThrow(() -> service("Client-windows").assertLocalDesktop(null));
}
@Test
void isLocalRequest_acceptsLoopbackForms_rejectsRemote() {
assertTrue(HardwareKeyStoreService.isLocalRequest("127.0.0.1"));
assertTrue(HardwareKeyStoreService.isLocalRequest("::1"));
assertTrue(HardwareKeyStoreService.isLocalRequest("0:0:0:0:0:0:0:1"));
// IPv4-mapped IPv6 loopback - what Tomcat reports for the desktop webview.
assertTrue(HardwareKeyStoreService.isLocalRequest("::ffff:127.0.0.1"));
assertFalse(HardwareKeyStoreService.isLocalRequest("203.0.113.5"));
assertFalse(HardwareKeyStoreService.isLocalRequest(null));
}
@Test
void validateLibraryAllowed_blankPath_throws() {
assertThrows(
IllegalArgumentException.class,
() -> service("Client-windows").validateLibraryAllowed(" "));
}
@Test
void validateLibraryAllowed_unknownPath_throws() {
assertThrows(
IllegalArgumentException.class,
() ->
service("Client-windows")
.validateLibraryAllowed("/definitely/not/a/real/driver.so"));
}
@Test
void validateLibraryAllowed_configuredPath_isAllowed(@TempDir Path tempDir) throws Exception {
Path fakeDriver = Files.createFile(tempDir.resolve("fake-pkcs11.so"));
String previous = System.getProperty(PKCS11_PROP);
try {
System.setProperty(PKCS11_PROP, fakeDriver.toString());
HardwareKeyStoreService service = service("Client-windows");
assertDoesNotThrow(() -> service.validateLibraryAllowed(fakeDriver.toString()));
assertTrue(
service.detectPkcs11Libraries().stream()
.anyMatch(l -> l.path().equals(fakeDriver.toString())));
} finally {
if (previous == null) {
System.clearProperty(PKCS11_PROP);
} else {
System.setProperty(PKCS11_PROP, previous);
}
}
}
}
@@ -1,265 +0,0 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
* A").
*
* <p>Two calls:
*
* <ul>
* <li>{@link #register} relays the admin's short-lived Supabase JWT to {@code POST
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
* <li>{@link #fetchEntitlement} authenticates with the stored device credential against {@code
* GET /api/v1/instance/entitlement}; what the local gate consults.
* </ul>
*
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern, see
* {@code AiEngineClient}). The base URL + client are injectable so tests can stub the SaaS
* endpoint.
*/
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkClient {
static final String HEADER_DEVICE_ID = "X-Device-Id";
static final String HEADER_DEVICE_SECRET = "X-Device-Secret";
private final AccountLinkProperties properties;
private final ObjectMapper mapper;
private final HttpClient httpClient;
@Autowired
public AccountLinkClient(AccountLinkProperties properties, ObjectMapper mapper) {
this(
properties,
mapper,
HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(properties.getRequestTimeoutSeconds()))
.build());
}
/** Package-private: lets tests inject a stub {@link HttpClient}. */
AccountLinkClient(
AccountLinkProperties properties, ObjectMapper mapper, HttpClient httpClient) {
this.properties = properties;
this.mapper = mapper;
this.httpClient = httpClient;
}
/** The device credential a successful {@link #register} returns. */
public record RegisterResult(String deviceId, String deviceSecret, Long teamId) {}
/**
* A non-2xx reply from the SaaS account-link API. Carries the upstream status so the caller can
* map auth failures (401/403) through rather than masking everything as a 502.
*/
public static class UpstreamException extends IOException {
private final int status;
public UpstreamException(int status, String body) {
super("SaaS account-link returned HTTP " + status + ": " + body);
this.status = status;
}
public int status() {
return status;
}
}
/**
* Authoritative deny (401/403) from the entitlement endpoint the device credential is revoked
* or invalid. Distinct from a transport/server failure (which returns {@code null} and fails
* open): the cache must BLOCK billable work on this rather than serve a stale entitled
* snapshot. Unchecked so it propagates cleanly through {@link #fetchEntitlement}'s transport
* try/catch.
*/
public static final class RevokedException extends RuntimeException {
private final int status;
public RevokedException(int status) {
super("SaaS entitlement denied (credential revoked/invalid): HTTP " + status);
this.status = status;
}
public int status() {
return status;
}
}
/**
* Relays the admin Supabase JWT to the SaaS register endpoint and returns the minted
* credential.
*
* @throws IOException on transport failure or a non-2xx response (caller surfaces to the
* admin).
*/
public RegisterResult register(String supabaseJwt, String instanceName) throws IOException {
String body =
instanceName == null || instanceName.isBlank()
? "{}"
: "{\"name\":" + mapper.writeValueAsString(instanceName) + "}";
HttpRequest request =
HttpRequest.newBuilder()
.uri(uri("/api/v1/account-link/register"))
.header("Authorization", "Bearer " + supabaseJwt)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout())
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = send(request);
if (response.statusCode() / 100 != 2) {
throw new UpstreamException(response.statusCode(), response.body());
}
JsonNode root = mapper.readTree(response.body());
String deviceId = text(root, "deviceId");
String deviceSecret = text(root, "deviceSecret");
if (deviceId == null || deviceSecret == null) {
throw new IOException("SaaS register response missing deviceId/deviceSecret");
}
Long teamId = root.hasNonNull("teamId") ? root.get("teamId").asLong() : null;
return new RegisterResult(deviceId, deviceSecret, teamId);
}
/**
* Revokes this instance's own credential on the SaaS side ({@code POST
* /api/v1/instance/revoke-self}), authenticated by the device credential a credential is
* allowed to revoke its own identity. Best-effort: returns {@code false} if SaaS is unreachable
* or rejects the call, so the caller (local unlink) can still clear locally and log the orphan
* row for follow-up. Idempotent on SaaS (already-revoked still 204).
*/
public boolean revokeSelf(String deviceId, String deviceSecret) {
try {
HttpRequest request =
HttpRequest.newBuilder()
.uri(uri("/api/v1/instance/revoke-self"))
.header(HEADER_DEVICE_ID, deviceId)
.header(HEADER_DEVICE_SECRET, deviceSecret)
.header("Accept", "application/json")
.timeout(timeout())
.POST(HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = send(request);
if (response.statusCode() / 100 != 2) {
log.debug("Self-revoke returned HTTP {}", response.statusCode());
return false;
}
return true;
} catch (Exception e) {
log.debug("Self-revoke failed: {}", e.getMessage());
return false;
}
}
/**
* Fetches the current entitlement using the stored device credential. Three outcomes:
*
* <ul>
* <li>2xx the parsed snapshot.
* <li>401/403 {@link RevokedException} (authoritative deny revoked/invalid credential);
* the caller must BLOCK, not fail open.
* <li>transport failure, other non-2xx (e.g. 5xx), or a malformed body {@code null}
* ("unknown" the caller fails open).
* </ul>
*/
public InstanceEntitlement fetchEntitlement(String deviceId, String deviceSecret) {
HttpResponse<String> response;
try {
HttpRequest request =
HttpRequest.newBuilder()
.uri(uri("/api/v1/instance/entitlement"))
.header(HEADER_DEVICE_ID, deviceId)
.header(HEADER_DEVICE_SECRET, deviceSecret)
.header("Accept", "application/json")
.timeout(timeout())
.GET()
.build();
response = send(request);
} catch (Exception e) {
// Transport failure (timeout / connection refused / interrupted) unknown, fail open.
log.debug("Entitlement fetch failed: {}", e.getMessage());
return null;
}
int status = response.statusCode();
if (status == 401 || status == 403) {
// Authoritative deny the SaaS side rejected the credential (revoked/invalid).
throw new RevokedException(status);
}
if (status / 100 != 2) {
// Server / transient error unknown, fail open (do NOT treat as a deny).
log.debug("Entitlement fetch returned HTTP {}", status);
return null;
}
try {
return parseEntitlement(response.body());
} catch (IOException e) {
log.debug("Entitlement parse failed: {}", e.getMessage());
return null;
}
}
private InstanceEntitlement parseEntitlement(String body) throws IOException {
JsonNode root = mapper.readTree(body);
boolean subscribed = root.path("subscribed").asBoolean(false);
long freeRemaining = root.path("freeRemainingUnits").asLong(0);
long periodSpend = root.path("periodSpendUnits").asLong(0);
Long periodCap =
root.hasNonNull("periodCapUnits") ? root.get("periodCapUnits").asLong() : null;
EntitlementState state = mapState(root.path("state").asText(null));
return new InstanceEntitlement(subscribed, freeRemaining, periodSpend, periodCap, state);
}
/** Maps the SaaS state string to our coarse enum; unrecognised → UNKNOWN. */
private static EntitlementState mapState(String raw) {
if (raw == null) {
return EntitlementState.UNKNOWN;
}
return switch (raw) {
case "OK", "ACTIVE", "SUBSCRIBED", "FREE" -> EntitlementState.OK;
case "OVER_LIMIT", "PAYG_LIMIT_REACHED", "BLOCKED" -> EntitlementState.OVER_LIMIT;
default -> EntitlementState.UNKNOWN;
};
}
private HttpResponse<String> send(HttpRequest request) throws IOException {
try {
return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted calling SaaS account-link", e);
}
}
private URI uri(String path) {
String base = properties.getSaasBaseUrl().strip().replaceAll("/+$", "");
return URI.create(base + path);
}
private Duration timeout() {
return Duration.ofSeconds(properties.getRequestTimeoutSeconds());
}
private static String text(JsonNode node, String field) {
return node.hasNonNull(field) ? node.get(field).asText() : null;
}
}
@@ -1,88 +0,0 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
/**
* Same-origin account-link surface on the self-hosted instance (combined-billing "Mode A").
*
* <p>The portal (served from this same origin, admin authenticated by the existing self-hosted
* security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
* backend, which mints + returns a device credential we store locally. {@code GET /status} backs
* the portal's link card.
*
* <p>Admin-only, {@code @Profile("!saas")}, gated behind {@code
* stirling.billing.account-link.enabled} off bean absent 404.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/account-link")
@Profile("!saas")
@PreAuthorize("hasRole('ADMIN')")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkController {
private final AccountLinkService service;
public AccountLinkController(AccountLinkService service) {
this.service = service;
}
/** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
public record LinkRequest(String supabaseJwt, String name) {}
@PostMapping("/link")
public ResponseEntity<?> link(@RequestBody LinkRequest req) {
if (req == null || req.supabaseJwt() == null || req.supabaseJwt().isBlank()) {
return ResponseEntity.badRequest()
.body(java.util.Map.of("error", "supabaseJwt is required"));
}
try {
return ResponseEntity.ok(service.link(req.supabaseJwt(), req.name()));
} catch (AccountLinkClient.UpstreamException e) {
// Auth failures are the admin's token, not a gateway fault: surface 401/403 as-is so
// the portal can prompt a re-sign-in. Anything else upstream 502. Don't echo the
// raw upstream body back to the browser.
HttpStatus status =
e.status() == HttpStatus.UNAUTHORIZED.value()
|| e.status() == HttpStatus.FORBIDDEN.value()
? HttpStatus.valueOf(e.status())
: HttpStatus.BAD_GATEWAY;
log.warn("Account-link register rejected upstream: HTTP {}", e.status());
return ResponseEntity.status(status).body(java.util.Map.of("error", "LINK_FAILED"));
} catch (IOException e) {
// Don't echo e.getMessage() to the browser: a DNS/connection/TLS failure can carry the
// configured SaaS host/IP. Log it server-side; return the same opaque body the
// UpstreamException branch does.
log.warn("Account-link failed (transport): {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(java.util.Map.of("error", "LINK_FAILED"));
}
}
@GetMapping("/status")
public ResponseEntity<AccountLinkService.LinkStatus> status() {
return ResponseEntity.ok(service.status());
}
@PostMapping("/unlink")
public ResponseEntity<Void> unlink() {
service.unlink();
return ResponseEntity.noContent().build();
}
}
@@ -1,39 +0,0 @@
package stirling.software.proprietary.accountlink;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import lombok.Getter;
import lombok.Setter;
/**
* Self-hosted side of combined-billing "Mode A" (connected self-hosted).
*
* <p>Binds the {@code stirling.billing.account-link.*} keys. {@link #enabled} mirrors the same flag
* the gated beans test with {@code @ConditionalOnProperty}; it is kept here only so non-conditional
* code (e.g. the gate's flag-off short-circuit, exposed status) can read it. The whole feature is
* <b>off by default</b> and <b>dark</b> when off nothing gates and the link endpoints 404.
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "stirling.billing.account-link")
public class AccountLinkProperties {
/** Master switch. When {@code false} (default) the feature is fully inert. */
private boolean enabled = false;
/**
* Base URL of the SaaS backend this instance links to (register + entitlement live there).
*
* <p>STUB: defaults to the public cloud host; an operator overrides it for staging. There is no
* existing SaaS-base-url property in the self-hosted profile, so this is introduced here.
*/
private String saasBaseUrl = "https://stirling.com/app";
/** Cached entitlement is reused for this long before a refresh is attempted. */
private long entitlementCacheSeconds = 300;
/** Connect/read timeout for the outbound SaaS calls. */
private int requestTimeoutSeconds = 10;
}
@@ -1,92 +0,0 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Linking orchestrator (self-hosted side of combined-billing "Mode A").
*
* <p>{@link #link} is the same-origin action the portal triggers: it relays the admin's Supabase
* JWT to the SaaS register endpoint, then persists the returned device credential secure-at-rest.
* The credential not the JWT authenticates all later unattended entitlement calls.
*/
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkService {
private final AccountLinkClient client;
private final DeviceCredentialStore credentialStore;
private final EntitlementCache entitlementCache;
public AccountLinkService(
AccountLinkClient client,
DeviceCredentialStore credentialStore,
EntitlementCache entitlementCache) {
this.client = client;
this.credentialStore = credentialStore;
this.entitlementCache = entitlementCache;
}
/** Status of this instance's link, for the portal's "Account link" card. */
public record LinkStatus(boolean linked, String deviceId, Long teamId, String linkedAt) {}
/**
* Registers this instance with the SaaS team behind {@code supabaseJwt} and stores the
* credential.
*
* @throws IOException if the SaaS register call fails (surfaced to the admin as a link error).
*/
public LinkStatus link(String supabaseJwt, String instanceName) throws IOException {
AccountLinkClient.RegisterResult result = client.register(supabaseJwt, instanceName);
credentialStore.save(result.deviceId(), result.deviceSecret(), result.teamId());
entitlementCache.invalidate();
log.info("Account-link: instance linked to team {}", result.teamId());
return status();
}
/**
* Unlinks this instance best-effort tells SaaS to revoke first (so the row gets {@code
* revoked_at} set), then clears locally regardless. If SaaS is unreachable the local clear
* still proceeds (admin's intent must win); the orphan row can be revoked from the portal.
*/
public void unlink() {
credentialStore
.get()
.ifPresent(
c -> {
boolean ok = client.revokeSelf(c.getDeviceId(), c.getDeviceSecret());
if (!ok) {
log.warn(
"Account-link: SaaS self-revoke failed for device {};"
+ " clearing locally anyway (admin can revoke"
+ " from the portal).",
c.getDeviceId());
}
});
credentialStore.clear();
entitlementCache.invalidate();
log.info("Account-link: instance unlinked");
}
public LinkStatus status() {
Optional<DeviceCredential> cred = credentialStore.get();
return cred.map(
c ->
new LinkStatus(
true,
c.getDeviceId(),
c.getTeamId(),
c.getLinkedAt() != null
? c.getLinkedAt().toString()
: null))
.orElseGet(() -> new LinkStatus(false, null, null, null));
}
}
@@ -1,36 +0,0 @@
package stirling.software.proprietary.accountlink;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
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;
/**
* Registers the account-link entitlement gate. Path patterns cover the billable API surface; the
* interceptor itself re-checks billability (and short-circuits manual tools), but scoping here
* keeps the gate off the bulk of interactive endpoints entirely.
*
* <p>Whole config is gated behind {@code stirling.billing.account-link.enabled} +
* {@code @Profile("!saas")}; absent when off, so no interceptor is registered.
*/
@Configuration
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkWebMvcConfig implements WebMvcConfigurer {
private final InstanceEntitlementInterceptor gateInterceptor;
public AccountLinkWebMvcConfig(InstanceEntitlementInterceptor gateInterceptor) {
this.gateInterceptor = gateInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// AI surface is always billable; the broad /api/v1/** catch lets automation-marked manual
// calls be gated too, while the interceptor lets genuine manual tools through.
registry.addInterceptor(gateInterceptor)
.addPathPatterns("/api/v1/**")
.excludePathPatterns("/api/v1/account-link/**");
}
}
@@ -1,38 +0,0 @@
package stirling.software.proprietary.accountlink;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.common.service.InternalApiClient;
/**
* Classifies a request as <b>billable</b> (AI / automation) or free (a manual tool).
*
* <p>Mirrors the saas billing categorisation at a coarse level, without depending on the saas
* module: billable = the AI surface ({@code /api/v1/ai/**}) or any request carrying the automation
* marker header ({@link InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy
* sub-steps). Everything else interactive manual PDF tools is always free.
*/
public final class BillableOperationClassifier {
private static final String AI_PATH_PREFIX = "/api/v1/ai/";
private BillableOperationClassifier() {}
public static boolean isBillable(HttpServletRequest request) {
if (request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null) {
return true;
}
String uri = request.getRequestURI();
if (uri == null) {
return false;
}
// Prefix-match the AI surface (not a loose substring contains), stripping a deployment
// context path so /<ctx>/api/v1/ai/** still classifies as billable.
String ctx = request.getContextPath();
String path =
ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)
? uri.substring(ctx.length())
: uri;
return path.startsWith(AI_PATH_PREFIX);
}
}
@@ -1,53 +0,0 @@
package stirling.software.proprietary.accountlink;
import java.io.Serializable;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* The device credential this self-hosted instance received when it linked a SaaS account
* (combined-billing "Mode A"). Singleton one instance links to exactly one SaaS team.
*
* <p>Unlike the SaaS side (which stores only a hash), the instance must keep the plaintext {@code
* deviceSecret} so it can present it on every unattended entitlement call. It lives in the local
* database (the same store that already holds API-key material and the license signature), so it is
* as secure-at-rest as the rest of the instance's secrets.
*/
@Entity
@Table(name = "account_link_device_credential")
@NoArgsConstructor
@Getter
@Setter
public class DeviceCredential implements Serializable {
private static final long serialVersionUID = 1L;
public static final Long SINGLETON_ID = 1L;
@Id
@Column(name = "id")
private Long id = SINGLETON_ID;
/** Public identifier minted by the SaaS register call; sent as {@code X-Device-Id}. */
@Column(name = "device_id", nullable = false, length = 64)
private String deviceId;
/** High-entropy secret returned once by register; sent as {@code X-Device-Secret}. */
@Column(name = "device_secret", nullable = false, length = 128)
private String deviceSecret;
/** SaaS team this instance is linked to; informational on the instance side. */
@Column(name = "team_id")
private Long teamId;
@Column(name = "linked_at", nullable = false)
private LocalDateTime linkedAt;
}
@@ -1,15 +0,0 @@
package stirling.software.proprietary.accountlink;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface DeviceCredentialRepository extends JpaRepository<DeviceCredential, Long> {
/** The singleton credential, if this instance has linked. */
default Optional<DeviceCredential> findCredential() {
return findById(DeviceCredential.SINGLETON_ID);
}
}
@@ -1,55 +0,0 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Secure-at-rest persistence for this instance's device credential. Thin wrapper over the
* singleton-row repository so the rest of the feature never touches JPA directly.
*
* <p>Gated + {@code @Profile("!saas")}: only the self-hosted profile links outward to a SaaS team.
*/
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class DeviceCredentialStore {
private final DeviceCredentialRepository repo;
public DeviceCredentialStore(DeviceCredentialRepository repo) {
this.repo = repo;
}
@Transactional(readOnly = true)
public Optional<DeviceCredential> get() {
return repo.findCredential();
}
@Transactional(readOnly = true)
public boolean isLinked() {
return repo.findCredential().isPresent();
}
/** Persists (or replaces) the credential returned by a SaaS register call. */
@Transactional
public void save(String deviceId, String deviceSecret, Long teamId) {
DeviceCredential cred = repo.findCredential().orElseGet(DeviceCredential::new);
cred.setId(DeviceCredential.SINGLETON_ID);
cred.setDeviceId(deviceId);
cred.setDeviceSecret(deviceSecret);
cred.setTeamId(teamId);
cred.setLinkedAt(LocalDateTime.now());
repo.save(cred);
}
/** Unlinks this instance locally (idempotent). */
@Transactional
public void clear() {
repo.findCredential().ifPresent(repo::delete);
}
}
@@ -1,124 +0,0 @@
package stirling.software.proprietary.accountlink;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Caches the linked team's entitlement so the request-time gate does not call the SaaS backend on
* every billable request. Single-slot (one instance = one linked team), TTL-based.
*
* <p>Fail-open friendly for TRANSPORT failures: {@link #current()} returns the freshest snapshot it
* has, even if a refresh just failed; it returns {@link Optional#empty()} only when nothing has
* ever been fetched <i>and</i> the latest refresh failed (the gate treats empty as "unknown →
* allow").
*
* <p>But an AUTHORITATIVE deny (revoked/invalid credential {@link
* AccountLinkClient.RevokedException}) is NOT a transport failure: the snapshot is replaced with a
* {@link EntitlementState#REVOKED} blocked entitlement so the gate stops billable work immediately
* rather than serving a stale entitled snapshot.
*/
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class EntitlementCache {
private final DeviceCredentialStore credentialStore;
private final AccountLinkClient client;
private final Duration ttl;
/** Entitlement + fetch time, swapped atomically as one value so readers never tear. */
private record Snapshot(InstanceEntitlement entitlement, Instant fetchedAt) {}
private static final Snapshot EMPTY = new Snapshot(null, Instant.EPOCH);
/** Blocked entitlement synthesised on an authoritative deny (revoked/invalid credential). */
private static final InstanceEntitlement REVOKED =
new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED);
private volatile Snapshot snapshot = EMPTY;
/** Single-flight guard: one thread refreshes while others serve the current snapshot. */
private final AtomicBoolean refreshing = new AtomicBoolean(false);
public EntitlementCache(
DeviceCredentialStore credentialStore,
AccountLinkClient client,
AccountLinkProperties properties) {
this.credentialStore = credentialStore;
this.client = client;
this.ttl = Duration.ofSeconds(properties.getEntitlementCacheSeconds());
}
/**
* Current entitlement, refreshing if stale. {@link Optional#empty()} means "unknown" either
* not linked or the SaaS side is unreachable and we have no prior snapshot.
*/
public Optional<InstanceEntitlement> current() {
// Single-flight: when stale, exactly one thread refreshes (blocking on the SaaS
// call) while concurrent callers serve the last snapshot no thundering herd of
// synchronous round-trips on the billable hot path. Safe because the gate fails open.
if (isStale(snapshot) && refreshing.compareAndSet(false, true)) {
try {
refresh();
} finally {
refreshing.set(false);
}
}
return Optional.ofNullable(snapshot.entitlement());
}
private boolean isStale(Snapshot snap) {
// fetchedAt is the last *attempt* time (stamped on success AND failure), so a failed
// fetch backs off for a full TTL instead of every billable request re-triggering a
// blocking round-trip against a dead/slow SaaS endpoint.
return Duration.between(snap.fetchedAt(), Instant.now()).compareTo(ttl) >= 0;
}
/**
* Pulls a fresh snapshot. Keeps the previous entitlement on a TRANSPORT failure (fail-open) but
* still stamps the attempt time so re-fetches throttle to the TTL; on an AUTHORITATIVE deny
* (revoked credential) replaces it with a blocked snapshot so the gate stops billable work.
*/
void refresh() {
Optional<DeviceCredential> cred = credentialStore.get();
if (cred.isEmpty()) {
// Unlinked: clear any stale snapshot so the gate sees "not linked".
snapshot = new Snapshot(null, Instant.now());
return;
}
try {
InstanceEntitlement fresh =
client.fetchEntitlement(cred.get().getDeviceId(), cred.get().getDeviceSecret());
if (fresh != null) {
snapshot = new Snapshot(fresh, Instant.now());
} else {
// Unreachable / server error: keep the last known entitlement (may be null) but
// stamp the attempt so we don't hammer SaaS; the gate fails open in the meantime.
log.debug(
"Entitlement refresh failed; reusing last known snapshot, backing off a TTL");
snapshot = new Snapshot(snapshot.entitlement(), Instant.now());
}
} catch (AccountLinkClient.RevokedException e) {
// Authoritative deny credential revoked/invalid. Do NOT fail open: block immediately
// rather than serving the stale entitled snapshot until the next unlink.
log.info(
"Entitlement denied (HTTP {}); blocking billable work for the revoked credential",
e.status());
snapshot = new Snapshot(REVOKED, Instant.now());
}
}
/** Forces a refresh on the next {@link #current()} (e.g. right after linking). */
public void invalidate() {
snapshot = new Snapshot(snapshot.entitlement(), Instant.EPOCH);
}
}
@@ -1,19 +0,0 @@
package stirling.software.proprietary.accountlink;
/**
* Coarse entitlement state the local gate enforces against. Proprietary-local (no coupling to the
* saas billing module): the SaaS entitlement response is parsed into this minimal shape.
*/
public enum EntitlementState {
/** Within free pool or covered by an active subscription — billable work allowed. */
OK,
/** Free pool exhausted and no subscription / over the period cap — billable work blocked. */
OVER_LIMIT,
/**
* Device credential revoked/invalid on the SaaS side (authoritative 401/403 deny) billable
* work blocked. Synthesised locally by {@code EntitlementCache}, never sent by SaaS.
*/
REVOKED,
/** Unrecognised/malformed reply — the gate falls back to its numeric checks, not this flag. */
UNKNOWN
}
@@ -1,34 +0,0 @@
package stirling.software.proprietary.accountlink;
/**
* Outcome of {@link InstanceEntitlementGate}. {@link #allowed} is what the interceptor enforces;
* {@link #reason} carries the machine-readable signal the FE maps to a prompt (e.g. "link to
* activate"). Manual-tool and fail-open allows carry an informational reason but never block.
*/
public record GateDecision(boolean allowed, Reason reason) {
public enum Reason {
/** Feature flag is off — gate is fully inert. */
FLAG_OFF,
/** Operation is a manual tool — always free, never gated. */
MANUAL_FREE,
/** Linked + within entitlement — billable work allowed. */
ENTITLED,
/** Entitlement source unreachable — fail open, allow. */
FAIL_OPEN,
/** Not linked — block billable work; FE should prompt to link. */
NOT_LINKED,
/** Linked but over the limit / no subscription — block billable work. */
OVER_LIMIT,
/** Credential revoked/invalid on the SaaS side — block billable work. */
REVOKED
}
public static GateDecision allow(Reason reason) {
return new GateDecision(true, reason);
}
public static GateDecision block(Reason reason) {
return new GateDecision(false, reason);
}
}
@@ -1,19 +0,0 @@
package stirling.software.proprietary.accountlink;
/**
* Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response
* just the fields the gate needs. Mirrors the saas {@code EntitlementResponse} shape but carries no
* saas types.
*
* @param subscribed team has an active subscription
* @param freeRemainingUnits remaining free-pool units (>0 means free work is available)
* @param periodSpendUnits paid units spent this period
* @param periodCapUnits paid cap for the period; {@code null} = uncapped
* @param state coarse state classification (see {@link EntitlementState})
*/
public record InstanceEntitlement(
boolean subscribed,
long freeRemainingUnits,
long periodSpendUnits,
Long periodCapUnits,
EntitlementState state) {}
@@ -1,104 +0,0 @@
package stirling.software.proprietary.accountlink;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
/**
* Decides whether a request may proceed under combined-billing "Mode A" on a self-hosted instance.
*
* <p>Rules (in order):
*
* <ol>
* <li>Flag off always allow (feature inert).
* <li>Manual tool always allow (manual tools are free, never metered).
* <li>Billable + not linked block with {@code NOT_LINKED} ("link to activate").
* <li>Billable + linked + entitlement unknown (unreachable) <b>fail open</b>, allow.
* <li>Billable + linked + entitled allow.
* <li>Billable + linked + credential revoked block with {@code REVOKED}.
* <li>Billable + linked + over limit block with {@code OVER_LIMIT}.
* </ol>
*
* <p>The decision logic is the pure static {@link #decide}; the Spring wrapper just supplies the
* live flag / linked-state / entitlement. This is the unit-tested core.
*/
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class InstanceEntitlementGate {
private final AccountLinkProperties properties;
private final DeviceCredentialStore credentialStore;
private final EntitlementCache entitlementCache;
public InstanceEntitlementGate(
AccountLinkProperties properties,
DeviceCredentialStore credentialStore,
EntitlementCache entitlementCache) {
this.properties = properties;
this.credentialStore = credentialStore;
this.entitlementCache = entitlementCache;
}
/** Evaluates the gate for a request, resolving live state from the store + cache. */
public GateDecision evaluate(boolean billable) {
if (!properties.isEnabled()) {
return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
}
if (!billable) {
return GateDecision.allow(GateDecision.Reason.MANUAL_FREE);
}
boolean linked = credentialStore.isLinked();
Optional<InstanceEntitlement> entitlement =
linked ? entitlementCache.current() : Optional.empty();
return decide(true, true, linked, entitlement);
}
/**
* Pure decision function no Spring, no I/O. {@code entitlement} empty means "unknown"
* (unreachable): when linked, that fails open.
*/
public static GateDecision decide(
boolean flagEnabled,
boolean billable,
boolean linked,
Optional<InstanceEntitlement> entitlement) {
if (!flagEnabled) {
return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
}
if (!billable) {
return GateDecision.allow(GateDecision.Reason.MANUAL_FREE);
}
if (!linked) {
return GateDecision.block(GateDecision.Reason.NOT_LINKED);
}
if (entitlement.isEmpty()) {
// Linked but entitlement source unreachable never hard-block billable work on our
// inability to reach billing.
return GateDecision.allow(GateDecision.Reason.FAIL_OPEN);
}
InstanceEntitlement e = entitlement.get();
if (e.state() == EntitlementState.REVOKED) {
// Credential revoked/invalid (authoritative deny) block, distinct from over-limit.
return GateDecision.block(GateDecision.Reason.REVOKED);
}
return entitled(e)
? GateDecision.allow(GateDecision.Reason.ENTITLED)
: GateDecision.block(GateDecision.Reason.OVER_LIMIT);
}
/** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */
private static boolean entitled(InstanceEntitlement e) {
if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) {
return false;
}
if (e.subscribed()) {
// Subscribed: allowed unless a period cap is set and exceeded.
return e.periodCapUnits() == null || e.periodSpendUnits() < e.periodCapUnits();
}
// Unsubscribed: only the free pool covers billable work.
return e.freeRemainingUnits() > 0;
}
}
@@ -1,65 +0,0 @@
package stirling.software.proprietary.accountlink;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
/**
* Request-time gate for combined-billing "Mode A". Runs before billable (AI / automation) work and
* blocks it when the instance is unlinked or over its limit; manual tools pass straight through.
*
* <p>Blocking responds {@code 402 Payment Required} with a small machine-readable body {@code
* {"error":"ACCOUNT_LINK_REQUIRED","reason":"NOT_LINKED"}} that the FE maps to a "link to
* activate" prompt (the same DownstreamEntitlementError-style envelope already used for saas limit
* responses). Fail-open and flag-off both let the request continue.
*
* <p>Gated + {@code @Profile("!saas")}; when the flag is off the bean is absent and the {@link
* AccountLinkWebMvcConfig} never registers it, so there is no per-request cost.
*/
@Slf4j
@Component
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class InstanceEntitlementInterceptor implements HandlerInterceptor {
private final InstanceEntitlementGate gate;
public InstanceEntitlementInterceptor(InstanceEntitlementGate gate) {
this.gate = gate;
}
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
GateDecision decision;
try {
decision = gate.evaluate(BillableOperationClassifier.isBillable(request));
} catch (RuntimeException e) {
// Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never
// turn into a hard block on billable work.
log.debug("Account-link gate evaluation failed; allowing request", e);
return true;
}
if (decision.allowed()) {
return true;
}
log.debug("Account-link gate blocked {} ({})", request.getRequestURI(), decision.reason());
response.setStatus(HttpStatus.PAYMENT_REQUIRED.value());
response.setContentType("application/json");
response.getWriter()
.write(
"{\"error\":\"ACCOUNT_LINK_REQUIRED\",\"reason\":\""
+ decision.reason().name()
+ "\"}");
return false;
}
}
@@ -2,7 +2,6 @@ package stirling.software.proprietary.policy.controller;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -53,15 +52,11 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
import stirling.software.proprietary.policy.model.PolicyRun;
import stirling.software.proprietary.policy.model.PolicyRunStatus;
import stirling.software.proprietary.policy.model.PolicyRunView;
import stirling.software.proprietary.policy.overview.PoliciesOverviewResponse;
import stirling.software.proprietary.policy.overview.PolicyOverviewService;
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
import stirling.software.proprietary.policy.source.SourceAccessGuard;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
import stirling.software.proprietary.policy.trigger.TriggerInfo;
/**
* Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code
@@ -85,8 +80,6 @@ public class PolicyController {
private final PolicyAccessGuard policyAccessGuard;
private final PolicyManagementAuthority policyManagementAuthority;
private final PolicyTriggerManager policyTriggerManager;
private final PolicyOverviewService policyOverviewService;
private final List<PolicyTrigger> policyTriggers;
private final ApplicationProperties applicationProperties;
private final TempFileManager tempFileManager;
private final JobOwnershipService jobOwnershipService;
@@ -294,31 +287,6 @@ public class PolicyController {
return policyAccessGuard.visibleFrom(policyStore);
}
@GetMapping("/overview")
@Operation(
summary = "Pipelines overview",
description =
"Returns the KPI strip plus one row per policy the caller's team owns, each with"
+ " its referenced sources resolved to names, its pipeline steps, and a"
+ " trigger/output summary. Backs the portal's all-pipelines surface.")
public PoliciesOverviewResponse overview() {
return policyOverviewService.overview();
}
@GetMapping("/triggers")
@Operation(
summary = "List available triggers",
description =
"Lists each trigger kind with whether it needs a source and which source types"
+ " it supports, so the UI can offer triggers and pair them with the"
+ " right sources.")
public List<TriggerInfo> triggers() {
return policyTriggers.stream()
.map(TriggerInfo::of)
.sorted(Comparator.comparing(TriggerInfo::type))
.toList();
}
@GetMapping("/{policyId}")
@Operation(summary = "Get a policy by id")
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
@@ -369,26 +337,6 @@ public class PolicyController {
return ResponseEntity.accepted().body(new JobResponse<>(true, runId, null));
}
@PostMapping("/{policyId}/trigger")
@Operation(
summary = "Run a stored policy against its sources",
description =
"Pulls the policy's configured sources and runs the pipeline now, regardless of"
+ " the enabled flag (which only gates automatic triggering). Returns"
+ " the ids of the runs started; poll the run-status endpoint for each."
+ " Empty when the sources yielded no work to do.")
public ResponseEntity<List<String>> trigger(@PathVariable String policyId) {
Policy policy =
policyStore
.get(policyId)
.filter(policyAccessGuard::canAccess)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND, "No policy: " + policyId));
return ResponseEntity.accepted().body(policyRunner.run(policy));
}
private static void requireRunnable(PipelineDefinition definition) {
if (definition.steps().isEmpty()) {
throw new ResponseStatusException(
@@ -1,7 +1,6 @@
package stirling.software.proprietary.policy.engine;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
@@ -42,15 +41,14 @@ public class PolicyRunner {
* Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so
* one failure does not affect the others. No sources means one run with no input (generator
* pipeline). Missing or disabled sources are skipped so one broken reference does not stop the
* rest. Returns the ids of the runs it started (empty when sources yielded no work), so a
* manual trigger can report back which runs to follow.
* rest.
*/
public List<String> run(Policy policy) {
public void run(Policy policy) {
List<String> sourceIds = policy.sourceIds();
if (sourceIds.isEmpty()) {
return List.of(startRun(policy, PolicyInputs.of(List.of()), unused -> {}));
startRun(policy, PolicyInputs.of(List.of()), unused -> {});
return;
}
List<String> runIds = new ArrayList<>();
for (String sourceId : sourceIds) {
Source source = sourceStore.get(sourceId).orElse(null);
if (source == null) {
@@ -65,9 +63,8 @@ public class PolicyRunner {
policy.id());
continue;
}
runIds.addAll(pullAndRun(policy, source.toInputSpec()));
pullAndRun(policy, source.toInputSpec());
}
return runIds;
}
/** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */
@@ -82,14 +79,14 @@ public class PolicyRunner {
return policyEngine.submit(definition, inputs, listener);
}
private List<String> pullAndRun(Policy policy, InputSpec spec) {
private void pullAndRun(Policy policy, InputSpec spec) {
InputSource source = sourceFor(spec);
if (source == null) {
log.warn(
"No input source for type '{}' (policy {}); skipping",
spec.type(),
policy.id());
return List.of();
return;
}
List<ResolvedInput> work;
try {
@@ -100,22 +97,19 @@ public class PolicyRunner {
spec.type(),
policy.id(),
e.getMessage());
return List.of();
return;
}
List<String> runIds = new ArrayList<>();
for (ResolvedInput unit : work) {
runIds.add(startRun(policy, unit.inputs(), unit.onComplete()));
startRun(policy, unit.inputs(), unit.onComplete());
}
return runIds;
}
private String startRun(Policy policy, PolicyInputs inputs, Consumer<Boolean> onComplete) {
private void startRun(Policy policy, PolicyInputs inputs, Consumer<Boolean> onComplete) {
log.info("Running policy {} ({})", policy.id(), policy.name());
PolicyRunHandle handle =
policyEngine.runPolicy(policy, inputs, PolicyProgressListener.NOOP);
handle.completion()
.whenComplete((run, throwable) -> onComplete.accept(succeeded(run, throwable)));
return handle.runId();
}
private static boolean succeeded(PolicyRun run, Throwable throwable) {
@@ -1,6 +0,0 @@
package stirling.software.proprietary.policy.overview;
import java.util.List;
/** The Pipelines overview payload: a KPI strip plus one row per policy. */
public record PoliciesOverviewResponse(List<PolicyKpi> kpis, List<PolicyView> pipelines) {}
@@ -1,4 +0,0 @@
package stirling.software.proprietary.policy.overview;
/** One headline figure in the Pipelines overview strip. */
public record PolicyKpi(long value, String description) {}
@@ -1,102 +0,0 @@
package stirling.software.proprietary.policy.overview;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceAccessGuard;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Builds the Pipelines overview: every policy the caller's team owns, each annotated with its
* referenced sources (resolved to display names), its pipeline steps, and a trigger/output summary.
* Source names are resolved from the team's sources in memory rather than persisted on the policy,
* so the view always reflects the live source set. This is the "all pipelines" admin surface; the
* user-facing Policies page builds only a friendly subset of the same backend policies.
*/
@Service
@RequiredArgsConstructor
@ConditionalOnBooleanProperty(name = "policies.enabled")
public class PolicyOverviewService {
private final PolicyStore policyStore;
private final SourceStore sourceStore;
private final PolicyAccessGuard policyAccessGuard;
private final SourceAccessGuard sourceAccessGuard;
public PoliciesOverviewResponse overview() {
List<Policy> policies = policyAccessGuard.visibleFrom(policyStore);
Map<String, String> sourceNames = sourceNames();
List<PolicyView> views =
policies.stream()
.map(policy -> toView(policy, sourceNames))
.sorted(
Comparator.comparing(
PolicyView::name, String.CASE_INSENSITIVE_ORDER))
.toList();
return new PoliciesOverviewResponse(buildKpis(policies), views);
}
/** Display names for every source the caller's team can see, keyed by source id. */
private Map<String, String> sourceNames() {
Map<String, String> names = new HashMap<>();
for (Source source : sourceAccessGuard.visibleFrom(sourceStore)) {
names.put(source.id(), source.name());
}
return names;
}
private static PolicyView toView(Policy policy, Map<String, String> sourceNames) {
List<PolicyView.SourceRef> sources =
policy.sourceIds().stream()
// An unresolved id (source deleted, or not visible) falls back to the id so
// the row still renders rather than dropping the reference silently.
.map(id -> new PolicyView.SourceRef(id, sourceNames.getOrDefault(id, id)))
.toList();
List<String> steps = policy.steps().stream().map(PipelineStep::operation).toList();
return new PolicyView(
policy.id(),
policy.name(),
policy.enabled(),
policy.enabled() ? "active" : "paused",
triggerSummary(policy.trigger()),
sources,
steps,
outputSummary(policy.output()),
policy.owner());
}
/** A null trigger is a manual-only policy; otherwise the trigger's type keys the summary. */
private static String triggerSummary(TriggerConfig trigger) {
return trigger == null ? "manual" : trigger.type();
}
private static String outputSummary(OutputSpec output) {
return output == null ? "inline" : output.type();
}
private static List<PolicyKpi> buildKpis(List<Policy> policies) {
long total = policies.size();
long active = policies.stream().filter(Policy::enabled).count();
long paused = total - active;
return List.of(
new PolicyKpi(total, "pipelines"),
new PolicyKpi(active, "running automatically"),
new PolicyKpi(paused, "paused"));
}
}
@@ -1,24 +0,0 @@
package stirling.software.proprietary.policy.overview;
import java.util.List;
/**
* One row in the Pipelines overview: a stored policy shown for the admin portal, with its
* referenced sources resolved to names and its pipeline summarised. The portal's "all pipelines"
* surface lists every backend policy (the user-facing Policies page builds only a friendly subset
* of these).
*/
public record PolicyView(
String id,
String name,
boolean enabled,
String status,
String trigger,
List<SourceRef> sources,
List<String> steps,
String output,
String owner) {
/** A source a policy pulls documents from, resolved to its display name. */
public record SourceRef(String id, String name) {}
}
@@ -27,7 +27,6 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.policy.config.FolderAccessGuard;
import stirling.software.proprietary.policy.engine.PolicyRunner;
import stirling.software.proprietary.policy.input.InputSource;
import stirling.software.proprietary.policy.model.InputSpec;
@@ -75,16 +74,6 @@ public class FolderWatchTrigger implements PolicyTrigger {
return TYPE;
}
@Override
public boolean requiresSource() {
return true;
}
@Override
public Set<String> supportedSourceTypes() {
return Set.of(FolderAccessGuard.FOLDER_TYPE);
}
@Override
public void validate(Policy policy) {
if (watchDirsOf(policy).isEmpty()) {
@@ -1,7 +1,5 @@
package stirling.software.proprietary.policy.trigger;
import java.util.Set;
import stirling.software.proprietary.policy.model.Policy;
/**
@@ -13,24 +11,6 @@ public interface PolicyTrigger {
/** Matches {@code TriggerConfig.type()}. */
String type();
/**
* Whether this trigger needs at least one compatible input source to function. A schedule fires
* on the clock regardless of sources, so it is false; folder-watch derives the directories it
* watches from the policy's sources, so it is true. Drives whether the UI offers the trigger.
*/
default boolean requiresSource() {
return false;
}
/**
* The source {@code type()}s this trigger is compatible with (e.g. {@code "folder"}). Empty
* means source-agnostic (no constraint). Lets the UI offer a trigger only when a compatible
* source is selected, without hard-coding the relationship.
*/
default Set<String> supportedSourceTypes() {
return Set.of();
}
/**
* Validate at save time so misconfiguration fails fast, not at fire time. Receives the whole
* {@link Policy} so triggers that depend on the policy's sources (folder-watch) can check that.
@@ -1,18 +0,0 @@
package stirling.software.proprietary.policy.trigger;
import java.util.List;
/**
* Describes an available trigger for the admin UI: its {@code type} (matching {@code
* TriggerConfig.type()}), whether it needs a compatible source, and which source types it works
* with. Lets the UI list supported triggers and pair them with sources without hard-coding the set.
*/
public record TriggerInfo(String type, boolean requiresSource, List<String> supportedSourceTypes) {
public static TriggerInfo of(PolicyTrigger trigger) {
return new TriggerInfo(
trigger.type(),
trigger.requiresSource(),
List.copyOf(trigger.supportedSourceTypes()));
}
}
@@ -33,7 +33,6 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.storage.repository",
"stirling.software.proprietary.workflow.repository",
"stirling.software.proprietary.policy.store",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.policy.source"
})
@EntityScan({
@@ -42,7 +41,6 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
"stirling.software.proprietary.storage.model",
"stirling.software.proprietary.workflow.model",
"stirling.software.proprietary.policy.store",
"stirling.software.proprietary.accountlink",
"stirling.software.proprietary.policy.source"
})
public class DatabaseConfig {
@@ -22,12 +22,12 @@ import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
@@ -40,22 +40,22 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
private static final String KEYSTORE_ALIAS = "stirling-pdf-server";
private static final String DEFAULT_PASSWORD = "stirling-pdf-server-cert";
@Value("${system.serverCertificate.enabled:false}")
private boolean enabled;
@Value("${system.serverCertificate.organizationName:Stirling-PDF}")
private String organizationName;
@Value("${system.serverCertificate.validity:365}")
private int validityDays;
@Value("${system.serverCertificate.regenerateOnStartup:false}")
private boolean regenerateOnStartup;
private final LicenseKeyChecker licenseKeyChecker;
public ServerCertificateService(
LicenseKeyChecker licenseKeyChecker, ApplicationProperties applicationProperties) {
public ServerCertificateService(LicenseKeyChecker licenseKeyChecker) {
this.licenseKeyChecker = licenseKeyChecker;
ApplicationProperties.System.ServerCertificate config =
applicationProperties.getSystem().getServerCertificate();
this.enabled = config.isEnabled();
this.organizationName = config.getOrganizationName();
this.validityDays = config.getValidity();
this.regenerateOnStartup = config.isRegenerateOnStartup();
}
static {
@@ -404,16 +404,18 @@ public class WorkflowParticipantController {
certSubmission.put("reason", request.getReason());
certSubmission.put("showLogo", request.getShowLogo());
// Store the certificate keystores encrypted at rest.
// Store certificate files as base64
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
certSubmission.put(
"p12Keystore",
metadataEncryptionService.encryptBytes(request.getP12File().getBytes()));
java.util.Base64.getEncoder()
.encodeToString(request.getP12File().getBytes()));
}
if (request.getJksFile() != null && !request.getJksFile().isEmpty()) {
certSubmission.put(
"jksKeystore",
metadataEncryptionService.encryptBytes(request.getJksFile().getBytes()));
java.util.Base64.getEncoder()
.encodeToString(request.getJksFile().getBytes()));
}
metadata.put("certificateSubmission", certSubmission);
@@ -32,7 +32,6 @@ public class SignDocumentRequest {
private String password;
private MultipartFile privateKeyFile;
private MultipartFile certFile;
private MultipartFile jksFile;
// Signature metadata (participant can override owner defaults)
private String reason; // Participant's reason for signing
@@ -96,28 +96,6 @@ public class MetadataEncryptionService {
}
}
/**
* Encodes raw bytes to Base64 and encrypts them for at-rest storage (e.g. keystore files held
* in JSONB metadata). Returns {@code null} for {@code null} input.
*/
public String encryptBytes(byte[] data) {
if (data == null) {
return null;
}
return encrypt(Base64.getEncoder().encodeToString(data));
}
/**
* Reverses {@link #encryptBytes}. Also accepts legacy values stored as plain Base64 before
* encryption was introduced {@link #decrypt} returns those unchanged, so they still decode.
*/
public byte[] decryptBytes(String stored) {
if (stored == null) {
return null;
}
return Base64.getDecoder().decode(decrypt(stored));
}
// Internals
private SecretKeySpec deriveKey() throws Exception {
@@ -1039,18 +1039,17 @@ public class SigningFinalizationService {
metadataEncryptionService.decrypt(submission.getPassword()));
}
// Decrypt + decode keystore bytes (supports both legacy plaintext base64 and
// encrypted values).
// Decode base64 keystore bytes
var certNode = node.get("certificateSubmission");
if (certNode.has("p12Keystore")) {
submission.setP12Keystore(
metadataEncryptionService.decryptBytes(
certNode.get("p12Keystore").asText()));
java.util.Base64.getDecoder()
.decode(certNode.get("p12Keystore").asText()));
}
if (certNode.has("jksKeystore")) {
submission.setJksKeystore(
metadataEncryptionService.decryptBytes(
certNode.get("jksKeystore").asText()));
java.util.Base64.getDecoder()
.decode(certNode.get("jksKeystore").asText()));
}
return submission;
}
@@ -81,7 +81,7 @@ public class UserServerCertificateService {
// Certificate details with username
String username = user.getUsername();
X500Name subject = new X500Name("CN=" + username + ", OU=User, O=Stirling PDF Inc, C=US");
X500Name subject = new X500Name("CN=" + username + ", O=Stirling-PDF User, C=US");
BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis());
Date notBefore = new Date();
Date notAfter =
@@ -1,13 +1,6 @@
package stirling.software.proprietary.workflow.service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
@@ -15,16 +8,6 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.openssl.PEMDecryptorProvider;
import org.bouncycastle.openssl.PEMEncryptedKeyPair;
import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder;
import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder;
import org.bouncycastle.operator.InputDecryptorProvider;
import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -722,67 +705,21 @@ public class WorkflowSessionService {
}
}
// Validate an uploaded JKS keystore too (same early rejection as P12/PFX).
if ("JKS".equalsIgnoreCase(request.getCertType())
&& request.getJksFile() != null
&& !request.getJksFile().isEmpty()) {
try {
certificateSubmissionValidator.validateAndExtractInfo(
request.getJksFile().getBytes(), "JKS", request.getPassword());
} catch (ResponseStatusException e) {
throw e;
} catch (IOException e) {
log.error("Failed to read JKS keystore file for validation", e);
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Failed to process certificate file");
}
}
// 2. Store certificate submission data
Map<String, Object> certSubmission = new HashMap<>();
certSubmission.put("certType", request.getCertType());
certSubmission.put("password", metadataEncryptionService.encrypt(request.getPassword()));
if ("PEM".equalsIgnoreCase(request.getCertType())) {
// PEM uploads are a separate private key + certificate, not a keystore. Convert them to
// a PKCS12 keystore here so finalization signs via the standard PKCS12 path.
byte[] p12 =
buildPkcs12FromPem(
request.getPrivateKeyFile(),
request.getCertFile(),
request.getPassword());
// Give PEM the same early validation (expiry, key recovery, test-sign) as uploaded
// PKCS12/JKS keystores, so an expired or unusable cert is rejected now rather than at
// finalization.
certificateSubmissionValidator.validateAndExtractInfo(
p12, "PKCS12", request.getPassword());
certSubmission.put("certType", "PKCS12");
// Store the keystore encrypted at rest.
certSubmission.put("p12Keystore", metadataEncryptionService.encryptBytes(p12));
} else {
certSubmission.put("certType", request.getCertType());
// Encrypt the uploaded keystore at rest: PKCS12/PFX p12Keystore, JKS jksKeystore.
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
try {
certSubmission.put(
"p12Keystore",
metadataEncryptionService.encryptBytes(
request.getP12File().getBytes()));
} catch (IOException e) {
log.error("Failed to read P12 keystore file", e);
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Failed to process certificate file");
}
} else if (request.getJksFile() != null && !request.getJksFile().isEmpty()) {
try {
certSubmission.put(
"jksKeystore",
metadataEncryptionService.encryptBytes(
request.getJksFile().getBytes()));
} catch (IOException e) {
log.error("Failed to read JKS keystore file", e);
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Failed to process certificate file");
}
// Store keystore files as base64 if provided
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
try {
byte[] keystoreBytes = request.getP12File().getBytes();
String base64Keystore = java.util.Base64.getEncoder().encodeToString(keystoreBytes);
certSubmission.put("p12Keystore", base64Keystore);
} catch (IOException e) {
log.error("Failed to read P12 keystore file", e);
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Failed to process certificate file");
}
}
@@ -900,69 +837,6 @@ public class WorkflowSessionService {
"User is not a participant in this session"));
}
/**
* Converts an uploaded PEM private key + certificate into a PKCS12 keystore (protected with the
* supplied password) so finalization can sign via the standard PKCS12 path.
*/
private byte[] buildPkcs12FromPem(
MultipartFile privateKeyFile, MultipartFile certFile, String password) {
if (privateKeyFile == null
|| privateKeyFile.isEmpty()
|| certFile == null
|| certFile.isEmpty()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"PEM signing requires both a private key file and a certificate file");
}
char[] pw = password != null ? password.toCharArray() : new char[0];
try {
PrivateKey privateKey = readPemPrivateKey(privateKeyFile.getBytes(), pw);
Certificate cert =
CertificateFactory.getInstance("X.509")
.generateCertificate(new ByteArrayInputStream(certFile.getBytes()));
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(null, null);
keyStore.setKeyEntry("alias", privateKey, pw, new Certificate[] {cert});
ByteArrayOutputStream out = new ByteArrayOutputStream();
keyStore.store(out, pw);
return out.toByteArray();
} catch (ResponseStatusException e) {
throw e;
} catch (Exception e) {
log.error("Failed to build keystore from PEM certificate", e);
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Failed to read PEM certificate — check the key/certificate files and password");
}
}
/** Reads a PEM private key (PKCS8/PKCS1, optionally password-encrypted). */
private PrivateKey readPemPrivateKey(byte[] pemBytes, char[] password) throws Exception {
try (PEMParser pemParser =
new PEMParser(new InputStreamReader(new ByteArrayInputStream(pemBytes)))) {
Object pemObject = pemParser.readObject();
JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC");
PrivateKeyInfo keyInfo;
if (pemObject instanceof PKCS8EncryptedPrivateKeyInfo encrypted) {
InputDecryptorProvider decryptor =
new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password);
keyInfo = encrypted.decryptPrivateKeyInfo(decryptor);
} else if (pemObject instanceof PEMEncryptedKeyPair encryptedKeyPair) {
PEMDecryptorProvider decryptor =
new JcePEMDecryptorProviderBuilder().build(password);
keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo();
} else if (pemObject instanceof PEMKeyPair keyPair) {
keyInfo = keyPair.getPrivateKeyInfo();
} else if (pemObject instanceof PrivateKeyInfo info) {
keyInfo = info;
} else {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Unsupported PEM private key format");
}
return converter.getPrivateKey(keyInfo);
}
}
/** Helper class to wrap byte array as MultipartFile. */
private static class ByteArrayMultipartFile implements MultipartFile {
private final byte[] content;
@@ -1,192 +0,0 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.net.ConnectException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import tools.jackson.databind.ObjectMapper;
/**
* Stubs the {@link HttpClient} so the SaaS endpoint is never actually called. Confirms register
* relays the JWT and parses the credential, and that entitlement parsing + the fail-open (null on
* unreachable) behaviour hold.
*/
class AccountLinkClientTest {
private AccountLinkProperties properties;
private HttpClient httpClient;
private AccountLinkClient client;
@BeforeEach
void setUp() {
properties = new AccountLinkProperties();
properties.setEnabled(true);
properties.setSaasBaseUrl("https://saas.example.com");
httpClient = mock(HttpClient.class);
client = new AccountLinkClient(properties, new ObjectMapper(), httpClient);
}
@SuppressWarnings("unchecked")
private HttpResponse<String> response(int status, String body) {
HttpResponse<String> resp = mock(HttpResponse.class);
when(resp.statusCode()).thenReturn(status);
when(resp.body()).thenReturn(body);
return resp;
}
@Test
@SuppressWarnings("unchecked")
void registerRelaysJwtAndParsesCredential() throws Exception {
// Build the stub response first: nesting response() inside when() trips Mockito's
// unfinished-stubbing check (inner when() runs mid outer when()).
HttpResponse<String> resp =
response(201, "{\"deviceId\":\"dev-1\",\"deviceSecret\":\"sec-1\",\"teamId\":42}");
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
.thenReturn(resp);
AccountLinkClient.RegisterResult result = client.register("jwt-token", "My Server");
assertEquals("dev-1", result.deviceId());
assertEquals("sec-1", result.deviceSecret());
assertEquals(42L, result.teamId());
HttpRequest sent = captor.getValue();
assertEquals("Bearer jwt-token", sent.headers().firstValue("Authorization").orElse(null));
assertEquals(
"https://saas.example.com/api/v1/account-link/register", sent.uri().toString());
}
@Test
@SuppressWarnings("unchecked")
void registerThrowsUpstreamExceptionWithStatusOnNon2xx() throws Exception {
HttpResponse<String> resp = response(401, "{\"error\":\"unauthorized\"}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
AccountLinkClient.UpstreamException ex =
assertThrows(
AccountLinkClient.UpstreamException.class,
() -> client.register("jwt", null));
assertEquals(401, ex.status());
}
@Test
@SuppressWarnings("unchecked")
void fetchEntitlementParsesSnapshotAndSendsDeviceHeaders() throws Exception {
HttpResponse<String> resp =
response(
200,
"{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":10,\"periodCapUnits\":100,\"state\":\"OK\"}");
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
.thenReturn(resp);
InstanceEntitlement e = client.fetchEntitlement("dev-1", "sec-1");
assertNotNull(e);
assertEquals(true, e.subscribed());
assertEquals(10, e.periodSpendUnits());
assertEquals(100L, e.periodCapUnits());
assertEquals(EntitlementState.OK, e.state());
HttpRequest sent = captor.getValue();
assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null));
assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null));
}
@Test
@SuppressWarnings("unchecked")
void fetchEntitlementMapsOverLimitState() throws Exception {
// Pins the consume side of the wire contract: InstanceController emits "OVER_LIMIT" (for a
// DEGRADED team) and the client must map it to the gate-blocking state.
HttpResponse<String> resp =
response(
200,
"{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":1300,\"periodCapUnits\":1250,\"state\":\"OVER_LIMIT\"}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
InstanceEntitlement e = client.fetchEntitlement("dev-1", "sec-1");
assertNotNull(e);
assertEquals(EntitlementState.OVER_LIMIT, e.state());
}
@Test
@SuppressWarnings("unchecked")
void fetchEntitlementReturnsNullWhenUnreachable() throws Exception {
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class)))
.thenThrow(new ConnectException("refused"));
// Null = unknown the cache/gate fail open.
assertNull(client.fetchEntitlement("dev-1", "sec-1"));
}
@Test
@SuppressWarnings("unchecked")
void fetchEntitlementReturnsNullOnServerError() throws Exception {
// 5xx is a transient/server failure, not a credential deny null, the cache fails open.
HttpResponse<String> resp = response(503, "{}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
assertNull(client.fetchEntitlement("dev-1", "sec-1"));
}
@Test
@SuppressWarnings("unchecked")
void fetchEntitlementThrowsRevokedOnDeny() throws Exception {
// 401/403 = authoritative deny (revoked/invalid credential) RevokedException, NOT null:
// the cache must block billable work rather than fail open on a stale snapshot.
for (int status : new int[] {401, 403}) {
HttpResponse<String> resp = response(status, "{}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
AccountLinkClient.RevokedException ex =
assertThrows(
AccountLinkClient.RevokedException.class,
() -> client.fetchEntitlement("dev-1", "sec-1"));
assertEquals(status, ex.status());
}
}
@Test
@SuppressWarnings("unchecked")
void revokeSelfSendsDeviceHeadersAndReturnsTrueOn2xx() throws Exception {
HttpResponse<String> resp = response(204, "");
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
.thenReturn(resp);
assertEquals(true, client.revokeSelf("dev-1", "sec-1"));
HttpRequest sent = captor.getValue();
assertEquals("https://saas.example.com/api/v1/instance/revoke-self", sent.uri().toString());
assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null));
assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null));
assertEquals("POST", sent.method());
}
@Test
@SuppressWarnings("unchecked")
void revokeSelfReturnsFalseOnErrorStatus() throws Exception {
HttpResponse<String> resp = response(403, "{}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
assertEquals(false, client.revokeSelf("dev-1", "sec-1"));
}
@Test
@SuppressWarnings("unchecked")
void revokeSelfReturnsFalseWhenUnreachable() throws Exception {
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class)))
.thenThrow(new ConnectException("refused"));
assertEquals(false, client.revokeSelf("dev-1", "sec-1"));
}
}
@@ -1,68 +0,0 @@
package stirling.software.proprietary.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.IOException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import stirling.software.proprietary.accountlink.AccountLinkController.LinkRequest;
/**
* The local (self-hosted) account-link controller's error mapping: an upstream auth rejection
* surfaces as 401/403 (so the portal can prompt a re-sign-in) while other upstream / transport
* faults are a 502.
*/
class AccountLinkControllerTest {
private AccountLinkService service;
private AccountLinkController controller;
@BeforeEach
void setUp() {
service = mock(AccountLinkService.class);
controller = new AccountLinkController(service);
}
@Test
void link_missingJwt_returns400() {
ResponseEntity<?> resp = controller.link(new LinkRequest(" ", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void link_upstreamUnauthorized_maps401() throws Exception {
when(service.link("jwt", null))
.thenThrow(new AccountLinkClient.UpstreamException(401, "bad token"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void link_upstreamForbidden_maps403() throws Exception {
when(service.link("jwt", null))
.thenThrow(new AccountLinkClient.UpstreamException(403, "forbidden"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void link_upstreamServerError_maps502() throws Exception {
when(service.link("jwt", null))
.thenThrow(new AccountLinkClient.UpstreamException(500, "boom"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
}
@Test
void link_transportFailure_maps502() throws Exception {
when(service.link("jwt", null)).thenThrow(new IOException("connection refused"));
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
}
}
@@ -1,111 +0,0 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class AccountLinkServiceTest {
private AccountLinkClient client;
private DeviceCredentialStore store;
private EntitlementCache cache;
private AccountLinkService service;
@BeforeEach
void setUp() {
client = mock(AccountLinkClient.class);
store = mock(DeviceCredentialStore.class);
cache = mock(EntitlementCache.class);
service = new AccountLinkService(client, store, cache);
}
@Test
void link_storesCredentialAndInvalidatesCache() throws IOException {
when(client.register("jwt", "name"))
.thenReturn(new AccountLinkClient.RegisterResult("dev-1", "sec-1", 7L));
DeviceCredential stored = new DeviceCredential();
stored.setDeviceId("dev-1");
stored.setTeamId(7L);
stored.setLinkedAt(LocalDateTime.now());
when(store.get()).thenReturn(Optional.of(stored));
AccountLinkService.LinkStatus status = service.link("jwt", "name");
verify(store).save("dev-1", "sec-1", 7L);
verify(cache).invalidate();
assertTrue(status.linked());
assertEquals("dev-1", status.deviceId());
assertEquals(7L, status.teamId());
}
@Test
void link_propagatesRegisterFailure() throws IOException {
when(client.register(any(), any())).thenThrow(new IOException("boom"));
org.junit.jupiter.api.Assertions.assertThrows(
IOException.class, () -> service.link("jwt", null));
verify(cache, org.mockito.Mockito.never()).invalidate();
}
@Test
void status_unlinkedWhenNoCredential() {
when(store.get()).thenReturn(Optional.empty());
AccountLinkService.LinkStatus status = service.status();
assertFalse(status.linked());
}
@Test
void unlink_callsSaasRevokeBeforeClearingLocally() {
DeviceCredential cred = new DeviceCredential();
cred.setDeviceId("dev-1");
cred.setDeviceSecret("sec-1");
cred.setTeamId(7L);
cred.setLinkedAt(LocalDateTime.now());
when(store.get()).thenReturn(Optional.of(cred));
when(client.revokeSelf("dev-1", "sec-1")).thenReturn(true);
service.unlink();
verify(client).revokeSelf("dev-1", "sec-1");
verify(store).clear();
verify(cache).invalidate();
}
@Test
void unlink_clearsLocallyEvenWhenSaasRevokeFails() {
DeviceCredential cred = new DeviceCredential();
cred.setDeviceId("dev-1");
cred.setDeviceSecret("sec-1");
cred.setLinkedAt(LocalDateTime.now());
when(store.get()).thenReturn(Optional.of(cred));
// SaaS unreachable / returns non-2xx.
when(client.revokeSelf("dev-1", "sec-1")).thenReturn(false);
service.unlink();
// Local clear MUST still happen admin's intent wins; orphan row is a follow-up.
verify(store).clear();
verify(cache).invalidate();
}
@Test
void unlink_whenAlreadyUnlinked_skipsSaasRevoke() {
when(store.get()).thenReturn(Optional.empty());
service.unlink();
org.mockito.Mockito.verifyNoInteractions(client);
verify(store).clear();
verify(cache).invalidate();
}
}
@@ -1,49 +0,0 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import stirling.software.common.service.InternalApiClient;
class BillableOperationClassifierTest {
@Test
void aiPathIsBillable() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/foo");
assertTrue(BillableOperationClassifier.isBillable(req));
}
@Test
void automationHeaderIsBillable() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
req.addHeader(InternalApiClient.AUTOMATION_HEADER, "1");
assertTrue(BillableOperationClassifier.isBillable(req));
}
@Test
void plainManualToolIsFree() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
assertFalse(BillableOperationClassifier.isBillable(req));
}
@Test
void aiSegmentNotAtPathStartIsFree() {
// Tightened from substring to prefix: the AI segment appearing mid-path (e.g. behind a
// proxy prefix) must NOT classify a manual tool as billable.
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/proxy/api/v1/ai/tools/foo");
assertFalse(BillableOperationClassifier.isBillable(req));
}
@Test
void aiPathUnderContextPathIsBillable() {
// A real context-path deployment still classifies: /<ctx>/api/v1/ai/** is billable.
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/stirling/api/v1/ai/tools/foo");
req.setContextPath("/stirling");
assertTrue(BillableOperationClassifier.isBillable(req));
}
}
@@ -1,114 +0,0 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class EntitlementCacheTest {
private DeviceCredentialStore store;
private AccountLinkClient client;
private AccountLinkProperties properties;
private EntitlementCache cache;
@BeforeEach
void setUp() {
store = mock(DeviceCredentialStore.class);
client = mock(AccountLinkClient.class);
properties = new AccountLinkProperties();
properties.setEntitlementCacheSeconds(300);
cache = new EntitlementCache(store, client, properties);
}
private DeviceCredential cred() {
DeviceCredential c = new DeviceCredential();
c.setDeviceId("dev-1");
c.setDeviceSecret("sec-1");
c.setTeamId(1L);
c.setLinkedAt(LocalDateTime.now());
return c;
}
@Test
void unlinked_returnsEmpty() {
when(store.get()).thenReturn(Optional.empty());
assertTrue(cache.current().isEmpty());
}
@Test
void linked_fetchesAndCachesWithinTtl() {
InstanceEntitlement snap = new InstanceEntitlement(false, 10, 0, null, EntitlementState.OK);
when(store.get()).thenReturn(Optional.of(cred()));
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap);
assertEquals(snap, cache.current().orElseThrow());
// Second read within TTL must not re-fetch.
assertEquals(snap, cache.current().orElseThrow());
verify(client, times(1)).fetchEntitlement(any(), any());
}
@Test
void linked_unreachable_keepsLastKnownSnapshot_failOpenFriendly() {
InstanceEntitlement snap = new InstanceEntitlement(true, 0, 1, 100L, EntitlementState.OK);
when(store.get()).thenReturn(Optional.of(cred()));
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap);
assertEquals(snap, cache.current().orElseThrow());
// TTL elapsed refresh attempted, but the SaaS side is now unreachable (null).
cache.invalidate();
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(null);
assertEquals(snap, cache.current().orElseThrow(), "stale snapshot retained on failure");
}
@Test
void linked_neverFetched_unreachable_backsOffWithinTtl() {
// No prior snapshot + SaaS unreachable: the gate fails open (empty), but a failed
// attempt stamps the TTL so a second read within the window does NOT re-fetch
// no sustained hammer of blocking round-trips against a dead endpoint.
when(store.get()).thenReturn(Optional.of(cred()));
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(null);
assertTrue(cache.current().isEmpty());
assertTrue(cache.current().isEmpty());
verify(client, times(1)).fetchEntitlement(any(), any());
}
@Test
void linked_revoked_blocksAndDropsStaleEntitlement() {
InstanceEntitlement entitled =
new InstanceEntitlement(true, 0, 1, 100L, EntitlementState.OK);
when(store.get()).thenReturn(Optional.of(cred()));
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(entitled);
assertEquals(entitled, cache.current().orElseThrow());
// Credential revoked: the next refresh is an authoritative deny. The cache must NOT keep
// serving the stale entitled snapshot it replaces it with a blocked REVOKED one.
cache.invalidate();
when(client.fetchEntitlement(anyString(), anyString()))
.thenThrow(new AccountLinkClient.RevokedException(401));
assertEquals(EntitlementState.REVOKED, cache.current().orElseThrow().state());
}
@Test
void invalidate_forcesRefetch() {
InstanceEntitlement snap = new InstanceEntitlement(false, 10, 0, null, EntitlementState.OK);
when(store.get()).thenReturn(Optional.of(cred()));
when(client.fetchEntitlement(anyString(), anyString())).thenReturn(snap);
cache.current();
cache.invalidate();
cache.current();
verify(client, times(2)).fetchEntitlement(any(), any());
}
}
@@ -1,117 +0,0 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import stirling.software.proprietary.accountlink.GateDecision.Reason;
/**
* Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, linked-free, and
* over-limit. Exercises the pure {@link InstanceEntitlementGate#decide} so no Spring / I/O is
* needed.
*/
class InstanceEntitlementGateTest {
private static InstanceEntitlement free() {
return new InstanceEntitlement(false, 100, 0, null, EntitlementState.OK);
}
private static InstanceEntitlement exhaustedUnsubscribed() {
return new InstanceEntitlement(false, 0, 0, null, EntitlementState.OVER_LIMIT);
}
private static InstanceEntitlement subscribedWithinCap() {
return new InstanceEntitlement(true, 0, 10, 100L, EntitlementState.OK);
}
private static InstanceEntitlement subscribedOverCap() {
return new InstanceEntitlement(true, 0, 100, 100L, EntitlementState.OK);
}
@Test
void flagOff_allowsEverything_evenBillableUnlinked() {
GateDecision d = InstanceEntitlementGate.decide(false, true, false, Optional.empty());
assertTrue(d.allowed());
assertEquals(Reason.FLAG_OFF, d.reason());
}
@Test
void manualTool_alwaysFree_evenUnlinked() {
GateDecision d = InstanceEntitlementGate.decide(true, false, false, Optional.empty());
assertTrue(d.allowed());
assertEquals(Reason.MANUAL_FREE, d.reason());
}
@Test
void billable_notLinked_blocksWithLinkSignal() {
GateDecision d = InstanceEntitlementGate.decide(true, true, false, Optional.empty());
assertFalse(d.allowed());
assertEquals(Reason.NOT_LINKED, d.reason());
}
@Test
void billable_linked_entitlementUnreachable_failsOpen() {
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.empty());
assertTrue(d.allowed());
assertEquals(Reason.FAIL_OPEN, d.reason());
}
@Test
void billable_linked_freePoolAvailable_allows() {
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(free()));
assertTrue(d.allowed());
assertEquals(Reason.ENTITLED, d.reason());
}
@Test
void billable_linked_unsubscribedAndExhausted_blocksOverLimit() {
GateDecision d =
InstanceEntitlementGate.decide(
true, true, true, Optional.of(exhaustedUnsubscribed()));
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
@Test
void billable_linked_subscribedWithinCap_allows() {
GateDecision d =
InstanceEntitlementGate.decide(
true, true, true, Optional.of(subscribedWithinCap()));
assertTrue(d.allowed());
assertEquals(Reason.ENTITLED, d.reason());
}
@Test
void billable_linked_subscribedOverCap_blocks() {
GateDecision d =
InstanceEntitlementGate.decide(true, true, true, Optional.of(subscribedOverCap()));
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
@Test
void billable_linked_revoked_blocksWithRevokedSignal() {
// Authoritative deny (revoked/invalid credential) surfaced by the cache as REVOKED
// blocks distinctly from over-limit, even though the snapshot is "present".
InstanceEntitlement revoked =
new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED);
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked));
assertFalse(d.allowed());
assertEquals(Reason.REVOKED, d.reason());
}
@Test
void billable_linked_unsubscribedWithFreePool_overLimitStateStillBlocks() {
// Defensive: an explicit OVER_LIMIT state blocks even if a stale free count looks positive.
InstanceEntitlement conflicting =
new InstanceEntitlement(false, 5, 0, null, EntitlementState.OVER_LIMIT);
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(conflicting));
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
}
@@ -1,71 +0,0 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/** Verifies {@link InstanceEntitlementGate#evaluate} resolves live state from store + cache. */
class InstanceEntitlementGateWiringTest {
private AccountLinkProperties properties;
private DeviceCredentialStore store;
private EntitlementCache cache;
private InstanceEntitlementGate gate;
@BeforeEach
void setUp() {
properties = new AccountLinkProperties();
properties.setEnabled(true);
store = mock(DeviceCredentialStore.class);
cache = mock(EntitlementCache.class);
gate = new InstanceEntitlementGate(properties, store, cache);
}
@Test
void manualNeverConsultsStoreOrCache() {
GateDecision d = gate.evaluate(false);
assertTrue(d.allowed());
assertEquals(GateDecision.Reason.MANUAL_FREE, d.reason());
verify(store, never()).isLinked();
verify(cache, never()).current();
}
@Test
void billableUnlinkedDoesNotHitCache() {
when(store.isLinked()).thenReturn(false);
GateDecision d = gate.evaluate(true);
assertFalse(d.allowed());
assertEquals(GateDecision.Reason.NOT_LINKED, d.reason());
verify(cache, never()).current();
}
@Test
void billableLinkedConsultsCache() {
when(store.isLinked()).thenReturn(true);
when(cache.current())
.thenReturn(
Optional.of(
new InstanceEntitlement(false, 5, 0, null, EntitlementState.OK)));
GateDecision d = gate.evaluate(true);
assertTrue(d.allowed());
assertEquals(GateDecision.Reason.ENTITLED, d.reason());
}
@Test
void flagOffShortCircuits() {
properties.setEnabled(false);
GateDecision d = gate.evaluate(true);
assertTrue(d.allowed());
assertEquals(GateDecision.Reason.FLAG_OFF, d.reason());
verify(store, never()).isLinked();
}
}
@@ -1,61 +0,0 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.when;
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.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
@ExtendWith(MockitoExtension.class)
class InstanceEntitlementInterceptorTest {
@Mock private InstanceEntitlementGate gate;
private boolean preHandle(MockHttpServletResponse response) throws Exception {
return new InstanceEntitlementInterceptor(gate)
.preHandle(
new MockHttpServletRequest("GET", "/api/v1/ai/x"), response, new Object());
}
@Test
void allowsWhenGateAllows() throws Exception {
when(gate.evaluate(anyBoolean()))
.thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
MockHttpServletResponse response = new MockHttpServletResponse();
assertTrue(preHandle(response));
assertEquals(200, response.getStatus());
}
@Test
void blocksWith402AndLinkSignalWhenGateBlocks() throws Exception {
when(gate.evaluate(anyBoolean()))
.thenReturn(GateDecision.block(GateDecision.Reason.NOT_LINKED));
MockHttpServletResponse response = new MockHttpServletResponse();
assertFalse(preHandle(response));
assertEquals(HttpStatus.PAYMENT_REQUIRED.value(), response.getStatus());
assertEquals("application/json", response.getContentType());
assertTrue(response.getContentAsString().contains("ACCOUNT_LINK_REQUIRED"));
assertTrue(response.getContentAsString().contains("NOT_LINKED"));
}
@Test
void failsOpenWhenGateThrows() throws Exception {
// A DB / SaaS blip while resolving entitlement must never hard-block billable work.
when(gate.evaluate(anyBoolean()))
.thenThrow(new RuntimeException("entitlement source down"));
MockHttpServletResponse response = new MockHttpServletResponse();
assertTrue(preHandle(response));
assertEquals(200, response.getStatus());
}
}
@@ -57,23 +57,12 @@ class PolicyControllerTest {
@Mock private PolicyAccessGuard policyAccessGuard;
@Mock private PolicyManagementAuthority policyManagementAuthority;
@Mock private PolicyTriggerManager policyTriggerManager;
@Mock
private stirling.software.proprietary.policy.overview.PolicyOverviewService
policyOverviewService;
@Mock private TempFileManager tempFileManager;
@Mock private JobOwnershipService jobOwnershipService;
private ApplicationProperties applicationProperties;
private PolicyController controller;
private final java.util.List<stirling.software.proprietary.policy.trigger.PolicyTrigger>
policyTriggers =
java.util.List.of(
trigger("schedule", false, java.util.Set.of()),
trigger("folder-watch", true, java.util.Set.of("folder")));
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
@@ -88,33 +77,11 @@ class PolicyControllerTest {
policyAccessGuard,
policyManagementAuthority,
policyTriggerManager,
policyOverviewService,
policyTriggers,
applicationProperties,
tempFileManager,
jobOwnershipService);
}
private static stirling.software.proprietary.policy.trigger.PolicyTrigger trigger(
String type, boolean requiresSource, java.util.Set<String> sourceTypes) {
return new stirling.software.proprietary.policy.trigger.PolicyTrigger() {
@Override
public String type() {
return type;
}
@Override
public boolean requiresSource() {
return requiresSource;
}
@Override
public java.util.Set<String> supportedSourceTypes() {
return sourceTypes;
}
};
}
private static PipelineDefinition definitionWithStep() {
return new PipelineDefinition(
"pipe", List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)), null);
@@ -464,50 +431,4 @@ class PolicyControllerTest {
.isEqualTo(HttpStatus.NOT_FOUND));
}
}
@Nested
@DisplayName("triggers / trigger")
class Triggers {
@Test
@DisplayName("lists triggers sorted, with source compatibility")
void listsTriggers() {
List<stirling.software.proprietary.policy.trigger.TriggerInfo> infos =
controller.triggers();
assertThat(infos).extracting(t -> t.type()).containsExactly("folder-watch", "schedule");
stirling.software.proprietary.policy.trigger.TriggerInfo folderWatch = infos.get(0);
assertThat(folderWatch.requiresSource()).isTrue();
assertThat(folderWatch.supportedSourceTypes()).containsExactly("folder");
assertThat(infos.get(1).requiresSource()).isFalse();
assertThat(infos.get(1).supportedSourceTypes()).isEmpty();
}
@Test
@DisplayName("trigger runs an accessible policy against its sources and returns run ids")
void triggersRun() {
Policy p = policy("a", 1L);
when(policyStore.get("a")).thenReturn(Optional.of(p));
when(policyAccessGuard.canAccess(p)).thenReturn(true);
when(policyRunner.run(p)).thenReturn(List.of("run-a", "run-b"));
ResponseEntity<List<String>> response = controller.trigger("a");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
assertThat(response.getBody()).containsExactly("run-a", "run-b");
}
@Test
@DisplayName("trigger is 404 when the policy is inaccessible")
void triggerNotFound() {
when(policyStore.get("z")).thenReturn(Optional.empty());
assertThatThrownBy(() -> controller.trigger("z"))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND));
}
}
}
@@ -1,186 +0,0 @@
package stirling.software.proprietary.policy.overview;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.UserServiceInterface;
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
import stirling.software.proprietary.policy.model.OutputSpec;
import stirling.software.proprietary.policy.model.PipelineStep;
import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.TriggerConfig;
import stirling.software.proprietary.policy.source.InProcessSourceStore;
import stirling.software.proprietary.policy.source.Source;
import stirling.software.proprietary.policy.source.SourceAccessGuard;
import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Tests for {@link PolicyOverviewService}: every policy appears once with its sources resolved to
* names, its steps and trigger/output summarised, and the KPI strip counting active vs paused.
* Login is disabled so the team guards pass everything through.
*/
class PolicyOverviewServiceTest {
private final SourceStore sourceStore = new InProcessSourceStore();
private final PolicyStore policyStore = new InProcessPolicyStore();
private PolicyOverviewService service;
@BeforeEach
void setUp() {
ApplicationProperties properties = new ApplicationProperties();
properties.getSecurity().setEnableLogin(false);
UserServiceInterface userService = mock(UserServiceInterface.class);
PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class);
SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
service = new PolicyOverviewService(policyStore, sourceStore, policyGuard, sourceGuard);
}
@Test
void eachPolicyAppearsWithResolvedSourcesStepsAndSummary() {
Source claims = source("Claims intake", "/claims");
policyStore.save(
new Policy(
null,
"Redaction",
"owner",
true,
new TriggerConfig("schedule", Map.of()),
List.of(claims.id()),
List.of(new PipelineStep("/api/v1/security/auto-redact", Map.of())),
OutputSpec.inline()));
policyStore.save(
new Policy(
null,
"Archive (paused)",
"owner",
false,
null,
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline()));
PoliciesOverviewResponse response = service.overview();
assertEquals(2, response.pipelines().size());
// Sorted by name, case-insensitive, so "Archive" leads "Redaction".
PolicyView archive = response.pipelines().get(0);
assertEquals("Archive (paused)", archive.name());
assertEquals("paused", archive.status());
assertEquals("manual", archive.trigger());
PolicyView redaction = find(response, "Redaction");
assertEquals("active", redaction.status());
assertEquals("schedule", redaction.trigger());
assertEquals("inline", redaction.output());
assertEquals(List.of("/api/v1/security/auto-redact"), redaction.steps());
assertEquals(1, redaction.sources().size());
assertEquals(claims.id(), redaction.sources().get(0).id());
assertEquals("Claims intake", redaction.sources().get(0).name());
// KPI strip: total, active, paused.
assertEquals(List.of(2L, 1L, 1L), response.kpis().stream().map(PolicyKpi::value).toList());
}
@Test
void anUnresolvedSourceFallsBackToItsId() {
policyStore.save(
new Policy(
null,
"Orphan",
"owner",
true,
null,
List.of("src-missing"),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline()));
PolicyView view = find(service.overview(), "Orphan");
assertEquals(1, view.sources().size());
assertEquals("src-missing", view.sources().get(0).id());
assertEquals("src-missing", view.sources().get(0).name());
}
@Test
void overviewLoadsOnlyTheCallersTeam() {
ApplicationProperties properties = new ApplicationProperties();
properties.getSecurity().setEnableLogin(true);
UserServiceInterface userService = mock(UserServiceInterface.class);
PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class);
when(authority.currentUserTeamId()).thenReturn(1L);
SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
PolicyOverviewService scoped =
new PolicyOverviewService(policyStore, sourceStore, policyGuard, sourceGuard);
Source ours = teamSource("Ours", "/ours", 1L);
teamPolicy("Our policy", 1L, ours.id());
teamPolicy("Their policy", 2L, ours.id());
PoliciesOverviewResponse response = scoped.overview();
assertEquals(1, response.pipelines().size());
PolicyView view = response.pipelines().get(0);
assertEquals("Our policy", view.name());
assertEquals("Ours", view.sources().get(0).name());
assertEquals(List.of(1L, 1L, 0L), response.kpis().stream().map(PolicyKpi::value).toList());
}
@Test
void emptyStoreReportsZeroKpis() {
PoliciesOverviewResponse response = service.overview();
assertTrue(response.pipelines().isEmpty());
assertEquals(List.of(0L, 0L, 0L), response.kpis().stream().map(PolicyKpi::value).toList());
}
private Source source(String name, String directory) {
return sourceStore.save(
new Source(
null, name, "folder", Map.of("directory", directory), true, "owner", null));
}
private Source teamSource(String name, String directory, Long teamId) {
return sourceStore.save(
new Source(
null,
name,
"folder",
Map.of("directory", directory),
true,
"owner",
teamId));
}
private void teamPolicy(String name, Long teamId, String... sourceIds) {
policyStore.save(
new Policy(
null,
name,
"owner",
true,
null,
List.of(sourceIds),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline(),
teamId));
}
private static PolicyView find(PoliciesOverviewResponse response, String name) {
return response.pipelines().stream()
.filter(view -> view.name().equals(name))
.findFirst()
.orElseThrow();
}
}
@@ -25,7 +25,6 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
@@ -49,7 +48,7 @@ class ServerCertificateServiceTest {
@BeforeEach
void setUp() {
service = new ServerCertificateService(licenseKeyChecker, new ApplicationProperties());
service = new ServerCertificateService(licenseKeyChecker);
// default: feature enabled, validity 365, org Stirling-PDF, no regenerate
ReflectionTestUtils.setField(service, "enabled", true);
ReflectionTestUtils.setField(service, "organizationName", "Stirling-PDF");
@@ -120,39 +120,4 @@ class MetadataEncryptionServiceTest {
assertThatThrownBy(() -> noKeyService.encrypt("anything"))
.isInstanceOf(IllegalStateException.class);
}
// -------------------------------------------------------------------------
// Byte-array (keystore) round-trip
// -------------------------------------------------------------------------
@Test
void encryptBytes_null_returnsNull() {
assertThat(service.encryptBytes(null)).isNull();
}
@Test
void decryptBytes_null_returnsNull() {
assertThat(service.decryptBytes(null)).isNull();
}
@Test
void encryptBytes_producesEncPrefix() {
String encrypted = service.encryptBytes(new byte[] {1, 2, 3});
assertThat(encrypted).startsWith(MetadataEncryptionService.ENC_PREFIX);
}
@Test
void byteRoundTrip_restoresOriginalBytes() {
byte[] original = {0, 1, 2, (byte) 0xFF, 64, 65, 66};
assertThat(service.decryptBytes(service.encryptBytes(original))).isEqualTo(original);
}
@Test
void decryptBytes_legacyPlainBase64_stillDecodes() {
// Values written before keystore encryption was introduced are stored as plain Base64
// (no enc: prefix) and must still decode.
byte[] original = {10, 20, 30};
String legacy = java.util.Base64.getEncoder().encodeToString(original);
assertThat(service.decryptBytes(legacy)).isEqualTo(original);
}
}
@@ -79,15 +79,6 @@ class SigningFinalizationServiceMoreTest {
metadataEncryptionService,
serverCertificateService,
userServerCertificateService);
// Keystores are stored encrypted at rest; these fixtures store them as plain Base64 (the
// legacy form), which decryptBytes decodes unchanged.
lenient()
.when(metadataEncryptionService.decryptBytes(any()))
.thenAnswer(
inv -> {
String stored = inv.getArgument(0, String.class);
return stored == null ? null : Base64.getDecoder().decode(stored);
});
}
// -------------------------------------------------------------------------
@@ -144,76 +144,6 @@ class WorkflowSessionServiceTest {
assertThat(cert.get("password")).isNotEqualTo("secret");
}
@Test
void signDocument_encryptsUploadedKeystoreBytesAtRest() throws Exception {
// Use a REAL encryption service (not a mock) so we verify the persisted keystore is
// genuinely AES-256-GCM encrypted, not merely base64-encoded.
ApplicationProperties.AutomaticallyGenerated generated =
new ApplicationProperties.AutomaticallyGenerated();
generated.setKey("test-encryption-key-for-unit-tests-only");
ApplicationProperties realProps = new ApplicationProperties();
realProps.setAutomaticallyGenerated(generated);
MetadataEncryptionService realEncryption = new MetadataEncryptionService(realProps);
// Validator is exercised by the real flow but its result is irrelevant here, so stub it.
CertificateSubmissionValidator validator = mock(CertificateSubmissionValidator.class);
WorkflowSessionService svc =
new WorkflowSessionService(
workflowSessionRepository,
workflowParticipantRepository,
storedFileRepository,
userRepository,
storageProvider,
objectMapper,
applicationProperties,
realEncryption,
validator);
User user = user("dave");
WorkflowParticipant participant = pendingParticipant(user);
sessionWithParticipant("s7", participant);
when(workflowParticipantRepository.save(any())).thenAnswer(i -> i.getArgument(0));
byte[] p12Bytes;
try (var in = getClass().getResourceAsStream("/test-certs/valid-test.p12")) {
assertThat(in).as("valid-test.p12 fixture present").isNotNull();
p12Bytes = in.readAllBytes();
}
SignDocumentRequest req = new SignDocumentRequest();
req.setCertType("PKCS12");
req.setPassword("changeit");
req.setP12File(
new MockMultipartFile(
"p12File", "valid-test.p12", "application/x-pkcs12", p12Bytes));
svc.signDocument("s7", user, req);
ArgumentCaptor<WorkflowParticipant> captor =
ArgumentCaptor.forClass(WorkflowParticipant.class);
verify(workflowParticipantRepository).save(captor.capture());
@SuppressWarnings("unchecked")
Map<String, Object> cert =
(Map<String, Object>)
captor.getValue().getParticipantMetadata().get("certificateSubmission");
String storedKeystore = (String) cert.get("p12Keystore");
// 1. Stored keystore is encrypted (enc: prefix), not plaintext base64.
assertThat(storedKeystore).startsWith(MetadataEncryptionService.ENC_PREFIX);
assertThat(storedKeystore)
.as("must not be the plain base64 of the keystore")
.isNotEqualTo(java.util.Base64.getEncoder().encodeToString(p12Bytes));
// 2. The raw keystore bytes must not appear anywhere in the stored value.
assertThat(storedKeystore)
.doesNotContain(java.util.Base64.getEncoder().encodeToString(p12Bytes));
// 3. It round-trips back to the exact original keystore bytes.
assertThat(realEncryption.decryptBytes(storedKeystore)).isEqualTo(p12Bytes);
}
@Test
void signDocument_preservesExistingParticipantMetadata() {
User user = user("carol");
@@ -1,161 +0,0 @@
package stirling.software.saas.accountlink;
import java.util.List;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/**
* Account-link registration surface (combined-billing "Mode A").
*
* <p>A self-hosted instance's local backend calls {@code POST /register} with the admin's
* short-lived Supabase JWT (validated by the existing {@code SupabaseSecurityConfig} chain no new
* auth here). We resolve the caller's team, mint a device credential bound to it, and return the
* secret exactly once. Ongoing entitlement reads authenticate with that device credential, not this
* JWT.
*
* <p>Whole surface gated behind {@code stirling.billing.account-link.enabled}: off beans absent
* 404. Leader-only, and the team is always derived from the caller (never the request body).
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/account-link")
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkController {
private final AccountLinkService service;
private final TeamMembershipRepository memberRepo;
private final UserRepository userRepository;
public AccountLinkController(
AccountLinkService service,
TeamMembershipRepository memberRepo,
UserRepository userRepository) {
this.service = service;
this.memberRepo = memberRepo;
this.userRepository = userRepository;
}
/** Optional display name for the instance (hostname / label). */
public record RegisterRequest(String name) {}
/** {@code deviceSecret} is plaintext and returned exactly once — the caller must store it. */
public record RegisterResponse(
Long instanceId, Long teamId, String deviceId, String deviceSecret, String name) {}
public record InstanceRow(
Long instanceId,
String deviceId,
String name,
String createdAt,
String lastSeenAt,
boolean revoked) {}
@PostMapping("/register")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<RegisterResponse> register(
@RequestBody(required = false) RegisterRequest req, Authentication auth) {
LeaderTeam lt = resolveLeaderTeam(auth);
if (lt.error() != null) {
return ResponseEntity.status(lt.error()).build();
}
String name = req != null ? req.name() : null;
AccountLinkService.RegisteredInstance reg =
service.register(lt.teamId(), lt.userId(), name);
return ResponseEntity.status(HttpStatus.CREATED)
.body(
new RegisterResponse(
reg.instanceId(),
lt.teamId(),
reg.deviceId(),
reg.deviceSecret(),
reg.name()));
}
@GetMapping("/instances")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<List<InstanceRow>> list(Authentication auth) {
LeaderTeam lt = resolveLeaderTeam(auth);
if (lt.error() != null) {
return ResponseEntity.status(lt.error()).build();
}
List<InstanceRow> rows =
service.list(lt.teamId()).stream()
.map(
i ->
new InstanceRow(
i.getInstanceId(),
i.getDeviceId(),
i.getName(),
i.getCreatedAt() != null
? i.getCreatedAt().toString()
: null,
i.getLastSeenAt() != null
? i.getLastSeenAt().toString()
: null,
i.getRevokedAt() != null))
.toList();
return ResponseEntity.ok(rows);
}
@PostMapping("/instances/{instanceId}/revoke")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> revoke(@PathVariable Long instanceId, Authentication auth) {
LeaderTeam lt = resolveLeaderTeam(auth);
if (lt.error() != null) {
return ResponseEntity.status(lt.error()).build();
}
boolean ok = service.revoke(lt.teamId(), instanceId);
return ok ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
}
// ---------------------------------------------------------------------------------------
// Helpers team always derived from the caller; instance linking is a leader (billing) action.
// ---------------------------------------------------------------------------------------
/**
* Resolved caller team, or an {@code error} status to return (teamId/userId null when error).
*/
private record LeaderTeam(Long teamId, Long userId, HttpStatus error) {}
private LeaderTeam resolveLeaderTeam(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return new LeaderTeam(null, null, HttpStatus.UNAUTHORIZED);
}
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
if (rows.isEmpty()) {
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
}
TeamMembership m = rows.get(0);
if (m.getRole() != TeamRole.LEADER) {
return new LeaderTeam(null, null, HttpStatus.FORBIDDEN);
}
return new LeaderTeam(m.getTeam().getId(), user.getId(), null);
}
}
@@ -1,117 +0,0 @@
package stirling.software.saas.accountlink;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.extern.slf4j.Slf4j;
/**
* Account-link instance registration + lifecycle (combined-billing "Mode A").
*
* <p>Mints a {@code device_id} (public) + {@code device_secret} (high-entropy, returned once) bound
* to a team, persisting only the SHA-256 hash of the secret. The instance authenticates its
* unattended entitlement reads with that credential.
*
* <p>Gated behind {@code stirling.billing.account-link.enabled}: when off the bean is absent, so
* {@link AccountLinkController} (which depends on it) drops out too and its endpoints 404.
*/
@Slf4j
@Service
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class AccountLinkService {
/** 32 bytes of entropy → URL-safe secret; high enough that an unsalted SHA-256 hash is fine. */
private static final int SECRET_BYTES = 32;
private final LinkedInstanceRepository repo;
private final SecureRandom random = new SecureRandom();
public AccountLinkService(LinkedInstanceRepository repo) {
this.repo = repo;
}
/** Result of {@link #register}; {@code deviceSecret} is plaintext and returned exactly once. */
public record RegisteredInstance(
Long instanceId, String deviceId, String deviceSecret, String name) {}
/**
* Creates a new linked instance for {@code teamId}, returning the one-time plaintext secret.
*/
@Transactional
public RegisteredInstance register(Long teamId, Long createdByUserId, String name) {
String deviceId = UUID.randomUUID().toString();
String deviceSecret = randomSecret();
LinkedInstance instance = new LinkedInstance();
instance.setTeamId(teamId);
instance.setCreatedByUserId(createdByUserId);
instance.setDeviceId(deviceId);
instance.setDeviceSecretHash(sha256Hex(deviceSecret));
instance.setName(name);
repo.save(instance);
log.info(
"Account-link: registered instance {} (device {}) for team {}",
instance.getInstanceId(),
deviceId,
teamId);
return new RegisteredInstance(instance.getInstanceId(), deviceId, deviceSecret, name);
}
/**
* All instances for a team, newest first (includes revoked, for the "Linked instances" list).
*/
@Transactional(readOnly = true)
public List<LinkedInstance> list(Long teamId) {
return repo.findByTeamIdOrderByCreatedAtDesc(teamId);
}
/**
* Revokes an instance iff it belongs to {@code teamId}. Returns false if not found or owned by
* a different team (so a caller can never revoke another team's instance). Idempotent.
*/
@Transactional
public boolean revoke(Long teamId, Long instanceId) {
Optional<LinkedInstance> found = repo.findById(instanceId);
if (found.isEmpty() || !found.get().getTeamId().equals(teamId)) {
return false;
}
LinkedInstance instance = found.get();
if (instance.getRevokedAt() == null) {
instance.setRevokedAt(LocalDateTime.now());
repo.save(instance);
log.info("Account-link: revoked instance {} for team {}", instanceId, teamId);
}
return true;
}
private String randomSecret() {
byte[] buf = new byte[SECRET_BYTES];
random.nextBytes(buf);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
}
/** SHA-256 hex of a value. The device secret is high-entropy, so no salt is required. */
static String sha256Hex(String value) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
}
@@ -1,108 +0,0 @@
package stirling.software.saas.accountlink;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
/**
* Authenticates a linked self-hosted instance by its device credential (combined-billing "Mode A").
*
* <p>Reads {@code X-Device-Id} + {@code X-Device-Secret}, looks up the active {@link
* LinkedInstance}, and constant-time compares the SHA-256 of the presented secret against the
* stored hash. On a match it sets a {@link LinkedInstanceAuthenticationToken} (team-scoped, {@code
* ROLE_LINKED_INSTANCE}); otherwise it does nothing and lets the chain continue ( 401 on a
* protected endpoint).
*
* <p>Read-only and <b>path-scoped to {@code /api/v1/instance/**}</b>: the device principal is never
* established for user-facing endpoints, so a leaked secret can only reach the instance surface.
* Gated behind {@code stirling.billing.account-link.enabled}; absent when the flag is off, so
* {@code SupabaseSecurityConfig} never wires it in.
*/
@Slf4j
@Component
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class DeviceCredentialAuthenticationFilter extends OncePerRequestFilter {
static final String HEADER_DEVICE_ID = "X-Device-Id";
static final String HEADER_DEVICE_SECRET = "X-Device-Secret";
static final String INSTANCE_PATH_PREFIX = "/api/v1/instance/";
private final LinkedInstanceRepository repo;
public DeviceCredentialAuthenticationFilter(LinkedInstanceRepository repo) {
this.repo = repo;
}
/** Only the instance surface uses the device credential; everything else skips this filter. */
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith(INSTANCE_PATH_PREFIX);
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String deviceId = request.getHeader(HEADER_DEVICE_ID);
String secret = request.getHeader(HEADER_DEVICE_SECRET);
if (deviceId != null
&& secret != null
&& SecurityContextHolder.getContext().getAuthentication() == null) {
repo.findByDeviceIdAndRevokedAtIsNull(deviceId)
.ifPresent(
instance -> {
if (constantTimeEquals(
AccountLinkService.sha256Hex(secret),
instance.getDeviceSecretHash())) {
SecurityContextHolder.getContext()
.setAuthentication(
new LinkedInstanceAuthenticationToken(
instance.getInstanceId(),
instance.getTeamId()));
// Stamp liveness, best-effort. Auth is already set above; a
// transient write failure must NOT 500 an otherwise-valid
// request, so swallow it. Targeted single-column UPDATE (not a
// full save) so a concurrent revoke between the read above and
// this write can't be clobbered back to active.
try {
repo.touchLastSeen(
instance.getInstanceId(), LocalDateTime.now());
} catch (RuntimeException e) {
log.debug(
"last_seen_at update failed for device {}: {}",
deviceId,
e.getMessage());
}
} else {
log.debug("Device credential mismatch for device {}", deviceId);
}
});
}
chain.doFilter(request, response);
}
private static boolean constantTimeEquals(String a, String b) {
if (a == null || b == null) {
return false;
}
return MessageDigest.isEqual(
a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
}
}
@@ -1,131 +0,0 @@
package stirling.software.saas.accountlink;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.payg.billing.TeamBillingContext;
import stirling.software.saas.payg.billing.TeamBillingService;
import stirling.software.saas.payg.entitlement.EntitlementService;
import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
import stirling.software.saas.payg.model.EntitlementState;
/**
* Instance-facing surface (combined-billing "Mode A"), authenticated by the <b>device
* credential</b> not a user JWT. Separate path prefix ({@code /api/v1/instance/**}) so the device
* credential is scoped here and nowhere else.
*
* <p>{@code GET /whoami} is the MVP round-trip proof: a registered instance presenting a valid
* device credential gets back its resolved {@code instanceId} + {@code teamId}. {@code GET
* /entitlement} is the read the local gate consumes the same team-scoped snapshot the FE wallet
* sees, trimmed to the fields the gate needs (subscription, free pool, period spend/cap, state),
* and built on the same device-credential auth.
*
* <p>Gated behind {@code stirling.billing.account-link.enabled}: off beans absent 404.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/instance")
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class InstanceController {
private final EntitlementService entitlementService;
private final TeamBillingService billingService;
private final AccountLinkService accountLinkService;
public InstanceController(
EntitlementService entitlementService,
TeamBillingService billingService,
AccountLinkService accountLinkService) {
this.entitlementService = entitlementService;
this.billingService = billingService;
this.accountLinkService = accountLinkService;
}
public record WhoAmIResponse(Long instanceId, Long teamId) {}
/**
* Minimal entitlement view the local gate enforces against. {@code periodCapUnits} null =
* uncapped. {@code state} is the coarse OK / OVER_LIMIT vocabulary the instance gate parses
* (see {@link #coarseState}), not the SaaS feature-state enum.
*/
public record EntitlementResponse(
boolean subscribed,
long freeRemainingUnits,
long periodSpendUnits,
Long periodCapUnits,
String state) {}
@GetMapping("/whoami")
@PreAuthorize("hasRole('LINKED_INSTANCE')")
public ResponseEntity<WhoAmIResponse> whoami(Authentication auth) {
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
// Belt-and-braces: hasRole already guarantees this, but never leak a non-instance
// principal.
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
return ResponseEntity.ok(new WhoAmIResponse(token.getInstanceId(), token.getTeamId()));
}
/**
* Revokes this instance's own credential a credential can mark itself revoked the same way a
* session logs itself out. Called by the proprietary backend on local unlink so the SaaS row
* gets {@code revoked_at} set; idempotent (already-revoked still 204).
*/
@PostMapping("/revoke-self")
@PreAuthorize("hasRole('LINKED_INSTANCE')")
public ResponseEntity<Void> revokeSelf(Authentication auth) {
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
accountLinkService.revoke(token.getTeamId(), token.getInstanceId());
return ResponseEntity.noContent().build();
}
@GetMapping("/entitlement")
@PreAuthorize("hasRole('LINKED_INSTANCE')")
@Transactional(readOnly = true)
public ResponseEntity<EntitlementResponse> entitlement(Authentication auth) {
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Long teamId = token.getTeamId();
// Same composition the FE wallet uses: billing facts (subscription, free pool) from
// TeamBillingService, period spend/cap + state from the entitlement snapshot.
TeamBillingContext billing = billingService.forTeam(teamId);
EntitlementSnapshot snap = entitlementService.getSnapshot(teamId);
return ResponseEntity.ok(
new EntitlementResponse(
billing.subscribed(),
billing.freeRemainingUnits(),
snap.periodSpendUnits(),
snap.periodCapUnits(),
coarseState(snap.state())));
}
/**
* Collapses the SaaS feature-state machine into the OK / OVER_LIMIT vocabulary the instance
* gate parses. DEGRADED means automation + AI are gated off which, for a gate that governs
* only billable work (manual tools are free-pathed before it), is exactly OVER_LIMIT; FULL and
* WARNED are OK.
*/
private static String coarseState(EntitlementState state) {
return state == EntitlementState.DEGRADED ? "OVER_LIMIT" : "OK";
}
}
@@ -1,77 +0,0 @@
package stirling.software.saas.accountlink;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* One self-hosted instance that has linked a SaaS account (combined-billing "Mode A", {@code
* linked_instance}, V22).
*
* <p>Created by {@code POST /api/v1/account-link/register}, authenticated with the admin's
* short-lived Supabase JWT. Registration mints a {@code device_id} (public) plus a {@code
* device_secret} (high-entropy, returned once and stored only on the instance we keep an unsalted
* SHA-256 hash, the same posture as API keys). The instance authenticates its unattended
* entitlement reads with that device credential, so no long-lived user JWT lives on the server
* side.
*
* <p>{@code revoked_at IS NULL} means active; revoking sets it and the credential stops
* authenticating. The whole surface is gated behind {@code stirling.billing.account-link.enabled}.
*/
@Entity
@Table(name = "linked_instance")
@Getter
@Setter
@NoArgsConstructor
public class LinkedInstance {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "instance_id")
private Long instanceId;
@Column(name = "team_id", nullable = false)
private Long teamId;
/**
* Admin who registered the instance; informational (no FK, so a user delete never offlines it).
*/
@Column(name = "created_by_user_id")
private Long createdByUserId;
/** Public, non-secret identifier the instance presents on every request. */
@Column(name = "device_id", nullable = false, unique = true, length = 64)
private String deviceId;
/** SHA-256 hex of the device secret; the secret itself is never stored. */
@Column(name = "device_secret_hash", nullable = false, length = 64)
private String deviceSecretHash;
/** Operator-set display label (hostname etc.) for the "Linked instances" list. */
@Column(name = "name", length = 255)
private String name;
/** Insert time; Hibernate populates this on persist (DB DEFAULT is belt-and-braces). */
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/** Stamped when the device credential last authenticated; powers staleness display. */
@Column(name = "last_seen_at")
private LocalDateTime lastSeenAt;
/** NULL = active. Set on unlink/revoke; a revoked credential fails authentication. */
@Column(name = "revoked_at")
private LocalDateTime revokedAt;
}
@@ -1,45 +0,0 @@
package stirling.software.saas.accountlink;
import java.util.List;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
/**
* Authentication for a linked self-hosted instance (combined-billing "Mode A").
*
* <p>Deliberately <em>not</em> a user: the principal is the instance ({@code instanceId}) bound to
* a {@code teamId}, with the single authority {@code ROLE_LINKED_INSTANCE}. It carries no {@code
* User} and creates no user row a device credential can never act as a person, only as its team's
* instance, and only on the instance-facing endpoints.
*/
public class LinkedInstanceAuthenticationToken extends AbstractAuthenticationToken {
private final Long instanceId;
private final Long teamId;
public LinkedInstanceAuthenticationToken(Long instanceId, Long teamId) {
super(List.of(new SimpleGrantedAuthority("ROLE_LINKED_INSTANCE")));
this.instanceId = instanceId;
this.teamId = teamId;
setAuthenticated(true);
}
@Override
public Object getCredentials() {
return null; // the secret is never retained on the authentication
}
@Override
public Object getPrincipal() {
return instanceId;
}
public Long getInstanceId() {
return instanceId;
}
public Long getTeamId() {
return teamId;
}
}
@@ -1,43 +0,0 @@
package stirling.software.saas.accountlink;
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.transaction.annotation.Transactional;
/**
* Data access for {@link LinkedInstance}. Plain Spring Data JPA against {@code stirling_pdf}
* native schema access, no RPC, consistent with the rest of the SaaS backend.
*/
public interface LinkedInstanceRepository extends JpaRepository<LinkedInstance, Long> {
/**
* Active-credential lookup for the device-credential auth filter (revoked rows never match).
*/
Optional<LinkedInstance> findByDeviceIdAndRevokedAtIsNull(String deviceId);
/** Backs the portal "Linked instances" list (includes revoked, newest first). */
List<LinkedInstance> findByTeamIdOrderByCreatedAtDesc(Long teamId);
/** Active (non-revoked) linked instances on a team — the orphan guard's count. */
long countByTeamIdAndRevokedAtIsNull(Long teamId);
/**
* Stamps liveness on a single instance. A targeted single-column UPDATE rather than a
* full-entity {@code save}: the auth filter loads the instance outside a transaction, so a full
* save would write back the stale (in-memory {@code null}) {@code revoked_at} and could
* silently un-revoke a credential that was revoked between the read and the write. The {@code
* revoked_at IS NULL} guard makes this a no-op once revoked.
*/
@Modifying
@Transactional
@Query(
"UPDATE LinkedInstance li SET li.lastSeenAt = :now "
+ "WHERE li.instanceId = :instanceId AND li.revokedAt IS NULL")
int touchLastSeen(@Param("instanceId") Long instanceId, @Param("now") LocalDateTime now);
}
@@ -14,14 +14,12 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@Profile("saas")
@EnableJpaRepositories(
basePackages = {
"stirling.software.saas.accountlink",
"stirling.software.saas.repository",
"stirling.software.saas.billing.repository",
"stirling.software.saas.ai.repository",
"stirling.software.saas.payg.repository"
})
@EntityScan({
"stirling.software.saas.accountlink",
"stirling.software.saas.model",
"stirling.software.saas.billing.model",
"stirling.software.saas.ai.model",
@@ -1,150 +0,0 @@
package stirling.software.saas.payg.api;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
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.TeamMembership;
import stirling.software.saas.payg.policy.PaygTeamExtensions;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
import stirling.software.saas.payg.stripe.StripeInvoiceDao;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/**
* Read-only Stripe-invoices surface for the linked org's billing page.
*
* <p>{@code GET /api/v1/payg/invoices?limit=N} returns the team's most recent Stripe invoices,
* sourced from the {@code stripe.invoices} table the Sync Engine maintains. The caller's team is
* resolved from the authenticated principal (same pattern as {@link PaygWalletController}); we
* never trust a team id from the request.
*
* <p>Defensive: when the team has no {@code stripe_customer_id} (not subscribed, or pre-checkout)
* or the {@code stripe} schema isn't synced (H2 tests, sync engine off), we return {@code 200} with
* an empty list rather than 500 the UI renders "no invoices yet". This keeps the page working
* through every link/subscription state.
*
* <p>{@code hostedInvoiceUrl} + {@code invoicePdf} are Stripe-hosted links the portal can deep-link
* from. We don't proxy the PDF ourselves; Stripe handles auth + caching.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/payg")
@Profile("saas")
public class PaygInvoicesController {
private static final int DEFAULT_LIMIT = 20;
private static final int MAX_LIMIT = 100;
private final StripeInvoiceDao invoiceDao;
private final PaygTeamExtensionsRepository extRepo;
private final TeamMembershipRepository memberRepo;
private final UserRepository userRepository;
public PaygInvoicesController(
StripeInvoiceDao invoiceDao,
PaygTeamExtensionsRepository extRepo,
TeamMembershipRepository memberRepo,
UserRepository userRepository) {
this.invoiceDao = Objects.requireNonNull(invoiceDao, "invoiceDao");
this.extRepo = Objects.requireNonNull(extRepo, "extRepo");
this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo");
this.userRepository = Objects.requireNonNull(userRepository, "userRepository");
}
/** The shape the portal renders. Trimmed; never echoes raw Stripe object fields verbatim. */
public record InvoiceResponse(
String id,
String number,
String status,
Long totalMinor,
String currency,
String createdAt,
String periodStart,
String periodEnd,
String hostedInvoiceUrl,
String invoicePdf,
String description,
Long pdfsProcessed) {}
@GetMapping("/invoices")
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
public ResponseEntity<List<InvoiceResponse>> list(
@RequestParam(name = "limit", required = false) Integer limit, Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
// Resolve the caller's team from their primary membership same pattern as
// PaygWalletController. The team id NEVER comes from the request.
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
if (rows.isEmpty()) {
return ResponseEntity.ok(List.of());
}
Long teamId = rows.get(0).getTeam().getId();
// No PAYG extension row OR no Stripe customer id team has never subscribed no
// invoices. Empty list, not 404 the UI distinguishes "no invoices yet" from a
// genuine error and we don't want to error a happy free team.
Optional<PaygTeamExtensions> ext = extRepo.findById(teamId);
if (ext.isEmpty() || ext.get().getStripeCustomerId() == null) {
return ResponseEntity.ok(List.of());
}
int safeLimit = clampLimit(limit);
List<InvoiceResponse> body =
invoiceDao.findRecentByCustomer(ext.get().getStripeCustomerId(), safeLimit).stream()
.map(PaygInvoicesController::toResponse)
.toList();
return ResponseEntity.ok(body);
}
private static int clampLimit(Integer requested) {
if (requested == null) return DEFAULT_LIMIT;
return Math.max(1, Math.min(requested, MAX_LIMIT));
}
private static InvoiceResponse toResponse(StripeInvoiceDao.InvoiceRow r) {
return new InvoiceResponse(
r.id(),
r.number(),
r.status(),
r.totalMinor(),
r.currency(),
iso(r.createdAt()),
iso(r.periodStart()),
iso(r.periodEnd()),
r.hostedInvoiceUrl(),
r.invoicePdf(),
r.description(),
r.pdfsProcessed());
}
private static String iso(LocalDateTime ldt) {
return ldt == null ? null : ldt.toString();
}
}
@@ -1,108 +0,0 @@
package stirling.software.saas.payg.api;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
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.TeamMembership;
import stirling.software.saas.payg.policy.PaygTeamExtensions;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
import stirling.software.saas.payg.stripe.StripePaymentMethodDao;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/**
* Read-only default-payment-method surface for the subscribed billing page.
*
* <p>{@code GET /api/v1/payg/payment-method} returns the team's default card (brand / last4 /
* expiry), sourced from {@code stripe.payment_methods} (Sync Engine mirror). The caller's team is
* resolved from the authenticated principal never trusted from the request exactly as {@link
* PaygInvoicesController} does.
*
* <p>Defensive: no team, no {@code stripe_customer_id} (free / pre-checkout), or the card simply
* not in the mirror all degrade to {@code 200 present=false} rather than an error. Card edits never
* happen here; the portal deep-links to Stripe's hosted customer portal for that.
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/payg")
@Profile("saas")
public class PaygPaymentMethodController {
/** Trimmed default-card shape. {@code present=false} carries no card fields. */
public record PaymentMethodResponse(
boolean present, String brand, String last4, Integer expMonth, Integer expYear) {
static PaymentMethodResponse absent() {
return new PaymentMethodResponse(false, null, null, null, null);
}
}
private final StripePaymentMethodDao paymentMethodDao;
private final PaygTeamExtensionsRepository extRepo;
private final TeamMembershipRepository memberRepo;
private final UserRepository userRepository;
public PaygPaymentMethodController(
StripePaymentMethodDao paymentMethodDao,
PaygTeamExtensionsRepository extRepo,
TeamMembershipRepository memberRepo,
UserRepository userRepository) {
this.paymentMethodDao = Objects.requireNonNull(paymentMethodDao, "paymentMethodDao");
this.extRepo = Objects.requireNonNull(extRepo, "extRepo");
this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo");
this.userRepository = Objects.requireNonNull(userRepository, "userRepository");
}
@GetMapping("/payment-method")
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
public ResponseEntity<PaymentMethodResponse> get(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
if (rows.isEmpty()) {
return ResponseEntity.ok(PaymentMethodResponse.absent());
}
Long teamId = rows.get(0).getTeam().getId();
Optional<PaygTeamExtensions> ext = extRepo.findById(teamId);
if (ext.isEmpty() || ext.get().getStripeCustomerId() == null) {
return ResponseEntity.ok(PaymentMethodResponse.absent());
}
return ResponseEntity.ok(
paymentMethodDao
.findDefaultCard(ext.get().getStripeCustomerId())
.map(
c ->
new PaymentMethodResponse(
true,
c.brand(),
c.last4(),
c.expMonth(),
c.expYear()))
.orElseGet(PaymentMethodResponse::absent));
}
}
@@ -15,9 +15,7 @@ import stirling.software.saas.payg.model.FeatureSet;
* <p>State transitions:
*
* <ul>
* <li>{@code capUnits == null} {@code FULL} / {@link FeatureSet#FULL} (uncapped).
* <li>{@code capUnits <= 0} (an explicit $0 cap) {@code DEGRADED}: metered work blocked, only
* the free grant + manual tools run.
* <li>{@code capUnits == null} {@code FULL} / {@link FeatureSet#FULL} unconditionally.
* <li>{@code spend / cap &lt; warnPct} {@code FULL}.
* <li><b>MINIMAL semantics:</b> under DEGRADED+MINIMAL manual server-side tools (gated by {@link
* FeatureGate#OFFSITE_PROCESSING}) and client-side tools still work; only {@link
@@ -51,19 +49,9 @@ public final class CapEvaluator {
int degradeAtPct,
FeatureSet degradedFeatureSet) {
if (capUnits == null) {
// No cap configured uncapped, full feature set.
if (capUnits == null || capUnits <= 0) {
return full();
}
if (capUnits <= 0) {
// An explicit cap that buys zero paid documents (a $0 cap, or one set
// below the per-document rate): metered work is blocked outright
// only the free grant and manual tools run. DEGRADED, same as hitting
// a positive cap.
FeatureSet effective =
degradedFeatureSet != null ? degradedFeatureSet : FeatureSet.MINIMAL;
return new Evaluation(EntitlementState.DEGRADED, effective, gatesFor(effective));
}
if (warnAtPct < 0 || degradeAtPct <= 0 || degradeAtPct < warnAtPct) {
// Defensive: misconfigured thresholds treat as no-cap-effect to avoid surprise
// degradation. The admin endpoints that set the policy should validate; this
@@ -1,215 +0,0 @@
package stirling.software.saas.payg.stripe;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import org.springframework.context.annotation.Profile;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import lombok.extern.slf4j.Slf4j;
/**
* Read-only accessor for {@code stripe.invoices} (synced into Postgres by the Stripe Sync Engine).
*
* <p>Same defensive posture as {@link StripeSubscriptionDao}: when the {@code stripe} schema is
* absent (H2 unit tests, sync engine not yet provisioned, or invoices not in the Sync Engine's
* target list), the lookup degrades to an empty list with a WARN the caller renders "no invoices
* yet" rather than 500ing the page.
*/
@Slf4j
@Repository
@Profile("saas")
public class StripeInvoiceDao {
/**
* One invoice row as the portal needs it. Money is in minor units of {@code currency} (e.g.
* cents for USD). {@code hostedInvoiceUrl} and {@code invoicePdf} are Stripe-hosted links that
* are stable for the lifetime of the invoice; safe to use as deep links from the UI.
*
* <p>{@code description} is the product name from the subscription chain the portal renders
* this as the row label (matching Stripe's customer-portal row layout). Falls back to the
* invoice's own {@code description} field, then to null when neither is set.
*/
public record InvoiceRow(
String id,
String number,
String status,
Long totalMinor,
String currency,
LocalDateTime createdAt,
LocalDateTime periodStart,
LocalDateTime periodEnd,
String hostedInvoiceUrl,
String invoicePdf,
String description,
/** Billed units (PDFs) on this invoice — summed line-item quantity; null if unknown. */
Long pdfsProcessed) {}
// Drafts are excluded: Stripe's API returns null for both
// {@code hosted_invoice_url} and {@code invoice_pdf} on unfinalized
// invoices, and Stripe's own customer portal hides drafts too there's no
// user-facing artefact to surface yet. The next finalize / webhook flips
// the status and the invoice shows up automatically.
//
// The LATERAL join walks the same subscription subscription_items prices
// products chain {@link StripeSubscriptionDao} uses to get the per-doc
// rate; here we use it to get the product NAME (e.g. "Stirling Processor
// Plan") so the portal can render Stripe's row label rather than the
// monospace invoice id. Falls back to {@code i.description}, then null.
private static final String QUERY =
"SELECT i.id, i.number, i.status::text AS status,"
+ " i.total, i.currency,"
+ " i.created, i.period_start, i.period_end,"
+ " i.hosted_invoice_url, i.invoice_pdf,"
+ " COALESCE(prod.name, i.description) AS description"
+ " FROM stripe.invoices i"
+ " LEFT JOIN LATERAL ("
+ " SELECT si.price FROM stripe.subscription_items si"
+ " WHERE si.subscription = i.subscription"
+ " AND COALESCE(si.deleted, false) = false"
+ " ORDER BY si.created DESC NULLS LAST LIMIT 1"
+ " ) item ON true"
+ " LEFT JOIN stripe.prices p ON p.id = item.price"
+ " LEFT JOIN stripe.products prod ON prod.id = p.product"
+ " WHERE i.customer = ?"
+ " AND i.status::text <> 'draft'"
+ " ORDER BY i.created DESC NULLS LAST"
+ " LIMIT ?";
private final JdbcTemplate jdbcTemplate;
public StripeInvoiceDao(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate");
}
/**
* The most recent {@code limit} invoices for {@code stripeCustomerId}, newest first. Empty list
* on missing schema / no rows / connectivity blip the controller surfaces this as 200 with an
* empty body rather than 500.
*/
public List<InvoiceRow> findRecentByCustomer(String stripeCustomerId, int limit) {
if (stripeCustomerId == null || stripeCustomerId.isBlank()) {
return List.of();
}
int safeLimit = Math.max(1, Math.min(limit, 100));
List<InvoiceRow> rows;
try {
rows =
jdbcTemplate.query(
QUERY,
(rs, i) ->
new InvoiceRow(
rs.getString("id"),
rs.getString("number"),
rs.getString("status"),
nullableLong(rs, "total"),
rs.getString("currency"),
toLocal(rs.getLong("created"), rs.wasNull()),
toLocal(rs.getLong("period_start"), rs.wasNull()),
toLocal(rs.getLong("period_end"), rs.wasNull()),
rs.getString("hosted_invoice_url"),
rs.getString("invoice_pdf"),
rs.getString("description"),
null),
stripeCustomerId,
safeLimit);
} catch (DataAccessException e) {
log.warn(
"stripe.invoices lookup failed for customer {}: {}",
stripeCustomerId,
e.getMessage());
return List.of();
}
if (rows.isEmpty()) {
return rows;
}
Map<String, Long> billed = sumBilledUnits(rows.stream().map(InvoiceRow::id).toList());
if (billed.isEmpty()) {
return rows;
}
return rows.stream()
.map(
r ->
new InvoiceRow(
r.id(),
r.number(),
r.status(),
r.totalMinor(),
r.currency(),
r.createdAt(),
r.periodStart(),
r.periodEnd(),
r.hostedInvoiceUrl(),
r.invoicePdf(),
r.description(),
billed.get(r.id())))
.toList();
}
/**
* Sums billed quantity (PDFs) per invoice from the {@code stripe.invoices.lines} JSONB the Sync
* Engine mirrors line items live in {@code lines->'data'}, NOT a separate {@code
* invoice_line_items} table (the sync engine never creates one).
*
* <p>Only the <b>metered</b> usage line counts: a Processor invoice can also carry flat
* subscription-fee, proration and tax lines, each with its own {@code quantity}, so summing
* every line would inflate the headline PDF count (usage 500 + a fee line of 1 "501"). We
* filter on {@code price.recurring.usage_type = 'metered'}. When no metered line is present the
* subquery is {@code NULL} and the invoice is <b>omitted</b> from the map, so {@code
* InvoiceRow.pdfsProcessed} stays {@code null} and the column renders "" rather than "0".
*
* <p>Run SEPARATELY from the invoice query and defensively wrapped, so a missing/changed schema
* degrades to an empty map (every row renders "") instead of failing the whole invoice list.
*/
private Map<String, Long> sumBilledUnits(List<String> invoiceIds) {
if (invoiceIds.isEmpty()) {
return Map.of();
}
String placeholders = invoiceIds.stream().map(id -> "?").collect(Collectors.joining(","));
String sql =
"SELECT i.id AS invoice_id,"
+ " (SELECT SUM((l->>'quantity')::int)"
+ " FROM jsonb_array_elements(COALESCE(i.lines->'data', '[]'::jsonb)) AS l"
+ " WHERE l->'price'->'recurring'->>'usage_type' = 'metered') AS qty"
+ " FROM stripe.invoices i"
+ " WHERE i.id IN ("
+ placeholders
+ ")";
try {
Map<String, Long> map = new HashMap<>();
jdbcTemplate.query(
sql,
(java.sql.ResultSet rs) -> {
long qty = rs.getLong("qty");
if (!rs.wasNull()) {
// null (no metered line) leave the key absent renders "".
map.put(rs.getString("invoice_id"), qty);
}
},
invoiceIds.toArray());
return map;
} catch (DataAccessException e) {
log.warn("stripe.invoices line-quantity sum failed: {}", e.getMessage());
return Map.of();
}
}
private static Long nullableLong(java.sql.ResultSet rs, String column)
throws java.sql.SQLException {
long v = rs.getLong(column);
return rs.wasNull() ? null : v;
}
private static LocalDateTime toLocal(long epochSeconds, boolean wasNull) {
if (wasNull) return null;
return LocalDateTime.ofInstant(Instant.ofEpochSecond(epochSeconds), ZoneId.systemDefault());
}
}
@@ -1,91 +0,0 @@
package stirling.software.saas.payg.stripe;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import lombok.extern.slf4j.Slf4j;
/**
* Read-only accessor for a team's default card off the Stripe Sync Engine schema ({@code
* stripe.payment_methods}). Prefers the customer's {@code invoice_settings.default_payment_method};
* falls back to their most recently created card. Card details (brand / last4 / expiry) live in the
* {@code card} JSONB column the sync engine mirrors.
*
* <p>Same defensive posture as {@link StripeInvoiceDao}/{@link StripeSubscriptionDao}: a missing
* schema or table H2 unit tests, sync engine not provisioned, or {@code payment_methods} simply
* absent from the sync target list degrades to {@link Optional#empty()} with a WARN, so the
* endpoint reports "no card on file" rather than 500ing the page. Editing always happens in
* Stripe's hosted portal; this never writes.
*/
@Slf4j
@Repository
@Profile("saas")
public class StripePaymentMethodDao {
/** Card brand (e.g. "visa"), last 4 digits, and numeric expiry; any field may be null. */
public record CardSummary(String brand, String last4, Integer expMonth, Integer expYear) {}
private static final String QUERY =
"SELECT pm.card->>'brand' AS brand, pm.card->>'last4' AS last4,"
+ " pm.card->>'exp_month' AS exp_month, pm.card->>'exp_year' AS exp_year"
+ " FROM stripe.payment_methods pm"
+ " WHERE pm.customer = ? AND pm.type = 'card'"
+ " ORDER BY (pm.id = ("
+ " SELECT c.invoice_settings->>'default_payment_method'"
+ " FROM stripe.customers c WHERE c.id = ?"
+ " )) DESC NULLS LAST, pm.created DESC NULLS LAST"
+ " LIMIT 1";
private final JdbcTemplate jdbcTemplate;
public StripePaymentMethodDao(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate");
}
/** The customer's default card; empty on missing schema / no card / connectivity blip. */
public Optional<CardSummary> findDefaultCard(String stripeCustomerId) {
if (stripeCustomerId == null || stripeCustomerId.isBlank()) {
return Optional.empty();
}
try {
List<CardSummary> rows =
jdbcTemplate.query(
QUERY,
(rs, i) ->
new CardSummary(
rs.getString("brand"),
rs.getString("last4"),
parseIntOrNull(rs, "exp_month"),
parseIntOrNull(rs, "exp_year")),
stripeCustomerId,
stripeCustomerId);
return rows.stream().filter(Objects::nonNull).findFirst();
} catch (DataAccessException e) {
log.warn(
"stripe.payment_methods lookup failed for customer {}: {}",
stripeCustomerId,
e.getMessage());
return Optional.empty();
}
}
private static Integer parseIntOrNull(ResultSet rs, String column) throws SQLException {
String raw = rs.getString(column);
if (raw == null || raw.isBlank()) {
return null;
}
try {
return Integer.valueOf(raw.trim());
} catch (NumberFormatException e) {
return null;
}
}
}
@@ -10,7 +10,6 @@ import java.util.Locale;
import java.util.Objects;
import java.util.stream.Collectors;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -50,7 +49,6 @@ import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.saas.accountlink.DeviceCredentialAuthenticationFilter;
import stirling.software.saas.service.SaasTeamService;
import stirling.software.saas.service.SupabaseUserService;
@@ -82,10 +80,7 @@ public class SupabaseSecurityConfig {
private long clockSkewSeconds;
@Bean
SecurityFilterChain saasSecurityFilterChain(
HttpSecurity http,
JwtDecoder jwtDecoder,
ObjectProvider<DeviceCredentialAuthenticationFilter> deviceCredentialFilterProvider)
SecurityFilterChain saasSecurityFilterChain(HttpSecurity http, JwtDecoder jwtDecoder)
throws Exception {
// CSRF protection intentionally disabled: this chain is bearer-token only (Supabase JWT in
// Authorization header / X-API-KEY) with SessionCreationPolicy.STATELESS, so there is no
@@ -140,16 +135,6 @@ public class SupabaseSecurityConfig {
.jwtAuthenticationConverter(
SupabaseSecurityConfig
::toAuthentication)));
// Device-credential auth for linked self-hosted instances (combined-billing Mode A).
// The filter bean exists only when stirling.billing.account-link.enabled=true; when off it
// is absent here, so the instance surface cannot authenticate at all until release.
DeviceCredentialAuthenticationFilter deviceFilter =
deviceCredentialFilterProvider.getIfAvailable();
if (deviceFilter != null) {
http.addFilterBefore(deviceFilter, BearerTokenAuthenticationFilter.class);
}
return http.build();
}
@@ -19,7 +19,6 @@ import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.saas.accountlink.LinkedInstanceRepository;
import stirling.software.saas.billing.repository.BillingSubscriptionRepository;
import stirling.software.saas.config.SupabaseConfigurationProperties;
import stirling.software.saas.model.TeamInvitation;
@@ -46,7 +45,6 @@ public class SaasTeamService {
private final UserRoleService userRoleService;
private final SaasTeamExtensionService saasTeamExtensionService;
private final SaasTeamExtensionsRepository saasTeamExtensionsRepository;
private final LinkedInstanceRepository linkedInstanceRepository;
private final stirling.software.proprietary.security.service.UserService userService;
public static final String DEFAULT_TEAM_NAME = "Default";
@@ -460,42 +458,22 @@ public class SaasTeamService {
* accept. The message points them at the right remedy cancel the plan if the team is paid,
* otherwise transfer leadership first.
*
* <p>Linked self-hosted instances (combined-billing "Mode A") bind to a team via {@code
* linked_instance.team_id}, so they too orphan a team that is left memberless a personal team
* that accept deletes, or a non-personal team left by its last leader. They're checked in that
* same orphaning branch (not for a non-leader leaving a team that lives on); the remedy is to
* revoke them.
*
* @param user the user attempting to accept an invitation
* @throws IllegalStateException if accepting would orphan a team the user leads or its
* instances
* @throws IllegalStateException if accepting would orphan a team the user leads
*/
private void assertCanLeaveCurrentTeamsToJoinAnother(User user) {
for (TeamMembership membership : membershipRepository.findByUserId(user.getId())) {
Team team = membership.getTeam();
boolean personal = saasTeamExtensionService.isPersonal(team);
if (!personal && !membership.isLeader()) {
// A non-leader leaving a shared team never orphans it.
if (saasTeamExtensionService.isPersonal(team) || !membership.isLeader()) {
// Personal teams are deleted on accept; non-leaders leaving never orphans a team.
continue;
}
if (!personal
&& membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER)
> 1) {
// Only reached for a non-personal team the user leads at most one such team in the
// one-team-per-user model so this count runs ~once, not per membership.
if (membershipRepository.countByTeamIdAndRole(team.getId(), TeamRole.LEADER) > 1) {
// Another leader remains, so the team keeps an owner.
continue;
}
// Leaving here orphans the team: a personal team is deleted on accept; a non-personal
// team is being left by its last leader. Either way its linked self-hosted instances
// lose their billing team, so block until they're revoked.
if (linkedInstanceRepository.countByTeamIdAndRevokedAtIsNull(team.getId()) > 0) {
throw new IllegalStateException(
"Revoke linked self-hosted instances on this team before joining another"
+ " team.");
}
if (personal) {
// Personal teams are disposable (deleted on accept) and never billed/shared.
continue;
}
if (hasActivePaidSubscription(team)) {
throw new IllegalStateException(
"Your team has an active plan and you are its last leader. Cancel the plan"
@@ -1,44 +0,0 @@
-- Account-link instances. One row per self-hosted instance that has linked a SaaS account.
--
-- Part of the combined-billing "Mode A" (connected self-hosted) flow:
-- 1. An admin signs into their SaaS account in the Stirling Portal via the Supabase JS SDK
-- (a short-lived Supabase JWT, refreshed client-side — it never reaches the server long-term).
-- 2. That JWT is used ONCE to call POST /api/v1/account-link/register, which mints a
-- device_id + device_secret bound to the admin's team. The secret is returned once and
-- stored only on the instance; we keep a SHA-256 hash here (the secret is high-entropy,
-- so an unsalted hash is sufficient — same posture as API keys).
-- 3. The instance authenticates all unattended metering / entitlement calls with that device
-- credential. No long-lived user JWT lives on the server side.
--
-- Twin of supabase/migrations/20260619000000_account_link_instances.sql (Stirling-PDF-SaaS).
-- Inert until release: the AccountLinkController + device-credential filter are gated behind
-- stirling.billing.account-link.enabled (default off). The table itself is harmless additive.
CREATE TABLE IF NOT EXISTS stirling_pdf.linked_instance (
instance_id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
created_by_user_id BIGINT,
-- admin who registered the instance; informational only (no FK so a user delete never
-- cascades a working instance offline).
device_id VARCHAR(64) NOT NULL UNIQUE,
-- public, non-secret identifier the instance presents on every request.
device_secret_hash VARCHAR(64) NOT NULL,
-- SHA-256 hex of the device secret; the secret itself is never stored.
name VARCHAR(255),
-- operator-set display label (hostname etc.) for the "Linked instances" list.
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TIMESTAMP,
-- stamped when the device credential last authenticated; powers staleness display.
revoked_at TIMESTAMP
-- NULL = active. Set on unlink/revoke; a revoked credential fails authentication.
);
CREATE INDEX IF NOT EXISTS idx_linked_instance_team
ON stirling_pdf.linked_instance (team_id);
COMMENT ON TABLE stirling_pdf.linked_instance IS
'One row per self-hosted instance linked to a SaaS account (combined-billing Mode A). '
'device_id is the public identifier; device_secret_hash is the SHA-256 of the bearer '
'secret (returned once at registration, stored only on the instance). The instance '
'authenticates unattended metering / entitlement calls with this credential; revoked_at '
'IS NULL means active.';
@@ -1,169 +0,0 @@
package stirling.software.saas.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
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.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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.User;
import stirling.software.saas.accountlink.AccountLinkController.RegisterRequest;
import stirling.software.saas.accountlink.AccountLinkController.RegisterResponse;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/**
* Pure-Mockito unit tests for {@link AccountLinkController} the leader-only auth ladder, and that
* the team is always derived from the caller's membership (never the request). Mirrors {@code
* PaygInvoicesControllerTest}'s static-mock of {@link AuthenticationUtils}.
*/
@ExtendWith(MockitoExtension.class)
class AccountLinkControllerTest {
@Mock private AccountLinkService service;
@Mock private TeamMembershipRepository memberRepo;
@Mock private UserRepository userRepository;
private AccountLinkController controller;
private Authentication auth;
@BeforeEach
void setUp() {
controller = new AccountLinkController(service, memberRepo, userRepository);
auth =
new AnonymousAuthenticationToken(
"k", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_USER")));
}
@Test
void register_unauthenticated_returns401() {
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
.thenThrow(new SecurityException("not authenticated"));
ResponseEntity<RegisterResponse> resp =
controller.register(new RegisterRequest("host"), auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
verifyNoInteractions(service);
}
}
@Test
void register_noMembership_returns403() {
User user = mockUser(42L);
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
.thenReturn(user);
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of());
ResponseEntity<RegisterResponse> resp = controller.register(null, auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(service);
}
}
@Test
void register_nonLeader_returns403() {
User user = mockUser(42L);
TeamMembership member = membership(7L, TeamRole.MEMBER);
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
.thenReturn(user);
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(member));
ResponseEntity<RegisterResponse> resp = controller.register(null, auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(service);
}
}
@Test
void register_leader_mintsCredentialForCallerTeam() {
User user = mockUser(42L);
TeamMembership leader = membership(7L, TeamRole.LEADER);
when(service.register(7L, 42L, "host"))
.thenReturn(
new AccountLinkService.RegisteredInstance(99L, "dev-x", "sec-x", "host"));
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
.thenReturn(user);
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader));
ResponseEntity<RegisterResponse> resp =
controller.register(new RegisterRequest("host"), auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
RegisterResponse body = resp.getBody();
assertThat(body).isNotNull();
// Team comes from the caller's membership and is surfaced in the response.
assertThat(body.teamId()).isEqualTo(7L);
assertThat(body.instanceId()).isEqualTo(99L);
assertThat(body.deviceSecret()).isEqualTo("sec-x");
}
}
@Test
void revoke_leader_returns204WhenServiceRevokes() {
User user = mockUser(42L);
TeamMembership leader = membership(7L, TeamRole.LEADER);
when(service.revoke(7L, 11L)).thenReturn(true);
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
.thenReturn(user);
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader));
ResponseEntity<Void> resp = controller.revoke(11L, auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
}
@Test
void revoke_leader_returns404WhenServiceReportsNotFound() {
User user = mockUser(42L);
TeamMembership leader = membership(7L, TeamRole.LEADER);
when(service.revoke(7L, 11L)).thenReturn(false);
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
.thenReturn(user);
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(leader));
ResponseEntity<Void> resp = controller.revoke(11L, auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
}
private static User mockUser(long id) {
User u = new User();
u.setId(id);
return u;
}
private static TeamMembership membership(long teamId, TeamRole role) {
Team team = new Team();
team.setId(teamId);
TeamMembership tm = new TeamMembership();
tm.setTeam(team);
tm.setRole(role);
return tm;
}
}
@@ -1,102 +0,0 @@
package stirling.software.saas.accountlink;
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.when;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
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 stirling.software.saas.accountlink.AccountLinkService.RegisteredInstance;
/**
* Pure-Mockito unit tests for {@link AccountLinkService}: register returns the plaintext secret
* once but persists only its hash, and revoke is team-scoped + idempotent a caller can never
* revoke another team's instance.
*/
@ExtendWith(MockitoExtension.class)
class AccountLinkServiceTest {
@Mock private LinkedInstanceRepository repo;
private AccountLinkService service;
@BeforeEach
void setUp() {
service = new AccountLinkService(repo);
}
@Test
void register_returnsPlaintextSecretOnce_persistsOnlyHash() {
ArgumentCaptor<LinkedInstance> captor = ArgumentCaptor.forClass(LinkedInstance.class);
RegisteredInstance reg = service.register(42L, 7L, "host-a");
verify(repo).save(captor.capture());
LinkedInstance saved = captor.getValue();
assertThat(reg.deviceSecret()).isNotBlank();
assertThat(reg.deviceId()).isEqualTo(saved.getDeviceId());
assertThat(saved.getDeviceSecretHash())
.isEqualTo(AccountLinkService.sha256Hex(reg.deviceSecret()))
.isNotEqualTo(reg.deviceSecret());
assertThat(saved.getTeamId()).isEqualTo(42L);
assertThat(saved.getCreatedByUserId()).isEqualTo(7L);
assertThat(saved.getName()).isEqualTo("host-a");
}
@Test
void revoke_owningTeam_setsRevokedAtAndReturnsTrue() {
LinkedInstance inst = instance(11L, 42L, null);
when(repo.findById(11L)).thenReturn(Optional.of(inst));
assertThat(service.revoke(42L, 11L)).isTrue();
assertThat(inst.getRevokedAt()).isNotNull();
verify(repo).save(inst);
}
@Test
void revoke_alreadyRevoked_isIdempotentAndDoesNotResave() {
LocalDateTime revoked = LocalDateTime.now().minusDays(1);
LinkedInstance inst = instance(11L, 42L, revoked);
when(repo.findById(11L)).thenReturn(Optional.of(inst));
assertThat(service.revoke(42L, 11L)).isTrue();
assertThat(inst.getRevokedAt()).isEqualTo(revoked);
verify(repo, never()).save(any());
}
@Test
void revoke_otherTeamsInstance_returnsFalseAndDoesNotSave() {
LinkedInstance inst = instance(11L, 99L, null);
when(repo.findById(11L)).thenReturn(Optional.of(inst));
assertThat(service.revoke(42L, 11L)).isFalse();
assertThat(inst.getRevokedAt()).isNull();
verify(repo, never()).save(any());
}
@Test
void revoke_unknownInstance_returnsFalse() {
when(repo.findById(404L)).thenReturn(Optional.empty());
assertThat(service.revoke(42L, 404L)).isFalse();
verify(repo, never()).save(any());
}
private static LinkedInstance instance(Long id, Long teamId, LocalDateTime revokedAt) {
LinkedInstance i = new LinkedInstance();
i.setInstanceId(id);
i.setTeamId(teamId);
i.setRevokedAt(revokedAt);
return i;
}
}
@@ -1,162 +0,0 @@
package stirling.software.saas.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import jakarta.servlet.ServletException;
@ExtendWith(MockitoExtension.class)
class DeviceCredentialAuthenticationFilterTest {
@Mock private LinkedInstanceRepository repo;
private DeviceCredentialAuthenticationFilter filter;
@BeforeEach
void setUp() {
filter = new DeviceCredentialAuthenticationFilter(repo);
SecurityContextHolder.clearContext();
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
private static LinkedInstance instanceWithSecret(String secret) {
LinkedInstance i = new LinkedInstance();
i.setInstanceId(1L);
i.setTeamId(42L);
i.setDeviceId("dev-1");
i.setDeviceSecretHash(AccountLinkService.sha256Hex(secret));
return i;
}
private static MockHttpServletRequest instanceRequest(String deviceId, String secret) {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/instance/whoami");
if (deviceId != null) {
req.addHeader("X-Device-Id", deviceId);
}
if (secret != null) {
req.addHeader("X-Device-Secret", secret);
}
return req;
}
@Test
void validCredentialAuthenticatesAsInstanceBoundToTeam() throws ServletException, IOException {
when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1"))
.thenReturn(Optional.of(instanceWithSecret("s3cr3t")));
filter.doFilter(
instanceRequest("dev-1", "s3cr3t"),
new MockHttpServletResponse(),
new MockFilterChain());
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
assertInstanceOf(LinkedInstanceAuthenticationToken.class, auth);
LinkedInstanceAuthenticationToken token = (LinkedInstanceAuthenticationToken) auth;
assertEquals(42L, token.getTeamId());
assertEquals(1L, token.getInstanceId());
assertEquals(
"ROLE_LINKED_INSTANCE", token.getAuthorities().iterator().next().getAuthority());
}
@Test
void successfulAuthStampsLastSeen() throws ServletException, IOException {
LinkedInstance instance = instanceWithSecret("s3cr3t");
when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")).thenReturn(Optional.of(instance));
filter.doFilter(
instanceRequest("dev-1", "s3cr3t"),
new MockHttpServletResponse(),
new MockFilterChain());
// Targeted single-column update (guarded by revoked_at IS NULL), not a full-entity save.
verify(repo).touchLastSeen(eq(1L), any(LocalDateTime.class));
verify(repo, never()).save(any());
}
@Test
void lastSeenWriteFailureDoesNotBreakAuth() throws ServletException, IOException {
LinkedInstance instance = instanceWithSecret("s3cr3t");
when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")).thenReturn(Optional.of(instance));
doThrow(new RuntimeException("transient db"))
.when(repo)
.touchLastSeen(anyLong(), any(LocalDateTime.class));
// A liveness-write failure must NOT propagate auth is already set, so the
// request stays authenticated rather than 500ing.
filter.doFilter(
instanceRequest("dev-1", "s3cr3t"),
new MockHttpServletResponse(),
new MockFilterChain());
assertInstanceOf(
LinkedInstanceAuthenticationToken.class,
SecurityContextHolder.getContext().getAuthentication());
}
@Test
void wrongSecretDoesNotAuthenticate() throws ServletException, IOException {
when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1"))
.thenReturn(Optional.of(instanceWithSecret("right-secret")));
filter.doFilter(
instanceRequest("dev-1", "wrong-secret"),
new MockHttpServletResponse(),
new MockFilterChain());
assertNull(SecurityContextHolder.getContext().getAuthentication());
}
@Test
void unknownOrRevokedDeviceDoesNotAuthenticate() throws ServletException, IOException {
when(repo.findByDeviceIdAndRevokedAtIsNull("dev-1")).thenReturn(Optional.empty());
filter.doFilter(
instanceRequest("dev-1", "whatever"),
new MockHttpServletResponse(),
new MockFilterChain());
assertNull(SecurityContextHolder.getContext().getAuthentication());
}
@Test
void nonInstancePathIsSkippedEntirely() throws ServletException, IOException {
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/payg/wallet");
req.addHeader("X-Device-Id", "dev-1");
req.addHeader("X-Device-Secret", "s3cr3t");
filter.doFilter(req, new MockHttpServletResponse(), new MockFilterChain());
// Path-scoped: the device credential never even reaches the repo on a non-instance path.
assertNull(SecurityContextHolder.getContext().getAuthentication());
verifyNoInteractions(repo);
}
}
@@ -1,190 +0,0 @@
package stirling.software.saas.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
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.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import stirling.software.saas.accountlink.InstanceController.EntitlementResponse;
import stirling.software.saas.payg.billing.TeamBillingContext;
import stirling.software.saas.payg.billing.TeamBillingService;
import stirling.software.saas.payg.entitlement.EntitlementService;
import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
import stirling.software.saas.payg.model.EntitlementState;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.payg.model.FeatureSet;
/**
* Pure-Mockito unit tests for {@link InstanceController} the device-credential entitlement read.
* The team is resolved from the {@link LinkedInstanceAuthenticationToken} principal, never a path
* or body, and the minimal DTO maps straight off the billing context + entitlement snapshot.
*/
@ExtendWith(MockitoExtension.class)
class InstanceControllerTest {
@Mock private EntitlementService entitlementService;
@Mock private TeamBillingService billingService;
@Mock private AccountLinkService accountLinkService;
private InstanceController controller() {
return new InstanceController(entitlementService, billingService, accountLinkService);
}
@Test
void entitlement_resolvesTeamFromTokenAndMapsSnapshot() {
Authentication token = new LinkedInstanceAuthenticationToken(1L, 42L);
when(billingService.forTeam(42L)).thenReturn(subscribedBilling("sub_42", 120L));
when(entitlementService.getSnapshot(42L))
.thenReturn(snapshot(EntitlementState.WARNED, 90L, 1250L));
ResponseEntity<EntitlementResponse> resp = controller().entitlement(token);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
EntitlementResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.subscribed()).isTrue();
assertThat(body.freeRemainingUnits()).isEqualTo(120L);
assertThat(body.periodSpendUnits()).isEqualTo(90L);
assertThat(body.periodCapUnits()).isEqualTo(1250L);
// WARNED is still within budget for the gate's purposes coarse OK.
assertThat(body.state()).isEqualTo("OK");
}
@Test
void entitlement_uncapped_returnsNullCapUnits() {
Authentication token = new LinkedInstanceAuthenticationToken(2L, 7L);
when(billingService.forTeam(7L)).thenReturn(freeBilling(500L));
when(entitlementService.getSnapshot(7L))
.thenReturn(snapshot(EntitlementState.FULL, 0L, null));
ResponseEntity<EntitlementResponse> resp = controller().entitlement(token);
EntitlementResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.subscribed()).isFalse();
assertThat(body.freeRemainingUnits()).isEqualTo(500L);
assertThat(body.periodCapUnits()).isNull();
assertThat(body.state()).isEqualTo("OK");
}
@Test
void entitlement_degradedMapsToOverLimit() {
// The instance gate parses OK / OVER_LIMIT, never the SaaS FULL/WARNED/DEGRADED enum.
// DEGRADED (automation + AI gated) must reach the wire as OVER_LIMIT.
Authentication token = new LinkedInstanceAuthenticationToken(3L, 8L);
when(billingService.forTeam(8L)).thenReturn(subscribedBilling("sub_8", 0L));
when(entitlementService.getSnapshot(8L))
.thenReturn(snapshot(EntitlementState.DEGRADED, 1300L, 1250L));
EntitlementResponse body = controller().entitlement(token).getBody();
assertThat(body).isNotNull();
assertThat(body.state()).isEqualTo("OVER_LIMIT");
}
@Test
void entitlement_nonInstancePrincipalIsRejected() {
Authentication anon =
new AnonymousAuthenticationToken(
"k",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
ResponseEntity<EntitlementResponse> resp = controller().entitlement(anon);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
verifyNoInteractions(entitlementService, billingService);
}
@Test
void revokeSelf_callsServiceWithTokenIdentityAndReturns204() {
Authentication token = new LinkedInstanceAuthenticationToken(11L, 22L);
ResponseEntity<Void> resp = controller().revokeSelf(token);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
verify(accountLinkService).revoke(22L, 11L);
}
@Test
void revokeSelf_rejectsNonInstancePrincipal() {
Authentication anon =
new AnonymousAuthenticationToken(
"k",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
ResponseEntity<Void> resp = controller().revokeSelf(anon);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
verifyNoInteractions(accountLinkService);
}
@Test
void whoami_returnsResolvedInstanceAndTeam() {
Authentication token = new LinkedInstanceAuthenticationToken(5L, 9L);
ResponseEntity<InstanceController.WhoAmIResponse> resp = controller().whoami(token);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody().instanceId()).isEqualTo(5L);
assertThat(resp.getBody().teamId()).isEqualTo(9L);
}
private static TeamBillingContext freeBilling(long freeRemaining) {
LocalDateTime start = LocalDateTime.now().withDayOfMonth(1);
return new TeamBillingContext(
false,
null,
start,
start.plusMonths(1),
freeRemaining,
freeRemaining,
null,
null,
null,
null);
}
private static TeamBillingContext subscribedBilling(String subId, long freeRemaining) {
LocalDateTime start = LocalDateTime.now().withDayOfMonth(1);
return new TeamBillingContext(
true,
subId,
start,
start.plusMonths(1),
500L,
freeRemaining,
BigDecimal.valueOf(2),
"usd",
2500L,
1250L);
}
private static EntitlementSnapshot snapshot(EntitlementState state, long spend, Long cap) {
LocalDateTime start = LocalDateTime.now().withDayOfMonth(1);
return new EntitlementSnapshot(
state,
FeatureSet.FULL,
List.of(FeatureGate.OFFSITE_PROCESSING),
spend,
cap,
start,
start.plusMonths(1),
false);
}
}
@@ -1,189 +0,0 @@
package stirling.software.saas.payg.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
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.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
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.TeamMembership;
import stirling.software.saas.payg.api.PaygInvoicesController.InvoiceResponse;
import stirling.software.saas.payg.policy.PaygTeamExtensions;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
import stirling.software.saas.payg.stripe.StripeInvoiceDao;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/**
* Pure-Mockito unit tests for {@link PaygInvoicesController}. Confirms team is resolved from the
* authenticated principal (never request), and the empty-list degrade paths (no team, no Stripe
* customer, no rows) all return 200 + [] rather than 4xx/5xx.
*/
@ExtendWith(MockitoExtension.class)
class PaygInvoicesControllerTest {
@Mock private StripeInvoiceDao invoiceDao;
@Mock private PaygTeamExtensionsRepository extRepo;
@Mock private TeamMembershipRepository memberRepo;
@Mock private UserRepository userRepository;
private PaygInvoicesController controller;
private Authentication auth;
@BeforeEach
void setUp() {
controller = new PaygInvoicesController(invoiceDao, extRepo, memberRepo, userRepository);
auth =
new AnonymousAuthenticationToken(
"k", "anonymousUser", List.of(new SimpleGrantedAuthority("ROLE_USER")));
}
@Test
void list_unauthenticated_returns401() {
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
.thenThrow(new SecurityException("not authenticated"));
ResponseEntity<List<InvoiceResponse>> resp = controller.list(null, auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
verifyNoInteractions(invoiceDao, extRepo, memberRepo);
}
}
@Test
void list_noTeam_returnsEmpty() {
User user = mockUser(42L);
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
.thenReturn(user);
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of());
ResponseEntity<List<InvoiceResponse>> resp = controller.list(null, auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody()).isEmpty();
verifyNoInteractions(invoiceDao, extRepo);
}
}
@Test
void list_noStripeCustomer_returnsEmpty() {
User user = mockUser(42L);
TeamMembership tm = mockMembership(7L);
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
.thenReturn(user);
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(tm));
when(extRepo.findById(7L)).thenReturn(Optional.empty());
ResponseEntity<List<InvoiceResponse>> resp = controller.list(null, auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody()).isEmpty();
verifyNoInteractions(invoiceDao);
}
}
@Test
void list_mapsRowsAndClampsLimit() {
User user = mockUser(42L);
TeamMembership tm = mockMembership(7L);
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(7L);
ext.setStripeCustomerId("cus_abc");
StripeInvoiceDao.InvoiceRow row =
new StripeInvoiceDao.InvoiceRow(
"in_1",
"STIR-0001",
"paid",
2500L,
"usd",
LocalDateTime.of(2026, 6, 1, 10, 0),
LocalDateTime.of(2026, 5, 1, 0, 0),
LocalDateTime.of(2026, 5, 31, 23, 59),
"https://stripe/invoice/1",
"https://stripe/invoice/1.pdf",
"Stirling Processor Plan",
50000L);
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
.thenReturn(user);
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(tm));
when(extRepo.findById(7L)).thenReturn(Optional.of(ext));
// 1000 should clamp to MAX_LIMIT (100) inside the controller.
when(invoiceDao.findRecentByCustomer(eq("cus_abc"), eq(100))).thenReturn(List.of(row));
ResponseEntity<List<InvoiceResponse>> resp = controller.list(1000, auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody()).hasSize(1);
InvoiceResponse body = resp.getBody().get(0);
assertThat(body.id()).isEqualTo("in_1");
assertThat(body.number()).isEqualTo("STIR-0001");
assertThat(body.status()).isEqualTo("paid");
assertThat(body.totalMinor()).isEqualTo(2500L);
assertThat(body.currency()).isEqualTo("usd");
assertThat(body.hostedInvoiceUrl()).isEqualTo("https://stripe/invoice/1");
assertThat(body.description()).isEqualTo("Stirling Processor Plan");
assertThat(body.pdfsProcessed()).isEqualTo(50000L);
}
}
@Test
void list_emptyDaoResult_returnsEmpty() {
User user = mockUser(42L);
TeamMembership tm = mockMembership(7L);
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(7L);
ext.setStripeCustomerId("cus_xyz");
try (var mocked = org.mockito.Mockito.mockStatic(AuthenticationUtils.class)) {
mocked.when(() -> AuthenticationUtils.getCurrentUser(auth, userRepository))
.thenReturn(user);
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of(tm));
when(extRepo.findById(7L)).thenReturn(Optional.of(ext));
when(invoiceDao.findRecentByCustomer(anyString(), anyInt())).thenReturn(List.of());
ResponseEntity<List<InvoiceResponse>> resp = controller.list(null, auth);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody()).isEmpty();
}
}
private static User mockUser(long id) {
User u = new User();
u.setId(id);
return u;
}
private static TeamMembership mockMembership(long teamId) {
Team team = new Team();
team.setId(teamId);
TeamMembership tm = new TeamMembership();
tm.setTeam(team);
return tm;
}
}

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