Summer Sale! All accounts are 50% off this week.

arifkhn46's avatar

Sanctum: Issue with user logout case + TDD

Description:

I am writing a use case to logout a user, so on Logout request, I delete all the user tokens so that every token issued previously becomes invalid for further requests.

Following is the feature test case:

    /** @test */
    public function a_user_can_logout()
    {

        // $this->withoutExceptionHandling();
        $this->jsonPost(route('api.user.logout'))->assertStatus(401);

        $user = factory('App\User')->create();

        $response = $this->json('POST', route('api.user.login'), [
            'email'=> $user->email,
            'password' => 'password'
        ])->assertStatus(200);
        
        $this->jsonPost(route('api.user.logout'), [], $response->json()['access_token'])->assertStatus(200);
                
        $this->jsonPost(route('api.user.profile'), [], $response->json()['access_token'])->assertStatus(401);
    }

Now on logout request following method runs:

    public function logout()
    {
        auth()->user()->tokens()->delete();
        return response()->json(['message' => 'Successfully logged out']);
    }

Now comes the bug part, in the last assertion i.e. $this->jsonPost(route('api.user.profile'), [], $response->json()['access_token'])->assertStatus(401); I am calling user profile API which should return the status as 401 but it always returns 200.

Screenshot 2020-04-14 at 8 12 47 PM

My profit method:

public function profile()
    {
        return response()->json(auth()->user());
    }

I tried to debug the issue and the following are my observations:

  1. I am facing this issue when I run this feature test through the command line. If I test the same thing on POSTMAN Client the functionality is just working fine. So this issue is related to TDD.

  2. There is a method in the class Illuminate/Auth/RequestGuard.php

   public function user()
   {
       // If we've already retrieved the user for the current request we can just
       // return it back immediately. We do not want to fetch the user data on
       // every call to this method because that would be tremendously slow.
       if (! is_null($this->user)) {
           return $this->user;
       }

       return $this->user = call_user_func(
           $this->callback, $this->request, $this->getProvider()
       );
   }

In this method Laravel is caching user object for performance optimization. And If we comment out the caching code i.e.

 if (! is_null($this->user)) {
      return $this->user;
 }

my feature test case runs perfectly.

Screenshot 2020-04-14 at 8 10 56 PM

Following is my route list:

Screenshot 2020-04-14 at 7 45 27 PM

Steps To Reproduce:

  1. Setup Laravel 7 and Sanctum
  2. Setup API routes for login, log out, and user profile or any sanctum token protected route.
  3. Create a feature test or use the above feature test code.
  4. Run the tests.
0 likes
14 replies
aktuba's avatar

@equimper, @alanrezende after

$this->jsonPost(route('api.user.logout'), [], $response->json()['access_token'])->assertStatus(200);

add

        $this->app->get('auth')->forgetGuards();

this will clear the guard list and force the app to update the user

2 likes
panthro's avatar

@aktuba this does not work with Sanctum, $this->app->get('auth')->forgetGuards(); clears the user and logs them out, it's pointless having in a test, as it won't actually check if the log out code is working correctly.

cariboufute's avatar

@panthro It is not a perfect solution but this test works when using $this->refreshApplication() and $this->refreshDatabase(). I followed Charis Yfantis's solution for this: https://github.com/laravel/sanctum/issues/256

The downside is that refreshApplication flushes also the database. I did not find a way to refresh the application while keeping the database yet.

This is a Pest test.

test('logout invalidate auth token', function () {
    $response = postJson('/api/login', $this->credentials);
    $token = $response->json()['token'];
    $headers = ['Authorization' => 'Bearer ' . $token];

    $userResponse = getJson('/api/user', $headers);
    $userResponse->assertJson([
        'id' => $this->user->id,
    ]);

    $logoutResponse = postJson('/api/logout', [], $headers);
    $logoutResponse->assertNoContent();
    expect($this->user->fresh()->tokens)->toBeEmpty();

    $this->refreshApplication();
    $this->refreshDatabase();

    $loggedOutUserResponse = getJson('/api/user', $headers);
    $loggedOutUserResponse->assertUnauthorized();
});
bomas's avatar

I‘m wondering why this less amount of people facing this problem and why this isn‘t addressed and/ or changed already. I mean the initial post is more than 2 years old now.

Anyways, my idea was to either mock the method somehow or simply swap the RequestGuard using the service container. Unfortunately I‘m not able to make it work. Simply adding a line like

    $this->app->bind(RequestGuard::class, \Tests\Fakes\RequestGuard::class);

To either the test or the AppServiceContainer does not do the trick. (The FakeRequestGuard simply has a comment on the line where it returns the already previously received user in case the class var user is not null)

So with that said - what‘s next? How can I proceed? Any ideas?

By the way this also effects testing token expiration. Routes that are secured by sanctum can be requested even after traveling far beyond the expiration time of a token. Even without adding the token to the request it still works within one unit test.

AlexMagne's avatar

Maybe this would help, I had the same problem yesterday so I tried this, and worked.

For more context I'm using the RefreshDatabase trait so that is why I create an user.

public function testRouteLogoutInvalidatesToken()
    {
        $testUser = User::factory()->create([
            'email' => '[email protected]',
            'password' => Hash::make('password')
        ]);

        $token = $testUser->createToken('Authentification Token')->plainTextToken;

        $testResponse = $this
            ->withHeader('Authorization', "Bearer $token")
            ->postJson(route('auth.logout'));

        $testResponse
            ->assertOk()
            ->assertJson([
                'message' => 'You have been successfully logged out'
            ]);

        self::assertCount(0, $testUser->tokens);
    }
1 like
BlueC's avatar

@EQuimper boom! this is what you're looking for people...

Auth::guard('sanctum')->forgetUser();
1 like
rafaelmm8999's avatar

@BlueC I saw your suggestion about using Auth::guard('sanctum')->forgetUser() to resolve issues with Sanctum logout tests. I’m curious to know where exactly this line should be implemented.

Should it be placed inside the logout action (like in the controller, service, or action class) or directly within the test case itself?

alazark's avatar

I believe you could check the personal_access_tokens table and assert that no tokens are there after the user has logged out.

public function test_a_user_can_log_out(): void
    {
        $user = User::factory()->create();

        $token = $user->createToken('tdd_token');

        $response = $this->postJson('/api/' . env('API_VERSION') . '/auth/logout', headers: [
            'Authorization' => 'Bearer ' . $token->plainTextToken
        ]);

        $response->assertNoContent();

        $this->assertDatabaseCount('personal_access_tokens', 0);
    }

I used the previous code.

Please or to participate in this conversation.