|
|
@@ -0,0 +1,189 @@
|
|
|
+<?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' => [],
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+}
|