diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..09a890b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,36 @@ +language: php + +services: + - postgresql + - mysql + +php: + - 7.2 + - 7.3 + +env: + - DB_TYPE=pgsql + DB_HOST=localhost + DB_NAME=userdevice + DB_PORT=5432 + DB_USERNAME=postgres + DB_PASSWORD=root + - DB_TYPE=mysql + DB_HOST=127.0.0.1 + DB_NAME=userdevice + DB_PORT=3306 + DB_USERNAME=root + DB_PASSWORD= + +before_script: + - travis_retry composer self-update + - travis_retry composer install --no-interaction --prefer-source + - sh -c "if [ '$DB_TYPE' = 'pgsql' ]; then psql -c 'CREATE DATABASE userdevice;' -U postgres; fi" + - sh -c "if [ '$DB_TYPE' = 'mysql' ]; then mysql -e 'CREATE DATABASE IF NOT EXISTS userdevice;'; fi" + +script: + - travis_retry composer lint + - travis_retry composer cover + +after_success: + - bash <(curl -s https://codecov.io/bash) diff --git a/composer.json b/composer.json index a4aaca9..749def6 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,8 @@ "require-dev": { "phpunit/phpunit": "^7.4", "squizlabs/php_codesniffer": "^3.3", - "vlucas/phpdotenv": "^2.5" + "vlucas/phpdotenv": "^2.5", + "yiisoft/yii2-phpunit": "dev-master" }, "license": "MIT", "authors": [ @@ -37,7 +38,8 @@ "scripts": { "lint": "./vendor/bin/phpcs --standard=PSR2 ./src ./tests", "phpcbf": "./vendor/bin/phpcbf --standard=PSR2 ./src ./tests", - "test": "./vendor/bin/phpunit" + "test": "./vendor/bin/phpunit", + "cover": "./vendor/bin/phpunit --coverage-clover=coverage.xml" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index e77cdd0..18da527 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0cc6cfdf8e020f436675c061259d2ff5", + "content-hash": "d52c029e091905f7eeb44d1e8f01ef0d", "packages": [ { "name": "bower-asset/inputmask", @@ -2169,11 +2169,51 @@ "validate" ], "time": "2018-01-29T19:49:41+00:00" + }, + { + "name": "yiisoft/yii2-phpunit", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/Horat1us/yii2-phpunit.git", + "reference": "2f864d917f12462997aef5c891b8d658d0b7a498" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Horat1us/yii2-phpunit/zipball/2f864d917f12462997aef5c891b8d658d0b7a498", + "reference": "2f864d917f12462997aef5c891b8d658d0b7a498", + "shasum": "" + }, + "require": { + "yiisoft/yii2": "^2.0.13.1" + }, + "require-dev": { + "phpunit/phpunit": "^6.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "yii\\phpunit\\": "src/" + } + }, + "authors": [ + { + "name": "Alexander Letnikow", + "email": "reclamme@gmail.com" + } + ], + "description": "Yii 2 PHPUnit compatibility layer and enhancements", + "support": { + "source": "https://github.com/Horat1us/yii2-phpunit/tree/master" + }, + "time": "2018-05-11T15:21:38+00:00" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": { + "yiisoft/yii2-phpunit": 20 + }, "prefer-stable": false, "prefer-lowest": false, "platform": { diff --git a/phpunit.xml b/phpunit.xml index 46a2044..4f53445 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -13,4 +13,3 @@ - diff --git a/src/Migrations/M181121080429CreateUserDeviceTable.php b/src/Migrations/M181121080429CreateUserDeviceTable.php index d0c95d2..e4858dc 100644 --- a/src/Migrations/M181121080429CreateUserDeviceTable.php +++ b/src/Migrations/M181121080429CreateUserDeviceTable.php @@ -17,7 +17,7 @@ public function safeUp(): void $this->createTable('user_device', [ 'id' => $this->primaryKey(), 'user_id' => $this->integer()->unsigned()->notNull(), - 'user_agent' => $this->text()->notNull(), + 'user_agent' => $this->text(256)->notNull(), 'ip' => $this->string(39)->notNull(), 'created_at' => $this->timestamp()->notNull()->defaultExpression('now()'), 'updated_at' => $this->timestamp()->notNull()->defaultExpression('now()'), @@ -25,7 +25,7 @@ public function safeUp(): void $this->createIndex('user_device_unique', 'user_device', [ 'user_id', - 'user_agent', + $this->db->getDriverName() === 'mysql' ? 'user_agent(256)' : 'user_agent', 'ip', ]); } diff --git a/tests/Mocks/UserMock.php b/tests/Mocks/UserMock.php new file mode 100644 index 0000000..f96afde --- /dev/null +++ b/tests/Mocks/UserMock.php @@ -0,0 +1,76 @@ +id = $id; + } + + public function behaviors() + { + return [ + 'store-device' => [ + 'class' => Behavior::class, + 'user' => $this, + 'request' => \Yii::$app->request, + ], + ]; + } + + public static function findIdentity($id) + { + return $id; + } + + /** + * @param mixed $token + * @param null $type + * + * @return void|web\IdentityInterface + * @throws \Exception + */ + public static function findIdentityByAccessToken($token, $type = null) + { + throw new \Exception('Method not implemented!'); + } + + public function getId(): int + { + return $this->id; + } + + /** + * @return string|void + * @throws \Exception + */ + public function getAuthKey() + { + throw new \Exception('Method not implemented!'); + } + + /** + * @param string $authKey + * + * @return bool|void + * @throws \Exception + */ + public function validateAuthKey($authKey) + { + throw new \Exception('Method not implemented!'); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..264fe60 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,28 @@ + MigrateFixture::class, + 'migrationNamespaces' => [ + 'Wearesho\\Yii\\UserDevice\\Migrations', + ], + ] + ]; + + return ArrayHelper::merge(parent::globalFixtures(), $fixtures); + } +} diff --git a/tests/Unit/BehaviorTest.php b/tests/Unit/BehaviorTest.php new file mode 100644 index 0000000..398517f --- /dev/null +++ b/tests/Unit/BehaviorTest.php @@ -0,0 +1,243 @@ +testLogger = new class extends TestLogger + { + /** @var array */ + public $log; + + public function log($message, $level, $category = 'application') + { + $this->log[] = [$message, $level, $category]; + } + }; + \Yii::setLogger($this->testLogger); + } + + public function testSuccessBehavior(): void + { + \Yii::$app->request->headers->set('User-Agent', static::FAKE_AGENT); + \Yii::$app->request->headers->set('X-Forwarded-For', static::FAKE_IP); + $this->createUser()->trigger(web\Application::EVENT_AFTER_REQUEST); + + $record = UserDevice\Record::find()->where(['=', 'ip', static::FAKE_IP])->one(); + $this->assertNotNull($record); + $this->assertEquals(static::FAKE_AGENT, $record->user_agent); + /** @noinspection PhpUnhandledExceptionInspection */ + $this->assertEquals(1, $record->delete()); + } + + public function testEmptyUserId(): void + { + $user = new class extends web\User implements web\IdentityInterface + { + public $identityClass = web\User::class; + + public static function findIdentity($id) + { + return $id; + } + + /** + * @param mixed $token + * @param null $type + * + * @return void|web\IdentityInterface + * @throws \Exception + */ + public static function findIdentityByAccessToken($token, $type = null) + { + throw new \Exception('Method not implemented!'); + } + + public function getId(): ?int + { + return null; + } + + /** + * @return string|void + * @throws \Exception + */ + public function getAuthKey() + { + throw new \Exception('Method not implemented!'); + } + + /** + * @param string $authKey + * + * @return bool|void + * @throws \Exception + */ + public function validateAuthKey($authKey) + { + throw new \Exception('Method not implemented!'); + } + }; + /** @noinspection PhpUnhandledExceptionInspection */ + $this->loginAs($user); + $behavior = $this->createBehavior($user); + $behavior->storeUserDevice(); + + $this->assertNull($user->id); + $this->assertArraySubset( + [ + [ + "User '' logged in from . Session not enabled.", + 4, + "yii\web\User::login", + ], + [ + "Skipping saving user device for guest", + 8, + "Wearesho\Yii\UserDevice\Behavior" + ] + ], + $this->testLogger->log + ); + } + + public function testEmptyUserAgent(): void + { + $user = $this->createUser(); + $user->trigger(web\Application::EVENT_AFTER_REQUEST); + + $this->assertRegExp("/^User '[0-9]+' logged in from . Session not enabled.$/", $this->testLogger->log[0][0]); + $this->assertEquals(4, $this->testLogger->log[0][1]); + $this->assertEquals("yii\web\User::login", $this->testLogger->log[0][2]); + $this->assertRegExp("/^Missing user agent header in request for user [0-9]+$/", $this->testLogger->log[1][0]); + $this->assertEquals(8, $this->testLogger->log[1][1]); + $this->assertEquals("Wearesho\Yii\UserDevice\Behavior", $this->testLogger->log[1][2]); + } + + public function testEmptyUserIP(): void + { + $user = $this->createUser(); + \Yii::$app->request->headers->set('User-Agent', static::FAKE_AGENT); + $user->trigger(web\Application::EVENT_AFTER_REQUEST); + + $this->assertRegExp("/^User '[0-9]+' logged in from . Session not enabled.$/", $this->testLogger->log[0][0]); + $this->assertEquals(4, $this->testLogger->log[0][1]); + $this->assertEquals("yii\web\User::login", $this->testLogger->log[0][2]); + $this->assertRegExp("/^Missing IP info for user [0-9]+$/", $this->testLogger->log[1][0]); + $this->assertEquals(8, $this->testLogger->log[1][1]); + $this->assertEquals("Wearesho\Yii\UserDevice\Behavior", $this->testLogger->log[1][2]); + } + + public function testSkipUpdate(): void + { + $user = $this->createUser(); + \Yii::$app->request->headers->set('User-Agent', static::FAKE_AGENT); + \Yii::$app->request->headers->set('X-Forwarded-For', static::FAKE_IP); + $user->trigger(web\Application::EVENT_AFTER_REQUEST); + $user->trigger(web\Application::EVENT_AFTER_REQUEST); + + $this->assertArraySubset( + [ + "Skipping updating user info", + 8, + "Wearesho\Yii\UserDevice\Behavior" + ], + $this->testLogger->log[14] + ); + } + + public function testUpdateExistDevice(): void + { + \Yii::$app->db->queryBuilder->truncateTable(UserDevice\Record::tableName()); + $user = $this->createUser(); + \Yii::$app->request->headers->set('User-Agent', static::FAKE_AGENT); + \Yii::$app->request->headers->set('X-Forwarded-For', static::FAKE_IP); + $user->trigger(web\Application::EVENT_AFTER_REQUEST); + /** @noinspection PhpUnhandledExceptionInspection */ + \Yii::$app->cache->flush(); + $first_date = UserDevice\Record::find()->andWhere(['=', 'ip', static::FAKE_IP])->one()->updated_at; + sleep(2); + $user->trigger(web\Application::EVENT_AFTER_REQUEST); + $this->assertGreaterThan( + $first_date, + UserDevice\Record::find()->andWhere(['=', 'ip', static::FAKE_IP])->one()->updated_at + ); + } + + public function testFailedUpdate(): void + { + $user = $this->createUser(); + \Yii::$app->request->headers->set('User-Agent', static::FAKE_AGENT); + \Yii::$app->request->headers->set('X-Forwarded-For', static::FAKE_IP); + $user->trigger(web\Application::EVENT_AFTER_REQUEST); + /** @noinspection PhpUnhandledExceptionInspection */ + \Yii::$app->cache->flush(); + \yii\base\Event::on( + UserDevice\Record::class, + \yii\db\ActiveRecord::EVENT_AFTER_VALIDATE, + function () use ($user) { + $row = UserDevice\Record::find() + ->andWhere(['=', 'user_id', $user->id,]) + ->andWhere(['=', 'user_agent', static::FAKE_AGENT,]) + ->andWhere(['=', 'ip', static::FAKE_IP,]) + ->one(); + + if (!$row) { + return; + } + $this->assertEquals(1, $row->delete()); + } + ); + + $user->trigger(web\Application::EVENT_AFTER_REQUEST); + $this->assertInstanceOf( + UserDevice\Record::class, + UserDevice\Record::find() + ->andWhere(['=', 'user_id', $user->id,]) + ->andWhere(['=', 'user_agent', static::FAKE_AGENT,]) + ->andWhere(['=', 'ip', static::FAKE_IP,]) + ->one() + ); + } + + protected function createBehavior($user): UserDevice\Behavior + { + /** @noinspection PhpUnhandledExceptionInspection */ + /** @var UserDevice\Behavior $behavior */ + $behavior = \Yii::$container->get(UserDevice\Behavior::class, [ + 'user' => $user, + ]); + + return $behavior; + } + + protected function createUser(): UserDevice\Tests\Mocks\UserMock + { + $user = new UserDevice\Tests\Mocks\UserMock(mt_rand()); + /** @noinspection PhpUnhandledExceptionInspection */ + $this->loginAs($user); + + return $user; + } +} diff --git a/tests/Unit/BootstrapTest.php b/tests/Unit/BootstrapTest.php new file mode 100644 index 0000000..4640af7 --- /dev/null +++ b/tests/Unit/BootstrapTest.php @@ -0,0 +1,65 @@ +aliases = \Yii::$aliases; + } + + protected function tearDown() + { + parent::tearDown(); + + \Yii::$aliases = $this->aliases; + } + + public function testBootstrapWebApp(): void + { + $bootstrap = new UserDevice\Bootstrap(); + $bootstrap->bootstrap($this->app); + $this->assertEquals( + \Yii::getAlias('@vendor/wearesho-team/yii2-user-device/src'), + \Yii::getAlias('@Wearesho/Yii/UserDevice') + ); + } + + public function testBootstrapConsoleWeb(): void + { + $bootstrap = new UserDevice\Bootstrap(); + /** @noinspection PhpUnhandledExceptionInspection */ + $bootstrap->bootstrap(new \yii\console\Application([ + 'id' => 'yii2-user-device', + 'basePath' => dirname(__DIR__), + 'components' => [ + 'user' => [ + 'class' => User::class, + 'identityClass' => UserDevice\Tests\Mocks\UserMock::class, + 'enableSession' => false, + ], + 'request' => [ + 'cookieValidationKey' => 'test', + ], + ], + ])); + $this->assertEquals( + \Yii::getAlias('@vendor/wearesho-team/yii2-user-device/src'), + \Yii::getAlias('@Wearesho/Yii/UserDevice') + ); + } +} diff --git a/tests/Unit/RecordTest.php b/tests/Unit/RecordTest.php new file mode 100644 index 0000000..47196fd --- /dev/null +++ b/tests/Unit/RecordTest.php @@ -0,0 +1,75 @@ +record = new Record(); + } + + public function testValidateEmptyRecord(): void + { + $this->assertFalse($this->record->validate()); + $this->assertArrayHasKey('user_id', $this->record->errors); + $this->assertArrayHasKey('user_agent', $this->record->errors); + $this->assertArrayHasKey('ip', $this->record->errors); + } + + public function testValidateUserId(): void + { + $this->assertFalse($this->record->validate('user_id')); + + $this->record->user_id = mt_rand(1, 100); + + $this->assertTrue($this->record->validate('user_id')); + } + + public function testValidateUserAgent(): void + { + $this->assertFalse($this->record->validate('user_agent')); + + $this->record->user_agent = 'test_user_agent'; + + $this->assertTrue($this->record->validate('user_agent')); + } + + public function testValidateIp(): void + { + $this->assertFalse($this->record->validate('ip')); + + $this->record->ip = '3ffe:1900:4545:3:200:f8ff:fe21:67cf'; // fake ipv4 + + $this->assertTrue($this->record->validate('ip')); + } + + public function testBehaviors(): void + { + $this->record = new Record([ + 'user_agent' => 'test_user_agent', + 'user_id' => mt_rand(1, 100), + 'ip' => '3ffe:1900:4545:3:200:f8ff:fe21:67cf' + ]); + $this->record->trigger(BaseActiveRecord::EVENT_BEFORE_INSERT); + $this->assertNotEmpty($this->record->created_at); + $this->assertNotEmpty($this->record->updated_at); + + $this->record->save(); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index f6d39fc..385e29a 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,9 +1,9 @@ load(); +if (file_exists(dirname(__DIR__) . DIRECTORY_SEPARATOR . '.env')) { + $dotEnv = new \Dotenv\Dotenv(dirname(__DIR__)); + $dotEnv->load(); +} getenv('DB_PATH') || putenv("DB_PATH=" . __DIR__ . '/db.sqlite'); diff --git a/tests/config.php b/tests/config.php index 4fde611..a534c5b 100644 --- a/tests/config.php +++ b/tests/config.php @@ -4,11 +4,13 @@ use yii\db\Connection; $localConfig = __DIR__ . DIRECTORY_SEPARATOR . 'config-local.php'; +$dbType = getenv('DB_TYPE'); $host = getenv('DB_HOST'); $name = getenv("DB_NAME"); $port = getenv("DB_PORT"); -$dsn = "pgsql:host={$host};dbname={$name};port={$port}"; +$dsn = "{$dbType}:host={$host};dbname={$name};port={$port}"; $config = [ + 'class' => \yii\web\Application::class, 'id' => 'yii2-user-device', 'basePath' => dirname(__DIR__), 'components' => [ @@ -18,6 +20,17 @@ 'username' => getenv("DB_USERNAME"), 'password' => getenv("DB_PASSWORD") ?: null, ], + 'user' => [ + 'class' => \yii\web\User::class, + 'identityClass' => \Wearesho\Yii\UserDevice\Tests\Mocks\UserMock::class, + 'enableSession' => false, + ], + 'request' => [ + 'cookieValidationKey' => 'test', + ], + 'cache' => [ + 'class' => \yii\caching\ArrayCache::class + ] ], ];