| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184 |
- <?php
- /*
- * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
- * 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']);
- }
- }
|