diff --git a/lib/Db/IdDocsMapper.php b/lib/Db/IdDocsMapper.php index 4805d23a10..b22f9a703b 100644 --- a/lib/Db/IdDocsMapper.php +++ b/lib/Db/IdDocsMapper.php @@ -192,8 +192,7 @@ private function getQueryBuilder(array $filter = [], bool $count = false): IQuer ->leftJoin('id', 'libresign_sign_request', 'sr', 'id.sign_request_id = sr.id'); if ($needsUserJoin) { - $joinType = !empty($filter['userId']) ? 'join' : 'leftJoin'; - $qb->$joinType('id', 'users', 'u', 'id.user_id = u.uid'); + $qb->leftJoin('id', 'users', 'u', 'id.user_id = u.uid'); } if (!empty($filter['userId'])) { diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php index 422a7e30ae..7263ac6294 100644 --- a/lib/Service/SignFileService.php +++ b/lib/Service/SignFileService.php @@ -1045,7 +1045,7 @@ private function updateEntityCacheAfterDbSave(FileEntity $file): void { } private function evaluateStatusFromSigners(): ?int { - $signers = $this->getSigners(); + $signers = $this->excludeIdDocUploaderPlaceholder($this->getSigners()); $total = count($signers); @@ -1066,6 +1066,41 @@ private function evaluateStatusFromSigners(): ?int { return null; } + /** + * @param SignRequestEntity[] $signers + * @return SignRequestEntity[] + */ + private function excludeIdDocUploaderPlaceholder(array $signers): array { + $placeholderId = $this->getIdDocUploaderSignRequestId(); + if ($placeholderId === null) { + return $signers; + } + + return array_values(array_filter( + $signers, + static fn (SignRequestEntity $signer): bool => $signer->getId() !== $placeholderId, + )); + } + + private function getIdDocUploaderSignRequestId(): ?int { + $fileId = $this->signRequest->getFileId(); + if ($fileId === null) { + return null; + } + + try { + $idDocs = $this->idDocsMapper->getByFileId($fileId); + } catch (DoesNotExistException) { + return null; + } + + if (!$idDocs instanceof IdDocs) { + return null; + } + + return $idDocs->getSignRequestId(); + } + private function getOrGeneratePfxContent(SignEngineHandler $engine): string { $result = $this->pfxProvider->getOrGeneratePfx( $engine, diff --git a/playwright/e2e/id-docs-visual.spec.ts b/playwright/e2e/id-docs-visual.spec.ts new file mode 100644 index 0000000000..bcea9508b1 --- /dev/null +++ b/playwright/e2e/id-docs-visual.spec.ts @@ -0,0 +1,144 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mkdir } from 'node:fs/promises' +import { resolve } from 'node:path' + +import { expect, test, type APIRequestContext, type Locator, type Page } from '@playwright/test' + +import { login } from '../support/nc-login' +import { + createAuthenticatedRequestContext, + getSystemPolicySnapshot, + policyRequest, + restoreSystemPolicySnapshot, + type SystemPolicySnapshot, +} from '../support/policy-api' + +const POLICY_KEY = 'identification_documents' +const SCREENSHOT_DIR = resolve(process.cwd(), 'build/playwright/visual-output') + +test.describe.configure({ mode: 'serial', timeout: 180000 }) + +let adminContext: APIRequestContext +let originalPolicy: SystemPolicySnapshot + +test.beforeEach(async () => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + adminContext = await createAuthenticatedRequestContext(adminUser, adminPassword) + originalPolicy = await getSystemPolicySnapshot(adminContext, POLICY_KEY) + + const response = await policyRequest( + adminContext, + 'POST', + `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { + value: { enabled: true, approvers: [adminUser] }, + allowChildOverride: true, + }, + ) + expect(response.httpStatus, response.message).toBe(200) +}) + +test.afterEach(async () => { + try { + if (adminContext && originalPolicy) { + await restoreSystemPolicySnapshot(adminContext, POLICY_KEY, originalPolicy) + } + } finally { + await adminContext?.dispose() + } +}) + +async function shot(page: Page, name: string): Promise { + await mkdir(SCREENSHOT_DIR, { recursive: true }) + await page.screenshot({ path: resolve(SCREENSHOT_DIR, `${name}.png`), fullPage: true }) +} + +function emptyStatus(page: Page): Locator { + return page.getByText(/Not sent yet/i) +} + +function deleteButton(page: Page): Locator { + return page.getByRole('button', { name: /Delete file/i }) +} + +function uploadButton(page: Page): Locator { + return page.getByRole('button', { name: /Upload file/i }) +} + +async function waitForIdDocsCard(page: Page): Promise { + await expect(emptyStatus(page).or(deleteButton(page))).toBeVisible({ timeout: 20_000 }) +} + +async function clearExistingIdDocument(page: Page): Promise { + await waitForIdDocsCard(page) + for (let attempt = 0; attempt < 5; attempt++) { + if (!(await deleteButton(page).isVisible().catch(() => false))) { + break + } + await deleteButton(page).click() + await expect(page.getByText(/File was deleted\./i)).toBeVisible({ timeout: 20_000 }) + await waitForIdDocsCard(page) + } + await expect(emptyStatus(page)).toBeVisible({ timeout: 20_000 }) + await expect(uploadButton(page)).toBeVisible() +} + +test('identification documents appear on the account page after upload', async ({ page }) => { + test.slow() + + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + await page.goto('./apps/libresign') + await expect(page.getByRole('button', { name: /Upload from URL/i })).toBeVisible({ timeout: 20_000 }) + await expect(page.getByRole('link', { name: /Documents Validation/i })).toBeVisible({ timeout: 20_000 }) + await shot(page, '01-libresign-home') + + await page.goto('./apps/libresign/f/account') + await expect(page.getByRole('heading', { name: /Identification documents/i })).toBeVisible({ timeout: 20_000 }) + await clearExistingIdDocument(page) + await shot(page, '02-account-id-docs-empty') + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + uploadButton(page).click(), + ]) + await fileChooser.setFiles(resolve(process.cwd(), 'tests/php/fixtures/pdfs/small_valid.pdf')) + + await expect(page.getByText(/File was sent\./i)).toBeVisible({ timeout: 20_000 }) + await expect(deleteButton(page)).toBeVisible({ timeout: 20_000 }) + await expect(emptyStatus(page)).toHaveCount(0) + await shot(page, '03-account-id-doc-uploaded') + + await page.goto('./apps/libresign/f/docs/id-docs/validation') + await expect(page.getByRole('columnheader', { name: /Owner/i })).toBeVisible({ timeout: 20_000 }) + await expect(page.getByRole('cell', { name: /^admin$/i }).first()).toBeVisible({ timeout: 20_000 }) + await expect(page.getByText(/waiting for approval/i).first()).toBeVisible() + await expect(page.getByRole('button', { name: /Sign/i }).first()).toBeVisible() + await shot(page, '04-id-docs-approval-list') + + await page.goto('./settings/admin/libresign') + const catalogSearch = page.locator('.policy-workbench__catalog-search').getByRole('textbox').first() + await expect(catalogSearch).toBeVisible({ timeout: 20_000 }) + + const collapseButton = page.getByRole('button', { + name: /Collapse settings categories|Expand settings categories/i, + }).first() + if (/Expand/i.test((await collapseButton.getAttribute('aria-label')) ?? '')) { + await collapseButton.click() + } + + await catalogSearch.fill('identifica') + await expect(page.getByRole('button', { + name: /Identification documents flow/i, + }).first()).toBeVisible({ timeout: 20_000 }) + await shot(page, '05-settings-identification-documents') +}) diff --git a/tests/php/Unit/Db/IdDocsMapperTest.php b/tests/php/Unit/Db/IdDocsMapperTest.php new file mode 100644 index 0000000000..d1c4065adb --- /dev/null +++ b/tests/php/Unit/Db/IdDocsMapperTest.php @@ -0,0 +1,70 @@ +fileMapper = Server::get(FileMapper::class); + $this->idDocsMapper = Server::get(IdDocsMapper::class); + $this->signRequestMapper = Server::get(SignRequestMapper::class); + } + + public function testListByUserIdReturnsDocumentsWhenAccountIsMissingFromUsersTable(): void { + $ldapUserId = 'ldap-user-without-oc-users-row'; + + $file = new File(); + $file->setNodeId(80808); + $file->setUserId($ldapUserId); + $file->setUuid('c3333333-3333-4333-8333-333333333333'); + $file->setCreatedAt(new \DateTime('now', new \DateTimeZone('UTC'))); + $file->setName('passport.pdf'); + $file->setStatus(FileStatus::ABLE_TO_SIGN->value); + $insertedFile = $this->fileMapper->insert($file); + + $signRequest = new SignRequest(); + $signRequest->setFileId($insertedFile->getId()); + $signRequest->setDisplayName('LDAP User'); + $signRequest->setUuid('d4444444-4444-4444-8444-444444444444'); + $signRequest->setCreatedAt(new \DateTime('now', new \DateTimeZone('UTC'))); + $insertedSignRequest = $this->signRequestMapper->insert($signRequest); + + $this->idDocsMapper->save( + $insertedFile->getId(), + $insertedSignRequest->getId(), + $ldapUserId, + 'IDENTIFICATION', + ); + + $result = $this->idDocsMapper->list( + ['userId' => $ldapUserId], + page: 1, + length: 10, + ); + + $this->assertCount(1, $result['data']); + $this->assertSame($insertedFile->getUuid(), $result['data'][0]['file']['uuid']); + $this->assertSame('LDAP User', $result['data'][0]['account']['displayName']); + } +} diff --git a/tests/php/Unit/Service/SignFileServiceTest.php b/tests/php/Unit/Service/SignFileServiceTest.php index 4b151b353a..ee4b8bcb92 100644 --- a/tests/php/Unit/Service/SignFileServiceTest.php +++ b/tests/php/Unit/Service/SignFileServiceTest.php @@ -29,6 +29,7 @@ use OCA\Libresign\Enum\DocMdpLevel; use OCA\Libresign\Enum\FileStatus; use OCA\Libresign\Enum\FileStatus as FileStatusEnum; +use OCA\Libresign\Enum\SignRequestStatus; use OCA\Libresign\Events\SignedEvent; use OCA\Libresign\Events\SignedEventFactory; use OCA\Libresign\Exception\LibresignException; @@ -986,6 +987,58 @@ private static function generateSigners(int $total, int $signed): array { return $signers; } + public function testIdDocApprovalReachesSignedWhenUploaderPlaceholderIsUnsigned(): void { + $uploader = new SignRequest(); + $uploader->setId(10); + $uploader->setStatus(SignRequestStatus::DRAFT->value); + + $approver = new SignRequest(); + $approver->setId(20); + $approver->setSigned(new DateTime()); + $approver->setStatus(SignRequestStatus::SIGNED->value); + + $idDocs = new IdDocs(); + $idDocs->setSignRequestId(10); + $this->idDocsMapper + ->method('getByFileId') + ->with(1) + ->willReturn($idDocs); + + $service = $this->getService(['getSigners']); + $service->method('getSigners')->willReturn([$uploader, $approver]); + + $signRequest = new SignRequest(); + $signRequest->setFileId(1); + $service->setSignRequest($signRequest); + + $status = self::invokePrivate($service, 'evaluateStatusFromSigners'); + $this->assertSame(FileStatus::SIGNED->value, $status); + } + + public function testDraftSignersStillCountWhenFileIsNotAnIdentificationDocument(): void { + $this->idDocsMapper + ->method('getByFileId') + ->willThrowException(new DoesNotExistException('no identification document')); + + $signed = new SignRequest(); + $signed->setId(1); + $signed->setSigned(new DateTime()); + + $draft = new SignRequest(); + $draft->setId(2); + $draft->setStatus(SignRequestStatus::DRAFT->value); + + $service = $this->getService(['getSigners']); + $service->method('getSigners')->willReturn([$signed, $draft]); + + $signRequest = new SignRequest(); + $signRequest->setFileId(99); + $service->setSignRequest($signRequest); + + $status = self::invokePrivate($service, 'evaluateStatusFromSigners'); + $this->assertSame(FileStatus::PARTIAL_SIGNED->value, $status); + } + #[DataProvider('providerGetEngineWillWorkWithLazyLoadedEngine')] public function testGetEngineWillWorkWithLazyLoadedEngine(string $extension, string $instanceOf): void { $expectedEngine = $this->createMock($instanceOf);