mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
@@ -217,6 +217,37 @@ jobs:
|
|||||||
path: frontend/dist/
|
path: frontend/dist/
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
|
playwright-e2e:
|
||||||
|
if: needs.files-changed.outputs.frontend == 'true'
|
||||||
|
needs: files-changed
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Harden Runner
|
||||||
|
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: cd frontend && npm ci
|
||||||
|
- name: Install Playwright (chromium only)
|
||||||
|
run: cd frontend && npx playwright install chromium --with-deps
|
||||||
|
- name: Run E2E tests (chromium)
|
||||||
|
run: cd frontend && npx playwright test src/core/tests/certValidation --project=chromium
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: playwright-report-pr-${{ github.run_id }}
|
||||||
|
path: frontend/playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
check-licence:
|
check-licence:
|
||||||
if: needs.files-changed.outputs.build == 'true'
|
if: needs.files-changed.outputs.build == 'true'
|
||||||
needs: [files-changed, build]
|
needs: [files-changed, build]
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
name: Nightly E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 2 * * *" # 2 AM UTC every night
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
playwright-all-browsers:
|
||||||
|
name: Playwright (chromium + firefox + webkit)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
|
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: cd frontend && npm ci
|
||||||
|
|
||||||
|
- name: Install all Playwright browsers
|
||||||
|
run: cd frontend && npx playwright install --with-deps
|
||||||
|
|
||||||
|
- name: Run E2E tests (all browsers)
|
||||||
|
run: cd frontend && npx playwright test src/core/tests/certValidation
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||||
|
with:
|
||||||
|
name: playwright-nightly-${{ github.run_id }}
|
||||||
|
path: frontend/playwright-report/
|
||||||
|
retention-days: 14
|
||||||
@@ -203,6 +203,9 @@ out/
|
|||||||
*.jks
|
*.jks
|
||||||
*.asc
|
*.asc
|
||||||
|
|
||||||
|
# Allow test fixture certificates (synthetic, no real credentials)
|
||||||
|
!frontend/src/core/tests/test-fixtures/certs/**
|
||||||
|
|
||||||
# SSH Keys
|
# SSH Keys
|
||||||
*.pub
|
*.pub
|
||||||
*.priv
|
*.priv
|
||||||
|
|||||||
@@ -180,7 +180,9 @@ public class RequestUriUtils {
|
|||||||
|| trimmedUri.startsWith("/readiness")
|
|| trimmedUri.startsWith("/readiness")
|
||||||
|| trimmedUri.startsWith(
|
|| trimmedUri.startsWith(
|
||||||
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|
"/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) {
|
private static String stripContextPath(String contextPath, String requestURI) {
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ out/
|
|||||||
*.jks
|
*.jks
|
||||||
*.asc
|
*.asc
|
||||||
|
|
||||||
|
# Allow test fixture certificates (synthetic, no real credentials)
|
||||||
|
!src/test/resources/test-certs/**
|
||||||
|
|
||||||
# SSH Keys
|
# SSH Keys
|
||||||
*.pub
|
*.pub
|
||||||
*.priv
|
*.priv
|
||||||
|
|||||||
+85
@@ -1,5 +1,6 @@
|
|||||||
package stirling.software.proprietary.workflow.controller;
|
package stirling.software.proprietary.workflow.controller;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.util.List;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
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.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
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.common.util.WebResponseUtils;
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
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.ParticipantRequest;
|
||||||
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
|
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
|
||||||
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
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.SigningFinalizationService;
|
||||||
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
|
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
|
||||||
|
|
||||||
@@ -48,6 +54,7 @@ public class SigningSessionController {
|
|||||||
private final WorkflowSessionService workflowSessionService;
|
private final WorkflowSessionService workflowSessionService;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final SigningFinalizationService signingFinalizationService;
|
private final SigningFinalizationService signingFinalizationService;
|
||||||
|
private final CertificateSubmissionValidator certificateSubmissionValidator;
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
@Operation(summary = "List all signing sessions for current user")
|
@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 =====
|
// ===== HELPER METHODS =====
|
||||||
|
|
||||||
private User getCurrentUser(Principal principal) {
|
private User getCurrentUser(Principal principal) {
|
||||||
|
|||||||
+114
@@ -2,6 +2,8 @@ package stirling.software.proprietary.workflow.controller;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@@ -27,6 +30,8 @@ import jakarta.validation.constraints.Size;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.ParticipantResponse;
|
||||||
import stirling.software.proprietary.workflow.dto.SignatureSubmissionRequest;
|
import stirling.software.proprietary.workflow.dto.SignatureSubmissionRequest;
|
||||||
import stirling.software.proprietary.workflow.dto.WetSignatureMetadata;
|
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.WorkflowParticipant;
|
||||||
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||||
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
|
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.MetadataEncryptionService;
|
||||||
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
|
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
|
||||||
import stirling.software.proprietary.workflow.util.WorkflowMapper;
|
import stirling.software.proprietary.workflow.util.WorkflowMapper;
|
||||||
@@ -59,6 +65,10 @@ public class WorkflowParticipantController {
|
|||||||
private final WorkflowParticipantRepository participantRepository;
|
private final WorkflowParticipantRepository participantRepository;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final MetadataEncryptionService metadataEncryptionService;
|
private final MetadataEncryptionService metadataEncryptionService;
|
||||||
|
private final CertificateSubmissionValidator certificateSubmissionValidator;
|
||||||
|
|
||||||
|
private static final DateTimeFormatter ISO_UTC =
|
||||||
|
DateTimeFormatter.ISO_INSTANT.withZone(ZoneOffset.UTC);
|
||||||
|
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "Get workflow session details by participant token",
|
summary = "Get workflow session details by participant token",
|
||||||
@@ -173,6 +183,8 @@ public class WorkflowParticipantController {
|
|||||||
|
|
||||||
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
|
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
|
||||||
|
|
||||||
|
} catch (ResponseStatusException e) {
|
||||||
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error submitting signature for participant {}", participant.getEmail(), e);
|
log.error("Error submitting signature for participant {}", participant.getEmail(), e);
|
||||||
throw new ResponseStatusException(
|
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
|
* Builds metadata map from signature submission request. Includes certificate submission and
|
||||||
* wet signature data.
|
* wet signature data.
|
||||||
@@ -276,6 +376,20 @@ public class WorkflowParticipantController {
|
|||||||
throws IOException {
|
throws IOException {
|
||||||
Map<String, Object> metadata = new HashMap<>();
|
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
|
// Add certificate submission if provided
|
||||||
if (request.getCertType() != null) {
|
if (request.getCertType() != null) {
|
||||||
Map<String, Object> certSubmission = new HashMap<>();
|
Map<String, Object> certSubmission = new HashMap<>();
|
||||||
|
|||||||
+11
@@ -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) {}
|
||||||
+18
@@ -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) {}
|
||||||
+213
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -822,6 +822,8 @@ public class SigningFinalizationService {
|
|||||||
|
|
||||||
switch (certType) {
|
switch (certType) {
|
||||||
case "P12":
|
case "P12":
|
||||||
|
case "PKCS12":
|
||||||
|
case "PFX":
|
||||||
if (submission.getP12Keystore() == null) {
|
if (submission.getP12Keystore() == null) {
|
||||||
throw new ResponseStatusException(
|
throw new ResponseStatusException(
|
||||||
HttpStatus.BAD_REQUEST, "P12 keystore data is required");
|
HttpStatus.BAD_REQUEST, "P12 keystore data is required");
|
||||||
|
|||||||
+22
-1
@@ -61,6 +61,7 @@ public class WorkflowSessionService {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ApplicationProperties applicationProperties;
|
private final ApplicationProperties applicationProperties;
|
||||||
private final MetadataEncryptionService metadataEncryptionService;
|
private final MetadataEncryptionService metadataEncryptionService;
|
||||||
|
private final CertificateSubmissionValidator certificateSubmissionValidator;
|
||||||
|
|
||||||
public void ensureSigningEnabled() {
|
public void ensureSigningEnabled() {
|
||||||
if (!applicationProperties.getStorage().isEnabled()
|
if (!applicationProperties.getStorage().isEnabled()
|
||||||
@@ -684,7 +685,27 @@ public class WorkflowSessionService {
|
|||||||
metadata = new HashMap<>(existingMetadata);
|
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<>();
|
Map<String, Object> certSubmission = new HashMap<>();
|
||||||
certSubmission.put("certType", request.getCertType());
|
certSubmission.put("certType", request.getCertType());
|
||||||
certSubmission.put("password", metadataEncryptionService.encrypt(request.getPassword()));
|
certSubmission.put("password", metadataEncryptionService.encrypt(request.getPassword()));
|
||||||
|
|||||||
+203
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+159
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+357
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,7 +4,7 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
* @see https://playwright.dev/docs/test-configuration
|
* @see https://playwright.dev/docs/test-configuration
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './src/tests',
|
testDir: './src/core/tests',
|
||||||
testMatch: '**/*.spec.ts',
|
testMatch: '**/*.spec.ts',
|
||||||
/* Run tests in files in parallel */
|
/* Run tests in files in parallel */
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
|
|||||||
@@ -2317,8 +2317,21 @@ continue = "Continue"
|
|||||||
[certSign.collab.signRequest.certModal]
|
[certSign.collab.signRequest.certModal]
|
||||||
description = "You have placed {{count}} signature(s). Choose your certificate to complete signing."
|
description = "You have placed {{count}} signature(s). Choose your certificate to complete signing."
|
||||||
sign = "Sign Document"
|
sign = "Sign Document"
|
||||||
|
certValidating = "Validating certificate..."
|
||||||
|
certValidUntil = "Certificate valid until {{date}}"
|
||||||
|
certInvalid = "Certificate invalid: {{error}}"
|
||||||
|
certInvalidFallback = "Invalid certificate"
|
||||||
|
certNetworkError = "Could not validate certificate"
|
||||||
title = "Configure Certificate"
|
title = "Configure Certificate"
|
||||||
|
|
||||||
|
[certSign.collab.participant]
|
||||||
|
certValidating = "Validating certificate..."
|
||||||
|
certValid = "✓ Certificate valid"
|
||||||
|
certValidUntil = " until {{date}}"
|
||||||
|
certInvalid = "✗ {{error}}"
|
||||||
|
certInvalidFallback = "Invalid certificate"
|
||||||
|
certNetworkError = "Could not validate certificate"
|
||||||
|
|
||||||
[certSign.collab.signRequest.image]
|
[certSign.collab.signRequest.image]
|
||||||
hint = "Upload a PNG or JPG image of your signature"
|
hint = "Upload a PNG or JPG image of your signature"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Modal, Stack, Group, Button, Text, Collapse, TextInput } from '@mantine/core';
|
import { Modal, Stack, Group, Button, Text, Collapse, TextInput, Loader } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useState } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import ErrorIcon from '@mui/icons-material/Error';
|
||||||
import { CertificateSelector, CertificateType, UploadFormat } from '@app/components/tools/certSign/CertificateSelector';
|
import { CertificateSelector, CertificateType, UploadFormat } from '@app/components/tools/certSign/CertificateSelector';
|
||||||
|
import apiClient from '@app/services/apiClient';
|
||||||
|
|
||||||
export interface CertificateSubmitData {
|
export interface CertificateSubmitData {
|
||||||
certType: CertificateType;
|
certType: CertificateType;
|
||||||
@@ -13,6 +16,12 @@ export interface CertificateSubmitData {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CertValidationState =
|
||||||
|
| { status: 'idle' }
|
||||||
|
| { status: 'validating' }
|
||||||
|
| { status: 'valid'; subjectName: string | null; notAfter: string | null }
|
||||||
|
| { status: 'error'; message: string };
|
||||||
|
|
||||||
interface CertificateConfigModalProps {
|
interface CertificateConfigModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -21,6 +30,8 @@ interface CertificateConfigModalProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
defaultReason?: string;
|
defaultReason?: string;
|
||||||
defaultLocation?: string;
|
defaultLocation?: string;
|
||||||
|
/** Share token for external participants. When present, the participant validation endpoint is used. */
|
||||||
|
participantToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
|
export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
|
||||||
@@ -31,6 +42,7 @@ export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
defaultReason = '',
|
defaultReason = '',
|
||||||
defaultLocation = '',
|
defaultLocation = '',
|
||||||
|
participantToken,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -42,6 +54,65 @@ export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
|
|||||||
const [jksFile, setJksFile] = useState<File | null>(null);
|
const [jksFile, setJksFile] = useState<File | null>(null);
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [signing, setSigning] = useState(false);
|
const [signing, setSigning] = useState(false);
|
||||||
|
const [certValidation, setCertValidation] = useState<CertValidationState>({ status: 'idle' });
|
||||||
|
const validationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Debounced certificate pre-validation: fires 600ms after cert file or password changes
|
||||||
|
useEffect(() => {
|
||||||
|
// Only validate uploaded keystores (not SERVER/USER_CERT, not PEM which uses separate files)
|
||||||
|
const keystoreFile = uploadFormat === 'JKS' ? jksFile : p12File;
|
||||||
|
if (certType !== 'UPLOAD' || !keystoreFile || uploadFormat === 'PEM') {
|
||||||
|
setCertValidation({ status: 'idle' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
|
||||||
|
setCertValidation({ status: 'validating' });
|
||||||
|
|
||||||
|
validationTimerRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('certType', uploadFormat === 'JKS' ? 'JKS' : 'P12');
|
||||||
|
formData.append('password', password);
|
||||||
|
if (uploadFormat === 'JKS') {
|
||||||
|
formData.append('jksFile', keystoreFile);
|
||||||
|
} else {
|
||||||
|
formData.append('p12File', keystoreFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = participantToken
|
||||||
|
? '/api/v1/workflow/participant/validate-certificate'
|
||||||
|
: '/api/v1/security/cert-sign/validate-certificate';
|
||||||
|
|
||||||
|
if (participantToken) {
|
||||||
|
formData.append('participantToken', participantToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiClient.post<{
|
||||||
|
valid: boolean;
|
||||||
|
subjectName: string | null;
|
||||||
|
notAfter: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}>(endpoint, formData, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||||
|
|
||||||
|
if (response.data.valid) {
|
||||||
|
setCertValidation({
|
||||||
|
status: 'valid',
|
||||||
|
subjectName: response.data.subjectName,
|
||||||
|
notAfter: response.data.notAfter,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setCertValidation({ status: 'error', message: response.data.error ?? t('certSign.collab.signRequest.certModal.certInvalidFallback', 'Invalid certificate') });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setCertValidation({ status: 'error', message: t('certSign.collab.signRequest.certModal.certNetworkError', 'Could not validate certificate') });
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [certType, uploadFormat, p12File, jksFile, password, participantToken]);
|
||||||
|
|
||||||
// Advanced settings
|
// Advanced settings
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
@@ -118,6 +189,39 @@ export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
|
|||||||
disabled={disabled || signing}
|
disabled={disabled || signing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Certificate validation status */}
|
||||||
|
{certValidation.status === 'validating' && (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Loader size="xs" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('certSign.collab.signRequest.certModal.certValidating', 'Validating certificate...')}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{certValidation.status === 'valid' && (
|
||||||
|
<Group gap="xs">
|
||||||
|
<CheckCircleIcon fontSize="small" style={{ color: 'var(--mantine-color-green-6)' }} />
|
||||||
|
<Text size="sm" c="green">
|
||||||
|
{t('certSign.collab.signRequest.certModal.certValidUntil', 'Certificate valid until {{date}}', {
|
||||||
|
date: certValidation.notAfter
|
||||||
|
? new Date(certValidation.notAfter).toLocaleDateString()
|
||||||
|
: '—',
|
||||||
|
})}
|
||||||
|
{certValidation.subjectName ? ` · ${certValidation.subjectName}` : ''}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{certValidation.status === 'error' && (
|
||||||
|
<Group gap="xs">
|
||||||
|
<ErrorIcon fontSize="small" style={{ color: 'var(--mantine-color-red-6)' }} />
|
||||||
|
<Text size="sm" c="red">
|
||||||
|
{t('certSign.collab.signRequest.certModal.certInvalid', 'Certificate invalid: {{error}}', {
|
||||||
|
error: certValidation.message,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Advanced Settings - Optional */}
|
{/* Advanced Settings - Optional */}
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
@@ -156,7 +260,7 @@ export const CertificateConfigModal: React.FC<CertificateConfigModalProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSign}
|
onClick={handleSign}
|
||||||
disabled={!isValid || disabled || signing}
|
disabled={!isValid || disabled || signing || certValidation.status === 'validating'}
|
||||||
loading={signing}
|
loading={signing}
|
||||||
>
|
>
|
||||||
{t('certSign.collab.signRequest.certModal.sign', 'Sign Document')}
|
{t('certSign.collab.signRequest.certModal.sign', 'Sign Document')}
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test fixtures — pre-generated keystores in test-fixtures/certs/
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const CERTS_DIR = path.join(__dirname, '../test-fixtures/certs');
|
||||||
|
const VALID_P12 = path.join(CERTS_DIR, 'valid-test.p12');
|
||||||
|
const EXPIRED_P12 = path.join(CERTS_DIR, 'expired-test.p12');
|
||||||
|
const NOT_YET_VALID_P12 = path.join(CERTS_DIR, 'not-yet-valid-test.p12');
|
||||||
|
const VALID_JKS = path.join(CERTS_DIR, 'valid-test.jks');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stable mock data returned by the mocked backend
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const MOCK_SESSION = {
|
||||||
|
sessionId: 'test-session-id',
|
||||||
|
documentName: 'test-document.pdf',
|
||||||
|
status: 'IN_PROGRESS',
|
||||||
|
ownerUsername: 'test-owner',
|
||||||
|
message: null,
|
||||||
|
dueDate: null,
|
||||||
|
participants: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_PARTICIPANT = {
|
||||||
|
id: 1,
|
||||||
|
email: '[email protected]',
|
||||||
|
name: 'Test User',
|
||||||
|
status: 'PENDING',
|
||||||
|
shareToken: 'test-token',
|
||||||
|
expiresAt: null,
|
||||||
|
hasCompleted: false,
|
||||||
|
isExpired: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: mock the participant session and document endpoints
|
||||||
|
// (called in beforeEach so each test starts from a clean active session)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function mockParticipantApis(page: Page) {
|
||||||
|
// Mock auth so AppProviders/Landing don't redirect to /login
|
||||||
|
await page.route('**/api/v1/auth/me', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: 1,
|
||||||
|
username: 'testuser',
|
||||||
|
email: '[email protected]',
|
||||||
|
roles: ['ROLE_USER'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.route('**/api/v1/workflow/participant/session**', (route) =>
|
||||||
|
route.fulfill({ json: MOCK_SESSION })
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/workflow/participant/details**', (route) =>
|
||||||
|
route.fulfill({ json: MOCK_PARTICIPANT })
|
||||||
|
);
|
||||||
|
// Minimal stub so the download-document call doesn't throw
|
||||||
|
await page.route('**/api/v1/workflow/participant/document**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
body: Buffer.alloc(128),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: set the Mantine <Select> value by opening its dropdown and clicking
|
||||||
|
// the matching option — Mantine renders a custom combobox, not a native select.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function selectCertType(page: Page, label: string) {
|
||||||
|
await page.getByTestId('cert-type-select').click();
|
||||||
|
await page.getByRole('option', { name: label }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: upload a file into the Mantine <FileInput> (hidden native input)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function uploadCertFile(page: Page, filePath: string) {
|
||||||
|
// Mantine FileInput uses a visually hidden <input type="file">.
|
||||||
|
// We click the visible button to expose it, then set files via the hidden input.
|
||||||
|
const certFileInput = page.getByTestId('cert-file-input');
|
||||||
|
await certFileInput.click();
|
||||||
|
// After click, the file chooser or the hidden input becomes interactive.
|
||||||
|
// Use the first file input on the page (Mantine places it near the button).
|
||||||
|
const fileInput = page.locator('input[type="file"]').first();
|
||||||
|
await fileInput.setInputFiles(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
test.describe('Certificate Validation — ParticipantView', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await mockParticipantApis(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Happy path — valid P12
|
||||||
|
test('valid P12 cert shows green "Certificate valid until" feedback', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
valid: true,
|
||||||
|
subjectName: 'Test Signer',
|
||||||
|
notAfter: '2027-01-01T00:00:00Z',
|
||||||
|
notBefore: '2025-01-01T00:00:00Z',
|
||||||
|
selfSigned: true,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/workflow/sign/test-token');
|
||||||
|
await page.waitForSelector('[data-testid="submit-signature-button"]');
|
||||||
|
|
||||||
|
await uploadCertFile(page, VALID_P12);
|
||||||
|
await page.getByTestId('cert-password-input').fill('testpass');
|
||||||
|
|
||||||
|
// Wait for debounce (600 ms) + network round-trip
|
||||||
|
const feedback = page.getByTestId('cert-validation-feedback');
|
||||||
|
await expect(feedback).toContainText('Certificate valid until', { timeout: 5000 });
|
||||||
|
await expect(feedback).toContainText('Test Signer');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Wrong password — red error
|
||||||
|
test('wrong password shows red error message', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
valid: false,
|
||||||
|
subjectName: null,
|
||||||
|
notAfter: null,
|
||||||
|
notBefore: null,
|
||||||
|
selfSigned: false,
|
||||||
|
error: 'Invalid certificate password or corrupt keystore file',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/workflow/sign/test-token');
|
||||||
|
await page.waitForSelector('[data-testid="submit-signature-button"]');
|
||||||
|
|
||||||
|
await uploadCertFile(page, VALID_P12);
|
||||||
|
await page.getByTestId('cert-password-input').fill('wrongpass');
|
||||||
|
|
||||||
|
const feedback = page.getByTestId('cert-validation-feedback');
|
||||||
|
await expect(feedback).toContainText('Invalid certificate password', { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Expired certificate
|
||||||
|
test('expired cert shows "Certificate has expired" error', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
valid: false,
|
||||||
|
subjectName: null,
|
||||||
|
notAfter: null,
|
||||||
|
notBefore: null,
|
||||||
|
selfSigned: false,
|
||||||
|
error: 'Certificate has expired (expired: 2023-01-02 00:00:00 UTC)',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/workflow/sign/test-token');
|
||||||
|
await page.waitForSelector('[data-testid="submit-signature-button"]');
|
||||||
|
|
||||||
|
await uploadCertFile(page, EXPIRED_P12);
|
||||||
|
await page.getByTestId('cert-password-input').fill('testpass');
|
||||||
|
|
||||||
|
const feedback = page.getByTestId('cert-validation-feedback');
|
||||||
|
await expect(feedback).toContainText('Certificate has expired', { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Not-yet-valid certificate
|
||||||
|
test('not-yet-valid cert shows "not yet valid" error', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
valid: false,
|
||||||
|
subjectName: null,
|
||||||
|
notAfter: null,
|
||||||
|
notBefore: null,
|
||||||
|
selfSigned: false,
|
||||||
|
error: 'Certificate is not yet valid (valid from: 2027-01-01 00:00:00 UTC)',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/workflow/sign/test-token');
|
||||||
|
await page.waitForSelector('[data-testid="submit-signature-button"]');
|
||||||
|
|
||||||
|
await uploadCertFile(page, NOT_YET_VALID_P12);
|
||||||
|
await page.getByTestId('cert-password-input').fill('testpass');
|
||||||
|
|
||||||
|
const feedback = page.getByTestId('cert-validation-feedback');
|
||||||
|
await expect(feedback).toContainText('not yet valid', { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Submit button disabled while validating
|
||||||
|
test('submit button is disabled while validation is in flight', async ({ page }) => {
|
||||||
|
// Slow response so we can assert the disabled state mid-flight
|
||||||
|
await page.route('**/api/v1/workflow/participant/validate-certificate', async (route) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
valid: true,
|
||||||
|
subjectName: 'Test Signer',
|
||||||
|
notAfter: '2027-01-01T00:00:00Z',
|
||||||
|
notBefore: '2025-01-01T00:00:00Z',
|
||||||
|
selfSigned: true,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/workflow/sign/test-token');
|
||||||
|
await page.waitForSelector('[data-testid="submit-signature-button"]');
|
||||||
|
|
||||||
|
await uploadCertFile(page, VALID_P12);
|
||||||
|
await page.getByTestId('cert-password-input').fill('testpass');
|
||||||
|
|
||||||
|
// Shortly after typing, validation is in flight — button must be disabled
|
||||||
|
const submitBtn = page.getByTestId('submit-signature-button');
|
||||||
|
await expect(submitBtn).toBeDisabled({ timeout: 3000 });
|
||||||
|
|
||||||
|
// After validation completes the button should be re-enabled
|
||||||
|
await expect(submitBtn).toBeEnabled({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. SERVER type — no validation call made, button stays enabled
|
||||||
|
test('SERVER cert type skips validation and keeps submit enabled', async ({ page }) => {
|
||||||
|
let validateCalled = false;
|
||||||
|
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) => {
|
||||||
|
validateCalled = true;
|
||||||
|
return route.fulfill({ json: { valid: true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/workflow/sign/test-token');
|
||||||
|
await page.waitForSelector('[data-testid="submit-signature-button"]');
|
||||||
|
|
||||||
|
await selectCertType(page, 'Server Certificate (if available)');
|
||||||
|
|
||||||
|
// Wait longer than debounce to confirm no call is made
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
expect(validateCalled).toBe(false);
|
||||||
|
await expect(page.getByTestId('submit-signature-button')).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Bonus — valid JKS keystore
|
||||||
|
test('valid JKS keystore shows green feedback', async ({ page }) => {
|
||||||
|
await page.route('**/api/v1/workflow/participant/validate-certificate', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
valid: true,
|
||||||
|
subjectName: 'JKS Signer',
|
||||||
|
notAfter: '2027-01-01T00:00:00Z',
|
||||||
|
notBefore: '2025-01-01T00:00:00Z',
|
||||||
|
selfSigned: true,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.goto('/workflow/sign/test-token');
|
||||||
|
await page.waitForSelector('[data-testid="submit-signature-button"]');
|
||||||
|
|
||||||
|
await selectCertType(page, 'JKS Keystore');
|
||||||
|
await uploadCertFile(page, VALID_JKS);
|
||||||
|
await page.getByTestId('cert-password-input').fill('jkspass');
|
||||||
|
|
||||||
|
const feedback = page.getByTestId('cert-validation-feedback');
|
||||||
|
await expect(feedback).toContainText('Certificate valid until', { timeout: 5000 });
|
||||||
|
await expect(feedback).toContainText('JKS Signer');
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,5 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route, useParams } from "react-router-dom";
|
||||||
import { AppProviders } from "@app/components/AppProviders";
|
import { AppProviders } from "@app/components/AppProviders";
|
||||||
import { AppLayout } from "@app/components/AppLayout";
|
import { AppLayout } from "@app/components/AppLayout";
|
||||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||||
@@ -11,6 +11,7 @@ import Signup from "@app/routes/Signup";
|
|||||||
import AuthCallback from "@app/routes/AuthCallback";
|
import AuthCallback from "@app/routes/AuthCallback";
|
||||||
import InviteAccept from "@app/routes/InviteAccept";
|
import InviteAccept from "@app/routes/InviteAccept";
|
||||||
import ShareLinkPage from "@app/routes/ShareLinkPage";
|
import ShareLinkPage from "@app/routes/ShareLinkPage";
|
||||||
|
import ParticipantView from "@app/components/workflow/ParticipantView";
|
||||||
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
||||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||||
|
|
||||||
@@ -34,6 +35,13 @@ function MobileScannerProviders({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Participant signing page — token-gated, no login required
|
||||||
|
function ParticipantViewPage() {
|
||||||
|
const { token } = useParams<{ token: string }>();
|
||||||
|
if (!token) return null;
|
||||||
|
return <ParticipantView token={token} />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
@@ -48,6 +56,16 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Participant signing — public, token-gated, no auth required */}
|
||||||
|
<Route
|
||||||
|
path="/workflow/sign/:token"
|
||||||
|
element={
|
||||||
|
<MobileScannerProviders>
|
||||||
|
<ParticipantViewPage />
|
||||||
|
</MobileScannerProviders>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* All other routes need AppProviders for backend integration */}
|
{/* All other routes need AppProviders for backend integration */}
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
@@ -55,7 +73,6 @@ export default function App() {
|
|||||||
<AppProviders>
|
<AppProviders>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Auth routes - no nested providers needed */}
|
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/signup" element={<Signup />} />
|
<Route path="/signup" element={<Signup />} />
|
||||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Card,
|
Card,
|
||||||
@@ -23,6 +24,7 @@ interface ParticipantViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { session, participant, loading, error, submitSignature, decline, downloadDocument } =
|
const { session, participant, loading, error, submitSignature, decline, downloadDocument } =
|
||||||
useParticipantSession(token);
|
useParticipantSession(token);
|
||||||
|
|
||||||
@@ -37,6 +39,57 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||||
|
|
||||||
|
type CertValidationState =
|
||||||
|
| { status: 'idle' }
|
||||||
|
| { status: 'validating' }
|
||||||
|
| { status: 'valid'; notAfter: string | null; subjectName: string | null }
|
||||||
|
| { status: 'error'; message: string };
|
||||||
|
|
||||||
|
const [certValidation, setCertValidation] = useState<CertValidationState>({ status: 'idle' });
|
||||||
|
const validationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (certType === 'SERVER' || !certFile) {
|
||||||
|
setCertValidation({ status: 'idle' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
|
||||||
|
setCertValidation({ status: 'validating' });
|
||||||
|
|
||||||
|
validationTimerRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('participantToken', token);
|
||||||
|
formData.append('certType', certType);
|
||||||
|
formData.append('password', password);
|
||||||
|
if (certType === 'JKS') {
|
||||||
|
formData.append('jksFile', certFile);
|
||||||
|
} else {
|
||||||
|
formData.append('p12File', certFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/v1/workflow/participant/validate-certificate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.valid) {
|
||||||
|
setCertValidation({ status: 'valid', notAfter: data.notAfter, subjectName: data.subjectName });
|
||||||
|
} else {
|
||||||
|
setCertValidation({ status: 'error', message: data.error ?? t('certSign.collab.participant.certInvalidFallback', 'Invalid certificate') });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setCertValidation({ status: 'error', message: t('certSign.collab.participant.certNetworkError', 'Could not validate certificate') });
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
|
||||||
|
};
|
||||||
|
}, [certFile, password, certType, token]);
|
||||||
|
|
||||||
const handleSubmitSignature = async () => {
|
const handleSubmitSignature = async () => {
|
||||||
if (!certFile && certType !== 'SERVER') {
|
if (!certFile && certType !== 'SERVER') {
|
||||||
setNotification({ type: 'error', message: 'Please select a certificate file' });
|
setNotification({ type: 'error', message: 'Please select a certificate file' });
|
||||||
@@ -190,6 +243,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
{ value: 'SERVER', label: 'Server Certificate (if available)' },
|
{ value: 'SERVER', label: 'Server Certificate (if available)' },
|
||||||
]}
|
]}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
data-testid="cert-type-select"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{certType !== 'SERVER' && (
|
{certType !== 'SERVER' && (
|
||||||
@@ -201,6 +255,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
onChange={setCertFile}
|
onChange={setCertFile}
|
||||||
accept=".p12,.pfx,.jks"
|
accept=".p12,.pfx,.jks"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
data-testid="cert-file-input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -209,7 +264,31 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
data-testid="cert-password-input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Certificate validation feedback */}
|
||||||
|
{certValidation.status === 'validating' && (
|
||||||
|
<Text size="sm" c="dimmed" data-testid="cert-validation-feedback">
|
||||||
|
{t('certSign.collab.participant.certValidating', 'Validating certificate...')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{certValidation.status === 'valid' && (
|
||||||
|
<Text size="sm" c="green" data-testid="cert-validation-feedback">
|
||||||
|
{t('certSign.collab.participant.certValid', '✓ Certificate valid')}
|
||||||
|
{certValidation.notAfter
|
||||||
|
? t('certSign.collab.participant.certValidUntil', ' until {{date}}', {
|
||||||
|
date: new Date(certValidation.notAfter).toLocaleDateString(),
|
||||||
|
})
|
||||||
|
: ''}
|
||||||
|
{certValidation.subjectName ? ` · ${certValidation.subjectName}` : ''}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{certValidation.status === 'error' && (
|
||||||
|
<Text size="sm" c="red" data-testid="cert-validation-feedback">
|
||||||
|
{t('certSign.collab.participant.certInvalid', '✗ {{error}}', { error: certValidation.message })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -243,7 +322,9 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
leftSection={<CheckCircleIcon fontSize="small" />}
|
leftSection={<CheckCircleIcon fontSize="small" />}
|
||||||
onClick={handleSubmitSignature}
|
onClick={handleSubmitSignature}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
|
disabled={isSubmitting || certValidation.status === 'validating'}
|
||||||
color="green"
|
color="green"
|
||||||
|
data-testid="submit-signature-button"
|
||||||
>
|
>
|
||||||
Submit Signature
|
Submit Signature
|
||||||
</Button>
|
</Button>
|
||||||
@@ -253,6 +334,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
onClick={handleDecline}
|
onClick={handleDecline}
|
||||||
color="red"
|
color="red"
|
||||||
variant="light"
|
variant="light"
|
||||||
|
data-testid="decline-button"
|
||||||
>
|
>
|
||||||
Decline
|
Decline
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user