1
0

SessionRevalidationTest.php 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Auth;
  4. use App\Auth\UserContext;
  5. use App\Tests\Integration\Support\AppTestCase;
  6. /**
  7. * SEC_REVIEW F36 — `AuthRequiredMiddleware` periodically revalidates the
  8. * cached user against `GET /api/v1/admin/me`. A role change in Entra,
  9. * an explicit disable in the api, or a deleted user record propagates
  10. * to the live UI session at the next protected request that's past the
  11. * revalidation window — no need to wait for the 8 h idle timeout.
  12. */
  13. final class SessionRevalidationTest extends AppTestCase
  14. {
  15. protected function setUp(): void
  16. {
  17. $this->bootApp();
  18. // The first /app/* request in a process triggers session_start(),
  19. // which clobbers $_SESSION values primed by the test. Hit a public
  20. // route first so the session is already active before the test
  21. // request fires (same workaround as SearchPageTest).
  22. $this->request('GET', '/healthz');
  23. }
  24. public function testWithinIntervalSkipsRevalidation(): void
  25. {
  26. // Prime a fresh session with `_revalidated_at = now`. The middleware
  27. // should NOT call the api on this request.
  28. $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time());
  29. // Dashboard fires its own getDashboardStats() call; stub it.
  30. $this->stubDashboardApiCalls();
  31. $response = $this->request('GET', '/app/dashboard');
  32. // We reached the dashboard handler — middleware did not bounce us
  33. // back to /login or trigger a revalidation request to /admin/me.
  34. self::assertNotSame('/login', $response->getHeaderLine('Location'));
  35. // Exactly one api call: the dashboard stats. No /admin/me call.
  36. $apiPaths = array_map(
  37. static fn (array $entry): string => (string) $entry['request']->getUri()->getPath(),
  38. $this->apiHistory,
  39. );
  40. self::assertNotContains('/api/v1/admin/me', $apiPaths);
  41. }
  42. public function testRevalidationKeepsSessionWhenRoleUnchanged(): void
  43. {
  44. // `_revalidated_at` set to 10 minutes ago → revalidation triggers.
  45. $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time() - 600);
  46. $this->enqueueApiResponse(200, [
  47. 'user_id' => 1, 'role' => 'admin', 'email' => 'a@x',
  48. 'display_name' => 'Admin', 'is_local' => true,
  49. ]);
  50. // Dashboard page itself fires its own api calls — feed a permissive stub.
  51. $this->stubDashboardApiCalls();
  52. $response = $this->request('GET', '/app/dashboard');
  53. self::assertNotSame(302, $response->getStatusCode());
  54. self::assertSame('admin', $_SESSION['_user']['role']);
  55. // `_revalidated_at` was rotated forward.
  56. self::assertGreaterThanOrEqual(time() - 5, (int) $_SESSION['_revalidated_at']);
  57. }
  58. public function testRevalidationUpdatesSessionWhenRoleChanged(): void
  59. {
  60. $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time() - 600);
  61. // Api now reports the user as a viewer (was admin in session).
  62. $this->enqueueApiResponse(200, [
  63. 'user_id' => 1, 'role' => 'viewer', 'email' => 'a@x',
  64. 'display_name' => 'Admin', 'is_local' => true,
  65. ]);
  66. $this->stubDashboardApiCalls();
  67. $this->request('GET', '/app/dashboard');
  68. self::assertSame('viewer', $_SESSION['_user']['role']);
  69. }
  70. public function testDisabledUserIsKickedToLogin(): void
  71. {
  72. // User disabled in api → ImpersonationMiddleware returns 403 user_disabled.
  73. $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time() - 600);
  74. $this->enqueueApiResponse(403, ['error' => 'user_disabled']);
  75. $response = $this->request('GET', '/app/dashboard');
  76. self::assertSame(302, $response->getStatusCode());
  77. self::assertSame('/login', $response->getHeaderLine('Location'));
  78. self::assertArrayNotHasKey('_user', $_SESSION);
  79. // The clear-and-flash-and-redirect is what the user sees.
  80. $flash = $_SESSION['_flash'] ?? [];
  81. self::assertNotEmpty($flash);
  82. self::assertSame('error', $flash[0]['type']);
  83. self::assertStringContainsString('revoked', $flash[0]['message']);
  84. }
  85. public function testDeletedUserIsKickedToLogin(): void
  86. {
  87. // Api reports 403 unknown impersonated user.
  88. $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time() - 600);
  89. $this->enqueueApiResponse(403, ['error' => 'unknown impersonated user']);
  90. $response = $this->request('GET', '/app/dashboard');
  91. self::assertSame(302, $response->getStatusCode());
  92. self::assertSame('/login', $response->getHeaderLine('Location'));
  93. self::assertArrayNotHasKey('_user', $_SESSION);
  94. }
  95. public function testApiUnreachableKeepsSessionAlive(): void
  96. {
  97. // API down: an outage must NOT lock every session out. The middleware
  98. // should log + tolerate, marking the session revalidated so it doesn't
  99. // hammer the api on every request.
  100. $this->seedSession(userId: 1, role: 'admin', revalidatedAt: time() - 600);
  101. // Two ConnectExceptions because ApiClient retries once on connect.
  102. $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
  103. 'down',
  104. new \GuzzleHttp\Psr7\Request('GET', '/'),
  105. ));
  106. $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
  107. 'down',
  108. new \GuzzleHttp\Psr7\Request('GET', '/'),
  109. ));
  110. $this->stubDashboardApiCalls();
  111. $response = $this->request('GET', '/app/dashboard');
  112. // Session preserved.
  113. self::assertNotSame('/login', $response->getHeaderLine('Location'));
  114. self::assertSame('admin', $_SESSION['_user']['role']);
  115. // Marked revalidated even though the api was down.
  116. self::assertGreaterThanOrEqual(time() - 5, (int) $_SESSION['_revalidated_at']);
  117. }
  118. public function testLegacySessionWithoutRevalidatedAtIsBootstrapped(): void
  119. {
  120. // Pre-F36 sessions don't have `_revalidated_at`. The middleware
  121. // must bootstrap the timestamp instead of revalidating immediately
  122. // (no api call, no test breakage on legacy fixtures).
  123. $_SESSION['_user'] = (new UserContext(1, 'Admin', 'admin', null, UserContext::SOURCE_LOCAL))->toArray();
  124. $_SESSION['_last_active'] = time();
  125. $_SESSION['_authenticated_at'] = time();
  126. // No _revalidated_at deliberately.
  127. $this->stubDashboardApiCalls();
  128. $this->request('GET', '/app/dashboard');
  129. self::assertArrayHasKey('_revalidated_at', $_SESSION);
  130. self::assertGreaterThanOrEqual(time() - 5, (int) $_SESSION['_revalidated_at']);
  131. }
  132. private function seedSession(int $userId, string $role, int $revalidatedAt): void
  133. {
  134. $_SESSION['_user'] = (new UserContext(
  135. $userId,
  136. 'Admin',
  137. $role,
  138. 'a@x',
  139. UserContext::SOURCE_LOCAL,
  140. ))->toArray();
  141. $_SESSION['_last_active'] = time();
  142. $_SESSION['_authenticated_at'] = time();
  143. $_SESSION['_revalidated_at'] = $revalidatedAt;
  144. }
  145. /**
  146. * Dashboard page fires its own api calls. Feed empty/200 stubs so the
  147. * test focuses on the revalidation outcome rather than the page content.
  148. */
  149. private function stubDashboardApiCalls(): void
  150. {
  151. // Dashboard issues a getDashboardStats() call; queue a permissive
  152. // empty payload. Tests that 302 away never reach the dashboard so
  153. // unconsumed stubs don't cause failures.
  154. $this->enqueueApiResponse(200, [
  155. 'totals' => [],
  156. 'top_categories' => [],
  157. 'top_countries' => [],
  158. 'last_7_days' => [],
  159. 'top_reporters_30d' => [],
  160. ]);
  161. }
  162. }