Closed5

【Laravel, Laravel Passport, PHPUnit】サブリクエストで envの向き先が .env.testing から .env になってしまう問題

TatsuyaaaTatsuyaaa

一旦枠だけ作成。

次回業務時に詳細を記載する予定だが、概要は以下。

  • ログインのAPIがある。
    • ログイン機能は、Laravel Passportを使って実装している。
    • そのログインAPIの中では OAuthトークンを使用するため、 GuzzleHttp/Client を使って更にリクエストを投げる仕組みとなっている。
  • phpunit.xml にて、APP_ENVtesting を向くように設定している。
    • 実際、Guzzleを使ってリクエストを投げる直前と直後に関しては、 testing を向いている。
    • しかし、リクエストを投げた後の Laravel Passport 側では、envは local を向いている。
    • そのため、事前に用意したユーザーやOAuth Client を使ってトークンを発行できず、テストがエラーになる。
      • 事前に用意しているデータは testing 用のDBにあって、 local 用のDBには存在しないため。
  • 試しに local が参照するDBと同じ OAuth Client と同じ ユーザーを事前に作成してみると、成功した。
TatsuyaaaTatsuyaaa

インストールされている、関係しそうなライブラリのバージョンは以下。
尚、PHPは 8.1

laravel/framework                       v9.52.4         The Laravel Framework.
laravel/passport                        v11.8.3         Laravel Passport provides OAuth2 server support to Laravel.
guzzlehttp/guzzle                       7.5.0           Guzzle is a PHP HTTP client library
guzzlehttp/promises                     1.5.2           Guzzle promises library
guzzlehttp/psr7                         2.4.4           PSR-7 message implementation that also provides common utility methods
guzzlehttp/uri-template                 v1.0.1          A polyfill class for uri_template of PHP

ログを仕込んで、どのタイミングで切り替わっているかを確認した。

調査して見る感じ、GuzzleHttp/Client がリクエストを投げているクラスは、 \GuzzleHttp\Handler\CurlHandler と思われるため、ここでcurlを実行する直前と直後それぞれログを出力させるように修正してみた。

\GuzzleHttp\Handler\CurlHandler
    public function __invoke(RequestInterface $request, array $options): PromiseInterface
    {
        if (isset($options['delay'])) {
            \usleep($options['delay'] * 1000);
        }

        $easy = $this->factory->create($request, $options);
        Log::debug('curl before: ', [getenv('APP_ENV')]);
        \curl_exec($easy->handle);
        Log::debug('curl after: ', [getenv('APP_ENV')]);
        $easy->errno = \curl_errno($easy->handle);

        return CurlFactory::finish($this, $easy, $this->factory);
    }

そして、リクエストを受け取る \Laravel\Passport\Http\Controllers\AccessController でも、ログを出力してみる。

\Laravel\Passport\Http\Controllers\AccessController
    /**
     * Authorize a client to access the user's account.
     *
     * @param  \Psr\Http\Message\ServerRequestInterface  $request
     * @return \Illuminate\Http\Response
     */
    public function issueToken(ServerRequestInterface $request)
    {
        Log::debug('AccessTokenController: IssueToken', [getenv('APP_ENV')]);
        return $this->withErrorHandling(function () use ($request) {
            return $this->convertResponse(
                $this->server->respondToAccessTokenRequest($request, new Psr7Response)
            );
        });
    }

すると、以下の結果がログとして出力された。

testing.log
[2023-12-25 09:56:20] testing.DEBUG: curl before:  ["testing"] 
[2023-12-25 09:56:20] local.DEBUG: AccessTokenController: IssueToken ["local"] 
[2023-12-25 09:56:21] testing.DEBUG: curl after:  ["testing"] 

サブリクエストを送ったタイミングで、localに切り替わっている。

.envAPP_ENVlocal から testing に変更してみると、以下の結果が返ってきた。

testing.log
[2023-12-25 10:08:11] testing.DEBUG: curl before:  ["testing"] 
[2023-12-25 10:08:11] testing.DEBUG: AccessTokenController: IssueToken ["testing"] 
[2023-12-25 10:08:12] testing.DEBUG: curl after:  ["testing"] 

この結果から、サブリクエストのタイミングで phpunit.xml で設定していた APP_ENV の設定を読まず、 .env を読む仕様になっていると予想した。

TatsuyaaaTatsuyaaa

どうにかして、.env.testing を読み込ませるようにできないか、検証してみた。

検証

Case 1. tests/bootstrap.php を作成し、APP_ENV=testing を読み込ませた

bootstrap.php
<?php

require __DIR__ . '/../vendor/autoload.php';

putenv('APP_ENV=testing');
phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>

<!-- bootstrapファイルを読み込ませている -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="tests/bootstrap.php"
         colors="true"
>
    <testsuites>
        <testsuite name="Unit">
            <directory suffix="Test.php">./tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory suffix="Test.php">./tests/Feature</directory>
        </testsuite>
    </testsuites>
    <coverage processUncoveredFiles="true">
        <include>
            <directory suffix=".php">./app</directory>
        </include>
    </coverage>
    <php>
        <env name="APP_ENV" value="testing" force="true"/>
        <env name="BCRYPT_ROUNDS" value="4"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="MAIL_MAILER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="TELESCOPE_ENABLED" value="false"/>
        <ini name="memory_limit" value="1G"/>
    </php>
</phpunit>

bootstrap.php 内に putenv で上書きさせる処理を追加してみた。
が、失敗。

失敗理由としては、サブリクエストのときは phpunit.xml 自体をロードしていないんだと思う。

Case 2. テスト実行するファイルで、 APP_ENV=testing を読み込ませた

テスト実行ファイル
protected function setUp(): void
{
    parent::setUp();

    putenv('APP_ENV=testing');

    $this->app->loadEnvironmentFrom('.env.testing');
}

これも意味無し。

ここでの環境変数変更が適用されるのは、リクエスト元であるテスト実行ファイルと、リクエスト先のファイルのみ。
リクエスト先の更にリクエスト先に関しては、別軸の世界?になっているのか、envは適用されていない。

Case 3. サブリクエスト先のクラスのモック化を試みた

「そもそもモック化できるのか?」を検証するために、以下のモック化を試してみた。

テスト実行ファイル
    protected function setUp(): void
    {
        parent::setUp();

        $access_token_controller = $this->createPartialMock(AccessTokenController::class, ['issueToken']);
        $access_token_controller->method('issueToken')->willReturnCallback(function () {
            Log::debug('実行されたよ');
            putenv('APP_ENV=testing');
        });
    }

結果として、この「実行されたよ」のログが出力されなかった。
そのため、モック化には失敗。

TatsuyaaaTatsuyaaa

サブリクエストのときは.env を参照してしまうという問題の解消法については、現在分かっていない。

今ふと思いついているというか、要調査な方法が一つある。

それは、サブリクエストで oauth/token に問い合わせをしているが、use して laravel/passportのライブラリを直接使用する方法を取れないか?というもの。

実際、oauth_clients は同じDBに保存している情報のため、わざわざ自身のAPIに再リクエストをする必要がなさそう。

調査を進める。

TatsuyaaaTatsuyaaa

(既に解消していたのに、スクラップに記載することを怠っていました...🙇‍♂)

こちらの問題、無事解消しました。

結論

GuzzleHttp/Client を使ってサブリクエストを投げる」ことを止めて、擬似的にリクエストインスタンスを生成し、vendor内に存在するOAuth認証ライブラリにアクセストークンを発行してもらうことにしました。

詳細

以下のような処理を実装しました。

namespace App\Lib;

use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\ServerRequest;
use Illuminate\Contracts\Container\BindingResolutionException;
use Laravel\Passport\Passport;
use League\OAuth2\Server\AuthorizationServer;
use League\OAuth2\Server\Exception\OAuthServerException;
use League\OAuth2\Server\Grant\PasswordGrant;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
use League\OAuth2\Server\Repositories\UserRepositoryInterface;

class OAuthClient
{
    private AuthorizationServer $server;

    /**
     * @throws BindingResolutionException
     */
    public function __construct()
    {
        $this->server = app()->make(AuthorizationServer::class);

        $password_grant = new PasswordGrant(
            app()->make(UserRepositoryInterface::class),
            app()->make(RefreshTokenRepositoryInterface::class)
        );

        // AuthServiceProviderで設定されている値を使用
        $password_grant->setRefreshTokenTTL(Passport::refreshTokensExpireIn());

        // AuthorizationServerにPasswordGrantを設定し、アクセストークンの有効期限にAuthServiceProviderの設定を使用
        $this->server->enableGrantType($password_grant, Passport::tokensExpireIn());
    }

    /**
     * @param string $client_id `php artisan passport:client --password` で生成した ID
     * @param string $client_secret `php artisan passport:client --password` で生成した Key
     * @param string $username ログインするユーザーの username
     * @param string $password ログインするユーザーの password
     * @throws OAuthServerException
     * @throws \JsonException
     */
    public function issueToken(string $client_id, string $client_secret, string $username, string $password): array
    {
        // ServerRequest の uri は何でも良い。
        // 実際にリクエストを投げるわけではなく、リクエストインスタンスを生成しているだけ。
        $request = (new ServerRequest(method: 'POST', uri: 'xxxxxxxxx'))->withParsedBody([
            'grant_type' => 'password',
            'client_id' => $client_id,
            'client_secret' => $client_secret,
            'username' => $username,
            'password' => $password,
            'scope' => '*',
        ]);

        // PasswordGrant によるアクセストークン発行
        $access_token_response = $this->server->respondToAccessTokenRequest($request, new Response());
        $result = json_decode((string) $access_token_response->getBody(), true, 512, JSON_THROW_ON_ERROR);

        return ['refresh_token' => $result['refresh_token'], 'access_token' => $result['access_token']];
    }
}

この issueToken というメソッドを使って、アクセストークンを発行します。

元々は以下のようにOAuth認証APIにリクエストを投げていました。

$http_client = new \GuzzleHttp\Client();
$url = route('oauth.client');

$http_client->post($url, [
    'form_params' => [
        'grant_type' => 'password'
        'client_id' => $client_id
        'client_secret' => $client_secret
        'username' => $username
        'password' => $password
        'scope' => '*'
    ],
]);

上記は実際にAPIリクエスト(サブリクエスト)を投げて、トークンを取得しています。

結論部分で記載のとおりですが、それをすると "どの env を参照するか?" を制御できなくなるので、擬似的にリクエストを発行させて、内部的にトークンを取得させる方法にしています。

        // ServerRequest の uri は何でも良い。
        // 実際にリクエストを投げるわけではなく、リクエストインスタンスを生成しているだけ。
        $request = (new ServerRequest(method: 'POST', uri: 'xxxxxxxxx'))->withParsedBody([
            'grant_type' => 'password',
            'client_id' => $client_id,
            'client_secret' => $client_secret,
            'username' => $username,
            'password' => $password,
            'scope' => '*',
        ]);

        // PasswordGrant によるアクセストークン発行
        $access_token_response = $this->server->respondToAccessTokenRequest($request, new Response());

注意点

これが通用するのは、同じサーバー内でOAuthのトークンも発行している場合です。

もしOAuthトークン発行用に別でサーバーを立てている場合は、この手段は通用しないのでお気をつけください(未検証)

このスクラップは3ヶ月前にクローズされました