bootApp(); // The first /app/* request in a process triggers session_start(), // which clobbers $_SESSION values primed by the test. Hit a public // route first so the session is already active before the test // request fires (same workaround as SearchPageTest). $this->request('GET', '/healthz'); } public function testWithinIntervalSkipsRevalidation(): void { // Prime a fresh session with `_revalidated_at = now`. The middleware // should NOT call the api on this request. $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time()); // Dashboard fires its own getDashboardStats() call; stub it. $this->stubDashboardApiCalls(); $response = $this->request('GET', '/app/dashboard'); // We reached the dashboard handler — middleware did not bounce us // back to /login or trigger a revalidation request to /admin/me. self::assertNotSame('/login', $response->getHeaderLine('Location')); // Exactly one api call: the dashboard stats. No /admin/me call. $apiPaths = array_map( static fn (array $entry): string => (string) $entry['request']->getUri()->getPath(), $this->apiHistory, ); self::assertNotContains('/api/v1/admin/me', $apiPaths); } public function testRevalidationKeepsSessionWhenRoleUnchanged(): void { // `_revalidated_at` set to 10 minutes ago → revalidation triggers. $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time() - 600); $this->enqueueApiResponse(200, [ 'user_id' => 1, 'role' => 'admin', 'email' => 'a@x', 'display_name' => 'Admin', 'is_local' => true, ]); // Dashboard page itself fires its own api calls — feed a permissive stub. $this->stubDashboardApiCalls(); $response = $this->request('GET', '/app/dashboard'); self::assertNotSame(302, $response->getStatusCode()); self::assertSame('admin', $_SESSION['_user']['role']); // `_revalidated_at` was rotated forward. self::assertGreaterThanOrEqual(time() - 5, (int) $_SESSION['_revalidated_at']); } public function testRevalidationUpdatesSessionWhenRoleChanged(): void { $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time() - 600); // Api now reports the user as a viewer (was admin in session). $this->enqueueApiResponse(200, [ 'user_id' => 1, 'role' => 'viewer', 'email' => 'a@x', 'display_name' => 'Admin', 'is_local' => true, ]); $this->stubDashboardApiCalls(); $this->request('GET', '/app/dashboard'); self::assertSame('viewer', $_SESSION['_user']['role']); } public function testDisabledUserIsKickedToLogin(): void { // User disabled in api → ImpersonationMiddleware returns 403 user_disabled. $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time() - 600); $this->enqueueApiResponse(403, ['error' => 'user_disabled']); $response = $this->request('GET', '/app/dashboard'); self::assertSame(302, $response->getStatusCode()); self::assertSame('/login', $response->getHeaderLine('Location')); self::assertArrayNotHasKey('_user', $_SESSION); // The clear-and-flash-and-redirect is what the user sees. $flash = $_SESSION['_flash'] ?? []; self::assertNotEmpty($flash); self::assertSame('error', $flash[0]['type']); self::assertStringContainsString('revoked', $flash[0]['message']); } public function testDeletedUserIsKickedToLogin(): void { // Api reports 403 unknown impersonated user. $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time() - 600); $this->enqueueApiResponse(403, ['error' => 'unknown impersonated user']); $response = $this->request('GET', '/app/dashboard'); self::assertSame(302, $response->getStatusCode()); self::assertSame('/login', $response->getHeaderLine('Location')); self::assertArrayNotHasKey('_user', $_SESSION); } public function testApiUnreachableKeepsSessionAlive(): void { // API down: an outage must NOT lock every session out. The middleware // should log + tolerate, marking the session revalidated so it doesn't // hammer the api on every request. $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time() - 600); // Two ConnectExceptions because ApiClient retries once on connect. $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException( 'down', new \GuzzleHttp\Psr7\Request('GET', '/'), )); $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException( 'down', new \GuzzleHttp\Psr7\Request('GET', '/'), )); $this->stubDashboardApiCalls(); $response = $this->request('GET', '/app/dashboard'); // Session preserved. self::assertNotSame('/login', $response->getHeaderLine('Location')); self::assertSame('admin', $_SESSION['_user']['role']); // Marked revalidated even though the api was down. self::assertGreaterThanOrEqual(time() - 5, (int) $_SESSION['_revalidated_at']); } public function testLegacySessionWithoutRevalidatedAtIsBootstrapped(): void { // Pre-F36 sessions don't have `_revalidated_at`. The middleware // must bootstrap the timestamp instead of revalidating immediately // (no api call, no test breakage on legacy fixtures). $_SESSION['_user'] = (new UserContext(1, 'Admin', 'admin', null, UserContext::SOURCE_LOCAL))->toArray(); $_SESSION['_last_active'] = time(); $_SESSION['_authenticated_at'] = time(); // No _revalidated_at deliberately. $this->stubDashboardApiCalls(); $this->request('GET', '/app/dashboard'); self::assertArrayHasKey('_revalidated_at', $_SESSION); self::assertGreaterThanOrEqual(time() - 5, (int) $_SESSION['_revalidated_at']); } private function seedSession(int $userId, string $role, int $revalidatedAt): void { $_SESSION['_user'] = (new UserContext( $userId, 'Admin', $role, 'a@x', UserContext::SOURCE_LOCAL, ))->toArray(); $_SESSION['_last_active'] = time(); $_SESSION['_authenticated_at'] = time(); $_SESSION['_revalidated_at'] = $revalidatedAt; } /** * Dashboard page fires its own api calls. Feed empty/200 stubs so the * test focuses on the revalidation outcome rather than the page content. */ private function stubDashboardApiCalls(): void { // Dashboard issues a getDashboardStats() call; queue a permissive // empty payload. Tests that 302 away never reach the dashboard so // unconsumed stubs don't cause failures. $this->enqueueApiResponse(200, [ 'totals' => [], 'top_categories' => [], 'top_countries' => [], 'last_7_days' => [], 'top_reporters_30d' => [], ]); } }