How to Test Laravel Socialite
Asked Answered
E

6

14

I have an application that makes use of socialite, I want to create test for Github authentication, So I used Socialite Facade to mock call to the Socialite driver method, but when I run my test it tells me that I am trying to get value on null type.

Below is the test I have written

public function testGithubLogin()
{
    Socialite::shouldReceive('driver')
        ->with('github')
        ->once();
    $this->call('GET', '/github/authorize')->isRedirection();
}

Below is the implementation of the test

public function authorizeProvider($provider)
{
    return Socialite::driver($provider)->redirect();
}

I understand why it might return such result because Sociallite::driver($provider) returns an instance of Laravel\Socialite\Two\GithubProvider, and considering that I am unable to instantiate this value it will be impossible to specify a return type. I need help to successfully test the controller. Thanks

Ergocalciferol answered 9/2, 2016 at 14:13 Comment(2)
I think you might want Socialite::shouldReceive('driver->redirect').Megillah
@Megillah That does not work, it complains that it does not see the method driver->redirectErgocalciferol
E
28

Well, both answers were great, but they have lots of codes that are not required, and I was able to infer my answer from them.

This is all I needed to do.

Firstly mock the Socialite User type

$abstractUser = Mockery::mock('Laravel\Socialite\Two\User')

Second, set the expected values for its method calls

$abstractUser
   ->shouldReceive('getId')
   ->andReturn(rand())
   ->shouldReceive('getName')
   ->andReturn(str_random(10))
   ->shouldReceive('getEmail')
   ->andReturn(str_random(10) . '@gmail.com')
   ->shouldReceive('getAvatar')
   ->andReturn('https://en.gravatar.com/userimage');

Thirdly, you need to mock the provider/user call

Socialite::shouldReceive('driver->user')->andReturn($abstractUser);

Then lastly you write your assertions

$this->visit('/auth/google/callback')
     ->seePageIs('/')
Ergocalciferol answered 15/11, 2016 at 20:21 Comment(5)
looks like a lot missing hereGyronny
Not a lot, just the driver mock I forgot to add.Ergocalciferol
I don't understand this part Socialite::shouldReceive('driver->user')->andReturn($abstractValidUser);, is something missing? the driver->user part to be exactlyHaustorium
This became a complete mock example for me. Thanks.Withdrawn
@Haustorium That part driver->user is a shortcut to mock a call to a chain of method. The reason for driver->user is because that is what is called when using Socialite. docs.mockery.io/en/latest/reference/demeter_chains.htmlSalvo
E
11
$provider = Mockery::mock('Laravel\Socialite\Contracts\Provider');
$provider->shouldReceive('redirect')->andReturn('Redirected');
$providerName = class_basename($provider);
//Call your model factory here
$socialAccount = factory('LearnCast\User')->create(['provider' => $providerName]);

$abstractUser = Mockery::mock('Laravel\Socialite\Two\User');
// Get the api user object here
$abstractUser->shouldReceive('getId') 
             ->andReturn($socialAccount->provider_user_id)
             ->shouldReceive('getEmail')
             ->andReturn(str_random(10).'@noemail.app')
             ->shouldReceive('getNickname')
             ->andReturn('Laztopaz')
             ->shouldReceive('getAvatar')
             ->andReturn('https://en.gravatar.com/userimage');

$provider = Mockery::mock('Laravel\Socialite\Contracts\Provider');
$provider->shouldReceive('user')->andReturn($abstractUser);

Socialite::shouldReceive('driver')->with('facebook')->andReturn($provider);

// After Oauth redirect back to the route
$this->visit('/auth/facebook/callback')
// See the page that the user login into
->seePageIs('/');

Note: use the socialite package at the top of your class

use Laravel\Socialite\Facades\Socialite;

I had the same problem, but I was able to solve it using the technique above; @ceejayoz. I hope this helps.

Epilate answered 13/5, 2016 at 23:29 Comment(0)
S
5

This may be harder to do, but I believe it makes for more readable tests. Hopefully you'll help me simplify what I'm about to describe.

My idea is to stub http requests. Considering facebook, there are two of them: 1) /oauth/access_token (to get access token), 2) /me (to get data about the user).

For that I temporarily attached php to mitmproxy to create vcr fixture:

  1. Tell php to use http proxy (add the following lines to the .env file):

    HTTP_PROXY=http://localhost:8080
    HTTPS_PROXY=http://localhost:8080
    
  2. Tell php where proxy's certificate is: add openssl.cafile = /etc/php/mitmproxy-ca-cert.pem to php.ini. Or curl.cainfo, for that matter.

  3. Restart php-fpm.
  4. Start mitmproxy.
  5. Make your browser connect through mitmproxy as well.
  6. Log in to the site you're developing using facebook (no TDD here).

    Press z in mitmproxy (C for mitmproxy < 0.18) to clear request (flow) list before redirecting to facebook if need be. Or alternatively, use f command (l for mitmproxy < 0.18) with graph.facebook.com to filter out extra requests.

    Do note, that for twitter you'll need league/oauth1-client 1.7 or newer. The one switched from guzzle/guzzle to guzzlehttp/guzzle. Or else you'll be unable to log in.

  7. Copy data from mimtproxy to tests/fixtures/facebook. I used yaml format and here's what it looks like:

    -
        request:
            method: GET
            url: https://graph.facebook.com/oauth/access_token?client_id=...&client_secret=...&code=...&redirect_uri=...
        response:
            status:
                http_version: '1.1'
                code: 200
                message: OK
            body: access_token=...&expires=...
    -
        request:
            method: GET
            url: https://graph.facebook.com/v2.5/me?access_token=...&appsecret_proof=...&fields=first_name,last_name,email,gender,verified
        response:
            status:
                http_version: '1.1'
                code: 200
                message: OK
            body: '{"first_name":"...","last_name":"...","email":"...","gender":"...","verified":true,"id":"..."}'
    

    For that you can use command E if you've got mitmproxy >= 0.18. Alternatively, use command P. It copies request/response to clipboard. If you want mitmproxy to save them right to file, you can run it with DISPLAY= mitmproxy.

    I see no way to use php-vcr's recording facilities, since I'm not testing the whole workflow.

With that I was able to write the following tests (and yes, they are fine with all those values replaced by dots, feel free to copy as is).

Do note though, fixtures depend on laravel/socialite's version. I had an issue with facebook. In version 2.0.16 laravel/socialite started doing post requests to get access token. Also there's api version in facebook urls.

These fixtures are for 2.0.14. One way to deal with it is to have laravel/socialite dependency in require-dev section of composer.json file as well (with strict version specification) to ensure that socialite is of proper version in development environment (Hopefully, composer will ignore the one in require-dev section in production environment.) Considering you do composer install --no-dev in production environment.

AuthController_HandleFacebookCallbackTest.php:

<?php

use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Auth;
use VCR\VCR;

use App\User;

class AuthController_HandleFacebookCallbackTest extends TestCase
{
    use DatabaseTransactions;

    static function setUpBeforeClass()
    {
        VCR::configure()->enableLibraryHooks(['stream_wrapper', 'curl'])
            ->enableRequestMatchers([
                'method',
                'url',
            ]);
    }

    /**
     * @vcr facebook
     */
    function testCreatesUserWithCorrespondingName()
    {
        $this->doCallbackRequest();

        $this->assertEquals('John Doe', User::first()->name);
    }

    /**
     * @vcr facebook
     */
    function testCreatesUserWithCorrespondingEmail()
    {
        $this->doCallbackRequest();

        $this->assertEquals('[email protected]', User::first()->email);
    }

    /**
     * @vcr facebook
     */
    function testCreatesUserWithCorrespondingFbId()
    {
        $this->doCallbackRequest();

        $this->assertEquals(123, User::first()->fb_id);
    }

    /**
     * @vcr facebook
     */
    function testCreatesUserWithFbData()
    {
        $this->doCallbackRequest();

        $this->assertNotEquals('', User::first()->fb_data);
    }

    /**
     * @vcr facebook
     */
    function testRedirectsToHomePage()
    {
        $this->doCallbackRequest();

        $this->assertRedirectedTo('/');
    }

    /**
     * @vcr facebook
     */
    function testAuthenticatesUser()
    {
        $this->doCallbackRequest();

        $this->assertEquals(User::first()->id, Auth::user()->id);
    }

    /**
     * @vcr facebook
     */
    function testDoesntCreateUserIfAlreadyExists()
    {
        $user = factory(User::class)->create([
            'fb_id' => 123,
        ]);

        $this->doCallbackRequest();

        $this->assertEquals(1, User::count());
    }

    function doCallbackRequest()
    {
        return $this->withSession([
            'state' => '...',
        ])->get('/auth/facebook/callback?' . http_build_query([
            'state' => '...',
        ]));
    }
}

tests/fixtures/facebook:

-
    request:
        method: GET
        url: https://graph.facebook.com/oauth/access_token
    response:
        status:
            http_version: '1.1'
            code: 200
            message: OK
        body: access_token=...
-
    request:
        method: GET
        url: https://graph.facebook.com/v2.5/me
    response:
        status:
            http_version: '1.1'
            code: 200
            message: OK
        body: '{"first_name":"John","last_name":"Doe","email":"john.doe\u0040gmail.com","id":"123"}'

AuthController_HandleTwitterCallbackTest.php:

<?php

use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Auth;
use VCR\VCR;
use League\OAuth1\Client\Credentials\TemporaryCredentials;

use App\User;

class AuthController_HandleTwitterCallbackTest extends TestCase
{
    use DatabaseTransactions;

    static function setUpBeforeClass()
    {
        VCR::configure()->enableLibraryHooks(['stream_wrapper', 'curl'])
            ->enableRequestMatchers([
                'method',
                'url',
            ]);
    }

    /**
     * @vcr twitter
     */
    function testCreatesUserWithCorrespondingName()
    {
        $this->doCallbackRequest();

        $this->assertEquals('joe', User::first()->name);
    }

    /**
     * @vcr twitter
     */
    function testCreatesUserWithCorrespondingTwId()
    {
        $this->doCallbackRequest();

        $this->assertEquals(123, User::first()->tw_id);
    }

    /**
     * @vcr twitter
     */
    function testCreatesUserWithTwData()
    {
        $this->doCallbackRequest();

        $this->assertNotEquals('', User::first()->tw_data);
    }

    /**
     * @vcr twitter
     */
    function testRedirectsToHomePage()
    {
        $this->doCallbackRequest();

        $this->assertRedirectedTo('/');
    }

    /**
     * @vcr twitter
     */
    function testAuthenticatesUser()
    {
        $this->doCallbackRequest();

        $this->assertEquals(User::first()->id, Auth::user()->id);
    }

    /**
     * @vcr twitter
     */
    function testDoesntCreateUserIfAlreadyExists()
    {
        $user = factory(User::class)->create([
            'tw_id' => 123,
        ]);

        $this->doCallbackRequest();

        $this->assertEquals(1, User::count());
    }

    function doCallbackRequest()
    {
        $temporaryCredentials = new TemporaryCredentials();
        $temporaryCredentials->setIdentifier('...');
        $temporaryCredentials->setSecret('...');
        return $this->withSession([
            'oauth.temp' => $temporaryCredentials,
        ])->get('/auth/twitter/callback?' . http_build_query([
            'oauth_token' => '...',
            'oauth_verifier' => '...',
        ]));
    }
}

tests/fixtures/twitter:

-
    request:
        method: POST
        url: https://api.twitter.com/oauth/access_token
    response:
        status:
            http_version: '1.1'
            code: 200
            message: OK
        body: oauth_token=...&oauth_token_secret=...
-
    request:
        method: GET
        url: https://api.twitter.com/1.1/account/verify_credentials.json
    response:
        status:
            http_version: '1.1'
            code: 200
            message: OK
        body: '{"id_str":"123","name":"joe","screen_name":"joe","location":"","description":"","profile_image_url":"http:\/\/pbs.twimg.com\/profile_images\/456\/userpic.png"}'

AuthController_HandleGoogleCallbackTest.php:

<?php

use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Auth;
use VCR\VCR;

use App\User;

class AuthController_HandleGoogleCallbackTest extends TestCase
{
    use DatabaseTransactions;

    static function setUpBeforeClass()
    {
        VCR::configure()->enableLibraryHooks(['stream_wrapper', 'curl'])
            ->enableRequestMatchers([
                'method',
                'url',
            ]);
    }

    /**
     * @vcr google
     */
    function testCreatesUserWithCorrespondingName()
    {
        $this->doCallbackRequest();

        $this->assertEquals('John Doe', User::first()->name);
    }

    /**
     * @vcr google
     */
    function testCreatesUserWithCorrespondingEmail()
    {
        $this->doCallbackRequest();

        $this->assertEquals('[email protected]', User::first()->email);
    }

    /**
     * @vcr google
     */
    function testCreatesUserWithCorrespondingGpId()
    {
        $this->doCallbackRequest();

        $this->assertEquals(123, User::first()->gp_id);
    }

    /**
     * @vcr google
     */
    function testCreatesUserWithGpData()
    {
        $this->doCallbackRequest();

        $this->assertNotEquals('', User::first()->gp_data);
    }

    /**
     * @vcr google
     */
    function testRedirectsToHomePage()
    {
        $this->doCallbackRequest();

        $this->assertRedirectedTo('/');
    }

    /**
     * @vcr google
     */
    function testAuthenticatesUser()
    {
        $this->doCallbackRequest();

        $this->assertEquals(User::first()->id, Auth::user()->id);
    }

    /**
     * @vcr google
     */
    function testDoesntCreateUserIfAlreadyExists()
    {
        $user = factory(User::class)->create([
            'gp_id' => 123,
        ]);

        $this->doCallbackRequest();

        $this->assertEquals(1, User::count());
    }

    function doCallbackRequest()
    {
        return $this->withSession([
            'state' => '...',
        ])->get('/auth/google/callback?' . http_build_query([
            'state' => '...',
        ]));
    }
}

tests/fixtures/google:

-
    request:
        method: POST
        url: https://accounts.google.com/o/oauth2/token
    response:
        status:
            http_version: '1.1'
            code: 200
            message: OK
        body: access_token=...
-
    request:
        method: GET
        url: https://www.googleapis.com/plus/v1/people/me
    response:
        status:
            http_version: '1.1'
            code: 200
            message: OK
        body: '{"emails":[{"value":"[email protected]"}],"id":"123","displayName":"John Doe","image":{"url":"https://googleusercontent.com/photo.jpg"}}'

Note. Make sure you have php-vcr/phpunit-testlistener-vcr required, and that you have the following line in your phpunit.xml:

<listeners>
    <listener class="PHPUnit_Util_Log_VCR" file="vendor/php-vcr/phpunit-testlistener-vcr/PHPUnit/Util/Log/VCR.php"/>
</listeners>

There also was an issue with $_SERVER['HTTP_HOST'] not being set, when running tests. I'm talking about config/services.php file here, namely about redirect url. I handled it like so:

 <?php

$app = include dirname(__FILE__) . '/app.php';

return [
    ...
    'facebook' => [
        ...
        'redirect' => (isset($_SERVER['HTTP_HOST']) ? 'http://' . $_SERVER['HTTP_HOST'] : $app['url']) . '/auth/facebook/callback',
    ],
];

Not particularly beautiful, but I failed to find a better way. I was going to use config('app.url') there, but it doesn't work in config files.

UPD You can get rid of setUpBeforeClass part by removing this method, running tests, and updating request part of fixtures with what vcr records. Actually, the whole thing might be done with vcr alone (no mitmproxy).

Sampan answered 24/10, 2016 at 10:58 Comment(1)
if anything, this is a real interesting idea using php-vcr.Welldisposed
B
3

I've actually created Fake classes that return a dummy user data because I'm interested in testing my logic, not whether Socialite, nor the vendor work properly.

// This is the fake class that extends the original SocialiteManager
class SocialiteManager extends SocialiteSocialiteManager
{
    protected function createFacebookDriver()
    {
        return $this->buildProvider(
            FacebookProvider::class, /* This class is a fake that returns dummy user in facebook's format */
            $this->app->make('config')['services.facebook']
        );
    }

    protected function createGoogleDriver()
    {
        return $this->buildProvider(
            GoogleProvider::class, /* This is a fake class that ereturns dummy user in google's format */
            $this->app->make('config')['services.google']
        );
    }
}

And here is how one of the Fake providers look like:

class FacebookProvider extends SocialiteFacebookProvider
{
    protected function getUserByToken($token)
    {
        return [
            'id' => '123123123',
            'name' => 'John Doe',
            'email' => '[email protected]',
            'avatar' => 'image.jpg',
        ];
    }
}

And of course in the test class, I replace the original SocialiteManager with my version:

public function setUp(): void
    {
        parent::setUp();

        $this->app->singleton(Factory::class, function ($app) {
            return new SocialiteManager($app);
        });
    }

This works pretty fine to me. No need to mock anything.

Brougham answered 5/6, 2020 at 22:53 Comment(0)
P
3

You can temporarily swap Socialite implementation, e.g. using anonymous classes. Basic implementation:

Socialite::swap(new class implements \Laravel\Socialite\Contracts\Factory
{
    public function driver($driver = null)
    {
        return new class implements \Laravel\Socialite\Contracts\Provider
        {
            public function redirect()
            {
                return new \Illuminate\Http\RedirectResponse('/');
            }

            public function user(): \Laravel\Socialite\Two\User
            {
                $user = new \Laravel\Socialite\Two\User();
                $user->name = 'John Doe';
                $user->email = '[email protected]';

                return $user;
            }

            public function stateless(): self // optional method
            {
                return $this;
            }
        };
    }
});

Of course, you can extract it into a separate method or even classes for readability reasons. Here, I just share the concept that can be improved (e.g. you can pass a callback to Provider implementation constructor that you can call in user() method and return value from this closure; this way you can return any value from user() method).

This is how you test may look like:

Socialite::swap(/* ... */);

$response = $this->get('/auth/google/callback');

$response->assertRedirect();
$this->assertDatabaseHas(\App\Models\User::class, [
    'email' => '[email protected]',
]);
Penitence answered 23/12, 2023 at 21:42 Comment(0)
C
0

For those who use stateless() in Laravel-Socialite which Laravel roled as pure API without a frontend.

You may add a stateless mock provider for your callback test.

$mock_provider->shouldReceive('stateless')->andReturn($mock_provider);

Your callback code may look like following

app/Http/Controllers/Auth/SocialTokensController.php

public function callback (String $type, Request $request) {
    $credentials = Socialite::driver($type)->stateless()->user();
    ...
}

tests/Feature/Auth/SocialTokens/CallbackTest.php

protected function boot_mocker($type, $social_account_id) {

    $mock_user = Mockery::mock('Laravel\Socialite\Two\User');
    $mock_user
        ->shouldReceive('getId')
        ->andReturn($social_account_id);

    $mock_provider = Mockery::mock('Laravel\Socialite\Contracts\Provider');
    $mock_provider
        ->shouldReceive('user')
        ->andReturn($mock_user);
    $mock_provider
        ->shouldReceive('stateless')
        ->andReturn($mock_provider);

    Socialite::shouldReceive('driver')
        ->with($type)
        ->andReturn($mock_provider)
        ->once();
}

protected function new_user_login_via_social_oauth ($type) {

    $social_key = 'social->' . $type;
    $social_account_id = Str::random(10);

    $this->assertDatabaseMissing('users', [
        $social_key => $social_account_id,
    ]);

    $this->boot_mocker($type, $social_account_id);

    $response = $this->post(
        route('auth.social.tokens.callback', ['type' => $type])
    );

    $response->assertStatus(302);
    $response->assertRedirect('/profile');
        
    $token_from_request_cookie = $response->headers->getCookies()[0]->getValue();
    $auth_response = $this->withHeaders([
            'Authorization' => 'Bearer ' . $token_from_request_cookie
        ])
        ->get(route('auth.user.show'));

    $auth_response->assertStatus(200);
}

Here is a full example which is 100% test coverage

Choochoo answered 9/2, 2023 at 1:1 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.