= 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']); } }