211 lines
9.8 KiB
C#
211 lines
9.8 KiB
C#
using System.Text;
|
|
using System.Security.Claims;
|
|
using JobTrackerApi.Controllers;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services;
|
|
using JobTrackerApi.Tests.TestSupport;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using Moq;
|
|
using Stripe;
|
|
using Xunit;
|
|
|
|
namespace JobTrackerApi.Tests;
|
|
|
|
public sealed class BillingControllerTests
|
|
{
|
|
[Fact]
|
|
public async Task Checkout_uses_the_configured_price_and_stable_user_metadata()
|
|
{
|
|
var user = new ApplicationUser { Id = "user-1", Email = "person@example.test" };
|
|
var users = TestHostFactory.CreateUserManager(user);
|
|
users.Setup(item => item.GetRolesAsync(user)).ReturnsAsync(Array.Empty<string>());
|
|
var gateway = new Mock<IStripeBillingGateway>();
|
|
Stripe.Checkout.SessionCreateOptions? captured = null;
|
|
gateway.Setup(item => item.CreateCheckoutAsync("sk_test_fake", It.IsAny<Stripe.Checkout.SessionCreateOptions>(), It.IsAny<CancellationToken>()))
|
|
.Callback((string _, Stripe.Checkout.SessionCreateOptions options, CancellationToken _) => captured = options)
|
|
.ReturnsAsync(new Stripe.Checkout.Session { Url = "https://checkout.stripe.test/session" });
|
|
var controller = Controller(Configuration(), users, CreateRoleManager(), gateway);
|
|
Authenticate(controller, user.Id);
|
|
|
|
var action = await controller.Checkout(default);
|
|
|
|
var result = Assert.IsType<OkObjectResult>(action.Result);
|
|
Assert.Equal("https://checkout.stripe.test/session", Assert.IsType<BillingController.BillingRedirectDto>(result.Value).Url);
|
|
Assert.NotNull(captured);
|
|
Assert.Equal("price_fake", Assert.Single(captured.LineItems).Price);
|
|
Assert.Equal(user.Id, captured.ClientReferenceId);
|
|
Assert.Equal(user.Id, captured.Metadata["jobtracker_user_id"]);
|
|
Assert.Equal(user.Id, captured.SubscriptionData.Metadata["jobtracker_user_id"]);
|
|
Assert.Equal("https://example.test/settings?billing=success", captured.SuccessUrl);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Product_identifier_cannot_enable_checkout_as_a_price()
|
|
{
|
|
var user = new ApplicationUser { Id = "user-1" };
|
|
var users = TestHostFactory.CreateUserManager(user);
|
|
users.Setup(item => item.GetRolesAsync(user)).ReturnsAsync(Array.Empty<string>());
|
|
var configuration = Configuration(new Dictionary<string, string?> { ["Stripe:PricePremium"] = "prod_wrong_kind" });
|
|
var controller = Controller(configuration, users, CreateRoleManager(), new Mock<IStripeBillingGateway>());
|
|
Authenticate(controller, user.Id);
|
|
|
|
var status = Assert.IsType<OkObjectResult>((await controller.Status(default)).Result);
|
|
Assert.False(Assert.IsType<BillingController.BillingStatusDto>(status.Value).Enabled);
|
|
Assert.IsType<ObjectResult>((await controller.Checkout(default)).Result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Signed_subscription_lifecycle_grants_then_revokes_on_expiry_idempotently()
|
|
{
|
|
var user = new ApplicationUser
|
|
{
|
|
Id = "user-1",
|
|
AiEnabled = true,
|
|
ProfileCvText = "non-AI profile data must survive",
|
|
};
|
|
var users = TestHostFactory.CreateUserManager(user);
|
|
users.Setup(item => item.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
|
var hasPremium = false;
|
|
users.Setup(item => item.IsInRoleAsync(user, "Premium")).ReturnsAsync(() => hasPremium);
|
|
users.Setup(item => item.AddToRoleAsync(user, "Premium"))
|
|
.Callback(() => hasPremium = true)
|
|
.ReturnsAsync(IdentityResult.Success);
|
|
users.Setup(item => item.RemoveFromRoleAsync(user, "Premium"))
|
|
.Callback(() => hasPremium = false)
|
|
.ReturnsAsync(IdentityResult.Success);
|
|
|
|
var roles = CreateRoleManager();
|
|
roles.Setup(item => item.RoleExistsAsync("Premium")).ReturnsAsync(true);
|
|
var webhookEvent = new Event
|
|
{
|
|
Type = EventTypes.CustomerSubscriptionUpdated,
|
|
Created = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc),
|
|
Data = new EventData { Object = new Subscription { Id = "sub_lifecycle" } },
|
|
};
|
|
var gateway = new Mock<IStripeBillingGateway>();
|
|
gateway.Setup(item => item.ConstructEvent(It.IsAny<string>(), "signed", "whsec_fake")).Returns(webhookEvent);
|
|
gateway.SetupSequence(item => item.GetSubscriptionAsync("sk_test_fake", "sub_lifecycle", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(Subscription("active"))
|
|
.ReturnsAsync(Subscription("past_due"))
|
|
.ReturnsAsync(Subscription("canceled"));
|
|
var controller = Controller(Configuration(), users, roles, gateway);
|
|
|
|
SetWebhookRequest(controller);
|
|
Assert.IsType<OkResult>(await controller.Webhook(default));
|
|
Assert.True(hasPremium);
|
|
Assert.Equal("active", user.StripeSubscriptionStatus);
|
|
|
|
SetWebhookRequest(controller);
|
|
Assert.IsType<OkResult>(await controller.Webhook(default));
|
|
Assert.False(hasPremium);
|
|
Assert.Equal("past_due", user.StripeSubscriptionStatus);
|
|
Assert.Equal("non-AI profile data must survive", user.ProfileCvText);
|
|
|
|
SetWebhookRequest(controller);
|
|
Assert.IsType<OkResult>(await controller.Webhook(default));
|
|
Assert.False(hasPremium);
|
|
Assert.Equal("canceled", user.StripeSubscriptionStatus);
|
|
users.Verify(item => item.AddToRoleAsync(user, "Premium"), Times.Once);
|
|
users.Verify(item => item.RemoveFromRoleAsync(user, "Premium"), Times.Once);
|
|
users.Verify(item => item.UpdateAsync(user), Times.Exactly(3));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Webhook_rejects_an_invalid_Stripe_signature()
|
|
{
|
|
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["Stripe:SecretKey"] = "sk_test_fake",
|
|
["Stripe:PricePremium"] = "price_fake",
|
|
["Stripe:WebhookSecret"] = "whsec_fake",
|
|
["App:PublicBaseUrl"] = "https://example.test",
|
|
}).Build();
|
|
|
|
var userStore = new Mock<IUserStore<ApplicationUser>>();
|
|
var users = new Mock<UserManager<ApplicationUser>>(
|
|
userStore.Object, Options.Create(new IdentityOptions()), new PasswordHasher<ApplicationUser>(),
|
|
Array.Empty<IUserValidator<ApplicationUser>>(), Array.Empty<IPasswordValidator<ApplicationUser>>(),
|
|
new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), null!, NullLogger<UserManager<ApplicationUser>>.Instance);
|
|
var roleStore = new Mock<IRoleStore<IdentityRole>>();
|
|
var roles = new Mock<RoleManager<IdentityRole>>(
|
|
roleStore.Object, Array.Empty<IRoleValidator<IdentityRole>>(), new UpperInvariantLookupNormalizer(),
|
|
new IdentityErrorDescriber(), NullLogger<RoleManager<IdentityRole>>.Instance);
|
|
|
|
var controller = new BillingController(configuration, users.Object, roles.Object, NullLogger<BillingController>.Instance)
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
|
|
};
|
|
controller.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{}"));
|
|
controller.Request.Headers["Stripe-Signature"] = "invalid";
|
|
|
|
var result = await controller.Webhook(CancellationToken.None);
|
|
|
|
Assert.IsType<BadRequestObjectResult>(result);
|
|
}
|
|
|
|
private static IConfiguration Configuration(Dictionary<string, string?>? overrides = null)
|
|
{
|
|
var values = new Dictionary<string, string?>
|
|
{
|
|
["Stripe:SecretKey"] = "sk_test_fake",
|
|
["Stripe:PricePremium"] = "price_fake",
|
|
["Stripe:WebhookSecret"] = "whsec_fake",
|
|
["App:PublicBaseUrl"] = "https://example.test",
|
|
};
|
|
if (overrides is not null)
|
|
foreach (var (key, value) in overrides) values[key] = value;
|
|
return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
|
}
|
|
|
|
private static Mock<RoleManager<IdentityRole>> CreateRoleManager()
|
|
{
|
|
var store = new Mock<IRoleStore<IdentityRole>>();
|
|
return new Mock<RoleManager<IdentityRole>>(
|
|
store.Object,
|
|
Array.Empty<IRoleValidator<IdentityRole>>(),
|
|
new UpperInvariantLookupNormalizer(),
|
|
new IdentityErrorDescriber(),
|
|
NullLogger<RoleManager<IdentityRole>>.Instance);
|
|
}
|
|
|
|
private static BillingController Controller(
|
|
IConfiguration configuration,
|
|
Mock<UserManager<ApplicationUser>> users,
|
|
Mock<RoleManager<IdentityRole>> roles,
|
|
Mock<IStripeBillingGateway> gateway)
|
|
=> new(configuration, users.Object, roles.Object, NullLogger<BillingController>.Instance, stripe: gateway.Object)
|
|
{
|
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
|
|
};
|
|
|
|
private static void Authenticate(BillingController controller, string userId)
|
|
=> controller.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity(
|
|
new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "local"));
|
|
|
|
private static void SetWebhookRequest(BillingController controller)
|
|
{
|
|
controller.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{}"));
|
|
controller.Request.Headers["Stripe-Signature"] = "signed";
|
|
}
|
|
|
|
private static Subscription Subscription(string status) => new()
|
|
{
|
|
Id = "sub_lifecycle",
|
|
CustomerId = "cus_lifecycle",
|
|
Status = status,
|
|
Metadata = new Dictionary<string, string> { ["jobtracker_user_id"] = "user-1" },
|
|
Items = new StripeList<SubscriptionItem>
|
|
{
|
|
Data = new List<SubscriptionItem>
|
|
{
|
|
new() { Price = new Price { Id = "price_fake" } },
|
|
},
|
|
},
|
|
};
|
|
}
|