@@ -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 ( ) ;
0 commit comments