calendarManager = $this->createMock(IManager::class); $this->contactsManager = $this->createMock(IContactsManager::class); $this->appManager = $this->createMock(IAppManager::class); $this->userSession = $this->createMock(IUserSession::class); $this->urlGenerator = $this->createMock(IURLGenerator::class); $this->logger = $this->createMock(LoggerInterface::class); } private function newController(): CalendarController { $dateTimeZone = $this->createMock(IDateTimeZone::class); $dateTimeZone->method('getTimeZone')->willReturn(new \DateTimeZone('UTC')); $l10n = $this->createMock(IL10N::class); $l10n->method('t')->willReturnCallback( static fn (string $text, array $params = []): string => $params === [] ? $text : vsprintf($text, $params) ); return new CalendarController( 'atocal', $this->createMock(IRequest::class), $this->calendarManager, $this->contactsManager, $this->appManager, $this->userSession, $this->urlGenerator, $dateTimeZone, $l10n, $this->logger ); } private function invoke(CalendarController $c, string $method, array $args = []): mixed { $m = new \ReflectionMethod(CalendarController::class, $method); return $m->invokeArgs($c, $args); } private function mockUser(string $uid): IUser&MockObject { $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn($uid); return $user; } private function mockCalendar( string $key, string $uri, string $displayName, int $permissions ): ICreateFromString&MockObject { $calendar = $this->createMock(ICreateFromString::class); $calendar->method('getKey')->willReturn($key); $calendar->method('getUri')->willReturn($uri); $calendar->method('getDisplayName')->willReturn($displayName); $calendar->method('getPermissions')->willReturn($permissions); return $calendar; } /* * assertClean */ public static function provideCleanCases(): iterable { return [ 'plain text' => ['Weekly sync', false, true], 'description with newline' => ["line one\nline two", true, true], 'newline without allowance' => ["line one\nline two", false, false], 'NUL byte' => ["bad\x00title", false, false], 'backspace' => ["bad\x08title", false, false], 'delete char' => ["bad\x7Ftitle", false, false], 'bidi override LRE' => ["\u{202A}evil", false, false], 'bidi override RLO' => ["\u{202E}evil", false, false], 'bidi isolate LRI' => ["\u{2066}evil", false, false], 'bidi isolate PDI' => ["\u{2069}evil", false, false], 'line separator' => ["a\u{2028}b", false, false], 'paragraph separator' => ["a\u{2029}b", false, false], 'next line' => ["a\u{0085}b", false, false], 'zero-width joiner allowed' => ["\u{1F469}\u{200D}\u{1F4BB} dev", false, true], 'combining accent allowed' => ["caf\u{00E9}", false, true], 'invalid utf-8' => ["bad\xC3\x28utf", false, false], ]; } #[DataProvider('provideCleanCases')] public function testAssertClean(string $value, bool $allowNewlines, bool $expectedClean): void { $c = $this->newController(); if ($expectedClean) { $this->invoke($c, 'assertClean', [$value, 'bad', $allowNewlines]); $this->addToAssertionCount(1); return; } $this->expectException(\InvalidArgumentException::class); $this->invoke($c, 'assertClean', [$value, 'bad', $allowNewlines]); } /* * assertHttpsUrl */ public static function provideUrlCases(): iterable { return [ 'valid https' => ['https://example.com/meeting', true], 'valid with path query' => ['https://example.com/p?a=1#frag', true], 'space inside' => ['https://exa mple.com/', false], 'backslash user confusion' => ['https://example.com\\@evil.com/', false], 'backslash in path' => ['https://example.com/path\\', false], 'non-ascii host' => ['https://еxample.com/', false], 'userinfo user' => ['https://user@example.com/', false], 'userinfo pass only' => ['https://:pass@example.com/', false], 'userinfo user and pass' => ['https://u:p@example.com/', false], 'http scheme' => ['http://example.com/', false], 'ftp scheme' => ['ftp://example.com/', false], 'no host' => ['https://', false], 'garbage' => ['not a url', false], ]; } #[DataProvider('provideUrlCases')] public function testAssertHttpsUrl(string $value, bool $expectedValid): void { $c = $this->newController(); if ($expectedValid) { $this->invoke($c, 'assertHttpsUrl', [$value, 'bad url']); $this->addToAssertionCount(1); return; } $this->expectException(\InvalidArgumentException::class); $this->invoke($c, 'assertHttpsUrl', [$value, 'bad url']); } /* * assertBoundedLength */ public function testAssertBoundedLength(): void { $c = $this->newController(); $this->invoke($c, 'assertBoundedLength', ['12345', 5, 'too long']); $this->addToAssertionCount(1); $this->expectException(\InvalidArgumentException::class); $this->invoke($c, 'assertBoundedLength', ['123456', 5, 'too long']); } /* * parseLocalDateTime */ public function testParseLocalDateTime(): void { $c = $this->newController(); $date = $this->invoke($c, 'parseLocalDateTime', ['2026-10-07T14:15']); $this->assertInstanceOf(\DateTimeImmutable::class, $date); $this->assertSame('2026-10-07 14:15:00 UTC', $date->format('Y-m-d H:i:s T')); $date = $this->invoke($c, 'parseLocalDateTime', ['2026-10-07T14:15:30']); $this->assertSame('2026-10-07 14:15:30 UTC', $date->format('Y-m-d H:i:s T')); $this->expectException(\InvalidArgumentException::class); $this->invoke($c, 'parseLocalDateTime', ['tomorrow']); } /* * validateEventData */ private function validInput(): array { return [ 'summary' => ' Weekly sync ', 'description' => ' Agenda attached ', 'start' => '2026-10-07T14:15', 'end' => '2026-10-07T15:15', 'organizer' => 'boss@example.com', 'location' => 'https://meet.example.com/room', 'url' => 'https://meet.example.com/room', ]; } public function testValidateEventDataNormalizes(): void { $c = $this->newController(); $data = $this->invoke($c, 'validateEventData', array_values($this->validInput())); $this->assertSame('Weekly sync', $data['summary']); $this->assertSame('Agenda attached', $data['description']); $this->assertSame('boss@example.com', $data['organizer']); $this->assertInstanceOf(\DateTimeImmutable::class, $data['start']); $this->assertInstanceOf(\DateTimeImmutable::class, $data['end']); $this->assertSame('https://meet.example.com/room', $data['location']); $this->assertSame('https://meet.example.com/room', $data['url']); } public static function provideValidationFailures(): iterable { return [ 'missing title' => [ ['summary' => ''], 'Meeting title is required.', ], 'title too long' => [ ['summary' => str_repeat('a', 256)], 'Meeting title is too long.', ], 'title control char' => [ ['summary' => "bad\x00title"], 'Meeting title contains invalid characters.', ], 'title bidi override' => [ ['summary' => "\u{202E}evil title"], 'Meeting title contains invalid characters.', ], 'description too long' => [ ['description' => str_repeat('a', 10001)], 'Meeting description is too long.', ], 'description control char' => [ ['description' => "bad\x01desc"], 'Meeting description contains invalid characters.', ], 'end before start' => [ ['start' => '2026-10-07T15:15', 'end' => '2026-10-07T14:15'], 'End time must be after start time.', ], 'bad date format' => [ ['start' => 'tomorrow', 'end' => '2026-10-07T14:15'], 'Invalid date/time.', ], 'location not https' => [ ['location' => 'http://meet.example.com/'], 'Location must be an HTTPS URL.', ], 'location with userinfo' => [ ['location' => 'https://user@meet.example.com/'], 'Location must be an HTTPS URL.', ], 'location too long' => [ ['location' => 'https://example.com/' . str_repeat('a', 2000)], 'Location is too long.', ], 'url not https' => [ ['url' => 'http://meet.example.com/'], 'Meeting URL must be an HTTPS URL.', ], 'url control char' => [ ['url' => "https://example.com/\x00/room"], 'Meeting URL contains invalid characters.', ], 'organizer no email' => [ ['organizer' => 'not-an-email'], 'Invalid organizer email address.', ], 'organizer too long' => [ ['organizer' => str_repeat('a', 250) . '@example.com'], 'Organizer email address is too long.', ], 'organizer control char' => [ ['organizer' => "boss\x00@example.com"], 'Organizer contains invalid characters.', ], ]; } #[DataProvider('provideValidationFailures')] public function testValidateEventDataRejects(array $overrides, string $message): void { $c = $this->newController(); $input = array_merge($this->validInput(), $overrides); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($message); $this->invoke($c, 'validateEventData', array_values($input)); } /* * mergeUrlIntoDescription */ public function testMergeUrlIntoDescription(): void { $c = $this->newController(); $urlEqualsLocation = [ 'url' => 'https://example.com/room', 'location' => 'https://example.com/room', 'description' => 'Agenda', ]; $this->assertSame( 'Agenda', $this->invoke($c, 'mergeUrlIntoDescription', [$urlEqualsLocation]) ); $emptyDescription = [ 'url' => 'https://example.com/room', 'location' => '', 'description' => '', ]; $this->assertSame( 'https://example.com/room', $this->invoke($c, 'mergeUrlIntoDescription', [$emptyDescription]) ); $both = [ 'url' => 'https://example.com/room', 'location' => 'https://meet.example.com/other', 'description' => 'Agenda', ]; $this->assertSame( "Agenda\n\nhttps://example.com/room", $this->invoke($c, 'mergeUrlIntoDescription', [$both]) ); } /* * calendar form value, selection and default */ public function testCalendarValue(): void { $c = $this->newController(); $calendar = $this->mockCalendar('5', 'personal', 'Personal', Constants::PERMISSION_CREATE); $this->assertSame('5::personal', $this->invoke($c, 'calendarValue', [$calendar])); } public function testSelectCalendarResolvesKeyCollision(): void { $c = $this->newController(); $personal = $this->mockCalendar('5', 'personal', 'Personal', Constants::PERMISSION_CREATE); $shared = $this->mockCalendar('5', 'shared-by-bob', 'Shared', Constants::PERMISSION_CREATE); $calendars = [$personal, $shared]; $this->assertSame($personal, $this->invoke($c, 'selectCalendar', [$calendars, '5::personal'])); $this->assertSame($shared, $this->invoke($c, 'selectCalendar', [$calendars, '5::shared-by-bob'])); } public static function provideNoMatchValues(): iterable { return [ 'no separator' => ['5'], 'wrong uri' => ['5::nope'], 'wrong key' => ['9::personal'], 'empty' => [''], ]; } #[DataProvider('provideNoMatchValues')] public function testSelectCalendarNoMatch(string $value): void { $c = $this->newController(); $calendar = $this->mockCalendar('5', 'personal', 'Personal', Constants::PERMISSION_CREATE); $this->assertNull($this->invoke($c, 'selectCalendar', [[$calendar], $value])); } public function testDefaultCalendarId(): void { $c = $this->newController(); $work = $this->mockCalendar('1', 'work', 'Work', Constants::PERMISSION_CREATE); $personal = $this->mockCalendar('2', 'personal', 'Personal', Constants::PERMISSION_CREATE); $this->assertSame('2::personal', $this->invoke($c, 'defaultCalendarId', [[$work, $personal]])); $this->assertSame('1::work', $this->invoke($c, 'defaultCalendarId', [[$work]])); $this->assertSame('', $this->invoke($c, 'defaultCalendarId', [[]])); } /* * findWritableCalendars */ public function testFindWritableCalendars(): void { $this->calendarManager->method('getCalendarsForPrincipal') ->with('principals/users/alice') ->willReturn([ $this->mockCalendar('1', 'work', 'Work', Constants::PERMISSION_CREATE), $this->mockCalendar('2', 'shared', 'Shared', Constants::PERMISSION_READ), $this->createMock(ICalendar::class), ]); $c = $this->newController(); $writable = $this->invoke($c, 'findWritableCalendars', ['alice']); $this->assertCount(1, $writable); $this->assertSame('1', $writable[0]->getKey()); } /* * createEvent: VALARM injection */ public function testCreateEventInjectsValarm(): void { $builder = $this->createMock(ICalendarEventBuilder::class); $builder->method('toIcs')->willReturn( "BEGIN:VCALENDAR\r\n" . "VERSION:2.0\r\n" . "PRODID:-//Nextcloud//AToCal//EN\r\n" . "BEGIN:VEVENT\r\n" . "UID:test-uid-123\r\n" . "SUMMARY:Weekly sync\r\n" . "END:VEVENT\r\n" . "END:VCALENDAR\r\n" ); $this->calendarManager->method('createEventBuilder')->willReturn($builder); $calendar = $this->mockCalendar('5', 'personal', 'Personal', Constants::PERMISSION_CREATE); $calendar->expects($this->once())->method('createFromString') ->willReturnCallback(function (string $name, string $data): bool { $this->assertSame('test-uid-123.ics', $name); $this->assertStringContainsString('BEGIN:VALARM', $data); $this->assertStringContainsString('TRIGGER:-PT15M', $data); return true; }); $c = $this->newController(); $data = [ 'summary' => 'Weekly sync', 'description' => '', 'start' => new \DateTimeImmutable('2026-10-07T14:15:00 UTC'), 'end' => new \DateTimeImmutable('2026-10-07T15:15:00 UTC'), 'organizer' => '', 'location' => '', 'url' => '', 'attendees' => [], ]; $this->invoke($c, 'createEvent', [$calendar, $data]); } /* * createEvent: ATTENDEE injection */ public function testCreateEventInvitesAttendees(): void { $builder = $this->createMock(ICalendarEventBuilder::class); $builder->method('toIcs')->willReturn( "BEGIN:VCALENDAR\r\n" . "VERSION:2.0\r\n" . "PRODID:-//Nextcloud//AToCal//EN\r\n" . "BEGIN:VEVENT\r\n" . "UID:test-uid-123\r\n" . "SUMMARY:Weekly sync\r\n" . "END:VEVENT\r\n" . "END:VCALENDAR\r\n" ); $this->calendarManager->method('createEventBuilder')->willReturn($builder); $calendar = $this->mockCalendar('5', 'personal', 'Personal', Constants::PERMISSION_CREATE); $calendar->expects($this->once())->method('createFromString') ->willReturnCallback(function (string $name, string $data): bool { $this->assertStringContainsString( 'ATTENDEE;CN=Alice Example:mailto:alice@example.com', $data ); $this->assertStringContainsString( 'ATTENDEE:mailto:bob@example.com', $data ); return true; }); $c = $this->newController(); $data = [ 'summary' => 'Weekly sync', 'description' => '', 'start' => new \DateTimeImmutable('2026-10-07T14:15:00 UTC'), 'end' => new \DateTimeImmutable('2026-10-07T15:15:00 UTC'), 'organizer' => '', 'location' => '', 'url' => '', 'attendees' => [ ['email' => 'alice@example.com', 'name' => 'Alice Example'], ['email' => 'bob@example.com', 'name' => ''], ], ]; $this->invoke($c, 'createEvent', [$calendar, $data]); } /* * create(): happy path redirects to the Calendar app */ private function setUpWritableCalendars(): void { $this->calendarManager->method('getCalendarsForPrincipal') ->willReturn([ $this->mockCalendar('5', 'personal', 'Personal', Constants::PERMISSION_CREATE), ]); } /* * The event builder ICS is parsed by VObject before it * reaches createFromString(), so the create() tests need * a valid skeleton ICS. */ private function setUpEventBuilder(): void { $builder = $this->createMock(ICalendarEventBuilder::class); $builder->method('toIcs')->willReturn( "BEGIN:VCALENDAR\r\n" . "VERSION:2.0\r\n" . "PRODID:-//Nextcloud//AToCal//EN\r\n" . "BEGIN:VEVENT\r\n" . "UID:test-uid-123\r\n" . "SUMMARY:Weekly sync\r\n" . "END:VEVENT\r\n" . "END:VCALENDAR\r\n" ); $this->calendarManager->method('createEventBuilder')->willReturn($builder); } private function validCreateArgs(): array { return [ 'Weekly sync', 'Agenda', '2026-10-07T14:15', '2026-10-07T15:15', 'boss@example.com', '', '', '5::personal', ]; } public function testCreateRedirectsToCalendarApp(): void { $this->userSession->method('getUser')->willReturn($this->mockUser('alice')); $this->setUpWritableCalendars(); $this->setUpEventBuilder(); $this->appManager->method('isEnabledForUser')->willReturn(true); $this->urlGenerator->method('linkToRoute')->willReturn('/apps/calendar/'); $c = $this->newController(); $response = $c->create(...$this->validCreateArgs()); $this->assertInstanceOf(RedirectResponse::class, $response); $this->assertSame('/apps/calendar/', $response->getRedirectURL()); } public function testCreateFallsBackWithoutCalendarApp(): void { $this->userSession->method('getUser')->willReturn($this->mockUser('alice')); $this->setUpWritableCalendars(); $this->setUpEventBuilder(); $this->appManager->method('isEnabledForUser')->willReturn(false); $this->urlGenerator->method('getAbsoluteURL')->willReturn('https://cloud.example.com/'); $c = $this->newController(); $response = $c->create(...$this->validCreateArgs()); $this->assertInstanceOf(RedirectResponse::class, $response); $this->assertSame('https://cloud.example.com/', $response->getRedirectURL()); } public function testCreateUnwritableSelectionShowsError(): void { $this->userSession->method('getUser')->willReturn($this->mockUser('alice')); $this->setUpWritableCalendars(); $args = $this->validCreateArgs(); $args[7] = '9::nope'; $c = $this->newController(); $response = $c->create(...$args); $this->assertInstanceOf(TemplateResponse::class, $response); $this->assertSame('confirm', $response->getTemplateName()); $this->assertSame( 'The selected calendar is no longer writable. Pick another one and try again.', $response->getParams()['error'] ); } public function testCreateNoWritableCalendars(): void { $this->userSession->method('getUser')->willReturn($this->mockUser('alice')); $this->calendarManager->method('getCalendarsForPrincipal')->willReturn([]); $c = $this->newController(); $response = $c->create(...$this->validCreateArgs()); $this->assertSame( 'No writable calendar was found in your account. Create one in the Nextcloud Calendar app, then try again.', $response->getParams()['error'] ); } public function testCreateLogsBackendFailure(): void { $this->userSession->method('getUser')->willReturn($this->mockUser('alice')); $this->setUpEventBuilder(); $calendar = $this->mockCalendar('5', 'personal', 'Personal', Constants::PERMISSION_CREATE); $calendar->method('createFromString') ->willThrowException(new \RuntimeException('backend down')); $this->calendarManager->method('getCalendarsForPrincipal') ->willReturn([$calendar]); $this->logger->expects($this->once())->method('error') ->with( 'Failed to create calendar event: backend down', ['exception' => new \RuntimeException('backend down')] ); $c = $this->newController(); $response = $c->create(...$this->validCreateArgs()); $this->assertInstanceOf(TemplateResponse::class, $response); $this->assertSame( 'The event could not be created. Please try again.', $response->getParams()['error'] ); } public function testCreateWithoutUserThrows(): void { $this->userSession->method('getUser')->willReturn(null); $c = $this->newController(); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('No authenticated Nextcloud user.'); $c->create(...$this->validCreateArgs()); } public function testCreateInvalidInputRendersErrorPage(): void { $c = $this->newController(); $args = $this->validCreateArgs(); $args[0] = ''; $response = $c->create(...$args); $this->assertInstanceOf(TemplateResponse::class, $response); $this->assertSame('error', $response->getTemplateName()); $this->assertSame('core', $response->getApp()); $this->assertSame( 'Meeting title is required.', $response->getParams()['errors'][0]['error'] ); } /* * confirm() */ public function testConfirmRendersTemplate(): void { $this->userSession->method('getUser')->willReturn($this->mockUser('alice')); $this->setUpWritableCalendars(); $this->urlGenerator->method('getAbsoluteURL')->willReturn('https://cloud.example.com/'); $c = $this->newController(); $response = $c->confirm( 'Weekly sync', 'Agenda', '2026-10-07T14:15', '2026-10-07T15:15', 'boss@example.com', 'https://meet.example.com/room', '' ); $this->assertInstanceOf(TemplateResponse::class, $response); $this->assertSame('confirm', $response->getTemplateName()); $params = $response->getParams(); $this->assertSame('Weekly sync', $params['summary']); $this->assertSame('2026-10-07T14:15', $params['start']); $this->assertSame('', $params['error']); $this->assertSame('5::personal', $params['calendars'][0]['value']); $this->assertSame('5::personal', $params['calendarId']); } public function testConfirmInvalidInputRendersErrorPage(): void { $c = $this->newController(); $response = $c->confirm( 'Weekly sync', '', '2026-10-07T14:15', '2026-10-07T13:15' ); $this->assertSame('error', $response->getTemplateName()); $this->assertSame( 'End time must be after start time.', $response->getParams()['errors'][0]['error'] ); } public function testConfirmRegistersStylesheet(): void { $this->userSession->method('getUser')->willReturn($this->mockUser('alice')); $this->setUpWritableCalendars(); $this->urlGenerator->method('getAbsoluteURL')->willReturn('https://cloud.example.com/'); $c = $this->newController(); $c->confirm('Weekly sync', '', '2026-10-07T14:15', '2026-10-07T15:15'); $this->assertContains( ['atocal', 'style', false], \OC_Util::$styles ); } /* * parseAttendees() */ public function testParseAttendeesNormalizesInput(): void { $c = $this->newController(); $attendees = $this->invoke($c, 'parseAttendees', [ 'alice@example.com, BOB@Example.com ; bob@example.com carl@example.com', ]); $this->assertSame([ ['email' => 'alice@example.com', 'name' => ''], ['email' => 'bob@example.com', 'name' => ''], ['email' => 'carl@example.com', 'name' => ''], ], $attendees); } public function testParseAttendeesEmptyInput(): void { $c = $this->newController(); $this->assertSame( [], $this->invoke($c, 'parseAttendees', [' ']) ); } public function testParseAttendeesRejectsInvalidEmail(): void { $c = $this->newController(); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('is not a valid email address.'); $this->invoke($c, 'parseAttendees', ['alice@example.com,not-an-email']); } public function testParseAttendeesEnforcesLimit(): void { $c = $this->newController(); $emails = implode(',', array_map( static fn (int $i): string => "person$i@example.com", range(1, 21) )); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('A meeting can invite at most 20 people.'); $this->invoke($c, 'parseAttendees', [$emails]); } /* * confirm(): contact suggestions for the datalist */ public function testConfirmOffersContactSuggestions(): void { $this->userSession->method('getUser')->willReturn($this->mockUser('alice')); $this->setUpWritableCalendars(); $this->urlGenerator->method('getAbsoluteURL')->willReturn('https://cloud.example.com/'); $this->contactsManager->method('search')->willReturn([ ['FN' => 'Alice Example', 'EMAIL' => 'alice@example.com'], ['FN' => 'Bob', 'EMAIL' => ['bob@example.com', 'bob@work.example.com']], ]); $c = $this->newController(); $response = $c->confirm( 'Weekly sync', '', '2026-10-07T14:15', '2026-10-07T15:15' ); $this->assertSame('confirm', $response->getTemplateName()); $this->assertSame([ ['email' => 'alice@example.com', 'name' => 'Alice Example'], ['email' => 'bob@example.com', 'name' => 'Bob'], ['email' => 'bob@work.example.com', 'name' => 'Bob'], ], $response->getParams()['contacts']); } /* * create(): invalid invitee input keeps the form usable */ public function testCreateInvalidAttendeeRendersConfirm(): void { $this->userSession->method('getUser')->willReturn($this->mockUser('alice')); $this->setUpWritableCalendars(); $args = $this->validCreateArgs(); $args[] = 'alice@example.com, not-an-email'; $c = $this->newController(); $response = $c->create(...$args); $this->assertSame('confirm', $response->getTemplateName()); $params = $response->getParams(); $this->assertStringContainsString( 'is not a valid email address.', $params['error'] ); $this->assertSame('alice@example.com, not-an-email', $params['attendees']); $this->assertSame('5::personal', $params['calendarId']); } }