Skip to content

Commit b1542cc

Browse files
authored
Merge pull request #7065 from LibreSign/backport/7060/stable33
[stable33] test: multi signer e2e
2 parents 98f9d28 + 2583279 commit b1542cc

11 files changed

Lines changed: 380 additions & 36 deletions

File tree

lib/Service/IdentifyMethod/Email.php

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -130,15 +130,16 @@ private function throwIfIsAuthenticatedWithDifferentAccount(): void {
130130
return;
131131
}
132132
$email = $this->entity->getIdentifierValue();
133-
if (!empty($user->getEMailAddress()) && $user->getEMailAddress() !== $email) {
134-
if ($this->getEntity()->getCode() && !$this->getEntity()->getIdentifiedAtDate()) {
135-
return;
136-
}
137-
throw new LibresignException(json_encode([
138-
'action' => JSActions::ACTION_DO_NOTHING,
139-
'errors' => [['message' => $this->identifyService->getL10n()->t('Invalid user')]],
140-
]));
133+
if (!empty($user->getEMailAddress()) && $user->getEMailAddress() === $email) {
134+
return;
141135
}
136+
if ($this->getEntity()->getCode() && !$this->getEntity()->getIdentifiedAtDate()) {
137+
return;
138+
}
139+
throw new LibresignException(json_encode([
140+
'action' => JSActions::ACTION_DO_NOTHING,
141+
'errors' => [['message' => $this->identifyService->getL10n()->t('This document is not yours. Log out and use the sign link again.')]],
142+
]));
142143
}
143144

144145
private function throwIfAccountAlreadyExists(): void {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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 { login } from '../support/nc-login'
8+
import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning'
9+
import { createMailpitClient, waitForEmailTo } from '../support/mailpit'
10+
11+
test('request signatures from two signers in parallel', async ({ page }) => {
12+
await login(
13+
page.request,
14+
process.env.NEXTCLOUD_ADMIN_USER ?? 'admin',
15+
process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin',
16+
)
17+
18+
await configureOpenSsl(page.request, 'LibreSign Test', {
19+
C: 'BR',
20+
OU: ['Organization Unit'],
21+
ST: 'Rio de Janeiro',
22+
O: 'LibreSign',
23+
L: 'Rio de Janeiro',
24+
})
25+
26+
await setAppConfig(
27+
page.request,
28+
'libresign',
29+
'identify_methods',
30+
JSON.stringify([
31+
{ name: 'account', enabled: false, mandatory: false },
32+
{ name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false },
33+
]),
34+
)
35+
36+
const mailpit = createMailpitClient()
37+
await mailpit.deleteMessages()
38+
39+
await page.goto('./apps/libresign')
40+
await page.getByRole('button', { name: 'Upload from URL' }).click()
41+
await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf')
42+
await page.getByRole('button', { name: 'Send' }).click()
43+
44+
// Add first signer — only email method is active, so the field appears directly (no tabs)
45+
await page.getByRole('button', { name: 'Add signer' }).click()
46+
await page.getByPlaceholder('Email').click()
47+
await page.getByPlaceholder('Email').pressSequentially('signer01@libresign.coop', { delay: 50 })
48+
await page.getByRole('option', { name: 'signer01@libresign.coop' }).click()
49+
await page.getByRole('textbox', { name: 'Signer name' }).fill('Signer 01')
50+
await page.getByRole('button', { name: 'Save' }).click()
51+
52+
// Add second signer
53+
await page.getByRole('button', { name: 'Add signer' }).click()
54+
await page.getByPlaceholder('Email').click()
55+
await page.getByPlaceholder('Email').pressSequentially('signer02@libresign.coop', { delay: 50 })
56+
await page.getByRole('option', { name: 'signer02@libresign.coop' }).click()
57+
await page.getByRole('textbox', { name: 'Signer name' }).fill('Signer 02')
58+
await page.getByRole('button', { name: 'Save' }).click()
59+
60+
// With 2+ signers the "Sign in order" switch must be visible and unchecked by default,
61+
// meaning parallel flow — both signers will be notified at the same time.
62+
const signInOrderSwitch = page.getByLabel('Sign in order')
63+
await expect(signInOrderSwitch).toBeVisible()
64+
await expect(signInOrderSwitch).not.toBeChecked()
65+
66+
// Send the signature request
67+
await page.getByRole('button', { name: 'Request signatures' }).click()
68+
await page.getByRole('button', { name: 'Send' }).click()
69+
70+
// In parallel mode both signers are notified simultaneously.
71+
// Proof: wait for signer01's email, then verify that signer02's email also arrived.
72+
await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: There is a file for you to sign')
73+
await waitForEmailTo(mailpit, 'signer02@libresign.coop', 'LibreSign: There is a file for you to sign')
74+
75+
// Both emails arrived — both signers were notified at the same time, confirming parallel mode.
76+
const result = await mailpit.searchMessages({ query: 'subject:"LibreSign: There is a file for you to sign"' })
77+
expect(result.messages).toHaveLength(2)
78+
})
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
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 { login } from '../support/nc-login'
8+
import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning'
9+
import { createMailpitClient, waitForEmailTo, extractSignLink } from '../support/mailpit'
10+
11+
test('request signatures from two signers in sequential order', async ({ page }) => {
12+
await login(
13+
page.request,
14+
process.env.NEXTCLOUD_ADMIN_USER ?? 'admin',
15+
process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin',
16+
)
17+
18+
await configureOpenSsl(page.request, 'LibreSign Test', {
19+
C: 'BR',
20+
OU: ['Organization Unit'],
21+
ST: 'Rio de Janeiro',
22+
O: 'LibreSign',
23+
L: 'Rio de Janeiro',
24+
})
25+
26+
await setAppConfig(
27+
page.request,
28+
'libresign',
29+
'identify_methods',
30+
JSON.stringify([
31+
{ name: 'account', enabled: false, mandatory: false },
32+
{ name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false },
33+
]),
34+
)
35+
36+
const mailpit = createMailpitClient()
37+
await mailpit.deleteMessages()
38+
39+
await page.goto('./apps/libresign')
40+
await page.getByRole('button', { name: 'Upload from URL' }).click()
41+
await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf')
42+
await page.getByRole('button', { name: 'Send' }).click()
43+
44+
// Add first signer — only email method is active, so the field appears directly (no tabs)
45+
await page.getByRole('button', { name: 'Add signer' }).click()
46+
await page.getByPlaceholder('Email').click()
47+
await page.getByPlaceholder('Email').pressSequentially('signer01@libresign.coop', { delay: 50 })
48+
await page.getByRole('option', { name: 'signer01@libresign.coop' }).click()
49+
await page.getByRole('textbox', { name: 'Signer name' }).fill('Signer 01')
50+
await page.getByRole('button', { name: 'Save' }).click()
51+
52+
// Add second signer
53+
await page.getByRole('button', { name: 'Add signer' }).click()
54+
await page.getByPlaceholder('Email').click()
55+
await page.getByPlaceholder('Email').pressSequentially('signer02@libresign.coop', { delay: 50 })
56+
await page.getByRole('option', { name: 'signer02@libresign.coop' }).click()
57+
await page.getByRole('textbox', { name: 'Signer name' }).fill('Signer 02')
58+
await page.getByRole('button', { name: 'Save' }).click()
59+
60+
// Enable sequential signing.
61+
// The checkbox input is hidden by CSS; click the visible label text to toggle it.
62+
await expect(page.getByLabel('Sign in order')).toBeVisible()
63+
await page.getByText('Sign in order').click()
64+
await expect(page.getByLabel('Sign in order')).toBeChecked()
65+
66+
// Send the signature request
67+
await page.getByRole('button', { name: 'Request signatures' }).click()
68+
await page.getByRole('button', { name: 'Send' }).click()
69+
70+
// In sequential mode only signer01 (order 1) gets the email immediately.
71+
// Proof: signer01's email arrives, but signer02's does NOT at this point.
72+
const email01 = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: There is a file for you to sign')
73+
74+
const afterFirst = await mailpit.searchMessages({ query: 'subject:"LibreSign: There is a file for you to sign"' })
75+
expect(afterFirst.messages).toHaveLength(1)
76+
77+
// Logout before signing as signer01 — the sign link is for an email-based signer
78+
// (no Nextcloud account), so it must be accessed without an active admin session.
79+
await page.getByRole('button', { name: 'Settings menu' }).click()
80+
await page.getByRole('link', { name: 'Log out' }).click()
81+
82+
// Signer01 signs via the link received in the email
83+
const signLink = extractSignLink(email01.Text)
84+
if (!signLink) throw new Error('Sign link not found in email')
85+
await page.goto(signLink)
86+
await page.getByRole('button', { name: 'Sign the document.' }).click()
87+
await page.getByRole('button', { name: 'Sign document' }).click()
88+
await page.waitForURL('**/validation/**')
89+
await expect(page.getByText('This document is valid')).toBeVisible()
90+
// Signer01 signed; signer02 is still waiting (sequential mode proof at this point)
91+
await expect(page.getByText('Signer 01')).toBeVisible()
92+
await page.getByRole('button', { name: 'Expand details of Signer 01' }).click()
93+
await page.getByRole('button', { name: 'Expand validation status', exact: true }).click();
94+
await page.getByRole('link', { name: 'Document integrity verified' }).click();
95+
await page.getByRole('button', { name: 'Expand document certification', exact: true }).click();
96+
await page.getByRole('link', { name: 'Document has not been' }).click();
97+
98+
await expect(page.getByText('Signer 02')).toBeVisible()
99+
await expect(page.getByText('Not signed yet')).toBeVisible()
100+
101+
// Now that signer01 has signed, signer02 must receive their notification.
102+
await waitForEmailTo(mailpit, 'signer02@libresign.coop', 'LibreSign: There is a file for you to sign')
103+
104+
const afterSecond = await mailpit.searchMessages({ query: 'subject:"LibreSign: There is a file for you to sign"' })
105+
expect(afterSecond.messages).toHaveLength(2)
106+
})

src/components/RightSidebar/RequestSignatureTab.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -744,8 +744,10 @@ export default {
744744
745745
if (value) {
746746
if (file?.signers) {
747+
const orders = file.signers.map(s => s.signingOrder || 0)
748+
const hasDuplicateOrders = orders.length !== new Set(orders).size
747749
file.signers.forEach((signer, index) => {
748-
if (!signer.signingOrder) {
750+
if (!signer.signingOrder || hasDuplicateOrders) {
749751
signer.signingOrder = index + 1
750752
}
751753
})

src/components/Signers/Signers.vue

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,23 @@
55
<template>
66
<draggable v-if="isOrderedNumeric && canReorder"
77
v-model="sortableSigners"
8+
item-key="identify"
89
tag="ul"
910
handle=".list-item"
1011
class="signers-list"
1112
chosenClass="signer-dragging"
1213
dragClass="signer-drag-ghost"
1314
@end="onDragEnd">
14-
<transition-group name="signer-list" tag="div">
15-
<Signer v-for="(signer, index) in sortableSigners"
16-
:key="signer.identify"
15+
<template #item="{ element: signer, index }">
16+
<Signer
1717
:signer-index="index"
1818
:event="event"
1919
:draggable="!signer.signed">
2020
<template #actions="{closeActions}">
2121
<slot name="actions" :signer="signer" :closeActions="closeActions" />
2222
</template>
2323
</Signer>
24-
</transition-group>
24+
</template>
2525
</draggable>
2626
<ul v-else>
2727
<Signer v-for="(signer, index) in signers"
@@ -128,20 +128,4 @@ export default {
128128
border-radius: var(--border-radius-large);
129129
}
130130
131-
.signer-list {
132-
&-move {
133-
transition: transform 0.3s ease;
134-
}
135-
136-
&-enter-active,
137-
&-leave-active {
138-
transition: all 0.3s ease;
139-
}
140-
141-
&-enter-from,
142-
&-leave-to {
143-
opacity: 0;
144-
transform: translateX(30px);
145-
}
146-
}
147131
</style>

src/components/validation/SignerDetails.vue

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
</template>
3232
<template #extra-actions>
3333
<NcButton v-if="signer.signed" variant="tertiary"
34-
:aria-label="isOpen ? t('libresign', 'Collapse details') : t('libresign', 'Expand details')"
34+
:aria-label="toggleDetailsAriaLabel"
3535
@click.stop="toggleOpen">
3636
<template #icon>
3737
<NcIconSvgWrapper v-if="isOpen"
@@ -296,6 +296,21 @@ export default {
296296
n,
297297
}
298298
},
299+
computed: {
300+
toggleDetailsAriaLabel() {
301+
const signerName = this.getName(this.signer)
302+
if (this.isOpen) {
303+
// TRANSLATORS Accessible label for the button that collapses the signature details of
304+
// a specific signer in the document validation page. {signerName} is the signer's
305+
// display name, email, or "Unknown" when no identification is available.
306+
return t('libresign', 'Collapse details of {signerName}', { signerName })
307+
}
308+
// TRANSLATORS Accessible label for the button that expands the signature details of
309+
// a specific signer in the document validation page. {signerName} is the signer's
310+
// display name, email, or "Unknown" when no identification is available.
311+
return t('libresign', 'Expand details of {signerName}', { signerName })
312+
},
313+
},
299314
data() {
300315
return {
301316
isOpen: this.initiallyOpen,

src/tests/components/RightSidebar/RequestSignatureTab.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,23 @@ describe('RequestSignatureTab - Critical Business Rules', () => {
793793
expect(filesStore.files[1].signers[1].signingOrder).toBe(2)
794794
})
795795

796+
it('reassigns sequential orders when all signers share the same signingOrder', async () => {
797+
// Signers saved via the API return signingOrder: 1 as default for all of them.
798+
// The old check (!signer.signingOrder) would skip them because !1 === false,
799+
// leaving both at order 1 and causing the backend to notify both simultaneously.
800+
await updateFile({
801+
signatureFlow: 'parallel',
802+
signers: [
803+
{ email: 'signer1@example.com', signed: [], signingOrder: 1 },
804+
{ email: 'signer2@example.com', signed: [], signingOrder: 1 },
805+
],
806+
})
807+
wrapper.vm.onPreserveOrderChange(true)
808+
await wrapper.vm.$nextTick()
809+
expect(filesStore.files[1].signers[0].signingOrder).toBe(1)
810+
expect(filesStore.files[1].signers[1].signingOrder).toBe(2)
811+
})
812+
796813
it('reverts to parallel when disabling', async () => {
797814
await wrapper.setData({ adminSignatureFlow: 'none' })
798815
await updateFile({

src/tests/components/Signers/Signers.spec.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,17 @@ describe('Signers', () => {
3636
plugins: [pinia],
3737
stubs: {
3838
Signer: true,
39+
// Stub for vuedraggable 4 (Vue 3) which uses #item slot per element
3940
draggable: {
4041
name: 'draggable',
41-
template: '<div><slot /></div>',
42-
props: ['modelValue', 'tag', 'handle', 'class', 'chosenClass', 'dragClass'],
43-
},
44-
'transition-group': {
45-
name: 'transition-group',
46-
template: '<div><slot /></div>',
42+
template: `
43+
<div>
44+
<template v-for="(element, index) in modelValue" :key="index">
45+
<slot name="item" :element="element" :index="index" />
46+
</template>
47+
</div>
48+
`,
49+
props: ['modelValue', 'itemKey', 'tag', 'handle', 'class', 'chosenClass', 'dragClass'],
4750
},
4851
},
4952
},

0 commit comments

Comments
 (0)