From 5206ef2e6205225acf32ed99b751978b2dab704e Mon Sep 17 00:00:00 2001 From: clphillips Date: Tue, 1 Dec 2015 16:30:50 -0800 Subject: [PATCH] Initial commit --- .gitignore | 4 + .travis.yml | 12 ++ README.md | 17 ++ composer.json | 28 +++ phpunit.xml.dist | 19 ++ src/Handlers/NativeHandler.php | 12 ++ src/Handlers/PdoHandler.php | 112 ++++++++++++ src/Session.php | 205 ++++++++++++++++++++++ tests/MockablePdo.php | 11 ++ tests/Unit/Handlers/NativeHandlerTest.php | 19 ++ tests/Unit/Handlers/PdoHandlerTest.php | 183 +++++++++++++++++++ tests/Unit/SessionTest.php | 194 ++++++++++++++++++++ 12 files changed, 816 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpunit.xml.dist create mode 100644 src/Handlers/NativeHandler.php create mode 100644 src/Handlers/PdoHandler.php create mode 100644 src/Session.php create mode 100644 tests/MockablePdo.php create mode 100644 tests/Unit/Handlers/NativeHandlerTest.php create mode 100644 tests/Unit/Handlers/PdoHandlerTest.php create mode 100644 tests/Unit/SessionTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..768fb6a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +vendor/ +composer.lock +phpunit.xml +.DS_Store \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..76c2336 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: php +php: + - 5.3 + - 5.4 + - 5.5 + - 5.6 + - 7.0 +before_script: + - composer install +script: + - ./vendor/bin/phpunit --coverage-text + - ./vendor/bin/phpcs --extensions=php --report=summary --standard=PSR2 ./src ./tests \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f83e40e --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Minphp/Session + +[![Build Status](https://travis-ci.org/phillipsdata/minphp-session.svg?branch=master)](https://travis-ci.org/phillipsdata/minphp-session) [![Coverage Status](https://coveralls.io/repos/phillipsdata/minphp-session/badge.svg)](https://coveralls.io/r/phillipsdata/minphp-session) + +Session Management Library. + +## Installation + +Install via composer: + +```sh +composer require minphp/session:dev-master +``` + +## Basic Usage + +TODO diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3b9496c --- /dev/null +++ b/composer.json @@ -0,0 +1,28 @@ +{ + "name": "minphp/session", + "description": "Session Management Library", + "homepage": "http://github.com/phillipsdata/minphp-session", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Cody Phillips", + "email": "therealclphillips@gmail.com" + } + ], + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "php": ">=5.4.0", + "phpunit/phpunit": "~4.0", + "squizlabs/php_codesniffer": "~2.2", + "satooshi/php-coveralls": "dev-master" + }, + "autoload": { + "psr-4": {"Minphp\\Session\\": "src"} + }, + "autoload-dev": { + "psr-4": {"Minphp\\Session\\Tests\\": "tests"} + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..f6cae9f --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,19 @@ + + + + + tests/Unit + + + + + + src/ + + + diff --git a/src/Handlers/NativeHandler.php b/src/Handlers/NativeHandler.php new file mode 100644 index 0000000..2d5abcd --- /dev/null +++ b/src/Handlers/NativeHandler.php @@ -0,0 +1,12 @@ +options = array_merge( + [ + 'tbl' => 'sessions', + 'tbl_id' => 'id', + 'tbl_exp' => 'expire', + 'tbl_val' => 'value' + ], + $options + ); + $this->db = $db; + } + + /** + * {@inheritdoc} + */ + public function close() + { + return true; + } + + /** + * {@inheritdoc} + */ + public function destroy($sessionId) + { + $query = "DELETE FROM {$this->options['tbl']} WHERE {$this->options['tbl_id']} = :id"; + $this->db->prepare($query) + ->execute([':id' => $sessionId]); + + return true; + } + + /** + * {@inheritdoc} + */ + public function gc($maxlifetime) + { + $query = "DELETE FROM {$this->options['tbl']} WHERE {$this->options['tbl_exp']} < :expire"; + $this->db->prepare($query) + ->execute([':expire' => date('Y-m-d H:i:s', time() - $maxlifetime)]); + + return true; + } + + /** + * {@inheritdoc} + */ + public function open($savePath, $name) + { + return true; + } + + /** + * {@inheritdoc} + */ + public function read($sessionId) + { + $query = "SELECT {$this->options['tbl_val']} FROM {$this->options['tbl']} " + . "WHERE {$this->options['tbl_id']} = :id AND {$this->options['tbl_exp']} >= :expire"; + $row = $this->db->prepare($query, [PDO::FETCH_OBJ]) + ->execute([':id' => $sessionId, ':expire' => date('Y-m-d H:i:s')]); + + if ($row) { + return $row->{$this->options['tbl_val']}; + } + return null; + } + + /** + * {@inheritdoc} + */ + public function write($sessionId, $data) + { + $ttl = ini_get('session.gc_maxlifetime'); + $session = [ + ':value' => $data, + ':id' => $sessionId, + ':expire' => date('Y-m-d H:i:s', time() + $ttl) + ]; + + $updateQuery = "UPDATE {$this->options['tbl']} SET {$this->options['tbl_val']} = :value, " + . "{$this->options['tbl_exp']} = :expire " + . "WHERE {$this->options['tbl_id']} = :id"; + $updateStmt = $this->db->prepare($updateQuery); + $updateStmt->execute($session); + + if (!$updateStmt->rowCount()) { + // Session does not exist, so create it + $insertQuery = "INSERT INTO {$this->options['tbl']} " + . "({$this->options['tbl_id']}, {$this->options['tbl_val']}, {$this->options['tbl_exp']}) " + . "VALUES (:id, :value, :expire)"; + $this->db->prepare($insertQuery)->execute($session); + } + return true; + } +} diff --git a/src/Session.php b/src/Session.php new file mode 100644 index 0000000..7dc0eb5 --- /dev/null +++ b/src/Session.php @@ -0,0 +1,205 @@ +setOptions($options); + + if (!$this->handler) { + $this->handler = $handler ?: new NativeHandler(); + session_set_save_handler($this->handler, false); + } + } + + /** + * Sets session ini variables + * + * @param array $options Options to set + * @see http://php.net/session.configuration + */ + public function setOptions(array $options) + { + $supportedOptions = ['save_path', 'name', 'save_handler', + 'gc_probability', 'gc_divisor', 'gc_maxlifetime', 'serialize_handler', + 'cookie_lifetime', 'cookie_path', 'cookie_domain', 'cookie_secure', + 'cookie_httponly', 'use_strict_mode', 'use_cooies', 'use_only_cookies', + 'referer_check', 'entropy_file', 'entropy_length', 'cache_limiter', + 'cache_expire', 'use_trans_sid', 'hash_function', 'hash_bits_per_character', + 'upload_progress.enabled', 'upload_progress.cleanup', 'upload_progress.prefix', + 'upload_progress.name', 'upload_progress.freq', 'upload_progress.min_freq', + 'lazy_write' + ]; + + foreach ($options as $key => $value) { + if (in_array($key, $supportedOptions)) { + ini_set('session.' . $key, $value); + } + } + } + + /** + * Start the session + * + * @return bool True if the session has started + */ + public function start() + { + if (!$this->hasStarted()) { + $this->started = true; + session_start(); + } + + return true; + } + + /** + * Return whether the session has started or not + * + * @return bool True if the session has started + */ + public function hasStarted() + { + return $this->started; + } + + /** + * Saves and closes the session + */ + public function save() + { + session_write_close(); + $this->started = false; + } + + /** + * Regenerates the session + * + * @param bool $destroy True to destroy the current session + * @param int $lifetime The lifetime of the session cookie in seconds + * @return bool True if regenerated, false otherwise + */ + public function regenerate($destroy = false, $lifetime = null) + { + if (!$this->hasStarted()) { + return false; + } + + if (null !== $lifetime) { + ini_set('session.cookie_lifetime', $lifetime); + } + + return session_regenerate_id($destroy); + } + + /** + * Return the session ID + * + * @return string The session ID + */ + public function getId() + { + return session_id(); + } + + /** + * Sets the session ID + * + * @param string $sessionId The ID to set + * @throws \LogicException + */ + public function setId($sessionId) + { + if ($this->hasStarted()) { + throw new LogicException('Session already started, can not change ID.'); + } + session_id($sessionId); + } + + /** + * Return the session name + * + * @return string The session name + */ + public function getName() + { + return session_name(); + } + + /** + * Sets the session name + * + * @param string $name The session name + */ + public function setName($name) + { + if ($this->hasStarted()) { + throw new LogicException('Session already started, can not change name.'); + } + session_name($name); + } + + /** + * Read session information for the given name + * + * @param string $name The name of the item to read + * @return mixed The value stored in $name of the session, or an empty string. + */ + public function read($name) + { + if (isset($_SESSION[$name])) { + return $_SESSION[$name]; + } + return ''; + } + + /** + * Writes the given session information to the given name + * + * @param string $name The name to write to + * @param mixed $value The value to write + */ + public function write($name, $value) + { + $_SESSION[$name] = $value; + } + + /** + * Unsets the value of a given session variable, or the entire session of + * all values + * + * @param string $name The name to unset + */ + public function clear($name = null) + { + if ($name) { + unset($_SESSION[$name]); + } else { + $_SESSION = []; + } + } +} diff --git a/tests/MockablePdo.php b/tests/MockablePdo.php new file mode 100644 index 0000000..c59644d --- /dev/null +++ b/tests/MockablePdo.php @@ -0,0 +1,11 @@ +assertInstanceOf('\SessionHandlerInterface', new NativeHandler()); + } +} diff --git a/tests/Unit/Handlers/PdoHandlerTest.php b/tests/Unit/Handlers/PdoHandlerTest.php new file mode 100644 index 0000000..66b3fbb --- /dev/null +++ b/tests/Unit/Handlers/PdoHandlerTest.php @@ -0,0 +1,183 @@ +getPdoMock(); + $this->handler = new PdoHandler($mockPdo); + } + + /** + * @covers ::__construct + */ + public function testInstanceOfSessionHandlerInterface() + { + $mockPdo = $this->getPdoMock(); + $this->assertInstanceOf('\SessionHandlerInterface', new PdoHandler($mockPdo)); + } + + /** + * @covers ::close + * @covers ::__construct + */ + public function testClose() + { + $this->assertTrue($this->handler->close()); + } + + /** + * @covers ::destroy + * @covers ::__construct + */ + public function testDestroy() + { + $sessionId = 'sessionId'; + + $mockPdo = $this->getPdoMock($this->equalTo([':id' => $sessionId])); + + $handler = new PdoHandler($mockPdo); + $handler->destroy($sessionId); + } + + /** + * @covers ::gc + * @covers ::__construct + */ + public function testGc() + { + $maxlifetime = 100; + + $mockPdo = $this->getPdoMock($this->callback(function ($data) use ($maxlifetime) { + $exp = strtotime($data[':expire']); + $t = time(); + return $exp < $t && ($exp + $maxlifetime <= $t); + })); + + $handler = new PdoHandler($mockPdo); + $handler->gc($maxlifetime); + } + + /** + * @covers ::open + * @covers ::__construct + */ + public function testOpen() + { + $savePath = ''; + $name = 'PHPSESSID'; + $this->assertTrue($this->handler->open($savePath, $name)); + } + + /** + * @covers ::read + * @covers ::__construct + */ + public function testRead() + { + $sessionId = 'sessionId'; + $returnVal = 'value'; + $mockPdo = $this->getPdoMock( + $this->callback(function ($data) use ($sessionId) { + return $sessionId === $data[':id']; + }), + $this->returnValue((object)['value' => $returnVal]) + ); + + $handler = new PdoHandler($mockPdo); + $this->assertEquals($returnVal, $handler->read($sessionId)); + } + + /** + * @covers ::write + * @covers ::__construct + */ + public function testWriteUpdate() + { + $sessionId = 'sessionId'; + $data = 'data'; + + $mockPdo = $this->getPdoMock( + $this->callback(function ($input) use ($sessionId, $data) { + return $sessionId === $input[':id'] + && $data === $input[':value']; + }), + null, + 1 + ); + + $handler = new PdoHandler($mockPdo); + $handler->write($sessionId, $data); + } + + /** + * @covers ::write + * @covers ::__construct + */ + public function testWriteInsert() + { + $sessionId = 'sessionId'; + $data = 'data'; + + $mockPdo = $this->getPdoMock( + $this->callback(function ($input) use ($sessionId, $data) { + return $sessionId === $input[':id'] + && $data === $input[':value']; + }), + $this->returnValue(null), + 0 + ); + + $handler = new PdoHandler($mockPdo); + $handler->write($sessionId, $data); + } + + /** + * Mock PDO and a request to PDO::prepare + * + * @return PDO + */ + protected function getPdoMock($executeWith = null, $executeReturn = null, $rowCount = null) + { + $mockPdo = $this->getMockBuilder('\Minphp\Session\Tests\MockablePdo') + ->getMock(); + + $mockStatement = $this->getMockBuilder('\PDOStatement') + ->getMock(); + if (null !== $executeWith) { + if (null !== $executeReturn) { + $mockStatement->expects($this->any()) + ->method('execute') + ->with($executeWith) + ->will($executeReturn); + } else { + $mockStatement->expects($this->once()) + ->method('execute') + ->with($executeWith); + } + } + + if (null !== $rowCount) { + $mockStatement->expects($this->once()) + ->method('rowCount') + ->will($this->returnValue($rowCount)); + } + + $mockPdo->expects($this->any()) + ->method('prepare') + ->will($this->returnValue($mockStatement)); + return $mockPdo; + } +} diff --git a/tests/Unit/SessionTest.php b/tests/Unit/SessionTest.php new file mode 100644 index 0000000..987e2c8 --- /dev/null +++ b/tests/Unit/SessionTest.php @@ -0,0 +1,194 @@ +assertInstanceOf('\Minphp\Session\Session', new Session()); + } + + /** + * @covers ::__construct + * @covers ::setOptions + */ + public function testConstructWithOptions() + { + $options = [ + 'name' => 'my-session-name' + ]; + $this->assertInstanceOf('\Minphp\Session\Session', new Session(null, $options)); + + $this->assertEquals($options['name'], ini_get('session.name')); + } + + /** + * @covers ::__construct + * @covers ::setOptions + * @covers ::start + * @covers ::hasStarted + * + * @runInSeparateProcess + */ + public function testStart() + { + $session = new Session(); + + $this->assertFalse($session->hasStarted()); + $this->assertTrue($session->start()); + $this->assertTrue($session->hasStarted()); + } + + /** + * @covers ::__construct + * @covers ::setOptions + * @covers ::save + * @covers ::hasStarted + */ + public function testSave() + { + $session = new Session(); + $session->save(); + + $this->assertFalse($session->hasStarted()); + } + + /** + * @covers ::__construct + * @covers ::setOptions + * @covers ::start + * @covers ::hasStarted + * @covers ::regenerate + * + * @runInSeparateProcess + */ + public function testRegenerate() + { + $session = new Session(); + $this->assertFalse($session->regenerate()); + + $session->start(); + $lifetime = 100; + $this->assertTrue($session->regenerate(false, $lifetime)); + $this->assertEquals($lifetime, ini_get('session.cookie_lifetime')); + + } + + /** + * @covers ::__construct + * @covers ::setOptions + * @covers ::getId + * @covers ::setId + * @covers ::hasStarted + */ + public function testId() + { + $sessionId = 'sessionId'; + $session = new Session(); + $this->assertNotNull($session->getId()); + + $session->setId($sessionId); + $this->assertEquals($sessionId, $session->getId()); + } + + /** + * @covers ::__construct + * @covers ::setOptions + * @covers ::getId + * @covers ::setId + * @covers ::hasStarted + * @expectedException \LogicException + * + * @runInSeparateProcess + */ + public function testIdException() + { + $session = new Session(); + $session->start(); + $session->setId('id-that-cant-be-set'); + } + + /** + * @covers ::__construct + * @covers ::setOptions + * @covers ::getName + * @covers ::setName + * @covers ::hasStarted + */ + public function testName() + { + $sessionName = 'sessionName'; + $session = new Session(); + $this->assertNotNull($session->getName()); + + $session->setName($sessionName); + $this->assertEquals($sessionName, $session->getName()); + } + + /** + * @covers ::__construct + * @covers ::setOptions + * @covers ::getName + * @covers ::setName + * @covers ::hasStarted + * @expectedException \LogicException + * + * @runInSeparateProcess + */ + public function testNameException() + { + $session = new Session(); + $session->start(); + $session->setName('name-that-cant-be-set'); + } + + /** + * @covers ::__construct + * @covers ::setOptions + * @covers ::read + * @covers ::write + */ + public function testReadWrite() + { + $key = 'value'; + $value = 'something'; + + $session = new Session(); + $this->assertEquals('', $session->read($key)); + $session->write($key, $value); + $this->assertEquals($value, $session->read($key)); + } + + /** + * @covers ::__construct + * @covers ::setOptions + * @covers ::read + * @covers ::write + * @covers ::clear + */ + public function testClear() + { + $session = new Session(); + $session->write('key1', 'value1'); + $session->write('key2', 'value2'); + $session->write('key3', 'value3'); + + $session->clear('key1'); + + $this->assertArrayNotHasKey('key1', $_SESSION); + $this->assertArrayHasKey('key2', $_SESSION); + + $session->clear(); + $this->assertEmpty($_SESSION); + } +}