Repository navigation
Stop asserting exception messages in PHP tests: define a convention (error codes or typed exceptions) #8117
Description
Follow-up of the discussion in #8092 (#8092 (comment)).
Many PHP tests assert exceptions by matching their message. When the message is translatable or just easy to change, editing the text in lib/ silently requires editing the test too, and whoever changes the text (not necessarily a developer) has to know that. The suggestion in #8092 was to assert the error code or the exception class instead of the text.
Current state
Numbers from main today:
expectExceptionMessage(): 174 calls in 52 test files (plus 13expectExceptionMessageMatches()).expectExceptionCode(): 12 calls in 9 files.throw new LibresignException(...): 287 places. 161 of them use a translated message ($l10n->t(...)), 126 use a literal string.- Only 51 of the 287 carry a code, and the values mix conventions:
1(22),422(11),404(7),500(4),400(3),Http::STATUS_UNPROCESSABLE_ENTITY(3) andHttp::STATUS_FORBIDDEN(1).LibresignExceptionitself defines no code constants, and the code is exposed to clients throughErrorPayloadBuilder(code) andSignFileService(error_code), so any convention also touches the API contract. - 8 test files assert a translated message while building the real
IL10Nfrom the factory, so they depend on the test locale. - There are already typed subclasses in
lib/Exception/(InvalidPasswordException,InvalidSignatureException,EmptyCertificateException, ...), but mostthrowsites still use the baseLibresignException.
What should be decided first
Two directions came up in #8092. They are not exclusive, but a convention is needed before touching many files:
- Error codes: define constants for the codes (a dedicated class, like
JSActions, following the "HTTP-like plus one digit" idea to avoid confusion with HTTP status codes), use them at thethrowsites, and assert them withexpectExceptionCode(). - Typed exceptions: extend the existing
lib/Exception/subclasses so tests assert the class withexpectException().
Please use this issue to settle the convention (and decide what to do with the existing 1/4xx codes), then the work can be split into small PRs.
How to contribute
Important
Do not solve this issue in one PR.
Like #8053, each PR should cover one or two related source/test pairs, reference this issue without Fixes/Closes, and keep the diff small. Please comment here with the files you want to work on before starting.
Suggested order:
- Tests that assert translated messages (
$l10n->t(...)inlib/, realIL10Nin the test), because they break on any wording change. - Tests that assert literal messages in business rules (signing, certificates, policies, permissions, validation, identity flows).
- The remaining message assertions.
Do not change production behavior (messages shown to users, API payloads) only to make a test easier; when a code or a class is added, keep the message as is.
Effort
The complete issue is medium-to-high effort because it covers many files, but each PR should be low effort.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Projects
- StatusShow more project fields0. Backlog
Activity
ajayraho commentedon Aug 31, 2026
Hi, I'd like to work on this. Per your guidance I'll keep it small starting with one or two source/test pairs from the "tests asserting translated messages" category first, converting them to typed exceptions or error-code assertions rather than message-string matching.
I'll open a separate PR per pair and reference this issue (not
Fixes/Closes) as you asked.If there's a specific file or exception class you'd like tackled first, let me know, otherwise I'll pick one from that first category and go from there.
vitormattos commentedon Aug 31, 2026
@ajayraho, since the suggestion is to avoid making a very large PR, I suggest you identify the first set of behaviors you'll be working on an share here before start the work. By this way, will be possible to more people to work on this issue.
ajayraho commentedon Aug 31, 2026
Hi, per your ask, here's the specific pair I'd like to start with, so it's visible for others to pick different ones:
lib/Service/File/Pdf/PdfParser.php(line 43) andlib/Service/File/Pdf/PdfValidator.php(line 48), both throwLibresignException($this->l10n->t(...))with translated, filename-interpolated messages, and their tests (PdfParserTest.php,PdfValidatorTest.php) each assert a filename substring viaexpectExceptionMessage().The plan: to introduce typed exceptions following the existing convention in
lib/Exception/(e.g.InvalidPasswordException), something like anUnprocessablePdfExceptionfor the "unable to process as PDF" case and update the two tests to assert viaexpectException(...)on the type instead of matching message text.Happy to use error codes instead if you'd prefer that convention here; typed exceptions just seemed to match what's already in the codebase.
Will open a PR scoped to just these two once you're good with the approach.
vitormattos commentedon Aug 31, 2026
I prefer typed exceptions over error codes whenever it's possible to use an existing native PHP exception, an OCP exception, or an existing LibreSign exception, rather than creating many custom exceptions and increasing the size of the codebase. Does you have other architecture suggestion or other point of view?
About the PR size, don't worry about add multiple changes into a PR only if don't make the PR very big and complex to review. I think that a good approach is:
ajayraho commentedon Aug 31, 2026
Thanks for the guidance, makes sense on avoiding exception sprawl.
For this specific pair, I actually don't think either native/OCP or an existing typed LibreSign exception fits cleanly:
LibresignExceptionis relied on directly byInjectionMiddlewareand several controllers (instanceof LibresignException,jsonSerialize()) for API error formatting, so switching to a native exception here would break that path, and none of the existing typed subclasses (PageException,InvalidSignatureException, etc.) are a semantic match.What I did find:
InjectionMiddleware::getStatusCodeFromException()already maps$exception->getCode()straight to the HTTP response status (defaulting to 422 when unset). So error codes aren't just a testing convention here, they're already load-bearing in production, just unused by these two throw sites.My proposal: keep
LibresignExceptionas-is (no new class), pass an explicit code at both throw sites (e.g.Http::STATUS_UNPROCESSABLE_ENTITY), and update the two tests toexpectExceptionCode(...)instead of matching message text. Zero new files, ~10-15 line diff.Let me know if that lines up with what you had in mind, or if you'd rather define named constants for these specific error cases instead of reusing the HTTP status directly.
vitormattos commentedon Aug 31, 2026
Go ahead and use your feeling. Isn't a real block about the PR size, is only a good practice.
refactor(exception): add error codes to the invalid token and gateway…
refactor(exception): add error codes to the invalid token and gateway…
vitormattos commentedon Sep 1, 2026
After #8103, I think we should clarify one important point in this issue before more PRs follow the same pattern.
We now have two different concepts that are currently sharing
Exception::getCode():Because of this, I don't think new tests should use values such as
422as the identity of an error. Many unrelated errors can return the same HTTP status.I think the convention should be:
LibresignException;#8103 introduced the semantic error code idea, but I think we should also review where those constants should live before this pattern is used in more places.
maia-andre commentedon Sep 2, 2026
Agreed on keeping each code next to the class that raises the error. The two constants from #8103 were the only ones on
LibresignException, so I opened #8196 moving them toAbstractIdentifyMethod::CODE_INVALID_TOKENandTwofactorGatewayService::CODE_NOT_ENABLED(values unchanged). With that,LibresignExceptionhas no error-code constants left and the layout matches #8172.refactor(exception): keep the semantic error codes next to the classe…
maia-andre commentedon Sep 6, 2026
I'd like to take this pair next — they are the files I've just been through in #8274/#8275/#8276, so the behaviors are fresh:
lib/Handler/SignEngine/Pkcs12Handler.phpand its parentlib/Handler/SignEngine/SignEngineHandler.phptests/php/Unit/Handler/SignEngine/Pkcs12HandlerTest.phpIt falls in category 1 of the suggested order: the test builds the real
IL10Nand asserts 5 translated messages, coming from three behaviors:SignEngineHandler::getPfxOfCurrentSigner()(2 sites, code400)SignEngineHandler::savePfx()/deletePfx()Pkcs12Handler::getSignatureValidation()Two things I would like to confirm before writing code:
Every class in
lib/Exception/today extends\Exception, notLibresignException. So a new typed exception here has to extendLibresignException, otherwise the existingcatch (LibresignException)sites and the API payload would change behavior — which this issue asks us not to do. Is that the direction you want (for examplePasswordToSignNotDefinedException extends LibresignException), or would you rather keep the single class and assert semantic codes kept next to the class that raises them, as in refactor(exception): keep the semantic error codes next to the classes that raise them #8196?You do not have permission for this action. is thrown from six places: two here, two in
ValidateHelperand two inSignFileService. A shared exception applied to only two of them would leave the concept half-converted, so I would leave that message out of this PR and do it in a dedicated one covering all six. Does that work for you?A note about the sibling file,
tests/php/Unit/Handler/SignEngine/JSignPdf/JSignPdfHandlerTest.php: it also asserts a message, but that one is not translated — it is the TSA diagnosis text built incheckTsaError(), where the wording that reaches the administrator is the behavior under test (#8274). Asserting a class or a code there would stop testing what that change was about, so I plan to leave it as it is unless you prefer otherwise.vitormattos commentedon Sep 6, 2026
Thanks, André. I think your questions show that we should define the error model a little better before we continue converting more tests.
Given how
LibresignExceptionis already used in LibreSign, I think it should remain the base for errors that are expected to cross the application boundary and be converted into API/frontend responses byInjectionMiddleware.Because of that, when a typed exception represents a public LibreSign error, I think it should extend
LibresignException, not\Exception.For the "Password to sign not defined" case, for example, something like
PasswordToSignNotDefinedException extends LibresignExceptionseems reasonable. The test could then assert the exception type instead of the translated message.At the same time, I think we should be careful with semantic error codes.
Today
Exception::getCode()is used for two different things:400,404or422are HTTP status codes;4010,4220,4221or5030are semantic error codes.This creates an ambiguity because
InjectionMiddlewarestill usesgetCode()directly as the HTTP response status.Maybe it would be better to separate these two concepts explicitly in
LibresignException.For example,
LibresignExceptioncould have an HTTP status property and a method likegetHttpStatus(). Then:Exception::getCode()can be used only as a semantic code when a stable machine-readable code is really needed;getHttpStatus()can be used only for the HTTP response;I would prefer not to calculate the HTTP status automatically from codes such as
4220 -> 422. It would work technically, but it would still make the semantic code depend on the HTTP transport. If the HTTP status changes later, we should not need to change the identity of the error too.Something like this would make the separation explicit:
Then
InjectionMiddlewarewould usegetHttpStatus()instead ofgetCode().This probably needs a small migration, because some current
LibresignExceptioncalls still use the second argument as an HTTP status. I would not try to change everything in one PR, but I think it is worth defining the direction first so we do not add more mixed cases.For the convention in this issue, my current preference would be:
LibresignException;So for your current PR, I agree with leaving the permission error for a separate change covering all related occurrences. I also agree with keeping the TSA message assertion, because in that case the diagnostic text itself is part of the behavior being tested.
I also think this convention is important enough to document after we agree on it. My suggestion would be to add a small
Architecturesection to the developer documentation, with anError handlingpage covering internal exceptions, publicLibresignExceptionerrors, typed exceptions, semantic codes, HTTP statuses, translated messages and testing.This would also fit the direction we have been discussing for
AGENTS.md: instead of growing it with every LibreSign-specific rule, it can become a short gateway to the canonical developer documentation. An agent should be able to start fromAGENTS.md, find the relevant project convention quickly, and then read the detailed rule indocs.libresign.coop.For example,
AGENTS.mdcould eventually point to the error handling documentation instead of duplicating the complete convention there. This should help us keep the agent instructions useful without making that file very large or having the same rule maintained in two places.The API/OpenAPI documentation could then document concrete semantic error codes only when they are really part of the public endpoint contract.
What do you think about this direction? In particular, I would like your opinion on separating the HTTP status into
getHttpStatus()before we continue adding more semantic codes.maia-andre commentedon Sep 7, 2026
Yes, I agree with separating the HTTP status from the semantic code, and after mapping the throw sites to answer you I think the case is stronger than the
400-vs-4010ambiguity alone.getCode()is carrying four different things today, and the two that have a real runtime consumer are not the ones we would expect.1. HTTP status
422,404,400,403,500,Http::STATUS_*… read byInjectionMiddleware::getStatusCodeFromException()(lib/Middleware/InjectionMiddleware.php:481), which falls back to 422 when the code is0.2. Semantic codes
PdfParser::CODE_INVALID_PDF(4220),PdfValidator::CODE_DOCMDP_RESTRICTED(4221),AbstractIdentifyMethod::CODE_INVALID_TOKEN(4010),TwofactorGatewayService::CODE_NOT_ENABLED(5030).I grepped for consumers, and none of the four reaches a client today. Their only readers are four
expectExceptionCode()assertions in unit tests. Every escape route drops the code before the response is built:FileController::save()→FileService::getNodeFromData()catch (LibresignException)returns['message' => …]with a hardcodedSTATUS_UNPROCESSABLE_ENTITY(FileController.php:592)SignFileController::sign()→AbstractIdentifyMethod::throwIfInvalidToken()catch (\Throwable)→SigningErrorHandler, hardcoded 422 (SignFileController.php:184)TwofactorGatewayService::resolveIntegrationService()So by the rule you proposed — a semantic code only when a caller really needs to identify the error programmatically — these four do not qualify yet. They are good candidates to become typed exceptions carrying no code at all.
3.
JSActionscodes — this one is liveValidateHelper::canSignWithIdentificationDocumentStatus()(ValidateHelper.php:910) throws withJSActions::ACTION_SIGN_ID_DOC, which is2750. It travels through thecatch (\Throwable)inSignFileController::sign()intoSigningErrorHandler::handleLibresignException(), which copiesgetCode()intoerrors[].code. The frontend reads it back:with
SIGN_ID_DOC: 2750insrc/helpers/ActionMapping.ts:36.And the mirror case is exactly the pair I claimed in this issue.
SignEngineHandler::getPfxOfCurrentSigner()throws Password to sign not defined with code400(lines 206 and 211), andSigningErrorHandlerdoes:That
400is not an HTTP status in practice — it is the marker that drives the "create a signing password" screen (ACTION_CODES.CREATE_SIGNATURE_PASSWORD→CreatePassword,ActionMapping.ts:53). A refactor that reads those two400s as "obviously the HTTP status" and moves them intohttpStatus:would silently break that screen, and no test would catch it, becausePkcs12HandlerTestasserts the message and not the code.This is the part I would not have noticed without your question, and it changes how I would write the PR I claimed.
4. A generic sentinel:
122 of the call sites pass
1. Examples:AccountService.php:95,MailService.php:86,SignFileService.php:341,SignFileController.php:313. It is not an HTTP status and not semantic — it reads like "some error happened".At least one of them escapes to the middleware.
SignFileController::requestCodeBySignerUuid()throws it from inside its owncatch, so nothing else catches it, andgetStatusCodeFromException()returns1as thestatusCodeof theDataResponse. I have not checked what the HTTP layer finally emits for a status of1, but it is worth checking — it may be worth treating separately from the migration.Counting the migration
301
new LibresignException(inlib/, 80 of which pass a second argument:1422,404,400,500,Http::STATUS_*, …)self::CODE_*(semantic)JSActions::ACTION_SIGN_ID_DOC0The mechanical part is small. What is not mechanical is that each of the 80 needs a decision about which of the four meanings it carries.
On the constructor
LibresignExceptiondeclares no constructor today, so it inherits\Exception::__construct(string $message, int $code, ?Throwable $previous). To keep step one a pure refactor with no behavior change, I would puthttpStatusafter$previous, nullable, and havegetHttpStatus()reproduce exactly what the middleware does today when it was not given:InjectionMiddlewarethen callsgetHttpStatus()forLibresignExceptionand keeps its current logic for anything else (afterExceptionalso handles non-LibreSign exceptions on the HTML branch). Every existing call site keeps compiling and every response keeps the status it has today, so the first PR carries no behavior change at all — the legacy branch is what makes it safe, and deleting it is the last step rather than the first.Suggested order
httpStatus+getHttpStatus()with the legacy fallback,InjectionMiddlewareswitched over. No behavior change.httpStatus:, in small groups per area.1— most of them look like they simply want the 422 default.SigningErrorHandlerandACTION_SIGN_ID_DOC: these are a frontend contract, not a transport concern. I would document the current behavior first and convert it deliberately, not as a side effect of step 2. Until then,getPfxOfCurrentSigner()'s400must stay ingetCode().CODE_*constants still earn their existence.Documentation
I agree with the
Architecturesection and theError handlingpage, and withAGENTS.mdbecoming a short gateway instead of a second copy of the convention — the duplication is the part that actually goes stale.One thing I would add to that page: a table of what
errors[].codemeans on the signing endpoints today. Sections 3 and 4 above are not guessable from the code, and I think a newcomer converting a test is exactly the person most likely to break them.About the PR I claimed
The convention you settled answers it:
PasswordToSignNotDefinedException extends LibresignException, and the test asserts the type instead of the message. But because of the400→ACTION_CREATE_SIGNATURE_PASSWORDmapping, the new class has to keep passing400as its code until step 4 above, otherwise the signer loses the "create a signing password" screen.The mapping itself is already covered —
SigningErrorHandlerTesthas acode 400 triggers password creation actiondataset. What is not covered is the link: nothing connects the literal400inSignEngineHandlerto that dataset, so the two can drift apart without a red test. I would close that in the same PR by asserting the code at the throw site as well, so the new typed exception documents why the400is there.Does that work for you, or would you rather I wait for step 1 to land before touching the pair?
maia-andre commentedon Oct 2, 2026
Friendly ping on this one, no rush. The open point from my last comment is whether to give
LibresignExceptionits own HTTP status (getHttpStatus()with the legacy fallback togetCode()) before converting more tests.If it helps to move forward, I can send step 1 alone as a small PR: the constructor argument,
getHttpStatus()andInjectionMiddlewarereading it, with no behavior change. The rest of the discussion could then happen on code.matheusa1 commentedon Oct 17, 2026
Hi! I’d like to take the CRL lock pair: CrlServiceTest asserts “CRL refresh/generation is already in progress” by message, and CrlRevocationChecker also detects that case with str_contains($e->getMessage(), 'already in progress'). I plan to add a typed CrlGenerationInProgressException (extending LibresignException, same message), use instanceof in the checker, and update the tests to assert the class. Small, single PR.
refactor(crl): use typed exception for CRL lock in progress
vitormattos commentedon Oct 24, 2026
Hi @maia-andre and @matheusa1,
Thanks for your work on this issue.
@matheusa1, the CRL lock exception work was completed in #9102. Thanks for implementing it! We can consider this part done.
@maia-andre, I reviewed your proposal, and I agree with separating the HTTP status from the semantic exception code.
Please go ahead with step 1 as a small PR:
httpStatusconstructor argument andgetHttpStatus()method toLibresignException.InjectionMiddlewareto use the new method forLibresignException.Please keep existing exception codes and frontend actions unchanged. We will handle their migration separately.
For the signing password exception you mentioned earlier, you can continue with that work after step 1. Please preserve the current code
400behavior that opens the password creation screen.Issue #8117 will remain open to track the remaining work. It does not need one contributor to implement everything. Other contributors can take small, independent parts, with coordination here to avoid duplicate work.
We will also update the issue description to reflect the agreed convention and the work already completed.
Thanks again for your contributions!
AdaaWu commentedon Oct 24, 2026
Hi! I'd like to take the identification-document pair:
lib/Service/UuidResolverService.phptests/php/Unit/Service/UuidResolverServiceTest.phpLike #9102,
resolveUuidForUser()decides whether to rethrow two errors bycomparing
$e->getMessage(). I plan to addIdDocAccessDeniedExceptionandNotAnIdDocException(extendingLibresignException, same messages, code 0),replace the comparison with a typed
catch, and make the tests assert theclass instead of the message.
No HTTP status or frontend code involved, so it doesn't depend on step 1.