Files
atocal/tests/Unit/CalendarControllerTest.php
T

731 lines
25 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace OCA\AToCal\Tests\Unit\Controller;
use OCA\AToCal\Controller\CalendarController;
use OCP\App\IAppManager;
use OCP\AppFramework\Http\RedirectResponse;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\Calendar\ICalendar;
use OCP\Calendar\ICalendarEventBuilder;
use OCP\Calendar\ICreateFromString;
use OCP\Calendar\IManager;
use OCP\Constants;
use OCP\IDateTimeZone;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserSession;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
require_once __DIR__ . '/OCUtilStub.php';
class CalendarControllerTest extends TestCase
{
private IManager&MockObject $calendarManager;
private IAppManager&MockObject $appManager;
private IUserSession&MockObject $userSession;
private IURLGenerator&MockObject $urlGenerator;
private LoggerInterface&MockObject $logger;
protected function setUp(): void
{
$this->calendarManager = $this->createMock(IManager::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) => $text
);
return new CalendarController(
'atocal',
$this->createMock(IRequest::class),
$this->calendarManager,
$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' => '',
];
$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
);
}
}