Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ public async Task<Result> Handle(HandleWebhookCommand request, CancellationToken
LogWebhookSignatureVerificationFailed(logger, ex);
return Result.Failure(ErrorMessages.InvalidWebhookSignature);
}
catch (System.Text.Json.JsonException ex)
{
LogWebhookSignatureVerificationFailed(logger, ex);
return Result.Failure(ErrorMessages.InvalidWebhookSignature);
}

LogStripeEventType(logger, stripeEvent.Type, stripeEvent.Id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Orbit.Application.Subscriptions.Services;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;
using Stripe;

namespace Orbit.Application.Tests.Commands.Subscriptions;

Expand Down Expand Up @@ -196,6 +197,57 @@ await _geoLocationService.DidNotReceive()
.GetCountryCodeAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>());
}

[Fact]
public async Task Handle_StripeCustomerCreationFails_ReturnsFailureAndDoesNotPersistPartialState()
{
var user = User.Create("Test", "test@example.com").Value;
SetupExistingUser(user);

_billingService.CreateCustomerAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new BillingProviderException(
"Failed to create customer", new StripeException("Stripe returned HTTP 500")));

var command = new CreateCheckoutCommand(UserId, "monthly", null, null);

var result = await _handler.Handle(command, CancellationToken.None);

result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("temporarily unavailable");
user.StripeCustomerId.Should().BeNull();
await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
}

[Theory]
[InlineData("http_500")]
[InlineData("network_timeout")]
[InlineData("malformed_response")]
public async Task Handle_StripeFailureDuringCheckout_FailsGracefullyWithoutThrowing(string scenario)
{
var user = User.Create("Test", "test@example.com").Value;
user.SetStripeCustomerId("cus_existing");
SetupExistingUser(user);

Exception inner = scenario switch
{
"http_500" => new StripeException("Stripe returned HTTP 500"),
"network_timeout" => new TaskCanceledException("The request to Stripe timed out"),
_ => new System.Text.Json.JsonException("Unexpected end of Stripe response body")
};

_billingService.CreateCheckoutSessionAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
Arg.Any<Guid>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new BillingProviderException("Failed to create checkout session", inner));

var command = new CreateCheckoutCommand(UserId, "monthly", null, null);

var result = await _handler.Handle(command, CancellationToken.None);

result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("temporarily unavailable");
}

private void SetupExistingUser(User user)
{
_userRepo.FindOneTrackedAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,186 @@ public async Task Handle_CheckoutSessionCompleted_YearlySubMissingPeriodEnd_Fall
user.PlanExpiresAt!.Value.Should().BeCloseTo(DateTime.UtcNow.AddYears(1), TimeSpan.FromMinutes(1));
}

[Fact]
public async Task Handle_SubscriptionCreated_IsSafeNoOp_SetupOwnedByCheckoutSession()
{
var (json, signature) = BuildSignedEvent("customer.subscription.created",
BuildSubscriptionJson("sub_new", "active"));

var result = await _handler.Handle(new HandleWebhookCommand(json, signature), CancellationToken.None);

result.IsSuccess.Should().BeTrue();
await _userRepo.DidNotReceive().FindOneTrackedIgnoringFiltersAsync(
Arg.Any<Expression<Func<User, bool>>>(), Arg.Any<CancellationToken>());
await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
}

[Fact]
public async Task Handle_InvoicePaymentFailed_DoesNotDowngradeProUser()
{
var user = User.Create("Thomas", "test@example.com").Value;
user.SetStripeSubscription("sub_test", DateTime.UtcNow.AddMonths(1));
user.IsPro.Should().BeTrue();

_userRepo.FindOneTrackedIgnoringFiltersAsync(
Arg.Any<Expression<Func<User, bool>>>(),
Arg.Any<CancellationToken>())
.Returns(user);

var (json, signature) = BuildSignedEvent("invoice.payment_failed", BuildInvoiceJson("sub_test"));

var result = await _handler.Handle(new HandleWebhookCommand(json, signature), CancellationToken.None);

result.IsSuccess.Should().BeTrue();
user.IsPro.Should().BeTrue();
user.StripeSubscriptionId.Should().Be("sub_test");
await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
}

[Fact]
public async Task Handle_CheckoutSessionCompleted_StripeApiErrorFetchingSubscription_ReturnsStripeApiFailure()
{
var user = User.Create("Thomas", "test@example.com").Value;
_userRepo.FindOneTrackedIgnoringFiltersAsync(
Arg.Any<Expression<Func<User, bool>>>(),
Arg.Any<CancellationToken>())
.Returns(user);

_subscriptionService.GetAsync("sub_test", Arg.Any<SubscriptionGetOptions>(),
Arg.Any<RequestOptions>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new StripeException("Stripe service is unavailable"));

var (json, signature) = BuildSignedEvent("checkout.session.completed", BuildCheckoutSessionJson(
UserId.ToString(), "sub_test", "cus_test"));

var result = await _handler.Handle(new HandleWebhookCommand(json, signature), CancellationToken.None);

result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("Stripe API");
user.IsPro.Should().BeFalse();
await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
}

[Fact]
public async Task Handle_SubscriptionFetchThrowsOperationCanceled_PropagatesNotSwallowedAsFailure()
{
var user = User.Create("Thomas", "test@example.com").Value;
_userRepo.FindOneTrackedIgnoringFiltersAsync(
Arg.Any<Expression<Func<User, bool>>>(),
Arg.Any<CancellationToken>())
.Returns(user);

_subscriptionService.GetAsync("sub_test", Arg.Any<SubscriptionGetOptions>(),
Arg.Any<RequestOptions>(), Arg.Any<CancellationToken>())
.ThrowsAsync(new OperationCanceledException());

var (json, signature) = BuildSignedEvent("checkout.session.completed", BuildCheckoutSessionJson(
UserId.ToString(), "sub_test", "cus_test"));

var act = () => _handler.Handle(new HandleWebhookCommand(json, signature), CancellationToken.None);

await act.Should().ThrowAsync<OperationCanceledException>();
}

[Fact]
public async Task Handle_MalformedJsonBody_RejectedGracefullyInsteadOfThrowing()
{
const string malformed = "{ not valid json";
var signature = SignPayload(malformed, DateTimeOffset.UtcNow.ToUnixTimeSeconds());

var result = await _handler.Handle(new HandleWebhookCommand(malformed, signature), CancellationToken.None);

result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("signature");
await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
}

[Fact]
public async Task Handle_EmptyBody_RejectedGracefullyInsteadOfThrowing()
{
var signature = SignPayload("", DateTimeOffset.UtcNow.ToUnixTimeSeconds());

var result = await _handler.Handle(new HandleWebhookCommand("", signature), CancellationToken.None);

result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("signature");
}

[Fact]
public async Task Handle_SubscriptionUpdated_DataObjectIsNotASubscription_SafeNoOp()
{
var (json, signature) = BuildSignedEvent("customer.subscription.updated",
"""{"id":"in_wrongtype","object":"invoice"}""");

var result = await _handler.Handle(new HandleWebhookCommand(json, signature), CancellationToken.None);

result.IsSuccess.Should().BeTrue();
await _userRepo.DidNotReceive().FindOneTrackedIgnoringFiltersAsync(
Arg.Any<Expression<Func<User, bool>>>(), Arg.Any<CancellationToken>());
await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
}

[Fact]
public async Task Handle_CheckoutSessionCompleted_SubscriptionWithNoItems_FallsBackToMonthlyOneMonth()
{
var user = User.Create("Thomas", "test@example.com").Value;
_userRepo.FindOneTrackedIgnoringFiltersAsync(
Arg.Any<Expression<Func<User, bool>>>(),
Arg.Any<CancellationToken>())
.Returns(user);

_subscriptionService.GetAsync("sub_noitems", Arg.Any<SubscriptionGetOptions>(),
Arg.Any<RequestOptions>(), Arg.Any<CancellationToken>())
.Returns(CreateSubscriptionWithoutItems("sub_noitems"));

var (json, signature) = BuildSignedEvent("checkout.session.completed", BuildCheckoutSessionJson(
UserId.ToString(), "sub_noitems", "cus_test"));

await _handler.Handle(new HandleWebhookCommand(json, signature), CancellationToken.None);

user.SubscriptionInterval.Should().Be(Orbit.Domain.Enums.SubscriptionInterval.Monthly);
user.PlanExpiresAt.Should().NotBeNull();
user.PlanExpiresAt!.Value.Should().BeCloseTo(DateTime.UtcNow.AddMonths(1), TimeSpan.FromMinutes(1));
}

[Fact]
public async Task Handle_CheckoutSessionCompleted_UnusualIntervalDefaultsToMonthlyButKeepsItemPeriodEnd()
{
var user = User.Create("Thomas", "test@example.com").Value;
_userRepo.FindOneTrackedIgnoringFiltersAsync(
Arg.Any<Expression<Func<User, bool>>>(),
Arg.Any<CancellationToken>())
.Returns(user);

var periodEnd = DateTime.UtcNow.AddDays(7);
_subscriptionService.GetAsync("sub_weekly", Arg.Any<SubscriptionGetOptions>(),
Arg.Any<RequestOptions>(), Arg.Any<CancellationToken>())
.Returns(CreateSubscriptionWithInterval("sub_weekly", "week", periodEnd));

var (json, signature) = BuildSignedEvent("checkout.session.completed", BuildCheckoutSessionJson(
UserId.ToString(), "sub_weekly", "cus_test"));

await _handler.Handle(new HandleWebhookCommand(json, signature), CancellationToken.None);

user.SubscriptionInterval.Should().Be(Orbit.Domain.Enums.SubscriptionInterval.Monthly);
user.PlanExpiresAt!.Value.Should().BeCloseTo(periodEnd, TimeSpan.FromMinutes(1));
}

[Fact]
public async Task Handle_ReplayedEventWithOldSignatureTimestamp_RejectedByTolerance()
{
var eventJson = BuildEventJson("customer.subscription.updated",
BuildSubscriptionJson("sub_test", "active"));
var staleTimestamp = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds();
var signature = SignPayload(eventJson, staleTimestamp);

var result = await _handler.Handle(new HandleWebhookCommand(eventJson, signature), CancellationToken.None);

result.IsFailure.Should().BeTrue();
result.Error.Should().Contain("signature");
await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
}

private static Subscription CreateMockSubscription(string subscriptionId)
{
return new Subscription
Expand Down Expand Up @@ -580,6 +760,34 @@ private static Subscription CreateYearlySubscriptionWithoutPeriodEnd(string subs
};
}

private static Subscription CreateSubscriptionWithoutItems(string subscriptionId) => new()
{
Id = subscriptionId,
Status = "active",
Items = new StripeList<SubscriptionItem> { Data = [] }
};

private static Subscription CreateSubscriptionWithInterval(
string subscriptionId, string interval, DateTime currentPeriodEnd) => new()
{
Id = subscriptionId,
Status = "active",
Items = new StripeList<SubscriptionItem>
{
Data =
[
new SubscriptionItem
{
CurrentPeriodEnd = currentPeriodEnd,
Price = new Price
{
Recurring = new PriceRecurring { Interval = interval, IntervalCount = 1 }
}
}
]
}
};

private static string BuildCheckoutSessionJson(string? userId, string subscriptionId, string customerId)
{
var metadata = userId is not null
Expand Down Expand Up @@ -650,7 +858,11 @@ private static string BuildInvoiceJson(string? subscriptionId)

private static (string Json, string Signature) BuildSignedEvent(string eventType, string dataObjectJson)
{
var eventJson = $$"""
var eventJson = BuildEventJson(eventType, dataObjectJson);
return (eventJson, SignPayload(eventJson, DateTimeOffset.UtcNow.ToUnixTimeSeconds()));
}

private static string BuildEventJson(string eventType, string dataObjectJson) => $$"""
{
"id": "evt_test_{{Guid.NewGuid():N}}",
"object": "event",
Expand All @@ -669,14 +881,11 @@ private static (string Json, string Signature) BuildSignedEvent(string eventType
}
""";

var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var payload = $"{timestamp}.{eventJson}";
private static string SignPayload(string body, long timestamp)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(WebhookSecret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
var hex = Convert.ToHexStringLower(hash);
var signature = $"t={timestamp},v1={hex}";

return (eventJson, signature);
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{body}"));
return $"t={timestamp},v1={Convert.ToHexStringLower(hash)}";
}

private sealed class FakeUniqueViolationException : DbException
Expand Down
Loading