Skip to content

fix: apply the rejection visibility rules to signer status and to the validation API - #8398

Open
maia-andre wants to merge 2 commits into
LibreSign:mainfrom
maia-andre:fix/8388-rejection-visibility-contract
Open

maia-andre wants to merge 2 commits into
LibreSign:mainfrom
maia-andre:fix/8388-rejection-visibility-contract

Conversation

@maia-andre

@maia-andre maia-andre commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Resolves: #8388

📝 Summary

Backend contract for rejection visibility, as specified in #8388 after the discussion in #8162 (option (a): redact every unsigned signer of the file when the viewer may not know about a rejection, so nobody is singled out by comparison).

One shared decisionSignatureRejectionVisibilityService:

  • hasHiddenRejection(file, signers, privilegedSignRequestIds): whether the file holds a rejection the viewer may not know about. Privileged = the requester of the file (for every entry) and the signer who rejected (for their own entry); everybody else only sees it when public_status is true.
  • presentSigner(signer, file, privileged, hiddenRejectionInFile)SignerPresentation (displayStatus, real status or null, statusText, rejection under the existing comment rules). With a hidden rejection every unsigned signer (DRAFT, ABLE_TO_SIGN, REJECTED) becomes not_signed / Not signed without the real status; signed signers keep signed; the viewer's own pending entry is redacted like the others (a real status there would tell them who rejected by comparison), and the only entry a viewer keeps is their own rejection, which they already know about.
  • SignerDisplayStatus enum: draft | ready_to_sign | signed | rejected | not_signed — presentation only, not persisted, never read for authorization. SignRequestStatus is untouched.

Every response path consumes it — no serializer reconstructs the rules:

  • File\SignersLoader (validation endpoint) — gains the rejection object it never had, and FileResponseOptions::isViewerOfSigner() (the identity check the loader used to do inline: identify method of the session, uid or e-mail of the authenticated user) so the envelope path resolves the viewer the same way;
  • FileListService — detailed file, file with children (authenticated and anonymous) and the child summaries; the child summary used to encode status: 1 for a signed signer and 0 otherwise, it now carries the real state and displayStatus like the contract says;
  • EnvelopeAssembler — child documents, same rules;
  • FileService::mapSignerDetailsToSummary() — passes the presentation through instead of mapping REJECTED (and anything unknown) to DRAFT.

Contracts: LibresignSignerDisplayStatus; LibresignSignerSummary (and LibresignSignerDetail / LibresignValidatedChildSigner built on it) declare displayStatus as required and status?: 0|1|2|3 as optional. Generated schema: SignerDisplayStatus enum, status no longer required; TypeScript displayStatus: components["schemas"]["SignerDisplayStatus"], status?: 0 | 1 | 2 | 3.

The buildSignerRejection() comment branch was folded into one return with identical behaviour (it was an equivalent mutant).

🧪 How to test

Behat (tests/integration/features/sign/reject.feature, 3 new scenarios, 90 steps, run locally):

  • private status: requester (list + validation: real state, rejection.comment), the rejecter (own entry rejected, the other ready_to_sign), the other signer (every unsigned entry not_signed, their own included — no entry differs from not_signed — no status, no rejection, on validation, on the list and on the child summary), an authenticated bystander and an anonymous reader ([.signers[].displayStatus] | unique == ["not_signed"], no status, no rejection); then the rejected signer still cannot reject/sign again and the requester still sees 3 — redaction is presentation only;
  • public status: anonymous gets rejected / 3 / rejectedAt and no comment (show_comment_on_validation: false), the requester gets the comment;
  • canceled workflow: anonymous gets file.status: 6 and every signer not_signed without status; the requester sees who rejected.

PHPUnit (all run locally): SignatureRejectionVisibilityServiceTest (26 — hidden decision incl. privileged/public/multiple rejections, the four mappings, redaction of each unsigned state, signed never redacted, own pending entry redacted, own rejection kept, rejection object), SignerDisplayStatusTest, SignerPresentationTest, FileResponseOptionsTest (viewer identity), and the viewer matrix — anonymous, another user, the pending signer, the requester, the rejecter — on SignersLoaderTest, FileListServiceTest (detailed, with children, child summary) and EnvelopeAssemblerTest; FileServiceTest summaries (visible rejection stays 3, redacted entry has no status, unknown status is no longer DRAFT).

Infection on SignatureRejectionVisibilityService, SignerPresentation and SignerDisplayStatus: 68 mutants, 68 killed, MSI 100%, no errors.

Full unit suite: 3857 tests; the only failures are the 4 order-dependent ones in AEngineHandlerTest that also fail on main. php-cs-fixer clean; psalm on the changed files: no new errors. OpenAPI and TypeScript types regenerated.

⚙️ API / Back‑end changes

  • New response field displayStatus on every signer entry; numeric status becomes optional (absent for a redacted entry); new neutral statusText "Not signed"
  • Validation endpoint now returns rejection to privileged viewers
  • Child file summaries carry the real signer status (2 for signed) instead of 1/0
  • FileResponseOptions::isViewerOfSigner(); SignatureRejectionVisibilityService gains IL10N

Frontend: the regenerated SignerSummaryRecord requires displayStatus, so the three places that build such records locally (VisibleElements.vue, SignPDF.vuedraft placeholders — and normalizeCertificateSigner() in validationDocument.ts, signed/draft from the certificate status) and five specs set it; no behaviour change there. One behaviour change in src/store/sign.js: the sign page's ableToSign gate used to require the real status on the viewer's own entry, which is now redacted while a rejection is hidden, so an own entry without status no longer blocks signing — the backend keeps enforcing the signing order ("You are not allowed to sign this document yet"). Vitest (incl. a new store case), vue-tsc and ESLint clean.

Note for #8162

The validation frontend is out of scope here, as the issue says. Two facts for whoever picks it up (me, next): validationDocument.ts accepts only signer status 0|1|2, so on main the validation page already fails to parse any file with a rejected signer (3) — this PR does not change that (redacted entries omit status, which the parser rejects the same way); and the list/sidebar components read statusText, so they show Not signed for a redacted entry without further change.

🚧 Backport

None (main only; the rejection feature is not released).

✅ Checklist

🤖 AI (if applicable)

  • The content of this PR was partially or fully generated using AI

@maia-andre
maia-andre requested a review from a team as a code owner September 13, 2026 23:13
@github-project-automation github-project-automation Bot moved this to 0. Backlog in Roadmap Sep 13, 2026
@codecov-commenter

codecov-commenter commented Sep 13, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 0% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/services/validationDocument.ts 0.00% 0 Missing and 1 partial ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Files with missing lines Coverage Δ
src/components/Request/VisibleElements.vue 62.57% <ø> (+0.55%) ⬆️
src/store/sign.js 100.00% <ø> (ø)
src/views/SignPDF/SignPDF.vue 45.02% <ø> (ø)
src/services/validationDocument.ts 74.19% <0.00%> (-1.51%) ⬇️

... and 219 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

… validation API

The rejection record was already filtered by
SignatureRejectionVisibilityService, but only on the file list, and the
signer status went out untouched everywhere: with `public_status: false`
another signer or an anonymous reader of the validation page still got
`status: 3` / "Rejected" for the signer who rejected, and the validation
payload (FileService → SignersLoader) never carried the `rejection`
object at all, not even for the requester.

One shared decision now drives every serializer:

- `SignatureRejectionVisibilityService::hasHiddenRejection()` says whether
  the file holds a rejection the viewer may not know about (privileged
  viewers are the requester of the file and the signer who rejected, for
  their own entry), and `presentSigner()` returns a `SignerPresentation`:
  `displayStatus`, the real `status` or null, the `statusText` and the
  `rejection` object under the existing comment rules. When a rejection
  is hidden, every unsigned signer of that file is presented as
  `not_signed` / "Not signed" without the real status, so the rejecter
  cannot be told apart by comparison; signed signers are unchanged, and a
  viewer keeps their own entry.
- `SignersLoader` (validation), `FileListService` (detailed file, file
  with children, child summaries), `EnvelopeAssembler` (child documents)
  and `FileService::mapSignerDetailsToSummary()` (which used to map
  REJECTED to DRAFT) consume that presentation. `FileResponseOptions`
  gained `isViewerOfSigner()`, the identity check SignersLoader used to
  do inline, so the validation and envelope paths resolve the viewer the
  same way.
- `SignerDisplayStatus` is the API presentation enum (draft,
  ready_to_sign, signed, rejected, not_signed); `LibresignSignerSummary`
  and the contracts built on it declare `displayStatus` as required and
  the numeric `status` as optional. Nothing is persisted and no
  authorization reads the presentation: SignRequestStatus stays the
  workflow state.

Tests: unit coverage of the decision and the presentation (Infection
100% MSI on the visibility service, the enum and the value object), the
viewer matrix on each serializer (anonymous, another user, the pending
signer, the requester, the rejecter), the summaries, and Behat scenarios
on the validation endpoint and the file list for a private status, a
public status and a canceled workflow.

Resolves: LibreSign#8388

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: André Maia <andrefnkmm@gmail.com>
@maia-andre
maia-andre force-pushed the fix/8388-rejection-visibility-contract branch from da8f062 to ab26e3e Compare September 13, 2026 23:20
bool $hiddenRejectionInFile,
): SignerPresentation {
$status = $signer->getStatusEnum();
if ($hiddenRejectionInFile && $status !== SignRequestStatus::SIGNED && !$privileged) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this !$privileged condition does not follow the rule defined in #8388.

When a rejection is hidden, all unsigned signers must have the same not_signed presentation. Otherwise it is still possible to find who rejected by comparing the signers.

For example, if signer A rejected and signer B can still sign, B currently sees:

  • A as not_signed
  • B as ready_to_sign

Since B knows that they did not reject, they can know that A rejected.

The privilege to see your own rejection should be separate from the rule that hides the status of all unsigned signers.

Please change this so a pending signer does not bypass the not_signed redaction, while the signer who rejected can still see their own rejection details. The PHPUnit and Behat tests that currently expect the pending signer to stay as ready_to_sign also need to be updated.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 54ea5d2: being the signer no longer lifts the redaction of a pending entry. presentSigner() keeps only the viewer's own rejection; with a hidden rejection every other unsigned entry, the viewer's own pending one included, is not_signed without the real status. The PHPUnit matrix (visibility service, SignersLoader, FileListService, EnvelopeAssembler) and the Behat scenario were updated — for the other signer the scenario now asserts that no entry differs from not_signed on the validation payload, the file list and the child summary. Infection on the service stays at 100% MSI.

One consequence I had to handle in the same commit: the sign page (/p/sign/{uuid}) feeds useSignStore().ableToSign from these same signers, and that gate required mySigner.status === ABLE_TO_SIGN. With the own entry redacted, the pending signer would get "Unable to sign." whenever another signer had rejected privately. The store now treats an own entry without status as signable and leaves the signing order to the backend, which already enforces it (SignerValidator: "You are not allowed to sign this document yet"). Residual corner: sequential flow + private rejection + viewer not yet on turn → the button shows and the backend answers with that message instead of the pre-emptive "Unable to sign.". If you want that closed too, the natural place is settings.canSign reflecting the viewer's own real state (today it is me && !signed, which also feeds "You need to sign this document") — I left it out to keep this PR to the contract.

One more observation, not blocking: even with every unsigned entry redacted, a viewer who knows the behaviour can still infer that a rejection exists, because their own entry reads not_signed instead of ready_to_sign only when something is hidden — with two signers that identifies the rejecter. Closing it fully would mean presenting unsigned signers as not_signed whenever public_status is false, rejection or not. That is a product decision, so I did not touch it; happy to do it here or in a follow-up if you want.

@github-project-automation github-project-automation Bot moved this from 0. Backlog to 1. to do in Roadmap Sep 13, 2026
Being the signer no longer lifts the `not_signed` redaction of a pending
entry: with A rejected and B still able to sign, B saw themselves as
`ready_to_sign` next to A as `not_signed` and, knowing they did not
reject, could tell that A did. `presentSigner()` now keeps only the
viewer's own rejection, which they already know about; every other
unsigned entry of a file with a hidden rejection, the viewer's own
pending one included, is presented as `not_signed` without the real
status.

The sign page fed its "able to sign" gate from that very entry, so the
store no longer requires the real status when the own entry comes
redacted; the backend keeps enforcing the signing order ("You are not
allowed to sign this document yet").

Tests: the visibility service (own pending entry redacted for draft and
ready-to-sign, own rejection kept; Infection 100% MSI), the pending
viewer on SignersLoader, FileListService and EnvelopeAssembler, the
sign store gate, and the Behat scenario for the other signer, which now
asserts that no entry differs from `not_signed` on the validation
payload, the file list and the child summary.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: André Maia <andrefnkmm@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: 1. to do

Development

Successfully merging this pull request may close these issues.

Apply signature rejection visibility rules to signer status and validation API

3 participants