|
@@ -0,0 +1,176 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+declare(strict_types=1);
|
|
|
|
|
+
|
|
|
|
|
+namespace App\Tests\Auth;
|
|
|
|
|
+
|
|
|
|
|
+use App\Auth\SessionGuard;
|
|
|
|
|
+use App\Tests\TestCase;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Pure-static unit tests for the R01-N08 idle-timeout helpers on
|
|
|
|
|
+ * SessionGuard. These cover the boundary semantics of `isIdleExpired`
|
|
|
|
|
+ * and the in-place mutation contract of `expireIdleSession` on a
|
|
|
|
|
+ * vanilla PHP array, so the tests don't depend on the global
|
|
|
|
|
+ * `$_SESSION`, cookies, or `session_start()` state.
|
|
|
|
|
+ *
|
|
|
|
|
+ * The CSRF rotation behaviour of `login()` is asserted indirectly
|
|
|
|
|
+ * through `expireIdleSession`'s "drop csrf_token on idle" assertion —
|
|
|
|
|
+ * the same `unset()` semantics are reused by the login() body to
|
|
|
|
|
+ * force a fresh token on the next `csrfToken()` call.
|
|
|
|
|
+ */
|
|
|
|
|
+final class SessionGuardTest extends TestCase
|
|
|
|
|
+{
|
|
|
|
|
+ public function testIdleTimeoutConstantIsThirtyMinutes(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ // Locks the policy so an accidental change shows up here as well as
|
|
|
|
|
+ // in the boundary tests below.
|
|
|
|
|
+ self::assertSame(1800, SessionGuard::IDLE_TIMEOUT_SECONDS);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testIsIdleExpiredFreshAndStaleBoundary(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $base = 1_700_000_000;
|
|
|
|
|
+
|
|
|
|
|
+ self::assertFalse(
|
|
|
|
|
+ SessionGuard::isIdleExpired($base, $base),
|
|
|
|
|
+ 'zero gap is fresh',
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertFalse(
|
|
|
|
|
+ SessionGuard::isIdleExpired($base, $base + 1),
|
|
|
|
|
+ '1s gap is fresh',
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertFalse(
|
|
|
|
|
+ SessionGuard::isIdleExpired($base, $base + 1799),
|
|
|
|
|
+ '29:59 is still fresh',
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertTrue(
|
|
|
|
|
+ SessionGuard::isIdleExpired($base, $base + 1800),
|
|
|
|
|
+ 'exactly the threshold is expired (>= boundary)',
|
|
|
|
|
+ );
|
|
|
|
|
+ self::assertTrue(
|
|
|
|
|
+ SessionGuard::isIdleExpired($base, $base + 7200),
|
|
|
|
|
+ '2h gap is expired',
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testExpireIdleSessionLeavesAnonymousSessionAlone(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $now = 1_700_000_000;
|
|
|
|
|
+ $before = ['oidc_state' => 'abc123', 'oidc_nonce' => 'xyz789'];
|
|
|
|
|
+
|
|
|
|
|
+ $session = $before;
|
|
|
|
|
+ $rotate = SessionGuard::expireIdleSession($session, $now);
|
|
|
|
|
+
|
|
|
|
|
+ self::assertFalse($rotate);
|
|
|
|
|
+ self::assertSame(
|
|
|
|
|
+ $before,
|
|
|
|
|
+ $session,
|
|
|
|
|
+ 'anonymous session: helper must not bump last_active or rotate',
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testExpireIdleSessionBumpsLastActiveOnFreshHit(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $now = 1_700_000_000;
|
|
|
|
|
+ $session = [
|
|
|
|
|
+ 'user_id' => 42,
|
|
|
|
|
+ 'login_at' => $now - 600,
|
|
|
|
|
+ 'last_active' => $now - 600,
|
|
|
|
|
+ 'csrf_token' => 'deadbeef',
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ $rotate = SessionGuard::expireIdleSession($session, $now);
|
|
|
|
|
+
|
|
|
|
|
+ self::assertFalse($rotate, '10-min idle is well within the window');
|
|
|
|
|
+ self::assertSame(42, $session['user_id']);
|
|
|
|
|
+ self::assertSame($now, $session['last_active'], 'last_active slides forward');
|
|
|
|
|
+ self::assertSame('deadbeef', $session['csrf_token'], 'csrf_token preserved');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testExpireIdleSessionBumpsLastActiveWhenMissing(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ // A session that was created before the R01-N08 patch shipped will
|
|
|
|
|
+ // have user_id but no last_active key. The helper must seed it
|
|
|
|
|
+ // (not clobber the user with a phantom expiry).
|
|
|
|
|
+ $now = 1_700_000_000;
|
|
|
|
|
+ $session = ['user_id' => 7, 'login_at' => $now - 60];
|
|
|
|
|
+
|
|
|
|
|
+ $rotate = SessionGuard::expireIdleSession($session, $now);
|
|
|
|
|
+
|
|
|
|
|
+ self::assertFalse($rotate);
|
|
|
|
|
+ self::assertSame($now, $session['last_active']);
|
|
|
|
|
+ self::assertSame(7, $session['user_id']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testExpireIdleSessionDropsAuthKeysWhenIdle(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $now = 1_700_000_000;
|
|
|
|
|
+ $session = [
|
|
|
|
|
+ 'user_id' => 42,
|
|
|
|
|
+ 'login_at' => $now - 7200,
|
|
|
|
|
+ 'last_active' => $now - SessionGuard::IDLE_TIMEOUT_SECONDS,
|
|
|
|
|
+ 'csrf_token' => 'deadbeef',
|
|
|
|
|
+ // Foreign keys must survive — the OIDC library can store
|
|
|
|
|
+ // pre-login state under arbitrary keys.
|
|
|
|
|
+ 'oidc_state' => 'abc123',
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ $rotate = SessionGuard::expireIdleSession($session, $now);
|
|
|
|
|
+
|
|
|
|
|
+ self::assertTrue($rotate, 'caller must rotate the session id');
|
|
|
|
|
+ self::assertArrayNotHasKey('user_id', $session);
|
|
|
|
|
+ self::assertArrayNotHasKey('login_at', $session);
|
|
|
|
|
+ self::assertArrayNotHasKey('last_active', $session);
|
|
|
|
|
+ self::assertArrayNotHasKey('csrf_token', $session);
|
|
|
|
|
+ self::assertSame(
|
|
|
|
|
+ 'abc123',
|
|
|
|
|
+ $session['oidc_state'] ?? null,
|
|
|
|
|
+ 'foreign session state must survive the timeout',
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testExpireIdleSessionExactlyAtBoundaryExpires(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ // The boundary is `>=` so a session whose last_active is exactly
|
|
|
|
|
+ // IDLE_TIMEOUT_SECONDS ago is treated as expired.
|
|
|
|
|
+ $now = 1_700_000_000;
|
|
|
|
|
+ $session = [
|
|
|
|
|
+ 'user_id' => 42,
|
|
|
|
|
+ 'last_active' => $now - SessionGuard::IDLE_TIMEOUT_SECONDS,
|
|
|
|
|
+ 'csrf_token' => 'deadbeef',
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ self::assertTrue(SessionGuard::expireIdleSession($session, $now));
|
|
|
|
|
+ self::assertArrayNotHasKey('user_id', $session);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testExpireIdleSessionOneSecondBeforeBoundaryIsFresh(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $now = 1_700_000_000;
|
|
|
|
|
+ $session = [
|
|
|
|
|
+ 'user_id' => 42,
|
|
|
|
|
+ 'last_active' => $now - (SessionGuard::IDLE_TIMEOUT_SECONDS - 1),
|
|
|
|
|
+ 'csrf_token' => 'deadbeef',
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ self::assertFalse(SessionGuard::expireIdleSession($session, $now));
|
|
|
|
|
+ self::assertSame(42, $session['user_id']);
|
|
|
|
|
+ self::assertSame($now, $session['last_active']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testExpireIdleSessionIgnoresNonIntUserId(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ // `currentUserId()` defends with `is_int($id) ? $id : null`, so a
|
|
|
|
|
+ // bogus string user_id is effectively anonymous. Mirror that
|
|
|
|
|
+ // contract here — no expiry, no last_active bump.
|
|
|
|
|
+ $now = 1_700_000_000;
|
|
|
|
|
+ $session = ['user_id' => 'not-an-int', 'last_active' => $now - 9999];
|
|
|
|
|
+
|
|
|
|
|
+ $rotate = SessionGuard::expireIdleSession($session, $now);
|
|
|
|
|
+
|
|
|
|
|
+ self::assertFalse($rotate);
|
|
|
|
|
+ self::assertSame('not-an-int', $session['user_id']);
|
|
|
|
|
+ self::assertSame($now - 9999, $session['last_active']);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|