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
2 changes: 1 addition & 1 deletion MIGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Phase 3 of the invitations↔org decouple epic (#3811). Two downstream-relevant
### Action required for downstream projects (`/update-project`)

1. The model/repository/migration changes are devkit-owned → arrive via `/update-stack` (`--theirs`).
2. **🔴 Before the first boot that carries the new schema, pre-check prod for case-variant duplicate emails** (`db.users.aggregate([{$group:{_id:{$toLower:'$email'},n:{$sum:1}}},{$match:{n:{$gt:1}}}])`). If any exist, resolve them BEFORE deploy — otherwise the migration aborts boot. (Trawl: handled in epic Phase 9 / #3815.)
2. **🔴 Before the first boot that carries the new schema, pre-check prod for case-variant duplicate emails** (`db.users.aggregate([{$group:{_id:{$toLower:'$email'},n:{$sum:1}}},{$match:{n:{$gt:1}}}])`). If any exist, resolve them BEFORE deploy — otherwise the migration aborts boot. Mixed-case **singles** need no action: the migration now lowercases them in place (post-dup-check, pre-index) so binary lookups keep finding those accounts. (Trawl: handled in epic Phase 9 / #3815.)
3. Migrations run at boot before `listen()`; the index swap + the `consumingAt` field land automatically once the dupe pre-check passes.
4. No client/contract change; existing 200/422 signup assertions pass unchanged.

Expand Down
55 changes: 48 additions & 7 deletions modules/auth/controllers/auth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,20 +101,31 @@ const signup = async (req, res) => {
// leave it stuck mid-claim (the lazy sweep would also recover it; this is
// immediate). Only the closed-signup path claims, so gate the release on it to
// avoid a no-op release (+ misleading log) on an open-signup presented token.
if (invite && !config.sign.up) await eligibility?.release?.();
if (invite && !config.sign.up) {
try {
await eligibility?.release?.();
} catch (releaseErr) {
// Best-effort — the sweep recovers; log it so a burst of "signup blocked"
// reports is diagnosable (a silently stuck claim looks like a dead invite).
logger.warn('[signup] invite release failed on capacity gate (left to the sweep)', {
err: releaseErr?.message,
stack: releaseErr?.stack,
});
}
}
return responses.error(res, 404, 'Signup error', 'Registration is currently deactivated')();
}
// Force default role on public signup — clients must not self-assign admin
const safeBody = { ...req.body, roles: ['user'] };
// Invite-gated signup: canonicalize the account email to the invite's pinned
// (lowercased) email. Enforces the pin exactly AND makes the case-sensitive
// unique-email index a reliable single-use backstop — concurrent case-variant
// (lowercased) email. Enforces the pin exactly AND makes the case-insensitive
// unique-email index (email_ci_unique, collation strength-2) a reliable single-use backstop — concurrent case-variant
// signups on the same invite collide on the index instead of creating two accounts.
if (invite) safeBody.email = invite.email;
// E2: the invite was atomically CLAIMED (consumingAt stamped) before we got here,
// so a throw FROM create itself must release the claim too — otherwise the invite
// stays locked until the 15-min sweep. The most realistic throw is an E11000 from
// the case-sensitive unique-email index when two case-variant signups race the same
// the case-insensitive unique-email index (email_ci_unique) when two case-variant signups race the same
// invited email (validation/transient errors land here as well). Mirror the three
// release sites below + the same `!config.sign.up` gating (only the closed-signup
// path claimed). Best-effort: a release failure must not mask the create error.
Expand Down Expand Up @@ -198,8 +209,23 @@ const signup = async (req, res) => {
// checker (invitations module owns it; auth never imports invitation code). This
// is the last pre-response step, and every earlier failure path (create-throw,
// verify-failure, org-failure) already released the claim, so reaching finalize
// means the claim is still ours to burn.
if (invite && !config.sign.up) await eligibility?.finalize?.(user._id || user.id);
// means the claim is still ours to burn. finalize itself is best-effort (see
// catch below).
if (invite && !config.sign.up) {
try {
await eligibility?.finalize?.(user._id || user.id);
} catch (finalizeErr) {
// Best-effort: the account exists and the response is about to succeed —
// a finalize DB hiccup must not convert a created account into a 422.
// The claim stays stamped and the 15-min lazy sweep releases it; the
// invite is reconcilable from its pending state + the account's email.
logger.warn('[signup] invite finalize failed post-create (left to the sweep)', {
userId: String(user._id || user.id),
err: finalizeErr?.message,
stack: finalizeErr?.stack,
});
Comment thread
Copilot marked this conversation as resolved.
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const token = jwt.sign({ userId: user.id }, config.jwt.secret, {
expiresIn: config.jwt.expiresIn,
Expand Down Expand Up @@ -540,7 +566,22 @@ const checkOAuthUserProfile = async (profil, key, provider) => {
// release on failure here — finalize marks it accepted+used (single-use). The
// finalize filter (acceptedAt:null) + the unique-email index are the single-use
// backstop against two concurrent OAuth signups for the same invited email.
if (oauthInvite) await oauthEligibility?.finalize?.(createdUser._id || createdUser.id);
// finalize itself is best-effort (see catch below).
if (oauthInvite) {
try {
await oauthEligibility?.finalize?.(createdUser._id || createdUser.id);
} catch (finalizeErr) {
// Best-effort: the account exists and the response is about to succeed —
// a finalize DB hiccup must not convert a created account into a 422.
// The claim stays stamped and the 15-min lazy sweep releases it; the
// invite is reconcilable from its pending state + the account's email.
logger.warn('[oauth] invite finalize failed post-create (left to the sweep)', {
userId: String(createdUser._id || createdUser.id),
err: finalizeErr?.message,
stack: finalizeErr?.stack,
});
Comment thread
Copilot marked this conversation as resolved.
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return createdUser;
} catch (err) {
if (err instanceof AppError) throw err;
Expand Down
8 changes: 5 additions & 3 deletions modules/auth/routes/auth.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@ import policy from '../../../lib/middlewares/policy.js';
import UsersSchema from '../../users/models/users.schema.js';
import auth from '../controllers/auth.controller.js';
import authPassword from '../controllers/auth.password.controller.js';
// TODO(P6, pierreb-devkit/Node#4279): Remove these two imports and the three routes
// below once Vue migrates to the canonical /api/invitations mount.
// TODO(P9, pierreb-devkit/Node#3815): Remove these two imports and the three routes
// below ONLY after (a) downstream (Trawl) has absorbed Vue P6 via /update-stack AND
// (b) Vue auth.store `verifyInvite` is repointed to the canonical
// /api/invitations/verify/:token (Vue pre-P9 fix PR) — it still calls the alias.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Deprecation alias only — the invitations feature lives in modules/invitations.
// This block MUST stay here (before the greedy `/api/auth/:strategy` wildcard) so
// the legacy `/api/auth/invitations*` paths the Vue admin store still calls keep
// working; it points at the MOVED controller. Canonical mount: /api/invitations
// (modules/invitations/routes/invitations.routes.js).
// INTENTIONAL core→optional import: temporary coupling until P6 removes these routes.
// INTENTIONAL core→optional import: temporary coupling until P9 removes these routes.
import invitations from '../../invitations/controllers/invitations.controller.js';
import InvitationSchema from '../../invitations/models/invitations.schema.js';

Expand Down
70 changes: 69 additions & 1 deletion modules/invitations/tests/invitations.integration.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ describe('Signup invitations:', () => {
afterEach(async () => {
config.sign.up = originalUp; config.sign.cap = originalCap;
jest.restoreAllMocks();
for (const email of ['e2-replay@example.com', 'e2-claimed@example.com', 'e2-stale@example.com', 'e2-createthrow@example.com', 'e2-concurrent@example.com']) {
for (const email of ['e2-replay@example.com', 'e2-claimed@example.com', 'e2-stale@example.com', 'e2-createthrow@example.com', 'e2-concurrent@example.com', 'e2-finalize-throw@example.com', 'e2-oauth-finalize-throw@example.com', 'e2-release-throw@example.com']) {
try {
const existing = await UserService.getBrut({ email });
if (existing) await UserService.remove(existing);
Expand Down Expand Up @@ -570,6 +570,74 @@ describe('Signup invitations:', () => {
expect(swept.consumingAt == null).toBe(true); // claim released
expect(await InvitationService.findValid(token, email)).not.toBeNull(); // reusable again
});

test('a finalize throw does NOT convert a successful signup into an error (best-effort)', async () => {
const adminAgent = await createAdminAndSignin();
const email = 'e2-finalize-throw@example.com';
const created = await adminAgent.post('/api/invitations').send({ email });
const { token } = created.body.data;
config.sign.up = false; config.sign.cap = null;

// finalize is the last pre-response step of an ALREADY-successful signup — make
// it blow up once (the eligibility closure routes finalize through accept, P8a).
const acceptSpy = jest.spyOn(InvitationService, 'accept').mockRejectedValueOnce(new Error('simulated finalize DB hiccup'));

const res = await request(app)
.post(`/api/auth/signup?inviteToken=${token}`)
.send({ email, password: 'Sup3rStr0ng!' });
// The account was created before finalize — the response must still succeed.
expect(acceptSpy).toHaveBeenCalledTimes(1);
expect(res.status).toBe(200);
expect(res.body.user.email).toBe(email);
});

test('OAuth: a finalize throw does NOT convert a successful OAuth signup into an error (best-effort)', async () => {
const adminAgent = await createAdminAndSignin();
const email = 'e2-oauth-finalize-throw@example.com';
const created = await adminAgent.post('/api/invitations').send({ email });
expect(created.status).toBe(200);
config.sign.up = false; config.sign.cap = null;

// Mirror of the local-signup test above on the OAuth path: the user is created,
// then finalize (the same accept closure — no claim to release on OAuth) blows up.
const AuthController = (await import(path.resolve('./modules/auth/controllers/auth.controller.js'))).default;
const acceptSpy = jest.spyOn(InvitationService, 'accept').mockRejectedValueOnce(new Error('simulated finalize DB hiccup'));

const createdUser = await AuthController.checkOAuthUserProfile({
firstName: 'Invited',
lastName: 'OAuth',
email,
avatar: '',
providerData: { sub: 'google-e2-oauth-finalize-throw-sub' },
emailVerifiedByProvider: true,
}, 'sub', 'google');

// The account was created before finalize — the call must still resolve with it.
expect(acceptSpy).toHaveBeenCalledTimes(1);
expect(createdUser).toBeDefined();
expect(createdUser.email).toBe(email);
});

test('cap-reached release: a release throw is swallowed — the gate still answers 404, not a 5xx', async () => {
const adminAgent = await createAdminAndSignin();
const email = 'e2-release-throw@example.com';
const created = await adminAgent.post('/api/invitations').send({ email });
const { token } = created.body.data;
// Closed signup + a positive cap already filled: the checker CLAIMS the invite,
// then the capacity gate rejects and must release the claim — make that release
// blow up. Best-effort: the 404 must come back unchanged (no 5xx), the stuck
// claim is the sweep's job.
config.sign.up = false;
config.sign.cap = await UserService.count(); // > 0 (the admin exists) and reached

const releaseSpy = jest.spyOn(InvitationService, 'release').mockRejectedValueOnce(new Error('simulated release DB hiccup'));

const res = await request(app)
.post(`/api/auth/signup?inviteToken=${token}`)
.send({ email, password: 'Sup3rStr0ng!' });
expect(releaseSpy).toHaveBeenCalledTimes(1);
expect(res.status).toBe(404); // swallowed — the capacity answer, not a masked 5xx
});
});

describe('E6 no auto-verify on invite (mailer off)', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const CI_INDEX = 'email_ci_unique';
* ABORT (throw) WITHOUT touching indexes — auto-merging accounts is a
* destructive product decision, not a migration's call. The thrown error
* lists the colliding groups so an operator can remediate, then re-run.
* (a2) Normalize legacy mixed-case emails (post-dup-check, so it can never collide).
* (b) Create the collation unique index FIRST, THEN drop the plain `email_1`.
* There is never a window without a unique constraint on email.
* (c) Idempotent: re-running after success is a no-op (the CI index already
Expand Down Expand Up @@ -66,6 +67,22 @@ export async function up() {
);
}

// ── (a2) Normalize legacy mixed-case emails to lowercase ──
// Post-chain every lookup lowercases its input (schema `lowercase:true` +
// repository normalizeEmail) and a plain findOne without per-query collation
// is a BINARY match — a legacy mixed-case row would be unreachable (signin,
// password-forgot, OAuth linkProviderByEmail, the invitations E9 guard all
// miss it). The dup pre-check above guarantees lowercasing cannot collide.
// Pipeline-update form so $toLower runs server-side; idempotent (the filter
// only matches rows still containing an uppercase letter).
const normalized = await collection.updateMany(
{ email: { $type: 'string', $regex: /[A-Z]/ } },
[{ $set: { email: { $toLower: '$email' } } }],
);
if (normalized.modifiedCount > 0) {
console.info(`[migration] users-email-ci-unique-index: lowercased ${normalized.modifiedCount} legacy mixed-case email(s)`);
}

// Snapshot existing indexes once (listIndexes throws if the collection does not
// exist yet — tolerate that: a fresh DB has no users collection and autoIndex /
// syncIndexes will create the CI index from the schema declaration).
Expand Down
45 changes: 45 additions & 0 deletions modules/users/tests/users.email.ci.index.integration.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import mongoose from 'mongoose';
import { beforeAll, afterAll, afterEach, describe, test, expect } from '@jest/globals';
import { bootstrap } from '../../../lib/app.js';

import { up } from '../migrations/20260610120000-users-email-ci-unique-index.js';

/**
* E3 — case-insensitive unique email index integration tests.
*
Expand Down Expand Up @@ -89,4 +91,47 @@ describe('E3 case-insensitive unique email index:', () => {
expect(found).not.toBeNull();
expect(found.email).toBe('ci-variant@example.com');
});

test('lowercases legacy mixed-case emails so post-chain binary lookups can find them', async () => {
const col = mongoose.connection.db.collection('users');
try {
// Bypass the schema (lowercase:true) — simulate a PRE-chain legacy row.
await col.insertOne({
email: 'Legacy.MixedCase@Example.COM',
firstName: 'Legacy',
lastName: 'Row',
provider: 'local',
roles: ['user'],
});
await up();
// Raw read (binary): the stored value itself must now be lowercase.
const raw = await col.findOne({ email: 'legacy.mixedcase@example.com' });
expect(raw).not.toBeNull();
const mixed = await col.findOne({ email: 'Legacy.MixedCase@Example.COM' });
expect(mixed).toBeNull();
} finally {
// Binary deletes — target BOTH forms so a failing (pre-fix) run cannot leak the row.
await col.deleteMany({ email: { $in: ['legacy.mixedcase@example.com', 'Legacy.MixedCase@Example.COM'] } });
}
});

test('still ABORTS on case-variant duplicates BEFORE normalizing anything', async () => {
const col = mongoose.connection.db.collection('users');
try {
// The bootstrapped DB already carries the CI unique index, which would reject the
// 2nd case-variant insert below — drop it to simulate the PRE-migration state the
// dup pre-check exists for (the finally re-runs up() to restore the end state).
try { await col.dropIndex('email_ci_unique'); } catch (_) { /* already absent */ }
await col.insertOne({ email: 'Dupe@x.com', firstName: 'A', lastName: 'A', provider: 'local', roles: ['user'] });
await col.insertOne({ email: 'dupe@x.com', firstName: 'B', lastName: 'B', provider: 'local', roles: ['user'] });
await expect(up()).rejects.toThrow(/case-variant duplicate/);
// Neither row was modified (abort happens before the normalization pass).
expect(await col.findOne({ email: 'Dupe@x.com' })).not.toBeNull();
expect(await col.findOne({ email: 'dupe@x.com' })).not.toBeNull();
} finally {
await col.deleteMany({ email: { $in: ['Dupe@x.com', 'dupe@x.com'] } });
// Restore the migrated end state (idempotent — the dup rows are gone).
await up();
}
});
});
Loading