diff --git a/modules/organizations/config/organizations.development.config.js b/modules/organizations/config/organizations.development.config.js index 7a1f2807f..636cfecd2 100644 --- a/modules/organizations/config/organizations.development.config.js +++ b/modules/organizations/config/organizations.development.config.js @@ -9,6 +9,14 @@ const config = { enabled: true, // false → B2C mode, organizations invisible autoCreate: true, // automatically create/join orgs at signup domainMatching: true, // match users to existing orgs by email domain + // Email-verification policy for org provisioning/discovery. + // 'strict' (default) → when the mailer is configured, an unverified user + // cannot provision an org at signup nor run domain search; they must + // verify their email first. + // 'off' → email verification is never required for these flows; the user + // is always auto-provisioned (same path as a mailer-not-configured env). + // emailVerified stays server-only; this policy only gates the existing checks. + emailVerification: { mode: 'strict' }, roles: ['owner', 'admin', 'member'], roleDescriptions: { owner: 'Full control — manage organization settings, members, roles, and billing.', diff --git a/modules/organizations/controllers/organizations.controller.js b/modules/organizations/controllers/organizations.controller.js index 2864bec3a..3e6b70158 100644 --- a/modules/organizations/controllers/organizations.controller.js +++ b/modules/organizations/controllers/organizations.controller.js @@ -184,8 +184,13 @@ 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) { + // Email-verification policy gate (config.organizations.emailVerification.mode). + // FAIL CLOSED: only the explicit 'off' value lifts the gate; the default, a typo, + // or wrong casing all keep the strict block, so a misconfiguration can never leak + // the domain search to unverified users. 'off' → never block (same path as + // mailer-not-configured). + const emailVerificationOff = (config.organizations?.emailVerification?.mode ?? 'strict') === 'off'; + if (!emailVerificationOff && mailer.isConfigured() && !req.user.emailVerified) { return responses.success(res, 'organization search')([]); } const organizations = await OrganizationsService.searchByDomain(req.user.email); diff --git a/modules/organizations/services/organizations.service.js b/modules/organizations/services/organizations.service.js index 9384396e5..a1d3a5790 100644 --- a/modules/organizations/services/organizations.service.js +++ b/modules/organizations/services/organizations.service.js @@ -136,8 +136,17 @@ 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) { + // Email-verification policy gate (config.organizations.emailVerification.mode). + // FAIL CLOSED: verification is bypassed ONLY for the explicit, permissive value + // 'off'. Any other value — the default, a typo ('stict'), or wrong casing + // ('STRICT') — keeps the strict gate, so a misconfiguration can never silently + // auto-provision unverified users. 'off' → always auto-provision (same effective + // path as a mailer-not-configured env). See module base config. + const emailVerificationOff = (orgConfig.emailVerification?.mode ?? 'strict') === 'off'; + + // When the policy is NOT 'off' and the mailer is configured, require email + // verification before any org provisioning. + if (!emailVerificationOff && mailer.isConfigured() && !user.emailVerified) { return { organization: null, membership: null, diff --git a/modules/organizations/tests/organizations.emailVerification.policy.unit.tests.js b/modules/organizations/tests/organizations.emailVerification.policy.unit.tests.js new file mode 100644 index 000000000..a86200afd --- /dev/null +++ b/modules/organizations/tests/organizations.emailVerification.policy.unit.tests.js @@ -0,0 +1,207 @@ +/** + * Unit tests — config-gated email-verification policy + * (config.organizations.emailVerification.mode). + * + * Covers BOTH modes on the two gated surfaces (signup org provisioning + domain + * search), with the mailer reported as configured and the user UNVERIFIED — the + * only case the mode actually changes: + * - 'strict' (default) → blocks the unverified user (no org, empty search). + * - 'off' → always provisions / always searches (mailer-on path + * behaves like a mailer-not-configured env). + * + * The default-strict + mailer-on + verified path stays byte-identical to today + * and is exercised by organizations.emailVerification.unit.tests.js. + */ +import mongoose from 'mongoose'; +import { jest, describe, test, expect, beforeEach } from '@jest/globals'; + +// --- Mutable config mock (so each test can flip emailVerification.mode) --- + +const orgConfig = { + enabled: false, + domainMatching: false, + emailVerification: { mode: 'strict' }, +}; +const configMock = { + organizations: orgConfig, + cookie: { secure: false, sameSite: 'strict' }, + jwt: { secret: 'test-secret', expiresIn: 3600 }, + get: jest.fn(), +}; +jest.unstable_mockModule('../../../config/index.js', () => ({ default: configMock })); + +// --- Mocks --- + +jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +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(); +jest.unstable_mockModule('../repositories/organizations.repository.js', () => ({ + default: { + create: mockOrganizationsRepositoryCreate, + list: mockOrganizationsRepositoryList, + exists: mockOrganizationsRepositoryExists, + findOne: jest.fn(), + get: jest.fn(), + }, +})); + +const mockMembershipRepositoryCreate = jest.fn(); +const mockMembershipRepositoryFindOne = jest.fn(); +jest.unstable_mockModule('../repositories/organizations.membership.repository.js', () => ({ + default: { + create: mockMembershipRepositoryCreate, + findOne: mockMembershipRepositoryFindOne, + list: jest.fn(), + count: jest.fn(), + }, +})); + +const mockUpdateById = jest.fn(); +jest.unstable_mockModule('../../users/services/users.service.js', () => ({ + default: { + getBrut: jest.fn(), + updateById: mockUpdateById, + findByEmail: jest.fn(), + searchByNameOrEmail: jest.fn(), + }, +})); + +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'), +})); + +const mockBillingGrantOnSignup = jest.fn().mockResolvedValue(undefined); +jest.unstable_mockModule('../../billing/services/billing.signupGrant.service.js', () => ({ + default: { grantOnSignup: mockBillingGrantOnSignup }, +})); + +// --- Dynamic imports after mocks --- + +const { default: OrganizationsService } = await import('../services/organizations.service.js'); + +describe('Email-verification policy modes:', () => { + const fakeUserId = new mongoose.Types.ObjectId(); + + beforeEach(() => { + jest.clearAllMocks(); + orgConfig.enabled = false; + orgConfig.domainMatching = false; + orgConfig.emailVerification = { mode: 'strict' }; + mockMembershipRepositoryFindOne.mockResolvedValue(null); + }); + + // --- handleSignupOrganization --- + + describe('handleSignupOrganization', () => { + test("strict mode (default) blocks an unverified user when mailer is configured", async () => { + orgConfig.emailVerification = { mode: 'strict' }; + 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(mockOrganizationsRepositoryCreate).not.toHaveBeenCalled(); + }); + + test("off mode provisions an org for an unverified user even when mailer is configured", async () => { + orgConfig.emailVerification = { mode: 'off' }; + 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: false }; + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.emailVerificationRequired).toBeUndefined(); + expect(result.organization).not.toBeNull(); + expect(mockOrganizationsRepositoryCreate).toHaveBeenCalled(); + }); + + test("unknown/typo mode fails closed (treated as strict) — blocks an unverified user", async () => { + orgConfig.emailVerification = { mode: 'stict' }; // typo: not the explicit permissive 'off' + 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.emailVerificationRequired).toBe(true); + expect(result.organization).toBeNull(); + expect(mockOrganizationsRepositoryCreate).not.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("strict mode (default) returns an empty array for an unverified user when mailer is configured", async () => { + orgConfig.emailVerification = { mode: 'strict' }; + 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); + + expect(mockOrganizationsRepositoryList).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ type: 'success', data: [] }), + ); + }); + + test("off mode runs the domain search for an unverified user even when mailer is configured", async () => { + orgConfig.emailVerification = { mode: 'off' }; + 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); + + expect(mockOrganizationsRepositoryList).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + }); + }); +});