calendarRepository = $this->make(CalendarRepository::class); $this->language = $this->make(Language::class); $this->settingsRepository = $this->make(Setting::class, [ 'getSetting' => 'secret', ]); $this->config = $this->make(Environment::class, [ 'sessionPassword' => '123abc', ]); // Load class to be tested $this->calendar = new \Leantime\Domain\Calendar\Services\Calendar( calendarRepo: $this->calendarRepository, language: $this->language, settingsRepo: $this->settingsRepository, config: $this->config ); } protected function _after() { $this->calendar = null; } // Write tests below /** * Test GetMenuTypes method */ public function test_get_i_cal_url() { // Sha is generated from id -1 and sessionpassword 123abc $sha = 'ba62fbd0d08f6607d6b3213dcccc1b50f4d82f19'; $url = $this->calendar->getICalUrl(1); $this->assertEquals(BASE_URL.'/calendar/ical/secret_'.$sha, $url, 'hash is not correct'); } /** * A token that does not split into exactly two hashes must throw. */ public function test_get_ical_by_request_token_rejects_malformed_token() { $this->expectException(MissingParameterException::class); // No underscore -> only one part -> invalid. $this->calendar->getIcalByRequestToken('notavalidtoken'); } /** * A token taken from the request id (no 3-part act) must parse into * userHash/calHash and route them to the repository correctly. */ public function test_get_ical_by_request_token_parses_id_token_and_routes_hashes() { $capturedUserHash = null; $capturedCalHash = null; $calendarRepo = $this->make(CalendarRepository::class, [ 'getCalendarBySecretHash' => function (string $userHash, string $calHash) use (&$capturedUserHash, &$capturedCalHash) { $capturedUserHash = $userHash; $capturedCalHash = $calHash; return [ [ 'id' => 1, 'title' => 'Event', 'description' => 'desc', 'dateFrom' => '2025-04-16 10:00:00', 'dateTo' => '2025-04-16 11:00:00', 'allDay' => false, 'eventType' => 'calendar', 'dateContext' => 'plan', 'url' => '', ], ]; }, ]); $service = new \Leantime\Domain\Calendar\Services\Calendar( calendarRepo: $calendarRepo, language: $this->language, settingsRepo: $this->settingsRepository, config: $this->config ); // Token format is {icalHash}_{userHash}. $result = $service->getIcalByRequestToken('calhash123_userhash456'); $this->assertInstanceOf(IcalCalendar::class, $result); $this->assertEquals('userhash456', $capturedUserHash, 'user hash should come from the second token segment'); $this->assertEquals('calhash123', $capturedCalHash, 'cal hash should come from the first token segment'); } /** * When the frontcontroller act value carries the token as its third * dot-separated segment it must take precedence over the id token. */ public function test_get_ical_by_request_token_prefers_act_segment() { $capturedUserHash = null; $capturedCalHash = null; $calendarRepo = $this->make(CalendarRepository::class, [ 'getCalendarBySecretHash' => function (string $userHash, string $calHash) use (&$capturedUserHash, &$capturedCalHash) { $capturedUserHash = $userHash; $capturedCalHash = $calHash; return [ [ 'id' => 1, 'title' => 'Event', 'description' => 'desc', 'dateFrom' => '2025-04-16 10:00:00', 'dateTo' => '2025-04-16 11:00:00', 'allDay' => false, 'eventType' => 'calendar', 'dateContext' => 'plan', 'url' => '', ], ]; }, ]); $service = new \Leantime\Domain\Calendar\Services\Calendar( calendarRepo: $calendarRepo, language: $this->language, settingsRepo: $this->settingsRepository, config: $this->config ); // act = calendar.ical.{icalHash}_{userHash}; id token is ignored. $result = $service->getIcalByRequestToken('ignored', 'calendar.ical.actcal_actuser'); $this->assertInstanceOf(IcalCalendar::class, $result); $this->assertEquals('actuser', $capturedUserHash, 'user hash should come from the act segment'); $this->assertEquals('actcal', $capturedCalHash, 'cal hash should come from the act segment'); } // ---- permission-engine authorization --------------------------------- /** Builds the service with a stubbed repo + PermissionService, as the session user (id 1). */ private function makeServiceWithPermissions( CalendarRepository $repo, \Leantime\Core\Auth\Permissions\PermissionService $perms ): \Leantime\Domain\Calendar\Services\Calendar { session(['userdata.id' => 1]); $service = new \Leantime\Domain\Calendar\Services\Calendar( calendarRepo: $repo, language: $this->language, settingsRepo: $this->settingsRepository, config: $this->config ); $service->setPermissionService($perms); return $service; } /** PermissionService stub: currentUserCan returns the given value for every key. */ private function permissions(bool $allow): \Leantime\Core\Auth\Permissions\PermissionService { return $this->make(\Leantime\Core\Auth\Permissions\PermissionService::class, [ 'currentUserCan' => fn () => $allow, 'authorize' => fn () => null, ]); } public function test_get_event_returns_own_event(): void { $repo = $this->make(CalendarRepository::class, [ 'getEvent' => fn () => ['id' => 5, 'userId' => 1, 'description' => 'mine'], ]); // can(MANAGE) = false, but the session user (1) owns the event. $service = $this->makeServiceWithPermissions($repo, $this->permissions(false)); $this->assertSame(1, $service->getEvent(5)['userId']); } public function test_get_event_soft_denies_foreign_event_without_manage(): void { $repo = $this->make(CalendarRepository::class, [ 'getEvent' => fn () => ['id' => 5, 'userId' => 2, 'description' => 'someone else'], ]); // Event owned by user 2; session user 1 lacks calendar.manage → soft-deny. $service = $this->makeServiceWithPermissions($repo, $this->permissions(false)); $this->assertFalse($service->getEvent(5)); } public function test_get_event_allows_foreign_event_with_manage(): void { $repo = $this->make(CalendarRepository::class, [ 'getEvent' => fn () => ['id' => 5, 'userId' => 2, 'description' => 'someone else'], ]); // calendar.manage (admin+) is the cross-user override. $service = $this->makeServiceWithPermissions($repo, $this->permissions(true)); $this->assertSame(2, $service->getEvent(5)['userId']); } public function test_get_external_calendar_ignores_passed_userid_and_uses_session(): void { $capturedUserId = null; $repo = $this->make(CalendarRepository::class, [ 'getExternalCalendar' => function ($id, $userId) use (&$capturedUserId) { $capturedUserId = $userId; return ['id' => $id, 'url' => 'https://example.com/cal.ics']; }, ]); $service = $this->makeServiceWithPermissions($repo, $this->permissions(true)); // Caller passes a FOREIGN userId (99); the service must query as the session user (1). $service->getExternalCalendar(7, 99); $this->assertSame(1, $capturedUserId, 'external calendar lookup must use the session user, not the passed id'); } public function test_get_my_external_calendars_ignores_passed_userid_and_uses_session(): void { $capturedUserId = null; $repo = $this->make(CalendarRepository::class, [ 'getMyExternalCalendars' => function ($userId) use (&$capturedUserId) { $capturedUserId = $userId; return []; }, ]); $service = $this->makeServiceWithPermissions($repo, $this->permissions(true)); $service->getMyExternalCalendars(99); $this->assertSame(1, $capturedUserId, 'calendar list must use the session user, not the passed id'); } public function test_rpc_surface_is_locked(): void { $reflect = fn (string $m) => (new \ReflectionMethod(\Leantime\Domain\Calendar\Services\Calendar::class, $m))->getDocComment(); $isApi = fn (string $m) => ($d = $reflect($m)) !== false && preg_match('/^\s*\*\s*@api\b/m', $d) === 1; $gate = function (string $m): ?string { $attrs = (new \ReflectionMethod(\Leantime\Domain\Calendar\Services\Calendar::class, $m)) ->getAttributes(\Leantime\Core\Auth\Permissions\RequiresPermission::class); return $attrs === [] ? null : $attrs[0]->newInstance()->permission; }; // The iCal feed methods are served by the public hash-authed route — never RPC-callable. $this->assertFalse($isApi('getIcalByHash'), 'getIcalByHash must not be @api'); $this->assertFalse($isApi('getIcalByRequestToken'), 'getIcalByRequestToken must not be @api'); // Every @api method carries a calendar.* dispatch gate. $expected = [ 'getEvent' => 'calendar.view', 'getExternalCalendar' => 'calendar.view', 'getMyExternalCalendars' => 'calendar.view', 'getCachedExternalCalendarContent' => 'calendar.view', 'addEvent' => 'calendar.create', 'addExternalCalendarUrl' => 'calendar.create', 'editEvent' => 'calendar.edit', 'editExternalCalendar' => 'calendar.edit', 'patch' => 'calendar.edit', 'delEvent' => 'calendar.delete', 'deleteGCal' => 'calendar.delete', ]; foreach ($expected as $method => $permission) { $this->assertTrue($isApi($method), "$method should stay @api"); $this->assertSame($permission, $gate($method), "$method must carry the $permission gate"); } } // ---- calendar feed robustness ---------------------------------------- /** * Regression for #3536: a ticket with a valid planned start (editFrom) but an * empty/sentinel editTo used to throw in parseDbDateTime(), 500-ing the whole * "My Work" calendar feed and leaving the dashboard widget loading forever. * editTo must now be guarded and fall back to editFrom. */ public function test_get_calendar_survives_ticket_with_empty_edit_to(): void { $repo = $this->make(CalendarRepository::class, [ 'getAll' => fn () => [], ]); $ticket = [ 'id' => 10, 'headline' => 'Planned task', 'description' => '', 'projectId' => 3, 'status' => 3, 'dateToFinish' => '', // invalid -> due-date block is skipped 'editFrom' => '2026-06-18 09:00:00', // valid planned start 'editTo' => '', // empty end date -> previously threw ]; $tickets = $this->make(Tickets::class, [ 'getOpenUserTicketsThisWeekAndLater' => fn () => ['thisWeek' => ['tickets' => [$ticket]]], 'getStatusLabels' => fn () => [], ]); app()->instance(Tickets::class, $tickets); $service = new \Leantime\Domain\Calendar\Services\Calendar( calendarRepo: $repo, language: $this->language, settingsRepo: $this->settingsRepository, config: $this->config ); $events = $service->getCalendar(1); $editEvents = array_values(array_filter( $events, fn ($event) => ($event['dateContext'] ?? null) === 'edit' )); $this->assertCount(1, $editEvents, 'the planned-edit event should still be produced (no exception)'); $this->assertSame('2026-06-18 09:00:00', $editEvents[0]['dateFrom']); $this->assertSame( '2026-06-18 09:00:00', $editEvents[0]['dateTo'], 'editTo should fall back to editFrom when empty' ); } }