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 createIssue7344Scenario ( ) : EnvelopeSigningScenario {
42+ return {
43+ envelopeName : `Issue 7344 envelope ${ 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 = createIssue7344Scenario ( )
213+ const mailpit = createMailpitClient ( )
214+
215+ // 1. Prepare the signing rules used in this scenario.
216+ await enableEnvelopeScenario ( page . request )
217+
218+ // 2. Start with a clean inbox and create the envelope already containing
219+ // a visible signature box inside the first PDF of the envelope.
220+ await mailpit . deleteMessages ( )
221+ await createEnvelopeWithVisibleSignatureRequirement ( page . request , scenario )
222+
223+ // 3. Open the invitation exactly as the external signer would do.
224+ const signLink = await waitForSignerInvitationLink ( scenario . signerEmail )
225+ await openInvitationAsExternalSigner ( page , signLink )
226+
227+ // 4. Define the visible signature and complete the signing action.
228+ await defineVisibleSignature ( page )
229+ await finishSigning ( page )
230+
231+ // 5. Confirm the signer reaches the final success screen.
232+ await expectEnvelopeSigned ( page , scenario . envelopeName )
233+ } )
0 commit comments