Skip to content
Open
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
90 changes: 90 additions & 0 deletions spec/ParseServerRESTController.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,96 @@ describe('ParseServerRESTController', () => {
);
});

it('should deep copy context so mutations in beforeSave do not leak across requests', async () => {
const sharedContext = { counter: 0, nested: { value: 'original' } };

Parse.Cloud.beforeSave('ContextTestObject', req => {
// Mutate the context in beforeSave
req.context.counter = (req.context.counter || 0) + 1;
req.context.nested.value = 'mutated';
req.context.addedByHook = true;
});

// First save — this should not affect the original sharedContext
await RESTController.request(
'POST',
'/classes/ContextTestObject',
{ key: 'value1' },
{ context: sharedContext }
);

// The original context object must remain unchanged
expect(sharedContext.counter).toEqual(0);
expect(sharedContext.nested.value).toEqual('original');
expect(sharedContext.addedByHook).toBeUndefined();

// Second save with the same context — should also start with the original values
await RESTController.request(
'POST',
'/classes/ContextTestObject',
{ key: 'value2' },
{ context: sharedContext }
);

// The original context object must still remain unchanged
expect(sharedContext.counter).toEqual(0);
expect(sharedContext.nested.value).toEqual('original');
expect(sharedContext.addedByHook).toBeUndefined();
});

it('should isolate context between concurrent requests', async () => {
const contexts = [];

Parse.Cloud.beforeSave('ConcurrentContextObject', req => {
// Each request should see its own context, not a shared one
req.context.requestId = req.object.get('requestId');
contexts.push({ ...req.context });
});

const sharedContext = { shared: true };

await Promise.all([
RESTController.request(
'POST',
'/classes/ConcurrentContextObject',
{ requestId: 'req1' },
{ context: sharedContext }
),
RESTController.request(
'POST',
'/classes/ConcurrentContextObject',
{ requestId: 'req2' },
{ context: sharedContext }
),
]);

// Each hook should have seen its own requestId, not the other's
const req1Context = contexts.find(c => c.requestId === 'req1');
const req2Context = contexts.find(c => c.requestId === 'req2');
expect(req1Context).toBeDefined();
expect(req2Context).toBeDefined();
expect(req1Context.requestId).toEqual('req1');
expect(req2Context.requestId).toEqual('req2');
// Original context must remain unchanged
expect(sharedContext.requestId).toBeUndefined();
});

it('should reject with an error when context contains non-cloneable values', async () => {
const nonCloneableContext = { fn: () => {} };
try {
await RESTController.request(
'POST',
'/classes/MyObject',
{ key: 'value' },
{ context: nonCloneableContext }
);
fail('should have rejected for non-cloneable context');
} catch (error) {
expect(error).toBeDefined();
expect(error.name).toEqual('DataCloneError');
}
});

it('ensures sessionTokens are properly handled', async () => {
const user = await Parse.User.signUp('user', 'pass');
const sessionToken = user.getSessionToken();
Expand Down
9 changes: 8 additions & 1 deletion src/ParseServerRESTController.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ function ParseServerRESTController(applicationId, router) {
}

return new Promise((resolve, reject) => {
let requestContext;
try {
requestContext = structuredClone(options.context || {});
} catch (error) {
reject(error);
return;
}
getAuth(options, config).then(auth => {
const request = {
body: data,
Expand All @@ -120,7 +127,7 @@ function ParseServerRESTController(applicationId, router) {
applicationId: applicationId,
sessionToken: options.sessionToken,
installationId: options.installationId,
context: options.context || {},
context: requestContext,
},
query,
};
Expand Down
Loading