Skip to content

Commit d99aee9

Browse files
committed
test: Installation deduplication resolves a create onto the record holding the presented deviceToken
1 parent 383d3e5 commit d99aee9

5 files changed

Lines changed: 330 additions & 3 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,8 @@ Parse Server deduplicates `_Installation` records when a new install collides wi
686686

687687
When `true`, the dedup operation runs with the caller's auth context so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag.
688688
689+
Because what this option enforces is the ACL and the class-level permissions, it has no effect on its own for a record that carries no ACL under permissive class-level permissions — which is what the Parse SDKs create, as an unauthenticated device registration has no principal to grant write access to. To scope the deduplication, combine this option with either an ACL on `_Installation` records or class-level permissions that withhold the `delete` operation from the caller.
690+
689691
#### `duplicateDeviceTokenAction`
690692
691693
What Parse Server does to the conflicting `_Installation` row(s) when a new install's `deviceToken` collides with an existing row.

spec/ParseInstallation.spec.js

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1502,6 +1502,127 @@ describe('Installations', () => {
15021502
expect(protectedRow.deviceToken).toBe(t);
15031503
});
15041504

1505+
// `duplicateDeviceTokenActionEnforceAuth` runs the dedup action under the caller's
1506+
// auth context, so what it enforces is the row ACL and the class-level permissions.
1507+
// It is therefore not by itself a switch that stops unauthenticated dedup: a row
1508+
// that carries no ACL under permissive class-level permissions is writable by the
1509+
// public, so it stays in scope. The two specs below pin both halves of that, so the
1510+
// option's documented scope stays measurable.
1511+
it('enforceAuth=true leaves conflicting rows that carry no ACL in dedup scope', async () => {
1512+
await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: true });
1513+
const t = randomUUID();
1514+
await rest.create(config, auth.nobody(config), '_Installation', {
1515+
deviceToken: t,
1516+
deviceType: 'ios',
1517+
installationId: 'iid-no-acl-a',
1518+
});
1519+
await rest.create(config, auth.nobody(config), '_Installation', {
1520+
deviceToken: t,
1521+
deviceType: 'ios',
1522+
installationId: 'iid-no-acl-b',
1523+
});
1524+
1525+
const all = await database.adapter.find('_Installation', installationSchema, {}, {});
1526+
expect(all.length).toBe(1);
1527+
expect(all[0].installationId).toBe('iid-no-acl-b');
1528+
});
1529+
1530+
it('enforceAuth=true preserves a conflicting row without an ACL when the class-level permissions deny the caller the delete operation', async () => {
1531+
await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: true });
1532+
const t = randomUUID();
1533+
await rest.create(config, auth.master(config), '_Installation', {
1534+
deviceToken: t,
1535+
deviceType: 'ios',
1536+
installationId: 'iid-clp-existing',
1537+
});
1538+
1539+
const schemaResponse = await request({
1540+
method: 'PUT',
1541+
url: 'http://localhost:8378/1/schemas/_Installation',
1542+
headers: {
1543+
'X-Parse-Application-Id': 'test',
1544+
'X-Parse-Master-Key': 'test',
1545+
'Content-Type': 'application/json',
1546+
},
1547+
body: JSON.stringify({
1548+
classLevelPermissions: {
1549+
get: { '*': true },
1550+
find: { '*': true },
1551+
count: { '*': true },
1552+
create: { '*': true },
1553+
update: { '*': true },
1554+
addField: { '*': true },
1555+
// The only operation withheld from the public. `find` and `delete` are
1556+
// blocked for non-master callers at the REST layer regardless, but the
1557+
// dedup delete runs below that layer, so the class-level permission is
1558+
// what it is checked against once `enforceAuth` is on.
1559+
delete: {},
1560+
},
1561+
}),
1562+
});
1563+
expect(schemaResponse.status).toBe(200);
1564+
1565+
await rest.create(config, auth.nobody(config), '_Installation', {
1566+
deviceToken: t,
1567+
deviceType: 'ios',
1568+
installationId: 'iid-clp-other',
1569+
});
1570+
1571+
// The dedup delete is rejected and swallowed, so the pre-existing row keeps its
1572+
// deviceToken and the new install is inserted alongside it.
1573+
const all = await database.adapter.find('_Installation', installationSchema, {}, {});
1574+
expect(all.length).toBe(2);
1575+
const preExisting = all.find(r => r.installationId === 'iid-clp-existing');
1576+
expect(preExisting).toBeDefined();
1577+
expect(preExisting.deviceToken).toBe(t);
1578+
});
1579+
1580+
it('enforceAuth=false dedups a conflicting row even when the class-level permissions deny the caller the delete operation', async () => {
1581+
// Control for the spec above: with `enforceAuth` off the dedup runs as master, so
1582+
// the class-level permissions are not consulted at all and the same class-level
1583+
// permissions that preserve the row above do not preserve it here. The two specs
1584+
// together pin that it is the combination that scopes the dedup, not either half.
1585+
await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: false });
1586+
const t = randomUUID();
1587+
await rest.create(config, auth.master(config), '_Installation', {
1588+
deviceToken: t,
1589+
deviceType: 'ios',
1590+
installationId: 'iid-clp-existing',
1591+
});
1592+
1593+
const schemaResponse = await request({
1594+
method: 'PUT',
1595+
url: 'http://localhost:8378/1/schemas/_Installation',
1596+
headers: {
1597+
'X-Parse-Application-Id': 'test',
1598+
'X-Parse-Master-Key': 'test',
1599+
'Content-Type': 'application/json',
1600+
},
1601+
body: JSON.stringify({
1602+
classLevelPermissions: {
1603+
get: { '*': true },
1604+
find: { '*': true },
1605+
count: { '*': true },
1606+
create: { '*': true },
1607+
update: { '*': true },
1608+
addField: { '*': true },
1609+
delete: {},
1610+
},
1611+
}),
1612+
});
1613+
expect(schemaResponse.status).toBe(200);
1614+
1615+
await rest.create(config, auth.nobody(config), '_Installation', {
1616+
deviceToken: t,
1617+
deviceType: 'ios',
1618+
installationId: 'iid-clp-other',
1619+
});
1620+
1621+
const all = await database.adapter.find('_Installation', installationSchema, {}, {});
1622+
expect(all.length).toBe(1);
1623+
expect(all[0].installationId).toBe('iid-clp-other');
1624+
});
1625+
15051626
it('enforceAuth=true with master-key caller still bypasses ACL and dedups', async () => {
15061627
await reconfigureWithInstallationOptions({ duplicateDeviceTokenActionEnforceAuth: true });
15071628
const t = randomUUID();
@@ -1800,6 +1921,210 @@ describe('Installations', () => {
18001921
});
18011922
});
18021923

1924+
1925+
describe('deviceToken adoption on create (no installationId presented)', () => {
1926+
const { randomUUID } = require('crypto');
1927+
const anonymousHeaders = {
1928+
'X-Parse-Application-Id': 'test',
1929+
'X-Parse-REST-API-Key': 'rest',
1930+
'Content-Type': 'application/json',
1931+
};
1932+
1933+
// A create that presents a `deviceToken` already held by exactly one row and no
1934+
// `installationId` of its own is resolved onto that row rather than inserting a
1935+
// second one. This is the documented deduplication contract: see the comment on
1936+
// `ios merge existing same token no installation id` above, which describes the
1937+
// imported-device-token flow the branch exists for and states that the matched
1938+
// row is reused so that fields added out-of-band (channels, custom columns) are
1939+
// preserved. The tests below pin that contract and the permission checks that
1940+
// still apply to the resulting write.
1941+
1942+
it('resolves onto the row holding the deviceToken instead of inserting a second row', async () => {
1943+
const t = randomUUID();
1944+
await rest.create(config, auth.nobody(config), '_Installation', {
1945+
deviceToken: t,
1946+
deviceType: 'ios',
1947+
installationId: 'iid-existing',
1948+
channels: ['news'],
1949+
});
1950+
1951+
const response = await request({
1952+
method: 'POST',
1953+
url: 'http://localhost:8378/1/installations',
1954+
headers: anonymousHeaders,
1955+
body: JSON.stringify({ deviceToken: t, deviceType: 'ios', channels: ['sports'] }),
1956+
});
1957+
1958+
// The write is applied to the existing row, so the response carries `updatedAt`
1959+
// rather than the `objectId`/`createdAt` of a newly inserted row.
1960+
expect(response.status).toBe(200);
1961+
expect(response.data.updatedAt).toBeDefined();
1962+
expect(response.data.objectId).toBeUndefined();
1963+
1964+
const all = await database.adapter.find('_Installation', installationSchema, {}, {});
1965+
expect(all.length).toBe(1);
1966+
expect(all[0].deviceToken).toBe(t);
1967+
expect(all[0].installationId).toBe('iid-existing');
1968+
expect(all[0].channels).toEqual(['sports']);
1969+
});
1970+
1971+
it('returns a created response when no row holds the deviceToken', async () => {
1972+
// Control for the spec above: the `200`/`updatedAt` response there is specific to
1973+
// resolving onto an existing row. A deviceToken no row holds is inserted normally,
1974+
// so the response shape distinguishes the two outcomes.
1975+
const response = await request({
1976+
method: 'POST',
1977+
url: 'http://localhost:8378/1/installations',
1978+
headers: anonymousHeaders,
1979+
body: JSON.stringify({ deviceToken: randomUUID(), deviceType: 'ios' }),
1980+
});
1981+
1982+
expect(response.status).toBe(201);
1983+
expect(response.data.objectId).toBeDefined();
1984+
expect(response.data.updatedAt).toBeUndefined();
1985+
1986+
const all = await database.adapter.find('_Installation', installationSchema, {}, {});
1987+
expect(all.length).toBe(1);
1988+
});
1989+
1990+
it('deduplicates the row holding the deviceToken when the caller presents its own installationId', async () => {
1991+
// With an installationId of its own the caller does not resolve onto the matched
1992+
// row; the deduplication configured by `installation.duplicateDeviceTokenAction`
1993+
// applies to it instead and the caller's own install is inserted.
1994+
const t = randomUUID();
1995+
await rest.create(config, auth.nobody(config), '_Installation', {
1996+
deviceToken: t,
1997+
deviceType: 'ios',
1998+
installationId: 'iid-first',
1999+
});
2000+
2001+
const response = await request({
2002+
method: 'POST',
2003+
url: 'http://localhost:8378/1/installations',
2004+
headers: anonymousHeaders,
2005+
body: JSON.stringify({
2006+
deviceToken: t,
2007+
deviceType: 'ios',
2008+
installationId: 'iid-second',
2009+
}),
2010+
});
2011+
2012+
expect(response.status).toBe(201);
2013+
const all = await database.adapter.find('_Installation', installationSchema, {}, {});
2014+
expect(all.length).toBe(1);
2015+
expect(all[0].installationId).toBe('iid-second');
2016+
});
2017+
2018+
it('rejects the write when the caller presents its installationId only as a request header', async () => {
2019+
// The branch above is reached from `installationId` in the request body. The
2020+
// header is resolved into the deduplication lookup but not into that branch's
2021+
// condition, so a caller that presents it only as a header is rejected instead.
2022+
const t = randomUUID();
2023+
await rest.create(config, auth.nobody(config), '_Installation', {
2024+
deviceToken: t,
2025+
deviceType: 'ios',
2026+
installationId: 'iid-first',
2027+
});
2028+
2029+
const response = await request({
2030+
method: 'POST',
2031+
url: 'http://localhost:8378/1/installations',
2032+
headers: Object.assign({}, anonymousHeaders, {
2033+
'X-Parse-Installation-Id': 'iid-second',
2034+
}),
2035+
body: JSON.stringify({ deviceToken: t, deviceType: 'ios' }),
2036+
}).catch(error => error);
2037+
2038+
expect(response.status).toBe(400);
2039+
expect(response.data.code).toBe(132);
2040+
2041+
const all = await database.adapter.find('_Installation', installationSchema, {}, {});
2042+
expect(all.length).toBe(1);
2043+
expect(all[0].installationId).toBe('iid-first');
2044+
expect(all[0].deviceToken).toBe(t);
2045+
});
2046+
2047+
it('rejects the write when the matched row has an ACL that excludes an unauthenticated caller', async () => {
2048+
const t = randomUUID();
2049+
const user = await Parse.User.signUp('installation-acl-' + randomUUID(), 'pass');
2050+
await rest.create(config, auth.master(config), '_Installation', {
2051+
deviceToken: t,
2052+
deviceType: 'ios',
2053+
installationId: 'iid-acl',
2054+
channels: ['news'],
2055+
ACL: { [user.id]: { read: true, write: true } },
2056+
});
2057+
2058+
const response = await request({
2059+
method: 'POST',
2060+
url: 'http://localhost:8378/1/installations',
2061+
headers: anonymousHeaders,
2062+
body: JSON.stringify({ deviceToken: t, deviceType: 'ios', channels: ['sports'] }),
2063+
}).catch(error => error);
2064+
2065+
expect(response.status).toBe(404);
2066+
expect(response.data.code).toBe(Parse.Error.OBJECT_NOT_FOUND);
2067+
2068+
const all = await database.adapter.find('_Installation', installationSchema, {}, {});
2069+
expect(all.length).toBe(1);
2070+
expect(all[0].channels).toEqual(['news']);
2071+
});
2072+
2073+
it('rejects the write when the matched row has an ACL that excludes an authenticated non-owner', async () => {
2074+
const t = randomUUID();
2075+
const owner = await Parse.User.signUp('installation-owner-' + randomUUID(), 'pass');
2076+
await rest.create(config, auth.master(config), '_Installation', {
2077+
deviceToken: t,
2078+
deviceType: 'ios',
2079+
installationId: 'iid-acl-owner',
2080+
channels: ['news'],
2081+
ACL: { [owner.id]: { read: true, write: true } },
2082+
});
2083+
const other = await Parse.User.signUp('installation-other-' + randomUUID(), 'pass');
2084+
2085+
const response = await request({
2086+
method: 'POST',
2087+
url: 'http://localhost:8378/1/installations',
2088+
headers: Object.assign({}, anonymousHeaders, {
2089+
'X-Parse-Session-Token': other.getSessionToken(),
2090+
}),
2091+
body: JSON.stringify({ deviceToken: t, deviceType: 'ios', channels: ['sports'] }),
2092+
}).catch(error => error);
2093+
2094+
expect(response.status).toBe(404);
2095+
expect(response.data.code).toBe(Parse.Error.OBJECT_NOT_FOUND);
2096+
2097+
const all = await database.adapter.find('_Installation', installationSchema, {}, {});
2098+
expect(all.length).toBe(1);
2099+
expect(all[0].channels).toEqual(['news']);
2100+
});
2101+
2102+
it('applies the write when the caller is granted write access by the matched row ACL', async () => {
2103+
const t = randomUUID();
2104+
const owner = await Parse.User.signUp('installation-granted-' + randomUUID(), 'pass');
2105+
await rest.create(config, auth.master(config), '_Installation', {
2106+
deviceToken: t,
2107+
deviceType: 'ios',
2108+
installationId: 'iid-acl-granted',
2109+
channels: ['news'],
2110+
ACL: { [owner.id]: { read: true, write: true } },
2111+
});
2112+
2113+
const response = await request({
2114+
method: 'POST',
2115+
url: 'http://localhost:8378/1/installations',
2116+
headers: Object.assign({}, anonymousHeaders, {
2117+
'X-Parse-Session-Token': owner.getSessionToken(),
2118+
}),
2119+
body: JSON.stringify({ deviceToken: t, deviceType: 'ios', channels: ['sports'] }),
2120+
});
2121+
2122+
expect(response.status).toBe(200);
2123+
const all = await database.adapter.find('_Installation', installationSchema, {}, {});
2124+
expect(all.length).toBe(1);
2125+
expect(all[0].channels).toEqual(['sports']);
2126+
});
2127+
});
18032128
describe('options validation', () => {
18042129
it('should accept default empty config', async () => {
18052130
await expectAsync(reconfigureServer({})).toBeResolved();

src/Options/Definitions.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -787,7 +787,7 @@ module.exports.InstallationOptions = {
787787
},
788788
duplicateDeviceTokenActionEnforceAuth: {
789789
env: 'PARSE_SERVER_INSTALLATION_DUPLICATE_DEVICE_TOKEN_ACTION_ENFORCE_AUTH',
790-
help: "Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Default is `false`.",
790+
help: "Whether the `_Installation` deduplication operation enforces the caller's auth context (and the resulting ACL and CLP). When `true`, the dedup `destroy`/`update` runs with the caller's `runOptions`, so ACL and CLP are honored. When `false`, the dedup runs as master and bypasses both. Master and maintenance keys always bypass regardless of this flag. Because what this option enforces is the ACL and the class-level permissions, it has no effect on its own for a record that carries no ACL under permissive class-level permissions, which is what the Parse SDKs create; to scope the deduplication, combine this option with an ACL on `_Installation` records or with class-level permissions that withhold the `delete` operation from the caller. Default is `false`.",
791791
action: parsers.booleanParser,
792792
default: false,
793793
},

src/Options/docs.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)