Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,13 @@ public function refreshSubscription(string $principalUri, string $uri) {

$sObject = $vObject->serialize();
$uid = $vBase->UID->getValue();
$etag = md5($sObject);
// Strip DTSTAMP lines for etag computation only.
// Many providers (Google Calendar, Outlook, itslearning) set DTSTAMP
// to the current time on every feed request per RFC 5545, causing
// every event to appear modified on every refresh.
// DTSTAMP is kept in the stored data as it is a required property.
$sObjectForEtag = preg_replace('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m', '', $sObject);
$etag = md5($sObjectForEtag);

// No existing object with this UID, create it
if (!isset($existingObjects[$uid])) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -460,9 +460,142 @@ public function testRunCreateCalendarBadRequest(string $body, string $format, st
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
}

public function testDtstampChangeDoesNotTriggerUpdate(): void {
$refreshWebcalService = new RefreshWebcalService(
$this->caldavBackend,
$this->logger,
$this->connection,
$this->timeFactory,
$this->importService
);

$this->caldavBackend->expects(self::once())
->method('getSubscriptionsForUser')
->with('principals/users/testuser')
->willReturn([
[
'id' => '42',
'uri' => 'sub123',
RefreshWebcalService::STRIP_TODOS => '1',
RefreshWebcalService::STRIP_ALARMS => '1',
RefreshWebcalService::STRIP_ATTACHMENTS => '1',
'source' => 'webcal://foo.bar/bla2',
'lastmodified' => 0,
],
]);

// Feed body has a new DTSTAMP (as happens on every fetch from Google/Outlook)
$body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nUID:dtstamp-test\r\nDTSTAMP:20260209T120000Z\r\nDTSTART:20260301T100000Z\r\nSUMMARY:Test Event\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
$stream = $this->createStreamFromString($body);

$this->connection->expects(self::once())
->method('queryWebcalFeed')
->willReturn(['data' => $stream, 'format' => 'ical']);

// The stored etag was computed from the DTSTAMP-stripped serialization
$existingEtag = md5(preg_replace('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m', '', $body));

$this->caldavBackend->expects(self::once())
->method('getLimitedCalendarObjects')
->willReturn([
'dtstamp-test' => [
'id' => 1,
'uid' => 'dtstamp-test',
'etag' => $existingEtag,
'uri' => 'dtstamp-test.ics',
],
]);

$vCalendar = VObject\Reader::read($body);
$generator = function () use ($vCalendar) {
yield $vCalendar;
};

$this->importService->expects(self::once())
->method('importText')
->willReturn($generator());

// DTSTAMP-only change must NOT trigger an update
$this->caldavBackend->expects(self::never())
->method('updateCalendarObject');

$this->caldavBackend->expects(self::never())
->method('createCalendarObject');

$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
}

public function testFoldedDtstampChangeDoesNotTriggerUpdate(): void {
$refreshWebcalService = new RefreshWebcalService(
$this->caldavBackend,
$this->logger,
$this->connection,
$this->timeFactory,
$this->importService
);

$this->caldavBackend->expects(self::once())
->method('getSubscriptionsForUser')
->with('principals/users/testuser')
->willReturn([
[
'id' => '42',
'uri' => 'sub123',
RefreshWebcalService::STRIP_TODOS => '1',
RefreshWebcalService::STRIP_ALARMS => '1',
RefreshWebcalService::STRIP_ATTACHMENTS => '1',
'source' => 'webcal://foo.bar/bla2',
'lastmodified' => 0,
],
]);

// DTSTAMP with TZID parameter exceeds 75 bytes, triggering RFC 5545 content line folding
$body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nUID:folded-dtstamp-test\r\nDTSTAMP;X-VOBJ-ORIGINAL-TZID=America/Argentina/Buenos_Aires:20260209T120000Z\r\nDTSTART:20260301T100000Z\r\nSUMMARY:Test Event\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
$stream = $this->createStreamFromString($body);

$this->connection->expects(self::once())
->method('queryWebcalFeed')
->willReturn(['data' => $stream, 'format' => 'ical']);

// Compute etag from the serialized output (which will be folded) minus DTSTAMP
$vCalForEtag = VObject\Reader::read($body);
$serialized = $vCalForEtag->serialize();
$existingEtag = md5(preg_replace('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m', '', $serialized));

$this->caldavBackend->expects(self::once())
->method('getLimitedCalendarObjects')
->willReturn([
'folded-dtstamp-test' => [
'id' => 1,
'uid' => 'folded-dtstamp-test',
'etag' => $existingEtag,
'uri' => 'folded-dtstamp-test.ics',
],
]);

$vCalendar = VObject\Reader::read($body);
$generator = function () use ($vCalendar) {
yield $vCalendar;
};

$this->importService->expects(self::once())
->method('importText')
->willReturn($generator());

// Folded DTSTAMP change must NOT trigger an update
$this->caldavBackend->expects(self::never())
->method('updateCalendarObject');

$this->caldavBackend->expects(self::never())
->method('createCalendarObject');

$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
}

public static function identicalDataProvider(): array {
$icalBody = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
$etag = md5($icalBody);
// Etag is computed from DTSTAMP-stripped serialization
$etag = md5(preg_replace('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m', '', $icalBody));

return [
[
Expand Down
Loading