Skip to content
Open
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 @@ -59,6 +59,14 @@ public async Task AddExistingOrganization(
Organization organization,
string key)
{
var existingProviderOrganization =
await providerOrganizationRepository.GetByOrganizationId(organization.Id);

if (existingProviderOrganization != null)
{
throw new ConflictException("Organization already belongs to a provider.");
}

await priceIncreaseScheduler.Release(
organization.GatewayCustomerId,
organization.GatewaySubscriptionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2679,6 +2679,47 @@ await eventService.Received(0).LogProviderOrganizationEventAsync(
Arg.Any<ProviderOrganization>(), Arg.Any<EventType>());
}

[Theory, BitAutoData]
public async Task AddExistingOrganization_OrgAlreadyBelongsToProvider_ThrowsAndShortCircuits(
Provider provider,
Organization organization,
string key,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange β€” the organization already has a ProviderOrganization row (PM-39894).
// The guard must fire before any billing side effects (Release/Stripe) or repo writes.
provider.Type = ProviderType.Msp;

var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
providerOrganizationRepository
.GetByOrganizationId(organization.Id)
.Returns(new ProviderOrganization { OrganizationId = organization.Id });

var priceIncreaseScheduler = sutProvider.GetDependency<IPriceIncreaseScheduler>();
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
var eventService = sutProvider.GetDependency<IEventService>();

// Act
var exception = await Assert.ThrowsAsync<ConflictException>(() =>
sutProvider.Sut.AddExistingOrganization(provider, organization, key));

// Assert β€” exact message and nothing downstream of the guard fired (representative calls asserted).
Assert.Equal("Organization already belongs to a provider.", exception.Message);
await providerOrganizationRepository.Received(1)
.GetByOrganizationId(organization.Id);
await priceIncreaseScheduler.Received(0)
.Release(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<Guid?>());
await stripeAdapter.Received(0)
.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
await stripeAdapter.Received(0)
.CancelSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionCancelOptions>());
await organizationRepository.Received(0).ReplaceAsync(Arg.Any<Organization>());
await providerOrganizationRepository.Received(0).CreateAsync(Arg.Any<ProviderOrganization>());
await eventService.Received(0).LogProviderOrganizationEventAsync(
Arg.Any<ProviderOrganization>(), Arg.Any<EventType>());
}

[Theory, BitAutoData]
public async Task AddExistingOrganization_StripeCancelSucceeds_OnlyWhenReleasePrecedesIt(
Provider provider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,8 @@ join organization in dbContext.Organizations on organizationUser.OrganizationId
organization.Seats > 0 &&
organization.Status == OrganizationStatusType.Created &&
!organization.UseSecretsManager &&
planTypes.Contains(organization.PlanType)
planTypes.Contains(organization.PlanType) &&
!dbContext.ProviderOrganizations.Any(po => po.OrganizationId == organization.Id)
select organization;

return await query.ToArrayAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,11 @@ BEGIN
-- All Teams & Enterprise for MSP
(@ProviderType = 0 AND O.[PlanType] IN (2, 3, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18, 19, 20) OR
-- All Enterprise for MOE
@ProviderType = 2 AND O.[PlanType] IN (4, 5, 10, 11, 14, 15, 19, 20));
@ProviderType = 2 AND O.[PlanType] IN (4, 5, 10, 11, 14, 15, 19, 20)) AND
-- Exclude organizations that already belong to a provider (PM-39894)
NOT EXISTS (
SELECT 1
FROM [dbo].[ProviderOrganization] PO
WHERE PO.[OrganizationId] = O.[Id]
);
END
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
ο»Ώusing System.Data.Common;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
Expand Down Expand Up @@ -234,7 +238,7 @@
Assert.NotNull(updateResult);
Assert.Equal(organization.Id, updateResult.Id);
Assert.True(updateResult.SyncSeats);
Assert.Equal(requestDate.ToString("yyyy-MM-dd HH:mm:ss"), updateResult.RevisionDate.ToString("yyyy-MM-dd HH:mm:ss"));

Check warning on line 241 in test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs

View workflow job for this annotation

GitHub Actions / Run tests

The behavior of 'DateTime.ToString(string)' could vary based on the current user's locale settings. Replace this call in 'OrganizationRepositoryTests.IncrementSeatCountAsync_GivenOrganizationHasNotChangedSeatCountBefore_WhenUpdatingOrgSeats_ThenSubscriptionUpdateIsSaved(IOrganizationRepository)' with a call to 'DateTime.ToString(string, IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 241 in test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs

View workflow job for this annotation

GitHub Actions / Run tests

The behavior of 'DateTime.ToString(string)' could vary based on the current user's locale settings. Replace this call in 'OrganizationRepositoryTests.IncrementSeatCountAsync_GivenOrganizationHasNotChangedSeatCountBefore_WhenUpdatingOrgSeats_ThenSubscriptionUpdateIsSaved(IOrganizationRepository)' with a call to 'DateTime.ToString(string, IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
}

[DatabaseData, Theory]
Expand All @@ -256,7 +260,7 @@
Assert.NotNull(updateResult);
Assert.Equal(organization.Id, updateResult.Id);
Assert.True(updateResult.SyncSeats);
Assert.Equal(requestDate.ToString("yyyy-MM-dd HH:mm:ss"), updateResult.RevisionDate.ToString("yyyy-MM-dd HH:mm:ss"));

Check warning on line 263 in test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs

View workflow job for this annotation

GitHub Actions / Run tests

The behavior of 'DateTime.ToString(string)' could vary based on the current user's locale settings. Replace this call in 'OrganizationRepositoryTests.IncrementSeatCountAsync_GivenOrganizationHasChangedSeatCountBeforeAndRecordExists_WhenUpdatingOrgSeats_ThenSubscriptionUpdateIsSaved(IOrganizationRepository)' with a call to 'DateTime.ToString(string, IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 263 in test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs

View workflow job for this annotation

GitHub Actions / Run tests

The behavior of 'DateTime.ToString(string)' could vary based on the current user's locale settings. Replace this call in 'OrganizationRepositoryTests.IncrementSeatCountAsync_GivenOrganizationHasChangedSeatCountBeforeAndRecordExists_WhenUpdatingOrgSeats_ThenSubscriptionUpdateIsSaved(IOrganizationRepository)' with a call to 'DateTime.ToString(string, IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
}

[DatabaseData, Theory]
Expand All @@ -276,7 +280,7 @@
Assert.NotNull(updateResult);
Assert.Equal(organization.Id, updateResult.Id);
Assert.True(updateResult.SyncSeats);
Assert.Equal(requestDate.ToString("yyyy-MM-dd HH:mm:ss"), updateResult.RevisionDate.ToString("yyyy-MM-dd HH:mm:ss"));

Check warning on line 283 in test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs

View workflow job for this annotation

GitHub Actions / Run tests

The behavior of 'DateTime.ToString(string)' could vary based on the current user's locale settings. Replace this call in 'OrganizationRepositoryTests.GetOrganizationsForSubscriptionSyncAsync_GivenOrganizationHasChangedSeatCount_WhenGettingOrgsToUpdate_ThenReturnsOrgSubscriptionUpdate(IOrganizationRepository)' with a call to 'DateTime.ToString(string, IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 283 in test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs

View workflow job for this annotation

GitHub Actions / Run tests

The behavior of 'DateTime.ToString(string)' could vary based on the current user's locale settings. Replace this call in 'OrganizationRepositoryTests.GetOrganizationsForSubscriptionSyncAsync_GivenOrganizationHasChangedSeatCount_WhenGettingOrgsToUpdate_ThenReturnsOrgSubscriptionUpdate(IOrganizationRepository)' with a call to 'DateTime.ToString(string, IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
}

[DatabaseData, Theory]
Expand Down Expand Up @@ -453,4 +457,155 @@

return (user, organization, organizationUser);
}

[Theory, DatabaseData]
public async Task GetAddableToProviderByUserIdAsync_StandaloneOrg_Included(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository)
{
// Arrange β€” an org meeting all addable criteria with no ProviderOrganization link.
var user = await userRepository.CreateTestUserAsync();
var organization = await CreateAddableOrganizationAsync(organizationRepository);
await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user);

// Act
var result = await organizationRepository.GetAddableToProviderByUserIdAsync(user.Id, ProviderType.Msp);

// Assert
Assert.Contains(result, o => o.Id == organization.Id);
}

[Theory, DatabaseData]
public async Task GetAddableToProviderByUserIdAsync_ResellerLinkedOrg_Excluded(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IProviderRepository providerRepository,
IProviderOrganizationRepository providerOrganizationRepository)
{
// Arrange β€” the regression PM-39894 addresses: a reseller-linked org keeps
// Status = Created, so it looks addable, but it already has a ProviderOrganization row.
var user = await userRepository.CreateTestUserAsync();
var organization = await CreateAddableOrganizationAsync(organizationRepository);
await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user);

var provider = await providerRepository.CreateAsync(new Provider
{
Name = $"Reseller {CombGuid.Generate()}",
Type = ProviderType.Reseller,
Status = ProviderStatusType.Created,
Enabled = true,
BillingEmail = "reseller@example.com"
});
await providerOrganizationRepository.CreateAsync(new ProviderOrganization
{
ProviderId = provider.Id,
OrganizationId = organization.Id
});

// Act
var result = await organizationRepository.GetAddableToProviderByUserIdAsync(user.Id, ProviderType.Msp);

// Assert
Assert.DoesNotContain(result, o => o.Id == organization.Id);
}

[Theory, DatabaseData]
public async Task GetAddableToProviderByUserIdAsync_MspLinkedOrg_Excluded(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IProviderRepository providerRepository,
IProviderOrganizationRepository providerOrganizationRepository)
{
// Arrange β€” an org already linked to an MSP provider must not be addable again.
var user = await userRepository.CreateTestUserAsync();
var organization = await CreateAddableOrganizationAsync(organizationRepository);
await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user);

var provider = await providerRepository.CreateAsync(new Provider
{
Name = $"MSP {CombGuid.Generate()}",
Type = ProviderType.Msp,
Status = ProviderStatusType.Created,
Enabled = true,
BillingEmail = "msp@example.com"
});
await providerOrganizationRepository.CreateAsync(new ProviderOrganization
{
ProviderId = provider.Id,
OrganizationId = organization.Id
});

// Act
var result = await organizationRepository.GetAddableToProviderByUserIdAsync(user.Id, ProviderType.Msp);

// Assert
Assert.DoesNotContain(result, o => o.Id == organization.Id);
}

[Theory, DatabaseData]
public async Task GetAddableToProviderByUserIdAsync_LinkedOrg_ExcludedForBusinessUnit(
IUserRepository userRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IProviderRepository providerRepository,
IProviderOrganizationRepository providerOrganizationRepository)
{
// Arrange β€” the provider-link exclusion must apply to the BusinessUnit branch of the
// query too, not just MSP; this pins the predicate's placement outside the OR'd
// plan-type group.
var user = await userRepository.CreateTestUserAsync();
var organization = await CreateAddableOrganizationAsync(organizationRepository);
await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user);

var provider = await providerRepository.CreateAsync(new Provider
{
Name = $"Reseller {CombGuid.Generate()}",
Type = ProviderType.Reseller,
Status = ProviderStatusType.Created,
Enabled = true,
BillingEmail = "reseller@example.com"
});
await providerOrganizationRepository.CreateAsync(new ProviderOrganization
{
ProviderId = provider.Id,
OrganizationId = organization.Id
});

// Act
var result = await organizationRepository.GetAddableToProviderByUserIdAsync(
user.Id, ProviderType.BusinessUnit);

// Assert
Assert.DoesNotContain(result, o => o.Id == organization.Id);
}

/// <summary>
/// Builds an Organization inline that satisfies every predicate of the addable-to-provider query.
/// The shared <c>CreateTestOrganizationAsync</c> helper defaults to Status = Managed and
/// UseSecretsManager = true, both of which the query excludes, so we construct the org directly.
/// </summary>
private static Task<Organization> CreateAddableOrganizationAsync(
IOrganizationRepository organizationRepository)
{
var id = CombGuid.Generate();
return organizationRepository.CreateAsync(new Organization
{
Name = $"addable-{id}",
BillingEmail = $"billing-{id}@example.com",
Plan = "Enterprise (Annually)",
PlanType = PlanType.EnterpriseAnnually,
Status = OrganizationStatusType.Created,
Enabled = true,
UseSecretsManager = false,
Seats = 5,
Gateway = GatewayType.Stripe,
GatewayCustomerId = $"cus_{id}",
GatewaySubscriptionId = $"sub_{id}",
PublicKey = "test-public-key",
PrivateKey = "test-private-key"
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
CREATE OR ALTER PROCEDURE [dbo].[Organization_ReadAddableToProviderByUserId]
@UserId UNIQUEIDENTIFIER,
@ProviderType TINYINT
AS
BEGIN
SET NOCOUNT ON
SELECT O.* FROM [dbo].[OrganizationUser] AS OU
JOIN [dbo].[Organization] AS O ON O.[Id] = OU.[OrganizationId]
WHERE
OU.[UserId] = @UserId AND
OU.[Type] = 0 AND
OU.[Status] = 2 AND
O.[Enabled] = 1 AND
O.[GatewayCustomerId] IS NOT NULL AND
O.[GatewaySubscriptionId] IS NOT NULL AND
O.[Seats] > 0 AND
O.[Status] = 1 AND
O.[UseSecretsManager] = 0 AND
-- All Teams & Enterprise for MSP
(@ProviderType = 0 AND O.[PlanType] IN (2, 3, 4, 5, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18, 19, 20) OR
-- All Enterprise for MOE
@ProviderType = 2 AND O.[PlanType] IN (4, 5, 10, 11, 14, 15, 19, 20)) AND
-- Exclude organizations that already belong to a provider (PM-39894)
NOT EXISTS (
SELECT 1
FROM [dbo].[ProviderOrganization] PO
WHERE PO.[OrganizationId] = O.[Id]
);
END
GO
Loading