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
10 changes: 10 additions & 0 deletions config/defaults/production.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ const config = {
standardHeaders: true,
legacyHeaders: false,
},
// Authenticated POST /api/invitations. Stricter prod cap to harden against
// mass-invite abuse once `invitations.userFacing` widens create beyond admins
// (see invitations.development.config.js for the base-layer profile).
invitationsCreate: {
windowMs: 15 * 60 * 1000,
max: 20,
message: { message: 'Too many requests, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
},
},
log: {
format: 'custom',
Expand Down
3 changes: 3 additions & 0 deletions config/defaults/test.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ const config = {
publicImage: {
max: Number.MAX_SAFE_INTEGER, // disable rate limiting in tests
},
invitationsCreate: {
max: Number.MAX_SAFE_INTEGER, // disable rate limiting in tests
},
},
uploads: {
avatar: {
Expand Down
12 changes: 12 additions & 0 deletions config/templates/referral-reward-earned.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<title>{{appName}} referral reward</title>
</head>
<body>
<p>Hello {{displayName}},</p>
<p>Great news — someone you invited just joined {{appName}}, and you've earned a referral reward of {{units}} units.</p>
<p>Thanks for helping grow {{appName}}.</p>
<p>The <b>{{appName}}</b> Team.</p>
</body>
</html>
9 changes: 9 additions & 0 deletions lib/middlewares/tests/rateLimiter.baseLayer.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import organizationsDevConfig from '../../../modules/organizations/config/organi
import billingDevConfig from '../../../modules/billing/config/billing.development.config.js';
import authDevConfig from '../../../modules/auth/config/auth.development.config.js';
import uploadsDevConfig from '../../../modules/uploads/config/uploads.development.config.js';
import invitationsDevConfig from '../../../modules/invitations/config/invitations.development.config.js';

/**
* Config-layering regression guard for the rate-limiter env-gate defect.
Expand Down Expand Up @@ -58,4 +59,12 @@ describe('rate-limiter base-layer profiles (env-gate config-layering):', () => {
// the base layer so the limiter is active under every NODE_ENV, not only prod.
expectUsableProfile(uploadsDevConfig.rateLimit.publicImage);
});

test('invitationsCreate profile lives in the invitations base layer (always merges)', () => {
// Guards POST /api/invitations (+ the /api/auth/invitations alias). Once
// `invitations.userFacing` (#3945) widens create beyond admins, an unbounded
// create is a DB-bloat / outbound-email-spam abuse surface — the profile must
// be present under every NODE_ENV, not only prod.
expectUsableProfile(invitationsDevConfig.rateLimit.invitationsCreate);
});
});
12 changes: 11 additions & 1 deletion modules/auth/controllers/auth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,17 @@ const signup = async (req, res) => {
AnalyticsService.capture({
distinctId: String(user.id),
event: 'user_signed_up',
properties: { email: user.email, plan: user.plan, createdAt: user.createdAt },
properties: {
email: user.email,
plan: user.plan,
createdAt: user.createdAt,
// #3945: carry invite/referral attribution on the signup event so the
// referral funnel is measurable. `invite` is the resolved (opaque) result
// from the eligibility registry — already in scope, no invitations import.
invited: Boolean(invite),
invitationId: invite ? String(invite.id) : null,
invitedBy: invite?.invitedBy ? String(invite.invitedBy) : null,
},
});
} catch (_) { /* analytics must not break auth */ }

Expand Down
6 changes: 5 additions & 1 deletion modules/auth/routes/auth.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ import InvitationSchema from '../../invitations/models/invitations.schema.js';
*/
export default (app) => {
const authLimiter = limiters.auth;
// #3945: mirrors the same profile applied on the canonical mount
// (invitations.routes.js) — this alias points at the SAME controller.create, so
// leaving it unlimited would let a caller bypass the canonical route's cap.
const createLimiter = limiters.invitationsCreate;

// Signup invitations — DEPRECATION ALIAS for the canonical /api/invitations mount
// (modules/invitations). MUST be declared before the greedy `/api/auth/:strategy`
Expand All @@ -42,7 +46,7 @@ export default (app) => {
.route('/api/auth/invitations')
.all(passport.authenticate('jwt', { session: false }), policy.isAllowed)
.get(invitations.list)
.post(model.isValid(InvitationSchema.Invitation), invitations.create);
.post(createLimiter, model.isValid(InvitationSchema.Invitation), invitations.create);
app
.route('/api/auth/invitations/:invitationId')
.all(passport.authenticate('jwt', { session: false }), policy.isAllowed)
Expand Down
270 changes: 270 additions & 0 deletions modules/auth/tests/auth.silent.catch.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,276 @@ describe('auth.controller signup mass-assignment strip:', () => {
});
});

describe('auth.controller signup analytics: invite/referral attribution (#3945):', () => {
beforeEach(() => {
jest.resetModules();

jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
default: { warn: jest.fn(), error: jest.fn(), info: jest.fn() },
}));
});

test('user_signed_up carries invited:true + invitationId + invitedBy when the eligibility registry resolved an invite', async () => {
const mockCreate = jest.fn().mockResolvedValue({
id: 'u1', email: 'invitee@y.com', firstName: 'A', lastName: 'B', provider: 'local',
});

jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({
default: {
create: mockCreate,
getBrut: jest.fn().mockResolvedValue({ id: 'u1' }),
update: jest.fn().mockResolvedValue({}),
remove: jest.fn(),
count: jest.fn().mockResolvedValue(0),
},
}));

// Closed-signup, invite-gated path: the eligibility registry resolves + claims
// the invite and returns { invite, finalize, release } — auth relays it verbatim.
jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({
default: {
registerSignupEligibility: jest.fn(),
assertSignupEligible: jest.fn().mockResolvedValue({
invite: { id: 'inv1', email: 'invitee@y.com', invitedBy: 'inviter1' },
finalize: jest.fn().mockResolvedValue({ id: 'inv1', status: 'accepted' }),
release: jest.fn(),
}),
_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: {
sign: { up: false, in: true }, // closed signup — invite is required to open the gate
jwt: { secret: 'test-secret', expiresIn: 3600 },
cookie: { secure: false, sameSite: 'lax' },
organizations: { enabled: false },
app: { title: 'Test', contact: 'test@test.com' },
},
}));

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'),
}));

const mockCapture = jest.fn();
jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({
default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: mockCapture },
}));

const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');

const req = {
body: { email: 'invitee@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(mockCapture).toHaveBeenCalledWith(expect.objectContaining({
distinctId: 'u1',
event: 'user_signed_up',
properties: expect.objectContaining({
invited: true,
invitationId: 'inv1',
invitedBy: 'inviter1',
}),
}));
});

test('user_signed_up carries invited:false + null invitationId/invitedBy on a non-invited (open) signup', async () => {
const mockCreate = jest.fn().mockResolvedValue({
id: 'u2', email: 'self@y.com', firstName: 'C', lastName: 'D', provider: 'local',
});

jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({
default: {
create: mockCreate,
getBrut: jest.fn().mockResolvedValue({ id: 'u2' }),
update: jest.fn().mockResolvedValue({}),
remove: jest.fn(),
count: jest.fn().mockResolvedValue(0),
},
}));

jest.unstable_mockModule('../../../modules/auth/services/auth.eligibility.js', () => ({
default: {
registerSignupEligibility: jest.fn(),
assertSignupEligible: jest.fn().mockResolvedValue(undefined), // no invite opened the gate
_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: {
sign: { up: true, in: true }, // open signup — no invite required
jwt: { secret: 'test-secret', expiresIn: 3600 },
cookie: { secure: false, sameSite: 'lax' },
organizations: { enabled: false },
app: { title: 'Test', contact: 'test@test.com' },
},
}));

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'),
}));

const mockCapture = jest.fn();
jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({
default: { identify: jest.fn(), groupIdentify: jest.fn(), capture: mockCapture },
}));

const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');

const req = {
body: { email: 'self@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(mockCapture).toHaveBeenCalledWith(expect.objectContaining({
distinctId: 'u2',
event: 'user_signed_up',
properties: expect.objectContaining({
invited: false,
invitationId: null,
invitedBy: null,
}),
}));
});
});

describe('auth.password.controller silent-catch error logging:', () => {
let mockWarn;
let mockError;
Expand Down
Loading
Loading