Skip to content

Commit f76d4a6

Browse files
author
tchapi
committed
chore
1 parent e969fa4 commit f76d4a6

9 files changed

Lines changed: 180 additions & 5 deletions

File tree

.github/workflows/ci.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ jobs:
200200
php bin/console doctrine:database:create --if-not-exists --env=test
201201
php bin/console doctrine:migrations:migrate --no-interaction --env=test
202202
php bin/console doctrine:schema:validate --env=test
203+
# An empty schema only proves the DDL is valid. Seed the kind of row a DAV client
204+
# creates (optional columns left NULL) so the rollback is exercised against data too.
205+
php bin/console dbal:run-sql "INSERT INTO addressbooks (principaluri, uri, synctoken) VALUES ('principals/ci', 'ci-rollback-probe', 1)" --env=test
203206
# Full chain check: roll every migration back down, then all the way up again.
204207
# Nothing else ever runs the down() methods, so a broken rollback (wrong column
205208
# name, duplicated ALTER clause, invalid cast) stays invisible until an operator
@@ -216,6 +219,9 @@ jobs:
216219
php bin/console doctrine:database:create --if-not-exists --env=test
217220
php bin/console doctrine:migrations:migrate --no-interaction --env=test
218221
php bin/console doctrine:schema:validate --skip-sync --env=test
222+
# An empty schema only proves the DDL is valid. Seed the kind of row a DAV client
223+
# creates (optional columns left NULL) so the rollback is exercised against data too.
224+
php bin/console dbal:run-sql "INSERT INTO addressbooks (id, principaluri, uri, synctoken) VALUES (nextval('addressbooks_id_seq'), 'principals/ci', 'ci-rollback-probe', 1)" --env=test
219225
# Full chain check: roll every migration back down, then all the way up again.
220226
# Nothing else ever runs the down() methods, so a broken rollback (wrong column
221227
# name, duplicated ALTER clause, invalid cast) stays invisible until an operator
@@ -231,6 +237,9 @@ jobs:
231237
run: |
232238
php bin/console doctrine:migrations:migrate --no-interaction --env=test
233239
php bin/console doctrine:schema:validate --skip-sync --env=test
240+
# An empty schema only proves the DDL is valid. Seed the kind of row a DAV client
241+
# creates (optional columns left NULL) so the rollback is exercised against data too.
242+
php bin/console dbal:run-sql "INSERT INTO addressbooks (principaluri, uri, synctoken) VALUES ('principals/ci', 'ci-rollback-probe', 1)" --env=test
234243
# Full chain check: roll every migration back down, then all the way up again.
235244
# Nothing else ever runs the down() methods, so a broken rollback (wrong column
236245
# name, duplicated ALTER clause, invalid cast) stays invisible until an operator

migrations/Version20191203111729.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ public function down(Schema $schema): void
2828
{
2929
$this->skipIf('mysql' !== $this->connection->getDatabasePlatform()->getName(), 'This migration is specific to \'mysql\'. Skipping it is fine.');
3030

31-
$this->addSql('ALTER TABLE addressbooks CHANGE description description LONGTEXT CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`');
31+
// Since up() made the column nullable, address books created in the meantime may have
32+
// no description at all; they would violate the restored NOT NULL.
33+
$this->addSql("UPDATE addressbooks SET description = '' WHERE description IS NULL");
34+
$this->addSql('ALTER TABLE addressbooks CHANGE description description LONGTEXT NOT NULL');
3235
}
3336
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DoctrineMigrations;
6+
7+
use Doctrine\DBAL\Schema\Schema;
8+
use Doctrine\Migrations\AbstractMigration;
9+
10+
/**
11+
* Allow addressbooks.displayname to be null.
12+
*/
13+
final class Version20260908220000 extends AbstractMigration
14+
{
15+
public function getDescription(): string
16+
{
17+
return 'Allow addressbooks.displayname to be null, as a display name is optional when a client creates an address book';
18+
}
19+
20+
public function up(Schema $schema): void
21+
{
22+
$engine = $this->connection->getDatabasePlatform()->getName();
23+
24+
// A display name is optional in CardDAV: \Sabre\CardDAV\Backend\PDO::createAddressBook()
25+
// binds NULL when the client's MKCOL carries no {DAV:}displayname, and updateAddressBook()
26+
// does the same when a PROPPATCH removes it. With a NOT NULL column both fail (HTTP 500).
27+
if ('mysql' === $engine) {
28+
$this->addSql('ALTER TABLE addressbooks CHANGE displayname displayname VARCHAR(255) DEFAULT NULL');
29+
} elseif ('postgresql' === $engine) {
30+
$this->addSql('ALTER TABLE addressbooks ALTER COLUMN displayname DROP NOT NULL');
31+
} elseif ('sqlite' === $engine) {
32+
// SQLite cannot alter a column in place: add the replacement, copy, swap, drop.
33+
$this->addSql('ALTER TABLE addressbooks ADD COLUMN new_displayname VARCHAR(255) DEFAULT NULL');
34+
$this->addSql('UPDATE addressbooks SET new_displayname = displayname');
35+
$this->addSql('ALTER TABLE addressbooks RENAME COLUMN displayname TO old_displayname');
36+
$this->addSql('ALTER TABLE addressbooks RENAME COLUMN new_displayname TO displayname');
37+
$this->addSql('ALTER TABLE addressbooks DROP COLUMN old_displayname');
38+
}
39+
}
40+
41+
public function down(Schema $schema): void
42+
{
43+
$engine = $this->connection->getDatabasePlatform()->getName();
44+
45+
// Address books created without a display name would violate the restored NOT NULL,
46+
// so fall back to their uri rather than losing the row.
47+
$this->addSql('UPDATE addressbooks SET displayname = uri WHERE displayname IS NULL');
48+
49+
if ('mysql' === $engine) {
50+
$this->addSql('ALTER TABLE addressbooks CHANGE displayname displayname VARCHAR(255) NOT NULL');
51+
} elseif ('postgresql' === $engine) {
52+
$this->addSql('ALTER TABLE addressbooks ALTER COLUMN displayname SET NOT NULL');
53+
} elseif ('sqlite' === $engine) {
54+
// SQLite refuses to ADD a NOT NULL column without a default, so rebuild the table.
55+
$this->addSql('CREATE TABLE addressbooks_old (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, principaluri VARCHAR(255) NOT NULL, displayname VARCHAR(255) NOT NULL, uri VARCHAR(255) NOT NULL, description CLOB DEFAULT NULL, synctoken VARCHAR(255) NOT NULL, included_in_birthday_calendar INTEGER DEFAULT 0)');
56+
$this->addSql('INSERT INTO addressbooks_old (id, principaluri, displayname, uri, description, synctoken, included_in_birthday_calendar) SELECT id, principaluri, displayname, uri, description, synctoken, included_in_birthday_calendar FROM addressbooks');
57+
$this->addSql('DROP TABLE addressbooks');
58+
$this->addSql('ALTER TABLE addressbooks_old RENAME TO addressbooks');
59+
}
60+
}
61+
}

src/Entity/AddressBook.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class AddressBook
2121
#[ORM\Column(name: 'principaluri', type: 'string', length: 255)]
2222
private $principalUri;
2323

24-
#[ORM\Column(name: 'displayname', type: 'string', length: 255)]
24+
#[ORM\Column(name: 'displayname', type: 'string', length: 255, nullable: true)]
2525
private $displayName;
2626

2727
#[ORM\Column(type: 'string', length: 255)]
@@ -73,7 +73,7 @@ public function getDisplayName(): ?string
7373
return $this->displayName;
7474
}
7575

76-
public function setDisplayName(string $displayName): self
76+
public function setDisplayName(?string $displayName): self
7777
{
7878
$this->displayName = $displayName;
7979

src/Form/AddressBookType.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
2424
->add('displayName', TextType::class, [
2525
'label' => 'form.displayName',
2626
'help' => 'form.name.help.carddav',
27+
// Optional in CardDAV: clients may create an address book without one
28+
'required' => false,
2729
])
2830
->add('includedInBirthdayCalendar', ChoiceType::class, [
2931
'label' => 'form.includedInBirthdayCalendar',

templates/addressbooks/edit.html.twig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
{% include '_partials/back_button.html.twig' with { url: path('addressbook_index', {userId: userId}), text: "addressbooks.back"|trans({'user': principal.displayName }) } %}
77

88
{% if addressbook.id %}
9-
<h1 class="display-4 fw-lighter mb-5">{{ "addressbooks.edit"|trans({'name': addressbook.displayName }) }}</h1>
9+
<h1 class="display-4 fw-lighter mb-5">{{ "addressbooks.edit"|trans({'name': addressbook.displayName ?? addressbook.uri }) }}</h1>
1010
{% else %}
1111
<h1 class="display-4 fw-lighter mb-5">{{ "addressbooks.new"|trans }} <small class="text-muted">for {{ principal.displayName }}</small></h1>
1212
{% endif %}

templates/addressbooks/index.html.twig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
{% for addressbook in addressbooks %}
1212
<div class="list-group-item p-3">
1313
<div class="d-flex w-100 justify-content-between">
14-
<h5 class="mb-1 me-auto">{{ addressbook.displayName }}</h5>
14+
<h5 class="mb-1 me-auto">{{ addressbook.displayName ?? addressbook.uri }}</h5>
1515
<div class="me-0 text-right d-md-block d-none">
1616
<a href="{{ path('addressbook_edit',{userId: userId, id: addressbook.id})}}" class="btn btn-sm btn-outline-primary ms-1">✎ {{ "edit"|trans }}</a>
1717
<a href="#"
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Tests\Functional;
6+
7+
use Doctrine\ORM\EntityManagerInterface;
8+
use Sabre\CardDAV\Backend\PDO as CardDavBackend;
9+
use Sabre\DAV\PropPatch;
10+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
11+
12+
class AddressBookDavTest extends KernelTestCase
13+
{
14+
private const PRINCIPAL = 'principals/test_user';
15+
16+
private EntityManagerInterface $em;
17+
private CardDavBackend $backend;
18+
19+
protected function setUp(): void
20+
{
21+
self::bootKernel();
22+
23+
$this->em = static::getContainer()->get(EntityManagerInterface::class);
24+
$this->backend = new CardDavBackend($this->em->getConnection()->getNativeConnection());
25+
26+
$this->em->getConnection()->beginTransaction();
27+
}
28+
29+
protected function tearDown(): void
30+
{
31+
$this->em->getConnection()->rollBack();
32+
parent::tearDown();
33+
}
34+
35+
private function addressBookFor(string $uri): array
36+
{
37+
foreach ($this->backend->getAddressBooksForUser(self::PRINCIPAL) as $book) {
38+
if ($uri === $book['uri']) {
39+
return $book;
40+
}
41+
}
42+
43+
$this->fail(sprintf('No address book found for uri "%s"', $uri));
44+
}
45+
46+
/**
47+
* Regression test for issue #275: a display name is optional in CardDAV, but the column
48+
* was NOT NULL, so an MKCOL without {DAV:}displayname failed with a 500.
49+
*/
50+
public function testAddressBookCanBeCreatedWithoutADisplayName(): void
51+
{
52+
$this->backend->createAddressBook(self::PRINCIPAL, 'nameless', []);
53+
54+
$this->assertNull($this->addressBookFor('nameless')['{DAV:}displayname']);
55+
}
56+
57+
public function testADisplayNameIsStillStoredWhenGiven(): void
58+
{
59+
$this->backend->createAddressBook(self::PRINCIPAL, 'named', ['{DAV:}displayname' => 'My contacts']);
60+
61+
$this->assertSame('My contacts', $this->addressBookFor('named')['{DAV:}displayname']);
62+
}
63+
64+
/**
65+
* Same column, the other way round: a client may remove the display name with a PROPPATCH.
66+
*/
67+
public function testADisplayNameCanBeRemovedAgain(): void
68+
{
69+
$id = $this->backend->createAddressBook(self::PRINCIPAL, 'transient', ['{DAV:}displayname' => 'Temporary']);
70+
71+
$propPatch = new PropPatch(['{DAV:}displayname' => null]);
72+
$this->backend->updateAddressBook($id, $propPatch);
73+
74+
$this->assertTrue($propPatch->commit(), 'The PROPPATCH should succeed');
75+
$this->assertNull($this->addressBookFor('transient')['{DAV:}displayname']);
76+
}
77+
}

tests/Functional/Controllers/AddressBookControllerTest.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,4 +183,27 @@ public function testAddressBookNewIgnoresASubmittedOwner(): void
183183
$addressbookRepository = static::getContainer()->get('doctrine.orm.entity_manager')->getRepository(AddressBook::class);
184184
$this->assertNull($addressbookRepository->findOneBy(['uri' => 'hijack']));
185185
}
186+
187+
public function testAddressBookWithoutADisplayNameFallsBackToItsUri(): void
188+
{
189+
$user = new AdminUser('admin', 'test');
190+
191+
$client = static::createClient();
192+
$client->loginUser($user);
193+
194+
$userId = $this->getUserId($client, 'test_user');
195+
196+
$em = static::getContainer()->get('doctrine.orm.entity_manager');
197+
$nameless = (new AddressBook())
198+
->setPrincipalUri('principals/test_user')
199+
->setUri('nameless-book')
200+
->setDisplayName(null);
201+
$em->persist($nameless);
202+
$em->flush();
203+
204+
$client->request('GET', '/addressbooks/'.$userId);
205+
206+
$this->assertResponseIsSuccessful();
207+
$this->assertAnySelectorTextContains('h5', 'nameless-book');
208+
}
186209
}

0 commit comments

Comments
 (0)