| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Integration\Auth;
- use App\Auth\UserContext;
- use App\Tests\Integration\Support\AppTestCase;
- /**
- * SEC_REVIEW F36 — `AuthRequiredMiddleware` periodically revalidates the
- * cached user against `GET /api/v1/admin/me`. A role change in Entra,
- * an explicit disable in the api, or a deleted user record propagates
- * to the live UI session at the next protected request that's past the
- * revalidation window — no need to wait for the 8 h idle timeout.
- */
- final class SessionRevalidationTest extends AppTestCase
- {
- protected function setUp(): void
- {
- $this->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' => [],
- ]);
- }
- }
|