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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions modules/invitations/config/invitations.development.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion modules/invitations/repositories/invitations.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Number>}
*/
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
Expand Down Expand Up @@ -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 };
29 changes: 27 additions & 2 deletions modules/invitations/services/invitations.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object>} 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();
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions modules/invitations/tests/invitations.integration.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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();
Expand Down
59 changes: 59 additions & 0 deletions modules/invitations/tests/invitations.service.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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 },
},
}));

Expand All @@ -48,15 +51,20 @@ 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
// pending invite (the duplicate-pending guard). clearMocks resets calls but NOT
// 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', () => {
Expand Down Expand Up @@ -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);
Expand Down
Loading