【Laravel, Laravel Passport, PHPUnit】サブリクエストで envの向き先が .env.testing から .env になってしまう問題
一旦枠だけ作成。
次回業務時に詳細を記載する予定だが、概要は以下。
- ログインのAPIがある。
- ログイン機能は、Laravel Passportを使って実装している。
- そのログインAPIの中では OAuthトークンを使用するため、
GuzzleHttp/Client
を使って更にリクエストを投げる仕組みとなっている。
-
phpunit.xml
にて、APP_ENV
はtesting
を向くように設定している。- 実際、Guzzleを使ってリクエストを投げる直前と直後に関しては、
testing
を向いている。 - しかし、リクエストを投げた後の Laravel Passport 側では、envは
local
を向いている。 - そのため、事前に用意したユーザーやOAuth Client を使ってトークンを発行できず、テストがエラーになる。
- 事前に用意しているデータは
testing
用のDBにあって、local
用のDBには存在しないため。
- 事前に用意しているデータは
- 実際、Guzzleを使ってリクエストを投げる直前と直後に関しては、
- 試しに
local
が参照するDBと同じ OAuth Client と同じ ユーザーを事前に作成してみると、成功した。
インストールされている、関係しそうなライブラリのバージョンは以下。
尚、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を実行する直前と直後それぞれログを出力させるように修正してみた。
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
でも、ログを出力してみる。
/**
* 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)
);
});
}
すると、以下の結果がログとして出力された。
[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に切り替わっている。
.env
の APP_ENV
を local
から testing
に変更してみると、以下の結果が返ってきた。
[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
を読む仕様になっていると予想した。
どうにかして、.env.testing
を読み込ませるようにできないか、検証してみた。
検証
tests/bootstrap.php
を作成し、APP_ENV=testing
を読み込ませた
Case 1. <?php
require __DIR__ . '/../vendor/autoload.php';
putenv('APP_ENV=testing');
<?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
自体をロードしていないんだと思う。
APP_ENV=testing
を読み込ませた
Case 2. テスト実行するファイルで、 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');
});
}
結果として、この「実行されたよ」のログが出力されなかった。
そのため、モック化には失敗。
サブリクエストのときは.env
を参照してしまうという問題の解消法については、現在分かっていない。
今ふと思いついているというか、要調査な方法が一つある。
それは、サブリクエストで oauth/token
に問い合わせをしているが、use して laravel/passportのライブラリを直接使用する方法を取れないか?というもの。
実際、oauth_clients
は同じDBに保存している情報のため、わざわざ自身のAPIに再リクエストをする必要がなさそう。
調査を進める。
(既に解消していたのに、スクラップに記載することを怠っていました...🙇♂)
こちらの問題、無事解消しました。
結論
「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トークン発行用に別でサーバーを立てている場合は、この手段は通用しないのでお気をつけください(未検証)