diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs
index 5c03ba0017..12394ff598 100644
--- a/bitwarden_license/src/Sso/Controllers/AccountController.cs
+++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs
@@ -499,9 +499,9 @@ public class AccountController : Controller
// Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one
if (orgUser == null && organization.Seats.HasValue)
{
- var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
+ var occupiedSeats = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var initialSeatCount = organization.Seats.Value;
- var availableSeats = initialSeatCount - occupiedSeats;
+ var availableSeats = initialSeatCount - occupiedSeats.Total;
if (availableSeats < 1)
{
try
diff --git a/src/Api/Models/Public/Response/CollectionResponseModel.cs b/src/Api/Models/Public/Response/CollectionResponseModel.cs
index 58968d4be7..d08db64290 100644
--- a/src/Api/Models/Public/Response/CollectionResponseModel.cs
+++ b/src/Api/Models/Public/Response/CollectionResponseModel.cs
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.AdminConsole.Public.Models.Response;
using Bit.Core.Entities;
+using Bit.Core.Enums;
using Bit.Core.Models.Data;
namespace Bit.Api.Models.Public.Response;
@@ -20,6 +21,7 @@ public class CollectionResponseModel : CollectionBaseModel, IResponseModel
Id = collection.Id;
ExternalId = collection.ExternalId;
Groups = groups?.Select(c => new AssociationWithPermissionsResponseModel(c));
+ Type = collection.Type;
}
///
@@ -38,4 +40,8 @@ public class CollectionResponseModel : CollectionBaseModel, IResponseModel
/// The associated groups that this collection is assigned to.
///
public IEnumerable Groups { get; set; }
+ ///
+ /// The type of this collection
+ ///
+ public CollectionType Type { get; set; }
}
diff --git a/src/Api/Models/Response/CollectionResponseModel.cs b/src/Api/Models/Response/CollectionResponseModel.cs
index d56ef5469a..5ce8310117 100644
--- a/src/Api/Models/Response/CollectionResponseModel.cs
+++ b/src/Api/Models/Response/CollectionResponseModel.cs
@@ -1,4 +1,5 @@
using Bit.Core.Entities;
+using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Models.Data;
@@ -18,12 +19,14 @@ public class CollectionResponseModel : ResponseModel
OrganizationId = collection.OrganizationId;
Name = collection.Name;
ExternalId = collection.ExternalId;
+ Type = collection.Type;
}
public Guid Id { get; set; }
public Guid OrganizationId { get; set; }
public string Name { get; set; }
public string ExternalId { get; set; }
+ public CollectionType Type { get; set; }
}
///
diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs
index db5d011e1d..1dddc8bf0c 100644
--- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs
+++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUsersCommand.cs
@@ -87,7 +87,7 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
InviteOrganization = request.InviteOrganization,
PerformedBy = request.PerformedBy,
PerformedAt = request.PerformedAt,
- OccupiedPmSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId),
+ OccupiedPmSeats = (await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId)).Total,
OccupiedSmSeats = await organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(request.InviteOrganization.OrganizationId)
});
diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs
index fe19cd1389..0d9955eecf 100644
--- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs
+++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/v1/RestoreOrganizationUserCommand.cs
@@ -70,8 +70,8 @@ public class RestoreOrganizationUserCommand(
}
var organization = await organizationRepository.GetByIdAsync(organizationUser.OrganizationId);
- var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
- var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
+ var seatCounts = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
+ var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total;
if (availableSeats < 1)
{
@@ -163,8 +163,8 @@ public class RestoreOrganizationUserCommand(
}
var organization = await organizationRepository.GetByIdAsync(organizationId);
- var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
- var availableSeats = organization.Seats.GetValueOrDefault(0) - occupiedSeats;
+ var seatCounts = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
+ var availableSeats = organization.Seats.GetValueOrDefault(0) - seatCounts.Total;
var newSeatsRequired = organizationUserIds.Count() - availableSeats;
await organizationService.AutoAddSeatsAsync(organization, newSeatsRequired);
diff --git a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs
index 7e315ed58b..7fff0d437f 100644
--- a/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs
+++ b/src/Core/AdminConsole/Repositories/IOrganizationRepository.cs
@@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Models.Data.Organizations;
+using Bit.Core.Models.Data.Organizations.OrganizationUsers;
#nullable enable
@@ -25,4 +26,14 @@ public interface IOrganizationRepository : IRepository
Task> GetByVerifiedUserEmailDomainAsync(Guid userId);
Task> GetAddableToProviderByUserIdAsync(Guid userId, ProviderType providerType);
Task> GetManyByIdsAsync(IEnumerable ids);
+
+ ///
+ /// Returns the number of occupied seats for an organization.
+ /// OrganizationUsers occupy a seat, unless they are revoked.
+ /// As of https://bitwarden.atlassian.net/browse/PM-17772, a seat is also occupied by a Families for Enterprise sponsorship sent by an
+ /// organization admin, even if the user sent the invitation doesn't have a corresponding OrganizationUser in the Enterprise organization.
+ ///
+ /// The ID of the organization to get the occupied seat count for.
+ /// The number of occupied seats for the organization.
+ Task GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId);
}
diff --git a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs
index 9692de897c..6e07bd9ff8 100644
--- a/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs
+++ b/src/Core/AdminConsole/Repositories/IOrganizationUserRepository.cs
@@ -18,16 +18,6 @@ public interface IOrganizationUserRepository : IRepository> GetManyByUserAsync(Guid userId);
Task> GetManyByOrganizationAsync(Guid organizationId, OrganizationUserType? type);
Task GetCountByOrganizationAsync(Guid organizationId, string email, bool onlyRegisteredUsers);
-
- ///
- /// Returns the number of occupied seats for an organization.
- /// Occupied seats are OrganizationUsers that have at least been invited.
- /// As of https://bitwarden.atlassian.net/browse/PM-17772, a seat is also occupied by a Families for Enterprise sponsorship sent by an
- /// organization admin, even if the user sent the invitation doesn't have a corresponding OrganizationUser in the Enterprise organization.
- ///
- /// The ID of the organization to get the occupied seat count for.
- /// The number of occupied seats for the organization.
- Task GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId);
Task> SelectKnownEmailsAsync(Guid organizationId, IEnumerable emails, bool onlyRegisteredUsers);
Task GetByOrganizationAsync(Guid organizationId, Guid userId);
Task>> GetByIdWithCollectionsAsync(Guid id);
diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs
index 16e58d27ad..4d709bb7cf 100644
--- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs
+++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs
@@ -294,11 +294,20 @@ public class OrganizationService : IOrganizationService
if (!organization.Seats.HasValue || organization.Seats.Value > newSeatTotal)
{
- var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
- if (occupiedSeats > newSeatTotal)
+ var seatCounts = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
+
+ if (seatCounts.Total > newSeatTotal)
{
- throw new BadRequestException($"Your organization currently has {occupiedSeats} seats filled. " +
- $"Your new plan only has ({newSeatTotal}) seats. Remove some users.");
+ if (organization.UseAdminSponsoredFamilies || seatCounts.Sponsored > 0)
+ {
+ throw new BadRequestException($"Your organization has {seatCounts.Users} members and {seatCounts.Sponsored} sponsored families. " +
+ $"To decrease the seat count below {seatCounts.Total}, you must remove members or sponsorships.");
+ }
+ else
+ {
+ throw new BadRequestException($"Your organization currently has {seatCounts.Total} seats filled. " +
+ $"Your new plan only has ({newSeatTotal}) seats. Remove some users.");
+ }
}
}
@@ -726,8 +735,8 @@ public class OrganizationService : IOrganizationService
var newSeatsRequired = 0;
if (organization.Seats.HasValue)
{
- var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
- var availableSeats = organization.Seats.Value - occupiedSeats;
+ var seatCounts = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
+ var availableSeats = organization.Seats.Value - seatCounts.Total;
newSeatsRequired = invites.Sum(i => i.invite.Emails.Count()) - existingEmails.Count() - availableSeats;
}
@@ -1177,8 +1186,8 @@ public class OrganizationService : IOrganizationService
var enoughSeatsAvailable = true;
if (organization.Seats.HasValue)
{
- var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
- seatsAvailable = organization.Seats.Value - occupiedSeats;
+ var seatCounts = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
+ seatsAvailable = organization.Seats.Value - seatCounts.Total;
enoughSeatsAvailable = seatsAvailable >= usersToAdd.Count;
}
diff --git a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs
index e2fddf049c..cd6301918a 100644
--- a/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs
+++ b/src/Core/Billing/Services/Implementations/OrganizationBillingService.cs
@@ -31,7 +31,6 @@ public class OrganizationBillingService(
IGlobalSettings globalSettings,
ILogger logger,
IOrganizationRepository organizationRepository,
- IOrganizationUserRepository organizationUserRepository,
IPricingClient pricingClient,
ISetupIntentCache setupIntentCache,
IStripeAdapter stripeAdapter,
@@ -78,14 +77,14 @@ public class OrganizationBillingService(
var isEligibleForSelfHost = await IsEligibleForSelfHostAsync(organization);
var isManaged = organization.Status == OrganizationStatusType.Managed;
- var orgOccupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
+ var orgOccupiedSeats = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
if (string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
return OrganizationMetadata.Default with
{
IsEligibleForSelfHost = isEligibleForSelfHost,
IsManaged = isManaged,
- OrganizationOccupiedSeats = orgOccupiedSeats
+ OrganizationOccupiedSeats = orgOccupiedSeats.Total
};
}
@@ -120,7 +119,7 @@ public class OrganizationBillingService(
invoice?.DueDate,
invoice?.Created,
subscription.CurrentPeriodEnd,
- orgOccupiedSeats);
+ orgOccupiedSeats.Total);
}
public async Task
diff --git a/src/Core/Models/Data/Organizations/OrganizationUsers/OrganizationSeatCounts.cs b/src/Core/Models/Data/Organizations/OrganizationUsers/OrganizationSeatCounts.cs
new file mode 100644
index 0000000000..6b9f615f64
--- /dev/null
+++ b/src/Core/Models/Data/Organizations/OrganizationUsers/OrganizationSeatCounts.cs
@@ -0,0 +1,8 @@
+namespace Bit.Core.Models.Data.Organizations.OrganizationUsers;
+
+public class OrganizationSeatCounts
+{
+ public int Users { get; set; }
+ public int Sponsored { get; set; }
+ public int Total => Users + Sponsored;
+}
diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs
index b15cbea240..a729937fad 100644
--- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs
+++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommand.cs
@@ -16,7 +16,7 @@ public class CreateSponsorshipCommand(
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
IUserService userService,
IOrganizationService organizationService,
- IOrganizationUserRepository organizationUserRepository) : ICreateSponsorshipCommand
+ IOrganizationRepository organizationRepository) : ICreateSponsorshipCommand
{
public async Task CreateSponsorshipAsync(
Organization sponsoringOrganization,
@@ -89,8 +89,8 @@ public class CreateSponsorshipCommand(
if (isAdminInitiated && sponsoringOrganization.Seats.HasValue)
{
- var occupiedSeats = await organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrganization.Id);
- var availableSeats = sponsoringOrganization.Seats.Value - occupiedSeats;
+ var seatCounts = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrganization.Id);
+ var availableSeats = sponsoringOrganization.Seats.Value - seatCounts.Total;
if (availableSeats <= 0)
{
diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs
index 838c1e97b9..761f59920c 100644
--- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs
+++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs
@@ -107,12 +107,20 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand
(newPlan.PasswordManager.HasAdditionalSeatsOption ? upgrade.AdditionalSeats : 0));
if (!organization.Seats.HasValue || organization.Seats.Value > updatedPasswordManagerSeats)
{
- var occupiedSeats =
- await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
- if (occupiedSeats > updatedPasswordManagerSeats)
+ var seatCounts =
+ await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
+ if (seatCounts.Total > updatedPasswordManagerSeats)
{
- throw new BadRequestException($"Your organization currently has {occupiedSeats} seats filled. " +
+ if (organization.UseAdminSponsoredFamilies || seatCounts.Sponsored > 0)
+ {
+ throw new BadRequestException($"Your organization has {seatCounts.Users} members and {seatCounts.Sponsored} sponsored families. " +
+ $"To decrease the seat count below {seatCounts.Total}, you must remove members or sponsorships.");
+ }
+ else
+ {
+ throw new BadRequestException($"Your organization currently has {seatCounts.Total} seats filled. " +
$"Your new plan only has ({updatedPasswordManagerSeats}) seats. Remove some users.");
+ }
}
}
diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs
index 3da8ad1a6c..27a08df3ed 100644
--- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs
+++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationRepository.cs
@@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Auth.Entities;
using Bit.Core.Entities;
using Bit.Core.Models.Data.Organizations;
+using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Dapper;
@@ -200,11 +201,23 @@ public class OrganizationRepository : Repository, IOrganizat
public async Task> GetManyByIdsAsync(IEnumerable ids)
{
await using var connection = new SqlConnection(ConnectionString);
-
return (await connection.QueryAsync(
$"[{Schema}].[{Table}_ReadManyByIds]",
new { OrganizationIds = ids.ToGuidIdArrayTVP() },
commandType: CommandType.StoredProcedure))
.ToList();
}
+
+ public async Task GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId)
+ {
+ using (var connection = new SqlConnection(ConnectionString))
+ {
+ var result = await connection.QueryAsync(
+ "[dbo].[Organization_ReadOccupiedSeatCountByOrganizationId]",
+ new { OrganizationId = organizationId },
+ commandType: CommandType.StoredProcedure);
+
+ return result.SingleOrDefault() ?? new OrganizationSeatCounts();
+ }
+ }
}
diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs
index 8968d1d243..5a6fcbe4aa 100644
--- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs
+++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs
@@ -88,19 +88,6 @@ public class OrganizationUserRepository : Repository, IO
}
}
- public async Task GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId)
- {
- using (var connection = new SqlConnection(ConnectionString))
- {
- var result = await connection.ExecuteScalarAsync(
- "[dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId]",
- new { OrganizationId = organizationId },
- commandType: CommandType.StoredProcedure);
-
- return result;
- }
- }
-
public async Task GetOccupiedSmSeatCountByOrganizationIdAsync(Guid organizationId)
{
using (var connection = new SqlConnection(ConnectionString))
diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs
index f83f7b70b6..c378fe5e7e 100644
--- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs
+++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs
@@ -5,6 +5,7 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations;
+using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using LinqToDB.Tools;
using Microsoft.EntityFrameworkCore;
@@ -375,4 +376,28 @@ public class OrganizationRepository : Repository GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId)
+ {
+ using (var scope = ServiceScopeFactory.CreateScope())
+ {
+ var dbContext = GetDatabaseContext(scope);
+ var users = await dbContext.OrganizationUsers
+ .Where(ou => ou.OrganizationId == organizationId && ou.Status >= 0)
+ .CountAsync();
+
+ var sponsored = await dbContext.OrganizationSponsorships
+ .Where(os => os.SponsoringOrganizationId == organizationId &&
+ os.IsAdminInitiated &&
+ (os.ToDelete == false || (os.ToDelete == true && os.ValidUntil != null && os.ValidUntil > DateTime.UtcNow)) &&
+ (os.SponsoredOrganizationId == null || (os.SponsoredOrganizationId != null && (os.ValidUntil == null || os.ValidUntil > DateTime.UtcNow))))
+ .CountAsync();
+
+ return new OrganizationSeatCounts
+ {
+ Users = users,
+ Sponsored = sponsored
+ };
+ }
+ }
}
diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs
index fc5626631a..26a72bb991 100644
--- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs
+++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs
@@ -228,12 +228,6 @@ public class OrganizationUserRepository : Repository GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId)
- {
- var query = new OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery(organizationId);
- return await GetCountFromQuery(query);
- }
-
public async Task GetCountByOrganizationIdAsync(Guid organizationId)
{
var query = new OrganizationUserReadCountByOrganizationIdQuery(organizationId);
diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery.cs
deleted file mode 100644
index 6be51f2036..0000000000
--- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery.cs
+++ /dev/null
@@ -1,48 +0,0 @@
-using Bit.Core.Enums;
-using Bit.Infrastructure.EntityFramework.Models;
-
-namespace Bit.Infrastructure.EntityFramework.Repositories.Queries;
-
-public class OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery : IQuery
-{
- private readonly Guid _organizationId;
-
- public OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery(Guid organizationId)
- {
- _organizationId = organizationId;
- }
-
- public IQueryable Run(DatabaseContext dbContext)
- {
- var orgUsersQuery = from ou in dbContext.OrganizationUsers
- where ou.OrganizationId == _organizationId && ou.Status >= OrganizationUserStatusType.Invited
- select new OrganizationUser { Id = ou.Id, OrganizationId = ou.OrganizationId, Status = ou.Status };
-
- // As of https://bitwarden.atlassian.net/browse/PM-17772, a seat is also occupied by a Families for Enterprise sponsorship sent by an
- // organization admin, even if the user sent the invitation doesn't have a corresponding OrganizationUser in the Enterprise organization.
- var sponsorshipsQuery = from os in dbContext.OrganizationSponsorships
- where os.SponsoringOrganizationId == _organizationId &&
- os.IsAdminInitiated &&
- (
- // Not marked for deletion - always count
- (!os.ToDelete) ||
- // Marked for deletion but has a valid until date in the future (RevokeWhenExpired status)
- (os.ToDelete && os.ValidUntil.HasValue && os.ValidUntil.Value > DateTime.UtcNow)
- ) &&
- (
- // SENT status: When SponsoredOrganizationId is null
- os.SponsoredOrganizationId == null ||
- // ACCEPTED status: When SponsoredOrganizationId is not null and ValidUntil is null or in the future
- (os.SponsoredOrganizationId != null &&
- (!os.ValidUntil.HasValue || os.ValidUntil.Value > DateTime.UtcNow))
- )
- select new OrganizationUser
- {
- Id = os.Id,
- OrganizationId = _organizationId,
- Status = OrganizationUserStatusType.Invited
- };
-
- return orgUsersQuery.Concat(sponsorshipsQuery);
- }
-}
diff --git a/src/Sql/dbo/Stored Procedures/Organization_ReadOccupiedSeatCountByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/Organization_ReadOccupiedSeatCountByOrganizationId.sql
new file mode 100644
index 0000000000..d560e994a1
--- /dev/null
+++ b/src/Sql/dbo/Stored Procedures/Organization_ReadOccupiedSeatCountByOrganizationId.sql
@@ -0,0 +1,38 @@
+CREATE PROCEDURE [dbo].[Organization_ReadOccupiedSeatCountByOrganizationId]
+ @OrganizationId UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT
+ (
+ -- Count organization users
+ SELECT COUNT(1)
+ FROM [dbo].[OrganizationUserView]
+ WHERE OrganizationId = @OrganizationId
+ AND Status >= 0 --Invited
+ ) as Users,
+ (
+ -- Count admin-initiated sponsorships towards the seat count
+ -- Introduced in https://bitwarden.atlassian.net/browse/PM-17772
+ SELECT COUNT(1)
+ FROM [dbo].[OrganizationSponsorship]
+ WHERE SponsoringOrganizationId = @OrganizationId
+ AND IsAdminInitiated = 1
+ AND (
+ -- Not marked for deletion - always count
+ (ToDelete = 0)
+ OR
+ -- Marked for deletion but has a valid until date in the future (RevokeWhenExpired status)
+ (ToDelete = 1 AND ValidUntil IS NOT NULL AND ValidUntil > GETUTCDATE())
+ )
+ AND (
+ -- SENT status: When SponsoredOrganizationId is null
+ SponsoredOrganizationId IS NULL
+ OR
+ -- ACCEPTED status: When SponsoredOrganizationId is not null and ValidUntil is null or in the future
+ (SponsoredOrganizationId IS NOT NULL AND (ValidUntil IS NULL OR ValidUntil > GETUTCDATE()))
+ )
+ ) as Sponsored
+END
+GO
\ No newline at end of file
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs
index e54e4aa99b..cee801d190 100644
--- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/InviteOrganizationUserCommandTests.cs
@@ -137,6 +137,14 @@ public class InviteOrganizationUserCommandTests
.ValidateAsync(Arg.Any())
.Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization)));
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
+
+ sutProvider.GetDependency()
+ .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(0);
+
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
@@ -202,6 +210,14 @@ public class InviteOrganizationUserCommandTests
.Returns(new Invalid(
new Error(errorMessage, validationRequest)));
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
+
+ sutProvider.GetDependency()
+ .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(0);
+
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
@@ -272,6 +288,14 @@ public class InviteOrganizationUserCommandTests
.Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization)
.WithPasswordManagerUpdate(new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1))));
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
+
+ sutProvider.GetDependency()
+ .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(0);
+
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
@@ -343,6 +367,14 @@ public class InviteOrganizationUserCommandTests
.WithPasswordManagerUpdate(
new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1))));
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
+
+ sutProvider.GetDependency()
+ .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(0);
+
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
@@ -413,6 +445,14 @@ public class InviteOrganizationUserCommandTests
.Returns(new Valid(GetInviteValidationRequestMock(request, inviteOrganization, organization)
.WithPasswordManagerUpdate(passwordManagerUpdate)));
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
+
+ sutProvider.GetDependency()
+ .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(0);
+
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
@@ -469,6 +509,7 @@ public class InviteOrganizationUserCommandTests
.AdjustSeats(request.Invites.Count(x => x.AccessSecretsManager));
var orgUserRepository = sutProvider.GetDependency();
+ var orgRepository = sutProvider.GetDependency();
orgUserRepository
.SelectKnownEmailsAsync(inviteOrganization.OrganizationId, Arg.Any>(), false)
@@ -476,11 +517,13 @@ public class InviteOrganizationUserCommandTests
orgUserRepository
.GetManyByMinimumRoleAsync(inviteOrganization.OrganizationId, OrganizationUserType.Owner)
.Returns([ownerDetails]);
- orgUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(1);
+ orgRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
orgUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(1);
- var orgRepository = sutProvider.GetDependency();
-
orgRepository.GetByIdAsync(organization.Id)
.Returns(organization);
@@ -566,6 +609,14 @@ public class InviteOrganizationUserCommandTests
.SendInvitesAsync(Arg.Any())
.Throws(new Exception("Something went wrong"));
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
+
+ sutProvider.GetDependency()
+ .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(0);
+
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
@@ -671,6 +722,14 @@ public class InviteOrganizationUserCommandTests
}
});
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
+
+ sutProvider.GetDependency()
+ .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(0);
+
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
@@ -762,6 +821,14 @@ public class InviteOrganizationUserCommandTests
}
});
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
+
+ sutProvider.GetDependency()
+ .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(0);
+
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
@@ -829,6 +896,14 @@ public class InviteOrganizationUserCommandTests
.WithPasswordManagerUpdate(
new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1))));
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
+
+ sutProvider.GetDependency()
+ .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(0);
+
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
@@ -900,6 +975,14 @@ public class InviteOrganizationUserCommandTests
.WithPasswordManagerUpdate(
new PasswordManagerSubscriptionUpdate(inviteOrganization, organization.Seats.Value, 1))));
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(new OrganizationSeatCounts { Sponsored = 0, Users = 0 });
+
+ sutProvider.GetDependency()
+ .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(0);
+
// Act
var result = await sutProvider.Sut.InviteScimOrganizationUserAsync(request);
diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs
index fbd711307c..4fa5e92abe 100644
--- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs
+++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/RestoreUser/RestoreOrganizationUserCommandTests.cs
@@ -31,7 +31,12 @@ public class RestoreOrganizationUserCommandTests
[OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, SutProvider sutProvider)
{
RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id);
await sutProvider.GetDependency()
@@ -49,7 +54,12 @@ public class RestoreOrganizationUserCommandTests
public async Task RestoreUser_WithEventSystemUser_Success(Organization organization, [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser, EventSystemUser eventSystemUser, SutProvider sutProvider)
{
RestoreUser_Setup(organization, null, organizationUser, sutProvider);
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
await sutProvider.Sut.RestoreUserAsync(organizationUser, eventSystemUser);
await sutProvider.GetDependency()
@@ -151,7 +161,12 @@ public class RestoreOrganizationUserCommandTests
sutProvider.GetDependency()
.AnyPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any())
.Returns(true);
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
var user = new User();
user.Email = "test@bitwarden.com";
sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user);
@@ -184,7 +199,12 @@ public class RestoreOrganizationUserCommandTests
sutProvider.GetDependency()
.TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value)))
.Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, false) });
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
sutProvider.GetDependency()
@@ -219,7 +239,12 @@ public class RestoreOrganizationUserCommandTests
SutProvider sutProvider)
{
organizationUser.Email = null;
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
sutProvider.GetDependency()
.IsEnabled(FeatureFlagKeys.PolicyRequirements)
.Returns(true);
@@ -278,7 +303,12 @@ public class RestoreOrganizationUserCommandTests
sutProvider.GetDependency()
.TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value)))
.Returns(new List<(Guid userId, bool twoFactorIsEnabled)>() { (organizationUser.UserId.Value, true) });
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id);
await sutProvider.GetDependency()
@@ -344,6 +374,15 @@ public class RestoreOrganizationUserCommandTests
sutProvider.GetDependency()
.GetManyByUserAsync(organizationUser.UserId.Value)
.Returns(new[] { organizationUser, secondOrganizationUser });
+ sutProvider.GetDependency()
+ .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value)))
+ .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) });
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
sutProvider.GetDependency()
.GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.SingleOrg, Arg.Any())
.Returns(new[]
@@ -392,7 +431,12 @@ public class RestoreOrganizationUserCommandTests
{
new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.SingleOrg, OrganizationUserStatus = OrganizationUserStatusType.Revoked }
});
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
sutProvider.GetDependency()
.GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any())
.Returns([
@@ -455,7 +499,12 @@ public class RestoreOrganizationUserCommandTests
PolicyType = PolicyType.TwoFactorAuthentication
}
]));
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
var user = new User { Email = "test@bitwarden.com" };
sutProvider.GetDependency().GetByIdAsync(organizationUser.UserId.Value).Returns(user);
@@ -475,6 +524,40 @@ public class RestoreOrganizationUserCommandTests
.PushSyncOrgKeysAsync(Arg.Any());
}
+ [Theory, BitAutoData]
+ public async Task RestoreUser_vNext_With2FAPolicyEnabled_WithUser2FAConfigured_Success(
+ Organization organization,
+ [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser owner,
+ [OrganizationUser(OrganizationUserStatusType.Revoked)] OrganizationUser organizationUser,
+ SutProvider sutProvider)
+ {
+ organizationUser.Email = null; // this is required to mock that the user as had already been confirmed before the revoke
+ RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
+ sutProvider.GetDependency()
+ .GetPoliciesApplicableToUserAsync(organizationUser.UserId.Value, PolicyType.TwoFactorAuthentication, Arg.Any())
+ .Returns([new OrganizationUserPolicyDetails { OrganizationId = organizationUser.OrganizationId, PolicyType = PolicyType.TwoFactorAuthentication }
+ ]);
+
+ sutProvider.GetDependency()
+ .TwoFactorIsEnabledAsync(Arg.Is>(i => i.Contains(organizationUser.UserId.Value)))
+ .Returns(new List<(Guid userId, bool twoFactorIsEnabled)> { (organizationUser.UserId.Value, true) });
+
+ await sutProvider.Sut.RestoreUserAsync(organizationUser, owner.Id);
+
+ await sutProvider.GetDependency()
+ .Received(1)
+ .RestoreAsync(organizationUser.Id, OrganizationUserStatusType.Confirmed);
+ await sutProvider.GetDependency()
+ .Received(1)
+ .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Restored);
+ }
+
[Theory, BitAutoData]
public async Task RestoreUser_WhenUserOwningAnotherFreeOrganization_ThenRestoreUserFails(
Organization organization,
@@ -492,7 +575,12 @@ public class RestoreOrganizationUserCommandTests
otherOrganization.PlanType = PlanType.Free;
RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
sutProvider.GetDependency()
.GetManyByUserAsync(organizationUser.UserId.Value)
.Returns([orgUserOwnerFromDifferentOrg]);
@@ -533,7 +621,12 @@ public class RestoreOrganizationUserCommandTests
otherOrganization.PlanType = PlanType.Free;
RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
var organizationUserRepository = sutProvider.GetDependency();
organizationUserRepository
.GetManyByUserAsync(organizationUser.UserId.Value)
@@ -584,7 +677,12 @@ public class RestoreOrganizationUserCommandTests
otherOrganization.PlanType = PlanType.Free;
RestoreUser_Setup(organization, owner, organizationUser, sutProvider);
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
var organizationUserRepository = sutProvider.GetDependency();
organizationUserRepository
.GetManyByUserAsync(organizationUser.UserId.Value)
@@ -636,7 +734,12 @@ public class RestoreOrganizationUserCommandTests
organizationUserRepository
.GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id)))
.Returns([orgUser1, orgUser2]);
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
twoFactorIsEnabledQuery
.TwoFactorIsEnabledAsync(Arg.Is>(ids => ids.Contains(orgUser1.UserId!.Value) && ids.Contains(orgUser2.UserId!.Value)))
.Returns(new List<(Guid userId, bool twoFactorIsEnabled)>
@@ -685,7 +788,12 @@ public class RestoreOrganizationUserCommandTests
organizationUserRepository
.GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id) && ids.Contains(orgUser3.Id)))
.Returns(new[] { orgUser1, orgUser2, orgUser3 });
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
userRepository.GetByIdAsync(orgUser2.UserId!.Value).Returns(new User { Email = "test@example.com" });
// Setup 2FA policy
@@ -820,7 +928,12 @@ public class RestoreOrganizationUserCommandTests
organizationUserRepository
.GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id) && ids.Contains(orgUser2.Id) && ids.Contains(orgUser3.Id)))
.Returns([orgUser1, orgUser2, orgUser3]);
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
userRepository.GetByIdAsync(orgUser2.UserId!.Value).Returns(new User { Email = "test@example.com" });
sutProvider.GetDependency()
@@ -882,7 +995,12 @@ public class RestoreOrganizationUserCommandTests
organizationUserRepository
.GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id)))
.Returns([orgUser1]);
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
organizationUserRepository
.GetManyByManyUsersAsync(Arg.Any>())
.Returns([orgUserFromOtherOrg]);
@@ -942,7 +1060,12 @@ public class RestoreOrganizationUserCommandTests
organizationUserRepository
.GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser1.Id)))
.Returns([orgUser1]);
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
organizationUserRepository
.GetManyByManyUsersAsync(Arg.Any>())
.Returns([orgUserFromOtherOrg]);
@@ -972,7 +1095,14 @@ public class RestoreOrganizationUserCommandTests
}
targetOrganizationUser.OrganizationId = organization.Id;
- sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization);
+ var organizationRepository = sutProvider.GetDependency();
+ organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
+ organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
+
sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(requestingOrganizationUser != null && requestingOrganizationUser.Type is OrganizationUserType.Owner);
sutProvider.GetDependency().ManageUsers(organization.Id).Returns(requestingOrganizationUser != null && (requestingOrganizationUser.Type is OrganizationUserType.Owner or OrganizationUserType.Admin));
}
diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs
index d926e282c9..3271ea559b 100644
--- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs
+++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs
@@ -60,7 +60,12 @@ public class OrganizationServiceTests
existingUsers.First().Type = OrganizationUserType.Owner;
sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org);
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(org.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
var organizationUserRepository = sutProvider.GetDependency();
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
@@ -117,7 +122,12 @@ public class OrganizationServiceTests
ExternalId = reInvitedUser.Email,
});
var expectedNewUsersCount = newUsers.Count - 1;
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(org.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org);
sutProvider.GetDependency().GetManyDetailsByOrganizationAsync(org.Id)
.Returns(existingUsers);
@@ -190,7 +200,12 @@ public class OrganizationServiceTests
sutProvider.Create();
invite.Emails = invite.Emails.Append(invite.Emails.First());
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true);
sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true);
@@ -221,6 +236,12 @@ public class OrganizationServiceTests
sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true);
sutProvider.GetDependency().ManageUsers(organization.Id).Returns(true);
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
var exception = await Assert.ThrowsAsync(
() => sutProvider.Sut.InviteUsersAsync(organization.Id, invitor.UserId, systemUser: null, new (OrganizationUserInvite, string)[] { (invite, null) }));
Assert.Contains("Organization must have at least one confirmed owner.", exception.Message);
@@ -314,6 +335,12 @@ public class OrganizationServiceTests
sutProvider.GetDependency()
.HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any>())
.Returns(true);
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
@@ -340,6 +367,13 @@ public class OrganizationServiceTests
var organizationUserRepository = sutProvider.GetDependency();
var currentContext = sutProvider.GetDependency();
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
+
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency()
.HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any>())
@@ -397,7 +431,12 @@ public class OrganizationServiceTests
var organizationRepository = sutProvider.GetDependency();
var currentContext = sutProvider.GetDependency();
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
currentContext.OrganizationCustom(organization.Id).Returns(true);
currentContext.ManageUsers(organization.Id).Returns(true);
@@ -425,7 +464,12 @@ public class OrganizationServiceTests
sutProvider.GetDependency()
.HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any>())
.Returns(true);
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
currentContext.OrganizationOwner(organization.Id).Returns(true);
@@ -473,7 +517,12 @@ public class OrganizationServiceTests
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository);
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
await sutProvider.Sut.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId);
await sutProvider.GetDependency().Received(1)
@@ -538,7 +587,12 @@ public class OrganizationServiceTests
SetupOrgUserRepositoryCreateManyAsyncMock(organizationUserRepository);
SetupOrgUserRepositoryCreateAsyncMock(organizationUserRepository);
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
var exception = await Assert.ThrowsAsync(() => sutProvider.Sut
.InviteUserAsync(organization.Id, invitor.UserId, systemUser: null, invite, externalId));
Assert.Contains("This user has already been invited", exception.Message);
@@ -595,7 +649,12 @@ public class OrganizationServiceTests
var organizationUserRepository = sutProvider.GetDependency();
organizationRepository.GetByIdAsync(organization.Id).Returns(organization);
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
sutProvider.GetDependency()
.HasConfirmedOwnersExceptAsync(organization.Id, Arg.Any>())
.Returns(true);
@@ -631,7 +690,12 @@ public class OrganizationServiceTests
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
});
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
var organizationRepository = sutProvider.GetDependency();
var organizationUserRepository = sutProvider.GetDependency();
var currentContext = sutProvider.GetDependency();
@@ -664,6 +728,13 @@ public class OrganizationServiceTests
organization.PlanType = PlanType.EnterpriseAnnually;
InviteUserHelper_ArrangeValidPermissions(organization, savingUser, sutProvider);
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
+
// Set up some invites to grant access to SM
invites.First().invite.AccessSecretsManager = true;
var invitedSmUsers = invites.First().invite.Emails.Count();
@@ -708,6 +779,13 @@ public class OrganizationServiceTests
invite.AccessSecretsManager = false;
}
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
+
// Assume we need to add seats for all invited SM users
sutProvider.GetDependency()
.CountNewSmSeatsRequiredAsync(organization.Id, invitedSmUsers).Returns(invitedSmUsers);
@@ -813,7 +891,12 @@ public class OrganizationServiceTests
sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization);
var actual = await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateSubscription(organization.Id, seatAdjustment, null));
diff --git a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs
index ab2f3a64c0..26e6b98667 100644
--- a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs
+++ b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs
@@ -3,6 +3,7 @@ using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Implementations;
+using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
@@ -25,30 +26,32 @@ public class OrganizationBillingServiceTests
SutProvider sutProvider)
{
sutProvider.GetDependency().GetByIdAsync(organizationId).Returns(organization);
-
sutProvider.GetDependency().ListPlans().Returns(StaticStore.Plans.ToList());
sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
var subscriberService = sutProvider.GetDependency();
-
- subscriberService
- .GetCustomer(organization, Arg.Is(options => options.Expand.FirstOrDefault() == "discount.coupon.applies_to"))
- .Returns(new Customer
+ var organizationSeatCount = new OrganizationSeatCounts { Users = 1, Sponsored = 0 };
+ var customer = new Customer
+ {
+ Discount = new Discount
{
- Discount = new Discount
+ Coupon = new Coupon
{
- Coupon = new Coupon
+ Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
+ AppliesTo = new CouponAppliesTo
{
- Id = StripeConstants.CouponIDs.SecretsManagerStandalone,
- AppliesTo = new CouponAppliesTo
- {
- Products = ["product_id"]
- }
+ Products = ["product_id"]
}
}
- });
+ }
+ };
+
+ subscriberService
+ .GetCustomer(organization, Arg.Is(options =>
+ options.Expand.Contains("discount.coupon.applies_to")))
+ .Returns(customer);
subscriberService.GetSubscription(organization).Returns(new Subscription
{
@@ -67,6 +70,10 @@ public class OrganizationBillingServiceTests
}
});
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id)
+ .Returns(new OrganizationSeatCounts { Users = 1, Sponsored = 0 });
+
var metadata = await sutProvider.Sut.GetMetadata(organizationId);
Assert.True(metadata!.IsOnSecretsManagerStandalone);
diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs
index 7dc6b7360d..770a566b44 100644
--- a/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs
+++ b/test/Core.Test/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/CreateSponsorshipCommandTests.cs
@@ -5,6 +5,7 @@ using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
+using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -169,9 +170,13 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
sutProvider.GetDependency().UserId.Returns(sponsoringOrgUser.UserId.Value);
// Setup for checking available seats
- sutProvider.GetDependency()
+ sutProvider.GetDependency()
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
- .Returns(0);
+ .Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 0
+ });
await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
@@ -318,9 +323,13 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
]);
// Setup for checking available seats - organization has plenty of seats
- sutProvider.GetDependency()
+ sutProvider.GetDependency()
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
- .Returns(5);
+ .Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 5
+ });
var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes);
@@ -378,9 +387,13 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
]);
// Setup for checking available seats - organization has no available seats
- sutProvider.GetDependency()
+ sutProvider.GetDependency()
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
- .Returns(10);
+ .Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 10
+ });
// Setup for checking if can scale
sutProvider.GetDependency()
@@ -443,9 +456,13 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
]);
// Setup for checking available seats - organization has no available seats
- sutProvider.GetDependency()
+ sutProvider.GetDependency()
.GetOccupiedSeatCountByOrganizationIdAsync(sponsoringOrg.Id)
- .Returns(10);
+ .Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 10
+ });
// Setup for checking if can scale - cannot scale
var failureReason = "Seat limit has been reached.";
diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs
index 8bcee1e8c6..704f89ba3f 100644
--- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs
+++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpgradeOrganizationPlanCommandTests.cs
@@ -2,6 +2,7 @@
using Bit.Core.Billing.Pricing;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
+using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
@@ -77,6 +78,12 @@ public class UpgradeOrganizationPlanCommandTests
upgrade.AdditionalSeats = 10;
upgrade.Plan = PlanType.TeamsAnnually;
sutProvider.GetDependency().GetPlanOrThrow(upgrade.Plan).Returns(StaticStore.GetPlan(upgrade.Plan));
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade);
await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync(organization);
}
@@ -107,7 +114,12 @@ public class UpgradeOrganizationPlanCommandTests
organizationUpgrade.Plan = planType;
sutProvider.GetDependency().GetPlanOrThrow(organizationUpgrade.Plan).Returns(StaticStore.GetPlan(organizationUpgrade.Plan));
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
await sutProvider.Sut.UpgradePlanAsync(organization.Id, organizationUpgrade);
await sutProvider.GetDependency().Received(1).AdjustSubscription(
organization,
@@ -141,7 +153,12 @@ public class UpgradeOrganizationPlanCommandTests
upgrade.AdditionalSeats = 15;
upgrade.AdditionalSmSeats = 10;
upgrade.AdditionalServiceAccounts = 20;
-
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
var result = await sutProvider.Sut.UpgradePlanAsync(organization.Id, upgrade);
await sutProvider.GetDependency().Received(1).ReplaceAndUpdateCacheAsync(
@@ -173,6 +190,12 @@ public class UpgradeOrganizationPlanCommandTests
sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(StaticStore.GetPlan(organization.PlanType));
sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization);
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
sutProvider.GetDependency()
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(2);
@@ -202,6 +225,12 @@ public class UpgradeOrganizationPlanCommandTests
sutProvider.GetDependency().GetPlanOrThrow(organization.PlanType).Returns(StaticStore.GetPlan(organization.PlanType));
sutProvider.GetDependency().GetByIdAsync(organization.Id).Returns(organization);
+ sutProvider.GetDependency()
+ .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id).Returns(new OrganizationSeatCounts
+ {
+ Sponsored = 0,
+ Users = 1
+ });
sutProvider.GetDependency()
.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id).Returns(1);
sutProvider.GetDependency()
diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs
index a95778b199..a0df63c94e 100644
--- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs
+++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationRepositoryTests.cs
@@ -286,4 +286,141 @@ public class OrganizationRepositoryTests
await organizationRepository.DeleteAsync(organization1);
await organizationRepository.DeleteAsync(organization2);
}
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithUsersAndSponsorships_ReturnsCorrectCounts(
+ IUserRepository userRepository,
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IOrganizationSponsorshipRepository organizationSponsorshipRepository)
+ {
+ // Arrange
+ var organization = await organizationRepository.CreateTestOrganizationAsync();
+
+ // Create users in different states
+ var user1 = await userRepository.CreateTestUserAsync("test1");
+ var user2 = await userRepository.CreateTestUserAsync("test2");
+ var user3 = await userRepository.CreateTestUserAsync("test3");
+
+ // Create organization users in different states
+ await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user1); // Confirmed state
+ await organizationUserRepository.CreateTestOrganizationUserInviteAsync(organization); // Invited state
+
+ // Create a revoked user manually since there's no helper for it
+ await organizationUserRepository.CreateAsync(new OrganizationUser
+ {
+ OrganizationId = organization.Id,
+ UserId = user3.Id,
+ Status = OrganizationUserStatusType.Revoked,
+ });
+
+ // Create sponsorships in different states
+ await organizationSponsorshipRepository.CreateAsync(new OrganizationSponsorship
+ {
+ SponsoringOrganizationId = organization.Id,
+ IsAdminInitiated = true,
+ ToDelete = false,
+ ValidUntil = null,
+ });
+
+ await organizationSponsorshipRepository.CreateAsync(new OrganizationSponsorship
+ {
+ SponsoringOrganizationId = organization.Id,
+ IsAdminInitiated = true,
+ ToDelete = true,
+ ValidUntil = DateTime.UtcNow.AddDays(1),
+ });
+
+ await organizationSponsorshipRepository.CreateAsync(new OrganizationSponsorship
+ {
+ SponsoringOrganizationId = organization.Id,
+ IsAdminInitiated = true,
+ ToDelete = true,
+ ValidUntil = DateTime.UtcNow.AddDays(-1), // Expired
+ });
+
+ await organizationSponsorshipRepository.CreateAsync(new OrganizationSponsorship
+ {
+ SponsoringOrganizationId = organization.Id,
+ IsAdminInitiated = false, // Not admin initiated
+ ToDelete = false,
+ ValidUntil = null,
+ });
+
+ // Act
+ var result = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
+
+ // Assert
+ Assert.Equal(2, result.Users); // Confirmed + Invited users
+ Assert.Equal(2, result.Sponsored); // Two valid sponsorships
+ Assert.Equal(4, result.Total); // Total occupied seats
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithNoUsersOrSponsorships_ReturnsZero(
+ IOrganizationRepository organizationRepository)
+ {
+ // Arrange
+ var organization = await organizationRepository.CreateTestOrganizationAsync();
+
+ // Act
+ var result = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
+
+ // Assert
+ Assert.Equal(0, result.Users);
+ Assert.Equal(0, result.Sponsored);
+ Assert.Equal(0, result.Total);
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithOnlyRevokedUsers_ReturnsZero(
+ IUserRepository userRepository,
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository)
+ {
+ // Arrange
+ var organization = await organizationRepository.CreateTestOrganizationAsync();
+
+ var user = await userRepository.CreateTestUserAsync("test1");
+
+ await organizationUserRepository.CreateAsync(new OrganizationUser
+ {
+ OrganizationId = organization.Id,
+ UserId = user.Id,
+ Status = OrganizationUserStatusType.Revoked,
+ });
+
+ // Act
+ var result = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
+
+ // Assert
+ Assert.Equal(0, result.Users);
+ Assert.Equal(0, result.Sponsored);
+ Assert.Equal(0, result.Total);
+ }
+
+ [DatabaseTheory, DatabaseData]
+ public async Task GetOccupiedSeatCountByOrganizationIdAsync_WithOnlyExpiredSponsorships_ReturnsZero(
+ IOrganizationRepository organizationRepository,
+ IOrganizationSponsorshipRepository organizationSponsorshipRepository)
+ {
+ // Arrange
+ var organization = await organizationRepository.CreateTestOrganizationAsync();
+
+ await organizationSponsorshipRepository.CreateAsync(new OrganizationSponsorship
+ {
+ SponsoringOrganizationId = organization.Id,
+ IsAdminInitiated = true,
+ ToDelete = true,
+ ValidUntil = DateTime.UtcNow.AddDays(-1), // Expired
+ });
+
+ // Act
+ var result = await organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
+
+ // Assert
+ Assert.Equal(0, result.Users);
+ Assert.Equal(0, result.Sponsored);
+ Assert.Equal(0, result.Total);
+ }
}
diff --git a/util/Migrator/DbScripts/2025-05-20_00_UpdateOrgReadOccupiedSeatCountForSponsorships.sql b/util/Migrator/DbScripts/2025-05-20_00_UpdateOrgReadOccupiedSeatCountForSponsorships.sql
new file mode 100644
index 0000000000..d3db0e5ce6
--- /dev/null
+++ b/util/Migrator/DbScripts/2025-05-20_00_UpdateOrgReadOccupiedSeatCountForSponsorships.sql
@@ -0,0 +1,38 @@
+CREATE OR ALTER PROCEDURE [dbo].[Organization_ReadOccupiedSeatCountByOrganizationId]
+ @OrganizationId UNIQUEIDENTIFIER
+AS
+BEGIN
+ SET NOCOUNT ON
+
+ SELECT
+ (
+ -- Count organization users
+ SELECT COUNT(1)
+ FROM [dbo].[OrganizationUserView]
+ WHERE OrganizationId = @OrganizationId
+ AND Status >= 0 --Invited
+ ) as Users,
+ (
+ -- Count admin-initiated sponsorships towards the seat count
+ -- Introduced in https://bitwarden.atlassian.net/browse/PM-17772
+ SELECT COUNT(1)
+ FROM [dbo].[OrganizationSponsorship]
+ WHERE SponsoringOrganizationId = @OrganizationId
+ AND IsAdminInitiated = 1
+ AND (
+ -- Not marked for deletion - always count
+ (ToDelete = 0)
+ OR
+ -- Marked for deletion but has a valid until date in the future (RevokeWhenExpired status)
+ (ToDelete = 1 AND ValidUntil IS NOT NULL AND ValidUntil > GETUTCDATE())
+ )
+ AND (
+ -- SENT status: When SponsoredOrganizationId is null
+ SponsoredOrganizationId IS NULL
+ OR
+ -- ACCEPTED status: When SponsoredOrganizationId is not null and ValidUntil is null or in the future
+ (SponsoredOrganizationId IS NOT NULL AND (ValidUntil IS NULL OR ValidUntil > GETUTCDATE()))
+ )
+ ) as Sponsored
+END
+GO