- LINEのBotを作るにあたり、テストで躓いたのでメモ
環境
- laravel/framework 5.8.*
- linecorp/line-bot-sdk ^3.10
LINE Botのおおまかなしくみ
- エンドユーザがLINE Botにメッセージを送る
- LINEのサーバーが自システムのコールバックURLをPOSTで叩く
-
自システムは、POSTリクエストをパースしてなんやかんやする(メッセージ返信とか)
- 公式のライブラリが配布されている
- リクエストをパースする部分は、ライブラリに委ねることにした
- LaravelのMiddleware層でリクエストのパースを行い、
Request
オブジェクトにマージする例
<?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
@runInSeparateProcess
アノテーションを使用するとファサードが壊れるようだ??- たぶんこれと同じ現象
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サーバーを切り離してテストする」という目標は達成できたので深追いしないことにした
検索用
- LINE API
- LINE Messaging API