勉強日記

チラ裏

【LINE@ Laravel】 コールバックリクエストをモックして、LINEサーバと切り離してテストする


  • LINEのBotを作るにあたり、テストで躓いたのでメモ

環境

  • laravel/framework 5.8.*
  • linecorp/line-bot-sdk ^3.10

LINE Botのおおまかなしくみ

公式資料

  1. エンドユーザがLINE Botにメッセージを送る
  2. LINEのサーバーが自システムのコールバックURLをPOSTで叩く
  3. 自システムは、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の両方でサポートされています。

定数は上書きできないためうまくテストできない。 そんなときはテストを別プロセスにする

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サーバーを切り離してテストする」という目標は達成できたので深追いしないことにした

検索用