Skip to content

Commit d671ce7

Browse files
authored
Merge pull request #7386 from LibreSign/backport/7383/stable32
[stable32] fix: handle visible signatures for envelope child files
2 parents 36dc33c + 678cd59 commit d671ce7

9 files changed

Lines changed: 461 additions & 40 deletions

File tree

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { expect, test } from '@playwright/test'
7+
import type { APIRequestContext, Page } from '@playwright/test'
8+
import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning'
9+
import { createMailpitClient, extractSignLink, waitForEmailTo } from '../support/mailpit'
10+
11+
/**
12+
* Issue #7344 in plain words:
13+
* an external signer receives an envelope with two PDFs, the visible signature
14+
* box exists only inside one child PDF, and the signer must still be able to
15+
* create the signature and finish the signing flow.
16+
*/
17+
18+
type EnvelopeSigningScenario = {
19+
envelopeName: string
20+
signerEmail: string
21+
signerName: string
22+
}
23+
24+
type OcsEnvelopeChildSigner = {
25+
signRequestId?: number
26+
email?: string
27+
displayName?: string
28+
}
29+
30+
type OcsEnvelopeChildFile = {
31+
id?: number
32+
name?: string
33+
signers?: OcsEnvelopeChildSigner[]
34+
}
35+
36+
type OcsEnvelopeResponse = {
37+
uuid?: string
38+
files?: OcsEnvelopeChildFile[]
39+
}
40+
41+
function buildSigningScenario(): EnvelopeSigningScenario {
42+
return {
43+
envelopeName: `Envelope with visible signature ${Date.now()}`,
44+
signerEmail: 'signer01@libresign.coop',
45+
signerName: 'Signer 01',
46+
}
47+
}
48+
49+
async function requestLibreSignApiAsAdmin(
50+
request: APIRequestContext,
51+
method: 'POST' | 'PATCH',
52+
path: string,
53+
body: Record<string, unknown>,
54+
) {
55+
const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin'
56+
const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin'
57+
const auth = 'Basic ' + Buffer.from(`${adminUser}:${adminPassword}`).toString('base64')
58+
const response = await request.fetch(`./ocs/v2.php/apps/libresign/api/v1${path}`, {
59+
method,
60+
headers: {
61+
'OCS-ApiRequest': 'true',
62+
Accept: 'application/json',
63+
Authorization: auth,
64+
'Content-Type': 'application/json',
65+
},
66+
data: JSON.stringify(body),
67+
failOnStatusCode: false,
68+
})
69+
70+
if (!response.ok()) {
71+
throw new Error(`LibreSign OCS request failed: ${method} ${path} -> ${response.status()} ${await response.text()}`)
72+
}
73+
74+
return response.json() as Promise<{ ocs: { data: OcsEnvelopeResponse } }>
75+
}
76+
77+
async function enableEnvelopeScenario(request: APIRequestContext) {
78+
await configureOpenSsl(request, 'LibreSign Test', {
79+
C: 'BR',
80+
OU: ['Organization Unit'],
81+
ST: 'Rio de Janeiro',
82+
O: 'LibreSign',
83+
L: 'Rio de Janeiro',
84+
})
85+
86+
await setAppConfig(request, 'libresign', 'envelope_enabled', '1')
87+
await setAppConfig(
88+
request,
89+
'libresign',
90+
'identify_methods',
91+
JSON.stringify([
92+
{ name: 'account', enabled: false, mandatory: false },
93+
{ name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false },
94+
]),
95+
)
96+
}
97+
98+
async function createEnvelopeWithVisibleSignatureRequirement(
99+
request: APIRequestContext,
100+
scenario: EnvelopeSigningScenario,
101+
) {
102+
const pdfResponse = await request.get('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf', {
103+
failOnStatusCode: true,
104+
})
105+
const pdfBase64 = Buffer.from(await pdfResponse.body()).toString('base64')
106+
107+
const createResponse = await requestLibreSignApiAsAdmin(request, 'POST', '/request-signature', {
108+
name: scenario.envelopeName,
109+
files: [
110+
{ name: 'issue-7344-a.pdf', base64: pdfBase64 },
111+
{ name: 'issue-7344-b.pdf', base64: pdfBase64 },
112+
],
113+
signers: [{
114+
displayName: scenario.signerName,
115+
identifyMethods: [{
116+
method: 'email',
117+
value: scenario.signerEmail,
118+
mandatory: 1,
119+
}],
120+
}],
121+
})
122+
123+
const envelope = createResponse.ocs.data
124+
const targetFile = envelope.files?.[0]
125+
const targetSigner = targetFile?.signers?.find((signer) => {
126+
return signer.email === scenario.signerEmail || signer.displayName === scenario.signerName
127+
})
128+
129+
if (!envelope.uuid || !targetFile?.id || !targetSigner?.signRequestId) {
130+
throw new Error('Failed to create envelope payload for issue #7344 e2e test')
131+
}
132+
133+
await requestLibreSignApiAsAdmin(request, 'PATCH', '/request-signature', {
134+
uuid: envelope.uuid,
135+
status: 1,
136+
visibleElements: [{
137+
type: 'signature',
138+
fileId: targetFile.id,
139+
signRequestId: targetSigner.signRequestId,
140+
coordinates: {
141+
page: 1,
142+
left: 32,
143+
top: 48,
144+
width: 180,
145+
height: 64,
146+
},
147+
}],
148+
})
149+
}
150+
151+
async function waitForSignerInvitationLink(signerEmail: string) {
152+
const email = await waitForEmailTo(
153+
createMailpitClient(),
154+
signerEmail,
155+
'LibreSign: There is a file for you to sign',
156+
)
157+
const signLink = extractSignLink(email.Text)
158+
if (!signLink) {
159+
throw new Error('Sign link not found in email')
160+
}
161+
return signLink
162+
}
163+
164+
async function openInvitationAsExternalSigner(page: Page, signLink: string) {
165+
// API setup runs as admin. Clear cookies so the browser behaves like the real external signer.
166+
await page.context().clearCookies()
167+
await page.goto(signLink)
168+
}
169+
170+
async function defineVisibleSignature(page: Page) {
171+
const deleteSignatureButton = page.getByRole('button', { name: 'Delete signature' })
172+
await deleteSignatureButton.waitFor({ state: 'visible', timeout: 3_000 }).catch(() => null)
173+
if (await deleteSignatureButton.isVisible()) {
174+
await deleteSignatureButton.click()
175+
}
176+
177+
await expect(page.getByRole('button', { name: 'Define your signature.' })).toBeVisible()
178+
await page.getByRole('button', { name: 'Define your signature.' }).click()
179+
180+
const signatureDialog = page.getByRole('dialog', { name: 'Customize your signatures' })
181+
await expect(signatureDialog).toBeVisible()
182+
await signatureDialog.locator('canvas').click({
183+
position: {
184+
x: 156,
185+
y: 132,
186+
},
187+
})
188+
await signatureDialog.getByRole('button', { name: 'Save' }).click()
189+
190+
const confirmDialog = page.getByLabel('Confirm your signature')
191+
await expect(confirmDialog).toBeVisible()
192+
await confirmDialog.getByRole('button', { name: 'Save' }).click()
193+
194+
await expect(page.getByRole('button', { name: 'Sign the document.' })).toBeVisible()
195+
}
196+
197+
async function finishSigning(page: Page) {
198+
await page.getByRole('button', { name: 'Sign the document.' }).click()
199+
await page.getByRole('button', { name: 'Sign document' }).click()
200+
}
201+
202+
async function expectEnvelopeSigned(page: Page, envelopeName: string) {
203+
await page.waitForURL('**/validation/**')
204+
await expect(page.getByText('Envelope information')).toBeVisible()
205+
await expect(page.getByText('Documents in this envelope')).toBeVisible()
206+
await expect(page.getByText('Congratulations you have digitally signed a document using LibreSign')).toBeVisible()
207+
await expect(page.locator('h2.app-sidebar-header__mainname')).toHaveText(envelopeName)
208+
await expect(page.getByText('You need to define a visible signature or initials to sign this document.')).not.toBeVisible()
209+
}
210+
211+
test('unauthenticated signer can define a visible signature for an envelope with multiple PDFs', async ({ page }) => {
212+
const scenario = buildSigningScenario()
213+
const mailpit = createMailpitClient()
214+
215+
await test.step('Given the system is configured to allow envelope signing via e-mail', async () => {
216+
await enableEnvelopeScenario(page.request)
217+
})
218+
219+
await test.step('And an envelope with two PDFs is created requiring a visible signature on the first document', async () => {
220+
await mailpit.deleteMessages()
221+
await createEnvelopeWithVisibleSignatureRequirement(page.request, scenario)
222+
})
223+
224+
await test.step('When the external signer opens the invitation link received by e-mail', async () => {
225+
const signLink = await waitForSignerInvitationLink(scenario.signerEmail)
226+
await openInvitationAsExternalSigner(page, signLink)
227+
})
228+
229+
await test.step('And the signer draws and saves their visible signature on the document', async () => {
230+
await defineVisibleSignature(page)
231+
})
232+
233+
await test.step('And the signer submits the signed document', async () => {
234+
await finishSigning(page)
235+
})
236+
237+
await test.step('Then the success confirmation screen is shown with the envelope name', async () => {
238+
await expectEnvelopeSigned(page, scenario.envelopeName)
239+
})
240+
})

playwright/support/mailpit.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ export type { MailpitClient }
1010

1111
type Message = Awaited<ReturnType<MailpitClient['getMessageSummary']>>
1212

13+
function resolveAgainstPlaywrightBaseUrl(url: string): string {
14+
const baseUrl = process.env.PLAYWRIGHT_BASE_URL
15+
if (!baseUrl) {
16+
return url
17+
}
18+
19+
const parsedUrl = new URL(url)
20+
const targetUrl = new URL(`${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`, baseUrl)
21+
return targetUrl.toString()
22+
}
23+
1324
/** Creates a MailpitClient using MAILPIT_URL (default: http://localhost:8025). */
1425
export function createMailpitClient(): MailpitClient {
1526
const defaultUrl = existsSync('/.dockerenv')
@@ -65,7 +76,12 @@ export async function waitForEmailTo(
6576
/** Extracts a LibreSign sign link from an email body matching /p/sign/{uuid}. */
6677
export function extractSignLink(body: string): string | null {
6778
const match = body.match(/\S+\/p\/sign\/[\w-]+/)
68-
return match ? match[0] : null
79+
if (!match) {
80+
return null
81+
}
82+
83+
const parsedUrl = new URL(match[0])
84+
return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`
6985
}
7086

7187
/** Extracts a numeric token from an email body. Default pattern: 4-8 digit sequence. */
@@ -80,5 +96,5 @@ export function extractTokenFromEmail(
8096
/** Extracts the first URL from an email body (email.Text). */
8197
export function extractLinkFromEmail(body: string): string | null {
8298
const match = body.match(/https?:\/\/\S+/)
83-
return match ? match[0] : null
99+
return match ? resolveAgainstPlaywrightBaseUrl(match[0]) : null
84100
}

src/services/SigningRequirementValidator.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@
44
*/
55

66
import { ACTION_CODES } from '../helpers/ActionMapping.ts'
7+
import { hasVisibleElementsForCurrentUser } from './visibleElementsService'
78

89
interface SignStore {
910
errors: Array<{ code?: number; [key: string]: unknown }>
1011
document?: {
1112
signers?: Array<{ me?: boolean; signRequestId?: number }> | null
1213
visibleElements?: Array<{ signRequestId?: number }> | null
14+
files?: Array<{
15+
signers?: Array<{ me?: boolean; signRequestId?: number }> | null
16+
visibleElements?: Array<{ signRequestId?: number }> | null
17+
}> | null
1318
}
1419
}
1520

@@ -110,14 +115,8 @@ export class SigningRequirementValidator {
110115
return false
111116
}
112117

113-
const signer = this.signStore.document?.signers?.find(row => row.me) || {}
114-
const signRequestId = (signer as { signRequestId?: number }).signRequestId
115-
116-
if (!signRequestId) {
117-
return false
118-
}
119-
120-
const visibleElements = this.signStore.document?.visibleElements || []
121-
return visibleElements.some(row => row.signRequestId === signRequestId)
118+
return hasVisibleElementsForCurrentUser(
119+
(this.signStore.document ?? {}) as Parameters<typeof hasVisibleElementsForCurrentUser>[0],
120+
)
122121
}
123122
}

src/services/visibleElementsService.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,24 @@ export const getFileSigners = (file: FileLike): SignerLike[] => {
123123
return []
124124
}
125125

126+
export const getCurrentUserSignRequestIds = (document: DocumentLike): number[] => {
127+
const signRequestIds = new Set<number>()
128+
const addSignRequestId = (signer: SignerLike | null | undefined) => {
129+
if (!isCurrentUserSigner(signer) || signer.signRequestId === undefined) {
130+
return
131+
}
132+
signRequestIds.add(signer.signRequestId)
133+
}
134+
135+
const signers = Array.isArray(document?.signers) ? document.signers : []
136+
signers.forEach(addSignRequestId)
137+
138+
const files = Array.isArray(document?.files) ? document.files : []
139+
files.flatMap((file) => getFileSigners(file)).forEach(addSignRequestId)
140+
141+
return Array.from(signRequestIds)
142+
}
143+
126144
export const getVisibleElementsFromDocument = (document: DocumentLike): VisibleElementRecord[] => {
127145
const topLevel = Array.isArray(document?.visibleElements) ? document.visibleElements : []
128146
const signers = Array.isArray(document?.signers) ? document.signers : []
@@ -131,6 +149,16 @@ export const getVisibleElementsFromDocument = (document: DocumentLike): VisibleE
131149
return deduplicateVisibleElements([...topLevel, ...nested, ...files])
132150
}
133151

152+
export const hasVisibleElementsForCurrentUser = (document: DocumentLike): boolean => {
153+
const signRequestIds = new Set(getCurrentUserSignRequestIds(document))
154+
if (signRequestIds.size === 0) {
155+
return false
156+
}
157+
158+
return getVisibleElementsFromDocument(document)
159+
.some((element) => element.signRequestId !== undefined && signRequestIds.has(element.signRequestId))
160+
}
161+
134162
export const getVisibleElementsFromFile = (file: FileLike): VisibleElementRecord[] => {
135163
const topLevel = Array.isArray(file?.visibleElements) ? file.visibleElements : []
136164
const nested = collectSignerVisibleElements(getFileSigners(file))

0 commit comments

Comments
 (0)