diff --git a/ERRORS.md b/ERRORS.md index fc334866d..eedad8bd9 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -33,3 +33,4 @@ Use this file as a compact memory of recurring AI mistakes. - [2026-07-16] security: `users.repository.js findByIdAndUpdatePopulated()` does `.populate()` with no `.select()`, so `organizations.controller.js switchOrganization` serialized the raw doc (password hash + OAuth tokens + reset/verification tokens) straight to the client; `users.account.controller.js me()` separately forwarded `providerData` (OAuth tokens) verbatim -> any endpoint returning a Mongoose user doc must go through `UserService.removeSensitive()` (whitelist, `modules/users/utils/sanitizeUser.js`) at the response boundary, never serialize `req.user`/a populated doc directly; a local-signup fixture's `providerData` defaults to `{}` so a naive falsy check won't catch this — seed a fake OAuth token in tests to prove the leak is actually closed; see pierreb-devkit/Node#3963 - [2026-07-16] billing/stripe: the #3742 priceId-map fix (`resolvePlan`/`buildPriceIdToPlanMap`) was applied only to the webhook handler; `billing.admin.service.js` `resolveStripePlan` (admin force-sync, a DB WRITE path) and `billing.reconcile.service.js` `resolveStripePlan` (LOG-ONLY divergence check) kept their own copies reading only `metadata.planId` -> a paid org's Stripe subscription (which never carries `metadata.planId`) resolved to `'free'` on admin sync (silently downgrading a paying org) and produced a false `planMismatch` alert on every reconcile run; fix = one shared resolver (`modules/billing/lib/billing.planResolver.js`) used by all three call sites, and a null-unresolved sentinel (never guess `'free'`) so the admin write path ABORTS instead of downgrading and the reconcile log path skips the comparison instead of alerting; see pierreb-devkit/Node#3964 - [2026-07-16] architecture: `users.service.js remove()`'s sole-owner cascade called `OrganizationsRepository.remove()` directly instead of `organizations.crud.service.js#remove()`, silently skipping `runOrganizationRemovedHandlers` (the `onOrganizationRemoved` seam `modules/tasks/tasks.init.js` registers for org-scoped task cleanup) -> deleting an org must always route through the owning module's SERVICE method, never straight to its Repository, even for a "bare" cascade delete from another module — a direct-repository shortcut silently bypasses any registered removal hook; a stale cross-test fixture in `tasks.integration.tests.js` (an orphaned-but-still-existing task reused across unrelated test cases) was inadvertently relying on this bug and needed a properly isolated fixture once the cascade actually cleaned up; see pierreb-devkit/Node#3965 +- [2026-07-17] error handling: shared helpers (`lib/helpers/mailer/index.js#sendMail`, `modules/audit/services/audit.service.js#log`) wrapped the failing call in their own try/catch, logged a single generic line, and resolved `null` -> every caller's own context-rich `.catch()` (action/userId/orgId) became dead code on a real outage, since the promise never rejected; a central helper must let the underlying call's rejection propagate and let each CALL SITE own the "never break the main flow" `.catch()` with its own context — never swallow centrally just because most callers currently attach a catch; see pierreb-devkit/Node#3966 diff --git a/lib/helpers/mailer/index.js b/lib/helpers/mailer/index.js index 41985c32e..7ad77dd76 100644 --- a/lib/helpers/mailer/index.js +++ b/lib/helpers/mailer/index.js @@ -2,7 +2,6 @@ import path from 'path'; import handlebars from 'handlebars'; import config from '../../../config/index.js'; -import logger from '../../services/logger.js'; import files from '../files.js'; import NodemailerProvider from './provider.nodemailer.js'; import ResendProvider from './provider.resend.js'; @@ -73,7 +72,12 @@ const validateAttachments = (attachments) => { * @param {Array} [mail.attachments] - Optional attachments array * @param {string} mail.attachments[].filename - Attachment filename * @param {string|Buffer} mail.attachments[].content - Attachment content (string or Buffer) - * @returns {Promise} The send result or null if not configured + * @returns {Promise} The send result, or null if not configured + * @throws {Error} If the provider's send() call fails — propagated so each + * caller's own `.catch()` can log with its flow-specific context + * (action, userId, orgId, ...). Callers that must never let a mail failure + * break their main flow are responsible for attaching that `.catch()` + * themselves; this helper does not swallow errors centrally. */ const sendMail = async (mail) => { if (!isConfigured()) return null; @@ -83,20 +87,15 @@ const sendMail = async (mail) => { const file = await files.readFile(path.resolve(`./config/templates/${sanitizedTemplate}.html`)); const template = handlebars.compile(file); const html = template(mail.params); - try { - const result = await getProvider().send({ - from: config.mailer.from, - to: mail.to, - subject: mail.subject, - html, - attachments: mail.attachments, - }); - if (!Array.isArray(result?.accepted)) return { ...result, accepted: [mail.to], rejected: [] }; - return result; - } catch (err) { - logger.error('Mail send error', err); - return null; - } + const result = await getProvider().send({ + from: config.mailer.from, + to: mail.to, + subject: mail.subject, + html, + attachments: mail.attachments, + }); + if (!Array.isArray(result?.accepted)) return { ...result, accepted: [mail.to], rejected: [] }; + return result; }; export default { sendMail, isConfigured }; diff --git a/lib/helpers/mailer/tests/mailer.unit.tests.js b/lib/helpers/mailer/tests/mailer.unit.tests.js index 0b5225ca0..26685c38e 100644 --- a/lib/helpers/mailer/tests/mailer.unit.tests.js +++ b/lib/helpers/mailer/tests/mailer.unit.tests.js @@ -29,15 +29,6 @@ jest.unstable_mockModule('../provider.nodemailer.js', () => ({ default: jest.fn().mockImplementation(() => ({ send: jest.fn() })), })); -jest.unstable_mockModule('../../../services/logger.js', () => ({ - default: { - error: jest.fn(), - warn: jest.fn(), - info: jest.fn(), - debug: jest.fn(), - }, -})); - const { default: mailer } = await import('../index.js'); describe('mailer index with resend provider unit tests:', () => { @@ -102,17 +93,35 @@ describe('mailer index with resend provider unit tests:', () => { ); }); - test('should return null on send error', async () => { + test('should propagate (reject) when the provider send fails, instead of swallowing to null', async () => { mockSend.mockRejectedValue(new Error('API failure')); - const result = await mailer.sendMail({ - to: 'user@example.com', - subject: 'Test', - template: 'welcome', - params: { name: 'Bob' }, - }); + await expect( + mailer.sendMail({ + to: 'user@example.com', + subject: 'Test', + template: 'welcome', + params: { name: 'Bob' }, + }), + ).rejects.toThrow('API failure'); + }); + + test('should let a provider rejection reach a caller-attached .catch() with its own context', async () => { + mockSend.mockRejectedValue(new Error('API failure')); + const callerLogger = { warn: jest.fn() }; + + // Mirrors the call-site pattern used across the codebase: fire-and-forget + // sendMail() with a local .catch() that logs flow-specific context. + await mailer + .sendMail({ + to: 'user@example.com', + subject: 'Test', + template: 'welcome', + params: { name: 'Bob' }, + }) + .catch((err) => callerLogger.warn('caller: mail failed', { message: err?.message, userId: 'u1' })); - expect(result).toBeNull(); + expect(callerLogger.warn).toHaveBeenCalledWith('caller: mail failed', { message: 'API failure', userId: 'u1' }); }); test('should throw when attachment is missing content', async () => { diff --git a/modules/audit/middlewares/audit.middleware.js b/modules/audit/middlewares/audit.middleware.js index f1cc6d145..008e9136f 100644 --- a/modules/audit/middlewares/audit.middleware.js +++ b/modules/audit/middlewares/audit.middleware.js @@ -118,15 +118,26 @@ const createAuditMiddleware = (options = {}) => { const targetType = deriveTargetType(routePath, req.baseUrl); const targetId = deriveTargetId(req.params); + const userId = req.user?._id || req.user?.id; + const organizationId = req.organization?._id || req.organization?.id; + AuditService.log({ action, - userId: req.user?._id || req.user?.id, - organizationId: req.organization?._id || req.organization?.id, + userId, + organizationId, ip: config.audit?.captureIp !== false ? (req.ip || req.connection?.remoteAddress || '') : undefined, userAgent: config.audit?.captureUserAgent !== false ? (req.headers?.['user-agent'] || '') : undefined, targetType, targetId, - }).catch((err) => logger.error('audit.middleware: audit log write failed', { message: err?.message, stack: err?.stack })); + }).catch((err) => logger.error('audit.middleware: audit log write failed', { + message: err?.message, + stack: err?.stack, + action, + userId, + organizationId, + targetType, + targetId, + })); }); return next(); diff --git a/modules/audit/services/audit.service.js b/modules/audit/services/audit.service.js index 208570e0d..bcd837028 100644 --- a/modules/audit/services/audit.service.js +++ b/modules/audit/services/audit.service.js @@ -2,7 +2,6 @@ * Module dependencies */ import config from '../../../config/index.js'; -import logger from '../../../lib/services/logger.js'; import AuditRepository from '../repositories/audit.repository.js'; /** @@ -17,7 +16,12 @@ import AuditRepository from '../repositories/audit.repository.js'; * @param {string} [params.targetType] - Type of the target entity * @param {string} [params.targetId] - ID of the target entity * @param {Object} [params.metadata] - Additional metadata - * @returns {Promise} The created audit log entry or null if disabled + * @returns {Promise} The created audit log entry, or null if disabled + * @throws {Error} If `AuditRepository.create` fails — propagated so the caller's + * own `.catch()` can log with its request context (action, userId, orgId, + * targetType, ...). Audit must never break the main flow — that guarantee is + * enforced by the caller's `.catch()` (see `audit.middleware.js`), not by + * swallowing the write failure here. */ const log = async ({ action, userId, organizationId, ip, userAgent, targetType, targetId, metadata } = {}) => { if (!config.audit?.enabled) return null; @@ -35,13 +39,7 @@ const log = async ({ action, userId, organizationId, ip, userAgent, targetType, if (ip !== undefined) entry.ip = ip || ''; if (userAgent !== undefined) entry.userAgent = userAgent || ''; - try { - return await AuditRepository.create(entry); - } catch (err) { - // Audit must never break the main flow - logger.error('AuditLog write failed:', { message: err.message }); - return null; - } + return AuditRepository.create(entry); }; /** diff --git a/modules/audit/tests/audit.middleware.unit.tests.js b/modules/audit/tests/audit.middleware.unit.tests.js index b477987a3..7d9820cf3 100644 --- a/modules/audit/tests/audit.middleware.unit.tests.js +++ b/modules/audit/tests/audit.middleware.unit.tests.js @@ -257,7 +257,7 @@ describe('Audit middleware unit tests:', () => { expect(mockLog).not.toHaveBeenCalled(); }); - test('should not throw when AuditService.log rejects and should log the error', async () => { + test('should not throw when AuditService.log rejects and should log the error with request context', async () => { const dbError = new Error('DB down'); mockLog = jest.fn().mockRejectedValue(dbError); const middleware = createAuditMiddleware(); @@ -266,13 +266,21 @@ describe('Audit middleware unit tests:', () => { const next = jest.fn(); middleware(req, res, next); - // Should not throw + // Should not throw — a rejected AuditService.log() must never break the request flow expect(() => res.emit('finish')).not.toThrow(); // Allow the .catch() handler to run await new Promise((r) => setTimeout(r, 10)); expect(mockLoggerError).toHaveBeenCalledWith( 'audit.middleware: audit log write failed', - { message: dbError.message, stack: dbError.stack }, + { + message: dbError.message, + stack: dbError.stack, + action: 'auth.signin', + userId: '507f1f77bcf86cd799439011', + organizationId: '507f1f77bcf86cd799439012', + targetType: 'User', + targetId: '', + }, ); }); }); diff --git a/modules/audit/tests/audit.service.unit.tests.js b/modules/audit/tests/audit.service.unit.tests.js index 56e2c915e..d772182ba 100644 --- a/modules/audit/tests/audit.service.unit.tests.js +++ b/modules/audit/tests/audit.service.unit.tests.js @@ -22,15 +22,6 @@ jest.unstable_mockModule('../../../config/index.js', () => ({ default: mockConfig, })); -// Mock logger to avoid winston config dependency in unit tests -jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ - default: { - error: jest.fn(), - warn: jest.fn(), - info: jest.fn(), - }, -})); - /** * Unit tests */ @@ -90,11 +81,28 @@ describe('AuditService unit tests:', () => { expect(arg.userAgent).toBeFalsy(); }); - test('should not throw when repository create fails', async () => { + test('should propagate (reject) when repository create fails, instead of swallowing to null', async () => { mockCreate = jest.fn().mockRejectedValue(new Error('DB down')); - const result = await AuditService.log({ action: 'test.fail' }); - expect(result).toBeNull(); + await expect(AuditService.log({ action: 'test.fail' })).rejects.toThrow('DB down'); + }); + + test('should let a repository rejection reach a caller-attached .catch() with its own context', async () => { + mockCreate = jest.fn().mockRejectedValue(new Error('DB down')); + const callerLogger = { error: jest.fn() }; + + // Mirrors audit.middleware.js's pattern: fire-and-forget log() with a + // local .catch() that logs request context (action/userId/orgId). + await AuditService.log({ action: 'test.fail', userId: 'u1', organizationId: 'o1' }).catch((err) => + callerLogger.error('audit.middleware: audit log write failed', { message: err?.message, action: 'test.fail', userId: 'u1', orgId: 'o1' }), + ); + + expect(callerLogger.error).toHaveBeenCalledWith('audit.middleware: audit log write failed', { + message: 'DB down', + action: 'test.fail', + userId: 'u1', + orgId: 'o1', + }); }); test('should list audit logs with filters', async () => { diff --git a/modules/auth/controllers/auth.controller.js b/modules/auth/controllers/auth.controller.js index 4e2e02c0f..391b2fcb2 100644 --- a/modules/auth/controllers/auth.controller.js +++ b/modules/auth/controllers/auth.controller.js @@ -850,7 +850,17 @@ const resendVerification = async (req, res) => { if (!acceptedCount) return responses.error(res, 400, 'Bad Request', 'Failure sending email')(); return responses.success(res, 'Verification email sent')({ status: true }); } catch (err) { - responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err); + // #3966 hardening: sendVerificationEmail (mailer.sendMail) now propagates a + // transport failure instead of swallowing it — the raw SMTP/provider error + // string must not leak to the client. Log the real error server-side with + // context (this catch is resend-email-scoped: an unexpected failure here is + // effectively always the mail send); respond with a stable generic message. + logger.error('[auth.resendVerification] failed', { + userId: req.user?.id, + message: err?.message, + stack: err?.stack, + }); + responses.error(res, 422, 'Unprocessable Entity', 'Failed to send the email, please try again.')(err); } }; diff --git a/modules/auth/tests/auth.silent.catch.unit.tests.js b/modules/auth/tests/auth.silent.catch.unit.tests.js index e0e8055eb..e1accd48f 100644 --- a/modules/auth/tests/auth.silent.catch.unit.tests.js +++ b/modules/auth/tests/auth.silent.catch.unit.tests.js @@ -411,3 +411,137 @@ describe('auth.password.controller silent-catch error logging:', () => { }); }); }); + +describe('auth.controller resendVerification mail-transport failure hardening (#3966):', () => { + let mockError; + + beforeEach(() => { + jest.resetModules(); + + mockError = jest.fn(); + + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { warn: jest.fn(), error: mockError, info: jest.fn() }, + })); + }); + + test('responds with a generic message (never the raw provider error) and still logs the real error server-side', async () => { + // sendVerificationEmail → mailer.sendMail is awaited directly (not + // fire-and-forget) and now propagates a transport failure (#3966) instead + // of swallowing it — the controller catch must not leak this raw string. + const providerError = new Error('Resend API error: 401 Unauthorized — invalid API key sk_live_abc123'); + + jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({ + default: { + create: jest.fn(), + getBrut: jest.fn().mockResolvedValue({ id: 'u1', email: 'x@y.com', emailVerified: false }), + update: jest.fn().mockResolvedValue({}), + remove: jest.fn(), + count: jest.fn().mockResolvedValue(0), + }, + })); + + jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({ + default: { + registerSignupEligibility: jest.fn(), + assertSignupEligible: jest.fn().mockResolvedValue(undefined), + _reset: jest.fn(), + }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({ + default: { handleSignupOrganization: jest.fn() }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.crud.service.js', () => ({ + default: { autoSetCurrentOrganization: jest.fn() }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.membership.service.js', () => ({ + default: { findByUserAndOrganization: jest.fn(), listPendingByUser: jest.fn().mockResolvedValue([]) }, + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { + sign: { up: true, in: true }, + jwt: { secret: 'test-secret', expiresIn: 3600 }, + cookie: { secure: false, sameSite: 'lax' }, + organizations: { enabled: false }, + app: { title: 'Test', contact: 'test@test.com' }, + }, + })); + + jest.unstable_mockModule('../../../lib/middlewares/model.js', () => ({ + default: { getResultFromZod: jest.fn(), checkError: jest.fn() }, + })); + + // Mailer configured, sendMail rejects with a raw provider error + jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ + default: { + isConfigured: jest.fn().mockReturnValue(true), + sendMail: jest.fn().mockRejectedValue(providerError), + }, + })); + + const successInner = jest.fn(); + const errorInner = jest.fn(); + const success = jest.fn(() => successInner); + const error = jest.fn(() => errorInner); + jest.unstable_mockModule('../../../lib/helpers/responses.js', () => ({ + default: { success, error }, + })); + + jest.unstable_mockModule('../../../lib/helpers/errors.js', () => ({ + default: { getMessage: jest.fn().mockReturnValue(providerError.message) }, + })); + + jest.unstable_mockModule('../../../lib/helpers/AppError.js', () => ({ + default: class AppError extends Error { + constructor(msg, opts) { + super(msg); + this.status = opts?.status; + this.code = opts?.code; + this.details = opts?.details; + } + }, + })); + + jest.unstable_mockModule('../../../modules/users/models/users.schema.js', () => ({ + default: { User: {} }, + })); + + jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({ + default: { defineAbilityFor: jest.fn().mockResolvedValue({}) }, + })); + + jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({ + default: jest.fn().mockReturnValue([]), + })); + + jest.unstable_mockModule('../../../lib/helpers/getBaseUrl.js', () => ({ + default: jest.fn().mockReturnValue('http://localhost:3000'), + })); + + jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ + default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: jest.fn() }, + })); + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + + const req = { user: { id: 'u1' } }; + const res = {}; + + await AuthController.resendVerification(req, res); + + expect(error).toHaveBeenCalledWith(res, 422, 'Unprocessable Entity', 'Failed to send the email, please try again.'); + const clientMessage = error.mock.calls[0][3]; + expect(clientMessage).not.toContain('sk_live_abc123'); + expect(clientMessage).not.toContain('Resend API error'); + + // Real error still logged server-side with context, so it is not lost. + expect(mockError).toHaveBeenCalledWith('[auth.resendVerification] failed', expect.objectContaining({ + userId: 'u1', + message: providerError.message, + })); + }); +}); diff --git a/modules/invitations/controllers/invitations.controller.js b/modules/invitations/controllers/invitations.controller.js index 59f51be7f..ee3a1304f 100644 --- a/modules/invitations/controllers/invitations.controller.js +++ b/modules/invitations/controllers/invitations.controller.js @@ -3,6 +3,7 @@ */ import errors from '../../../lib/helpers/errors.js'; import responses from '../../../lib/helpers/responses.js'; +import logger from '../../../lib/services/logger.js'; import InvitationService from '../services/invitations.service.js'; /** @@ -83,10 +84,24 @@ const resend = async (req, res) => { responses.success(res, 'invitation resent')(invitation); } catch (err) { // Thread ANY service-thrown AppError status (409 duplicate-pending, 404 - // unknown id, etc.) rather than flattening everything but 409 to 422 — a - // 404 must not surface as Unprocessable Entity if a future caller reaches - // the service without the invitationByID param-loader's 404 guard. - const status = err.status ?? 422; + // unknown id, 422 mailer-not-configured, etc.) rather than flattening + // everything but 409 to 422 — a 404 must not surface as Unprocessable + // Entity if a future caller reaches the service without the + // invitationByID param-loader's 404 guard. + // #3966 hardening: InvitationService.resend's mails.sendMail(...) call is + // unguarded and now propagates (#3966) — a raw transport rejection has no + // `.status` (unlike the AppErrors thrown deliberately above) and must not + // leak the raw SMTP/provider error string to the client. Log the real + // error server-side with context; respond with a stable generic message. + if (err.status == null) { + logger.error('[invitations.resend] failed', { + invitationId: req.invitation?.id, + message: err?.message, + stack: err?.stack, + }); + return responses.error(res, 422, 'Unprocessable Entity', 'Failed to send the email, please try again.')(err); + } + const status = err.status; const title = status === 409 ? 'Conflict' : status === 404 ? 'Not Found' : 'Unprocessable Entity'; responses.error(res, status, title, errors.getMessage(err))(err); } diff --git a/modules/invitations/tests/invitations.controller.unit.tests.js b/modules/invitations/tests/invitations.controller.unit.tests.js index 4891c8ecf..9162b23a3 100644 --- a/modules/invitations/tests/invitations.controller.unit.tests.js +++ b/modules/invitations/tests/invitations.controller.unit.tests.js @@ -13,10 +13,12 @@ const successInner = jest.fn(); const errorInner = jest.fn(); const success = jest.fn(() => successInner); const error = jest.fn(() => errorInner); +const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn() }; jest.unstable_mockModule('../services/invitations.service.js', () => ({ default: mockService })); jest.unstable_mockModule('../../../lib/helpers/responses.js', () => ({ default: { success, error } })); jest.unstable_mockModule('../../../lib/helpers/errors.js', () => ({ default: { getMessage: jest.fn((e) => e?.message || 'err') } })); +jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ default: mockLogger })); const controller = (await import('../controllers/invitations.controller.js')).default; @@ -94,6 +96,26 @@ describe('invitations.controller.resend', () => { await controller.resend({ invitation: { id: 'i1' } }, makeRes()); expect(error).toHaveBeenCalledWith(expect.anything(), 422, 'Unprocessable Entity', expect.any(String)); }); + // #3966 hardening: a bare transport rejection (no `.status`, unlike the + // deliberate AppErrors above) must not leak the raw provider/SMTP error + // string to the client — only a stable generic message — while the real + // error still gets logged server-side with context. + test('mail-transport failure (no .status) responds with a generic message, never the raw provider error, and logs server-side', async () => { + const providerError = new Error('Resend API error: 401 Unauthorized — invalid API key sk_live_abc123'); + mockService.resend.mockRejectedValue(providerError); + await controller.resend({ invitation: { id: 'i1' } }, makeRes()); + + expect(error).toHaveBeenCalledWith(expect.anything(), 422, 'Unprocessable Entity', expect.any(String)); + const clientMessage = error.mock.calls[0][3]; + expect(clientMessage).not.toContain('sk_live_abc123'); + expect(clientMessage).not.toContain('Resend API error'); + expect(clientMessage).toBe('Failed to send the email, please try again.'); + + expect(mockLogger.error).toHaveBeenCalledWith('[invitations.resend] failed', expect.objectContaining({ + invitationId: 'i1', + message: providerError.message, + })); + }); }); describe('invitations.controller.verify', () => { diff --git a/modules/invitations/tests/invitations.service.unit.tests.js b/modules/invitations/tests/invitations.service.unit.tests.js index aa1180c5d..b04203e80 100644 --- a/modules/invitations/tests/invitations.service.unit.tests.js +++ b/modules/invitations/tests/invitations.service.unit.tests.js @@ -448,4 +448,11 @@ describe('InvitationService.resend', () => { await expect(InvitationService.resend('i1')).rejects.toMatchObject({ status: 422 }); expect(mockMailer.sendMail).not.toHaveBeenCalled(); }); + + test('propagates a transport failure as a rejection (resend IS the email — no silent success)', async () => { + const transportError = new Error('SMTP timeout'); + InvitationRepository.get.mockResolvedValue(pending); + mockMailer.sendMail.mockRejectedValue(transportError); + await expect(InvitationService.resend('i1')).rejects.toThrow('SMTP timeout'); + }); });