@@ -1572,6 +1572,110 @@ public function testCreateDocumentDefaults(): void
15721572 $ database ->deleteCollection ('defaults ' );
15731573 }
15741574
1575+ /**
1576+ * When a document's UID changes on update, its permission rows in the
1577+ * collection's _perms table must follow the new UID. Otherwise the old
1578+ * rows are orphaned and the renamed document is left with no permissions,
1579+ * even when the permission set itself was not changed.
1580+ */
1581+ public function testUpdateDocumentChangeIdMigratesPermissions (): void
1582+ {
1583+ /** @var Database $database */
1584+ $ database = $ this ->getDatabase ();
1585+
1586+ $ auth = $ database ->getAuthorization ();
1587+
1588+ $ collection = 'update_change_id_perms ' ;
1589+
1590+ // documentSecurity with no collection-level permissions: reads are
1591+ // governed purely by the document's rows in the _perms table.
1592+ $ database ->createCollection ($ collection , permissions: [], documentSecurity: true );
1593+ $ this ->assertEquals (true , $ database ->createAttribute ($ collection , 'name ' , Database::VAR_STRING , 128 , false ));
1594+
1595+ // Create a document whose read permission is scoped to a single role,
1596+ // so that find() must consult the _perms table to return it.
1597+ $ document = $ auth ->skip (fn () => $ database ->createDocument ($ collection , new Document ([
1598+ '$id ' => 'old_id ' ,
1599+ 'name ' => 'test ' ,
1600+ '$permissions ' => [
1601+ Permission::read (Role::user ('alice ' )),
1602+ Permission::update (Role::user ('alice ' )),
1603+ Permission::delete (Role::user ('alice ' )),
1604+ ],
1605+ ])));
1606+ $ this ->assertEquals ('old_id ' , $ document ->getId ());
1607+
1608+ // Sanity: as alice the document is visible via the _perms table.
1609+ $ auth ->addRole (Role::user ('alice ' )->toString ());
1610+ $ this ->assertCount (1 , $ database ->find ($ collection ));
1611+
1612+ // Rename the document WITHOUT changing its permission set.
1613+ $ renamed = $ auth ->skip (fn () => $ database ->updateDocument ($ collection , 'old_id ' , new Document (\array_merge (
1614+ $ document ->getArrayCopy (),
1615+ ['$id ' => 'new_id ' ],
1616+ ))));
1617+ $ this ->assertEquals ('new_id ' , $ renamed ->getId ());
1618+
1619+ // The old UID must no longer resolve to a document.
1620+ $ this ->assertTrue ($ auth ->skip (fn () => $ database ->getDocument ($ collection , 'old_id ' ))->isEmpty ());
1621+
1622+ // The new UID must exist and keep its permissions on the main row.
1623+ $ newDoc = $ auth ->skip (fn () => $ database ->getDocument ($ collection , 'new_id ' ));
1624+ $ this ->assertFalse ($ newDoc ->isEmpty ());
1625+ $ this ->assertContains (Permission::read (Role::user ('alice ' )), $ newDoc ->getPermissions ());
1626+
1627+ // The crucial check: the permission rows must have migrated to the new
1628+ // UID in the _perms table. As alice, find() (which joins _perms) must
1629+ // still return exactly the renamed document. With orphaned rows under
1630+ // the old UID this returns 0.
1631+ $ found = $ database ->find ($ collection );
1632+ $ this ->assertCount (1 , $ found );
1633+ $ this ->assertEquals ('new_id ' , $ found [0 ]->getId ());
1634+
1635+ /**
1636+ * Second scenario: change the UID AND the permission set in the same
1637+ * update. Drop alice's access and grant bob instead. The removed rows
1638+ * must be gone, the added rows must land under the new UID, and nothing
1639+ * may be left orphaned under the old UID.
1640+ */
1641+ $ rekeyed = $ auth ->skip (fn () => $ database ->updateDocument ($ collection , 'new_id ' , new Document (\array_merge (
1642+ $ newDoc ->getArrayCopy (),
1643+ [
1644+ '$id ' => 'final_id ' ,
1645+ '$permissions ' => [
1646+ Permission::read (Role::user ('bob ' )),
1647+ Permission::update (Role::user ('bob ' )),
1648+ Permission::delete (Role::user ('bob ' )),
1649+ ],
1650+ ],
1651+ ))));
1652+ $ this ->assertEquals ('final_id ' , $ rekeyed ->getId ());
1653+
1654+ // The old UID must no longer resolve to a document.
1655+ $ this ->assertTrue ($ auth ->skip (fn () => $ database ->getDocument ($ collection , 'new_id ' ))->isEmpty ());
1656+
1657+ // The main row must reflect the new permission set.
1658+ $ finalDoc = $ auth ->skip (fn () => $ database ->getDocument ($ collection , 'final_id ' ));
1659+ $ this ->assertFalse ($ finalDoc ->isEmpty ());
1660+ $ this ->assertContains (Permission::read (Role::user ('bob ' )), $ finalDoc ->getPermissions ());
1661+ $ this ->assertNotContains (Permission::read (Role::user ('alice ' )), $ finalDoc ->getPermissions ());
1662+
1663+ // alice's permission rows were removed: as alice nothing is returned.
1664+ $ this ->assertCount (0 , $ database ->find ($ collection ));
1665+
1666+ // bob's permission rows landed under the new UID: as bob the renamed
1667+ // document is returned via the _perms join.
1668+ $ auth ->addRole (Role::user ('bob ' )->toString ());
1669+ $ foundAsBob = $ database ->find ($ collection );
1670+ $ this ->assertCount (1 , $ foundAsBob );
1671+ $ this ->assertEquals ('final_id ' , $ foundAsBob [0 ]->getId ());
1672+
1673+ $ auth ->removeRole (Role::user ('alice ' )->toString ());
1674+ $ auth ->removeRole (Role::user ('bob ' )->toString ());
1675+
1676+ $ auth ->skip (fn () => $ database ->deleteCollection ($ collection ));
1677+ }
1678+
15751679 public function testIncreaseDecrease (): Document
15761680 {
15771681 /** @var Database $database */
0 commit comments