* SPDX-License-Identifier: Apache-2.0 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * See the LICENSE file in the project root for the full license text. */ 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']); } }