Skip to content

Commit d0c59f4

Browse files
authored
Merge pull request #7647 from LibreSign/fix/envelope-validation-multifile-display
fix: render envelope validation data correctly for multi-file requests
2 parents b9f0087 + df68129 commit d0c59f4

3 files changed

Lines changed: 268 additions & 2 deletions

File tree

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*
5+
* Bug reproduction: Validation screen doesn't display data correctly for envelopes with 2+ files
6+
*/
7+
8+
import { expect, test } from '@playwright/test'
9+
import type { APIRequestContext, Page } from '@playwright/test'
10+
import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning'
11+
import { createMailpitClient, extractSignLink, waitForEmailTo } from '../support/mailpit'
12+
13+
type EnvelopeSigningScenario = {
14+
envelopeName: string
15+
signerEmail: string
16+
signerName: string
17+
}
18+
19+
type OcsEnvelopeChildSigner = {
20+
signRequestId?: number
21+
email?: string
22+
displayName?: string
23+
}
24+
25+
type OcsEnvelopeChildFile = {
26+
id?: number
27+
name?: string
28+
signers?: OcsEnvelopeChildSigner[]
29+
}
30+
31+
type OcsEnvelopeResponse = {
32+
uuid?: string
33+
files?: OcsEnvelopeChildFile[]
34+
}
35+
36+
function buildSigningScenario(): EnvelopeSigningScenario {
37+
return {
38+
envelopeName: `Envelope Validation Bug - ${Date.now()}`,
39+
signerEmail: 'signer-validation@libresign.coop',
40+
signerName: 'Validation Tester',
41+
}
42+
}
43+
44+
async function requestLibreSignApiAsAdmin(
45+
request: APIRequestContext,
46+
method: 'POST' | 'PATCH',
47+
path: string,
48+
body: Record<string, unknown>,
49+
) {
50+
const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin'
51+
const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin'
52+
const auth = 'Basic ' + Buffer.from(`${adminUser}:${adminPassword}`).toString('base64')
53+
const response = await request.fetch(`./ocs/v2.php/apps/libresign/api/v1${path}`, {
54+
method,
55+
headers: {
56+
'OCS-ApiRequest': 'true',
57+
Accept: 'application/json',
58+
Authorization: auth,
59+
'Content-Type': 'application/json',
60+
},
61+
data: JSON.stringify(body),
62+
failOnStatusCode: false,
63+
})
64+
65+
if (!response.ok()) {
66+
throw new Error(`LibreSign OCS request failed: ${method} ${path} -> ${response.status()} ${await response.text()}`)
67+
}
68+
69+
return response.json() as Promise<{ ocs: { data: OcsEnvelopeResponse } }>
70+
}
71+
72+
async function enableEnvelopeScenario(request: APIRequestContext) {
73+
await configureOpenSsl(request, 'LibreSign Test', {
74+
C: 'BR',
75+
OU: ['Organization Unit'],
76+
ST: 'Rio de Janeiro',
77+
O: 'LibreSign',
78+
L: 'Rio de Janeiro',
79+
})
80+
81+
await setAppConfig(request, 'libresign', 'envelope_enabled', '1')
82+
await setAppConfig(
83+
request,
84+
'libresign',
85+
'identify_methods',
86+
JSON.stringify([
87+
{ name: 'account', enabled: false, mandatory: false },
88+
{ name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false },
89+
]),
90+
)
91+
}
92+
93+
async function createEnvelopeWithMultipleFiles(
94+
request: APIRequestContext,
95+
scenario: EnvelopeSigningScenario,
96+
) {
97+
const pdfResponse = await request.get('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf', {
98+
failOnStatusCode: true,
99+
})
100+
const pdfBase64 = Buffer.from(await pdfResponse.body()).toString('base64')
101+
102+
const createResponse = await requestLibreSignApiAsAdmin(request, 'POST', '/request-signature', {
103+
name: scenario.envelopeName,
104+
files: [
105+
{ name: 'document-1.pdf', base64: pdfBase64 },
106+
{ name: 'document-2.pdf', base64: pdfBase64 },
107+
],
108+
signers: [{
109+
displayName: scenario.signerName,
110+
identifyMethods: [{
111+
method: 'email',
112+
value: scenario.signerEmail,
113+
mandatory: 1,
114+
}],
115+
}],
116+
})
117+
118+
const envelope = createResponse.ocs.data
119+
120+
if (!envelope.uuid) {
121+
throw new Error('Failed to create envelope with multiple files')
122+
}
123+
124+
// Activate the envelope
125+
await requestLibreSignApiAsAdmin(request, 'PATCH', '/request-signature', {
126+
uuid: envelope.uuid,
127+
status: 1,
128+
})
129+
130+
return envelope
131+
}
132+
133+
async function waitForSignerInvitationLink(signerEmail: string) {
134+
const email = await waitForEmailTo(
135+
createMailpitClient(),
136+
signerEmail,
137+
'LibreSign: There is a file for you to sign',
138+
)
139+
const signLink = extractSignLink(email.Text)
140+
if (!signLink) {
141+
throw new Error('Sign link not found in email')
142+
}
143+
return signLink
144+
}
145+
146+
async function openInvitationAsExternalSigner(page: Page, signLink: string) {
147+
// API setup runs as admin. Clear cookies so the browser behaves like the real external signer.
148+
await page.context().clearCookies()
149+
await page.goto(signLink)
150+
}
151+
152+
async function defineClickToSignature(page: Page) {
153+
// Wait for click-to-sign button
154+
await expect(page.getByRole('button', { name: 'Sign the document.' })).toBeVisible({ timeout: 5_000 })
155+
}
156+
157+
async function finishSigning(page: Page) {
158+
const signButton = page.getByRole('button', { name: 'Sign the document.' })
159+
await signButton.scrollIntoViewIfNeeded()
160+
await signButton.click()
161+
await page.getByRole('button', { name: 'Sign document' }).click()
162+
}
163+
164+
test('validation screen should display all data correctly for envelope with 2 files', async ({ page }) => {
165+
const scenario = buildSigningScenario()
166+
const mailpit = createMailpitClient()
167+
168+
await test.step('Given the system is configured to allow envelope signing via e-mail', async () => {
169+
await enableEnvelopeScenario(page.request)
170+
})
171+
172+
await test.step('And an envelope with two files is created', async () => {
173+
await mailpit.deleteMessages()
174+
await createEnvelopeWithMultipleFiles(page.request, scenario)
175+
})
176+
177+
await test.step('When the external signer opens the invitation link', async () => {
178+
const signLink = await waitForSignerInvitationLink(scenario.signerEmail)
179+
await openInvitationAsExternalSigner(page, signLink)
180+
})
181+
182+
await test.step('And completes the signing process with click-to-sign', async () => {
183+
await defineClickToSignature(page)
184+
await finishSigning(page)
185+
})
186+
187+
await test.step('Then the validation screen should display the envelope information correctly', async () => {
188+
// Wait for validation page to load
189+
await page.waitForURL('**/validation/**')
190+
191+
// Verify envelope information section is visible
192+
await expect(page.getByText('Envelope information')).toBeVisible()
193+
194+
// Verify envelope name is displayed
195+
const envelopeName = page.locator('h2.app-sidebar-header__mainname')
196+
await expect(envelopeName).toHaveText(scenario.envelopeName)
197+
198+
// Verify documents in envelope section exists
199+
await expect(page.getByText('Documents in this envelope')).toBeVisible()
200+
201+
await expect(page.getByText('Number of documents:')).toBeVisible()
202+
203+
// Get the documents list
204+
const documentsList = page.locator('ul.documents-list li.document-item')
205+
const documentsCount = await documentsList.count()
206+
207+
console.log(`Found ${documentsCount} documents in the list`)
208+
expect(documentsCount).toBe(2)
209+
210+
// Success message should be visible
211+
await expect(page.getByText('Congratulations you have digitally signed a document using LibreSign')).toBeVisible()
212+
})
213+
})

src/components/validation/EnvelopeValidation.vue

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@
2929
{{ documentStatus }}
3030
</template>
3131
</NcListItem>
32-
<NcListItem v-if="document.filesCount" class="extra" compact>
32+
<NcListItem v-if="envelopeFilesCount !== null" class="extra" compact>
3333
<template #name>
3434
<strong>{{ t('libresign', 'Number of documents:') }}</strong>
35-
{{ document.filesCount }}
35+
{{ envelopeFilesCount }}
3636
</template>
3737
</NcListItem>
3838
<NcListItem v-if="document.signedDate" class="extra" compact>
@@ -161,6 +161,13 @@
161161
<script setup lang="ts">
162162
import { n, t } from '@nextcloud/l10n'
163163
import { generateUrl } from '@nextcloud/router'
164+
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
165+
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
166+
import NcButton from '@nextcloud/vue/components/NcButton'
167+
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
168+
import NcListItem from '@nextcloud/vue/components/NcListItem'
169+
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
170+
import NcRichText from '@nextcloud/vue/components/NcRichText'
164171
import { computed, ref, watch } from 'vue'
165172
166173
import {
@@ -215,6 +222,15 @@ const fileOpenState = ref<Record<number, boolean>>({})
215222
const signerOpenState = ref<Record<number, boolean>>({})
216223
217224
const documentStatus = computed(() => getStatusLabel(props.document.status))
225+
const envelopeFilesCount = computed(() => {
226+
if (typeof props.document.filesCount === 'number') {
227+
return props.document.filesCount
228+
}
229+
if (Array.isArray(props.document.files)) {
230+
return props.document.files.length
231+
}
232+
return null
233+
})
218234
219235
function resetDisclosureState() {
220236
fileOpenState.value = {}
@@ -274,6 +290,7 @@ watch(() => props.document, () => {
274290
defineExpose({
275291
isTouchDevice,
276292
documentStatus,
293+
envelopeFilesCount,
277294
isSignerOpen,
278295
isFileOpen,
279296
getFileStatusText,

src/tests/components/validation/EnvelopeValidation.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ type ViewerModule = typeof import('../../../utils/viewer.js')
5454
type EnvelopeValidationVm = {
5555
isTouchDevice: boolean
5656
documentStatus: string
57+
envelopeFilesCount: number | null
5758
$nextTick: () => Promise<void>
5859
toggleDetail: (signerIndex: number) => void
5960
toggleFileDetail: (fileIndex: number) => void
@@ -386,6 +387,41 @@ describe('EnvelopeValidation', () => {
386387
})
387388
})
388389

390+
describe('RULE: envelopeFilesCount shows robust documents count', () => {
391+
it('keeps 0 as a valid count instead of hiding it', async () => {
392+
wrapper = createWrapper({
393+
document: {
394+
filesCount: 0,
395+
files: [],
396+
},
397+
})
398+
399+
await wrapper.vm.$nextTick()
400+
401+
expect(wrapper.vm.envelopeFilesCount).toBe(0)
402+
expect(wrapper.text()).toContain('Number of documents:')
403+
expect(wrapper.text()).toContain('0')
404+
})
405+
406+
it('falls back to files length when filesCount is missing', async () => {
407+
wrapper = createWrapper({
408+
document: {
409+
filesCount: undefined as unknown as number,
410+
files: [
411+
{ id: 1, status: '3', name: 'first.pdf' },
412+
{ id: 2, status: '1', name: 'second.pdf' },
413+
],
414+
},
415+
})
416+
417+
await wrapper.vm.$nextTick()
418+
419+
expect(wrapper.vm.envelopeFilesCount).toBe(2)
420+
expect(wrapper.text()).toContain('Number of documents:')
421+
expect(wrapper.text()).toContain('2')
422+
})
423+
})
424+
389425
describe('RULE: created lifecycle initializes local UI state', () => {
390426
it('starts file details collapsed on created', () => {
391427
const files: EnvelopeFile[] = [{ id: 1, status: '3' }]

0 commit comments

Comments
 (0)