Unit testing stripe webhook
I am trying to avoid using any auto mocking library. And instead trying to rely on Fakes when writing unit tests. There is an alternative approach to writing unit test using mock available elsewhere.
When it comes to creating a stripe webhook controller on asp.net MVC, we need to verify that the request is infact coming from stripe using Stripe-Signature
value.
[Route("stripe_webhook")]
public class StripeWebHookController : Controller
{
[HttpPost]
public async Task<IActionResult> Index()
{
var json = await new StreamReader(Request.Body)
.ReadToEndAsync();
try
{
var signatureHeader = Request.Headers["Stripe-Signature"];
var stripeEvent = EventUtility.ConstructEvent(
json,
signatureHeader,
"whsec_xxxx");
switch (stripeEvent.Type)
{
case Events.CustomerSubscriptionTrialWillEnd:
{
await new EndOfTrialEmail(emailClient)
.SendAsync(stripeEvent);
break;
}
return Ok();
}
}
catch(StripeException e)
{
return BadRequest();
}
}
}
If we were to write a unit test against for this action, it might look like…
public class StripeWebHookControllerTests
{
[Fact]
public async Task Customer_subscription_trial_will_expire_sends_out_an_email()
{
var emailClient = new FakeEmailClient();
var sut = new StripeWebHookController(emailClient, ...);
var result = await sut.Index();
result.Should().BeOfType<OkResult>();
var expected = new MailMessage(
"noreply@example.org",
"user@example.com",
"Your trial ends soon",
null);
emailClient.Emails.Should().ContainEquivalentOf(
expected,
opt => opt.Including(x => x.From)
.Including(x => x.To)
.Including(x => x.Subject));
}
}
I am using FluentAssertions (v6.11.9) library for my assertion on the email that got send out.
This test is going to fail because, the Request
is null. And we haven’t got a value for Stripe-Signature
header.
var sut = new StripeWebHookController(emailClient, ...)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
Request =
{
Headers = { ["Stripe-Signature"] = string.Empty }
}
}
}
}
The test will now start failing because the EventUtility.ConstructEvent
method won’t be able to verify the signature. What value do we provide to Stripe-Signature
header for the verification to pass?
According to stripe nodejs repository, there is a stripe.webhooks.generateTestHeaderString
method available in the library. There isn’t one as far as I can tell with the Stripe.net library.
Looking into how the validation logic is implemented, Stripe signature is computed using request body, and a unix timestamp value. The header value has the format t={timestamp},v1={signature}
. v1
is represent the schema.
This ComputeSignature
method is taken straight from stripe-dotnet repository.
private static string ComputeSignature(
string secret,
string timestamp,
string payload)
{
var secretBytes = Encoding.UTF8.GetBytes(secret);
var payloadBytes = Encoding.UTF8.GetBytes($"{timestamp}.{payload}");
using var cryptographer = new HMACSHA256(secretBytes);
var hash = cryptographer.ComputeHash(payloadBytes);
return BitConverter.ToString(hash)
.Replace("-", string.Empty).ToLowerInvariant();
}
With the ComputeSignature
method in place, let’s modify our test to create Stripe-Signature
header.
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
.ToString();
var signature = ComputeSignature("whsec_xxx", timestamp, "{}");
...
Request.Headers = { ["Stripe-Signature"] = $"t={timestamp},v1={signature}" }
The timestamp
needs to be close to the current time because there is threshold in the validation logic.
The next exception will be on Parsing the request body. It needs to look like a Stripe Event
. And also match the ApiVersion
specified with StripeConfiguration.ApiVersion
or default version included with the library.
StripeConfiguration.ApiVersion = "2022-11-15";
var utf8Json = new MemoryStream();
await JsonSerializer.SerializeAsync(utf8Json,
new
{
type = Events.CustomerSubscriptionTrialWillEnd,
api_version = "2022-11-15",
data = new
{
@object = new
{
@object = "subscription",
customer = "cus_123"
}
},
request = new EventRequest()
});
utf8Json.Position = 0;
...
var signature = ComputeSignature("whsec_xxx", timestamp,
Encoding.UTF8.GetString(utf8Json.ToArray()));
...
Request.Body = utf8Json
And with that, your test should now start passing.