Compare commits

...
Author SHA1 Message Date
ConnorYoh 170f317f11 Merge branch 'main' into worktree-guest-signing-support 2026-03-30 11:04:28 +01:00
Connor Yoh 5f553ece11 feat(signing): add external/guest user signing support
Allow document owners to invite external users (no Stirling-PDF account)
to sign documents via a one-time share link.

Backend:
- GuestCertificateService: generates ephemeral PKCS12 keystores keyed by
  email, with rfc822Name SAN for industry-traceable digital signatures;
  uses emailProtection EKU, cryptographically random serial numbers, and
  HMAC-SHA256 derived passwords
- SigningFinalizationService: handles GUEST_CERT case in buildKeystore()
  and getKeystorePassword()
- WorkflowParticipantController: defaults certType to GUEST_CERT for
  unregistered participants, captures hashed-IP audit trail on submission,
  skips password encryption for GUEST_CERT paths
- WorkflowSessionService: sends signing invitation email to guest
  participants on session creation; redacts email PII in logs
- EmailService: adds sendSigningInvitationEmail() with HTML-escaped
  user-supplied values and javascript: URL guard to prevent XSS

Frontend:
- GuestSignPage: new token-gated route /sign/:token outside AppProviders;
  full page-state machine (loading/ready/expired/signed/declined/error)
- GuestCertificateChooser: auto-generated cert vs upload P12 chooser
- SelectParticipantsStep: refactored to support registered + external
  (email) participants via tabbed UI with validation
- AddParticipantsFlow: same external email tab added for mid-session adds
- CreateSessionFlow/SignPopout: thread new Participant[] shape through
  to split userIds and emails on submit
- App.tsx: /sign/:token route with minimal GuestSigningProviders (no auth)
- i18n: [guestSigning] keys added to en-GB/translation.toml

Tests:
- GuestCertificateServiceTest: keystore generation, SAN, deterministic passwords
- WorkflowParticipantControllerTest: GUEST_CERT defaulting, audit trail,
  token guards, encrypt() not called for guest cert
- EmailServiceTest: invitation email, HTML injection safety, JS URL guard
- GuestSignPage.test.tsx: 9 vitest unit tests (all page states + submit)
- SelectParticipantsStep.test.tsx: 15 vitest unit tests
- GuestSigningE2E.spec.ts: 19 Playwright E2E tests (route-mocked, no backend)
- playwright.config.ts: fix testDir to src/core/tests, use port 5174
2026-03-27 17:51:43 +00:00
23 changed files with 3326 additions and 98 deletions
@@ -6,6 +6,7 @@ import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.HtmlUtils;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
@@ -260,6 +261,111 @@ public class EmailService {
sendPlainEmail(to, subject, body, true);
}
/**
* Sends a document signing invitation to an external/guest user.
*
* @param to the recipient email address
* @param ownerName the display name of the session owner
* @param documentName the name of the document to be signed
* @param signingUrl the full URL of the guest signing page
* @param expiresAt human-readable expiry date/time string
* @param message optional personal message from the owner (may be null)
* @throws MessagingException if sending fails
*/
@Async
public void sendSigningInvitationEmail(
String to,
String ownerName,
String documentName,
String signingUrl,
String expiresAt,
String message)
throws MessagingException {
// Escape all user-supplied values before embedding in HTML
String safeOwner = HtmlUtils.htmlEscape(ownerName != null ? ownerName : "");
String safeDoc = HtmlUtils.htmlEscape(documentName != null ? documentName : "");
// Only allow http/https URLs in the signing link to prevent javascript: injection
String safeUrl =
(signingUrl != null
&& (signingUrl.startsWith("https://")
|| signingUrl.startsWith("http://")))
? signingUrl
: "#";
String safeUrlText = HtmlUtils.htmlEscape(safeUrl);
String subject = "Please sign: " + (documentName != null ? documentName : "");
String messageSection =
(message != null && !message.isBlank())
? """
<div style="background-color: #f8f9fa; border-left: 4px solid #6c757d; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-style: italic; color: #555;">"%s"</p>
</div>
"""
.formatted(HtmlUtils.htmlEscape(message))
: "";
String expirySection =
(expiresAt != null && !expiresAt.isBlank())
? """
<div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; color: #856404; font-size: 14px;"><strong>&#9888; Important:</strong> This signing link will expire on %s.</p>
</div>
"""
.formatted(HtmlUtils.htmlEscape(expiresAt))
: "";
String body =
"""
<html><body style="margin: 0; padding: 0;">
<div style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;">
<div style="max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;">
<!-- Logo -->
<div style="text-align: center; padding: 20px; background-color: #222;">
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling-transparent.svg" alt="Stirling PDF" style="max-height: 60px;">
</div>
<!-- Content -->
<div style="padding: 30px; color: #333;">
<h2 style="color: #222; margin-top: 0;">You have been asked to sign a document</h2>
<p>Hi there,</p>
<p><strong>%s</strong> has requested your signature on:</p>
<!-- Document Name Box -->
<div style="background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-size: 16px;"><strong>&#128196; %s</strong></p>
</div>
%s
<!-- CTA Button -->
<div style="text-align: center; margin: 30px 0;">
<a href="%s" style="display: inline-block; background-color: #007bff; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 5px; font-weight: bold;">Review and Sign Document</a>
</div>
<p style="font-size: 14px; color: #666;">Or copy and paste this link in your browser:</p>
<div style="background-color: #f8f9fa; padding: 12px; margin: 15px 0; border-radius: 4px; word-break: break-all; font-size: 13px; color: #555;">
%s
</div>
%s
<p style="font-size: 13px; color: #888;">No account is required. Click the button above to review the document and apply your signature.</p>
<p>If you did not expect this request, you can safely ignore this email.</p>
<p style="margin-bottom: 0;">&#8212; The Stirling PDF Team</p>
</div>
<!-- Footer -->
<div style="text-align: center; padding: 15px; font-size: 12px; color: #777; background-color: #f0f0f0;">
&copy; 2025 Stirling PDF. All rights reserved.
</div>
</div>
</div>
</body></html>
"""
.formatted(
safeOwner,
safeDoc,
messageSection,
safeUrl,
safeUrlText,
expirySection);
sendPlainEmail(to, subject, body, true);
}
@Async
public void sendPasswordChangedNotification(
String to, String username, String newPassword, String loginUrl)
@@ -2,8 +2,11 @@ package stirling.software.proprietary.workflow.controller;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
@@ -24,6 +27,7 @@ import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
@@ -134,7 +138,7 @@ public class WorkflowParticipantController {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ParticipantResponse> submitSignature(
@ModelAttribute SignatureSubmissionRequest request) {
@ModelAttribute SignatureSubmissionRequest request, HttpServletRequest httpRequest) {
workflowSessionService.ensureSigningEnabled();
@@ -169,7 +173,8 @@ public class WorkflowParticipantController {
try {
// Build metadata map with certificate and wet signature data
Map<String, Object> metadata = buildSubmissionMetadata(request);
Map<String, Object> metadata =
buildSubmissionMetadata(request, participant, httpRequest);
participant.setParticipantMetadata(metadata);
// Update status to SIGNED
@@ -369,15 +374,27 @@ public class WorkflowParticipantController {
}
/**
* Builds metadata map from signature submission request. Includes certificate submission and
* wet signature data.
* Builds metadata map from signature submission request. Includes certificate submission, wet
* signature data, and an audit trail entry for compliance traceability.
*/
private Map<String, Object> buildSubmissionMetadata(SignatureSubmissionRequest request)
private Map<String, Object> buildSubmissionMetadata(
SignatureSubmissionRequest request,
WorkflowParticipant participant,
HttpServletRequest httpRequest)
throws IOException {
Map<String, Object> metadata = new HashMap<>();
// Validate certificate before storing — throws 400 if invalid, expired, or wrong password
if (request.getCertType() != null && !"SERVER".equalsIgnoreCase(request.getCertType())) {
// Default to GUEST_CERT for external (non-registered) participants that did not
// supply their own certificate.
String effectiveCertType = request.getCertType();
if (effectiveCertType == null && participant.getUser() == null) {
effectiveCertType = "GUEST_CERT";
}
// Validate uploaded certificate before storing — skip for GUEST_CERT (no upload)
if (effectiveCertType != null
&& !"SERVER".equalsIgnoreCase(effectiveCertType)
&& !"GUEST_CERT".equalsIgnoreCase(effectiveCertType)) {
byte[] keystoreBytes = null;
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
keystoreBytes = request.getP12File().getBytes();
@@ -386,16 +403,20 @@ public class WorkflowParticipantController {
}
if (keystoreBytes != null) {
certificateSubmissionValidator.validateAndExtractInfo(
keystoreBytes, request.getCertType(), request.getPassword());
keystoreBytes, effectiveCertType, request.getPassword());
}
}
// Add certificate submission if provided
if (request.getCertType() != null) {
// Add certificate submission if provided (or defaulted)
if (effectiveCertType != null) {
Map<String, Object> certSubmission = new HashMap<>();
certSubmission.put("certType", request.getCertType());
certSubmission.put(
"password", metadataEncryptionService.encrypt(request.getPassword()));
certSubmission.put("certType", effectiveCertType);
// GUEST_CERT passwords are derived at finalization time; no password to store
String encryptedPassword =
"GUEST_CERT".equalsIgnoreCase(effectiveCertType)
? null
: metadataEncryptionService.encrypt(request.getPassword());
certSubmission.put("password", encryptedPassword);
certSubmission.put("showSignature", request.getShowSignature());
certSubmission.put("pageNumber", request.getPageNumber());
certSubmission.put("location", request.getLocation());
@@ -437,6 +458,31 @@ public class WorkflowParticipantController {
metadata.put("wetSignatures", wetSigs);
}
// Audit trail for compliance traceability (IP is hashed, not stored raw)
Map<String, Object> auditTrail = new HashMap<>();
auditTrail.put("ipHash", hashIp(httpRequest.getRemoteAddr()));
String userAgent = httpRequest.getHeader("User-Agent");
auditTrail.put(
"userAgent",
userAgent != null && userAgent.length() > 500
? userAgent.substring(0, 500)
: userAgent);
auditTrail.put("submittedAt", Instant.now().toString());
auditTrail.put("email", participant.getEmail());
metadata.put("auditTrail", auditTrail);
return metadata;
}
/** Returns a SHA-256 Base64-encoded digest of an IP address for privacy-safe audit logging. */
private String hashIp(String ip) {
if (ip == null) return null;
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(ip.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,187 @@
package stirling.software.proprietary.workflow.service;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.KeyStore;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.security.Security;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Base64;
import java.util.Date;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.BasicConstraints;
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.asn1.x509.KeyPurposeId;
import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
/**
* Generates ephemeral (in-memory) PKCS12 keystores for guest/external signers. The generated
* certificate includes the signer's email address as a Subject Alternative Name (rfc822Name),
* providing industry-standard traceability. Guest keystores are never persisted to the database.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class GuestCertificateService {
private static final String KEYSTORE_ALIAS = "stirling-pdf-guest-cert";
private static final int VALIDITY_DAYS = 365;
private final ApplicationProperties applicationProperties;
static {
if (Security.getProvider("BC") == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
/**
* Generates a new in-memory PKCS12 keystore for a guest signer identified by email. The X.509
* certificate includes: - CN = sanitized email (no special chars) - O = Stirling-PDF Guest -
* SAN rfc822Name = raw email (industry-standard signer identity field)
*
* @param email the guest signer's email address
* @return an in-memory PKCS12 KeyStore ready for use in PDF signing
*/
public KeyStore generateGuestKeyStore(String email) throws Exception {
log.debug(
"Generating guest certificate for external signer (domain: {})",
email.contains("@") ? email.substring(email.indexOf('@')) : "<no-domain>");
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
keyPairGenerator.initialize(2048, new SecureRandom());
KeyPair keyPair = keyPairGenerator.generateKeyPair();
// Sanitize email for use in DN (remove chars that break X.500 parsing)
String safeCn = email.replaceAll("[,=+<>#;\"\\\\]", "_");
X500Name subject = new X500Name("CN=" + safeCn + ", O=Stirling-PDF Guest, C=US");
BigInteger serialNumber = new BigInteger(64, new SecureRandom());
Date notBefore = new Date();
Date notAfter =
new Date(notBefore.getTime() + ((long) VALIDITY_DAYS * 24 * 60 * 60 * 1000));
JcaX509v3CertificateBuilder certBuilder =
new JcaX509v3CertificateBuilder(
subject, serialNumber, notBefore, notAfter, subject, keyPair.getPublic());
JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils();
// End-entity certificate, not a CA
certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
// Key usage for PDF digital signatures
certBuilder.addExtension(
Extension.keyUsage,
true,
new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation));
// Extended key usage: emailProtection is the correct OID for email-identified signers
certBuilder.addExtension(
Extension.extendedKeyUsage,
false,
new ExtendedKeyUsage(KeyPurposeId.id_kp_emailProtection));
// Subject Key Identifier
certBuilder.addExtension(
Extension.subjectKeyIdentifier,
false,
extUtils.createSubjectKeyIdentifier(keyPair.getPublic()));
// Authority Key Identifier (self-signed)
certBuilder.addExtension(
Extension.authorityKeyIdentifier,
false,
extUtils.createAuthorityKeyIdentifier(keyPair.getPublic()));
// Subject Alternative Name: rfc822Name = email (industry-standard signer identity)
GeneralName emailSan = new GeneralName(GeneralName.rfc822Name, email);
GeneralNames subjectAltName = new GeneralNames(emailSan);
certBuilder.addExtension(Extension.subjectAlternativeName, false, subjectAltName);
// Sign the certificate
ContentSigner signer =
new JcaContentSignerBuilder("SHA256WithRSA")
.setProvider("BC")
.build(keyPair.getPrivate());
X509CertificateHolder certHolder = certBuilder.build(signer);
X509Certificate cert =
new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder);
// Build in-memory PKCS12 keystore
String password = generateGuestPassword(email);
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(null, null);
keyStore.setKeyEntry(
KEYSTORE_ALIAS,
keyPair.getPrivate(),
password.toCharArray(),
new Certificate[] {cert});
// Round-trip through serialization so the keystore is fully initialised
ByteArrayOutputStream baos = new ByteArrayOutputStream();
keyStore.store(baos, password.toCharArray());
KeyStore loaded = KeyStore.getInstance("PKCS12");
loaded.load(new java.io.ByteArrayInputStream(baos.toByteArray()), password.toCharArray());
log.debug("Guest certificate generated (CN={}, SAN=rfc822Name:[email])", safeCn);
return loaded;
}
/**
* Derives a deterministic password for a guest keystore. The password is a Base64-encoded
* HMAC-SHA256 of the email using the application's secret key, truncated to 32 characters. Two
* calls with the same email always return the same password.
*
* @param email the guest signer's email address
* @return a deterministic password string
*/
public String generateGuestPassword(String email) {
try {
String rawKey = applicationProperties.getAutomaticallyGenerated().getKey();
if (rawKey == null || rawKey.isBlank()) {
// Fallback: SHA-256 of email if no application key available
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(email.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash).substring(0, 32);
}
Mac hmac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec =
new SecretKeySpec(rawKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
hmac.init(keySpec);
byte[] macBytes = hmac.doFinal(email.getBytes(StandardCharsets.UTF_8));
String encoded = Base64.getEncoder().encodeToString(macBytes);
// Truncate to 32 chars; use URL-safe replacement to avoid keystore password issues
return encoded.replace("/", "_").replace("+", "-").substring(0, 32);
} catch (Exception e) {
throw new IllegalStateException("Failed to derive guest keystore password", e);
}
}
}
@@ -72,6 +72,9 @@ public class SigningFinalizationService {
@Autowired(required = false)
private final UserServerCertificateService userServerCertificateService;
@Autowired(required = false)
private final GuestCertificateService guestCertificateService;
// ===== PUBLIC API =====
/**
@@ -891,6 +894,28 @@ public class SigningFinalizationService {
"Failed to generate or retrieve user certificate: " + e.getMessage());
}
case "GUEST_CERT":
if (guestCertificateService == null) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Guest certificate service is not available");
}
if (participant.getEmail() == null || participant.getEmail().isBlank()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Guest certificate requires participant email address");
}
try {
return guestCertificateService.generateGuestKeyStore(participant.getEmail());
} catch (Exception e) {
log.error(
"Failed to generate guest certificate for {}: {}",
participant.getEmail(),
e.getMessage());
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Failed to generate guest certificate: " + e.getMessage());
}
default:
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Invalid certificate type: " + certType);
@@ -943,6 +968,12 @@ public class SigningFinalizationService {
}
}
if ("GUEST_CERT".equalsIgnoreCase(certType)
&& guestCertificateService != null
&& participant.getEmail() != null) {
return guestCertificateService.generateGuestPassword(participant.getEmail());
}
return submission.getPassword();
}
@@ -8,11 +8,13 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -63,6 +65,9 @@ public class WorkflowSessionService {
private final MetadataEncryptionService metadataEncryptionService;
private final CertificateSubmissionValidator certificateSubmissionValidator;
@Autowired(required = false)
private stirling.software.proprietary.security.service.EmailService emailService;
public void ensureSigningEnabled() {
if (!applicationProperties.getStorage().isEnabled()
|| !applicationProperties.getStorage().getSigning().isEnabled()) {
@@ -230,6 +235,62 @@ public class WorkflowSessionService {
session.addParticipant(participant);
participant = workflowParticipantRepository.save(participant);
// Send signing invitation email to external/guest participants
if (request.getEmail() != null
&& request.getUserId() == null
&& emailService != null
&& (request.isSendNotification())) {
try {
String signingUrl = buildSigningUrl(participant.getShareToken());
String ownerName =
session.getOwnerEmail() != null
? session.getOwnerEmail()
: "A Stirling PDF user";
String expiresAt =
participant.getExpiresAt() != null
? participant.getExpiresAt().toString()
: null;
emailService.sendSigningInvitationEmail(
participant.getEmail(),
ownerName,
session.getDocumentName(),
signingUrl,
expiresAt,
session.getMessage());
participant.setStatus(ParticipantStatus.NOTIFIED);
workflowParticipantRepository.save(participant);
log.info(
"Sent signing invitation to external participant (domain: {})",
emailDomain(participant.getEmail()));
} catch (Exception e) {
log.warn(
"Failed to send signing invitation email (domain: {}): {}",
emailDomain(participant.getEmail()),
e.getMessage());
}
}
}
}
/** Returns only the domain portion of an email for privacy-safe logging. */
private static String emailDomain(String email) {
if (email == null) return "<null>";
int at = email.indexOf('@');
return at >= 0 ? email.substring(at) : "<no-domain>";
}
/** Builds the guest signing URL for a share token using the current request context. */
private String buildSigningUrl(String shareToken) {
try {
return ServletUriComponentsBuilder.fromCurrentContextPath()
.path("/sign/")
.path(shareToken)
.toUriString();
} catch (Exception e) {
// Fallback if called outside of a request context (e.g. in tests)
log.debug("Could not determine base URL from request context, using relative path");
return "/sign/" + shareToken;
}
}
@@ -164,4 +164,110 @@ public class EmailServiceTest {
assertEquals("Invalid Addresses", e.getMessage());
}
}
@Test
void sendSigningInvitationEmail_sendsEmailWithDocumentNameInSubject()
throws MessagingException {
when(applicationProperties.getMail()).thenReturn(mailProperties);
when(mailProperties.getFrom()).thenReturn("no-reply@stirling-software.com");
MimeMessage mimeMessage = mock(MimeMessage.class);
when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
emailService.sendSigningInvitationEmail(
"guest@example.com",
"Alice Owner",
"Contract 2025.pdf",
"https://example.com/sign/abc-token",
"2025-12-31",
"Please review and sign");
verify(mailSender).send(mimeMessage);
}
@Test
void sendSigningInvitationEmail_throwsForBlankRecipient() {
assertThrows(
MessagingException.class,
() ->
emailService.sendSigningInvitationEmail(
"",
"Owner",
"Doc.pdf",
"https://example.com/sign/tok",
null,
null));
}
// ── HTML injection / URL guard tests ──────────────────────────────────
@Test
void sendSigningInvitationEmail_doesNotThrowForHtmlInDocumentName() throws MessagingException {
when(applicationProperties.getMail()).thenReturn(mailProperties);
when(mailProperties.getFrom()).thenReturn("no-reply@stirling.com");
MimeMessage mimeMessage = mock(MimeMessage.class);
when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
// Document name with injected HTML — must not cause an exception
emailService.sendSigningInvitationEmail(
"guest@example.com",
"Alice",
"<script>alert('xss')</script>Contract.pdf",
"https://example.com/sign/tok",
null,
null);
verify(mailSender).send(mimeMessage);
}
@Test
void sendSigningInvitationEmail_doesNotThrowForHtmlInMessage() throws MessagingException {
when(applicationProperties.getMail()).thenReturn(mailProperties);
when(mailProperties.getFrom()).thenReturn("no-reply@stirling.com");
MimeMessage mimeMessage = mock(MimeMessage.class);
when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
// Personal message with HTML injection
emailService.sendSigningInvitationEmail(
"guest@example.com",
"Bob <b>Owner</b>",
"Contract.pdf",
"https://example.com/sign/tok",
"2026-12-31",
"<img src=x onerror=alert(1)> Please sign");
verify(mailSender).send(mimeMessage);
}
@Test
void sendSigningInvitationEmail_doesNotThrowForJavascriptUrl() throws MessagingException {
when(applicationProperties.getMail()).thenReturn(mailProperties);
when(mailProperties.getFrom()).thenReturn("no-reply@stirling.com");
MimeMessage mimeMessage = mock(MimeMessage.class);
when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
// javascript: URL — must be replaced with '#', not thrown into email
emailService.sendSigningInvitationEmail(
"guest@example.com",
"Owner",
"Contract.pdf",
"javascript:alert(document.cookie)",
null,
null);
verify(mailSender).send(mimeMessage);
}
@Test
void sendSigningInvitationEmail_acceptsNullOptionalParams() throws MessagingException {
when(applicationProperties.getMail()).thenReturn(mailProperties);
when(mailProperties.getFrom()).thenReturn("no-reply@stirling.com");
MimeMessage mimeMessage = mock(MimeMessage.class);
when(mailSender.createMimeMessage()).thenReturn(mimeMessage);
// expiresAt and message are optional — null values must not cause NPE
emailService.sendSigningInvitationEmail(
"guest@example.com", null, null, "https://example.com/sign/tok", null, null);
verify(mailSender).send(mimeMessage);
}
}
@@ -0,0 +1,354 @@
package stirling.software.proprietary.workflow.controller;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import stirling.software.proprietary.workflow.dto.ParticipantResponse;
import stirling.software.proprietary.workflow.dto.SignatureSubmissionRequest;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.model.WorkflowStatus;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.service.MetadataEncryptionService;
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
import tools.jackson.databind.ObjectMapper;
@ExtendWith(MockitoExtension.class)
class WorkflowParticipantControllerTest {
@Mock private WorkflowSessionService workflowSessionService;
@Mock private WorkflowParticipantRepository participantRepository;
@Mock private MetadataEncryptionService metadataEncryptionService;
private ObjectMapper objectMapper;
private WorkflowParticipantController controller;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
controller =
new WorkflowParticipantController(
workflowSessionService,
participantRepository,
objectMapper,
metadataEncryptionService);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private WorkflowParticipant guestParticipant(String token, String email) {
WorkflowSession session = new WorkflowSession();
session.setStatus(WorkflowStatus.IN_PROGRESS);
session.setFinalized(false);
WorkflowParticipant participant = new WorkflowParticipant();
participant.setId(1L);
participant.setShareToken(token);
participant.setEmail(email);
participant.setUser(null); // guest — no registered user
participant.setStatus(ParticipantStatus.VIEWED);
participant.setWorkflowSession(session);
return participant;
}
// ── GUEST_CERT defaulting ─────────────────────────────────────────────
@Test
void submitSignature_guestNoCertType_defaultsToGuestCert() throws Exception {
String token = "test-token-123";
WorkflowParticipant participant = guestParticipant(token, "guest@example.com");
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
when(participantRepository.save(any())).thenReturn(participant);
// No encrypt() stub — GUEST_CERT skips encryption entirely (fix verified in separate test)
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
request.setParticipantToken(token);
// certType intentionally not set — should default to GUEST_CERT
MockHttpServletRequest httpRequest = new MockHttpServletRequest();
httpRequest.setRemoteAddr("192.168.1.1");
httpRequest.addHeader("User-Agent", "TestBrowser/1.0");
ResponseEntity<ParticipantResponse> response =
controller.submitSignature(request, httpRequest);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// Capture the saved participant and verify certType in metadata
ArgumentCaptor<WorkflowParticipant> captor =
ArgumentCaptor.forClass(WorkflowParticipant.class);
verify(participantRepository).save(captor.capture());
WorkflowParticipant saved = captor.getValue();
assertThat(saved.getStatus()).isEqualTo(ParticipantStatus.SIGNED);
Map<String, Object> metadata = saved.getParticipantMetadata();
assertThat(metadata).containsKey("certificateSubmission");
@SuppressWarnings("unchecked")
Map<String, Object> certSub = (Map<String, Object>) metadata.get("certificateSubmission");
assertThat(certSub.get("certType")).isEqualTo("GUEST_CERT");
}
@Test
void submitSignature_registeredUserNoCertType_doesNotDefaultToGuestCert() throws Exception {
String token = "registered-token";
WorkflowParticipant participant = guestParticipant(token, "user@example.com");
// Give participant a registered user
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("registered");
participant.setUser(user);
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
when(participantRepository.save(any())).thenReturn(participant);
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
request.setParticipantToken(token);
// certType intentionally not set
MockHttpServletRequest httpRequest = new MockHttpServletRequest();
ResponseEntity<ParticipantResponse> response =
controller.submitSignature(request, httpRequest);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
ArgumentCaptor<WorkflowParticipant> captor =
ArgumentCaptor.forClass(WorkflowParticipant.class);
verify(participantRepository).save(captor.capture());
WorkflowParticipant saved = captor.getValue();
Map<String, Object> metadata = saved.getParticipantMetadata();
// No certType defaulted — certificateSubmission should be absent
assertThat(metadata).doesNotContainKey("certificateSubmission");
}
// ── Audit trail ───────────────────────────────────────────────────────
@Test
void submitSignature_capturesAuditTrail() throws Exception {
String token = "audit-token";
WorkflowParticipant participant = guestParticipant(token, "audited@example.com");
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
when(participantRepository.save(any())).thenReturn(participant);
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
request.setParticipantToken(token);
MockHttpServletRequest httpRequest = new MockHttpServletRequest();
httpRequest.setRemoteAddr("10.0.0.1");
httpRequest.addHeader("User-Agent", "AuditBrowser/2.0");
controller.submitSignature(request, httpRequest);
ArgumentCaptor<WorkflowParticipant> captor =
ArgumentCaptor.forClass(WorkflowParticipant.class);
verify(participantRepository).save(captor.capture());
Map<String, Object> metadata = captor.getValue().getParticipantMetadata();
assertThat(metadata).containsKey("auditTrail");
@SuppressWarnings("unchecked")
Map<String, Object> audit = (Map<String, Object>) metadata.get("auditTrail");
assertThat(audit).containsKey("ipHash");
assertThat(audit).containsKey("userAgent");
assertThat(audit).containsKey("submittedAt");
assertThat(audit.get("email")).isEqualTo("audited@example.com");
// IP should be hashed, not stored raw
assertThat(audit.get("ipHash")).isNotEqualTo("10.0.0.1");
assertThat(audit.get("userAgent")).isEqualTo("AuditBrowser/2.0");
}
// ── GUEST_CERT password not stored ───────────────────────────────────
@Test
void submitSignature_guestCert_doesNotStoreEncryptedPassword() throws Exception {
String token = "no-pwd-token";
WorkflowParticipant participant = guestParticipant(token, "guest@example.com");
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
when(participantRepository.save(any())).thenReturn(participant);
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
request.setParticipantToken(token);
// certType not set → defaults to GUEST_CERT; no password submitted
MockHttpServletRequest httpRequest = new MockHttpServletRequest();
controller.submitSignature(request, httpRequest);
// metadataEncryptionService.encrypt() must NOT be called for GUEST_CERT
verify(metadataEncryptionService, never()).encrypt(any());
ArgumentCaptor<WorkflowParticipant> captor =
ArgumentCaptor.forClass(WorkflowParticipant.class);
verify(participantRepository).save(captor.capture());
@SuppressWarnings("unchecked")
Map<String, Object> certSub =
(Map<String, Object>)
captor.getValue().getParticipantMetadata().get("certificateSubmission");
assertThat(certSub.get("password")).isNull();
}
// ── Audit trail edge cases ────────────────────────────────────────────
@Test
void submitSignature_userAgentTruncatedAt500Chars() throws Exception {
String token = "ua-token";
WorkflowParticipant participant = guestParticipant(token, "ua@example.com");
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
when(participantRepository.save(any())).thenReturn(participant);
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
request.setParticipantToken(token);
MockHttpServletRequest httpRequest = new MockHttpServletRequest();
httpRequest.addHeader("User-Agent", "A".repeat(600));
controller.submitSignature(request, httpRequest);
ArgumentCaptor<WorkflowParticipant> captor =
ArgumentCaptor.forClass(WorkflowParticipant.class);
verify(participantRepository).save(captor.capture());
@SuppressWarnings("unchecked")
Map<String, Object> audit =
(Map<String, Object>) captor.getValue().getParticipantMetadata().get("auditTrail");
String storedUa = (String) audit.get("userAgent");
assertThat(storedUa).hasSize(500);
}
@Test
void submitSignature_nullIp_storesNullHash() throws Exception {
String token = "null-ip-token";
WorkflowParticipant participant = guestParticipant(token, "noip@example.com");
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
when(participantRepository.save(any())).thenReturn(participant);
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
request.setParticipantToken(token);
MockHttpServletRequest httpRequest = new MockHttpServletRequest();
httpRequest.setRemoteAddr(null);
controller.submitSignature(request, httpRequest);
ArgumentCaptor<WorkflowParticipant> captor =
ArgumentCaptor.forClass(WorkflowParticipant.class);
verify(participantRepository).save(captor.capture());
@SuppressWarnings("unchecked")
Map<String, Object> audit =
(Map<String, Object>) captor.getValue().getParticipantMetadata().get("auditTrail");
assertThat(audit.get("ipHash")).isNull();
}
// ── Token guards ──────────────────────────────────────────────────────
@Test
void submitSignature_expiredToken_returnsForbidden() {
String token = "expired-token";
WorkflowParticipant participant = guestParticipant(token, "expired@example.com");
participant.setExpiresAt(java.time.LocalDateTime.now().minusDays(1)); // expired
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
request.setParticipantToken(token);
org.springframework.web.server.ResponseStatusException ex =
org.junit.jupiter.api.Assertions.assertThrows(
org.springframework.web.server.ResponseStatusException.class,
() -> controller.submitSignature(request, new MockHttpServletRequest()));
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void submitSignature_alreadySigned_returnsBadRequest() {
String token = "signed-token";
WorkflowParticipant participant = guestParticipant(token, "done@example.com");
participant.setStatus(ParticipantStatus.SIGNED);
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
request.setParticipantToken(token);
org.springframework.web.server.ResponseStatusException ex =
org.junit.jupiter.api.Assertions.assertThrows(
org.springframework.web.server.ResponseStatusException.class,
() -> controller.submitSignature(request, new MockHttpServletRequest()));
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void submitSignature_alreadyDeclined_returnsBadRequest() {
String token = "declined-token";
WorkflowParticipant participant = guestParticipant(token, "declined@example.com");
participant.setStatus(ParticipantStatus.DECLINED);
when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant));
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
request.setParticipantToken(token);
org.springframework.web.server.ResponseStatusException ex =
org.junit.jupiter.api.Assertions.assertThrows(
org.springframework.web.server.ResponseStatusException.class,
() -> controller.submitSignature(request, new MockHttpServletRequest()));
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void submitSignature_unknownToken_returnsForbidden() {
when(participantRepository.findByShareToken("no-such-token")).thenReturn(Optional.empty());
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
request.setParticipantToken("no-such-token");
org.springframework.web.server.ResponseStatusException ex =
org.junit.jupiter.api.Assertions.assertThrows(
org.springframework.web.server.ResponseStatusException.class,
() -> controller.submitSignature(request, new MockHttpServletRequest()));
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void submitSignature_blankToken_returnsBadRequest() {
SignatureSubmissionRequest request = new SignatureSubmissionRequest();
request.setParticipantToken(" ");
org.springframework.web.server.ResponseStatusException ex =
org.junit.jupiter.api.Assertions.assertThrows(
org.springframework.web.server.ResponseStatusException.class,
() -> controller.submitSignature(request, new MockHttpServletRequest()));
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
}
@@ -0,0 +1,138 @@
package stirling.software.proprietary.workflow.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import java.security.KeyStore;
import java.security.cert.X509Certificate;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
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 stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.AutomaticallyGenerated;
@ExtendWith(MockitoExtension.class)
class GuestCertificateServiceTest {
@Mock private ApplicationProperties applicationProperties;
@Mock private AutomaticallyGenerated automaticallyGenerated;
private GuestCertificateService service;
@BeforeEach
void setUp() {
when(applicationProperties.getAutomaticallyGenerated()).thenReturn(automaticallyGenerated);
when(automaticallyGenerated.getKey()).thenReturn("test-secret-key-for-unit-tests");
service = new GuestCertificateService(applicationProperties);
}
@Test
void generateGuestKeyStore_createsValidPKCS12() throws Exception {
String email = "signer@example.com";
KeyStore keyStore = service.generateGuestKeyStore(email);
assertThat(keyStore).isNotNull();
assertThat(keyStore.getType()).isEqualToIgnoringCase("PKCS12");
assertThat(keyStore.aliases().hasMoreElements()).isTrue();
String alias = keyStore.aliases().nextElement();
assertThat(keyStore.isKeyEntry(alias)).isTrue();
assertThat(keyStore.getCertificate(alias)).isNotNull();
}
@Test
void generateGuestKeyStore_subjectContainsSanitizedEmail() throws Exception {
String email = "signer@example.com";
KeyStore keyStore = service.generateGuestKeyStore(email);
String alias = keyStore.aliases().nextElement();
X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
String subjectDn = cert.getSubjectX500Principal().getName();
assertThat(subjectDn).contains("signer@example.com");
assertThat(subjectDn).contains("Stirling-PDF Guest");
}
@Test
void generateGuestKeyStore_sanContainsEmailRfc822Name() throws Exception {
String email = "guest@test.org";
KeyStore keyStore = service.generateGuestKeyStore(email);
String alias = keyStore.aliases().nextElement();
X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
// Parse SAN extension via BouncyCastle
JcaX509CertificateHolder holder = new JcaX509CertificateHolder(cert);
org.bouncycastle.asn1.x509.GeneralNames sans =
org.bouncycastle.asn1.x509.GeneralNames.getInstance(
holder.getExtension(Extension.subjectAlternativeName).getParsedValue());
boolean foundEmail = false;
for (org.bouncycastle.asn1.x509.GeneralName gn : sans.getNames()) {
if (gn.getTagNo() == org.bouncycastle.asn1.x509.GeneralName.rfc822Name) {
String value = gn.getName().toString();
if (value.equals(email)) {
foundEmail = true;
}
}
}
assertThat(foundEmail)
.as("SAN should contain rfc822Name matching the signer's email")
.isTrue();
}
@Test
void generateGuestPassword_isDeterministic() {
String email = "signer@example.com";
String pw1 = service.generateGuestPassword(email);
String pw2 = service.generateGuestPassword(email);
assertThat(pw1).isEqualTo(pw2);
assertThat(pw1).hasSize(32);
}
@Test
void generateGuestPassword_differentEmails_differentPasswords() {
String pw1 = service.generateGuestPassword("alice@example.com");
String pw2 = service.generateGuestPassword("bob@example.com");
assertThat(pw1).isNotEqualTo(pw2);
}
@Test
void generateGuestPassword_fallbackWhenNoKey() {
// If no app key is configured, should fall back to SHA-256 of email
when(automaticallyGenerated.getKey()).thenReturn(null);
String pw = service.generateGuestPassword("fallback@example.com");
assertThat(pw).isNotNull();
assertThat(pw).hasSize(32);
}
@Test
void generateGuestKeyStore_certIsValidForSigning() throws Exception {
String email = "signer@example.com";
KeyStore keyStore = service.generateGuestKeyStore(email);
String alias = keyStore.aliases().nextElement();
X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
// Certificate must be currently valid
cert.checkValidity();
// Key usage must include digitalSignature (bit 0) and nonRepudiation (bit 1)
boolean[] keyUsage = cert.getKeyUsage();
assertThat(keyUsage).isNotNull();
assertThat(keyUsage[0]).as("digitalSignature key usage").isTrue();
assertThat(keyUsage[1]).as("nonRepudiation key usage").isTrue();
}
}
+270
View File
@@ -0,0 +1,270 @@
# Guest Signing — Test Plan
## Automated coverage summary
The following scenarios are already covered by automated tests and **do not need manual testing**:
| Area | Coverage | Test file |
|------|----------|-----------|
| Guest certificate generation (PKCS12, SAN, key usage) | Unit | `GuestCertificateServiceTest.java` |
| EKU is `emailProtection` (not `codeSigning`) | Unit | `GuestCertificateServiceTest.java` |
| Password determinism / uniqueness | Unit | `GuestCertificateServiceTest.java` |
| GUEST_CERT defaulted for external participants | Unit | `WorkflowParticipantControllerTest.java` |
| Registered users not defaulted to GUEST_CERT | Unit | `WorkflowParticipantControllerTest.java` |
| Audit trail: IP hashed, UA stored, timestamp present | Unit | `WorkflowParticipantControllerTest.java` |
| Audit trail: User-agent truncated at 500 chars | Unit | `WorkflowParticipantControllerTest.java` |
| Audit trail: null IP stored as null (not exception) | Unit | `WorkflowParticipantControllerTest.java` |
| GUEST_CERT password not stored in metadata | Unit | `WorkflowParticipantControllerTest.java` |
| Expired token → 403 | Unit | `WorkflowParticipantControllerTest.java` |
| Already-SIGNED → 400 | Unit | `WorkflowParticipantControllerTest.java` |
| Already-DECLINED → 400 | Unit | `WorkflowParticipantControllerTest.java` |
| Unknown token → 403 | Unit | `WorkflowParticipantControllerTest.java` |
| Blank token → 400 | Unit | `WorkflowParticipantControllerTest.java` |
| HTML injection in email: docName, ownerName, message | Unit | `EmailServiceTest.java` |
| `javascript:` URL replaced with `#` in email | Unit | `EmailServiceTest.java` |
| Email sends for null optional params | Unit | `EmailServiceTest.java` |
| `/sign/:token` — loading spinner shown | E2E | `GuestSigningE2E.spec.ts` |
| `/sign/:token` — expired (403) page | E2E | `GuestSigningE2E.spec.ts` |
| `/sign/:token` — already-SIGNED state on load | E2E | `GuestSigningE2E.spec.ts` |
| `/sign/:token` — already-DECLINED state on load | E2E | `GuestSigningE2E.spec.ts` |
| `/sign/:token` — 500 error page | E2E | `GuestSigningE2E.spec.ts` |
| Signing form renders with doc name, owner, message | E2E | `GuestSigningE2E.spec.ts` |
| PDF iframe present with correct src | E2E | `GuestSigningE2E.spec.ts` |
| Auto-cert selected by default + info alert | E2E | `GuestSigningE2E.spec.ts` |
| Switching to P12 shows file + password inputs | E2E | `GuestSigningE2E.spec.ts` |
| Switching back to auto-cert hides P12 inputs | E2E | `GuestSigningE2E.spec.ts` |
| Submit → success page; FormData contains GUEST_CERT + token | E2E | `GuestSigningE2E.spec.ts` |
| Submit failure shows error with server message | E2E | `GuestSigningE2E.spec.ts` |
| Submit button disabled while in-flight | E2E | `GuestSigningE2E.spec.ts` |
| Decline → opens modal with correct title + body | E2E | `GuestSigningE2E.spec.ts` |
| Decline → Cancel closes modal, form still visible | E2E | `GuestSigningE2E.spec.ts` |
| Decline → Confirm transitions to declined state | E2E | `GuestSigningE2E.spec.ts` |
| SelectParticipantsStep — registered/external tabs | Unit | `SelectParticipantsStep.test.tsx` |
| SelectParticipantsStep — email validation + duplicates | Unit | `SelectParticipantsStep.test.tsx` |
| SelectParticipantsStep — add by button / Enter key | Unit | `SelectParticipantsStep.test.tsx` |
| SelectParticipantsStep — remove participant | Unit | `SelectParticipantsStep.test.tsx` |
| SelectParticipantsStep — Continue disabled when empty | Unit | `SelectParticipantsStep.test.tsx` |
| GuestSignPage — all page states (loading/expired/signed/etc.) | Unit | `GuestSignPage.test.tsx` |
| GuestSignPage — submit calls correct endpoint | Unit | `GuestSignPage.test.tsx` |
---
## Running the automated tests
```bash
# Backend unit tests (from repo root)
./gradlew :app:proprietary:test \
--tests "stirling.software.proprietary.workflow.service.GuestCertificateServiceTest" \
--tests "stirling.software.proprietary.workflow.controller.WorkflowParticipantControllerTest" \
--tests "stirling.software.proprietary.security.service.EmailServiceTest"
# Frontend unit tests
cd frontend && npm test -- --run \
src/core/routes/GuestSignPage.test.tsx \
src/core/components/shared/signing/steps/SelectParticipantsStep.test.tsx
# Frontend E2E (requires dev server running)
cd frontend && npx playwright test src/core/tests/guestSigning/GuestSigningE2E.spec.ts
```
---
## Residual manual test plan
These scenarios require a running system with real SMTP, storage, and a PDF.
### Prerequisites
- Stirling PDF running with the config below applied
- A real SMTP server (or MailHog locally) receiving email
- A test PDF document
- A valid .p12 certificate for the P12 upload test
---
### MT-1 — Owner invites an external guest (email delivery)
**Steps:**
1. Log in as a registered user
2. Open a PDF and start a signing session
3. In the participants step, switch to the **External (by email)** tab
4. Enter a real email address and click Add
5. Continue through the session creation flow
6. Check the inbox of the guest email address
**Expected:**
- Email received with subject `Please sign: <document name>`
- Email shows owner's email, document name, optional message
- "Review and Sign Document" button links to `https://<your-host>/sign/<token>`
- No raw HTML visible in the email body (escaping check)
- No raw IP address visible anywhere in the email
---
### MT-2 — Guest signs with auto-generated certificate (happy path)
**Steps:**
1. Open the signing link from MT-1
2. Verify the loading spinner appears briefly
3. Verify document name and owner info are displayed
4. Verify PDF is visible in the embedded preview
5. Verify **"Use auto-generated certificate (recommended)"** is selected by default
6. Draw a signature in the canvas
7. Click **Submit Signature**
**Expected:**
- Success page: "Your signature has been submitted successfully."
- PDF in session now has a digital signature
- Signature certificate CN includes the guest's email (sanitized)
- Certificate SAN contains `rfc822Name=<email>` — verify in Adobe Acrobat / PDF viewer signature panel
- Participant status in admin panel changed to SIGNED
- Audit trail in database: `ipHash` is a Base64 SHA-256 (not raw IP), `userAgent` matches browser, `submittedAt` timestamp is recent
---
### MT-3 — Guest signs with their own P12 certificate
**Steps:**
1. Open a fresh signing link (new session)
2. Select **"Upload my own certificate"**
3. Upload a valid `.p12` file
4. Enter the certificate password
5. Draw a signature and submit
**Expected:**
- Success page shown
- PDF signed with the uploaded certificate (not a Stirling-generated one)
- Signature visible in PDF viewer with details from the P12
---
### MT-4 — Guest uses an expired signing link
**Steps:**
1. Set a participant's `expires_at` to the past in the database (or wait for expiry)
2. Navigate to the signing link
**Expected:**
- "This signing link has expired." page
- Contact message displayed
- No way to proceed to the signing form
---
### MT-5 — Guest declines and owner is notified
**Steps:**
1. Open a valid signing link
2. Click **Decline**
3. Confirm in the modal
**Expected:**
- "You have declined this signing request." page
- Participant status in session changes to DECLINED
- Session owner sees participant marked as declined in the session view
---
### MT-6 — Duplicate submission prevented
**Steps:**
1. Complete signing (MT-2)
2. Navigate back to the same `/sign/:token` URL
**Expected:**
- Page immediately shows the already-signed state ("Your signature has been submitted successfully.")
- No signing form is shown
---
### MT-7 — Email content security
**Steps:**
1. Create a session with a document named: `<img src=x onerror=alert(1)>.pdf`
2. Send invitation to a guest
**Expected:**
- Email subject: `Please sign: <img src=x onerror=alert(1)>.pdf` (angle brackets visible as text, not rendered)
- Email body: document name appears as text `&lt;img src=x onerror=alert(1)&gt;.pdf`
- No JavaScript executes in the email client
---
### MT-8 — Mobile / responsive layout
**Steps:**
1. Open the signing link on a mobile device or via browser DevTools responsive mode (375px width)
**Expected:**
- Page is usable at mobile width
- PDF preview, certificate chooser, and signature canvas are all visible and usable
- Buttons are appropriately sized for touch
---
### MT-9 — No-email configuration (mail disabled)
**Steps:**
1. Disable `mail.enabled` in config
2. Create a session with an external guest participant
**Expected:**
- Session creation succeeds
- No email is sent (no error in logs)
- Participant status remains `PENDING` (not `NOTIFIED`)
- Operator can still manually share the signing URL from the session detail view
---
## Config required
Add to your `settings.yml` (or environment variables):
```yaml
storage:
enabled: true # required for group signing
signing:
enabled: true # enables the signing session feature
mail:
enabled: true
host: smtp.example.com # your SMTP server
port: 587
username: stirling@example.com
password: your-smtp-password
from: noreply@stirling-pdf.example.com
startTlsEnable: true # recommended; use sslEnable: true + port 465 for implicit TLS
```
### Local development with MailHog
MailHog provides a local SMTP server with a web UI at `http://localhost:8025`.
```bash
# Start MailHog (Docker)
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
# settings.yml for local dev
mail:
enabled: true
host: localhost
port: 1025
username: ""
password: ""
from: test@stirling-pdf.local
startTlsEnable: false
```
### Environment variable equivalents
```
STIRLING_STORAGE_ENABLED=true
STIRLING_STORAGE_SIGNING_ENABLED=true
MAIL_ENABLED=true
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=stirling@example.com
MAIL_PASSWORD=secret
MAIL_FROM=noreply@stirling-pdf.example.com
MAIL_STARTTLS_ENABLE=true
```
+3 -3
View File
@@ -19,7 +19,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:5173',
baseURL: 'http://localhost:5174',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
@@ -68,8 +68,8 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
command: 'npm run dev -- --port 5174',
url: 'http://localhost:5174',
reuseExistingServer: !process.env.CI,
},
});
+40 -1
View File
@@ -3750,10 +3750,20 @@ selectedFile = "Selected document"
title = "Select Document"
[groupSigning.steps.selectParticipants]
addEmail = "Add"
badgeExternal = "Guest"
badgeRegistered = "User"
continue = "Continue to Signature Settings"
count = "{{count}} participant(s) selected"
count = "{{count}} participant(s)"
duplicateEmail = "This email has already been added"
emailInviteNote = "External participants will receive an email invitation with a signing link."
emailPlaceholder = "signer@example.com"
externalNote = "An invitation email will be sent with a signing link. No account required."
invalidEmail = "Please enter a valid email address"
label = "Select participants"
placeholder = "Choose participants to sign..."
tabExternal = "External (by email)"
tabRegistered = "Registered Users"
title = "Choose Participants"
[getPdfInfo]
@@ -3868,6 +3878,35 @@ message = "Create a free account to save your work, access more features, and su
signUp = "Sign Up Free"
title = "You're using Stirling PDF as a guest!"
[guestSigning]
certAutoNote = "A certificate will be generated using your email address for traceability."
certChoiceAuto = "Use auto-generated certificate (recommended)"
certChoiceUpload = "Upload my own certificate"
certFileLabel = "Certificate file (.p12 / .pfx)"
certFilePlaceholder = "Select .p12 or .pfx file"
certFileRequired = "Please select a certificate file."
certPasswordLabel = "Certificate password"
certPasswordPlaceholder = "Enter certificate password"
certSectionTitle = "Signing Certificate"
declineButton = "Decline"
declineConfirmBody = "Are you sure you want to decline? This action cannot be undone."
declineConfirmTitle = "Decline signing?"
declinedNote = "The document owner has been notified. You may close this window."
declineSuccess = "You have declined this signing request."
documentPreview = "Document"
dueDate = "Due {{date}}"
errorTitle = "Something went wrong"
expiredNote = "Please contact the document owner for a new link."
expiredToken = "This signing link has expired."
loadingSession = "Loading signing session..."
pageTitle = "Sign Document"
requestedBy = "Requested by {{owner}}"
signatureTitle = "Your Signature"
signedNote = "The document owner has been notified. You may close this window."
submitButton = "Submit Signature"
submitError = "Failed to submit signature."
submitSuccess = "Your signature has been submitted successfully."
[home]
alphabetical = "Alphabetical"
desc = "Your locally hosted one-stop-shop for all your PDF needs."
@@ -3,7 +3,10 @@ import { Stack, Text, Group, Badge } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import CheckIcon from '@mui/icons-material/Check';
import { SelectDocumentStep } from '@app/components/shared/signing/steps/SelectDocumentStep';
import { SelectParticipantsStep } from '@app/components/shared/signing/steps/SelectParticipantsStep';
import {
SelectParticipantsStep,
type Participant,
} from '@app/components/shared/signing/steps/SelectParticipantsStep';
import { ConfigureSignatureDefaultsStep } from '@app/components/shared/signing/steps/ConfigureSignatureDefaultsStep';
import { ReviewSessionStep } from '@app/components/shared/signing/steps/ReviewSessionStep';
import { useGroupSigningTips } from '@app/components/tooltips/useGroupSigningTips';
@@ -13,8 +16,8 @@ import type { FileState } from '@app/types/file';
interface CreateSessionFlowProps {
selectedFiles: FileState[];
selectedUserIds: number[];
onSelectedUserIdsChange: (userIds: number[]) => void;
participants: Participant[];
onParticipantsChange: (participants: Participant[]) => void;
dueDate: string;
onDueDateChange: (date: string) => void;
creating: boolean;
@@ -102,8 +105,8 @@ const StepWrapper: React.FC<StepWrapperProps> = ({
export const CreateSessionFlow: React.FC<CreateSessionFlowProps> = ({
selectedFiles,
selectedUserIds,
onSelectedUserIdsChange,
participants,
onParticipantsChange,
dueDate,
onDueDateChange,
creating,
@@ -186,8 +189,8 @@ export const CreateSessionFlow: React.FC<CreateSessionFlowProps> = ({
isCompleted={currentStep > 2}
>
<SelectParticipantsStep
selectedUserIds={selectedUserIds}
onSelectedUserIdsChange={onSelectedUserIdsChange}
participants={participants}
onParticipantsChange={onParticipantsChange}
onBack={() => setCurrentStep(1)}
onNext={() => setCurrentStep(3)}
disabled={creating}
@@ -220,7 +223,7 @@ export const CreateSessionFlow: React.FC<CreateSessionFlowProps> = ({
{selectedFile && (
<ReviewSessionStep
selectedFile={selectedFile}
participantCount={selectedUserIds.length}
participantCount={participants.length}
signatureSettings={signatureSettings}
dueDate={dueDate}
onDueDateChange={onDueDateChange}
@@ -1,12 +1,17 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Text, Switch } from '@mantine/core';
import { ActionIcon, Badge, Group, Switch, Tabs, Text, TextInput } from '@mantine/core';
import CloseIcon from '@mui/icons-material/Close';
import PersonIcon from '@mui/icons-material/Person';
import EmailIcon from '@mui/icons-material/Email';
import UserSelector from '@app/components/shared/UserSelector';
import type { Participant } from '@app/components/shared/signing/steps/SelectParticipantsStep';
import type { FileState } from '@app/types/file';
interface CreateSessionPanelProps {
selectedFiles: FileState[];
selectedUserIds: number[];
onSelectedUserIdsChange: (userIds: number[]) => void;
participants: Participant[];
onParticipantsChange: (participants: Participant[]) => void;
dueDate: string;
onDueDateChange: (date: string) => void;
creating: boolean;
@@ -14,10 +19,12 @@ interface CreateSessionPanelProps {
onIncludeSummaryPageChange: (value: boolean) => void;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const CreateSessionPanel = ({
selectedFiles,
selectedUserIds,
onSelectedUserIdsChange,
participants,
onParticipantsChange,
dueDate,
onDueDateChange,
creating,
@@ -25,9 +32,40 @@ const CreateSessionPanel = ({
onIncludeSummaryPageChange,
}: CreateSessionPanelProps) => {
const { t } = useTranslation();
const [emailInput, setEmailInput] = useState('');
const hasValidFile = selectedFiles.length === 1;
const registeredUserIds = participants
.filter((p) => p.type === 'registered' && p.userId != null)
.map((p) => p.userId as number);
function handleRegisteredChange(userIds: number[]) {
const external = participants.filter((p) => p.type === 'external');
onParticipantsChange([
...userIds.map((id): Participant => ({ type: 'registered', userId: id })),
...external,
]);
}
function handleAddEmail() {
const trimmed = emailInput.trim();
if (!EMAIL_RE.test(trimmed)) return;
if (participants.some((p) => p.email === trimmed)) return;
onParticipantsChange([...participants, { type: 'external', email: trimmed }]);
setEmailInput('');
}
function handleRemove(participant: Participant) {
onParticipantsChange(
participants.filter((p) =>
participant.type === 'registered'
? !(p.type === 'registered' && p.userId === participant.userId)
: !(p.type === 'external' && p.email === participant.email)
)
);
}
return (
<div className="quick-access-popout__panel">
{!hasValidFile ? (
@@ -46,14 +84,89 @@ const CreateSessionPanel = ({
</div>
<div className="quick-access-popout__section">
<div className="quick-access-popout__label">{t('quickAccess.selectUsers', 'Select users to sign')}</div>
<UserSelector
value={selectedUserIds}
onChange={onSelectedUserIdsChange}
size="xs"
placeholder={t('quickAccess.selectUsersPlaceholder', 'Choose participants...')}
disabled={creating}
/>
<div className="quick-access-popout__label">{t('quickAccess.selectUsers', 'Select participants to sign')}</div>
<Tabs defaultValue="registered" style={{ marginBottom: 8 }}>
<Tabs.List>
<Tabs.Tab value="registered" leftSection={<PersonIcon sx={{ fontSize: 13 }} />} style={{ fontSize: 11, padding: '4px 8px' }}>
{t('groupSigning.steps.selectParticipants.tabRegistered', 'Registered')}
</Tabs.Tab>
<Tabs.Tab value="external" leftSection={<EmailIcon sx={{ fontSize: 13 }} />} style={{ fontSize: 11, padding: '4px 8px' }}>
{t('groupSigning.steps.selectParticipants.tabExternal', 'External')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="registered" pt="xs">
<UserSelector
value={registeredUserIds}
onChange={handleRegisteredChange}
size="xs"
placeholder={t('quickAccess.selectUsersPlaceholder', 'Choose participants...')}
disabled={creating}
/>
</Tabs.Panel>
<Tabs.Panel value="external" pt="xs">
<Group gap="xs" align="center">
<TextInput
style={{ flex: 1 }}
size="xs"
placeholder="signer@example.com"
value={emailInput}
onChange={(e) => setEmailInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddEmail();
}
}}
disabled={creating}
/>
<button
type="button"
className="quick-access-popout__secondary"
onClick={handleAddEmail}
disabled={creating || !EMAIL_RE.test(emailInput.trim())}
style={{ padding: '4px 8px', fontSize: 12 }}
>
{t('groupSigning.steps.selectParticipants.addEmail', 'Add')}
</button>
</Group>
</Tabs.Panel>
</Tabs>
{/* Combined participant list */}
{participants.length > 0 && (
<div style={{ marginTop: 4 }}>
{participants.map((p, i) => (
<Group key={i} gap={4} justify="space-between" wrap="nowrap" style={{ marginBottom: 2 }}>
<Group gap={4} style={{ flex: 1, minWidth: 0 }}>
<Badge
variant="light"
color={p.type === 'external' ? 'orange' : 'blue'}
size="xs"
>
{p.type === 'external'
? t('groupSigning.steps.selectParticipants.badgeExternal', 'Guest')
: t('groupSigning.steps.selectParticipants.badgeRegistered', 'User')}
</Badge>
<Text size="xs" style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{p.type === 'external' ? p.email : `#${p.userId}`}
</Text>
</Group>
<ActionIcon
variant="subtle"
color="red"
size="xs"
onClick={() => handleRemove(p)}
disabled={creating}
>
<CloseIcon sx={{ fontSize: 12 }} />
</ActionIcon>
</Group>
))}
</div>
)}
</div>
<div className="quick-access-popout__section">
@@ -0,0 +1,82 @@
import { Alert, FileInput, PasswordInput, Radio, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
export type GuestCertType = 'GUEST_CERT' | 'P12';
interface GuestCertificateChooserProps {
value: GuestCertType;
onChange: (certType: GuestCertType) => void;
onFileChange: (file: File | null) => void;
onPasswordChange: (password: string) => void;
p12File: File | null;
password: string;
}
export const GuestCertificateChooser: React.FC<GuestCertificateChooserProps> = ({
value,
onChange,
onFileChange,
onPasswordChange,
p12File,
password,
}) => {
const { t } = useTranslation();
return (
<Stack gap="sm">
<Text fw={500} size="sm">
{t('guestSigning.certSectionTitle', 'Signing Certificate')}
</Text>
<Radio.Group
value={value}
onChange={(val) => {
onChange(val as GuestCertType);
// Clear uploaded file when switching back to auto
if (val === 'GUEST_CERT') {
onFileChange(null);
onPasswordChange('');
}
}}
>
<Stack gap="xs">
<Radio
value="GUEST_CERT"
label={t('guestSigning.certChoiceAuto', 'Use auto-generated certificate (recommended)')}
/>
<Radio
value="P12"
label={t('guestSigning.certChoiceUpload', 'Upload my own certificate')}
/>
</Stack>
</Radio.Group>
{value === 'GUEST_CERT' && (
<Alert color="blue" variant="light">
{t(
'guestSigning.certAutoNote',
'A certificate will be generated using your email address for traceability.'
)}
</Alert>
)}
{value === 'P12' && (
<Stack gap="xs">
<FileInput
label={t('guestSigning.certFileLabel', 'Certificate file (.p12 / .pfx)')}
placeholder={t('guestSigning.certFilePlaceholder', 'Select .p12 or .pfx file')}
accept=".p12,.pfx"
value={p12File}
onChange={onFileChange}
/>
<PasswordInput
label={t('guestSigning.certPasswordLabel', 'Certificate password')}
placeholder={t('guestSigning.certPasswordPlaceholder', 'Enter certificate password')}
value={password}
onChange={(e) => onPasswordChange(e.currentTarget.value)}
/>
</Stack>
)}
</Stack>
);
};
@@ -82,7 +82,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
const [loading, setLoading] = useState(false);
// Create form state
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
const [participants, setParticipants] = useState<import('@app/components/shared/signing/steps/SelectParticipantsStep').Participant[]>([]);
const [dueDate, setDueDate] = useState('');
const [creating, setCreating] = useState(false);
const [includeSummaryPage, setIncludeSummaryPage] = useState(false);
@@ -309,7 +309,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
// Create session handler
const handleCreateSession = useCallback(async () => {
if (selectedUserIds.length === 0 || selectedFiles.length !== 1) return;
if (participants.length === 0 || selectedFiles.length !== 1) return;
setCreating(true);
try {
@@ -321,8 +321,13 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
formData.append('file', stirlingFile, selectedFile.name);
formData.append('workflowType', 'SIGNING');
formData.append('documentName', selectedFile.name);
selectedUserIds.forEach((userId, index) => {
formData.append(`participantUserIds[${index}]`, userId.toString());
const registeredIds = participants.filter((p) => p.type === 'registered' && p.userId != null);
const externalEmails = participants.filter((p) => p.type === 'external' && p.email != null);
registeredIds.forEach((p, index) => {
formData.append(`participantUserIds[${index}]`, (p.userId as number).toString());
});
externalEmails.forEach((p, index) => {
formData.append(`participantEmails[${index}]`, p.email as string);
});
if (dueDate) formData.append('dueDate', dueDate);
formData.append('notifyOnCreate', 'true');
@@ -364,7 +369,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
} finally {
setCreating(false);
}
}, [selectedUserIds, dueDate, selectedFiles, fetchData, t, includeSummaryPage]);
}, [participants, dueDate, selectedFiles, fetchData, t, includeSummaryPage]);
// Handle clicking a sign request
const handleSignRequestClick = useCallback(async (request: SignRequestSummary) => {
@@ -465,8 +470,8 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
pdfFile,
onFinalize: () => handleFinalize(session.sessionId, session.documentName),
onLoadSignedPdf: () => handleLoadSignedPdf(session.sessionId, session.documentName),
onAddParticipants: (userIds: number[], defaultReason?: string) =>
handleAddParticipants(session.sessionId, userIds, defaultReason),
onAddParticipants: (userIds: number[], emails: string[], defaultReason?: string) =>
handleAddParticipants(session.sessionId, userIds, emails, defaultReason),
onRemoveParticipant: (participantId: number) => handleRemoveParticipant(session.sessionId, participantId),
onDelete: () => handleDeleteSession(session.sessionId),
onBack: () => {
@@ -566,12 +571,19 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
navigationActions.setWorkbench('viewer');
};
const handleAddParticipants = async (sessionId: string, userIds: number[], defaultReason?: string) => {
const requests = userIds.map(userId => ({
userId,
defaultReason: defaultReason || undefined,
sendNotification: true,
}));
const handleAddParticipants = async (sessionId: string, userIds: number[], emails: string[], defaultReason?: string) => {
const requests = [
...userIds.map(userId => ({
userId,
defaultReason: defaultReason || undefined,
sendNotification: true,
})),
...emails.map(email => ({
email,
defaultReason: defaultReason || undefined,
sendNotification: true,
})),
];
await apiClient.post(`/api/v1/security/cert-sign/sessions/${sessionId}/participants`, requests);
await handleRefreshSession(sessionId);
};
@@ -750,8 +762,8 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
{showCreatePanel ? (
<CreateSessionPanel
selectedFiles={selectedFiles}
selectedUserIds={selectedUserIds}
onSelectedUserIdsChange={setSelectedUserIds}
participants={participants}
onParticipantsChange={setParticipants}
dueDate={dueDate}
onDueDateChange={setDueDate}
creating={creating}
@@ -793,7 +805,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }:
type="button"
className="quick-access-popout__primary"
onClick={handleCreateSession}
disabled={selectedFiles.length !== 1 || selectedUserIds.length === 0 || creating}
disabled={selectedFiles.length !== 1 || participants.length === 0 || creating}
>
<LocalIcon icon="send-rounded" width="1rem" height="1rem" />
{creating
@@ -0,0 +1,317 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MantineProvider } from '@mantine/core';
// ── i18n ────────────────────────────────────────────────────────────────────
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') return fallback;
if (typeof fallback === 'object' && fallback !== null) {
// Handle interpolation like {{count}}
const defaultValue = (fallback as Record<string, unknown>).defaultValue;
if (typeof defaultValue === 'string') return defaultValue;
}
return key;
},
}),
initReactI18next: { type: '3rdParty', init: vi.fn() },
}));
// ── UserSelector stub — renders a simple multi-select ───────────────────────
vi.mock('@app/components/shared/UserSelector', () => ({
default: ({
value,
onChange,
}: {
value: number[];
onChange: (ids: number[]) => void;
}) => (
<div data-testid="user-selector">
<button onClick={() => onChange([...value, 42])}>Add User 42</button>
<button onClick={() => onChange([])}>Clear Users</button>
<span data-testid="user-count">{value.length}</span>
</div>
),
}));
// ── MUI icons stub (to avoid SVG transform issues in jsdom) ─────────────────
vi.mock('@mui/icons-material/ArrowBack', () => ({ default: () => null }));
vi.mock('@mui/icons-material/Close', () => ({ default: () => null }));
vi.mock('@mui/icons-material/Person', () => ({ default: () => null }));
vi.mock('@mui/icons-material/Email', () => ({ default: () => null }));
// ── Component under test ────────────────────────────────────────────────────
import { SelectParticipantsStep, Participant } from './SelectParticipantsStep';
// ── Test wrapper ────────────────────────────────────────────────────────────
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
// ── Default props ───────────────────────────────────────────────────────────
function makeProps(overrides?: Partial<{
participants: Participant[];
onParticipantsChange: (p: Participant[]) => void;
onBack: () => void;
onNext: () => void;
}>) {
return {
participants: [] as Participant[],
onParticipantsChange: vi.fn(),
onBack: vi.fn(),
onNext: vi.fn(),
...overrides,
};
}
// ── Tests ───────────────────────────────────────────────────────────────────
describe('SelectParticipantsStep', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the registered users tab by default', () => {
render(
<TestWrapper>
<SelectParticipantsStep {...makeProps()} />
</TestWrapper>
);
expect(screen.getByText('Registered Users')).toBeInTheDocument();
expect(screen.getByTestId('user-selector')).toBeInTheDocument();
});
it('renders the external tab', () => {
render(
<TestWrapper>
<SelectParticipantsStep {...makeProps()} />
</TestWrapper>
);
expect(screen.getByText('External (by email)')).toBeInTheDocument();
});
it('Continue button is disabled when participants list is empty', () => {
render(
<TestWrapper>
<SelectParticipantsStep {...makeProps({ participants: [] })} />
</TestWrapper>
);
const continueBtn = screen.getByText('Continue to Signature Settings');
expect(continueBtn.closest('button')).toBeDisabled();
});
it('Continue button is enabled when participants list has entries', () => {
render(
<TestWrapper>
<SelectParticipantsStep
{...makeProps({
participants: [{ type: 'external', email: 'alice@example.com' }],
})}
/>
</TestWrapper>
);
const continueBtn = screen.getByText('Continue to Signature Settings');
expect(continueBtn.closest('button')).not.toBeDisabled();
});
it('adds external email to participant list on Add button click', async () => {
const user = userEvent.setup();
const onParticipantsChange = vi.fn();
render(
<TestWrapper>
<SelectParticipantsStep
{...makeProps({ onParticipantsChange })}
/>
</TestWrapper>
);
// Switch to external tab
await user.click(screen.getByText('External (by email)'));
const input = screen.getByPlaceholderText('signer@example.com');
await user.type(input, 'new@example.com');
await user.click(screen.getByText('Add'));
expect(onParticipantsChange).toHaveBeenCalledWith([
{ type: 'external', email: 'new@example.com' },
]);
});
it('adds external email on Enter keypress', async () => {
const user = userEvent.setup();
const onParticipantsChange = vi.fn();
render(
<TestWrapper>
<SelectParticipantsStep {...makeProps({ onParticipantsChange })} />
</TestWrapper>
);
await user.click(screen.getByText('External (by email)'));
const input = screen.getByPlaceholderText('signer@example.com');
await user.type(input, 'enter@example.com{Enter}');
expect(onParticipantsChange).toHaveBeenCalledWith([
{ type: 'external', email: 'enter@example.com' },
]);
});
it('shows validation error for invalid email format', async () => {
const user = userEvent.setup();
render(
<TestWrapper>
<SelectParticipantsStep {...makeProps()} />
</TestWrapper>
);
await user.click(screen.getByText('External (by email)'));
const input = screen.getByPlaceholderText('signer@example.com');
await user.type(input, 'not-an-email');
await user.click(screen.getByText('Add'));
expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument();
});
it('shows duplicate email error when same email is added twice', async () => {
const user = userEvent.setup();
render(
<TestWrapper>
<SelectParticipantsStep
{...makeProps({
participants: [{ type: 'external', email: 'existing@example.com' }],
})}
/>
</TestWrapper>
);
await user.click(screen.getByText('External (by email)'));
const input = screen.getByPlaceholderText('signer@example.com');
await user.type(input, 'existing@example.com');
await user.click(screen.getByText('Add'));
expect(screen.getByText('This email has already been added')).toBeInTheDocument();
});
it('displays external participants as Guest badges', () => {
render(
<TestWrapper>
<SelectParticipantsStep
{...makeProps({
participants: [{ type: 'external', email: 'guest@example.com' }],
})}
/>
</TestWrapper>
);
expect(screen.getByText('guest@example.com')).toBeInTheDocument();
expect(screen.getByText('Guest')).toBeInTheDocument();
});
it('removes participant when close button is clicked', () => {
const onParticipantsChange = vi.fn();
render(
<TestWrapper>
<SelectParticipantsStep
{...makeProps({
participants: [{ type: 'external', email: 'remove@example.com' }],
onParticipantsChange,
})}
/>
</TestWrapper>
);
// The remove action icon
const removeButtons = screen.getAllByRole('button');
const removeBtn = removeButtons.find(
(btn) => btn.getAttribute('data-variant') === 'subtle'
);
expect(removeBtn).toBeDefined();
fireEvent.click(removeBtn!);
expect(onParticipantsChange).toHaveBeenCalledWith([]);
});
it('shows email invite alert when external participants are present', () => {
render(
<TestWrapper>
<SelectParticipantsStep
{...makeProps({
participants: [{ type: 'external', email: 'invited@example.com' }],
})}
/>
</TestWrapper>
);
expect(
screen.getByText(/External participants will receive an email invitation/i)
).toBeInTheDocument();
});
it('does not show email invite alert when only registered users are present', () => {
render(
<TestWrapper>
<SelectParticipantsStep
{...makeProps({
participants: [{ type: 'registered', userId: 1 }],
})}
/>
</TestWrapper>
);
expect(
screen.queryByText(/External participants will receive an email invitation/i)
).not.toBeInTheDocument();
});
it('calls onBack when Back button is clicked', async () => {
const user = userEvent.setup();
const onBack = vi.fn();
render(
<TestWrapper>
<SelectParticipantsStep {...makeProps({ onBack })} />
</TestWrapper>
);
await user.click(screen.getByText('Back'));
expect(onBack).toHaveBeenCalledOnce();
});
it('calls onNext when Continue button is clicked with participants', async () => {
const user = userEvent.setup();
const onNext = vi.fn();
render(
<TestWrapper>
<SelectParticipantsStep
{...makeProps({
participants: [{ type: 'external', email: 'a@b.com' }],
onNext,
})}
/>
</TestWrapper>
);
await user.click(screen.getByText('Continue to Signature Settings'));
expect(onNext).toHaveBeenCalledOnce();
});
});
@@ -1,58 +1,234 @@
import { Button, Stack, Text, Group } from '@mantine/core';
import { useState } from 'react';
import {
ActionIcon,
Alert,
Badge,
Button,
Group,
Stack,
Tabs,
Text,
TextInput,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import CloseIcon from '@mui/icons-material/Close';
import PersonIcon from '@mui/icons-material/Person';
import EmailIcon from '@mui/icons-material/Email';
import UserSelector from '@app/components/shared/UserSelector';
// ─── Shared participant model ─────────────────────────────────────────────────
export interface Participant {
type: 'registered' | 'external';
/** Present for registered users */
userId?: number;
/** Present for external/guest users */
email?: string;
name?: string;
}
// ─── Props ───────────────────────────────────────────────────────────────────
interface SelectParticipantsStepProps {
selectedUserIds: number[];
onSelectedUserIdsChange: (userIds: number[]) => void;
participants: Participant[];
onParticipantsChange: (participants: Participant[]) => void;
onBack: () => void;
onNext: () => void;
disabled?: boolean;
}
// ─── Email validation ─────────────────────────────────────────────────────────
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// ─── Component ───────────────────────────────────────────────────────────────
export const SelectParticipantsStep: React.FC<SelectParticipantsStepProps> = ({
selectedUserIds,
onSelectedUserIdsChange,
participants,
onParticipantsChange,
onBack,
onNext,
disabled = false,
}) => {
const { t } = useTranslation();
const hasParticipants = selectedUserIds.length > 0;
// External email input state
const [emailInput, setEmailInput] = useState('');
const [emailError, setEmailError] = useState('');
const registeredUserIds = participants
.filter((p) => p.type === 'registered' && p.userId != null)
.map((p) => p.userId as number);
function handleRegisteredChange(userIds: number[]) {
const external = participants.filter((p) => p.type === 'external');
const registered: Participant[] = userIds.map((id) => ({ type: 'registered', userId: id }));
onParticipantsChange([...registered, ...external]);
}
function handleAddEmail() {
const trimmed = emailInput.trim();
if (!EMAIL_RE.test(trimmed)) {
setEmailError(
t(
'groupSigning.steps.selectParticipants.invalidEmail',
'Please enter a valid email address'
)
);
return;
}
if (participants.some((p) => p.email === trimmed)) {
setEmailError(
t(
'groupSigning.steps.selectParticipants.duplicateEmail',
'This email has already been added'
)
);
return;
}
onParticipantsChange([...participants, { type: 'external', email: trimmed }]);
setEmailInput('');
setEmailError('');
}
function handleRemove(participant: Participant) {
onParticipantsChange(
participants.filter((p) =>
participant.type === 'registered'
? !(p.type === 'registered' && p.userId === participant.userId)
: !(p.type === 'external' && p.email === participant.email)
)
);
}
return (
<Stack gap="md">
<div>
<Text size="sm" c="dimmed" mb="xs">
{t('groupSigning.steps.selectParticipants.label', 'Select participants')}
</Text>
<UserSelector
value={selectedUserIds}
onChange={onSelectedUserIdsChange}
placeholder={t(
'groupSigning.steps.selectParticipants.placeholder',
'Choose participants to sign...'
)}
disabled={disabled}
/>
</div>
<Tabs defaultValue="registered">
<Tabs.List>
<Tabs.Tab value="registered" leftSection={<PersonIcon sx={{ fontSize: 16 }} />}>
{t('groupSigning.steps.selectParticipants.tabRegistered', 'Registered Users')}
</Tabs.Tab>
<Tabs.Tab value="external" leftSection={<EmailIcon sx={{ fontSize: 16 }} />}>
{t('groupSigning.steps.selectParticipants.tabExternal', 'External (by email)')}
</Tabs.Tab>
</Tabs.List>
{selectedUserIds.length > 0 && (
<Text size="xs" c="dimmed">
{t('groupSigning.steps.selectParticipants.count', {
count: selectedUserIds.length,
defaultValue: '{{count}} participant(s) selected',
})}
</Text>
<Tabs.Panel value="registered" pt="sm">
<Stack gap="xs">
<Text size="sm" c="dimmed">
{t('groupSigning.steps.selectParticipants.label', 'Select participants')}
</Text>
<UserSelector
value={registeredUserIds}
onChange={handleRegisteredChange}
placeholder={t(
'groupSigning.steps.selectParticipants.placeholder',
'Choose participants to sign...'
)}
disabled={disabled}
/>
</Stack>
</Tabs.Panel>
<Tabs.Panel value="external" pt="sm">
<Stack gap="xs">
<Text size="sm" c="dimmed">
{t(
'groupSigning.steps.selectParticipants.externalNote',
'An invitation email will be sent with a signing link. No account required.'
)}
</Text>
<Group gap="xs" align="flex-start">
<TextInput
style={{ flex: 1 }}
placeholder={t(
'groupSigning.steps.selectParticipants.emailPlaceholder',
'signer@example.com'
)}
value={emailInput}
onChange={(e) => {
setEmailInput(e.currentTarget.value);
if (emailError) setEmailError('');
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddEmail();
}
}}
error={emailError}
disabled={disabled}
/>
<Button onClick={handleAddEmail} disabled={disabled} style={{ marginTop: emailError ? 0 : 0 }}>
{t('groupSigning.steps.selectParticipants.addEmail', 'Add')}
</Button>
</Group>
</Stack>
</Tabs.Panel>
</Tabs>
{/* Combined participant list */}
{participants.length > 0 && (
<Stack gap="xs">
<Text size="xs" c="dimmed">
{t('groupSigning.steps.selectParticipants.count', {
count: participants.length,
defaultValue: '{{count}} participant(s)',
})}
</Text>
{participants.map((p, i) => (
<Group key={i} gap="xs" justify="space-between" wrap="nowrap">
<Group gap="xs" style={{ flex: 1, minWidth: 0 }}>
<Badge
variant="light"
color={p.type === 'external' ? 'orange' : 'blue'}
size="xs"
>
{p.type === 'external'
? t('groupSigning.steps.selectParticipants.badgeExternal', 'Guest')
: t('groupSigning.steps.selectParticipants.badgeRegistered', 'User')}
</Badge>
<Text size="sm" style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{p.type === 'external' ? p.email : `User #${p.userId}`}
</Text>
</Group>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => handleRemove(p)}
disabled={disabled}
>
<CloseIcon sx={{ fontSize: 14 }} />
</ActionIcon>
</Group>
))}
</Stack>
)}
{participants.some((p) => p.type === 'external') && (
<Alert color="blue" variant="light" icon={<EmailIcon sx={{ fontSize: 16 }} />}>
{t(
'groupSigning.steps.selectParticipants.emailInviteNote',
'External participants will receive an email invitation with a signing link.'
)}
</Alert>
)}
<Group gap="sm">
<Button variant="default" onClick={onBack} leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}>
<Button
variant="default"
onClick={onBack}
leftSection={<ArrowBackIcon sx={{ fontSize: 16 }} />}
>
{t('groupSigning.steps.back', 'Back')}
</Button>
<Button onClick={onNext} disabled={!hasParticipants || disabled} style={{ flex: 1 }}>
<Button
onClick={onNext}
disabled={participants.length === 0 || disabled}
style={{ flex: 1 }}
>
{t('groupSigning.steps.selectParticipants.continue', 'Continue to Signature Settings')}
</Button>
</Group>
@@ -21,7 +21,7 @@ export interface SessionDetailWorkbenchData {
pdfFile: File | null;
onFinalize: () => Promise<void>;
onLoadSignedPdf: () => Promise<void>;
onAddParticipants: (userIds: number[], defaultReason?: string) => Promise<void>;
onAddParticipants: (userIds: number[], emails: string[], defaultReason?: string) => Promise<void>;
onRemoveParticipant: (participantId: number) => Promise<void>;
onDelete: () => Promise<void>;
onBack: () => void;
@@ -67,9 +67,9 @@ const SessionDetailWorkbenchView = ({ data }: SessionDetailWorkbenchViewProps) =
}
}, [session.finalized, onRefresh]);
const handleAddParticipants = async (userIds: number[], defaultReason?: string) => {
const handleAddParticipants = async (userIds: number[], emails: string[], defaultReason?: string) => {
try {
await onAddParticipants(userIds, defaultReason);
await onAddParticipants(userIds, emails, defaultReason);
alert({
alertType: 'success',
title: t('success'),
@@ -1,13 +1,28 @@
import { useState } from 'react';
import { Modal, Stack, TextInput, Button, Group } from '@mantine/core';
import {
ActionIcon,
Badge,
Button,
Group,
Modal,
Stack,
Tabs,
Text,
TextInput,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import AddIcon from '@mui/icons-material/Add';
import CloseIcon from '@mui/icons-material/Close';
import PersonIcon from '@mui/icons-material/Person';
import EmailIcon from '@mui/icons-material/Email';
import UserSelector from '@app/components/shared/UserSelector';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
interface AddParticipantsFlowProps {
opened: boolean;
onClose: () => void;
onSubmit: (userIds: number[], defaultReason?: string) => Promise<void>;
onSubmit: (userIds: number[], emails: string[], defaultReason?: string) => Promise<void>;
}
export const AddParticipantsFlow: React.FC<AddParticipantsFlowProps> = ({
@@ -17,19 +32,52 @@ export const AddParticipantsFlow: React.FC<AddParticipantsFlowProps> = ({
}) => {
const { t } = useTranslation();
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
const [externalEmails, setExternalEmails] = useState<string[]>([]);
const [emailInput, setEmailInput] = useState('');
const [emailError, setEmailError] = useState('');
const [defaultReason, setDefaultReason] = useState('');
const [submitting, setSubmitting] = useState(false);
const totalCount = selectedUserIds.length + externalEmails.length;
const handleClose = () => {
setSelectedUserIds([]);
setExternalEmails([]);
setEmailInput('');
setEmailError('');
setDefaultReason('');
onClose();
};
function handleAddEmail() {
const trimmed = emailInput.trim();
if (!EMAIL_RE.test(trimmed)) {
setEmailError(
t(
'groupSigning.steps.selectParticipants.invalidEmail',
'Please enter a valid email address'
)
);
return;
}
if (externalEmails.includes(trimmed)) {
setEmailError(
t(
'groupSigning.steps.selectParticipants.duplicateEmail',
'This email has already been added'
)
);
return;
}
setExternalEmails((prev) => [...prev, trimmed]);
setEmailInput('');
setEmailError('');
}
const handleSubmit = async () => {
setSubmitting(true);
try {
await onSubmit(selectedUserIds, defaultReason.trim() || undefined);
await onSubmit(selectedUserIds, externalEmails, defaultReason.trim() || undefined);
handleClose();
} catch (error) {
console.error('Failed to add participants:', error);
@@ -46,18 +94,87 @@ export const AddParticipantsFlow: React.FC<AddParticipantsFlowProps> = ({
size="lg"
>
<Stack gap="md">
<UserSelector
value={selectedUserIds}
onChange={setSelectedUserIds}
placeholder={t('certSign.collab.sessionDetail.selectUsers', 'Select users...')}
/>
<Tabs defaultValue="registered">
<Tabs.List>
<Tabs.Tab value="registered" leftSection={<PersonIcon sx={{ fontSize: 16 }} />}>
{t('groupSigning.steps.selectParticipants.tabRegistered', 'Registered Users')}
</Tabs.Tab>
<Tabs.Tab value="external" leftSection={<EmailIcon sx={{ fontSize: 16 }} />}>
{t('groupSigning.steps.selectParticipants.tabExternal', 'External (by email)')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="registered" pt="sm">
<UserSelector
value={selectedUserIds}
onChange={setSelectedUserIds}
placeholder={t('certSign.collab.sessionDetail.selectUsers', 'Select users...')}
/>
</Tabs.Panel>
<Tabs.Panel value="external" pt="sm">
<Stack gap="xs">
<Text size="sm" c="dimmed">
{t(
'groupSigning.steps.selectParticipants.externalNote',
'An invitation email will be sent with a signing link. No account required.'
)}
</Text>
<Group gap="xs" align="flex-start">
<TextInput
style={{ flex: 1 }}
placeholder="signer@example.com"
value={emailInput}
onChange={(e) => {
setEmailInput(e.currentTarget.value);
if (emailError) setEmailError('');
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddEmail();
}
}}
error={emailError}
/>
<Button onClick={handleAddEmail} variant="default">
{t('groupSigning.steps.selectParticipants.addEmail', 'Add')}
</Button>
</Group>
{externalEmails.map((email) => (
<Group key={email} gap="xs" justify="space-between" wrap="nowrap">
<Group gap="xs">
<Badge variant="light" color="orange" size="xs">
{t('groupSigning.steps.selectParticipants.badgeExternal', 'Guest')}
</Badge>
<Text size="sm">{email}</Text>
</Group>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => setExternalEmails((prev) => prev.filter((e) => e !== email))}
>
<CloseIcon sx={{ fontSize: 14 }} />
</ActionIcon>
</Group>
))}
</Stack>
</Tabs.Panel>
</Tabs>
<TextInput
label={t('certSign.reason', 'Default Reason')}
description={t('certSign.collab.addParticipants.reasonHelp', 'Pre-set a signing reason for these participants (optional, they can override when signing)')}
description={t(
'certSign.collab.addParticipants.reasonHelp',
'Pre-set a signing reason for these participants (optional, they can override when signing)'
)}
value={defaultReason}
onChange={(e) => setDefaultReason(e.currentTarget.value)}
placeholder={t('certSign.collab.addParticipants.reasonPlaceholder', 'e.g. Approval, Review...')}
placeholder={t(
'certSign.collab.addParticipants.reasonPlaceholder',
'e.g. Approval, Review...'
)}
size="sm"
/>
@@ -68,12 +185,12 @@ export const AddParticipantsFlow: React.FC<AddParticipantsFlowProps> = ({
<Button
onClick={handleSubmit}
loading={submitting}
disabled={selectedUserIds.length === 0}
disabled={totalCount === 0}
leftSection={<AddIcon sx={{ fontSize: 16 }} />}
color="green"
>
{t('certSign.collab.addParticipants.add', 'Add {{count}} Participant(s)', {
count: selectedUserIds.length,
count: totalCount,
})}
</Button>
</Group>
@@ -0,0 +1,321 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MantineProvider } from '@mantine/core';
// ── i18n ────────────────────────────────────────────────────────────────────
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') return fallback;
return key;
},
}),
initReactI18next: { type: '3rdParty', init: vi.fn() },
}));
// ── react-router-dom: useParams returns our injected token ──────────────────
const mockToken = 'test-token-abc';
vi.mock('react-router-dom', () => ({
useParams: () => ({ token: mockToken }),
}));
// ── Heavy sub-components — stub them out so we don't need full canvas / MUI ─
vi.mock('@app/components/shared/wetSignature/DrawSignatureCanvas', () => ({
DrawSignatureCanvas: ({ onChange }: { onChange: (v: string | null) => void }) => (
<button onClick={() => onChange('sig-data-stub')}>Draw</button>
),
}));
vi.mock('@app/components/shared/wetSignature/SignatureTypeSelector', () => ({
SignatureTypeSelector: () => <div data-testid="sig-type-selector" />,
SignatureType: {},
}));
vi.mock('@app/components/shared/wetSignature/TypeSignatureText', () => ({
TypeSignatureText: () => <div data-testid="type-sig-text" />,
}));
vi.mock('@app/components/shared/wetSignature/UploadSignatureImage', () => ({
UploadSignatureImage: () => <div data-testid="upload-sig-image" />,
}));
vi.mock('@app/components/shared/signing/GuestCertificateChooser', () => ({
GuestCertificateChooser: ({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) => (
<div data-testid="cert-chooser" data-value={value}>
<button onClick={() => onChange('GUEST_CERT')}>Auto Cert</button>
<button onClick={() => onChange('P12')}>Upload Cert</button>
</div>
),
GuestCertType: {},
}));
vi.mock('@app/types/signingSession', () => ({}));
// ── Component under test ────────────────────────────────────────────────────
import GuestSignPage from './GuestSignPage';
// ── Test wrapper ────────────────────────────────────────────────────────────
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
// ── Fetch helpers ───────────────────────────────────────────────────────────
function mockFetchSuccess(
sessionData = { sessionId: '1', documentName: 'Contract.pdf', ownerEmail: 'owner@example.com' },
participantData = { id: 1, email: 'guest@example.com', name: 'Guest', status: 'VIEWED' }
) {
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation((url: string) => {
if (url.includes('/session')) {
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(sessionData) });
}
if (url.includes('/details')) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(participantData),
});
}
if (url.includes('/document')) {
return Promise.resolve({ ok: true, status: 200 });
}
// submit-signature or decline
return Promise.resolve({ ok: true, status: 200 });
})
);
}
function mockFetchForbidden() {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 403 })
);
}
// ── Tests ───────────────────────────────────────────────────────────────────
describe('GuestSignPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders loading state on mount before fetch resolves', () => {
// Never resolve the fetch — stays in loading state
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {})));
render(
<TestWrapper>
<GuestSignPage />
</TestWrapper>
);
expect(screen.getByText(/loading signing session/i)).toBeInTheDocument();
});
it('shows expired message when fetch returns 403', async () => {
mockFetchForbidden();
render(
<TestWrapper>
<GuestSignPage />
</TestWrapper>
);
await waitFor(() => {
expect(
screen.getByText(/This signing link has expired\./i)
).toBeInTheDocument();
});
});
it('shows signed success state when participant status is SIGNED', async () => {
mockFetchSuccess(undefined, {
id: 1,
email: 'guest@example.com',
name: 'Guest',
status: 'SIGNED',
});
render(
<TestWrapper>
<GuestSignPage />
</TestWrapper>
);
await waitFor(() => {
expect(
screen.getByText(/Your signature has been submitted successfully\./i)
).toBeInTheDocument();
});
});
it('shows declined state when participant status is DECLINED', async () => {
mockFetchSuccess(undefined, {
id: 1,
email: 'guest@example.com',
name: 'Guest',
status: 'DECLINED',
});
render(
<TestWrapper>
<GuestSignPage />
</TestWrapper>
);
await waitFor(() => {
expect(
screen.getByText(/You have declined this signing request\./i)
).toBeInTheDocument();
});
});
it('renders the signing form in ready state', async () => {
mockFetchSuccess();
render(
<TestWrapper>
<GuestSignPage />
</TestWrapper>
);
await waitFor(() => {
expect(screen.getByText('Sign Document')).toBeInTheDocument();
expect(screen.getByText('Contract.pdf')).toBeInTheDocument();
});
});
it('shows auto-cert chooser selected by default', async () => {
mockFetchSuccess();
render(
<TestWrapper>
<GuestSignPage />
</TestWrapper>
);
await waitFor(() => {
const chooser = screen.getByTestId('cert-chooser');
expect(chooser).toHaveAttribute('data-value', 'GUEST_CERT');
});
});
it('submits with GUEST_CERT when auto cert is selected', async () => {
const user = userEvent.setup();
mockFetchSuccess();
render(
<TestWrapper>
<GuestSignPage />
</TestWrapper>
);
await waitFor(() => {
expect(screen.getByText('Sign Document')).toBeInTheDocument();
});
const submitButton = screen.getByText('Submit Signature');
await user.click(submitButton);
await waitFor(() => {
const fetchMock = vi.mocked(fetch);
const submitCall = fetchMock.mock.calls.find(([url]) =>
String(url).includes('submit-signature')
);
expect(submitCall).toBeDefined();
const body = submitCall![1]!.body as FormData;
expect(body.get('certType')).toBe('GUEST_CERT');
expect(body.get('participantToken')).toBe(mockToken);
});
});
it('shows success state after successful submission', async () => {
const user = userEvent.setup();
mockFetchSuccess();
render(
<TestWrapper>
<GuestSignPage />
</TestWrapper>
);
await waitFor(() => {
expect(screen.getByText('Sign Document')).toBeInTheDocument();
});
await user.click(screen.getByText('Submit Signature'));
await waitFor(() => {
expect(
screen.getByText(/Your signature has been submitted successfully\./i)
).toBeInTheDocument();
});
});
it('opens decline confirmation modal when Decline is clicked', async () => {
const user = userEvent.setup();
mockFetchSuccess();
render(
<TestWrapper>
<GuestSignPage />
</TestWrapper>
);
await waitFor(() => {
expect(screen.getByText('Sign Document')).toBeInTheDocument();
});
// Click the outer Decline button (variant="subtle")
const outerDeclineBtn = screen.getAllByRole('button').find(
(btn) => btn.getAttribute('data-variant') === 'subtle'
);
expect(outerDeclineBtn).toBeDefined();
await user.click(outerDeclineBtn!);
// Modal confirmation dialog should appear
await waitFor(() => {
expect(screen.getByText('Decline signing?')).toBeInTheDocument();
expect(screen.getByText(/Are you sure you want to decline/i)).toBeInTheDocument();
});
// Cancel button should close the modal
await user.click(screen.getByRole('button', { name: /cancel/i }));
await waitFor(() => {
expect(screen.queryByText('Decline signing?')).not.toBeInTheDocument();
});
});
it('shows error state when fetch fails with non-403 error', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 500 })
);
render(
<TestWrapper>
<GuestSignPage />
</TestWrapper>
);
await waitFor(() => {
expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
});
});
});
+385
View File
@@ -0,0 +1,385 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
Alert,
Button,
Center,
Divider,
Group,
Loader,
Modal,
Paper,
Stack,
Text,
Title,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import { DrawSignatureCanvas } from '@app/components/shared/wetSignature/DrawSignatureCanvas';
import { SignatureTypeSelector, SignatureType } from '@app/components/shared/wetSignature/SignatureTypeSelector';
import { TypeSignatureText } from '@app/components/shared/wetSignature/TypeSignatureText';
import { UploadSignatureImage } from '@app/components/shared/wetSignature/UploadSignatureImage';
import { GuestCertificateChooser, GuestCertType } from '@app/components/shared/signing/GuestCertificateChooser';
import type { WetSignatureMetadata } from '@app/types/signingSession';
// ─── Types ──────────────────────────────────────────────────────────────────
interface SessionInfo {
sessionId: string;
documentName: string;
ownerEmail: string;
message?: string;
dueDate?: string;
}
interface ParticipantDetails {
id: number;
email: string;
name: string;
status: string;
expiresAt?: string;
}
type PageState = 'loading' | 'ready' | 'expired' | 'signed' | 'declined' | 'error';
// ─── Component ──────────────────────────────────────────────────────────────
export default function GuestSignPage() {
const { token } = useParams<{ token: string }>();
const { t } = useTranslation();
// Page state
const [pageState, setPageState] = useState<PageState>('loading');
const [errorMessage, setErrorMessage] = useState<string>('');
const [session, setSession] = useState<SessionInfo | null>(null);
const [participant, setParticipant] = useState<ParticipantDetails | null>(null);
// Signature state
const [sigType, setSigType] = useState<SignatureType>('draw');
const [sigData, setSigData] = useState<string | null>(null);
// Certificate state
const [certType, setCertType] = useState<GuestCertType>('GUEST_CERT');
const [p12File, setP12File] = useState<File | null>(null);
const [certPassword, setCertPassword] = useState('');
// Submission state
const [submitting, setSubmitting] = useState(false);
const [declineOpen, { open: openDecline, close: closeDecline }] = useDisclosure(false);
const [declining, setDeclining] = useState(false);
// ── Load session on mount ──────────────────────────────────────────────
useEffect(() => {
if (!token) {
setPageState('error');
setErrorMessage('Missing signing token.');
return;
}
async function load() {
try {
const [sessionRes, detailsRes] = await Promise.all([
fetch(`/api/v1/workflow/participant/session?token=${encodeURIComponent(token!)}`),
fetch(`/api/v1/workflow/participant/details?token=${encodeURIComponent(token!)}`),
]);
if (sessionRes.status === 403 || detailsRes.status === 403) {
setPageState('expired');
return;
}
if (!sessionRes.ok || !detailsRes.ok) {
setPageState('error');
setErrorMessage('Unable to load signing session.');
return;
}
const sessionData: SessionInfo = await sessionRes.json();
const participantData: ParticipantDetails = await detailsRes.json();
setSession(sessionData);
setParticipant(participantData);
if (participantData.status === 'SIGNED') {
setPageState('signed');
} else if (participantData.status === 'DECLINED') {
setPageState('declined');
} else {
setPageState('ready');
}
} catch {
setPageState('error');
setErrorMessage('An unexpected error occurred.');
}
}
load();
}, [token]);
// ── Submit signature ───────────────────────────────────────────────────
async function handleSubmit() {
if (!token) return;
setSubmitting(true);
try {
const formData = new FormData();
formData.append('participantToken', token);
formData.append('certType', certType);
if (certType === 'P12') {
if (!p12File) {
alert(t('guestSigning.certFileRequired', 'Please select a certificate file.'));
setSubmitting(false);
return;
}
formData.append('p12File', p12File);
formData.append('password', certPassword);
}
if (sigData) {
const wetSig: WetSignatureMetadata = {
type: sigType === 'type' ? 'text' : sigType === 'draw' ? 'canvas' : 'image',
data: sigData,
page: 0,
x: 50,
y: 50,
width: 200,
height: 60,
};
formData.append('wetSignaturesData', JSON.stringify([wetSig]));
}
const res = await fetch('/api/v1/workflow/participant/submit-signature', {
method: 'POST',
body: formData,
});
if (!res.ok) {
const body = await res.text();
setErrorMessage(body || t('guestSigning.submitError', 'Failed to submit signature.'));
setPageState('error');
return;
}
setPageState('signed');
} finally {
setSubmitting(false);
}
}
// ── Decline ────────────────────────────────────────────────────────────
async function handleDecline() {
if (!token) return;
setDeclining(true);
try {
const res = await fetch(
`/api/v1/workflow/participant/decline?token=${encodeURIComponent(token)}`,
{ method: 'POST' }
);
if (res.ok) {
setPageState('declined');
}
} finally {
setDeclining(false);
closeDecline();
}
}
// ── Render helpers ─────────────────────────────────────────────────────
if (pageState === 'loading') {
return (
<Center h="100vh">
<Stack align="center" gap="md">
<Loader size="lg" />
<Text>{t('guestSigning.loadingSession', 'Loading signing session...')}</Text>
</Stack>
</Center>
);
}
if (pageState === 'expired') {
return (
<Center h="100vh">
<Paper shadow="sm" p="xl" maw={480} w="100%">
<Stack align="center" gap="md">
<ErrorOutlineIcon style={{ fontSize: 48, color: '#fa5252' }} />
<Title order={3}>{t('guestSigning.expiredToken', 'This signing link has expired.')}</Title>
<Text c="dimmed" ta="center">
{t('guestSigning.expiredNote', 'Please contact the document owner for a new link.')}
</Text>
</Stack>
</Paper>
</Center>
);
}
if (pageState === 'signed') {
return (
<Center h="100vh">
<Paper shadow="sm" p="xl" maw={480} w="100%">
<Stack align="center" gap="md">
<CheckCircleIcon style={{ fontSize: 48, color: '#40c057' }} />
<Title order={3}>{t('guestSigning.submitSuccess', 'Your signature has been submitted successfully.')}</Title>
<Text c="dimmed" ta="center">
{t('guestSigning.signedNote', 'The document owner has been notified. You may close this window.')}
</Text>
</Stack>
</Paper>
</Center>
);
}
if (pageState === 'declined') {
return (
<Center h="100vh">
<Paper shadow="sm" p="xl" maw={480} w="100%">
<Stack align="center" gap="md">
<ErrorOutlineIcon style={{ fontSize: 48, color: '#fab005' }} />
<Title order={3}>{t('guestSigning.declineSuccess', 'You have declined this signing request.')}</Title>
<Text c="dimmed" ta="center">
{t('guestSigning.declinedNote', 'The document owner has been notified. You may close this window.')}
</Text>
</Stack>
</Paper>
</Center>
);
}
if (pageState === 'error') {
return (
<Center h="100vh">
<Paper shadow="sm" p="xl" maw={480} w="100%">
<Stack align="center" gap="md">
<ErrorOutlineIcon style={{ fontSize: 48, color: '#fa5252' }} />
<Title order={3}>{t('guestSigning.errorTitle', 'Something went wrong')}</Title>
<Text c="dimmed" ta="center">{errorMessage}</Text>
</Stack>
</Paper>
</Center>
);
}
// ── Main signing form ──────────────────────────────────────────────────
return (
<Center py="xl" px="md">
<Paper shadow="sm" p="xl" maw={680} w="100%">
<Stack gap="lg">
{/* Header */}
<Stack gap="xs">
<Title order={2}>{t('guestSigning.pageTitle', 'Sign Document')}</Title>
{session && (
<>
<Text size="lg" fw={500}>{session.documentName}</Text>
<Text c="dimmed" size="sm">
{t('guestSigning.requestedBy', 'Requested by {{owner}}', {
owner: session.ownerEmail,
})}
</Text>
{session.dueDate && (
<Text c="orange" size="sm">
{t('guestSigning.dueDate', 'Due {{date}}', { date: session.dueDate })}
</Text>
)}
{session.message && (
<Alert color="gray" variant="light">
{session.message}
</Alert>
)}
</>
)}
</Stack>
<Divider />
{/* PDF preview */}
{token && (
<Stack gap="xs">
<Text fw={500} size="sm">
{t('guestSigning.documentPreview', 'Document')}
</Text>
<iframe
src={`/api/v1/workflow/participant/document?token=${encodeURIComponent(token)}`}
title="Document to sign"
style={{ width: '100%', height: 400, border: '1px solid #dee2e6', borderRadius: 4 }}
/>
</Stack>
)}
<Divider />
{/* Certificate chooser */}
<GuestCertificateChooser
value={certType}
onChange={setCertType}
onFileChange={setP12File}
onPasswordChange={setCertPassword}
p12File={p12File}
password={certPassword}
/>
<Divider />
{/* Wet signature */}
<Stack gap="sm">
<Text fw={500} size="sm">
{t('guestSigning.signatureTitle', 'Your Signature')}
</Text>
<SignatureTypeSelector value={sigType} onChange={setSigType} />
{sigType === 'draw' && (
<DrawSignatureCanvas signature={sigData} onChange={setSigData} />
)}
{sigType === 'type' && (
<TypeSignatureText value={sigData ?? ''} onChange={setSigData} />
)}
{sigType === 'upload' && (
<UploadSignatureImage value={sigData} onChange={setSigData} />
)}
</Stack>
<Divider />
{/* Actions */}
<Group justify="space-between">
<Button variant="subtle" color="red" onClick={openDecline} disabled={submitting}>
{t('guestSigning.declineButton', 'Decline')}
</Button>
<Button onClick={handleSubmit} loading={submitting}>
{t('guestSigning.submitButton', 'Submit Signature')}
</Button>
</Group>
</Stack>
</Paper>
{/* Decline confirmation modal */}
<Modal
opened={declineOpen}
onClose={closeDecline}
title={t('guestSigning.declineConfirmTitle', 'Decline signing?')}
centered
>
<Stack gap="md">
<Text>
{t(
'guestSigning.declineConfirmBody',
"Are you sure you want to decline? This action cannot be undone."
)}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={closeDecline}>
{t('common.cancel', 'Cancel')}
</Button>
<Button color="red" onClick={handleDecline} loading={declining}>
{t('guestSigning.declineButton', 'Decline')}
</Button>
</Group>
</Stack>
</Modal>
</Center>
);
}
@@ -0,0 +1,342 @@
/**
* E2E tests for the Guest Signing flow (/sign/:token).
*
* These tests use Playwright's route mocking to intercept all API calls, so no
* running backend is required. They cover the full page-state machine from the
* browser perspective.
*
* Run: npx playwright test src/core/tests/guestSigning/GuestSigningE2E.spec.ts
*/
import { test, expect, Page, Route } from '@playwright/test';
// ─── Shared fixtures ─────────────────────────────────────────────────────────
const TOKEN = 'test-share-token-abc';
const SIGN_URL = `/sign/${TOKEN}`;
const SESSION_PAYLOAD = {
sessionId: 'session-1',
documentName: 'Contract_2026.pdf',
ownerEmail: 'owner@company.com',
message: 'Please review and sign by end of week.',
};
const PARTICIPANT_PENDING = {
id: 1,
email: 'guest@example.com',
name: 'Guest Signer',
status: 'PENDING',
};
const PARTICIPANT_SIGNED = { ...PARTICIPANT_PENDING, status: 'SIGNED' };
const PARTICIPANT_DECLINED = { ...PARTICIPANT_PENDING, status: 'DECLINED' };
// ─── Route helpers ────────────────────────────────────────────────────────────
async function mockHappyPath(page: Page) {
await page.route('**/workflow/participant/session**', (route: Route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SESSION_PAYLOAD) })
);
await page.route('**/workflow/participant/details**', (route: Route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARTICIPANT_PENDING) })
);
await page.route('**/workflow/participant/document**', (route: Route) =>
route.fulfill({ status: 200, contentType: 'application/pdf', body: Buffer.from('%PDF-1.4 stub') })
);
await page.route('**/workflow/participant/submit-signature', (route: Route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'SIGNED' }) })
);
await page.route('**/workflow/participant/decline**', (route: Route) =>
route.fulfill({ status: 200 })
);
}
async function mockForbidden(page: Page) {
await page.route('**/workflow/participant/**', (route: Route) =>
route.fulfill({ status: 403 })
);
}
async function mockAlreadySigned(page: Page) {
await page.route('**/workflow/participant/session**', (route: Route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SESSION_PAYLOAD) })
);
await page.route('**/workflow/participant/details**', (route: Route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARTICIPANT_SIGNED) })
);
}
async function mockAlreadyDeclined(page: Page) {
await page.route('**/workflow/participant/session**', (route: Route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SESSION_PAYLOAD) })
);
await page.route('**/workflow/participant/details**', (route: Route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARTICIPANT_DECLINED) })
);
}
async function mockServerError(page: Page) {
await page.route('**/workflow/participant/**', (route: Route) =>
route.fulfill({ status: 500 })
);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
test.describe('GuestSignPage', () => {
// ── Page state machine ──────────────────────────────────────────────────
test('shows loading spinner before API responds', async ({ page }) => {
// Delay the session response so we can catch the loading state
await page.route('**/workflow/participant/session**', async (route) => {
await new Promise(resolve => setTimeout(resolve, 500));
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SESSION_PAYLOAD) });
});
await page.route('**/workflow/participant/details**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARTICIPANT_PENDING) })
);
await page.goto(SIGN_URL);
await expect(page.getByText(/loading signing session/i)).toBeVisible();
});
test('shows expired message for 403 response', async ({ page }) => {
await mockForbidden(page);
await page.goto(SIGN_URL);
await expect(page.getByText(/this signing link has expired/i)).toBeVisible();
await expect(page.getByText(/contact the document owner for a new link/i)).toBeVisible();
});
test('shows already-signed state when participant status is SIGNED', async ({ page }) => {
await mockAlreadySigned(page);
await page.goto(SIGN_URL);
await expect(page.getByText(/your signature has been submitted successfully/i)).toBeVisible();
await expect(page.getByText(/you may close this window/i)).toBeVisible();
});
test('shows declined state when participant status is DECLINED', async ({ page }) => {
await mockAlreadyDeclined(page);
await page.goto(SIGN_URL);
await expect(page.getByText(/you have declined this signing request/i)).toBeVisible();
});
test('shows error state on 500 server error', async ({ page }) => {
await mockServerError(page);
await page.goto(SIGN_URL);
await expect(page.getByText(/something went wrong/i)).toBeVisible();
});
// ── Ready (signing form) ────────────────────────────────────────────────
test('renders signing form with document details', async ({ page }) => {
await mockHappyPath(page);
await page.goto(SIGN_URL);
await expect(page.getByText('Sign Document')).toBeVisible();
await expect(page.getByText('Contract_2026.pdf')).toBeVisible();
await expect(page.getByText(/Requested by owner@company.com/i)).toBeVisible();
await expect(page.getByText('Please review and sign by end of week.')).toBeVisible();
});
test('shows document preview iframe', async ({ page }) => {
await mockHappyPath(page);
await page.goto(SIGN_URL);
await expect(page.locator('iframe[title="Document to sign"]')).toBeVisible();
const iframeSrc = await page.locator('iframe[title="Document to sign"]').getAttribute('src');
expect(iframeSrc).toContain(TOKEN);
expect(iframeSrc).toContain('document');
});
test('auto-cert option is selected by default', async ({ page }) => {
await mockHappyPath(page);
await page.goto(SIGN_URL);
// The auto-cert radio should be checked
const autoRadio = page.getByLabel(/use auto-generated certificate/i);
await expect(autoRadio).toBeChecked();
});
test('shows auto-cert info alert when auto cert is selected', async ({ page }) => {
await mockHappyPath(page);
await page.goto(SIGN_URL);
await expect(page.getByText(/a certificate will be generated using your email address/i)).toBeVisible();
});
// ── Certificate chooser ─────────────────────────────────────────────────
test('switching to P12 shows file input and password field', async ({ page }) => {
await mockHappyPath(page);
await page.goto(SIGN_URL);
await page.getByLabel(/upload my own certificate/i).check();
await expect(page.getByLabel(/certificate file/i)).toBeVisible();
await expect(page.getByLabel(/certificate password/i)).toBeVisible();
// Auto-cert alert should be hidden
await expect(page.getByText(/a certificate will be generated using your email address/i)).not.toBeVisible();
});
test('switching back to auto-cert hides file inputs', async ({ page }) => {
await mockHappyPath(page);
await page.goto(SIGN_URL);
await page.getByLabel(/upload my own certificate/i).check();
await expect(page.getByLabel(/certificate file/i)).toBeVisible();
await page.getByLabel(/use auto-generated certificate/i).check();
await expect(page.getByLabel(/certificate file/i)).not.toBeVisible();
await expect(page.getByText(/a certificate will be generated using your email address/i)).toBeVisible();
});
// ── Submit signature ────────────────────────────────────────────────────
test('submits with GUEST_CERT and shows success state', async ({ page }) => {
let capturedFormData: Record<string, string> = {};
await page.route('**/workflow/participant/session**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SESSION_PAYLOAD) })
);
await page.route('**/workflow/participant/details**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARTICIPANT_PENDING) })
);
await page.route('**/workflow/participant/document**', (route) =>
route.fulfill({ status: 200, contentType: 'application/pdf', body: Buffer.from('%PDF-1.4') })
);
await page.route('**/workflow/participant/submit-signature', async (route) => {
const request = route.request();
const postData = request.postData();
if (postData) capturedFormData.raw = postData;
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'SIGNED' }) });
});
await page.goto(SIGN_URL);
await expect(page.getByText('Sign Document')).toBeVisible();
// Submit with default auto-cert
await page.getByRole('button', { name: /submit signature/i }).click();
// Should transition to success state
await expect(page.getByText(/your signature has been submitted successfully/i)).toBeVisible();
// Verify certType was GUEST_CERT
expect(capturedFormData.raw).toContain('GUEST_CERT');
expect(capturedFormData.raw).toContain(TOKEN);
});
test('shows error message when submission fails', async ({ page }) => {
await page.route('**/workflow/participant/session**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SESSION_PAYLOAD) })
);
await page.route('**/workflow/participant/details**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARTICIPANT_PENDING) })
);
await page.route('**/workflow/participant/document**', (route) =>
route.fulfill({ status: 200, contentType: 'application/pdf', body: Buffer.from('%PDF-1.4') })
);
await page.route('**/workflow/participant/submit-signature', (route) =>
route.fulfill({ status: 400, body: 'Session has expired' })
);
await page.goto(SIGN_URL);
await expect(page.getByText('Sign Document')).toBeVisible();
await page.getByRole('button', { name: /submit signature/i }).click();
await expect(page.getByText(/something went wrong/i)).toBeVisible();
await expect(page.getByText('Session has expired')).toBeVisible();
});
test('Submit Signature button shows loading state while in flight', async ({ page }) => {
await page.route('**/workflow/participant/session**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SESSION_PAYLOAD) })
);
await page.route('**/workflow/participant/details**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(PARTICIPANT_PENDING) })
);
await page.route('**/workflow/participant/document**', (route) =>
route.fulfill({ status: 200, contentType: 'application/pdf', body: Buffer.from('%PDF-1.4') })
);
await page.route('**/workflow/participant/submit-signature', async (route) => {
await new Promise(resolve => setTimeout(resolve, 600));
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'SIGNED' }) });
});
await page.goto(SIGN_URL);
await expect(page.getByText('Sign Document')).toBeVisible();
await page.getByRole('button', { name: /submit signature/i }).click();
// Button should be in loading/disabled state
const submitBtn = page.getByRole('button', { name: /submit signature/i });
await expect(submitBtn).toBeDisabled();
// Eventually success
await expect(page.getByText(/your signature has been submitted successfully/i)).toBeVisible();
});
// ── Decline flow ────────────────────────────────────────────────────────
test('decline button opens confirmation modal', async ({ page }) => {
await mockHappyPath(page);
await page.goto(SIGN_URL);
await expect(page.getByText('Sign Document')).toBeVisible();
await page.getByRole('button', { name: /decline/i }).first().click();
await expect(page.getByText('Decline signing?')).toBeVisible();
await expect(page.getByText(/are you sure you want to decline/i)).toBeVisible();
});
test('Cancel in decline modal closes modal without declining', async ({ page }) => {
await mockHappyPath(page);
await page.goto(SIGN_URL);
await page.getByRole('button', { name: /decline/i }).first().click();
await expect(page.getByText('Decline signing?')).toBeVisible();
await page.getByRole('button', { name: /cancel/i }).click();
await expect(page.getByText('Decline signing?')).not.toBeVisible();
// Signing form still present
await expect(page.getByRole('button', { name: /submit signature/i })).toBeVisible();
});
test('confirming decline transitions to declined state', async ({ page }) => {
await mockHappyPath(page);
await page.goto(SIGN_URL);
await page.getByRole('button', { name: /decline/i }).first().click();
await expect(page.getByText('Decline signing?')).toBeVisible();
// Click the confirm Decline button inside the modal
const modalDeclineBtn = page.getByRole('dialog').getByRole('button', { name: /decline/i });
await modalDeclineBtn.click();
await expect(page.getByText(/you have declined this signing request/i)).toBeVisible();
});
// ── Accessibility ───────────────────────────────────────────────────────
test('Submit Signature button is initially enabled', async ({ page }) => {
await mockHappyPath(page);
await page.goto(SIGN_URL);
await expect(page.getByText('Sign Document')).toBeVisible();
await expect(page.getByRole('button', { name: /submit signature/i })).toBeEnabled();
});
test('Decline button is initially enabled', async ({ page }) => {
await mockHappyPath(page);
await page.goto(SIGN_URL);
await expect(page.getByText('Sign Document')).toBeVisible();
await expect(page.getByRole('button', { name: /decline/i }).first()).toBeEnabled();
});
});
+22
View File
@@ -13,6 +13,7 @@ import InviteAccept from "@app/routes/InviteAccept";
import ShareLinkPage from "@app/routes/ShareLinkPage";
import ParticipantView from "@app/components/workflow/ParticipantView";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import GuestSignPage from "@app/routes/GuestSignPage";
import Onboarding from "@app/components/onboarding/Onboarding";
// Import global styles
@@ -42,6 +43,17 @@ function ParticipantViewPage() {
return <ParticipantView token={token} />;
}
// Minimal providers for guest signing - token-based, no authentication required
function GuestSigningProviders({ children }: { children: React.ReactNode }) {
return (
<PreferencesProvider>
<RainbowThemeProvider>
{children}
</RainbowThemeProvider>
</PreferencesProvider>
);
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
@@ -66,6 +78,16 @@ export default function App() {
}
/>
{/* Guest signing route - token-based, no authentication required */}
<Route
path="/sign/:token"
element={
<GuestSigningProviders>
<GuestSignPage />
</GuestSigningProviders>
}
/>
{/* All other routes need AppProviders for backend integration */}
<Route
path="*"