1
0

SessionGuardTest.php 6.5 KB

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