diff --git a/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php b/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php index 9a255ce903..465ac7643e 100644 --- a/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php +++ b/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php @@ -9,7 +9,6 @@ namespace OCA\Libresign\Service\IdentifyMethod; use DateTime; -use DateTimeInterface; use InvalidArgumentException; use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\IdentifyMethod; @@ -219,7 +218,7 @@ protected function throwIfInvalidToken(): void { protected function renewSession(): void { $this->identifyService->getSessionService()->setIdentifyMethodId($this->getEntity()->getId()); - $renewalInterval = (int)$this->identifyService->getAppConfig()->getValueInt(Application::APP_ID, 'renewal_interval', SessionService::NO_RENEWAL_INTERVAL); + $renewalInterval = $this->getRuntimeConfigInt('renewal_interval', SessionService::NO_RENEWAL_INTERVAL); if ($renewalInterval <= 0) { return; } @@ -237,7 +236,7 @@ protected function updateIdentifiedAt(): void { } protected function throwIfRenewalIntervalExpired(): void { - $renewalInterval = (int)$this->identifyService->getAppConfig()->getValueInt(Application::APP_ID, 'renewal_interval', SessionService::NO_RENEWAL_INTERVAL); + $renewalInterval = $this->getRuntimeConfigInt('renewal_interval', SessionService::NO_RENEWAL_INTERVAL); if ($renewalInterval <= 0) { return; } @@ -250,24 +249,17 @@ protected function throwIfRenewalIntervalExpired(): void { } $createdAt = $signRequest->getCreatedAt(); $lastAttempt = $this->getEntity()->getLastAttemptDate(); + $identifiedAt = $this->getEntity()->getIdentifiedAtDate(); $lastActionDate = max( $startTime, $createdAt, $lastAttempt, + $identifiedAt, ); $now = $this->identifyService->getTimeFactory()->getDateTime(); - $this->identifyService->getLogger()->debug('AbstractIdentifyMethod::throwIfRenewalIntervalExpired Times', [ - 'renewalInterval' => $renewalInterval, - 'startTime' => $startTime, - 'createdAt' => $createdAt, - 'lastAttempt' => $lastAttempt, - 'lastActionDate' => $lastActionDate, - 'now' => $now->format(DateTimeInterface::ATOM), - ]); $endRenewal = (clone $lastActionDate) ->add(new \DateInterval('PT' . $renewalInterval . 'S')); if ($endRenewal < $now) { - $this->identifyService->getLogger()->debug('AbstractIdentifyMethod::throwIfRenewalIntervalExpired Exception'); if ($this->getName() === 'email') { $blur = new Blur($this->getEntity()->getIdentifierValue()); throw new LibresignException(json_encode([ @@ -296,6 +288,12 @@ private function getRenewAction(): int { }; } + private function getRuntimeConfigInt(string $key, int $default): int { + $appConfig = $this->identifyService->getAppConfig(); + $appConfig->clearCache(true); + return (int)$appConfig->getValueInt(Application::APP_ID, $key, $default); + } + protected function throwIfAlreadySigned(): void { $signRequest = $this->identifyService->getSignRequestMapper()->getById($this->getEntity()->getSignRequestId()); $fileEntity = $this->identifyService->getFileMapper()->getById($signRequest->getFileId()); diff --git a/lib/Service/SessionService.php b/lib/Service/SessionService.php index ffe3dc85ca..3e62b9a747 100644 --- a/lib/Service/SessionService.php +++ b/lib/Service/SessionService.php @@ -27,6 +27,13 @@ public function getSignStartTime(): int { } public function getSessionId(): string { + if ($this->isAuthenticated()) { + return $this->session->getId(); + } + $uuid = $this->session->get('libresign-uuid'); + if (is_string($uuid) && $uuid !== '') { + return $uuid; + } return $this->session->getId(); } diff --git a/src/components/Request/VisibleElements.vue b/src/components/Request/VisibleElements.vue index ba3e202e89..8a4d3dd180 100644 --- a/src/components/Request/VisibleElements.vue +++ b/src/components/Request/VisibleElements.vue @@ -213,6 +213,25 @@ export default { }) const childFiles = response?.data?.ocs?.data?.data || [] this.document.files = Array.isArray(childFiles) ? childFiles : [] + + const allVisibleElements = this.aggregateVisibleElementsByFiles(this.document.files) + if (allVisibleElements.length > 0) { + this.document.visibleElements = allVisibleElements + } + }, + aggregateVisibleElementsByFiles(files) { + if (!Array.isArray(files) || files.length === 0) { + return [] + } + + const allVisibleElements = [] + files.forEach(file => { + if (Array.isArray(file?.visibleElements)) { + allVisibleElements.push(...file.visibleElements) + } + }) + + return allVisibleElements }, buildFilePagesMap() { this.filePagesMap = {} diff --git a/src/tests/components/Request/VisibleElements.spec.js b/src/tests/components/Request/VisibleElements.spec.js index 57b744fbc4..a7f80db528 100644 --- a/src/tests/components/Request/VisibleElements.spec.js +++ b/src/tests/components/Request/VisibleElements.spec.js @@ -602,4 +602,101 @@ describe('VisibleElements Component - Business Rules', () => { expect(showError).toHaveBeenCalledWith('save failed') }) }) + + describe('RULE: aggregateVisibleElementsByFiles', () => { + it.each([ + { + label: 'undefined input', + input: undefined, + expected: [], + }, + { + label: 'null input', + input: null, + expected: [], + }, + { + label: 'empty array', + input: [], + expected: [], + }, + { + label: 'mixed files with invalid entries', + input: [ + { id: 545, visibleElements: [{ elementId: 185, fileId: 545 }] }, + { id: 999, visibleElements: null }, + { id: 546, visibleElements: [{ elementId: 186, fileId: 546 }] }, + ], + expected: [ + { elementId: 185, fileId: 545 }, + { elementId: 186, fileId: 546 }, + ], + }, + { + label: 'preserves order when a file has multiple elements', + input: [ + { + id: 100, + visibleElements: [ + { elementId: 1, fileId: 100 }, + { elementId: 2, fileId: 100 }, + ], + }, + { id: 200, visibleElements: [{ elementId: 3, fileId: 200 }] }, + ], + expected: [ + { elementId: 1, fileId: 100 }, + { elementId: 2, fileId: 100 }, + { elementId: 3, fileId: 200 }, + ], + }, + ])('handles $label', ({ input, expected }) => { + expect(wrapper.vm.aggregateVisibleElementsByFiles(input)).toEqual(expected) + }) + }) + + describe('RULE: fetchFiles updates document files and visible elements', () => { + it.each([ + { + label: 'applies aggregated visible elements when available', + childFiles: [ + { id: 545, name: 'file1.pdf', visibleElements: [{ elementId: 185, fileId: 545 }] }, + { id: 546, name: 'file2.pdf', visibleElements: [{ elementId: 186, fileId: 546 }] }, + ], + initialVisibleElements: [{ elementId: 999, fileId: 1 }], + expectedVisibleElements: [ + { elementId: 185, fileId: 545 }, + { elementId: 186, fileId: 546 }, + ], + }, + { + label: 'keeps existing visibleElements when aggregated result is empty', + childFiles: [ + { id: 545, name: 'file1.pdf', visibleElements: [] }, + { id: 546, name: 'file2.pdf' }, + ], + initialVisibleElements: [{ elementId: 999, fileId: 1 }], + expectedVisibleElements: [{ elementId: 999, fileId: 1 }], + }, + ])('$label', async ({ childFiles, initialVisibleElements, expectedVisibleElements }) => { + filesStore.files[1].id = 544 + filesStore.files[1].files = [] + filesStore.files[1].visibleElements = initialVisibleElements + + axios.get.mockResolvedValue({ + data: { + ocs: { + data: { + data: childFiles, + }, + }, + }, + }) + + await wrapper.vm.fetchFiles() + + expect(wrapper.vm.document.files).toEqual(childFiles) + expect(wrapper.vm.document.visibleElements).toEqual(expectedVisibleElements) + }) + }) }) diff --git a/tests/integration/features/account/signature.feature b/tests/integration/features/account/signature.feature index 023b6a0121..7e3555ae27 100644 --- a/tests/integration/features/account/signature.feature +++ b/tests/integration/features/account/signature.feature @@ -238,6 +238,22 @@ Feature: account/signature When sending "delete" to ocs "/apps/libresign/api/v1/signature/elements/" Then the response should have a status code 200 + Scenario: CRUD of signature element authenticated with public sign header + Given user "signer1" exists + And as user "signer1" + And set the custom http header "libresign-sign-request-uuid" with "11111111-1111-1111-1111-111111111111" as value to next request + When sending "post" to ocs "/apps/libresign/api/v1/signature/elements" + | elements | [{"type":"signature","file":{"base64":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="}}] | + Then the response should have a status code 200 + When sending "get" to ocs "/apps/libresign/api/v1/signature/elements" + Then the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.elements\|length | 1 | + | (jq).ocs.data.elements[0].type | signature | + And fetch field "(NODE_ID)ocs.data.elements.0.file.nodeId" from previous JSON response + When sending "delete" to ocs "/apps/libresign/api/v1/signature/elements/" + Then the response should have a status code 200 + Scenario: CRUD of signature element to signer by email without account Given run the command "config:app:set guests whitelist --value=libresign" with result code 0 And run the command "libresign:configure:openssl --cn test" with result code 0 diff --git a/tests/php/Unit/Service/IdentifyMethod/AbstractIdentifyMethodTest.php b/tests/php/Unit/Service/IdentifyMethod/AbstractIdentifyMethodTest.php new file mode 100644 index 0000000000..59eee545a3 --- /dev/null +++ b/tests/php/Unit/Service/IdentifyMethod/AbstractIdentifyMethodTest.php @@ -0,0 +1,184 @@ +identifyService = $this->createMock(IdentifyService::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->sessionService = $this->createMock(SessionService::class); + $this->signRequestMapper = $this->createMock(SignRequestMapper::class); + $this->timeFactory = $this->createMock(ITimeFactory::class); + $l10n = $this->createMock(IL10N::class); + + $l10n->method('t') + ->willReturnCallback(static fn (string $text): string => $text); + $this->identifyService->method('getL10n')->willReturn($l10n); + $this->identifyService->method('getAppConfig')->willReturn($this->appConfig); + $this->identifyService->method('getSessionService')->willReturn($this->sessionService); + $this->identifyService->method('getSignRequestMapper')->willReturn($this->signRequestMapper); + $this->identifyService->method('getTimeFactory')->willReturn($this->timeFactory); + } + + #[DataProvider('providerRuntimeConfigReadPaths')] + public function testRuntimeConfigIsReadWithCacheRefresh(string $path): void { + $cacheCleared = false; + $this->appConfig->expects($this->once()) + ->method('clearCache') + ->with(true) + ->willReturnCallback(function () use (&$cacheCleared): void { + $cacheCleared = true; + }); + $this->appConfig->expects($this->once()) + ->method('getValueInt') + ->with(Application::APP_ID, 'renewal_interval', SessionService::NO_RENEWAL_INTERVAL) + ->willReturnCallback(function () use (&$cacheCleared): int { + $this->assertTrue($cacheCleared); + return 10; + }); + + $identifyMethod = $this->newIdentifyMethodEntity( + signRequestId: 10, + identifierValue: 'signer@domain.test', + lastAttemptDate: '2026-02-16T10:00:01+00:00', + identifiedAtDate: null, + ); + + if ($path === 'renewSession') { + $this->sessionService->expects($this->once()) + ->method('setIdentifyMethodId') + ->with(99); + $this->sessionService->expects($this->once()) + ->method('resetDurationOfSignPage'); + $this->newMethodWithEntity($identifyMethod)->runRenewSession(); + return; + } + + $this->sessionService->method('getSignStartTime')->willReturn(0); + $this->signRequestMapper->method('getById')->with(10)->willReturn($this->newSignRequest( + createdAt: '2026-02-16T10:00:09+00:00', + uuid: '9f95dc38-c2f8-43e5-a91d-8e191ca9520d', + )); + $this->timeFactory->method('getDateTime')->willReturn(new \DateTime('2026-02-16T10:00:10+00:00')); + + $this->newMethodWithEntity($identifyMethod)->runThrowIfRenewalIntervalExpired(); + } + + public static function providerRuntimeConfigReadPaths(): array { + return [ + 'renewSession path' => ['renewSession'], + 'throwIfRenewalIntervalExpired path' => ['throwIfRenewalIntervalExpired'], + ]; + } + + #[DataProvider('providerRenewalWindowByLastAction')] + public function testRenewalWindowUsesIdentifiedAtAsLastAction(?string $identifiedAtDate, bool $mustExpire): void { + $this->appConfig->method('clearCache'); + $this->appConfig->method('getValueInt') + ->with(Application::APP_ID, 'renewal_interval', SessionService::NO_RENEWAL_INTERVAL) + ->willReturn(10); + + $this->sessionService->method('getSignStartTime')->willReturn(0); + $this->signRequestMapper->method('getById')->with(10)->willReturn($this->newSignRequest( + createdAt: '2026-02-16T10:00:00+00:00', + uuid: '903c8fa8-f140-4213-a2fd-f435eea3492d', + )); + $this->timeFactory->method('getDateTime')->willReturn(new \DateTime('2026-02-16T10:00:12+00:00')); + + $identifyMethod = $this->newIdentifyMethodEntity( + signRequestId: 10, + identifierValue: 'signer@domain.test', + lastAttemptDate: '2026-02-16T10:00:01+00:00', + identifiedAtDate: $identifiedAtDate, + ); + + $method = $this->newMethodWithEntity($identifyMethod); + $method->forceName('email'); + + if ($mustExpire) { + $this->expectException(LibresignException::class); + $this->expectExceptionMessageMatches('/.*Link expired.*/'); + $method->runThrowIfRenewalIntervalExpired(); + return; + } + + $method->runThrowIfRenewalIntervalExpired(); + $this->assertSame(10, $method->getEntity()->getSignRequestId()); + } + + public static function providerRenewalWindowByLastAction(): array { + return [ + 'without identifiedAt expires by older lastAttempt' => [null, true], + 'with recent identifiedAt keeps renewal valid' => ['2026-02-16T10:00:05+00:00', false], + ]; + } + + private function newMethodWithEntity(IdentifyMethod $entity): AbstractIdentifyMethodForTest { + $method = new AbstractIdentifyMethodForTest($this->identifyService); + $method->setEntity($entity); + return $method; + } + + private function newIdentifyMethodEntity( + int $signRequestId, + string $identifierValue, + ?string $lastAttemptDate, + ?string $identifiedAtDate, + ): IdentifyMethod { + $identifyMethod = new IdentifyMethod(); + $identifyMethod->setId(99); + $identifyMethod->setSignRequestId($signRequestId); + $identifyMethod->setIdentifierValue($identifierValue); + $identifyMethod->setLastAttemptDate($lastAttemptDate); + $identifyMethod->setIdentifiedAtDate($identifiedAtDate); + return $identifyMethod; + } + + private function newSignRequest(string $createdAt, string $uuid): SignRequest { + $signRequest = new SignRequest(); + $signRequest->setCreatedAt(new \DateTime($createdAt)); + $signRequest->setUuid($uuid); + return $signRequest; + } +} + +final class AbstractIdentifyMethodForTest extends AbstractIdentifyMethod { + public function runRenewSession(): void { + $this->renewSession(); + } + + public function runThrowIfRenewalIntervalExpired(): void { + $this->throwIfRenewalIntervalExpired(); + } + + public function forceName(string $name): void { + $this->name = $name; + } +} diff --git a/tests/php/Unit/Service/SessionServiceTest.php b/tests/php/Unit/Service/SessionServiceTest.php new file mode 100644 index 0000000000..8b00725fe3 --- /dev/null +++ b/tests/php/Unit/Service/SessionServiceTest.php @@ -0,0 +1,58 @@ +session = $this->createMock(ISession::class); + $this->appConfig = $this->createMock(IAppConfig::class); + } + + private function getService(): SessionService { + return new SessionService( + $this->session, + $this->appConfig, + ); + } + + #[DataProvider('providerGetSessionId')] + public function testGetSessionIdResolvesByContext(?string $userId, mixed $uuid, string $expected): void { + $this->session->method('get') + ->willReturnCallback(function (string $key) use ($userId, $uuid) { + return match ($key) { + 'user_id' => $userId, + 'libresign-uuid' => $uuid, + default => null, + }; + }); + $this->session->method('getId') + ->willReturn('session-raw-id'); + + $this->assertSame($expected, $this->getService()->getSessionId()); + } + + public static function providerGetSessionId(): array { + return [ + 'authenticated keeps raw session id' => ['admin', 'public-uuid', 'session-raw-id'], + 'anonymous uses public uuid when available' => [null, 'public-uuid', 'public-uuid'], + 'anonymous ignores non-string public uuid' => [null, 123, 'session-raw-id'], + 'anonymous falls back to raw session id' => [null, null, 'session-raw-id'], + ]; + } +}