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,