From 6f7f0f8d831fe333329fd3363c5cc3c6e50d8002 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 19 May 2026 20:00:24 +0200 Subject: [PATCH 1/9] feat(organizations): canonical email-domain normalization helper --- .../services/organizations.domain.js | 33 +++++++++++++++++++ .../tests/organizations.domain.unit.tests.js | 28 ++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 modules/organizations/services/organizations.domain.js create mode 100644 modules/organizations/tests/organizations.domain.unit.tests.js diff --git a/modules/organizations/services/organizations.domain.js b/modules/organizations/services/organizations.domain.js new file mode 100644 index 000000000..c8990d60f --- /dev/null +++ b/modules/organizations/services/organizations.domain.js @@ -0,0 +1,33 @@ +/** + * Email-domain normalization helpers. + * + * Maintained constant. Not auto-refreshed (spec H2 — freshness out of scope). + */ + +export const PUBLIC_DOMAINS = new Set([ + 'gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', + 'yahoo.com', 'icloud.com', 'me.com', 'proton.me', 'protonmail.com', + 'aol.com', 'gmx.com', 'mail.com', 'yandex.com', 'zoho.com', +]); + +/** + * Extract and normalize the domain portion of an email address. + * @param {*} email - Raw value to parse. + * @returns {string|null} Lowercased, trimmed domain, or null on malformed input. + */ +export function normalizeEmailDomain(email) { + if (typeof email !== 'string') return null; + const at = email.indexOf('@'); + if (at === -1) return null; + const domain = email.slice(at + 1).trim().toLowerCase(); + return domain.length > 0 && domain.includes('.') ? domain : null; +} + +/** + * Return true when the domain belongs to a known public e-mail provider. + * @param {string} domain - Normalized or raw domain string. + * @returns {boolean} + */ +export function isPublicDomain(domain) { + return PUBLIC_DOMAINS.has(String(domain ?? '').trim().toLowerCase()); +} diff --git a/modules/organizations/tests/organizations.domain.unit.tests.js b/modules/organizations/tests/organizations.domain.unit.tests.js new file mode 100644 index 000000000..36a8bebc9 --- /dev/null +++ b/modules/organizations/tests/organizations.domain.unit.tests.js @@ -0,0 +1,28 @@ +/** + * Unit tests for organizations.domain.js helper. + */ +import { describe, it, expect } from '@jest/globals'; + +import { normalizeEmailDomain, isPublicDomain } from '../services/organizations.domain.js'; + +describe('organizations.domain', () => { + describe('normalizeEmailDomain', () => { + it('lowercases and trims the domain part', () => { + expect(normalizeEmailDomain('User@Sub.Example.COM ')).toBe('sub.example.com'); + }); + it('returns null for malformed input', () => { + expect(normalizeEmailDomain('not-an-email')).toBeNull(); + expect(normalizeEmailDomain('')).toBeNull(); + expect(normalizeEmailDomain(null)).toBeNull(); + }); + }); + describe('isPublicDomain', () => { + it('flags common public providers', () => { + expect(isPublicDomain('gmail.com')).toBe(true); + expect(isPublicDomain('outlook.com')).toBe(true); + }); + it('does not flag a corporate domain', () => { + expect(isPublicDomain('acme.com')).toBe(false); + }); + }); +}); From 70ef4e1057a8d9796b8dcbef08f781f90f168075 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 19 May 2026 20:27:35 +0200 Subject: [PATCH 2/9] feat(organizations): always-create workspace + suggestedJoin hint, drop autoCreate footgun MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec D5 — every signup provisions an active workspace. autoCreate config flag is a deprecated no-op; no path returns organizationSetupRequired or pendingJoin. - handleSignupOrganization: delete autoCreate:false footgun branch entirely; all enabled paths call createOrganizationForUser returning {organization, membership}. - Domain-match branch: drop createJoinRequest+pendingJoin; return suggestedJoin: {orgId,orgName} name-only hint alongside the newly created workspace. - Use A1 isPublicDomain for the public-domain gate (hardcoded list + config overrides). - Naming: domainMatching+corporate domain → domain-based name; all other cases (domainMatching off, public domain) → "{firstName}'s organization". - signupGrant: credited inside createOrganizationForUser, once per real new org, never double-credited. Preserved exactly from pre-A2. - Auth controller: no change needed — uses ||false/||null fallbacks on removed keys. - Test updates (required 0-failure): emailVerification, domainJoin E2E, auth signup integration + auth E2E updated to assert new always-create contract. - New unit test: organizations.service.signup.unit.tests.js — all config combos, suggestedJoin shape (name-only), signupGrant credited once, createJoinRequest never. --- modules/auth/tests/auth.e2e.tests.js | 33 +- ...h.signup.organization.integration.tests.js | 48 ++- .../services/organizations.service.js | 123 +++--- .../organizations.domainJoin.e2e.tests.js | 49 +-- ...anizations.emailVerification.unit.tests.js | 4 +- ...organizations.service.signup.unit.tests.js | 380 ++++++++++++++++++ 6 files changed, 507 insertions(+), 130 deletions(-) create mode 100644 modules/organizations/tests/organizations.service.signup.unit.tests.js diff --git a/modules/auth/tests/auth.e2e.tests.js b/modules/auth/tests/auth.e2e.tests.js index 4a665f903..b2971a33e 100644 --- a/modules/auth/tests/auth.e2e.tests.js +++ b/modules/auth/tests/auth.e2e.tests.js @@ -118,7 +118,9 @@ describe('Auth E2E tests:', () => { secondUser = null; }); - test('should create pending join request when email domain matches existing org', async () => { + test('spec D5: second user with same domain gets own workspace + suggestedJoin (no pending join request)', async () => { + // Spec D5 always-create: domain-match no longer creates a pending join request. + // Both users get their own active workspace. config.organizations = { enabled: true, autoCreate: true, domainMatching: true }; try { @@ -139,7 +141,7 @@ describe('Auth E2E tests:', () => { expect(firstOrg).toBeDefined(); expect(firstOrg.domain).toBe('e2etest.com'); - // Step 2: signup second user with same domain + // Step 2: signup second user with same domain — gets own workspace (spec D5) const result2 = await agent .post('/api/auth/signup') .send({ @@ -153,26 +155,29 @@ describe('Auth E2E tests:', () => { secondUser = result2.body.user; - // Verify org is returned with pending flag + // Second user gets their OWN active workspace (not a join on firstOrg) expect(result2.body.organization).toBeDefined(); - expect(result2.body.organization._id).toBe(firstOrg._id); - expect(result2.body.pendingJoin).toBe(true); + expect(result2.body.organization).not.toBeNull(); + expect(result2.body.organization._id).not.toBe(firstOrg._id); + // pendingJoin is always false (spec D5) + expect(result2.body.pendingJoin).toBeFalsy(); - // Verify second user has a PENDING membership (not active) - const pendingMemberships = await MembershipRepository.list({ + // Second user has an ACTIVE membership on their new org (not pending on firstOrg) + const activeMemberships = await MembershipRepository.list({ userId: secondUser.id, - organizationId: firstOrg._id, - status: 'pending', + organizationId: result2.body.organization._id, + status: 'active', }); - expect(pendingMemberships).toHaveLength(1); + expect(activeMemberships).toHaveLength(1); + expect(activeMemberships[0].role).toBe('owner'); - // Verify NO active membership - const activeMemberships = await MembershipRepository.list({ + // NO pending membership on firstOrg (spec D5: no join request created) + const pendingOnFirst = await MembershipRepository.list({ userId: secondUser.id, organizationId: firstOrg._id, - status: 'active', + status: 'pending', }); - expect(activeMemberships).toHaveLength(0); + expect(pendingOnFirst).toHaveLength(0); } catch (err) { console.log(err); expect(err).toBeFalsy(); diff --git a/modules/auth/tests/auth.signup.organization.integration.tests.js b/modules/auth/tests/auth.signup.organization.integration.tests.js index 5070be60b..0d572a0d1 100644 --- a/modules/auth/tests/auth.signup.organization.integration.tests.js +++ b/modules/auth/tests/auth.signup.organization.integration.tests.js @@ -116,13 +116,13 @@ describe('Auth signup organization integration tests:', () => { }); describe('Signup with organizations enabled + autoCreate + domainMatching', () => { - test('should create pending join request when domain matches existing org', async () => { + test('spec D5: second user with same domain gets own workspace + suggestedJoin hint (no pending request)', async () => { + // Spec D5 always-create: domain-match no longer creates a pending join request. + // Both users get their own active workspace; the second receives a suggestedJoin hint. config.organizations = { enabled: true, autoCreate: true, domainMatching: true }; - // First, create an existing organization with a specific domain let firstUser; let secondUser; - // clean up stale users from previous runs on shared databases await purgeUser('first@matchdomain.com'); await purgeUser('second@matchdomain.com'); try { @@ -143,7 +143,7 @@ describe('Auth signup organization integration tests:', () => { expect(firstOrg).toBeDefined(); expect(firstOrg.domain).toBe('matchdomain.com'); - // Sign up second user with same domain — should get pending join request + // Sign up second user with same domain — spec D5: gets own workspace, not a join request const result2 = await agent .post('/api/auth/signup') .send({ @@ -157,13 +157,14 @@ describe('Auth signup organization integration tests:', () => { secondUser = result2.body.user; - // Should reference the same org but with pending flag + // Second user gets their OWN active workspace, not a reference to firstOrg expect(result2.body.organization).toBeDefined(); - expect(result2.body.organization._id).toBe(firstOrg._id); - expect(result2.body.pendingJoin).toBe(true); - expect(result2.body.organizationSetupRequired).toBe(false); + expect(result2.body.organization).not.toBeNull(); + expect(result2.body.organization._id).not.toBe(firstOrg._id); + expect(result2.body.pendingJoin).toBeFalsy(); + expect(result2.body.organizationSetupRequired).toBeFalsy(); - // Abilities should be present (without org context) + // Abilities should be present (with org context — user is owner of their own org) expect(result2.body.abilities).toBeDefined(); expect(result2.body.abilities).toBeInstanceOf(Array); } catch (err) { @@ -215,11 +216,10 @@ describe('Auth signup organization integration tests:', () => { }); describe('Signup with organizations enabled + autoCreate + no domainMatching', () => { - test('should create a personal organization for the user', async () => { + test('should create a personal organization for the user (domainMatching off = personal name)', async () => { config.organizations = { enabled: true, autoCreate: true, domainMatching: false }; let user; - // clean up stale users from previous runs on shared databases await purgeUser('personal@somecompany.com'); try { const result = await agent @@ -235,12 +235,12 @@ describe('Auth signup organization integration tests:', () => { user = result.body.user; - // A personal organization should be created (no domain matching) + // domainMatching off → personal workspace name regardless of domain expect(result.body.organization).toBeDefined(); expect(result.body.organization).not.toBeNull(); expect(result.body.organization.name).toBe("Personal's organization"); expect(result.body.organization.domain).toBe(''); - expect(result.body.organizationSetupRequired).toBe(false); + expect(result.body.organizationSetupRequired).toBeFalsy(); // Abilities should be present expect(result.body.abilities).toBeDefined(); @@ -254,12 +254,13 @@ describe('Auth signup organization integration tests:', () => { }); }); - describe('Signup with organizations enabled + no autoCreate', () => { - test('should not create an organization and flag setup as required', async () => { + describe('Signup with organizations enabled + no autoCreate (spec D5: autoCreate is deprecated no-op)', () => { + test('spec D5: autoCreate:false is a no-op — workspace always provisioned', async () => { + // Spec D5: config.organizations.autoCreate is a deprecated no-op. + // Signup ALWAYS provisions a workspace; organizationSetupRequired is never returned. config.organizations = { enabled: true, autoCreate: false, domainMatching: true }; let user; - // clean up stale users from previous runs on shared databases await purgeUser('manual@setup.com'); try { const result = await agent @@ -275,11 +276,13 @@ describe('Auth signup organization integration tests:', () => { user = result.body.user; - // No organization should be created - expect(result.body.organization).toBeNull(); - expect(result.body.organizationSetupRequired).toBe(true); + // Workspace is always created now (spec D5) + expect(result.body.organization).not.toBeNull(); + expect(result.body.organization).toBeDefined(); + // organizationSetupRequired defaults to false (auth controller || false) + expect(result.body.organizationSetupRequired).toBeFalsy(); - // Abilities should still be present (guest-level for org context) + // Abilities should be present (user is owner of their new workspace) expect(result.body.abilities).toBeDefined(); expect(result.body.abilities).toBeInstanceOf(Array); } catch (err) { @@ -312,11 +315,12 @@ describe('Auth signup organization integration tests:', () => { user = result.body.user; - // Verify response structure includes the new fields + // Verify response structure includes the expected fields expect(result.body).toHaveProperty('user'); expect(result.body).toHaveProperty('organization'); expect(result.body).toHaveProperty('abilities'); - expect(result.body).toHaveProperty('organizationSetupRequired'); + // organizationSetupRequired is always false (auth controller || false default) + expect(result.body.organizationSetupRequired).toBeFalsy(); expect(result.body).toHaveProperty('tokenExpiresIn'); expect(result.body.type).toBe('success'); expect(result.body.message).toBe('Sign up'); diff --git a/modules/organizations/services/organizations.service.js b/modules/organizations/services/organizations.service.js index 00149e48c..8636aac4d 100644 --- a/modules/organizations/services/organizations.service.js +++ b/modules/organizations/services/organizations.service.js @@ -13,6 +13,7 @@ import UserService from '../../users/services/users.service.js'; import { slugify, generateOrganizationSlug } from '../helpers/organizations.slug.js'; import { MEMBERSHIP_ROLES } from '../lib/constants.js'; import BillingSignupGrantService from '../../billing/services/billing.signupGrant.service.js'; +import { isPublicDomain } from './organizations.domain.js'; /** * @desc Strip sensitive fields from an organization document before returning to public flows. @@ -132,16 +133,23 @@ const createOrganizationForUser = async ({ name, slug, domain, user, slugGenerat /** * Handle organization provisioning during the signup flow. * - * Behaviour depends on the organizations configuration flags: + * Spec D5 — always-create: every successful signup provisions a workspace for the user. + * The `autoCreate` config flag is a deprecated no-op (spec D5): signup always provisions + * a workspace regardless of its value. Domain-matching is a hint mechanism only. + * + * Behaviour: * - **!enabled**: Creates a silent/default org named "{firstName}'s organization". - * - **enabled**: Never auto-creates or auto-joins. Returns information for the - * frontend to handle organization setup as step 2. If domainMatching is on and - * the user's email domain is not in the publicDomains blocklist, a matching - * existing org is returned as `suggestedOrganization`. + * - **enabled**: Always calls createOrganizationForUser — user always gets an active workspace. + * If domainMatching is on, the user's email domain is not public, and an existing org matches, + * a `suggestedJoin: { orgId, orgName }` hint (name-only) is returned alongside the new org. + * + * signupGrant: credited inside createOrganizationForUser (best-effort, never throws). Called + * exactly once per real new org — no double-credit possible. * * @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, emailVerificationRequired: boolean|undefined, suggestedOrganization: Object|null}>} + * @returns {Promise<{organization: Object, membership: Object, abilities: Array, emailVerificationRequired?: boolean, suggestedJoin?: {orgId: string, orgName: string}}>} * An object containing the organization context for the signup response. + * NEVER returns `organizationSetupRequired` or `pendingJoin`. */ const handleSignupOrganization = async (user) => { const orgConfig = config.organizations || {}; @@ -152,10 +160,7 @@ const handleSignupOrganization = async (user) => { organization: null, membership: null, abilities: [], - organizationSetupRequired: true, emailVerificationRequired: true, - pendingJoin: false, - suggestedOrganization: null, }; } @@ -178,79 +183,61 @@ const handleSignupOrganization = async (user) => { organization, membership, abilities: serializeAbilities(ability), - organizationSetupRequired: false, }; } - // Case 2: Organizations enabled + // Case 2: Organizations enabled — always provision a workspace for the user. + // config.organizations.autoCreate is a deprecated no-op (spec D5): signup always provisions + // a workspace regardless of its value. const domain = extractDomain(user.email); const publicDomains = orgConfig.publicDomains || []; - const isPublic = publicDomains.includes(domain.toLowerCase()); - - // Case 2a: autoCreate enabled — automatically provision or join an organization - if (orgConfig.autoCreate) { - // Try domain matching first if enabled and domain is not public - if (orgConfig.domainMatching && !isPublic) { - const existingOrgs = await OrganizationsRepository.list({ domain }); - if (existingOrgs.length > 0) { - // Create a pending join request — admin must approve - const organization = sanitizeOrg(existingOrgs[0]); - await MembershipService.createJoinRequest(user.id || user._id, existingOrgs[0]._id); - const ability = await policy.defineAbilityFor(user, null); - return { - organization, - membership: null, - abilities: serializeAbilities(ability), - organizationSetupRequired: false, - pendingJoin: true, - }; - } - // No existing org — create a new one with the domain - const name = nameFromDomain(domain); - const slug = await generateSlugFromDomain(domain); - const { organization, membership } = await createOrganizationForUser({ name, slug, domain, user }); - const ability = await policy.defineAbilityFor(user, membership); - return { - organization, - membership, - abilities: serializeAbilities(ability), - organizationSetupRequired: false, + // Use A1's isPublicDomain for the hardcoded public-provider list, then fall through to + // the config publicDomains list for project-level overrides. + const domainIsPublic = isPublicDomain(domain) || publicDomains.includes(domain.toLowerCase()); + + // Domain matching: when on, non-public domain, and an existing org matches → suggestedJoin hint. + // The user still always gets their own new workspace (no join-request, no pendingJoin). + let suggestedJoin; + if (orgConfig.domainMatching && !domainIsPublic) { + const existingOrgs = await OrganizationsRepository.list({ domain }); + if (existingOrgs.length > 0) { + // Return name-only hint — no size/domain/membership/plan disclosure + const matched = existingOrgs[0]; + suggestedJoin = { + orgId: (matched._id || matched.id).toString(), + orgName: matched.name, }; } - // No domain matching — create a personal organization - const firstName = user.firstName || 'User'; - const name = `${firstName}'s organization`; - const slug = await generateOrganizationSlug(firstName, user.lastName || ''); - const { organization, membership } = await createOrganizationForUser({ name, slug, domain: '', user }); - const ability = await policy.defineAbilityFor(user, membership); - return { - organization, - membership, - abilities: serializeAbilities(ability), - organizationSetupRequired: false, - }; } - // Case 2b: autoCreate disabled — require step 2 - let suggestedOrganization = null; - - // If domain matching enabled and domain is NOT public, suggest existing org - if (orgConfig.domainMatching && !isPublic) { - const existingOrgs = await OrganizationsRepository.list({ domain }); - if (existingOrgs.length > 0) { - suggestedOrganization = sanitizeOrg(existingOrgs[0]); - } + // Derive org name/slug: use domain-based name only when domainMatching is on AND + // the domain is corporate (non-public). Otherwise fall back to a personal workspace name. + // This preserves the original naming convention from the autoCreate:true/domainMatching paths. + let name; + let slug; + if (orgConfig.domainMatching && !domainIsPublic) { + name = nameFromDomain(domain); + slug = await generateSlugFromDomain(domain); + } else { + const firstName = user.firstName || 'User'; + name = `${firstName}'s organization`; + slug = await generateOrganizationSlug(firstName, user.lastName || ''); } - // Compute abilities without org context (user has no org yet) - const ability = await policy.defineAbilityFor(user, null); + const { organization, membership } = await createOrganizationForUser({ + name, + slug, + domain: (orgConfig.domainMatching && !domainIsPublic) ? domain : '', + user, + }); + + const ability = await policy.defineAbilityFor(user, membership); return { - organization: null, - membership: null, + organization, + membership, abilities: serializeAbilities(ability), - organizationSetupRequired: true, - suggestedOrganization, // null or { _id, name, slug, domain } + ...(suggestedJoin ? { suggestedJoin } : {}), }; }; diff --git a/modules/organizations/tests/organizations.domainJoin.e2e.tests.js b/modules/organizations/tests/organizations.domainJoin.e2e.tests.js index 7bc9286ad..502aef345 100644 --- a/modules/organizations/tests/organizations.domainJoin.e2e.tests.js +++ b/modules/organizations/tests/organizations.domainJoin.e2e.tests.js @@ -116,7 +116,9 @@ describe('Organizations domain join E2E tests:', () => { } }); - test('second user signs up with same domain — pending join request, not auto-joined', async () => { + test('second user signs up with same domain — always gets own workspace + suggestedJoin hint (spec D5)', async () => { + // Spec D5: signup always provisions a workspace. Domain-match no longer creates a pending + // join request — instead, a suggestedJoin hint is returned alongside the user's new org. config.organizations = { enabled: true, autoCreate: true, domainMatching: true, publicDomains: ['gmail.com'] }; agentMember = request.agent(agent.app); @@ -134,55 +136,54 @@ describe('Organizations domain join E2E tests:', () => { memberUser = result.body.user; - // Verify the org is returned but join is pending (not active) + // Spec D5: always-create — member gets their OWN active workspace expect(result.body.organization).toBeDefined(); - expect(result.body.organization._id).toBe(org._id); - expect(result.body.pendingJoin).toBe(true); + expect(result.body.organization).not.toBeNull(); + // pendingJoin is gone — service never returns it (controller defaults to false) + expect(result.body.pendingJoin).toBeFalsy(); expect(result.body.joined).toBeFalsy(); - // Verify NO active membership — only a pending request + // Member has their own active membership (not a pending request on owner's org) const activeMemberships = await MembershipRepository.list({ userId: memberUser.id, - organizationId: org._id, + organizationId: result.body.organization._id, status: 'active', }); - expect(activeMemberships).toHaveLength(0); - - // Verify pending request exists - const pendingMemberships = await MembershipRepository.list({ - userId: memberUser.id, - organizationId: org._id, - status: 'pending', - }); - expect(pendingMemberships).toHaveLength(1); - expect(pendingMemberships[0].role).toBe('member'); + expect(activeMemberships).toHaveLength(1); + expect(activeMemberships[0].role).toBe('owner'); + + // suggestedJoin hint returned (name-only) pointing to owner's org + expect(result.body.suggestedOrganization).toBeNull(); // old key gone + // Note: suggestedJoin is a service-level key; the auth controller serializes it + // as suggestedOrganization:null (old shape kept for API compat until client updated). + // The service-level suggestedJoin is tested in organizations.service.signup unit tests. } catch (err) { console.log(err); expect(err).toBeFalsy(); } }); - test('owner can list pending requests and sees the new joiner — 200', async () => { + test('owner pending requests list — no pending requests from member (member has own org)', async () => { + // After spec D5: domain-match no longer creates join requests. + // Member got their own org instead of a pending request on owner's org. try { const result = await agentOwner .get(`/api/organizations/${org._id}/requests`) .expect(200); expect(result.body.data).toBeInstanceOf(Array); - expect(result.body.data.length).toBeGreaterThanOrEqual(1); - // The pending request should be from the member user - const pendingRequest = result.body.data.find( - (r) => String(r.userId?._id || r.userId) === String(memberUser.id), + // No pending requests — member did not create a join request (spec D5) + const pendingFromMember = result.body.data.filter( + (r) => String(r.userId?._id || r.userId) === String(memberUser?.id), ); - expect(pendingRequest).toBeDefined(); - expect(pendingRequest.status).toBe('pending'); + expect(pendingFromMember).toHaveLength(0); } catch (err) { console.log(err); expect(err).toBeFalsy(); } }); - test('member cannot list pending requests — returns empty array — 200', async () => { + test('member cannot list pending requests on owner org — returns empty array — 200', async () => { try { const result = await agentMember .get(`/api/organizations/${org._id}/requests`) diff --git a/modules/organizations/tests/organizations.emailVerification.unit.tests.js b/modules/organizations/tests/organizations.emailVerification.unit.tests.js index 44afd3ed6..9f3c20e27 100644 --- a/modules/organizations/tests/organizations.emailVerification.unit.tests.js +++ b/modules/organizations/tests/organizations.emailVerification.unit.tests.js @@ -91,8 +91,8 @@ describe('Email verification gates:', () => { expect(result.organization).toBeNull(); expect(result.membership).toBeNull(); expect(result.emailVerificationRequired).toBe(true); - expect(result.organizationSetupRequired).toBe(true); - expect(result.pendingJoin).toBe(false); + // organizationSetupRequired and pendingJoin removed — spec D5: service no longer + // returns these keys; signup always provisions a workspace post-verification. }); test('should proceed normally when mailer is not configured', async () => { diff --git a/modules/organizations/tests/organizations.service.signup.unit.tests.js b/modules/organizations/tests/organizations.service.signup.unit.tests.js new file mode 100644 index 000000000..3ea86198c --- /dev/null +++ b/modules/organizations/tests/organizations.service.signup.unit.tests.js @@ -0,0 +1,380 @@ +/** + * Unit tests — handleSignupOrganization always-create + suggestedJoin (spec D5 / A2). + * + * Contract: + * - Every signup path (autoCreate true|false × domainMatching true|false × domain public|corporate × match|no-match) + * returns a real `organization` + active `membership`. NEVER `organizationSetupRequired` or `pendingJoin`. + * - `suggestedJoin` returned ONLY when: domainMatching on + non-public domain + existing different org matches. + * Shape is name-only: { orgId: string, orgName: string } — no size/domain/membership keys. + * - Public domain (e.g. gmail.com) with same-domain existing org → NO suggestedJoin. + * - signupGrant credited exactly once per real new org creation (via BillingSignupGrantService.grantOnSignup); + * not double-credited on any path. + * + * RED: written before A2 implementation — current code fails these assertions. + */ +import mongoose from 'mongoose'; +import { jest, describe, test, expect, beforeEach } from '@jest/globals'; + +// --- Mocks (must precede dynamic imports) --- + +const mockGrantOnSignup = jest.fn().mockResolvedValue(null); +jest.unstable_mockModule('../../billing/services/billing.signupGrant.service.js', () => ({ + default: { grantOnSignup: mockGrantOnSignup }, +})); + +const mockIsConfigured = jest.fn().mockReturnValue(false); +jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ + default: { isConfigured: mockIsConfigured }, +})); + +const mockOrgCreate = jest.fn(); +const mockOrgList = jest.fn(); +const mockOrgExists = jest.fn().mockResolvedValue(false); +jest.unstable_mockModule('../repositories/organizations.repository.js', () => ({ + default: { + create: mockOrgCreate, + list: mockOrgList, + exists: mockOrgExists, + remove: jest.fn().mockResolvedValue({}), + }, +})); + +const mockMembershipCreate = jest.fn(); +jest.unstable_mockModule('../repositories/organizations.membership.repository.js', () => ({ + default: { + create: mockMembershipCreate, + deleteMany: jest.fn().mockResolvedValue({}), + list: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null), + }, +})); + +const mockUpdateById = jest.fn().mockResolvedValue({}); +jest.unstable_mockModule('../../users/services/users.service.js', () => ({ + default: { updateById: mockUpdateById }, +})); + +// MembershipService.createJoinRequest should never be called after A2 +const mockCreateJoinRequest = jest.fn(); +jest.unstable_mockModule('../services/organizations.membership.service.js', () => ({ + default: { createJoinRequest: mockCreateJoinRequest }, +})); + +jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({ + default: { defineAbilityFor: jest.fn().mockResolvedValue({ rules: [] }) }, +})); + +jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({ + default: jest.fn().mockReturnValue(['ability-stub']), +})); + +jest.unstable_mockModule('../helpers/organizations.slug.js', () => ({ + slugify: (str) => str.toLowerCase().replace(/\s+/g, '-'), + generateOrganizationSlug: jest.fn().mockResolvedValue('alice-org'), +})); + +jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { error: jest.fn(), warn: jest.fn(), info: jest.fn() }, +})); + +// Config store — MUST be mutated in-place (not reassigned) because jest.unstable_mockModule +// captures the default export value at import time. Object.assign ensures live updates. +const configStore = { organizations: {} }; +jest.unstable_mockModule('../../../config/index.js', () => ({ + default: configStore, +})); + +// --- Dynamic import after all mocks --- +const { default: OrganizationsService } = await import('../services/organizations.service.js'); + +// --- Helpers --- + +/** + * Build a minimal user object for testing. + * @param {string} email + * @returns {Object} + */ +function makeUser(email = 'alice@acme.com') { + return { + id: new mongoose.Types.ObjectId().toString(), + _id: new mongoose.Types.ObjectId().toString(), + email, + firstName: 'Alice', + lastName: 'Smith', + emailVerified: true, + }; +} + +/** + * Build a fake organization document returned by the repository. + * @param {Object} overrides + * @returns {Object} + */ +function makeFakeOrg(overrides = {}) { + const _id = new mongoose.Types.ObjectId(); + return { + _id, + name: overrides.name || 'Acme Corp', + slug: overrides.slug || 'acme', + domain: overrides.domain || 'acme.com', + plan: 'free', + toJSON() { return { _id: this._id, name: this.name, slug: this.slug, domain: this.domain }; }, + ...overrides, + }; +} + +/** + * Build a fake membership document. + * @returns {Object} + */ +function makeFakeMembership() { + return { _id: new mongoose.Types.ObjectId(), role: 'owner' }; +} + +/** + * Configure config mock and repository happy-path defaults. + * Must mutate configStore IN-PLACE (not reassign) because the mock module captures + * the object reference at import time — reassigning the variable breaks the binding. + * @param {Object} orgConfig - `config.organizations` values + */ +function setupConfig(orgConfig) { + // Clear and repopulate in-place + Object.keys(configStore).forEach((k) => delete configStore[k]); + Object.assign(configStore, { organizations: { publicDomains: [], ...orgConfig } }); + mockOrgCreate.mockResolvedValue(makeFakeOrg()); + mockMembershipCreate.mockResolvedValue(makeFakeMembership()); + mockOrgList.mockResolvedValue([]); + mockUpdateById.mockResolvedValue({}); +} + +// --- Tests --- + +describe('handleSignupOrganization — always-create (spec D5 / A2):', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ─── Core invariant: always returns a real org + membership ────────────── + + describe('autoCreate:false — footgun branch deleted:', () => { + test('non-public domain, no existing org → creates workspace, no suggestedJoin', async () => { + setupConfig({ enabled: true, autoCreate: false, domainMatching: false }); + const user = makeUser('alice@acme.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.organization).not.toBeNull(); + expect(result.membership).not.toBeNull(); + expect(result).not.toHaveProperty('organizationSetupRequired'); + expect(result).not.toHaveProperty('pendingJoin'); + expect(result.suggestedJoin).toBeUndefined(); + }); + + test('public domain → creates workspace, no suggestedJoin', async () => { + setupConfig({ enabled: true, autoCreate: false, domainMatching: true, publicDomains: ['gmail.com'] }); + const user = makeUser('alice@gmail.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.organization).not.toBeNull(); + expect(result.membership).not.toBeNull(); + expect(result.suggestedJoin).toBeUndefined(); + expect(result).not.toHaveProperty('organizationSetupRequired'); + }); + + test('domainMatching on, non-public, existing org → creates workspace + suggestedJoin name-only', async () => { + setupConfig({ enabled: true, autoCreate: false, domainMatching: true, publicDomains: [] }); + const existingOrg = makeFakeOrg({ name: 'Acme Corp', domain: 'acme.com' }); + mockOrgList.mockResolvedValue([existingOrg]); + const user = makeUser('alice@acme.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + // Always creates own workspace + expect(result.organization).not.toBeNull(); + expect(result.membership).not.toBeNull(); + expect(result).not.toHaveProperty('organizationSetupRequired'); + expect(result).not.toHaveProperty('pendingJoin'); + + // suggestedJoin is name-only + expect(result.suggestedJoin).toBeDefined(); + expect(result.suggestedJoin.orgId).toBeDefined(); + expect(result.suggestedJoin.orgName).toBe('Acme Corp'); + // Must NOT leak sensitive fields + expect(result.suggestedJoin).not.toHaveProperty('domain'); + expect(result.suggestedJoin).not.toHaveProperty('memberCount'); + expect(result.suggestedJoin).not.toHaveProperty('membership'); + expect(result.suggestedJoin).not.toHaveProperty('plan'); + }); + + test('domainMatching on, public domain, existing org → NO suggestedJoin', async () => { + setupConfig({ enabled: true, autoCreate: false, domainMatching: true, publicDomains: ['gmail.com'] }); + const existingOrg = makeFakeOrg({ name: 'Gmail Users', domain: 'gmail.com' }); + mockOrgList.mockResolvedValue([existingOrg]); + const user = makeUser('alice@gmail.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.organization).not.toBeNull(); + expect(result.suggestedJoin).toBeUndefined(); + }); + }); + + describe('autoCreate:true — existing paths preserved, same always-create guarantee:', () => { + test('domainMatching off → creates personal workspace', async () => { + setupConfig({ enabled: true, autoCreate: true, domainMatching: false }); + const user = makeUser('alice@acme.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.organization).not.toBeNull(); + expect(result.membership).not.toBeNull(); + expect(result).not.toHaveProperty('organizationSetupRequired'); + expect(result).not.toHaveProperty('pendingJoin'); + expect(result.suggestedJoin).toBeUndefined(); + }); + + test('domainMatching on, non-public, no existing org → creates domain-named workspace', async () => { + setupConfig({ enabled: true, autoCreate: true, domainMatching: true }); + mockOrgList.mockResolvedValue([]); + const user = makeUser('alice@acme.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.organization).not.toBeNull(); + expect(result.membership).not.toBeNull(); + expect(result).not.toHaveProperty('pendingJoin'); + expect(result.suggestedJoin).toBeUndefined(); + }); + + test('domainMatching on, non-public, existing org → creates own workspace + suggestedJoin (no pendingJoin/join-request)', async () => { + setupConfig({ enabled: true, autoCreate: true, domainMatching: true }); + const existingOrg = makeFakeOrg({ name: 'Acme Corp', domain: 'acme.com' }); + mockOrgList.mockResolvedValue([existingOrg]); + const user = makeUser('alice@acme.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + // Always creates own workspace — MembershipService.createJoinRequest must NOT be called + expect(result.organization).not.toBeNull(); + expect(result.membership).not.toBeNull(); + expect(mockCreateJoinRequest).not.toHaveBeenCalled(); + expect(result).not.toHaveProperty('pendingJoin'); + + // suggestedJoin returned, name-only + expect(result.suggestedJoin).toBeDefined(); + expect(result.suggestedJoin.orgName).toBe('Acme Corp'); + }); + + test('domainMatching on, public domain (gmail), existing org → NO suggestedJoin', async () => { + setupConfig({ enabled: true, autoCreate: true, domainMatching: true, publicDomains: ['gmail.com'] }); + const existingOrg = makeFakeOrg({ name: 'Gmail Users', domain: 'gmail.com' }); + mockOrgList.mockResolvedValue([existingOrg]); + const user = makeUser('alice@gmail.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.organization).not.toBeNull(); + expect(result.suggestedJoin).toBeUndefined(); + }); + }); + + describe('organizations disabled (enabled:false) — silent default org:', () => { + test('always creates workspace', async () => { + setupConfig({ enabled: false }); + const user = makeUser('alice@acme.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.organization).not.toBeNull(); + expect(result.membership).not.toBeNull(); + expect(result.suggestedJoin).toBeUndefined(); + expect(result).not.toHaveProperty('organizationSetupRequired'); + }); + }); + + // ─── signupGrant — credited exactly once per real new org ──────────────── + + describe('signupGrant (BillingSignupGrantService):', () => { + test('credited exactly once for a real new signup (autoCreate:false, no domain match)', async () => { + setupConfig({ enabled: true, autoCreate: false, domainMatching: false }); + const user = makeUser('alice@acme.com'); + + await OrganizationsService.handleSignupOrganization(user); + + expect(mockGrantOnSignup).toHaveBeenCalledTimes(1); + expect(mockGrantOnSignup).toHaveBeenCalledWith( + expect.objectContaining({ planId: 'free' }), + ); + }); + + test('credited exactly once for a real new signup (autoCreate:true, domain match → always-create)', async () => { + setupConfig({ enabled: true, autoCreate: true, domainMatching: true }); + const existingOrg = makeFakeOrg({ name: 'Acme Corp', domain: 'acme.com' }); + mockOrgList.mockResolvedValue([existingOrg]); + const user = makeUser('alice@acme.com'); + + await OrganizationsService.handleSignupOrganization(user); + + // One grant for the newly created workspace (not for the existing org) + expect(mockGrantOnSignup).toHaveBeenCalledTimes(1); + }); + + test('NOT called on email-verification early-return path', async () => { + setupConfig({ enabled: true, autoCreate: false, domainMatching: false }); + mockIsConfigured.mockReturnValue(true); + const user = makeUser('alice@acme.com'); + user.emailVerified = false; + + const result = await OrganizationsService.handleSignupOrganization(user); + + // Early return — no org created, no grant + expect(result.organization).toBeNull(); + expect(mockGrantOnSignup).not.toHaveBeenCalled(); + }); + }); + + // ─── suggestedJoin shape / isolation ───────────────────────────────────── + + describe('suggestedJoin shape:', () => { + test('orgId is a string representation of the matched org id', async () => { + setupConfig({ enabled: true, autoCreate: false, domainMatching: true }); + const existingOrg = makeFakeOrg({ name: 'Acme Corp', domain: 'acme.com' }); + mockOrgList.mockResolvedValue([existingOrg]); + const user = makeUser('alice@acme.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(typeof result.suggestedJoin.orgId).toBe('string'); + expect(result.suggestedJoin.orgId).toBe(existingOrg._id.toString()); + }); + + test('suggestedJoin uses isPublicDomain (A1) for the public-domain gate', async () => { + // Use the A1 PUBLIC_DOMAINS list — icloud.com is in it + setupConfig({ enabled: true, autoCreate: false, domainMatching: true, publicDomains: [] }); + const existingOrg = makeFakeOrg({ name: 'iCloud Corp', domain: 'icloud.com' }); + mockOrgList.mockResolvedValue([existingOrg]); + const user = makeUser('alice@icloud.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + // icloud.com is in A1's PUBLIC_DOMAINS hardcoded list — should not suggest + expect(result.suggestedJoin).toBeUndefined(); + }); + + test('MembershipService.createJoinRequest is NEVER called on any path', async () => { + // Test all paths that previously called createJoinRequest + for (const autoCreate of [true, false]) { + jest.clearAllMocks(); + setupConfig({ enabled: true, autoCreate, domainMatching: true }); + const existingOrg = makeFakeOrg({ name: 'Acme Corp', domain: 'acme.com' }); + mockOrgList.mockResolvedValue([existingOrg]); + const user = makeUser('alice@acme.com'); + + await OrganizationsService.handleSignupOrganization(user); + + expect(mockCreateJoinRequest).not.toHaveBeenCalled(); + } + }); + }); +}); From 63332d6d3922f97896a138038749ec33e130a59c Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 19 May 2026 20:41:56 +0200 Subject: [PATCH 3/9] fix(organizations): remove dead imports + harden signup test mock isolation - I1: remove unused MembershipService import and dead sanitizeOrg helper (both orphaned by A2); file is now lint-clean (no-unused-vars: 0) - I2: replace clearAllMocks() with resetAllMocks() + explicit defaults in top-level beforeEach; prevents mockReturnValue leaking across describe blocks - m1: hoist triple `orgConfig.domainMatching && !domainIsPublic` into const isCorporateDomain; used at all 3 call sites, behavior identical - m2: reword misleading e2e comment on suggestedOrganization:null to state actual controller contract and reference A2b for wiring --- .../services/organizations.service.js | 21 +++++-------------- .../organizations.domainJoin.e2e.tests.js | 7 +++---- ...organizations.service.signup.unit.tests.js | 10 ++++++++- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/modules/organizations/services/organizations.service.js b/modules/organizations/services/organizations.service.js index 8636aac4d..dcba59b6d 100644 --- a/modules/organizations/services/organizations.service.js +++ b/modules/organizations/services/organizations.service.js @@ -8,25 +8,12 @@ import policy from '../../../lib/middlewares/policy.js'; import serializeAbilities from '../../../lib/helpers/abilities.js'; import OrganizationsRepository from '../repositories/organizations.repository.js'; import MembershipRepository from '../repositories/organizations.membership.repository.js'; -import MembershipService from './organizations.membership.service.js'; import UserService from '../../users/services/users.service.js'; import { slugify, generateOrganizationSlug } from '../helpers/organizations.slug.js'; import { MEMBERSHIP_ROLES } from '../lib/constants.js'; import BillingSignupGrantService from '../../billing/services/billing.signupGrant.service.js'; import { isPublicDomain } from './organizations.domain.js'; -/** - * @desc Strip sensitive fields from an organization document before returning to public flows. - * @param {Object} org - Organization document (Mongoose or plain). - * @returns {Object} Safe projection without createdBy or plan. - */ -const sanitizeOrg = (org) => { - const obj = org.toJSON ? org.toJSON() : { ...org }; - delete obj.createdBy; - delete obj.plan; - return obj; -}; - /** * Extract the domain part from an email address. * @param {string} email - A valid email address. @@ -194,11 +181,13 @@ const handleSignupOrganization = async (user) => { // Use A1's isPublicDomain for the hardcoded public-provider list, then fall through to // the config publicDomains list for project-level overrides. const domainIsPublic = isPublicDomain(domain) || publicDomains.includes(domain.toLowerCase()); + // True when domain-matching is active AND the domain belongs to a corporate (non-public) email. + const isCorporateDomain = orgConfig.domainMatching && !domainIsPublic; // Domain matching: when on, non-public domain, and an existing org matches → suggestedJoin hint. // The user still always gets their own new workspace (no join-request, no pendingJoin). let suggestedJoin; - if (orgConfig.domainMatching && !domainIsPublic) { + if (isCorporateDomain) { const existingOrgs = await OrganizationsRepository.list({ domain }); if (existingOrgs.length > 0) { // Return name-only hint — no size/domain/membership/plan disclosure @@ -215,7 +204,7 @@ const handleSignupOrganization = async (user) => { // This preserves the original naming convention from the autoCreate:true/domainMatching paths. let name; let slug; - if (orgConfig.domainMatching && !domainIsPublic) { + if (isCorporateDomain) { name = nameFromDomain(domain); slug = await generateSlugFromDomain(domain); } else { @@ -227,7 +216,7 @@ const handleSignupOrganization = async (user) => { const { organization, membership } = await createOrganizationForUser({ name, slug, - domain: (orgConfig.domainMatching && !domainIsPublic) ? domain : '', + domain: isCorporateDomain ? domain : '', user, }); diff --git a/modules/organizations/tests/organizations.domainJoin.e2e.tests.js b/modules/organizations/tests/organizations.domainJoin.e2e.tests.js index 502aef345..f080ad085 100644 --- a/modules/organizations/tests/organizations.domainJoin.e2e.tests.js +++ b/modules/organizations/tests/organizations.domainJoin.e2e.tests.js @@ -153,10 +153,9 @@ describe('Organizations domain join E2E tests:', () => { expect(activeMemberships[0].role).toBe('owner'); // suggestedJoin hint returned (name-only) pointing to owner's org - expect(result.body.suggestedOrganization).toBeNull(); // old key gone - // Note: suggestedJoin is a service-level key; the auth controller serializes it - // as suggestedOrganization:null (old shape kept for API compat until client updated). - // The service-level suggestedJoin is tested in organizations.service.signup unit tests. + // controller still emits suggestedOrganization (always null post-footgun-removal); + // suggestedJoin is wired to the response in a follow-up (A2b). + expect(result.body.suggestedOrganization).toBeNull(); } catch (err) { console.log(err); expect(err).toBeFalsy(); diff --git a/modules/organizations/tests/organizations.service.signup.unit.tests.js b/modules/organizations/tests/organizations.service.signup.unit.tests.js index 3ea86198c..48b3ab56c 100644 --- a/modules/organizations/tests/organizations.service.signup.unit.tests.js +++ b/modules/organizations/tests/organizations.service.signup.unit.tests.js @@ -151,7 +151,15 @@ function setupConfig(orgConfig) { describe('handleSignupOrganization — always-create (spec D5 / A2):', () => { beforeEach(() => { - jest.clearAllMocks(); + // resetAllMocks clears both call counts AND mockReturnValue/mockResolvedValue implementations + // set by individual tests — prevents state leaking between describes (e.g. mockIsConfigured + // set to true in signupGrant tests bleeding into suggestedJoin describe). + jest.resetAllMocks(); + // Re-establish module-level defaults that resetAllMocks wipes. + mockIsConfigured.mockReturnValue(false); + mockOrgExists.mockResolvedValue(false); + mockUpdateById.mockResolvedValue({}); + mockGrantOnSignup.mockResolvedValue(null); }); // ─── Core invariant: always returns a real org + membership ────────────── From 2d91e106d54098a163274f56f7629642f8f2376c Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 19 May 2026 20:54:31 +0200 Subject: [PATCH 4/9] fix(organizations): normalize domain on write + exact-match read via canonical normalizeEmailDomain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit replace extractDomain(user.email) with normalizeEmailDomain (A1) at the two signup sites in handleSignupOrganization: the repository list query (read) and the domain passed to createOrganizationForUser (write). both now use the single canonical lowercased/trimmed/null-safe path from organizations.domain.js. guard isCorporateDomain against null domain (malformed email → normalizeEmailDomain returns null → treated as non-corporate, falls through to personal workspace). extractDomain kept as exported public API for non-signup callers; not modified. tests (A3): mixed-case write normalization, case-insensitive match, subdomain non-match (eu.acme.com ≠ acme.com), public-domain gate with mixed-case email. --- .../services/organizations.service.js | 12 ++- ...organizations.service.signup.unit.tests.js | 75 +++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/modules/organizations/services/organizations.service.js b/modules/organizations/services/organizations.service.js index dcba59b6d..95c199b4c 100644 --- a/modules/organizations/services/organizations.service.js +++ b/modules/organizations/services/organizations.service.js @@ -12,7 +12,7 @@ import UserService from '../../users/services/users.service.js'; import { slugify, generateOrganizationSlug } from '../helpers/organizations.slug.js'; import { MEMBERSHIP_ROLES } from '../lib/constants.js'; import BillingSignupGrantService from '../../billing/services/billing.signupGrant.service.js'; -import { isPublicDomain } from './organizations.domain.js'; +import { isPublicDomain, normalizeEmailDomain } from './organizations.domain.js'; /** * Extract the domain part from an email address. @@ -176,13 +176,17 @@ const handleSignupOrganization = async (user) => { // Case 2: Organizations enabled — always provision a workspace for the user. // config.organizations.autoCreate is a deprecated no-op (spec D5): signup always provisions // a workspace regardless of its value. - const domain = extractDomain(user.email); + // + // A3: use normalizeEmailDomain (A1) as the single canonical path — lowercased/trimmed, + // null-safe. extractDomain is kept for non-signup callers (exported public API). + const domain = normalizeEmailDomain(user.email); const publicDomains = orgConfig.publicDomains || []; // Use A1's isPublicDomain for the hardcoded public-provider list, then fall through to // the config publicDomains list for project-level overrides. - const domainIsPublic = isPublicDomain(domain) || publicDomains.includes(domain.toLowerCase()); + const domainIsPublic = isPublicDomain(domain) || publicDomains.includes(domain?.toLowerCase() ?? ''); // True when domain-matching is active AND the domain belongs to a corporate (non-public) email. - const isCorporateDomain = orgConfig.domainMatching && !domainIsPublic; + // domain is null only on malformed email (normalizeEmailDomain returns null) — treat as non-corporate. + const isCorporateDomain = Boolean(domain) && orgConfig.domainMatching && !domainIsPublic; // Domain matching: when on, non-public domain, and an existing org matches → suggestedJoin hint. // The user still always gets their own new workspace (no join-request, no pendingJoin). diff --git a/modules/organizations/tests/organizations.service.signup.unit.tests.js b/modules/organizations/tests/organizations.service.signup.unit.tests.js index 48b3ab56c..77000f297 100644 --- a/modules/organizations/tests/organizations.service.signup.unit.tests.js +++ b/modules/organizations/tests/organizations.service.signup.unit.tests.js @@ -385,4 +385,79 @@ describe('handleSignupOrganization — always-create (spec D5 / A2):', () => { } }); }); + + // ─── A3: domain normalization on write + exact-match read ──────────────── + + describe('A3 — domain normalization (write + read, exact-match, no subdomain recursion):', () => { + test('org created with mixed-case email → domain persisted normalized (lowercase)', async () => { + // RED: current code uses extractDomain which lowercases, but normalizeEmailDomain + // is the canonical path. We assert the create call receives the normalized domain. + setupConfig({ enabled: true, autoCreate: false, domainMatching: true, publicDomains: [] }); + mockOrgList.mockResolvedValue([]); + const user = makeUser('dave@ACME.com'); + + await OrganizationsService.handleSignupOrganization(user); + + // The org create call must receive domain: 'acme.com' (normalized) + expect(mockOrgCreate).toHaveBeenCalledWith( + expect.objectContaining({ domain: 'acme.com' }), + ); + }); + + test('mixed-case email matches existing org with stored lowercase domain (case-insensitive match)', async () => { + // RED: signup from Dave@Acme.COM should match existing org stored as 'acme.com' + setupConfig({ enabled: true, autoCreate: false, domainMatching: true, publicDomains: [] }); + const existingOrg = makeFakeOrg({ name: 'Acme Corp', domain: 'acme.com' }); + mockOrgList.mockResolvedValue([existingOrg]); + const user = makeUser('Dave@Acme.COM'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + // suggestedJoin must be present — the normalized domain 'acme.com' matches + expect(result.suggestedJoin).toBeDefined(); + expect(result.suggestedJoin.orgName).toBe('Acme Corp'); + // Always-create contract still holds + expect(result.organization).not.toBeNull(); + expect(result.membership).not.toBeNull(); + expect(result).not.toHaveProperty('organizationSetupRequired'); + expect(result).not.toHaveProperty('pendingJoin'); + // mockOrgList must have been called with the normalized domain + expect(mockOrgList).toHaveBeenCalledWith(expect.objectContaining({ domain: 'acme.com' })); + }); + + test('subdomain email does NOT match org with base domain (no subdomain recursion)', async () => { + // RED: erin@eu.acme.com must NOT match an org stored with domain 'acme.com' + // Exact equality only — 'eu.acme.com' !== 'acme.com' + setupConfig({ enabled: true, autoCreate: false, domainMatching: true, publicDomains: [] }); + // Even if the repo returns a result (simulate loose DB query), the service must not suggest + // In practice with exact-equality the repo won't be called with 'acme.com' at all — + // it'll be called with 'eu.acme.com'. Return [] to simulate no exact match. + mockOrgList.mockResolvedValue([]); + const user = makeUser('erin@eu.acme.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + // No suggestedJoin — 'eu.acme.com' !== 'acme.com' + expect(result.suggestedJoin).toBeUndefined(); + // Always-create still holds + expect(result.organization).not.toBeNull(); + expect(result.membership).not.toBeNull(); + expect(result).not.toHaveProperty('organizationSetupRequired'); + // The lookup must have been called with the exact subdomain, not the base domain + expect(mockOrgList).toHaveBeenCalledWith(expect.objectContaining({ domain: 'eu.acme.com' })); + }); + + test('public domain still skipped even with mixed-case email (no suggestedJoin)', async () => { + setupConfig({ enabled: true, autoCreate: false, domainMatching: true, publicDomains: [] }); + // gmail.com is in A1 PUBLIC_DOMAINS hardcoded list + const existingOrg = makeFakeOrg({ name: 'Gmail Users', domain: 'gmail.com' }); + mockOrgList.mockResolvedValue([existingOrg]); + const user = makeUser('alice@Gmail.COM'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.suggestedJoin).toBeUndefined(); + expect(result.organization).not.toBeNull(); + }); + }); }); From af9cf6bd29f05737e995c61db07cba93be1b76a3 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 19 May 2026 21:03:15 +0200 Subject: [PATCH 5/9] refactor(organizations): drop dead extractDomain + simplify public-domain guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove extractDomain (def + JSDoc + export entry) — fully superseded by normalizeEmailDomain since A3; 0 callers in repo. Drop stale "kept for non-signup callers" comment (was factually wrong). Simplify domainIsPublic to `isPublicDomain(domain) || publicDomains.includes(domain ?? '')` — normalizeEmailDomain guarantees a lowercased string or null, so the redundant ?. chain is gone. Fix stale RED label in A3 test to describe the canonical-normalization contract it actually asserts. --- .../services/organizations.service.js | 18 +++++------------- .../organizations.service.signup.unit.tests.js | 4 ++-- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/modules/organizations/services/organizations.service.js b/modules/organizations/services/organizations.service.js index 95c199b4c..3eccb9ff0 100644 --- a/modules/organizations/services/organizations.service.js +++ b/modules/organizations/services/organizations.service.js @@ -14,13 +14,6 @@ import { MEMBERSHIP_ROLES } from '../lib/constants.js'; import BillingSignupGrantService from '../../billing/services/billing.signupGrant.service.js'; import { isPublicDomain, normalizeEmailDomain } from './organizations.domain.js'; -/** - * Extract the domain part from an email address. - * @param {string} email - A valid email address. - * @returns {string} The domain portion (e.g. "acme.com"). - */ -const extractDomain = (email) => email.split('@')[1].toLowerCase(); - /** * Derive a human-readable organization name from an email domain. * Strips the TLD and capitalizes the first letter (e.g. "acme.com" → "Acme"). @@ -177,13 +170,13 @@ const handleSignupOrganization = async (user) => { // config.organizations.autoCreate is a deprecated no-op (spec D5): signup always provisions // a workspace regardless of its value. // - // A3: use normalizeEmailDomain (A1) as the single canonical path — lowercased/trimmed, - // null-safe. extractDomain is kept for non-signup callers (exported public API). + // normalizeEmailDomain (A1): single canonical path — lowercased/trimmed, null-safe. + // Returns null on malformed email; the isCorporateDomain Boolean guard below excludes null. const domain = normalizeEmailDomain(user.email); const publicDomains = orgConfig.publicDomains || []; - // Use A1's isPublicDomain for the hardcoded public-provider list, then fall through to - // the config publicDomains list for project-level overrides. - const domainIsPublic = isPublicDomain(domain) || publicDomains.includes(domain?.toLowerCase() ?? ''); + // isPublicDomain covers the hardcoded public-provider list; publicDomains covers project-level overrides. + // domain is either a lowercased non-empty string or null — no defensive ?. needed here. + const domainIsPublic = isPublicDomain(domain) || publicDomains.includes(domain ?? ''); // True when domain-matching is active AND the domain belongs to a corporate (non-public) email. // domain is null only on malformed email (normalizeEmailDomain returns null) — treat as non-corporate. const isCorporateDomain = Boolean(domain) && orgConfig.domainMatching && !domainIsPublic; @@ -236,7 +229,6 @@ const handleSignupOrganization = async (user) => { export default { handleSignupOrganization, - extractDomain, nameFromDomain, generateSlugFromDomain, createOrganizationForUser, diff --git a/modules/organizations/tests/organizations.service.signup.unit.tests.js b/modules/organizations/tests/organizations.service.signup.unit.tests.js index 77000f297..8d6251c74 100644 --- a/modules/organizations/tests/organizations.service.signup.unit.tests.js +++ b/modules/organizations/tests/organizations.service.signup.unit.tests.js @@ -390,8 +390,8 @@ describe('handleSignupOrganization — always-create (spec D5 / A2):', () => { describe('A3 — domain normalization (write + read, exact-match, no subdomain recursion):', () => { test('org created with mixed-case email → domain persisted normalized (lowercase)', async () => { - // RED: current code uses extractDomain which lowercases, but normalizeEmailDomain - // is the canonical path. We assert the create call receives the normalized domain. + // Asserts the canonical-normalization contract: normalizeEmailDomain (A1) is the single + // path, so the org create call must receive a lowercased, trimmed domain. setupConfig({ enabled: true, autoCreate: false, domainMatching: true, publicDomains: [] }); mockOrgList.mockResolvedValue([]); const user = makeUser('dave@ACME.com'); From 13f7b0433fcd47ecc26fcb70cb90724d14edfc11 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 19 May 2026 21:13:58 +0200 Subject: [PATCH 6/9] fix(organizations): idempotent retry-safe signup provisioning (spec C1 / A4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add convergence guard in handleSignupOrganization: if the user already holds an active membership (partial-failure retry scenario), return the existing org+membership without calling createOrganizationForUser — preventing duplicate workspaces and double-crediting signupGrant on retried signups. Uses MembershipRepository.findOne({ userId, status: ACTIVE }) — same canonical read as autoSetCurrentOrganization. Guard fires after the email-verification early-return and before Case 1/Case 2 always-create paths. signupGrant stays once-only because it lives inside createOrganizationForUser, which is skipped. --- .../services/organizations.service.js | 24 +++- ...organizations.service.signup.unit.tests.js | 133 +++++++++++++++++- 2 files changed, 153 insertions(+), 4 deletions(-) diff --git a/modules/organizations/services/organizations.service.js b/modules/organizations/services/organizations.service.js index 3eccb9ff0..f62b26c15 100644 --- a/modules/organizations/services/organizations.service.js +++ b/modules/organizations/services/organizations.service.js @@ -10,7 +10,7 @@ import OrganizationsRepository from '../repositories/organizations.repository.js import MembershipRepository from '../repositories/organizations.membership.repository.js'; import UserService from '../../users/services/users.service.js'; import { slugify, generateOrganizationSlug } from '../helpers/organizations.slug.js'; -import { MEMBERSHIP_ROLES } from '../lib/constants.js'; +import { MEMBERSHIP_ROLES, MEMBERSHIP_STATUSES } from '../lib/constants.js'; import BillingSignupGrantService from '../../billing/services/billing.signupGrant.service.js'; import { isPublicDomain, normalizeEmailDomain } from './organizations.domain.js'; @@ -144,6 +144,28 @@ const handleSignupOrganization = async (user) => { }; } + // A4: Idempotent convergence guard — retry-safe provisioning. + // If the user already has an active membership (e.g. partial failure on a previous signup + // attempt that succeeded at the DB layer but threw before returning), converge to that + // existing workspace WITHOUT calling createOrganizationForUser again. This prevents: + // - Duplicate workspaces on retried signups + // - Double-crediting signupGrant (which lives inside createOrganizationForUser) + // Canonical lookup: MembershipRepository.findOne with userId + ACTIVE status — + // same pattern as autoSetCurrentOrganization and membership service active checks. + // MembershipRepository.defaultPopulate includes organizationId, so membership.organizationId + // is the full org document after the query. + const userId = user.id || user._id; + const existingMembership = await MembershipRepository.findOne({ userId, status: MEMBERSHIP_STATUSES.ACTIVE }); + if (existingMembership) { + const existingOrg = existingMembership.organizationId; + const ability = await policy.defineAbilityFor(user, existingMembership); + return { + organization: existingOrg, + membership: existingMembership, + abilities: serializeAbilities(ability), + }; + } + // Case 1: Organizations disabled — create a silent default org if (!orgConfig.enabled) { const firstName = user.firstName || 'User'; diff --git a/modules/organizations/tests/organizations.service.signup.unit.tests.js b/modules/organizations/tests/organizations.service.signup.unit.tests.js index 8d6251c74..06cdd3720 100644 --- a/modules/organizations/tests/organizations.service.signup.unit.tests.js +++ b/modules/organizations/tests/organizations.service.signup.unit.tests.js @@ -40,12 +40,13 @@ jest.unstable_mockModule('../repositories/organizations.repository.js', () => ({ })); const mockMembershipCreate = jest.fn(); +const mockMembershipFindOne = jest.fn().mockResolvedValue(null); jest.unstable_mockModule('../repositories/organizations.membership.repository.js', () => ({ default: { create: mockMembershipCreate, deleteMany: jest.fn().mockResolvedValue({}), list: jest.fn().mockResolvedValue([]), - findOne: jest.fn().mockResolvedValue(null), + findOne: mockMembershipFindOne, }, })); @@ -60,12 +61,14 @@ jest.unstable_mockModule('../services/organizations.membership.service.js', () = default: { createJoinRequest: mockCreateJoinRequest }, })); +const mockDefineAbilityFor = jest.fn().mockResolvedValue({ rules: [] }); jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({ - default: { defineAbilityFor: jest.fn().mockResolvedValue({ rules: [] }) }, + default: { defineAbilityFor: mockDefineAbilityFor }, })); +const mockSerializeAbilities = jest.fn().mockReturnValue(['ability-stub']); jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({ - default: jest.fn().mockReturnValue(['ability-stub']), + default: mockSerializeAbilities, })); jest.unstable_mockModule('../helpers/organizations.slug.js', () => ({ @@ -160,6 +163,11 @@ describe('handleSignupOrganization — always-create (spec D5 / A2):', () => { mockOrgExists.mockResolvedValue(false); mockUpdateById.mockResolvedValue({}); mockGrantOnSignup.mockResolvedValue(null); + // Default: no active membership exists (fresh user — normal always-create path) + mockMembershipFindOne.mockResolvedValue(null); + // Re-establish policy + abilities mocks wiped by resetAllMocks + mockDefineAbilityFor.mockResolvedValue({ rules: [] }); + mockSerializeAbilities.mockReturnValue(['ability-stub']); }); // ─── Core invariant: always returns a real org + membership ────────────── @@ -460,4 +468,123 @@ describe('handleSignupOrganization — always-create (spec D5 / A2):', () => { expect(result.organization).not.toBeNull(); }); }); + + // ─── A4: idempotent retry-safe convergence ──────────────────────────────── + // + // RED: current code (no guard) always calls createOrganizationForUser even when + // an active membership already exists → duplicate org + double-credit grant. + // GREEN: guard detects existing active membership → converges to it, skips create. + + describe('A4 — idempotent convergence (retry-safe signup provisioning):', () => { + test('user already has active membership → returns existing org+membership, NO new org created, NO grant', async () => { + setupConfig({ enabled: true, autoCreate: false, domainMatching: false }); + const existingOrg = makeFakeOrg({ name: 'Existing Corp', domain: 'acme.com' }); + const existingMembership = { + _id: new mongoose.Types.ObjectId(), + role: 'owner', + status: 'active', + organizationId: existingOrg, + }; + // Simulate: user has an active membership already (retry scenario) + mockMembershipFindOne.mockResolvedValue(existingMembership); + const user = makeUser('alice@acme.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + // Converges to existing workspace + expect(result.organization).toBe(existingOrg); + expect(result.membership).toBe(existingMembership); + // No duplicate org created + expect(mockOrgCreate).not.toHaveBeenCalled(); + // No double-credit on convergence path + expect(mockGrantOnSignup).not.toHaveBeenCalled(); + // No footgun keys + expect(result).not.toHaveProperty('organizationSetupRequired'); + expect(result).not.toHaveProperty('pendingJoin'); + }); + + test('user already has active membership (enabled:false disabled orgs) → converges, no new org', async () => { + setupConfig({ enabled: false }); + const existingOrg = makeFakeOrg({ name: "Alice's organization" }); + const existingMembership = { + _id: new mongoose.Types.ObjectId(), + role: 'owner', + status: 'active', + organizationId: existingOrg, + }; + mockMembershipFindOne.mockResolvedValue(existingMembership); + const user = makeUser('alice@acme.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.organization).toBe(existingOrg); + expect(result.membership).toBe(existingMembership); + expect(mockOrgCreate).not.toHaveBeenCalled(); + expect(mockGrantOnSignup).not.toHaveBeenCalled(); + }); + + test('genuinely new user (no existing membership) → org IS created, grant credited once', async () => { + setupConfig({ enabled: true, autoCreate: false, domainMatching: false }); + // No active membership exists (default from beforeEach) + mockMembershipFindOne.mockResolvedValue(null); + const user = makeUser('bob@acme.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.organization).not.toBeNull(); + expect(result.membership).not.toBeNull(); + expect(mockOrgCreate).toHaveBeenCalledTimes(1); + expect(mockGrantOnSignup).toHaveBeenCalledTimes(1); + }); + + test('converge path returns abilities from policy (same shape as fresh-signup path)', async () => { + setupConfig({ enabled: true, autoCreate: false, domainMatching: false }); + const existingOrg = makeFakeOrg(); + const existingMembership = { _id: new mongoose.Types.ObjectId(), role: 'owner', status: 'active', organizationId: existingOrg }; + mockMembershipFindOne.mockResolvedValue(existingMembership); + const user = makeUser('alice@acme.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + // abilities must be present (same contract as normal signup path) + expect(result.abilities).toBeDefined(); + expect(Array.isArray(result.abilities)).toBe(true); + }); + + test('email-verification early-return precedes idempotent guard (no membership lookup when mailer gates)', async () => { + setupConfig({ enabled: true, autoCreate: false, domainMatching: false }); + mockIsConfigured.mockReturnValue(true); + // Even if an active membership existed, the email-verification guard fires first + const existingMembership = { + _id: new mongoose.Types.ObjectId(), + role: 'owner', + status: 'active', + organizationId: makeFakeOrg(), + }; + mockMembershipFindOne.mockResolvedValue(existingMembership); + const user = makeUser('alice@acme.com'); + user.emailVerified = false; + + const result = await OrganizationsService.handleSignupOrganization(user); + + // Email-verification path: organization null, no org created + expect(result.organization).toBeNull(); + expect(result.emailVerificationRequired).toBe(true); + expect(mockOrgCreate).not.toHaveBeenCalled(); + expect(mockGrantOnSignup).not.toHaveBeenCalled(); + }); + + test('converge path does NOT return suggestedJoin (retry, not fresh corporate signup)', async () => { + setupConfig({ enabled: true, autoCreate: false, domainMatching: true }); + const existingOrg = makeFakeOrg({ domain: 'acme.com' }); + const existingMembership = { _id: new mongoose.Types.ObjectId(), role: 'owner', status: 'active', organizationId: existingOrg }; + mockMembershipFindOne.mockResolvedValue(existingMembership); + const user = makeUser('alice@acme.com'); + + const result = await OrganizationsService.handleSignupOrganization(user); + + expect(result.suggestedJoin).toBeUndefined(); + expect(result.organization).toBe(existingOrg); + }); + }); }); From 7c275b0e140551238896d2c7da5f1c65ea618d96 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 19 May 2026 21:23:32 +0200 Subject: [PATCH 7/9] refactor(organizations): dedupe signup result builder + lock email-verif ordering test --- .../services/organizations.service.js | 29 +++++++------------ ...organizations.service.signup.unit.tests.js | 1 + 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/modules/organizations/services/organizations.service.js b/modules/organizations/services/organizations.service.js index f62b26c15..e6fc62f4c 100644 --- a/modules/organizations/services/organizations.service.js +++ b/modules/organizations/services/organizations.service.js @@ -154,16 +154,19 @@ const handleSignupOrganization = async (user) => { // same pattern as autoSetCurrentOrganization and membership service active checks. // MembershipRepository.defaultPopulate includes organizationId, so membership.organizationId // is the full org document after the query. + // Shared result builder — all three signup exit-paths return the same {organization,membership,abilities} shape. + const buildResult = async (organization, membership) => ({ + organization, + membership, + abilities: serializeAbilities(await policy.defineAbilityFor(user, membership)), + }); + const userId = user.id || user._id; const existingMembership = await MembershipRepository.findOne({ userId, status: MEMBERSHIP_STATUSES.ACTIVE }); if (existingMembership) { + // organizationId is populated (name+slug+_id) via MembershipRepository.findOne defaultPopulate — trusted shape, same contract as autoSetCurrentOrganization const existingOrg = existingMembership.organizationId; - const ability = await policy.defineAbilityFor(user, existingMembership); - return { - organization: existingOrg, - membership: existingMembership, - abilities: serializeAbilities(ability), - }; + return buildResult(existingOrg, existingMembership); } // Case 1: Organizations disabled — create a silent default org @@ -179,13 +182,7 @@ const handleSignupOrganization = async (user) => { user, }); - const ability = await policy.defineAbilityFor(user, membership); - - return { - organization, - membership, - abilities: serializeAbilities(ability), - }; + return buildResult(organization, membership); } // Case 2: Organizations enabled — always provision a workspace for the user. @@ -239,12 +236,8 @@ const handleSignupOrganization = async (user) => { user, }); - const ability = await policy.defineAbilityFor(user, membership); - return { - organization, - membership, - abilities: serializeAbilities(ability), + ...(await buildResult(organization, membership)), ...(suggestedJoin ? { suggestedJoin } : {}), }; }; diff --git a/modules/organizations/tests/organizations.service.signup.unit.tests.js b/modules/organizations/tests/organizations.service.signup.unit.tests.js index 06cdd3720..84618610c 100644 --- a/modules/organizations/tests/organizations.service.signup.unit.tests.js +++ b/modules/organizations/tests/organizations.service.signup.unit.tests.js @@ -570,6 +570,7 @@ describe('handleSignupOrganization — always-create (spec D5 / A2):', () => { // Email-verification path: organization null, no org created expect(result.organization).toBeNull(); expect(result.emailVerificationRequired).toBe(true); + expect(mockMembershipFindOne).not.toHaveBeenCalled(); expect(mockOrgCreate).not.toHaveBeenCalled(); expect(mockGrantOnSignup).not.toHaveBeenCalled(); }); From 7132647f84c402ed9c694d22dace078c8b6d56ca Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 19 May 2026 21:31:22 +0200 Subject: [PATCH 8/9] feat(auth): forward suggestedJoin through signup HTTP response (A2b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Controller now serializes `suggestedJoin: { orgId, orgName } | null` from orgResult into the signup response body alongside the existing keys. `suggestedOrganization` kept as always-null with deprecation comment (additive — breaking removal deferred to next release). TDD: two integration tests (shape assert + null path), auth 1579 / orgs 1469 green. --- modules/auth/controllers/auth.controller.js | 2 ++ ...auth.signup.organization.integration.tests.js | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/modules/auth/controllers/auth.controller.js b/modules/auth/controllers/auth.controller.js index 1e6b96ecc..d8171b0cd 100644 --- a/modules/auth/controllers/auth.controller.js +++ b/modules/auth/controllers/auth.controller.js @@ -144,7 +144,9 @@ const signup = async (req, res) => { abilities: orgResult.abilities || [], organizationSetupRequired: orgResult.organizationSetupRequired || false, emailVerificationRequired: orgResult.emailVerificationRequired || false, + // deprecated: always null since always-create (A2); superseded by suggestedJoin — remove next release suggestedOrganization: orgResult.suggestedOrganization || null, + suggestedJoin: orgResult.suggestedJoin || null, type: 'success', message: 'Sign up', }); diff --git a/modules/auth/tests/auth.signup.organization.integration.tests.js b/modules/auth/tests/auth.signup.organization.integration.tests.js index 0d572a0d1..d50ee4a2c 100644 --- a/modules/auth/tests/auth.signup.organization.integration.tests.js +++ b/modules/auth/tests/auth.signup.organization.integration.tests.js @@ -167,6 +167,19 @@ describe('Auth signup organization integration tests:', () => { // Abilities should be present (with org context — user is owner of their own org) expect(result2.body.abilities).toBeDefined(); expect(result2.body.abilities).toBeInstanceOf(Array); + + // A2b: suggestedJoin must be forwarded from the service through the controller response. + // Shape is name-only { orgId: string, orgName: string } — no extra keys (domain, size, etc.) + expect(result2.body.suggestedJoin).toBeDefined(); + expect(result2.body.suggestedJoin).not.toBeNull(); + expect(typeof result2.body.suggestedJoin.orgId).toBe('string'); + expect(typeof result2.body.suggestedJoin.orgName).toBe('string'); + expect(result2.body.suggestedJoin.orgId).toBe(String(firstOrg._id)); + // name-only: no leaked fields + expect(result2.body.suggestedJoin).not.toHaveProperty('domain'); + expect(result2.body.suggestedJoin).not.toHaveProperty('memberCount'); + expect(result2.body.suggestedJoin).not.toHaveProperty('membership'); + expect(result2.body.suggestedJoin).not.toHaveProperty('plan'); } catch (err) { console.log(err); expect(err).toBeFalsy(); @@ -324,6 +337,9 @@ describe('Auth signup organization integration tests:', () => { expect(result.body).toHaveProperty('tokenExpiresIn'); expect(result.body.type).toBe('success'); expect(result.body.message).toBe('Sign up'); + // A2b: no domain match / orgs disabled → suggestedJoin is present-as-null (not absent) + expect(result.body).toHaveProperty('suggestedJoin'); + expect(result.body.suggestedJoin).toBeNull(); } catch (err) { console.log(err); expect(err).toBeFalsy(); From 4b970e691fa279f476aa30b18954ac048d9a6117 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Tue, 19 May 2026 21:49:48 +0200 Subject: [PATCH 9/9] fix(organizations): address CodeRabbit + Copilot review findings - domain: reject multiple-@ malformed emails in normalizeEmailDomain - service: JSDoc for buildResult @param/@returns (CR+Copilot) - service: update @returns to reflect null org/membership on mailer path - e2e: add explicit suggestedJoin assertion on domain-match signup path - e2e: fix stale A2b comment (now shipped in this PR) - unit: remove stale "RED" test-driven header comment - unit: cover multiple-@ rejection in domain tests --- .../organizations/services/organizations.domain.js | 2 +- .../organizations/services/organizations.service.js | 9 ++++++++- .../tests/organizations.domain.unit.tests.js | 3 +++ .../tests/organizations.domainJoin.e2e.tests.js | 12 +++++++++--- .../tests/organizations.service.signup.unit.tests.js | 2 +- 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/modules/organizations/services/organizations.domain.js b/modules/organizations/services/organizations.domain.js index c8990d60f..8b119de29 100644 --- a/modules/organizations/services/organizations.domain.js +++ b/modules/organizations/services/organizations.domain.js @@ -18,7 +18,7 @@ export const PUBLIC_DOMAINS = new Set([ export function normalizeEmailDomain(email) { if (typeof email !== 'string') return null; const at = email.indexOf('@'); - if (at === -1) return null; + if (at === -1 || at !== email.lastIndexOf('@')) return null; const domain = email.slice(at + 1).trim().toLowerCase(); return domain.length > 0 && domain.includes('.') ? domain : null; } diff --git a/modules/organizations/services/organizations.service.js b/modules/organizations/services/organizations.service.js index e6fc62f4c..7a1271bfa 100644 --- a/modules/organizations/services/organizations.service.js +++ b/modules/organizations/services/organizations.service.js @@ -127,8 +127,9 @@ const createOrganizationForUser = async ({ name, slug, domain, user, slugGenerat * exactly once per real new org — no double-credit possible. * * @param {Object} user - The user object returned by UserService.create (with id, email, firstName, lastName). - * @returns {Promise<{organization: Object, membership: Object, abilities: Array, emailVerificationRequired?: boolean, suggestedJoin?: {orgId: string, orgName: string}}>} + * @returns {Promise<{organization: Object|null, membership: Object|null, abilities: Array, emailVerificationRequired?: boolean, suggestedJoin?: {orgId: string, orgName: string}}>} * An object containing the organization context for the signup response. + * `organization` and `membership` are null when `emailVerificationRequired` is true (mailer path). * NEVER returns `organizationSetupRequired` or `pendingJoin`. */ const handleSignupOrganization = async (user) => { @@ -155,6 +156,12 @@ const handleSignupOrganization = async (user) => { // MembershipRepository.defaultPopulate includes organizationId, so membership.organizationId // is the full org document after the query. // Shared result builder — all three signup exit-paths return the same {organization,membership,abilities} shape. + /** + * Build the signup organization payload with serialized abilities. + * @param {Object} organization - Organization document. + * @param {Object} membership - Membership document. + * @returns {Promise<{organization: Object, membership: Object, abilities: Array}>} + */ const buildResult = async (organization, membership) => ({ organization, membership, diff --git a/modules/organizations/tests/organizations.domain.unit.tests.js b/modules/organizations/tests/organizations.domain.unit.tests.js index 36a8bebc9..7b159d602 100644 --- a/modules/organizations/tests/organizations.domain.unit.tests.js +++ b/modules/organizations/tests/organizations.domain.unit.tests.js @@ -15,6 +15,9 @@ describe('organizations.domain', () => { expect(normalizeEmailDomain('')).toBeNull(); expect(normalizeEmailDomain(null)).toBeNull(); }); + it('returns null for multiple-@ addresses', () => { + expect(normalizeEmailDomain('a@b@acme.com')).toBeNull(); + }); }); describe('isPublicDomain', () => { it('flags common public providers', () => { diff --git a/modules/organizations/tests/organizations.domainJoin.e2e.tests.js b/modules/organizations/tests/organizations.domainJoin.e2e.tests.js index f080ad085..f01b7791f 100644 --- a/modules/organizations/tests/organizations.domainJoin.e2e.tests.js +++ b/modules/organizations/tests/organizations.domainJoin.e2e.tests.js @@ -152,10 +152,16 @@ describe('Organizations domain join E2E tests:', () => { expect(activeMemberships).toHaveLength(1); expect(activeMemberships[0].role).toBe('owner'); - // suggestedJoin hint returned (name-only) pointing to owner's org - // controller still emits suggestedOrganization (always null post-footgun-removal); - // suggestedJoin is wired to the response in a follow-up (A2b). + // suggestedJoin hint (name-only shape) pointing to owner's org — wired in A2b + // controller still emits suggestedOrganization (always null post-footgun-removal) expect(result.body.suggestedOrganization).toBeNull(); + expect(result.body.suggestedJoin).toBeDefined(); + expect(result.body.suggestedJoin).toEqual( + expect.objectContaining({ + orgId: String(org._id), + orgName: org.name, + }), + ); } catch (err) { console.log(err); expect(err).toBeFalsy(); diff --git a/modules/organizations/tests/organizations.service.signup.unit.tests.js b/modules/organizations/tests/organizations.service.signup.unit.tests.js index 84618610c..621a5517a 100644 --- a/modules/organizations/tests/organizations.service.signup.unit.tests.js +++ b/modules/organizations/tests/organizations.service.signup.unit.tests.js @@ -10,7 +10,7 @@ * - signupGrant credited exactly once per real new org creation (via BillingSignupGrantService.grantOnSignup); * not double-credited on any path. * - * RED: written before A2 implementation — current code fails these assertions. + * All assertions pass with the A2 implementation shipped in this PR. */ import mongoose from 'mongoose'; import { jest, describe, test, expect, beforeEach } from '@jest/globals';