Shared Sign Cert Validation (#5996)

## PR: Certificate Pre-Validation for Document Signing

### Problem

When a participant uploaded a certificate to sign a document, there was
no validation at submission time. If the certificate had the wrong
password, was expired, or was incompatible with the signing algorithm,
the error only surfaced during **finalization** — potentially days
later, after all other participants had signed. At that point the
session is stuck with no way to recover.

Additionally, `buildKeystore` in the finalization service only
recognised `"P12"` as a cert type, causing a `400 Invalid certificate
type: PKCS12` error when the **owner** signed using the standard
`PKCS12` identifier.

---

### What this PR does

#### Backend — Certificate pre-validation service

Adds `CertificateSubmissionValidator`, which validates a keystore before
it is stored by:
1. Loading the keystore with the provided password (catches wrong
password / corrupt file)
2. Checking the certificate's validity dates (catches expired and
not-yet-valid certs)
3. Test-signing a blank PDF using the same `PdfSigningService` code path
as finalization (catches algorithm incompatibilities)

This runs on both the participant submission endpoint
(`WorkflowParticipantController`) and the owner signing endpoint
(`SigningSessionController`), so both flows are protected.

#### Backend — Bug fix

`SigningFinalizationService.buildKeystore` now accepts `"PKCS12"` and
`"PFX"` as aliases for `"P12"`, consistent with how the validator
already handles them. This fixes a `400` error when the owner signed
using the `PKCS12` cert type.

#### Frontend — Real-time validation feedback

`ParticipantView` gains a debounced validation call (600ms) triggered
whenever the cert file or password changes. The UI shows:
- A spinner while validating
- Green "Certificate valid until [date] · [subject name]" on success
- Red error message on failure (wrong password, expired, not yet valid)
- The submit button is disabled while validation is in flight

#### Tests — Three layers

| Layer | File | Coverage |
|---|---|---|
| Service unit | `CertificateSubmissionValidatorTest` | 11 tests — valid
P12/JKS, wrong password, corrupt bytes, expired, not-yet-valid, signing
failure, cert type aliases |
| Controller unit | `WorkflowParticipantValidateCertificateTest` | 4
tests — valid cert, invalid cert, missing file, invalid token |
| Controller integration | `CertificateValidationIntegrationTest` | 6
tests — real `.p12`/`.jks` files through the full controller → validator
stack |
| Frontend E2E | `CertificateValidationE2E.spec.ts` | 7 Playwright tests
— all feedback states, button behaviour, SERVER type bypass |

#### CI

- **PR**: Playwright runs on chromium when frontend files change (~2-3
min)
- **Nightly / on-demand**: All three browsers (chromium, firefox,
webkit) at 2 AM UTC, also manually triggerable via `workflow_dispatch`
This commit is contained in:
ConnorYoh
2026-03-27 14:01:10 +00:00
committed by GitHub
parent e10c5f6283
commit dd44de349c
29 changed files with 1777 additions and 9 deletions

View File

@@ -180,7 +180,9 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/readiness")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|| trimmedUri.startsWith("/v1/api-docs");
|| trimmedUri.startsWith("/v1/api-docs")
// Workflow participant endpoints — access controlled by share tokens, not login
|| trimmedUri.startsWith("/api/v1/workflow/participant/");
}
private static String stripContextPath(String contextPath, String requestURI) {

View File

@@ -171,6 +171,9 @@ out/
*.jks
*.asc
# Allow test fixture certificates (synthetic, no real credentials)
!src/test/resources/test-certs/**
# SSH Keys
*.pub
*.priv

View File

@@ -1,5 +1,6 @@
package stirling.software.proprietary.workflow.controller;
import java.io.IOException;
import java.security.Principal;
import java.util.List;
@@ -14,7 +15,9 @@ 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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -32,9 +35,12 @@ import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.workflow.dto.CertificateInfo;
import stirling.software.proprietary.workflow.dto.CertificateValidationResponse;
import stirling.software.proprietary.workflow.dto.ParticipantRequest;
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
import stirling.software.proprietary.workflow.service.SigningFinalizationService;
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
@@ -48,6 +54,7 @@ public class SigningSessionController {
private final WorkflowSessionService workflowSessionService;
private final UserService userService;
private final SigningFinalizationService signingFinalizationService;
private final CertificateSubmissionValidator certificateSubmissionValidator;
private final ObjectMapper objectMapper = new ObjectMapper();
@Operation(summary = "List all signing sessions for current user")
@@ -397,6 +404,84 @@ public class SigningSessionController {
}
}
@Operation(
summary = "Pre-validate a certificate before signing",
description =
"Validates that the provided certificate is loadable, not expired, and can "
+ "successfully sign a document. Returns validation details so the "
+ "user can confirm the correct certificate before committing.")
@PostMapping(
value = "/cert-sign/validate-certificate",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<CertificateValidationResponse> validateCertificate(
@RequestParam("certType") String certType,
@RequestParam(value = "password", required = false) String password,
@RequestParam(value = "p12File", required = false) MultipartFile p12File,
@RequestParam(value = "jksFile", required = false) MultipartFile jksFile,
Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
if (!"SERVER".equalsIgnoreCase(certType)
&& !"USER_CERT".equalsIgnoreCase(certType)
&& (p12File == null || p12File.isEmpty())
&& (jksFile == null || jksFile.isEmpty())) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "No certificate file provided");
}
try {
byte[] keystoreBytes = null;
if (p12File != null && !p12File.isEmpty()) {
keystoreBytes = p12File.getBytes();
} else if (jksFile != null && !jksFile.isEmpty()) {
keystoreBytes = jksFile.getBytes();
}
CertificateInfo info =
certificateSubmissionValidator.validateAndExtractInfo(
keystoreBytes, certType, password);
if (info == null) {
return ResponseEntity.ok(
new CertificateValidationResponse(
true, null, null, null, null, false, null));
}
return ResponseEntity.ok(
new CertificateValidationResponse(
true,
info.subjectName(),
info.issuerName(),
info.notAfter() != null ? info.notAfter().toInstant().toString() : null,
info.notBefore() != null
? info.notBefore().toInstant().toString()
: null,
info.selfSigned(),
null));
} catch (ResponseStatusException e) {
return ResponseEntity.ok(
new CertificateValidationResponse(
false, null, null, null, null, false, e.getReason()));
} catch (IOException e) {
log.error("Error reading certificate file during pre-validation", e);
return ResponseEntity.ok(
new CertificateValidationResponse(
false,
null,
null,
null,
null,
false,
"Failed to read certificate file"));
}
}
// ===== HELPER METHODS =====
private User getCurrentUser(Principal principal) {

View File

@@ -2,6 +2,8 @@ package stirling.software.proprietary.workflow.controller;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
@@ -16,6 +18,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Operation;
@@ -27,6 +30,8 @@ import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.workflow.dto.CertificateInfo;
import stirling.software.proprietary.workflow.dto.CertificateValidationResponse;
import stirling.software.proprietary.workflow.dto.ParticipantResponse;
import stirling.software.proprietary.workflow.dto.SignatureSubmissionRequest;
import stirling.software.proprietary.workflow.dto.WetSignatureMetadata;
@@ -35,6 +40,7 @@ import stirling.software.proprietary.workflow.model.ParticipantStatus;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
import stirling.software.proprietary.workflow.service.MetadataEncryptionService;
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
import stirling.software.proprietary.workflow.util.WorkflowMapper;
@@ -59,6 +65,10 @@ public class WorkflowParticipantController {
private final WorkflowParticipantRepository participantRepository;
private final ObjectMapper objectMapper;
private final MetadataEncryptionService metadataEncryptionService;
private final CertificateSubmissionValidator certificateSubmissionValidator;
private static final DateTimeFormatter ISO_UTC =
DateTimeFormatter.ISO_INSTANT.withZone(ZoneOffset.UTC);
@Operation(
summary = "Get workflow session details by participant token",
@@ -173,6 +183,8 @@ public class WorkflowParticipantController {
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
} catch (ResponseStatusException e) {
throw e;
} catch (Exception e) {
log.error("Error submitting signature for participant {}", participant.getEmail(), e);
throw new ResponseStatusException(
@@ -268,6 +280,94 @@ public class WorkflowParticipantController {
}
}
@Operation(
summary = "Pre-validate a certificate before submission",
description =
"Validates that the provided certificate is loadable, not expired, and can "
+ "successfully sign a document. Returns validation details so the "
+ "participant can confirm the correct certificate before committing.")
@PostMapping(
value = "/validate-certificate",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<CertificateValidationResponse> validateCertificate(
@RequestParam("participantToken") @NotBlank String participantToken,
@RequestParam("certType") String certType,
@RequestParam(value = "password", required = false) String password,
@RequestParam(value = "p12File", required = false) MultipartFile p12File,
@RequestParam(value = "jksFile", required = false) MultipartFile jksFile) {
workflowSessionService.ensureSigningEnabled();
participantRepository
.findByShareToken(participantToken)
.filter(p -> !p.isExpired())
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
// Require a file for non-SERVER/non-USER_CERT types — this is a request error, not a
// validation failure
if (!"SERVER".equalsIgnoreCase(certType)
&& !"USER_CERT".equalsIgnoreCase(certType)
&& (p12File == null || p12File.isEmpty())
&& (jksFile == null || jksFile.isEmpty())) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "No certificate file provided");
}
try {
byte[] keystoreBytes = null;
if (p12File != null && !p12File.isEmpty()) {
keystoreBytes = p12File.getBytes();
} else if (jksFile != null && !jksFile.isEmpty()) {
keystoreBytes = jksFile.getBytes();
}
CertificateInfo info =
certificateSubmissionValidator.validateAndExtractInfo(
keystoreBytes, certType, password);
if (info == null) {
// SERVER type — nothing to validate
return ResponseEntity.ok(
new CertificateValidationResponse(
true, null, null, null, null, false, null));
}
return ResponseEntity.ok(
new CertificateValidationResponse(
true,
info.subjectName(),
info.issuerName(),
info.notAfter() != null ? info.notAfter().toInstant().toString() : null,
info.notBefore() != null
? info.notBefore().toInstant().toString()
: null,
info.selfSigned(),
null));
} catch (ResponseStatusException e) {
// Validation failure — return 200 with valid:false so the frontend can display inline
return ResponseEntity.ok(
new CertificateValidationResponse(
false, null, null, null, null, false, e.getReason()));
} catch (IOException e) {
log.error("Error reading certificate file during pre-validation", e);
return ResponseEntity.ok(
new CertificateValidationResponse(
false,
null,
null,
null,
null,
false,
"Failed to read certificate file"));
}
}
/**
* Builds metadata map from signature submission request. Includes certificate submission and
* wet signature data.
@@ -276,6 +376,20 @@ public class WorkflowParticipantController {
throws IOException {
Map<String, Object> metadata = new HashMap<>();
// Validate certificate before storing — throws 400 if invalid, expired, or wrong password
if (request.getCertType() != null && !"SERVER".equalsIgnoreCase(request.getCertType())) {
byte[] keystoreBytes = null;
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
keystoreBytes = request.getP12File().getBytes();
} else if (request.getJksFile() != null && !request.getJksFile().isEmpty()) {
keystoreBytes = request.getJksFile().getBytes();
}
if (keystoreBytes != null) {
certificateSubmissionValidator.validateAndExtractInfo(
keystoreBytes, request.getCertType(), request.getPassword());
}
}
// Add certificate submission if provided
if (request.getCertType() != null) {
Map<String, Object> certSubmission = new HashMap<>();

View File

@@ -0,0 +1,11 @@
package stirling.software.proprietary.workflow.dto;
import java.util.Date;
/**
* Certificate metadata extracted from a keystore submission. Returned by
* CertificateSubmissionValidator after successful validation so callers can surface details
* (expiry, subject) to the user.
*/
public record CertificateInfo(
String subjectName, String issuerName, Date notBefore, Date notAfter, boolean selfSigned) {}

View File

@@ -0,0 +1,18 @@
package stirling.software.proprietary.workflow.dto;
/**
* API response returned by the certificate pre-validation endpoints. Always returns HTTP 200; the
* {@code valid} field indicates success. Frontend should use this to display inline feedback before
* the user completes signing.
*/
public record CertificateValidationResponse(
boolean valid,
String subjectName,
String issuerName,
/** ISO-8601 formatted expiry date, or null if validation failed. */
String notAfter,
/** ISO-8601 formatted start-of-validity date, or null if validation failed. */
String notBefore,
boolean selfSigned,
/** Human-readable error message, or null if valid. */
String error) {}

View File

@@ -0,0 +1,213 @@
package stirling.software.proprietary.workflow.service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.PrivateKey;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.CertificateExpiredException;
import java.security.cert.CertificateNotYetValidException;
import java.security.cert.X509Certificate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Enumeration;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.PdfSigningService;
import stirling.software.proprietary.workflow.dto.CertificateInfo;
/**
* Validates a certificate submission before it is stored in participant metadata. Catches issues
* (wrong password, expired cert, algorithm incompatibility) at signing time rather than days later
* at finalization.
*
* <p>The core check is a test-sign of a minimal blank PDF using the exact same {@link
* PdfSigningService} code path used at finalization, so any failure that would block finalization
* is caught here first.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CertificateSubmissionValidator {
private static final DateTimeFormatter DATE_FORMAT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z").withZone(ZoneId.systemDefault());
private final PdfSigningService pdfSigningService;
/**
* Validates a certificate submission end-to-end by:
*
* <ol>
* <li>Loading the keystore with the provided password
* <li>Checking certificate validity (expiry, not-yet-valid)
* <li>Test-signing a blank PDF to confirm the key and certificate are fully functional
* </ol>
*
* @param keystoreBytes raw bytes of the keystore file
* @param certType "P12", "PKCS12", "PFX", or "JKS" (case-insensitive)
* @param password keystore password (may be null or empty)
* @return {@link CertificateInfo} with subject, issuer, and validity dates on success
* @throws ResponseStatusException HTTP 400 with a user-friendly message on any failure
*/
public CertificateInfo validateAndExtractInfo(
byte[] keystoreBytes, String certType, String password) {
if (certType == null
|| "SERVER".equalsIgnoreCase(certType)
|| "USER_CERT".equalsIgnoreCase(certType)) {
// Server-managed or pre-configured user certificate — no file uploaded, nothing to
// validate
return null;
}
char[] passwordChars = password != null ? password.toCharArray() : new char[0];
KeyStore keystore = loadKeyStore(keystoreBytes, certType, passwordChars);
X509Certificate cert = extractSigningCert(keystore, passwordChars);
validateCertValidity(cert);
String subjectName = extractCN(cert.getSubjectX500Principal().getName());
String issuerName = extractCN(cert.getIssuerX500Principal().getName());
boolean selfSigned = cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal());
testSign(keystore, passwordChars, subjectName);
return new CertificateInfo(
subjectName, issuerName, cert.getNotBefore(), cert.getNotAfter(), selfSigned);
}
// ---- private helpers ----
private KeyStore loadKeyStore(byte[] bytes, String certType, char[] password) {
String keystoreType = resolveKeystoreType(certType);
try {
KeyStore ks = KeyStore.getInstance(keystoreType);
ks.load(new java.io.ByteArrayInputStream(bytes), password);
return ks;
} catch (IOException e) {
// PKCS12: wrong password produces an IOException with "keystore password was incorrect"
// JKS: wrong password produces IOException wrapping UnrecoverableKeyException
log.debug("Failed to load {} keystore: {}", keystoreType, e.getMessage());
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Invalid certificate password or corrupt keystore file");
} catch (Exception e) {
log.debug("Failed to instantiate {} keystore: {}", keystoreType, e.getMessage());
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Invalid certificate password or corrupt keystore file");
}
}
private X509Certificate extractSigningCert(KeyStore keystore, char[] password) {
try {
Enumeration<String> aliases = keystore.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
PrivateKey key = null;
try {
key = (PrivateKey) keystore.getKey(alias, password);
} catch (UnrecoverableKeyException | java.security.NoSuchAlgorithmException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Invalid certificate password or corrupt keystore file");
}
if (key == null) continue;
Certificate[] chain = keystore.getCertificateChain(alias);
if (chain != null && chain.length > 0 && chain[0] instanceof X509Certificate) {
return (X509Certificate) chain[0];
}
}
} catch (ResponseStatusException e) {
throw e;
} catch (KeyStoreException e) {
log.debug("KeyStore alias enumeration failed: {}", e.getMessage());
}
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "No private key found in the provided keystore");
}
private void validateCertValidity(X509Certificate cert) {
try {
cert.checkValidity();
} catch (CertificateExpiredException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Certificate has expired (expired: "
+ DATE_FORMAT.format(cert.getNotAfter().toInstant())
+ ")");
} catch (CertificateNotYetValidException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Certificate is not yet valid (valid from: "
+ DATE_FORMAT.format(cert.getNotBefore().toInstant())
+ ")");
}
}
private void testSign(KeyStore keystore, char[] password, String signerName) {
try {
byte[] blankPdf = createBlankPdf();
pdfSigningService.signWithKeystore(
blankPdf, keystore, password, false, null, signerName, null, null, false);
} catch (ResponseStatusException e) {
throw e;
} catch (Exception e) {
log.debug("Certificate test-sign failed: {}", e.getMessage());
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Certificate is not compatible with the signing algorithm: " + e.getMessage());
}
}
/** Creates a minimal valid 1-page blank PDF for use in test-signing. */
private byte[] createBlankPdf() throws IOException {
try (PDDocument doc = new PDDocument();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
doc.addPage(new PDPage());
doc.save(out);
return out.toByteArray();
}
}
/**
* Maps the user-facing certType string to a JCA KeyStore type string.
*
* <p>All PKCS12 variants ("P12", "PKCS12", "PFX") map to {@code "PKCS12"}. {@code "JKS"} maps
* to {@code "JKS"}.
*/
private String resolveKeystoreType(String certType) {
if (certType == null) return "PKCS12";
return switch (certType.toUpperCase()) {
case "JKS" -> "JKS";
default -> "PKCS12"; // P12, PKCS12, PFX
};
}
/**
* Extracts the CN value from an X.500 distinguished name string. Falls back to the full DN if
* no CN attribute is present.
*/
private String extractCN(String dn) {
if (dn == null) return "";
for (String part : dn.split(",")) {
String trimmed = part.trim();
if (trimmed.toUpperCase().startsWith("CN=")) {
return trimmed.substring(3);
}
}
return dn;
}
}

View File

@@ -822,6 +822,8 @@ public class SigningFinalizationService {
switch (certType) {
case "P12":
case "PKCS12":
case "PFX":
if (submission.getP12Keystore() == null) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "P12 keystore data is required");

View File

@@ -61,6 +61,7 @@ public class WorkflowSessionService {
private final ObjectMapper objectMapper;
private final ApplicationProperties applicationProperties;
private final MetadataEncryptionService metadataEncryptionService;
private final CertificateSubmissionValidator certificateSubmissionValidator;
public void ensureSigningEnabled() {
if (!applicationProperties.getStorage().isEnabled()
@@ -684,7 +685,27 @@ public class WorkflowSessionService {
metadata = new HashMap<>(existingMetadata);
}
// 1. Store certificate submission data
// 1. Validate certificate before storing — throws 400 if invalid, expired, or wrong
// password
if (request.getCertType() != null
&& !"SERVER".equalsIgnoreCase(request.getCertType())
&& request.getP12File() != null
&& !request.getP12File().isEmpty()) {
try {
certificateSubmissionValidator.validateAndExtractInfo(
request.getP12File().getBytes(),
request.getCertType(),
request.getPassword());
} catch (ResponseStatusException e) {
throw e;
} catch (IOException e) {
log.error("Failed to read P12 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()));

View File

@@ -0,0 +1,203 @@
package stirling.software.proprietary.workflow.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.io.InputStream;
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.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import stirling.software.common.service.PdfSigningService;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
import stirling.software.proprietary.workflow.service.MetadataEncryptionService;
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
import tools.jackson.databind.ObjectMapper;
/**
* Integration test that wires the real {@link CertificateSubmissionValidator} into {@link
* WorkflowParticipantController} and exercises it with the actual test-certificate files.
*
* <p>This fills the gap between the two unit-test layers:
*
* <ul>
* <li>{@link WorkflowParticipantValidateCertificateTest} — controller only, validator mocked
* <li>{@link stirling.software.proprietary.workflow.service.CertificateSubmissionValidatorTest} —
* validator only, certs generated programmatically
* </ul>
*
* Here both layers run together against real .p12 / .jks files, so any field-name mismatch, routing
* bug, or wiring issue between controller and validator is caught.
*/
@ExtendWith(MockitoExtension.class)
class CertificateValidationIntegrationTest {
// Infrastructure mocks — not under test
@Mock private WorkflowSessionService workflowSessionService;
@Mock private WorkflowParticipantRepository participantRepository;
@Mock private MetadataEncryptionService metadataEncryptionService;
// Mock PdfSigningService so the test-sign step succeeds without a real PDF engine
@Mock private PdfSigningService pdfSigningService;
private MockMvc mockMvc;
private static final String TOKEN = "integration-test-token";
@BeforeEach
void setUp() throws Exception {
// Use the REAL validator wired with the mock signing service
CertificateSubmissionValidator realValidator =
new CertificateSubmissionValidator(pdfSigningService);
WorkflowParticipantController controller =
new WorkflowParticipantController(
workflowSessionService,
participantRepository,
new ObjectMapper(),
metadataEncryptionService,
realValidator);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
// Return a non-expired participant for all tests
WorkflowParticipant participant = new WorkflowParticipant();
when(participantRepository.findByShareToken(TOKEN)).thenReturn(Optional.of(participant));
// test-sign succeeds — only reached by valid-cert tests, lenient to avoid
// UnnecessaryStubbingException on error-path tests (wrong password, expired, etc.)
org.mockito.Mockito.lenient()
.when(
pdfSigningService.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
isNull(),
anyString(),
isNull(),
isNull(),
anyBoolean()))
.thenReturn(new byte[0]);
}
// ---- helpers ----
private static byte[] loadCert(String filename) throws Exception {
try (InputStream in =
CertificateValidationIntegrationTest.class.getResourceAsStream(
"/test-certs/" + filename)) {
if (in == null) throw new IllegalStateException("cert not found: " + filename);
return in.readAllBytes();
}
}
private static MockMultipartFile p12Part(String filename) throws Exception {
return new MockMultipartFile(
"p12File", filename, "application/octet-stream", loadCert(filename));
}
private static MockMultipartFile jksPart(String filename) throws Exception {
return new MockMultipartFile(
"jksFile", filename, "application/octet-stream", loadCert(filename));
}
// ---- tests ----
@Test
void validP12_returnsValidTrueWithSubjectName() throws Exception {
mockMvc.perform(
multipart("/api/v1/workflow/participant/validate-certificate")
.file(p12Part("valid-test.p12"))
.param("participantToken", TOKEN)
.param("certType", "P12")
.param("password", "testpass"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.valid").value(true))
.andExpect(jsonPath("$.subjectName").isNotEmpty())
.andExpect(jsonPath("$.notAfter").isNotEmpty());
}
@Test
void wrongPassword_returnsValidFalseWithErrorMessage() throws Exception {
mockMvc.perform(
multipart("/api/v1/workflow/participant/validate-certificate")
.file(p12Part("valid-test.p12"))
.param("participantToken", TOKEN)
.param("certType", "P12")
.param("password", "wrongpassword"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.valid").value(false))
.andExpect(
jsonPath("$.error")
.value("Invalid certificate password or corrupt keystore file"));
}
@Test
void expiredP12_returnsValidFalseWithExpiryMessage() throws Exception {
mockMvc.perform(
multipart("/api/v1/workflow/participant/validate-certificate")
.file(p12Part("expired-test.p12"))
.param("participantToken", TOKEN)
.param("certType", "P12")
.param("password", "testpass"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.valid").value(false))
.andExpect(
jsonPath("$.error").value(org.hamcrest.Matchers.containsString("expired")));
}
@Test
void notYetValidP12_returnsValidFalseWithNotYetValidMessage() throws Exception {
mockMvc.perform(
multipart("/api/v1/workflow/participant/validate-certificate")
.file(p12Part("not-yet-valid-test.p12"))
.param("participantToken", TOKEN)
.param("certType", "P12")
.param("password", "testpass"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.valid").value(false))
.andExpect(
jsonPath("$.error")
.value(org.hamcrest.Matchers.containsString("not yet valid")));
}
@Test
void validJks_returnsValidTrueWithSubjectName() throws Exception {
mockMvc.perform(
multipart("/api/v1/workflow/participant/validate-certificate")
.file(jksPart("valid-test.jks"))
.param("participantToken", TOKEN)
.param("certType", "JKS")
.param("password", "jkspass"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.valid").value(true))
.andExpect(jsonPath("$.subjectName").isNotEmpty());
}
@Test
void serverCertType_returnsValidTrueWithoutFileUpload() throws Exception {
mockMvc.perform(
multipart("/api/v1/workflow/participant/validate-certificate")
.param("participantToken", TOKEN)
.param("certType", "SERVER"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.valid").value(true));
}
}

View File

@@ -0,0 +1,159 @@
package stirling.software.proprietary.workflow.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Date;
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.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.proprietary.workflow.dto.CertificateInfo;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
import stirling.software.proprietary.workflow.service.MetadataEncryptionService;
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
import tools.jackson.databind.ObjectMapper;
@ExtendWith(MockitoExtension.class)
class WorkflowParticipantValidateCertificateTest {
@Mock private WorkflowSessionService workflowSessionService;
@Mock private WorkflowParticipantRepository participantRepository;
@Mock private MetadataEncryptionService metadataEncryptionService;
@Mock private CertificateSubmissionValidator certificateSubmissionValidator;
private MockMvc mockMvc;
private static final String VALID_TOKEN = "valid-share-token-abc123";
private static final byte[] DUMMY_CERT = "dummy-cert-bytes".getBytes();
@BeforeEach
void setUp() {
WorkflowParticipantController controller =
new WorkflowParticipantController(
workflowSessionService,
participantRepository,
new ObjectMapper(),
metadataEncryptionService,
certificateSubmissionValidator);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
private WorkflowParticipant activeParticipant() {
WorkflowParticipant p = new WorkflowParticipant();
// isExpired() returns false by default (no expiry date set)
return p;
}
// ---- Happy path: valid cert ----
@Test
void validCertificate_returns200WithValidTrue() throws Exception {
when(participantRepository.findByShareToken(VALID_TOKEN))
.thenReturn(Optional.of(activeParticipant()));
CertificateInfo info =
new CertificateInfo(
"Test Signer",
"Test CA",
new Date(),
new Date(System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000),
true);
when(certificateSubmissionValidator.validateAndExtractInfo(any(), eq("P12"), eq("secret")))
.thenReturn(info);
MockMultipartFile certFile =
new MockMultipartFile(
"p12File", "cert.p12", "application/octet-stream", DUMMY_CERT);
mockMvc.perform(
multipart("/api/v1/workflow/participant/validate-certificate")
.file(certFile)
.param("participantToken", VALID_TOKEN)
.param("certType", "P12")
.param("password", "secret"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.valid").value(true))
.andExpect(jsonPath("$.subjectName").value("Test Signer"));
}
// ---- Bad password / invalid cert → validator throws 400, we return 200 valid:false ----
@Test
void invalidCertificate_returns200WithValidFalseAndErrorMessage() throws Exception {
when(participantRepository.findByShareToken(VALID_TOKEN))
.thenReturn(Optional.of(activeParticipant()));
when(certificateSubmissionValidator.validateAndExtractInfo(any(), any(), any()))
.thenThrow(
new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Invalid certificate password or corrupt keystore file"));
MockMultipartFile certFile =
new MockMultipartFile(
"p12File", "cert.p12", "application/octet-stream", DUMMY_CERT);
mockMvc.perform(
multipart("/api/v1/workflow/participant/validate-certificate")
.file(certFile)
.param("participantToken", VALID_TOKEN)
.param("certType", "P12")
.param("password", "wrong"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.valid").value(false))
.andExpect(
jsonPath("$.error")
.value("Invalid certificate password or corrupt keystore file"));
}
// ---- No file → 400 bad request ----
@Test
void missingCertFile_returns400() throws Exception {
when(participantRepository.findByShareToken(VALID_TOKEN))
.thenReturn(Optional.of(activeParticipant()));
mockMvc.perform(
multipart("/api/v1/workflow/participant/validate-certificate")
.param("participantToken", VALID_TOKEN)
.param("certType", "P12")
.param("password", "pass"))
.andExpect(status().isBadRequest());
}
// ---- Invalid / expired token → 403 ----
@Test
void invalidToken_returns403() throws Exception {
when(participantRepository.findByShareToken("bad-token")).thenReturn(Optional.empty());
MockMultipartFile certFile =
new MockMultipartFile(
"p12File", "cert.p12", "application/octet-stream", DUMMY_CERT);
mockMvc.perform(
multipart("/api/v1/workflow/participant/validate-certificate")
.file(certFile)
.param("participantToken", "bad-token")
.param("certType", "P12")
.param("password", "pass"))
.andExpect(status().isForbidden());
}
}

View File

@@ -0,0 +1,357 @@
package stirling.software.proprietary.workflow.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Date;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
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.web.server.ResponseStatusException;
import stirling.software.common.service.PdfSigningService;
import stirling.software.proprietary.workflow.dto.CertificateInfo;
@ExtendWith(MockitoExtension.class)
class CertificateSubmissionValidatorTest {
@Mock private PdfSigningService pdfSigningService;
private CertificateSubmissionValidator validator;
@BeforeEach
void setUp() {
validator = new CertificateSubmissionValidator(pdfSigningService);
}
// ---- helper: build a PKCS12 keystore with a self-signed cert ----
private static byte[] buildP12Keystore(
String alias, String password, Date notBefore, Date notAfter) throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
KeyPair kp = kpg.generateKeyPair();
X500Name subject = new X500Name("CN=Test Signer,O=Test Org,C=GB");
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate());
X509Certificate cert =
new JcaX509CertificateConverter()
.getCertificate(
new JcaX509v3CertificateBuilder(
subject,
BigInteger.valueOf(System.currentTimeMillis()),
notBefore,
notAfter,
subject,
kp.getPublic())
.build(signer));
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(null, null);
ks.setKeyEntry(alias, kp.getPrivate(), password.toCharArray(), new Certificate[] {cert});
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ks.store(bos, password.toCharArray());
return bos.toByteArray();
}
private static byte[] validP12(String password) throws Exception {
Date now = new Date();
Date future = new Date(now.getTime() + 365L * 24 * 60 * 60 * 1000);
return buildP12Keystore("test", password, now, future);
}
private static byte[] expiredP12(String password) throws Exception {
Date past1 = new Date(System.currentTimeMillis() - 10_000_000L);
Date past2 = new Date(System.currentTimeMillis() - 1_000L);
return buildP12Keystore("test", password, past1, past2);
}
private static byte[] notYetValidP12(String password) throws Exception {
Date future1 = new Date(System.currentTimeMillis() + 10_000_000L);
Date future2 = new Date(System.currentTimeMillis() + 20_000_000L);
return buildP12Keystore("test", password, future1, future2);
}
// ---- SERVER type: skip validation ----
@Test
void serverCertType_returnsNull_withoutCallingSigningService() throws Exception {
CertificateInfo result = validator.validateAndExtractInfo(new byte[0], "SERVER", "pass");
assertThat(result).isNull();
verify(pdfSigningService, never())
.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
any(),
any(),
any(),
any(),
anyBoolean());
}
@Test
void nullCertType_returnsNull_withoutCallingSigningService() throws Exception {
CertificateInfo result = validator.validateAndExtractInfo(new byte[0], null, "pass");
assertThat(result).isNull();
verify(pdfSigningService, never())
.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
any(),
any(),
any(),
any(),
anyBoolean());
}
// ---- Valid P12 certificate ----
@Test
void validP12Certificate_returnsInfo() throws Exception {
byte[] p12 = validP12("password");
when(pdfSigningService.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
isNull(),
anyString(),
isNull(),
isNull(),
anyBoolean()))
.thenReturn(new byte[0]);
CertificateInfo info = validator.validateAndExtractInfo(p12, "P12", "password");
assertThat(info).isNotNull();
assertThat(info.subjectName()).isEqualTo("Test Signer");
assertThat(info.notAfter()).isNotNull();
assertThat(info.notAfter()).isAfter(new Date());
}
@Test
void validPkcs12Alias_acceptedAsCertType() throws Exception {
byte[] p12 = validP12("password");
when(pdfSigningService.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
isNull(),
anyString(),
isNull(),
isNull(),
anyBoolean()))
.thenReturn(new byte[0]);
CertificateInfo info = validator.validateAndExtractInfo(p12, "PKCS12", "password");
assertThat(info).isNotNull();
}
@Test
void validPfxAlias_acceptedAsCertType() throws Exception {
byte[] p12 = validP12("password");
when(pdfSigningService.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
isNull(),
anyString(),
isNull(),
isNull(),
anyBoolean()))
.thenReturn(new byte[0]);
CertificateInfo info = validator.validateAndExtractInfo(p12, "PFX", "password");
assertThat(info).isNotNull();
}
// ---- Wrong password ----
@Test
void wrongPassword_throws400() {
byte[] p12;
try {
p12 = validP12("correct-password");
} catch (Exception e) {
throw new RuntimeException(e);
}
assertThatThrownBy(() -> validator.validateAndExtractInfo(p12, "P12", "wrong-password"))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
ex ->
assertThat(((ResponseStatusException) ex).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST));
}
// ---- Corrupt keystore bytes ----
@Test
void corruptBytes_throws400() {
byte[] garbage = "this is not a keystore".getBytes();
assertThatThrownBy(() -> validator.validateAndExtractInfo(garbage, "P12", "password"))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
ex ->
assertThat(((ResponseStatusException) ex).getStatusCode())
.isEqualTo(HttpStatus.BAD_REQUEST));
}
// ---- Expired certificate ----
@Test
void expiredCertificate_throws400WithExpiryDateInMessage() {
byte[] p12;
try {
p12 = expiredP12("password");
} catch (Exception e) {
throw new RuntimeException(e);
}
assertThatThrownBy(() -> validator.validateAndExtractInfo(p12, "P12", "password"))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
ex -> {
ResponseStatusException rse = (ResponseStatusException) ex;
assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(rse.getReason()).contains("expired");
});
}
// ---- Not-yet-valid certificate ----
@Test
void notYetValidCertificate_throws400() {
byte[] p12;
try {
p12 = notYetValidP12("password");
} catch (Exception e) {
throw new RuntimeException(e);
}
assertThatThrownBy(() -> validator.validateAndExtractInfo(p12, "P12", "password"))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
ex -> {
ResponseStatusException rse = (ResponseStatusException) ex;
assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(rse.getReason()).contains("not yet valid");
});
}
// ---- Test-sign failure ----
@Test
void signingServiceThrows_wrapsAs400() throws Exception {
byte[] p12 = validP12("password");
doThrow(new RuntimeException("algorithm not supported"))
.when(pdfSigningService)
.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
isNull(),
anyString(),
isNull(),
isNull(),
anyBoolean());
assertThatThrownBy(() -> validator.validateAndExtractInfo(p12, "P12", "password"))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
ex -> {
ResponseStatusException rse = (ResponseStatusException) ex;
assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(rse.getReason()).contains("compatible");
});
}
// ---- JKS keystore ----
@Test
void validJksKeystore_returnsInfo() throws Exception {
// Build a JKS keystore with the same cert
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
kpg.initialize(2048);
KeyPair kp = kpg.generateKeyPair();
X500Name subject = new X500Name("CN=JKS Signer,O=Test,C=GB");
Date now = new Date();
Date future = new Date(now.getTime() + 365L * 24 * 60 * 60 * 1000);
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate());
X509Certificate cert =
new JcaX509CertificateConverter()
.getCertificate(
new JcaX509v3CertificateBuilder(
subject,
BigInteger.valueOf(System.currentTimeMillis()),
now,
future,
subject,
kp.getPublic())
.build(signer));
KeyStore jks = KeyStore.getInstance("JKS");
jks.load(null, null);
jks.setKeyEntry(
"jks-test", kp.getPrivate(), "jkspass".toCharArray(), new Certificate[] {cert});
ByteArrayOutputStream bos = new ByteArrayOutputStream();
jks.store(bos, "jkspass".toCharArray());
byte[] jksBytes = bos.toByteArray();
when(pdfSigningService.signWithKeystore(
any(),
any(),
any(),
anyBoolean(),
isNull(),
anyString(),
isNull(),
isNull(),
anyBoolean()))
.thenReturn(new byte[0]);
CertificateInfo info = validator.validateAndExtractInfo(jksBytes, "JKS", "jkspass");
assertThat(info).isNotNull();
assertThat(info.subjectName()).isEqualTo("JKS Signer");
}
}