From 659b5ca480db55390854951d7bb6a7f69b01dbcc Mon Sep 17 00:00:00 2001 From: Daniel Padrino Date: Mon, 13 Jul 2026 15:56:52 -0300 Subject: [PATCH] fix(realunit): log Aktionariat confirm-link rejections at warn instead of error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 4xx from the confirm call is a rejected link (expired code, unknown email; 400/417 observed in prod) — the same handled outcome that mapConfirmationStatus buckets as INVALID, not a system fault. The call carries no API key (the code is the credential), so a 4xx cannot mean broken credentials. 429 stays at error: throttling is systemic. 5xx/timeout/no-status keep logging at error as before. --- .../__tests__/realunit.service.spec.ts | 22 +++++++++++++++++-- .../supporting/realunit/realunit.service.ts | 19 +++++++++++----- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index aab04c664f..379e8faabf 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -2553,7 +2553,25 @@ describe('RealUnitService', () => { // a non-confirming call never touches a registration (the latch is only set on a 2xx) expect(aktionariatManager.transaction).not.toHaveBeenCalled(); expect(aktionariatTxManager.save).not.toHaveBeenCalled(); + // a 4xx is a handled rejection (mapped to INVALID above), not a fault — logged at warn + expect((service as any).logger.warn).toHaveBeenCalled(); + expect((service as any).logger.error).not.toHaveBeenCalled(); + }); + + it('keeps a 429 (throttling) at error — a systemic fault, not a rejected link', async () => { + mockEnvironment = 'prd'; + mockRegisteredWallets([walletA]); + httpService.getRaw.mockRejectedValue({ + response: { status: 429, data: { status: 429, message: 'Too many requests' } }, + }); + + const result = await service.confirmAktionariat({ email, code, user }, rawRequest); + + // the client-facing mapping still buckets it as INVALID (4xx), only the log level differs + expect(result.status).toBe(RealUnitAktionariatConfirmationStatus.INVALID); expect((service as any).logger.error).toHaveBeenCalled(); + const warnText = (service as any).logger.warn.mock.calls.flat().join(' '); + expect(warnText).not.toContain('Aktionariat confirmation call'); }); it('keeps the FIRST confirmedDate on a re-confirm and returns the stored (not transient) date', async () => { @@ -2717,8 +2735,8 @@ describe('RealUnitService', () => { expect(msg.response).toBeUndefined(); expect(audit.message).toContain(leakedEmail); - // Loki stays PII-free: the leaked email must never reach this.logger.error - const lokiText = (service as any).logger.error.mock.calls.flat().join(' '); + // Loki stays PII-free: the leaked email must never reach this.logger.warn (a 4xx logs at warn, not error) + const lokiText = (service as any).logger.warn.mock.calls.flat().join(' '); expect(lokiText).toContain('status=403'); expect(lokiText).not.toContain(leakedEmail); }); diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index d6449e00f7..b7741f2a59 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -2080,11 +2080,20 @@ export class RealUnitService { // Loki is the PII-free channel: log only the redacted status/type summary here (an Aktionariat error // body may echo the submitted email). The FULL body still reaches the DB `log` audit store, via the // returned raw error passed through describeError. - this.logger.error( - `Aktionariat confirmation call to ${endpoint} failed (httpStatus: ${httpStatus ?? 'none'}): ${this.summarizeError( - error, - )}`, - ); + const logMessage = `Aktionariat confirmation call to ${endpoint} failed (httpStatus: ${httpStatus ?? 'none'}): ${this.summarizeError( + error, + )}`; + // A rejected confirm link (expired code, unknown email, ...) is the handled INVALID outcome of + // mapConfirmationStatus — there is no API key on this call (the code is the credential), so a 4xx + // cannot mean broken credentials. 429 is carved back out: throttling is a systemic fault. Anything + // else (5xx, timeout, no status) is genuinely unavailable; both stay at error. + const isRejectedLink = + this.mapConfirmationStatus(httpStatus) === RealUnitAktionariatConfirmationStatus.INVALID && httpStatus !== 429; + if (isRejectedLink) { + this.logger.warn(logMessage); + } else { + this.logger.error(logMessage); + } return { httpStatus, responseBody, error }; } }