diff --git a/src/Orbit.Application/Subscriptions/Commands/HandleWebhookCommand.cs b/src/Orbit.Application/Subscriptions/Commands/HandleWebhookCommand.cs index 09676692..dc85289a 100644 --- a/src/Orbit.Application/Subscriptions/Commands/HandleWebhookCommand.cs +++ b/src/Orbit.Application/Subscriptions/Commands/HandleWebhookCommand.cs @@ -49,6 +49,11 @@ public async Task 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); diff --git a/tests/Orbit.Application.Tests/Commands/Subscriptions/CreateCheckoutCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Subscriptions/CreateCheckoutCommandHandlerTests.cs index d2fd7c6f..86283bdc 100644 --- a/tests/Orbit.Application.Tests/Commands/Subscriptions/CreateCheckoutCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Subscriptions/CreateCheckoutCommandHandlerTests.cs @@ -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; @@ -196,6 +197,57 @@ await _geoLocationService.DidNotReceive() .GetCountryCodeAsync(Arg.Any(), Arg.Any()); } + [Fact] + public async Task Handle_StripeCustomerCreationFails_ReturnsFailureAndDoesNotPersistPartialState() + { + var user = User.Create("Test", "test@example.com").Value; + SetupExistingUser(user); + + _billingService.CreateCustomerAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .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()); + } + + [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(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .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( diff --git a/tests/Orbit.Application.Tests/Commands/Subscriptions/HandleWebhookCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Subscriptions/HandleWebhookCommandHandlerTests.cs index d3092710..fcd3bb85 100644 --- a/tests/Orbit.Application.Tests/Commands/Subscriptions/HandleWebhookCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Subscriptions/HandleWebhookCommandHandlerTests.cs @@ -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>>(), Arg.Any()); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [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>>(), + Arg.Any()) + .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()); + } + + [Fact] + public async Task Handle_CheckoutSessionCompleted_StripeApiErrorFetchingSubscription_ReturnsStripeApiFailure() + { + var user = User.Create("Thomas", "test@example.com").Value; + _userRepo.FindOneTrackedIgnoringFiltersAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(user); + + _subscriptionService.GetAsync("sub_test", Arg.Any(), + Arg.Any(), Arg.Any()) + .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()); + } + + [Fact] + public async Task Handle_SubscriptionFetchThrowsOperationCanceled_PropagatesNotSwallowedAsFailure() + { + var user = User.Create("Thomas", "test@example.com").Value; + _userRepo.FindOneTrackedIgnoringFiltersAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(user); + + _subscriptionService.GetAsync("sub_test", Arg.Any(), + Arg.Any(), Arg.Any()) + .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(); + } + + [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()); + } + + [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>>(), Arg.Any()); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_CheckoutSessionCompleted_SubscriptionWithNoItems_FallsBackToMonthlyOneMonth() + { + var user = User.Create("Thomas", "test@example.com").Value; + _userRepo.FindOneTrackedIgnoringFiltersAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(user); + + _subscriptionService.GetAsync("sub_noitems", Arg.Any(), + Arg.Any(), Arg.Any()) + .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>>(), + Arg.Any()) + .Returns(user); + + var periodEnd = DateTime.UtcNow.AddDays(7); + _subscriptionService.GetAsync("sub_weekly", Arg.Any(), + Arg.Any(), Arg.Any()) + .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()); + } + private static Subscription CreateMockSubscription(string subscriptionId) { return new Subscription @@ -580,6 +760,34 @@ private static Subscription CreateYearlySubscriptionWithoutPeriodEnd(string subs }; } + private static Subscription CreateSubscriptionWithoutItems(string subscriptionId) => new() + { + Id = subscriptionId, + Status = "active", + Items = new StripeList { Data = [] } + }; + + private static Subscription CreateSubscriptionWithInterval( + string subscriptionId, string interval, DateTime currentPeriodEnd) => new() + { + Id = subscriptionId, + Status = "active", + Items = new StripeList + { + 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 @@ -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", @@ -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