diff --git a/modules/auth/controllers/auth.controller.js b/modules/auth/controllers/auth.controller.js index bf195cc2f..e1ceb13ef 100644 --- a/modules/auth/controllers/auth.controller.js +++ b/modules/auth/controllers/auth.controller.js @@ -70,10 +70,12 @@ const signup = async (req, res) => { // invited users included; (2) eligibility — public signup open OR a valid // invite token. The eligibility check is supplied by optional modules via the // generic registry (auth never imports invitation code). The invitations - // checker resolves the email-pinned invite, atomically CLAIMS it (the replay - // guard, E2), and RETURNS `{ invite, finalize, release }`, which auth relays - // back here verbatim (opaque result) so this controller can canonicalize the - // account email + finalize/release the invite below. + // checker resolves the email-pinned invite, atomically CLAIMS it when required + // (closed signup) or opted into (open signup + `invitations.userFacing`, #3981; + // a lost claim race there downgrades to unclaimed instead of blocking signup — + // see invitations.init.js), and RETURNS `{ invite, claimed, finalize, release }`, + // which auth relays back here verbatim (opaque result) so this controller can + // canonicalize the account email + finalize/release the invite below. // E4: cap is computed by computeSignupCapacity (single source of truth shared // with getConfig) — a BLANK cap ('') means UNCAPPED. The old inline Number('')→0 // hard-rejected everyone while getConfig advertised the deployment as open. @@ -89,19 +91,33 @@ const signup = async (req, res) => { // positive ceiling that filled up (which DOES reject invites — they count in the cap). const capReached = cap != null && cap > 0 && remaining <= 0; // `signupOpen` tells the checker whether the invite is REQUIRED to open the gate. - // When public signup is open a token may be PRESENTED but is not required — the - // checker must then resolve WITHOUT claiming (E2), so an open-signup signup never - // burns / locks a presented token (preserves the P2 `!config.sign.up` gating). + // When public signup is open a token may be PRESENTED but is not required — by + // default (`invitations.userFacing: false`) the checker resolves WITHOUT claiming + // (E2), so an open-signup signup never burns / locks a presented token (preserves + // the P2 `!config.sign.up` gating). With `userFacing: true` the checker DOES claim + // it (#3981), but open signup's own invariant — a presented token must never be + // able to fail an otherwise-valid signup — still holds: a lost claim race there + // downgrades to unclaimed rather than throwing (see invitations.init.js). const eligibility = await Eligibility.assertSignupEligible({ email: req.body.email, body: req.body, req, signupOpen: !!config.sign.up }); // null when no optional module opened the gate (registry empty or no valid invite). const invite = eligibility?.invite || null; + // #3981: whether the invite needs finalize on success / release on failure is + // decided by `eligibility.claimed` — relayed verbatim from the checker that + // actually called claim() (invitations.init.js), the single source of truth for + // "was this atomically claimed". Reading it here rather than re-deriving the + // closed-signup / userFacing condition a second time from config avoids the two + // sides ever drifting out of lockstep (a duplicated condition could finalize an + // invite that was never claimed, or leave a claimed one stuck) — auth stays + // import-free of invitation code either way, since `claimed` is just a boolean on + // the opaque relayed result, not a call into invitations. + const inviteHonored = !!eligibility?.claimed; if (capReached || (!config.sign.up && !invite)) { - // On the closed-signup path the eligibility checker CLAIMED the invite (E2) - // before the cap was found exhausted — release it so a cap bump later does not - // leave it stuck mid-claim (the lazy sweep would also recover it; this is - // immediate). Only the closed-signup path claims, so gate the release on it to - // avoid a no-op release (+ misleading log) on an open-signup presented token. - if (invite && !config.sign.up) { + // On the closed-signup (or userFacing open-signup) path the eligibility checker + // CLAIMED the invite (E2) before the cap was found exhausted — release it so a + // cap bump later does not leave it stuck mid-claim (the lazy sweep would also + // recover it; this is immediate). Gate the release on `inviteHonored` to avoid a + // no-op release (+ misleading log) on a presented-but-never-claimed token. + if (inviteHonored) { try { await eligibility?.release?.(); } catch (releaseErr) { @@ -150,13 +166,14 @@ const signup = async (req, res) => { // stays locked until the 15-min sweep. The most realistic throw is an E11000 from // the case-insensitive unique-email index (email_ci_unique) when two case-variant signups race the same // invited email (validation/transient errors land here as well). Mirror the three - // release sites below + the same `!config.sign.up` gating (only the closed-signup - // path claimed). Best-effort: a release failure must not mask the create error. + // release sites below + the same `inviteHonored` gating (only a claimed invite — + // closed signup, or userFacing open signup — needs releasing). Best-effort: a + // release failure must not mask the create error. let user; try { user = await UserService.create(safeBody); } catch (createErr) { - if (invite && !config.sign.up) { try { await eligibility?.release?.(); } catch (_releaseErr) { /* best-effort */ } } + if (inviteHonored) { try { await eligibility?.release?.(); } catch (_releaseErr) { /* best-effort */ } } throw createErr; } @@ -185,9 +202,10 @@ const signup = async (req, res) => { } catch (verifyErr) { try { await UserService.remove(user); } catch (_cleanupErr) { /* best-effort */ } // E2: a claimed invite must be released on a pre-response failure so the token - // is reusable (it was only claimed, never finalized). Only the closed-signup - // path claimed, so gate the release on it (open signup never claimed). - if (invite && !config.sign.up) { try { await eligibility?.release?.(); } catch (_releaseErr) { /* best-effort */ } } + // is reusable (it was only claimed, never finalized). Gate the release on + // `inviteHonored` — only a claimed invite (closed signup, or userFacing open + // signup) needs releasing. + if (inviteHonored) { try { await eligibility?.release?.(); } catch (_releaseErr) { /* best-effort */ } } throw verifyErr; } @@ -204,8 +222,8 @@ const signup = async (req, res) => { // Best-effort cleanup; log but don't mask original error } // E2: release the claimed invite on org-provisioning failure so it can retry - // (only the closed-signup path claimed — open signup never did). - if (invite && !config.sign.up) { try { await eligibility?.release?.(); } catch (_releaseErr) { /* best-effort */ } } + // (gate on `inviteHonored` — only a claimed invite needs releasing). + if (inviteHonored) { try { await eligibility?.release?.(); } catch (_releaseErr) { /* best-effort */ } } throw orgErr; } @@ -234,17 +252,19 @@ const signup = async (req, res) => { }); } catch (_) { /* analytics must not break auth */ } - // E2 single-use: FINALIZE only when the invite actually opened the gate (signup - // was closed, so the invite was required). When signup is open, a token can be - // presented but is not required — and the checker never claimed it, so there is - // nothing to finalize. finalize burns single-use (usedAt + status:'accepted') - // and records the user; it runs through the closure returned by the eligibility - // checker (invitations module owns it; auth never imports invitation code). This - // is the last pre-response step, and every earlier failure path (create-throw, - // verify-failure, org-failure) already released the claim, so reaching finalize - // means the claim is still ours to burn. finalize itself is best-effort (see - // catch below). - if (invite && !config.sign.up) { + // E2 single-use: FINALIZE only when the invite was actually CLAIMED — closed + // signup (the invite was required), or open signup with `invitations.userFacing` + // on (#3981: a presented token still converts even though it wasn't required — + // closes the open-signup hole documented in the invitations README). Otherwise a + // token can be presented but is not required, and the checker never claimed it, + // so there is nothing to finalize. finalize burns single-use (usedAt + + // status:'accepted') and records the user; it runs through the closure returned + // by the eligibility checker (invitations module owns it; auth never imports + // invitation code). This is the last pre-response step, and every earlier failure + // path (create-throw, verify-failure, org-failure) already released the claim + // under the same `inviteHonored` condition, so reaching finalize means the claim + // is still ours to burn. finalize itself is best-effort (see catch below). + if (inviteHonored) { try { await eligibility?.finalize?.(user._id || user.id); } catch (finalizeErr) { @@ -776,6 +796,15 @@ const getConfig = async (req, res) => { return config.package?.version || 'dev'; })(), }, + // #3981: same top-level, unauthenticated pattern as `sign` above — the invitations + // module's own README (point 3) documents that its Vue Referrals tab already reads + // `sign.up` from this same endpoint to decide whether the open-signup deployment + // can still convert referrals; `userFacing` is the second half of that gate. Expose + // ONLY this boolean — nothing else from `config.invitations` (rate-limit tuning, + // etc.) is public API surface. + invitations: { + userFacing: !!config.invitations?.userFacing, + }, }; // Authenticated users get extended org config and billing config diff --git a/modules/auth/services/auth.eligibility.js b/modules/auth/services/auth.eligibility.js index e0bfa9365..3caf31d49 100644 --- a/modules/auth/services/auth.eligibility.js +++ b/modules/auth/services/auth.eligibility.js @@ -32,7 +32,9 @@ export const registerSignupEligibility = (fn) => { * Returns the FIRST non-null result collected across all checks (in registration * order), or null when no check returned one. The result is opaque to auth: it is * handed straight back to the caller (e.g. the invitations checker returns - * `{ invite, finalize, release }`, which auth relays without importing any invitation code). + * `{ invite, claimed, finalize, release }` — `claimed` (#3981) tells the caller whether + * THIS check actually claimed the invite, the single source of truth for whether a + * finalize/release is meaningful — which auth relays without importing any invitation code). * * NOTE for future check authors (P5/P8 will register more): a THROW from ANY * check aborts the whole chain and blocks signup — even a check that runs AFTER diff --git a/modules/auth/tests/auth.config.controller.unit.tests.js b/modules/auth/tests/auth.config.controller.unit.tests.js index 4b814aef4..b3ced788c 100644 --- a/modules/auth/tests/auth.config.controller.unit.tests.js +++ b/modules/auth/tests/auth.config.controller.unit.tests.js @@ -154,6 +154,37 @@ describe('auth.controller getConfig:', () => { expect(data.billing.equivalences).toBeNull(); }); + test('data.invitations.userFacing defaults to false, exposed unauthenticated (#3981, config.invitations undefined)', async () => { + // No `invitations` key on mockConfig at all — must default safely, and must be + // present WITHOUT req.user (same top-level, unauthenticated shape as `sign`). + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + + const req = {}; // no req.user + const res = {}; + + await AuthController.getConfig(req, res); + + const [data] = mockResponses.successCb.mock.calls[0]; + expect(data.invitations).toBeDefined(); + expect(data.invitations.userFacing).toBe(false); + }); + + test('data.invitations.userFacing reflects config:true, unauthenticated (#3981)', async () => { + mockConfig.invitations = { userFacing: true }; + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + + const req = {}; // no req.user — must not require auth, unlike billing + const res = {}; + + await AuthController.getConfig(req, res); + + const [data] = mockResponses.successCb.mock.calls[0]; + expect(data.invitations.userFacing).toBe(true); + // ONLY the boolean is exposed — no other invitations config (e.g. rate-limit tuning). + expect(Object.keys(data.invitations)).toEqual(['userFacing']); + }); + test('data.billing.equivalences is returned verbatim when set in config (authenticated)', async () => { const equivalences = { plans: { diff --git a/modules/auth/tests/auth.signup.inviteHonored.unit.tests.js b/modules/auth/tests/auth.signup.inviteHonored.unit.tests.js new file mode 100644 index 000000000..c0ef20057 --- /dev/null +++ b/modules/auth/tests/auth.signup.inviteHonored.unit.tests.js @@ -0,0 +1,325 @@ +/** + * Module dependencies. + */ +import { jest, describe, test, expect } from '@jest/globals'; + +/** + * Unit tests — #3981: `inviteHonored` (auth.controller.signup) gates whether a + * resolved invite is finalized on success / released on failure. auth.controller.js + * trusts `eligibility.claimed` verbatim — a boolean the eligibility checker + * (invitations.init.js, exercised separately in + * invitations/tests/invitations.init.userFacing.unit.tests.js) sets to `true` only + * when it actually atomically CLAIMED the invite: + * - signup CLOSED (the invite was required to open the gate) — always claimed. + * - signup OPEN AND `invitations.userFacing: true` — the #3981 fix: a presented + * token still claims/finalizes on an open-signup deployment. + * Outside those two cases (open signup, `userFacing: false`, the default) the + * checker never claims, so `claimed` is false and a presented-but-unclaimed invite + * must NEVER be finalized/released — asserting this is what proves "today's + * behavior byte-for-byte" is preserved. This file only exercises auth.controller's + * side of that contract (it trusts `claimed`, not a re-derived config condition) — + * no DB, eligibility/UserService/org service all mocked. + */ + +/** + * @desc Mock every auth.controller dependency EXCEPT config/eligibility/UserService.create, + * which are supplied per test so each scenario can vary sign.up / invite / create outcome. + * Must run before the dynamic import of auth.controller.js in each test (jest.resetModules() + * + jest.unstable_mockModule are call-order-sensitive). + * @param {Object} args + * @param {Object} args.config - the mocked config module default export + * @param {Object} [args.eligibility] - the mocked auth.eligibility default export + * @param {Function} [args.create] - UserService.create mock implementation + * @returns {void} + */ +function mockCommonDeps({ config, eligibility, create }) { + jest.resetModules(); + + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { warn: jest.fn(), error: jest.fn(), info: jest.fn() }, + })); + + jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({ + default: { + create: create || jest.fn().mockResolvedValue({ + id: 'u1', email: 'x@y.com', firstName: 'A', lastName: 'B', provider: 'local', + }), + getBrut: jest.fn().mockResolvedValue({ id: 'u1' }), + update: jest.fn().mockResolvedValue({}), + remove: jest.fn(), + count: jest.fn().mockResolvedValue(0), + }, + })); + + jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({ + default: eligibility || { + registerSignupEligibility: jest.fn(), + assertSignupEligible: jest.fn().mockResolvedValue(undefined), + _reset: jest.fn(), + }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({ + default: { + handleSignupOrganization: jest.fn().mockResolvedValue({ + organization: null, joined: false, pendingJoin: false, + abilities: [], organizationSetupRequired: false, + emailVerificationRequired: false, suggestedOrganization: null, + }), + }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.crud.service.js', () => ({ + default: { autoSetCurrentOrganization: jest.fn() }, + })); + + jest.unstable_mockModule('../../../modules/organizations/services/organizations.membership.service.js', () => ({ + default: { findByUserAndOrganization: jest.fn(), listPendingByUser: jest.fn().mockResolvedValue([]) }, + })); + + jest.unstable_mockModule('../../../config/index.js', () => ({ default: config })); + + jest.unstable_mockModule('../../../lib/middlewares/model.js', () => ({ + default: { getResultFromZod: jest.fn(), checkError: jest.fn() }, + })); + + jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ + default: { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn() }, + })); + + jest.unstable_mockModule('../../../lib/helpers/responses.js', () => ({ + default: { + success: jest.fn().mockReturnValue(jest.fn()), + error: jest.fn().mockReturnValue(jest.fn()), + }, + })); + + jest.unstable_mockModule('../../../lib/helpers/errors.js', () => ({ + default: { getMessage: jest.fn().mockReturnValue('error') }, + })); + + jest.unstable_mockModule('../../../lib/helpers/AppError.js', () => ({ + default: class AppError extends Error { + constructor(msg, opts) { + super(msg); + this.status = opts?.status; + this.code = opts?.code; + this.details = opts?.details; + } + }, + })); + + jest.unstable_mockModule('../../../modules/users/models/users.schema.js', () => ({ + default: { User: {}, SignupUser: {} }, + })); + + jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({ + default: { defineAbilityFor: jest.fn().mockResolvedValue({}) }, + })); + + jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({ + default: jest.fn().mockReturnValue([]), + })); + + jest.unstable_mockModule('../../../lib/helpers/getBaseUrl.js', () => ({ + default: jest.fn().mockReturnValue('http://localhost:3000'), + })); + + jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ + default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: jest.fn() }, + })); +} + +/** + * @desc Build a mocked eligibility result carrying a resolved invite, the `claimed` + * flag (#3981 — the single source of truth auth.controller trusts verbatim, set by + * the real checker in invitations.init.js), plus spy-able finalize/release closures. + * @param {Object} [invite] - resolved invite doc; defaults to a valid token-signup invite + * @param {Boolean} [claimed] - whether the checker actually claimed this invite + * @returns {{ eligibility: Object, finalize: jest.Mock, release: jest.Mock }} + */ +function mockEligibilityWithInvite(invite = { id: 'inv1', email: 'x@y.com', invitedBy: 'inviter1' }, claimed = true) { + const finalize = jest.fn().mockResolvedValue({ id: invite.id, status: 'accepted' }); + const release = jest.fn().mockResolvedValue({ id: invite.id }); + const eligibility = { + registerSignupEligibility: jest.fn(), + assertSignupEligible: jest.fn().mockResolvedValue({ invite, claimed, finalize, release }), + _reset: jest.fn(), + }; + return { eligibility, finalize, release }; +} + +const baseConfig = (overrides = {}) => ({ + sign: { up: true, in: true, ...overrides.sign }, + jwt: { secret: 'test-secret', expiresIn: 3600 }, + cookie: { secure: false, sameSite: 'lax' }, + organizations: { enabled: false }, + app: { title: 'Test', contact: 'test@test.com' }, + // Unused by auth.controller's signup() flow (it trusts eligibility.claimed, not this + // flag — see invitations.init.js for where userFacing actually gets consulted); kept + // here only because getConfig() reads it for the exposed `invitations.userFacing` + // boolean, unrelated to the scenarios below. + invitations: { userFacing: false }, +}); + +describe('auth.controller signup: inviteHonored gate (#3981)', () => { + test('closed signup + invite present, claimed:true (checker required it) ⇒ finalized', async () => { + const { eligibility, finalize, release } = mockEligibilityWithInvite(undefined, true); + mockCommonDeps({ config: baseConfig({ sign: { up: false } }), eligibility }); + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + const req = { body: { email: 'x@y.com', firstName: 'A', lastName: 'B', password: 'P@ss1234!' }, query: { inviteToken: 'tok' } }; + const res = { status: jest.fn().mockReturnThis(), cookie: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() }; + + await AuthController.signup(req, res); + + expect(finalize).toHaveBeenCalledWith('u1'); + expect(release).not.toHaveBeenCalled(); + }); + + test('open signup + invite present, claimed:false (userFacing off — the checker never claimed) ⇒ NEVER finalized (today\'s behavior, byte-for-byte)', async () => { + const { eligibility, finalize, release } = mockEligibilityWithInvite(undefined, false); + mockCommonDeps({ config: baseConfig({ sign: { up: true } }), eligibility }); + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + const req = { body: { email: 'x@y.com', firstName: 'A', lastName: 'B', password: 'P@ss1234!' }, query: { inviteToken: 'tok' } }; + const res = { status: jest.fn().mockReturnThis(), cookie: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() }; + + await AuthController.signup(req, res); + + expect(finalize).not.toHaveBeenCalled(); + expect(release).not.toHaveBeenCalled(); + }); + + test('open signup + invite present, claimed:true (userFacing on — the checker DID claim) ⇒ FINALIZED (#3981 fix — the open-signup hole closes)', async () => { + const { eligibility, finalize, release } = mockEligibilityWithInvite(undefined, true); + mockCommonDeps({ config: baseConfig({ sign: { up: true } }), eligibility }); + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + const req = { body: { email: 'x@y.com', firstName: 'A', lastName: 'B', password: 'P@ss1234!' }, query: { inviteToken: 'tok' } }; + const res = { status: jest.fn().mockReturnThis(), cookie: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() }; + + await AuthController.signup(req, res); + + expect(finalize).toHaveBeenCalledWith('u1'); + expect(release).not.toHaveBeenCalled(); + }); + + test('open signup + NO invite (no token presented, or checker resolved nothing) ⇒ plain signup, nothing to finalize', async () => { + mockCommonDeps({ + config: baseConfig({ sign: { up: true } }), + eligibility: { + registerSignupEligibility: jest.fn(), + assertSignupEligible: jest.fn().mockResolvedValue(undefined), // no eligible invite resolved + _reset: jest.fn(), + }, + }); + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + const req = { body: { email: 'plain@y.com', firstName: 'C', lastName: 'D', password: 'P@ss1234!' }, query: {} }; + const res = { status: jest.fn().mockReturnThis(), cookie: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() }; + + await AuthController.signup(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + }); + + test('open signup + invite present, claimed:true + create() throws ⇒ claim is RELEASED', async () => { + const { eligibility, release } = mockEligibilityWithInvite(undefined, true); + const create = jest.fn().mockRejectedValue(new Error('E11000 duplicate key')); + mockCommonDeps({ config: baseConfig({ sign: { up: true } }), eligibility, create }); + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + const req = { body: { email: 'x@y.com', firstName: 'A', lastName: 'B', password: 'P@ss1234!' }, query: { inviteToken: 'tok' } }; + const res = { status: jest.fn().mockReturnThis(), cookie: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() }; + + await AuthController.signup(req, res); + + expect(release).toHaveBeenCalledTimes(1); + }); + + test('open signup + invite present, claimed:false + create() throws ⇒ release is NEVER called (nothing was claimed)', async () => { + const { eligibility, release } = mockEligibilityWithInvite(undefined, false); + const create = jest.fn().mockRejectedValue(new Error('E11000 duplicate key')); + mockCommonDeps({ config: baseConfig({ sign: { up: true } }), eligibility, create }); + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + const req = { body: { email: 'x@y.com', firstName: 'A', lastName: 'B', password: 'P@ss1234!' }, query: { inviteToken: 'tok' } }; + const res = { status: jest.fn().mockReturnThis(), cookie: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() }; + + await AuthController.signup(req, res); + + expect(release).not.toHaveBeenCalled(); + }); + + test('open signup + invite present, claimed:true + email-verification step throws ⇒ claim is RELEASED', async () => { + const { eligibility, release } = mockEligibilityWithInvite(undefined, true); + mockCommonDeps({ config: baseConfig({ sign: { up: true } }), eligibility }); + // Mailer configured ⇒ the verification-token branch runs; make the persist step + // (UserService.update) throw so the outer try/catch's `verifyErr` path fires. + jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({ + default: { + create: jest.fn().mockResolvedValue({ id: 'u1', email: 'x@y.com', firstName: 'A', lastName: 'B', provider: 'local' }), + getBrut: jest.fn().mockResolvedValue({ id: 'u1' }), + update: jest.fn().mockRejectedValue(new Error('DB write failed persisting verification token')), + remove: jest.fn(), + count: jest.fn().mockResolvedValue(0), + }, + })); + jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({ + default: { isConfigured: jest.fn().mockReturnValue(true), sendMail: jest.fn() }, + })); + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + const req = { body: { email: 'x@y.com', firstName: 'A', lastName: 'B', password: 'P@ss1234!' }, query: { inviteToken: 'tok' } }; + const res = { status: jest.fn().mockReturnThis(), cookie: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() }; + + await AuthController.signup(req, res); + + expect(release).toHaveBeenCalledTimes(1); + }); + + test('open signup + invite present, claimed:true + org-provisioning throws ⇒ claim is RELEASED', async () => { + const { eligibility, release } = mockEligibilityWithInvite(undefined, true); + mockCommonDeps({ config: baseConfig({ sign: { up: true } }), eligibility }); + jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({ + default: { + handleSignupOrganization: jest.fn().mockRejectedValue(new Error('org provisioning DB error')), + }, + })); + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + const req = { body: { email: 'x@y.com', firstName: 'A', lastName: 'B', password: 'P@ss1234!' }, query: { inviteToken: 'tok' } }; + const res = { status: jest.fn().mockReturnThis(), cookie: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() }; + + await AuthController.signup(req, res); + + expect(release).toHaveBeenCalledTimes(1); + }); + + test('capacity gate: open signup + invite claimed:true + cap reached ⇒ 404 AND the claim is released', async () => { + const { eligibility, release } = mockEligibilityWithInvite(undefined, true); + mockCommonDeps({ + config: baseConfig({ sign: { up: true, cap: 1 } }), + eligibility, + }); + // Cap already full: UserService.count() resolves 1 (>= cap 1) ⇒ capReached. + jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({ + default: { + create: jest.fn(), + getBrut: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + count: jest.fn().mockResolvedValue(1), + }, + })); + + const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js'); + const req = { body: { email: 'x@y.com', firstName: 'A', lastName: 'B', password: 'P@ss1234!' }, query: { inviteToken: 'tok' } }; + const res = { status: jest.fn().mockReturnThis(), cookie: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() }; + + await AuthController.signup(req, res); + + expect(release).toHaveBeenCalledTimes(1); + }); +}); diff --git a/modules/auth/tests/auth.silent.catch.unit.tests.js b/modules/auth/tests/auth.silent.catch.unit.tests.js index 9b89cf3ff..f06b565de 100644 --- a/modules/auth/tests/auth.silent.catch.unit.tests.js +++ b/modules/auth/tests/auth.silent.catch.unit.tests.js @@ -360,6 +360,9 @@ describe('auth.controller signup analytics: invite/referral attribution (#3945): registerSignupEligibility: jest.fn(), assertSignupEligible: jest.fn().mockResolvedValue({ invite: { id: 'inv1', email: 'invitee@y.com', invitedBy: 'inviter1' }, + // closed signup ⇒ the checker claimed it (#3981: auth.controller trusts this + // flag verbatim rather than re-deriving it from config). + claimed: true, finalize: jest.fn().mockResolvedValue({ id: 'inv1', status: 'accepted' }), release: jest.fn(), }), diff --git a/modules/invitations/README.md b/modules/invitations/README.md index 4a944bd6b..c79e0410f 100644 --- a/modules/invitations/README.md +++ b/modules/invitations/README.md @@ -160,14 +160,17 @@ and hard to cap/expire/audit ("when was this credited?"). Good for simple boosts same-account pairs only. **Alias/variant self-invites (a second personal email → a separate account) are NOT prevented** — accepted residual risk; revisit (fraud review / email-normalization dedup) before any paid-rewards launch. -3. **Open-signup hole — DOCUMENTED, intentionally NOT changed**: claim/finalize stay - gated on `!config.sign.up` (the "open signup never burns a token" invariant). - **Referral rewards therefore require `sign.up: false`.** On an open-signup - deployment a presented token is *resolved* but never *claimed/finalized*, so - `invitation.accepted` never fires and no grant occurs — enabling - `billing.referral` there is a silent no-op. The Vue Referrals tab reads - `GET /api/auth/config` (`sign.up`) and replaces the invite form with an - informational state when signup is open (the referrals list stays read-only). +3. **Open-signup hole — CLOSED behind `invitations.userFacing` (#3981)**: claim/finalize + are gated on `!config.sign.up`, EXCEPT when `config.invitations.userFacing` is `true` + — then a presented, valid (email-pinned, unexpired, single-use) token still claims + and finalizes even on an open-signup deployment, so `invitation.accepted` + + `invitation_redeemed` + the referral grant fire exactly as on closed signup. With + `userFacing: false` (the default) the original invariant holds unchanged: a + presented token on open signup is *resolved* but never *claimed/finalized*, so + enabling `billing.referral` there is still a silent no-op. `GET /api/auth/config` + now exposes `invitations.userFacing` (same top-level, unauthenticated shape as + `sign.up`) so a consumer can tell the two open-signup states apart and gate the + Referrals tab's invite-form-vs-informational-state accordingly. 4. **Index `referredBy` — SHIPPED (#3945)**: `{ referredBy: 1 }` on the `User` model, added alongside the CASL widening above (the account Referrals view is the first real referral query). diff --git a/modules/invitations/config/invitations.development.config.js b/modules/invitations/config/invitations.development.config.js index 81c5d24d2..3d1f0b9b6 100644 --- a/modules/invitations/config/invitations.development.config.js +++ b/modules/invitations/config/invitations.development.config.js @@ -19,8 +19,14 @@ const config = { * link) and `read` (scoped server-side to invitations THEY sent — see * InvitationsService.list(), #3833); platform admins keep `manage all` * regardless. Pair with `billing.referral.enabled` (billing.development.config.js) - * to actually reward accepted referrals — this flag only controls WHO can see/ - * create invitations, not whether a reward is granted. + * to actually reward accepted referrals. + * + * #3981: ALSO controls whether the signup flow claims/finalizes a presented + * invite token while public signup is OPEN (`config.sign.up: true`) — the + * open-signup hole documented in this module's README point 3. With signup + * CLOSED, a valid invite always claims/finalizes regardless of this flag (it is + * what opens the gate). Exposed read-only via `GET /api/auth/config` + * (`invitations.userFacing`) so the frontend can gate referral UI on it. */ userFacing: false, }, diff --git a/modules/invitations/invitations.init.js b/modules/invitations/invitations.init.js index 643ea5e3b..2d8528d32 100644 --- a/modules/invitations/invitations.init.js +++ b/modules/invitations/invitations.init.js @@ -5,6 +5,7 @@ import { registerSignupEligibility } from '../auth/services/auth.eligibility.js' import InvitationsService from './services/invitations.service.js'; import invitationEvents from './lib/events.js'; import logger from '../../lib/services/logger.js'; +import config from '../../config/index.js'; /** * Invitations module initialisation. @@ -48,13 +49,26 @@ export default async () => { // Returns undefined (no result) when no eligible invite — auth then sees null. registerSignupEligibility(async (ctx = {}) => { let invite = null; + // Whether THIS checker atomically claimed the invite (local path only — OAuth never + // claims, see below). Relayed back as `claimed` (#3981) so auth.controller gates + // finalize/release on the checker's own answer instead of re-deriving the same + // closed-signup / userFacing condition a second time from config: this module is the + // ONLY code that calls `claim()`, so it is the single source of truth for whether a + // finalize/release is meaningful — duplicating the condition on the auth side would + // risk drifting out of lockstep and either finalizing an invite that was never + // claimed (`InvitationRepository.finalize` does not require `consumingAt`, so it + // would silently accept an unclaimed token) or leaving a claimed one stuck. + let claimed = false; if (ctx.oauth) { // E7: honor an OAuth invite only when the provider verified the email. if (ctx.oauth.emailVerifiedByProvider) { invite = await InvitationsService.assertInvitedByEmail({ email: ctx.email }); } // OAuth has no token to claim; the consumingAt exclusion on findValidByEmail - // already hides a claimed-but-unfinalized invite. No two-phase claim here. + // already hides a claimed-but-unfinalized invite. No two-phase claim here — + // `claimed` stays false, but auth's OAuth controller path (checkOAuthUserProfile) + // finalizes unconditionally on a resolved OAuth invite regardless (unaffected by + // #3981 — OAuth's eligibility check only ever runs under closed signup today). } else { const carrier = ctx.req; if (!carrier) return undefined; @@ -66,27 +80,56 @@ export default async () => { const token = carrier.query?.inviteToken ?? carrier.body?.inviteToken; // E5: "no email supplied with a token ⇒ no eligibility" lives in assertInvited. invite = await InvitationsService.assertInvited({ token, email: ctx.email }); - // E2: atomically CLAIM the resolved invite BEFORE the user is created — BUT ONLY - // when the invite is REQUIRED to open the gate (closed signup). When public - // signup is open the token is presented but not required, so we resolve WITHOUT - // claiming: auth won't finalize it either (it gates finalize behind !sign.up), so - // claiming would only lock the token mid-claim for no reason (preserves P2 gating). - // A replay / concurrent accept on the closed-signup path races here and loses - // (claim throws 422). assertInvited already enforced the email pin (E5); the claim - // filters token+pending+unclaimed. if (invite && !ctx.signupOpen) { + // E2: closed signup — the invite is REQUIRED to open the gate, so atomically + // CLAIM it BEFORE the user is created. A replay / concurrent accept races here + // and loses: claim() throws AppError(422), which propagates out of this checker + // and blocks signup entirely — correct here, because without the invite this + // signup was never eligible in the first place (the throw IS the eligibility + // decision on this path). await InvitationsService.claim(token); // throws AppError(422) if not claimable + claimed = true; + } else if (invite && config.invitations?.userFacing) { + // #3981: public signup is OPEN, but userFacing opts a deployment INTO honoring a + // presented token anyway (the open-signup hole documented in this module's + // README point 3 — a presented token used to resolve but never claim/finalize + // while signup was open, so the referral loop could never convert on open-signup + // deployments). CRITICAL DIFFERENCE from the closed-signup branch above: the + // invite is a BONUS here, never required — open signup's own invariant is that a + // presented token must NEVER be able to block or fail an otherwise-valid signup. + // So a lost claim race (two near-simultaneous submits of the same invite link — + // plausible: a double-click or a client retry) must NOT propagate; it must + // downgrade to "unclaimed" and let signup proceed as if the token had merely been + // presented-but-not-required (pre-#3981 behavior for this exact case). `invite` + // stays resolved (harmless — the email-pin downstream is already a no-op, since + // assertInvited required the submitted email to match it), but `claimed` stays + // false, so auth.controller's `eligibility.claimed` gate correctly skips + // finalize/release for a token this checker never actually got to burn. + try { + await InvitationsService.claim(token); + claimed = true; + } catch (claimErr) { + logger.warn('[invitations] userFacing open-signup claim lost a race or the invite was already consumed — proceeding as a plain (unattributed) signup', { + message: claimErr?.message, + }); + } } + // Outside both branches (open signup, userFacing off) the token is presented but + // not required, so we resolve WITHOUT claiming: `claimed` stays false, so auth + // won't finalize it either — claiming would only lock the token mid-claim for no + // reason (preserves the original P2 gating). assertInvited already enforced the + // email pin (E5); the claim filters token+pending+unclaimed+unexpired. } if (!invite) return undefined; - // Return the resolved (+claimed, local) invite plus finalize/release closures - // bound to it. The accept/release logic stays in this module; auth just relays. - // P8a: `finalize` now routes through InvitationsService.accept, which finalizes - // the invite AND wires the referral substrate (#3842) — stamps referredBy on the new - // user (server-side) + emits `invitation.accepted`. The closure name stays - // `finalize` so auth.controller relays it unchanged (auth never imports us); accept - // is a superset of finalize. Fires on BOTH the token AND the OAuth path (both go - // through this same closure), so OAuth-invited users are credited too. + // Return the resolved (+claimed, local) invite plus `claimed` + finalize/release + // closures bound to it. The accept/release logic stays in this module; auth just + // relays. P8a: `finalize` now routes through InvitationsService.accept, which + // finalizes the invite AND wires the referral substrate (#3842) — stamps + // referredBy on the new user (server-side) + emits `invitation.accepted`. The + // closure name stays `finalize` so auth.controller relays it unchanged (auth + // never imports us); accept is a superset of finalize. Fires on BOTH the token + // AND the OAuth path (both go through this same closure), so OAuth-invited users + // are credited too. /** * @desc Finalize accepted invite and run referral side-effects (P8a). @@ -98,6 +141,7 @@ export default async () => { return { invite, + claimed, finalize: finalizeInvite, release: () => InvitationsService.release(invite.id), }; diff --git a/modules/invitations/tests/invitations.init.userFacing.unit.tests.js b/modules/invitations/tests/invitations.init.userFacing.unit.tests.js new file mode 100644 index 000000000..0a4ffb1ab --- /dev/null +++ b/modules/invitations/tests/invitations.init.userFacing.unit.tests.js @@ -0,0 +1,121 @@ +import { jest } from '@jest/globals'; + +/** + * #3981 — the local-signup checker in invitations.init.js must atomically CLAIM a + * resolved invite not only when signup is closed (the invite was required) but ALSO + * when signup is OPEN and `config.invitations.userFacing` is true (the open-signup + * hole: a presented token should still convert on a userFacing deployment). Config is + * mocked here (unlike the sibling invitations.init.unit.tests.js, which relies on the + * real config's `userFacing: false` default) so both flag states can be asserted. + */ + +const mockService = { + assertInvited: jest.fn(), + assertInvitedByEmail: jest.fn(), + claim: jest.fn(), + finalize: jest.fn(), + accept: jest.fn(), + release: jest.fn(), + sweepStaleClaims: jest.fn(), +}; +const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn() }; +const mockConfig = { invitations: { userFacing: false } }; + +jest.unstable_mockModule('../services/invitations.service.js', () => ({ default: mockService })); +jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ default: mockLogger })); +jest.unstable_mockModule('../../../config/index.js', () => ({ default: mockConfig })); + +const Eligibility = (await import('../../auth/services/auth.eligibility.js')).default; +const init = (await import('../invitations.init.js')).default; + +beforeEach(async () => { + jest.clearAllMocks(); + Eligibility._reset(); + mockConfig.invitations.userFacing = false; + mockService.sweepStaleClaims.mockResolvedValue(undefined); + mockService.claim.mockResolvedValue({ id: 'claimed' }); + await init(); +}); + +describe('invitations.init — userFacing open-signup claim (#3981)', () => { + test('open signup + userFacing:false (default) ⇒ still NOT claimed (unchanged from pre-#3981), result.claimed is false', async () => { + mockService.assertInvited.mockResolvedValue({ id: 'i1', email: 'a@b.co' }); + const req = { query: { inviteToken: 'tok' }, body: { email: 'a@b.co' } }; + const result = await Eligibility.assertSignupEligible({ email: 'a@b.co', body: req.body, req, signupOpen: true }); + expect(mockService.claim).not.toHaveBeenCalled(); + expect(result.invite).toEqual({ id: 'i1', email: 'a@b.co' }); + expect(result.claimed).toBe(false); + }); + + test('open signup + userFacing:true ⇒ CLAIMED (#3981 fix), result.claimed is true (auth.controller trusts this, not config)', async () => { + mockConfig.invitations.userFacing = true; + mockService.assertInvited.mockResolvedValue({ id: 'i2', email: 'a@b.co' }); + const req = { query: { inviteToken: 'tok' }, body: { email: 'a@b.co' } }; + const result = await Eligibility.assertSignupEligible({ email: 'a@b.co', body: req.body, req, signupOpen: true }); + expect(mockService.claim).toHaveBeenCalledWith('tok'); + expect(result.invite).toEqual({ id: 'i2', email: 'a@b.co' }); + expect(result.claimed).toBe(true); + }); + + test('closed signup + userFacing:true ⇒ still CLAIMED (flag is a no-op when signup is closed, invite already required)', async () => { + mockConfig.invitations.userFacing = true; + mockService.assertInvited.mockResolvedValue({ id: 'i3', email: 'a@b.co' }); + const req = { query: { inviteToken: 'tok' }, body: { email: 'a@b.co' } }; + const result = await Eligibility.assertSignupEligible({ email: 'a@b.co', body: req.body, req, signupOpen: false }); + expect(mockService.claim).toHaveBeenCalledWith('tok'); + expect(result.claimed).toBe(true); + }); + + test('open signup + userFacing:true + no invite resolved (no/invalid token) ⇒ no claim, nothing relayed', async () => { + mockConfig.invitations.userFacing = true; + mockService.assertInvited.mockResolvedValue(null); + const req = { query: {}, body: { email: 'a@b.co' } }; + const result = await Eligibility.assertSignupEligible({ email: 'a@b.co', body: req.body, req, signupOpen: true }); + expect(mockService.claim).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + test('open signup + userFacing:true ⇒ claimed invite still relays finalize()/release() closures routed through accept()/release()', async () => { + mockConfig.invitations.userFacing = true; + const invite = { id: 'i4', email: 'a@b.co' }; + mockService.assertInvited.mockResolvedValue(invite); + const req = { query: { inviteToken: 'tok' }, body: { email: 'a@b.co' } }; + const result = await Eligibility.assertSignupEligible({ email: 'a@b.co', body: req.body, req, signupOpen: true }); + await result.finalize('u1'); + expect(mockService.accept).toHaveBeenCalledWith(invite, 'u1'); + await result.release(); + expect(mockService.release).toHaveBeenCalledWith('i4'); + }); + + test('open signup + userFacing:true + claim() loses a race (AppError 422) ⇒ downgrades to unclaimed, does NOT throw/block signup', async () => { + // Pre-push review finding: open signup's own invariant is that a presented token + // must NEVER be able to fail an otherwise-valid signup. A double-submit / client + // retry on the same invite link is realistic; the loser of the claim CAS must + // proceed as a plain (unattributed) signup, not propagate the 422. + mockConfig.invitations.userFacing = true; + const invite = { id: 'i5', email: 'a@b.co' }; + mockService.assertInvited.mockResolvedValue(invite); + mockService.claim.mockRejectedValue(Object.assign(new Error('invitation is no longer valid'), { status: 422, code: 'VALIDATION_ERROR' })); + const req = { query: { inviteToken: 'tok' }, body: { email: 'a@b.co' } }; + + const result = await Eligibility.assertSignupEligible({ email: 'a@b.co', body: req.body, req, signupOpen: true }); + + expect(mockService.claim).toHaveBeenCalledWith('tok'); + expect(result.invite).toEqual(invite); // still resolved — email-pin behavior downstream is a no-op anyway + expect(result.claimed).toBe(false); // NOT claimed — auth.controller will skip finalize/release + expect(mockLogger.warn).toHaveBeenCalled(); // the race is surfaced, not silently dropped + }); + + test('closed signup + claim() loses a race (AppError 422) ⇒ STILL throws/blocks signup (unaffected — the invite was required here)', async () => { + // Regression guard: the #3981 downgrade-on-race fix must be scoped to the + // open-signup + userFacing branch only. Closed signup's replay guard (a lost + // claim race legitimately blocks signup, since the invite was the only thing + // that opened the gate) must keep throwing exactly as before. + mockService.assertInvited.mockResolvedValue({ id: 'i6', email: 'a@b.co' }); + mockService.claim.mockRejectedValue(Object.assign(new Error('invitation is no longer valid'), { status: 422, code: 'VALIDATION_ERROR' })); + const req = { query: { inviteToken: 'tok' }, body: { email: 'a@b.co' } }; + + await expect(Eligibility.assertSignupEligible({ email: 'a@b.co', body: req.body, req, signupOpen: false })) + .rejects.toMatchObject({ status: 422 }); + }); +}); diff --git a/modules/invitations/tests/invitations.integration.tests.js b/modules/invitations/tests/invitations.integration.tests.js index 6f66e59e8..1c2f795fc 100644 --- a/modules/invitations/tests/invitations.integration.tests.js +++ b/modules/invitations/tests/invitations.integration.tests.js @@ -910,4 +910,218 @@ describe('Signup invitations:', () => { expect(res.status).toBe(200); }); }); + + describe('Open-signup userFacing claim/finalize (#3981)', () => { + let invitationEvents; + let InvitationService; + let originalUp; let originalCap; let originalUserFacing; + + beforeAll(async () => { + invitationEvents = (await import(path.resolve('./modules/invitations/lib/events.js'))).default; + InvitationService = (await import(path.resolve('./modules/invitations/services/invitations.service.js'))).default; + }); + + beforeEach(() => { + originalUp = config.sign.up; + originalCap = config.sign.cap; + originalUserFacing = config.invitations.userFacing; + }); + afterEach(async () => { + config.sign.up = originalUp; + config.sign.cap = originalCap; + config.invitations.userFacing = originalUserFacing; + jest.restoreAllMocks(); + for (const email of [ + '3981-open-userfacing@example.com', + '3981-open-userfacing-replay@example.com', + '3981-open-nofacing@example.com', + '3981-open-mismatch@example.com', + '3981-open-mismatch-plain@example.com', + '3981-open-notoken@example.com', + '3981-closed-userfacing@example.com', + '3981-open-race@example.com', + ]) { + try { + const existing = await UserService.getBrut({ email }); + if (existing) await UserService.remove(existing); + } catch (_) { /* cleanup */ } + } + }); + + test('userFacing:true + OPEN signup + valid matching-email token ⇒ CLAIMED + FINALIZED (referredBy set, invitation.accepted fires — the #3981 fix)', async () => { + const adminAgent = await createAdminAndSignin(); + const email = '3981-open-userfacing@example.com'; + const created = await adminAgent.post('/api/invitations').send({ email }); + const { token } = created.body.data; + const admin = await UserService.getBrut({ email: 'inv-admin@test.com' }); + const inviterId = String(admin._id); + + config.invitations.userFacing = true; + config.sign.up = true; config.sign.cap = null; + + const emitSpy = jest.spyOn(invitationEvents, 'emit'); + + const res = await request(app) + .post(`/api/auth/signup?inviteToken=${token}`) + .send({ email, password: 'Sup3rStr0ng!' }); + expect(res.status).toBe(200); + + // The invite was CONSUMED (unlike the userFacing:false open-signup case below) — + // single-use is enforced exactly as on closed signup. + const verify = await request(app).get(`/api/invitations/verify/${token}`); + expect(verify.body.data.valid).toBe(false); + + const brut = await UserService.getBrut({ email }); + expect(String(brut.referredBy)).toBe(inviterId); + + const acceptedCall = emitSpy.mock.calls.find(([evt]) => evt === 'invitation.accepted'); + expect(acceptedCall).toBeDefined(); + expect(acceptedCall[1].invitationId).toBe(created.body.data.id); + expect(String(acceptedCall[1].invitedBy)).toBe(inviterId); + expect(String(acceptedCall[1].acceptedUserId)).toBe(String(brut._id)); + }); + + test('userFacing:true + OPEN signup: single-use still enforced — a replay of the same token is rejected', async () => { + const adminAgent = await createAdminAndSignin(); + const email = '3981-open-userfacing-replay@example.com'; + const created = await adminAgent.post('/api/invitations').send({ email }); + const { token } = created.body.data; + + config.invitations.userFacing = true; + config.sign.up = true; config.sign.cap = null; + + const first = await request(app) + .post(`/api/auth/signup?inviteToken=${token}`) + .send({ email, password: 'Sup3rStr0ng!' }); + expect(first.status).toBe(200); + + // Same token again, different email this time (open signup does not require the + // invite, so a plain signup would otherwise succeed) — the invite itself must be + // dead (accepted), proving it was truly burned, not merely ignored. + const verify = await request(app).get(`/api/invitations/verify/${token}`); + expect(verify.body.data.valid).toBe(false); + }); + + test('userFacing:true + OPEN signup: a lost claim race (double-submit) does NOT hard-fail the signup — it downgrades to a plain signup', async () => { + // Pre-push review finding: open signup's own invariant is that a presented token + // must never be able to BLOCK an otherwise-valid signup — that must hold even when + // userFacing tries to claim it. Force the underlying claim() to reject once + // (simulating a lost CAS race from a concurrent double-submit) and assert the + // signup still succeeds with 200, not a 422. + const adminAgent = await createAdminAndSignin(); + const email = '3981-open-race@example.com'; + const created = await adminAgent.post('/api/invitations').send({ email }); + const { token } = created.body.data; + + config.invitations.userFacing = true; + config.sign.up = true; config.sign.cap = null; + + const claimSpy = jest.spyOn(InvitationService, 'claim').mockRejectedValueOnce( + Object.assign(new Error('invitation is no longer valid'), { status: 422, code: 'VALIDATION_ERROR' }), + ); + + const res = await request(app) + .post(`/api/auth/signup?inviteToken=${token}`) + .send({ email, password: 'Sup3rStr0ng!' }); + + expect(claimSpy).toHaveBeenCalledTimes(1); + expect(res.status).toBe(200); // NOT 422 — the race must not block open signup + + // Downgraded to unclaimed: no attribution, and the invite is untouched (neither + // consumed nor stuck mid-claim) — reusable for whoever actually holds it next. + const brut = await UserService.getBrut({ email }); + expect(brut.referredBy == null).toBe(true); + const verify = await request(app).get(`/api/invitations/verify/${token}`); + expect(verify.body.data.valid).toBe(true); + }); + + test('userFacing:false (default) + OPEN signup + valid token ⇒ still NOT claimed/burned (unchanged baseline, byte-for-byte)', async () => { + const adminAgent = await createAdminAndSignin(); + const email = '3981-open-nofacing@example.com'; + const created = await adminAgent.post('/api/invitations').send({ email }); + const { token } = created.body.data; + + config.invitations.userFacing = false; + config.sign.up = true; config.sign.cap = null; + + const res = await request(app) + .post(`/api/auth/signup?inviteToken=${token}`) + .send({ email, password: 'Sup3rStr0ng!' }); + expect(res.status).toBe(200); + + // The invite must remain VALID — presented, never consumed. + const verify = await request(app).get(`/api/invitations/verify/${token}`); + expect(verify.body.data.valid).toBe(true); + + const brut = await UserService.getBrut({ email }); + expect(brut.referredBy == null).toBe(true); // no attribution invented + }); + + test('userFacing:true + OPEN signup + token presented but email MISMATCH ⇒ invite never resolves, falls back to a plain signup (no attribution invented)', async () => { + const adminAgent = await createAdminAndSignin(); + const pinnedEmail = '3981-open-mismatch@example.com'; + const created = await adminAgent.post('/api/invitations').send({ email: pinnedEmail }); + const { token } = created.body.data; + + config.invitations.userFacing = true; + config.sign.up = true; config.sign.cap = null; + + const submittedEmail = '3981-open-mismatch-plain@example.com'; + const res = await request(app) + .post(`/api/auth/signup?inviteToken=${token}`) + .send({ email: submittedEmail, password: 'Sup3rStr0ng!' }); + // Open signup never requires the invite, so a mismatched token does not block + // signup — it just never opens the referral gate. + expect(res.status).toBe(200); + expect(res.body.user.email).toBe(submittedEmail); // NOT canonicalized to pinnedEmail + + const brut = await UserService.getBrut({ email: submittedEmail }); + expect(brut.referredBy == null).toBe(true); + + // The pinned invite is untouched — still valid, awaiting its actual invitee. + const verify = await request(app).get(`/api/invitations/verify/${token}`); + expect(verify.body.data.valid).toBe(true); + }); + + test('userFacing:true + OPEN signup + NO token ⇒ plain signup, no attribution invented', async () => { + config.invitations.userFacing = true; + config.sign.up = true; config.sign.cap = null; + + const email = '3981-open-notoken@example.com'; + const res = await request(app).post('/api/auth/signup').send({ email, password: 'Sup3rStr0ng!' }); + expect(res.status).toBe(200); + + const brut = await UserService.getBrut({ email }); + expect(brut.referredBy == null).toBe(true); + }); + + test('userFacing:true + CLOSED signup + valid token ⇒ unaffected — claims/finalizes exactly as before #3981', async () => { + const adminAgent = await createAdminAndSignin(); + const email = '3981-closed-userfacing@example.com'; + const created = await adminAgent.post('/api/invitations').send({ email }); + const { token } = created.body.data; + + config.invitations.userFacing = true; + config.sign.up = false; config.sign.cap = null; + + const res = await request(app) + .post(`/api/auth/signup?inviteToken=${token}`) + .send({ email, password: 'Sup3rStr0ng!' }); + expect(res.status).toBe(200); + + const verify = await request(app).get(`/api/invitations/verify/${token}`); + expect(verify.body.data.valid).toBe(false); + }); + + test('GET /api/auth/config exposes invitations.userFacing (unauthenticated, mirrors sign.up)', async () => { + config.invitations.userFacing = true; + const on = await request(app).get('/api/auth/config'); + expect(on.status).toBe(200); + expect(on.body.data.invitations.userFacing).toBe(true); + + config.invitations.userFacing = false; + const off = await request(app).get('/api/auth/config'); + expect(off.body.data.invitations.userFacing).toBe(false); + }); + }); });