Skip to content
Merged
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
323 changes: 323 additions & 0 deletions spec/ParseInstallation.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1405,6 +1405,329 @@ describe('Installations', () => {
});
});

describe('update checks for a caller without write access to the installation', () => {
const { randomUUID } = require('crypto');
const anonymousHeaders = {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
'Content-Type': 'application/json',
};
const masterHeaders = {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
'Content-Type': 'application/json',
};
const installationId = '12345678-abcd-abcd-abcd-123456789abc';
const otherInstallationId = '22222222-abcd-abcd-abcd-123456789abc';
const deviceToken = '11433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306';
const otherDeviceToken = '91433856eed2f1285fb3aa11136718c1198ed5647875096952c66bf8cb976306';
const baseFields = { installationId, deviceToken, deviceType: 'ios' };

const signUp = () => Parse.User.signUp('installation-user-' + randomUUID(), 'pass-12345678');

// An installation that only its owner can read and write
const createOwnedInstallation = async (owner, fields) => {
const created = await rest.create(config, auth.master(config), '_Installation', {
...fields,
ACL: { [owner.id]: { read: true, write: true } },
});
return created.response.objectId;
};

const update = (objectId, body, headers = anonymousHeaders) =>
request({
method: 'PUT',
url: 'http://localhost:8378/1/installations/' + objectId,
headers,
body: JSON.stringify(body),
}).catch(error => error);

const findRows = () => database.adapter.find('_Installation', installationSchema, {}, {});

const expectNotFound = response => {
expect(response.status).toBe(404);
expect(response.data.code).toBe(Parse.Error.OBJECT_NOT_FOUND);
expect(response.data.error).toBe('Object not found.');
};

// The responses to a differing value, a matching value and a nonexistent id must not differ
const expectSameResponses = (responses, status, code) => {
for (const response of responses) {
expect(response.status).toBe(status);
expect(response.data.code).toBe(code);
expect(response.data.error).toBe(responses[0].data.error);
}
};

const expectPermissionDenied = response => {
expect(response.status).toBe(400);
expect(response.data.code).toBe(Parse.Error.OPERATION_FORBIDDEN);
expect(response.data.error).toBe('Permission denied');
};

it('answers like a nonexistent installation when the deviceType differs from the stored value', async () => {
const owner = await signUp();
const objectId = await createOwnedInstallation(owner, baseFields);

expectNotFound(await update(objectId, { deviceType: 'android' }));
expectNotFound(await update(objectId, { deviceType: 'ios' }));
expectNotFound(await update('xxxxxxxxxx', { deviceType: 'android' }));

const rows = await findRows();
expect(rows.length).toBe(1);
expect(rows[0].deviceType).toBe('ios');
});

it('answers like a nonexistent installation when the installationId differs from the stored value', async () => {
const owner = await signUp();
const objectId = await createOwnedInstallation(owner, baseFields);

expectNotFound(await update(objectId, { installationId: otherInstallationId }));
expectNotFound(await update(objectId, { installationId }));

const rows = await findRows();
expect(rows.length).toBe(1);
expect(rows[0].installationId).toBe(installationId);
});

it('answers like a nonexistent installation when the deviceToken differs from the stored value of an installation without installationId', async () => {
const owner = await signUp();
const objectId = await createOwnedInstallation(owner, { deviceToken, deviceType: 'android' });

expectNotFound(await update(objectId, { deviceToken: otherDeviceToken }));
expectNotFound(await update(objectId, { deviceToken }));

const rows = await findRows();
expect(rows.length).toBe(1);
expect(rows[0].deviceToken).toBe(deviceToken);
});

it('answers like a nonexistent installation for an authenticated user without write access', async () => {
const owner = await signUp();
const other = await signUp();
const objectId = await createOwnedInstallation(owner, baseFields);
const headers = { ...anonymousHeaders, 'X-Parse-Session-Token': other.getSessionToken() };

expectNotFound(await update(objectId, { deviceType: 'android' }, headers));
expectNotFound(await update(objectId, { installationId: otherInstallationId }, headers));
expectNotFound(await update(objectId, { deviceType: 'ios', installationId }, headers));
expectNotFound(await update('xxxxxxxxxx', { deviceType: 'android' }, headers));

const rows = await findRows();
expect(rows.length).toBe(1);
expect(rows[0].deviceType).toBe('ios');
expect(rows[0].installationId).toBe(installationId);
});

it('answers like a nonexistent installation on the classes route', async () => {
const owner = await signUp();
const objectId = await createOwnedInstallation(owner, baseFields);
const classesRoute = (id, body) =>
request({
method: 'PUT',
url: 'http://localhost:8378/1/classes/_Installation/' + id,
headers: anonymousHeaders,
body: JSON.stringify(body),
}).catch(error => error);

expectNotFound(await classesRoute(objectId, { deviceType: 'android' }));
expectNotFound(await classesRoute(objectId, { deviceType: 'ios' }));
expectNotFound(await classesRoute('xxxxxxxxxx', { deviceType: 'android' }));
});

it('answers like a nonexistent installation when no installation exists yet', async () => {
expectNotFound(await update('xxxxxxxxxx', { deviceType: 'android' }));
expectNotFound(await update('xxxxxxxxxx', { installationId }));
});

it('answers the same for an invalid field regardless of the stored values', async () => {
const owner = await signUp();
const objectId = await createOwnedInstallation(owner, baseFields);

// badge is a Number field; the schema check runs before the write for every class
const responses = [
await update(objectId, { deviceType: 'android', badge: 'x' }),
await update(objectId, { deviceType: 'ios', badge: 'x' }),
await update(objectId, { installationId: otherInstallationId, badge: 'x' }),
await update('xxxxxxxxxx', { deviceType: 'android', badge: 'x' }),
];
expectSameResponses(responses, 400, Parse.Error.INCORRECT_TYPE);

const rows = await findRows();
expect(rows.length).toBe(1);
expect(rows[0].deviceType).toBe('ios');
});

it('answers the same for a beforeSave trigger regardless of the stored values', async () => {
const owner = await signUp();
const objectId = await createOwnedInstallation(owner, baseFields);
const trigger = jasmine.createSpy('beforeSave');
Parse.Cloud.beforeSave(Parse.Installation, trigger);

expectNotFound(await update(objectId, { deviceType: 'android' }));
expectNotFound(await update(objectId, { deviceType: 'ios' }));
expectNotFound(await update('xxxxxxxxxx', { deviceType: 'android' }));
expect(trigger).not.toHaveBeenCalled();
});

it('answers with the class-level permission error when the class denies the update', async () => {
const created = await rest.create(config, auth.master(config), '_Installation', baseFields);
const objectId = created.response.objectId;
const schema = new Parse.Schema('_Installation');
schema.setCLP({
find: {},
count: {},
get: {},
create: { '*': true },
update: {},
delete: {},
addField: {},
protectedFields: { '*': [] },
});
await schema.update();

expectPermissionDenied(await update(objectId, { deviceType: 'android' }));
expectPermissionDenied(await update(objectId, { deviceType: 'ios' }));
expectPermissionDenied(await update(objectId, { installationId: otherInstallationId }));
expectPermissionDenied(await update('xxxxxxxxxx', { deviceType: 'android' }));

// An invalid file value is rejected before the class-level permission check for every class
const invalidFile = { __type: 'File' };
const responses = [
await update(objectId, { deviceType: 'android', file: invalidFile }),
await update(objectId, { deviceType: 'ios', file: invalidFile }),
await update('xxxxxxxxxx', { deviceType: 'android', file: invalidFile }),
];
expectSameResponses(responses, 400, Parse.Error.INCORRECT_TYPE);

const rows = await findRows();
expect(rows.length).toBe(1);
expect(rows[0].deviceType).toBe('ios');
});

it('answers the same for an unauthenticated caller when the class requires authentication for updates', async () => {
const created = await rest.create(config, auth.master(config), '_Installation', baseFields);
const objectId = created.response.objectId;
const schema = new Parse.Schema('_Installation');
schema.setCLP({
find: { '*': true },
count: { '*': true },
get: { '*': true },
create: { '*': true },
update: { requiresAuthentication: true },
delete: { '*': true },
addField: { '*': true },
protectedFields: { '*': [] },
});
await schema.update();

const responses = [
await update(objectId, { deviceType: 'android' }),
await update(objectId, { deviceType: 'ios' }),
await update(objectId, { installationId: otherInstallationId }),
await update('xxxxxxxxxx', { deviceType: 'android' }),
];
expectSameResponses(responses, 404, Parse.Error.OBJECT_NOT_FOUND);
expect(responses[0].data.error).toBe('Permission denied');

const user = await signUp();
const headers = { ...anonymousHeaders, 'X-Parse-Session-Token': user.getSessionToken() };
const response = await update(objectId, { deviceType: 'android' }, headers);
expect(response.status).toBe(400);
expect(response.data.code).toBe(136);

const rows = await findRows();
expect(rows.length).toBe(1);
expect(rows[0].deviceType).toBe('ios');
});

it('answers like a nonexistent installation when a pointer permission excludes the caller', async () => {
const owner = await signUp();
const other = await signUp();
const created = await rest.create(config, auth.master(config), '_Installation', {
...baseFields,
user: { __type: 'Pointer', className: '_User', objectId: owner.id },
});
const objectId = created.response.objectId;
const schema = new Parse.Schema('_Installation');
schema.setCLP({
find: { '*': true },
count: { '*': true },
get: { '*': true },
create: { '*': true },
update: { pointerFields: ['user'] },
delete: { '*': true },
addField: { '*': true },
protectedFields: { '*': [] },
});
await schema.update();
const otherHeaders = {
...anonymousHeaders,
'X-Parse-Session-Token': other.getSessionToken(),
};

expectNotFound(await update(objectId, { deviceType: 'android' }));
expectNotFound(await update(objectId, { deviceType: 'ios' }));
expectNotFound(await update('xxxxxxxxxx', { deviceType: 'android' }));
expectNotFound(await update(objectId, { deviceType: 'android' }, otherHeaders));
expectNotFound(await update(objectId, { deviceType: 'ios' }, otherHeaders));
expectNotFound(await update('xxxxxxxxxx', { deviceType: 'android' }, otherHeaders));

const ownerHeaders = {
...anonymousHeaders,
'X-Parse-Session-Token': owner.getSessionToken(),
};
const rejected = await update(objectId, { deviceType: 'android' }, ownerHeaders);
expect(rejected.status).toBe(400);
expect(rejected.data.code).toBe(136);
const applied = await update(
objectId,
{ deviceType: 'ios', channels: ['news'] },
ownerHeaders
);
expect(applied.status).toBe(200);

const rows = await findRows();
expect(rows.length).toBe(1);
expect(rows[0].deviceType).toBe('ios');
expect(rows[0].channels).toEqual(['news']);
});

it('applies the update for the owner when the stored values are unchanged', async () => {
const owner = await signUp();
const objectId = await createOwnedInstallation(owner, baseFields);
const headers = { ...anonymousHeaders, 'X-Parse-Session-Token': owner.getSessionToken() };

const response = await update(objectId, { deviceType: 'ios', channels: ['news'] }, headers);
expect(response.status).toBe(200);

const rows = await findRows();
expect(rows.length).toBe(1);
expect(rows[0].channels).toEqual(['news']);
});

it('rejects a changed installationId for the owner', async () => {
const owner = await signUp();
const objectId = await createOwnedInstallation(owner, baseFields);
const headers = { ...anonymousHeaders, 'X-Parse-Session-Token': owner.getSessionToken() };

const response = await update(objectId, { installationId: otherInstallationId }, headers);
expect(response.status).toBe(400);
expect(response.data.code).toBe(136);
expect(response.data.error).toBe('installationId may not be changed in this operation');
});

it('rejects a changed deviceType for the master key', async () => {
const owner = await signUp();
const objectId = await createOwnedInstallation(owner, baseFields);

const response = await update(objectId, { deviceType: 'android' }, masterHeaders);
expect(response.status).toBe(400);
expect(response.data.code).toBe(136);
expect(response.data.error).toBe('deviceType may not be changed in this operation');
});
});

describe('deviceToken deduplication on new install (no installationId match)', () => {
const { randomUUID } = require('crypto');
const installationSchema = {
Expand Down
Loading
Loading