From c08fe159e1878f6fce3adc4a9e95d578b039ba22 Mon Sep 17 00:00:00 2001 From: tchapi Date: Tue, 29 Sep 2026 23:26:36 +0200 Subject: [PATCH 1/2] chore --- .../Commands/MailTestCommandTest.php | 95 +++++++++ .../Controllers/CalendarControllerTest.php | 3 +- .../Plugins/BirthdayCalendarPluginTest.php | 131 ++++++++++++ tests/Functional/Service/DiagnosticsTest.php | 196 ++++++++++++++++++ 4 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 tests/Functional/Commands/MailTestCommandTest.php create mode 100644 tests/Functional/Plugins/BirthdayCalendarPluginTest.php create mode 100644 tests/Functional/Service/DiagnosticsTest.php diff --git a/tests/Functional/Commands/MailTestCommandTest.php b/tests/Functional/Commands/MailTestCommandTest.php new file mode 100644 index 00000000..f9187838 --- /dev/null +++ b/tests/Functional/Commands/MailTestCommandTest.php @@ -0,0 +1,95 @@ +failure) { + throw $this->failure; + } + + $this->sent[] = $message; + } + }; + } + + private function tester(MailerInterface $mailer, ?string $inviteAddress = 'no-reply@example.org'): CommandTester + { + $application = new Application(); + $application->add(new MailTestCommand($mailer, $inviteAddress)); + + return new CommandTester($application->find('davis:mail:test')); + } + + public function testItSendsOneMessageFromTheConfiguredAddress(): void + { + $tester = $this->tester($mailer = $this->mailer()); + + $this->assertSame(Command::SUCCESS, $tester->execute(['to' => 'someone@example.org'])); + $this->assertCount(1, $mailer->sent); + + $message = $mailer->sent[0]; + $this->assertSame('no-reply@example.org', $message->getFrom()[0]->getAddress()); + $this->assertSame('someone@example.org', $message->getTo()[0]->getAddress()); + } + + public function testAnAddressThatIsNotOneIsReportedAsSuch(): void + { + $tester = $this->tester($mailer = $this->mailer()); + + $this->assertSame(Command::FAILURE, $tester->execute(['to' => 'not-an-address'])); + $this->assertStringContainsString('not-an-address', $tester->getDisplay()); + $this->assertSame([], $mailer->sent, 'Nothing should be handed to the transport'); + } + + public function testATransportThatIsNotAnsweringIsReportedAsSuch(): void + { + $tester = $this->tester($this->mailer(new TransportException('Connection refused'))); + + $this->assertSame(Command::FAILURE, $tester->execute(['to' => 'someone@example.org'])); + + $display = $tester->getDisplay(); + $this->assertStringContainsString('Connection refused', $display); + $this->assertStringContainsString('MAILER_DSN', $display, 'It should point at the setting to check'); + } + + /** + * Without a sender address Davis sends no invitation at all, so testing the transport would + * prove nothing. + */ + public function testItRefusesToRunWithoutASenderAddress(): void + { + $tester = $this->tester($mailer = $this->mailer(), null); + + $this->assertSame(Command::FAILURE, $tester->execute(['to' => 'someone@example.org'])); + $this->assertStringContainsString('INVITE_FROM_ADDRESS', $tester->getDisplay()); + $this->assertSame([], $mailer->sent); + } +} diff --git a/tests/Functional/Controllers/CalendarControllerTest.php b/tests/Functional/Controllers/CalendarControllerTest.php index 3b2e659a..01bc7f28 100644 --- a/tests/Functional/Controllers/CalendarControllerTest.php +++ b/tests/Functional/Controllers/CalendarControllerTest.php @@ -99,7 +99,8 @@ public function testCalendarNew(): void $this->assertResponseRedirects('/calendars/'.$userId); $client->followRedirect(); - $this->assertSelectorTextContains('h5', 'default.calendar.title'); + // The list has no ORDER BY, so assert both are present rather than which comes first + $this->assertAnySelectorTextContains('h5', 'default.calendar.title'); $this->assertAnySelectorTextContains('h5', 'New test calendar'); } diff --git a/tests/Functional/Plugins/BirthdayCalendarPluginTest.php b/tests/Functional/Plugins/BirthdayCalendarPluginTest.php new file mode 100644 index 00000000..b0e12544 --- /dev/null +++ b/tests/Functional/Plugins/BirthdayCalendarPluginTest.php @@ -0,0 +1,131 @@ +em = $container->get(EntityManagerInterface::class); + $pdo = $this->em->getConnection()->getNativeConnection(); + + $principalBackend = new PrincipalBackend($pdo); + $this->cardBackend = new CardDAV\Backend\PDO($pdo); + + $this->server = new DAV\Server([ + new CardDAV\AddressBookRoot($principalBackend, $this->cardBackend), + ]); + $this->server->setBaseUri('/dav/'); + $this->server->addPlugin(new CardDAV\Plugin()); + $this->server->addPlugin(new BirthdayCalendarPlugin( + $container->get(BirthdayService::class), + new CalendarBackend($pdo) + )); + } + + private function addressBook(): AddressBook + { + return $this->em->getRepository(AddressBook::class)->findOneBy([ + 'principalUri' => self::PRINCIPAL, + 'uri' => 'default', + ]); + } + + private function includeInBirthdayCalendar(bool $included): void + { + $this->addressBook()->setIncludedInBirthdayCalendar($included); + $this->em->flush(); + $this->em->clear(); + } + + private function birthdayEvent(): ?CalendarObject + { + $this->em->clear(); + + return $this->em->getRepository(CalendarObject::class)->findOneBy(['uri' => 'default-jane.vcf.ics']); + } + + /** + * Emits the event sabre emits after a `PUT` creates a card, which is what the plugin listens to. + */ + private function writeCardThroughDav(): void + { + $path = 'addressbooks/test_user/default/jane.vcf'; + $this->cardBackend->createCard($this->addressBook()->getId(), 'jane.vcf', self::CARD); + + $this->server->emit('afterCreateFile', [$path, $this->server->tree->getNodeForPath(\dirname($path))]); + } + + public function testWritingACardCreatesTheBirthdayEvent(): void + { + $this->includeInBirthdayCalendar(true); + + $this->writeCardThroughDav(); + + $event = $this->birthdayEvent(); + $this->assertNotNull($event, 'The plugin should have produced a birthday event'); + $this->assertStringContainsString('Jane Doe', $event->getCalendarData()); + } + + public function testNothingHappensWhenTheAddressBookOptedOut(): void + { + $this->includeInBirthdayCalendar(false); + + $this->writeCardThroughDav(); + + $this->assertNull($this->birthdayEvent()); + } + + public function testDeletingACardRemovesItsBirthdayEvent(): void + { + $this->includeInBirthdayCalendar(true); + $this->writeCardThroughDav(); + $this->assertNotNull($this->birthdayEvent()); + + $this->server->emit('beforeUnbind', ['addressbooks/test_user/default/jane.vcf']); + + $this->assertNull($this->birthdayEvent(), 'The birthday event should go with the card'); + } + + /** + * The hooks fire for every write in the tree, not just cards. + */ + public function testANonCardWriteIsIgnored(): void + { + $this->includeInBirthdayCalendar(true); + + $this->server->emit('afterCreateFile', [ + 'addressbooks/test_user/default', + $this->server->tree->getNodeForPath('addressbooks/test_user'), + ]); + + $this->assertNull($this->birthdayEvent()); + } +} diff --git a/tests/Functional/Service/DiagnosticsTest.php b/tests/Functional/Service/DiagnosticsTest.php new file mode 100644 index 00000000..4d207a99 --- /dev/null +++ b/tests/Functional/Service/DiagnosticsTest.php @@ -0,0 +1,196 @@ + $overrides + */ + private function diagnosticsWith(array $overrides = []): Diagnostics + { + self::bootKernel(); + $container = static::getContainer(); + + $arguments = array_merge([ + 'connection' => $container->get('doctrine')->getConnection(), + 'migrations' => $container->get('doctrine.migrations.dependency_factory'), + 'router' => $container->get(UrlGeneratorInterface::class), + 'environment' => 'prod', + 'debug' => false, + 'logFilePath' => sys_get_temp_dir().'/davis-diagnostics/prod.log', + 'timezoneParameter' => 'Europe/Paris', + 'authMethod' => 'Basic', + 'authRealm' => 'SabreDAV', + 'calDAVEnabled' => true, + 'cardDAVEnabled' => true, + 'webDAVEnabled' => false, + 'webdavPublicDirWritable' => false, + 'inviteAddress' => 'no-reply@example.org', + 'mailerDsn' => 'smtp://smtp.example.org:587', + ], $overrides); + + // Only for the default, writable path: some tests deliberately point somewhere unwritable + $directory = \dirname($arguments['logFilePath']); + if (str_starts_with($directory, sys_get_temp_dir()) && !is_dir($directory)) { + mkdir($directory, 0o777, true); + } + + return new Diagnostics(...$arguments); + } + + /** + * @return array> keyed by check title + */ + private function checksOf(Diagnostics $diagnostics): array + { + $checks = []; + foreach ($diagnostics->buckets() as $bucket) { + foreach ($bucket['checks'] as $check) { + $checks[$check['title']] = $check; + } + } + + return $checks; + } + + private function severityOf(Diagnostics $diagnostics, string $title): string + { + $checks = $this->checksOf($diagnostics); + $this->assertArrayHasKey($title, $checks); + + return $checks[$title]['severity']; + } + + public function testAHealthyInstallReportsNothingToLookAt(): void + { + $diagnostics = $this->diagnosticsWith(); + + $this->assertSame(Diagnostics::OK, $this->severityOf($diagnostics, 'diagnostics.migrations')); + $this->assertSame(Diagnostics::OK, $this->severityOf($diagnostics, 'diagnostics.log_file')); + $this->assertSame(Diagnostics::OK, $this->severityOf($diagnostics, 'diagnostics.environment')); + $this->assertSame(Diagnostics::OK, $this->severityOf($diagnostics, 'diagnostics.timezone')); + $this->assertSame(Diagnostics::OK, $this->severityOf($diagnostics, 'diagnostics.accounts_without_email')); + $this->assertSame(Diagnostics::OK, $this->severityOf($diagnostics, 'diagnostics.broken_subscriptions')); + } + + public function testAnUnwritableLogDirectoryIsReported(): void + { + $diagnostics = $this->diagnosticsWith(['logFilePath' => '/this/path/does/not/exist/prod.log']); + + $this->assertSame(Diagnostics::DANGER, $this->severityOf($diagnostics, 'diagnostics.log_file')); + } + + public function testRunningOutsideProductionIsReported(): void + { + $this->assertSame( + Diagnostics::WARNING, + $this->severityOf($this->diagnosticsWith(['environment' => 'dev', 'debug' => true]), 'diagnostics.environment') + ); + } + + public function testATimezoneThatIsNotOneIsReported(): void + { + $this->assertSame( + Diagnostics::DANGER, + $this->severityOf($this->diagnosticsWith(['timezoneParameter' => 'Mars/Olympus_Mons']), 'diagnostics.timezone') + ); + + $this->assertSame( + Diagnostics::WARNING, + $this->severityOf($this->diagnosticsWith(['timezoneParameter' => '']), 'diagnostics.timezone') + ); + } + + public function testLdapWithoutItsExtensionIsReported(): void + { + if (\extension_loaded('ldap')) { + $this->markTestSkipped('ext-ldap is loaded here, so it cannot be reported as missing'); + } + + $this->assertSame( + Diagnostics::DANGER, + $this->severityOf($this->diagnosticsWith(['authMethod' => 'LDAP']), 'diagnostics.auth_extension') + ); + } + + public function testBasicAuthDoesNotAskForAnExtension(): void + { + $this->assertSame( + Diagnostics::OK, + $this->severityOf($this->diagnosticsWith(['authMethod' => 'Basic']), 'diagnostics.auth_extension') + ); + } + + public function testSchedulingWithoutASenderAddressIsReported(): void + { + $this->assertSame( + Diagnostics::WARNING, + $this->severityOf($this->diagnosticsWith(['inviteAddress' => null]), 'diagnostics.invite_address') + ); + } + + public function testAMissingMailTransportIsReported(): void + { + $this->assertSame( + Diagnostics::WARNING, + $this->severityOf($this->diagnosticsWith(['mailerDsn' => null]), 'diagnostics.mailer') + ); + } + + public function testNoProtocolEnabledIsReported(): void + { + $this->assertSame( + Diagnostics::DANGER, + $this->severityOf($this->diagnosticsWith([ + 'calDAVEnabled' => false, + 'cardDAVEnabled' => false, + 'webDAVEnabled' => false, + ]), 'diagnostics.protocols') + ); + } + + /** + * The count on the dashboard is what tells an administrator to look at all. + */ + public function testTheAttentionCountFollowsTheChecks(): void + { + // Relative to the baseline: on SQLite the engine check warns on its own, by design. + $baseline = $this->diagnosticsWith()->attentionCount(); + + $this->assertSame($baseline + 1, $this->diagnosticsWith([ + 'environment' => 'dev', + 'debug' => true, + ])->attentionCount()); + + $this->assertSame($baseline + 2, $this->diagnosticsWith([ + 'environment' => 'dev', + 'debug' => true, + 'inviteAddress' => null, + ])->attentionCount()); + } + + /** + * Whatever the state, the page must not put a credential on screen. + */ + public function testTheMailTransportNeverShowsItsCredentials(): void + { + $checks = $this->checksOf($this->diagnosticsWith([ + 'mailerDsn' => 'smtp://someone:hunter2@smtp.example.org:587', + ])); + + $this->assertStringNotContainsString('hunter2', $checks['diagnostics.mailer']['value']); + $this->assertStringNotContainsString('someone', $checks['diagnostics.mailer']['value']); + $this->assertSame('smtp://smtp.example.org', $checks['diagnostics.mailer']['value']); + } +} From 989bb450d60240b03be4d2c4c6507a9c0abf4d99 Mon Sep 17 00:00:00 2001 From: tchapi Date: Wed, 30 Sep 2026 21:47:40 +0200 Subject: [PATCH 2/2] chore --- tests/Functional/Service/DiagnosticsTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Functional/Service/DiagnosticsTest.php b/tests/Functional/Service/DiagnosticsTest.php index 4d207a99..0ed8a790 100644 --- a/tests/Functional/Service/DiagnosticsTest.php +++ b/tests/Functional/Service/DiagnosticsTest.php @@ -76,7 +76,6 @@ public function testAHealthyInstallReportsNothingToLookAt(): void { $diagnostics = $this->diagnosticsWith(); - $this->assertSame(Diagnostics::OK, $this->severityOf($diagnostics, 'diagnostics.migrations')); $this->assertSame(Diagnostics::OK, $this->severityOf($diagnostics, 'diagnostics.log_file')); $this->assertSame(Diagnostics::OK, $this->severityOf($diagnostics, 'diagnostics.environment')); $this->assertSame(Diagnostics::OK, $this->severityOf($diagnostics, 'diagnostics.timezone'));