diff --git a/3rdparty b/3rdparty
index 9a181f79a2ad8..4b8c973530ab1 160000
--- a/3rdparty
+++ b/3rdparty
@@ -1 +1 @@
-Subproject commit 9a181f79a2ad8fa3be4b24a6bfbc0af57ed51e49
+Subproject commit 4b8c973530ab1464bbcb76ed9a59478bbaecc9ca
diff --git a/apps/dav/appinfo/info.xml b/apps/dav/appinfo/info.xml
index 65dcd1b29b30b..a82e389b50792 100644
--- a/apps/dav/appinfo/info.xml
+++ b/apps/dav/appinfo/info.xml
@@ -10,7 +10,7 @@
WebDAV
WebDAV endpoint
WebDAV endpoint
- 3.0.0-dev.1
+ 3.0.0-dev.2
agpl
owncloud.org
DAV
diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php
index b3b8576f1aec0..bbf9614617022 100644
--- a/apps/dav/composer/composer/autoload_classmap.php
+++ b/apps/dav/composer/composer/autoload_classmap.php
@@ -419,6 +419,7 @@
'OCA\\DAV\\Migration\\Version1039Date20260408000000' => $baseDir . '/../lib/Migration/Version1039Date20260408000000.php',
'OCA\\DAV\\Migration\\Version1040Date20260805000000' => $baseDir . '/../lib/Migration/Version1040Date20260805000000.php',
'OCA\\DAV\\Migration\\Version3000Date20260828000000' => $baseDir . '/../lib/Migration/Version3000Date20260828000000.php',
+ 'OCA\\DAV\\Migration\\Version3000Date20261019000000' => $baseDir . '/../lib/Migration/Version3000Date20261019000000.php',
'OCA\\DAV\\Model\\ExampleEvent' => $baseDir . '/../lib/Model/ExampleEvent.php',
'OCA\\DAV\\Paginate\\LimitedCopyIterator' => $baseDir . '/../lib/Paginate/LimitedCopyIterator.php',
'OCA\\DAV\\Paginate\\PaginateCache' => $baseDir . '/../lib/Paginate/PaginateCache.php',
diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php
index c52bf9f3acfed..e1f4b38d5eda5 100644
--- a/apps/dav/composer/composer/autoload_static.php
+++ b/apps/dav/composer/composer/autoload_static.php
@@ -434,6 +434,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Migration\\Version1039Date20260408000000' => __DIR__ . '/..' . '/../lib/Migration/Version1039Date20260408000000.php',
'OCA\\DAV\\Migration\\Version1040Date20260805000000' => __DIR__ . '/..' . '/../lib/Migration/Version1040Date20260805000000.php',
'OCA\\DAV\\Migration\\Version3000Date20260828000000' => __DIR__ . '/..' . '/../lib/Migration/Version3000Date20260828000000.php',
+ 'OCA\\DAV\\Migration\\Version3000Date20261019000000' => __DIR__ . '/..' . '/../lib/Migration/Version3000Date20261019000000.php',
'OCA\\DAV\\Model\\ExampleEvent' => __DIR__ . '/..' . '/../lib/Model/ExampleEvent.php',
'OCA\\DAV\\Paginate\\LimitedCopyIterator' => __DIR__ . '/..' . '/../lib/Paginate/LimitedCopyIterator.php',
'OCA\\DAV\\Paginate\\PaginateCache' => __DIR__ . '/..' . '/../lib/Paginate/PaginateCache.php',
diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php
index b4617565677c5..586f586b913d6 100644
--- a/apps/dav/lib/CalDAV/CalDavBackend.php
+++ b/apps/dav/lib/CalDAV/CalDavBackend.php
@@ -50,7 +50,9 @@
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IUserManager;
+use OCP\Security\ICrypto;
use OCP\Security\ISecureRandom;
+use OCP\Server;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Sabre\CalDAV\Backend\AbstractBackend;
@@ -2976,6 +2978,7 @@ public function getSubscriptionsForUser($principalUri) {
$fields[] = 'principaluri';
$fields[] = 'lastmodified';
$fields[] = 'synctoken';
+ $fields[] = 'credentials';
$query = $this->db->getQueryBuilder();
$query->select($fields)
@@ -2991,6 +2994,7 @@ public function getSubscriptionsForUser($principalUri) {
'uri' => $row['uri'],
'principaluri' => $row['principaluri'],
'source' => $row['source'],
+ 'credentials' => $this->decryptSubscriptionCredentials($row['id'], $row['credentials']),
'lastmodified' => $row['lastmodified'],
'{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VTODO', 'VEVENT']),
@@ -3020,10 +3024,12 @@ public function createSubscription($principalUri, $uri, array $properties) {
throw new Forbidden('The {http://calendarserver.org/ns/}source property is required when creating subscriptions');
}
+ [$source, $credentials] = self::extractSubscriptionCredentials($properties['{http://calendarserver.org/ns/}source']->getHref());
$values = [
'principaluri' => $principalUri,
'uri' => $uri,
- 'source' => $properties['{http://calendarserver.org/ns/}source']->getHref(),
+ 'source' => $source,
+ 'credentials' => $credentials,
'lastmodified' => time(),
];
@@ -3085,7 +3091,7 @@ public function updateSubscription($subscriptionId, PropPatch $propPatch) {
foreach ($mutations as $propertyName => $propertyValue) {
if ($propertyName === '{http://calendarserver.org/ns/}source') {
- $newValues['source'] = $propertyValue->getHref();
+ [$newValues['source'], $newValues['credentials']] = self::extractSubscriptionCredentials($propertyValue->getHref());
} else {
$fieldName = $this->subscriptionPropertyMap[$propertyName][0];
$newValues[$fieldName] = $propertyValue;
@@ -3093,6 +3099,14 @@ public function updateSubscription($subscriptionId, PropPatch $propPatch) {
}
$subscriptionRow = $this->atomic(function () use ($subscriptionId, $newValues) {
+ // clients only see the source without credentials, so sending it back unchanged keeps them
+ if (array_key_exists('credentials', $newValues) && $newValues['credentials'] === null) {
+ $currentSource = $this->getSubscriptionById($subscriptionId)['source'] ?? null;
+ if ($currentSource === $newValues['source']) {
+ unset($newValues['credentials']);
+ }
+ }
+
$query = $this->db->getQueryBuilder();
$query->update('calendarsubscriptions')
->set('lastmodified', $query->createNamedParameter(time()));
@@ -3111,6 +3125,57 @@ public function updateSubscription($subscriptionId, PropPatch $propPatch) {
});
}
+ private static function encryptSubscriptionCredentials(string $username, string $password): string {
+ return Server::get(ICrypto::class)->encrypt(json_encode([
+ 'username' => $username,
+ 'password' => $password,
+ ], JSON_THROW_ON_ERROR));
+ }
+
+ /**
+ * @return array{0: string, 1: string}|null username and password
+ */
+ private function decryptSubscriptionCredentials(int|string $subscriptionId, mixed $credentials): ?array {
+ if (!is_string($credentials) || $credentials === '') {
+ return null;
+ }
+
+ try {
+ $decoded = json_decode(Server::get(ICrypto::class)->decrypt($credentials), true, 2, JSON_THROW_ON_ERROR);
+ } catch (\Exception $e) {
+ $this->logger->error('Could not decrypt the credentials of subscription ' . $subscriptionId, [
+ 'exception' => $e,
+ ]);
+ return null;
+ }
+
+ return [(string)$decoded['username'], (string)$decoded['password']];
+ }
+
+ /**
+ * Splits the user info off a subscription source url
+ *
+ * The user info is percent-decoded and encrypted.
+ *
+ * @return array{0: string, 1: ?string} the source without user info and the encrypted credentials
+ */
+ public static function extractSubscriptionCredentials(string $source): array {
+ // the greedy match stops at the last '@' of the authority
+ if (preg_match('/^([a-z][a-z0-9+.\-]*:\/\/)([^\/?#]*)@/i', $source, $matches) !== 1) {
+ return [$source, null];
+ }
+
+ $source = $matches[1] . substr($source, strlen($matches[0]));
+ [$username, $password] = array_pad(explode(':', $matches[2], 2), 2, '');
+ $username = rawurldecode($username);
+ $password = rawurldecode($password);
+ if ($username === '' && $password === '') {
+ return [$source, null];
+ }
+
+ return [$source, self::encryptSubscriptionCredentials($username, $password)];
+ }
+
/**
* Deletes a subscription.
*
diff --git a/apps/dav/lib/CalDAV/WebcalCaching/Connection.php b/apps/dav/lib/CalDAV/WebcalCaching/Connection.php
index 1728b9c211483..4788f8070413f 100644
--- a/apps/dav/lib/CalDAV/WebcalCaching/Connection.php
+++ b/apps/dav/lib/CalDAV/WebcalCaching/Connection.php
@@ -59,10 +59,8 @@ public function queryWebcalFeed(array $subscription): ?array {
'stream' => true,
];
- $user = parse_url($subscription['source'], PHP_URL_USER);
- $pass = parse_url($subscription['source'], PHP_URL_PASS);
- if ($user !== null && $pass !== null) {
- $params[RequestOptions::AUTH] = [$user, $pass];
+ if (isset($subscription['credentials'])) {
+ $params[RequestOptions::AUTH] = $subscription['credentials'];
}
try {
diff --git a/apps/dav/lib/Migration/Version3000Date20261019000000.php b/apps/dav/lib/Migration/Version3000Date20261019000000.php
new file mode 100644
index 0000000000000..40bcb3e97e989
--- /dev/null
+++ b/apps/dav/lib/Migration/Version3000Date20261019000000.php
@@ -0,0 +1,76 @@
+getTable('calendarsubscriptions');
+ if ($table->hasColumn('credentials')) {
+ return null;
+ }
+
+ $table->addColumn('credentials', Types::TEXT, [
+ 'notnull' => false,
+ ]);
+
+ return $schema;
+ }
+
+ #[Override]
+ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
+ $select = $this->connection->getQueryBuilder();
+ $select->select('id', 'source')
+ ->from('calendarsubscriptions')
+ ->where($select->expr()->like('source', $select->createNamedParameter('%@%')))
+ ->andWhere($select->expr()->isNull('credentials'));
+ $result = $select->executeQuery();
+
+ $update = $this->connection->getQueryBuilder();
+ $update->update('calendarsubscriptions')
+ ->set('source', $update->createParameter('source'))
+ ->set('credentials', $update->createParameter('credentials'))
+ ->where($update->expr()->eq('id', $update->createParameter('id')));
+
+ while ($row = $result->fetchAssociative()) {
+ [$source, $credentials] = CalDavBackend::extractSubscriptionCredentials((string)$row['source']);
+ if ($credentials === null) {
+ continue;
+ }
+
+ $update->setParameter('source', $source)
+ ->setParameter('credentials', $credentials)
+ ->setParameter('id', (int)$row['id'], IQueryBuilder::PARAM_INT)
+ ->executeStatement();
+ }
+ $result->closeCursor();
+ }
+}
diff --git a/apps/dav/lib/UserMigration/CalendarMigrator.php b/apps/dav/lib/UserMigration/CalendarMigrator.php
index 418fc45f59d4a..d8af5c45deda5 100644
--- a/apps/dav/lib/UserMigration/CalendarMigrator.php
+++ b/apps/dav/lib/UserMigration/CalendarMigrator.php
@@ -219,11 +219,18 @@ private function exportSubscriptions(IUser $user, IExportDestination $exportDest
$exportData = [];
foreach ($subscriptions as $subscription) {
+ $credentials = null;
+ if (isset($subscription['credentials'])) {
+ [$username, $password] = $subscription['credentials'];
+ $credentials = ['username' => $username, 'password' => $password];
+ }
+
$exportData[] = [
'uri' => $subscription[self::DAV_PROPERTY_URI],
'displayname' => $subscription[self::DAV_PROPERTY_DISPLAYNAME] ?? null,
'color' => $subscription[self::DAV_PROPERTY_CALENDAR_COLOR] ?? null,
'source' => $subscription[self::DAV_PROPERTY_SUBSCRIBED_SOURCE] ?? null,
+ 'credentials' => $credentials,
'striptodos' => $subscription[self::DAV_PROPERTY_SUBSCRIBED_STRIP_TODOS] ?? null,
'stripalarms' => $subscription[self::DAV_PROPERTY_SUBSCRIBED_STRIP_ALARMS] ?? null,
'stripattachments' => $subscription[self::DAV_PROPERTY_SUBSCRIBED_STRIP_ATTACHMENTS] ?? null,
@@ -496,7 +503,7 @@ public function importSubscriptions(IUser $user, IImportSource $importSource, Ou
$principalUri,
$subscription['uri'] ? self::MIGRATED_URI_PREFIX . $subscription['uri'] : self::MIGRATED_URI_PREFIX . bin2hex(random_bytes(16)),
[
- '{http://calendarserver.org/ns/}source' => new Href($subscription['source']),
+ '{http://calendarserver.org/ns/}source' => new Href($this->importSubscriptionSource($subscription)),
self::DAV_PROPERTY_DISPLAYNAME => $subscription['displayname'] ?? null,
self::DAV_PROPERTY_CALENDAR_COLOR => $subscription['color'] ?? null,
self::DAV_PROPERTY_SUBSCRIBED_STRIP_TODOS => $subscription['striptodos'] ?? null,
@@ -513,6 +520,20 @@ public function importSubscriptions(IUser $user, IImportSource $importSource, Ou
}
}
+ /**
+ * Returns the source url of an imported subscription with its credentials as user info
+ */
+ private function importSubscriptionSource(array $subscription): string {
+ $source = (string)$subscription['source'];
+ $credentials = $subscription['credentials'] ?? null;
+ if (!is_array($credentials) || !is_string($credentials['username'] ?? null) || !is_string($credentials['password'] ?? null)) {
+ return $source;
+ }
+
+ $userInfo = rawurlencode($credentials['username']) . ':' . rawurlencode($credentials['password']) . '@';
+ return preg_replace('/^([a-z][a-z0-9+.\-]*:\/\/)/i', '${1}' . $userInfo, $source, 1);
+ }
+
/**
* @param iterable $stream
*/
diff --git a/apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php b/apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php
index 18c6ca6ee4ec5..4a2df0840055f 100644
--- a/apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php
+++ b/apps/dav/tests/integration/UserMigration/CalendarMigratorTest.php
@@ -489,12 +489,89 @@ public function testExportSubscriptions(): void {
$this->assertEquals('Test Subscription', $exportedSubscription['displayname']);
$this->assertEquals('#ff0000', $exportedSubscription['color']);
$this->assertEquals('https://example.com/calendar.ics', $exportedSubscription['source']);
+ $this->assertNull($exportedSubscription['credentials']);
$this->assertEquals('1', $exportedSubscription['striptodos']);
} finally {
$this->deleteUser($user);
}
}
+ public function testExportSubscriptionsWithCredentials(): void {
+ $user = $this->createTestUser();
+
+ try {
+ $principalUri = self::USERS_URI_ROOT . $user->getUID();
+ $this->calDavBackend->createSubscription(
+ $principalUri,
+ 'credentials-subscription',
+ [
+ '{http://calendarserver.org/ns/}source' => new \Sabre\DAV\Xml\Property\Href('https://us%40er:p%2Bss%3Aw@example.com/calendar.ics'),
+ ]
+ );
+
+ $exportDestination = $this->createMock(IExportDestination::class);
+
+ $exportedSubscriptionsJson = null;
+
+ $exportDestination->method('addFileContents')
+ ->willReturnCallback(function (string $path, string $content) use (&$exportedSubscriptionsJson): void {
+ if ($path === 'dav/calendars/subscriptions.json') {
+ $exportedSubscriptionsJson = json_decode($content, true);
+ }
+ });
+
+ $exportDestination->method('addFileAsStream');
+
+ $this->migrator->export($user, $exportDestination, $this->output);
+
+ $this->assertCount(1, $exportedSubscriptionsJson);
+ $this->assertEquals('https://example.com/calendar.ics', $exportedSubscriptionsJson[0]['source']);
+ $this->assertEquals(['username' => 'us@er', 'password' => 'p+ss:w'], $exportedSubscriptionsJson[0]['credentials']);
+ } finally {
+ $this->deleteUser($user);
+ }
+ }
+
+ public function testImportSubscriptionsWithCredentials(): void {
+ $user = $this->createTestUser();
+
+ try {
+ $importSource = $this->createMock(IImportSource::class);
+
+ $subscriptionsJson = json_encode([[
+ 'uri' => 'imported-subscription',
+ 'displayname' => 'Imported Subscription',
+ 'source' => 'https://example.com/imported.ics',
+ 'credentials' => ['username' => 'us@er', 'password' => 'p+ss:w'],
+ ]]);
+
+ $importSource->method('getMigratorVersion')
+ ->with('calendar')
+ ->willReturn(2);
+
+ $importSource->method('pathExists')
+ ->willReturnCallback(fn (string $path) => $path === 'dav/calendars/subscriptions.json');
+
+ $importSource->method('getFileContents')
+ ->willReturnCallback(function (string $path) use ($subscriptionsJson) {
+ if ($path === 'dav/calendars/subscriptions.json') {
+ return $subscriptionsJson;
+ }
+ throw new \Exception("Unexpected path: $path");
+ });
+
+ $this->migrator->import($user, $importSource, $this->output);
+
+ $principalUri = self::USERS_URI_ROOT . $user->getUID();
+ $subscriptions = $this->calDavBackend->getSubscriptionsForUser($principalUri);
+ $this->assertCount(1, $subscriptions);
+ $this->assertEquals('https://example.com/imported.ics', $subscriptions[0]['source']);
+ $this->assertEquals(['us@er', 'p+ss:w'], $subscriptions[0]['credentials']);
+ } finally {
+ $this->deleteUser($user);
+ }
+ }
+
public function testImportSubscriptions(): void {
$user = $this->createTestUser();
diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendSubscriptionCredentialsTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendSubscriptionCredentialsTest.php
new file mode 100644
index 0000000000000..2dcd2b7adba1a
--- /dev/null
+++ b/apps/dav/tests/unit/CalDAV/CalDavBackendSubscriptionCredentialsTest.php
@@ -0,0 +1,61 @@
+ ['https://user:pass@example.com/cal.ics', 'https://example.com/cal.ics', 'user', 'pass'],
+ 'webcal' => ['webcal://user:pass@example.com/cal.ics', 'webcal://example.com/cal.ics', 'user', 'pass'],
+ 'port, query and fragment' => ['https://user:pass@example.com:8443/cal.ics?a=b@c#frag', 'https://example.com:8443/cal.ics?a=b@c#frag', 'user', 'pass'],
+ 'percent-encoded' => ['https://us%40er:p%23ss%3Aw%2Frd%25@example.com/cal.ics', 'https://example.com/cal.ics', 'us@er', 'p#ss:w/rd%'],
+ 'plus is literal' => ['https://user:pa+ss@example.com/cal.ics', 'https://example.com/cal.ics', 'user', 'pa+ss'],
+ 'utf-8' => ['https://user:p%C3%A4ss@example.com/cal.ics', 'https://example.com/cal.ics', 'user', 'päss'],
+ 'unencoded colon in password' => ['https://user:pa:ss@example.com/cal.ics', 'https://example.com/cal.ics', 'user', 'pa:ss'],
+ 'unencoded at sign in username' => ['https://user@domain.tld:pass@example.com/cal.ics', 'https://example.com/cal.ics', 'user@domain.tld', 'pass'],
+ 'username only' => ['https://user@example.com/cal.ics', 'https://example.com/cal.ics', 'user', ''],
+ 'password only' => ['https://:pass@example.com/cal.ics', 'https://example.com/cal.ics', '', 'pass'],
+ ];
+ }
+
+ #[DataProvider('sourceWithCredentialsProvider')]
+ public function testExtractWithCredentials(string $source, string $expectedSource, string $expectedUsername, string $expectedPassword): void {
+ [$strippedSource, $credentials] = CalDavBackend::extractSubscriptionCredentials($source);
+
+ $this->assertSame($expectedSource, $strippedSource);
+ $this->assertIsString($credentials);
+ $this->assertSame(
+ ['username' => $expectedUsername, 'password' => $expectedPassword],
+ json_decode(Server::get(ICrypto::class)->decrypt($credentials), true),
+ );
+ }
+
+ public static function sourceWithoutCredentialsProvider(): array {
+ return [
+ 'no user info' => ['https://example.com/cal.ics', 'https://example.com/cal.ics'],
+ 'at sign in path' => ['https://example.com/user@domain/cal.ics', 'https://example.com/user@domain/cal.ics'],
+ 'at sign in query' => ['https://example.com/cal.ics?user=a@b', 'https://example.com/cal.ics?user=a@b'],
+ 'empty user info' => ['https://@example.com/cal.ics', 'https://example.com/cal.ics'],
+ 'empty username and password' => ['https://:@example.com/cal.ics', 'https://example.com/cal.ics'],
+ 'no scheme' => ['user:pass@example.com/cal.ics', 'user:pass@example.com/cal.ics'],
+ ];
+ }
+
+ #[DataProvider('sourceWithoutCredentialsProvider')]
+ public function testExtractWithoutCredentials(string $source, string $expectedSource): void {
+ $this->assertSame([$expectedSource, null], CalDavBackend::extractSubscriptionCredentials($source));
+ }
+}
diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
index 0694795433756..31d460961192e 100644
--- a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
+++ b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
@@ -774,6 +774,62 @@ public function testSubscriptions(): void {
$this->assertCount(0, $subscriptions);
}
+ public function testSubscriptionCredentials(): void {
+ $id = $this->backend->createSubscription(self::UNIT_TEST_USER, 'Subscription', [
+ '{http://calendarserver.org/ns/}source' => new Href('webcal://us%40er:p%23ss@example.com/cal.ics'),
+ ]);
+
+ $this->assertSame('webcal://example.com/cal.ics', $this->backend->getSubscriptionById($id)['source']);
+ $this->assertSame(['us@er', 'p#ss'], $this->getSubscriptionCredentials($id));
+
+ // unchanged source without user info keeps the credentials
+ $patch = new PropPatch([
+ '{http://calendarserver.org/ns/}source' => new Href('webcal://example.com/cal.ics'),
+ ]);
+ $this->backend->updateSubscription($id, $patch);
+ $patch->commit();
+ $this->assertSame(['us@er', 'p#ss'], $this->getSubscriptionCredentials($id));
+
+ $patch = new PropPatch([
+ '{http://calendarserver.org/ns/}source' => new Href('webcal://other:secret@example.com/cal.ics'),
+ ]);
+ $this->backend->updateSubscription($id, $patch);
+ $patch->commit();
+ $this->assertSame('webcal://example.com/cal.ics', $this->backend->getSubscriptionById($id)['source']);
+ $this->assertSame(['other', 'secret'], $this->getSubscriptionCredentials($id));
+
+ // changed source without user info drops the credentials
+ $patch = new PropPatch([
+ '{http://calendarserver.org/ns/}source' => new Href('webcal://example.org/cal.ics'),
+ ]);
+ $this->backend->updateSubscription($id, $patch);
+ $patch->commit();
+ $this->assertSame('webcal://example.org/cal.ics', $this->backend->getSubscriptionById($id)['source']);
+ $this->assertNull($this->getSubscriptionCredentials($id));
+
+ $this->backend->deleteSubscription($id);
+ }
+
+ private function getSubscriptionCredentials(int|string $id): ?array {
+ foreach ($this->backend->getSubscriptionsForUser(self::UNIT_TEST_USER) as $subscription) {
+ if ((string)$subscription['id'] === (string)$id) {
+ return $subscription['credentials'];
+ }
+ }
+ $this->fail('Subscription ' . $id . ' not found');
+ }
+
+ public function testSubscriptionWithoutCredentials(): void {
+ $id = $this->backend->createSubscription(self::UNIT_TEST_USER, 'Subscription', [
+ '{http://calendarserver.org/ns/}source' => new Href('https://example.com/cal.ics'),
+ ]);
+
+ $this->assertSame('https://example.com/cal.ics', $this->backend->getSubscriptionById($id)['source']);
+ $this->assertNull($this->getSubscriptionCredentials($id));
+
+ $this->backend->deleteSubscription($id);
+ }
+
public static function providesSchedulingData(): array {
$data = << [['us@er', 'p#ss'], ['us@er', 'p#ss']],
+ 'without credentials' => [null, null],
+ ];
+ }
+
+ #[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'credentialsDataProvider')]
+ public function testConnectionCredentials(?array $credentials, ?array $expectedAuth): void {
+ $client = $this->createMock(IClient::class);
+ $response = $this->createMock(IResponse::class);
+ $subscription = [
+ 'id' => 42,
+ 'uri' => 'sub123',
+ 'source' => 'webcal://foo.bar/bla2',
+ 'credentials' => $credentials,
+ 'lastmodified' => 0,
+ ];
+
+ $this->clientService->expects($this->once())
+ ->method('newClient')
+ ->willReturn($client);
+
+ $this->config->expects($this->once())
+ ->method('getValueString')
+ ->with('dav', 'webcalAllowLocalAccess', 'no')
+ ->willReturn('no');
+
+ $client->expects($this->once())
+ ->method('get')
+ ->with('https://foo.bar/bla2', $this->callback(fn (array $params): bool => ($params[RequestOptions::AUTH] ?? null) === $expectedAuth))
+ ->willReturn($response);
+
+ $response->method('getHeader')
+ ->willReturn('text/calendar');
+ $response->method('getBody')
+ ->willReturn('not a resource');
+
+ $this->connection->queryWebcalFeed($subscription);
+ }
+
public function testConnectionReturnsNullWhenBodyIsNotResource(): void {
$client = $this->createMock(IClient::class);
$response = $this->createMock(IResponse::class);
diff --git a/apps/dav/tests/unit/Migration/Version3000Date20261019000000Test.php b/apps/dav/tests/unit/Migration/Version3000Date20261019000000Test.php
new file mode 100644
index 0000000000000..f156c037b5512
--- /dev/null
+++ b/apps/dav/tests/unit/Migration/Version3000Date20261019000000Test.php
@@ -0,0 +1,104 @@
+connection = $this->createMock(IDBConnection::class);
+ $this->output = $this->createMock(IOutput::class);
+
+ $this->migration = new Version3000Date20261019000000($this->connection);
+ }
+
+ public function testChangeSchemaAddsColumn(): void {
+ $table = $this->createMock(ITable::class);
+ $table->method('hasColumn')->with('credentials')->willReturn(false);
+ $table->expects($this->once())
+ ->method('addColumn')
+ ->with('credentials', Types::TEXT, ['notnull' => false]);
+ $schema = $this->createMock(ISchemaWrapper::class);
+ $schema->method('getTable')->with('calendarsubscriptions')->willReturn($table);
+
+ $this->assertSame($schema, $this->migration->changeSchema($this->output, fn () => $schema, []));
+ }
+
+ public function testChangeSchemaColumnExists(): void {
+ $table = $this->createMock(ITable::class);
+ $table->method('hasColumn')->with('credentials')->willReturn(true);
+ $table->expects($this->never())->method('addColumn');
+ $schema = $this->createMock(ISchemaWrapper::class);
+ $schema->method('getTable')->with('calendarsubscriptions')->willReturn($table);
+
+ $this->assertNull($this->migration->changeSchema($this->output, fn () => $schema, []));
+ }
+
+ public function testPostSchemaChangeMovesCredentials(): void {
+ $result = $this->createMock(IResult::class);
+ $result->method('fetchAssociative')->willReturnOnConsecutiveCalls(
+ ['id' => '1', 'source' => 'https://user:pass@example.com/cal.ics'],
+ ['id' => '2', 'source' => 'https://example.com/user@domain/cal.ics'],
+ false,
+ );
+
+ $select = $this->createMock(IQueryBuilder::class);
+ $select->method('expr')->willReturn($this->createMock(IExpressionBuilder::class));
+ $select->method('select')->willReturnSelf();
+ $select->method('from')->willReturnSelf();
+ $select->method('where')->willReturnSelf();
+ $select->method('andWhere')->willReturnSelf();
+ $select->method('executeQuery')->willReturn($result);
+
+ $parameters = [];
+ $update = $this->createMock(IQueryBuilder::class);
+ $update->method('expr')->willReturn($this->createMock(IExpressionBuilder::class));
+ $update->method('update')->willReturnSelf();
+ $update->method('set')->willReturnSelf();
+ $update->method('where')->willReturnSelf();
+ $update->method('setParameter')->willReturnCallback(function (string $key, mixed $value) use (&$parameters, $update) {
+ $parameters[$key] = $value;
+ return $update;
+ });
+ $update->expects($this->once())
+ ->method('executeStatement')
+ ->willReturnCallback(function () use (&$parameters) {
+ $this->assertSame('https://example.com/cal.ics', $parameters['source']);
+ $this->assertSame(1, $parameters['id']);
+ $this->assertSame(
+ ['username' => 'user', 'password' => 'pass'],
+ json_decode(Server::get(ICrypto::class)->decrypt($parameters['credentials']), true),
+ );
+ return 1;
+ });
+
+ $this->connection->method('getQueryBuilder')->willReturnOnConsecutiveCalls($select, $update);
+
+ $this->migration->postSchemaChange($this->output, fn () => null, []);
+ }
+}