1
0

SessionGuardTest.php 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Auth;
  4. use App\Auth\SessionGuard;
  5. use App\Tests\TestCase;
  6. /**
  7. * Pure-static unit tests for the R01-N08 idle-timeout helpers on
  8. * SessionGuard. These cover the boundary semantics of `isIdleExpired`
  9. * and the in-place mutation contract of `expireIdleSession` on a
  10. * vanilla PHP array, so the tests don't depend on the global
  11. * `$_SESSION`, cookies, or `session_start()` state.
  12. *
  13. * The CSRF rotation behaviour of `login()` is asserted indirectly
  14. * through `expireIdleSession`'s "drop csrf_token on idle" assertion —
  15. * the same `unset()` semantics are reused by the login() body to
  16. * force a fresh token on the next `csrfToken()` call.
  17. */
  18. final class SessionGuardTest extends TestCase
  19. {
  20. public function testIdleTimeoutConstantIsThirtyMinutes(): void
  21. {
  22. // Locks the policy so an accidental change shows up here as well as
  23. // in the boundary tests below.
  24. self::assertSame(1800, SessionGuard::IDLE_TIMEOUT_SECONDS);
  25. }
  26. public function testIsIdleExpiredFreshAndStaleBoundary(): void
  27. {
  28. $base = 1_700_000_000;
  29. self::assertFalse(
  30. SessionGuard::isIdleExpired($base, $base),
  31. 'zero gap is fresh',
  32. );
  33. self::assertFalse(
  34. SessionGuard::isIdleExpired($base, $base + 1),
  35. '1s gap is fresh',
  36. );
  37. self::assertFalse(
  38. SessionGuard::isIdleExpired($base, $base + 1799),
  39. '29:59 is still fresh',
  40. );
  41. self::assertTrue(
  42. SessionGuard::isIdleExpired($base, $base + 1800),
  43. 'exactly the threshold is expired (>= boundary)',
  44. );
  45. self::assertTrue(
  46. SessionGuard::isIdleExpired($base, $base + 7200),
  47. '2h gap is expired',
  48. );
  49. }
  50. public function testExpireIdleSessionLeavesAnonymousSessionAlone(): void
  51. {
  52. $now = 1_700_000_000;
  53. $before = ['oidc_state' => 'abc123', 'oidc_nonce' => 'xyz789'];
  54. $session = $before;
  55. $rotate = SessionGuard::expireIdleSession($session, $now);
  56. self::assertFalse($rotate);
  57. self::assertSame(
  58. $before,
  59. $session,
  60. 'anonymous session: helper must not bump last_active or rotate',
  61. );
  62. }
  63. public function testExpireIdleSessionBumpsLastActiveOnFreshHit(): void
  64. {
  65. $now = 1_700_000_000;
  66. $session = [
  67. 'user_id' => 42,
  68. 'login_at' => $now - 600,
  69. 'last_active' => $now - 600,
  70. 'csrf_token' => 'deadbeef',
  71. ];
  72. $rotate = SessionGuard::expireIdleSession($session, $now);
  73. self::assertFalse($rotate, '10-min idle is well within the window');
  74. self::assertSame(42, $session['user_id']);
  75. self::assertSame($now, $session['last_active'], 'last_active slides forward');
  76. self::assertSame('deadbeef', $session['csrf_token'], 'csrf_token preserved');
  77. }
  78. public function testExpireIdleSessionBumpsLastActiveWhenMissing(): void
  79. {
  80. // A session that was created before the R01-N08 patch shipped will
  81. // have user_id but no last_active key. The helper must seed it
  82. // (not clobber the user with a phantom expiry).
  83. $now = 1_700_000_000;
  84. $session = ['user_id' => 7, 'login_at' => $now - 60];
  85. $rotate = SessionGuard::expireIdleSession($session, $now);
  86. self::assertFalse($rotate);
  87. self::assertSame($now, $session['last_active']);
  88. self::assertSame(7, $session['user_id']);
  89. }
  90. public function testExpireIdleSessionDropsAuthKeysWhenIdle(): void
  91. {
  92. $now = 1_700_000_000;
  93. $session = [
  94. 'user_id' => 42,
  95. 'login_at' => $now - 7200,
  96. 'last_active' => $now - SessionGuard::IDLE_TIMEOUT_SECONDS,
  97. 'csrf_token' => 'deadbeef',
  98. // Foreign keys must survive — the OIDC library can store
  99. // pre-login state under arbitrary keys.
  100. 'oidc_state' => 'abc123',
  101. ];
  102. $rotate = SessionGuard::expireIdleSession($session, $now);
  103. self::assertTrue($rotate, 'caller must rotate the session id');
  104. self::assertArrayNotHasKey('user_id', $session);
  105. self::assertArrayNotHasKey('login_at', $session);
  106. self::assertArrayNotHasKey('last_active', $session);
  107. self::assertArrayNotHasKey('csrf_token', $session);
  108. self::assertSame(
  109. 'abc123',
  110. $session['oidc_state'] ?? null,
  111. 'foreign session state must survive the timeout',
  112. );
  113. }
  114. public function testExpireIdleSessionExactlyAtBoundaryExpires(): void
  115. {
  116. // The boundary is `>=` so a session whose last_active is exactly
  117. // IDLE_TIMEOUT_SECONDS ago is treated as expired.
  118. $now = 1_700_000_000;
  119. $session = [
  120. 'user_id' => 42,
  121. 'last_active' => $now - SessionGuard::IDLE_TIMEOUT_SECONDS,
  122. 'csrf_token' => 'deadbeef',
  123. ];
  124. self::assertTrue(SessionGuard::expireIdleSession($session, $now));
  125. self::assertArrayNotHasKey('user_id', $session);
  126. }
  127. public function testExpireIdleSessionOneSecondBeforeBoundaryIsFresh(): void
  128. {
  129. $now = 1_700_000_000;
  130. $session = [
  131. 'user_id' => 42,
  132. 'last_active' => $now - (SessionGuard::IDLE_TIMEOUT_SECONDS - 1),
  133. 'csrf_token' => 'deadbeef',
  134. ];
  135. self::assertFalse(SessionGuard::expireIdleSession($session, $now));
  136. self::assertSame(42, $session['user_id']);
  137. self::assertSame($now, $session['last_active']);
  138. }
  139. public function testExpireIdleSessionIgnoresNonIntUserId(): void
  140. {
  141. // `currentUserId()` defends with `is_int($id) ? $id : null`, so a
  142. // bogus string user_id is effectively anonymous. Mirror that
  143. // contract here — no expiry, no last_active bump.
  144. $now = 1_700_000_000;
  145. $session = ['user_id' => 'not-an-int', 'last_active' => $now - 9999];
  146. $rotate = SessionGuard::expireIdleSession($session, $now);
  147. self::assertFalse($rotate);
  148. self::assertSame('not-an-int', $session['user_id']);
  149. self::assertSame($now - 9999, $session['last_active']);
  150. }
  151. }