使用 HTTP 测试测试 Laravel 中间件

发布于 作者:

Testing Laravel Middleware with HTTP Tests image

在这篇文章中,我想演示一个使用 HTTP 测试测试中间件的实际示例。在 HTTP 层面进行测试可以使您的测试更具弹性,更易于阅读。

在最近的一期 Full Stack Radio(#72)中,Adam Wathan 和 Taylor Otwell 发现 HTTP 测试具有很多实际价值,这让我耳目一新。我发现 HTTP 测试更容易编写和维护,但我确实感觉自己是在“错误地进行测试”™,或者是在通过不模拟和隔离所有内容来作弊。如果您还没有听过这期节目,请听一下,它充满了良好的、实用的测试建议。

介绍

今年早些时候,我在其中一个项目中构建了一个用于验证和保护 Mailgun Webhook 的中间件,并在 Laravel 新闻上发表了有关 Laravel 中的电子邮件入站处理 的文章。简而言之,我演示了如何使用 Laravel 中间件验证 Mailgun Webhook(以确保 webhook 确实 来自 Mailgun),同时处理入站电子邮件。

在设置 Mailgun Webhook 的核心,建议您通过使用请求中提供的签名、时间戳和令牌验证 HTTP POST 负载中包含的签名来保护您的 Webhook。以下是来自我文章的完整中间件

<?php
 
namespace App\Http\Middleware;
 
use Closure;
use Illuminate\Http\Response;
 
class ValidateMailgunWebhook
{
public function handle($request, Closure $next)
{
if (!$request->isMethod('post')) {
abort(Response::HTTP_FORBIDDEN, 'Only POST requests are allowed.');
}
 
if ($this->verify($request)) {
return $next($request);
}
 
abort(Response::HTTP_FORBIDDEN, 'The webhook signature was invalid.');
}
 
protected function buildSignature($request)
{
return hash_hmac(
'sha256',
sprintf('%s%s', $request->input('timestamp'), $request->input('token')),
config('services.mailgun.secret')
);
}
 
protected function verify($request)
{
if (abs(time() - $request->input('timestamp')) > 15) {
return false;
}
 
return $this->buildSignature($request) === $request->input('signature');
}
}

此中间件仅接受 POST 请求,并将传入的签名与使用 Mailgun 密钥作为密钥生成的签名进行比较。

我见过各种测试中间件的方法,例如在单元测试中直接构建它,根据需要模拟对象,以及直接运行中间件。在这篇文章中,我将向您展示如何使用更高级别的 HTTP 测试来测试此中间件。您的整个堆栈将在测试中运行,让您更有信心您的应用程序按预期工作。

您要理解的一个重要好处是,您的测试不直接绑定到特定的中间件实现。我们可以完全重构中间件,而无需更改任何测试或更新模拟来验证中间件是否正常工作。我相信您会发现这些测试非常健壮。

设置

让我们使用一个示例 Laravel 5.5 项目快速构建上述中间件的测试。

$ laravel new middleware-tests
 
# Change to the middleware-tests/ folder
$ cd $_
 
$ php artisan make:middleware ValidateMailgunWebhook

将上面的中间件代码复制并粘贴到此中间件文件中。

接下来,将此中间件添加到 app/Http/Kernel.php 文件中

protected $routeMiddleware = [
// ...
'mailgun.webhook' => \App\Http\Middleware\ValidateMailgunWebhook::class,
];

编写 HTTP 测试

我们准备针对此中间件编写一些测试,我们甚至不需要在 routes/api.php 中定义任何路由来测试它!

首先,让我们创建功能测试文件

$ php artisan make:test SecureMailgunWebhookTest

查看 Mailgun 中间件,以下是我们要测试的内容,以确保中间件按预期工作

  1. 任何除 POST 之外的 HTTP 动词都应该导致 403 Forbidden 响应。
  2. 无效签名应该产生 403 Forbidden 响应。
  3. 有效签名应该通过并命中路由可调用对象。
  4. 旧时间戳应该导致 403 Forbidden 响应。

测试无效 HTTP 方法

有了这些介绍,让我们编写第一个测试并设置我们的测试。

使用以下内容更新 SecureMailgunWebhookTest 文件

<?php
 
namespace Tests\Feature;
 
use Tests\TestCase;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
 
class SecureMailgunWebhookTest extends TestCase
{
protected function setUp()
{
parent::setUp();
 
config()->set('services.mailgun.secret', 'secret');
 
\Route::middleware('mailgun.webhook')->any('/_test/webhook', function () {
return 'OK';
});
}
 
/** @test */
public function it_forbids_non_post_methods()
{
$this->withoutExceptionHandling();
 
$exceptionCount = 0;
$httpVerbs = ['get', 'put', 'patch', 'delete'];
 
foreach ($httpVerbs as $httpVerb) {
try {
$response = $this->$httpVerb('/_test/webhook');
} catch (HttpException $e) {
$exceptionCount++;
$this->assertEquals(Response::HTTP_FORBIDDEN, $e->getStatusCode());
$this->assertEquals('Only POST requests are allowed.', $e->getMessage());
}
}
 
if (count($httpVerbs) === $exceptionCount) {
return;
}
 
$this->fail('Expected a 403 forbidden');
}
}

setUp() 方法中,我们定义了一个假的 Mailgun 密钥,以便我们可以针对它编写测试,然后使用 any() 路由方法定义一个通配符路由。我们的路由将允许我们使用假的测试路由来使用中间件发出 HTTP 请求。

Laravel 5.5 引入了 withoutExceptionHandling() 方法,这意味着我们将在测试中获得抛出的异常,而不是代表异常的 HTTP 响应。

try/catch 将确保我们捕获每个 HTTP 动词的 HttpException,然后增加捕获异常计数器。如果捕获异常的数量与测试的 HTTP 动词数量匹配,则测试通过。否则,如果我们的请求没有引起异常,则调用 $this->fail() 方法。

我喜欢捕获和断言异常的方法,而不是使用注释。对我来说,这感觉更清晰,我还可以对异常进行断言,以确保异常是我预期的。

您可以使用以下 PhpUnit 命令直接运行中间件功能测试

# Run all tests in the file
$ ./vendor/bin/phpunit tests/Feature/SecureMailgunWebhookTest.php
 
# Filter a specific method
$ ./vendor/bin/phpunit \
tests/Feature/SecureMailgunWebhookTest.php \
--filter=it_forbids_non_post_methods

测试无效签名

下一个测试验证无效签名会导致 403 Forbidden 错误。此测试与第一个测试不同,因为它使用 POST 方法,但发送无效的请求数据

/** @test */
public function it_aborts_with_an_invalid_signature()
{
$this->withoutExceptionHandling();
 
try {
$this->post('/_test/webhook', [
'timestamp' => abs(time() - 100),
'token' => 'invalid-token',
'signature' => 'invalid-signature',
]);
} catch (HttpException $e) {
$this->assertEquals(Response::HTTP_FORBIDDEN, $e->getStatusCode());
$this->assertEquals('The webhook signature was invalid.', $e->getMessage());
return;
}
 
$this->fail('Expected the webhook signature to be invalid.');
}

我们传递会导致无效签名的假数据,然后断言正确的响应状态和消息已设置在 HttpException 中。

测试有效签名

当 Webhook 发送有效签名时,路由将处理响应,而不会使中间件中止。如果签名匹配,中间件将调用 verify(),然后调用 $next()

if ($this->verify($request)) {
return $next($request);
}

为了编写此测试,我们需要发送有效的签名、时间戳和令牌。我们将在测试类中构建我们自己的 SHA-256 哈希版本,它几乎是中间件中相同方法的复制。中间件和我们的测试都将使用我们在 setUp() 方法中配置的 services.mailgun.secret 密钥

/** @test */
public function it_passes_with_a_valid_signature()
{
$this->withoutExceptionHandling();
 
$timestamp = time();
$token = 'token';
$response = $this->post('/_test/webhook', [
'timestamp' => $timestamp,
'token' => $token,
'signature' => $this->buildSignature($timestamp, $token),
]);
 
$this->assertEquals('OK', $response->getContent());
}
 
protected function buildSignature($timestamp, $token)
{
return hash_hmac(
'sha256',
sprintf('%s%s', $timestamp, $token),
config('services.mailgun.secret')
);
}

我们的测试使用中间件中的相同代码构建签名,因此我们可以生成中间件期望的有效签名。在测试结束时,我们断言返回的响应内容等于“OK”,这就是我们在测试路由中返回的内容。

测试使用旧时间戳失败

我们的中间件采取的另一项预防措施是不允许请求在 timestamp 有效期过长的情况下继续。此测试类似于我们其他测试断言失败,但这次我们让所有内容(签名和令牌)都有效除了 时间戳

/** @test */
public function it_fails_with_an_old_timestamp()
{
try {
$this->withoutExceptionHandling();
 
$timestamp = abs(time() - 16);
$token = 'token';
$response = $this->post('/_test/webhook', [
'timestamp' => $timestamp,
'token' => $token,
'signature' => $this->buildSignature($timestamp, $token),
]);
} catch (HttpException $e) {
$this->assertEquals(Response::HTTP_FORBIDDEN, $e->getStatusCode());
$this->assertEquals('The webhook signature was invalid.', $e->getMessage());
return;
}
 
$this->fail('The timestamp should have failed verification.');
}

请注意 $timestamp = abs(time() - 16);,这将使中间件的时间戳比较无效。

了解更多

这只是对在 HTTP 层面测试中间件的快速概述。我更喜欢这种级别的测试,因为在中间件上使用模拟可能很乏味,并且绑定到特定实现。如果我选择以后重构,我的测试很可能需要重写以匹配新的中间件。使用 HTTP 测试,我可以自由地重构中间件,并应该期望得到相同的结果。

在 Laravel 中编写 HTTP 测试 非常容易和方便,我发现自己在越来越多的情况下使用这种级别的测试。我认为我编写的测试很容易理解,因为我们没有模拟任何东西。您应该熟悉通过 Laravel 可用于测试套件的断言。这些工具使您的测试工作更容易,我敢说,更有趣。

如果您是测试新手,我们也在 Laravel News 上回顾了 Test Driven Laravel。我个人已经完成了这门课程;如果您刚开始测试 Web 应用程序,它是一个非常棒的资源。

Paul Redmond photo

Laravel News 的撰稿人。全栈 Web 开发人员和作家。

Cube

Laravel 时事通讯

加入 40,000 多名其他开发者,绝不错过新的技巧、教程等。

Laravel Forge logo

Laravel Forge

轻松创建和管理您的服务器,并在几秒钟内部署您的 Laravel 应用程序。

Laravel Forge
Tinkerwell logo

Tinkerwell

Laravel 开发人员必备的代码运行器。使用 AI、自动完成和本地和生产环境的即时反馈进行 Tinker。

Tinkerwell
No Compromises logo

无妥协

Joel 和 Aaron,来自 No Compromises 播客的两位经验丰富的开发者,现在可以为您的 Laravel 项目聘用。 ⬧ 每月 7500 美元的固定费用。 ⬧ 没有冗长的销售流程。 ⬧ 没有合同。 ⬧ 100% 退款保证。

无妥协
Kirschbaum logo

Kirschbaum

提供创新和稳定性,以确保您的 Web 应用程序成功。

Kirschbaum
Shift logo

Shift

运行旧的 Laravel 版本?即时、自动化的 Laravel 升级和代码现代化,使您的应用程序保持新鲜。

Shift
Bacancy logo

Bacancy

只需每月 2500 美元,即可使用经验丰富的 Laravel 开发人员(拥有 4-6 年的经验)为您的项目增光添彩。获得 160 小时的专项专业知识和 15 天的无风险试用。立即安排通话!

Bacancy
Lucky Media logo

Lucky Media

现在就试试 Lucky - Laravel 开发的理想选择,拥有超过十年的经验!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel 电子商务

Laravel 的电子商务。一个开源软件包,将现代无头电子商务功能的力量带入 Laravel。

Lunar: Laravel 电子商务
LaraJobs logo

LaraJobs

官方 Laravel 职位信息板

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS 启动工具包

SaaSykit 是一个 Laravel SaaS 启动工具包,它包含运行现代 SaaS 所需的所有功能。支付、漂亮结帐、管理面板、用户仪表板、身份验证、现成组件、统计信息、博客、文档等。

SaaSykit: Laravel SaaS 启动工具包
Rector logo

Rector

您无缝升级 Laravel 的合作伙伴,降低成本,并为成功的公司加速创新

Rector
MongoDB logo

MongoDB

通过 MongoDB 和 Laravel 的强大集成来增强您的 PHP 应用程序,使开发人员能够轻松高效地构建应用程序。支持事务、搜索、分析和移动用例,同时使用熟悉的 Eloquent API。探索 MongoDB 的灵活、现代数据库如何改变您的 Laravel 应用程序。

MongoDB
Maska is a Simple Zero-dependency Input Mask Library image

Maska 是一个简单的零依赖输入掩码库

阅读文章
Add Swagger UI to Your Laravel Application image

将 Swagger UI 添加到您的 Laravel 应用程序

阅读文章
Assert the Exact JSON Structure of a Response in Laravel 11.19 image

在 Laravel 11.19 中断言响应的精确 JSON 结构

阅读文章
Build SSH Apps with PHP and Laravel Prompts image

使用 PHP 和 Laravel 提示构建 SSH 应用程序

阅读文章
Building fast, fuzzy site search with Laravel and Typesense image

使用 Laravel 和 Typesense 构建快速、模糊的网站搜索

阅读文章
Add Comments to your Laravel Application with the Commenter Package image

使用 Commenter 软件包在您的 Laravel 应用程序中添加评论

阅读文章