diff --git a/lib/Controller/CalendarController.php b/lib/Controller/CalendarController.php index 20119a4..f8e6487 100644 --- a/lib/Controller/CalendarController.php +++ b/lib/Controller/CalendarController.php @@ -844,27 +844,36 @@ class CalendarController extends Controller { $timeZone = $this->dateTimeZone->getTimeZone(); - $date = \DateTimeImmutable::createFromFormat( - self::DATETIME_LOCAL, - $value, - $timeZone - ); - - if ($date === false) { + /* + * "!" resets all fields, so no parts of the current date + * leak in. PHP normalizes impossible dates instead of + * failing (2026-02-31 rolls into March), so require zero + * warnings and an exact format round-trip on top of the + * parse result. + */ + foreach (['!' . self::DATETIME_LOCAL, '!Y-m-d\TH:i:s'] as $format) { $date = \DateTimeImmutable::createFromFormat( - 'Y-m-d\TH:i:s', + $format, $value, $timeZone ); + + $errors = \DateTimeImmutable::getLastErrors(); + + if ( + $date !== false + && ($errors === false + || ($errors['warning_count'] === 0 + && $errors['error_count'] === 0)) + && $date->format(substr($format, 1)) === $value + ) { + return $date; + } } - if ($date === false) { - throw new \InvalidArgumentException( - $this->l10n->t('Invalid date/time.') - ); - } - - return $date; + throw new \InvalidArgumentException( + $this->l10n->t('Invalid date/time.') + ); } /** diff --git a/tests/Unit/CalendarControllerTest.php b/tests/Unit/CalendarControllerTest.php index b06d657..52a1376 100644 --- a/tests/Unit/CalendarControllerTest.php +++ b/tests/Unit/CalendarControllerTest.php @@ -218,6 +218,35 @@ class CalendarControllerTest extends TestCase $this->invoke($c, 'parseLocalDateTime', ['tomorrow']); } + public function testParseLocalDateTimeAcceptsLeapDay(): void + { + $c = $this->newController(); + + $date = $this->invoke($c, 'parseLocalDateTime', ['2028-02-29T10:00']); + $this->assertSame('2028-02-29 10:00:00 UTC', $date->format('Y-m-d H:i:s T')); + } + + public static function provideInvalidLocalDateTimes(): iterable + { + return [ + 'impossible day rolls into next month' => ['2026-02-31T10:00'], + 'leap day in a non-leap year' => ['2027-02-29T10:00'], + 'month out of range' => ['2026-13-01T10:00'], + 'day out of range' => ['2026-00-15T10:00'], + 'hour out of range' => ['2026-02-03T25:00'], + 'non-padded components' => ['2026-2-3T10:00'], + ]; + } + + #[DataProvider('provideInvalidLocalDateTimes')] + public function testParseLocalDateTimeRejectsInvalidValues(string $value): void + { + $c = $this->newController(); + + $this->expectException(\InvalidArgumentException::class); + $this->invoke($c, 'parseLocalDateTime', [$value]); + } + /* * validateEventData */