From 5906821f3a753a2d363d318416b0b58c58bc8e56 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sun, 15 Mar 2026 09:41:56 +0100 Subject: [PATCH 1/5] feat(organizations): require email verification before domain-based org features When mailer is configured, users must verify their email before: - Organization auto-provisioning during signup - Explicit organization creation - Join request creation - Domain-based organization search Without mailer configured, the flow remains unchanged (dev-friendly). Closes #3232 --- lib/helpers/emailVerification.js | 17 ++ .../controllers/organizations.controller.js | 5 + .../services/organizations.crud.service.js | 3 + .../organizations.membership.service.js | 4 + .../services/organizations.service.js | 13 + ...anizations.emailVerification.unit.tests.js | 265 ++++++++++++++++++ 6 files changed, 307 insertions(+) create mode 100644 lib/helpers/emailVerification.js create mode 100644 modules/organizations/tests/organizations.emailVerification.unit.tests.js diff --git a/lib/helpers/emailVerification.js b/lib/helpers/emailVerification.js new file mode 100644 index 000000000..a6601d0a0 --- /dev/null +++ b/lib/helpers/emailVerification.js @@ -0,0 +1,17 @@ +import mailer from './mailer/index.js'; +import AppError from './AppError.js'; + +/** + * @desc Assert that the user's email is verified when mailer is configured. + * When mailer is not configured (e.g. local dev), this is a no-op. + * @param {Object} user - The user object (must have emailVerified field). + * @throws {AppError} If mailer is configured and user.emailVerified is falsy. + */ +const assertEmailVerified = (user) => { + if (mailer.isConfigured() && !user.emailVerified) { + throw new AppError('Email verification required before this action', { code: 'FORBIDDEN' }); + } +}; + +export { assertEmailVerified }; +export default { assertEmailVerified }; diff --git a/modules/organizations/controllers/organizations.controller.js b/modules/organizations/controllers/organizations.controller.js index 720f9fed7..3ead0bd3d 100644 --- a/modules/organizations/controllers/organizations.controller.js +++ b/modules/organizations/controllers/organizations.controller.js @@ -5,6 +5,7 @@ import jwt from 'jsonwebtoken'; import errors from '../../../lib/helpers/errors.js'; import responses from '../../../lib/helpers/responses.js'; import config from '../../../config/index.js'; +import mailer from '../../../lib/helpers/mailer/index.js'; import policy from '../../../lib/middlewares/policy.js'; import serializeAbilities from '../../../lib/helpers/abilities.js'; import OrganizationsService from '../services/organizations.crud.service.js'; @@ -157,6 +158,10 @@ const organizationByPage = async (req, res, next, params) => { */ const search = async (req, res) => { try { + // Block domain search for unverified users when mailer is configured + if (mailer.isConfigured() && !req.user.emailVerified) { + return responses.success(res, 'organization search')([]); + } const organizations = await OrganizationsService.searchByDomain(req.user.email); responses.success(res, 'organization search')(organizations); } catch (err) { diff --git a/modules/organizations/services/organizations.crud.service.js b/modules/organizations/services/organizations.crud.service.js index ac94538a3..0dc31dc14 100644 --- a/modules/organizations/services/organizations.crud.service.js +++ b/modules/organizations/services/organizations.crud.service.js @@ -2,6 +2,7 @@ * Module dependencies */ import AppError from '../../../lib/helpers/AppError.js'; +import { assertEmailVerified } from '../../../lib/helpers/emailVerification.js'; import config from '../../../config/index.js'; /** @@ -65,6 +66,8 @@ const listByUser = async (user) => { * @returns {Promise} A promise resolving to the newly created organization. */ const create = async (body, user) => { + assertEmailVerified(user); + // Auto-generate slug from name if not provided let slug = body.slug || slugify(body.name); let counter = 1; diff --git a/modules/organizations/services/organizations.membership.service.js b/modules/organizations/services/organizations.membership.service.js index 1d0e63eaf..24d59fd49 100644 --- a/modules/organizations/services/organizations.membership.service.js +++ b/modules/organizations/services/organizations.membership.service.js @@ -5,6 +5,7 @@ import crypto from 'crypto'; import config from '../../../config/index.js'; import mailer from '../../../lib/helpers/mailer/index.js'; +import { assertEmailVerified } from '../../../lib/helpers/emailVerification.js'; import MembershipRepository from '../repositories/organizations.membership.repository.js'; import OrganizationRepository from '../repositories/organizations.repository.js'; import UserService from '../../users/services/users.service.js'; @@ -128,6 +129,9 @@ const listPendingByUser = (userId) => MembershipRepository.list({ userId, status * @returns {Promise} The created pending membership. */ const createJoinRequest = async (userId, organizationId) => { + const user = await UserService.getBrut({ id: String(userId) }); + assertEmailVerified(user); + const existing = await MembershipRepository.findOne({ userId, organizationId, status: { $in: ['active', 'pending'] } }); if (existing) { if (existing.status === 'active') throw new Error('Already a member of this organization'); diff --git a/modules/organizations/services/organizations.service.js b/modules/organizations/services/organizations.service.js index b9a1c975c..169ca050e 100644 --- a/modules/organizations/services/organizations.service.js +++ b/modules/organizations/services/organizations.service.js @@ -2,6 +2,7 @@ * Module dependencies */ import config from '../../../config/index.js'; +import mailer from '../../../lib/helpers/mailer/index.js'; import policy from '../../../lib/middlewares/policy.js'; import serializeAbilities from '../../../lib/helpers/abilities.js'; import OrganizationsRepository from '../repositories/organizations.repository.js'; @@ -135,6 +136,18 @@ const createOrganizationForUser = async ({ name, slug, domain, user, slugGenerat const handleSignupOrganization = async (user) => { const orgConfig = config.organizations || {}; + // When mailer is configured, require email verification before any org provisioning + if (mailer.isConfigured() && !user.emailVerified) { + return { + organization: null, + membership: null, + abilities: [], + organizationSetupRequired: true, + emailVerificationRequired: true, + pendingJoin: false, + }; + } + // Case 1: Organizations disabled — create a silent default org if (!orgConfig.enabled) { const firstName = user.firstName || 'User'; diff --git a/modules/organizations/tests/organizations.emailVerification.unit.tests.js b/modules/organizations/tests/organizations.emailVerification.unit.tests.js new file mode 100644 index 000000000..bf9786e3a --- /dev/null +++ b/modules/organizations/tests/organizations.emailVerification.unit.tests.js @@ -0,0 +1,265 @@ +/** + * Unit tests for email verification gates on organization operations. + */ +import mongoose from 'mongoose'; +import { jest, describe, test, expect, beforeEach } from '@jest/globals'; + +// --- Mocks --- + +const mockIsConfigured = jest.fn(); +jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ + default: { isConfigured: mockIsConfigured, sendMail: jest.fn() }, +})); + +const mockOrganizationsRepositoryCreate = jest.fn(); +const mockOrganizationsRepositoryList = jest.fn(); +const mockOrganizationsRepositoryExists = jest.fn(); +const mockOrganizationsRepositoryFindOne = jest.fn(); +const mockOrganizationsRepositoryGet = jest.fn(); +jest.unstable_mockModule('../repositories/organizations.repository.js', () => ({ + default: { + create: mockOrganizationsRepositoryCreate, + list: mockOrganizationsRepositoryList, + exists: mockOrganizationsRepositoryExists, + findOne: mockOrganizationsRepositoryFindOne, + get: mockOrganizationsRepositoryGet, + }, +})); + +const mockMembershipRepositoryCreate = jest.fn(); +const mockMembershipRepositoryFindOne = jest.fn(); +const mockMembershipRepositoryList = jest.fn(); +const mockMembershipRepositoryCount = jest.fn(); +jest.unstable_mockModule('../repositories/organizations.membership.repository.js', () => ({ + default: { + create: mockMembershipRepositoryCreate, + findOne: mockMembershipRepositoryFindOne, + list: mockMembershipRepositoryList, + count: mockMembershipRepositoryCount, + }, +})); + +const mockGetBrut = jest.fn(); +const mockUpdateById = jest.fn(); +const mockFindByEmail = jest.fn(); +const mockSearchByNameOrEmail = jest.fn(); +jest.unstable_mockModule('../../users/services/users.service.js', () => ({ + default: { + getBrut: mockGetBrut, + updateById: mockUpdateById, + findByEmail: mockFindByEmail, + searchByNameOrEmail: mockSearchByNameOrEmail, + }, +})); + +jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({ + default: { defineAbilityFor: jest.fn().mockResolvedValue({ rules: [] }) }, +})); + +jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({ + default: jest.fn().mockReturnValue([]), +})); + +jest.unstable_mockModule('../helpers/organizations.slug.js', () => ({ + slugify: (str) => str.toLowerCase().replace(/\s+/g, '-'), + generateOrganizationSlug: jest.fn().mockResolvedValue('test-slug'), +})); + +// --- Dynamic imports after mocks --- + +const { default: OrganizationsService } = await import('../services/organizations.service.js'); +const { default: OrganizationsCrudService } = await import('../services/organizations.crud.service.js'); +const { default: MembershipService } = await import('../services/organizations.membership.service.js'); + +describe('Email verification gates:', () => { + const fakeUserId = new mongoose.Types.ObjectId(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + // --- handleSignupOrganization --- + + describe('handleSignupOrganization', () => { + test('should skip org provisioning when mailer is configured and email is not verified', async () => { + mockIsConfigured.mockReturnValue(true); + + const user = { id: fakeUserId.toString(), email: 'test@acme.com', firstName: 'Test', lastName: 'User', emailVerified: false }; + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.organization).toBeNull(); + expect(result.membership).toBeNull(); + expect(result.emailVerificationRequired).toBe(true); + expect(result.organizationSetupRequired).toBe(true); + expect(result.pendingJoin).toBe(false); + }); + + test('should proceed normally when mailer is not configured', async () => { + mockIsConfigured.mockReturnValue(false); + + const fakeOrg = { _id: new mongoose.Types.ObjectId(), name: 'Test', toJSON: () => ({ name: 'Test' }) }; + const fakeMembership = { _id: new mongoose.Types.ObjectId(), role: 'owner' }; + mockOrganizationsRepositoryCreate.mockResolvedValue(fakeOrg); + mockMembershipRepositoryCreate.mockResolvedValue(fakeMembership); + mockUpdateById.mockResolvedValue({}); + + const user = { id: fakeUserId.toString(), email: 'test@acme.com', firstName: 'Test', lastName: 'User', emailVerified: false }; + const result = await OrganizationsService.handleSignupOrganization(user); + + // Should have created an org (organizations disabled path) + expect(result.organization).toBeDefined(); + expect(result.organization).not.toBeNull(); + }); + + test('should proceed normally when mailer is configured and email is verified', async () => { + mockIsConfigured.mockReturnValue(true); + + const fakeOrg = { _id: new mongoose.Types.ObjectId(), name: 'Test', toJSON: () => ({ name: 'Test' }) }; + const fakeMembership = { _id: new mongoose.Types.ObjectId(), role: 'owner' }; + mockOrganizationsRepositoryCreate.mockResolvedValue(fakeOrg); + mockMembershipRepositoryCreate.mockResolvedValue(fakeMembership); + mockUpdateById.mockResolvedValue({}); + + const user = { id: fakeUserId.toString(), email: 'test@acme.com', firstName: 'Test', lastName: 'User', emailVerified: true }; + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.organization).toBeDefined(); + expect(result.organization).not.toBeNull(); + }); + }); + + // --- create (crud service) --- + + describe('OrganizationsCrudService.create', () => { + test('should throw FORBIDDEN when mailer is configured and email is not verified', async () => { + mockIsConfigured.mockReturnValue(true); + + const user = { id: fakeUserId.toString(), emailVerified: false }; + await expect(OrganizationsCrudService.create({ name: 'Acme' }, user)).rejects.toThrow('Email verification required before this action'); + }); + + test('should proceed when mailer is not configured', async () => { + mockIsConfigured.mockReturnValue(false); + + const fakeOrg = { _id: new mongoose.Types.ObjectId(), name: 'Acme' }; + const fakeMembership = { _id: new mongoose.Types.ObjectId() }; + mockOrganizationsRepositoryFindOne.mockResolvedValue(null); + mockOrganizationsRepositoryCreate.mockResolvedValue(fakeOrg); + mockMembershipRepositoryCreate.mockResolvedValue(fakeMembership); + mockUpdateById.mockResolvedValue({}); + + const user = { id: fakeUserId.toString(), emailVerified: false }; + const result = await OrganizationsCrudService.create({ name: 'Acme' }, user); + + expect(result).toBeDefined(); + expect(mockOrganizationsRepositoryCreate).toHaveBeenCalled(); + }); + + test('should proceed when mailer is configured and email is verified', async () => { + mockIsConfigured.mockReturnValue(true); + + const fakeOrg = { _id: new mongoose.Types.ObjectId(), name: 'Acme' }; + const fakeMembership = { _id: new mongoose.Types.ObjectId() }; + mockOrganizationsRepositoryFindOne.mockResolvedValue(null); + mockOrganizationsRepositoryCreate.mockResolvedValue(fakeOrg); + mockMembershipRepositoryCreate.mockResolvedValue(fakeMembership); + mockUpdateById.mockResolvedValue({}); + + const user = { id: fakeUserId.toString(), emailVerified: true }; + const result = await OrganizationsCrudService.create({ name: 'Acme' }, user); + + expect(result).toBeDefined(); + expect(mockOrganizationsRepositoryCreate).toHaveBeenCalled(); + }); + }); + + // --- createJoinRequest --- + + describe('MembershipService.createJoinRequest', () => { + test('should throw FORBIDDEN when mailer is configured and email is not verified', async () => { + mockIsConfigured.mockReturnValue(true); + mockGetBrut.mockResolvedValue({ _id: fakeUserId, email: 'test@acme.com', emailVerified: false }); + + const orgId = new mongoose.Types.ObjectId().toString(); + await expect(MembershipService.createJoinRequest(fakeUserId.toString(), orgId)).rejects.toThrow('Email verification required before this action'); + }); + + test('should proceed when mailer is not configured', async () => { + mockIsConfigured.mockReturnValue(false); + mockGetBrut.mockResolvedValue({ _id: fakeUserId, email: 'test@acme.com', emailVerified: false }); + mockMembershipRepositoryFindOne.mockResolvedValue(null); + mockMembershipRepositoryCreate.mockResolvedValue({ _id: new mongoose.Types.ObjectId(), status: 'pending' }); + + const orgId = new mongoose.Types.ObjectId().toString(); + const result = await MembershipService.createJoinRequest(fakeUserId.toString(), orgId); + + expect(result).toBeDefined(); + expect(mockMembershipRepositoryCreate).toHaveBeenCalled(); + }); + + test('should proceed when mailer is configured and email is verified', async () => { + mockIsConfigured.mockReturnValue(true); + mockGetBrut.mockResolvedValue({ _id: fakeUserId, email: 'test@acme.com', emailVerified: true }); + mockMembershipRepositoryFindOne.mockResolvedValue(null); + mockMembershipRepositoryCreate.mockResolvedValue({ _id: new mongoose.Types.ObjectId(), status: 'pending' }); + + const orgId = new mongoose.Types.ObjectId().toString(); + const result = await MembershipService.createJoinRequest(fakeUserId.toString(), orgId); + + expect(result).toBeDefined(); + expect(mockMembershipRepositoryCreate).toHaveBeenCalled(); + }); + }); + + // --- search controller gate --- + + describe('search controller gate', () => { + /** + * @desc Build a minimal Express-like res object with spies. + * @returns {Object} mock response + */ + function mockRes() { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; + } + + test('should return empty array when mailer is configured and email is not verified', async () => { + // Import controller after mocks are set up + const { default: controller } = await import('../controllers/organizations.controller.js'); + + mockIsConfigured.mockReturnValue(true); + + const req = { user: { email: 'test@acme.com', emailVerified: false } }; + const res = mockRes(); + + await controller.search(req, res); + + // Should return success with empty array + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + data: [], + }), + ); + }); + + test('should call searchByDomain when mailer is not configured', async () => { + const { default: controller } = await import('../controllers/organizations.controller.js'); + + mockIsConfigured.mockReturnValue(false); + + // searchByDomain is on the crud service — mock it via the repository + mockOrganizationsRepositoryList.mockResolvedValue([]); + + const req = { user: { email: 'test@acme.com', emailVerified: false } }; + const res = mockRes(); + + await controller.search(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + }); + }); +}); From a281fa631ec77863a5cf2ca59bc71949ea194878 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sun, 15 Mar 2026 09:58:49 +0100 Subject: [PATCH 2/5] =?UTF-8?q?fix(organizations):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20403=20status,=20reuse=20user,=20surface=20flag,=20u?= =?UTF-8?q?pdate=20JSDoc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/helpers/emailVerification.js | 2 +- modules/auth/controllers/auth.controller.js | 1 + .../organizations/services/organizations.crud.service.js | 3 +++ .../services/organizations.membership.service.js | 6 ++++-- modules/organizations/services/organizations.service.js | 2 +- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/helpers/emailVerification.js b/lib/helpers/emailVerification.js index a6601d0a0..8189ecb71 100644 --- a/lib/helpers/emailVerification.js +++ b/lib/helpers/emailVerification.js @@ -9,7 +9,7 @@ import AppError from './AppError.js'; */ const assertEmailVerified = (user) => { if (mailer.isConfigured() && !user.emailVerified) { - throw new AppError('Email verification required before this action', { code: 'FORBIDDEN' }); + throw new AppError('Email verification required before this action', { code: 'FORBIDDEN', status: 403 }); } }; diff --git a/modules/auth/controllers/auth.controller.js b/modules/auth/controllers/auth.controller.js index c05b634c0..67f13ac43 100644 --- a/modules/auth/controllers/auth.controller.js +++ b/modules/auth/controllers/auth.controller.js @@ -136,6 +136,7 @@ const signup = async (req, res) => { pendingJoin: orgResult.pendingJoin || false, abilities: orgResult.abilities || [], organizationSetupRequired: orgResult.organizationSetupRequired || false, + emailVerificationRequired: orgResult.emailVerificationRequired || false, suggestedOrganization: orgResult.suggestedOrganization || null, type: 'success', message: 'Sign up', diff --git a/modules/organizations/services/organizations.crud.service.js b/modules/organizations/services/organizations.crud.service.js index 0dc31dc14..eb1289899 100644 --- a/modules/organizations/services/organizations.crud.service.js +++ b/modules/organizations/services/organizations.crud.service.js @@ -61,9 +61,12 @@ const listByUser = async (user) => { /** * @function create * @description Service to create a new organization and make the creator the owner. + * When mailer is configured, requires email verification first (throws AppError + * with code FORBIDDEN / status 403 if not verified). * @param {Object} body - The object containing organization details. * @param {Object} user - The user creating the organization. * @returns {Promise} A promise resolving to the newly created organization. + * @throws {AppError} If mailer is configured and user email is not verified. */ const create = async (body, user) => { assertEmailVerified(user); diff --git a/modules/organizations/services/organizations.membership.service.js b/modules/organizations/services/organizations.membership.service.js index 24d59fd49..89994fcd7 100644 --- a/modules/organizations/services/organizations.membership.service.js +++ b/modules/organizations/services/organizations.membership.service.js @@ -123,10 +123,13 @@ const listPendingByUser = (userId) => MembershipRepository.list({ userId, status /** * @function createJoinRequest - * @description Create a pending membership (join request). Validates no existing active/pending membership. + * @description Create a pending membership (join request). When mailer is configured, requires email + * verification first (throws AppError with code FORBIDDEN / status 403 if not verified). + * Also validates no existing active/pending membership and enforces a single pending request limit. * @param {String} userId - The ID of the requesting user. * @param {String} organizationId - The ID of the organization to join. * @returns {Promise} The created pending membership. + * @throws {AppError} If mailer is configured and user email is not verified. */ const createJoinRequest = async (userId, organizationId) => { const user = await UserService.getBrut({ id: String(userId) }); @@ -143,7 +146,6 @@ const createJoinRequest = async (userId, organizationId) => { const membership = await MembershipRepository.create({ userId, organizationId, role: 'member', status: 'pending' }); if (mailer.isConfigured()) { - const user = await UserService.getBrut({ id: String(userId) }); const org = await OrganizationRepository.get(organizationId); if (user?.email && org?.name) { const admins = await MembershipRepository.list({ organizationId, role: { $in: ['owner', 'admin'] }, status: 'active' }); diff --git a/modules/organizations/services/organizations.service.js b/modules/organizations/services/organizations.service.js index 169ca050e..87a4ecada 100644 --- a/modules/organizations/services/organizations.service.js +++ b/modules/organizations/services/organizations.service.js @@ -130,7 +130,7 @@ const createOrganizationForUser = async ({ name, slug, domain, user, slugGenerat * existing org is returned as `suggestedOrganization`. * * @param {Object} user - The user object returned by UserService.create (with id, email, firstName, lastName). - * @returns {Promise<{organization: Object|null, membership: Object|null, abilities: Array, organizationSetupRequired: boolean, suggestedOrganization: Object|null}>} + * @returns {Promise<{organization: Object|null, membership: Object|null, abilities: Array, organizationSetupRequired: boolean, emailVerificationRequired: boolean|undefined, suggestedOrganization: Object|null}>} * An object containing the organization context for the signup response. */ const handleSignupOrganization = async (user) => { From e6153fc343a0c7d2b1ec8a304ec96dbf7aeef1c3 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sun, 15 Mar 2026 11:43:44 +0100 Subject: [PATCH 3/5] fix(auth,mail): unify base URL helper, fix email templates, keep emailVerified false - Extract getBaseUrl() into shared lib/helpers/getBaseUrl.js - Replace all config.cors.origin[0] and config.app.front with getBaseUrl() - Remove unused app.front config - Fix backslash in verify-email template - Fix displayName trailing space when lastName is empty - Keep emailVerified false at signup even without mailer (gates enforce only when mailer configured) - Update consecutive_zero threshold to 3 in pull-request skill --- config/defaults/development.config.js | 1 - config/templates/verify-email.html | 2 +- lib/helpers/getBaseUrl.js | 18 ++++++++++++++++++ modules/auth/controllers/auth.controller.js | 18 ++++-------------- .../controllers/auth.password.controller.js | 7 ++++--- .../organizations.membership.service.js | 13 +++++++------ .../users/controllers/users.data.controller.js | 2 +- 7 files changed, 35 insertions(+), 26 deletions(-) create mode 100644 lib/helpers/getBaseUrl.js diff --git a/config/defaults/development.config.js b/config/defaults/development.config.js index cde771cff..05a43a240 100644 --- a/config/defaults/development.config.js +++ b/config/defaults/development.config.js @@ -5,7 +5,6 @@ const config = { keywords: 'node, express, mongo, jwt, sequelize, stack, boilerplate', googleAnalyticsTrackingID: 'DEVKIT_NODE_app_googleAnalyticsTrackingID', contact: 'contact@example.com', - front: 'DEVKIT_NODE_app_front', }, swagger: { enable: true, diff --git a/config/templates/verify-email.html b/config/templates/verify-email.html index 225ab4025..313e8ae30 100644 --- a/config/templates/verify-email.html +++ b/config/templates/verify-email.html @@ -12,7 +12,7 @@

The {{appName}} Support Team.


If you didn\'t create this account, you can ignore this email. Please do + >If you didn't create this account, you can ignore this email. Please do not reply to this email, you can contact us here. diff --git a/lib/helpers/getBaseUrl.js b/lib/helpers/getBaseUrl.js new file mode 100644 index 000000000..2f69f3a8e --- /dev/null +++ b/lib/helpers/getBaseUrl.js @@ -0,0 +1,18 @@ +/** + * Module dependencies. + */ +import config from '../../config/index.js'; + +/** + * @desc Resolve the first CORS origin as the client-facing base URL. + * Handles both array and string forms of config.cors.origin. + * @returns {string} The base URL for building client-facing links. + */ +const getBaseUrl = () => { + const origin = config.cors?.origin; + if (Array.isArray(origin) && origin.length > 0) return origin[0]; + if (typeof origin === 'string') return origin; + return ''; +}; + +export default getBaseUrl; diff --git a/modules/auth/controllers/auth.controller.js b/modules/auth/controllers/auth.controller.js index 67f13ac43..c6ededc40 100644 --- a/modules/auth/controllers/auth.controller.js +++ b/modules/auth/controllers/auth.controller.js @@ -32,17 +32,7 @@ const tokenCookieOptions = { */ const isMailerConfigured = () => mails.isConfigured(); -/** - * @desc Resolve the first CORS origin as a base URL string. - * Handles both array and string forms of config.cors.origin. - * @returns {string} The base URL for building client-facing links. - */ -const getBaseUrl = () => { - const origin = config.cors?.origin; - if (Array.isArray(origin) && origin.length > 0) return origin[0]; - if (typeof origin === 'string') return origin; - return ''; -}; +import getBaseUrl from '../../../lib/helpers/getBaseUrl.js'; /** * @desc Send a verification email to the user with a signed token link @@ -56,7 +46,7 @@ const sendVerificationEmail = async (user, verificationToken) => { to: user.email, subject: 'Verify your email address', params: { - displayName: `${user.firstName} ${user.lastName}`, + displayName: [user.firstName, user.lastName].filter(Boolean).join(' '), url: `${getBaseUrl()}/verify-email?token=${verificationToken}`, appName: config.app.title, appContact: config.app.contact, @@ -364,7 +354,7 @@ const oauthCallback = async (req, res, next) => { } // classic web oAuth passport.authenticate(strategy, (err, user) => { - const url = config.cors.origin[0]; + const url = getBaseUrl(); if (err) { const _err = JSON.stringify(err); const path = 'token?message=Unprocessable%20Entity'; @@ -378,7 +368,7 @@ const oauthCallback = async (req, res, next) => { expiresIn: config.jwt.expiresIn, }); res.cookie('TOKEN', token, tokenCookieOptions); - res.redirect(302, `${config.cors.origin[0]}/token`); + res.redirect(302, `${getBaseUrl()}/token`); } })(req, res, next); }; diff --git a/modules/auth/controllers/auth.password.controller.js b/modules/auth/controllers/auth.password.controller.js index d5597e6c5..779f62948 100644 --- a/modules/auth/controllers/auth.password.controller.js +++ b/modules/auth/controllers/auth.password.controller.js @@ -6,6 +6,7 @@ import jwt from 'jsonwebtoken'; import AuthService from '../services/auth.service.js'; import UserService from '../../users/services/users.service.js'; import mails from '../../../lib/helpers/mailer/index.js'; +import getBaseUrl from '../../../lib/helpers/getBaseUrl.js'; import errors from '../../../lib/helpers/errors.js'; import responses from '../../../lib/helpers/responses.js'; import config from '../../../config/index.js'; @@ -46,8 +47,8 @@ const forgot = async (req, res) => { to: user.email, subject: 'Password Reset', params: { - displayName: `${user.firstName} ${user.lastName}`, - url: `${config.cors.origin[0]}/reset?token=${user.resetPasswordToken}`, + displayName: [user.firstName, user.lastName].filter(Boolean).join(' '), + url: `${getBaseUrl()}/reset?token=${user.resetPasswordToken}`, appName: config.app.title, appContact: config.app.contact, }, @@ -99,7 +100,7 @@ const reset = async (req, res) => { to: user.email, subject: 'Your password has been changed', params: { - displayName: `${user.firstName} ${user.lastName}`, + displayName: [user.firstName, user.lastName].filter(Boolean).join(' '), appName: config.app.title, appContact: config.app.contact, }, diff --git a/modules/organizations/services/organizations.membership.service.js b/modules/organizations/services/organizations.membership.service.js index 89994fcd7..44112da00 100644 --- a/modules/organizations/services/organizations.membership.service.js +++ b/modules/organizations/services/organizations.membership.service.js @@ -4,6 +4,7 @@ import crypto from 'crypto'; import config from '../../../config/index.js'; +import getBaseUrl from '../../../lib/helpers/getBaseUrl.js'; import mailer from '../../../lib/helpers/mailer/index.js'; import { assertEmailVerified } from '../../../lib/helpers/emailVerification.js'; import MembershipRepository from '../repositories/organizations.membership.repository.js'; @@ -156,10 +157,10 @@ const createJoinRequest = async (userId, organizationId) => { subject: `New join request for ${org.name}`, template: 'org-request-new', params: { - requesterName: `${user.firstName} ${user.lastName}`, + requesterName: [user.firstName, user.lastName].filter(Boolean).join(' '), requesterEmail: user.email, orgName: org.name, - url: `${config.app?.front || ''}/users/organizations/${organizationId}`, + url: `${getBaseUrl()}/users/organizations/${organizationId}`, appName: config.app.title, }, }).catch(() => {}); @@ -200,7 +201,7 @@ const approveRequest = async (membership) => { subject: `Your request to join ${org.name} has been approved`, template: 'org-request-approved', params: { - displayName: `${user.firstName} ${user.lastName}`, + displayName: [user.firstName, user.lastName].filter(Boolean).join(' '), orgName: org.name, appName: config.app.title, }, @@ -229,7 +230,7 @@ const rejectRequest = async (membership) => { subject: `Your request to join ${org.name}`, template: 'org-request-rejected', params: { - displayName: `${user.firstName} ${user.lastName}`, + displayName: [user.firstName, user.lastName].filter(Boolean).join(' '), orgName: org.name, appName: config.app.title, }, @@ -311,9 +312,9 @@ const invite = async (organizationId, email, invitedBy) => { subject: `You've been invited to join ${org.name}`, template: 'org-invite', params: { - inviterName: `${invitedBy.firstName} ${invitedBy.lastName}`, + inviterName: [invitedBy.firstName, invitedBy.lastName].filter(Boolean).join(' '), orgName: org.name, - url: `${config.app?.front || ''}/invite?token=${inviteToken}`, + url: `${getBaseUrl()}/invite?token=${inviteToken}`, appName: config.app.title, appContact: config.mailer.from, }, diff --git a/modules/users/controllers/users.data.controller.js b/modules/users/controllers/users.data.controller.js index b80c74bad..9acb0da6f 100644 --- a/modules/users/controllers/users.data.controller.js +++ b/modules/users/controllers/users.data.controller.js @@ -66,7 +66,7 @@ const getMail = async (req, res) => { subject: `${config.app.title}: your data`, params: { result: JSON.stringify(result), - displayName: `${req.user.firstName} ${req.user.lastName}`, + displayName: [req.user.firstName, req.user.lastName].filter(Boolean).join(' '), appName: config.app.title, appContact: config.app.contact, }, From c07f64e2dfad633485bc059f9157dc41f4fcac37 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sun, 15 Mar 2026 11:59:30 +0100 Subject: [PATCH 4/5] =?UTF-8?q?fix(organizations):=20address=20CodeRabbit?= =?UTF-8?q?=20review=20=E2=80=94=20guards,=20JSDoc,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/helpers/emailVerification.js | 3 ++- lib/helpers/getBaseUrl.js | 9 +++++--- .../controllers/auth.password.controller.js | 2 ++ .../organizations.membership.service.js | 1 + .../services/organizations.service.js | 1 + ...anizations.emailVerification.unit.tests.js | 22 +++++++++++++++++-- .../controllers/users.data.controller.js | 1 + 7 files changed, 33 insertions(+), 6 deletions(-) diff --git a/lib/helpers/emailVerification.js b/lib/helpers/emailVerification.js index 8189ecb71..4bed1775a 100644 --- a/lib/helpers/emailVerification.js +++ b/lib/helpers/emailVerification.js @@ -5,10 +5,11 @@ import AppError from './AppError.js'; * @desc Assert that the user's email is verified when mailer is configured. * When mailer is not configured (e.g. local dev), this is a no-op. * @param {Object} user - The user object (must have emailVerified field). + * @returns {void} * @throws {AppError} If mailer is configured and user.emailVerified is falsy. */ const assertEmailVerified = (user) => { - if (mailer.isConfigured() && !user.emailVerified) { + if (mailer.isConfigured() && !user?.emailVerified) { throw new AppError('Email verification required before this action', { code: 'FORBIDDEN', status: 403 }); } }; diff --git a/lib/helpers/getBaseUrl.js b/lib/helpers/getBaseUrl.js index 2f69f3a8e..68a5f8b40 100644 --- a/lib/helpers/getBaseUrl.js +++ b/lib/helpers/getBaseUrl.js @@ -10,9 +10,12 @@ import config from '../../config/index.js'; */ const getBaseUrl = () => { const origin = config.cors?.origin; - if (Array.isArray(origin) && origin.length > 0) return origin[0]; - if (typeof origin === 'string') return origin; - return ''; + const resolved = Array.isArray(origin) && origin.length > 0 + ? origin[0] + : typeof origin === 'string' + ? origin + : ''; + return typeof resolved === 'string' ? resolved.replace(/\/+$/, '') : ''; }; export default getBaseUrl; diff --git a/modules/auth/controllers/auth.password.controller.js b/modules/auth/controllers/auth.password.controller.js index 779f62948..78f1daf7e 100644 --- a/modules/auth/controllers/auth.password.controller.js +++ b/modules/auth/controllers/auth.password.controller.js @@ -21,6 +21,7 @@ const tokenCookieOptions = { * @desc Endpoint to init password reset mail * @param {Object} req - Express request object * @param {Object} res - Express response object + * @returns {Promise} Sends a JSON response with reset status. */ const forgot = async (req, res) => { let user; @@ -79,6 +80,7 @@ const validateResetToken = async (req, res) => { * @desc Endpoint to reset password from url with token * @param {Object} req - Express request object * @param {Object} res - Express response object + * @returns {Promise} Sends a JSON response with the updated user and token. */ const reset = async (req, res) => { let user; diff --git a/modules/organizations/services/organizations.membership.service.js b/modules/organizations/services/organizations.membership.service.js index 44112da00..71a1664ee 100644 --- a/modules/organizations/services/organizations.membership.service.js +++ b/modules/organizations/services/organizations.membership.service.js @@ -134,6 +134,7 @@ const listPendingByUser = (userId) => MembershipRepository.list({ userId, status */ const createJoinRequest = async (userId, organizationId) => { const user = await UserService.getBrut({ id: String(userId) }); + if (!user) throw new Error('User not found'); assertEmailVerified(user); const existing = await MembershipRepository.findOne({ userId, organizationId, status: { $in: ['active', 'pending'] } }); diff --git a/modules/organizations/services/organizations.service.js b/modules/organizations/services/organizations.service.js index 87a4ecada..8a5735a16 100644 --- a/modules/organizations/services/organizations.service.js +++ b/modules/organizations/services/organizations.service.js @@ -145,6 +145,7 @@ const handleSignupOrganization = async (user) => { organizationSetupRequired: true, emailVerificationRequired: true, pendingJoin: false, + suggestedOrganization: null, }; } diff --git a/modules/organizations/tests/organizations.emailVerification.unit.tests.js b/modules/organizations/tests/organizations.emailVerification.unit.tests.js index bf9786e3a..44afd3ed6 100644 --- a/modules/organizations/tests/organizations.emailVerification.unit.tests.js +++ b/modules/organizations/tests/organizations.emailVerification.unit.tests.js @@ -75,6 +75,7 @@ describe('Email verification gates:', () => { const fakeUserId = new mongoose.Types.ObjectId(); beforeEach(() => { + jest.restoreAllMocks(); jest.clearAllMocks(); }); @@ -230,12 +231,15 @@ describe('Email verification gates:', () => { const { default: controller } = await import('../controllers/organizations.controller.js'); mockIsConfigured.mockReturnValue(true); + mockOrganizationsRepositoryList.mockResolvedValue([]); const req = { user: { email: 'test@acme.com', emailVerified: false } }; const res = mockRes(); await controller.search(req, res); + // searchByDomain should NOT have been called in the blocked branch + expect(mockOrganizationsRepositoryList).not.toHaveBeenCalled(); // Should return success with empty array expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith( @@ -250,8 +254,6 @@ describe('Email verification gates:', () => { const { default: controller } = await import('../controllers/organizations.controller.js'); mockIsConfigured.mockReturnValue(false); - - // searchByDomain is on the crud service — mock it via the repository mockOrganizationsRepositoryList.mockResolvedValue([]); const req = { user: { email: 'test@acme.com', emailVerified: false } }; @@ -259,6 +261,22 @@ describe('Email verification gates:', () => { await controller.search(req, res); + expect(mockOrganizationsRepositoryList).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + + test('should call searchByDomain when mailer is configured and email is verified', async () => { + const { default: controller } = await import('../controllers/organizations.controller.js'); + + mockIsConfigured.mockReturnValue(true); + mockOrganizationsRepositoryList.mockResolvedValue([]); + + const req = { user: { email: 'test@acme.com', emailVerified: true } }; + const res = mockRes(); + + await controller.search(req, res); + + expect(mockOrganizationsRepositoryList).toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(200); }); }); diff --git a/modules/users/controllers/users.data.controller.js b/modules/users/controllers/users.data.controller.js index 9acb0da6f..c2020fc9a 100644 --- a/modules/users/controllers/users.data.controller.js +++ b/modules/users/controllers/users.data.controller.js @@ -50,6 +50,7 @@ const get = async (req, res) => { * @desc Endpoint to ask the service to get all user data and send it to user mail * @param {Object} req - Express request object * @param {Object} res - Express response object + * @returns {Promise} Sends a JSON response confirming the email was sent. */ const getMail = async (req, res) => { try { From e15a1d66bebab281184c60f3e31d4ca602bb486a Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sun, 15 Mar 2026 12:05:12 +0100 Subject: [PATCH 5/5] chore: trigger CI