Files
parse-community-parse-server/spec/ParseLiveQuery.spec.js
T

2008 lines
64 KiB
JavaScript

'use strict';
const http = require('http');
const Auth = require('../lib/Auth');
const UserController = require('../lib/Controllers/UserController').UserController;
const Config = require('../lib/Config');
const ParseServer = require('../lib/index').ParseServer;
const triggers = require('../lib/triggers');
const { resolvingPromise, sleep, getConnectionsCount } = require('../lib/TestUtils');
const request = require('../lib/request');
const validatorFail = () => {
throw 'you are not authorized';
};
describe('ParseLiveQuery', function () {
beforeEach(() => {
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
});
afterEach(async () => {
const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient();
await client.close();
});
it('access user on onLiveQueryEvent disconnect', async done => {
const requestedUser = new Parse.User();
requestedUser.setUsername('username');
requestedUser.setPassword('password');
Parse.Cloud.onLiveQueryEvent(req => {
const { event, sessionToken } = req;
if (event === 'ws_disconnect') {
Parse.Cloud._removeAllHooks();
expect(sessionToken).toBeDefined();
expect(sessionToken).toBe(requestedUser.getSessionToken());
done();
}
});
await requestedUser.signUp();
const query = new Parse.Query(TestObject);
await query.subscribe();
const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient();
await client.close();
});
it('can subscribe to query', async done => {
const object = new TestObject();
await object.save();
const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
const subscription = await query.subscribe();
subscription.on('update', object => {
expect(object.get('foo')).toBe('bar');
done();
});
object.set({ foo: 'bar' });
await object.save();
});
it('can use patterns in className', async done => {
await reconfigureServer({
liveQuery: {
classNames: ['Test.*'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const object = new TestObject();
await object.save();
const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
const subscription = await query.subscribe();
subscription.on('update', object => {
expect(object.get('foo')).toBe('bar');
done();
});
object.set({ foo: 'bar' });
await object.save();
});
it('expect afterEvent create', async done => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
Parse.Cloud.afterLiveQueryEvent('TestObject', req => {
expect(req.event).toBe('create');
expect(req.user).toBeUndefined();
expect(req.object.get('foo')).toBe('bar');
});
const query = new Parse.Query(TestObject);
const subscription = await query.subscribe();
subscription.on('create', object => {
expect(object.get('foo')).toBe('bar');
done();
});
const object = new TestObject();
object.set('foo', 'bar');
await object.save();
});
it('expect afterEvent payload', async done => {
const object = new TestObject();
await object.save();
Parse.Cloud.afterLiveQueryEvent('TestObject', req => {
expect(req.event).toBe('update');
expect(req.user).toBeUndefined();
expect(req.object.get('foo')).toBe('bar');
expect(req.original.get('foo')).toBeUndefined();
done();
});
const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
await query.subscribe();
object.set({ foo: 'bar' });
await object.save();
});
it('expect afterEvent enter', async done => {
Parse.Cloud.afterLiveQueryEvent('TestObject', req => {
expect(req.event).toBe('enter');
expect(req.user).toBeUndefined();
expect(req.object.get('foo')).toBe('bar');
expect(req.original.get('foo')).toBeUndefined();
});
const object = new TestObject();
await object.save();
const query = new Parse.Query(TestObject);
query.equalTo('foo', 'bar');
const subscription = await query.subscribe();
subscription.on('enter', object => {
expect(object.get('foo')).toBe('bar');
done();
});
object.set('foo', 'bar');
await object.save();
});
it('expect afterEvent leave', async done => {
Parse.Cloud.afterLiveQueryEvent('TestObject', req => {
expect(req.event).toBe('leave');
expect(req.user).toBeUndefined();
expect(req.object.get('foo')).toBeUndefined();
expect(req.original.get('foo')).toBe('bar');
});
const object = new TestObject();
object.set('foo', 'bar');
await object.save();
const query = new Parse.Query(TestObject);
query.equalTo('foo', 'bar');
const subscription = await query.subscribe();
subscription.on('leave', object => {
expect(object.get('foo')).toBeUndefined();
done();
});
object.unset('foo');
await object.save();
});
it('expect afterEvent delete', async done => {
Parse.Cloud.afterLiveQueryEvent('TestObject', req => {
expect(req.event).toBe('delete');
expect(req.user).toBeUndefined();
req.object.set('foo', 'bar');
});
const object = new TestObject();
await object.save();
const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
const subscription = await query.subscribe();
subscription.on('delete', object => {
expect(object.get('foo')).toBe('bar');
done();
});
await object.destroy();
});
it('can handle afterEvent modification', async done => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const object = new TestObject();
await object.save();
Parse.Cloud.afterLiveQueryEvent('TestObject', req => {
const current = req.object;
current.set('foo', 'yolo');
const original = req.original;
original.set('yolo', 'foo');
});
const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
const subscription = await query.subscribe();
subscription.on('update', (object, original) => {
expect(object.get('foo')).toBe('yolo');
expect(original.get('yolo')).toBe('foo');
done();
});
object.set({ foo: 'bar' });
await object.save();
});
it('can return different object in afterEvent', async done => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const object = new TestObject();
await object.save();
Parse.Cloud.afterLiveQueryEvent('TestObject', req => {
const object = new Parse.Object('Yolo');
req.object = object;
});
const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
const subscription = await query.subscribe();
subscription.on('update', object => {
expect(object.className).toBe('Yolo');
done();
});
object.set({ foo: 'bar' });
await object.save();
});
it('can handle afterEvent throw', async done => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const object = new TestObject();
await object.save();
Parse.Cloud.afterLiveQueryEvent('TestObject', () => {
throw 'Throw error from LQ afterEvent.';
});
const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
const subscription = await query.subscribe();
subscription.on('update', () => {
fail('update should not have been called.');
});
subscription.on('error', e => {
expect(e).toBe('Throw error from LQ afterEvent.');
done();
});
object.set({ foo: 'bar' });
await object.save();
});
it('can log on afterLiveQueryEvent throw', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const object = new TestObject();
await object.save();
const logger = require('../lib/logger').logger;
spyOn(logger, 'error').and.callFake(() => {});
let session = undefined;
Parse.Cloud.afterLiveQueryEvent('TestObject', ({ sessionToken }) => {
session = sessionToken;
/* eslint-disable no-undef */
foo.bar();
/* eslint-enable no-undef */
});
const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
const subscription = await query.subscribe();
object.set({ foo: 'bar' });
await object.save();
await new Promise(resolve => subscription.on('error', resolve));
expect(logger.error).toHaveBeenCalledWith(
`Failed running afterLiveQueryEvent on class TestObject for event update with session ${session} with:\n Error: {"message":"foo is not defined","code":141}`
);
});
it('can handle afterEvent sendEvent to false', async () => {
const object = new TestObject();
await object.save();
const promise = resolvingPromise();
Parse.Cloud.afterLiveQueryEvent('TestObject', req => {
const current = req.object;
const original = req.original;
if (current.get('foo') != original.get('foo')) {
req.sendEvent = false;
}
promise.resolve();
});
const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
const subscription = await query.subscribe();
subscription.on('update', () => {
fail('update should not have been called.');
});
subscription.on('error', () => {
fail('error should not have been called.');
});
object.set({ foo: 'bar' });
await object.save();
await promise;
});
it('can handle live query with fields', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['Test'],
},
startLiveQueryServer: true,
});
const query = new Parse.Query('Test');
query.watch('yolo');
const subscription = await query.subscribe();
const spy = {
create(obj) {
if (!obj.get('yolo')) {
fail('create should not have been called');
}
},
update(object, original) {
if (object.get('yolo') === original.get('yolo')) {
fail('create should not have been called');
}
},
};
const createSpy = spyOn(spy, 'create').and.callThrough();
const updateSpy = spyOn(spy, 'update').and.callThrough();
subscription.on('create', spy.create);
subscription.on('update', spy.update);
const obj = new Parse.Object('Test');
obj.set('foo', 'bar');
await obj.save();
obj.set('foo', 'xyz');
obj.set('yolo', 'xyz');
await obj.save();
const obj2 = new Parse.Object('Test');
obj2.set('foo', 'bar');
obj2.set('yolo', 'bar');
await obj2.save();
obj2.set('foo', 'bart');
await obj2.save();
expect(createSpy).toHaveBeenCalledTimes(1);
expect(updateSpy).toHaveBeenCalledTimes(1);
});
it('can handle afterEvent set pointers', async done => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const object = new TestObject();
await object.save();
const secondObject = new Parse.Object('Test2');
secondObject.set('foo', 'bar');
await secondObject.save();
Parse.Cloud.afterLiveQueryEvent('TestObject', async ({ object }) => {
const query = new Parse.Query('Test2');
const obj = await query.first();
object.set('obj', obj);
});
const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
const subscription = await query.subscribe();
subscription.on('update', object => {
expect(object.get('obj')).toBeDefined();
expect(object.get('obj').get('foo')).toBe('bar');
done();
});
subscription.on('error', () => {
fail('error should not have been called.');
});
object.set({ foo: 'bar' });
await object.save();
});
it('can handle async afterEvent modification', async done => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const parent = new TestObject();
const child = new TestObject();
child.set('bar', 'foo');
await Parse.Object.saveAll([parent, child]);
Parse.Cloud.afterLiveQueryEvent('TestObject', async req => {
const current = req.object;
const pointer = current.get('child');
await pointer.fetch();
});
const query = new Parse.Query(TestObject);
query.equalTo('objectId', parent.id);
const subscription = await query.subscribe();
subscription.on('update', object => {
expect(object.get('child')).toBeDefined();
expect(object.get('child').get('bar')).toBe('foo');
done();
});
parent.set('child', child);
await parent.save();
});
it('can handle beforeConnect / beforeSubscribe hooks', async done => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
});
const object = new TestObject();
await object.save();
const hooks = {
beforeSubscribe(req) {
expect(req.op).toBe('subscribe');
expect(req.requestId).toBe(1);
expect(req.query).toBeDefined();
expect(req.user).toBeUndefined();
},
beforeConnect(req) {
expect(req.event).toBe('connect');
expect(req.clients).toBe(0);
expect(req.subscriptions).toBe(0);
expect(req.useMasterKey).toBe(false);
expect(req.installationId).toBeDefined();
expect(req.user).toBeUndefined();
expect(req.client).toBeDefined();
},
};
spyOn(hooks, 'beforeSubscribe').and.callThrough();
spyOn(hooks, 'beforeConnect').and.callThrough();
Parse.Cloud.beforeSubscribe('TestObject', hooks.beforeSubscribe);
Parse.Cloud.beforeConnect(hooks.beforeConnect);
const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
const subscription = await query.subscribe();
subscription.on('update', object => {
expect(object.get('foo')).toBe('bar');
expect(hooks.beforeConnect).toHaveBeenCalled();
expect(hooks.beforeSubscribe).toHaveBeenCalled();
done();
});
object.set({ foo: 'bar' });
await object.save();
});
it('can handle beforeConnect validation function', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
});
const object = new TestObject();
await object.save();
Parse.Cloud.beforeConnect(() => {}, validatorFail);
const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
await expectAsync(query.subscribe()).toBeRejectedWith(
new Parse.Error(Parse.Error.VALIDATION_ERROR, 'you are not authorized')
);
});
it('can handle beforeSubscribe validation function', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
});
const object = new TestObject();
await object.save();
Parse.Cloud.beforeSubscribe(TestObject, () => {}, validatorFail);
const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
await expectAsync(query.subscribe()).toBeRejectedWith(
new Parse.Error(Parse.Error.VALIDATION_ERROR, 'you are not authorized')
);
});
it('can handle afterEvent validation function', async done => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
Parse.Cloud.afterLiveQueryEvent('TestObject', () => {}, validatorFail);
const query = new Parse.Query(TestObject);
const subscription = await query.subscribe();
subscription.on('error', error => {
expect(error).toBe('you are not authorized');
done();
});
const object = new TestObject();
object.set('foo', 'bar');
await object.save();
});
it('can handle beforeConnect error', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
});
const object = new TestObject();
await object.save();
Parse.Cloud.beforeConnect(() => {
throw new Error('You shall not pass!');
});
const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
await expectAsync(query.subscribe()).toBeRejectedWith(new Error('You shall not pass!'));
});
it('can log on beforeConnect throw', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
});
const logger = require('../lib/logger').logger;
spyOn(logger, 'error').and.callFake(() => {});
let token = undefined;
Parse.Cloud.beforeConnect(({ sessionToken }) => {
token = sessionToken;
/* eslint-disable no-undef */
foo.bar();
/* eslint-enable no-undef */
});
await expectAsync(new Parse.Query(TestObject).subscribe()).toBeRejectedWith(
new Error('foo is not defined')
);
expect(logger.error).toHaveBeenCalledWith(
`Failed running beforeConnect for session ${token} with:\n Error: {"message":"foo is not defined","code":141}`
);
});
it('can handle beforeSubscribe error', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
});
const object = new TestObject();
await object.save();
Parse.Cloud.beforeSubscribe(TestObject, () => {
throw new Error('You shall not subscribe!');
});
const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
await expectAsync(query.subscribe()).toBeRejectedWith(new Error('You shall not subscribe!'));
});
it('can log on beforeSubscribe error', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
});
const logger = require('../lib/logger').logger;
spyOn(logger, 'error').and.callFake(() => {});
Parse.Cloud.beforeSubscribe(TestObject, () => {
/* eslint-disable no-undef */
foo.bar();
/* eslint-enable no-undef */
});
const query = new Parse.Query(TestObject);
await expectAsync(query.subscribe()).toBeRejectedWith(new Error('foo is not defined'));
expect(logger.error).toHaveBeenCalledWith(
`Failed running beforeSubscribe on TestObject for session undefined with:\n Error: {"message":"foo is not defined","code":141}`
);
});
it('rejects subscription with invalid $regex pattern', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const query = new Parse.Query('TestObject');
query._where = { foo: { $regex: '[invalid' } };
await expectAsync(query.subscribe()).toBeRejectedWithError(/Invalid regular expression/);
});
it('rejects subscription with non-string $regex value', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const query = new Parse.Query('TestObject');
query._where = { foo: { $regex: 123 } };
await expectAsync(query.subscribe()).toBeRejectedWithError(
/\$regex must be a string or RegExp/
);
});
it('does not crash server when subscription matching throws and other subscriptions still work', async () => {
const server = await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const object = new TestObject();
object.set('foo', 'bar');
await object.save();
// Create a valid subscription
const validQuery = new Parse.Query('TestObject');
validQuery.equalTo('objectId', object.id);
const validSubscription = await validQuery.subscribe();
// Inject a malformed subscription directly into the LiveQuery server
// to bypass subscribe-time validation and test the try-catch in _onAfterSave
const lqServer = server.liveQueryServer;
const Subscription = require('../lib/LiveQuery/Subscription').Subscription;
const badSubscription = new Subscription('TestObject', { foo: { $regex: '[invalid' } });
badSubscription.addClientSubscription('fakeClientId', 'fakeRequestId');
const classSubscriptions = lqServer.subscriptions.get('TestObject');
classSubscriptions.set('bad-hash', badSubscription);
// Verify the valid subscription still receives updates despite the bad subscription
const updatePromise = new Promise(resolve => {
validSubscription.on('update', obj => {
expect(obj.get('foo')).toBe('baz');
resolve();
});
});
object.set('foo', 'baz');
await object.save();
await updatePromise;
// Clean up the injected subscription
classSubscriptions.delete('bad-hash');
});
it('can handle mutate beforeSubscribe query', async done => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
});
const hook = {
beforeSubscribe(request) {
request.query.equalTo('yolo', 'abc');
},
};
spyOn(hook, 'beforeSubscribe').and.callThrough();
Parse.Cloud.beforeSubscribe('TestObject', hook.beforeSubscribe);
const object = new TestObject();
await object.save();
const query = new Parse.Query('TestObject');
query.equalTo('objectId', object.id);
const subscription = await query.subscribe();
subscription.on('update', () => {
fail('beforeSubscribe should restrict subscription');
});
subscription.on('enter', object => {
if (object.get('yolo') === 'abc') {
done();
} else {
fail('beforeSubscribe should restrict queries');
}
});
object.set({ yolo: 'bar' });
await object.save();
object.set({ yolo: 'abc' });
await object.save();
expect(hook.beforeSubscribe).toHaveBeenCalled();
});
it('can return a new beforeSubscribe query', async done => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
Parse.Cloud.beforeSubscribe(TestObject, request => {
const query = new Parse.Query(TestObject);
query.equalTo('foo', 'yolo');
request.query = query;
});
const query = new Parse.Query(TestObject);
query.equalTo('foo', 'bar');
const subscription = await query.subscribe();
subscription.on('create', object => {
expect(object.get('foo')).toBe('yolo');
done();
});
const object = new TestObject();
object.set({ foo: 'yolo' });
await object.save();
});
it('can handle select beforeSubscribe query', async done => {
Parse.Cloud.beforeSubscribe(TestObject, request => {
const query = request.query;
query.select('yolo');
});
const object = new TestObject();
await object.save();
const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
const subscription = await query.subscribe();
subscription.on('update', object => {
expect(object.get('foo')).toBeUndefined();
expect(object.get('yolo')).toBe('abc');
done();
});
object.set({ foo: 'bar', yolo: 'abc' });
await object.save();
});
it('LiveQuery with ACL', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['Chat'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const user = new Parse.User();
user.setUsername('username');
user.setPassword('password');
await user.signUp();
const calls = {
beforeConnect(req) {
expect(req.event).toBe('connect');
expect(req.clients).toBe(0);
expect(req.subscriptions).toBe(0);
expect(req.useMasterKey).toBe(false);
expect(req.installationId).toBeDefined();
expect(req.client).toBeDefined();
},
beforeSubscribe(req) {
expect(req.op).toBe('subscribe');
expect(req.requestId).toBe(1);
expect(req.query).toBeDefined();
expect(req.user).toBeDefined();
},
afterLiveQueryEvent(req) {
expect(req.user).toBeDefined();
expect(req.object.get('foo')).toBe('bar');
},
create(object) {
expect(object.get('foo')).toBe('bar');
},
delete(object) {
expect(object.get('foo')).toBe('bar');
},
};
for (const key in calls) {
spyOn(calls, key).and.callThrough();
}
Parse.Cloud.beforeConnect(calls.beforeConnect);
Parse.Cloud.beforeSubscribe('Chat', calls.beforeSubscribe);
Parse.Cloud.afterLiveQueryEvent('Chat', calls.afterLiveQueryEvent);
const chatQuery = new Parse.Query('Chat');
const subscription = await chatQuery.subscribe();
subscription.on('create', calls.create);
subscription.on('delete', calls.delete);
const object = new Parse.Object('Chat');
const acl = new Parse.ACL(user);
object.setACL(acl);
object.set({ foo: 'bar' });
await object.save();
await object.destroy();
await sleep(200);
for (const key in calls) {
expect(calls[key]).toHaveBeenCalled();
}
});
it('LiveQuery should work with changing role', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['Chat'],
},
startLiveQueryServer: true,
});
const user = new Parse.User();
user.setUsername('username');
user.setPassword('password');
await user.signUp();
const role = new Parse.Role('Test', new Parse.ACL(user));
await role.save();
const chatQuery = new Parse.Query('Chat');
const subscription = await chatQuery.subscribe();
subscription.on('create', () => {
fail('should not call create as user is not part of role.');
});
const object = new Parse.Object('Chat');
const acl = new Parse.ACL();
acl.setRoleReadAccess(role, true);
object.setACL(acl);
object.set({ foo: 'bar' });
await object.save(null, { useMasterKey: true });
role.getUsers().add(user);
await sleep(1000);
await role.save();
await sleep(1000);
object.set('foo', 'yolo');
await Promise.all([
new Promise(resolve => {
subscription.on('update', obj => {
expect(obj.get('foo')).toBe('yolo');
expect(obj.getACL().toJSON()).toEqual({ 'role:Test': { read: true } });
resolve();
});
}),
object.save(null, { useMasterKey: true }),
]);
});
it('liveQuery on Session class', async () => {
await reconfigureServer({
liveQuery: { classNames: [Parse.Session] },
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const user = new Parse.User();
user.setUsername('username');
user.setPassword('password');
await user.signUp();
const query = new Parse.Query(Parse.Session);
const subscription = await query.subscribe();
const createEvent = new Promise(resolve => {
subscription.on('create', resolve);
});
await Parse.User.logIn('username', 'password');
const obj = await createEvent;
expect(obj.get('user').id).toBe(user.id);
expect(obj.get('createdWith')).toEqual({ action: 'login', authProvider: 'password' });
expect(obj.get('expiresAt')).toBeInstanceOf(Date);
expect(obj.get('installationId')).toBeDefined();
expect(obj.get('createdAt')).toBeInstanceOf(Date);
expect(obj.get('updatedAt')).toBeInstanceOf(Date);
});
it('prevent liveQuery on Session class when not logged in', async () => {
await reconfigureServer({
liveQuery: {
classNames: [Parse.Session],
},
startLiveQueryServer: true,
});
const query = new Parse.Query(Parse.Session);
await expectAsync(query.subscribe()).toBeRejectedWith(new Error('Invalid session token'));
});
it_id('4ccc9508-ae6a-46ec-932a-9f5e49ab3b9e')(it)('handle invalid websocket payload length', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
websocketTimeout: 100,
});
const object = new TestObject();
await object.save();
const query = new Parse.Query(TestObject);
query.equalTo('objectId', object.id);
const subscription = await query.subscribe();
// All control frames must have a payload length of 125 bytes or less.
// https://tools.ietf.org/html/rfc6455#section-5.5
//
// 0x89 = 10001001 = ping
// 0xfe = 11111110 = first bit is masking the remaining 7 are 1111110 or 126 the payload length
// https://tools.ietf.org/html/rfc6455#section-5.2
const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient();
// Wait for the initial subscription 'open' event (fires 200ms after subscribe)
// before sending the invalid frame, so we don't confuse it with the reconnection 'open'
await new Promise(resolve => subscription.on('open', resolve));
// Now listen for close followed by reopen from the reconnection cycle
const reopened = new Promise(resolve => {
subscription.on('close', () => {
subscription.on('open', resolve);
});
});
client.socket._socket.write(Buffer.from([0x89, 0xfe]));
await reopened;
// After reconnection, save an update and verify the subscription receives it
const updated = new Promise(resolve => {
subscription.on('update', object => {
expect(object.get('foo')).toBe('bar');
resolve();
});
});
object.set({ foo: 'bar' });
await object.save();
await updated;
});
it_id('39a9191f-26dd-4e05-a379-297a67928de8')(it)('should execute live query update on email validation', async done => {
const emailAdapter = {
sendVerificationEmail: () => {},
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => {},
};
await reconfigureServer({
maintenanceKey: 'test2',
liveQuery: {
classNames: [Parse.User],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
websocketTimeout: 100,
appName: 'liveQueryEmailValidation',
verifyUserEmails: true,
emailAdapter: emailAdapter,
emailVerifyTokenValidityDuration: 20, // 0.5 second
publicServerURL: 'http://localhost:8378/1',
}).then(() => {
const user = new Parse.User();
user.set('password', 'asdf');
user.set('email', 'asdf@example.com');
user.set('username', 'zxcv');
user
.signUp()
.then(() => {
const config = Config.get('test');
return config.database.find(
'_User',
{
username: 'zxcv',
},
{},
Auth.maintenance(config)
);
})
.then(async results => {
const foundUser = results[0];
const query = new Parse.Query('_User');
query.equalTo('objectId', foundUser.objectId);
const subscription = await query.subscribe();
subscription.on('update', async object => {
expect(object).toBeDefined();
expect(object.get('emailVerified')).toBe(true);
done();
});
const userController = new UserController(emailAdapter, 'test', {
verifyUserEmails: true,
});
userController.verifyEmail(foundUser._email_verify_token);
});
});
});
it('should not broadcast event to client with invalid session token - avisory GHSA-2xm2-xj2q-qgpj', async done => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
liveQueryServerOptions: {
cacheTimeout: 100,
},
startLiveQueryServer: true,
verbose: false,
silent: true,
cacheTTL: 100,
});
const user = new Parse.User();
user.setUsername('username');
user.setPassword('password');
await user.signUp();
const obj1 = new Parse.Object('TestObject');
const obj1ACL = new Parse.ACL();
obj1ACL.setPublicReadAccess(false);
obj1ACL.setReadAccess(user, true);
obj1.setACL(obj1ACL);
const obj2 = new Parse.Object('TestObject');
const obj2ACL = new Parse.ACL();
obj2ACL.setPublicReadAccess(false);
obj2ACL.setReadAccess(user, true);
obj2.setACL(obj2ACL);
const query = new Parse.Query('TestObject');
const subscription = await query.subscribe();
subscription.on('create', obj => {
if (obj.id !== obj1.id) {
done.fail('should not fire');
}
});
await obj1.save();
await Parse.User.logOut();
await new Promise(resolve => setTimeout(resolve, 200));
await obj2.save();
await new Promise(resolve => setTimeout(resolve, 200));
done();
});
it('should strip out session token in LiveQuery', async () => {
await reconfigureServer({
liveQuery: { classNames: ['_User'] },
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const user = new Parse.User();
user.setUsername('username');
user.setPassword('password');
user.set('foo', 'bar');
const acl = new Parse.ACL();
acl.setPublicReadAccess(true);
user.setACL(acl);
const query = new Parse.Query(Parse.User);
query.equalTo('foo', 'bar');
const subscription = await query.subscribe();
const events = ['create', 'update', 'enter', 'leave', 'delete'];
const response = (obj, prev) => {
expect(obj.get('sessionToken')).toBeUndefined();
expect(obj.sessionToken).toBeUndefined();
expect(prev && prev.sessionToken).toBeUndefined();
if (prev && prev.get) {
expect(prev.get('sessionToken')).toBeUndefined();
}
};
const calls = {};
for (const key of events) {
calls[key] = response;
spyOn(calls, key).and.callThrough();
subscription.on(key, calls[key]);
}
await user.signUp();
user.unset('foo');
await user.save();
user.set('foo', 'bar');
await user.save();
user.set('yolo', 'bar');
await user.save();
await user.destroy();
await new Promise(resolve => setTimeout(resolve, 10));
for (const key of events) {
expect(calls[key]).toHaveBeenCalled();
}
});
it('should strip out protected fields', async () => {
await reconfigureServer({
liveQuery: { classNames: ['Test'] },
startLiveQueryServer: true,
});
const obj1 = new Parse.Object('Test');
obj1.set('foo', 'foo');
obj1.set('bar', 'bar');
obj1.set('qux', 'qux');
await obj1.save();
const config = Config.get(Parse.applicationId);
const schemaController = await config.database.loadSchema();
await schemaController.updateClass(
'Test',
{},
{
get: { '*': true },
find: { '*': true },
update: { '*': true },
protectedFields: {
'*': ['foo'],
},
}
);
const object = await obj1.fetch();
expect(object.get('foo')).toBe(undefined);
expect(object.get('bar')).toBeDefined();
expect(object.get('qux')).toBeDefined();
const subscription = await new Parse.Query('Test').subscribe();
await Promise.all([
new Promise(resolve => {
subscription.on('update', (obj, original) => {
expect(obj.get('foo')).toBe(undefined);
expect(obj.get('bar')).toBeDefined();
expect(obj.get('qux')).toBeDefined();
expect(original.get('foo')).toBe(undefined);
expect(original.get('bar')).toBeDefined();
expect(original.get('qux')).toBeDefined();
resolve();
});
}),
obj1.save({ foo: 'abc' }),
]);
});
it('can subscribe to query and return object with withinKilometers with last parameter on update', async done => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const object = new TestObject();
const firstPoint = new Parse.GeoPoint({ latitude: 40.0, longitude: -30.0 });
object.set({ location: firstPoint });
await object.save();
// unsorted will use $centerSphere operator
const sorted = false;
const query = new Parse.Query(TestObject);
query.withinKilometers(
'location',
new Parse.GeoPoint({ latitude: 40.0, longitude: -30.0 }),
2,
sorted
);
const subscription = await query.subscribe();
subscription.on('update', obj => {
expect(obj.id).toBe(object.id);
done();
});
const secondPoint = new Parse.GeoPoint({ latitude: 40.0, longitude: -30.0 });
object.set({ location: secondPoint });
await object.save();
});
it_id('2f95d8a9-7675-45ba-a4a6-e45cb7efb1fb')(it)('does shutdown liveQuery server', async () => {
await reconfigureServer({ appId: 'test_app_id' });
const config = {
appId: 'hello_test',
masterKey: 'world',
port: 1345,
mountPath: '/1',
serverURL: 'http://localhost:1345/1',
liveQuery: {
classNames: ['Yolo'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
};
if (process.env.PARSE_SERVER_TEST_DB === 'postgres') {
config.databaseAdapter = new databaseAdapter.constructor({
uri: databaseURI,
collectionPrefix: 'test_',
});
config.filesAdapter = defaultConfiguration.filesAdapter;
}
const server = await ParseServer.startApp(config);
const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient();
client.serverURL = 'ws://localhost:1345/1';
const query = await new Parse.Query('Yolo').subscribe();
let liveQueryConnectionCount = await getConnectionsCount(server.liveQueryServer.server);
expect(liveQueryConnectionCount > 0).toBe(true);
await Promise.all([
server.handleShutdown(),
new Promise(resolve => query.on('close', resolve)),
]);
await sleep(100);
expect(server.liveQueryServer.server.address()).toBeNull();
expect(server.liveQueryServer.subscriber.isOpen).toBeFalse();
liveQueryConnectionCount = await getConnectionsCount(server.liveQueryServer.server);
expect(liveQueryConnectionCount).toBe(0);
});
it_id('45655b74-716f-4fa1-a058-67eb21f3c3db')(it)('does shutdown separate liveQuery server', async () => {
await reconfigureServer({ appId: 'test_app_id' });
let close = false;
const config = {
appId: 'hello_test',
masterKey: 'world',
port: 1345,
mountPath: '/1',
serverURL: 'http://localhost:1345/1',
liveQuery: {
classNames: ['Yolo'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
liveQueryServerOptions: {
port: 1346,
},
serverCloseComplete: () => {
close = true;
},
};
if (process.env.PARSE_SERVER_TEST_DB === 'postgres') {
config.databaseAdapter = new databaseAdapter.constructor({
uri: databaseURI,
collectionPrefix: 'test_',
});
config.filesAdapter = defaultConfiguration.filesAdapter;
}
const parseServer = await ParseServer.startApp(config);
expect(parseServer.liveQueryServer).toBeDefined();
expect(parseServer.liveQueryServer.server).not.toBe(parseServer.server);
// Open a connection to the liveQuery server
const client = await Parse.CoreManager.getLiveQueryController().getDefaultLiveQueryClient();
client.serverURL = 'ws://localhost:1346/1';
const query = await new Parse.Query('Yolo').subscribe();
// Open a connection to the parse server
const health = await request({
method: 'GET',
url: `http://localhost:1345/1/health`,
json: true,
headers: {
'X-Parse-Application-Id': 'hello_test',
'X-Parse-Master-Key': 'world',
'Content-Type': 'application/json',
},
agent: new http.Agent({ keepAlive: true }),
}).then(res => res.data);
expect(health.status).toBe('ok');
let parseConnectionCount = await getConnectionsCount(parseServer.server);
let liveQueryConnectionCount = await getConnectionsCount(parseServer.liveQueryServer.server);
expect(parseConnectionCount > 0).toBe(true);
expect(liveQueryConnectionCount > 0).toBe(true);
await Promise.all([
parseServer.handleShutdown(),
new Promise(resolve => query.on('close', resolve)),
]);
expect(close).toBe(true);
await sleep(100);
expect(parseServer.liveQueryServer.server.address()).toBeNull();
expect(parseServer.liveQueryServer.subscriber.isOpen).toBeFalse();
parseConnectionCount = await getConnectionsCount(parseServer.server);
liveQueryConnectionCount = await getConnectionsCount(parseServer.liveQueryServer.server);
expect(parseConnectionCount).toBe(0);
expect(liveQueryConnectionCount).toBe(0);
});
it('prevent afterSave trigger if not exists', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
spyOn(triggers, 'maybeRunTrigger').and.callThrough();
const object1 = new TestObject();
const object2 = new TestObject();
const object3 = new TestObject();
await Parse.Object.saveAll([object1, object2, object3]);
expect(triggers.maybeRunTrigger).toHaveBeenCalledTimes(0);
expect(object1.id).toBeDefined();
expect(object2.id).toBeDefined();
expect(object3.id).toBeDefined();
});
it('triggers query event with constraint not equal to null', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['TestObject'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const spy = {
create(obj) {
expect(obj.attributes.foo).toEqual('bar');
},
};
const createSpy = spyOn(spy, 'create');
const query = new Parse.Query(TestObject);
query.notEqualTo('foo', null);
const subscription = await query.subscribe();
subscription.on('create', spy.create);
const object1 = new TestObject();
object1.set('foo', 'bar');
await object1.save();
await new Promise(resolve => setTimeout(resolve, 100));
expect(createSpy).toHaveBeenCalledTimes(1);
});
describe('class level permissions', () => {
async function setPermissionsOnClass(className, permissions, doPut) {
const method = doPut ? 'PUT' : 'POST';
const response = await fetch(Parse.serverURL + '/schemas/' + className, {
method,
headers: {
'X-Parse-Application-Id': Parse.applicationId,
'X-Parse-Master-Key': Parse.masterKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
classLevelPermissions: permissions,
}),
});
const body = await response.json();
if (body.error) {
throw body;
}
return body;
}
it('delivers LiveQuery event to authenticated client when CLP allows find', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['SecureChat'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const user = new Parse.User();
user.setUsername('admin');
user.setPassword('password');
await user.signUp();
await setPermissionsOnClass('SecureChat', {
create: { '*': true },
find: { [user.id]: true },
});
// Subscribe as the authorized user
const query = new Parse.Query('SecureChat');
const subscription = await query.subscribe(user.getSessionToken());
const spy = jasmine.createSpy('create');
subscription.on('create', spy);
const obj = new Parse.Object('SecureChat');
obj.set('secret', 'data');
await obj.save(null, { useMasterKey: true });
await sleep(500);
expect(spy).toHaveBeenCalledTimes(1);
});
it('rejects LiveQuery subscription when CLP denies find at subscription time', async () => {
await reconfigureServer({
liveQuery: {
classNames: ['SecureChat'],
},
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const user = new Parse.User();
user.setUsername('admin');
user.setPassword('password');
await user.signUp();
await setPermissionsOnClass('SecureChat', {
create: { '*': true },
find: { [user.id]: true },
});
// Log out so subscription is unauthenticated
await Parse.User.logOut();
const query = new Parse.Query('SecureChat');
await expectAsync(query.subscribe()).toBeRejected();
});
});
});
describe('ParseLiveQuery duplicate requestId handling', function () {
const WebSocket = require('ws');
const waitFor = async predicate => {
const deadline = Date.now() + 4000;
while (Date.now() < deadline) {
if (predicate()) {
return;
}
await sleep(20);
}
throw new Error('timed out waiting for condition');
};
let sockets;
beforeEach(() => {
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
sockets = [];
});
afterEach(() => {
for (const socket of sockets) {
if (socket.readyState === WebSocket.OPEN) {
socket.close();
}
}
sockets = [];
});
const configureServer = async () => {
const parseServer = await reconfigureServer({
liveQuery: { classNames: ['LQDupA', 'LQDupB'] },
startLiveQueryServer: true,
verbose: false,
silent: true,
});
return parseServer.liveQueryServer;
};
// Opens a raw LiveQuery WebSocket client and returns a small protocol helper.
const openClient = async () => {
const socket = new WebSocket('ws://localhost:8378/1');
sockets.push(socket);
const messages = [];
socket.on('message', data => messages.push(JSON.parse(data.toString())));
await new Promise((resolve, reject) => {
socket.on('open', resolve);
socket.on('error', reject);
});
socket.send(JSON.stringify({ op: 'connect', applicationId: Parse.applicationId }));
const client = {
socket,
messages,
subscribe(requestId, className, where) {
socket.send(JSON.stringify({ op: 'subscribe', requestId, query: { className, where } }));
},
update(requestId, className, where) {
socket.send(JSON.stringify({ op: 'update', requestId, query: { className, where } }));
},
countOp(op) {
return messages.filter(message => message.op === op).length;
},
waitForOpCount(op, count) {
return waitFor(() => this.countOp(op) === count);
},
};
await waitFor(() => messages.some(message => message.op === 'connected'));
return client;
};
it('replaces rather than leaks subscriptions when a client reuses a requestId with different queries', async () => {
const lqServer = await configureServer();
const client = await openClient();
for (let i = 0; i < 5; i++) {
client.subscribe(7, 'LQDupA', { marker: `ws-${i}` });
}
await client.waitForOpCount('subscribed', 5);
// Reusing one requestId must keep a single active subscription, not one per frame.
expect(lqServer.subscriptions.get('LQDupA').size).toBe(1);
client.socket.close();
await waitFor(() => lqServer.clients.size === 0);
// No stale subscriptions may survive the disconnect.
expect(lqServer.subscriptions.get('LQDupA')?.size ?? 0).toBe(0);
});
it('does not leak subscriptions when a client reuses a requestId with the same query', async () => {
const lqServer = await configureServer();
const client = await openClient();
for (let i = 0; i < 5; i++) {
client.subscribe(7, 'LQDupA', { marker: 'same' });
}
await client.waitForOpCount('subscribed', 5);
expect(lqServer.subscriptions.get('LQDupA').size).toBe(1);
client.socket.close();
await waitFor(() => lqServer.clients.size === 0);
expect(lqServer.subscriptions.get('LQDupA')?.size ?? 0).toBe(0);
});
it('cleans up the prior subscription when a client reuses a requestId on a different class', async () => {
const lqServer = await configureServer();
const client = await openClient();
client.subscribe(7, 'LQDupA', { marker: 'a' });
await client.waitForOpCount('subscribed', 1);
client.subscribe(7, 'LQDupB', { marker: 'b' });
await client.waitForOpCount('subscribed', 2);
client.subscribe(7, 'LQDupA', { marker: 'a2' });
await client.waitForOpCount('subscribed', 3);
// Only the most recent subscription survives; the prior class entry is pruned.
expect(lqServer.subscriptions.get('LQDupA').size).toBe(1);
expect(lqServer.subscriptions.has('LQDupB')).toBe(false);
client.socket.close();
await waitFor(() => lqServer.clients.size === 0);
expect(lqServer.subscriptions.get('LQDupA')?.size ?? 0).toBe(0);
expect(lqServer.subscriptions.has('LQDupB')).toBe(false);
});
it('does not tear down a subscription still held by another client when a client reuses a requestId', async () => {
const lqServer = await configureServer();
const clientA = await openClient();
const clientB = await openClient();
// Both clients share the same query, so they share one Subscription.
clientA.subscribe(7, 'LQDupA', { marker: 'shared' });
await clientA.waitForOpCount('subscribed', 1);
clientB.subscribe(9, 'LQDupA', { marker: 'shared' });
await clientB.waitForOpCount('subscribed', 1);
expect(lqServer.subscriptions.get('LQDupA').size).toBe(1);
// Client A reuses its requestId with a different query.
clientA.subscribe(7, 'LQDupA', { marker: 'other' });
await clientA.waitForOpCount('subscribed', 2);
// The shared subscription must survive (B still holds it), alongside A's new one.
expect(lqServer.subscriptions.get('LQDupA').size).toBe(2);
// The shared subscription still delivers events to B, but not to A anymore.
const shared = new Parse.Object('LQDupA');
shared.set('marker', 'shared');
await shared.save(null, { useMasterKey: true });
await clientB.waitForOpCount('create', 1);
expect(clientB.countOp('create')).toBe(1);
expect(clientA.countOp('create')).toBe(0);
clientA.socket.close();
clientB.socket.close();
await waitFor(() => lqServer.clients.size === 0);
expect(lqServer.subscriptions.get('LQDupA')?.size ?? 0).toBe(0);
});
it('delivers events only for the replacement query after a client reuses a requestId', async () => {
const lqServer = await configureServer();
const client = await openClient();
client.subscribe(7, 'LQDupA', { marker: 'old' });
await client.waitForOpCount('subscribed', 1);
client.subscribe(7, 'LQDupA', { marker: 'new' });
await client.waitForOpCount('subscribed', 2);
expect(lqServer.subscriptions.get('LQDupA').size).toBe(1);
const oldObject = new Parse.Object('LQDupA');
oldObject.set('marker', 'old');
await oldObject.save(null, { useMasterKey: true });
const newObject = new Parse.Object('LQDupA');
newObject.set('marker', 'new');
await newObject.save(null, { useMasterKey: true });
await client.waitForOpCount('create', 1);
// Only the replacement query (marker 'new') may produce an event.
expect(client.countOp('create')).toBe(1);
expect(client.messages.find(message => message.op === 'create').object.marker).toBe('new');
});
it('keeps the update op working after the duplicate-subscribe cleanup', async () => {
const lqServer = await configureServer();
const client = await openClient();
client.subscribe(7, 'LQDupA', { marker: 'old' });
await client.waitForOpCount('subscribed', 1);
client.update(7, 'LQDupA', { marker: 'new' });
await client.waitForOpCount('subscribed', 2);
expect(lqServer.subscriptions.get('LQDupA').size).toBe(1);
const updated = new Parse.Object('LQDupA');
updated.set('marker', 'new');
await updated.save(null, { useMasterKey: true });
await client.waitForOpCount('create', 1);
expect(client.countOp('create')).toBe(1);
client.socket.close();
await waitFor(() => lqServer.clients.size === 0);
expect(lqServer.subscriptions.get('LQDupA')?.size ?? 0).toBe(0);
});
});
describe('ParseLiveQuery cross-origin connection authorization', function () {
// CSWSH report (WSAdapter): LiveQuery auth is bound to the sessionToken in the
// `connect` message body, not to ambient/cookie credentials. A cross-origin page
// cannot read the victim's sessionToken, so its connection is anonymous and ACL
// filtering limits it to public-read objects only.
const WebSocket = require('ws');
const waitFor = async predicate => {
const deadline = Date.now() + 4000;
while (Date.now() < deadline) {
if (predicate()) {
return;
}
await sleep(20);
}
throw new Error('timed out waiting for condition');
};
let sockets;
beforeEach(() => {
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
sockets = [];
});
afterEach(() => {
for (const socket of sockets) {
if (socket.readyState === WebSocket.OPEN) {
socket.close();
}
}
sockets = [];
});
// Opens a raw LiveQuery WebSocket client with no session token, modeling a
// cross-origin page that has no access to the victim's session.
const openClient = async () => {
const socket = new WebSocket('ws://localhost:8378/1');
sockets.push(socket);
const messages = [];
socket.on('message', data => messages.push(JSON.parse(data.toString())));
await new Promise((resolve, reject) => {
socket.on('open', resolve);
socket.on('error', reject);
});
socket.send(JSON.stringify({ op: 'connect', applicationId: Parse.applicationId }));
const client = {
socket,
messages,
subscribe(requestId, className, where) {
socket.send(JSON.stringify({ op: 'subscribe', requestId, query: { className, where } }));
},
countOp(op) {
return messages.filter(message => message.op === op).length;
},
createdIds() {
return messages.filter(m => m.op === 'create').map(m => m.object && m.object.objectId);
},
waitForOpCount(op, count) {
return waitFor(() => this.countOp(op) === count);
},
};
await waitFor(() => messages.some(message => message.op === 'connected'));
return client;
};
it('does not deliver ACL-protected objects to a connection that presents no session token', async () => {
await reconfigureServer({
liveQuery: { classNames: ['CrossOriginChat'] },
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const victim = new Parse.User();
victim.setUsername('victim');
victim.setPassword('password');
await victim.signUp();
// The attacker page connects with no session token and subscribes to everything.
const attacker = await openClient();
attacker.subscribe(1, 'CrossOriginChat', {});
await attacker.waitForOpCount('subscribed', 1);
// A public-read object is delivered to the anonymous connection (proves the socket
// and subscription are live — the missing protected object below is a real denial,
// not a dead connection).
const publicObj = new Parse.Object('CrossOriginChat');
const publicACL = new Parse.ACL();
publicACL.setPublicReadAccess(true);
publicObj.setACL(publicACL);
publicObj.set('body', 'public');
await publicObj.save(null, { useMasterKey: true });
await attacker.waitForOpCount('create', 1);
expect(attacker.createdIds()).toEqual([publicObj.id]);
// The victim's private object (readable only by the victim) must never reach the
// attacker's session-less connection.
const secretObj = new Parse.Object('CrossOriginChat');
const secretACL = new Parse.ACL();
secretACL.setPublicReadAccess(false);
secretACL.setReadAccess(victim, true);
secretObj.setACL(secretACL);
secretObj.set('body', 'secret');
await secretObj.save(null, { useMasterKey: true });
// A second public save acts as an ordering barrier: LiveQuery delivers events on a
// subscription in publish order, so once this later object's `create` arrives, the
// earlier `secret` save has already had its chance. Asserting the exact id list is
// then deterministic rather than relying on a wall-clock window.
const publicObj2 = new Parse.Object('CrossOriginChat');
const publicACL2 = new Parse.ACL();
publicACL2.setPublicReadAccess(true);
publicObj2.setACL(publicACL2);
publicObj2.set('body', 'public-2');
await publicObj2.save(null, { useMasterKey: true });
await attacker.waitForOpCount('create', 2);
expect(attacker.createdIds()).toEqual([publicObj.id, publicObj2.id]);
});
});
describe('ParseLiveQuery ACL transition disclosure', function () {
const WebSocket = require('ws');
const waitFor = async predicate => {
const deadline = Date.now() + 6000;
while (Date.now() < deadline) {
if (predicate()) {
return;
}
await sleep(20);
}
throw new Error('timed out waiting for condition');
};
let sockets;
beforeEach(() => {
Parse.CoreManager.getLiveQueryController().setDefaultLiveQueryClient(null);
sockets = [];
});
afterEach(() => {
for (const socket of sockets) {
if (socket.readyState === WebSocket.OPEN) {
socket.close();
}
}
sockets = [];
});
// Opens a raw LiveQuery WebSocket client authenticated with the given session
// token so the exact wire payload of each event can be asserted directly.
const openClient = async sessionToken => {
const socket = new WebSocket('ws://localhost:8378/1');
sockets.push(socket);
const messages = [];
socket.on('message', data => messages.push(JSON.parse(data.toString())));
await new Promise((resolve, reject) => {
socket.on('open', resolve);
socket.on('error', reject);
});
socket.send(
JSON.stringify({ op: 'connect', applicationId: Parse.applicationId, sessionToken })
);
const client = {
socket,
messages,
subscribe(requestId, className, where) {
socket.send(
JSON.stringify({ op: 'subscribe', requestId, query: { className, where }, sessionToken })
);
},
messagesForOp(op) {
return messages.filter(message => message.op === op);
},
waitForOpCount(op, count) {
return waitFor(() => this.messagesForOp(op).length >= count);
},
};
await waitFor(() => messages.some(message => message.op === 'connected'));
return client;
};
it('does not leak the post-revocation object body in a leave event when a save revokes the subscriber ACL read access', async () => {
await reconfigureServer({
liveQuery: { classNames: ['TestObject'] },
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const user = new Parse.User();
user.setUsername('leave-acl-user');
user.setPassword('password');
await user.signUp();
// Object readable by the user, with an initial value.
const obj = new Parse.Object('TestObject');
const acl = new Parse.ACL();
acl.setPublicReadAccess(false);
acl.setReadAccess(user, true);
obj.setACL(acl);
obj.set('secretField', 'INITIAL');
await obj.save(null, { useMasterKey: true });
const client = await openClient(user.getSessionToken());
client.subscribe(1, 'TestObject', {});
await client.waitForOpCount('subscribed', 1);
// Control update: keep the user's ACL read access, only change the field. The
// user is still authorized and receives the new value via an update event.
await obj.save({ secretField: 'BENIGN_VISIBLE' }, { useMasterKey: true });
await client.waitForOpCount('update', 1);
expect(client.messagesForOp('update')[0].object.secretField).toBe('BENIGN_VISIBLE');
// Attack update: change the field AND remove the user's read access in the same save.
const revokedACL = new Parse.ACL();
revokedACL.setPublicReadAccess(false);
obj.setACL(revokedACL);
obj.set('secretField', 'POST_REVOCATION_SECRET');
await obj.save(null, { useMasterKey: true });
await client.waitForOpCount('leave', 1);
const leave = client.messagesForOp('leave')[0];
// The subscriber must not receive the post-revocation value they can no longer read.
expect(leave.object.secretField).not.toBe('POST_REVOCATION_SECRET');
// They receive the last value they were authorized to see.
expect(leave.object.secretField).toBe('BENIGN_VISIBLE');
});
it('does not leak the pre-grant original object body in an enter event when a save grants the subscriber ACL read access', async () => {
await reconfigureServer({
liveQuery: { classNames: ['TestObject'] },
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const user = new Parse.User();
user.setUsername('enter-acl-user');
user.setPassword('password');
await user.signUp();
// Object NOT readable by the user, with a pre-grant value.
const obj = new Parse.Object('TestObject');
const noAccessACL = new Parse.ACL();
noAccessACL.setPublicReadAccess(false);
obj.setACL(noAccessACL);
obj.set('secretField', 'PRE_GRANT_SECRET');
await obj.save(null, { useMasterKey: true });
const client = await openClient(user.getSessionToken());
client.subscribe(1, 'TestObject', {});
await client.waitForOpCount('subscribed', 1);
// Grant update: change the field AND add the user's read access in the same save.
const grantedACL = new Parse.ACL();
grantedACL.setPublicReadAccess(false);
grantedACL.setReadAccess(user, true);
obj.setACL(grantedACL);
obj.set('secretField', 'GRANTED_VALUE');
await obj.save(null, { useMasterKey: true });
await client.waitForOpCount('enter', 1);
const enter = client.messagesForOp('enter')[0];
// The current (now-authorized) value is delivered.
expect(enter.object.secretField).toBe('GRANTED_VALUE');
// The pre-grant state the user was never authorized to read must not be delivered.
expect(enter.original).toBeUndefined();
});
it('still delivers the current object in a leave event caused by a query mismatch when the subscriber retains read access', async () => {
await reconfigureServer({
liveQuery: { classNames: ['TestObject'] },
startLiveQueryServer: true,
verbose: false,
silent: true,
});
const user = new Parse.User();
user.setUsername('leave-query-user');
user.setPassword('password');
await user.signUp();
// Object readable by the user that matches the subscription query.
const obj = new Parse.Object('TestObject');
const acl = new Parse.ACL();
acl.setPublicReadAccess(false);
acl.setReadAccess(user, true);
obj.setACL(acl);
obj.set('status', 'active');
obj.set('secretField', 'INITIAL');
await obj.save(null, { useMasterKey: true });
const client = await openClient(user.getSessionToken());
client.subscribe(1, 'TestObject', { status: 'active' });
await client.waitForOpCount('subscribed', 1);
// Update the field so the object no longer matches the query (query-mismatch leave)
// while preserving the user's ACL read access. The user is still authorized to read
// the current object, so the current state is delivered as designed.
await obj.save({ status: 'archived', secretField: 'VISIBLE_NEW' }, { useMasterKey: true });
await client.waitForOpCount('leave', 1);
const leave = client.messagesForOp('leave')[0];
expect(leave.object.status).toBe('archived');
expect(leave.object.secretField).toBe('VISIBLE_NEW');
});
});