LibreChat/api/server/middleware/checkInviteUser.spec.js
Danny Avila 5e3cdd0b2c
📮 fix: Consume an Invite Only Once the Account Exists (#15611)
* 📮 fix: Consume an Invite Only Once the Account Exists

`checkInviteUser` deleted the invite token and then called `next()`, but everything
that can still reject a registration runs after it: the schema, the allowed-domain
check, and the email-already-in-use check. A mistyped password confirmation therefore
destroyed the invite — the invitee corrected it, resubmitted, and got "Invalid invite
token" with no way back short of an admin re-inviting them.

The deletion moves to `registrationController`, after `registerUser` reports success.

That report needed a new signal. `registerUser` returns the same 200 and the same
generic message whether it created an account or found the email already in use —
deliberately, so the response cannot be used to enumerate accounts — so the status
alone cannot say whether an account exists. It now also returns `userCreated` on the
creation path, which the controller reads and never forwards; the response body is
unchanged.

A failed deletion is logged rather than surfaced. By that point the account exists,
and leaving a usable invite behind is recoverable in a way that telling the user their
registration failed is not.

Fixes #15541

* ♻️ test: Fold the Invite Tests Into the Existing AuthController Spec

From Copilot's review. The new spec reached for `jest.requireActual` on
`@librechat/data-schemas`, `~/server/services/AuthService` and `~/models`, which pulls
real implementations into a unit test — `~/models` builds the data-schemas methods
against mongoose — and diverges from `AuthController.spec.js`, which stubs each module
outright.

Rather than restate that harness with narrower stubs, the tests move into the spec that
already has it. `deleteTokens` joins its `~/models` mock, which also keeps that shared
mock in step with the controller's imports: a stale mock there hands the controller an
undefined function, and the omission only surfaces when some later test happens to
exercise the path.
2026-09-04 15:38:32 -04:00

70 lines
2.2 KiB
JavaScript

const mockGetInvite = jest.fn();
const mockDeleteTokens = jest.fn();
jest.mock('@librechat/api', () => ({
getInvite: (...args) => mockGetInvite(...args),
}));
jest.mock('~/models', () => ({
createToken: jest.fn(),
findToken: jest.fn(),
deleteTokens: (...args) => mockDeleteTokens(...args),
}));
const checkInviteUser = require('./checkInviteUser');
const buildRes = () => {
const res = {};
res.status = jest.fn(() => res);
res.json = jest.fn(() => res);
return res;
};
describe('checkInviteUser', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('does not consume the invite, so a later failure cannot destroy it', async () => {
/** The reported bug: the invite was deleted here, before the schema, the
* allowed-domain check or the email-in-use check had run. A mistyped password
* confirmation left the invitee with no invite and no account. */
mockGetInvite.mockResolvedValue({ token: 'hashed-invite', email: 'invitee@example.com' });
const req = { body: { token: 'raw-token', email: 'invitee@example.com' } };
const next = jest.fn();
await checkInviteUser(req, buildRes(), next);
expect(mockDeleteTokens).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
});
it('hands the validated invite to the rest of the chain', async () => {
const invite = { token: 'hashed-invite', email: 'invitee@example.com' };
mockGetInvite.mockResolvedValue(invite);
const req = { body: { token: 'raw-token', email: 'invitee@example.com' } };
await checkInviteUser(req, buildRes(), jest.fn());
expect(req.invite).toBe(invite);
});
it('rejects an invalid invite without touching the token store', async () => {
mockGetInvite.mockResolvedValue(null);
const res = buildRes();
await checkInviteUser({ body: { token: 'raw-token', email: 'a@b.c' } }, res, jest.fn());
expect(res.status).toHaveBeenCalledWith(400);
expect(mockDeleteTokens).not.toHaveBeenCalled();
});
it('passes straight through when no invite token is supplied', async () => {
const next = jest.fn();
await checkInviteUser({ body: {} }, buildRes(), next);
expect(next).toHaveBeenCalledTimes(1);
expect(mockGetInvite).not.toHaveBeenCalled();
});
});