diff --git a/.github/workflows/build-sqlite.yml b/.github/workflows/build-sqlite.yml index 96588dcf..ccc727dc 100644 --- a/.github/workflows/build-sqlite.yml +++ b/.github/workflows/build-sqlite.yml @@ -65,7 +65,6 @@ jobs: - name: Run SQLite tests with PHPUnit and generate coverage. if: matrix.php == '8.1' - continue-on-error: true run: vendor/bin/phpunit --group sqlite --coverage-clover=coverage.xml --colors=always --verbose - name: Run SQLite tests with PHPUnit. diff --git a/composer.json b/composer.json index 6ad3df65..a80c71fe 100644 --- a/composer.json +++ b/composer.json @@ -39,6 +39,7 @@ "require-dev": { "dms/phpunit-arraysubset-asserts": "^0.5", "maglnet/composer-require-checker": "^4.7", + "php-mock/php-mock-phpunit": "^2.10", "phpunit/phpunit": "^9.5", "yiisoft/cache-file": "^3.1" }, diff --git a/src/web/Application.php b/src/web/Application.php index b05ac8b1..23f15c2d 100644 --- a/src/web/Application.php +++ b/src/web/Application.php @@ -1,15 +1,13 @@ - * @since 2.0 */ class Application extends \yii\base\Application { @@ -195,7 +190,7 @@ public function coreComponents() return array_merge(parent::coreComponents(), [ 'request' => ['class' => 'yii\web\Request'], 'response' => ['class' => 'yii\web\Response'], - 'session' => ['class' => 'yii\web\Session'], + 'session' => ['class' => Session::class], 'user' => ['class' => 'yii\web\User'], 'errorHandler' => ['class' => 'yii\web\ErrorHandler'], ]); diff --git a/src/web/CacheSession.php b/src/web/CacheSession.php deleted file mode 100644 index 5763a854..00000000 --- a/src/web/CacheSession.php +++ /dev/null @@ -1,146 +0,0 @@ - [ - * 'class' => 'yii\web\CacheSession', - * // 'cache' => 'mycache', - * ] - * ``` - * - * @property-read bool $useCustomStorage Whether to use custom storage. - * - * @author Qiang Xue - * @since 2.0 - */ -class CacheSession extends Session -{ - /** - * @var CacheInterface|array|string the cache object or the application component ID of the cache object. - * The session data will be stored using this cache object. - * - * After the CacheSession object is created, if you want to change this property, - * you should only assign it with a cache object. - * - * Starting from version 2.0.2, this can also be a configuration array for creating the object. - */ - public $cache = 'cache'; - - - /** - * Initializes the application component. - */ - public function init() - { - parent::init(); - $this->cache = Instance::ensure($this->cache, 'yii\caching\CacheInterface'); - } - - /** - * Returns a value indicating whether to use custom session storage. - * This method overrides the parent implementation and always returns true. - * @return bool whether to use custom storage. - */ - public function getUseCustomStorage() - { - return true; - } - - /** - * Session open handler. - * @internal Do not call this method directly. - * @param string $savePath session save path - * @param string $sessionName session name - * @return bool whether session is opened successfully - */ - public function openSession($savePath, $sessionName) - { - if ($this->getUseStrictMode()) { - $id = $this->getId(); - if (!$this->cache->exists($this->calculateKey($id))) { - //This session id does not exist, mark it for forced regeneration - $this->_forceRegenerateId = $id; - } - } - - return parent::openSession($savePath, $sessionName); - } - - /** - * Session read handler. - * @internal Do not call this method directly. - * @param string $id session ID - * @return string the session data - */ - public function readSession($id) - { - $data = $this->cache->get($this->calculateKey($id)); - - return $data === false ? '' : $data; - } - - /** - * Session write handler. - * @internal Do not call this method directly. - * @param string $id session ID - * @param string $data session data - * @return bool whether session write is successful - */ - public function writeSession($id, $data) - { - if ($this->getUseStrictMode() && $id === $this->_forceRegenerateId) { - //Ignore write when forceRegenerate is active for this id - return true; - } - - return $this->cache->set($this->calculateKey($id), $data, $this->getTimeout()); - } - - /** - * Session destroy handler. - * @internal Do not call this method directly. - * @param string $id session ID - * @return bool whether session is destroyed successfully - */ - public function destroySession($id) - { - $cacheId = $this->calculateKey($id); - if ($this->cache->exists($cacheId) === false) { - return true; - } - - return $this->cache->delete($cacheId); - } - - /** - * Generates a unique key used for storing session data in cache. - * @param string $id session variable name - * @return mixed a safe cache key associated with the session variable name - */ - protected function calculateKey($id) - { - return [__CLASS__, $id]; - } -} diff --git a/src/web/DbSession.php b/src/web/DbSession.php deleted file mode 100644 index 7c7743b5..00000000 --- a/src/web/DbSession.php +++ /dev/null @@ -1,290 +0,0 @@ - [ - * 'class' => 'yii\web\DbSession', - * // 'db' => 'mydb', - * // 'sessionTable' => 'my_session', - * ] - * ``` - * - * DbSession extends [[MultiFieldSession]], thus it allows saving extra fields into the [[sessionTable]]. - * Refer to [[MultiFieldSession]] for more details. - * - * @author Qiang Xue - * @since 2.0 - */ -class DbSession extends MultiFieldSession -{ - /** - * @var Connection|array|string the DB connection object or the application component ID of the DB connection. - * After the DbSession object is created, if you want to change this property, you should only assign it - * with a DB connection object. - * Starting from version 2.0.2, this can also be a configuration array for creating the object. - */ - public $db = 'db'; - /** - * @var string the name of the DB table that stores the session data. - * The table should be pre-created as follows: - * - * ```sql - * CREATE TABLE session - * ( - * id CHAR(40) NOT NULL PRIMARY KEY, - * expire INTEGER, - * data BLOB - * ) - * ``` - * - * where 'BLOB' refers to the BLOB-type of your preferred DBMS. Below are the BLOB type - * that can be used for some popular DBMS: - * - * - MySQL: LONGBLOB - * - PostgreSQL: BYTEA - * - MSSQL: BLOB - * - * When using DbSession in a production server, we recommend you create a DB index for the 'expire' - * column in the session table to improve the performance. - * - * Note that according to the php.ini setting of `session.hash_function`, you may need to adjust - * the length of the `id` column. For example, if `session.hash_function=sha256`, you should use - * length 64 instead of 40. - */ - public $sessionTable = '{{%session}}'; - - /** - * @var array Session fields to be written into session table columns - * @since 2.0.17 - */ - protected $fields = []; - - - /** - * Initializes the DbSession component. - * This method will initialize the [[db]] property to make sure it refers to a valid DB connection. - * @throws InvalidConfigException if [[db]] is invalid. - */ - public function init() - { - parent::init(); - $this->db = Instance::ensure($this->db, Connection::className()); - } - - /** - * Session open handler. - * @internal Do not call this method directly. - * @param string $savePath session save path - * @param string $sessionName session name - * @return bool whether session is opened successfully - */ - public function openSession($savePath, $sessionName) - { - if ($this->getUseStrictMode()) { - $id = $this->getId(); - if (!$this->getReadQuery($id)->exists($this->db)) { - //This session id does not exist, mark it for forced regeneration - $this->_forceRegenerateId = $id; - } - } - - return parent::openSession($savePath, $sessionName); - } - - /** - * {@inheritdoc} - */ - public function regenerateID($deleteOldSession = false) - { - $oldID = session_id(); - - // if no session is started, there is nothing to regenerate - if (empty($oldID)) { - return; - } - - parent::regenerateID(false); - $newID = session_id(); - // if session id regeneration failed, no need to create/update it. - if (empty($newID)) { - Yii::warning('Failed to generate new session ID', __METHOD__); - return; - } - - $row = $this->db->useMaster(function () use ($oldID) { - return (new Query())->from($this->sessionTable) - ->where(['id' => $oldID]) - ->createCommand($this->db) - ->queryOne(); - }); - - if ($row !== false && $this->getIsActive()) { - if ($deleteOldSession) { - $this->db->createCommand() - ->update($this->sessionTable, ['id' => $newID], ['id' => $oldID]) - ->execute(); - } else { - $row['id'] = $newID; - $this->db->createCommand() - ->insert($this->sessionTable, $row) - ->execute(); - } - } - } - - /** - * Ends the current session and store session data. - * @since 2.0.17 - */ - public function close() - { - if ($this->getIsActive()) { - // prepare writeCallback fields before session closes - $this->fields = $this->composeFields(); - YII_DEBUG ? session_write_close() : @session_write_close(); - } - } - - /** - * Session read handler. - * @internal Do not call this method directly. - * @param string $id session ID - * @return string the session data - */ - public function readSession($id) - { - $query = $this->getReadQuery($id); - - if ($this->readCallback !== null) { - $fields = $query->one($this->db); - return $fields === false ? '' : $this->extractData($fields); - } - - $data = $query->select(['data'])->scalar($this->db); - return $data === false ? '' : $data; - } - - /** - * Session write handler. - * @internal Do not call this method directly. - * @param string $id session ID - * @param string $data session data - * @return bool whether session write is successful - */ - public function writeSession($id, $data) - { - if ($this->getUseStrictMode() && $id === $this->_forceRegenerateId) { - //Ignore write when forceRegenerate is active for this id - return true; - } - - // exception must be caught in session write handler - // https://www.php.net/manual/en/function.session-set-save-handler.php#refsect1-function.session-set-save-handler-notes - try { - // ensure backwards compatability (fixed #9438) - if ($this->writeCallback && !$this->fields) { - $this->fields = $this->composeFields(); - } - // ensure data consistency - if (!isset($this->fields['data'])) { - $this->fields['data'] = $data; - } else { - $_SESSION = $this->fields['data']; - } - // ensure 'id' and 'expire' are never affected by [[writeCallback]] - $this->fields = array_merge($this->fields, [ - 'id' => $id, - 'expire' => time() + $this->getTimeout(), - ]); - $this->fields = $this->typecastFields($this->fields); - $this->db->createCommand()->upsert($this->sessionTable, $this->fields)->execute(); - $this->fields = []; - } catch (\Exception $e) { - Yii::$app->errorHandler->handleException($e); - return false; - } - return true; - } - - /** - * Session destroy handler. - * @internal Do not call this method directly. - * @param string $id session ID - * @return bool whether session is destroyed successfully - */ - public function destroySession($id) - { - $this->db->createCommand() - ->delete($this->sessionTable, ['id' => $id]) - ->execute(); - - return true; - } - - /** - * Session GC (garbage collection) handler. - * @internal Do not call this method directly. - * @param int $maxLifetime the number of seconds after which data will be seen as 'garbage' and cleaned up. - * @return bool whether session is GCed successfully - */ - public function gcSession($maxLifetime) - { - $this->db->createCommand() - ->delete($this->sessionTable, '[[expire]]<:expire', [':expire' => time()]) - ->execute(); - - return true; - } - - /** - * Generates a query to get the session from db - * @param string $id The id of the session - * @return Query - */ - protected function getReadQuery($id) - { - return (new Query()) - ->from($this->sessionTable) - ->where('[[expire]]>:expire AND [[id]]=:id', [':expire' => time(), ':id' => $id]); - } - - /** - * Method typecasts $fields before passing them to PDO. - * Default implementation casts field `data` to `\PDO::PARAM_LOB`. - * You can override this method in case you need special type casting. - * - * @param array $fields Fields, that will be passed to PDO. Key - name, Value - value - * @return array - * @since 2.0.13 - */ - protected function typecastFields($fields) - { - if (isset($fields['data']) && !is_array($fields['data']) && !is_object($fields['data'])) { - $fields['data'] = new PdoValue($fields['data'], \PDO::PARAM_LOB); - } - - return $fields; - } -} diff --git a/src/web/MultiFieldSession.php b/src/web/MultiFieldSession.php deleted file mode 100644 index 012efcf7..00000000 --- a/src/web/MultiFieldSession.php +++ /dev/null @@ -1,131 +0,0 @@ - - * @since 2.0.6 - */ -abstract class MultiFieldSession extends Session -{ - /** - * @var callable a callback that will be called during session data reading. - * The signature of the callback should be as follows: - * - * ``` - * function ($fields) - * ``` - * - * where `$fields` is the storage field set for read session and `$session` is this session instance. - * If callback returns an array, it will be merged into the session data. - * - * For example: - * - * ```php - * function ($fields) { - * return [ - * 'expireDate' => Yii::$app->formatter->asDate($fields['expire']), - * ]; - * } - * ``` - */ - public $readCallback; - /** - * @var callable a callback that will be called during session data writing. - * The signature of the callback should be as follows: - * - * ``` - * function ($session) - * ``` - * - * where `$session` is this session instance, this variable can be used to retrieve session data. - * Callback should return the actual fields set, which should be saved into the session storage. - * - * For example: - * - * ```php - * function ($session) { - * return [ - * 'user_id' => Yii::$app->user->id, - * 'ip' => $_SERVER['REMOTE_ADDR'], - * 'is_trusted' => $session->get('is_trusted', false), - * ]; - * } - * ``` - */ - public $writeCallback; - - - /** - * Returns a value indicating whether to use custom session storage. - * This method overrides the parent implementation and always returns true. - * @return bool whether to use custom storage. - */ - public function getUseCustomStorage() - { - return true; - } - - /** - * Composes storage field set for session writing. - * @param string|null $id Optional session id - * @param string|null $data Optional session data - * @return array storage fields - */ - protected function composeFields($id = null, $data = null) - { - $fields = $this->writeCallback ? call_user_func($this->writeCallback, $this) : []; - if ($id !== null) { - $fields['id'] = $id; - } - if ($data !== null) { - $fields['data'] = $data; - } - return $fields; - } - - /** - * Extracts session data from storage field set. - * @param array $fields storage fields. - * @return string session data. - */ - protected function extractData($fields) - { - if ($this->readCallback !== null) { - if (!isset($fields['data'])) { - $fields['data'] = ''; - } - $extraData = call_user_func($this->readCallback, $fields); - if (!empty($extraData)) { - session_decode($fields['data']); - $_SESSION = array_merge((array) $_SESSION, (array) $extraData); - return session_encode(); - } - - return $fields['data']; - } - - return isset($fields['data']) ? $fields['data'] : ''; - } -} diff --git a/src/web/session/DbSession.php b/src/web/session/DbSession.php new file mode 100644 index 00000000..0a1a4195 --- /dev/null +++ b/src/web/session/DbSession.php @@ -0,0 +1,135 @@ + [ + * 'class' => 'yii\web\session\DbSession', + * // 'db' => 'mydb', + * // 'sessionTable' => 'my_session', + * ] + * ``` + */ +class DbSession extends Session +{ + /** + * @var Connection|array|string the DB connection object or the application component ID of the DB connection. + * After the DbSession object is created, if you want to change this property, you should only assign it with a DB + * connection object. + */ + public Connection|array|string|null $db = 'db'; + /** + * @var string the name of the DB table that stores the session data. + * The table should be pre-created as follows: + * + * ```sql + * CREATE TABLE session + * ( + * id CHAR(40) NOT NULL PRIMARY KEY, + * expire INTEGER, + * data BLOB + * ) + * ``` + * + * where 'BLOB' refers to the BLOB-type of your preferred DBMS. Below are the BLOB type that can be used for some + * popular DBMS: + * + * - MySQL: LONGBLOB + * - PostgreSQL: BYTEA + * - MSSQL: BLOB + * + * When using DbSession in a production server, we recommend you create a DB index for the 'expire' column in the + * session table to improve the performance. + * + * Note that according to the php.ini setting of `session.hash_function`, you may need to adjust the length of the + * `id` column. For example, if `session.hash_function=sha256`, you should use length 64 instead of 40. + */ + public string $sessionTable = '{{%session}}'; + + /** + * Initializes the DbSession component. + * This method will initialize the [[db]] property to make sure it refers to a valid DB connection. + * + * @throws InvalidConfigException if [[db]] is invalid. + */ + public function init() + { + parent::init(); + + $this->db = Instance::ensure($this->db, Connection::class); + $this->_handler ??= Instance::ensure( + [ + 'class' => DbSessionHandler::class, + '__construct()' => [$this->db, $this->sessionTable], + ] + ); + } + + /** + * {@inheritdoc} + */ + public function close(): void + { + if ($this->getIsActive()) { + YII_DEBUG ? session_write_close() : @session_write_close(); + } + } + + /** + * {@inheritdoc} + */ + public function regenerateID(bool $deleteOldSession = false): void + { + $oldID = session_id(); + + // if no session is started, there is nothing to regenerate + if (empty($oldID)) { + return; + } + + parent::regenerateID(false); + + $newID = session_id(); + + // if session id regeneration failed, no need to create/update it. + if (empty($newID)) { + Yii::warning('Failed to generate new session ID', __METHOD__); + + return; + } + + $row = $this->db->useMaster(function () use ($oldID) { + return (new Query())->from($this->sessionTable) + ->where(['id' => $oldID]) + ->createCommand($this->db) + ->queryOne(); + }); + + if ($row !== false && $this->getIsActive()) { + if ($deleteOldSession) { + $this->db->createCommand()->update($this->sessionTable, ['id' => $newID], ['id' => $oldID])->execute(); + } else { + $row['id'] = $newID; + $this->db->createCommand()->insert($this->sessionTable, $row)->execute(); + } + } + } +} diff --git a/src/web/session/Flash.php b/src/web/session/Flash.php new file mode 100644 index 00000000..03946cbc --- /dev/null +++ b/src/web/session/Flash.php @@ -0,0 +1,178 @@ +fetch(); + + if (!isset($flashes[$key], $flashes[self::COUNTERS][$key])) { + return null; + } + + if ($flashes[self::COUNTERS][$key] < 0) { + // Mark for deletion in the next request. + $flashes[self::COUNTERS][$key] = 1; + + $this->save($flashes); + } + + return $flashes[$key]; + } + + public function getAll(): array + { + $flashes = $this->fetch(); + + $list = []; + + foreach ($flashes as $key => $value) { + if ($key === self::COUNTERS) { + continue; + } + + $list[$key] = $value; + + if ($flashes[self::COUNTERS][$key] < 0) { + // Mark for deletion in the next request. + $flashes[self::COUNTERS][$key] = 1; + } + } + + $this->save($flashes); + + return $list; + } + + public function set(string $key, $value = true, bool $removeAfterAccess = true): void + { + $flashes = $this->fetch(); + + /** @psalm-suppress MixedArrayAssignment */ + $flashes[self::COUNTERS][$key] = $removeAfterAccess ? -1 : 0; + $flashes[$key] = $value; + + $this->save($flashes); + } + + public function add(string $key, $value = true, bool $removeAfterAccess = true): void + { + $flashes = $this->fetch(); + + /** @psalm-suppress MixedArrayAssignment */ + $flashes[self::COUNTERS][$key] = $removeAfterAccess ? -1 : 0; + + if (empty($flashes[$key])) { + $flashes[$key] = [$value]; + } elseif (is_array($flashes[$key])) { + $flashes[$key][] = $value; + } else { + $flashes[$key] = [$flashes[$key], $value]; + } + + $this->save($flashes); + } + + public function remove(string $key): void + { + $flashes = $this->fetch(); + unset($flashes[self::COUNTERS][$key], $flashes[$key]); + $this->save($flashes); + } + + public function removeAll(): void + { + $this->save([self::COUNTERS => []]); + } + + public function has(string $key): bool + { + $flashes = $this->fetch(); + + return isset($flashes[$key], $flashes[self::COUNTERS][$key]); + } + + /** + * Updates the counters for flash messages and removes outdated flash messages. + * This method should be called once after session initialization. + */ + private function updateCounters(): void + { + $flashes = $this->session->get(self::FLASH_PARAM, []); + + if (!is_array($flashes)) { + $flashes = [self::COUNTERS => []]; + } + + $counters = $flashes[self::COUNTERS] ?? []; + + if (!is_array($counters)) { + $counters = []; + } + + /** @var array $counters */ + foreach ($counters as $key => $count) { + if ($count > 0) { + unset($counters[$key], $flashes[$key]); + } elseif ($count === 0) { + $counters[$key]++; + } + } + + $flashes[self::COUNTERS] = $counters; + + $this->save($flashes); + } + + /** + * Obtains flash messages. Updates counters once per session. + * + * @return array Flash messages array. + * + * @psalm-return array{__counters:array}&array + */ + private function fetch(): array + { + // Ensure session is active (and has id). + $this->session->open(); + + if ($this->session->getIsActive()) { + $this->updateCounters(); + } + + /** @psalm-var array{__counters:array}&array */ + return $this->session->get(self::FLASH_PARAM, []); + } + + /** + * Save flash messages into session. + * + * @param array $flashes Flash messages to save. + */ + private function save(array $flashes): void + { + $this->session->set(self::FLASH_PARAM, $flashes); + } +} diff --git a/src/web/session/PSRCacheSession.php b/src/web/session/PSRCacheSession.php new file mode 100644 index 00000000..2442d91c --- /dev/null +++ b/src/web/session/PSRCacheSession.php @@ -0,0 +1,62 @@ + [ + * 'class' => 'yii\web\session\PSRCacheSession', + * // 'cache' => new \Yiisoft\Cache\FileCache(Yii::getAlias('@runtime/cache')), + * ] + * ``` + */ +class PSRCacheSession extends Session +{ + /** + * @var CacheInterface|array|string|null the cache object or the application component ID of the cache object. + * The session data will be stored using this cache object. + * + * After the CacheSession object is created, if you want to change this property, + * you should only assign it with a cache object. + */ + public CacheInterface|array|string|null $cache = CacheInterface::class; + + /** + * Initializes the PSRCacheSession component. + * This method will initialize the [[cache]] property to make sure it refers to a valid cache. + * + * @throws InvalidConfigException if [[cache]] is invalid. + */ + public function init() + { + parent::init(); + + $this->cache = Instance::ensure($this->cache, CacheInterface::class); + $this->_handler ??= Instance::ensure( + [ + 'class' => PSRCacheSessionHandler::class, + '__construct()' => [$this->cache], + ] + ); + } +} diff --git a/src/web/Session.php b/src/web/session/Session.php similarity index 51% rename from src/web/Session.php rename to src/web/session/Session.php index bcb7eaff..a2942101 100644 --- a/src/web/Session.php +++ b/src/web/session/Session.php @@ -1,59 +1,20 @@ session`. - * - * To start the session, call [[open()]]; To complete and send out session data, call [[close()]]; - * To destroy the session, call [[destroy()]]. - * - * Session can be used like an array to set and get session data. For example, - * - * ```php - * $session = new Session; - * $session->open(); - * $value1 = $session['name1']; // get session variable 'name1' - * $value2 = $session['name2']; // get session variable 'name2' - * foreach ($session as $name => $value) // traverse all session variables - * $session['name3'] = $value3; // set session variable 'name3' - * ``` - * - * Session can be extended to support customized session storage. - * To do so, override [[useCustomStorage]] so that it returns true, and - * override these methods with the actual logic about using custom storage: - * [[openSession()]], [[closeSession()]], [[readSession()]], [[writeSession()]], - * [[destroySession()]] and [[gcSession()]]. - * - * Session also supports a special type of session data, called *flash messages*. - * A flash message is available only in the current request and the next request. - * After that, it will be deleted automatically. Flash messages are particularly - * useful for displaying confirmation messages. To use flash messages, simply - * call methods such as [[setFlash()]], [[getFlash()]]. - * - * For more details and usage information on Session, see the [guide article on sessions](guide:runtime-sessions-cookies). - * * @property-read array $allFlashes Flash messages (key => message or key => [message1, message2]). * @property-read string $cacheLimiter Current cache limiter. * @property-read array $cookieParams The session cookie parameters. * @property-read int $count The number of session variables. - * @property-write string $flash The key identifying the flash message. Note that flash messages and normal - * session variables share the same name space. If you have a normal session variable using the same name, its - * value will be overwritten by this method. - * @property float $gCProbability The probability (percentage) that the GC (garbage collection) process is - * started on every session initialization. + * @property float $gCProbability The probability (percentage) that the GC (garbage collection) process is started on + * every session initialization. * @property bool $hasSessionId Whether the current request has sent the session ID. * @property string $id The current session ID. * @property-read bool $isActive Whether the session has started. @@ -62,50 +23,43 @@ * @property int $timeout The number of seconds after which data will be seen as 'garbage' and cleaned up. The * default value is 1440 seconds (or the value of "session.gc_maxlifetime" set in php.ini). * @property bool|null $useCookies The value indicating whether cookies should be used to store session IDs. - * @property-read bool $useCustomStorage Whether to use custom storage. * @property bool $useStrictMode Whether strict mode is enabled or not. - * @property bool $useTransparentSessionID Whether transparent sid support is enabled or not, defaults to - * false. - * - * @author Qiang Xue - * @since 2.0 + * @property bool $useTransparentSessionID Whether transparent sid support is enabled or not, defaults to false. */ class Session extends Component implements \IteratorAggregate, \ArrayAccess, \Countable { /** - * @var string|null Holds the original session module (before a custom handler is registered) so that it can be - * restored when a Session component without custom handler is used after one that has. - */ - protected static $_originalSessionModule = null; - /** - * Polyfill for ini directive session.use-strict-mode for PHP < 5.5.2. - */ - private static $_useStrictModePolyfill = false; - /** - * @var string the name of the session variable that stores the flash message data. + * @var SessionHandlerInterface|array|string|null $_handler The session handler to be used for storing and + * retrieving session data. This can either be an instance of a class implementing SessionHandlerInterface, an array + * configuration that can be used to create such an instance, or a string representing the handler class name. */ - public $flashParam = '__flash'; + public SessionHandlerInterface|array|string|null $_handler = null; + + protected Flash $flash; /** - * @var \SessionHandlerInterface|array an object implementing the SessionHandlerInterface or a configuration array. If set, will be used to provide persistency instead of build-in methods. + * @var string|null Holds the session id in case useStrictMode is enabled and the session id needs to be + * regenerated. */ - public $handler; - + public string|null $_forceRegenerateId = null; /** - * @var string|null Holds the session id in case useStrictMode is enabled and the session id needs to be regenerated + * @var string|null Holds the original session module (before a custom handler is registered) so that it can be + * restored when a Session component without custom handler is used after one that has. */ - protected $_forceRegenerateId = null; + protected string|null $_originalSessionModule = null; /** - * @var array parameter-value pairs to override default session cookie parameters that are used for session_set_cookie_params() function - * Array may have the following possible keys: 'lifetime', 'path', 'domain', 'secure', 'httponly' + * @var array parameter-value pairs to override default session cookie parameters that are used for + * session_set_cookie_params() function Array may have the following possible keys: 'lifetime', 'path', 'domain', + * 'secure', 'httponly'. + * * @see https://www.php.net/manual/en/function.session-set-cookie-params.php */ - private $_cookieParams = ['httponly' => true]; + private array $_cookieParams = ['httponly' => true]; /** * @var array|null is used for saving session between recreations due to session parameters update. */ - private $_frozenSessionData; - + private array|null $_frozenSessionData = null; + private bool|null $_hasSessionId = null; /** * Initializes the application component. @@ -114,110 +68,53 @@ class Session extends Component implements \IteratorAggregate, \ArrayAccess, \Co public function init() { parent::init(); + + $this->flash = new Flash($this); + register_shutdown_function([$this, 'close']); + if ($this->getIsActive()) { Yii::warning('Session is already started', __METHOD__); - $this->updateFlashCounters(); } } - /** - * Returns a value indicating whether to use custom session storage. - * This method should be overridden to return true by child classes that implement custom session storage. - * To implement custom session storage, override these methods: [[openSession()]], [[closeSession()]], - * [[readSession()]], [[writeSession()]], [[destroySession()]] and [[gcSession()]]. - * @return bool whether to use custom storage. - */ - public function getUseCustomStorage() - { - return false; - } - /** * Starts the session. */ - public function open() + public function open(): void { if ($this->getIsActive()) { return; } $this->registerSessionHandler(); - $this->setCookieParamsInternal(); YII_DEBUG ? session_start() : @session_start(); - if ($this->getUseStrictMode() && $this->_forceRegenerateId) { + if ($this->getUseStrictMode() && $this->isRegenerateId()) { $this->regenerateID(); + $this->_forceRegenerateId = null; } if ($this->getIsActive()) { Yii::info('Session started', __METHOD__); - $this->updateFlashCounters(); } else { $error = error_get_last(); $message = isset($error['message']) ? $error['message'] : 'Failed to start session.'; - Yii::error($message, __METHOD__); - } - } - - /** - * Registers session handler. - * @throws \yii\base\InvalidConfigException - */ - protected function registerSessionHandler() - { - $sessionModuleName = session_module_name(); - if (static::$_originalSessionModule === null) { - static::$_originalSessionModule = $sessionModuleName; - } - if ($this->handler !== null) { - if (!is_object($this->handler)) { - $this->handler = Yii::createObject($this->handler); - } - if (!$this->handler instanceof \SessionHandlerInterface) { - throw new InvalidConfigException('"' . get_class($this) . '::handler" must implement the SessionHandlerInterface.'); - } - YII_DEBUG ? session_set_save_handler($this->handler, false) : @session_set_save_handler($this->handler, false); - } elseif ($this->getUseCustomStorage()) { - if (YII_DEBUG) { - session_set_save_handler( - [$this, 'openSession'], - [$this, 'closeSession'], - [$this, 'readSession'], - [$this, 'writeSession'], - [$this, 'destroySession'], - [$this, 'gcSession'] - ); - } else { - @session_set_save_handler( - [$this, 'openSession'], - [$this, 'closeSession'], - [$this, 'readSession'], - [$this, 'writeSession'], - [$this, 'destroySession'], - [$this, 'gcSession'] - ); - } - } elseif ( - $sessionModuleName !== static::$_originalSessionModule - && static::$_originalSessionModule !== null - && static::$_originalSessionModule !== 'user' - ) { - session_module_name(static::$_originalSessionModule); + Yii::error($message, __METHOD__); } } /** * Ends the current session and store session data. */ - public function close() + public function close(): void { if ($this->getIsActive()) { - YII_DEBUG ? session_write_close() : @session_write_close(); + YII_DEBUG ? @session_write_close() : @session_write_close(); } $this->_forceRegenerateId = null; @@ -228,44 +125,50 @@ public function close() * * This method has no effect when session is not [[getIsActive()|active]]. * Make sure to call [[open()]] before calling it. + * * @see open() * @see isActive */ - public function destroy() + public function destroy(string $id = null): bool { if ($this->getIsActive()) { $sessionId = session_id(); + $this->close(); $this->setId($sessionId); $this->open(); + session_unset(); session_destroy(); + $this->setId($sessionId); } + + return true; } /** * @return bool whether the session has started */ - public function getIsActive() + public function getIsActive(): bool { return session_status() === PHP_SESSION_ACTIVE; } - private $_hasSessionId; - /** * Returns a value indicating whether the current request has sent the session ID. * The default implementation will check cookie and $_GET using the session name. - * If you send session ID via other ways, you may need to override this method - * or call [[setHasSessionId()]] to explicitly set whether the session ID is sent. + * If you send session ID via other ways, you may need to override this method or call [[setHasSessionId()]] to + * explicitly set whether the session ID is sent. + * * @return bool whether the current request has sent the session ID. */ - public function getHasSessionId() + public function getHasSessionId(): bool { if ($this->_hasSessionId === null) { $name = $this->getName(); $request = Yii::$app->getRequest(); + if (!empty($_COOKIE[$name]) && ini_get('session.use_cookies')) { $this->_hasSessionId = true; } elseif (!ini_get('session.use_only_cookies') && ini_get('session.use_trans_sid')) { @@ -280,11 +183,11 @@ public function getHasSessionId() /** * Sets the value indicating whether the current request has sent the session ID. - * This method is provided so that you can override the default way of determining - * whether the session ID is sent. + * This method is provided so that you can override the default way of determining whether the session ID is sent. + * * @param bool $value whether the current request has sent the session ID. */ - public function setHasSessionId($value) + public function setHasSessionId(bool $value): void { $this->_hasSessionId = $value; } @@ -292,9 +195,10 @@ public function setHasSessionId($value) /** * Gets the session ID. * This is a wrapper for [PHP session_id()](https://www.php.net/manual/en/function.session-id.php). - * @return string the current session ID + * + * @return string the current session ID. */ - public function getId() + public function getId(): string { return session_id(); } @@ -302,9 +206,10 @@ public function getId() /** * Sets the session ID. * This is a wrapper for [PHP session_id()](https://www.php.net/manual/en/function.session-id.php). - * @param string $value the session ID for the current session + * + * @param string $value the session ID for the current session. */ - public function setId($value) + public function setId(string $value): void { session_id($value); } @@ -318,28 +223,26 @@ public function setId($value) * Make sure to call [[open()]] before calling it. * * @param bool $deleteOldSession Whether to delete the old associated session file or not. + * * @see open() * @see isActive */ - public function regenerateID($deleteOldSession = false) + public function regenerateID(bool $deleteOldSession = false): void { if ($this->getIsActive()) { - // add @ to inhibit possible warning due to race condition - // https://github.com/yiisoft/yii2/pull/1812 - if (YII_DEBUG && !headers_sent()) { - session_regenerate_id($deleteOldSession); - } else { - @session_regenerate_id($deleteOldSession); - } + // add @ to inhibit possible warning due to race condition https://github.com/yiisoft/yii2/pull/1812 + YII_DEBUG && !headers_sent() + ? session_regenerate_id($deleteOldSession) : @session_regenerate_id($deleteOldSession); } } /** * Gets the name of the current session. * This is a wrapper for [PHP session_name()](https://www.php.net/manual/en/function.session-name.php). - * @return string the current session name + * + * @return string the current session name. */ - public function getName() + public function getName(): string { return session_name(); } @@ -347,22 +250,27 @@ public function getName() /** * Sets the name for the current session. * This is a wrapper for [PHP session_name()](https://www.php.net/manual/en/function.session-name.php). + * * @param string $value the session name for the current session, must be an alphanumeric string. + * * It defaults to "PHPSESSID". */ - public function setName($value) + public function setName(string $value): void { $this->freeze(); + session_name($value); + $this->unfreeze(); } /** * Gets the current session save path. * This is a wrapper for [PHP session_save_path()](https://www.php.net/manual/en/function.session-save-path.php). + * * @return string the current session save path, defaults to '/tmp'. */ - public function getSavePath() + public function getSavePath(): string { return session_save_path(); } @@ -370,12 +278,15 @@ public function getSavePath() /** * Sets the current session save path. * This is a wrapper for [PHP session_save_path()](https://www.php.net/manual/en/function.session-save-path.php). + * * @param string $value the current session save path. This can be either a directory name or a [path alias](guide:concept-aliases). - * @throws InvalidArgumentException if the path is not a valid directory + * + * @throws InvalidArgumentException if the path is not a valid directory. */ - public function setSavePath($value) + public function setSavePath(string $value): void { $path = Yii::getAlias($value); + if (is_dir($path)) { session_save_path($path); } else { @@ -385,9 +296,10 @@ public function setSavePath($value) /** * @return array the session cookie parameters. + * * @see https://www.php.net/manual/en/function.session-get-cookie-params.php */ - public function getCookieParams() + public function getCookieParams(): array { return array_merge(session_get_cookie_params(), array_change_key_case($this->_cookieParams)); } @@ -396,54 +308,34 @@ public function getCookieParams() * Sets the session cookie parameters. * The cookie parameters passed to this method will be merged with the result * of `session_get_cookie_params()`. + * * @param array $value cookie parameters, valid keys include: `lifetime`, `path`, `domain`, `secure` and `httponly`. - * Starting with Yii 2.0.21 `sameSite` is also supported. It requires PHP version 7.3.0 or higher. * For security, an exception will be thrown if `sameSite` is set while using an unsupported version of PHP. * To use this feature across different PHP versions check the version first. E.g. * ```php * [ - * 'sameSite' => PHP_VERSION_ID >= 70300 ? yii\web\Cookie::SAME_SITE_LAX : null, + * 'sameSite' => yii\web\Cookie::SAME_SITE_LAX, * ] * ``` * See https://owasp.org/www-community/SameSite for more information about `sameSite`. * * @throws InvalidArgumentException if the parameters are incomplete. + * * @see https://www.php.net/manual/en/function.session-set-cookie-params.php */ - public function setCookieParams(array $value) + public function setCookieParams(array $value): void { $this->_cookieParams = $value; } - /** - * Sets the session cookie parameters. - * This method is called by [[open()]] when it is about to open the session. - * @throws InvalidArgumentException if the parameters are incomplete. - * @see https://www.php.net/manual/en/function.session-set-cookie-params.php - */ - private function setCookieParamsInternal() - { - $data = $this->getCookieParams(); - if (isset($data['lifetime'], $data['path'], $data['domain'], $data['secure'], $data['httponly'])) { - if (PHP_VERSION_ID >= 70300) { - session_set_cookie_params($data); - } else { - if (!empty($data['samesite'])) { - $data['path'] .= '; samesite=' . $data['samesite']; - } - session_set_cookie_params($data['lifetime'], $data['path'], $data['domain'], $data['secure'], $data['httponly']); - } - } else { - throw new InvalidArgumentException('Please make sure cookieParams contains these elements: lifetime, path, domain, secure and httponly.'); - } - } - /** * Returns the value indicating whether cookies should be used to store session IDs. + * * @return bool|null the value indicating whether cookies should be used to store session IDs. + * * @see setUseCookies() */ - public function getUseCookies() + public function getUseCookies(): bool|null { if (ini_get('session.use_cookies') === '0') { return false; @@ -461,55 +353,67 @@ public function getUseCookies() * * - true: cookies and only cookies will be used to store session IDs. * - false: cookies will not be used to store session IDs. - * - null: if possible, cookies will be used to store session IDs; if not, other mechanisms will be used (e.g. GET parameter) + * - null: if possible, cookies will be used to store session IDs; if not, other mechanisms will be used + * (e.g. GET parameter) * * @param bool|null $value the value indicating whether cookies should be used to store session IDs. */ - public function setUseCookies($value) + public function setUseCookies(bool|null $value): void { $this->freeze(); - if ($value === false) { - ini_set('session.use_cookies', '0'); - ini_set('session.use_only_cookies', '0'); - } elseif ($value === true) { - ini_set('session.use_cookies', '1'); - ini_set('session.use_only_cookies', '1'); - } else { - ini_set('session.use_cookies', '1'); - ini_set('session.use_only_cookies', '0'); - } + + match ($value) { + false => [ + ini_set('session.use_cookies', '0'), + ini_set('session.use_only_cookies', '0'), + ], + true => [ + ini_set('session.use_cookies', '1'), + ini_set('session.use_only_cookies', '1'), + ], + default => [ + ini_set('session.use_cookies', '1'), + ini_set('session.use_only_cookies', '0'), + ], + }; + $this->unfreeze(); } /** - * @return float the probability (percentage) that the GC (garbage collection) process is started on every session initialization. + * @return float the probability (percentage) that the GC (garbage collection) process is started on every session + * initialization. */ - public function getGCProbability() + public function getGCProbability(): float { return (float) (ini_get('session.gc_probability') / ini_get('session.gc_divisor') * 100); } /** - * @param float $value the probability (percentage) that the GC (garbage collection) process is started on every session initialization. + * @param float $value the probability (percentage) that the GC (garbage collection) process is started on every + * session initialization. + * * @throws InvalidArgumentException if the value is not between 0 and 100. */ - public function setGCProbability($value) + public function setGCProbability(float $value): void { - $this->freeze(); - if ($value >= 0 && $value <= 100) { - // percent * 21474837 / 2147483647 ≈ percent * 0.01 - ini_set('session.gc_probability', floor($value * 21474836.47)); - ini_set('session.gc_divisor', 2147483647); - } else { + if ($value < 0 || $value > 100) { throw new InvalidArgumentException('GCProbability must be a value between 0 and 100.'); } + + $this->freeze(); + + // percent * 21474837 / 2147483647 ≈ percent * 0.01 + ini_set('session.gc_probability', floor($value * 21474836.47)); + ini_set('session.gc_divisor', 2147483647); + $this->unfreeze(); } /** * @return bool whether transparent sid support is enabled or not, defaults to false. */ - public function getUseTransparentSessionID() + public function getUseTransparentSessionID(): bool { return ini_get('session.use_trans_sid') == 1; } @@ -517,10 +421,12 @@ public function getUseTransparentSessionID() /** * @param bool $value whether transparent sid support is enabled or not. */ - public function setUseTransparentSessionID($value) + public function setUseTransparentSessionID(bool $value): void { $this->freeze(); + ini_set('session.use_trans_sid', $value ? '1' : '0'); + $this->unfreeze(); } @@ -528,18 +434,20 @@ public function setUseTransparentSessionID($value) * @return int the number of seconds after which data will be seen as 'garbage' and cleaned up. * The default value is 1440 seconds (or the value of "session.gc_maxlifetime" set in php.ini). */ - public function getTimeout() + public function getTimeout(): int { return (int) ini_get('session.gc_maxlifetime'); } /** - * @param int $value the number of seconds after which data will be seen as 'garbage' and cleaned up + * @param int $value the number of seconds after which data will be seen as 'garbage' and cleaned up. */ - public function setTimeout($value) + public function setTimeout(int $value): void { $this->freeze(); + ini_set('session.gc_maxlifetime', $value); + $this->unfreeze(); } @@ -547,141 +455,62 @@ public function setTimeout($value) * @param bool $value Whether strict mode is enabled or not. * When `true` this setting prevents the session component to use an uninitialized session ID. * Note: Enabling `useStrictMode` on PHP < 5.5.2 is only supported with custom storage classes. - * Warning! Although enabling strict mode is mandatory for secure sessions, the default value of 'session.use-strict-mode' is `0`. + * Warning! Although enabling strict mode is mandatory for secure sessions, the default value of + * 'session.use-strict-mode' is `0`. + * * @see https://www.php.net/manual/en/session.configuration.php#ini.session.use-strict-mode - * @since 2.0.38 - */ - public function setUseStrictMode($value) - { - if (PHP_VERSION_ID < 50502) { - if ($this->getUseCustomStorage() || !$value) { - self::$_useStrictModePolyfill = $value; - } else { - throw new InvalidConfigException('Enabling `useStrictMode` on PHP < 5.5.2 is only supported with custom storage classes.'); - } - } else { - $this->freeze(); - ini_set('session.use_strict_mode', $value ? '1' : '0'); - $this->unfreeze(); - } - } - - /** - * @return bool Whether strict mode is enabled or not. - * @see setUseStrictMode() - * @since 2.0.38 */ - public function getUseStrictMode() + public function setUseStrictMode(bool $value): void { - if (PHP_VERSION_ID < 50502) { - return self::$_useStrictModePolyfill; - } - - return (bool)ini_get('session.use_strict_mode'); - } - - /** - * Session open handler. - * This method should be overridden if [[useCustomStorage]] returns true. - * @internal Do not call this method directly. - * @param string $savePath session save path - * @param string $sessionName session name - * @return bool whether session is opened successfully - */ - public function openSession($savePath, $sessionName) - { - return true; - } - - /** - * Session close handler. - * This method should be overridden if [[useCustomStorage]] returns true. - * @internal Do not call this method directly. - * @return bool whether session is closed successfully - */ - public function closeSession() - { - return true; - } - - /** - * Session read handler. - * This method should be overridden if [[useCustomStorage]] returns true. - * @internal Do not call this method directly. - * @param string $id session ID - * @return string the session data - */ - public function readSession($id) - { - return ''; - } + $this->freeze(); - /** - * Session write handler. - * This method should be overridden if [[useCustomStorage]] returns true. - * @internal Do not call this method directly. - * @param string $id session ID - * @param string $data session data - * @return bool whether session write is successful - */ - public function writeSession($id, $data) - { - return true; - } + ini_set('session.use_strict_mode', $value ? '1' : '0'); - /** - * Session destroy handler. - * This method should be overridden if [[useCustomStorage]] returns true. - * @internal Do not call this method directly. - * @param string $id session ID - * @return bool whether session is destroyed successfully - */ - public function destroySession($id) - { - return true; + $this->unfreeze(); } /** - * Session GC (garbage collection) handler. - * This method should be overridden if [[useCustomStorage]] returns true. - * @internal Do not call this method directly. - * @param int $maxLifetime the number of seconds after which data will be seen as 'garbage' and cleaned up. - * @return bool whether session is GCed successfully + * @return bool Whether strict mode is enabled or not. + * + * @see setUseStrictMode() */ - public function gcSession($maxLifetime) + public function getUseStrictMode(): bool { - return true; + return (bool) ini_get('session.use_strict_mode'); } /** * Returns an iterator for traversing the session variables. * This method is required by the interface [[\IteratorAggregate]]. + * * @return SessionIterator an iterator for traversing the session variables. */ - #[\ReturnTypeWillChange] - public function getIterator() + public function getIterator(): SessionIterator { $this->open(); + return new SessionIterator(); } /** * Returns the number of items in the session. - * @return int the number of session variables + * + * @return int the number of session variables. */ - public function getCount() + public function getCount(): int { $this->open(); + return count($_SESSION); } /** * Returns the number of items in the session. * This method is required by [[\Countable]] interface. + * * @return int number of items in the session. */ - #[\ReturnTypeWillChange] - public function count() + public function count(): int { return $this->getCount(); } @@ -689,36 +518,44 @@ public function count() /** * Returns the session variable value with the session variable name. * If the session variable does not exist, the `$defaultValue` will be returned. - * @param string $key the session variable name + * + * @param string $key the session variable name. * @param mixed $defaultValue the default value to be returned when the session variable does not exist. + * * @return mixed the session variable value, or $defaultValue if the session variable does not exist. */ - public function get($key, $defaultValue = null) + public function get(string $key, mixed $defaultValue = null): mixed { $this->open(); + return isset($_SESSION[$key]) ? $_SESSION[$key] : $defaultValue; } /** * Adds a session variable. * If the specified name already exists, the old value will be overwritten. - * @param string $key session variable name - * @param mixed $value session variable value + * + * @param string $key session variable name. + * @param mixed $value session variable value. */ - public function set($key, $value) + public function set(string $key, mixed $value): void { $this->open(); + $_SESSION[$key] = $value; } /** * Removes a session variable. - * @param string $key the name of the session variable to be removed + * + * @param string $key the name of the session variable to be removed. + * * @return mixed the removed value, null if no such session variable. */ - public function remove($key) + public function remove(string $key): mixed { $this->open(); + if (isset($_SESSION[$key])) { $value = $_SESSION[$key]; unset($_SESSION[$key]); @@ -732,76 +569,52 @@ public function remove($key) /** * Removes all session variables. */ - public function removeAll() + public function removeAll(): void { $this->open(); + foreach (array_keys($_SESSION) as $key) { unset($_SESSION[$key]); } } /** - * @param mixed $key session variable name - * @return bool whether there is the named session variable + * @param mixed $key session variable name. + * + * @return bool whether there is the named session variable. */ - public function has($key) + public function has(mixed $key): bool { $this->open(); - return isset($_SESSION[$key]); - } - /** - * Updates the counters for flash messages and removes outdated flash messages. - * This method should only be called once in [[init()]]. - */ - protected function updateFlashCounters() - { - $counters = $this->get($this->flashParam, []); - if (is_array($counters)) { - foreach ($counters as $key => $count) { - if ($count > 0) { - unset($counters[$key], $_SESSION[$key]); - } elseif ($count == 0) { - $counters[$key]++; - } - } - $_SESSION[$this->flashParam] = $counters; - } else { - // fix the unexpected problem that flashParam doesn't return an array - unset($_SESSION[$this->flashParam]); - } + return isset($_SESSION[$key]); } /** * Returns a flash message. - * @param string $key the key identifying the flash message + * + * @param string $key the key identifying the flash message. * @param mixed $defaultValue value to be returned if the flash message does not exist. * @param bool $delete whether to delete this flash message right after this method is called. * If false, the flash message will be automatically deleted in the next request. + * * @return mixed the flash message or an array of messages if addFlash was used + * * @see setFlash() * @see addFlash() * @see hasFlash() * @see getAllFlashes() * @see removeFlash() */ - public function getFlash($key, $defaultValue = null, $delete = false) + public function getFlash(string $key, mixed $defaultValue = null, bool $delete = false): mixed { - $counters = $this->get($this->flashParam, []); - if (isset($counters[$key])) { - $value = $this->get($key, $defaultValue); - if ($delete) { - $this->removeFlash($key); - } elseif ($counters[$key] < 0) { - // mark for deletion in the next request - $counters[$key] = 1; - $_SESSION[$this->flashParam] = $counters; - } + $value = $this->flash->get($key) ?? $defaultValue; - return $value; + if ($delete) { + $this->flash->remove($key); } - return $defaultValue; + return $value; } /** @@ -816,8 +629,8 @@ public function getFlash($key, $defaultValue = null, $delete = false) * } ?> * ``` * - * With the above code you can use the [bootstrap alert][] classes such as `success`, `info`, `danger` - * as the flash message key to influence the color of the div. + * With the above code you can use the [bootstrap alert][] classes such as `success`, `info`, `danger` as the flash + * message key to influence the color of the div. * * Note that if you use [[addFlash()]], `$message` will be an array, and you will have to adjust the above code. * @@ -825,145 +638,121 @@ public function getFlash($key, $defaultValue = null, $delete = false) * * @param bool $delete whether to delete the flash messages right after this method is called. * If false, the flash messages will be automatically deleted in the next request. + * * @return array flash messages (key => message or key => [message1, message2]). + * * @see setFlash() * @see addFlash() * @see getFlash() * @see hasFlash() * @see removeFlash() */ - public function getAllFlashes($delete = false) + public function getAllFlashes(bool $delete = false): array { - $counters = $this->get($this->flashParam, []); - $flashes = []; - foreach (array_keys($counters) as $key) { - if (array_key_exists($key, $_SESSION)) { - $flashes[$key] = $_SESSION[$key]; - if ($delete) { - unset($counters[$key], $_SESSION[$key]); - } elseif ($counters[$key] < 0) { - // mark for deletion in the next request - $counters[$key] = 1; - } - } else { - unset($counters[$key]); - } - } + $values = $this->flash->getAll(); - $_SESSION[$this->flashParam] = $counters; + if ($delete) { + $this->flash->removeAll(); + } - return $flashes; + return $values; } /** * Sets a flash message. - * A flash message will be automatically deleted after it is accessed in a request and the deletion will happen - * in the next request. + * A flash message will be automatically deleted after it is accessed in a request and the deletion will happen in + * the next request. * If there is already an existing flash message with the same key, it will be overwritten by the new one. - * @param string $key the key identifying the flash message. Note that flash messages - * and normal session variables share the same name space. If you have a normal - * session variable using the same name, its value will be overwritten by this method. - * @param mixed $value flash message - * @param bool $removeAfterAccess whether the flash message should be automatically removed only if - * it is accessed. If false, the flash message will be automatically removed after the next request, - * regardless if it is accessed or not. If true (default value), the flash message will remain until after - * it is accessed. + * + * @param string $key the key identifying the flash message. Note that flash messages and normal session variables + * share the same name space. If you have a normal session variable using the same name, its value will be + * overwritten by this method. + * @param mixed $value flash message. + * @param bool $removeAfterAccess whether the flash message should be automatically removed only if it is accessed. + * If false, the flash message will be automatically removed after the next request, regardless if it is accessed + * or not. If true (default value), the flash message will remain until after it is accessed. + * * @see getFlash() * @see addFlash() * @see removeFlash() */ - public function setFlash($key, $value = true, $removeAfterAccess = true) + public function setFlash(string $key, mixed $value = true, bool $removeAfterAccess = true): void { - $counters = $this->get($this->flashParam, []); - $counters[$key] = $removeAfterAccess ? -1 : 0; - $_SESSION[$key] = $value; - $_SESSION[$this->flashParam] = $counters; + $this->flash->set($key, $value, $removeAfterAccess); } /** * Adds a flash message. - * If there are existing flash messages with the same key, the new one will be appended to the existing message array. + * If there are existing flash messages with the same key, the new one will be appended to the existing message + * array. + * * @param string $key the key identifying the flash message. - * @param mixed $value flash message - * @param bool $removeAfterAccess whether the flash message should be automatically removed only if - * it is accessed. If false, the flash message will be automatically removed after the next request, - * regardless if it is accessed or not. If true (default value), the flash message will remain until after - * it is accessed. + * @param mixed $value flash message. + * @param bool $removeAfterAccess whether the flash message should be automatically removed only if it is accessed. + * If false, the flash message will be automatically removed after the next request, regardless if it is accessed or + * not. If true (default value), the flash message will remain until after it is accessed. + * * @see getFlash() * @see setFlash() * @see removeFlash() */ - public function addFlash($key, $value = true, $removeAfterAccess = true) + public function addFlash(string $key, mixed $value = true, bool $removeAfterAccess = true): void { - $counters = $this->get($this->flashParam, []); - $counters[$key] = $removeAfterAccess ? -1 : 0; - $_SESSION[$this->flashParam] = $counters; - if (empty($_SESSION[$key])) { - $_SESSION[$key] = [$value]; - } elseif (is_array($_SESSION[$key])) { - $_SESSION[$key][] = $value; - } else { - $_SESSION[$key] = [$_SESSION[$key], $value]; - } + $this->flash->add($key, $value, $removeAfterAccess); } /** * Removes a flash message. - * @param string $key the key identifying the flash message. Note that flash messages - * and normal session variables share the same name space. If you have a normal - * session variable using the same name, it will be removed by this method. - * @return mixed the removed flash message. Null if the flash message does not exist. + * + * @param string $key the key identifying the flash message. Note that flash messages and normal session variables + * share the same name space. If you have a normal session variable using the same name, it will be removed by this + * method. + * * @see getFlash() * @see setFlash() * @see addFlash() * @see removeAllFlashes() */ - public function removeFlash($key) + public function removeFlash(string $key): void { - $counters = $this->get($this->flashParam, []); - $value = isset($_SESSION[$key], $counters[$key]) ? $_SESSION[$key] : null; - unset($counters[$key], $_SESSION[$key]); - $_SESSION[$this->flashParam] = $counters; - - return $value; + $this->flash->remove($key); } /** * Removes all flash messages. * Note that flash messages and normal session variables share the same name space. - * If you have a normal session variable using the same name, it will be removed - * by this method. + * If you have a normal session variable using the same name, it will be removed by this method. + * * @see getFlash() * @see setFlash() * @see addFlash() * @see removeFlash() */ - public function removeAllFlashes() + public function removeAllFlashes(): void { - $counters = $this->get($this->flashParam, []); - foreach (array_keys($counters) as $key) { - unset($_SESSION[$key]); - } - unset($_SESSION[$this->flashParam]); + $this->flash->removeAll(); } /** * Returns a value indicating whether there are flash messages associated with the specified key. - * @param string $key key identifying the flash message type - * @return bool whether any flash messages exist under specified key + * + * @param string $key key identifying the flash message type. + * + * @return bool whether any flash messages exist under specified key. */ - public function hasFlash($key) + public function hasFlash(string $key): bool { - return $this->getFlash($key) !== null; + return $this->flash->has($key); } /** * This method is required by the interface [[\ArrayAccess]]. - * @param int|string $offset the offset to check on - * @return bool + * + * @param mixed $offset the offset to check on. + * + * @return bool whether or not the offset exists. */ - #[\ReturnTypeWillChange] - public function offsetExists($offset) + public function offsetExists(mixed $offset): bool { $this->open(); @@ -972,11 +761,12 @@ public function offsetExists($offset) /** * This method is required by the interface [[\ArrayAccess]]. - * @param int|string $offset the offset to retrieve element. + * + * @param mixed $offset the offset to retrieve element. + * * @return mixed the element at the offset, null if no element is found at the offset */ - #[\ReturnTypeWillChange] - public function offsetGet($offset) + public function offsetGet(mixed $offset): mixed { $this->open(); @@ -985,11 +775,11 @@ public function offsetGet($offset) /** * This method is required by the interface [[\ArrayAccess]]. - * @param int|string $offset the offset to set element - * @param mixed $item the element value + * + * @param mixed $offset the offset to set element. + * @param mixed $item the element value. */ - #[\ReturnTypeWillChange] - public function offsetSet($offset, $item) + public function offsetSet(mixed $offset, mixed $item): void { $this->open(); $_SESSION[$offset] = $item; @@ -997,36 +787,37 @@ public function offsetSet($offset, $item) /** * This method is required by the interface [[\ArrayAccess]]. - * @param int|string $offset the offset to unset element + * + * @param mixed $offset the offset to unset element */ - #[\ReturnTypeWillChange] - public function offsetUnset($offset) + public function offsetUnset(mixed $offset): void { $this->open(); + unset($_SESSION[$offset]); } /** * If session is started it's not possible to edit session ini settings. In PHP7.2+ it throws exception. * This function saves session data to temporary variable and stop session. - * @since 2.0.14 */ - protected function freeze() + protected function freeze(): void { if ($this->getIsActive()) { if (isset($_SESSION)) { $this->_frozenSessionData = $_SESSION; } + $this->close(); + Yii::info('Session frozen', __METHOD__); } } /** - * Starts session and restores data from temporary variable - * @since 2.0.14 + * Starts session and restores data from temporary variable. */ - protected function unfreeze() + protected function unfreeze(): void { if (null !== $this->_frozenSessionData) { YII_DEBUG ? session_start() : @session_start(); @@ -1047,24 +838,78 @@ protected function unfreeze() /** * Set cache limiter * - * @param string $cacheLimiter - * @since 2.0.14 + * @param string $cacheLimiter cache limiter. */ - public function setCacheLimiter($cacheLimiter) + public function setCacheLimiter(string $cacheLimiter) { $this->freeze(); + session_cache_limiter($cacheLimiter); + $this->unfreeze(); } /** - * Returns current cache limiter + * Returns current cache limiter. * - * @return string current cache limiter - * @since 2.0.14 + * @return string current cache limiter. */ - public function getCacheLimiter() + public function getCacheLimiter(): string { return session_cache_limiter(); } + + protected function isRegenerateId(): bool + { + if ($this->_handler?->isRegenerateId()) { + return true; + } + + return $this->_forceRegenerateId !== null; + } + + /** + * Register module name to session module name. + */ + protected function registerSessionHandler(): void + { + $sessionModuleName = session_module_name(); + + if ($this->_originalSessionModule === null) { + $this->_originalSessionModule = $sessionModuleName; + } + + if ( + $sessionModuleName !== $this->_originalSessionModule + && $this->_originalSessionModule !== null + && $this->_originalSessionModule !== 'user' + ) { + session_module_name($this->_originalSessionModule); + } + + if ($this->_handler !== null) { + session_set_save_handler($this->_handler, false); + } + } + + /** + * Sets the session cookie parameters. + * This method is called by [[open()]] when it is about to open the session. + * + * @throws InvalidArgumentException if the parameters are incomplete. + + * @see https://www.php.net/manual/en/function.session-set-cookie-params.php + */ + protected function setCookieParamsInternal(): void + { + $data = $this->getCookieParams(); + + if (isset($data['lifetime'], $data['path'], $data['domain'], $data['secure'], $data['httponly'])) { + session_set_cookie_params($data); + } else { + throw new InvalidArgumentException( + 'Please make sure cookieParams contains these elements: lifetime, path, domain, secure and httponly.' + ); + } + } } diff --git a/src/web/session/SessionHandlerInterface.php b/src/web/session/SessionHandlerInterface.php new file mode 100644 index 00000000..fe106068 --- /dev/null +++ b/src/web/session/SessionHandlerInterface.php @@ -0,0 +1,13 @@ + - * @since 2.0 */ class SessionIterator implements \Iterator { /** * @var array list of keys in the map */ - private $_keys; + private array $_keys = []; /** * @var string|int|false current key */ - private $_key; - + private string|int|false $_key = false; /** * Constructor. @@ -31,6 +24,7 @@ class SessionIterator implements \Iterator public function __construct() { $this->_keys = array_keys(isset($_SESSION) ? $_SESSION : []); + $this->rewind(); } @@ -38,8 +32,7 @@ public function __construct() * Rewinds internal array pointer. * This method is required by the interface [[\Iterator]]. */ - #[\ReturnTypeWillChange] - public function rewind() + public function rewind(): void { $this->_key = reset($this->_keys); } @@ -47,10 +40,10 @@ public function rewind() /** * Returns the key of the current array element. * This method is required by the interface [[\Iterator]]. + * * @return string|int|null the key of the current array element */ - #[\ReturnTypeWillChange] - public function key() + public function key(): string|int|null { return $this->_key === false ? null : $this->_key; } @@ -58,10 +51,10 @@ public function key() /** * Returns the current array element. * This method is required by the interface [[\Iterator]]. + * * @return mixed the current array element */ - #[\ReturnTypeWillChange] - public function current() + public function current(): mixed { return $this->_key !== false && isset($_SESSION[$this->_key]) ? $_SESSION[$this->_key] : null; } @@ -70,8 +63,7 @@ public function current() * Moves the internal pointer to the next array element. * This method is required by the interface [[\Iterator]]. */ - #[\ReturnTypeWillChange] - public function next() + public function next(): void { do { $this->_key = next($this->_keys); @@ -81,10 +73,10 @@ public function next() /** * Returns whether there is an element at current position. * This method is required by the interface [[\Iterator]]. + * * @return bool */ - #[\ReturnTypeWillChange] - public function valid() + public function valid(): bool { return $this->_key !== false; } diff --git a/src/web/session/handler/DbSessionHandler.php b/src/web/session/handler/DbSessionHandler.php new file mode 100644 index 00000000..37aa4e9e --- /dev/null +++ b/src/web/session/handler/DbSessionHandler.php @@ -0,0 +1,112 @@ +getReadQuery($id)->exists($this->db)) { + $this->forceRegenerateId = $id; + } + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function close(): bool + { + return true; + } + + /** + * {@inheritdoc} + */ + public function read(string $id, mixed $defaultValue = ''): string + { + $query = $this->getReadQuery($id); + $data = $query->select(['data'])->scalar($this->db); + + return $data === false ? '' : $data; + } + + /** + * {@inheritdoc} + */ + public function write(string $id, string $data): bool + { + $timeout = (int) ini_get('session.gc_maxlifetime'); + + $this->db->createCommand() + ->upsert($this->sessionTable, ['id' => $id, 'expire' => time() + $timeout, 'data' => $data]) + ->execute(); + + return true; + } + + /** + * {@inheritdoc} + */ + public function destroy(string $id): bool + { + $this->db->createCommand()->delete($this->sessionTable, ['id' => $id])->execute(); + + return true; + } + + /** + * {@inheritdoc} + */ + public function gc(int $maxLifetime): int|false + { + return $this->db->createCommand() + ->delete($this->sessionTable, '[[expire]]<:expire', [':expire' => time()]) + ->execute(); + } + + /** + * @return bool Whether the session id needs to be regenerated. + */ + public function isRegenerateId(): bool + { + return $this->forceRegenerateId !== ''; + } + + /** + * Generates a query to get the session from db. + * + * @param string $id The id of the session. + * + * @return Query The query to get the session from db. + */ + private function getReadQuery($id) + { + return (new Query()) + ->from($this->sessionTable) + ->where('[[expire]]>:expire AND [[id]]=:id', [':expire' => time(), ':id' => $id]); + } +} diff --git a/src/web/session/handler/PSRCacheSessionHandler.php b/src/web/session/handler/PSRCacheSessionHandler.php new file mode 100644 index 00000000..4c78995f --- /dev/null +++ b/src/web/session/handler/PSRCacheSessionHandler.php @@ -0,0 +1,113 @@ +cache->has($this->calculateKey($id))) { + //This session id does not exist, mark it for forced regeneration + $this->forceRegenerateId = $id; + } + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function close(): bool + { + return true; + } + + /** + * {@inheritdoc} + */ + public function read(string $id, mixed $defaultValue = ''): string + { + $data = $this->cache->get($this->calculateKey($id), false); + + return $data === false ? $defaultValue : $data; + } + + /** + * {@inheritdoc} + */ + public function write(string $id, string $data): bool + { + $strictMode = (bool) ini_get('session.use_strict_mode'); + $timeout = (int) ini_get('session.gc_maxlifetime'); + + if ($strictMode && $id === $this->forceRegenerateId) { + //Ignore write when forceRegenerate is active for this id + return true; + } + + return $this->cache->set($this->calculateKey($id), $data, $timeout); + } + + /** + * {@inheritdoc} + */ + public function destroy(string $id): bool + { + $cacheId = $this->calculateKey($id); + + if ($this->cache->has($cacheId) === false) { + return true; + } + + return $this->cache->delete($cacheId); + } + + /** + * {@inheritdoc} + */ + public function gc(int $maxLifetime): int|false + { + return 0; + } + + /** + * @return bool Whether the session id needs to be regenerated. + */ + public function isRegenerateId(): bool + { + return $this->forceRegenerateId !== ''; + } + + /** + * @return string Normalized cache key. + */ + private function calculateKey($id): string + { + return CacheKeyNormalizer::normalize([__CLASS__, $id]); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 192a3ccb..ed405df9 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,9 +1,6 @@ 'testapp', - 'basePath' => __DIR__, - 'vendorPath' => $this->getVendorPath(), - ], $config)); + new $appClass( + ArrayHelper::merge( + [ + 'id' => 'testapp', + 'basePath' => __DIR__, + 'vendorPath' => $this->getVendorPath(), + ], + $config, + ) + ); } - protected function mockWebApplication($config = [], $appClass = '\yii\web\Application') + /** + * Populates Yii::$app with a new web application. + * + * The application will be destroyed on tearDown() automatically. + * + * @param array $config The application configuration, if needed. + * @param string $appClass name of the application class to create. + */ + protected function mockWebApplication($config = [], $appClass = '\yii\web\Application'): void { - new $appClass(ArrayHelper::merge([ - 'id' => 'testapp', - 'basePath' => __DIR__, - 'vendorPath' => $this->getVendorPath(), - 'aliases' => [ - '@bower' => '@vendor/bower-asset', - '@npm' => '@vendor/npm-asset', - ], - 'components' => [ - 'request' => [ - 'cookieValidationKey' => 'wefJDF8sfdsfSDefwqdxj9oq', - 'scriptFile' => __DIR__ . '/index.php', - 'scriptUrl' => '/index.php', - 'isConsoleRequest' => false, + new $appClass( + ArrayHelper::merge( + [ + 'id' => 'testapp', + 'basePath' => __DIR__, + 'vendorPath' => $this->getVendorPath(), + 'aliases' => [ + '@bower' => '@vendor/bower-asset', + '@npm' => '@vendor/npm-asset', + ], + 'components' => [ + 'request' => [ + 'cookieValidationKey' => 'wefJDF8sfdsfSDefwqdxj9oq', + 'scriptFile' => __DIR__ . '/index.php', + 'scriptUrl' => '/index.php', + 'isConsoleRequest' => false, + ], + ], ], - ], - ], $config)); + $config, + ) + ); } protected function getVendorPath() diff --git a/tests/framework/web/UserTest.php b/tests/framework/web/UserTest.php index eaf58e3c..f7edc63e 100644 --- a/tests/framework/web/UserTest.php +++ b/tests/framework/web/UserTest.php @@ -404,7 +404,7 @@ public function testAccessChecker() public function testGetIdentityException() { - $session = $this->getMockBuilder(\yii\web\Session::class)->getMock(); + $session = $this->getMockBuilder(\yii\web\session\Session::class)->getMock(); $session->method('getHasSessionId')->willReturn(true); $session->method('get')->with($this->equalTo('__id'))->willReturn('1'); diff --git a/tests/framework/web/session/AbstractDbSession.php b/tests/framework/web/session/AbstractDbSession.php new file mode 100644 index 00000000..1f3e949a --- /dev/null +++ b/tests/framework/web/session/AbstractDbSession.php @@ -0,0 +1,249 @@ +set('session', ['class' => DbSession::class]); + + $this->session = Yii::$app->getSession(); + + $this->dropTableSession(); + $this->createTableSession(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->dropTableSession(); + } + + public function testGarbageCollection(): void + { + $expiredSessionId = 'expired_session_id'; + + $this->session->setId($expiredSessionId); + $this->session->set('expire', 'expire data'); + $this->session->close(); + + $this->session->db->createCommand() + ->update($this->session->sessionTable, ['expire' => time() - 100], ['id' => $expiredSessionId]) + ->execute(); + + $validSessionId = 'valid_session_id'; + + $this->session->setId($validSessionId); + $this->session->set('new', 'new data'); + $this->session->setGCProbability(100); + $this->session->close(); + + $expiredData = $this->session->db->createCommand("SELECT * FROM {$this->session->sessionTable} WHERE id = :id") + ->bindValue(':id', $expiredSessionId) + ->queryOne(); + + $this->assertFalse($expiredData); + + $validData = $this->session->db->createCommand("SELECT * FROM {$this->session->sessionTable} WHERE id = :id") + ->bindValue(':id', $validSessionId) + ->queryOne(); + + $this->assertNotNull($validData); + + if (is_resource($validData['data'])) { + $validData['data'] = stream_get_contents($validData['data']); + } + + $this->assertSame('new|s:8:"new data";', $validData['data']); + + $this->session->setGCProbability(1); + } + + public function testInitializeWithConfig(): void + { + // should produce no exceptions + $session = new DbSession(['useCookies' => true]); + + $session->set('test', 'session data'); + $this->assertEquals('session data', $session->get('test')); + + $session->destroy('test'); + $this->assertEquals('', $session->get('test')); + } + + public function testInstantiate(): void + { + $oldTimeout = ini_get('session.gc_maxlifetime'); + + Yii::$app->set('sessionDb', Yii::$app->db); + Yii::$app->set('db', null); + + $session = new DbSession( + [ + 'timeout' => 300, + 'db' => 'sessionDb', + '_handler' => [ + 'class' => DbSessionHandler::class, + '__construct()' => ['sessionDb'], + ], + ], + ); + + $this->assertSame(Yii::$app->sessionDb, $session->db); + $this->assertSame(300, $session->timeout); + $session->close(); + + Yii::$app->set('db', Yii::$app->sessionDb); + Yii::$app->set('sessionDb', null); + + ini_set('session.gc_maxlifetime', $oldTimeout); + } + + public function testMigration(): void + { + $this->dropTableSession(); + + $history = $this->runMigrate('history'); + + $this->assertSame(['base'], $history); + + $history = $this->runMigrate('up'); + + $this->assertSame(['base', 'session_init'], $history); + + $history = $this->runMigrate('down'); + + $this->assertSame(['base'], $history); + $this->createTableSession(); + } + + public function testRegenerateIDWithNoActiveSession(): void + { + if ($this->session->getIsActive()) { + $this->session->close(); + } + + $this->session->setId(''); + $this->session->regenerateID(); + + $this->assertFalse($this->session->getIsActive(), 'No debería haberse iniciado una sesión'); + + $count = (new Query())->from('session')->count('*', $this->session->db); + + $this->assertEquals(0, $count); + } + + public function testRegenerateIDWithDeleteSession(): void + { + $this->session->setId('old_session_id'); + $this->session->set('data', 'data'); + $this->session->regenerateID(true); + + $count = (new Query())->from('session')->count('*', $this->session->db); + + $this->assertEquals(1, $count); + + $data = (new Query())->from('session')->where(['id' => session_id()])->one($this->session->db); + + $this->assertNotNull($data); + $this->assertNotSame('old_session_id', $data['id']); + + if (is_resource($data['data'])) { + $data['data'] = stream_get_contents($data['data']); + } + + $this->assertSame('data|s:4:"data";', $data['data']); + } + + public function testSerializedObjectSaving(): void + { + $object = $this->buildObjectForSerialization(); + $serializedObject = serialize($object); + + $this->session->set('test', $serializedObject); + + $this->assertSame($serializedObject, $this->session->get('test')); + + $object->foo = 'modification checked'; + $serializedObject = serialize($object); + + $this->session->set('test', $serializedObject); + + $this->assertSame($serializedObject, $this->session->get('test')); + + $this->session->close(); + } + + protected function buildObjectForSerialization(): object + { + $object = new \stdClass(); + $object->nullValue = null; + $object->floatValue = pi(); + $object->textValue = str_repeat('Qweå߃Тест', 200); + $object->array = [null, 'ab' => 'cd']; + $object->binary = base64_decode('5qS2UUcXWH7rjAmvhqGJTDNkYWFiOGMzNTFlMzNmMWIyMDhmOWIwYzAwYTVmOTFhM2E5MDg5YjViYzViN2RlOGZlNjllYWMxMDA0YmQxM2RQ3ZC0in5ahjNcehNB/oP/NtOWB0u3Skm67HWGwGt9MA=='); + $object->with_null_byte = 'hey!' . "\0" . 'y"ûƒ^äjw¾bðúl5êù-Ö=W¿Š±¬GP¥Œy÷&ø'; + + return $object; + } + + protected function createTableSession(): void + { + $this->runMigrate('up'); + } + + protected function dropTableSession(): void + { + try { + $this->runMigrate('down', ['all']); + } catch (\Exception $e) { + // Table may not exist for different reasons, but since this method + // reverts DB changes to make next test pass, this exception is skipped. + } + } + + protected function runMigrate($action, $params = []): array + { + $migrate = new EchoMigrateController( + 'migrate', + Yii::$app, + [ + 'migrationPath' => '@yii/web/migrations', + 'interactive' => false, + ], + ); + + ob_start(); + ob_implicit_flush(false); + $migrate->run($action, $params); + ob_get_clean(); + + return array_map( + static function ($version): string { + return substr($version, 15); + }, + (new Query())->select(['version'])->from('migration')->column(), + ); + } +} diff --git a/tests/framework/web/session/AbstractDbSessionTest.php b/tests/framework/web/session/AbstractDbSessionTest.php deleted file mode 100644 index f8dbb32f..00000000 --- a/tests/framework/web/session/AbstractDbSessionTest.php +++ /dev/null @@ -1,281 +0,0 @@ -mockApplication(); - Yii::$app->set('db', $this->getDbConfig()); - $this->dropTableSession(); - $this->createTableSession(); - } - - protected function tearDown(): void - { - $this->dropTableSession(); - parent::tearDown(); - } - - protected function getDbConfig() - { - $driverNames = $this->getDriverNames(); - $databases = self::getParam('databases'); - foreach ($driverNames as $driverName) { - if (in_array($driverName, \PDO::getAvailableDrivers()) && array_key_exists($driverName, $databases)) { - $driverAvailable = $driverName; - break; - } - } - if (!isset($driverAvailable)) { - $this->markTestIncomplete(get_called_class() . ' requires ' . implode(' or ', $driverNames) . ' PDO driver! Configuration for connection required too.'); - return []; - } - $config = $databases[$driverAvailable]; - - $result = [ - 'class' => Connection::className(), - 'dsn' => $config['dsn'], - ]; - - if (isset($config['username'])) { - $result['username'] = $config['username']; - } - if (isset($config['password'])) { - $result['password'] = $config['password']; - } - - return $result; - } - - protected function createTableSession() - { - $this->runMigrate('up'); - } - - protected function dropTableSession() - { - try { - $this->runMigrate('down', ['all']); - } catch (\Exception $e) { - // Table may not exist for different reasons, but since this method - // reverts DB changes to make next test pass, this exception is skipped. - } - } - - // Tests : - - public function testReadWrite() - { - $session = new DbSession(); - - $session->writeSession('test', 'session data'); - $this->assertEquals('session data', $session->readSession('test')); - $session->destroySession('test'); - $this->assertEquals('', $session->readSession('test')); - } - - public function testInitializeWithConfig() - { - // should produce no exceptions - $session = new DbSession([ - 'useCookies' => true, - ]); - - $session->writeSession('test', 'session data'); - $this->assertEquals('session data', $session->readSession('test')); - $session->destroySession('test'); - $this->assertEquals('', $session->readSession('test')); - } - - /** - * @depends testReadWrite - */ - public function testGarbageCollection() - { - $session = new DbSession(); - - $session->writeSession('new', 'new data'); - $session->writeSession('expire', 'expire data'); - - $session->db->createCommand() - ->update('session', ['expire' => time() - 100], 'id = :id', ['id' => 'expire']) - ->execute(); - $session->gcSession(1); - - $this->assertEquals('', $session->readSession('expire')); - $this->assertEquals('new data', $session->readSession('new')); - } - - /** - * @depends testReadWrite - */ - public function testWriteCustomField() - { - $session = new DbSession(); - - $session->writeCallback = function ($session) { - return ['data' => 'changed by callback data']; - }; - - $session->writeSession('test', 'session data'); - - $query = new Query(); - $this->assertSame('changed by callback data', $session->readSession('test')); - } - - /** - * @depends testReadWrite - */ - public function testWriteCustomFieldWithUserId() - { - $session = new DbSession(); - $session->open(); - $session->set('user_id', 12345); - - // add mapped custom column - $migration = new Migration; - $migration->compact = true; - $migration->addColumn($session->sessionTable, 'user_id', $migration->integer()); - - $session->writeCallback = function ($session) { - return ['user_id' => $session['user_id']]; - }; - - // here used to be error, fixed issue #9438 - $session->close(); - - // reopen & read session from DB - $session->open(); - $loadedUserId = empty($session['user_id']) ? null : $session['user_id']; - $this->assertSame($loadedUserId, 12345); - $session->close(); - } - - protected function buildObjectForSerialization() - { - $object = new \stdClass(); - $object->nullValue = null; - $object->floatValue = pi(); - $object->textValue = str_repeat('Qweå߃Тест', 200); - $object->array = [null, 'ab' => 'cd']; - $object->binary = base64_decode('5qS2UUcXWH7rjAmvhqGJTDNkYWFiOGMzNTFlMzNmMWIyMDhmOWIwYzAwYTVmOTFhM2E5MDg5YjViYzViN2RlOGZlNjllYWMxMDA0YmQxM2RQ3ZC0in5ahjNcehNB/oP/NtOWB0u3Skm67HWGwGt9MA=='); - $object->with_null_byte = 'hey!' . "\0" . 'y"ûƒ^äjw¾bðúl5êù-Ö=W¿Š±¬GP¥Œy÷&ø'; - - if (version_compare(PHP_VERSION, '5.5.0', '<')) { - unset($object->binary); - // Binary data can not be inserted on PHP <5.5 - } - - return $object; - } - - public function testSerializedObjectSaving() - { - $session = new DbSession(); - - $object = $this->buildObjectForSerialization(); - $serializedObject = serialize($object); - $session->writeSession('test', $serializedObject); - $this->assertSame($serializedObject, $session->readSession('test')); - - $object->foo = 'modification checked'; - $serializedObject = serialize($object); - $session->writeSession('test', $serializedObject); - $this->assertSame($serializedObject, $session->readSession('test')); - } - - protected function runMigrate($action, $params = []) - { - $migrate = new EchoMigrateController('migrate', Yii::$app, [ - 'migrationPath' => '@yii/web/migrations', - 'interactive' => false, - ]); - - ob_start(); - ob_implicit_flush(false); - $migrate->run($action, $params); - ob_get_clean(); - - return array_map(function ($version) { - return substr($version, 15); - }, (new Query())->select(['version'])->from('migration')->column()); - } - - public function testMigration() - { - $this->dropTableSession(); - $this->mockWebApplication([ - 'components' => [ - 'db' => $this->getDbConfig(), - ], - ]); - - $history = $this->runMigrate('history'); - $this->assertEquals(['base'], $history); - - $history = $this->runMigrate('up'); - $this->assertEquals(['base', 'session_init'], $history); - - $history = $this->runMigrate('down'); - $this->assertEquals(['base'], $history); - $this->createTableSession(); - } - - public function testInstantiate() - { - $oldTimeout = ini_get('session.gc_maxlifetime'); - // unset Yii::$app->db to make sure that all queries are made against sessionDb - Yii::$app->set('sessionDb', Yii::$app->db); - Yii::$app->set('db', null); - - $session = new DbSession([ - 'timeout' => 300, - 'db' => 'sessionDb', - ]); - - $this->assertSame(Yii::$app->sessionDb, $session->db); - $this->assertSame(300, $session->timeout); - $session->close(); - - Yii::$app->set('db', Yii::$app->sessionDb); - Yii::$app->set('sessionDb', null); - ini_set('session.gc_maxlifetime', $oldTimeout); - } - - public function testInitUseStrictMode() - { - $this->initStrictModeTest(DbSession::className()); - } - - public function testUseStrictMode() - { - $this->useStrictModeTest(DbSession::className()); - } -} diff --git a/tests/framework/web/session/AbstractSession.php b/tests/framework/web/session/AbstractSession.php new file mode 100644 index 00000000..5e95ffcc --- /dev/null +++ b/tests/framework/web/session/AbstractSession.php @@ -0,0 +1,616 @@ +session = Yii::$app->getSession(); + } + + protected function tearDown(): void + { + $this->session->destroy(); + + $this->session = null; + + parent::tearDown(); + + $this->destroyApplication(); + } + + public function testAddFlash(): void + { + $this->session->addFlash('key', 'value'); + + $this->assertSame(['value'], $this->session->getFlash('key')); + + $this->session->removeFlash('key'); + } + + public function testAddToExistingArrayFlash(): void + { + $this->session->addFlash('key', 'value1', false); + $this->session->addFlash('key', 'value2', false); + + $this->assertSame(['value1', 'value2'], $this->session->getFlash('key')); + + $this->session->removeFlash('key'); + } + + public function testAddValueToExistingNonArrayFlash(): void + { + $this->session->setFlash('testKey', 'initialValue'); + $this->session->addFlash('testKey', 'newValue'); + + $result = $this->session->getFlash('testKey'); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertSame('initialValue', $result[0]); + $this->assertSame('newValue', $result[1]); + + $this->session->removeFlash('testKey'); + } + + public function testAddWithRemoveFlash(): void + { + $this->session->addFlash('key', 'value', true); + + $this->assertSame(['value'], $this->session->getFlash('key')); + $this->assertSame(null, $this->session->getFlash('key')); + + $this->session->removeFlash('key'); + } + + public function testCount(): void + { + $this->assertSame(0, $this->session->count()); + + $this->session->set('name', 'value'); + + $this->assertSame(1, $this->session->count()); + } + + public function testDestroySessionId(): void + { + $this->session->open(); + + $oldSessionId = @session_id(); + + $this->assertNotEmpty($oldSessionId); + + $this->session->destroy(); + + $newSessionId = @session_id(); + + $this->assertNotEmpty($newSessionId); + $this->assertSame($oldSessionId, $newSessionId); + } + + public function testGetCount(): void + { + $this->assertSame(0, $this->session->getCount()); + + $this->session->set('name', 'value'); + + $this->assertSame(1, $this->session->getCount()); + } + + public function testGetFlash(): void + { + $this->assertNull($this->session->getFlash('key')); + $this->assertFalse($this->session->get('key', false)); + } + + public function testGellAllFlashes(): void + { + $this->session->addFlash('key1', 'value1'); + $this->session->addFlash('key2', 'value2'); + + $this->assertSame(['key1' => ['value1'], 'key2' => ['value2']], $this->session->getAllFlashes()); + + $this->session->removeAllFlashes(); + + $this->assertSame([], $this->session->getAllFlashes()); + } + + public function testGellAllFlashesWithDelete(): void + { + $this->session->addFlash('key1', 'value1'); + $this->session->addFlash('key2', 'value2'); + + $this->assertSame(['key1' => ['value1'], 'key2' => ['value2']], $this->session->getAllFlashes(true)); + $this->assertSame([], $this->session->getAllFlashes()); + } + + public function testGetWithRemoveFlash(): void + { + $this->session->addFlash('key', 'value', true); + + $this->assertSame(['value'], $this->session->getFlash('key', null, true)); + $this->assertNull($this->session->getFlash('key')); + + } + + public function testHas(): void + { + $this->assertFalse($this->session->has('name')); + + $this->session->set('name', 'value'); + + $this->assertTrue($this->session->has('name')); + + } + + public function testHasFlash(): void + { + $this->assertFalse($this->session->hasFlash('key')); + + $this->session->addFlash('key', 'value'); + + $this->assertTrue($this->session->hasFlash('key')); + + $this->session->removeFlash('key'); + } + + public function testIdIsSet(): void + { + $_COOKIE['PHPSESSID'] = 'test-id'; + + $this->session->setName('PHPSESSID'); + $this->session->setUseCookies(true); + + $this->assertTrue($this->session->getUseCookies()); + $this->assertTrue($this->session->getHasSessionId()); + + $this->session->setUseCookies(false); + + $this->assertFalse($this->session->getUseCookies()); + + $_COOKIE = []; + } + + public function testIdSetWithTransSid(): void + { + $_COOKIE['PHPSESSID'] = 'test-id'; + + Yii::$app->request->setQueryParams(['PHPSESSID' => 'test-id']); + + $this->session->setName('PHPSESSID'); + $this->session->setUseCookies(false); + $this->session->setUseTransparentSessionID(true); + + $this->assertTrue($this->session->getUseTransparentSessionID()); + $this->assertTrue($this->session->getHasSessionID()); + + $this->session->setUseTransparentSessionID(false); + + $this->assertFalse($this->session->getUseTransparentSessionID()); + + Yii::$app->request->setQueryParams([]); + + $_COOKIE = []; + } + + public function testInitUseStrictMode(): void + { + $this->session->useStrictMode = false; + + $this->assertSame(false, $this->session->getUseStrictMode()); + + $this->session->useStrictMode = true; + + $this->assertEquals(true, $this->session->getUseStrictMode()); + } + + public function testIterator(): void + { + $this->session->set('key1', 'value1'); + + $iterator = $this->session->getIterator(); + + $this->assertInstanceOf(\Iterator::class, $iterator); + $this->assertSame('key1', $iterator->key()); + $this->assertSame('value1', $iterator->current()); + + $iterator->next(); + + $this->assertNull($iterator->key()); + $this->assertNull($iterator->current()); + } + + public function testIteratorValid() + { + $iterator = $this->session->getIterator(); + + $this->assertFalse($iterator->valid()); + + $this->session->set('key1', 'value1'); + $this->session->set('key2', 'value2'); + $this->session->set('key3', 'value3'); + + $iterator = $this->session->getIterator(); + + $this->assertTrue($iterator->valid()); + + $count = 0; + + while ($iterator->valid()) { + $count++; + + $iterator->next(); + } + + $this->assertEquals(3, $count); + $this->assertFalse($iterator->valid()); + + $this->session->removeAll(); + + $iterator = $this->session->getIterator(); + + $this->assertFalse($iterator->valid()); + } + + public function testOffsetExists(): void + { + $this->session->open(); + + $this->assertFalse(isset($this->session['name'])); + + $this->session['name'] = 'value'; + + $this->assertTrue(isset($this->session['name'])); + } + + public function testOffsetGet(): void + { + $this->session->open(); + + $this->assertNull($this->session['name']); + + $this->session['name'] = 'value'; + + $this->assertSame('value', $this->session['name']); + } + + public function testOffsetSet(): void + { + $this->session->open(); + + $this->session['name'] = 'value'; + + $this->assertSame('value', $this->session['name']); + } + + public function testOffsetUnset(): void + { + $this->session->open(); + + $this->session['name'] = 'value'; + + $this->assertSame('value', $this->session['name']); + + unset($this->session['name']); + + $this->assertNull($this->session['name']); + } + + /** + * Test to prove that after Session::open changing session parameters will not throw exceptions and its values will + * be changed as expected. + */ + public function testParamsAfterSessionStart(): void + { + $this->session->open(); + + $this->session->setUseCookies(true); + + $oldUseTransparentSession = $this->session->getUseTransparentSessionID(); + $this->session->setUseTransparentSessionID(true); + $newUseTransparentSession = $this->session->getUseTransparentSessionID(); + + $this->assertNotSame($oldUseTransparentSession, $newUseTransparentSession); + $this->assertTrue($newUseTransparentSession); + + $this->session->setUseTransparentSessionID(false); + $oldTimeout = $this->session->getTimeout(); + $this->session->setTimeout(600); + $newTimeout = $this->session->getTimeout(); + + $this->assertNotEquals($oldTimeout, $newTimeout); + $this->assertSame(600, $newTimeout); + + $oldUseCookies = $this->session->getUseCookies(); + + $this->session->setUseCookies(false); + + $newUseCookies = $this->session->getUseCookies(); + + if (null !== $newUseCookies) { + $this->assertNotSame($oldUseCookies, $newUseCookies); + $this->assertFalse($newUseCookies); + } + + $oldGcProbability = $this->session->getGCProbability(); + $this->session->setGCProbability(100); + $newGcProbability = $this->session->getGCProbability(); + + $this->assertNotEquals($oldGcProbability, $newGcProbability); + $this->assertEquals(100, $newGcProbability); + + $this->session->setGCProbability($oldGcProbability); + } + + public function testRegenerateID(): void + { + $this->session->open(); + + $oldSessionId = $this->session->getId(); + + $this->session->regenerateID(); + + $newSessionId = $this->session->getId(); + + $this->assertNotSame($oldSessionId, $newSessionId); + } + + public function testRemove(): void + { + $this->session->set('name', 'value'); + + $this->assertSame('value', $this->session->get('name')); + + $this->session->remove('name'); + + $this->assertNull($this->session->get('name')); + } + + public function testRemoveAll(): void + { + $this->session->set('name1', 'value1'); + $this->session->set('name2', 'value2'); + + $this->assertSame('value1', $this->session->get('name1')); + $this->assertSame('value2', $this->session->get('name2')); + + $this->session->removeAll(); + + $this->assertNull($this->session->get('name1')); + $this->assertNull($this->session->get('name2')); + } + + public function testRemoveAllFlash(): void + { + $this->session->addFlash('key1', 'value1'); + $this->session->addFlash('key2', 'value2'); + + $this->assertSame(['key1' => ['value1'], 'key2' => ['value2']], $this->session->getAllFlashes()); + + $this->session->removeAllFlashes(); + + $this->assertSame([], $this->session->getAllFlashes()); + } + + public function testRemoveFlash(): void + { + $this->session->addFlash('key', 'value'); + + $this->assertSame(['value'], $this->session->getFlash('key')); + + $this->session->removeFlash('key'); + + $this->assertNull($this->session->getFlash('key')); + } + + /** + * @dataProvider \yiiunit\framework\web\session\provider\SessionProvider::setCacheLimiterDataProvider + * + * @param string $cacheLimiter + */ + public function testSetCacheLimiter(string $cacheLimiter): void + { + $this->session->open(); + + $this->session->setCacheLimiter($cacheLimiter); + + $this->assertSame($cacheLimiter, $this->session->getCacheLimiter()); + } + + /** + * @dataProvider \yiiunit\framework\web\session\provider\SessionProvider::setCookieParamsDataProvider + * + * @param array $cookieParams + */ + public function testSetCookieParams(array $cookieParams): void + { + $this->session->setCookieParams($cookieParams); + + $this->assertSame($cookieParams, $this->session->getCookieParams()); + } + + public function testSetFlash(): void + { + $this->session->setFlash('key'); + + $this->assertSame(['key' => true], $this->session->getAllFlashes()); + + $this->session->setFlash('key', 'value'); + + $this->assertSame(['key' => 'value'], $this->session->getAllFlashes()); + + $this->session->setFlash('key', 'value', true); + + $this->assertSame(['key' => 'value'], $this->session->getAllFlashes()); + $this->assertNull($this->session->getFlash('key')); + + $this->session->removeFlash('key'); + } + + public function testSetGCProbabilityException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('GCProbability must be a value between 0 and 100.'); + + $this->session->setGCProbability(101); + } + + public function testSetHasSessionId(): void + { + $this->session->open(); + + $this->assertFalse($this->session->getHasSessionID()); + + $this->session->setHasSessionID(false); + + $this->assertFalse($this->session->getHasSessionID()); + + $this->session->setHasSessionID(true); + + $this->assertTrue($this->session->getHasSessionID()); + } + + /** + * Test set name. Also check set name twice and after open. + */ + public function testSetName(): void + { + $this->session->setName('oldName'); + + $this->assertSame('oldName', $this->session->getName()); + + $this->session->open(); + $this->session->setName('newName'); + + $this->assertSame('newName', $this->session->getName()); + } + + public function testSetSavePath(): void + { + if (!is_dir(dirname(__DIR__, 3) . '/runtime/sessions')) { + mkdir(dirname(__DIR__, 3) . '/runtime/sessions', 0777, true); + } + + $this->session->setSavePath(dirname(__DIR__, 3) . '/runtime/sessions'); + + $this->assertSame(dirname(__DIR__, 3) . '/runtime/sessions', $this->session->getSavePath()); + + $this->session->setSavePath(dirname(__DIR__, 3) . '/runtime'); + $this->session->open(); + + $this->assertSame(dirname(__DIR__, 3) . '/runtime', $this->session->getSavePath()); + } + + public function testSetSavePathWithInvalidPath(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Session save path is not a valid directory: /non-existing-directory'); + + $this->session->setSavePath('/non-existing-directory'); + } + + public function testSetUseCookiesWithNullValue(): void + { + $this->session->setUseCookies(null); + + $this->assertNull($this->session->getUseCookies()); + } + + public function testUpdateCountersWithNonArrayFlashes(): void + { + $this->session->set('__flash', 'not an array'); + + $result = $this->session->getAllFlashes(); + + $this->assertIsArray($result); + $this->assertEmpty($result); + + $flashes = $this->session->get('__flash'); + + $this->assertIsArray($flashes); + $this->assertArrayHasKey('__counters', $flashes); + $this->assertIsArray($flashes['__counters']); + $this->assertEmpty($flashes['__counters']); + + $this->session->remove('__flash'); + } + + public function testUpdateCountersWithNonArrayCounters(): void + { + $this->session->set('__flash', ['__counters' => 'not an array']); + $this->session->addFlash('testKey', 'testValue'); + + $flashes = $this->session->get('__flash'); + + $this->assertIsArray($flashes); + $this->assertArrayHasKey('__counters', $flashes); + $this->assertIsArray($flashes['__counters']); + $this->assertArrayHasKey('testKey', $flashes['__counters']); + $this->assertEquals(-1, $flashes['__counters']['testKey']); + + $this->session->remove('__flash'); + } + + public function testUseStrictMode(): void + { + //Manual garbage collection since native storage module might not support removing data via Session::destroy() + $sessionSavePath = session_save_path() ?: sys_get_temp_dir(); + + // Only perform garbage collection if "N argument" is not used, + // see https://www.php.net/manual/en/session.configuration.php#ini.session.save-path + if (strpos($sessionSavePath, ';') === false) { + foreach (['non-existing-non-strict', 'non-existing-strict'] as $sessionId) { + @unlink($sessionSavePath . '/sess_' . $sessionId); + } + } + + //non-strict-mode test + $this->session->useStrictMode = false; + $this->session->destroy('non-existing-non-strict'); + $this->session->setId('non-existing-non-strict'); + $this->session->open(); + + $this->assertSame('non-existing-non-strict', $this->session->getId()); + $this->session->close(); + + //strict-mode test + $this->session->useStrictMode = true; + $this->session->destroy('non-existing-strict'); + $this->session->setId('non-existing-strict'); + $this->session->open(); + + $id = $this->session->getId(); + + $this->assertNotSame('non-existing-strict', $id); + + $this->session->set('strict_mode_test', 'session data'); + $this->session->close(); + + //Ensure session was not stored under forced id + $this->session->setId('non-existing-strict'); + $this->session->open(); + + $this->assertNotSame('session data', $this->session->get('strict_mode_test')); + $this->session->close(); + + //Ensure session can be accessed with the new (and thus existing) id. + $this->session->setId($id); + $this->session->open(); + + $this->assertNotEmpty($id); + $this->assertSame($id, $this->session->getId()); + $this->assertSame('session data', $this->session->get('strict_mode_test')); + } +} diff --git a/tests/framework/web/session/CacheSessionTest.php b/tests/framework/web/session/CacheSessionTest.php deleted file mode 100644 index cc62e1b3..00000000 --- a/tests/framework/web/session/CacheSessionTest.php +++ /dev/null @@ -1,66 +0,0 @@ -mockApplication(); - Yii::$app->set('cache', new FileCache()); - } - - public function testCacheSession() - { - $session = new CacheSession(); - - $session->writeSession('test', 'sessionData'); - $this->assertEquals('sessionData', $session->readSession('test')); - $session->destroySession('test'); - $this->assertEquals('', $session->readSession('test')); - } - - public function testInvalidCache() - { - $this->expectException('\Exception'); - new CacheSession(['cache' => 'invalid']); - } - - /** - * @see https://github.com/yiisoft/yii2/issues/13537 - */ - public function testNotWrittenSessionDestroying() - { - $session = new CacheSession(); - - $session->set('foo', 'bar'); - $this->assertEquals('bar', $session->get('foo')); - - $this->assertTrue($session->destroySession($session->getId())); - } - - public function testInitUseStrictMode() - { - $this->initStrictModeTest(CacheSession::className()); - } - - public function testUseStrictMode() - { - $this->useStrictModeTest(CacheSession::className()); - } -} diff --git a/tests/framework/web/session/SessionExceptionTest.php b/tests/framework/web/session/SessionExceptionTest.php new file mode 100644 index 00000000..4ff92cce --- /dev/null +++ b/tests/framework/web/session/SessionExceptionTest.php @@ -0,0 +1,89 @@ +getFunctionMock('yii\web\session', 'error_get_last') + ->expects($this->once()) + ->willReturn(['message' => 'Failed to start session.']); + + /** @var Session $session */ + $session = $this + ->getMockBuilder(Session::class) + ->onlyMethods(['getIsActive']) + ->getMock(); + $session->method('getIsActive')->willReturn(false); + + Yii::getLogger()->flush(); + + $session->open(); + + $this->assertCount(1, Yii::getLogger()->messages); + $this->assertSame(Logger::LEVEL_ERROR, Yii::getLogger()->messages[0][1]); + $this->assertSame('Failed to start session.', Yii::getLogger()->messages[0][0]); + $this->assertSame('yii\web\session\Session::open', Yii::getLogger()->messages[0][2]); + + $session->close(); + } + + public function testUnfreezeFailure(): void + { + $this + ->getFunctionMock('yii\web\session', 'error_get_last') + ->expects($this->once()) + ->willReturn(['message' => 'Failed to unfreeze session.']); + + $_SESSION = ['test' => 'value']; + + Yii::getLogger()->flush(); + + /** @var Session $session */ + $session = $this->getMockBuilder(Session::class)->onlyMethods(['getIsActive'])->getMock(); + $session->method('getIsActive')->will($this->onConsecutiveCalls(true, true, false, false)); + + $session->setName('test'); + + $this->assertStringContainsString('Failed to unfreeze session.', Yii::getLogger()->messages[1][0]); + + $session->close(); + + unset($_SESSION); + } + + public function testSetCookieParamsFailure(): void + { + /** @var Session $session */ + $session = $this->getMockBuilder(Session::class)->onlyMethods(['getCookieParams', 'getIsActive'])->getMock(); + $session->method('getCookieParams')->willReturn(['test' => 'value']); + $session->method('getIsActive')->willReturn(false); + + $this->assertSame(['test' => 'value'], $session->getCookieParams()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Please make sure cookieParams contains these elements: lifetime, path, domain, secure and httponly.' + ); + + $session->open(); + } +} diff --git a/tests/framework/web/session/SessionTest.php b/tests/framework/web/session/SessionTest.php index 1270aa4a..37d046ff 100644 --- a/tests/framework/web/session/SessionTest.php +++ b/tests/framework/web/session/SessionTest.php @@ -1,114 +1,25 @@ open(); - $oldSessionId = @session_id(); - - $this->assertNotEmpty($oldSessionId); - - $session->destroy(); - - $newSessionId = @session_id(); - $this->assertNotEmpty($newSessionId); - $this->assertEquals($oldSessionId, $newSessionId); - } - - /** - * Test to prove that after Session::open changing session parameters will not throw exceptions - * and its values will be changed as expected. - */ - public function testParamsAfterSessionStart() - { - $session = new Session(); - $session->open(); - - $oldUseTransparentSession = $session->getUseTransparentSessionID(); - $session->setUseTransparentSessionID(true); - $newUseTransparentSession = $session->getUseTransparentSessionID(); - $this->assertNotEquals($oldUseTransparentSession, $newUseTransparentSession); - $this->assertTrue($newUseTransparentSession); - //without this line phpunit will complain about risky tests due to unclosed buffer - $session->setUseTransparentSessionID(false); - - $oldTimeout = $session->getTimeout(); - $session->setTimeout(600); - $newTimeout = $session->getTimeout(); - $this->assertNotEquals($oldTimeout, $newTimeout); - $this->assertEquals(600, $newTimeout); - - $oldUseCookies = $session->getUseCookies(); - $session->setUseCookies(false); - $newUseCookies = $session->getUseCookies(); - if (null !== $newUseCookies) { - $this->assertNotEquals($oldUseCookies, $newUseCookies); - $this->assertFalse($newUseCookies); - } - - $oldGcProbability = $session->getGCProbability(); - $session->setGCProbability(100); - $newGcProbability = $session->getGCProbability(); - $this->assertNotEquals($oldGcProbability, $newGcProbability); - $this->assertEquals(100, $newGcProbability); - $session->setGCProbability($oldGcProbability); - } - - /** - * Test set name. Also check set name twice and after open - */ - public function testSetName() + protected function setUp(): void { - $session = new Session(); - $session->setName('oldName'); - - $this->assertEquals('oldName', $session->getName()); - - $session->open(); - $session->setName('newName'); - - $this->assertEquals('newName', $session->getName()); + $this->mockWebApplication(); - $session->destroy(); - } - - public function testInitUseStrictMode() - { - $this->initStrictModeTest(Session::className()); - } - - public function testUseStrictMode() - { - //Manual garbage collection since native storage module might not support removing data via Session::destroySession() - $sessionSavePath = session_save_path() ?: sys_get_temp_dir(); - // Only perform garbage collection if "N argument" is not used, - // see https://www.php.net/manual/en/session.configuration.php#ini.session.save-path - if (strpos($sessionSavePath, ';') === false) { - foreach (['non-existing-non-strict', 'non-existing-strict'] as $sessionId) { - @unlink($sessionSavePath . '/sess_' . $sessionId); - } - } + Yii::$app->set('session', ['class' => Session::class]); - $this->useStrictModeTest(Session::className()); + parent::setUp(); } } diff --git a/tests/framework/web/session/SessionTestTrait.php b/tests/framework/web/session/SessionTestTrait.php deleted file mode 100644 index 1c3e3da6..00000000 --- a/tests/framework/web/session/SessionTestTrait.php +++ /dev/null @@ -1,73 +0,0 @@ -useStrictMode = false; - $this->assertEquals(false, $session->getUseStrictMode()); - - if (PHP_VERSION_ID < 50502 && !$session->getUseCustomStorage()) { - $this->expectException('yii\base\InvalidConfigException'); - $session->useStrictMode = true; - return; - } - - $session->useStrictMode = true; - $this->assertEquals(true, $session->getUseStrictMode()); - } - - /** - * @param string $class - */ - protected function useStrictModeTest($class) - { - /** @var Session $session */ - $session = new $class(); - - if (PHP_VERSION_ID < 50502 && !$session->getUseCustomStorage()) { - $this->markTestSkipped('Can not be tested on PHP < 5.5.2 without custom storage class.'); - return; - } - - //non-strict-mode test - $session->useStrictMode = false; - $session->close(); - $session->destroySession('non-existing-non-strict'); - $session->setId('non-existing-non-strict'); - $session->open(); - $this->assertEquals('non-existing-non-strict', $session->getId()); - $session->close(); - - //strict-mode test - $session->useStrictMode = true; - $session->close(); - $session->destroySession('non-existing-strict'); - $session->setId('non-existing-strict'); - $session->open(); - $id = $session->getId(); - $this->assertNotEquals('non-existing-strict', $id); - $session->set('strict_mode_test', 'session data'); - $session->close(); - //Ensure session was not stored under forced id - $session->setId('non-existing-strict'); - $session->open(); - $this->assertNotEquals('session data', $session->get('strict_mode_test')); - $session->close(); - //Ensure session can be accessed with the new (and thus existing) id. - $session->setId($id); - $session->open(); - $this->assertNotEmpty($id); - $this->assertEquals($id, $session->getId()); - $this->assertEquals('session data', $session->get('strict_mode_test')); - $session->close(); - } -} diff --git a/tests/framework/web/session/mssql/DbSessionTest.php b/tests/framework/web/session/mssql/DbSessionTest.php index 9c203dfb..34bf2a2f 100644 --- a/tests/framework/web/session/mssql/DbSessionTest.php +++ b/tests/framework/web/session/mssql/DbSessionTest.php @@ -1,36 +1,38 @@ - * * @group db * @group mssql + * @group session-db */ -class DbSessionTest extends \yiiunit\framework\web\session\AbstractDbSessionTest +class DbSessionTest extends AbstractDbSession { - protected function getDriverNames() + protected function setUp(): void { - return ['mssql', 'sqlsrv', 'dblib']; + $this->mockWebApplication(); + + $this->db = MssqlConnection::getConnection(); + + parent::setUp(); } - protected function buildObjectForSerialization() + public function testSerializedObjectSaving(): void { - $object = parent::buildObjectForSerialization(); - unset($object->binary); - // Binary data produce error on insert: - // `An error occurred translating string for input param 1 to UCS-2` - // I failed to make it work either with `nvarchar(max)` or `varbinary(max)` column - // in Microsoft SQL server. © SilverFire TODO: fix it - - return $object; + // Data is 8-bit characters as specified in the code page of the Windows locale that is set on the system. + // Any multi-byte characters or characters that do not map into this code page are substituted with a + // single-byte question mark (?) character. + $this->db->getSlavePdo()->setAttribute(PDO::SQLSRV_ATTR_ENCODING, PDO::SQLSRV_ENCODING_SYSTEM); + + parent::testSerializedObjectSaving(); } } diff --git a/tests/framework/web/session/mysql/DbSessionTest.php b/tests/framework/web/session/mysql/DbSessionTest.php index 01ceaf74..4ea3b981 100644 --- a/tests/framework/web/session/mysql/DbSessionTest.php +++ b/tests/framework/web/session/mysql/DbSessionTest.php @@ -1,24 +1,27 @@ - * * @group db * @group mysql + * @group session-db */ -class DbSessionTest extends \yiiunit\framework\web\session\AbstractDbSessionTest +class DbSessionTest extends AbstractDbSession { - protected function getDriverNames() + protected function setUp(): void { - return ['mysql']; + $this->mockWebApplication(); + + $this->db = MysqlConnection::getConnection(); + + parent::setUp(); } } diff --git a/tests/framework/web/session/pgsql/DbSessionTest.php b/tests/framework/web/session/pgsql/DbSessionTest.php index 9af861b0..dca737b5 100644 --- a/tests/framework/web/session/pgsql/DbSessionTest.php +++ b/tests/framework/web/session/pgsql/DbSessionTest.php @@ -1,33 +1,27 @@ - * * @group db * @group pgsql + * @group session-db */ -class DbSessionTest extends \yiiunit\framework\web\session\AbstractDbSessionTest +class DbSessionTest extends AbstractDbSession { protected function setUp(): void { - if (defined('HHVM_VERSION')) { - $this->markTestSkipped('HHVMs PgSQL implementation does not seem to support blob columns in the way they are used here.'); - } + $this->mockWebApplication(); - parent::setUp(); - } + $this->db = PgsqlConnection::getConnection(); - protected function getDriverNames() - { - return ['pgsql']; + parent::setUp(); } } diff --git a/tests/framework/web/session/provider/SessionProvider.php b/tests/framework/web/session/provider/SessionProvider.php new file mode 100644 index 00000000..ecbc4306 --- /dev/null +++ b/tests/framework/web/session/provider/SessionProvider.php @@ -0,0 +1,44 @@ + 0, + 'path' => '/', + 'domain' => '', + 'secure' => false, + 'httponly' => false, + 'samesite' => '', + ] + ], + [ + [ + 'lifetime' => 3600, + 'path' => '/path', + 'domain' => 'example.com', + 'secure' => true, + 'httponly' => true, + 'samesite' => 'Strict', + ] + ], + ]; + } +} diff --git a/tests/framework/web/session/psrcache/PSRCacheSessionTest.php b/tests/framework/web/session/psrcache/PSRCacheSessionTest.php new file mode 100644 index 00000000..54946393 --- /dev/null +++ b/tests/framework/web/session/psrcache/PSRCacheSessionTest.php @@ -0,0 +1,88 @@ +mockWebApplication(); + + Yii::$app->set(CacheInterface::class, new FileCache(Yii::getAlias('@runtime/cache'))); + Yii::$app->set('session', ['class' => PSRCacheSession::class]); + + parent::setUp(); + } + + protected function tearDown(): void + { + $cache = Yii::$app->get(CacheInterface::class); + $cache->clear(); + + Yii::$app->set(CacheInterface::class, null); + Yii::$app->set('session', null); + + parent::tearDown(); + } + + public function testConfigWithArrayConfig(): void + { + $session = new PSRCacheSession( + [ + 'cache' => [ + 'class' => ArrayCache::class, + ], + '_handler' => [ + 'class' => PSRCacheSessionHandler::class, + '__construct()' => ['cache'], + ], + ], + ); + + $psrCache = $this->getInaccessibleProperty($session, 'cache'); + $this->assertInstanceOf(ArrayCache::class, $psrCache); + } + + public function testConfigWithInvalidCache(): void + { + $this->expectException('\Exception'); + $this->expectExceptionMessage('Failed to instantiate component or class "invalid".'); + + new PSRCacheSession(['cache' => 'invalid']); + } + + public function testGarbageCollection(): void + { + $psrCache = new FileCache(Yii::getAlias('@runtime/cache')); + $session = new PSRCacheSession(['cache' => $psrCache]); + + $session->setGCProbability(100); + $session->setTimeout(0); + $session->set('expired', 'expiredData'); + + $this->assertSame('expiredData', $session->get('expired')); + + $session->close(); + + $this->assertNull($session->get('expired')); + + $session->setGCProbability(0); + $session->setTimeout(1440); + + $session->destroy(); + } +} diff --git a/tests/framework/web/session/sqlite/DbSessionTest.php b/tests/framework/web/session/sqlite/DbSessionTest.php index a7e0154f..7827047d 100644 --- a/tests/framework/web/session/sqlite/DbSessionTest.php +++ b/tests/framework/web/session/sqlite/DbSessionTest.php @@ -1,35 +1,27 @@ - * * @group db * @group sqlite + * @group session-db */ -class DbSessionTest extends \yiiunit\framework\web\session\AbstractDbSessionTest +class DbSessionTest extends AbstractDbSession { protected function setUp(): void { - parent::setUp(); + $this->mockWebApplication(); - if (version_compare(Yii::$app->get('db')->getServerVersion(), '3.8.3', '<')) { - $this->markTestSkipped('SQLite < 3.8.3 does not support "WITH" keyword.'); - } - } + $this->db = SqliteConnection::getConnection(); - protected function getDriverNames() - { - return ['sqlite']; + parent::setUp(); } } diff --git a/tests/framework/web/session/sqlite/RegenerateFailureTest.php b/tests/framework/web/session/sqlite/RegenerateFailureTest.php new file mode 100644 index 00000000..8b6509ef --- /dev/null +++ b/tests/framework/web/session/sqlite/RegenerateFailureTest.php @@ -0,0 +1,101 @@ +mockWebApplication(); + + $this->db = SqliteConnection::getConnection(); + + parent::setUp(); + + $this->dropTableSession(); + $this->createTableSession(); + } + + protected function tearDown(): void + { + $this->dropTableSession(); + + parent::tearDown(); + } + + public function testRegenerateIDFailure(): void + { + $this + ->getFunctionMock('yii\web\session', 'session_id') + ->expects($this->exactly(2)) + ->will($this->onConsecutiveCalls('old_session_id', '')); + + /** @var DbSession $session */ + $session = $this->getMockBuilder(DbSession::class)->onlyMethods(['getIsActive'])->getMock(); + $session->method('getIsActive')->willReturn(false); + + Yii::getLogger()->flush(); + + $session->regenerateID(); + + $this->assertStringContainsString('Failed to generate new session ID', Yii::getLogger()->messages[0][0]); + } + + protected function createTableSession(): void + { + $this->runMigrate('up'); + } + + protected function dropTableSession(): void + { + try { + $this->runMigrate('down', ['all']); + } catch (\Exception $e) { + // Table may not exist for different reasons, but since this method + // reverts DB changes to make next test pass, this exception is skipped. + } + } + + protected function runMigrate($action, $params = []): array + { + $migrate = new EchoMigrateController( + 'migrate', + Yii::$app, + [ + 'migrationPath' => '@yii/web/migrations', + 'interactive' => false, + ], + ); + + ob_start(); + ob_implicit_flush(false); + $migrate->run($action, $params); + ob_get_clean(); + + return array_map( + static function ($version): string { + return substr($version, 15); + }, + (new Query())->select(['version'])->from('migration')->column(), + ); + } +} diff --git a/tests/support/DbHelper.php b/tests/support/DbHelper.php new file mode 100644 index 00000000..e5c0fbb7 --- /dev/null +++ b/tests/support/DbHelper.php @@ -0,0 +1,62 @@ +open(); + + if ($db->getDriverName() === 'oci') { + [$drops, $creates] = explode('/* STATEMENTS */', file_get_contents($fixture), 2); + [$statements, $triggers, $data] = explode('/* TRIGGERS */', $creates, 3); + $lines = array_merge( + explode('--', $drops), + explode(';', $statements), + explode('/', $triggers), + explode(';', $data) + ); + } else { + $lines = explode(';', file_get_contents($fixture)); + } + + foreach ($lines as $line) { + if (trim($line) !== '') { + $db->getPDO()?->exec($line); + } + } + } + + /** + * Adjust dbms specific escaping. + * + * @param string $sql string SQL statement to adjust. + * @param string $driverName string DBMS name. + * + * @return mixed + */ + public static function replaceQuotes(string $sql, string $driverName): string + { + return match ($driverName) { + 'mysql', 'sqlite' => str_replace(['[[', ']]'], '`', $sql), + 'oci' => str_replace(['[[', ']]'], '"', $sql), + 'pgsql' => str_replace(['\\[', '\\]'], ['[', ']'], preg_replace('/(\[\[)|((? str_replace(['[[', ']]'], ['[', ']'], $sql), + default => $sql, + }; + } +} diff --git a/tests/support/MssqlConnection.php b/tests/support/MssqlConnection.php new file mode 100644 index 00000000..e9460297 --- /dev/null +++ b/tests/support/MssqlConnection.php @@ -0,0 +1,34 @@ +set('db', self::getConfig()); + + if ($fixture) { + DbHelper::loadFixture(Yii::$app->getDb(), dirname(__DIR__) . '/data/mssql.sql'); + } + + return Yii::$app->getDb(); + } + + public static function getConfig(): array + { + return [ + '__class' => Connection::class, + 'dsn' => 'sqlsrv:Server=127.0.0.1,1433;Database=yiitest', + 'username' => 'SA', + 'password' => 'YourStrong!Passw0rd', + ]; + } +} diff --git a/tests/support/MysqlConnection.php b/tests/support/MysqlConnection.php new file mode 100644 index 00000000..4b94efe9 --- /dev/null +++ b/tests/support/MysqlConnection.php @@ -0,0 +1,34 @@ +set('db', self::getConfig()); + + if ($fixture) { + DbHelper::loadFixture(Yii::$app->getDb(), dirname(__DIR__) . '/data/mysql.sql'); + } + + return Yii::$app->getDb(); + } + + public static function getConfig(): array + { + return [ + '__class' => Connection::class, + 'dsn' => 'mysql:host=127.0.0.1;dbname=yiitest', + 'username' => 'root', + 'password' => 'root', + ]; + } +} diff --git a/tests/support/PgsqlConnection.php b/tests/support/PgsqlConnection.php new file mode 100644 index 00000000..7ffe6e9c --- /dev/null +++ b/tests/support/PgsqlConnection.php @@ -0,0 +1,35 @@ +set('db', self::getConfig()); + + if ($fixture) { + DbHelper::loadFixture(Yii::$app->getDb(), dirname(__DIR__) . '/data/' . self::$fixture); + } + + return Yii::$app->getDb(); + } + + public static function getConfig(): array + { + return [ + '__class' => Connection::class, + 'dsn' => 'pgsql:host=localhost;dbname=yiitest;port=5432;', + 'username' => 'postgres', + 'password' => 'postgres', + ]; + } +} diff --git a/tests/support/SqliteConnection.php b/tests/support/SqliteConnection.php new file mode 100644 index 00000000..168d3605 --- /dev/null +++ b/tests/support/SqliteConnection.php @@ -0,0 +1,32 @@ +set('db', self::getConfig()); + + if ($fixture) { + DbHelper::loadFixture(Yii::$app->getDb(), dirname(__DIR__) . '/data/sqlite.sql'); + } + + return Yii::$app->getDb(); + } + + public static function getConfig(): array + { + return [ + '__class' => Connection::class, + 'dsn' => 'sqlite::memory:', + ]; + } +}