mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +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:
+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.
Reference in New Issue
Block a user