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.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..d50ee4a2c 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,15 +157,29 @@ 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); + + // 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(); @@ -215,11 +229,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 +248,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 +267,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 +289,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,14 +328,18 @@ 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'); + // 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(); diff --git a/modules/organizations/services/organizations.domain.js b/modules/organizations/services/organizations.domain.js new file mode 100644 index 000000000..8b119de29 --- /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 || at !== email.lastIndexOf('@')) 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/services/organizations.service.js b/modules/organizations/services/organizations.service.js index 00149e48c..7a1271bfa 100644 --- a/modules/organizations/services/organizations.service.js +++ b/modules/organizations/services/organizations.service.js @@ -8,30 +8,11 @@ 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 { MEMBERSHIP_ROLES, MEMBERSHIP_STATUSES } from '../lib/constants.js'; import BillingSignupGrantService from '../../billing/services/billing.signupGrant.service.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. - * @returns {string} The domain portion (e.g. "acme.com"). - */ -const extractDomain = (email) => email.split('@')[1].toLowerCase(); +import { isPublicDomain, normalizeEmailDomain } from './organizations.domain.js'; /** * Derive a human-readable organization name from an email domain. @@ -132,16 +113,24 @@ 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|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) => { const orgConfig = config.organizations || {}; @@ -152,13 +141,41 @@ const handleSignupOrganization = async (user) => { organization: null, membership: null, abilities: [], - organizationSetupRequired: true, emailVerificationRequired: true, - pendingJoin: false, - suggestedOrganization: null, }; } + // 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. + // 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, + 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; + return buildResult(existingOrg, existingMembership); + } + // Case 1: Organizations disabled — create a silent default org if (!orgConfig.enabled) { const firstName = user.firstName || 'User'; @@ -172,91 +189,68 @@ const handleSignupOrganization = async (user) => { user, }); - const ability = await policy.defineAbilityFor(user, membership); - - return { - organization, - membership, - abilities: serializeAbilities(ability), - organizationSetupRequired: false, - }; + return buildResult(organization, membership); } - // Case 2: Organizations enabled - const domain = extractDomain(user.email); + // 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. + // + // 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 || []; - const isPublic = 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; - // 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, + // 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 (isCorporateDomain) { + 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 (isCorporateDomain) { + 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: isCorporateDomain ? domain : '', + user, + }); return { - organization: null, - membership: null, - abilities: serializeAbilities(ability), - organizationSetupRequired: true, - suggestedOrganization, // null or { _id, name, slug, domain } + ...(await buildResult(organization, membership)), + ...(suggestedJoin ? { suggestedJoin } : {}), }; }; export default { handleSignupOrganization, - extractDomain, nameFromDomain, generateSlugFromDomain, createOrganizationForUser, 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..7b159d602 --- /dev/null +++ b/modules/organizations/tests/organizations.domain.unit.tests.js @@ -0,0 +1,31 @@ +/** + * 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(); + }); + it('returns null for multiple-@ addresses', () => { + expect(normalizeEmailDomain('a@b@acme.com')).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); + }); + }); +}); diff --git a/modules/organizations/tests/organizations.domainJoin.e2e.tests.js b/modules/organizations/tests/organizations.domainJoin.e2e.tests.js index 7bc9286ad..f01b7791f 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,59 @@ 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 (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(); } }); - 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..621a5517a --- /dev/null +++ b/modules/organizations/tests/organizations.service.signup.unit.tests.js @@ -0,0 +1,591 @@ +/** + * 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. + * + * All assertions pass with the A2 implementation shipped in this PR. + */ +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(); +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: mockMembershipFindOne, + }, +})); + +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 }, +})); + +const mockDefineAbilityFor = jest.fn().mockResolvedValue({ rules: [] }); +jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({ + default: { defineAbilityFor: mockDefineAbilityFor }, +})); + +const mockSerializeAbilities = jest.fn().mockReturnValue(['ability-stub']); +jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({ + default: mockSerializeAbilities, +})); + +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(() => { + // 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); + // 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 ────────────── + + 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(); + } + }); + }); + + // ─── 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 () => { + // 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'); + + 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(); + }); + }); + + // ─── 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(mockMembershipFindOne).not.toHaveBeenCalled(); + 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); + }); + }); +});