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 @@
+
+
+