1
0
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:
cyprain-okeke
2025-10-27 13:19:42 +01:00
committed by GitHub
parent 9b313d9c0a
commit 427600d0cc
4 changed files with 500 additions and 2 deletions

View 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;
}
}

View File

@@ -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}"));
}
}