From 7ac0d9a50bd07ec74398b36b8789810e410f88c4 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Tue, 21 Mar 2023 18:11:56 +0100 Subject: [PATCH] feat(CardDAV): Add Sabre\DAV\IMoveTarget support to OCA\DAV\CardDAV\AddressBook This allows to just UPDATE the card row instead of deleting it and reinsert it. It's very similar to https://github.com/nextcloud/server/pull/30120 for calendars. As we need the addressbookid exposed, this introduces OCA\DAV\CardDAV\Card that extends Sabre's. I chose specifically NOT to auto-inject LoggerInterface in Addressbook like in #30120 because the chain of DI is huge just for ONE simple call and it would break an existing dirty call (OCA\Contacts calling OCA\DAV) of ContactsManager in Contacts: https://github.com/nextcloud/contacts/pull/1722 (in SocialApiService), but this is debatable. Signed-off-by: Thomas Citharel --- .../composer/composer/autoload_classmap.php | 2 + .../dav/composer/composer/autoload_static.php | 2 + apps/dav/lib/CardDAV/AddressBook.php | 51 +++++++- apps/dav/lib/CardDAV/Card.php | 58 +++++++++ apps/dav/lib/CardDAV/CardDavBackend.php | 46 ++++++- apps/dav/lib/Events/CardMovedEvent.php | 120 ++++++++++++++++++ .../tests/unit/CardDAV/AddressBookTest.php | 81 ++++++++---- 7 files changed, 331 insertions(+), 29 deletions(-) create mode 100644 apps/dav/lib/CardDAV/Card.php create mode 100644 apps/dav/lib/Events/CardMovedEvent.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index a9bf60698fdb8..48fc9907532a7 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -111,6 +111,7 @@ 'OCA\\DAV\\CardDAV\\AddressBook' => $baseDir . '/../lib/CardDAV/AddressBook.php', 'OCA\\DAV\\CardDAV\\AddressBookImpl' => $baseDir . '/../lib/CardDAV/AddressBookImpl.php', 'OCA\\DAV\\CardDAV\\AddressBookRoot' => $baseDir . '/../lib/CardDAV/AddressBookRoot.php', + 'OCA\\DAV\\CardDAV\\Card' => $baseDir . '/../lib/CardDAV/Card.php', 'OCA\\DAV\\CardDAV\\CardDavBackend' => $baseDir . '/../lib/CardDAV/CardDavBackend.php', 'OCA\\DAV\\CardDAV\\ContactsManager' => $baseDir . '/../lib/CardDAV/ContactsManager.php', 'OCA\\DAV\\CardDAV\\Converter' => $baseDir . '/../lib/CardDAV/Converter.php', @@ -226,6 +227,7 @@ 'OCA\\DAV\\Events\\CalendarUpdatedEvent' => $baseDir . '/../lib/Events/CalendarUpdatedEvent.php', 'OCA\\DAV\\Events\\CardCreatedEvent' => $baseDir . '/../lib/Events/CardCreatedEvent.php', 'OCA\\DAV\\Events\\CardDeletedEvent' => $baseDir . '/../lib/Events/CardDeletedEvent.php', + 'OCA\\DAV\\Events\\CardMovedEvent' => $baseDir . '/../lib/Events/CardMovedEvent.php', 'OCA\\DAV\\Events\\CardUpdatedEvent' => $baseDir . '/../lib/Events/CardUpdatedEvent.php', 'OCA\\DAV\\Events\\SabrePluginAuthInitEvent' => $baseDir . '/../lib/Events/SabrePluginAuthInitEvent.php', 'OCA\\DAV\\Events\\SubscriptionCreatedEvent' => $baseDir . '/../lib/Events/SubscriptionCreatedEvent.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 48104281cd4b2..3d178ebc2e00f 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -126,6 +126,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\CardDAV\\AddressBook' => __DIR__ . '/..' . '/../lib/CardDAV/AddressBook.php', 'OCA\\DAV\\CardDAV\\AddressBookImpl' => __DIR__ . '/..' . '/../lib/CardDAV/AddressBookImpl.php', 'OCA\\DAV\\CardDAV\\AddressBookRoot' => __DIR__ . '/..' . '/../lib/CardDAV/AddressBookRoot.php', + 'OCA\\DAV\\CardDAV\\Card' => __DIR__ . '/..' . '/../lib/CardDAV/Card.php', 'OCA\\DAV\\CardDAV\\CardDavBackend' => __DIR__ . '/..' . '/../lib/CardDAV/CardDavBackend.php', 'OCA\\DAV\\CardDAV\\ContactsManager' => __DIR__ . '/..' . '/../lib/CardDAV/ContactsManager.php', 'OCA\\DAV\\CardDAV\\Converter' => __DIR__ . '/..' . '/../lib/CardDAV/Converter.php', @@ -241,6 +242,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Events\\CalendarUpdatedEvent' => __DIR__ . '/..' . '/../lib/Events/CalendarUpdatedEvent.php', 'OCA\\DAV\\Events\\CardCreatedEvent' => __DIR__ . '/..' . '/../lib/Events/CardCreatedEvent.php', 'OCA\\DAV\\Events\\CardDeletedEvent' => __DIR__ . '/..' . '/../lib/Events/CardDeletedEvent.php', + 'OCA\\DAV\\Events\\CardMovedEvent' => __DIR__ . '/..' . '/../lib/Events/CardMovedEvent.php', 'OCA\\DAV\\Events\\CardUpdatedEvent' => __DIR__ . '/..' . '/../lib/Events/CardUpdatedEvent.php', 'OCA\\DAV\\Events\\SabrePluginAuthInitEvent' => __DIR__ . '/..' . '/../lib/Events/SabrePluginAuthInitEvent.php', 'OCA\\DAV\\Events\\SubscriptionCreatedEvent' => __DIR__ . '/..' . '/../lib/Events/SubscriptionCreatedEvent.php', diff --git a/apps/dav/lib/CardDAV/AddressBook.php b/apps/dav/lib/CardDAV/AddressBook.php index bca478feec12d..f59e358b834b6 100644 --- a/apps/dav/lib/CardDAV/AddressBook.php +++ b/apps/dav/lib/CardDAV/AddressBook.php @@ -6,6 +6,7 @@ * @author Georg Ehrke * @author Joas Schilling * @author Roeland Jago Douma + * @author Thomas Citharel * @author Thomas Müller * * @license AGPL-3.0 @@ -27,11 +28,15 @@ use OCA\DAV\DAV\Sharing\IShareable; use OCA\DAV\Exception\UnsupportedLimitOnInitialSyncException; +use OCP\DB\Exception; use OCP\IL10N; +use OCP\Server; +use Psr\Log\LoggerInterface; use Sabre\CardDAV\Backend\BackendInterface; -use Sabre\CardDAV\Card; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\IMoveTarget; +use Sabre\DAV\INode; use Sabre\DAV\PropPatch; /** @@ -40,7 +45,7 @@ * @package OCA\DAV\CardDAV * @property BackendInterface|CardDavBackend $carddavBackend */ -class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { +class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable, IMoveTarget { /** * AddressBook constructor. @@ -52,6 +57,7 @@ class AddressBook extends \Sabre\CardDAV\AddressBook implements IShareable { public function __construct(BackendInterface $carddavBackend, array $addressBookInfo, IL10N $l10n) { parent::__construct($carddavBackend, $addressBookInfo); + if ($this->addressBookInfo['{DAV:}displayname'] === CardDavBackend::PERSONAL_ADDRESSBOOK_NAME && $this->getName() === CardDavBackend::PERSONAL_ADDRESSBOOK_URI) { $this->addressBookInfo['{DAV:}displayname'] = $l10n->t('Contacts'); @@ -160,6 +166,30 @@ public function getChild($name) { return new Card($this->carddavBackend, $this->addressBookInfo, $obj); } + public function getChildren() + { + $objs = $this->carddavBackend->getCards($this->addressBookInfo['id']); + $children = []; + foreach ($objs as $obj) { + $obj['acl'] = $this->getChildACL(); + $children[] = new Card($this->carddavBackend, $this->addressBookInfo, $obj); + } + + return $children; + } + + public function getMultipleChildren(array $paths) + { + $objs = $this->carddavBackend->getMultipleCards($this->addressBookInfo['id'], $paths); + $children = []; + foreach ($objs as $obj) { + $obj['acl'] = $this->getChildACL(); + $children[] = new Card($this->carddavBackend, $this->addressBookInfo, $obj); + } + + return $children; + } + public function getResourceId(): int { return $this->addressBookInfo['id']; } @@ -223,4 +253,21 @@ public function getChanges($syncToken, $syncLevel, $limit = null) { return parent::getChanges($syncToken, $syncLevel, $limit); } + + /** + * @inheritDoc + */ + public function moveInto($targetName, $sourcePath, INode $sourceNode) { + if (!($sourceNode instanceof Card)) { + return false; + } + + try { + return $this->carddavBackend->moveCard($sourceNode->getAddressbookId(), (int)$this->addressBookInfo['id'], $sourceNode->getUri(), $sourceNode->getOwner()); + } catch (Exception $e) { + // Avoid injecting LoggerInterface everywhere + Server::get(LoggerInterface::class)->error('Could not move calendar object: ' . $e->getMessage(), ['exception' => $e]); + return false; + } + } } diff --git a/apps/dav/lib/CardDAV/Card.php b/apps/dav/lib/CardDAV/Card.php new file mode 100644 index 0000000000000..1a8953ed51e80 --- /dev/null +++ b/apps/dav/lib/CardDAV/Card.php @@ -0,0 +1,58 @@ + + * + * @author Thomas Citharel + * + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ +declare(strict_types=1); + +namespace OCA\DAV\CardDAV; + +class Card extends \Sabre\CardDAV\Card +{ + public function getId(): int { + return (int) $this->cardData['id']; + } + + public function getUri(): string { + return $this->cardData['uri']; + } + + protected function isShared(): bool { + if (!isset($this->cardData['{http://owncloud.org/ns}owner-principal'])) { + return false; + } + + return $this->cardData['{http://owncloud.org/ns}owner-principal'] !== $this->cardData['principaluri']; + } + + public function getAddressbookId(): int { + return (int)$this->cardData['addressbookid']; + } + + public function getPrincipalUri(): string { + return $this->addressBookInfo['principaluri']; + } + + public function getOwner(): ?string { + if (isset($this->addressBookInfo['{http://owncloud.org/ns}owner-principal'])) { + return $this->addressBookInfo['{http://owncloud.org/ns}owner-principal']; + } + return parent::getOwner(); + } +} diff --git a/apps/dav/lib/CardDAV/CardDavBackend.php b/apps/dav/lib/CardDAV/CardDavBackend.php index ab21af1ce1048..23dfb3bd9bde4 100644 --- a/apps/dav/lib/CardDAV/CardDavBackend.php +++ b/apps/dav/lib/CardDAV/CardDavBackend.php @@ -44,13 +44,14 @@ use OCA\DAV\Events\AddressBookUpdatedEvent; use OCA\DAV\Events\CardCreatedEvent; use OCA\DAV\Events\CardDeletedEvent; +use OCA\DAV\Events\CardMovedEvent; use OCA\DAV\Events\CardUpdatedEvent; use OCP\AppFramework\Db\TTransactional; +use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; use OCP\IDBConnection; use OCP\IGroupManager; -use OCP\IUser; use OCP\IUserManager; use PDO; use Sabre\CardDAV\Backend\BackendInterface; @@ -732,6 +733,49 @@ public function updateCard($addressBookId, $cardUri, $cardData) { return '"' . $etag . '"'; } + /** + * @throws Exception + */ + public function moveCard(int $sourceAddressBookId, int $targetAddressBookId, string $cardUri, string $oldPrincipalUri): bool { + return $this->atomic(function () use ($sourceAddressBookId, $targetAddressBookId, $cardUri, $oldPrincipalUri) { + $card = $this->getCard($sourceAddressBookId, $cardUri); + if (empty($object)) { + return false; + } + + $query = $this->db->getQueryBuilder(); + $query->update('cards') + ->set('addressbookid', $query->createNamedParameter($targetAddressBookId, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('uri', $query->createNamedParameter($cardUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR)) + ->andWhere($query->expr()->eq('addressbookid', $query->createNamedParameter($sourceAddressBookId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) + ->executeStatement(); + + $this->purgeProperties($sourceAddressBookId, (int)$card['id']); + $this->updateProperties($sourceAddressBookId, $card['uri'], $card['carddata']); + + $this->addChange($sourceAddressBookId, $card['uri'], 3); + $this->addChange($targetAddressBookId, $card['uri'], 1); + + $object = $this->getCard($targetAddressBookId, $cardUri); + // Card wasn't found - possibly because it was deleted in the meantime by a different client + if (empty($object)) { + return false; + } + + $targetAddressBookRow = $this->getAddressBookById($targetAddressBookId); + // the address book this card is being moved to does not exist any longer + if (empty($targetAddressBookRow)) { + return false; + } + + $sourceShares = $this->getShares($sourceAddressBookId); + $targetShares = $this->getShares($targetAddressBookId); + $sourceAddressBookRow = $this->getAddressBookById($sourceAddressBookId); + $this->dispatcher->dispatchTyped(new CardMovedEvent($sourceAddressBookId, $sourceAddressBookRow, $targetAddressBookId, $targetAddressBookRow, $sourceShares, $targetShares, $object)); + return true; + }, $this->db); + } + /** * Deletes a card * diff --git a/apps/dav/lib/Events/CardMovedEvent.php b/apps/dav/lib/Events/CardMovedEvent.php new file mode 100644 index 0000000000000..07139cfdecf7e --- /dev/null +++ b/apps/dav/lib/Events/CardMovedEvent.php @@ -0,0 +1,120 @@ + + * + * @author Thomas Citharel + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +namespace OCA\DAV\Events; + +use OCP\EventDispatcher\Event; + +/** + * Class CardMovedEvent + * + * @package OCA\DAV\Events + * @since 27.0.0 + */ +class CardMovedEvent extends Event { + private int $sourceAddressBookId; + private array $sourceAddressBookData; + private int $targetAddressBookId; + private array $targetAddressBookData; + private array $sourceShares; + private array $targetShares; + private array $objectData; + + /** + * @since 27.0.0 + */ + public function __construct(int $sourceAddressBookId, + array $sourceAddressBookData, + int $targetAddressBookId, + array $targetAddressBookData, + array $sourceShares, + array $targetShares, + array $objectData) { + parent::__construct(); + $this->sourceAddressBookId = $sourceAddressBookId; + $this->sourceAddressBookData = $sourceAddressBookData; + $this->targetAddressBookId = $targetAddressBookId; + $this->targetAddressBookData = $targetAddressBookData; + $this->sourceShares = $sourceShares; + $this->targetShares = $targetShares; + $this->objectData = $objectData; + } + + /** + * @return int + * @since 27.0.0 + */ + public function getSourceAddressBookId(): int { + return $this->sourceAddressBookId; + } + + /** + * @return array + * @since 27.0.0 + */ + public function getSourceAddressBookData(): array { + return $this->sourceAddressBookData; + } + + /** + * @return int + * @since 27.0.0 + */ + public function getTargetAddressBookId(): int { + return $this->targetAddressBookId; + } + + /** + * @return array + * @since 27.0.0 + */ + public function getTargetAddressBookData(): array { + return $this->targetAddressBookData; + } + + /** + * @return array + * @since 27.0.0 + */ + public function getSourceShares(): array { + return $this->sourceShares; + } + + /** + * @return array + * @since 27.0.0 + */ + public function getTargetShares(): array { + return $this->targetShares; + } + + /** + * @return array + * @since 27.0.0 + */ + public function getObjectData(): array { + return $this->objectData; + } +} diff --git a/apps/dav/tests/unit/CardDAV/AddressBookTest.php b/apps/dav/tests/unit/CardDAV/AddressBookTest.php index ecee09f238eeb..81361d02068ee 100644 --- a/apps/dav/tests/unit/CardDAV/AddressBookTest.php +++ b/apps/dav/tests/unit/CardDAV/AddressBookTest.php @@ -6,6 +6,7 @@ * @author Joas Schilling * @author Morris Jobke * @author Roeland Jago Douma + * @author Thomas Citharel * @author Thomas Müller * * @license AGPL-3.0 @@ -26,94 +27,122 @@ namespace OCA\DAV\Tests\unit\CardDAV; use OCA\DAV\CardDAV\AddressBook; +use OCA\DAV\CardDAV\Card; use OCA\DAV\CardDAV\CardDavBackend; use OCP\IL10N; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; +use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\PropPatch; use Test\TestCase; class AddressBookTest extends TestCase { + public function testMove(): void { + $backend = $this->createMock(CardDavBackend::class); + $addressBookInfo = [ + '{http://owncloud.org/ns}owner-principal' => 'user1', + '{DAV:}displayname' => 'Test address book', + 'principaluri' => 'user2', + 'id' => 666, + 'uri' => 'default', + ]; + $l10n = $this->createMock(IL10N::class); + $logger = $this->createMock(LoggerInterface::class); + $addressBook = new AddressBook($backend, $addressBookInfo, $l10n, $logger); + + $card = new Card($backend, $addressBookInfo, ['id' => 5, 'carddata' => 'RANDOM VCF DATA', 'uri' => 'something', 'addressbookid' => 23]); + + $backend->expects($this->once())->method('moveCard')->with(23, 666, 'something', 'user1')->willReturn(true); + + $addressBook->moveInto('new', 'old', $card); + } + public function testDelete(): void { - /** @var \PHPUnit\Framework\MockObject\MockObject | CardDavBackend $backend */ + /** @var MockObject | CardDavBackend $backend */ $backend = $this->getMockBuilder(CardDavBackend::class)->disableOriginalConstructor()->getMock(); $backend->expects($this->once())->method('updateShares'); $backend->expects($this->any())->method('getShares')->willReturn([ ['href' => 'principal:user2'] ]); - $calendarInfo = [ + $addressBookInfo = [ '{http://owncloud.org/ns}owner-principal' => 'user1', '{DAV:}displayname' => 'Test address book', 'principaluri' => 'user2', 'id' => 666, 'uri' => 'default', ]; - $l = $this->createMock(IL10N::class); - $c = new AddressBook($backend, $calendarInfo, $l); - $c->delete(); + $l10n = $this->createMock(IL10N::class); + $logger = $this->createMock(LoggerInterface::class); + $addressBook = new AddressBook($backend, $addressBookInfo, $l10n, $logger); + $addressBook->delete(); } public function testDeleteFromGroup(): void { - $this->expectException(\Sabre\DAV\Exception\Forbidden::class); + $this->expectException(Forbidden::class); - /** @var \PHPUnit\Framework\MockObject\MockObject | CardDavBackend $backend */ + /** @var MockObject | CardDavBackend $backend */ $backend = $this->getMockBuilder(CardDavBackend::class)->disableOriginalConstructor()->getMock(); $backend->expects($this->never())->method('updateShares'); $backend->expects($this->any())->method('getShares')->willReturn([ ['href' => 'principal:group2'] ]); - $calendarInfo = [ + $addressBookInfo = [ '{http://owncloud.org/ns}owner-principal' => 'user1', '{DAV:}displayname' => 'Test address book', 'principaluri' => 'user2', 'id' => 666, 'uri' => 'default', ]; - $l = $this->createMock(IL10N::class); - $c = new AddressBook($backend, $calendarInfo, $l); - $c->delete(); + $l10n = $this->createMock(IL10N::class); + $logger = $this->createMock(LoggerInterface::class); + $addressBook = new AddressBook($backend, $addressBookInfo, $l10n, $logger); + $addressBook->delete(); } public function testPropPatch(): void { - $this->expectException(\Sabre\DAV\Exception\Forbidden::class); + $this->expectException(Forbidden::class); - /** @var \PHPUnit\Framework\MockObject\MockObject | CardDavBackend $backend */ + /** @var MockObject | CardDavBackend $backend */ $backend = $this->getMockBuilder(CardDavBackend::class)->disableOriginalConstructor()->getMock(); - $calendarInfo = [ + $addressBookInfo = [ '{http://owncloud.org/ns}owner-principal' => 'user1', '{DAV:}displayname' => 'Test address book', 'principaluri' => 'user2', 'id' => 666, 'uri' => 'default', ]; - $l = $this->createMock(IL10N::class); - $c = new AddressBook($backend, $calendarInfo, $l); - $c->propPatch(new PropPatch([])); + $l10n = $this->createMock(IL10N::class); + $logger = $this->createMock(LoggerInterface::class); + $addressBook = new AddressBook($backend, $addressBookInfo, $l10n, $logger); + $addressBook->propPatch(new PropPatch([])); } /** * @dataProvider providesReadOnlyInfo */ public function testAcl($expectsWrite, $readOnlyValue, $hasOwnerSet): void { - /** @var \PHPUnit\Framework\MockObject\MockObject | CardDavBackend $backend */ + /** @var MockObject | CardDavBackend $backend */ $backend = $this->getMockBuilder(CardDavBackend::class)->disableOriginalConstructor()->getMock(); $backend->expects($this->any())->method('applyShareAcl')->willReturnArgument(1); - $calendarInfo = [ + $addressBookInfo = [ '{DAV:}displayname' => 'Test address book', 'principaluri' => 'user2', 'id' => 666, 'uri' => 'default' ]; if (!is_null($readOnlyValue)) { - $calendarInfo['{http://owncloud.org/ns}read-only'] = $readOnlyValue; + $addressBookInfo['{http://owncloud.org/ns}read-only'] = $readOnlyValue; } if ($hasOwnerSet) { - $calendarInfo['{http://owncloud.org/ns}owner-principal'] = 'user1'; + $addressBookInfo['{http://owncloud.org/ns}owner-principal'] = 'user1'; } - $l = $this->createMock(IL10N::class); - $c = new AddressBook($backend, $calendarInfo, $l); - $acl = $c->getACL(); - $childAcl = $c->getChildACL(); + $l10n = $this->createMock(IL10N::class); + $logger = $this->createMock(LoggerInterface::class); + $addressBook = new AddressBook($backend, $addressBookInfo, $l10n, $logger); + $acl = $addressBook->getACL(); + $childAcl = $addressBook->getChildACL(); $expectedAcl = [[ 'privilege' => '{DAV:}read', @@ -142,7 +171,7 @@ public function testAcl($expectsWrite, $readOnlyValue, $hasOwnerSet): void { $this->assertEquals($expectedAcl, $childAcl); } - public function providesReadOnlyInfo() { + public function providesReadOnlyInfo(): array { return [ 'read-only property not set' => [true, null, true], 'read-only property is false' => [true, false, true],