Skip to content

Commit 4fd1edc

Browse files
fix(auth): address CodeRabbit/Copilot review findings on invite gate
- email template: add non-empty <title> to signup-invite.html - auth.controller: short-circuit UserService.count() when cap is null - auth.controller: coerce sign.cap to Number + guard non-finite at gate - auth.controller: only consume invite when it actually opened the gate (sign.up=true = invite not required, burning it would be wrong) - auth.invitation.controller: add full JSDoc (@param/@returns) to all controller functions per project standard - auth.invitations.yml: add InvitationListItem schema (token omitted); admin list endpoint now references it instead of Invitation - auth.invitation.model.mongoose.js: add JSDoc to addID virtual getter - auth.invitation.integration.tests.js: add @returns to test helpers; fix cap test to create admin before setting cap (was order-dependent) - auth.signout.controller.unit.tests.js: complete InvitationService mock - auth.silent.catch.unit.tests.js: complete InvitationService mock
1 parent 5ba9358 commit 4fd1edc

8 files changed

Lines changed: 103 additions & 18 deletions

config/templates/signup-invite.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<!DOCTYPE html>
22
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
33
<head>
4-
<title></title>
4+
<title>You've been invited — complete your signup</title>
55
</head>
66
<body>
77
<p>Hello,</p>

modules/auth/controllers/auth.controller.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,10 @@ const signup = async (req, res) => {
6868
// Two AND-ed gates: (1) capacity — a hard ceiling on total accounts,
6969
// invited users included; (2) eligibility — public signup open OR a valid
7070
// invite token (read from query: model.isValid strips unknown body keys).
71-
const total = await UserService.count();
72-
const capReached = config.sign.cap != null && total >= config.sign.cap;
71+
// Short-circuit count() when cap is not set (null = unlimited) to avoid a
72+
// collection-wide count on every signup request for uncapped deployments.
73+
const cap = config.sign.cap != null ? Number(config.sign.cap) : null;
74+
const capReached = cap != null && Number.isFinite(cap) && (await UserService.count()) >= cap;
7375
let invite = null;
7476
if (req.query?.inviteToken) {
7577
invite = await InvitationService.findValid(req.query.inviteToken, req.body.email);
@@ -138,8 +140,10 @@ const signup = async (req, res) => {
138140
});
139141
} catch (_) { /* analytics must not break auth */ }
140142

141-
// Single-use: consume only after the account is fully provisioned (best-effort)
142-
if (invite) await InvitationService.consume(invite.id);
143+
// Single-use: consume only when the invite actually opened the gate (signup
144+
// was closed, so the invite was required). When signup is open, a token can
145+
// be presented but is not required — consuming it would silently burn it.
146+
if (invite && !config.sign.up) await InvitationService.consume(invite.id);
143147

144148
const token = jwt.sign({ userId: user.id }, config.jwt.secret, {
145149
expiresIn: config.jwt.expiresIn,
@@ -425,8 +429,9 @@ const checkOAuthUserProfile = async (profil, key, provider) => {
425429
try {
426430
// Same two gates as local signup. OAuth can't carry an invite token through
427431
// the redirect, so the invite is matched on the provider's verified email.
428-
const total = await UserService.count();
429-
const capReached = config.sign.cap != null && total >= config.sign.cap;
432+
// Short-circuit count() when cap is not set (null = unlimited).
433+
const oauthCap = config.sign.cap != null ? Number(config.sign.cap) : null;
434+
const capReached = oauthCap != null && Number.isFinite(oauthCap) && (await UserService.count()) >= oauthCap;
430435
const oauthInvite = config.sign.up ? null : await InvitationService.findValidByEmail(profil.email);
431436
if (capReached || (!config.sign.up && !oauthInvite)) {
432437
// Mirror the local signup endpoint's error shape so clients see the same

modules/auth/controllers/auth.invitation.controller.js

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ import errors from '../../../lib/helpers/errors.js';
55
import responses from '../../../lib/helpers/responses.js';
66
import InvitationService from '../services/auth.invitation.service.js';
77

8-
/** @function create — Admin: create + email a signup invitation. */
8+
/**
9+
* @desc Admin: create + email a signup invitation
10+
* @param {Object} req - Express request object
11+
* @param {string} req.body.email - Email address to invite
12+
* @param {Object} req.user - Authenticated admin user
13+
* @param {Object} res - Express response object
14+
* @returns {Promise<void>} Sends HTTP 200 with created invitation or 422 on error
15+
*/
916
const create = async (req, res) => {
1017
try {
1118
const invitation = await InvitationService.create(req.body.email, req.user);
@@ -15,7 +22,12 @@ const create = async (req, res) => {
1522
}
1623
};
1724

18-
/** @function list — Admin: list all signup invitations. */
25+
/**
26+
* @desc Admin: list all signup invitations
27+
* @param {Object} req - Express request object
28+
* @param {Object} res - Express response object
29+
* @returns {Promise<void>} Sends HTTP 200 with invitation array or 422 on error
30+
*/
1931
const list = async (req, res) => {
2032
try {
2133
const invitations = await InvitationService.list();
@@ -25,7 +37,14 @@ const list = async (req, res) => {
2537
}
2638
};
2739

28-
/** @function remove — Admin: revoke an invitation. */
40+
/**
41+
* @desc Admin: revoke an invitation
42+
* @param {Object} req - Express request object
43+
* @param {Object} req.invitation - Loaded invitation document (set by invitationByID middleware)
44+
* @param {string} req.invitation.id - Invitation id
45+
* @param {Object} res - Express response object
46+
* @returns {Promise<void>} Sends HTTP 200 with deleted id or 422 on error
47+
*/
2948
const remove = async (req, res) => {
3049
try {
3150
await InvitationService.revoke(req.invitation.id);
@@ -35,7 +54,13 @@ const remove = async (req, res) => {
3554
}
3655
};
3756

38-
/** @function verify — Public: report whether a token is a valid invite (+ email). */
57+
/**
58+
* @desc Public: report whether a token is a valid invite (+ prefill email)
59+
* @param {Object} req - Express request object
60+
* @param {string} req.params.token - Invitation token to verify
61+
* @param {Object} res - Express response object
62+
* @returns {Promise<void>} Sends HTTP 200 with { valid, email } or 422 on error
63+
*/
3964
const verify = async (req, res) => {
4065
try {
4166
const invite = await InvitationService.findValid(req.params.token);
@@ -45,7 +70,14 @@ const verify = async (req, res) => {
4570
}
4671
};
4772

48-
/** @function invitationByID — Middleware to load an invitation into req.invitation. */
73+
/**
74+
* @desc Middleware to load an invitation into req.invitation by id param
75+
* @param {Object} req - Express request object
76+
* @param {Object} res - Express response object
77+
* @param {Function} next - Express next middleware function
78+
* @param {string} id - Invitation id from URL param
79+
* @returns {Promise<void>} Calls next() with invitation on req, or sends 404
80+
*/
4981
const invitationByID = async (req, res, next, id) => {
5082
try {
5183
const invitation = await InvitationService.get(id);

modules/auth/doc/auth.invitations.yml

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ paths:
2121
data:
2222
type: array
2323
items:
24-
$ref: '#/components/schemas/Invitation'
24+
$ref: '#/components/schemas/InvitationListItem'
2525
'401':
2626
description: Not authenticated
2727
'403':
@@ -115,6 +115,28 @@ paths:
115115

116116
components:
117117
schemas:
118+
InvitationListItem:
119+
type: object
120+
description: Invitation as returned by the admin list endpoint (token omitted)
121+
properties:
122+
id:
123+
type: string
124+
email:
125+
type: string
126+
format: email
127+
expiresAt:
128+
type: string
129+
format: date-time
130+
usedAt:
131+
type: string
132+
format: date-time
133+
nullable: true
134+
invitedBy:
135+
type: string
136+
nullable: true
137+
createdAt:
138+
type: string
139+
format: date-time
118140
Invitation:
119141
type: object
120142
properties:

modules/auth/models/auth.invitation.model.mongoose.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ const InvitationMongoose = new Schema(
2323
InvitationMongoose.index({ token: 1 }, { unique: true });
2424
InvitationMongoose.index({ email: 1 });
2525

26+
/**
27+
* @desc Return document id as hex string
28+
* @this {Object} Mongoose Invitation document
29+
* @returns {String} hex string id
30+
*/
2631
function addID() {
2732
return this._id.toHexString();
2833
}

modules/auth/tests/auth.invitation.integration.tests.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ describe('Signup invitations:', () => {
4141
* 1. signup via /api/auth/signup
4242
* 2. promote to admin via UserService.update (roles stripped on signup)
4343
* 3. signin so the agent cookie jar holds the fresh JWT reflecting the new role
44+
* @returns {Promise<import('supertest').SuperAgentTest>} Supertest agent with admin JWT cookie
4445
*/
4546
async function createAdminAndSignin() {
4647
const email = 'inv-admin@test.com';
@@ -75,6 +76,7 @@ describe('Signup invitations:', () => {
7576

7677
/**
7778
* Helper: create a regular user and return a bound supertest agent with the auth cookie.
79+
* @returns {Promise<import('supertest').SuperAgentTest>} Supertest agent with user JWT cookie
7880
*/
7981
async function createUserAndSignin() {
8082
const email = 'inv-user@test.com';
@@ -190,16 +192,19 @@ describe('Signup invitations:', () => {
190192
});
191193

192194
test('cap reached → 404 even with a valid invite (invites count in the cap)', async () => {
193-
config.sign.up = true;
194-
const total = await UserService.count();
195-
config.sign.cap = total; // total >= cap → blocked
195+
// Create admin and issue invite BEFORE setting cap so the helper signup
196+
// is not blocked. Then snapshot the user count and set cap = total.
197+
config.sign.up = true; config.sign.cap = null;
196198
const adminAgent = await createAdminAndSignin();
197199
const created = await adminAgent.post('/api/auth/invitations').send({ email: 'capped@example.com' });
198200
const { token } = created.body.data;
201+
// Freeze the cap at the current total — next signup must be blocked
202+
config.sign.cap = await UserService.count(); // total >= cap → blocked
199203
const res = await request(app)
200204
.post(`/api/auth/signup?inviteToken=${token}`)
201205
.send({ email: 'capped@example.com', password: 'Sup3rStr0ng!' });
202206
expect(res.status).toBe(404);
207+
config.sign.cap = null;
203208
});
204209

205210
test('under cap + signup open → normal signup works', async () => {

modules/auth/tests/auth.signout.controller.unit.tests.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,15 @@ describe('auth.controller signout:', () => {
2323
default: { create: jest.fn(), getBrut: jest.fn(), update: jest.fn(), remove: jest.fn(), search: jest.fn(), count: jest.fn().mockResolvedValue(0) },
2424
}));
2525
jest.unstable_mockModule('../../../modules/auth/services/auth.invitation.service.js', () => ({
26-
default: { findValid: jest.fn().mockResolvedValue(null), consume: jest.fn().mockResolvedValue(null) },
26+
default: {
27+
findValid: jest.fn().mockResolvedValue(null),
28+
findValidByEmail: jest.fn().mockResolvedValue(null),
29+
consume: jest.fn().mockResolvedValue(null),
30+
create: jest.fn(),
31+
list: jest.fn(),
32+
get: jest.fn(),
33+
revoke: jest.fn(),
34+
},
2735
}));
2836
jest.unstable_mockModule('../../../modules/users/repositories/users.repository.js', () => ({
2937
default: { update: jest.fn() },

modules/auth/tests/auth.silent.catch.unit.tests.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,15 @@ describe('auth.controller silent-catch error logging:', () => {
4040
}));
4141

4242
jest.unstable_mockModule('../../../modules/auth/services/auth.invitation.service.js', () => ({
43-
default: { findValid: jest.fn().mockResolvedValue(null), consume: jest.fn().mockResolvedValue(null) },
43+
default: {
44+
findValid: jest.fn().mockResolvedValue(null),
45+
findValidByEmail: jest.fn().mockResolvedValue(null),
46+
consume: jest.fn().mockResolvedValue(null),
47+
create: jest.fn(),
48+
list: jest.fn(),
49+
get: jest.fn(),
50+
revoke: jest.fn(),
51+
},
4452
}));
4553

4654
jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({

0 commit comments

Comments
 (0)