diff --git a/modules/invitations/config/invitations.development.config.js b/modules/invitations/config/invitations.development.config.js index 3d1f0b9b6..b56649a50 100644 --- a/modules/invitations/config/invitations.development.config.js +++ b/modules/invitations/config/invitations.development.config.js @@ -29,6 +29,21 @@ const config = { * (`invitations.userFacing`) so the frontend can gate referral UI on it. */ userFacing: false, + /** + * Lifetime cap per inviter (#3986) — stack default OFF (absent/null = disabled, + * no behavior change). DB-backed: InvitationsService.create() counts ALL + * invitations (any status) already sent by the same `invitedBy` and rejects + * (422) once the count reaches this value. Bounds cumulative volume — the + * exposure that matters when `billing.referral` rewards are enabled (credit + * farming via fake referrer/referee pairs) — as a complement to + * `rateLimit.invitationsCreate` (bounds burst rate, not lifetime volume). + * Downstream consumers opt in with a number in their config layer, e.g.: + * invitations: { maxLifetime: 50 } + * NOTE: `0` is a valid (if unusual) opt-in, not an alias for "disabled" — it + * rejects every invitation immediately (count >= 0 is always true). Use + * `null`/omit the key to disable the cap. + */ + maxLifetime: null, }, // #3945: POST /api/invitations already had no rate limiter under admin-only access // (a trusted caller); with `invitations.userFacing` able to widen `create` to any diff --git a/modules/invitations/repositories/invitations.repository.js b/modules/invitations/repositories/invitations.repository.js index dc996d294..8dc64ab31 100644 --- a/modules/invitations/repositories/invitations.repository.js +++ b/modules/invitations/repositories/invitations.repository.js @@ -33,6 +33,17 @@ const findByEmail = (email) => .sort('-createdAt') .exec(); +/** + * @desc #3986 — count ALL invitations ever created by a given inviter, across every + * status (pending, accepted, revoked, expired). Unlike findByEmail (pending-only, + * gate-opening), this is the cumulative-volume signal for the maxLifetime cap: a + * status filter here would let the cap reset by letting invites expire or revoking + * them, defeating the guard. + * @param {String} invitedBy - the inviter's user id + * @returns {Promise} + */ +const countByInvitedBy = (invitedBy) => Invitation.countDocuments({ invitedBy }).exec(); + /** * @desc E2 — atomically CLAIM a pending, unclaimed invite by token. Stamps * `consumingAt` so a concurrent claim (or the email-resolved path) sees it as @@ -165,4 +176,4 @@ const revoke = (id) => { returnDocument: 'after' }, ).exec(); -export default { create, findByToken, findByEmail, claim, finalize, release, releaseStaleClaims, list, findAccepted, findByAcceptedUserId, get, revoke }; +export default { create, findByToken, findByEmail, countByInvitedBy, claim, finalize, release, releaseStaleClaims, list, findAccepted, findByAcceptedUserId, get, revoke }; diff --git a/modules/invitations/services/invitations.service.js b/modules/invitations/services/invitations.service.js index 014d54b52..d916d354d 100644 --- a/modules/invitations/services/invitations.service.js +++ b/modules/invitations/services/invitations.service.js @@ -41,7 +41,7 @@ const inviteMailPayload = (invitation) => ({ * @param {String} email - invitee email (any case) * @param {Object} invitedBy - the admin user creating the invite * @returns {Promise} created invitation - * @throws {AppError} 409 when a pending invitation already exists for the email; 422 when the invitee email is the inviter's own, or a user already exists for this email + * @throws {AppError} 409 when a pending invitation already exists for the email; 422 when the invitee email is the inviter's own, a user already exists for this email, or the inviter has reached `config.invitations.maxLifetime` (if set) */ const create = async (email, invitedBy) => { const normalizedEmail = String(email).toLowerCase().trim(); @@ -61,6 +61,31 @@ const create = async (email, invitedBy) => { details: { message: 'You cannot invite yourself.' }, }); } + // #3986: config-gated lifetime cap per inviter. Absent/null = disabled (stack default, + // mirrors billing.referral) — no behavior change until a downstream consumer opts in + // with a number. DB-backed, not a rate window: counts ALL invitations ever created by + // this inviter (any status), so the cap cannot be reset by letting invites expire or + // revoking them. Bounds cumulative volume — the exposure that matters when + // billing.referral rewards are enabled (credit farming via fake referrer/referee + // pairs) — as a complement to rateLimit.invitationsCreate (bounds burst rate only). + // Best-effort, same accepted shape as the outstanding-pending-invite guard below (no + // atomic counter): two racing creates at count === maxLifetime-1 can both pass the + // check and both land, admitting the cap by a document or two. Acceptable for an + // admin/owner surface already bounded by rateLimit.invitationsCreate — turning this + // into a hard ceiling needs an atomically-incremented counter (a schema change), out + // of this fix's scope. + const inviterId = invitedBy?.id || invitedBy?._id || null; + const maxLifetime = config.invitations?.maxLifetime; + if (typeof maxLifetime === 'number' && inviterId) { + const lifetimeCount = await InvitationRepository.countByInvitedBy(inviterId); + if (lifetimeCount >= maxLifetime) { + throw new AppError('You have reached the maximum number of invitations you can send', { + status: 422, + code: 'VALIDATION_ERROR', + details: { message: 'You have reached the maximum number of invitations you can send.' }, + }); + } + } // E9: an already-registered email must not be invited — they are already a user. const existing = await UserService.findByEmail(normalizedEmail); if (existing) { @@ -91,7 +116,7 @@ const create = async (email, invitedBy) => { email: normalizedEmail, token, expiresAt, - invitedBy: invitedBy?.id || invitedBy?._id || null, + invitedBy: inviterId, }); if (mails.isConfigured()) { mails diff --git a/modules/invitations/tests/invitations.integration.tests.js b/modules/invitations/tests/invitations.integration.tests.js index 1c2f795fc..c974542e6 100644 --- a/modules/invitations/tests/invitations.integration.tests.js +++ b/modules/invitations/tests/invitations.integration.tests.js @@ -697,6 +697,34 @@ describe('Signup invitations:', () => { }); }); + describe('#3986 lifetime cap per inviter', () => { + let originalMaxLifetime; + beforeEach(() => { originalMaxLifetime = config.invitations.maxLifetime; }); + afterEach(() => { config.invitations.maxLifetime = originalMaxLifetime; }); + + test('rejects (422) once the REAL DB count reaches config.invitations.maxLifetime, via the real HTTP path', async () => { + config.invitations.maxLifetime = 2; + // createAdminAndSignin() always (re)creates a fresh admin, so this admin's + // invitedBy count starts at 0 — no cross-test bleed. + const adminAgent = await createAdminAndSignin(); + const first = await adminAgent.post('/api/invitations').send({ email: 'cap1-3986@example.com' }); + expect(first.status).toBe(200); + const second = await adminAgent.post('/api/invitations').send({ email: 'cap2-3986@example.com' }); + expect(second.status).toBe(200); // boundary: count was 1 (cap - 1), passes + const third = await adminAgent.post('/api/invitations').send({ email: 'cap3-3986@example.com' }); + expect(third.status).toBe(422); // count is now 2 (== cap), rejected + }); + + test('default-off (maxLifetime null): unaffected regardless of how many invitations already exist', async () => { + config.invitations.maxLifetime = null; + const adminAgent = await createAdminAndSignin(); + const first = await adminAgent.post('/api/invitations').send({ email: 'cap4-3986@example.com' }); + expect(first.status).toBe(200); + const second = await adminAgent.post('/api/invitations').send({ email: 'cap5-3986@example.com' }); + expect(second.status).toBe(200); + }); + }); + describe('P8a referral substrate (referredBy + invitation.accepted)', () => { let invitationEvents; let originalUp; let originalCap; diff --git a/modules/invitations/tests/invitations.repository.unit.tests.js b/modules/invitations/tests/invitations.repository.unit.tests.js index d267f12e6..38546fcd1 100644 --- a/modules/invitations/tests/invitations.repository.unit.tests.js +++ b/modules/invitations/tests/invitations.repository.unit.tests.js @@ -23,6 +23,7 @@ InvitationModel.find = jest.fn(() => chain); InvitationModel.findById = jest.fn(() => chain); InvitationModel.deleteOne = jest.fn(() => chain); InvitationModel.updateMany = jest.fn(() => chain); +InvitationModel.countDocuments = jest.fn(() => chain); const isValid = jest.fn(() => true); @@ -136,6 +137,14 @@ describe('InvitationRepository', () => { expect(chain.sort).toHaveBeenCalledWith('-createdAt'); }); + test('countByInvitedBy counts across ALL statuses for a given inviter (#3986)', async () => { + exec.mockResolvedValue(5); + const result = await InvitationRepository.countByInvitedBy('u1'); + // No status filter — the lifetime cap must not reset on expire/revoke. + expect(InvitationModel.countDocuments).toHaveBeenCalledWith({ invitedBy: 'u1' }); + expect(result).toBe(5); + }); + test('findAccepted scans status:accepted lean with the minimal referral projection (#3842)', async () => { exec.mockResolvedValue([{ _id: 'i1', invitedBy: 'u1', acceptedUserId: 'u2' }]); const result = await InvitationRepository.findAccepted(); diff --git a/modules/invitations/tests/invitations.service.unit.tests.js b/modules/invitations/tests/invitations.service.unit.tests.js index d9ac455bd..32af92f96 100644 --- a/modules/invitations/tests/invitations.service.unit.tests.js +++ b/modules/invitations/tests/invitations.service.unit.tests.js @@ -5,6 +5,7 @@ jest.unstable_mockModule('../repositories/invitations.repository.js', () => ({ create: jest.fn(), findByToken: jest.fn(), findByEmail: jest.fn(), + countByInvitedBy: jest.fn(), claim: jest.fn(), finalize: jest.fn(), release: jest.fn(), @@ -28,6 +29,8 @@ jest.unstable_mockModule('../../../config/index.js', () => ({ default: { sign: { inviteExpiresInDays: 14 }, app: { title: 'Test App', contact: 'contact@test.com' }, + // #3986: stack default OFF (mirrors production config/index.js absent/null shape). + invitations: { maxLifetime: null }, }, })); @@ -48,6 +51,9 @@ const InvitationRepository = (await import('../repositories/invitations.reposito const InvitationService = (await import('../services/invitations.service.js')).default; // Real events singleton — the service emits on it; we spy to assert the payload. const invitationEvents = (await import('../lib/events.js')).default; +// Mocked config singleton — mutated per-test for the #3986 lifetime-cap describe block +// (mirrors how integration tests flip config.sign.up/cap, kept + restored per test). +const config = (await import('../../../config/index.js')).default; beforeEach(() => { // Default: no stale claims to sweep, no pre-existing user (E9), no outstanding @@ -55,8 +61,10 @@ beforeEach(() => { // mockResolvedValue, so a prior test's findByEmail value would otherwise leak in. InvitationRepository.releaseStaleClaims.mockResolvedValue({ modifiedCount: 0 }); InvitationRepository.findByEmail.mockResolvedValue(undefined); + InvitationRepository.countByInvitedBy.mockResolvedValue(0); mockUserService.findByEmail.mockResolvedValue(null); mockUserService.updateById.mockResolvedValue({}); + config.invitations.maxLifetime = null; // stack default OFF, reset between tests }); describe('InvitationService.findValid', () => { @@ -401,6 +409,57 @@ describe('InvitationService.create — #3833 self-invite guard', () => { }); }); +describe('InvitationService.create — #3986 lifetime cap per inviter', () => { + test('default-off (config absent/null): create succeeds regardless of count, countByInvitedBy never consulted', async () => { + InvitationRepository.countByInvitedBy.mockResolvedValue(999); // would be way over any real cap + InvitationRepository.create.mockImplementation((doc) => Promise.resolve({ ...doc, id: '1' })); + const inv = await InvitationService.create('friend@example.com', { id: 'admin1' }); + expect(inv.email).toBe('friend@example.com'); + expect(InvitationRepository.countByInvitedBy).not.toHaveBeenCalled(); + expect(InvitationRepository.create).toHaveBeenCalledTimes(1); + }); + + test('boundary: count at cap−1 passes (create proceeds)', async () => { + config.invitations.maxLifetime = 5; + InvitationRepository.countByInvitedBy.mockResolvedValue(4); // cap - 1 + InvitationRepository.create.mockImplementation((doc) => Promise.resolve({ ...doc, id: '1' })); + const inv = await InvitationService.create('friend@example.com', { id: 'admin1' }); + expect(inv.email).toBe('friend@example.com'); + expect(InvitationRepository.countByInvitedBy).toHaveBeenCalledWith('admin1'); + expect(InvitationRepository.create).toHaveBeenCalledTimes(1); + }); + + test('capped path: count at threshold rejects (422), same AppError shape as the self-invite guard', async () => { + config.invitations.maxLifetime = 5; + InvitationRepository.countByInvitedBy.mockResolvedValue(5); // == cap + await expect(InvitationService.create('friend@example.com', { id: 'admin1' })).rejects.toMatchObject({ + status: 422, + code: 'VALIDATION_ERROR', + }); + expect(InvitationRepository.create).not.toHaveBeenCalled(); + // Fires BEFORE E9 / the outstanding-invite lookup, same fail-fast ordering as self-invite. + expect(mockUserService.findByEmail).not.toHaveBeenCalled(); + }); + + test('capped path: count past threshold also rejects (422)', async () => { + config.invitations.maxLifetime = 5; + InvitationRepository.countByInvitedBy.mockResolvedValue(6); // > cap + await expect(InvitationService.create('friend@example.com', { id: 'admin1' })).rejects.toMatchObject({ + status: 422, + code: 'VALIDATION_ERROR', + }); + expect(InvitationRepository.create).not.toHaveBeenCalled(); + }); + + test('an inviter with no resolvable id (defensive) skips the cap entirely, even when configured', async () => { + config.invitations.maxLifetime = 1; + InvitationRepository.create.mockImplementation((doc) => Promise.resolve({ ...doc, id: '1' })); + const inv = await InvitationService.create('friend@example.com', {}); + expect(inv.email).toBe('friend@example.com'); + expect(InvitationRepository.countByInvitedBy).not.toHaveBeenCalled(); + }); +}); + describe('InvitationService.create — email sending branch', () => { test('sends email when mailer is configured', async () => { mockMailer.isConfigured.mockReturnValue(true);