Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
9 changes: 7 additions & 2 deletions modules/organizations/controllers/organizations.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 11 additions & 2 deletions modules/organizations/services/organizations.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});

Comment thread
PierreBrisorgueil marked this conversation as resolved.
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);
});
});
});
Loading