mirror of
https://github.com/bitwarden/server
synced 2025-12-06 00:03:34 +00:00
[PM-26194] Fix: Provider Portal not automatically disabled, when subscription is cancelled (#6480)
* Add the fix for the bug * Move the org disable to job
This commit is contained in:
234
test/Billing.Test/Jobs/ProviderOrganizationDisableJobTests.cs
Normal file
234
test/Billing.Test/Jobs/ProviderOrganizationDisableJobTests.cs
Normal file
@@ -0,0 +1,234 @@
|
||||
using Bit.Billing.Jobs;
|
||||
using Bit.Core.AdminConsole.Models.Data.Provider;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using Quartz;
|
||||
using Xunit;
|
||||
|
||||
namespace Bit.Billing.Test.Jobs;
|
||||
|
||||
public class ProviderOrganizationDisableJobTests
|
||||
{
|
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
|
||||
private readonly IOrganizationDisableCommand _organizationDisableCommand;
|
||||
private readonly ILogger<ProviderOrganizationDisableJob> _logger;
|
||||
private readonly ProviderOrganizationDisableJob _sut;
|
||||
|
||||
public ProviderOrganizationDisableJobTests()
|
||||
{
|
||||
_providerOrganizationRepository = Substitute.For<IProviderOrganizationRepository>();
|
||||
_organizationDisableCommand = Substitute.For<IOrganizationDisableCommand>();
|
||||
_logger = Substitute.For<ILogger<ProviderOrganizationDisableJob>>();
|
||||
_sut = new ProviderOrganizationDisableJob(
|
||||
_providerOrganizationRepository,
|
||||
_organizationDisableCommand,
|
||||
_logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_NoOrganizations_LogsAndReturns()
|
||||
{
|
||||
// Arrange
|
||||
var providerId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(providerId, DateTime.UtcNow);
|
||||
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
|
||||
.Returns((ICollection<ProviderOrganizationOrganizationDetails>)null);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _organizationDisableCommand.DidNotReceiveWithAnyArgs().DisableAsync(default, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_WithOrganizations_DisablesAllOrganizations()
|
||||
{
|
||||
// Arrange
|
||||
var providerId = Guid.NewGuid();
|
||||
var expirationDate = DateTime.UtcNow.AddDays(30);
|
||||
var org1Id = Guid.NewGuid();
|
||||
var org2Id = Guid.NewGuid();
|
||||
var org3Id = Guid.NewGuid();
|
||||
|
||||
var organizations = new List<ProviderOrganizationOrganizationDetails>
|
||||
{
|
||||
new() { OrganizationId = org1Id },
|
||||
new() { OrganizationId = org2Id },
|
||||
new() { OrganizationId = org3Id }
|
||||
};
|
||||
|
||||
var context = CreateJobExecutionContext(providerId, expirationDate);
|
||||
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
|
||||
.Returns(organizations);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _organizationDisableCommand.Received(1).DisableAsync(org1Id, Arg.Any<DateTime?>());
|
||||
await _organizationDisableCommand.Received(1).DisableAsync(org2Id, Arg.Any<DateTime?>());
|
||||
await _organizationDisableCommand.Received(1).DisableAsync(org3Id, Arg.Any<DateTime?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_WithExpirationDate_PassesDateToDisableCommand()
|
||||
{
|
||||
// Arrange
|
||||
var providerId = Guid.NewGuid();
|
||||
var expirationDate = new DateTime(2025, 12, 31, 23, 59, 59);
|
||||
var orgId = Guid.NewGuid();
|
||||
|
||||
var organizations = new List<ProviderOrganizationOrganizationDetails>
|
||||
{
|
||||
new() { OrganizationId = orgId }
|
||||
};
|
||||
|
||||
var context = CreateJobExecutionContext(providerId, expirationDate);
|
||||
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
|
||||
.Returns(organizations);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _organizationDisableCommand.Received(1).DisableAsync(orgId, expirationDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_WithNullExpirationDate_PassesNullToDisableCommand()
|
||||
{
|
||||
// Arrange
|
||||
var providerId = Guid.NewGuid();
|
||||
var orgId = Guid.NewGuid();
|
||||
|
||||
var organizations = new List<ProviderOrganizationOrganizationDetails>
|
||||
{
|
||||
new() { OrganizationId = orgId }
|
||||
};
|
||||
|
||||
var context = CreateJobExecutionContext(providerId, null);
|
||||
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
|
||||
.Returns(organizations);
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _organizationDisableCommand.Received(1).DisableAsync(orgId, null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_OneOrganizationFails_ContinuesProcessingOthers()
|
||||
{
|
||||
// Arrange
|
||||
var providerId = Guid.NewGuid();
|
||||
var expirationDate = DateTime.UtcNow.AddDays(30);
|
||||
var org1Id = Guid.NewGuid();
|
||||
var org2Id = Guid.NewGuid();
|
||||
var org3Id = Guid.NewGuid();
|
||||
|
||||
var organizations = new List<ProviderOrganizationOrganizationDetails>
|
||||
{
|
||||
new() { OrganizationId = org1Id },
|
||||
new() { OrganizationId = org2Id },
|
||||
new() { OrganizationId = org3Id }
|
||||
};
|
||||
|
||||
var context = CreateJobExecutionContext(providerId, expirationDate);
|
||||
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
|
||||
.Returns(organizations);
|
||||
|
||||
// Make org2 fail
|
||||
_organizationDisableCommand.DisableAsync(org2Id, Arg.Any<DateTime?>())
|
||||
.Throws(new Exception("Database error"));
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert - all three should be attempted
|
||||
await _organizationDisableCommand.Received(1).DisableAsync(org1Id, Arg.Any<DateTime?>());
|
||||
await _organizationDisableCommand.Received(1).DisableAsync(org2Id, Arg.Any<DateTime?>());
|
||||
await _organizationDisableCommand.Received(1).DisableAsync(org3Id, Arg.Any<DateTime?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_ManyOrganizations_ProcessesWithLimitedConcurrency()
|
||||
{
|
||||
// Arrange
|
||||
var providerId = Guid.NewGuid();
|
||||
var expirationDate = DateTime.UtcNow.AddDays(30);
|
||||
|
||||
// Create 20 organizations
|
||||
var organizations = Enumerable.Range(1, 20)
|
||||
.Select(_ => new ProviderOrganizationOrganizationDetails { OrganizationId = Guid.NewGuid() })
|
||||
.ToList();
|
||||
|
||||
var context = CreateJobExecutionContext(providerId, expirationDate);
|
||||
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
|
||||
.Returns(organizations);
|
||||
|
||||
var concurrentCalls = 0;
|
||||
var maxConcurrentCalls = 0;
|
||||
var lockObj = new object();
|
||||
|
||||
_organizationDisableCommand.DisableAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>())
|
||||
.Returns(callInfo =>
|
||||
{
|
||||
lock (lockObj)
|
||||
{
|
||||
concurrentCalls++;
|
||||
if (concurrentCalls > maxConcurrentCalls)
|
||||
{
|
||||
maxConcurrentCalls = concurrentCalls;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.Delay(50).ContinueWith(_ =>
|
||||
{
|
||||
lock (lockObj)
|
||||
{
|
||||
concurrentCalls--;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(maxConcurrentCalls <= 5, $"Expected max concurrency of 5, but got {maxConcurrentCalls}");
|
||||
await _organizationDisableCommand.Received(20).DisableAsync(Arg.Any<Guid>(), Arg.Any<DateTime?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Execute_EmptyOrganizationsList_DoesNotCallDisableCommand()
|
||||
{
|
||||
// Arrange
|
||||
var providerId = Guid.NewGuid();
|
||||
var context = CreateJobExecutionContext(providerId, DateTime.UtcNow);
|
||||
_providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId)
|
||||
.Returns(new List<ProviderOrganizationOrganizationDetails>());
|
||||
|
||||
// Act
|
||||
await _sut.Execute(context);
|
||||
|
||||
// Assert
|
||||
await _organizationDisableCommand.DidNotReceiveWithAnyArgs().DisableAsync(default, default);
|
||||
}
|
||||
|
||||
private static IJobExecutionContext CreateJobExecutionContext(Guid providerId, DateTime? expirationDate)
|
||||
{
|
||||
var context = Substitute.For<IJobExecutionContext>();
|
||||
var jobDataMap = new JobDataMap
|
||||
{
|
||||
{ "providerId", providerId.ToString() },
|
||||
{ "expirationDate", expirationDate?.ToString("O") }
|
||||
};
|
||||
context.MergedJobDataMap.Returns(jobDataMap);
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
using Bit.Billing.Constants;
|
||||
using Bit.Billing.Jobs;
|
||||
using Bit.Billing.Services;
|
||||
using Bit.Billing.Services.Implementations;
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Services;
|
||||
using NSubstitute;
|
||||
using Quartz;
|
||||
using Stripe;
|
||||
using Xunit;
|
||||
|
||||
@@ -16,6 +21,10 @@ public class SubscriptionDeletedHandlerTests
|
||||
private readonly IUserService _userService;
|
||||
private readonly IStripeEventUtilityService _stripeEventUtilityService;
|
||||
private readonly IOrganizationDisableCommand _organizationDisableCommand;
|
||||
private readonly IProviderRepository _providerRepository;
|
||||
private readonly IProviderService _providerService;
|
||||
private readonly ISchedulerFactory _schedulerFactory;
|
||||
private readonly IScheduler _scheduler;
|
||||
private readonly SubscriptionDeletedHandler _sut;
|
||||
|
||||
public SubscriptionDeletedHandlerTests()
|
||||
@@ -24,11 +33,19 @@ public class SubscriptionDeletedHandlerTests
|
||||
_userService = Substitute.For<IUserService>();
|
||||
_stripeEventUtilityService = Substitute.For<IStripeEventUtilityService>();
|
||||
_organizationDisableCommand = Substitute.For<IOrganizationDisableCommand>();
|
||||
_providerRepository = Substitute.For<IProviderRepository>();
|
||||
_providerService = Substitute.For<IProviderService>();
|
||||
_schedulerFactory = Substitute.For<ISchedulerFactory>();
|
||||
_scheduler = Substitute.For<IScheduler>();
|
||||
_schedulerFactory.GetScheduler().Returns(_scheduler);
|
||||
_sut = new SubscriptionDeletedHandler(
|
||||
_stripeEventService,
|
||||
_userService,
|
||||
_stripeEventUtilityService,
|
||||
_organizationDisableCommand);
|
||||
_organizationDisableCommand,
|
||||
_providerRepository,
|
||||
_providerService,
|
||||
_schedulerFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -59,6 +76,7 @@ public class SubscriptionDeletedHandlerTests
|
||||
// Assert
|
||||
await _organizationDisableCommand.DidNotReceiveWithAnyArgs().DisableAsync(default, default);
|
||||
await _userService.DidNotReceiveWithAnyArgs().DisablePremiumAsync(default, default);
|
||||
await _providerService.DidNotReceiveWithAnyArgs().UpdateAsync(default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -192,4 +210,120 @@ public class SubscriptionDeletedHandlerTests
|
||||
await _organizationDisableCommand.DidNotReceiveWithAnyArgs()
|
||||
.DisableAsync(default, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_ProviderSubscriptionCanceled_DisablesProviderAndQueuesJob()
|
||||
{
|
||||
// Arrange
|
||||
var stripeEvent = new Event();
|
||||
var providerId = Guid.NewGuid();
|
||||
var provider = new Provider
|
||||
{
|
||||
Id = providerId,
|
||||
Enabled = true
|
||||
};
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Status = StripeSubscriptionStatus.Canceled,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }
|
||||
};
|
||||
|
||||
_stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
|
||||
_providerRepository.GetByIdAsync(providerId).Returns(provider);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(stripeEvent);
|
||||
|
||||
// Assert
|
||||
Assert.False(provider.Enabled);
|
||||
await _providerService.Received(1).UpdateAsync(provider);
|
||||
await _scheduler.Received(1).ScheduleJob(
|
||||
Arg.Is<IJobDetail>(j => j.JobType == typeof(ProviderOrganizationDisableJob)),
|
||||
Arg.Any<ITrigger>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_ProviderSubscriptionCanceled_ProviderNotFound_DoesNotThrow()
|
||||
{
|
||||
// Arrange
|
||||
var stripeEvent = new Event();
|
||||
var providerId = Guid.NewGuid();
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Status = StripeSubscriptionStatus.Canceled,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }
|
||||
};
|
||||
|
||||
_stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
|
||||
_providerRepository.GetByIdAsync(providerId).Returns((Provider)null);
|
||||
|
||||
// Act & Assert - Should not throw
|
||||
await _sut.HandleAsync(stripeEvent);
|
||||
|
||||
// Assert
|
||||
await _providerService.DidNotReceiveWithAnyArgs().UpdateAsync(default);
|
||||
await _scheduler.DidNotReceiveWithAnyArgs().ScheduleJob(default, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAsync_ProviderSubscriptionCanceled_QueuesJobWithCorrectParameters()
|
||||
{
|
||||
// Arrange
|
||||
var stripeEvent = new Event();
|
||||
var providerId = Guid.NewGuid();
|
||||
var expirationDate = DateTime.UtcNow.AddDays(30);
|
||||
var provider = new Provider
|
||||
{
|
||||
Id = providerId,
|
||||
Enabled = true
|
||||
};
|
||||
var subscription = new Subscription
|
||||
{
|
||||
Status = StripeSubscriptionStatus.Canceled,
|
||||
Items = new StripeList<SubscriptionItem>
|
||||
{
|
||||
Data =
|
||||
[
|
||||
new SubscriptionItem { CurrentPeriodEnd = expirationDate }
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string> { { "providerId", providerId.ToString() } }
|
||||
};
|
||||
|
||||
_stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription);
|
||||
_stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata)
|
||||
.Returns(Tuple.Create<Guid?, Guid?, Guid?>(null, null, providerId));
|
||||
_providerRepository.GetByIdAsync(providerId).Returns(provider);
|
||||
|
||||
// Act
|
||||
await _sut.HandleAsync(stripeEvent);
|
||||
|
||||
// Assert
|
||||
Assert.False(provider.Enabled);
|
||||
await _providerService.Received(1).UpdateAsync(provider);
|
||||
await _scheduler.Received(1).ScheduleJob(
|
||||
Arg.Is<IJobDetail>(j =>
|
||||
j.JobType == typeof(ProviderOrganizationDisableJob) &&
|
||||
j.JobDataMap.GetString("providerId") == providerId.ToString() &&
|
||||
j.JobDataMap.GetString("expirationDate") == expirationDate.ToString("O")),
|
||||
Arg.Is<ITrigger>(t => t.Key.Name == $"disable-trigger-{providerId}"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user