【LINE@ Laravel】 コールバックリクエストをモックして、LINEサーバと切り離してテストする
- LINEのBotを作るにあたり、テストで躓いたのでメモ
環境
LINE Botのおおまかなしくみ
- エンドユーザがLINE Botにメッセージを送る
- LINEのサーバーが自システムのコールバックURLをPOSTで叩く
- 自システムは、POSTリクエストをパースしてなんやかんやする(メッセージ返信とか)
<?php namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use LINE\LINEBot; use LINE\LINEBot\Event\BaseEvent; use LINE\LINEBot\Constant\HTTPHeader; class LineSignature { /** @var LINEBot */ private $lineBot; public function __construct(LINEBot $lineBot) { $this->lineBot = $lineBot; } /** * Handle an incoming request. * * @param \Illuminate\Http\Request $request * @param \Closure $next * @return mixed */ public function handle(Request $request, Closure $next) { /** * リクエストヘッダから署名取得 * @var string */ $signature = $request->header(HTTPHeader::LINE_SIGNATURE); /** * リクエストボディをパースしてLINEのイベント構築 * 署名検証で例外が投げられることあり * @var BaseEvent[] * @throws LINEBot\Exception\InvalidEventRequestException * @throws LINEBot\Exception\InvalidSignatureException */ $events = $this->lineBot->parseEventRequest( $request->getContent(), $signature ); /** * リクエストにLINEのEventオブジェクトを乗せ、Controllerから利用可能に */ $request->merge(['line-events' => $events]); return $next($request); } }
テストしたい
- 外接系(LINEのサーバー)から切り離してテストしたくなるのが人情というもの
- コールバックURLに対して、
postJson
ヘルパメソッドを使ってテストする
<?php namespace Tests\Feature; use Tests\TestCase; class LineBotHelloTest extends TestCase { /** * @test * @dataProvider dataProvider_sampleRequest */ public function api_postすると_Hello_Worldが返答される(array $body, array $header) { $response = $this->postJson('/api', $body, $header); $response->assertStatus(200); } ... // ---------------------------------------- public function dataProvider_sampleRequest() { return [ 'message-event' => [ // body [ 'events' => [ [ 'type' => 'message', 'replyToken' => 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', 'source' => [ 'userId' => 'yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy', 'type' => 'user', ], 'timestamp' => 1556530304434, 'message' => [ 'type' => 'text', 'id' => '111111111111', 'text' => 'hi', ], ], ], 'destination' => 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', ], // header [ 'x-line-signature' => [ 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz', ] ] ] ]; } ...
署名検証部分でエラー出る
PHPUnit 7.5.9 by Sebastian Bergmann and contributors. ..FF 4 / 4 (100%) Time: 149 ms, Memory: 18.00 MB There were 2 failures: 1) Tests\Feature\LineBotHelloTest::api_postすると_200返る with data set "message-event" (array(array(array('message', 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', array('yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy', 'user'), 1556530304434, array('text', '111111111111', 'hi'))), 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'), array(array('zzzzzzzzzzzzzzzzzzzzzzzzzzzzz...zzzzzz'))) Expected status code 200 but received 500. Failed asserting that false is true. ...
- エラーログ
[2019-05-13 11:48:34] testing.ERROR: Invalid signature has given {"exception":"[object] (LINE\\LINEBot\\Exception\\InvalidSignatureException(code: 0): Invalid signature has given at /var/www/vendor/linecorp/line-bot-sdk/src/LINEBot/Event/Parser/EventRequestParser.php:68)
Invalid signature has given
とのこと- リクエストのパース時に署名検証を行っており、下記のようなテキトウ署名では通らないのである
<?php ... // header [ 'x-line-signature' => [ 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz', ] ]
署名検証部分をモックする
- 署名を発行してリクエストヘッダに乗せるのはLINEサーバー
- LINE公式ライブラリを使用しているので、署名検証部をことさらにテストする必要はない
- ので、署名検証部をバイパスしてテストを実施する
- コードを読んで、署名を検証しているクラスを探す
vendor/linecorp/line-bot-sdk/src/LINEBot/Event/Parser/EventRequestParser.php
<?php ... /** * @param string $body * @param string $channelSecret * @param string $signature * @return mixed * @throws InvalidEventRequestException * @throws InvalidSignatureException */ public static function parseEventRequest($body, $channelSecret, $signature, $eventsOnly = true) { if (!isset($signature)) { throw new InvalidSignatureException('Request does not contain signature'); } if (!SignatureValidator::validateSignature($body, $channelSecret, $signature)) { throw new InvalidSignatureException('Invalid signature has given'); } ...
SignatureValidator::validateSignature()
がfalse
を返しているor例外を送出しているためエラーが出ている
<?php ... if (!SignatureValidator::validateSignature($body, $channelSecret, $signature)) { throw new InvalidSignatureException('Invalid signature has given'); } ...
SignatureValidator::validateSignature()
をモックして、常にtrue
が返るようにすれば、 署名検証をバイパスしてテストできるようになる
<?php ... /** * A validator class of signature. * * @package LINE\LINEBot */ class SignatureValidator { /** * Validate request with signature. * * @param string $body Request body. * @param string $channelSecret Your channel secret. * @param string $signature Signature (probably retrieve from HTTP header). * @return bool Request is valid or not. * @throws InvalidSignatureException When empty signature is given. */ public static function validateSignature($body, $channelSecret, $signature) { if (!isset($signature)) { throw new InvalidSignatureException('Signature must not be empty'); } return hash_equals(base64_encode(hash_hmac('sha256', $body, $channelSecret, true)), $signature); } }
SignatureValidator
は単一のstaticメソッドのみを持つ- ので、「他のメソッドはモックしたくないんだけど…」というような悩みはない。よかった
- staticメソッドをモックするには、
Mockery::mock()
するとき、 クラス名にalias:
をつければよい
<?php namespace Tests\Feature; use Tests\TestCase; use Mockery; class LineBotHelloTest extends TestCase { protected function setUp() :void { parent::setUp(); $validatorMock = Mockery::mock('alias:LINE\LINEBot\SignatureValidator'); $validatorMock->shouldReceive('validateSignature')->andReturn(true); } protected function tearDown() :void { parent::tearDown(); Mockery::close(); } /** * @test * @dataProvider dataProvider_sampleRequest */ public function api_postすると_200返る(array $body, array $header) { $response = $this->postJson('/api', $body, $header); $response->assertStatus(200); } ...
- 無事、署名検証をバイパスしてテストできるようになった
PHPUnit 7.5.9 by Sebastian Bergmann and contributors. .... 4 / 4 (100%) Time: 415 ms, Memory: 16.00 MB OK (4 tests, 17 assertions)
課題
注意:同じエイリアス/インスタンスモックを一つ以上のテストで使用すると、同じ名前で複数のクラスは持てないため、Fatalエラーが発生します。これを避けるには、この種のテストはそれぞれ別のPHPプロセスで実行してください。PHPUnitやPHPTの両方でサポートされています。
定数は上書きできないためうまくテストできない。 そんなときはテストを別プロセスにする
@runInSeparateProcess
アノテーションを試したところ、下記エラーが出るように
Class 'Route' not found in /var/www/routes/api.php:16
When trying to run a test that uses the @runInSeparateProcess anotation from phpunit. Facades won't load correctly. This only happens if it's not the first test to run.
vendor/bin/phpunit
にパスを通してみてもダメだった- 「LINEサーバーを切り離してテストする」という目標は達成できたので深追いしないことにした