From 4f9e38b86b4745c0fac410307386700ceadcb10a Mon Sep 17 00:00:00 2001 From: Paulo Raoni Date: Fri, 23 Oct 2026 00:15:40 -0300 Subject: [PATCH 01/16] fix: sign a frozen request with the footer it stores Later footer policy changes, locks and the validator no longer apply to it. Signed-off-by: Paulo Raoni --- lib/Db/FileMapper.php | 17 +++ .../Provider/Footer/FooterPolicyValue.php | 17 +++ lib/Service/SignFileService.php | 39 ++---- tests/php/Integration/Db/FileMapperTest.php | 21 +++ .../Provider/Footer/FooterPolicyValueTest.php | 24 ++++ .../php/Unit/Service/SignFileServiceTest.php | 122 ++++++++++++++++++ 6 files changed, 214 insertions(+), 26 deletions(-) diff --git a/lib/Db/FileMapper.php b/lib/Db/FileMapper.php index 82924f23ba..d27ff72510 100644 --- a/lib/Db/FileMapper.php +++ b/lib/Db/FileMapper.php @@ -311,6 +311,23 @@ public function getParentEnvelope(int $fileId): ?File { return $this->getById($file->getParentFileId()); } + /** + * The envelope of a document, or null when it has none or the envelope no + * longer exists. + */ + public function findEnvelope(File $file): ?File { + $envelopeId = $file->getParentFileId(); + if ($envelopeId === null) { + return null; + } + + try { + return $this->getById($envelopeId); + } catch (DoesNotExistException) { + return null; + } + } + public function countChildrenFiles(int $envelopeId): int { $qb = $this->db->getQueryBuilder(); diff --git a/lib/Service/Policy/Provider/Footer/FooterPolicyValue.php b/lib/Service/Policy/Provider/Footer/FooterPolicyValue.php index 5a390bcf1c..ece2562899 100644 --- a/lib/Service/Policy/Provider/Footer/FooterPolicyValue.php +++ b/lib/Service/Policy/Provider/Footer/FooterPolicyValue.php @@ -8,6 +8,8 @@ namespace OCA\Libresign\Service\Policy\Provider\Footer; +use OCA\Libresign\Db\File as FileEntity; + final class FooterPolicyValue { /** @return array{enabled: bool, writeQrcodeOnFooter: bool, validationSite: string, customizeFooterTemplate: bool, footerTemplate: string, previewWidth: int, previewHeight: int, previewZoom: int} */ public static function defaults(string $defaultTemplate = ''): array { @@ -78,6 +80,21 @@ public static function isQrCodeEnabled(mixed $rawValue): bool { return $normalized['enabled'] && $normalized['writeQrcodeOnFooter']; } + public static function getSnapshotEffectiveValue(FileEntity $file): ?string { + $metadata = $file->getMetadata() ?? []; + $policySnapshot = $metadata['policy_snapshot'] ?? null; + if (!is_array($policySnapshot)) { + return null; + } + + $effectiveValue = $policySnapshot[FooterPolicy::KEY]['effectiveValue'] ?? null; + if (!is_string($effectiveValue) || trim($effectiveValue) === '') { + return null; + } + + return $effectiveValue; + } + private static function toBool(mixed $rawValue): bool { if (is_bool($rawValue)) { return $rawValue; diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php index 519e13a877..afcc25d500 100644 --- a/lib/Service/SignFileService.php +++ b/lib/Service/SignFileService.php @@ -987,9 +987,7 @@ private function buildBaseSignatureParams(array $certificateData): array { } private function buildValidationUrl(string $uuid): string { - $footerPolicy = FooterPolicyValue::normalize( - $this->policyService->resolve(FooterPolicy::KEY)->getEffectiveValue() - ); + $footerPolicy = FooterPolicyValue::normalize($this->getFooterPolicyValue()); $validationSite = trim($footerPolicy['validationSite']); if ($validationSite !== '') { return rtrim($validationSite, '/') . '/' . $uuid; @@ -1425,31 +1423,20 @@ protected function getPdfToSign(File $originalFile): File { return $this->createSignedFile($originalFile, $pdfContent); } - /** @return array */ - private function resolveFooterPolicyRequestOverridesFromFileMetadata(): array { - $metadata = $this->libreSignFile->getMetadata(); - if (!is_array($metadata)) { - return []; - } - - $policySnapshot = $metadata['policy_snapshot'] ?? null; - if (!is_array($policySnapshot)) { - return []; - } - - $footerSnapshot = $policySnapshot[FooterPolicy::KEY] ?? null; - if (!is_array($footerSnapshot)) { - return []; - } - - $effectiveValue = $footerSnapshot['effectiveValue'] ?? null; - if (!is_string($effectiveValue) || trim($effectiveValue) === '') { - return []; + /** + * Once frozen, the request signs with the footer it stores: it was resolved + * and validated when stored, so later policy changes do not apply to it. + */ + private function getFooterPolicyValue(): string { + if ($this->footerPolicyFile !== $this->libreSignFile) { + $storedFooter = FooterPolicyValue::getSnapshotEffectiveValue($this->libreSignFile); + $this->footerPolicyValue = $storedFooter !== null && FileStatusService::isPolicySnapshotFrozen($this->libreSignFile, $this->fileMapper->findEnvelope($this->libreSignFile)) + ? $storedFooter + : (string)$this->policyService->resolve(FooterPolicy::KEY)->getEffectiveValue(); + $this->footerPolicyFile = $this->libreSignFile; } - return [ - FooterPolicy::KEY => $effectiveValue, - ]; + return $this->footerPolicyValue; } protected function getSignedFile(): ?File { diff --git a/tests/php/Integration/Db/FileMapperTest.php b/tests/php/Integration/Db/FileMapperTest.php index 381b2af6bb..9802746c35 100644 --- a/tests/php/Integration/Db/FileMapperTest.php +++ b/tests/php/Integration/Db/FileMapperTest.php @@ -109,6 +109,27 @@ public function testLateQueuePublicationCannotRevertCompletedSignature(): void { } } + public function testFindEnvelope(): void { + $envelope = $this->fileMapper->insert($this->createFileEntity( + uuid: 'c3333333-3333-4333-8333-333333333333', + nodeId: 30303, + userId: 'owner-user' + )); + $document = $this->createFileEntity( + uuid: 'd4444444-4444-4444-8444-444444444444', + nodeId: 40404, + userId: 'owner-user' + ); + + $this->assertNull($this->fileMapper->findEnvelope($document)); + + $document->setParentFileId($envelope->getId()); + $this->assertSame($envelope->getId(), $this->fileMapper->findEnvelope($document)?->getId()); + + $document->setParentFileId($envelope->getId() + 1000000); + $this->assertNull($this->fileMapper->findEnvelope($document)); + } + private function createFileEntity(string $uuid, int $nodeId, ?string $userId): File { $file = new File(); $file->setNodeId($nodeId); diff --git a/tests/php/Unit/Service/Policy/Provider/Footer/FooterPolicyValueTest.php b/tests/php/Unit/Service/Policy/Provider/Footer/FooterPolicyValueTest.php index 6325096595..072b1aa6b9 100644 --- a/tests/php/Unit/Service/Policy/Provider/Footer/FooterPolicyValueTest.php +++ b/tests/php/Unit/Service/Policy/Provider/Footer/FooterPolicyValueTest.php @@ -8,6 +8,7 @@ namespace OCA\Libresign\Tests\Unit\Service\Policy\Provider\Footer; +use OCA\Libresign\Db\File; use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -233,4 +234,27 @@ public static function isQrCodeEnabledCases(): array { ], ]; } + + /** @param array|null $metadata */ + #[DataProvider('snapshotCases')] + public function testGetSnapshotEffectiveValue(?array $metadata, ?string $expected): void { + $file = new File(); + if ($metadata !== null) { + $file->setMetadata($metadata); + } + + $this->assertSame($expected, FooterPolicyValue::getSnapshotEffectiveValue($file)); + } + + /** @return array|null, 1: ?string}> */ + public static function snapshotCases(): array { + $footer = FooterPolicyValue::encode(['enabled' => false]); + return [ + 'no metadata' => [null, null], + 'no snapshot' => [[], null], + 'no footer in the snapshot' => [['policy_snapshot' => ['signature_flow' => ['effectiveValue' => 'parallel']]], null], + 'empty footer' => [['policy_snapshot' => ['add_footer' => ['effectiveValue' => ' ']]], null], + 'stored footer' => [['policy_snapshot' => ['add_footer' => ['effectiveValue' => $footer]]], $footer], + ]; + } } diff --git a/tests/php/Unit/Service/SignFileServiceTest.php b/tests/php/Unit/Service/SignFileServiceTest.php index db5b9bdc4f..8b7bc4a357 100644 --- a/tests/php/Unit/Service/SignFileServiceTest.php +++ b/tests/php/Unit/Service/SignFileServiceTest.php @@ -198,6 +198,7 @@ public function setUp(): void { ->method('resolve') ->willReturnCallback(function (string|\BackedEnum $policyKey): ResolvedPolicy { $key = $policyKey instanceof \BackedEnum ? (string)$policyKey->value : $policyKey; + $this->resolvedPolicyKeys[] = $key; return (new ResolvedPolicy()) ->setPolicyKey($key) ->setEffectiveValue($this->policyValues[$key] ?? null); @@ -1353,6 +1354,127 @@ public function testGetSignatureParamsUsesCustomValidationSite(): void { $this->assertArrayNotHasKey('qrcode', $actual); } + /** @param array $metadata */ + #[DataProvider('provideFrozenFooterRequests')] + public function testAFrozenRequestValidatesAtTheSiteItStores(int $status, array $metadata, ?File $envelope): void { + // A regular signer could not submit this site, and a later admin change must not replace it. + $this->policyValues[FooterPolicy::KEY] = FooterPolicyValue::encode(['validationSite' => 'https://current.example/']); + $libreSignFile = $this->createRequestWithStoredFooter($status, $metadata, FooterPolicyValue::encode(['validationSite' => 'https://frozen.example/'])); + if ($envelope !== null) { + $libreSignFile->setParentFileId(5); + $this->fileMapper->method('findEnvelope')->with($libreSignFile)->willReturn($envelope); + } + + $actual = $this->getSignatureParamsFor($libreSignFile); + + $this->assertSame('https://frozen.example/uuid', $actual['ValidationURL']); + $this->assertNotContains(FooterPolicy::KEY, $this->resolvedPolicyKeys); + } + + /** @return iterable, 2: ?File}> */ + public static function provideFrozenFooterRequests(): iterable { + $frozenEnvelope = new File(); + $frozenEnvelope->setMetadata(['policy_snapshot_frozen_at' => '2026-01-01T00:00:00+00:00']); + + yield 'sent' => [FileStatus::ABLE_TO_SIGN->value, ['policy_snapshot_frozen_at' => '2026-01-01T00:00:00+00:00'], null]; + yield 'returned to draft' => [FileStatus::DRAFT->value, ['policy_snapshot_frozen_at' => '2026-01-01T00:00:00+00:00'], null]; + yield 'sent before the freeze was recorded' => [FileStatus::ABLE_TO_SIGN->value, [], null]; + yield 'document of a sent envelope' => [FileStatus::DRAFT->value, [], $frozenEnvelope]; + } + + /** @param array $metadata */ + #[DataProvider('provideRequestsWithoutAFrozenFooter')] + public function testARequestWithoutAFrozenFooterValidatesAtTheCurrentSite(int $status, array $metadata, ?string $storedFooter): void { + $this->policyValues[FooterPolicy::KEY] = FooterPolicyValue::encode(['validationSite' => 'https://current.example/']); + $libreSignFile = $this->createRequestWithStoredFooter($status, $metadata, $storedFooter); + + $actual = $this->getSignatureParamsFor($libreSignFile); + + $this->assertSame('https://current.example/uuid', $actual['ValidationURL']); + } + + /** @return iterable, 2: ?string}> */ + public static function provideRequestsWithoutAFrozenFooter(): iterable { + yield 'sent before footers were stored' => [FileStatus::ABLE_TO_SIGN->value, [], null]; + yield 'draft never sent' => [FileStatus::DRAFT->value, [], FooterPolicyValue::encode(['validationSite' => 'https://draft.example/'])]; + } + + public function testAFrozenEnvelopeDocumentStampsAndValidatesWithTheFooterItStores(): void { + $storedFooter = FooterPolicyValue::encode(['validationSite' => 'https://frozen.example/', 'customizeFooterTemplate' => true, 'footerTemplate' => '

Stored

']); + $this->policyValues[FooterPolicy::KEY] = FooterPolicyValue::encode(['enabled' => false]); + $libreSignFile = $this->createRequestWithStoredFooter(FileStatus::DRAFT->value, [], $storedFooter); + $libreSignFile->setId(10); + $libreSignFile->setParentFileId(5); + $frozenEnvelope = new File(); + $frozenEnvelope->setMetadata(['policy_snapshot_frozen_at' => '2026-01-01T00:00:00+00:00']); + // The stamp and the validation URL share one lookup. + $this->fileMapper->expects($this->once())->method('findEnvelope')->with($libreSignFile)->willReturn($frozenEnvelope); + + $owner = $this->createMock(\OCP\IUser::class); + $owner->method('getUID')->willReturn('signer1'); + $signedFile = $this->createMock(\OCP\Files\File::class); + $folder = $this->createMock(\OCP\Files\Folder::class); + $folder->method('newFile')->willReturn($signedFile); + $originalFile = $this->createMock(\OCP\Files\File::class); + $originalFile->method('getContent')->willReturn('%PDF-original-content%'); + $originalFile->method('getExtension')->willReturn('pdf'); + $originalFile->method('getPath')->willReturn('/Documents/source.pdf'); + $originalFile->method('getOwner')->willReturn($owner); + $originalFile->method('getParent')->willReturn($folder); + + $this->footerHandler->method('getMetadata')->willReturn(['d' => [['w' => 595.0, 'h' => 842.0]]]); + $this->footerHandler->method('setTemplateVar')->willReturnSelf(); + $this->footerHandler + ->expects($this->once()) + ->method('setPolicyValue') + ->with($storedFooter) + ->willReturnSelf(); + $this->footerHandler->method('getFooter')->willReturn(''); + + $service = $this->getService(['getSignedFile', 'getSigners', 'readCertificate']); + $service->method('getSignedFile')->willReturn(null); + $service->method('getSigners')->willReturn([]); + $service->method('readCertificate')->willReturn(['issuer' => ['CN' => 'LibreCode'], 'subject' => ['CN' => 'Jane Doe']]); + $service->setSignRequest($this->createSignRequestMock(['getId' => 171, 'getMetadata' => []])); + + $actual = self::invokePrivate($service->setLibreSignFile($libreSignFile), 'getPdfToSign', [$originalFile]); + $signatureParams = self::invokePrivate($service, 'getSignatureParams'); + + $this->assertSame($signedFile, $actual); + $this->assertSame('https://frozen.example/uuid', $signatureParams['ValidationURL']); + $this->assertNotContains(FooterPolicy::KEY, $this->resolvedPolicyKeys); + } + + /** @param array $metadata */ + private function createRequestWithStoredFooter(int $status, array $metadata, ?string $storedFooter): File { + $libreSignFile = new File(); + $libreSignFile->setUuid('uuid'); + $libreSignFile->setStatus($status); + if ($storedFooter !== null) { + $metadata['policy_snapshot'] = [ + FooterPolicy::KEY => ['effectiveValue' => $storedFooter, 'sourceScope' => 'request'], + ]; + } + $libreSignFile->setMetadata($metadata); + return $libreSignFile; + } + + /** @return array */ + private function getSignatureParamsFor(File $libreSignFile): array { + $service = $this->getService(['readCertificate']); + $service->setLibreSignFile($libreSignFile); + $service->method('readCertificate')->willReturn([ + 'issuer' => ['CN' => 'LibreCode'], + 'subject' => ['CN' => 'Jane Doe'], + ]); + $service->setSignRequest($this->createSignRequestMock([ + 'getId' => 171, + 'getMetadata' => [], + ])); + + return $this->invokePrivate($service, 'getSignatureParams'); + } + public static function providerGetSignatureParamsCommonName(): array { return [ 'simple CNs' => [ From 9a406ecaf5c6556cbaaa27ab1d51dce3903dac72 Mon Sep 17 00:00:00 2001 From: Paulo Raoni Date: Fri, 23 Oct 2026 00:15:40 -0300 Subject: [PATCH 02/16] fix: freeze the footer once the request is sent Signed-off-by: Paulo Raoni --- .../FilePolicy/FooterFilePolicyApplier.php | 33 +++++++ .../RequestSignatureControllerTest.php | 2 + .../FooterFilePolicyApplierTest.php | 90 +++++++++++++++++++ 3 files changed, 125 insertions(+) diff --git a/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php b/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php index 66774bf5c3..2db4793b94 100644 --- a/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php +++ b/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php @@ -9,9 +9,12 @@ namespace OCA\Libresign\Service\Policy\Provider\Footer\FilePolicy; use OCA\Libresign\Db\File as FileEntity; +use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Service\Policy\AbstractFilePolicyApplier; use OCA\Libresign\Service\Policy\Model\ResolvedPolicy; use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicy; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue; +use OCP\AppFramework\Http; use OCP\IUser; class FooterFilePolicyApplier extends AbstractFilePolicyApplier { @@ -31,6 +34,11 @@ public function apply(FileEntity $file, array $data): void { #[\Override] public function sync(FileEntity $file, array $data): void { $requestOverrides = $this->getOverrides($data); + if ($this->isPolicySnapshotFrozen($file, FooterPolicy::KEY)) { + $this->assertFrozenFooterIsKept($file, $requestOverrides); + return; + } + $activeContext = $this->extractActiveContext($data); $resolvedPolicy = $activeContext === null ? $this->policyService->resolveForUserId(FooterPolicy::KEY, $file->getUserId(), $requestOverrides) @@ -59,4 +67,29 @@ private function getOverrides(array $data): array { private function assertOverrideAllowed(array $requestOverrides, ResolvedPolicy $resolvedPolicy): void { $this->assertRequestOverrideAllowed($requestOverrides, $resolvedPolicy, 'Footer template override is blocked by %s.'); } + + /** + * A frozen request keeps the footer it was sent with. A client may still send + * that same footer along an unrelated update, which changes nothing. + * + * @param array $requestOverrides + */ + private function assertFrozenFooterIsKept(FileEntity $file, array $requestOverrides): void { + $submittedFooter = $requestOverrides[FooterPolicy::KEY] ?? null; + if ($submittedFooter === null) { + return; + } + + $storedFooter = FooterPolicyValue::getSnapshotEffectiveValue($file); + if ($storedFooter !== null && FooterPolicyValue::normalize($submittedFooter) === FooterPolicyValue::normalize($storedFooter)) { + return; + } + + throw new LibresignException( + // TRANSLATORS Error shown when someone tries to change the footer stamped on the signed document after the signing flow already started. + $this->l10n?->t('The footer cannot be changed after the signing flow has started.') + ?? 'The footer cannot be changed after the signing flow has started.', + Http::STATUS_UNPROCESSABLE_ENTITY, + ); + } } diff --git a/tests/php/Api/Controller/RequestSignatureControllerTest.php b/tests/php/Api/Controller/RequestSignatureControllerTest.php index e79afb8b7d..28ed513319 100644 --- a/tests/php/Api/Controller/RequestSignatureControllerTest.php +++ b/tests/php/Api/Controller/RequestSignatureControllerTest.php @@ -9,6 +9,8 @@ namespace OCA\Libresign\Tests\Api\Controller; use OCA\Libresign\AppInfo\Application; +use OCA\Libresign\Db\SignRequestMapper; +use OCA\Libresign\Service\RequestSignatureService; use OCA\Libresign\Tests\Api\ApiTestCase; /** diff --git a/tests/php/Unit/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplierTest.php b/tests/php/Unit/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplierTest.php index b01489afc4..b643188330 100644 --- a/tests/php/Unit/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplierTest.php +++ b/tests/php/Unit/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplierTest.php @@ -8,16 +8,23 @@ namespace OCA\Libresign\Tests\Unit\Service\Policy\Provider\Footer\FilePolicy; +use OCA\Libresign\Db\File; +use OCA\Libresign\Enum\FileStatus; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Service\FileService; +use OCA\Libresign\Service\Policy\Model\PolicySpec; use OCA\Libresign\Service\Policy\Model\ResolvedPolicy; use OCA\Libresign\Service\Policy\PolicyService; use OCA\Libresign\Service\Policy\Provider\Footer\FilePolicy\FooterFilePolicyApplier; use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicy; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue; use OCP\IL10N; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; final class FooterFilePolicyApplierTest extends \OCA\Libresign\Tests\Unit\TestCase { + private const FROZEN = ['policy_snapshot_frozen_at' => '2026-01-01T00:00:00+00:00']; + private PolicyService&MockObject $policyService; private FileService&MockObject $fileService; private IL10N&MockObject $l10n; @@ -25,6 +32,7 @@ final class FooterFilePolicyApplierTest extends \OCA\Libresign\Tests\Unit\TestCa public function setUp(): void { parent::setUp(); $this->policyService = $this->createMock(PolicyService::class); + $this->policyService->method('getRequestLifecycle')->willReturn(PolicySpec::LIFECYCLE_REQUEST_SNAPSHOT); $this->fileService = $this->createMock(FileService::class); $this->l10n = $this->createMock(IL10N::class); $this->l10n->method('t')->willReturnArgument(0); @@ -106,6 +114,88 @@ public function testSyncPersistsWhenSnapshotChanges(): void { ]); } + /** + * @param array $metadata + * @param array $policyOverrides + */ + #[DataProvider('provideFrozenUpdates')] + public function testAFrozenRequestKeepsItsStoredFooter(int $status, array $metadata, array $policyOverrides): void { + $file = $this->createStoredRequest($status, $metadata); + $storedMetadata = $file->getMetadata(); + + $this->policyService->expects($this->never())->method('resolveForUserId'); + $this->fileService->expects($this->never())->method('update'); + + $this->getApplier()->sync($file, ['policyOverrides' => $policyOverrides]); + + $this->assertSame($storedMetadata, $file->getMetadata()); + } + + /** @return iterable, 2: array}> */ + public static function provideFrozenUpdates(): iterable { + yield 'nothing submitted' => [FileStatus::ABLE_TO_SIGN->value, self::FROZEN, []]; + yield 'stored footer sent again' => [FileStatus::ABLE_TO_SIGN->value, self::FROZEN, [FooterPolicy::KEY => self::storedFooter()]]; + yield 'stored footer sent again in another shape' => [FileStatus::ABLE_TO_SIGN->value, self::FROZEN, [FooterPolicy::KEY => ['customizeFooterTemplate' => true, 'footerTemplate' => '

Stored

']]]; + yield 'null submitted' => [FileStatus::ABLE_TO_SIGN->value, self::FROZEN, [FooterPolicy::KEY => null]]; + yield 'returned to draft' => [FileStatus::DRAFT->value, self::FROZEN, []]; + yield 'sent before the freeze was recorded' => [FileStatus::ABLE_TO_SIGN->value, [], []]; + } + + /** @param array $metadata */ + #[DataProvider('provideFrozenChangeAttempts')] + public function testChangingTheFooterOfAFrozenRequestIsRefused(int $status, array $metadata, string $submittedFooter): void { + $file = $this->createStoredRequest($status, $metadata); + + $this->policyService->expects($this->never())->method('resolveForUserId'); + $this->fileService->expects($this->never())->method('update'); + $this->expectException(LibresignException::class); + $this->expectExceptionCode(422); + $this->expectExceptionMessage('The footer cannot be changed after the signing flow has started.'); + + $this->getApplier()->sync($file, ['policyOverrides' => [FooterPolicy::KEY => $submittedFooter]]); + } + + /** @return iterable, 2: string}> */ + public static function provideFrozenChangeAttempts(): iterable { + $anotherFooter = FooterPolicyValue::encode(['enabled' => false]); + yield 'another footer' => [FileStatus::ABLE_TO_SIGN->value, self::FROZEN, $anotherFooter]; + yield 'after returning to draft' => [FileStatus::DRAFT->value, self::FROZEN, $anotherFooter]; + yield 'sent before the footer was stored' => [FileStatus::ABLE_TO_SIGN->value, ['policy_snapshot' => []], self::storedFooter()]; + } + + public function testADraftStillResolvesTheSubmittedFooter(): void { + $file = $this->createStoredRequest(FileStatus::DRAFT->value, []); + $submittedFooter = FooterPolicyValue::encode(['enabled' => false]); + + $this->policyService + ->expects($this->once()) + ->method('resolveForUserId') + ->with(FooterPolicy::KEY, 'john', [FooterPolicy::KEY => $submittedFooter]) + ->willReturn($this->createResolvedPolicy($submittedFooter, sourceScope: 'request')); + $this->fileService->expects($this->once())->method('update'); + + $this->getApplier()->sync($file, ['policyOverrides' => [FooterPolicy::KEY => $submittedFooter]]); + + $this->assertSame($submittedFooter, $file->getMetadata()['policy_snapshot'][FooterPolicy::KEY]['effectiveValue']); + } + + private static function storedFooter(): string { + return FooterPolicyValue::encode(['customizeFooterTemplate' => true, 'footerTemplate' => '

Stored

']); + } + + /** @param array $metadata */ + private function createStoredRequest(int $status, array $metadata): File { + $file = new File(); + $file->setUserId('john'); + $file->setStatus($status); + $file->setMetadata($metadata + [ + 'policy_snapshot' => [ + FooterPolicy::KEY => ['effectiveValue' => self::storedFooter(), 'sourceScope' => 'request'], + ], + ]); + return $file; + } + private function createResolvedPolicy( string $effectiveValue, string $sourceScope = 'system', From 89790cc22cd3ed87375090123cd5951d04a1ee9a Mon Sep 17 00:00:00 2001 From: Paulo Raoni Date: Fri, 23 Oct 2026 00:15:41 -0300 Subject: [PATCH 03/16] fix: stop offering the footer template choice once the request is frozen Signed-off-by: Paulo Raoni --- .../RightSidebar/RequestSignatureTab.vue | 2 ++ .../RightSidebar/RequestSignatureTab.spec.ts | 34 +++++++++++++++++++ .../settings/signature-footer/model.spec.ts | 9 +++++ .../settings/signature-footer/model.ts | 4 ++- 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/components/RightSidebar/RequestSignatureTab.vue b/src/components/RightSidebar/RequestSignatureTab.vue index 3f2a92c237..5b30011437 100644 --- a/src/components/RightSidebar/RequestSignatureTab.vue +++ b/src/components/RightSidebar/RequestSignatureTab.vue @@ -513,6 +513,7 @@ const showFooterTemplateSelector = computed(() => { && isCurrentFileDetailed.value && filesStore.canSave() && canChooseFooterTemplateAtRequestLevel.value + && !isPolicySnapshotFrozen.value && footerTemplateSourceOptions.value.length > 1 }) const showRememberFooterTemplate = computed(() => showFooterTemplateSelector.value && canSaveFooterPreference.value) @@ -552,6 +553,7 @@ function getFooterPolicyPayloadForSave(): string | null { canChooseFooterTemplateAtRequestLevel.value, footerTemplateSourceOptions.value, selectedFooterTemplateSource.value, + isPolicySnapshotFrozen.value, ) } diff --git a/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts b/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts index 6768dc3874..9419b0e3f4 100644 --- a/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts +++ b/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts @@ -568,6 +568,40 @@ describe('RequestSignatureTab - Critical Business Rules', () => { }) }) + describe('RULE: a frozen request keeps the footer it was sent with', () => { + const twoTemplates = { + canSaveAsUserDefault: true, + canUseAsRequestOverride: true, + effectiveValue: '{"enabled":true,"writeQrcodeOnFooter":true,"validationSite":"","customizeFooterTemplate":true,"footerTemplate":"

Personal template

","previewWidth":595,"previewHeight":100,"previewZoom":100}', + inheritedValue: '{"enabled":true,"writeQrcodeOnFooter":true,"validationSite":"","customizeFooterTemplate":true,"footerTemplate":"

Group template

","previewWidth":595,"previewHeight":100,"previewZoom":100}', + } + + it('does not offer the footer template choice and sends no footer', async () => { + await updateFile({ + status: FILE_STATUS.DRAFT, + policySnapshotFrozen: true, + signers: [{ email: 'test1@example.com', signed: [] }], + }) + await updateFooterPolicy(twoTemplates) + + expect(wrapper.vm.showFooterTemplateSelector).toBe(false) + expect(wrapper.vm.showRememberFooterTemplate).toBe(false) + expect(wrapper.vm.getFooterPolicyPayloadForSave()).toBeNull() + }) + + it('keeps the footer template choice while the request is a draft', async () => { + await updateFile({ + status: FILE_STATUS.DRAFT, + policySnapshotFrozen: false, + signers: [{ email: 'test1@example.com', signed: [] }], + }) + await updateFooterPolicy(twoTemplates) + + expect(wrapper.vm.showFooterTemplateSelector).toBe(true) + expect(wrapper.vm.getFooterPolicyPayloadForSave()).toContain('Personal template') + }) + }) + describe('RULE: showViewOrderButton for ordered signatures', () => { it('shows when signature flow is ordered_numeric', async () => { await updateFile({ diff --git a/src/tests/views/Settings/PolicyWorkbench/settings/signature-footer/model.spec.ts b/src/tests/views/Settings/PolicyWorkbench/settings/signature-footer/model.spec.ts index c9ab39af6b..46632ef0ee 100644 --- a/src/tests/views/Settings/PolicyWorkbench/settings/signature-footer/model.spec.ts +++ b/src/tests/views/Settings/PolicyWorkbench/settings/signature-footer/model.spec.ts @@ -141,4 +141,13 @@ describe('signature-footer model', () => { expect(resolveFooterPolicyPayloadForRequest(false, options, 'effective')).toBeNull() expect(resolveFooterPolicyPayloadForRequest(true, [], 'effective')).toBeNull() }) + + it('returns null footer payload once the request is frozen', () => { + const options = buildFooterTemplateSourceOptions({ + effectiveValue: '{"enabled":true,"writeQrcodeOnFooter":true,"validationSite":"","customizeFooterTemplate":true,"footerTemplate":"

Personal template

","previewWidth":595,"previewHeight":100,"previewZoom":100}', + sourceScope: 'user', + }, footerTemplateLabels) + + expect(resolveFooterPolicyPayloadForRequest(true, options, 'effective', true)).toBeNull() + }) }) diff --git a/src/views/Settings/PolicyWorkbench/settings/signature-footer/model.ts b/src/views/Settings/PolicyWorkbench/settings/signature-footer/model.ts index 1600438165..6e08177e80 100644 --- a/src/views/Settings/PolicyWorkbench/settings/signature-footer/model.ts +++ b/src/views/Settings/PolicyWorkbench/settings/signature-footer/model.ts @@ -235,8 +235,10 @@ export function resolveFooterPolicyPayloadForRequest( canChooseFooterTemplateAtRequestLevel: boolean, options: FooterTemplateSourceOption[], selectedSource: FooterTemplateSource, + isFrozen = false, ): string | null { - if (!canChooseFooterTemplateAtRequestLevel) { + // A frozen request keeps the footer it stores, so it is not sent again. + if (!canChooseFooterTemplateAtRequestLevel || isFrozen) { return null } From 6280583555f5521b7151ee64cdd9e7768f55a586 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:50:52 +0000 Subject: [PATCH 04/16] fix: synchronize footer snapshots on UUID request updates Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Footer/FilePolicy/FooterFilePolicyApplier.php | 2 +- .../Api/Controller/RequestSignatureControllerTest.php | 3 +++ tests/php/Unit/Service/Policy/FilePolicyApplierTest.php | 9 +++++++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php b/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php index 2db4793b94..1bb1cea91d 100644 --- a/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php +++ b/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php @@ -55,7 +55,7 @@ public function sync(FileEntity $file, array $data): void { #[\Override] public function supportsCoreFlowSync(): bool { - return false; + return true; } /** @return array */ diff --git a/tests/php/Api/Controller/RequestSignatureControllerTest.php b/tests/php/Api/Controller/RequestSignatureControllerTest.php index 28ed513319..ced6d7f60c 100644 --- a/tests/php/Api/Controller/RequestSignatureControllerTest.php +++ b/tests/php/Api/Controller/RequestSignatureControllerTest.php @@ -9,6 +9,9 @@ namespace OCA\Libresign\Tests\Api\Controller; use OCA\Libresign\AppInfo\Application; +use OCA\Libresign\Db\FileMapper; +use OCA\Libresign\Enum\FileStatus; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Service\RequestSignatureService; use OCA\Libresign\Tests\Api\ApiTestCase; diff --git a/tests/php/Unit/Service/Policy/FilePolicyApplierTest.php b/tests/php/Unit/Service/Policy/FilePolicyApplierTest.php index a16f4a15d1..eb0642f90c 100644 --- a/tests/php/Unit/Service/Policy/FilePolicyApplierTest.php +++ b/tests/php/Unit/Service/Policy/FilePolicyApplierTest.php @@ -157,7 +157,7 @@ public function testApplyAllAppliesAllRegisteredFilePolicies(): void { ], $metadata['policy_snapshot'][SignatureRejectionPolicy::KEY] ?? null); } - public function testSyncCoreFlowPoliciesSkipsNonCoreProviders(): void { + public function testSyncCoreFlowPoliciesPersistsFooterSnapshot(): void { $file = new FileEntity(); $file->setUserId('john'); $file->setSignatureFlowEnum(SignatureFlow::NONE); @@ -178,6 +178,11 @@ public function testSyncCoreFlowPoliciesSkipsNonCoreProviders(): void { DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS->value, 'system', ), + FooterPolicy::KEY => $this->createResolvedPolicy( + FooterPolicy::KEY, + '{"enabled":false}', + 'system', + ), IdentificationDocumentsPolicy::KEY => $this->createResolvedPolicy( IdentificationDocumentsPolicy::KEY, ['enabled' => true, 'approvers' => ['legal']], @@ -206,7 +211,7 @@ public function testSyncCoreFlowPoliciesSkipsNonCoreProviders(): void { $this->getApplier()->syncCoreFlowPolicies($file, []); - $this->assertArrayNotHasKey(FooterPolicy::KEY, $file->getMetadata()['policy_snapshot']); + $this->assertSame('{"enabled":false}', $file->getMetadata()['policy_snapshot'][FooterPolicy::KEY]['effectiveValue']); $this->assertArrayHasKey(IdentificationDocumentsPolicy::KEY, $file->getMetadata()['policy_snapshot']); $this->assertArrayHasKey(SignatureRejectionPolicy::KEY, $file->getMetadata()['policy_snapshot']); $this->assertSame(SignatureFlow::ORDERED_NUMERIC, $file->getSignatureFlowEnum()); From 75c0fc5d0147c30e784f2b86e22dcc67fed984e5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:57:15 +0000 Subject: [PATCH 05/16] test: fix UUID footer regression CI assertions Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Api/Controller/RequestSignatureControllerTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/php/Api/Controller/RequestSignatureControllerTest.php b/tests/php/Api/Controller/RequestSignatureControllerTest.php index ced6d7f60c..dcd2c553f5 100644 --- a/tests/php/Api/Controller/RequestSignatureControllerTest.php +++ b/tests/php/Api/Controller/RequestSignatureControllerTest.php @@ -10,9 +10,9 @@ use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\FileMapper; +use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Enum\FileStatus; use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue; -use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Service\RequestSignatureService; use OCA\Libresign\Tests\Api\ApiTestCase; From 5e0cdb7af45dc9e0f60710da640e664191f190dc Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:03:38 +0000 Subject: [PATCH 06/16] test: avoid self-signer response in footer lifecycle regression Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> From e5397cf0f9dfccfdf49d6275954109ee3af997cd Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:10:55 +0000 Subject: [PATCH 07/16] fix: preserve inherited validation site in footer policy resolution Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Policy/Provider/Footer/FooterPolicy.php | 5 ++-- .../Provider/Footer/FooterPolicyTest.php | 28 ++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/lib/Service/Policy/Provider/Footer/FooterPolicy.php b/lib/Service/Policy/Provider/Footer/FooterPolicy.php index bf2d1c9f38..2f5d009068 100644 --- a/lib/Service/Policy/Provider/Footer/FooterPolicy.php +++ b/lib/Service/Policy/Provider/Footer/FooterPolicy.php @@ -55,8 +55,9 @@ public function get(string|\BackedEnum $policyKey): IPolicyDefinition { } if (!self::canManageTechnicalFooterSettings($context)) { - $normalized = FooterPolicyValue::normalize($decoded); - if ($normalized['validationSite'] !== '') { + // An inherited administrator-configured URL is not a request override. + $submittedFooter = $context->getRequestOverrides()[self::KEY] ?? null; + if ($submittedFooter !== null && FooterPolicyValue::normalize($submittedFooter)['validationSite'] !== '') { throw new \InvalidArgumentException('Validation URL override is not allowed for this actor'); } } diff --git a/tests/php/Unit/Service/Policy/Provider/Footer/FooterPolicyTest.php b/tests/php/Unit/Service/Policy/Provider/Footer/FooterPolicyTest.php index 0f24f0c50a..f01e9f1897 100644 --- a/tests/php/Unit/Service/Policy/Provider/Footer/FooterPolicyTest.php +++ b/tests/php/Unit/Service/Policy/Provider/Footer/FooterPolicyTest.php @@ -178,7 +178,8 @@ public function testValidationSiteOverrideRules( ]); $context = (new PolicyContext()) - ->setActorRole($actorRole); + ->setActorRole($actorRole) + ->setRequestOverrides([FooterPolicy::KEY => $value]); if ($expectException) { $this->expectException(\InvalidArgumentException::class); @@ -191,6 +192,31 @@ public function testValidationSiteOverrideRules( } } + /** A regular requester must be able to use the URL stored by an administrator. */ + #[DataProvider('inheritedValidationSiteCases')] + public function testInheritedValidationSiteIsAllowedForRegularActors(array $requestOverrides): void { + $definition = (new FooterPolicy())->get(FooterPolicy::KEY); + $inheritedFooter = $definition->normalizeValue([ + 'enabled' => true, + 'validationSite' => 'https://admin.example/validate', + ]); + $context = (new PolicyContext()) + ->setActorRole(ActorRole::regularUser()) + ->setRequestOverrides($requestOverrides); + + $definition->validateValue($inheritedFooter, $context); + + $this->addToAssertionCount(1); + } + + /** @return iterable}> */ + public static function inheritedValidationSiteCases(): iterable { + yield 'no request override' => [[]]; + yield 'footer override without validation URL' => [[ + FooterPolicy::KEY => FooterPolicyValue::encode(['enabled' => false]), + ]]; + } + #[DataProvider('provideCanCurrentActorManageGroupPolicyCases')] public function testCanCurrentActorManageGroupPolicyWithDataProvider( ActorRole $actorRole, From fb13775be166c1e150c365f578bb0209d7e8de93 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:13:44 +0000 Subject: [PATCH 08/16] fix: reject unauthorized validation URL persistence fix: reject unauthorized validation URL persistence Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> [skip ci] --- .../Policy/Provider/Footer/FooterPolicy.php | 5 ++++- .../Policy/Provider/Footer/FooterPolicyTest.php | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/Service/Policy/Provider/Footer/FooterPolicy.php b/lib/Service/Policy/Provider/Footer/FooterPolicy.php index 2f5d009068..b32a7b3bdb 100644 --- a/lib/Service/Policy/Provider/Footer/FooterPolicy.php +++ b/lib/Service/Policy/Provider/Footer/FooterPolicy.php @@ -62,8 +62,11 @@ public function get(string|\BackedEnum $policyKey): IPolicyDefinition { } } }, - persistenceValidator: static function (mixed $value, PolicyContext $_context): void { + persistenceValidator: static function (mixed $value, PolicyContext $context): void { $footerPolicy = FooterPolicyValue::normalize($value); + if (!self::canManageTechnicalFooterSettings($context) && $footerPolicy['validationSite'] !== '') { + throw new \InvalidArgumentException('Validation URL override is not allowed for this actor'); + } // Allow a legacy template to be disabled or replaced without executing it. if ($footerPolicy['enabled'] && $footerPolicy['customizeFooterTemplate'] && $footerPolicy['footerTemplate'] !== '') { SandboxedTemplateRenderer::validate($footerPolicy['footerTemplate']); diff --git a/tests/php/Unit/Service/Policy/Provider/Footer/FooterPolicyTest.php b/tests/php/Unit/Service/Policy/Provider/Footer/FooterPolicyTest.php index f01e9f1897..e16f64f00b 100644 --- a/tests/php/Unit/Service/Policy/Provider/Footer/FooterPolicyTest.php +++ b/tests/php/Unit/Service/Policy/Provider/Footer/FooterPolicyTest.php @@ -217,6 +217,20 @@ public static function inheritedValidationSiteCases(): iterable { ]]; } + public function testRegularActorCannotPersistCustomValidationSite(): void { + $definition = (new FooterPolicy())->get(FooterPolicy::KEY); + $footer = $definition->normalizeValue([ + 'enabled' => true, + 'validationSite' => 'https://not-allowed.example', + ]); + $context = (new PolicyContext())->setActorRole(ActorRole::regularUser()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Validation URL override is not allowed for this actor'); + + $definition->validateValueForPersistence($footer, $context); + } + #[DataProvider('provideCanCurrentActorManageGroupPolicyCases')] public function testCanCurrentActorManageGroupPolicyWithDataProvider( ActorRole $actorRole, From d14620d44f092fcae6cddcc5d6acd6079a2053d4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:03:05 +0000 Subject: [PATCH 09/16] fix: complete frozen-footer rendering for stable35 Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Handler/FooterHandler.php | 25 +++++++++++++------ lib/Service/FileStatusService.php | 14 +++++++++++ .../Policy/AbstractFilePolicyApplier.php | 17 +++++++++++++ lib/Service/SignFileService.php | 5 +++- .../php/Unit/Service/SignFileServiceTest.php | 3 +++ 5 files changed, 56 insertions(+), 8 deletions(-) diff --git a/lib/Handler/FooterHandler.php b/lib/Handler/FooterHandler.php index 57b9a68481..86168bd53e 100644 --- a/lib/Handler/FooterHandler.php +++ b/lib/Handler/FooterHandler.php @@ -38,6 +38,7 @@ class FooterHandler { private QrCode $qrCode; /** @var array */ private array $requestPolicyOverrides = []; + private ?string $policyValue = null; private ?string $templateOverride = null; private ?bool $writeQrcodeOnFooterOverride = null; private const int MIN_QRCODE_SIZE = 100; @@ -125,6 +126,15 @@ public function setTemplateVar(string $name, mixed $value): self { /** @param array $requestPolicyOverrides */ public function setRequestPolicyOverrides(array $requestPolicyOverrides): self { $this->requestPolicyOverrides = $requestPolicyOverrides; + $this->policyValue = null; + return $this; + } + + /** + * Render with the exact policy frozen on the request, without re-resolving it. + */ + public function setPolicyValue(?string $policyValue): self { + $this->policyValue = $policyValue; return $this; } @@ -139,14 +149,12 @@ public function setTemplateOverride(?string $template): self { } public function getEffectiveFooterPolicyAsJson(): string { - return (string)$this->policyService->resolve(FooterPolicy::KEY, $this->requestPolicyOverrides)->getEffectiveValue(); + return (string)$this->getPolicyValue(); } /** @return array{enabled: bool, writeQrcodeOnFooter: bool, validationSite: string, customizeFooterTemplate: bool, footerTemplate: string, previewWidth: int, previewHeight: int, previewZoom: int} */ private function resolveFooterPolicy(): array { - return FooterPolicyValue::normalize( - $this->policyService->resolve(FooterPolicy::KEY, $this->requestPolicyOverrides)->getEffectiveValue() - ); + return FooterPolicyValue::normalize($this->getPolicyValue()); } private function prepareTemplateVars(bool $forceEnabled = false): array { @@ -256,8 +264,11 @@ public function getTemplateVariablesMetadata(): array { } private function isFooterEnabled(): bool { - return FooterPolicyValue::isEnabled( - $this->policyService->resolve(FooterPolicy::KEY, $this->requestPolicyOverrides)->getEffectiveValue() - ); + return FooterPolicyValue::isEnabled($this->getPolicyValue()); + } + + private function getPolicyValue(): mixed { + return $this->policyValue + ?? $this->policyService->resolve(FooterPolicy::KEY, $this->requestPolicyOverrides)->getEffectiveValue(); } } diff --git a/lib/Service/FileStatusService.php b/lib/Service/FileStatusService.php index bba744bbd0..a3b0e60daf 100644 --- a/lib/Service/FileStatusService.php +++ b/lib/Service/FileStatusService.php @@ -175,6 +175,20 @@ public function markPolicySnapshotFrozen(FileEntity $file, int $newStatus): void $file->setMetadata($metadata); } + /** + * A request remains frozen if it returns to draft; documents inherit + * their envelope's freeze state. Legacy sent requests have no marker. + */ + public static function isPolicySnapshotFrozen(FileEntity $file, ?FileEntity $envelope = null): bool { + return self::hasFrozenPolicySnapshot($file) + || ($envelope !== null && self::hasFrozenPolicySnapshot($envelope)); + } + + private static function hasFrozenPolicySnapshot(FileEntity $file): bool { + return isset(($file->getMetadata() ?? [])[self::POLICY_SNAPSHOT_FROZEN_AT]) + || $file->getStatus() >= FileStatus::ABLE_TO_SIGN->value; + } + private function touchStatusChangedAt(FileEntity $file): void { $metadata = $file->getMetadata() ?? []; $metadata['status_changed_at'] = (new DateTime())->format(DateTimeInterface::ATOM); diff --git a/lib/Service/Policy/AbstractFilePolicyApplier.php b/lib/Service/Policy/AbstractFilePolicyApplier.php index 2f82ead042..91c415850a 100644 --- a/lib/Service/Policy/AbstractFilePolicyApplier.php +++ b/lib/Service/Policy/AbstractFilePolicyApplier.php @@ -9,9 +9,12 @@ namespace OCA\Libresign\Service\Policy; use OCA\Libresign\Db\File as FileEntity; +use OCA\Libresign\Db\FileMapper; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Service\FileService; +use OCA\Libresign\Service\FileStatusService; use OCA\Libresign\Service\Policy\Contract\IFilePolicyApplier; +use OCA\Libresign\Service\Policy\Model\PolicySpec; use OCA\Libresign\Service\Policy\Model\ResolvedPolicy; use OCP\IL10N; @@ -20,6 +23,7 @@ public function __construct( protected readonly PolicyService $policyService, protected readonly FileService $fileService, protected readonly ?IL10N $l10n = null, + protected readonly ?FileMapper $fileMapper = null, ) { } @@ -76,6 +80,19 @@ protected function assertRequestOverrideAllowed(array $requestOverrides, Resolve throw new LibresignException($translatedMessage, 422); } + /** + * Only policies with request-snapshot lifecycle can be frozen. + */ + protected function isPolicySnapshotFrozen(FileEntity $file, string $policyKey, string ...$otherPolicyKeys): bool { + foreach ([$policyKey, ...$otherPolicyKeys] as $key) { + if ($this->policyService->getRequestLifecycle($key) !== PolicySpec::LIFECYCLE_REQUEST_SNAPSHOT) { + throw new \LogicException(sprintf('The %s policy is not stored on the request, so it cannot be frozen.', $key)); + } + } + + return FileStatusService::isPolicySnapshotFrozen($file, $this->fileMapper?->findEnvelope($file)); + } + protected function storePolicySnapshot(FileEntity $file, ResolvedPolicy $resolvedPolicy, mixed $effectiveValue = null): void { $metadata = $file->getMetadata() ?? []; $policySnapshot = $metadata['policy_snapshot'] ?? []; diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php index afcc25d500..a02a986d19 100644 --- a/lib/Service/SignFileService.php +++ b/lib/Service/SignFileService.php @@ -87,6 +87,9 @@ class SignFileService { private string $friendlyName = ''; private ?IUser $user = null; private ?SignEngineHandler $engine = null; + // The stamp and validation URL must share one resolved footer policy. + private ?FileEntity $footerPolicyFile = null; + private string $footerPolicyValue = ''; public function __construct( protected IL10N $l10n, @@ -1390,7 +1393,7 @@ protected function getPdfToSign(File $originalFile): File { return $this->createSignedFile($originalFile, $originalContent); } $metadata = $this->footerHandler->getMetadata($originalFile, $this->libreSignFile); - $this->footerHandler->setRequestPolicyOverrides($this->resolveFooterPolicyRequestOverridesFromFileMetadata()); + $this->footerHandler->setPolicyValue($this->getFooterPolicyValue()); $footer = $this->footerHandler ->setTemplateVar('uuid', $this->libreSignFile->getUuid()) ->setTemplateVar('signers', array_map(fn (SignRequestEntity $signer) => [ diff --git a/tests/php/Unit/Service/SignFileServiceTest.php b/tests/php/Unit/Service/SignFileServiceTest.php index 8b7bc4a357..654889f91f 100644 --- a/tests/php/Unit/Service/SignFileServiceTest.php +++ b/tests/php/Unit/Service/SignFileServiceTest.php @@ -135,6 +135,8 @@ final class SignFileServiceTest extends \OCA\Libresign\Tests\Unit\TestCase { private PolicyService&MockObject $policyService; /** @var array */ private array $policyValues = []; + /** @var list */ + private array $resolvedPolicyKeys = []; public function setUp(): void { parent::setUp(); @@ -190,6 +192,7 @@ public function setUp(): void { ); $this->signRequestService = $this->createMock(SignRequestService::class); $this->policyService = $this->createMock(PolicyService::class); + $this->resolvedPolicyKeys = []; $this->policyValues = [ CollectMetadataPolicy::KEY => false, FooterPolicy::KEY => FooterPolicyValue::encode(FooterPolicyValue::defaults()), From 303088b5096cb9550590d0bff74037433a1a2531 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:04:12 +0000 Subject: [PATCH 10/16] fix: detect stable35 frozen footer state in request sidebar Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/RightSidebar/RequestSignatureTab.vue | 11 +++++++++++ .../RightSidebar/RequestSignatureTab.spec.ts | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/RightSidebar/RequestSignatureTab.vue b/src/components/RightSidebar/RequestSignatureTab.vue index 5b30011437..ea721c8622 100644 --- a/src/components/RightSidebar/RequestSignatureTab.vue +++ b/src/components/RightSidebar/RequestSignatureTab.vue @@ -460,6 +460,17 @@ const footerPolicy = computed(() => policiesStore.getPolicy('add_footer')) const canChooseSigningOrderAtRequestLevel = computed(() => policiesStore.canUseRequestOverride('signature_flow')) const canChooseFooterTemplateAtRequestLevel = computed(() => policiesStore.canUseRequestOverride('add_footer')) const isAdminFlowForced = computed(() => !canChooseSigningOrderAtRequestLevel.value) +// The snapshot stays frozen even when a request returns to draft. Legacy +// requests that were already sent may not have the freeze timestamp. +const isPolicySnapshotFrozen = computed(() => { + const file = filesStore.getFile() + if (!file) { + return false + } + const metadata = file.metadata as Record | null | undefined + return file.status >= FILE_STATUS.ABLE_TO_SIGN + || typeof metadata?.policy_snapshot_frozen_at === 'string' +}) watch(() => policiesStore.getEffectiveValue('identify_methods'), (value) => { methods.value = normalizeIdentifyMethodsPolicy(value) diff --git a/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts b/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts index 9419b0e3f4..2f0ac118fb 100644 --- a/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts +++ b/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts @@ -579,7 +579,7 @@ describe('RequestSignatureTab - Critical Business Rules', () => { it('does not offer the footer template choice and sends no footer', async () => { await updateFile({ status: FILE_STATUS.DRAFT, - policySnapshotFrozen: true, + metadata: { policy_snapshot_frozen_at: '2026-01-01T00:00:00+00:00' }, signers: [{ email: 'test1@example.com', signed: [] }], }) await updateFooterPolicy(twoTemplates) @@ -592,7 +592,7 @@ describe('RequestSignatureTab - Critical Business Rules', () => { it('keeps the footer template choice while the request is a draft', async () => { await updateFile({ status: FILE_STATUS.DRAFT, - policySnapshotFrozen: false, + metadata: {}, signers: [{ email: 'test1@example.com', signed: [] }], }) await updateFooterPolicy(twoTemplates) From d2114c76a1311d874da1187d06b7887bb96680ec Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:05:56 +0000 Subject: [PATCH 11/16] test: restore footer lifecycle API regressions on stable35 Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../RequestSignatureControllerTest.php | 151 ++++++++++++++++++ tests/php/Integration/Db/FileMapperTest.php | 24 +-- 2 files changed, 165 insertions(+), 10 deletions(-) diff --git a/tests/php/Api/Controller/RequestSignatureControllerTest.php b/tests/php/Api/Controller/RequestSignatureControllerTest.php index dcd2c553f5..7d34dd355b 100644 --- a/tests/php/Api/Controller/RequestSignatureControllerTest.php +++ b/tests/php/Api/Controller/RequestSignatureControllerTest.php @@ -159,4 +159,155 @@ public function testPatchRegisterWithSuccess():void { $body = json_decode($response->getBody()->getContents(), true); $body['ocs']['data']['data']['signers'][] = ['email' => 'user@test.coop']; } + /** + * @runInSeparateProcess + */ + public function testPatchRefusesAnotherFooterOnceTheRequestWasSent():void { + $user = $this->createAccount('allowrequestsign', 'password', 'testGroup'); + + $appConfig = $this->getMockAppConfig(); + $appConfig->setValueString(Application::APP_ID, 'groups_request_sign', '{"allowGroups":["admin","testGroup"],"denyGroups":[]}'); + $appConfig->setValueBool(Application::APP_ID, 'notifyUnsignedUser', false); + + $user->setEMailAddress('person@test.coop'); + $signers = [ + [ + 'identifyMethods' => [[ + 'method' => 'email', + 'requirement' => 'optional', + 'value' => 'person@test.coop', + ]], + ], + ]; + $file = $this->requestSignFile([ + 'file' => ['base64' => base64_encode(file_get_contents(__DIR__ . '/../../fixtures/pdfs/small_valid.pdf'))], + 'name' => 'test', + 'signers' => $signers, + 'userManager' => $user, + ]); + // Without signers the request returns to draft, and the document can be sent again by node id. + foreach (\OCP\Server::get(SignRequestMapper::class)->getByFileId($file->getId()) as $signRequest) { + \OCP\Server::get(RequestSignatureService::class)->unassociateToUser($file->getId(), $signRequest->getId()); + } + + $this->request + ->withMethod('PATCH') + ->withPath('/api/v1/request-signature') + ->withRequestHeader([ + 'Authorization' => 'Basic ' . base64_encode('allowrequestsign:password'), + 'Content-Type' => 'application/json' + ]) + ->withRequestBody([ + 'file' => ['nodeId' => $file->getNodeId()], + 'name' => 'test', + 'signers' => $signers, + 'policy' => ['overrides' => ['add_footer' => '{"enabled":false}']], + ]) + ->expectStatus(422); + + $response = $this->assertRequest(); + $body = json_decode($response->getBody()->getContents(), true); + $this->assertSame('The footer cannot be changed after the signing flow has started.', $body['ocs']['data']['message']); + } + + /** + * @runInSeparateProcess + */ + public function testUuidPatchRefreshesDraftFooterBeforeFreezing(): void { + $user = $this->createAccount('allowrequestsign', 'password', 'testGroup'); + $user->setEMailAddress('person@test.coop'); + $appConfig = $this->getMockAppConfig(); + $appConfig->setValueString(Application::APP_ID, 'groups_request_sign', '{"allowGroups":["admin","testGroup"],"denyGroups":[]}'); + $appConfig->setValueBool(Application::APP_ID, 'notifyUnsignedUser', false); + $initialFooter = FooterPolicyValue::encode(['enabled' => false]); + $updatedFooter = FooterPolicyValue::encode(['enabled' => true, 'validationSite' => 'https://updated.example/']); + $appConfig->setValueString(Application::APP_ID, 'add_footer', $initialFooter); + $appConfig->setValueString(Application::APP_ID, 'add_footer.allow_child_override', '0'); + + $file = $this->requestSignFile([ + 'file' => ['base64' => base64_encode(file_get_contents(__DIR__ . '/../../fixtures/pdfs/small_valid.pdf'))], + 'name' => 'draft', + 'status' => FileStatus::DRAFT->value, + 'signers' => [], + 'userManager' => $user, + ]); + $fileMapper = \OCP\Server::get(FileMapper::class); + $draft = $fileMapper->getById($file->getId())->getMetadata(); + $this->assertSame($initialFooter, $draft['policy_snapshot']['add_footer']['effectiveValue']); + + $appConfig->setValueString(Application::APP_ID, 'add_footer', $updatedFooter); + $this->request + ->withMethod('PATCH') + ->withPath('/api/v1/request-signature') + ->withRequestHeader([ + 'Authorization' => 'Basic ' . base64_encode('allowrequestsign:password'), + 'Content-Type' => 'application/json', + ]) + ->withRequestBody([ + 'uuid' => $file->getUuid(), + 'status' => FileStatus::ABLE_TO_SIGN->value, + 'signers' => [[ + 'identifyMethods' => [[ + 'method' => 'email', + 'requirement' => 'optional', + 'value' => 'external-signer@test.coop', + ]], + ]], + ]) + ->expectStatus(200); + + $this->assertRequest(); + $sent = $fileMapper->getById($file->getId())->getMetadata(); + $this->assertSame($updatedFooter, $sent['policy_snapshot']['add_footer']['effectiveValue']); + $this->assertArrayHasKey('policy_snapshot_frozen_at', $sent); + } + + /** + * @runInSeparateProcess + */ + public function testUuidPatchRejectsChangedFrozenFooterWithoutMutatingSnapshot(): void { + $user = $this->createAccount('allowrequestsign', 'password', 'testGroup'); + $user->setEMailAddress('person@test.coop'); + $appConfig = $this->getMockAppConfig(); + $appConfig->setValueString(Application::APP_ID, 'groups_request_sign', '{"allowGroups":["admin","testGroup"],"denyGroups":[]}'); + $appConfig->setValueBool(Application::APP_ID, 'notifyUnsignedUser', false); + $storedFooter = FooterPolicyValue::encode(['enabled' => true, 'customizeFooterTemplate' => true, 'footerTemplate' => '

Stored

']); + $appConfig->setValueString(Application::APP_ID, 'add_footer', $storedFooter); + $appConfig->setValueString(Application::APP_ID, 'add_footer.allow_child_override', '1'); + $signers = [[ + 'identifyMethods' => [[ + 'method' => 'email', + 'requirement' => 'optional', + 'value' => 'person@test.coop', + ]], + ]]; + $file = $this->requestSignFile([ + 'file' => ['base64' => base64_encode(file_get_contents(__DIR__ . '/../../fixtures/pdfs/small_valid.pdf'))], + 'name' => 'sent', + 'signers' => $signers, + 'userManager' => $user, + ]); + $fileMapper = \OCP\Server::get(FileMapper::class); + $before = $fileMapper->getById($file->getId())->getMetadata(); + $this->assertSame($storedFooter, $before['policy_snapshot']['add_footer']['effectiveValue']); + + $this->request + ->withMethod('PATCH') + ->withPath('/api/v1/request-signature') + ->withRequestHeader([ + 'Authorization' => 'Basic ' . base64_encode('allowrequestsign:password'), + 'Content-Type' => 'application/json', + ]) + ->withRequestBody([ + 'uuid' => $file->getUuid(), + 'signers' => $signers, + 'policy' => ['overrides' => ['add_footer' => FooterPolicyValue::encode(['enabled' => false])]], + ]) + ->expectStatus(422); + + $response = $this->assertRequest(); + $body = json_decode($response->getBody()->getContents(), true); + $this->assertSame('The footer cannot be changed after the signing flow has started.', $body['ocs']['data']['message']); + $this->assertSame($before['policy_snapshot'], $fileMapper->getById($file->getId())->getMetadata()['policy_snapshot']); + } } diff --git a/tests/php/Integration/Db/FileMapperTest.php b/tests/php/Integration/Db/FileMapperTest.php index 9802746c35..14da3776f6 100644 --- a/tests/php/Integration/Db/FileMapperTest.php +++ b/tests/php/Integration/Db/FileMapperTest.php @@ -115,19 +115,23 @@ public function testFindEnvelope(): void { nodeId: 30303, userId: 'owner-user' )); - $document = $this->createFileEntity( - uuid: 'd4444444-4444-4444-8444-444444444444', - nodeId: 40404, - userId: 'owner-user' - ); + try { + $document = $this->createFileEntity( + uuid: 'd4444444-4444-4444-8444-444444444444', + nodeId: 40404, + userId: 'owner-user' + ); - $this->assertNull($this->fileMapper->findEnvelope($document)); + $this->assertNull($this->fileMapper->findEnvelope($document)); - $document->setParentFileId($envelope->getId()); - $this->assertSame($envelope->getId(), $this->fileMapper->findEnvelope($document)?->getId()); + $document->setParentFileId($envelope->getId()); + $this->assertSame($envelope->getId(), $this->fileMapper->findEnvelope($document)?->getId()); - $document->setParentFileId($envelope->getId() + 1000000); - $this->assertNull($this->fileMapper->findEnvelope($document)); + $document->setParentFileId($envelope->getId() + 1000000); + $this->assertNull($this->fileMapper->findEnvelope($document)); + } finally { + $this->fileMapper->delete($envelope); + } } private function createFileEntity(string $uuid, int $nodeId, ?string $userId): File { From ad19541269cef470a365e0b6467e963a1438c16b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:07:46 +0000 Subject: [PATCH 12/16] fix: adapt frozen-policy checks to stable35 policy API Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/Policy/AbstractFilePolicyApplier.php | 12 +++--------- .../Footer/FilePolicy/FooterFilePolicyApplier.php | 2 +- .../FilePolicy/FooterFilePolicyApplierTest.php | 2 -- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/lib/Service/Policy/AbstractFilePolicyApplier.php b/lib/Service/Policy/AbstractFilePolicyApplier.php index 91c415850a..a262227b68 100644 --- a/lib/Service/Policy/AbstractFilePolicyApplier.php +++ b/lib/Service/Policy/AbstractFilePolicyApplier.php @@ -14,7 +14,6 @@ use OCA\Libresign\Service\FileService; use OCA\Libresign\Service\FileStatusService; use OCA\Libresign\Service\Policy\Contract\IFilePolicyApplier; -use OCA\Libresign\Service\Policy\Model\PolicySpec; use OCA\Libresign\Service\Policy\Model\ResolvedPolicy; use OCP\IL10N; @@ -81,15 +80,10 @@ protected function assertRequestOverrideAllowed(array $requestOverrides, Resolve } /** - * Only policies with request-snapshot lifecycle can be frozen. + * The request freezes on first send, including legacy sent requests and + * documents of a frozen envelope. */ - protected function isPolicySnapshotFrozen(FileEntity $file, string $policyKey, string ...$otherPolicyKeys): bool { - foreach ([$policyKey, ...$otherPolicyKeys] as $key) { - if ($this->policyService->getRequestLifecycle($key) !== PolicySpec::LIFECYCLE_REQUEST_SNAPSHOT) { - throw new \LogicException(sprintf('The %s policy is not stored on the request, so it cannot be frozen.', $key)); - } - } - + protected function isPolicySnapshotFrozen(FileEntity $file): bool { return FileStatusService::isPolicySnapshotFrozen($file, $this->fileMapper?->findEnvelope($file)); } diff --git a/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php b/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php index 1bb1cea91d..6e36a0c17d 100644 --- a/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php +++ b/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php @@ -34,7 +34,7 @@ public function apply(FileEntity $file, array $data): void { #[\Override] public function sync(FileEntity $file, array $data): void { $requestOverrides = $this->getOverrides($data); - if ($this->isPolicySnapshotFrozen($file, FooterPolicy::KEY)) { + if ($this->isPolicySnapshotFrozen($file)) { $this->assertFrozenFooterIsKept($file, $requestOverrides); return; } diff --git a/tests/php/Unit/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplierTest.php b/tests/php/Unit/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplierTest.php index b643188330..d80adc90de 100644 --- a/tests/php/Unit/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplierTest.php +++ b/tests/php/Unit/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplierTest.php @@ -12,7 +12,6 @@ use OCA\Libresign\Enum\FileStatus; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Service\FileService; -use OCA\Libresign\Service\Policy\Model\PolicySpec; use OCA\Libresign\Service\Policy\Model\ResolvedPolicy; use OCA\Libresign\Service\Policy\PolicyService; use OCA\Libresign\Service\Policy\Provider\Footer\FilePolicy\FooterFilePolicyApplier; @@ -32,7 +31,6 @@ final class FooterFilePolicyApplierTest extends \OCA\Libresign\Tests\Unit\TestCa public function setUp(): void { parent::setUp(); $this->policyService = $this->createMock(PolicyService::class); - $this->policyService->method('getRequestLifecycle')->willReturn(PolicySpec::LIFECYCLE_REQUEST_SNAPSHOT); $this->fileService = $this->createMock(FileService::class); $this->l10n = $this->createMock(IL10N::class); $this->l10n->method('t')->willReturnArgument(0); From 075af067bada36e0837f64e544f175763a925de6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:08:45 +0000 Subject: [PATCH 13/16] test: cover frozen footer and envelope behavior on stable35 Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Handler/FooterHandlerTest.php | 17 ++++++ .../Unit/Service/FileStatusServiceTest.php | 32 +++++++++++ .../Policy/AbstractFilePolicyApplierTest.php | 57 ++++++++++++++++++- 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/tests/php/Unit/Handler/FooterHandlerTest.php b/tests/php/Unit/Handler/FooterHandlerTest.php index 9fdbd3cf03..07515f5d01 100644 --- a/tests/php/Unit/Handler/FooterHandlerTest.php +++ b/tests/php/Unit/Handler/FooterHandlerTest.php @@ -98,6 +98,23 @@ public function testPreviouslyStoredUnsafeFooterFailsClosed(): void { $this->getClass()->getFooter([['w' => 595, 'h' => 100]]); } + + public function testExplicitFrozenFooterBypassesCurrentPolicy(): void { + $storedFooter = FooterPolicyValue::encode([ + 'enabled' => true, + 'customizeFooterTemplate' => true, + 'footerTemplate' => '

Frozen

', + ]); + $this->policyService = $this->createMock(PolicyService::class); + $this->policyService->expects($this->never())->method('resolve'); + $this->l10n = $this->l10nFactory->get(Application::APP_ID); + + $handler = $this->getClass()->setPolicyValue($storedFooter); + + $this->assertSame($storedFooter, $handler->getEffectiveFooterPolicyAsJson()); + $this->assertSame('

Frozen

', $handler->getTemplate()); + } + public function testGetFooterWithoutValidationSite(): void { $this->setFooterPolicy([ 'enabled' => false, diff --git a/tests/php/Unit/Service/FileStatusServiceTest.php b/tests/php/Unit/Service/FileStatusServiceTest.php index 5f17a83041..97a2a25132 100644 --- a/tests/php/Unit/Service/FileStatusServiceTest.php +++ b/tests/php/Unit/Service/FileStatusServiceTest.php @@ -575,6 +575,38 @@ public function testPolicySnapshotFreezesOnFirstSigningTransition(): void { $this->assertArrayHasKey(FileStatusService::POLICY_SNAPSHOT_FROZEN_AT, $file->getMetadata()); } + /** + * @param array $metadata + * @param array{0: int, 1: array}|null $envelopeState + */ + #[DataProvider('dataIsPolicySnapshotFrozen')] + public function testIsPolicySnapshotFrozen(int $status, array $metadata, ?array $envelopeState, bool $expected): void { + $file = new FileEntity(); + $file->setStatus($status); + $file->setMetadata($metadata); + $envelope = null; + if ($envelopeState !== null) { + $envelope = new FileEntity(); + $envelope->setStatus($envelopeState[0]); + $envelope->setMetadata($envelopeState[1]); + } + + $this->assertSame($expected, FileStatusService::isPolicySnapshotFrozen($file, $envelope)); + } + + public static function dataIsPolicySnapshotFrozen(): array { + $frozen = ['policy_snapshot_frozen_at' => '2026-01-01T00:00:00+00:00']; + return [ + 'draft never sent' => [FileStatus::DRAFT->value, [], null, false], + 'sent request' => [FileStatus::ABLE_TO_SIGN->value, $frozen, null, true], + 'sent request that returned to draft' => [FileStatus::DRAFT->value, $frozen, null, true], + 'sent before the freeze was recorded' => [FileStatus::ABLE_TO_SIGN->value, [], null, true], + 'document of a draft envelope' => [FileStatus::DRAFT->value, [], [FileStatus::DRAFT->value, []], false], + 'document of a sent envelope' => [FileStatus::DRAFT->value, [], [FileStatus::DRAFT->value, $frozen], true], + 'document of an envelope sent before the freeze was recorded' => [FileStatus::DRAFT->value, [], [FileStatus::ABLE_TO_SIGN->value, []], true], + ]; + } + private function assertStatusChangedAtSet(FileEntity $file): void { $metadata = $file->getMetadata(); $this->assertIsArray($metadata); diff --git a/tests/php/Unit/Service/Policy/AbstractFilePolicyApplierTest.php b/tests/php/Unit/Service/Policy/AbstractFilePolicyApplierTest.php index 400d6849cf..537ea485bc 100644 --- a/tests/php/Unit/Service/Policy/AbstractFilePolicyApplierTest.php +++ b/tests/php/Unit/Service/Policy/AbstractFilePolicyApplierTest.php @@ -9,6 +9,8 @@ namespace OCA\Libresign\Tests\Unit\Service\Policy; use OCA\Libresign\Db\File as FileEntity; +use OCA\Libresign\Db\FileMapper; +use OCA\Libresign\Enum\FileStatus; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Service\FileService; use OCA\Libresign\Service\Policy\Model\ResolvedPolicy; @@ -47,17 +49,23 @@ public function exposeStorePolicySnapshot(FileEntity $file, ResolvedPolicy $reso public function exposeAssertRequestOverrideAllowed(array $requestOverrides, ResolvedPolicy $resolvedPolicy, string $message): void { $this->assertRequestOverrideAllowed($requestOverrides, $resolvedPolicy, $message); } + + public function exposeIsPolicySnapshotFrozen(FileEntity $file): bool { + return $this->isPolicySnapshotFrozen($file); + } } final class AbstractFilePolicyApplierTest extends TestCase { private PolicyService&MockObject $policyService; private FileService&MockObject $fileService; private IL10N&MockObject $l10n; + private FileMapper&MockObject $fileMapper; protected function setUp(): void { parent::setUp(); $this->policyService = $this->createMock(PolicyService::class); $this->fileService = $this->createMock(FileService::class); + $this->fileMapper = $this->createMock(FileMapper::class); $this->l10n = $this->createMock(IL10N::class); $this->l10n->method('t')->willReturnCallback(static fn (string $text, array $parameters = []): string => $parameters === [] ? $text @@ -199,6 +207,53 @@ public static function allowedOverrideProvider(): array { ]; } + + /** @param array|null $envelopeMetadata */ + #[DataProvider('frozenSnapshotProvider')] + public function testIsPolicySnapshotFrozen(int $status, array $metadata, ?array $envelopeMetadata, bool $expected): void { + $file = new FileEntity(); + $file->setStatus($status); + $file->setMetadata($metadata); + if ($envelopeMetadata !== null) { + $envelope = new FileEntity(); + $envelope->setId(1); + $envelope->setNodeType('envelope'); + $envelope->setMetadata($envelopeMetadata); + $file->setParentFileId(1); + $this->fileMapper->method('findEnvelope')->with($file)->willReturn($envelope); + } + $this->fileMapper->expects($this->never())->method('update'); + $this->fileService->expects($this->never())->method('update'); + + self::assertSame($expected, $this->createApplier()->exposeIsPolicySnapshotFrozen($file)); + self::assertSame($metadata, $file->getMetadata()); + } + + public static function frozenSnapshotProvider(): array { + $frozen = ['policy_snapshot_frozen_at' => '2026-01-01T00:00:00+00:00']; + return [ + 'draft never sent' => [FileStatus::DRAFT->value, [], null, false], + 'sent request' => [FileStatus::ABLE_TO_SIGN->value, $frozen, null, true], + 'sent request that returned to draft' => [FileStatus::DRAFT->value, $frozen, null, true], + 'document of a sent envelope' => [FileStatus::DRAFT->value, [], $frozen, true], + 'document of a draft envelope' => [FileStatus::DRAFT->value, [], [], false], + 'sent before the freeze was recorded' => [FileStatus::ABLE_TO_SIGN->value, [], null, true], + 'partially signed before the freeze was recorded' => [FileStatus::PARTIAL_SIGNED->value, [], null, true], + ]; + } + + public function testLegacyDraftDocumentFreezesWithSentEnvelope(): void { + $file = new FileEntity(); + $file->setStatus(FileStatus::DRAFT->value); + $file->setParentFileId(1); + $envelope = new FileEntity(); + $envelope->setId(1); + $envelope->setNodeType('envelope'); + $envelope->setStatus(FileStatus::ABLE_TO_SIGN->value); + $this->fileMapper->method('findEnvelope')->with($file)->willReturn($envelope); + self::assertTrue($this->createApplier()->exposeIsPolicySnapshotFrozen($file)); + } + private function createResolvedPolicy( string $policyKey, mixed $effectiveValue, @@ -215,6 +270,6 @@ private function createResolvedPolicy( } private function createApplier(): AbstractFilePolicyApplierTestDouble { - return new AbstractFilePolicyApplierTestDouble($this->policyService, $this->fileService, $this->l10n); + return new AbstractFilePolicyApplierTestDouble($this->policyService, $this->fileService, $this->l10n, $this->fileMapper); } } From 0aa327e94118005626aba0670e1beabd2f53388a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:10:49 +0000 Subject: [PATCH 14/16] style: align footer regression tests with php-cs-fixer Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Handler/FooterHandlerTest.php | 1 - tests/php/Unit/Service/Policy/AbstractFilePolicyApplierTest.php | 1 - 2 files changed, 2 deletions(-) diff --git a/tests/php/Unit/Handler/FooterHandlerTest.php b/tests/php/Unit/Handler/FooterHandlerTest.php index 07515f5d01..81a1bde977 100644 --- a/tests/php/Unit/Handler/FooterHandlerTest.php +++ b/tests/php/Unit/Handler/FooterHandlerTest.php @@ -98,7 +98,6 @@ public function testPreviouslyStoredUnsafeFooterFailsClosed(): void { $this->getClass()->getFooter([['w' => 595, 'h' => 100]]); } - public function testExplicitFrozenFooterBypassesCurrentPolicy(): void { $storedFooter = FooterPolicyValue::encode([ 'enabled' => true, diff --git a/tests/php/Unit/Service/Policy/AbstractFilePolicyApplierTest.php b/tests/php/Unit/Service/Policy/AbstractFilePolicyApplierTest.php index 537ea485bc..2b00de3884 100644 --- a/tests/php/Unit/Service/Policy/AbstractFilePolicyApplierTest.php +++ b/tests/php/Unit/Service/Policy/AbstractFilePolicyApplierTest.php @@ -207,7 +207,6 @@ public static function allowedOverrideProvider(): array { ]; } - /** @param array|null $envelopeMetadata */ #[DataProvider('frozenSnapshotProvider')] public function testIsPolicySnapshotFrozen(int $status, array $metadata, ?array $envelopeMetadata, bool $expected): void { From 3a636d666c446f9b61955ec9dbae2d348fafec15 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:11:34 +0000 Subject: [PATCH 15/16] fix: resolve stable35 type and inherited mapper conflicts Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/Policy/AbstractFilePolicyApplier.php | 4 ++-- src/components/RightSidebar/RequestSignatureTab.vue | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/Service/Policy/AbstractFilePolicyApplier.php b/lib/Service/Policy/AbstractFilePolicyApplier.php index a262227b68..d96b501e9a 100644 --- a/lib/Service/Policy/AbstractFilePolicyApplier.php +++ b/lib/Service/Policy/AbstractFilePolicyApplier.php @@ -22,7 +22,7 @@ public function __construct( protected readonly PolicyService $policyService, protected readonly FileService $fileService, protected readonly ?IL10N $l10n = null, - protected readonly ?FileMapper $fileMapper = null, + protected readonly ?FileMapper $snapshotFileMapper = null, ) { } @@ -84,7 +84,7 @@ protected function assertRequestOverrideAllowed(array $requestOverrides, Resolve * documents of a frozen envelope. */ protected function isPolicySnapshotFrozen(FileEntity $file): bool { - return FileStatusService::isPolicySnapshotFrozen($file, $this->fileMapper?->findEnvelope($file)); + return FileStatusService::isPolicySnapshotFrozen($file, $this->snapshotFileMapper?->findEnvelope($file)); } protected function storePolicySnapshot(FileEntity $file, ResolvedPolicy $resolvedPolicy, mixed $effectiveValue = null): void { diff --git a/src/components/RightSidebar/RequestSignatureTab.vue b/src/components/RightSidebar/RequestSignatureTab.vue index ea721c8622..9a033a4e97 100644 --- a/src/components/RightSidebar/RequestSignatureTab.vue +++ b/src/components/RightSidebar/RequestSignatureTab.vue @@ -468,7 +468,7 @@ const isPolicySnapshotFrozen = computed(() => { return false } const metadata = file.metadata as Record | null | undefined - return file.status >= FILE_STATUS.ABLE_TO_SIGN + return (file.status ?? FILE_STATUS.DRAFT) >= FILE_STATUS.ABLE_TO_SIGN || typeof metadata?.policy_snapshot_frozen_at === 'string' }) From 23e327c55e162f3162036d42d2314f37b27a5252 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:45:13 +0000 Subject: [PATCH 16/16] test: use stable35 file access and footer sync fixtures Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Service/Policy/FilePolicyApplierTest.php | 4 ++-- tests/php/Unit/Service/SignFileServiceTest.php | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/php/Unit/Service/Policy/FilePolicyApplierTest.php b/tests/php/Unit/Service/Policy/FilePolicyApplierTest.php index eb0642f90c..f7ec987869 100644 --- a/tests/php/Unit/Service/Policy/FilePolicyApplierTest.php +++ b/tests/php/Unit/Service/Policy/FilePolicyApplierTest.php @@ -164,7 +164,7 @@ public function testSyncCoreFlowPoliciesPersistsFooterSnapshot(): void { $file->setDocmdpLevelEnum(DocMdpLevel::NOT_CERTIFIED); $this->policyService - ->expects($this->exactly(5)) + ->expects($this->exactly(6)) ->method('resolveForUserId') ->willReturnCallback(function (string $policyKey): ResolvedPolicy { return match ($policyKey) { @@ -205,7 +205,7 @@ public function testSyncCoreFlowPoliciesPersistsFooterSnapshot(): void { }); $this->fileService - ->expects($this->exactly(5)) + ->expects($this->exactly(6)) ->method('update') ->with($this->identicalTo($file)); diff --git a/tests/php/Unit/Service/SignFileServiceTest.php b/tests/php/Unit/Service/SignFileServiceTest.php index 654889f91f..8369fc5d02 100644 --- a/tests/php/Unit/Service/SignFileServiceTest.php +++ b/tests/php/Unit/Service/SignFileServiceTest.php @@ -1423,7 +1423,10 @@ public function testAFrozenEnvelopeDocumentStampsAndValidatesWithTheFooterItStor $originalFile->method('getExtension')->willReturn('pdf'); $originalFile->method('getPath')->willReturn('/Documents/source.pdf'); $originalFile->method('getOwner')->willReturn($owner); - $originalFile->method('getParent')->willReturn($folder); + $originalFile->method('getParentId')->willReturn(101); + $userFolder = $this->createMock(\OCP\Files\Folder::class); + $userFolder->expects($this->once())->method('getFirstNodeById')->with(101)->willReturn($folder); + $this->root->expects($this->once())->method('getUserFolder')->with('signer1')->willReturn($userFolder); $this->footerHandler->method('getMetadata')->willReturn(['d' => [['w' => 595.0, 'h' => 842.0]]]); $this->footerHandler->method('setTemplateVar')->willReturnSelf();