Skip to content
282 changes: 240 additions & 42 deletions spec/CloudCode.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3307,19 +3307,19 @@ describe('afterFind hooks', () => {
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
expect(() => {
Parse.Cloud.beforeLogin('SomeClass', () => { });
}).toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
}).toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
expect(() => {
Parse.Cloud.afterLogin(() => { });
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
}).not.toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
expect(() => {
Parse.Cloud.afterLogin('_User', () => { });
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
}).not.toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
expect(() => {
Parse.Cloud.afterLogin(Parse.User, () => { });
}).not.toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
}).not.toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
expect(() => {
Parse.Cloud.afterLogin('SomeClass', () => { });
}).toThrow('Only the _User class is allowed for the beforeLogin and afterLogin triggers');
}).toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
expect(() => {
Parse.Cloud.afterLogout(() => { });
}).not.toThrow();
Expand Down Expand Up @@ -3777,60 +3777,258 @@ describe('beforeLogin hook', () => {
await Parse.User.logIn('tupac', 'shakur');
done();
});
});

describe('beforePasswordResetRequest hook', () => {
it('should run beforePasswordResetRequest with valid user', async done => {
let hit = 0;
let sendPasswordResetEmailCalled = false;
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => {
sendPasswordResetEmailCalled = true;
},
sendMail: () => {},
};

it('afterFind should not be triggered when saving an object', async () => {
let beforeSaves = 0;
Parse.Cloud.beforeSave('SavingTest', () => {
beforeSaves++;
await reconfigureServer({
appName: 'test',
emailAdapter: emailAdapter,
publicServerURL: 'http://localhost:8378/1',
});

let afterSaves = 0;
Parse.Cloud.afterSave('SavingTest', () => {
afterSaves++;
Parse.Cloud.beforePasswordResetRequest(req => {
hit++;
expect(req.object).toBeDefined();
expect(req.object.get('email')).toEqual('[email protected]');
expect(req.object.get('username')).toEqual('testuser');
});

let beforeFinds = 0;
Parse.Cloud.beforeFind('SavingTest', () => {
beforeFinds++;
const user = new Parse.User();
user.setUsername('testuser');
user.setPassword('password');
user.set('email', '[email protected]');
await user.signUp();

await Parse.User.requestPasswordReset('[email protected]');
expect(hit).toBe(1);
expect(sendPasswordResetEmailCalled).toBe(true);
done();
});

it('should be able to block password reset request if an error is thrown', async done => {
let hit = 0;
let sendPasswordResetEmailCalled = false;
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => {
sendPasswordResetEmailCalled = true;
},
sendMail: () => {},
};

await reconfigureServer({
appName: 'test',
emailAdapter: emailAdapter,
publicServerURL: 'http://localhost:8378/1',
});

let afterFinds = 0;
Parse.Cloud.afterFind('SavingTest', () => {
afterFinds++;
Parse.Cloud.beforePasswordResetRequest(req => {
hit++;
if (req.object.get('isBanned')) {
throw new Error('banned account');
}
});

const obj = new Parse.Object('SavingTest');
obj.set('someField', 'some value 1');
await obj.save();
const user = new Parse.User();
user.setUsername('banneduser');
user.setPassword('password');
user.set('email', '[email protected]');
await user.signUp();
await user.save({ isBanned: true });

expect(beforeSaves).toEqual(1);
expect(afterSaves).toEqual(1);
expect(beforeFinds).toEqual(0);
expect(afterFinds).toEqual(0);
try {
await Parse.User.requestPasswordReset('[email protected]');
throw new Error('should not have sent password reset email.');
} catch (e) {
expect(e.message).toBe('banned account');
}
expect(hit).toBe(1);
expect(sendPasswordResetEmailCalled).toBe(false);
done();
});

obj.set('someField', 'some value 2');
await obj.save();
it('should be able to block password reset request if an error is thrown even if the user has an attached file', async done => {
let hit = 0;
let sendPasswordResetEmailCalled = false;
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => {
sendPasswordResetEmailCalled = true;
},
sendMail: () => {},
};

expect(beforeSaves).toEqual(2);
expect(afterSaves).toEqual(2);
expect(beforeFinds).toEqual(0);
expect(afterFinds).toEqual(0);
await reconfigureServer({
appName: 'test',
emailAdapter: emailAdapter,
publicServerURL: 'http://localhost:8378/1',
});

await obj.fetch();
Parse.Cloud.beforePasswordResetRequest(req => {
hit++;
if (req.object.get('isBanned')) {
throw new Error('banned account');
}
});

expect(beforeSaves).toEqual(2);
expect(afterSaves).toEqual(2);
expect(beforeFinds).toEqual(1);
expect(afterFinds).toEqual(1);
const user = new Parse.User();
user.setUsername('banneduser2');
user.setPassword('password');
user.set('email', '[email protected]');
await user.signUp();
const base64 = 'V29ya2luZyBhdCBQYXJzZSBpcyBncmVhdCE=';
const file = new Parse.File('myfile.txt', { base64 });
await file.save();
await user.save({ isBanned: true, file });

obj.set('someField', 'some value 3');
await obj.save();
try {
await Parse.User.requestPasswordReset('[email protected]');
throw new Error('should not have sent password reset email.');
} catch (e) {
expect(e.message).toBe('banned account');
}
expect(hit).toBe(1);
expect(sendPasswordResetEmailCalled).toBe(false);
done();
});

it('should not run beforePasswordResetRequest if email does not exist', async done => {
let hit = 0;
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => {},
sendMail: () => {},
};

await reconfigureServer({
emailAdapter: emailAdapter,
publicServerURL: 'http://localhost:8378/1',
});

Parse.Cloud.beforePasswordResetRequest(req => {
hit++;
});

try {
await Parse.User.requestPasswordReset('[email protected]');
} catch (e) {
// May or may not throw depending on passwordPolicy.resetPasswordSuccessOnInvalidEmail
}
expect(hit).toBe(0);
done();
});

it('should have expected data in request in beforePasswordResetRequest', async done => {
const emailAdapter = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => {},
sendMail: () => {},
};

await reconfigureServer({
appName: 'test',
emailAdapter: emailAdapter,
publicServerURL: 'http://localhost:8378/1',
});

Parse.Cloud.beforePasswordResetRequest(req => {
expect(req.object).toBeDefined();
expect(req.object.get('email')).toBeDefined();
expect(req.headers).toBeDefined();
expect(req.ip).toBeDefined();
expect(req.installationId).toBeDefined();
expect(req.context).toBeDefined();
expect(req.config).toBeDefined();
});

const user = new Parse.User();
user.setUsername('testuser2');
user.setPassword('password');
user.set('email', '[email protected]');
await user.signUp();
await Parse.User.requestPasswordReset('[email protected]');
done();
});

it('should validate that only _User class is allowed for beforePasswordResetRequest', () => {
expect(() => {
Parse.Cloud.beforePasswordResetRequest('SomeClass', () => { });
}).toThrow('Only the _User class is allowed for the beforeLogin, afterLogin, and beforePasswordResetRequest triggers');
expect(() => {
Parse.Cloud.beforePasswordResetRequest(() => { });
}).not.toThrow();
expect(() => {
Parse.Cloud.beforePasswordResetRequest('_User', () => { });
}).not.toThrow();
expect(() => {
Parse.Cloud.beforePasswordResetRequest(Parse.User, () => { });
}).not.toThrow();
});
});

it('afterFind should not be triggered when saving an object', async () => {
let beforeSaves = 0;
Parse.Cloud.beforeSave('SavingTest', () => {
beforeSaves++;
});

let afterSaves = 0;
Parse.Cloud.afterSave('SavingTest', () => {
afterSaves++;
});

let beforeFinds = 0;
Parse.Cloud.beforeFind('SavingTest', () => {
beforeFinds++;
});

expect(beforeSaves).toEqual(3);
expect(afterSaves).toEqual(3);
expect(beforeFinds).toEqual(1);
expect(afterFinds).toEqual(1);
let afterFinds = 0;
Parse.Cloud.afterFind('SavingTest', () => {
afterFinds++;
});

const obj = new Parse.Object('SavingTest');
obj.set('someField', 'some value 1');
await obj.save();

expect(beforeSaves).toEqual(1);
expect(afterSaves).toEqual(1);
expect(beforeFinds).toEqual(0);
expect(afterFinds).toEqual(0);

obj.set('someField', 'some value 2');
await obj.save();

expect(beforeSaves).toEqual(2);
expect(afterSaves).toEqual(2);
expect(beforeFinds).toEqual(0);
expect(afterFinds).toEqual(0);

await obj.fetch();

expect(beforeSaves).toEqual(2);
expect(afterSaves).toEqual(2);
expect(beforeFinds).toEqual(1);
expect(afterFinds).toEqual(1);

obj.set('someField', 'some value 3');
await obj.save();

expect(beforeSaves).toEqual(3);
expect(afterSaves).toEqual(3);
expect(beforeFinds).toEqual(1);
expect(afterFinds).toEqual(1);
});

describe('afterLogin hook', () => {
Expand Down
45 changes: 42 additions & 3 deletions src/Routers/UsersRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Types as TriggerTypes,
getRequestObject,
resolveError,
inflate,
} from '../triggers';
import { promiseEnsureIdempotency } from '../middlewares';
import RestWrite from '../RestWrite';
Expand Down Expand Up @@ -444,21 +445,59 @@ export class UsersRouter extends ClassesRouter {
if (!email && !token) {
throw new Parse.Error(Parse.Error.EMAIL_MISSING, 'you must provide an email');
}

let userResults = null;
let userData = null;

// We can find the user using token
if (token) {
const results = await req.config.database.find('_User', {
userResults = await req.config.database.find('_User', {
_perishable_token: token,
_perishable_token_expires_at: { $lt: Parse._encode(new Date()) },
});
if (results && results[0] && results[0].email) {
email = results[0].email;
if (userResults && userResults.length > 0) {
userData = userResults[0];
if (userData.email) {
email = userData.email;
}
}
// Or using email if no token provided
} else if (typeof email === 'string') {
userResults = await req.config.database.find(
'_User',
{ $or: [{ email }, { username: email, email: { $exists: false } }] },
{ limit: 1 },
Auth.maintenance(req.config)
);
Comment on lines +454 to +471
Copy link

@coderabbitai coderabbitai bot Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Token lookup uses inverted expiry comparator and lacks Auth.maintenance/limit.

  • _perishable_token_expires_at should be greater than “now” for a valid token.
  • Use Auth.maintenance like the email path for consistency.
  • Limit results to 1.

Apply:

-      userResults = await req.config.database.find('_User', {
-        _perishable_token: token,
-        _perishable_token_expires_at: { $lt: Parse._encode(new Date()) },
-      });
+      userResults = await req.config.database.find(
+        '_User',
+        {
+          _perishable_token: token,
+          _perishable_token_expires_at: { $gt: Parse._encode(new Date()) },
+        },
+        { limit: 1 },
+        Auth.maintenance(req.config)
+      );
🤖 Prompt for AI Agents
In src/Routers/UsersRouter.js around lines 454 to 471, the token lookup query
uses the wrong expiry comparator and is missing the same options as the email
path; change the _perishable_token_expires_at check to $gt Parse._encode(new
Date()) so only unexpired tokens match, and call req.config.database.find with
the same options as the email branch: pass { limit: 1 } and
Auth.maintenance(req.config) as the query options so the lookup is limited to
one result and runs under maintenance context.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mtrezza an opininion since this code already exists before my modifcations ?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

if (userResults && userResults.length > 0) {
userData = userResults[0];
}
}

if (typeof email !== 'string') {
throw new Parse.Error(
Parse.Error.INVALID_EMAIL_ADDRESS,
'you must provide a valid email string'
);
}

if (userData) {
this._sanitizeAuthData(userData);
// Useful to get User attached files in the trigger (photo picture for example)
await req.config.filesController.expandFilesInObject(req.config, userData);

const user = inflate('_User', userData);

await maybeRunTrigger(
TriggerTypes.beforePasswordResetRequest,
req.auth,
user,
null,
req.config,
req.info.context
);
}

const userController = req.config.userController;
try {
await userController.sendPasswordResetEmail(email);
Expand Down
Loading
Loading