From ad9fd537cd70165d442a1e00b88462014921694d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:07:21 -0300 Subject: [PATCH 01/15] refactor: add certificate signers merge service Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../File/CertificateSignersMergeService.php | 438 ++++++++++++++++++ 1 file changed, 438 insertions(+) create mode 100644 lib/Service/File/CertificateSignersMergeService.php diff --git a/lib/Service/File/CertificateSignersMergeService.php b/lib/Service/File/CertificateSignersMergeService.php new file mode 100644 index 0000000000..cdb1756d48 --- /dev/null +++ b/lib/Service/File/CertificateSignersMergeService.php @@ -0,0 +1,438 @@ +signers ?? []; + $hasContractSigners = $this->hasContractSigners($existingSigners); + $indexMap = $this->buildSignerIndexMap($existingSigners, $buildIdentifier); + $usedIndexes = []; + $lastMatchedSignerIndex = null; + $singleContractSignerIndex = $this->getSingleContractSignerIndex($existingSigners); + + foreach ($certData as $index => $signer) { + $resolvedUid = $this->resolveCertSignerUid($signer, $existingSigners, $host, $resolveUid); + $matchedIndex = $this->findMatchingSignerIndex($indexMap, $resolvedUid, $signer); + $targetIndex = $this->resolveTargetIndex( + $matchedIndex, + $existingSigners, + $usedIndexes, + $hasContractSigners, + $index, + ); + if ($targetIndex === null) { + $timestampTargetIndex = $lastMatchedSignerIndex ?? $singleContractSignerIndex; + if ($timestampTargetIndex !== null + && isset($fileData->signers[$timestampTargetIndex]) + && $this->isTechnicalTimestampEntry($signer) + ) { + $this->hydrateTimestampOnly($fileData->signers[$timestampTargetIndex], $signer); + } + continue; + } + + $isLibreSignMatch = $matchedIndex !== null && isset($existingSigners[$matchedIndex]->signRequestId); + $usedIndexes[$targetIndex] = true; + + $this->ensureSignerSlotExists($fileData, $targetIndex); + $this->hydrateSignerFromCertData( + $fileData->signers[$targetIndex], + $signer, + $resolvedUid, + $isLibreSignMatch, + $host, + $signedStatusText, + $resolveUid, + $lookupAccountDisplayName, + ); + + if (isset($fileData->signers[$targetIndex]->uid)) { + $indexMap[strtolower((string)$fileData->signers[$targetIndex]->uid)] = $targetIndex; + } + + $lastMatchedSignerIndex = $targetIndex; + } + } + + private function isTechnicalTimestampEntry(array $signer): bool { + if (!isset($signer['timestamp']) || !is_array($signer['timestamp'])) { + return false; + } + + if (isset($signer['uid']) && is_string($signer['uid']) && $signer['uid'] !== '') { + return false; + } + + $subjectUid = $signer['chain'][0]['subject']['UID'] ?? null; + if (is_string($subjectUid) && $subjectUid !== '') { + return false; + } + + return true; + } + + private function getSingleContractSignerIndex(array $signers): ?int { + $contractIndexes = []; + foreach ($signers as $index => $signer) { + if (is_object($signer) && isset($signer->signRequestId) && is_numeric($signer->signRequestId)) { + $contractIndexes[] = $index; + } + } + + if (count($contractIndexes) === 1) { + return $contractIndexes[0]; + } + + return null; + } + + private function hydrateTimestampOnly(\stdClass $targetSigner, array $signer): void { + $targetSigner->timestamp = $signer['timestamp']; + if (isset($signer['timestamp']['genTime']) && $signer['timestamp']['genTime'] instanceof DateTimeInterface) { + $targetSigner->timestamp['genTime'] = $signer['timestamp']['genTime']->format(DateTimeInterface::ATOM); + } + } + + /** + * @param callable(array, string): (?string) $resolveUid + */ + private function resolveCertSignerUid(array $signer, array $existingSigners, string $host, callable $resolveUid): ?string { + if (!isset($signer['chain'][0]) || !is_array($signer['chain'][0])) { + return is_string($signer['uid'] ?? null) ? $signer['uid'] : null; + } + + $resolvedUid = $this->tryMatchWithExistingSigners($signer['chain'][0], $existingSigners, $host, $resolveUid); + if ($resolvedUid) { + return $resolvedUid; + } + + $isLibreSignCert = isset($signer['chain'][0]['isLibreSignRootCA']) + && $signer['chain'][0]['isLibreSignRootCA'] === true; + if ($isLibreSignCert) { + $certUid = $signer['chain'][0]['subject']['UID'] ?? null; + if (!is_string($certUid) || $certUid === '') { + return null; + } + return str_contains($certUid, ':') ? $certUid : 'account:' . $certUid; + } + + if (is_string($signer['uid'] ?? null) && $signer['uid'] !== '') { + return $signer['uid']; + } + + return $resolveUid($signer['chain'][0], $host); + } + + private function resolveTargetIndex( + ?int $matchedIndex, + array $existingSigners, + array $usedIndexes, + bool $hasContractSigners, + int $defaultIndex, + ): ?int { + if ($matchedIndex !== null) { + return $matchedIndex; + } + + if ($hasContractSigners) { + return null; + } + + if (empty($existingSigners)) { + return $defaultIndex; + } + + return $this->nextAvailableSignerIndex($existingSigners, $usedIndexes); + } + + private function ensureSignerSlotExists(\stdClass $fileData, int $targetIndex): void { + if (!isset($fileData->signers[$targetIndex])) { + $fileData->signers[$targetIndex] = new \stdClass(); + } + } + + /** + * @param callable(array, string): (?string) $resolveUid + * @param callable(string): (?string) $lookupAccountDisplayName + */ + private function hydrateSignerFromCertData( + \stdClass $targetSigner, + array $signer, + ?string $resolvedUid, + bool $isLibreSignMatch, + string $host, + string $signedStatusText, + callable $resolveUid, + callable $lookupAccountDisplayName, + ): void { + $preservedDisplayName = $isLibreSignMatch && isset($targetSigner->displayName) + ? $targetSigner->displayName + : null; + + $targetSigner->status = 2; + $targetSigner->statusText = $signedStatusText; + + if (isset($signer['timestamp'])) { + $targetSigner->timestamp = $signer['timestamp']; + if (isset($signer['timestamp']['genTime']) && $signer['timestamp']['genTime'] instanceof DateTimeInterface) { + $targetSigner->timestamp['genTime'] = $signer['timestamp']['genTime']->format(DateTimeInterface::ATOM); + } + } + if (isset($signer['signingTime']) && $signer['signingTime'] instanceof DateTimeInterface) { + $targetSigner->signingTime = $signer['signingTime']; + $targetSigner->signed = $signer['signingTime']->format(DateTimeInterface::ATOM); + } + if (isset($signer['docmdp'])) { + $targetSigner->docmdp = $signer['docmdp']; + } + if (isset($signer['docmdp_validation'])) { + $targetSigner->docmdp_validation = $signer['docmdp_validation']; + } + if (isset($signer['modifications'])) { + $targetSigner->modifications = $signer['modifications']; + } + if (isset($signer['modification_validation'])) { + $targetSigner->modification_validation = $signer['modification_validation']; + } + + if (isset($signer['chain']) && is_array($signer['chain'])) { + $this->processChainData($targetSigner, $signer['chain']); + } + + $this->assignSignerUid($targetSigner, $signer, $resolvedUid, $host, $resolveUid); + + if (isset($signer['signDate'])) { + $targetSigner->signDate = $signer['signDate']; + } + if (isset($signer['type'])) { + $targetSigner->type = $signer['type']; + } + + $this->assignSignerDisplayName($targetSigner, $signer, $preservedDisplayName, $lookupAccountDisplayName); + } + + /** + * @param callable(array, string): (?string) $resolveUid + */ + private function assignSignerUid(\stdClass $targetSigner, array $signer, ?string $resolvedUid, string $host, callable $resolveUid): void { + if (isset($signer['uid'])) { + $targetSigner->uid = $signer['uid']; + return; + } + + if ($resolvedUid) { + $targetSigner->uid = $resolvedUid; + return; + } + + if (isset($signer['chain'][0]) && is_array($signer['chain'][0])) { + $targetSigner->uid = $resolveUid($signer['chain'][0], $host); + } + } + + /** + * @param callable(string): (?string) $lookupAccountDisplayName + */ + private function assignSignerDisplayName(\stdClass $targetSigner, array $signer, ?string $preservedDisplayName, callable $lookupAccountDisplayName): void { + if ($preservedDisplayName) { + $targetSigner->displayName = $preservedDisplayName; + return; + } + + if (isset($targetSigner->uid) && str_starts_with($targetSigner->uid, 'account:')) { + $accountId = substr($targetSigner->uid, strlen('account:')); + $displayName = $lookupAccountDisplayName($accountId); + $targetSigner->displayName = $displayName ?: $accountId; + return; + } + + if (!isset($targetSigner->displayName) && isset($signer['chain'][0])) { + $targetSigner->displayName = $signer['chain'][0]['name'] ?? ($signer['chain'][0]['subject']['CN'] ?? ''); + } + } + + private function hasContractSigners(array $signers): bool { + foreach ($signers as $signer) { + if (is_object($signer) && isset($signer->signRequestId) && is_numeric($signer->signRequestId)) { + return true; + } + if (is_array($signer) && isset($signer['signRequestId']) && is_numeric($signer['signRequestId'])) { + return true; + } + } + + return false; + } + + /** + * @param callable(string, string): string $buildIdentifier + */ + private function buildSignerIndexMap(array $signers, callable $buildIdentifier): array { + $map = []; + foreach ($signers as $index => $signer) { + if (isset($signer->uid)) { + $map[strtolower((string)$signer->uid)] = $index; + } + if (!empty($signer->identifyMethods)) { + foreach ($signer->identifyMethods as $identifyMethod) { + if (isset($identifyMethod['method']) && isset($identifyMethod['value'])) { + $identifier = $buildIdentifier($identifyMethod['method'], $identifyMethod['value']); + $map[strtolower($identifier)] = $index; + } + } + } + } + return $map; + } + + private function findMatchingSignerIndex(array $indexMap, ?string $resolvedUid, array $certSigner): ?int { + $identifiers = []; + if ($resolvedUid) { + $identifiers[] = strtolower($resolvedUid); + } + if (!empty($certSigner['uid'])) { + $identifiers[] = strtolower((string)$certSigner['uid']); + } + foreach ($identifiers as $identifier) { + if (isset($indexMap[$identifier])) { + return $indexMap[$identifier]; + } + } + return null; + } + + private function nextAvailableSignerIndex(array $existingSigners, array $usedIndexes): int { + $index = count($existingSigners); + while (isset($existingSigners[$index]) || isset($usedIndexes[$index])) { + $index++; + } + return $index; + } + + private function processChainData(\stdClass $signer, array $chain): void { + $signer->chain = []; + + foreach ($chain as $chainIndex => $chainItem) { + $chainArr = $chainItem; + + if (isset($chainItem['validFrom_time_t']) && is_numeric($chainItem['validFrom_time_t'])) { + $chainArr['valid_from'] = (new DateTime('@' . $chainItem['validFrom_time_t'], new \DateTimeZone('UTC')))->format(DateTimeInterface::ATOM); + } + if (isset($chainItem['validTo_time_t']) && is_numeric($chainItem['validTo_time_t'])) { + $chainArr['valid_to'] = (new DateTime('@' . $chainItem['validTo_time_t'], new \DateTimeZone('UTC')))->format(DateTimeInterface::ATOM); + } + + $chainArr['displayName'] = $chainArr['name'] ?? ($chainArr['subject']['CN'] ?? ''); + $signer->chain[$chainIndex] = $chainArr; + } + + if (isset($chain[0])) { + $this->enrichSignerWithCertificateValidation($signer, $chain[0]); + } + } + + private function enrichSignerWithCertificateValidation(\stdClass $signer, array $endEntityCert): void { + if (isset($endEntityCert['name']) && !isset($signer->name)) { + $signer->name = $endEntityCert['name']; + } + if (isset($endEntityCert['hash']) && !isset($signer->hash)) { + $signer->hash = $endEntityCert['hash']; + } + if (isset($endEntityCert['serialNumber']) && !isset($signer->serialNumber)) { + $signer->serialNumber = $endEntityCert['serialNumber']; + } + if (isset($endEntityCert['serialNumberHex']) && !isset($signer->serialNumberHex)) { + $signer->serialNumberHex = $endEntityCert['serialNumberHex']; + } + if (isset($endEntityCert['signatureTypeSN']) && !isset($signer->signatureTypeSN)) { + $signer->signatureTypeSN = $endEntityCert['signatureTypeSN']; + } + + if (isset($endEntityCert['subject']) && !isset($signer->subject)) { + $signer->subject = $endEntityCert['subject']; + } + + if (isset($endEntityCert['crl_urls']) && !isset($signer->crl_urls)) { + $signer->crl_urls = $endEntityCert['crl_urls']; + } + if (isset($endEntityCert['crl_validation']) && !isset($signer->crl_validation)) { + $signer->crl_validation = $endEntityCert['crl_validation']; + } + if (isset($endEntityCert['crl_revoked_at']) && !isset($signer->crl_revoked_at)) { + $signer->crl_revoked_at = $endEntityCert['crl_revoked_at']; + } + + if (isset($endEntityCert['signature_validation']) && !isset($signer->signature_validation)) { + $signer->signature_validation = $endEntityCert['signature_validation']; + } + + if (isset($endEntityCert['isLibreSignRootCA']) && !isset($signer->isLibreSignRootCA)) { + $signer->isLibreSignRootCA = $endEntityCert['isLibreSignRootCA']; + } + } + + /** + * @param callable(array, string): (?string) $resolveUid + */ + private function tryMatchWithExistingSigners(array $certData, array $existingSigners, string $host, callable $resolveUid): ?string { + if (empty($existingSigners)) { + return null; + } + + $certSerialNumber = $certData['serialNumber'] ?? null; + $certSerialNumberHex = $certData['serialNumberHex'] ?? null; + $certHash = $certData['hash'] ?? null; + + if (!$certSerialNumber && !$certSerialNumberHex && !$certHash) { + return null; + } + + foreach ($existingSigners as $signer) { + if (!isset($signer->metadata) || !is_array($signer->metadata)) { + continue; + } + + $certInfo = $signer->metadata['certificate_info'] ?? null; + if (!is_array($certInfo)) { + continue; + } + + if ($certSerialNumber && isset($certInfo['serialNumber']) && $certSerialNumber === $certInfo['serialNumber']) { + return $signer->uid ?? $resolveUid($certData, $host); + } + + if ($certSerialNumberHex && isset($certInfo['serialNumberHex']) && $certSerialNumberHex === $certInfo['serialNumberHex']) { + return $signer->uid ?? $resolveUid($certData, $host); + } + + if ($certHash && isset($certInfo['hash']) && $certHash === $certInfo['hash']) { + return $signer->uid ?? $resolveUid($certData, $host); + } + } + + return null; + } +} From 7e58e06c641ae18d6c5b3ffffe377c5e043534ac Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:07:21 -0300 Subject: [PATCH 02/15] test: add certificate signers merge service tests Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../CertificateSignersMergeServiceTest.php | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 tests/php/Unit/Service/File/CertificateSignersMergeServiceTest.php diff --git a/tests/php/Unit/Service/File/CertificateSignersMergeServiceTest.php b/tests/php/Unit/Service/File/CertificateSignersMergeServiceTest.php new file mode 100644 index 0000000000..306652f354 --- /dev/null +++ b/tests/php/Unit/Service/File/CertificateSignersMergeServiceTest.php @@ -0,0 +1,186 @@ +signers = [ + (object)[ + 'signRequestId' => 1, + 'uid' => 'whatsapp:+5500000000', + 'displayName' => 'Contract Signer', + ], + ]; + + $certData = [ + [ + 'chain' => [ + [ + 'subject' => [ + 'UID' => 'whatsapp:+5500000000', + 'CN' => 'Contract Signer', + ], + ], + ], + ], + [ + 'timestamp' => [ + 'genTime' => new DateTime('2026-04-25T18:36:28Z'), + ], + 'chain' => [ + [ + 'subject' => ['CN' => 'www.freetsa.org'], + ], + ], + ], + ]; + + $this->getService()->merge( + $fileData, + $certData, + 'example.com', + 'Signed', + fn (array $cert, string $host): ?string => $cert['subject']['UID'] ?? null, + fn (string $method, string $value): string => $method . ':' . $value, + fn (string $accountId): ?string => null, + ); + + $this->assertCount(1, $fileData->signers); + $this->assertArrayHasKey('timestamp', (array)$fileData->signers[0]); + $this->assertSame('2026-04-25T18:36:28+00:00', $fileData->signers[0]->timestamp['genTime']); + $this->assertObjectNotHasProperty('tsa', $fileData); + } + + #[DataProvider('providerDisplayNameResolution')] + public function testMergeResolvesDisplayNameForAccountUid(?string $accountDisplayName, string $expected): void { + $fileData = new \stdClass(); + + $certData = [ + [ + 'uid' => 'account:admin', + 'chain' => [ + [ + 'name' => 'Admin Cert', + 'subject' => ['CN' => 'admin'], + ], + ], + ], + ]; + + $this->getService()->merge( + $fileData, + $certData, + 'example.com', + 'Signed', + fn (array $cert, string $host): ?string => null, + fn (string $method, string $value): string => $method . ':' . $value, + fn (string $accountId): ?string => $accountDisplayName, + ); + + $this->assertSame($expected, $fileData->signers[0]->displayName); + } + + public static function providerDisplayNameResolution(): array { + return [ + 'display name from lookup' => ['Admin User', 'Admin User'], + 'fallback to account id' => [null, 'admin'], + ]; + } + + #[DataProvider('providerCertificateInfoMatching')] + public function testMergeMatchesExistingSignerByCertificateInfo(string $certificateField, string $value): void { + $fileData = new \stdClass(); + $fileData->signers = [ + (object)[ + 'signRequestId' => 88, + 'uid' => 'account:existing', + 'displayName' => 'Existing Display Name', + 'metadata' => [ + 'certificate_info' => [ + $certificateField => $value, + ], + ], + ], + ]; + + $certData = [ + [ + 'chain' => [ + [ + $certificateField => $value, + 'subject' => ['CN' => 'Certificate CN'], + ], + ], + ], + ]; + + $this->getService()->merge( + $fileData, + $certData, + 'example.com', + 'Signed', + fn (array $cert, string $host): ?string => 'resolved:uid', + fn (string $method, string $rawValue): string => $method . ':' . $rawValue, + fn (string $accountId): ?string => 'Overwritten Name', + ); + + $this->assertCount(1, $fileData->signers); + $this->assertSame('account:existing', $fileData->signers[0]->uid); + $this->assertSame('Existing Display Name', $fileData->signers[0]->displayName); + $this->assertSame(2, $fileData->signers[0]->status); + } + + public static function providerCertificateInfoMatching(): array { + return [ + 'match by serial number' => ['serialNumber', '1234567890'], + 'match by serial number hex' => ['serialNumberHex', 'ABCDEF01'], + 'match by hash' => ['hash', 'deadbeefcafebabe'], + ]; + } + + public function testMergeDoesNotExportTopLevelTsaWithTimestampData(): void { + $fileData = new \stdClass(); + $fileData->signers = []; + + $certData = [[ + 'uid' => 'email:signer@example.com', + 'timestamp' => [ + 'genTime' => new DateTime('2026-01-01T00:00:00Z'), + 'cnHints' => ['commonName' => 'tsa.example.org'], + ], + 'chain' => [[ + 'subject' => ['CN' => 'Signer User'], + ]], + ]]; + + $this->getService()->merge( + $fileData, + $certData, + 'example.com', + 'Signed', + fn (array $cert, string $host): ?string => null, + fn (string $method, string $value): string => $method . ':' . $value, + fn (string $accountId): ?string => null, + ); + + $this->assertCount(1, $fileData->signers); + $this->assertArrayHasKey('timestamp', (array)$fileData->signers[0]); + $this->assertObjectNotHasProperty('tsa', $fileData); + } +} From 724921ddd663043bd326fa9503b2b6a7e4b182a6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:07:21 -0300 Subject: [PATCH 03/15] refactor: delegate signer merge to dedicated service Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/File/SignersLoader.php | 283 ++--------------------------- 1 file changed, 19 insertions(+), 264 deletions(-) diff --git a/lib/Service/File/SignersLoader.php b/lib/Service/File/SignersLoader.php index ae1ec21d6b..d5b6bfd2f5 100644 --- a/lib/Service/File/SignersLoader.php +++ b/lib/Service/File/SignersLoader.php @@ -8,21 +8,21 @@ namespace OCA\Libresign\Service\File; -use DateTime; use DateTimeInterface; use OCA\Libresign\Db\File; use OCA\Libresign\Db\SignRequestMapper; +use OCA\Libresign\Service\File\CertificateSignersMergeService; use OCA\Libresign\Service\IdentifyMethodService; use OCA\Libresign\Service\SubjectAlternativeNameService; use OCP\Accounts\IAccountManager; use OCP\IUserManager; -use stdClass; /** * Handles loading signer data for files */ class SignersLoader { private bool $signersLibreSignLoaded = false; + private CertificateSignersMergeService $certificateSignersMergeService; public function __construct( private SignRequestMapper $signRequestMapper, @@ -31,11 +31,12 @@ public function __construct( private IAccountManager $accountManager, private IUserManager $userManager, ) { + $this->certificateSignersMergeService = new CertificateSignersMergeService(); } public function loadLibreSignSigners( ?File $file, - stdClass $fileData, + \stdClass $fileData, FileResponseOptions $options, array $certData = [], ): void { @@ -54,7 +55,7 @@ public function loadLibreSignSigners( foreach ($signers as $signer) { $identifyMethods = $identifyMethodsBatch[$signer->getId()] ?? []; if (!empty($fileData->signers)) { - $found = array_filter($fileData->signers, function (stdClass $found) use ($identifyMethods) { + $found = array_filter($fileData->signers, function (\stdClass $found) use ($identifyMethods) { if (!isset($found->uid)) { return false; } @@ -78,7 +79,7 @@ public function loadLibreSignSigners( $index = 0; } if (!isset($fileData->signers[$index])) { - $fileData->signers[$index] = new stdClass(); + $fileData->signers[$index] = new \stdClass(); } $fileData->signers[$index]->signRequestId = $signer->getId(); $fileData->signers[$index]->signed = $signer->getSigned()?->format(DateTimeInterface::ATOM); @@ -160,7 +161,7 @@ public function loadLibreSignSigners( } $fileData->signers[$index]->me = false; if ($options->getMe() || $options->getIdentifyMethodId()) { - $currentUserData = new stdClass(); + $currentUserData = new \stdClass(); $currentUserData->me = false; foreach ($identifyMethods as $methods) { foreach ($methods as $identifyMethod) { @@ -209,269 +210,23 @@ public function loadLibreSignSigners( $this->signersLibreSignLoaded = true; } - public function loadSignersFromCertData(stdClass $fileData, array $certData, string $host): void { - $existingSigners = $fileData->signers ?? []; - $indexMap = $this->buildSignerIndexMap($existingSigners); - $usedIndexes = []; - - foreach ($certData as $index => $signer) { - $targetIndex = $index; - $isLibreSignMatch = false; - - $resolvedUid = $this->tryMatchWithExistingSigners($signer['chain'][0], $existingSigners, $host); - if (!$resolvedUid) { - $isLibreSignCert = isset($signer['chain'][0]['isLibreSignRootCA']) - && $signer['chain'][0]['isLibreSignRootCA'] === true; - if ($isLibreSignCert) { - $certUid = $signer['chain'][0]['subject']['UID'] ?? null; - if ($certUid) { - $resolvedUid = str_contains($certUid, ':') ? $certUid : 'account:' . $certUid; - } else { - $resolvedUid = null; - } - } else { - $resolvedUid = $signer['uid'] ?? null; - if (!$resolvedUid && isset($signer['chain'][0])) { - $resolvedUid = $this->identifyMethodService->resolveUid($signer['chain'][0], $host); - } - } - } - - $matchedIndex = $this->findMatchingSignerIndex($indexMap, $resolvedUid, $signer); - if ($matchedIndex !== null) { - $targetIndex = $matchedIndex; - $isLibreSignMatch = isset($existingSigners[$matchedIndex]->signRequestId); - } else { - if (!empty($existingSigners)) { - $targetIndex = $this->nextAvailableSignerIndex($existingSigners, $usedIndexes); - } - } - $usedIndexes[$targetIndex] = true; - - if (!isset($fileData->signers[$targetIndex])) { - $fileData->signers[$targetIndex] = new stdClass(); - } - - $preservedDisplayName = $isLibreSignMatch && isset($fileData->signers[$targetIndex]->displayName) - ? $fileData->signers[$targetIndex]->displayName - : null; - - $fileData->signers[$targetIndex]->status = 2; - $fileData->signers[$targetIndex]->statusText = $this->signRequestMapper->getTextOfSignerStatus(2); - - if (isset($signer['timestamp'])) { - $fileData->signers[$targetIndex]->timestamp = $signer['timestamp']; - if (isset($signer['timestamp']['genTime']) && $signer['timestamp']['genTime'] instanceof DateTimeInterface) { - $fileData->signers[$targetIndex]->timestamp['genTime'] = $signer['timestamp']['genTime']->format(DateTimeInterface::ATOM); - } - } - if (isset($signer['signingTime']) && $signer['signingTime'] instanceof DateTimeInterface) { - $fileData->signers[$targetIndex]->signingTime = $signer['signingTime']; - $fileData->signers[$targetIndex]->signed = $signer['signingTime']->format(DateTimeInterface::ATOM); - } - if (isset($signer['docmdp'])) { - $fileData->signers[$targetIndex]->docmdp = $signer['docmdp']; - } - if (isset($signer['docmdp_validation'])) { - $fileData->signers[$targetIndex]->docmdp_validation = $signer['docmdp_validation']; - } - if (isset($signer['modifications'])) { - $fileData->signers[$targetIndex]->modifications = $signer['modifications']; - } - if (isset($signer['modification_validation'])) { - $fileData->signers[$targetIndex]->modification_validation = $signer['modification_validation']; - } - - if (isset($signer['chain'])) { - $this->processChainData($fileData->signers[$targetIndex], $signer['chain']); - } - - if (isset($signer['uid'])) { - $fileData->signers[$targetIndex]->uid = $signer['uid']; - } elseif ($resolvedUid) { - $fileData->signers[$targetIndex]->uid = $resolvedUid; - } elseif (isset($signer['chain'][0])) { - $fileData->signers[$targetIndex]->uid = $this->identifyMethodService->resolveUid($signer['chain'][0], $host); - } - - if (isset($signer['signDate'])) { - $fileData->signers[$targetIndex]->signDate = $signer['signDate']; - } - if (isset($signer['type'])) { - $fileData->signers[$targetIndex]->type = $signer['type']; - } - - if ($preservedDisplayName) { - $fileData->signers[$targetIndex]->displayName = $preservedDisplayName; - } elseif (isset($fileData->signers[$targetIndex]->uid) && str_starts_with($fileData->signers[$targetIndex]->uid, 'account:')) { - $accountId = substr($fileData->signers[$targetIndex]->uid, strlen('account:')); + public function loadSignersFromCertData(\stdClass $fileData, array $certData, string $host): void { + $this->certificateSignersMergeService->merge( + $fileData, + $certData, + $host, + $this->signRequestMapper->getTextOfSignerStatus(2), + fn (array $certData, string $currentHost): ?string => $this->identifyMethodService->resolveUid($certData, $currentHost), + fn (string $method, string $value): string => $this->subjectAlternativeNameService->build($method, $value), + function (string $accountId): ?string { $user = $this->userManager->get($accountId); - if ($user) { - $fileData->signers[$targetIndex]->displayName = $user->getDisplayName(); - } else { - $fileData->signers[$targetIndex]->displayName = $accountId; - } - } elseif (!isset($fileData->signers[$targetIndex]->displayName) && isset($signer['chain'][0])) { - $fileData->signers[$targetIndex]->displayName = $signer['chain'][0]['name'] ?? ($signer['chain'][0]['subject']['CN'] ?? ''); - } - - if (isset($fileData->signers[$targetIndex]->uid)) { - $indexMap[strtolower((string)$fileData->signers[$targetIndex]->uid)] = $targetIndex; - } - } - } - - private function buildSignerIndexMap(array $signers): array { - $map = []; - foreach ($signers as $index => $signer) { - if (isset($signer->uid)) { - $map[strtolower((string)$signer->uid)] = $index; - } - if (!empty($signer->identifyMethods)) { - foreach ($signer->identifyMethods as $identifyMethod) { - if (isset($identifyMethod['method']) && isset($identifyMethod['value'])) { - $identifier = $this->subjectAlternativeNameService->build($identifyMethod['method'], $identifyMethod['value']); - $map[strtolower($identifier)] = $index; - } - } - } - } - return $map; - } - - private function findMatchingSignerIndex(array $indexMap, ?string $resolvedUid, array $certSigner): ?int { - $identifiers = []; - if ($resolvedUid) { - $identifiers[] = strtolower($resolvedUid); - } - if (!empty($certSigner['uid'])) { - $identifiers[] = strtolower((string)$certSigner['uid']); - } - foreach ($identifiers as $identifier) { - if (isset($indexMap[$identifier])) { - return $indexMap[$identifier]; - } - } - return null; - } - - private function nextAvailableSignerIndex(array $existingSigners, array $usedIndexes): int { - $index = count($existingSigners); - while (isset($existingSigners[$index]) || isset($usedIndexes[$index])) { - $index++; - } - return $index; + return $user ? $user->getDisplayName() : null; + }, + ); } public function reset(): void { $this->signersLibreSignLoaded = false; } - private function processChainData(stdClass $signer, array $chain): void { - $signer->chain = []; - - foreach ($chain as $chainIndex => $chainItem) { - $chainArr = $chainItem; - - if (isset($chainItem['validFrom_time_t']) && is_numeric($chainItem['validFrom_time_t'])) { - $chainArr['valid_from'] = (new DateTime('@' . $chainItem['validFrom_time_t'], new \DateTimeZone('UTC')))->format(DateTimeInterface::ATOM); - } - if (isset($chainItem['validTo_time_t']) && is_numeric($chainItem['validTo_time_t'])) { - $chainArr['valid_to'] = (new DateTime('@' . $chainItem['validTo_time_t'], new \DateTimeZone('UTC')))->format(DateTimeInterface::ATOM); - } - - $chainArr['displayName'] = $chainArr['name'] ?? ($chainArr['subject']['CN'] ?? ''); - $signer->chain[$chainIndex] = $chainArr; - } - - if (isset($chain[0])) { - $this->enrichSignerWithCertificateValidation($signer, $chain[0]); - } - } - - private function enrichSignerWithCertificateValidation(stdClass $signer, array $endEntityCert): void { - if (isset($endEntityCert['name']) && !isset($signer->name)) { - $signer->name = $endEntityCert['name']; - } - if (isset($endEntityCert['hash']) && !isset($signer->hash)) { - $signer->hash = $endEntityCert['hash']; - } - if (isset($endEntityCert['serialNumber']) && !isset($signer->serialNumber)) { - $signer->serialNumber = $endEntityCert['serialNumber']; - } - if (isset($endEntityCert['serialNumberHex']) && !isset($signer->serialNumberHex)) { - $signer->serialNumberHex = $endEntityCert['serialNumberHex']; - } - if (isset($endEntityCert['signatureTypeSN']) && !isset($signer->signatureTypeSN)) { - $signer->signatureTypeSN = $endEntityCert['signatureTypeSN']; - } - - if (isset($endEntityCert['subject']) && !isset($signer->subject)) { - $signer->subject = $endEntityCert['subject']; - } - - if (isset($endEntityCert['crl_urls']) && !isset($signer->crl_urls)) { - $signer->crl_urls = $endEntityCert['crl_urls']; - } - if (isset($endEntityCert['crl_validation']) && !isset($signer->crl_validation)) { - $signer->crl_validation = $endEntityCert['crl_validation']; - } - if (isset($endEntityCert['crl_revoked_at']) && !isset($signer->crl_revoked_at)) { - $signer->crl_revoked_at = $endEntityCert['crl_revoked_at']; - } - - if (isset($endEntityCert['signature_validation']) && !isset($signer->signature_validation)) { - $signer->signature_validation = $endEntityCert['signature_validation']; - } - - if (isset($endEntityCert['isLibreSignRootCA']) && !isset($signer->isLibreSignRootCA)) { - $signer->isLibreSignRootCA = $endEntityCert['isLibreSignRootCA']; - } - } - - private function tryMatchWithExistingSigners(array $certData, array $existingSigners, string $host): ?string { - if (empty($existingSigners)) { - return null; - } - - $certSerialNumber = $certData['serialNumber'] ?? null; - $certSerialNumberHex = $certData['serialNumberHex'] ?? null; - $certHash = $certData['hash'] ?? null; - - if (!$certSerialNumber && !$certSerialNumberHex && !$certHash) { - return null; - } - - foreach ($existingSigners as $signer) { - if (!isset($signer->metadata) || !is_array($signer->metadata)) { - continue; - } - - $certInfo = $signer->metadata['certificate_info'] ?? null; - if (!is_array($certInfo)) { - continue; - } - - if ($certSerialNumber && isset($certInfo['serialNumber'])) { - if ($certSerialNumber === $certInfo['serialNumber']) { - return $signer->uid ?? $this->identifyMethodService->resolveUid($certData, $host); - } - } - - if ($certSerialNumberHex && isset($certInfo['serialNumberHex'])) { - if ($certSerialNumberHex === $certInfo['serialNumberHex']) { - return $signer->uid ?? $this->identifyMethodService->resolveUid($certData, $host); - } - } - - if ($certHash && isset($certInfo['hash'])) { - if ($certHash === $certInfo['hash']) { - return $signer->uid ?? $this->identifyMethodService->resolveUid($certData, $host); - } - } - } - - return null; - } - } From be83c3c108bb4d534634ce7a064ceece6026e3cc Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:07:21 -0300 Subject: [PATCH 04/15] test: update signers loader expectations Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Unit/Service/File/SignersLoaderTest.php | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/php/Unit/Service/File/SignersLoaderTest.php b/tests/php/Unit/Service/File/SignersLoaderTest.php index c8b4829efc..7533fef791 100644 --- a/tests/php/Unit/Service/File/SignersLoaderTest.php +++ b/tests/php/Unit/Service/File/SignersLoaderTest.php @@ -380,6 +380,80 @@ public function testLoadSignersFromCertDataMatchesLibreSignSignerByUid(): void { $this->assertTrue(isset($signer->name)); } + public function testLoadSignersFromCertDataDoesNotAppendUnmatchedTsaEntryWhenContractSignersExist(): void { + $this->signRequestMapper->method('getTextOfSignerStatus')->willReturn('Signed'); + $this->identifyMethodService->method('resolveUid')->willReturn('email:external@example.com'); + + $fileData = new \stdClass(); + $fileData->signers = [ + (object)[ + 'signRequestId' => 638, + 'uid' => 'whatsapp:+5521976887906', + 'displayName' => 'Daiane Alves', + ], + ]; + + $certData = [ + [ + 'chain' => [ + [ + 'subject' => [ + 'UID' => 'whatsapp:+5521976887906', + 'CN' => 'Daiane Alves', + ], + ], + ], + 'signingTime' => new DateTime('2026-04-25T18:36:27Z'), + ], + [ + 'status' => 2, + 'statusText' => 'Signed', + 'timestamp' => [ + 'genTime' => new DateTime('2026-04-25T18:36:28Z'), + ], + 'chain' => [ + [ + 'subject' => [ + 'CN' => 'www.freetsa.org', + ], + ], + ], + ], + ]; + + $this->getService()->loadSignersFromCertData($fileData, $certData, 'example.com'); + + $this->assertCount(1, $fileData->signers); + $this->assertSame(638, $fileData->signers[0]->signRequestId); + $this->assertSame('Daiane Alves', $fileData->signers[0]->displayName); + $this->assertArrayHasKey('timestamp', (array)$fileData->signers[0]); + $this->assertSame('2026-04-25T18:36:28+00:00', $fileData->signers[0]->timestamp['genTime']); + $this->assertObjectNotHasProperty('tsa', $fileData); + } + + public function testLoadSignersFromCertDataDoesNotExportTopLevelTsaWithoutTimestamp(): void { + $this->signRequestMapper->method('getTextOfSignerStatus')->willReturn('Signed'); + $this->identifyMethodService->method('resolveUid')->willReturn('email:signer@example.com'); + + $fileData = new \stdClass(); + + $certData = [ + [ + 'chain' => [ + [ + 'subject' => [ + 'CN' => 'Signer CN', + ], + ], + ], + ], + ]; + + $this->getService()->loadSignersFromCertData($fileData, $certData, 'example.com'); + + $this->assertObjectNotHasProperty('tsa', $fileData); + } + public function testLoadSignersFromCertDataPreventsDuplicateFormattedDates(): void { $this->signRequestMapper->method('getTextOfSignerStatus')->willReturn('Signed'); $this->identifyMethodService->expects($this->never())->method('resolveUid'); From 94c8025b5580de688819175ab8dddaf54f4aa0a5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:07:21 -0300 Subject: [PATCH 05/15] refactor: extract signer timestamp component Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/validation/SignerTimestamp.vue | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 src/components/validation/SignerTimestamp.vue diff --git a/src/components/validation/SignerTimestamp.vue b/src/components/validation/SignerTimestamp.vue new file mode 100644 index 0000000000..1270fde642 --- /dev/null +++ b/src/components/validation/SignerTimestamp.vue @@ -0,0 +1,216 @@ + + + + + + From 92b8475392eb2c0dd02870a6e5bf2e40416231aa Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:07:21 -0300 Subject: [PATCH 06/15] test: add signer timestamp component tests Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../validation/SignerTimestamp.spec.ts | 314 ++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 src/tests/components/validation/SignerTimestamp.spec.ts diff --git a/src/tests/components/validation/SignerTimestamp.spec.ts b/src/tests/components/validation/SignerTimestamp.spec.ts new file mode 100644 index 0000000000..5a7d299e84 --- /dev/null +++ b/src/tests/components/validation/SignerTimestamp.spec.ts @@ -0,0 +1,314 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import type { VueWrapper } from '@vue/test-utils' + +type SignerTimestampComponent = typeof import('../../../components/validation/SignerTimestamp.vue').default + +type SignerTimestampData = { + genTime?: string + policy?: string + policyName?: string + hash?: string + hashAlgorithm?: string + serialNumber?: string | number + authority?: string + tsaName?: string + cnHints?: { commonName?: string } +} + +type SignerTimestampVm = { + open: boolean + authority: string + policy: string + hashAlgorithm: string + serialNumber: string + hasContent: boolean + toggleAriaLabel: string + $nextTick: () => Promise + dateFromSqlAnsi: (date?: string | number | null) => string +} + +type SignerTimestampWrapper = VueWrapper + +let SignerTimestamp: SignerTimestampComponent + +vi.mock('@nextcloud/l10n', () => globalThis.mockNextcloudL10n()) + +vi.mock('@nextcloud/moment', () => ({ + default: vi.fn((value: string) => ({ + format: vi.fn(() => `Formatted: ${value}`), + })), +})) + +beforeAll(async () => { + ;({ default: SignerTimestamp } = await import('../../../components/validation/SignerTimestamp.vue')) +}) + +describe('SignerTimestamp', () => { + let wrapper!: SignerTimestampWrapper + + const createWrapper = (props: { timestamp?: SignerTimestampData } = {}): SignerTimestampWrapper => { + return mount(SignerTimestamp, { + props, + global: { + stubs: { + NcListItem: false, + NcButton: true, + NcIconSvgWrapper: { template: '
' }, + }, + mocks: { + t: (_app: string, text: string) => text, + }, + }, + }) as unknown as SignerTimestampWrapper + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('RULE: renders nothing when timestamp has no meaningful content', () => { + it('renders nothing when timestamp is undefined', () => { + wrapper = createWrapper() + expect(wrapper.find('.extra').exists()).toBe(false) + }) + + it('renders nothing when timestamp is an empty object', () => { + wrapper = createWrapper({ timestamp: {} }) + expect(wrapper.find('.extra').exists()).toBe(false) + }) + + it('hasContent is false when all fields are absent', () => { + wrapper = createWrapper({ timestamp: {} }) + expect(wrapper.vm.hasContent).toBe(false) + }) + }) + + describe('RULE: renders header when any timestamp field is present', () => { + it('renders header when authority is provided', () => { + wrapper = createWrapper({ timestamp: { authority: 'tsa.example.org' } }) + expect(wrapper.find('.extra').exists()).toBe(true) + expect(wrapper.vm.hasContent).toBe(true) + }) + + it('renders header when cnHints.commonName is provided', () => { + wrapper = createWrapper({ timestamp: { cnHints: { commonName: 'tsa.example.org' } } }) + expect(wrapper.find('.extra').exists()).toBe(true) + }) + + it('renders header when tsaName is provided', () => { + wrapper = createWrapper({ timestamp: { tsaName: 'FreeTSA' } }) + expect(wrapper.find('.extra').exists()).toBe(true) + }) + + it('renders header when genTime is provided', () => { + wrapper = createWrapper({ timestamp: { genTime: '2026-04-25T18:36:28+00:00' } }) + expect(wrapper.find('.extra').exists()).toBe(true) + }) + + it('renders header when policyName is provided', () => { + wrapper = createWrapper({ timestamp: { policyName: '1.2.3.4.1' } }) + expect(wrapper.find('.extra').exists()).toBe(true) + }) + + it('renders header when hashAlgorithm is provided', () => { + wrapper = createWrapper({ timestamp: { hashAlgorithm: 'SHA-256' } }) + expect(wrapper.find('.extra').exists()).toBe(true) + }) + + it('renders header when serialNumber is a string', () => { + wrapper = createWrapper({ timestamp: { serialNumber: 'AABB' } }) + expect(wrapper.find('.extra').exists()).toBe(true) + }) + + it('renders header when serialNumber is a number', () => { + wrapper = createWrapper({ timestamp: { serialNumber: 42 } }) + expect(wrapper.find('.extra').exists()).toBe(true) + }) + }) + + describe('RULE: details panel is hidden by default and toggles on click', () => { + it('initializes with closed state', () => { + wrapper = createWrapper({ timestamp: { authority: 'tsa.example.org' } }) + expect(wrapper.vm.open).toBe(false) + }) + + it('does not render detail panel when closed', () => { + wrapper = createWrapper({ timestamp: { authority: 'tsa.example.org' } }) + expect(wrapper.find('.timestamp-wrapper').exists()).toBe(false) + }) + + it('renders detail panel when open', async () => { + wrapper = createWrapper({ timestamp: { authority: 'tsa.example.org' } }) + wrapper.vm.open = true + await wrapper.vm.$nextTick() + expect(wrapper.find('.timestamp-wrapper').exists()).toBe(true) + }) + + it('toggles open state', () => { + wrapper = createWrapper({ timestamp: { authority: 'tsa.example.org' } }) + wrapper.vm.open = true + expect(wrapper.vm.open).toBe(true) + wrapper.vm.open = false + expect(wrapper.vm.open).toBe(false) + }) + }) + + describe('RULE: authority resolves with priority cnHints > authority > tsaName', () => { + it('prefers cnHints.commonName over authority and tsaName', () => { + wrapper = createWrapper({ + timestamp: { + cnHints: { commonName: 'cn.example.org' }, + authority: 'auth.example.org', + tsaName: 'tsa.example.org', + }, + }) + expect(wrapper.vm.authority).toBe('cn.example.org') + }) + + it('falls back to authority when cnHints is absent', () => { + wrapper = createWrapper({ + timestamp: { + authority: 'auth.example.org', + tsaName: 'tsa.example.org', + }, + }) + expect(wrapper.vm.authority).toBe('auth.example.org') + }) + + it('falls back to tsaName when both cnHints and authority are absent', () => { + wrapper = createWrapper({ timestamp: { tsaName: 'tsa.example.org' } }) + expect(wrapper.vm.authority).toBe('tsa.example.org') + }) + + it('returns empty string when no authority field is present', () => { + wrapper = createWrapper({ timestamp: { genTime: '2026-01-01T00:00:00Z' } }) + expect(wrapper.vm.authority).toBe('') + }) + }) + + describe('RULE: policy resolves policyName before policy', () => { + it('prefers policyName over policy', () => { + wrapper = createWrapper({ timestamp: { policyName: 'named-policy', policy: 'raw-policy' } }) + expect(wrapper.vm.policy).toBe('named-policy') + }) + + it('falls back to policy when policyName is absent', () => { + wrapper = createWrapper({ timestamp: { policy: 'raw-policy' } }) + expect(wrapper.vm.policy).toBe('raw-policy') + }) + + it('returns empty string when neither is present', () => { + wrapper = createWrapper({ timestamp: { authority: 'tsa.example.org' } }) + expect(wrapper.vm.policy).toBe('') + }) + }) + + describe('RULE: hashAlgorithm resolves hashAlgorithm before hash', () => { + it('prefers hashAlgorithm over hash', () => { + wrapper = createWrapper({ timestamp: { hashAlgorithm: 'SHA-256', hash: 'SHA-1' } }) + expect(wrapper.vm.hashAlgorithm).toBe('SHA-256') + }) + + it('falls back to hash when hashAlgorithm is absent', () => { + wrapper = createWrapper({ timestamp: { hash: 'SHA-1' } }) + expect(wrapper.vm.hashAlgorithm).toBe('SHA-1') + }) + }) + + describe('RULE: serialNumber is coerced to string', () => { + it('returns string serial as-is', () => { + wrapper = createWrapper({ timestamp: { serialNumber: 'AABB' } }) + expect(wrapper.vm.serialNumber).toBe('AABB') + }) + + it('converts numeric serial to string', () => { + wrapper = createWrapper({ timestamp: { serialNumber: 42 } }) + expect(wrapper.vm.serialNumber).toBe('42') + }) + + it('returns empty string when serialNumber is absent', () => { + wrapper = createWrapper({ timestamp: { authority: 'tsa.example.org' } }) + expect(wrapper.vm.serialNumber).toBe('') + }) + }) + + describe('RULE: toggleAriaLabel reflects open state', () => { + it('shows "Expand" label when closed', () => { + wrapper = createWrapper({ timestamp: { authority: 'tsa.example.org' } }) + expect(wrapper.vm.toggleAriaLabel).toBe('Expand timestamp authority details') + }) + + it('shows "Collapse" label when open', () => { + wrapper = createWrapper({ timestamp: { authority: 'tsa.example.org' } }) + wrapper.vm.open = true + expect(wrapper.vm.toggleAriaLabel).toBe('Collapse timestamp authority details') + }) + }) + + describe('RULE: detail fields render when open and data is present', () => { + const fullTimestamp: SignerTimestampData = { + cnHints: { commonName: 'tsa.example.org' }, + genTime: '2026-04-25T18:36:28+00:00', + policyName: '1.2.3.4.1', + hashAlgorithm: 'SHA-256', + serialNumber: '01AB', + } + + it('renders authority field', async () => { + wrapper = createWrapper({ timestamp: fullTimestamp }) + wrapper.vm.open = true + await wrapper.vm.$nextTick() + expect(wrapper.find('.timestamp-wrapper').text()).toContain('tsa.example.org') + }) + + it('renders policy field', async () => { + wrapper = createWrapper({ timestamp: fullTimestamp }) + wrapper.vm.open = true + await wrapper.vm.$nextTick() + expect(wrapper.find('.timestamp-wrapper').text()).toContain('1.2.3.4.1') + }) + + it('renders hashAlgorithm field', async () => { + wrapper = createWrapper({ timestamp: fullTimestamp }) + wrapper.vm.open = true + await wrapper.vm.$nextTick() + expect(wrapper.find('.timestamp-wrapper').text()).toContain('SHA-256') + }) + + it('renders serialNumber field', async () => { + wrapper = createWrapper({ timestamp: fullTimestamp }) + wrapper.vm.open = true + await wrapper.vm.$nextTick() + expect(wrapper.find('.timestamp-wrapper').text()).toContain('01AB') + }) + + it('does not render absent optional fields', async () => { + wrapper = createWrapper({ timestamp: { authority: 'tsa.example.org' } }) + wrapper.vm.open = true + await wrapper.vm.$nextTick() + const fields = wrapper.findAll('.timestamp-field') + expect(fields).toHaveLength(1) + }) + }) + + describe('RULE: dateFromSqlAnsi formats dates via Moment', () => { + it('returns empty string for falsy input', () => { + wrapper = createWrapper({ timestamp: { authority: 'tsa.example.org' } }) + expect(wrapper.vm.dateFromSqlAnsi(null)).toBe('') + expect(wrapper.vm.dateFromSqlAnsi(undefined)).toBe('') + expect(wrapper.vm.dateFromSqlAnsi('')).toBe('') + }) + + it('formats a valid date string', () => { + wrapper = createWrapper({ timestamp: { authority: 'tsa.example.org' } }) + const result = wrapper.vm.dateFromSqlAnsi('2026-04-25T18:36:28+00:00') + expect(result).toContain('Formatted:') + }) + }) +}) From e06726d07be4e4c3125963581a818fd1e4a55f0f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 25 Apr 2026 20:07:22 -0300 Subject: [PATCH 07/15] fix: simplify signer details crl messaging Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/validation/SignerDetails.vue | 51 ++++++++++++++++++--- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/src/components/validation/SignerDetails.vue b/src/components/validation/SignerDetails.vue index 89e2ef89c2..7e26bc1413 100644 --- a/src/components/validation/SignerDetails.vue +++ b/src/components/validation/SignerDetails.vue @@ -59,6 +59,9 @@ + + +