'UTC']); CarbonImmutable::setTestNow('2026-09-04T12:00:00Z'); } protected function tearDown(): void { CarbonImmutable::setTestNow(); parent::tearDown(); } public function test_requires_sso(): void { $this->get('/')->assertStatus(401); } public function test_renders_groups_cards_archive_and_settings(): void { $this->get('/', ['X-Remote-User' => 'nikita']); $user = User::sole(); $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]); $manual = Source::create(['name' => 'manual', 'token_hash' => hash('sha256', 'y')]); $make = fn (Source $src, string $topic, EventState $state, string $due, string $time = '24:00:00', ?string $doneLabel = null, ?string $link = '/x') => Event::create([ 'source_id' => $src->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => $topic, 'due_mode' => DueMode::Local, 'due_date' => $due, 'due_time' => $time, 'fire_on' => $due, 'after_due' => AfterDue::Keep, 'payload' => array_filter(['title' => "Событие $topic", 'subtitle' => 'подзаголовок', 'deep_link' => $link, 'done_label' => $doneLabel]), 'state' => $state, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'), ]); $make($source, 'a', EventState::Today, '2026-09-04', '19:40:00', 'Поздравил'); $make($source, 'b', EventState::Overdue, '2026-09-01'); $make($manual, 'c', EventState::Preparing, '2026-09-30', '24:00:00', null, null); $make($source, 'd', EventState::Scheduled, '2026-12-15'); $make($source, 'e', EventState::Done, '2026-08-28', '24:00:00', 'Оплатил'); $make($source, 'f', EventState::Expired, '2026-08-20'); $html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent(); // шапка и счётчик $this->assertStringContainsString('(2) Hado', $html); $this->assertStringContainsString('波動', $html); $this->assertStringContainsString('+ Добавить', $html); // группы в порядке и с кандзи $this->assertMatchesRegularExpression('/今日.*СЕГОДНЯ.*遅延.*ПРОСРОЧЕНО.*近日.*ГРЯДЁТ.*予定.*ЗАПЛАНИРОВАНО/su', $html); // карточки $this->assertStringContainsString('Событие a', $html); $this->assertStringContainsString('19:40', $html); $this->assertStringContainsString('СЕН', $html); $this->assertStringContainsString('сегодня', $html); $this->assertStringContainsString('3 дня назад', $html); $this->assertStringContainsString('через 26 дней', $html); $this->assertStringContainsString('вручную', $html); $this->assertStringContainsString('Поздравил', $html); $this->assertStringContainsString('Помню', $html); // архив $this->assertStringContainsString('2 закрыто', $html); $this->assertStringContainsString('Оплатил', $html); $this->assertStringContainsString('Истекло', $html); // настройки, форма добавления, присутствие $this->assertStringContainsString('nikita', $html); $this->assertStringContainsString('НОВОЕ СОБЫТИЕ', $html); $this->assertStringContainsString('/inbox.js', $html); $this->assertStringContainsString('csrf-token', $html); // инлайн-ошибка карточки и обработка 419 в клиентском скрипте $this->assertStringContainsString('card-err', $html); $this->assertStringContainsString('419', file_get_contents(public_path('inbox.js'))); // Запланировано — витрина, без действий «Помню»/«Сделано» $this->assertStringContainsString('>Помню<', $html); $scheduledSection = substr($html, (int) strpos($html, '予定')); $this->assertStringNotContainsString('>Помню<', $scheduledSection); } public function test_counter_hidden_when_zero(): void { $html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent(); $this->assertStringContainsString('Hado', $html); $this->assertStringNotContainsString('class="counter"', $html); } public function test_unsafe_deep_link_is_not_rendered_as_href(): void { $this->get('/', ['X-Remote-User' => 'nikita']); $user = User::sole(); $source = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]); Event::create([ 'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => 'a', 'due_mode' => DueMode::Local, 'due_date' => '2026-09-04', 'due_time' => '24:00:00', 'fire_on' => '2026-09-04', 'after_due' => AfterDue::Keep, 'payload' => ['title' => 'Опасное событие', 'deep_link' => 'javascript:alert(1)'], 'state' => EventState::Today, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'), ]); Event::create([ 'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => 'b', 'due_mode' => DueMode::Local, 'due_date' => '2026-09-04', 'due_time' => '24:00:00', 'fire_on' => '2026-09-04', 'after_due' => AfterDue::Keep, 'payload' => ['title' => 'Протокольная ссылка', 'deep_link' => '//evil.com/x'], 'state' => EventState::Today, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'), ]); Event::create([ 'source_id' => $source->id, 'user_id' => $user->id, 'source_ref' => 'r', 'topic' => 'c', 'due_mode' => DueMode::Local, 'due_date' => '2026-09-04', 'due_time' => '24:00:00', 'fire_on' => '2026-09-04', 'after_due' => AfterDue::Keep, 'payload' => ['title' => 'Обратный слэш', 'deep_link' => '/\evil.com'], 'state' => EventState::Today, 'quiet_until' => CarbonImmutable::parse('2000-01-01Z'), ]); $html = $this->get('/', ['X-Remote-User' => 'nikita'])->assertOk()->getContent(); $this->assertStringNotContainsString('href="javascript:', $html); $this->assertStringNotContainsString('href="//evil', $html); $this->assertStringNotContainsString('href="/\\', $html); $this->assertStringContainsString('Опасное событие', $html); $this->assertStringContainsString('Протокольная ссылка', $html); $this->assertStringContainsString('Обратный слэш', $html); } }