From 458c473d9eafee841a3b017229a6d21f028c1027 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 3 Sep 2026 07:46:41 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=A7=20fix:=20Normalize=20Token=20Email?= =?UTF-8?q?s=20on=20Write,=20as=20Every=20Read=20Already=20Does=20(#15536)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📧 fix: Normalize Token Emails on Write, as Every Read Already Does `findToken` and `deleteTokens` both trim and lowercase the email before querying — the comment on `findToken` even documents it as automatic — but nothing normalized the write. A token created with mixed case or surrounding whitespace could therefore never be found or deleted again: an invite issued to `User@Example.com` was unredeemable. Fixed on the schema rather than in a caller. `createInvite` is the caller that surfaced it (#15529), but the asymmetry belongs to `Token`, so every writer had the same trap and any new one would inherit it. `User.email` is already declared `lowercase: true` in this package, so this only brings `Token.email` in line with the convention beside it — and a schema-level setter cannot be bypassed the way a helper can. Existing documents are not migrated. A token already stored with mixed case stays unfindable, exactly as it is today; only new writes are corrected. Fixes #15529 * ✏️ fix: Correct the Schema Comment and Name the No-Email Test for What It Asserts Both from Copilot's review. `User.email` sets `lowercase` but not `trim`, so claiming this "mirrors" it overstated the parallel — this change trims as well, because the read side does. The comment now states the `findToken`/`deleteTokens` contract it actually follows. The no-email test was named for `null` while omitting the field and asserting `undefined`. Renamed to match, and a real null case added: `TokenCreateData.email` is `string | undefined` so null is only reachable by writing the model directly, but both read methods branch on `email === null`, so it is worth proving the new setters leave that value alone. --- .../data-schemas/src/methods/token.spec.ts | 85 +++++++++++++++++++ packages/data-schemas/src/schema/token.ts | 7 ++ 2 files changed, 92 insertions(+) diff --git a/packages/data-schemas/src/methods/token.spec.ts b/packages/data-schemas/src/methods/token.spec.ts index bc4335bd84..5661aca552 100644 --- a/packages/data-schemas/src/methods/token.spec.ts +++ b/packages/data-schemas/src/methods/token.spec.ts @@ -1105,3 +1105,88 @@ describe('Token Methods - Detailed Tests', () => { }); }); }); + +describe('Token email normalization', () => { + const userId = new mongoose.Types.ObjectId(); + + const createFor = (email: string) => + methods.createToken({ + userId, + email, + token: 'hashed-token-value', + expiresIn: 3600, + }); + + it('stores the email normalized so reads can find it again', async () => { + await createFor('User@Example.COM'); + + const stored = await Token.findOne({ userId }); + expect(stored?.email).toBe('user@example.com'); + }); + + it('trims surrounding whitespace on write', async () => { + await createFor(' spaced@example.com '); + + const stored = await Token.findOne({ userId }); + expect(stored?.email).toBe('spaced@example.com'); + }); + + it('finds a token created with mixed case, whatever case the lookup uses', async () => { + await createFor('User@Example.COM'); + + for (const lookup of ['User@Example.COM', 'user@example.com', 'USER@EXAMPLE.COM']) { + const found = await methods.findToken({ token: 'hashed-token-value', email: lookup }); + expect(found).not.toBeNull(); + } + }); + + it('finds a token created with padding when the lookup is clean', async () => { + await createFor(' spaced@example.com '); + + const found = await methods.findToken({ + token: 'hashed-token-value', + email: 'spaced@example.com', + }); + + expect(found).not.toBeNull(); + }); + + it('deletes a token created with mixed case', async () => { + await createFor('User@Example.COM'); + + const result = await methods.deleteTokens({ email: 'user@example.com' }); + + expect(result.deletedCount).toBe(1); + expect(await Token.countDocuments({ userId })).toBe(0); + }); + + it('leaves a token written without an email alone', async () => { + await methods.createToken({ + userId, + token: 'no-email-token', + expiresIn: 3600, + }); + + const stored = await Token.findOne({ userId }); + expect(stored?.email).toBeUndefined(); + }); + + it('leaves an explicitly null email as null, which the read side matches on', async () => { + /** `TokenCreateData.email` is `string | undefined`, so a null is only reachable by + * writing the model directly — but `findToken` and `deleteTokens` both branch on + * `email === null`, so the setters must not coerce it into something else. */ + await Token.create({ + userId, + email: null, + token: 'null-email-token', + createdAt: new Date(), + expiresAt: new Date(Date.now() + 3600000), + }); + + const stored = await Token.findOne({ userId }); + expect(stored?.email).toBeNull(); + + const found = await methods.findToken({ token: 'null-email-token', email: null }); + expect(found).not.toBeNull(); + }); +}); diff --git a/packages/data-schemas/src/schema/token.ts b/packages/data-schemas/src/schema/token.ts index 44410036d9..a9dac9c6fe 100644 --- a/packages/data-schemas/src/schema/token.ts +++ b/packages/data-schemas/src/schema/token.ts @@ -9,6 +9,13 @@ const tokenSchema: Schema = new Schema({ }, email: { type: String, + /** `findToken` and `deleteTokens` both apply `.trim().toLowerCase()` to the email + * before querying, so the write side has to match on both counts or a token + * created with mixed case or surrounding whitespace can never be found again — + * an invite issued to `User@Example.com` was unredeemable. `User.email` sets + * `lowercase` alone; the read contract here also trims, so this adds `trim`. */ + lowercase: true, + trim: true, }, type: { type: String,