From 7c22716dc4180f8f1934adfca46f314a5348e768 Mon Sep 17 00:00:00 2001 From: Ad Amlas <30976697+AD-Amlas@users.noreply.github.com> Date: Fri, 19 Jul 2024 04:46:22 +0800 Subject: [PATCH] feat(core): Support Laravel (#30) * usage with laravel app * add middleware and make base url dynamic for laravel * update middleware tag * rename services and components * Update UserInfoMiddleware.php * rename middleware and service provider and user registred class * WIP Signed-off-by: Salah Alkhwlani * Create unit-test.yaml * Update unit-test.yaml * Update unit-test.yaml * Create lint-pr.yaml * Create lint-branch.yaml * Update unit-test.yaml * wip Signed-off-by: Salah Alkhwlani * WIP Signed-off-by: Salah Alkhwlani * WIP Signed-off-by: Salah Alkhwlani * wip Signed-off-by: Salah Alkhwlani * WIP Signed-off-by: Salah Alkhwlani * wip Signed-off-by: Salah Alkhwlani * wip Signed-off-by: Salah Alkhwlani * wip Signed-off-by: Salah Alkhwlani * wip Signed-off-by: Salah Alkhwlani --------- Signed-off-by: Salah Alkhwlani Co-authored-by: Salah Alkhwlani Co-authored-by: Salah Alkhwlani --- .github/workflows/lint-branch.yaml | 17 +++ .github/workflows/lint-pr.yaml | 65 +++++++++ .github/workflows/unit-test.yaml | 76 ++++++++++ README.md | 53 +++++++ composer.json | 28 ++-- config/salla-oauth.php | 9 ++ phpunit.xml.dist => phpunit.xml | 10 +- src/Auth/AuthRequest.php | 20 +++ src/Auth/Guard.php | 8 + src/Contracts/SallaOauth.php | 8 + src/Facade/SallaOauth.php | 13 ++ src/Http/OauthMiddleware.php | 63 ++++++++ src/Models/OAuthUser.php | 51 +++++++ src/Provider/Salla.php | 8 +- src/Provider/SallaUser.php | 24 ++- src/ServiceProvider.php | 47 ++++++ test/OAuthUserTest.php | 66 +++++++++ test/OauthMiddlewareTest.php | 92 ++++++++++++ test/SallaFacadeTest.php | 20 +++ test/{src => }/SallaScopeTest.php | 2 +- test/SallaTest.php | 227 +++++++++++++++++++++++++++++ test/{src => }/SallaUserTest.php | 0 test/TestCase.php | 12 ++ test/src/SallaTest.php | 195 ------------------------- 24 files changed, 899 insertions(+), 215 deletions(-) create mode 100644 .github/workflows/lint-branch.yaml create mode 100644 .github/workflows/lint-pr.yaml create mode 100644 .github/workflows/unit-test.yaml create mode 100644 config/salla-oauth.php rename phpunit.xml.dist => phpunit.xml (74%) create mode 100644 src/Auth/AuthRequest.php create mode 100644 src/Auth/Guard.php create mode 100644 src/Contracts/SallaOauth.php create mode 100644 src/Facade/SallaOauth.php create mode 100644 src/Http/OauthMiddleware.php create mode 100644 src/Models/OAuthUser.php create mode 100644 src/ServiceProvider.php create mode 100644 test/OAuthUserTest.php create mode 100644 test/OauthMiddlewareTest.php create mode 100644 test/SallaFacadeTest.php rename test/{src => }/SallaScopeTest.php (93%) create mode 100644 test/SallaTest.php rename test/{src => }/SallaUserTest.php (100%) create mode 100644 test/TestCase.php delete mode 100644 test/src/SallaTest.php diff --git a/.github/workflows/lint-branch.yaml b/.github/workflows/lint-branch.yaml new file mode 100644 index 0000000..f011e0e --- /dev/null +++ b/.github/workflows/lint-branch.yaml @@ -0,0 +1,17 @@ +name: Lint Branch +on: pull_request +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + lint: + runs-on: ubuntu-latest + name: Validate branch name + steps: + - name: Lint branch name + uses: lekterable/branchlint-action@2.0.0 + with: + allowed: | + /(bugfix|hotfix|feature)\/[a-zA-Z]+-\d{1,10}(?:-[^\/]+)?$/i + errorMessage: 'The allowed prefixs for branch name are (bugfix|hotfix|feature)' + startAfter: '2023-12-03 00:00:00' diff --git a/.github/workflows/lint-pr.yaml b/.github/workflows/lint-pr.yaml new file mode 100644 index 0000000..4cae9cf --- /dev/null +++ b/.github/workflows/lint-pr.yaml @@ -0,0 +1,65 @@ +name: "Lint PR" + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + - labeled + - unlabeled +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Configure which types are allowed (newline-delimited). + # Default: https://github.com/commitizen/conventional-commit-types + types: | + fix + feat + BREAKING CHANGE + refactor + test + perf + build + ci + revert + # Configure which scopes are allowed (newline-delimited). + # These are regex patterns auto-wrapped in `^ $`. + scopes: | + JIRA-\d+ + [A-Z]+-\d+ + catalog + orders + payments + checkout + journey + partners + platform + themes + auth + marketing + devops + # Configure that a scope must always be provided. + requireScope: true + # If the PR contains one of these newline-delimited labels, the + # validation is skipped. If you want to rerun the validation when + # labels change, you might want to use the `labeled` and `unlabeled` + # event triggers in your workflow. + ignoreLabels: | + bot + ignore-semantic-pull-request + # If you're using a format for the PR title that differs from the traditional Conventional + # Commits spec, you can use these options to customize the parsing of the type, scope and + # subject. The `headerPattern` should contain a regex where the capturing groups in parentheses + # correspond to the parts listed in `headerPatternCorrespondence`. + # See: https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-commits-parser#headerpattern + headerPattern: '^(\w*)(?:\(([\w$.\-*/ ]*)\))?: (.*)$' + headerPatternCorrespondence: type, scope, subject + wip: true + # upperCase: true diff --git a/.github/workflows/unit-test.yaml b/.github/workflows/unit-test.yaml new file mode 100644 index 0000000..dbf688c --- /dev/null +++ b/.github/workflows/unit-test.yaml @@ -0,0 +1,76 @@ +name: Tests + +on: + push: + branches: + - master + pull_request: + types: [opened, synchronize, reopened] + +jobs: + test: + name: Test PHP ${{ matrix.php-version }} + runs-on: ubuntu-latest + + strategy: + matrix: + php-version: ['8.1', '8.2'] + + services: + redis: + image: bitnami/redis:6.2.4-debian-10-r35 + ports: + - 6379:6379 + env: + ALLOW_EMPTY_PASSWORD: 'yes' + options: >- + --health-cmd "redis-cli -p 6379 ping" + --health-start-period 5s + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Install dependencies + uses: php-actions/composer@v6 + with: + php_version: ${{ matrix.php-version }} + php_extensions: "redis-5.3.7" + + - name: PHPUnit Tests + uses: php-actions/phpunit@v3 + with: + version: 9.5.26 + php_version: ${{ matrix.php-version }} + php_extensions: "xdebug redis-5.3.7" + bootstrap: vendor/autoload.php + configuration: phpunit.xml + coverage_clover: "coverage/clover.xml" + args: --coverage-text + env: + XDEBUG_MODE: coverage + REDIS_HOST: 127.0.0.1 + REDIS_PORT: 6379 + + - name: Run codacy-coverage-reporter + uses: codacy/codacy-coverage-reporter-action@v1 + with: + project-token: ${{ secrets.CODACY_API_TOKEN }} + coverage-reports: "coverage/clover.xml" + + - name: Dump docker logs on failure + if: failure() + uses: jwalton/gh-docker-logs@v2 + + + unit-test-ready: + needs: [ test ] + runs-on: ubuntu-latest + name: "Testing Ready" + steps: + - name: All tests passed + run: echo "All matrix jobs succeeded" diff --git a/README.md b/README.md index b3e5d6c..51b123c 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,59 @@ $token = $provider->getAccessToken('refresh_token', ['refresh_token' => $refresh ``` +## Using Salla OAuth2 Inside Laravel + +You can seamlessly integrate Salla OAuth2 within Laravel using the facade helper provided by the package. Here's how you can do it: + +First, use the facade helper in your Laravel project: + +```php +use \Salla\OAuth2\Client\Facade\SallaOauth; + +// Generate the authorization URL with the required scope +$authUrl = SallaOauth::getAuthorizationUrl([ + 'scope' => 'offline_access', + // Important: Set this value to 'offline_access' to generate a refresh token +]); + +// Retrieve the access token using the authorization code +$token = SallaOauth::getAccessToken('authorization_code', [ + 'code' => request()->get('code') +]); +``` + +To configure the OAuth2 service, set the necessary environment variables in your `.env` file: + +```dotenv +SALLA_OAUTH_CLIENT_ID="" +SALLA_OAUTH_CLIENT_SECRET="" +SALLA_OAUTH_CLIENT_REDIRECT_URI="" +``` + +These settings ensure that your Laravel application can properly communicate with the Salla OAuth2 service, allowing you to handle authentication and retrieve access tokens efficiently. + +## Using Salla OAuth2 as a Laravel Guard + +When integrating Salla OAuth2 for authentication, you may need to validate the merchant's access token and retrieve user information during a request. + +To achieve this, add the `\Salla\OAuth2\Client\Http\OauthMiddleware` middleware to the routes you wish to protect. This middleware ensures that a user is logged in via Salla OAuth2. + +However, note that this middleware only verifies user authentication. Additional authorization checks must be implemented separately as needed. The package conveniently stores the resource owner information as a request attribute, facilitating further authorization. + +After adding the middleware to your route, you can access the current authenticated user using the following code: + +```php +auth()->guard('salla-oauth'); +// To check if a user is authenticated: +auth()->guard('salla-oauth')->check(); +// To get the authenticated user's ID: +auth()->guard('salla-oauth')->id(); +// To get the merchant information of the authenticated user: +auth()->guard('salla-oauth')->merchant(); +``` + +By leveraging this middleware, you ensure secure access to your routes while maintaining flexibility for additional authorization requirements. + ## Testing ```bash diff --git a/composer.json b/composer.json index c498a05..ffcc761 100644 --- a/composer.json +++ b/composer.json @@ -14,20 +14,20 @@ "type": "library", "authors": [ { - "name": "Ahmed Bally", - "email": "ahmed.bally@salla.sa" + "name": "Salla Team", + "email": "support@salla.dev" } ], "minimum-stability": "dev", "require": { - "league/oauth2-client": "^2.0" + "php": "^8.1", + "league/oauth2-client": "^2.0", + "illuminate/support": "^9.0|^10.0" }, "require-dev": { - "eloquent/phony-phpunit": "^4.0.0 || ^7.0.0", - "phpunit/phpunit": "~7.0.0 || ~7.5.0", - "mockery/mockery": "~1.3.0 || ~1.3.0 || ~1.3.0 || ~1.3.0", - "jakub-onderka/php-parallel-lint": "^0.9.2 || ^1.0.0", - "php-coveralls/php-coveralls": "^2.1", + "laravel/framework": ">=8.0", + "orchestra/testbench": "^6.0|^7.0|^8.0", + "phpunit/phpunit": "^8.0|^9.0", "squizlabs/php_codesniffer": "^2.0 || ^3.0" }, "autoload": { @@ -37,7 +37,17 @@ }, "autoload-dev": { "psr-4": { - "Salla\\OAuth2\\Client\\Test\\": "test/src/" + "Salla\\OAuth2\\Client\\Test\\": "test/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Salla\\OAuth2\\Client\\ServiceProvider" + ], + "aliases": { + "SallaOauth": "Salla\\OAuth2\\Client\\Facade\\SallaOauth" + } } }, "scripts": { diff --git a/config/salla-oauth.php b/config/salla-oauth.php new file mode 100644 index 0000000..35b3060 --- /dev/null +++ b/config/salla-oauth.php @@ -0,0 +1,9 @@ + env('SALLA_OAUTH_CLIENT_ID'), + 'client_secret' => env('SALLA_OAUTH_CLIENT_SECRET'), + 'redirect_url' => env('SALLA_OAUTH_CLIENT_REDIRECT_URI'), + 'base_url' => env('SALLA_OAUTH_BASE_URL', 'https://accounts.salla.sa'), + 'cache-prefix' => env('SALLA_OAUTH_PREFIX_CACHE', 'oauth'), +]; diff --git a/phpunit.xml.dist b/phpunit.xml similarity index 74% rename from phpunit.xml.dist rename to phpunit.xml index fb05784..abe92f6 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml @@ -9,6 +9,11 @@ processIsolation="false" stopOnFailure="false" > + + + src + + ./test @@ -19,9 +24,4 @@ src/ - - - - - diff --git a/src/Auth/AuthRequest.php b/src/Auth/AuthRequest.php new file mode 100644 index 0000000..615deee --- /dev/null +++ b/src/Auth/AuthRequest.php @@ -0,0 +1,20 @@ +attributes->get('salla.oauth.user'); + + if (!$user) { + return null; + } + + return new OAuthUser($user); + } +} diff --git a/src/Auth/Guard.php b/src/Auth/Guard.php new file mode 100644 index 0000000..56410b1 --- /dev/null +++ b/src/Auth/Guard.php @@ -0,0 +1,8 @@ +bearerToken(); + + if (empty($token)) { + abort(401, 'Please provide a valid token'); + } + + $cacheKey = config()->get('salla-oauth.cache-prefix') . '.' . $token; + + $user = Cache::get($cacheKey); + + if ($user) { + $this->user = new SallaUser($user); + } + + if ($this->user) { + return $this->nextRequest($next, $request); + } + + try { + /** @var \Salla\OAuth2\Client\Provider\SallaUser $user */ + $this->user = SallaOauth::getResourceOwner(new AccessToken([ + 'access_token' => $token, + ])); + } catch (\Exception $exception) { + abort(401, 'Unauthorized Access'); + } + + // todo :: implement check the scopes + // todo:: $this->user->getScope() + + $exception_at = now()->diffInSeconds($this->user->getExpiredAt()); + + Cache::put($cacheKey, $this->user->toArray(), now()->addSeconds($exception_at)); + + return $this->nextRequest($next, $request); + } + + public function nextRequest(Closure $next, $request): mixed + { + request()->attributes->set('salla.oauth.user', $this->user); + + return $next($request); + } +} diff --git a/src/Models/OAuthUser.php b/src/Models/OAuthUser.php new file mode 100644 index 0000000..896015e --- /dev/null +++ b/src/Models/OAuthUser.php @@ -0,0 +1,51 @@ +user = $user; + } + + public function __get($name) + { + return $this->user->toArray()[$name] ?? null; + } + + public function getAuthIdentifierName() + { + throw new \BadMethodCallException('Not implemented'); + } + + public function getAuthIdentifier() + { + return $this->user->getId(); + } + + public function getAuthPassword() + { + throw new \BadMethodCallException('Not available for OAuth users'); + } + + public function getRememberToken() + { + throw new \BadMethodCallException('Not available for OAuth users'); + } + + public function setRememberToken($value) + { + throw new \BadMethodCallException('Not available for OAuth users'); + } + + public function getRememberTokenName() + { + throw new \BadMethodCallException('Not available for OAuth users'); + } +} diff --git a/src/Provider/Salla.php b/src/Provider/Salla.php index f34bbce..06f4c47 100644 --- a/src/Provider/Salla.php +++ b/src/Provider/Salla.php @@ -23,7 +23,7 @@ class Salla extends AbstractProvider */ public function getBaseAuthorizationUrl() { - return $this->base_url.'/oauth2/auth'; + return $this->base_url . '/oauth2/auth'; } /** @@ -35,7 +35,7 @@ public function getBaseAuthorizationUrl() */ public function getBaseAccessTokenUrl(array $params) { - return $this->base_url.'/oauth2/token'; + return $this->base_url . '/oauth2/token'; } /** @@ -47,7 +47,7 @@ public function getBaseAccessTokenUrl(array $params) */ public function getResourceOwnerDetailsUrl(AccessToken $token) { - return $this->base_url.'/oauth2/user/info'; + return $this->base_url . '/oauth2/user/info'; } /** @@ -160,7 +160,7 @@ public function fetchResource(string $method, string $url, $token, array $option /** * Returns the default headers used by this provider. * - * Typically this is used to set 'Accept' or 'Content-Type' headers. + * Typically, this is used to set 'Accept' or 'Content-Type' headers. * * @return array */ diff --git a/src/Provider/SallaUser.php b/src/Provider/SallaUser.php index 6911cc5..813b1ee 100644 --- a/src/Provider/SallaUser.php +++ b/src/Provider/SallaUser.php @@ -209,6 +209,28 @@ public function getStoreCreatedAt() return new \DateTime($this->getResponseValue('data.merchant.created_at')); } + /** + * Get token expiration + * + * @return \DateTime + * @throws Exception + */ + public function getExpiredAt() + { + return (new \DateTime())->setTimestamp($this->getResponseValue('data.context.exp')); + } + + /** + * Get token scopes + * + * @return string + * @throws Exception + */ + public function getScope() + { + return $this->getResponseValue('data.context.scope'); + } + /** * Get user data as an array. * @@ -219,7 +241,7 @@ public function toArray() { try { return $this->response['data']; - }catch (Exception $exception){ + } catch (Exception $exception) { throw new Exception('User data not found'); } } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php new file mode 100644 index 0000000..8750a23 --- /dev/null +++ b/src/ServiceProvider.php @@ -0,0 +1,47 @@ +mergeConfigFrom(__DIR__ . '/../config/salla-oauth.php', 'salla-oauth'); + + $this->app->singleton(SallaOauth::class, function () { + return (new Salla([ + 'clientId' => config('salla-oauth.client_id'), + 'clientSecret' => config('salla-oauth.client_secret'), + 'redirectUri' => config('salla-oauth.redirect_url'), + ]))->setBaseUrl(config('salla-oauth.base_url')); + }); + + $this->app['config']->set('auth.guards.salla-oauth', [ + 'driver' => 'salla-oauth' + ]); + } + + public function boot() + { + $this->publishes([ + __DIR__ . '/../config/salla-oauth.php' => config_path('salla-oauth.php') + ], 'salla-oauth'); + + app('router')->aliasMiddleware('salla.oauth', OauthMiddleware::class); + + Auth::extend('salla-oauth', function () { + $guard = new Guard($this->app->make(AuthRequest::class), $this->app['request']); + + $this->app->refresh('request', $guard, 'setRequest'); + + return $guard; + }); + } +} diff --git a/test/OAuthUserTest.php b/test/OAuthUserTest.php new file mode 100644 index 0000000..b09a4be --- /dev/null +++ b/test/OAuthUserTest.php @@ -0,0 +1,66 @@ +resourceOwnerMock = $this->createMock(ResourceOwnerInterface::class); + $this->oauthUser = new OAuthUser($this->resourceOwnerMock); + } + + public function testConstructor() + { + $this->assertInstanceOf(OAuthUser::class, $this->oauthUser); + } + + public function testMagicGet() + { + $this->resourceOwnerMock->method('toArray')->willReturn(['name' => 'John Doe']); + $this->assertEquals('John Doe', $this->oauthUser->name); + } + + public function testGetAuthIdentifier() + { + $this->resourceOwnerMock->method('getId')->willReturn('12345'); + $this->assertEquals('12345', $this->oauthUser->getAuthIdentifier()); + } + + public function testGetAuthIdentifierNameThrowsException() + { + $this->expectException(\BadMethodCallException::class); + $this->oauthUser->getAuthIdentifierName(); + } + + public function testGetAuthPasswordThrowsException() + { + $this->expectException(\BadMethodCallException::class); + $this->oauthUser->getAuthPassword(); + } + + public function testGetRememberTokenThrowsException() + { + $this->expectException(\BadMethodCallException::class); + $this->oauthUser->getRememberToken(); + } + + public function testSetRememberTokenThrowsException() + { + $this->expectException(\BadMethodCallException::class); + $this->oauthUser->setRememberToken('token'); + } + + public function testGetRememberTokenNameThrowsException() + { + $this->expectException(\BadMethodCallException::class); + $this->oauthUser->getRememberTokenName(); + } +} diff --git a/test/OauthMiddlewareTest.php b/test/OauthMiddlewareTest.php new file mode 100644 index 0000000..b41ee35 --- /dev/null +++ b/test/OauthMiddlewareTest.php @@ -0,0 +1,92 @@ +get('hello/user')->name('auth.user')->uses(function () { + return 'hello '. auth()->guard('salla-oauth')->user()->getAuthIdentifier(); + })->middleware(OauthMiddleware::class); + + $app['router']->get('hello/guest')->name('auth.guest')->uses(function () { + return 'hello guest'; + }); + } + + public function testUnAuthWhenTokenIsNotProvided() + { + /** @var \Illuminate\Testing\TestResponse|\Illuminate\Http\Response $response */ + $response = $this->get('hello/user'); + $response->assertStatus(401); + } + + public function testUnAuthWhenTokenIsNotValid() + { + /** @var \Illuminate\Testing\TestResponse|\Illuminate\Http\Response $response */ + $response = $this->get('hello/user', [ + 'Authorization' => 'Bearer foobar' + ]); + $response->assertStatus(401); + } + + public function testAddsUserinfoToRequest() + { + $this->app->singleton(SallaOauth::class, function () { + return $this->getMockBuilder(Salla::class) + ->disableOriginalConstructor() + ->onlyMethods(['fetchResourceOwnerDetails']) + ->getMock(); + }); + + + // Mock response + $user = [ + 'data' => [ + 'id' => '12345', + 'name' => 'mock name', + 'email' => 'mock.name@example.com', + 'mobile' => '05000000', + 'role' => 'user', + 'created_at' => '2018-04-28 17:46:25', + 'merchant' => [ + 'id' => '11111', + 'owner_id' => '12345', + 'owner_name' => 'mock name', + 'username' => 'mock_name', + 'name' => 'mock name', + 'avatar' => 'mock_avatar', + 'store_location' => 'mock_location', + 'plan' => 'mock_plan', + 'status' => 'mock_status', + 'created_at' => '2018-04-28 17:46:25', + ] + ] + ]; + + $token = new AccessToken([ + 'access_token' => 'foobar', + ]); + + // Set up the expectation for fetchResourceOwnerDetails method + $this->app->make(SallaOauth::class)->expects($this->once()) + ->method('fetchResourceOwnerDetails') + ->with($this->equalTo($token)) + ->willReturn($user); + + $response = $this->get('hello/user', [ + 'Authorization' => 'Bearer foobar' + ]); + $response->assertStatus(200)->assertSeeText('hello 12345'); + + $authGuard = auth()->guard('salla-oauth'); + $this->assertTrue($authGuard->check()); + $this->assertSame($user['data']['id'], $authGuard->user()->getAuthIdentifier()); + } +} diff --git a/test/SallaFacadeTest.php b/test/SallaFacadeTest.php new file mode 100644 index 0000000..1f7c1f8 --- /dev/null +++ b/test/SallaFacadeTest.php @@ -0,0 +1,20 @@ +assertInstanceOf(Salla::class, SallaOauth::getFacadeRoot()); + $this->assertStringStartsWith('https://accounts.salla.sa/oauth2/auth?state=', \Salla\OAuth2\Client\Facade\SallaOauth::getAuthorizationUrl()); + } + + public function testGetProvideBySingleton() + { + $this->assertInstanceOf(Salla::class, $this->app->make(\Salla\OAuth2\Client\Contracts\SallaOauth::class)); + } +} diff --git a/test/src/SallaScopeTest.php b/test/SallaScopeTest.php similarity index 93% rename from test/src/SallaScopeTest.php rename to test/SallaScopeTest.php index 3b5b6fc..fb18b1e 100644 --- a/test/src/SallaScopeTest.php +++ b/test/SallaScopeTest.php @@ -36,6 +36,6 @@ public function testOfflineAccessScope() parse_str($uri['query'], $query); $this->assertArrayHasKey('scope', $query); - $this->assertContains('offline_access', $query['scope']); + $this->assertContains('offline_access', [$query['scope']]); } } diff --git a/test/SallaTest.php b/test/SallaTest.php new file mode 100644 index 0000000..1ef8748 --- /dev/null +++ b/test/SallaTest.php @@ -0,0 +1,227 @@ +provider = new Salla([ + 'clientId' => 'ac940263c5658074da4ec65530f813bd', + 'clientSecret' => '654c5698fb336a2751bf65470b656bcf', + 'redirectUri' => 'https://yourservice.com/callback_url', + ]); + } + + public function testAuthorizationUrl() + { + $url = $this->provider->getAuthorizationUrl(); + $uri = parse_url($url); + parse_str($uri['query'], $query); + + $this->assertArrayHasKey('client_id', $query); + $this->assertArrayHasKey('redirect_uri', $query); + $this->assertArrayHasKey('response_type', $query); + $this->assertArrayHasKey('scope', $query); + + $reflection = new \ReflectionClass($this->provider); + $property = $reflection->getProperty('state'); + $property->setAccessible(true); + $state = $property->getValue($this->provider); + + $this->assertNotEmpty($state); + } + + public function testBaseAccessTokenUrl() + { + $url = $this->provider->getBaseAccessTokenUrl([]); + $uri = parse_url($url); + + $this->assertEquals('/oauth2/token', $uri['path']); + $this->assertEquals('accounts.salla.sa', $uri['host']); + } + + public function testResourceOwnerDetailsUrl() + { + $token = $this->mockAccessToken(); + + $url = $this->provider->getResourceOwnerDetailsUrl($token); + + $this->assertEquals('https://accounts.salla.sa/oauth2/user/info', $url); + } + + public function testUserData() + { + $this->provider = $this->getMockBuilder(Salla::class) + ->disableOriginalConstructor() + ->onlyMethods(['fetchResourceOwnerDetails']) + ->getMock(); + + // Mock response + $response = [ + 'data' => [ + 'id' => '12345', + 'name' => 'mock name', + 'email' => 'mock.name@example.com', + 'mobile' => '05000000', + 'role' => 'user', + 'created_at' => '2018-04-28 17:46:25', + 'merchant' => [ + 'id' => '11111', + 'owner_id' => '12345', + 'owner_name' => 'mock name', + 'username' => 'mock_name', + 'name' => 'mock name', + 'avatar' => 'mock_avatar', + 'store_location' => 'mock_location', + 'plan' => 'mock_plan', + 'status' => 'mock_status', + 'created_at' => '2018-04-28 17:46:25', + ] + ] + ]; + + $token = $this->mockAccessToken(); + + // Set up the expectation for fetchResourceOwnerDetails method + $this->provider->expects($this->once()) + ->method('fetchResourceOwnerDetails') + ->with($this->equalTo($token)) + ->willReturn($response); + + // Execute + $salla = $this->provider; + $user = $salla->getResourceOwner($token); + + // Verify + $this->assertInstanceOf(ResourceOwnerInterface::class, $user); + $this->assertEquals(12345, $user->getId()); + $this->assertEquals('mock name', $user->getName()); + $this->assertEquals('mock.name@example.com', $user->getEmail()); + $this->assertEquals('05000000', $user->getMobile()); + $this->assertEquals('user', $user->getRole()); + $this->assertEquals('2018-04-28 17:46:25', $user->getCreatedAt()->format('Y-m-d H:i:s')); + $this->assertEquals(11111, $user->getStoreId()); + $this->assertEquals(12345, $user->getStoreOwnerID()); + $this->assertEquals('mock name', $user->getStoreOwnerName()); + $this->assertEquals('mock_name', $user->getStoreUsername()); + $this->assertEquals('mock name', $user->getStoreName()); + $this->assertEquals('mock_avatar', $user->getStoreAvatar()); + $this->assertEquals('mock_location', $user->getStoreLocation()); + $this->assertEquals('mock_plan', $user->getStorePlan()); + $this->assertEquals('mock_status', $user->getStoreStatus()); + $this->assertEquals('2018-04-28 17:46:25', $user->getStoreCreatedAt()->format('Y-m-d H:i:s')); + + $userArray = $user->toArray(); + + $this->assertArrayHasKey('id', $userArray); + $this->assertArrayHasKey('name', $userArray); + $this->assertArrayHasKey('email', $userArray); + $this->assertArrayHasKey('mobile', $userArray); + $this->assertArrayHasKey('role', $userArray); + $this->assertArrayHasKey('created_at', $userArray); + $this->assertArrayHasKey('merchant', $userArray); + } + + public function testErrorResponse() + { + $this->provider = $this->getMockBuilder(Salla::class) + ->onlyMethods(['getResponse']) + ->getMock(); + + $response = $this->getMockBuilder(Response::class) + ->disableOriginalConstructor() + ->onlyMethods(['getHeader', 'getBody', 'getStatusCode']) + ->getMock(); + + $response->expects($this->once()) + ->method('getHeader') + ->with('content-type') + ->willReturn(['application/json']); + + + $error_json = '{"error": "invalid_code"}'; + $error_stream = Utils::streamFor($error_json); + $response->expects($this->once()) + ->method('getBody') + ->willReturn($error_stream); + + $response->expects($this->once()) + ->method('getStatusCode') + ->willReturn(400); + + $this->provider->expects($this->once()) + ->method('getResponse') + ->with($this->isInstanceOf('GuzzleHttp\Psr7\Request')) + ->willReturn($response); + + $token = $this->createMock(AccessToken::class); + + // Expect + $this->expectException(IdentityProviderException::class); + + // Execute + $this->provider->getResourceOwner($token); + } + + public function testCreateAccessToken() + { + $live_time = 3600; + $response_json = [ + 'access_token' => 'moc_access_token', + 'refresh_token' => 'moc_refresh_token', + 'expires_in' => $live_time, + ]; + + $this->provider = $this->getMockBuilder(Salla::class) + ->onlyMethods(['getResponse']) + ->getMock(); + + $response = $this->getMockBuilder(Response::class) + ->disableOriginalConstructor() + ->onlyMethods(['getHeader', 'getBody', 'getStatusCode']) + ->getMock(); + + $response->expects($this->once()) + ->method('getHeader') + ->with('content-type') + ->willReturn(['application/json']); + + $response->expects($this->once()) + ->method('getBody') + ->willReturn(Utils::streamFor(json_encode($response_json))); + + $this->provider->expects($this->once()) + ->method('getResponse') + ->with($this->isInstanceOf('GuzzleHttp\Psr7\Request')) + ->willReturn($response); + + /** + * @var AccessToken $token + */ + $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); + $this->assertEquals($response_json['access_token'], $token->getToken()); + $this->assertEquals(time() + $response_json['expires_in'], $token->getExpires()); + $this->assertEquals($response_json['refresh_token'], $token->getRefreshToken()); + } + + /** + * @return AccessToken + */ + private function mockAccessToken() + { + return new AccessToken([ + 'access_token' => 'mock_access_token', + ]); + } +} diff --git a/test/src/SallaUserTest.php b/test/SallaUserTest.php similarity index 100% rename from test/src/SallaUserTest.php rename to test/SallaUserTest.php diff --git a/test/TestCase.php b/test/TestCase.php new file mode 100644 index 0000000..6033a51 --- /dev/null +++ b/test/TestCase.php @@ -0,0 +1,12 @@ +provider = new Salla([ - 'clientId' => 'ac940263c5658074da4ec65530f813bd', - 'clientSecret' => '654c5698fb336a2751bf65470b656bcf', - 'redirectUri' => 'https://yourservice.com/callback_url', - ]); - } - - public function tearDown() :void - { - m::close(); - } - - public function testAuthorizationUrl() - { - $url = $this->provider->getAuthorizationUrl(); - $uri = parse_url($url); - parse_str($uri['query'], $query); - - $this->assertArrayHasKey('client_id', $query); - $this->assertArrayHasKey('redirect_uri', $query); - $this->assertArrayHasKey('response_type', $query); - $this->assertArrayHasKey('scope', $query); - - $this->assertAttributeNotEmpty('state', $this->provider); - } - - public function testBaseAccessTokenUrl() - { - $url = $this->provider->getBaseAccessTokenUrl([]); - $uri = parse_url($url); - - $this->assertEquals('/oauth2/token', $uri['path']); - $this->assertEquals('accounts.salla.sa', $uri['host']); - } - - public function testResourceOwnerDetailsUrl() - { - $token = $this->mockAccessToken(); - - $url = $this->provider->getResourceOwnerDetailsUrl($token); - - $this->assertEquals('https://accounts.salla.sa/oauth2/user/info', $url); - } - - public function testUserData() - { - // Mock - $response = [ - 'id' => '12345', - 'name' => 'mock name', - 'email' => 'mock.name@example.com', - 'mobile' => '05000000', - 'role' => 'user', - 'created_at' => '2018-04-28 17:46:25', - 'store'=>[ - 'id'=>'11111', - 'owner_id'=> '12345', - 'owner_name'=> 'mock name', - 'username'=> 'mock_name', - 'name'=> 'mock name', - 'avatar'=>'mock_avatar', - 'store_location'=>'mock_location', - 'plan'=>'mock_plan', - 'status'=>'mock_status', - 'created_at'=>'2018-04-28 17:46:25', - ] - ]; - - $token = $this->mockAccessToken(); - - $provider = Phony::partialMock(Salla::class); - $provider->fetchResourceOwnerDetails->returns($response); - $salla = $provider->get(); - - // Execute - $user = $salla->getResourceOwner($token); - - // Verify - Phony::inOrder( - $provider->fetchResourceOwnerDetails->called() - ); - - $this->assertInstanceOf('League\OAuth2\Client\Provider\ResourceOwnerInterface', $user); - - $this->assertEquals(12345, $user->getId()); - $this->assertEquals('mock name', $user->getName()); - $this->assertEquals('mock.name@example.com', $user->getEmail()); - $this->assertEquals('05000000', $user->getMobile()); - $this->assertEquals('user', $user->getRole()); - $this->assertEquals( '2018-04-28 17:46:25', $user->getCreatedAt()->format('Y-m-d H:i:s')); - $this->assertEquals(11111, $user->getStoreId()); - $this->assertEquals(12345, $user->getStoreOwnerID()); - $this->assertEquals('mock name', $user->getStoreOwnerName()); - $this->assertEquals('mock_name', $user->getStoreUsername()); - $this->assertEquals('mock name', $user->getStoreName()); - $this->assertEquals('mock_avatar', $user->getStoreAvatar()); - $this->assertEquals('mock_location', $user->getStoreLocation()); - $this->assertEquals('mock_plan', $user->getStorePlan()); - $this->assertEquals('mock_status', $user->getStoreStatus()); - $this->assertEquals( '2018-04-28 17:46:25', $user->getStoreCreatedAt()->format('Y-m-d H:i:s')); - - $user = $user->toArray(); - - $this->assertArrayHasKey('id', $user); - $this->assertArrayHasKey('name', $user); - $this->assertArrayHasKey('email', $user); - $this->assertArrayHasKey('mobile', $user); - $this->assertArrayHasKey('role', $user); - $this->assertArrayHasKey('role', $user); - $this->assertArrayHasKey('created_at', $user); - $this->assertArrayHasKey('store', $user); - } - - public function testErrorResponse() - { - // Mock - $error_json = '{"error": "invalid_code"}'; - $error_stream = Utils::streamFor('{"error": "invalid_code"}'); - - $response = Phony::mock('GuzzleHttp\Psr7\Response'); - $response->getHeader->returns(['application/json']); - $response->getBody->returns($error_stream); - $provider = Phony::partialMock(Salla::class); - $provider->getResponse->returns($response); - - $salla = $provider->get(); - - $token = $this->mockAccessToken(); - // Expect - $this->expectException(IdentityProviderException::class); - - // Execute - $user = $salla->getResourceOwner($token); - - // Verify - Phony::inOrder( - $provider->getResponse->calledWith($this->instanceOf('GuzzleHttp\Psr7\Request')), - $response->getHeader->called(), - $response->getBody->called() - ); - } - - public function testCreateAccessToken() - { - $live_time = 3600; - $response_json = [ - 'access_token' => 'moc_access_token', - 'refresh_token' => 'moc_refresh_token', - 'expires_in' => $live_time, - ]; - $response = m::mock('Psr\Http\Message\ResponseInterface'); - $response->shouldReceive('getBody')->andReturn(json_encode($response_json)); - $response->shouldReceive('getHeader')->andReturn(['content-type' => 'json']); - $response->shouldReceive('getStatusCode')->andReturn(200); - $client = m::mock('GuzzleHttp\ClientInterface'); - $client->shouldReceive('send')->times(1)->andReturn($response); - $this->provider->setHttpClient($client); - /** - * @var AccessToken $token - * - * */ - $token = $this->provider->getAccessToken('authorization_code', ['code' => 'mock_authorization_code']); - $this->assertEquals($response_json['access_token'], $token->getToken()); - $this->assertEquals(time() + $response_json['expires_in'], $token->getExpires()); - $this->assertEquals($response_json['refresh_token'], $token->getRefreshToken()); - } - - /** - * @return AccessToken - */ - private function mockAccessToken() - { - return new AccessToken([ - 'access_token' => 'mock_access_token', - ]); - } -}