diff --git a/README.md b/README.md index d50d54d..18f9cc6 100644 --- a/README.md +++ b/README.md @@ -65,3 +65,27 @@ A few contract details worth knowing: - **`window_start` is an unsigned 32-bit epoch bucket** (seconds since the epoch, rounded down to a window boundary), matching the column's `int unsigned` type above. + +Sessions +-------- +Routes with the `session` filter start a session through the +application's session handler (`Neuron\SessionHandlers\SessionHandler`, +which wraps PHP's own save handler, or `DbSessionHandler`, which stores +sessions in a `sessions` table). + +- **Session ids are validated.** A request may only bring an id in the + format PHP generates (22 to 256 characters out of `A-Za-z0-9,-`) that + has a stored session. Any other id is ignored and the request gets a + new session. The handlers switch on `session.use_strict_mode` when a + session starts. `SessionHandler` can only check for a stored session + when PHP's save handler is `files`. +- **The `PSID` query parameter is opt-in.** A session id in the query + string is only read after + `\Neuron\SessionHandlers\SessionHandler::setQueryParameterEnabled (true);` + and only when the request has no (valid) session cookie. +- **Sessions expire after `session.gc_maxlifetime` seconds without a + request** (`DbSessionHandler`). An older row is treated as absent and + removed when it is read; garbage collection uses the same lifetime. + Set `session.gc_maxlifetime` before the session starts. +- **`$request->getSession ()->regenerate ()`** gives the session a new + id and keeps its data. Call it when a user logs in. diff --git a/src/Neuron/Net/Session.php b/src/Neuron/Net/Session.php index 3d7d843..4938f66 100644 --- a/src/Neuron/Net/Session.php +++ b/src/Neuron/Net/Session.php @@ -40,6 +40,29 @@ public function disconnect () } } + /** + * Give the active session a new id and remove the stored session of + * the old one. The session data is kept. Call this whenever the + * privileges of the session change, for example when a user logs in. + * Does nothing when no session is active, or when the new session + * cookie could not be sent any more. + * @return bool True if the session has a new id. + */ + public function regenerate () + { + if (session_status () !== PHP_SESSION_ACTIVE) + { + return false; + } + + if (headers_sent () && ini_get ('session.use_cookies')) + { + return false; + } + + return session_regenerate_id (true); + } + /** * Destroy a session */ diff --git a/src/Neuron/SessionHandlers/DbSessionHandler.php b/src/Neuron/SessionHandlers/DbSessionHandler.php index de22b4e..597ede9 100644 --- a/src/Neuron/SessionHandlers/DbSessionHandler.php +++ b/src/Neuron/SessionHandlers/DbSessionHandler.php @@ -16,8 +16,40 @@ class DbSessionHandler { /** @var Database $db */ private $db; + + /** @var array Session data by id; null for an id without a (live) session. */ private $sessions = array (); + /** + * Has a session that was last written at $setTime outlived its lifetime? + * @param int|string $setTime Unix timestamp of the last write. + * @param int $now + * @param int $maxLifetime In seconds. + * @return bool + */ + public static function isExpired ($setTime, $now, $maxLifetime) + { + return (int) $setTime < (int) $now - (int) $maxLifetime; + } + + /** + * Seconds a session may go without being written before it expires: + * session.gc_maxlifetime. + * @return int + */ + protected function getMaxLifetime () + { + return (int) ini_get ('session.gc_maxlifetime'); + } + + /** + * @return int + */ + protected function now () + { + return time (); + } + /* Methods */ #[\ReturnTypeWillChange] public function open ( $save_path , $name ) @@ -39,41 +71,80 @@ public function close ( ) #[\ReturnTypeWillChange] public function destroy ( $session_id ) { - Query::delete ('sessions', array ('id' => $session_id))->execute (); + Query::delete ('sessions', array ('id' => array ($session_id, Query::PARAM_STR)))->execute (); + $this->sessions[$session_id] = null; + return true; } #[\ReturnTypeWillChange] public function gc ( $maxlifetime ) { - Query::delete ('sessions', array ('set_time' => array (time () - 60 * 10, Query::PARAM_NUMBER, '<')))->execute (); + Query::delete ('sessions', array ('set_time' => array ($this->now () - (int) $maxlifetime, Query::PARAM_NUMBER, '<')))->execute (); return true; } #[\ReturnTypeWillChange] public function read ( $session_id ) { - if (!isset ($this->sessions[$session_id])) + $data = $this->load ($session_id); + return $data === null ? '' : $data; + } + + /** + * The data of a stored session that has not expired, or null. A row + * that outlived the lifetime is removed. + * @param string $session_id + * @return string|null + */ + private function load ($session_id) + { + if (!self::isValidSessionId ($session_id)) { - $data = Query::select ('sessions', array ('data'), array ('id' => $session_id))->execute (); - if (count ($data) > 0) - { - $this->sessions[$session_id] = $data[0]['data']; - } - else + return null; + } + + if (!array_key_exists ($session_id, $this->sessions)) + { + $this->sessions[$session_id] = null; + + $rows = Query::select ( + 'sessions', + array ('data', 'set_time'), + array ('id' => array ($session_id, Query::PARAM_STR)) + )->execute (); + + if (count ($rows) > 0) { - $this->sessions[$session_id] = ''; + if (self::isExpired ($rows[0]['set_time'], $this->now (), $this->getMaxLifetime ())) + { + $this->destroy ($session_id); + } + else + { + $this->sessions[$session_id] = (string) $rows[0]['data']; + } } } + return $this->sessions[$session_id]; } + /** + * @param string $session_id + * @return bool + */ + protected function sessionExists ($session_id) + { + return $this->load ($session_id) !== null; + } + #[\ReturnTypeWillChange] public function write ( $session_id , $session_data ) { $this->sessions[$session_id] = $session_data; - $time = time (); + $time = $this->now (); $this->db->query (" REPLACE @@ -95,4 +166,4 @@ public function write ( $session_id , $session_data ) return true; } -} \ No newline at end of file +} diff --git a/src/Neuron/SessionHandlers/SessionHandler.php b/src/Neuron/SessionHandlers/SessionHandler.php index 7ded358..5b7e82f 100644 --- a/src/Neuron/SessionHandlers/SessionHandler.php +++ b/src/Neuron/SessionHandlers/SessionHandler.php @@ -3,7 +3,6 @@ namespace Neuron\SessionHandlers; use Neuron\Models\Logger; -use Neuron\Core\Tools; /** * Class SessionHandler @@ -11,35 +10,139 @@ */ class SessionHandler extends \SessionHandler + implements \SessionUpdateTimestampHandlerInterface { private $started = false; const SESSION_QUERY_PARAMETER = 'PSID'; + /** Where the id of a starting session comes from. */ + const SOURCE_PROVIDED = 'provided'; + const SOURCE_COOKIE = 'cookie'; + const SOURCE_QUERY = 'query'; + const SOURCE_NEW = 'new'; + + /** + * Session ids as PHP generates them: 22 to 256 characters out of + * a-z, A-Z, 0-9, "," and "-" (session.sid_length and + * session.sid_bits_per_character). + */ + const SESSION_ID_PATTERN = '/\A[A-Za-z0-9,-]{22,256}\z/'; + + /** @var bool */ + private static $queryParameterEnabled = false; + + /** @var string|null The save handler PHP used before this one was registered. */ + private $nativeSaveHandler; + + /** @var string|null */ + private $savePath; + + /** + * Accept a session id from the query parameter + * (SESSION_QUERY_PARAMETER) when the request has no session cookie. + * Off by default; turn it on before the session starts. + * @param bool $enabled + */ + public static function setQueryParameterEnabled ($enabled = true) + { + self::$queryParameterEnabled = (bool) $enabled; + } + + /** + * @return bool + */ + public static function isQueryParameterEnabled () + { + return self::$queryParameterEnabled; + } + + /** + * Does this look like a session id PHP could have generated? + * @param mixed $sessionId + * @return bool + */ + public static function isValidSessionId ($sessionId) + { + return is_string ($sessionId) && preg_match (self::SESSION_ID_PATTERN, $sessionId) === 1; + } + + /** + * Decide which session id a request starts with. An id that is not + * in the format PHP generates is ignored, wherever it comes from. + * @param string|null $providedId Id given by the application. + * @param array $cookies + * @param array $query + * @return array [ 'id' => string|null, 'source' => one of the SOURCE_ constants ] + */ + public static function resolveSessionId ($providedId, array $cookies, array $query) + { + if (self::isValidSessionId ($providedId)) { + return array ('id' => $providedId, 'source' => self::SOURCE_PROVIDED); + } + + if (isset ($cookies['PHPSESSID']) && self::isValidSessionId ($cookies['PHPSESSID'])) { + return array ('id' => $cookies['PHPSESSID'], 'source' => self::SOURCE_COOKIE); + } + + if ( + self::$queryParameterEnabled + && isset ($query[self::SESSION_QUERY_PARAMETER]) + && self::isValidSessionId ($query[self::SESSION_QUERY_PARAMETER]) + ) { + return array ('id' => $query[self::SESSION_QUERY_PARAMETER], 'source' => self::SOURCE_QUERY); + } + + return array ('id' => null, 'source' => self::SOURCE_NEW); + } + + /** + * The log line for a starting session. It names where the id came + * from and never the id itself. + * @param string $source One of the SOURCE_ constants. + * @return string + */ + public static function getStartLogMessage ($source) + { + switch ($source) { + case self::SOURCE_PROVIDED: + return 'Starting session with an id provided by the application'; + + case self::SOURCE_COOKIE: + return 'Starting session with the id from the session cookie'; + + case self::SOURCE_QUERY: + return 'Starting session with the id from the query parameter'; + + default: + return 'Starting a new session'; + } + } + public final function start ($sessionId = null) { if (!$this->started) { $this->register (); - if (isset($sessionId)) { - session_id ($sessionId); - - ini_set ("session.use_cookies", 0); - ini_set ("session.use_only_cookies", 0); - ini_set ("session.use_trans_sid", 0); # Forgot this one! - - Logger::getInstance ()->log ("Starting session with provided id " . $sessionId, false, 'cyan'); - } elseif ($defaultSession = Tools::getInput ($_COOKIE, 'PHPSESSID', 'varchar')) { - Logger::getInstance()->log("Starting session with default cookie " . $defaultSession, false, 'cyan'); - session_id($defaultSession); - } elseif ($queryParamSession = Tools::getInput ($_GET, self::SESSION_QUERY_PARAMETER, 'varchar')) { - Logger::getInstance()->log("Starting session with query parameter " . $queryParamSession, false, 'cyan'); - session_id($queryParamSession); - } elseif (session_status() == PHP_SESSION_ACTIVE) { - session_regenerate_id (); - Logger::getInstance ()->log ("Starting brand new session with id " . session_id (), false, 'cyan'); - } + // An id that has no stored session is not adopted (see validateId). + ini_set ("session.use_strict_mode", 1); + + $resolved = self::resolveSessionId ($sessionId, $_COOKIE, $_GET); + + if ($resolved['source'] === self::SOURCE_PROVIDED) { + session_id ($resolved['id']); + + ini_set ("session.use_cookies", 0); + ini_set ("session.use_only_cookies", 0); + ini_set ("session.use_trans_sid", 0); # Forgot this one! + } elseif ($resolved['id'] !== null) { + session_id ($resolved['id']); + } elseif (session_status() == PHP_SESSION_ACTIVE) { + session_regenerate_id (); + } + + Logger::getInstance ()->log (self::getStartLogMessage ($resolved['source']), false, 'cyan'); session_start (); @@ -49,7 +152,7 @@ public final function start ($sessionId = null) public final function stop () { - Logger::getInstance ()->log ("Closing session with id " . session_id (), false, 'cyan'); + Logger::getInstance ()->log ("Closing session", false, 'cyan'); session_write_close (); $this->started = false; @@ -85,6 +188,7 @@ public function gc ( $maxlifetime ) #[\ReturnTypeWillChange] public function open ( $save_path , $name ) { + $this->savePath = $save_path; return parent::open ( $save_path, $name ); } @@ -101,8 +205,82 @@ public function write ( $session_id , $session_data ) return parent::write ($session_id, $session_data); } + /** + * Called by PHP in strict mode (session.use_strict_mode) for an id + * the request brought along: only an id in the generated format + * that has a stored session is kept, any other is replaced by a + * newly generated one. + * @param string $session_id + * @return bool + */ + #[\ReturnTypeWillChange] + public function validateId ($session_id) + { + return self::isValidSessionId ($session_id) && $this->sessionExists ($session_id); + } + + /** + * Called by PHP instead of write() when the session data did not + * change. Writing the session again refreshes its timestamp, and + * stores a session that has no data yet. + * @param string $session_id + * @param string $session_data + * @return bool + */ + #[\ReturnTypeWillChange] + public function updateTimestamp ($session_id, $session_data) + { + return $this->write ($session_id, $session_data); + } + + /** + * Is there a stored session for this (well formed) id? + * This class wraps PHP's own save handler and can only tell for the + * "files" handler; with any other it answers yes. Subclasses with + * their own storage override this. + * @param string $session_id + * @return bool + */ + protected function sessionExists ($session_id) + { + if ($this->nativeSaveHandler !== 'files' || $this->savePath === null) { + return true; + } + + return is_file (self::getSessionFilePath ($this->savePath, $session_id)); + } + + /** + * Where PHP's "files" save handler keeps a session, for a + * session.save_path of the form "[depth;[mode;]]path". + * @param string $savePath + * @param string $sessionId + * @return string + */ + public static function getSessionFilePath ($savePath, $sessionId) + { + $parts = explode (';', (string) $savePath); + $directory = rtrim (array_pop ($parts), '/\\'); + $depth = count ($parts) > 0 ? (int) $parts[0] : 0; + + if ($directory === '') { + $directory = rtrim (sys_get_temp_dir (), '/\\'); + } + + for ($i = 0; $i < $depth; $i ++) { + $directory .= '/' . substr ($sessionId, $i, 1); + } + + return $directory . '/sess_' . $sessionId; + } + public function register () { + $handler = ini_get ('session.save_handler'); + if ($handler !== 'user') { + $this->nativeSaveHandler = $handler; + } + session_set_save_handler($this, true); } } diff --git a/tests/DbSessionHandlerTest.php b/tests/DbSessionHandlerTest.php new file mode 100644 index 0000000..b339e23 --- /dev/null +++ b/tests/DbSessionHandlerTest.php @@ -0,0 +1,151 @@ +db = new SessionsDatabase (); + Database::setInstance ($this->db); + + // now = 100000, lifetime = 1000: rows set before 99000 are expired. + $this->handler = new FixedClockDbSessionHandler (); + $this->handler->open ('', 'PHPSESSID'); + } + + protected function tearDown (): void + { + Database::setInstance (null); + } + + public function testExpiryDecision () + { + $this->assertFalse (DbSessionHandler::isExpired (99000, 100000, 1000)); + $this->assertFalse (DbSessionHandler::isExpired ('99500', 100000, 1000)); + $this->assertTrue (DbSessionHandler::isExpired (98999, 100000, 1000)); + $this->assertTrue (DbSessionHandler::isExpired ('1', 100000, 1000)); + } + + public function testReadReturnsTheDataOfALiveSession () + { + $this->db->rows = array (array ('data' => 'user|i:7;', 'set_time' => '99500')); + + $this->assertSame ('user|i:7;', $this->handler->read (self::ID)); + $this->assertSame ( + array ("SELECT data, set_time FROM `sessions` WHERE id = '" . self::ID . "'"), + $this->db->queries + ); + } + + public function testReadTreatsAnExpiredSessionAsAbsentAndRemovesIt () + { + $this->db->rows = array (array ('data' => 'user|i:7;', 'set_time' => '98999')); + + $this->assertSame ('', $this->handler->read (self::ID)); + $this->assertSame ("DELETE FROM `sessions`WHERE id = '" . self::ID . "'", $this->db->queries[1]); + } + + public function testReadReturnsAnEmptyStringWithoutARow () + { + $this->assertSame ('', $this->handler->read (self::ID)); + } + + public function testReadDoesNotQueryForAnInvalidId () + { + $this->db->rows = array (array ('data' => 'user|i:7;', 'set_time' => '99500')); + + $this->assertSame ('', $this->handler->read ("x' OR '1'='1")); + $this->assertSame (array (), $this->db->queries); + } + + public function testValidateIdAcceptsOnlyAStoredLiveSession () + { + $this->db->rows = array (array ('data' => '', 'set_time' => '99500')); + $this->assertTrue ($this->handler->validateId (self::ID)); + } + + public function testValidateIdRejectsAnIdWithoutARow () + { + $this->assertFalse ($this->handler->validateId (self::ID)); + } + + public function testValidateIdRejectsAnExpiredSession () + { + $this->db->rows = array (array ('data' => 'user|i:7;', 'set_time' => '98999')); + $this->assertFalse ($this->handler->validateId (self::ID)); + } + + public function testValidateIdRejectsAnInvalidFormatWithoutQuerying () + { + $this->db->rows = array (array ('data' => '', 'set_time' => '99500')); + + $this->assertFalse ($this->handler->validateId ('../../etc/passwd-aaaaaaaaaaaaaa')); + $this->assertSame (array (), $this->db->queries); + } + + public function testValidateAndReadShareOneQuery () + { + $this->db->rows = array (array ('data' => 'user|i:7;', 'set_time' => '99500')); + + $this->assertTrue ($this->handler->validateId (self::ID)); + $this->assertSame ('user|i:7;', $this->handler->read (self::ID)); + $this->assertCount (1, $this->db->queries); + } + + public function testANewlyWrittenSessionIsValid () + { + $this->assertFalse ($this->handler->validateId (self::ID)); + $this->assertSame ('', $this->handler->read (self::ID)); + + $this->assertTrue ($this->handler->write (self::ID, '')); + + $this->assertTrue ($this->handler->validateId (self::ID)); + $this->assertSame ( + "REPLACE sessions SET id = '" . self::ID . "', set_time = '100000', data = ''", + $this->db->queries[1] + ); + } + + public function testUpdateTimestampStoresTheRowWithTheCurrentTime () + { + $this->assertTrue ($this->handler->updateTimestamp (self::ID, 'user|i:7;')); + $this->assertSame ( + array ("REPLACE sessions SET id = '" . self::ID . "', set_time = '100000', data = 'user|i:7;'"), + $this->db->queries + ); + } + + public function testADestroyedSessionIsNoLongerValid () + { + $this->handler->write (self::ID, 'user|i:7;'); + $this->assertTrue ($this->handler->destroy (self::ID)); + + $this->assertFalse ($this->handler->validateId (self::ID)); + $this->assertSame ('', $this->handler->read (self::ID)); + } + + public function testGcHonoursTheGivenLifetime () + { + $this->handler->gc (3600); + $this->assertSame (array ('DELETE FROM `sessions`WHERE set_time < 96400'), $this->db->queries); + } +} diff --git a/tests/SessionHandlerTest.php b/tests/SessionHandlerTest.php new file mode 100644 index 0000000..7e80f86 --- /dev/null +++ b/tests/SessionHandlerTest.php @@ -0,0 +1,193 @@ +assertTrue (SessionHandler::isValidSessionId (self::ID)); + $this->assertTrue (SessionHandler::isValidSessionId (self::OTHER_ID)); + $this->assertTrue (SessionHandler::isValidSessionId (str_repeat ('a', 22))); + $this->assertTrue (SessionHandler::isValidSessionId (str_repeat ('a', 256))); + } + + /** + * @dataProvider invalidIds + */ + public function testOtherIdsAreInvalid ($id) + { + $this->assertFalse (SessionHandler::isValidSessionId ($id)); + } + + public static function invalidIds () + { + return array ( + 'empty' => array (''), + 'null' => array (null), + 'array' => array (array (self::ID)), + 'number' => array (1234567890123456789012), + 'too short' => array (str_repeat ('a', 21)), + 'too long' => array (str_repeat ('a', 257)), + 'slash' => array ('../../../../etc/passwd-aaaaaaaaaa'), + 'dot' => array ('abcdef0123456789.abcdef012345678'), + 'quote' => array ("abcdef0123456789'abcdef012345678"), + 'space' => array ('abcdef0123456789 abcdef012345678'), + 'trailing newline' => array (self::ID . "\n"), + 'nul byte' => array (self::ID . "\0"), + 'non-ascii' => array ('abcdef0123456789abcdef012345678é'), + ); + } + + public function testAnIdGivenByTheApplicationComesFirst () + { + $resolved = SessionHandler::resolveSessionId ( + self::ID, + array ('PHPSESSID' => self::OTHER_ID), + array ('PSID' => self::OTHER_ID) + ); + + $this->assertSame (array ('id' => self::ID, 'source' => SessionHandler::SOURCE_PROVIDED), $resolved); + } + + public function testAnInvalidIdGivenByTheApplicationIsIgnored () + { + $resolved = SessionHandler::resolveSessionId ('not a session id', array (), array ()); + $this->assertSame (array ('id' => null, 'source' => SessionHandler::SOURCE_NEW), $resolved); + } + + public function testTheCookieIdIsUsed () + { + $resolved = SessionHandler::resolveSessionId (null, array ('PHPSESSID' => self::ID), array ()); + $this->assertSame (array ('id' => self::ID, 'source' => SessionHandler::SOURCE_COOKIE), $resolved); + } + + public function testAnInvalidCookieIdIsIgnored () + { + $resolved = SessionHandler::resolveSessionId (null, array ('PHPSESSID' => 'abc/../def'), array ()); + $this->assertSame (array ('id' => null, 'source' => SessionHandler::SOURCE_NEW), $resolved); + + $resolved = SessionHandler::resolveSessionId (null, array ('PHPSESSID' => array (self::ID)), array ()); + $this->assertSame (array ('id' => null, 'source' => SessionHandler::SOURCE_NEW), $resolved); + } + + public function testTheQueryParameterIsIgnoredByDefault () + { + $this->assertFalse (SessionHandler::isQueryParameterEnabled ()); + + $resolved = SessionHandler::resolveSessionId (null, array (), array ('PSID' => self::ID)); + $this->assertSame (array ('id' => null, 'source' => SessionHandler::SOURCE_NEW), $resolved); + } + + public function testTheQueryParameterIsUsedOnceEnabled () + { + SessionHandler::setQueryParameterEnabled (true); + $this->assertTrue (SessionHandler::isQueryParameterEnabled ()); + + $resolved = SessionHandler::resolveSessionId (null, array (), array ('PSID' => self::ID)); + $this->assertSame (array ('id' => self::ID, 'source' => SessionHandler::SOURCE_QUERY), $resolved); + } + + public function testAnInvalidQueryParameterIdIsIgnored () + { + SessionHandler::setQueryParameterEnabled (true); + + $resolved = SessionHandler::resolveSessionId (null, array (), array ('PSID' => "abc'; --")); + $this->assertSame (array ('id' => null, 'source' => SessionHandler::SOURCE_NEW), $resolved); + } + + public function testTheCookieWinsOverTheQueryParameter () + { + SessionHandler::setQueryParameterEnabled (true); + + $resolved = SessionHandler::resolveSessionId ( + null, + array ('PHPSESSID' => self::ID), + array ('PSID' => self::OTHER_ID) + ); + $this->assertSame (array ('id' => self::ID, 'source' => SessionHandler::SOURCE_COOKIE), $resolved); + } + + public function testAnInvalidCookieFallsBackToTheEnabledQueryParameter () + { + SessionHandler::setQueryParameterEnabled (true); + + $resolved = SessionHandler::resolveSessionId ( + null, + array ('PHPSESSID' => 'nope'), + array ('PSID' => self::OTHER_ID) + ); + $this->assertSame (array ('id' => self::OTHER_ID, 'source' => SessionHandler::SOURCE_QUERY), $resolved); + } + + public function testTheStartLogMessageNamesTheSourceAndNeverTheId () + { + $sources = array ( + SessionHandler::SOURCE_PROVIDED, + SessionHandler::SOURCE_COOKIE, + SessionHandler::SOURCE_QUERY, + SessionHandler::SOURCE_NEW, + ); + + $messages = array (); + foreach ($sources as $source) { + $message = SessionHandler::getStartLogMessage ($source); + $this->assertStringContainsString ('session', $message); + $messages[] = $message; + } + + // One distinct message per source. + $this->assertCount (4, array_unique ($messages)); + + // The message is built from the source alone. + $resolved = SessionHandler::resolveSessionId (null, array ('PHPSESSID' => self::ID), array ()); + $this->assertStringNotContainsString (self::ID, SessionHandler::getStartLogMessage ($resolved['source'])); + } + + public function testValidateIdRejectsAnInvalidFormat () + { + $handler = new SessionHandler (); + $this->assertFalse ($handler->validateId ('../not-a-session-id')); + } + + public function testTheSessionFileOfTheFilesHandlerIsLocated () + { + $this->assertSame ('/var/sessions/sess_' . self::ID, SessionHandler::getSessionFilePath ('/var/sessions', self::ID)); + $this->assertSame ('/var/sessions/sess_' . self::ID, SessionHandler::getSessionFilePath ('/var/sessions/', self::ID)); + $this->assertSame ('/var/sessions/sess_' . self::ID, SessionHandler::getSessionFilePath ('0;0600;/var/sessions', self::ID)); + $this->assertSame ('/var/sessions/a/b/sess_' . self::ID, SessionHandler::getSessionFilePath ('2;/var/sessions', self::ID)); + $this->assertSame ('/var/sessions/a/sess_' . self::ID, SessionHandler::getSessionFilePath ('1;0600;/var/sessions', self::ID)); + $this->assertSame ( + rtrim (sys_get_temp_dir (), '/\\') . '/sess_' . self::ID, + SessionHandler::getSessionFilePath ('', self::ID) + ); + } + + public function testUpdateTimestampWritesTheSession () + { + $handler = new RecordingSessionHandler (); + $this->assertTrue ($handler->updateTimestamp (self::ID, 'data')); + $this->assertSame (array (array (self::ID, 'data')), $handler->writes); + } + + public function testItImplementsTheInterfaceStrictModeNeeds () + { + $this->assertInstanceOf (\SessionUpdateTimestampHandlerInterface::class, new SessionHandler ()); + } +} diff --git a/tests/SessionHandlers/FixedClockDbSessionHandler.php b/tests/SessionHandlers/FixedClockDbSessionHandler.php new file mode 100644 index 0000000..db19a6c --- /dev/null +++ b/tests/SessionHandlers/FixedClockDbSessionHandler.php @@ -0,0 +1,28 @@ +now; + } + + protected function getMaxLifetime () + { + return $this->maxLifetime; + } +} diff --git a/tests/SessionHandlers/RecordingSessionHandler.php b/tests/SessionHandlers/RecordingSessionHandler.php new file mode 100644 index 0000000..431695d --- /dev/null +++ b/tests/SessionHandlers/RecordingSessionHandler.php @@ -0,0 +1,21 @@ +writes[] = array ($session_id, $session_data); + return true; + } +} diff --git a/tests/SessionHandlers/SessionsDatabase.php b/tests/SessionHandlers/SessionsDatabase.php new file mode 100644 index 0000000..015b40f --- /dev/null +++ b/tests/SessionHandlers/SessionsDatabase.php @@ -0,0 +1,59 @@ +queries[] = $sSQL; + + if (strpos ($sSQL, 'SELECT') === 0) { + return $this->rows; + } + + return 0; + } + + public function multiQuery ($sSQL) + { + return 0; + } + + public function escape ($txt) + { + return strtr ((string) $txt, array ( + "\\" => "\\\\", + "\x00" => "\\0", + "\n" => "\\n", + "\r" => "\\r", + "'" => "\\'", + '"' => '\\"', + "\x1a" => "\\Z", + )); + } + + public function fromUnixtime ($timestamp) + { + return date ('Y-m-d H:i:s', $timestamp); + } + + public function toUnixtime ($date) + { + return strtotime ($date); + } +} diff --git a/tests/SessionTest.php b/tests/SessionTest.php new file mode 100644 index 0000000..d8cbdd8 --- /dev/null +++ b/tests/SessionTest.php @@ -0,0 +1,20 @@ +assertSame (PHP_SESSION_NONE, session_status ()); + + $session = new Session (new SessionHandler ()); + $this->assertFalse ($session->regenerate ()); + + $this->assertSame (PHP_SESSION_NONE, session_status ()); + } +}